payment-kit 1.20.10 → 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.
Files changed (83) hide show
  1. package/README.md +25 -24
  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/invoice.ts +50 -16
  13. package/api/src/libs/math-utils.ts +6 -0
  14. package/api/src/libs/price.ts +43 -0
  15. package/api/src/libs/session.ts +242 -57
  16. package/api/src/libs/subscription.ts +2 -6
  17. package/api/src/locales/en.ts +38 -38
  18. package/api/src/queues/auto-recharge.ts +1 -1
  19. package/api/src/queues/discount-status.ts +200 -0
  20. package/api/src/queues/subscription.ts +98 -5
  21. package/api/src/queues/usage-record.ts +1 -1
  22. package/api/src/routes/auto-recharge-configs.ts +5 -3
  23. package/api/src/routes/checkout-sessions.ts +755 -64
  24. package/api/src/routes/connect/change-payment.ts +6 -1
  25. package/api/src/routes/connect/change-plan.ts +6 -1
  26. package/api/src/routes/connect/setup.ts +6 -1
  27. package/api/src/routes/connect/shared.ts +80 -9
  28. package/api/src/routes/connect/subscribe.ts +12 -2
  29. package/api/src/routes/coupons.ts +518 -0
  30. package/api/src/routes/index.ts +4 -0
  31. package/api/src/routes/invoices.ts +44 -3
  32. package/api/src/routes/meter-events.ts +2 -1
  33. package/api/src/routes/payment-currencies.ts +1 -0
  34. package/api/src/routes/promotion-codes.ts +482 -0
  35. package/api/src/routes/subscriptions.ts +23 -2
  36. package/api/src/store/migrations/20250904-discount.ts +136 -0
  37. package/api/src/store/migrations/20250910-timestamp-fields.ts +116 -0
  38. package/api/src/store/migrations/20250916-add-description-fields.ts +30 -0
  39. package/api/src/store/models/checkout-session.ts +12 -0
  40. package/api/src/store/models/coupon.ts +144 -4
  41. package/api/src/store/models/discount.ts +23 -10
  42. package/api/src/store/models/index.ts +13 -2
  43. package/api/src/store/models/promotion-code.ts +295 -18
  44. package/api/src/store/models/types.ts +30 -1
  45. package/api/tests/libs/session.spec.ts +48 -27
  46. package/blocklet.yml +1 -1
  47. package/doc/vendor_fulfillment_system.md +38 -38
  48. package/package.json +20 -20
  49. package/src/app.tsx +2 -0
  50. package/src/components/customer/link.tsx +1 -1
  51. package/src/components/discount/discount-info.tsx +178 -0
  52. package/src/components/invoice/table.tsx +140 -48
  53. package/src/components/invoice-pdf/styles.ts +6 -0
  54. package/src/components/invoice-pdf/template.tsx +59 -33
  55. package/src/components/metadata/form.tsx +14 -5
  56. package/src/components/payment-link/actions.tsx +42 -0
  57. package/src/components/price/form.tsx +91 -65
  58. package/src/components/product/vendor-config.tsx +5 -3
  59. package/src/components/promotion/active-redemptions.tsx +534 -0
  60. package/src/components/promotion/currency-multi-select.tsx +350 -0
  61. package/src/components/promotion/currency-restrictions.tsx +117 -0
  62. package/src/components/promotion/product-select.tsx +292 -0
  63. package/src/components/promotion/promotion-code-form.tsx +534 -0
  64. package/src/components/subscription/portal/list.tsx +6 -1
  65. package/src/components/subscription/vendor-service-list.tsx +13 -2
  66. package/src/locales/en.tsx +253 -26
  67. package/src/locales/zh.tsx +222 -1
  68. package/src/pages/admin/billing/subscriptions/detail.tsx +5 -0
  69. package/src/pages/admin/products/coupons/applicable-products.tsx +166 -0
  70. package/src/pages/admin/products/coupons/create.tsx +612 -0
  71. package/src/pages/admin/products/coupons/detail.tsx +538 -0
  72. package/src/pages/admin/products/coupons/edit.tsx +127 -0
  73. package/src/pages/admin/products/coupons/index.tsx +210 -3
  74. package/src/pages/admin/products/index.tsx +22 -3
  75. package/src/pages/admin/products/products/detail.tsx +12 -2
  76. package/src/pages/admin/products/promotion-codes/actions.tsx +103 -0
  77. package/src/pages/admin/products/promotion-codes/create.tsx +235 -0
  78. package/src/pages/admin/products/promotion-codes/detail.tsx +416 -0
  79. package/src/pages/admin/products/promotion-codes/list.tsx +247 -0
  80. package/src/pages/admin/products/promotion-codes/verification-config.tsx +327 -0
  81. package/src/pages/admin/products/vendors/index.tsx +17 -5
  82. package/src/pages/customer/subscription/detail.tsx +5 -0
  83. package/vite.config.ts +4 -3
