payment-kit 1.20.11 → 1.20.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/api/src/crons/index.ts +8 -0
  2. package/api/src/index.ts +2 -0
  3. package/api/src/integrations/stripe/handlers/invoice.ts +63 -5
  4. package/api/src/integrations/stripe/handlers/payment-intent.ts +1 -0
  5. package/api/src/integrations/stripe/resource.ts +253 -2
  6. package/api/src/libs/currency.ts +31 -0
  7. package/api/src/libs/discount/coupon.ts +1061 -0
  8. package/api/src/libs/discount/discount.ts +349 -0
  9. package/api/src/libs/discount/nft.ts +239 -0
  10. package/api/src/libs/discount/redemption.ts +636 -0
  11. package/api/src/libs/discount/vc.ts +73 -0
  12. package/api/src/libs/env.ts +1 -0
  13. package/api/src/libs/invoice.ts +44 -10
  14. package/api/src/libs/math-utils.ts +6 -0
  15. package/api/src/libs/price.ts +43 -0
  16. package/api/src/libs/session.ts +242 -57
  17. package/api/src/libs/subscription.ts +2 -6
  18. package/api/src/libs/vendor-util/adapters/launcher-adapter.ts +1 -1
  19. package/api/src/libs/vendor-util/adapters/types.ts +1 -0
  20. package/api/src/libs/vendor-util/fulfillment.ts +1 -1
  21. package/api/src/queues/auto-recharge.ts +1 -1
  22. package/api/src/queues/discount-status.ts +200 -0
  23. package/api/src/queues/subscription.ts +98 -5
  24. package/api/src/queues/usage-record.ts +1 -1
  25. package/api/src/queues/vendors/fulfillment-coordinator.ts +1 -29
  26. package/api/src/queues/vendors/return-processor.ts +184 -0
  27. package/api/src/queues/vendors/return-scanner.ts +119 -0
  28. package/api/src/queues/vendors/status-check.ts +1 -1
  29. package/api/src/routes/auto-recharge-configs.ts +5 -3
  30. package/api/src/routes/checkout-sessions.ts +755 -64
  31. package/api/src/routes/connect/change-payment.ts +6 -1
  32. package/api/src/routes/connect/change-plan.ts +6 -1
  33. package/api/src/routes/connect/setup.ts +6 -1
  34. package/api/src/routes/connect/shared.ts +80 -9
  35. package/api/src/routes/connect/subscribe.ts +12 -2
  36. package/api/src/routes/coupons.ts +518 -0
  37. package/api/src/routes/index.ts +4 -0
  38. package/api/src/routes/invoices.ts +44 -3
  39. package/api/src/routes/meter-events.ts +2 -1
  40. package/api/src/routes/payment-currencies.ts +1 -0
  41. package/api/src/routes/promotion-codes.ts +482 -0
  42. package/api/src/routes/subscriptions.ts +23 -2
  43. package/api/src/routes/vendor.ts +89 -2
  44. package/api/src/store/migrations/20250904-discount.ts +136 -0
  45. package/api/src/store/migrations/20250910-timestamp-fields.ts +116 -0
  46. package/api/src/store/migrations/20250916-add-description-fields.ts +30 -0
  47. package/api/src/store/migrations/20250918-add-vendor-extends.ts +20 -0
  48. package/api/src/store/models/checkout-session.ts +17 -2
  49. package/api/src/store/models/coupon.ts +144 -4
  50. package/api/src/store/models/discount.ts +23 -10
  51. package/api/src/store/models/index.ts +13 -2
  52. package/api/src/store/models/product-vendor.ts +6 -0
  53. package/api/src/store/models/promotion-code.ts +295 -18
  54. package/api/src/store/models/types.ts +30 -1
  55. package/api/tests/libs/session.spec.ts +48 -27
  56. package/blocklet.yml +1 -1
  57. package/package.json +20 -20
  58. package/src/app.tsx +2 -0
  59. package/src/components/customer/link.tsx +1 -1
  60. package/src/components/discount/discount-info.tsx +178 -0
  61. package/src/components/invoice/table.tsx +140 -48
  62. package/src/components/invoice-pdf/styles.ts +6 -0
  63. package/src/components/invoice-pdf/template.tsx +59 -33
  64. package/src/components/metadata/form.tsx +14 -5
  65. package/src/components/payment-link/actions.tsx +42 -0
  66. package/src/components/price/form.tsx +91 -65
  67. package/src/components/product/vendor-config.tsx +5 -3
  68. package/src/components/promotion/active-redemptions.tsx +534 -0
  69. package/src/components/promotion/currency-multi-select.tsx +350 -0
  70. package/src/components/promotion/currency-restrictions.tsx +117 -0
  71. package/src/components/promotion/product-select.tsx +292 -0
  72. package/src/components/promotion/promotion-code-form.tsx +534 -0
  73. package/src/components/subscription/portal/list.tsx +6 -1
  74. package/src/components/subscription/vendor-service-list.tsx +13 -2
  75. package/src/locales/en.tsx +227 -0
  76. package/src/locales/zh.tsx +222 -1
  77. package/src/pages/admin/billing/subscriptions/detail.tsx +5 -0
  78. package/src/pages/admin/products/coupons/applicable-products.tsx +166 -0
  79. package/src/pages/admin/products/coupons/create.tsx +612 -0
  80. package/src/pages/admin/products/coupons/detail.tsx +538 -0
  81. package/src/pages/admin/products/coupons/edit.tsx +127 -0
  82. package/src/pages/admin/products/coupons/index.tsx +210 -3
  83. package/src/pages/admin/products/index.tsx +22 -3
  84. package/src/pages/admin/products/products/detail.tsx +12 -2
  85. package/src/pages/admin/products/promotion-codes/actions.tsx +103 -0
  86. package/src/pages/admin/products/promotion-codes/create.tsx +235 -0
  87. package/src/pages/admin/products/promotion-codes/detail.tsx +416 -0
  88. package/src/pages/admin/products/promotion-codes/list.tsx +247 -0
  89. package/src/pages/admin/products/promotion-codes/verification-config.tsx +327 -0
  90. package/src/pages/admin/products/vendors/index.tsx +17 -5
  91. package/src/pages/customer/subscription/detail.tsx +5 -0
  92. package/vite.config.ts +4 -3
@@ -0,0 +1,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>