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.
- package/README.md +25 -24
- 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 +50 -16
- 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/locales/en.ts +38 -38
- 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/doc/vendor_fulfillment_system.md +38 -38
- 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 +253 -26
- 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,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;
|
package/api/src/routes/index.ts
CHANGED
|
@@ -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.
|
|
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({
|
|
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 =
|
|
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,
|