@@ -0,0 +1,518 @@
1
+ /* eslint-disable @typescript-eslint/require-await */
2
+ import { Router } from 'express';
3
+ import Joi from 'joi';
4
+ import pick from 'lodash/pick';
5
+
6
+ import { CustomError, formatError } from '@blocklet/error';
7
+ import { fromTokenToUnit } from '@ocap/util';
8
+ import { authenticate } from '../libs/security';
9
+ import { createListParamSchema, getOrder, getWhereFromKvQuery, MetadataSchema } from '../libs/api';
10
+ import { createIdGenerator, formatMetadata } from '../libs/util';
11
+ import { trimDecimals } from '../libs/math-utils';
12
+ import { Coupon, PaymentCurrency, PromotionCode } from '../store/models';
13
+ import { getRedemptionData } from '../libs/discount/redemption';
14
+ import logger from '../libs/logger';
15
+
16
+ const router = Router();
17
+ const auth = authenticate({ component: true, roles: ['owner', 'admin'] });
18
+
19
+ const PromotionCodeSchema = Joi.object({
20
+ code: Joi.string().optional().max(16),
21
+ description: Joi.string().empty('').max(250).optional(),
22
+ active: Joi.boolean().default(true),
23
+ max_redemptions: Joi.number().integer().positive().optional(),
24
+ expires_at: Joi.number().integer().min(0).optional(),
25
+ verification_type: Joi.string().valid('code', 'nft', 'vc', 'user_restricted').default('code'),
26
+ nft_config: Joi.object({
27
+ addresses: Joi.array().items(Joi.string()).optional(),
28
+ tags: Joi.array().items(Joi.string()).optional(),
29
+ trusted_issuers: Joi.array().items(Joi.string()).optional(),
30
+ trusted_parents: Joi.array().items(Joi.string()).optional(),
31
+ min_balance: Joi.number().integer().positive().default(1),
32
+ }).optional(),
33
+ vc_config: Joi.object({
34
+ roles: Joi.array().items(Joi.string()).optional(),
35
+ trusted_issuers: Joi.array().items(Joi.string()).optional(),
36
+ }).optional(),
37
+ customer_dids: Joi.array().items(Joi.string()).optional(),
38
+ restrictions: Joi.object({
39
+ currency_options: Joi.object()
40
+ .pattern(
41
+ Joi.string(),
42
+ Joi.object({
43
+ minimum_amount: Joi.number().min(0).required(),
44
+ })
45
+ )
46
+ .optional(),
47
+ first_time_transaction: Joi.boolean().optional(),
48
+ minimum_amount: Joi.number().positive().optional(),
49
+ minimum_amount_currency: Joi.string().optional(),
50
+ require_minimum_amount: Joi.boolean().optional(),
51
+ })
52
+ .optional()
53
+ .unknown(true),
54
+ metadata: Joi.object().optional(),
55
+ }).unknown(true);
56
+
57
+ const createCouponSchema = Joi.object({
58
+ id: Joi.string().empty('').optional(),
59
+ name: Joi.string().required().max(64),
60
+ description: Joi.string().empty('').max(250).optional(),
61
+ amount_off: Joi.string().empty('').optional(),
62
+ percent_off: Joi.number().min(0).max(100).optional(),
63
+ currency_id: Joi.string().empty('').optional(),
64
+ duration: Joi.string().valid('once', 'forever', 'repeating').required(),
65
+ duration_in_months: Joi.number().min(1).when('duration', {
66
+ is: 'repeating',
67
+ then: Joi.required(),
68
+ otherwise: Joi.optional(),
69
+ }),
70
+ max_redemptions: Joi.number().min(1).empty('').optional(),
71
+ redeem_by: Joi.number().integer().min(0).empty('').optional(),
72
+ applies_to: Joi.object({
73
+ products: Joi.array().items(Joi.string()),
74
+ }).optional(),
75
+ currency_options: Joi.object()
76
+ .pattern(
77
+ Joi.string(),
78
+ Joi.object({
79
+ amount_off: Joi.number().min(0).required(),
80
+ })
81
+ )
82
+ .optional(),
83
+ metadata: MetadataSchema,
84
+ promotion_codes: Joi.array().items(PromotionCodeSchema).optional(),
85
+ })
86
+ .unknown(true)
87
+ .custom((value, helpers) => {
88
+ const { amount_off: amountOff, percent_off: percentOff, currency_id: currencyId } = value;
89
+ const hasAmountOff = amountOff && amountOff.trim() !== '';
90
+ const hasPercentOff = percentOff !== undefined && percentOff !== null;
91
+
92
+ if (!hasAmountOff && !hasPercentOff) {
93
+ return helpers.error('any.required', { message: 'Either amount_off or percent_off must be provided' });
94
+ }
95
+ if (hasAmountOff && hasPercentOff) {
96
+ return helpers.error('any.custom', { message: 'Cannot provide both amount_off and percent_off' });
97
+ }
98
+
99
+ // If amount_off is provided, currency_id is required
100
+ if (hasAmountOff && (!currencyId || currencyId.trim() === '')) {
101
+ return helpers.error('any.required', { message: 'currency_id is required when amount_off is provided' });
102
+ }
103
+
104
+ return value;
105
+ });
106
+
107
+ const updateCouponSchema = Joi.object({
108
+ name: Joi.string().empty('').max(64).optional(),
109
+ description: Joi.string().empty('').max(250).optional(),
110
+ max_redemptions: Joi.number().min(1).empty('').optional(),
111
+ redeem_by: Joi.number().integer().min(0).empty('').optional(),
112
+ valid: Joi.boolean().empty('').optional(),
113
+ currency_options: Joi.object()
114
+ .pattern(
115
+ Joi.string(),
116
+ Joi.object({
117
+ amount_off: Joi.number().min(0).required(),
118
+ })
119
+ )
120
+ .optional(),
121
+ metadata: MetadataSchema,
122
+ }).unknown(true);
123
+
124
+ // Get expanded coupon with all related information
125
+ export async function getExpandedCoupon(id: string) {
126
+ const coupon = await Coupon.findByPk(id, {
127
+ include: [{ model: PaymentCurrency, as: 'currency' }],
128
+ });
129
+
130
+ if (coupon) {
131
+ const couponData = coupon.toJSON();
132
+
133
+ // Get related promotion codes
134
+ const promotionCodes = await PromotionCode.findAll({
135
+ where: { coupon_id: id },
136
+ order: [['created_at', 'DESC']],
137
+ });
138
+
139
+ const appliedProducts = await coupon.getAppliedProducts();
140
+
141
+ return {
142
+ ...couponData,
143
+ promotion_codes: promotionCodes,
144
+ applied_products: appliedProducts,
145
+ };
146
+ }
147
+
148
+ return null;
149
+ }
150
+
151
+ // Helper function to create coupon with promotion codes
152
+ export async function createCouponAndPromotionCodes(payload: any) {
153
+ // 1. Prepare coupon data
154
+ const couponData = pick(payload, [
155
+ 'id',
156
+ 'name',
157
+ 'description',
158
+ 'amount_off',
159
+ 'percent_off',
160
+ 'currency_id',
161
+ 'currency_options',
162
+ 'duration',
163
+ 'max_redemptions',
164
+ 'redeem_by',
165
+ 'applies_to',
166
+ 'metadata',
167
+ ]);
168
+
169
+ if (payload.duration === 'repeating' && payload.duration_in_months) {
170
+ (couponData as any).duration_in_months = payload.duration_in_months;
171
+ }
172
+
173
+ (couponData as any).livemode = !!payload.livemode;
174
+ (couponData as any).created_via = payload.created_via || 'api';
175
+ couponData.metadata = formatMetadata(couponData.metadata);
176
+
177
+ // 2. Validate currency if amount_off is used
178
+ if (couponData.amount_off && !couponData.currency_id) {
179
+ throw new CustomError(400, 'currency_id is required when amount_off is provided');
180
+ }
181
+ // Get all payment currencies for formatting
182
+ const allCurrencies = await PaymentCurrency.findAll();
183
+ if (couponData.currency_id) {
184
+ const currency = await PaymentCurrency.findByPk(couponData.currency_id);
185
+ if (!currency) {
186
+ throw new Error(`Currency ${couponData.currency_id} not found`);
187
+ }
188
+ if (couponData.amount_off) {
189
+ couponData.amount_off = fromTokenToUnit(
190
+ trimDecimals(couponData.amount_off || '0', currency.decimal),
191
+ currency.decimal
192
+ ).toString();
193
+ }
194
+ }
195
+
196
+ // Format currency_options if provided
197
+ if ((couponData as any).currency_options) {
198
+ (couponData as any).currency_options = Coupon.formatCurrencyOptions(
199
+ (couponData as any).currency_options,
200
+ allCurrencies
201
+ );
202
+ if (couponData.currency_id && couponData.amount_off && !couponData.currency_options[couponData.currency_id]) {
203
+ couponData.currency_options[couponData.currency_id] = {
204
+ amount_off: couponData.amount_off,
205
+ };
206
+ }
207
+ }
208
+
209
+ // 3. Create coupon
210
+ const coupon = await Coupon.insert(couponData);
211
+
212
+ // 4. Create promotion codes if provided
213
+ if (Array.isArray(payload.promotion_codes) && payload.promotion_codes.length > 0) {
214
+ const promotionCodes = await Promise.all(
215
+ payload.promotion_codes.map(async (promoCodeData: any) => {
216
+ // Generate code if not provided or ensure it's unique
217
+ if (!promoCodeData.code || !promoCodeData.code.trim()) {
218
+ promoCodeData.code = await createIdGenerator('', 8)();
219
+ } else {
220
+ // Check if provided code is unique
221
+ const existingCode = await PromotionCode.findOne({
222
+ where: { code: promoCodeData.code, livemode: coupon.livemode },
223
+ });
224
+ if (existingCode) {
225
+ throw new Error(`Promotion code '${promoCodeData.code}' already exists`);
226
+ }
227
+ }
228
+
229
+ // Format restrictions currency_options if provided
230
+ const formattedRestrictions = PromotionCode.formatRestrictionsCurrencyOptions(
231
+ promoCodeData.restrictions,
232
+ allCurrencies
233
+ );
234
+
235
+ return PromotionCode.insert({
236
+ ...promoCodeData,
237
+ restrictions: formattedRestrictions,
238
+ active: true,
239
+ coupon_id: coupon.id,
240
+ livemode: coupon.livemode,
241
+ created_via: coupon.created_via,
242
+ metadata: promoCodeData.metadata,
243
+ });
244
+ })
245
+ );
246
+ return { coupon, promotion_codes: promotionCodes };
247
+ }
248
+
249
+ return { coupon, promotion_codes: [] };
250
+ }
251
+
252
+ /**
253
+ * POST /api/coupons
254
+ * Create a new coupon with optional promotion codes
255
+ */
256
+ router.post('/', auth, async (req, res) => {
257
+ try {
258
+ logger.info('Creating coupon with body:', req.body);
259
+ const { error, value } = createCouponSchema.validate(req.body);
260
+ if (error) {
261
+ return res.status(400).json({
262
+ error: error.details?.[0]?.message || 'Validation error',
263
+ });
264
+ }
265
+
266
+ const result = await createCouponAndPromotionCodes({
267
+ ...value,
268
+ livemode: req.livemode,
269
+ created_via: req.user?.via || 'api',
270
+ });
271
+
272
+ logger.info('Coupon and promotion codes created', {
273
+ couponId: result.coupon.id,
274
+ promotionCodesCount: result.promotion_codes.length,
275
+ requestedBy: req.user?.did,
276
+ });
277
+
278
+ return res.json(result);
279
+ } catch (error) {
280
+ logger.error('Error creating coupon', {
281
+ error,
282
+ body: req.body,
283
+ });
284
+ return res.status(500).json(formatError(error));
285
+ }
286
+ });
287
+
288
+ // Create pagination schema
289
+ const paginationSchema = createListParamSchema<{
290
+ valid?: boolean;
291
+ name?: string;
292
+ }>({
293
+ valid: Joi.boolean().empty('').optional(),
294
+ name: Joi.string().empty('').optional(),
295
+ });
296
+
297
+ /**
298
+ * GET /api/coupons
299
+ * List all coupons with pagination and filtering
300
+ */
301
+ router.get('/', auth, async (req, res) => {
302
+ const { page, pageSize, valid, name, ...query } = await paginationSchema.validateAsync(req.query, {
303
+ stripUnknown: false,
304
+ allowUnknown: true,
305
+ });
306
+
307
+ const where = query.q ? getWhereFromKvQuery(query.q as any) : {};
308
+
309
+ if (valid !== undefined) {
310
+ where.valid = !!valid;
311
+ }
312
+
313
+ if (typeof req.livemode === 'boolean') {
314
+ where.livemode = !!req.livemode;
315
+ }
316
+
317
+ if (name) {
318
+ where.name = name;
319
+ }
320
+
321
+ const { count, rows } = await Coupon.findAndCountAll({
322
+ where,
323
+ include: [
324
+ {
325
+ model: PaymentCurrency,
326
+ as: 'currency',
327
+ required: false,
328
+ },
329
+ ],
330
+ limit: pageSize,
331
+ offset: (page - 1) * pageSize,
332
+ order: getOrder(req.query, [['created_at', 'DESC']]),
333
+ });
334
+
335
+ return res.json({
336
+ count,
337
+ list: rows,
338
+ paging: { page, pageSize },
339
+ });
340
+ });
341
+
342
+ /**
343
+ * GET /api/coupons/:id
344
+ * Retrieve a specific coupon
345
+ */
346
+ router.get('/:id', auth, async (req, res) => {
347
+ const coupon = await getExpandedCoupon(req.params.id as string);
348
+ if (!coupon) {
349
+ return res.status(404).json({ error: 'Coupon not found' });
350
+ }
351
+ return res.json(coupon);
352
+ });
353
+
354
+ /**
355
+ * PUT /api/coupons/:id
356
+ * Update a coupon (limited fields can be updated)
357
+ */
358
+ router.put('/:id', auth, async (req, res) => {
359
+ const { error, value } = updateCouponSchema.validate(req.body);
360
+ if (error) {
361
+ return res.status(400).json({
362
+ error: error.details?.[0]?.message || 'Validation error',
363
+ });
364
+ }
365
+
366
+ if (!req.params.id) {
367
+ return res.status(400).json({ error: 'Coupon ID is required' });
368
+ }
369
+
370
+ const coupon = await Coupon.findByPk(req.params.id);
371
+
372
+ if (!coupon) {
373
+ return res.status(404).json({ error: 'Coupon not found' });
374
+ }
375
+
376
+ if (coupon.locked) {
377
+ const allowedUpdates = pick(value, ['name', 'metadata', 'description']);
378
+ if (Object.keys(allowedUpdates).length === 0) {
379
+ return res.status(403).json({ error: 'Coupon is locked and cannot be modified' });
380
+ }
381
+ await coupon.update(Coupon.formatBeforeSave(allowedUpdates));
382
+ }
383
+
384
+ // Format currency_options if provided
385
+ if (value.currency_options) {
386
+ const allCurrencies = await PaymentCurrency.findAll();
387
+ value.currency_options = Coupon.formatCurrencyOptions(value.currency_options, allCurrencies);
388
+ }
389
+
390
+ const isUsed = await coupon.isUsed();
391
+
392
+ // Check if coupon is being used
393
+ if (isUsed && !coupon.locked) {
394
+ // Lock the coupon and only allow limited updates
395
+ await coupon.update({ locked: true });
396
+ const allowedUpdates = pick(value, ['name', 'metadata', 'description']);
397
+ if (Object.keys(allowedUpdates).length === 0) {
398
+ return res.status(403).json({
399
+ error: 'Coupon is being used. Only name and metadata can be updated.',
400
+ });
401
+ }
402
+ await coupon.update(Coupon.formatBeforeSave(allowedUpdates));
403
+ } else if (!coupon.locked && !isUsed) {
404
+ // Full update allowed
405
+ await coupon.update(Coupon.formatBeforeSave(value));
406
+ }
407
+
408
+ logger.info('Coupon updated', {
409
+ couponId: req.params.id,
410
+ updatedFields: Object.keys(value),
411
+ requestedBy: req.user?.did,
412
+ });
413
+
414
+ const doc = await getExpandedCoupon(req.params.id as string);
415
+
416
+ return res.json(doc);
417
+ });
418
+
419
+ /**
420
+ * DELETE /api/coupons/:id
421
+ * Delete a coupon (mark as invalid if used, otherwise hard delete)
422
+ */
423
+ router.delete('/:id', auth, async (req, res) => {
424
+ try {
425
+ const coupon = await Coupon.findOne({
426
+ where: {
427
+ id: req.params.id,
428
+ livemode: req.livemode,
429
+ },
430
+ });
431
+
432
+ if (!coupon) {
433
+ return res.status(404).json({ error: 'Coupon not found' });
434
+ }
435
+
436
+ if (coupon.locked) {
437
+ return res.status(403).json({ error: 'Coupon is locked and cannot be deleted' });
438
+ }
439
+
440
+ const isUsed = await coupon.isUsed();
441
+
442
+ // Check if coupon is being used
443
+ if (isUsed) {
444
+ // Mark as invalid instead of deleting
445
+ await coupon.update({ locked: true });
446
+ return res.status(403).json({ error: 'Coupon is being used and cannot be deleted' });
447
+ }
448
+
449
+ // Hard delete if not used
450
+ await coupon.destroy();
451
+
452
+ logger.info('Coupon deleted', {
453
+ couponId: req.params.id,
454
+ });
455
+
456
+ return res.json(coupon);
457
+ } catch (error) {
458
+ logger.error('Error deleting coupon', {
459
+ error: error.message,
460
+ couponId: req.params.id,
461
+ });
462
+ return res.status(400).json(formatError(error));
463
+ }
464
+ });
465
+
466
+ router.get('/:id/used', auth, async (req, res) => {
467
+ const coupon = await Coupon.findByPk(req.params.id);
468
+ if (!coupon) {
469
+ return res.status(404).json({ error: 'Coupon not found' });
470
+ }
471
+ const used = await coupon.isUsed();
472
+ if (used && !coupon.locked) {
473
+ await coupon.update({ locked: true });
474
+ }
475
+ return res.json({ used });
476
+ });
477
+
478
+ // Create redemptions pagination schema
479
+ const redemptionsSchema = createListParamSchema<{
480
+ type?: string;
481
+ }>({
482
+ type: Joi.string().valid('customer', 'subscription').optional(),
483
+ });
484
+
485
+ // Get active redemptions for a coupon with detailed admin analytics
486
+ router.get('/:id/redemptions', auth, async (req, res) => {
487
+ try {
488
+ const couponId = req.params.id as string;
489
+
490
+ const { page, pageSize, type } = await redemptionsSchema.validateAsync(req.query, {
491
+ stripUnknown: false,
492
+ allowUnknown: true,
493
+ });
494
+
495
+ const coupon = await Coupon.findByPk(couponId);
496
+ if (!coupon) {
497
+ return res.status(404).json({ error: 'Coupon not found' });
498
+ }
499
+
500
+ const result = await getRedemptionData(
501
+ { coupon_id: couponId },
502
+ { page, pageSize, type: type as 'customer' | 'subscription' | undefined },
503
+ coupon,
504
+ 'coupon'
505
+ );
506
+
507
+ return res.json(result);
508
+ } catch (error: any) {
509
+ logger.error('Error getting coupon redemptions', {
510
+ error: error.message,
511
+ stack: error.stack,
512
+ couponId: req.params.id,
513
+ });
514
+ return res.status(500).json(formatError(error));
515
+ }
516
+ });
517
+
518
+ export default router;
@@ -3,6 +3,7 @@ import { Router } from 'express';
3
3
  import { PaymentCurrency } from '../store/models/payment-currency';
