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,636 @@
|
|
|
1
|
+
import { Op } from 'sequelize';
|
|
2
|
+
import { BN } from '@ocap/util';
|
|
3
|
+
import {
|
|
4
|
+
Discount,
|
|
5
|
+
Customer,
|
|
6
|
+
PromotionCode,
|
|
7
|
+
Coupon,
|
|
8
|
+
Subscription,
|
|
9
|
+
Invoice,
|
|
10
|
+
PaymentCurrency,
|
|
11
|
+
SubscriptionItem,
|
|
12
|
+
Product,
|
|
13
|
+
Price,
|
|
14
|
+
PaymentMethod,
|
|
15
|
+
} from '../../store/models';
|
|
16
|
+
import { expandLineItems } from '../session';
|
|
17
|
+
import { formatCurrencyInfo } from '../util';
|
|
18
|
+
|
|
19
|
+
interface RedemptionFilters {
|
|
20
|
+
coupon_id?: string;
|
|
21
|
+
promotion_code_id?: string;
|
|
22
|
+
start?: { [Op.lte]: number };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface RedemptionOptions {
|
|
26
|
+
page: number;
|
|
27
|
+
pageSize: number;
|
|
28
|
+
type?: 'customer' | 'subscription';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface SavingsByCurrency {
|
|
32
|
+
amount: string;
|
|
33
|
+
currency: PaymentCurrency;
|
|
34
|
+
formattedAmount: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface DiscountRecordData {
|
|
38
|
+
id: string;
|
|
39
|
+
coupon_id?: string;
|
|
40
|
+
promotion_code_id?: string;
|
|
41
|
+
checkout_session_id?: string;
|
|
42
|
+
subscription_id?: string;
|
|
43
|
+
start: number;
|
|
44
|
+
end?: number;
|
|
45
|
+
created_at: Date | string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface CustomerUsageStats {
|
|
49
|
+
customer: any;
|
|
50
|
+
total_discount_records: number;
|
|
51
|
+
unique_checkout_sessions: Set<string>;
|
|
52
|
+
unique_subscriptions: Set<string>;
|
|
53
|
+
promotion_codes_used: Map<string, any>;
|
|
54
|
+
first_used: Date;
|
|
55
|
+
last_used: Date;
|
|
56
|
+
discount_records: any[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function getCustomerRedemptions(filters: RedemptionFilters) {
|
|
60
|
+
const customerDiscounts = await Discount.findAll({
|
|
61
|
+
where: filters as any,
|
|
62
|
+
include: [
|
|
63
|
+
{
|
|
64
|
+
model: Customer,
|
|
65
|
+
as: 'customer',
|
|
66
|
+
required: true,
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
model: PromotionCode,
|
|
70
|
+
as: 'promotionCode',
|
|
71
|
+
required: false,
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
model: Coupon,
|
|
75
|
+
as: 'coupon',
|
|
76
|
+
required: false,
|
|
77
|
+
},
|
|
78
|
+
],
|
|
79
|
+
order: [['created_at', 'DESC']],
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
return buildCustomerUsageMap(customerDiscounts);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function buildCustomerUsageMap(customerDiscounts: any[]) {
|
|
86
|
+
const customerUsageMap = new Map<string, CustomerUsageStats>();
|
|
87
|
+
|
|
88
|
+
for (const discount of customerDiscounts) {
|
|
89
|
+
const customerId = discount.customer_id;
|
|
90
|
+
const discountWithIncludes = discount as any;
|
|
91
|
+
|
|
92
|
+
if (!customerUsageMap.has(customerId)) {
|
|
93
|
+
customerUsageMap.set(customerId, {
|
|
94
|
+
customer: discountWithIncludes.customer,
|
|
95
|
+
total_discount_records: 0,
|
|
96
|
+
unique_checkout_sessions: new Set(),
|
|
97
|
+
unique_subscriptions: new Set(),
|
|
98
|
+
promotion_codes_used: new Map(),
|
|
99
|
+
first_used: discount.created_at,
|
|
100
|
+
last_used: discount.created_at,
|
|
101
|
+
discount_records: [],
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const usage = customerUsageMap.get(customerId)!;
|
|
106
|
+
usage.total_discount_records++;
|
|
107
|
+
|
|
108
|
+
const promotionCodeInfo = discountWithIncludes.promotionCode || null;
|
|
109
|
+
|
|
110
|
+
usage.discount_records.push({
|
|
111
|
+
id: discount.id,
|
|
112
|
+
checkout_session_id: discount.checkout_session_id,
|
|
113
|
+
subscription_id: discount.subscription_id,
|
|
114
|
+
discount_start: discount.start,
|
|
115
|
+
discount_end: discount.end,
|
|
116
|
+
created_at: discount.created_at,
|
|
117
|
+
promotion_code: promotionCodeInfo,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// Track unique sessions and subscriptions
|
|
121
|
+
if (discount.checkout_session_id) {
|
|
122
|
+
usage.unique_checkout_sessions.add(discount.checkout_session_id);
|
|
123
|
+
}
|
|
124
|
+
if (discount.subscription_id) {
|
|
125
|
+
usage.unique_subscriptions.add(discount.subscription_id);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Track promotion codes used
|
|
129
|
+
if (discountWithIncludes.promotionCode) {
|
|
130
|
+
const promoKey = `${discountWithIncludes.promotionCode.id}|${discountWithIncludes.promotionCode.code}`;
|
|
131
|
+
usage.promotion_codes_used.set(promoKey, promotionCodeInfo);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Track usage time range
|
|
135
|
+
if (discount.created_at < usage.first_used) {
|
|
136
|
+
usage.first_used = discount.created_at;
|
|
137
|
+
}
|
|
138
|
+
if (discount.created_at > usage.last_used) {
|
|
139
|
+
usage.last_used = discount.created_at;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return customerUsageMap;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function getSubscriptionRedemptions(filters: RedemptionFilters) {
|
|
147
|
+
const subscriptionDiscounts = await Discount.findAll({
|
|
148
|
+
where: {
|
|
149
|
+
...filters,
|
|
150
|
+
subscription_id: { [Op.ne]: null as any },
|
|
151
|
+
},
|
|
152
|
+
include: [
|
|
153
|
+
{
|
|
154
|
+
model: PromotionCode,
|
|
155
|
+
as: 'promotionCode',
|
|
156
|
+
required: false,
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
model: Coupon,
|
|
160
|
+
as: 'coupon',
|
|
161
|
+
required: false,
|
|
162
|
+
},
|
|
163
|
+
],
|
|
164
|
+
order: [['created_at', 'DESC']],
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const subscriptionIds = [...new Set(subscriptionDiscounts.map((d) => d.subscription_id).filter(Boolean))] as string[];
|
|
168
|
+
|
|
169
|
+
if (subscriptionIds.length === 0) {
|
|
170
|
+
return [];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Query subscriptions with all related data, similar to subscriptions.ts get('/')
|
|
174
|
+
const subscriptions = await Subscription.findAll({
|
|
175
|
+
where: { id: { [Op.in]: subscriptionIds } },
|
|
176
|
+
include: [
|
|
177
|
+
{ model: PaymentCurrency, as: 'paymentCurrency' },
|
|
178
|
+
{ model: PaymentMethod, as: 'paymentMethod' },
|
|
179
|
+
{ model: SubscriptionItem, as: 'items' },
|
|
180
|
+
{ model: Customer, as: 'customer' },
|
|
181
|
+
],
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// Get products and prices for expanding line items, similar to subscriptions.ts
|
|
185
|
+
const products = (await Product.findAll()).map((x) => x.toJSON());
|
|
186
|
+
const prices = (await Price.findAll()).map((x) => x.toJSON());
|
|
187
|
+
|
|
188
|
+
// Convert to JSON and expand line items
|
|
189
|
+
const subscriptionDocs = subscriptions.map((x) => x.toJSON());
|
|
190
|
+
subscriptionDocs.forEach((doc) => {
|
|
191
|
+
// @ts-ignore
|
|
192
|
+
expandLineItems(doc.items, products, prices);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const subscriptionMap = new Map(subscriptionDocs.map((s) => [s.id, s]));
|
|
196
|
+
|
|
197
|
+
// Group discounts by subscription for efficient processing
|
|
198
|
+
const discountsBySubscription = new Map<string, any[]>();
|
|
199
|
+
subscriptionDiscounts.forEach((discount) => {
|
|
200
|
+
const subId = discount.subscription_id!;
|
|
201
|
+
if (!discountsBySubscription.has(subId)) {
|
|
202
|
+
discountsBySubscription.set(subId, []);
|
|
203
|
+
}
|
|
204
|
+
discountsBySubscription.get(subId)!.push(discount);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// Build results with savings calculation
|
|
208
|
+
const results = await Promise.all(
|
|
209
|
+
Array.from(discountsBySubscription.entries()).map(async ([subscriptionId, discounts]) => {
|
|
210
|
+
const subscription = subscriptionMap.get(subscriptionId);
|
|
211
|
+
if (!subscription) return [];
|
|
212
|
+
|
|
213
|
+
// Get discount IDs for this subscription
|
|
214
|
+
const discountIds = discounts.map((d) => d.id);
|
|
215
|
+
|
|
216
|
+
// Calculate total savings for this subscription
|
|
217
|
+
const totalSavingsByCurrency = await calculateSubscriptionTotalSavingsFromInvoices(subscriptionId, discountIds);
|
|
218
|
+
|
|
219
|
+
// Return enhanced subscription data with discount info and savings for each discount
|
|
220
|
+
return discounts.map((discount) =>
|
|
221
|
+
buildSubscriptionResultWithSavings(discount, subscription, totalSavingsByCurrency)
|
|
222
|
+
);
|
|
223
|
+
})
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
return results.flat();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function buildSubscriptionResultWithSavings(
|
|
230
|
+
discount: Discount & { promotionCode?: PromotionCode | null; coupon?: Coupon | null },
|
|
231
|
+
subscription: Record<string, any>,
|
|
232
|
+
totalSavingsByCurrency: Record<string, SavingsByCurrency>
|
|
233
|
+
) {
|
|
234
|
+
const promotionCodeInfo = discount.promotionCode || null;
|
|
235
|
+
const couponInfo = discount.coupon || null;
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
...subscription, // subscription is already a JSON object with expanded items
|
|
239
|
+
discount_info: {
|
|
240
|
+
discount_id: discount.id,
|
|
241
|
+
checkout_session_id: discount.checkout_session_id,
|
|
242
|
+
discount_start: discount.start,
|
|
243
|
+
discount_end: discount.end,
|
|
244
|
+
discount_created_at: discount.created_at,
|
|
245
|
+
promotion_code: promotionCodeInfo,
|
|
246
|
+
coupon_info: couponInfo,
|
|
247
|
+
total_savings: totalSavingsByCurrency,
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function paginateResults<T>(results: T[], page: number, pageSize: number) {
|
|
253
|
+
const total = results.length;
|
|
254
|
+
const paginatedResults = results.slice((page - 1) * pageSize, page * pageSize);
|
|
255
|
+
return { results: paginatedResults, total };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Generic function to calculate total savings by currency from Invoice.total_discount_amounts
|
|
260
|
+
* @param whereCondition Where condition for Invoice query
|
|
261
|
+
* @param discountIds Array of discount IDs to match
|
|
262
|
+
* @returns Object with currency_id as key and savings data
|
|
263
|
+
*/
|
|
264
|
+
async function calculateTotalSavingsFromInvoices(
|
|
265
|
+
whereCondition: Record<string, any>,
|
|
266
|
+
discountIds: string[]
|
|
267
|
+
): Promise<Record<string, SavingsByCurrency>> {
|
|
268
|
+
if (discountIds.length === 0) {
|
|
269
|
+
return {};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Query invoices with minimal attributes to reduce data transfer
|
|
273
|
+
const invoices = await Invoice.findAll({
|
|
274
|
+
where: whereCondition,
|
|
275
|
+
attributes: ['id', 'currency_id', 'total_discount_amounts'],
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
// Query currencies - simple and straightforward since there won't be many
|
|
279
|
+
const currencies = await PaymentCurrency.findAll({
|
|
280
|
+
attributes: ['id', 'symbol', 'decimal', 'name'],
|
|
281
|
+
});
|
|
282
|
+
const currencyMap = new Map(currencies.map((c) => [c.id, c]));
|
|
283
|
+
|
|
284
|
+
// Single pass calculation using BN for precise arithmetic
|
|
285
|
+
const savingsByCurrency: Record<string, BN> = {};
|
|
286
|
+
|
|
287
|
+
invoices.forEach((invoice) => {
|
|
288
|
+
if (invoice.total_discount_amounts && Array.isArray(invoice.total_discount_amounts)) {
|
|
289
|
+
const currencyId = invoice.currency_id;
|
|
290
|
+
|
|
291
|
+
// Process all discount amounts in one pass
|
|
292
|
+
invoice.total_discount_amounts.forEach((discountAmount: Record<string, any>) => {
|
|
293
|
+
if (discountAmount.discount && discountIds.includes(discountAmount.discount)) {
|
|
294
|
+
const amount = discountAmount.amount || '0';
|
|
295
|
+
const bnAmount = new BN(amount);
|
|
296
|
+
|
|
297
|
+
if (savingsByCurrency[currencyId]) {
|
|
298
|
+
savingsByCurrency[currencyId] = savingsByCurrency[currencyId].add(bnAmount);
|
|
299
|
+
} else {
|
|
300
|
+
savingsByCurrency[currencyId] = bnAmount;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
// Convert BN back to string and add formatted amount
|
|
308
|
+
const result: Record<string, SavingsByCurrency> = {};
|
|
309
|
+
Object.entries(savingsByCurrency).forEach(([currencyId, bnAmount]) => {
|
|
310
|
+
const currency = currencyMap.get(currencyId);
|
|
311
|
+
if (!currency) {
|
|
312
|
+
console.warn(`Currency not found for ID: ${currencyId}`);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const rawAmount = bnAmount.toString();
|
|
317
|
+
const formattedAmount = formatCurrencyInfo(rawAmount, currency, null, false);
|
|
318
|
+
|
|
319
|
+
result[currencyId] = {
|
|
320
|
+
amount: rawAmount,
|
|
321
|
+
currency,
|
|
322
|
+
formattedAmount,
|
|
323
|
+
};
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
return result;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Calculate total savings by currency from Invoice.total_discount_amounts for a customer
|
|
331
|
+
* @param customerId Customer ID
|
|
332
|
+
* @param discountIds Array of discount IDs to match
|
|
333
|
+
* @returns Object with currency_id as key and total amount as value
|
|
334
|
+
*/
|
|
335
|
+
function calculateCustomerTotalSavingsFromInvoices(
|
|
336
|
+
customerId: string,
|
|
337
|
+
discountIds: string[]
|
|
338
|
+
): Promise<Record<string, SavingsByCurrency>> {
|
|
339
|
+
return calculateTotalSavingsFromInvoices({ customer_id: customerId }, discountIds);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Calculate total savings by currency from Invoice.total_discount_amounts for a subscription
|
|
344
|
+
* @param subscriptionId Subscription ID
|
|
345
|
+
* @param discountIds Array of discount IDs to match
|
|
346
|
+
* @returns Object with currency_id as key and savings data
|
|
347
|
+
*/
|
|
348
|
+
function calculateSubscriptionTotalSavingsFromInvoices(
|
|
349
|
+
subscriptionId: string,
|
|
350
|
+
discountIds: string[]
|
|
351
|
+
): Promise<Record<string, SavingsByCurrency>> {
|
|
352
|
+
return calculateTotalSavingsFromInvoices({ subscription_id: subscriptionId }, discountIds);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Process discounts and extract statistics
|
|
357
|
+
* @param discounts Array of discount records
|
|
358
|
+
* @returns Processing results with unique tracking
|
|
359
|
+
*/
|
|
360
|
+
function processDiscountRecords(
|
|
361
|
+
discounts: (Discount & { promotionCode?: PromotionCode | null; coupon?: Coupon | null })[]
|
|
362
|
+
) {
|
|
363
|
+
const couponsUsed = new Map<string, string>();
|
|
364
|
+
const promotionCodesUsed = new Map<string, PromotionCode>();
|
|
365
|
+
const uniqueCheckoutSessions = new Set<string>();
|
|
366
|
+
const uniqueSubscriptions = new Set<string>();
|
|
367
|
+
|
|
368
|
+
let firstUsed: Date | null = null;
|
|
369
|
+
let lastUsed: Date | null = null;
|
|
370
|
+
|
|
371
|
+
const discountRecords = discounts.map((discount) => {
|
|
372
|
+
try {
|
|
373
|
+
const discountData = discount.toJSON() as unknown as DiscountRecordData & { subscription_id?: string };
|
|
374
|
+
const { promotionCode, coupon } = discount;
|
|
375
|
+
|
|
376
|
+
// Track unique sessions and subscriptions
|
|
377
|
+
if (discountData.checkout_session_id) {
|
|
378
|
+
uniqueCheckoutSessions.add(discountData.checkout_session_id);
|
|
379
|
+
}
|
|
380
|
+
if (discountData.subscription_id) {
|
|
381
|
+
uniqueSubscriptions.add(discountData.subscription_id);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Track unique coupons
|
|
385
|
+
if (discountData.coupon_id) {
|
|
386
|
+
couponsUsed.set(discountData.coupon_id, discountData.coupon_id);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Track unique promotion codes
|
|
390
|
+
if (promotionCode) {
|
|
391
|
+
const promoKey = `${promotionCode.id}|${promotionCode.code}`;
|
|
392
|
+
promotionCodesUsed.set(promoKey, promotionCode);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Track usage time range
|
|
396
|
+
const createdAtDate = new Date(discountData.created_at);
|
|
397
|
+
if (!firstUsed || createdAtDate < firstUsed) {
|
|
398
|
+
firstUsed = createdAtDate;
|
|
399
|
+
}
|
|
400
|
+
if (!lastUsed || createdAtDate > lastUsed) {
|
|
401
|
+
lastUsed = createdAtDate;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return {
|
|
405
|
+
id: discountData.id,
|
|
406
|
+
coupon_id: discountData.coupon_id,
|
|
407
|
+
promotion_code_id: discountData.promotion_code_id,
|
|
408
|
+
checkout_session_id: discountData.checkout_session_id,
|
|
409
|
+
subscription_id: discountData.subscription_id,
|
|
410
|
+
discount_start: discountData.start,
|
|
411
|
+
discount_end: discountData.end,
|
|
412
|
+
created_at: discountData.created_at,
|
|
413
|
+
promotion_code: promotionCode,
|
|
414
|
+
coupon,
|
|
415
|
+
};
|
|
416
|
+
} catch (error) {
|
|
417
|
+
console.error('Error processing discount record:', error);
|
|
418
|
+
// Return a fallback record to avoid breaking the entire operation
|
|
419
|
+
return {
|
|
420
|
+
id: discount.id,
|
|
421
|
+
coupon_id: undefined,
|
|
422
|
+
promotion_code_id: undefined,
|
|
423
|
+
checkout_session_id: undefined,
|
|
424
|
+
subscription_id: undefined,
|
|
425
|
+
discount_start: 0,
|
|
426
|
+
discount_end: undefined,
|
|
427
|
+
created_at: discount.created_at,
|
|
428
|
+
promotion_code: null,
|
|
429
|
+
coupon: null,
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
return {
|
|
435
|
+
discountRecords,
|
|
436
|
+
couponsUsed: Array.from(couponsUsed.keys()),
|
|
437
|
+
promotionCodesUsed: Array.from(promotionCodesUsed.values()),
|
|
438
|
+
uniqueCheckoutSessions: uniqueCheckoutSessions.size,
|
|
439
|
+
uniqueSubscriptions: uniqueSubscriptions.size,
|
|
440
|
+
firstUsed,
|
|
441
|
+
lastUsed,
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Get comprehensive discount statistics for a subscription
|
|
447
|
+
* @param subscriptionId Subscription ID
|
|
448
|
+
* @returns Subscription discount statistics including total savings, coupons, and promotion codes used
|
|
449
|
+
*/
|
|
450
|
+
export async function getSubscriptionDiscountStats(subscriptionId: string) {
|
|
451
|
+
const discounts = await Discount.findAll({
|
|
452
|
+
where: { subscription_id: subscriptionId },
|
|
453
|
+
include: [
|
|
454
|
+
{
|
|
455
|
+
model: PromotionCode,
|
|
456
|
+
as: 'promotionCode',
|
|
457
|
+
required: false,
|
|
458
|
+
},
|
|
459
|
+
{
|
|
460
|
+
model: Coupon,
|
|
461
|
+
as: 'coupon',
|
|
462
|
+
required: false,
|
|
463
|
+
},
|
|
464
|
+
],
|
|
465
|
+
order: [['created_at', 'DESC']],
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
if (discounts.length === 0) {
|
|
469
|
+
return {
|
|
470
|
+
subscription_id: subscriptionId,
|
|
471
|
+
total_discount_records: 0,
|
|
472
|
+
total_savings: {},
|
|
473
|
+
coupons_used: [],
|
|
474
|
+
promotion_codes_used: [],
|
|
475
|
+
discount_records: [],
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const discountIds = discounts.map((d) => d.id);
|
|
480
|
+
|
|
481
|
+
// Calculate total savings from invoices
|
|
482
|
+
const totalSavings = await calculateSubscriptionTotalSavingsFromInvoices(subscriptionId, discountIds);
|
|
483
|
+
|
|
484
|
+
// Process discount records and extract statistics
|
|
485
|
+
const processedData = processDiscountRecords(
|
|
486
|
+
discounts as (Discount & { promotionCode?: PromotionCode | null; coupon?: Coupon | null })[]
|
|
487
|
+
);
|
|
488
|
+
|
|
489
|
+
return {
|
|
490
|
+
subscription_id: subscriptionId,
|
|
491
|
+
total_discount_records: discounts.length,
|
|
492
|
+
total_savings: totalSavings,
|
|
493
|
+
coupons_used: processedData.couponsUsed,
|
|
494
|
+
promotion_codes_used: processedData.promotionCodesUsed,
|
|
495
|
+
discount_records: processedData.discountRecords,
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Get comprehensive discount statistics for a customer
|
|
501
|
+
* @param customerId Customer ID
|
|
502
|
+
* @returns Customer discount statistics including total savings, coupons, and promotion codes used
|
|
503
|
+
*/
|
|
504
|
+
export async function getCustomerDiscountStats(customerId: string) {
|
|
505
|
+
// Get all discounts for this customer
|
|
506
|
+
const discounts = await Discount.findAll({
|
|
507
|
+
where: { customer_id: customerId },
|
|
508
|
+
include: [
|
|
509
|
+
{
|
|
510
|
+
model: PromotionCode,
|
|
511
|
+
as: 'promotionCode',
|
|
512
|
+
required: false,
|
|
513
|
+
},
|
|
514
|
+
],
|
|
515
|
+
order: [['created_at', 'DESC']],
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
if (discounts.length === 0) {
|
|
519
|
+
return {
|
|
520
|
+
customer_id: customerId,
|
|
521
|
+
total_discount_records: 0,
|
|
522
|
+
unique_checkout_sessions: 0,
|
|
523
|
+
unique_subscriptions: 0,
|
|
524
|
+
total_savings: {},
|
|
525
|
+
coupons_used: [],
|
|
526
|
+
promotion_codes_used: [],
|
|
527
|
+
first_used: null,
|
|
528
|
+
last_used: null,
|
|
529
|
+
discount_records: [],
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const discountIds = discounts.map((d) => d.id);
|
|
534
|
+
|
|
535
|
+
// Calculate total savings from invoices
|
|
536
|
+
const totalSavings = await calculateCustomerTotalSavingsFromInvoices(customerId, discountIds);
|
|
537
|
+
|
|
538
|
+
// Process discount records and extract statistics
|
|
539
|
+
const processedData = processDiscountRecords(
|
|
540
|
+
discounts as (Discount & { promotionCode?: PromotionCode | null; coupon?: Coupon | null })[]
|
|
541
|
+
);
|
|
542
|
+
|
|
543
|
+
return {
|
|
544
|
+
customer_id: customerId,
|
|
545
|
+
total_discount_records: discounts.length,
|
|
546
|
+
unique_checkout_sessions: processedData.uniqueCheckoutSessions,
|
|
547
|
+
unique_subscriptions: processedData.uniqueSubscriptions,
|
|
548
|
+
total_savings: totalSavings,
|
|
549
|
+
coupons_used: processedData.couponsUsed,
|
|
550
|
+
promotion_codes_used: processedData.promotionCodesUsed,
|
|
551
|
+
first_used: processedData.firstUsed,
|
|
552
|
+
last_used: processedData.lastUsed,
|
|
553
|
+
discount_records: processedData.discountRecords,
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
export async function getRedemptionData(
|
|
558
|
+
filters: RedemptionFilters,
|
|
559
|
+
options: RedemptionOptions,
|
|
560
|
+
entity: any,
|
|
561
|
+
entityType: 'coupon' | 'promotion_code'
|
|
562
|
+
) {
|
|
563
|
+
const { page, pageSize, type } = options;
|
|
564
|
+
const now = Math.floor(Date.now() / 1000);
|
|
565
|
+
|
|
566
|
+
// Add time filter
|
|
567
|
+
const queryFilters = {
|
|
568
|
+
...filters,
|
|
569
|
+
start: { [Op.lte]: now },
|
|
570
|
+
};
|
|
571
|
+
|
|
572
|
+
const entityInfo = entity.toJSON();
|
|
573
|
+
|
|
574
|
+
if (!type || type === 'customer') {
|
|
575
|
+
const customerUsageMap = await getCustomerRedemptions(queryFilters);
|
|
576
|
+
|
|
577
|
+
// Enhance customer data with accurate savings calculation
|
|
578
|
+
const customerUsages = await Promise.all(
|
|
579
|
+
Array.from(customerUsageMap.values()).map(async (usage: any) => {
|
|
580
|
+
// Get discount IDs from this customer's discount records
|
|
581
|
+
const discountIds = usage.discount_records.map((record: any) => record.id).filter(Boolean);
|
|
582
|
+
|
|
583
|
+
// Calculate total savings by currency from invoices
|
|
584
|
+
const totalSavingsByCurrency = await calculateCustomerTotalSavingsFromInvoices(usage.customer.id, discountIds);
|
|
585
|
+
|
|
586
|
+
return {
|
|
587
|
+
...usage.customer.toJSON(),
|
|
588
|
+
coupon_usage_stats: {
|
|
589
|
+
total_discount_records: usage.total_discount_records,
|
|
590
|
+
unique_checkout_sessions: usage.unique_checkout_sessions.size,
|
|
591
|
+
unique_subscriptions: usage.unique_subscriptions.size,
|
|
592
|
+
promotion_codes_used: Array.from(usage.promotion_codes_used.values()),
|
|
593
|
+
first_used: usage.first_used,
|
|
594
|
+
last_used: usage.last_used,
|
|
595
|
+
total_savings: totalSavingsByCurrency,
|
|
596
|
+
},
|
|
597
|
+
discount_records: usage.discount_records,
|
|
598
|
+
};
|
|
599
|
+
})
|
|
600
|
+
);
|
|
601
|
+
|
|
602
|
+
const { results: paginatedCustomers, total } = paginateResults(customerUsages, page, pageSize);
|
|
603
|
+
|
|
604
|
+
return {
|
|
605
|
+
count: total,
|
|
606
|
+
customers: paginatedCustomers,
|
|
607
|
+
paging: { page, pageSize },
|
|
608
|
+
[`${entityType}_info`]: entityInfo,
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
if (type === 'subscription') {
|
|
613
|
+
const subscriptionResults = await getSubscriptionRedemptions(queryFilters);
|
|
614
|
+
const { results: paginatedSubscriptions, total } = paginateResults(subscriptionResults, page, pageSize);
|
|
615
|
+
|
|
616
|
+
// Add entity info to each subscription result
|
|
617
|
+
const subscriptionsWithEntityInfo = paginatedSubscriptions.map((sub) => ({
|
|
618
|
+
...sub,
|
|
619
|
+
discount_info: {
|
|
620
|
+
...sub.discount_info,
|
|
621
|
+
[`${entityType}_info`]: entity.toJSON(),
|
|
622
|
+
},
|
|
623
|
+
}));
|
|
624
|
+
|
|
625
|
+
return {
|
|
626
|
+
count: total,
|
|
627
|
+
subscriptions: subscriptionsWithEntityInfo,
|
|
628
|
+
paging: { page, pageSize },
|
|
629
|
+
[`${entityType}_info`]: entityInfo,
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
throw new Error('Type parameter must be either "customer" or "subscription"');
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
export type { RedemptionFilters, RedemptionOptions };
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Verifiable Credential (VC) verification for promotion codes
|
|
2
|
+
// TODO: This module needs implementation for VC-based promotion code validation
|
|
3
|
+
|
|
4
|
+
import logger from '../logger';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* VC verification config interface
|
|
8
|
+
*/
|
|
9
|
+
export interface VCConfig {
|
|
10
|
+
roles?: string[];
|
|
11
|
+
trusted_issuers?: string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* VC verification result interface
|
|
16
|
+
*/
|
|
17
|
+
export interface VCVerificationResult {
|
|
18
|
+
valid: boolean;
|
|
19
|
+
matchedVCs: Array<{
|
|
20
|
+
vcId: string;
|
|
21
|
+
issuer: string;
|
|
22
|
+
role?: string;
|
|
23
|
+
claims: Record<string, any>;
|
|
24
|
+
}>;
|
|
25
|
+
error?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Verify Verifiable Credential (VC) using separated VC config structure
|
|
30
|
+
* TODO: This module needs to be implemented for VC-based verification
|
|
31
|
+
*/
|
|
32
|
+
export function verifyVCConfig(userDid: string, vcConfig: VCConfig): Promise<VCVerificationResult> {
|
|
33
|
+
return Promise.resolve()
|
|
34
|
+
.then(() => {
|
|
35
|
+
logger.info('VC verification called', { userDid, vcConfig });
|
|
36
|
+
|
|
37
|
+
// TODO: Implement proper VC verification client
|
|
38
|
+
// This should:
|
|
39
|
+
// 1. Connect to a VC storage/registry service
|
|
40
|
+
// 2. Retrieve user's verifiable credentials
|
|
41
|
+
// 3. Validate credential signatures and expiration
|
|
42
|
+
// 4. Check issuer trust and role requirements
|
|
43
|
+
// 5. Return matching credentials
|
|
44
|
+
|
|
45
|
+
// Placeholder implementation - always returns false for now
|
|
46
|
+
return {
|
|
47
|
+
valid: false, // TODO: Implement actual verification
|
|
48
|
+
matchedVCs: [],
|
|
49
|
+
error: 'VC verification not yet implemented',
|
|
50
|
+
};
|
|
51
|
+
})
|
|
52
|
+
.catch((error) => {
|
|
53
|
+
logger.error('Error verifying VC config', {
|
|
54
|
+
userDid,
|
|
55
|
+
vcConfig,
|
|
56
|
+
error: error.message,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
valid: false,
|
|
61
|
+
matchedVCs: [],
|
|
62
|
+
error: `VC verification failed: ${error.message}`,
|
|
63
|
+
};
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// TODO: Additional VC-related functions to be implemented:
|
|
68
|
+
// - validateVCFormat(vc: any): boolean
|
|
69
|
+
// - checkVCExpiration(vc: any): boolean
|
|
70
|
+
// - verifyVCSignature(vc: any): Promise<boolean>
|
|
71
|
+
// - getVCsForUser(userDid: string): Promise<any[]>
|
|
72
|
+
// - checkVCIssuerTrust(issuer: string, trustedIssuers: string[]): boolean
|
|
73
|
+
// - extractVCClaims(vc: any): Record<string, any>
|