4
4
  import autoRechargeConfigs from './auto-recharge-configs';
5
5
  import checkoutSessions from './checkout-sessions';
6
+ import coupons from './coupons';
6
7
  import creditGrants from './credit-grants';
7
8
  import creditTransactions from './credit-transactions';
8
9
  import customers from './customers';
@@ -22,6 +23,7 @@ import payouts from './payouts';
22
23
  import prices from './prices';
23
24
  import pricingTables from './pricing-table';
24
25
  import products from './products';
26
+ import promotionCodes from './promotion-codes';
25
27
  import redirect from './redirect';
26
28
  import refunds from './refunds';
27
29
  import settings from './settings';
@@ -55,6 +57,7 @@ router.use(async (req, _, next) => {
55
57
 
56
58
  router.use('/auto-recharge-configs', autoRechargeConfigs);
57
59
  router.use('/checkout-sessions', checkoutSessions);
60
+ router.use('/coupons', coupons);
58
61
  router.use('/credit-grants', creditGrants);
59
62
  router.use('/credit-transactions', creditTransactions);
60
63
  router.use('/customers', customers);
@@ -73,6 +76,7 @@ router.use('/payment-stats', paymentStats);
73
76
  router.use('/prices', prices);
74
77
  router.use('/pricing-tables', pricingTables);
75
78
  router.use('/products', products);
79
+ router.use('/promotion-codes', promotionCodes);
76
80
  router.use('/payouts', payouts);
77
81
  router.use('/redirect', redirect);
78
82
  router.use('/refunds', refunds);
@@ -23,7 +23,7 @@ import { Price } from '../store/models/price';
23
23
  import { Product } from '../store/models/product';
24
24
  import { Subscription } from '../store/models/subscription';
25
25
  import { getReturnStakeInvoices, getStakingInvoices, retryUncollectibleInvoices } from '../libs/invoice';
26
- import { CheckoutSession, PaymentLink, TInvoiceExpanded } from '../store/models';
26
+ import { CheckoutSession, PaymentLink, TInvoiceExpanded, Discount, Coupon, PromotionCode } from '../store/models';
27
27
  import { mergePaginate, defaultTimeOrderBy, getCachedOrFetch, DataSource } from '../libs/pagination';
28
28
  import logger from '../libs/logger';
29
29
  import { returnOverdraftProtectionQueue, returnStakeQueue } from '../queues/subscription';
@@ -186,7 +186,7 @@ router.get('/', authMine, async (req, res) => {
186
186
  .filter(Boolean);
187
187
  }
188
188
  if (ignore_zero) {
189
- where.total = { [Op.ne]: '0' };
189
+ where.subtotal = { [Op.ne]: '0' };
190
190
  }
191
191
  if (query.customer_id) {
192
192
  where.customer_id = query.customer_id;
@@ -644,14 +644,55 @@ router.get('/:id', authPortal, async (req, res) => {
644
644
  ).map((x) => x.toJSON());
645
645
  // @ts-ignore
646
646
  expandLineItems(json.lines, products, prices, paymentCurrencies);
647
+
648
+ // Get discount details from total_discount_amounts
649
+ let discountDetails: any[] = [];
650
+ if (doc.total_discount_amounts && doc.total_discount_amounts.length > 0) {
651
+ try {
652
+ // Extract discount IDs from total_discount_amounts
653
+ const discountIds = doc.total_discount_amounts.map((item: any) => item.discount).filter((id: any) => id);
654
+
655
+ if (discountIds.length > 0) {
656
+ discountDetails = await Discount.findAll({
657
+ where: {
658
+ id: discountIds,
659
+ },
660
+ include: [
661
+ {
662
+ model: Coupon,
663
+ as: 'coupon',
664
+ },
665
+ {
666
+ model: PromotionCode,
667
+ as: 'promotionCode',
668
+ },
669
+ ],
670
+ });
671
+ }
672
+ } catch (error) {
673
+ logger.error('Failed to fetch discount details from total_discount_amounts', {
674
+ error,
675
+ invoiceId: doc.id,
676
+ total_discount_amounts: doc.total_discount_amounts,
677
+ });
678
+ }
679
+ }
680
+
647
681
  if (doc.metadata?.invoice_id || doc.metadata?.prev_invoice_id) {
648
682
  const relatedInvoice = await Invoice.findByPk(doc.metadata.invoice_id || doc.metadata.prev_invoice_id, {
649
683
  attributes: ['id', 'number', 'status', 'billing_reason'],
650
684
  });
651
- return res.json({ ...json, relatedInvoice, paymentLink, checkoutSession });
685
+ return res.json({
686
+ ...json,
687
+ discountDetails,
688
+ relatedInvoice,
689
+ paymentLink,
690
+ checkoutSession,
691
+ });
652
692
  }
653
693
  return res.json({
654
694
  ...json,
695
+ discountDetails,
655
696
  paymentLink,
656
697
  checkoutSession,
657
698
  });
@@ -7,6 +7,7 @@ import { createListParamSchema, getOrder, getWhereFromKvQuery, MetadataSchema }
7
7
  import logger from '../libs/logger';
8
8
  import { authenticate } from '../libs/security';
9
9
  import { formatMetadata } from '../libs/util';
10
+ import { trimDecimals } from '../libs/math-utils';
10
11
  import { Customer, Meter, MeterEvent, MeterEventStatus, PaymentCurrency, Subscription } from '../store/models';
11
12
 
12
13
  const router = Router();
@@ -238,7 +239,7 @@ router.post('/', auth, async (req, res) => {
238
239
 
239
240
  const timestamp = req.body.timestamp || req.body.payload.timestamp || Math.floor(Date.now() / 1000);
240
241
 
241
- const value = parseFloat(req.body.payload.value).toFixed(paymentCurrency.decimal);
242
+ const value = trimDecimals(req.body.payload.value, paymentCurrency.decimal);
242
243
  const eventData = {
243
244
  event_name: req.body.event_name,
244
245
  payload: {
@@ -411,6 +411,7 @@ router.get('/:id/recharge-config', user, async (req, res) => {
411
411
  currency_id: basePrice?.currency_id,
412
412
  name: basePrice?.product?.name || `${currency.name} Recharge`,
413
413
  submit_type: 'pay',
414
+ allow_promotion_codes: true,
414
415
  line_items: [
415
416
  {
416
417
  price_id: rechargeConfig.base_price_id,