payment-kit 1.20.10 → 1.20.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/README.md +25 -24
  2. package/api/src/index.ts +2 -0
  3. package/api/src/integrations/stripe/handlers/invoice.ts +63 -5
  4. package/api/src/integrations/stripe/handlers/payment-intent.ts +1 -0
  5. package/api/src/integrations/stripe/resource.ts +253 -2
  6. package/api/src/libs/currency.ts +31 -0
  7. package/api/src/libs/discount/coupon.ts +1061 -0
  8. package/api/src/libs/discount/discount.ts +349 -0
  9. package/api/src/libs/discount/nft.ts +239 -0
  10. package/api/src/libs/discount/redemption.ts +636 -0
  11. package/api/src/libs/discount/vc.ts +73 -0
  12. package/api/src/libs/invoice.ts +50 -16
  13. package/api/src/libs/math-utils.ts +6 -0
  14. package/api/src/libs/price.ts +43 -0
  15. package/api/src/libs/session.ts +242 -57
  16. package/api/src/libs/subscription.ts +2 -6
  17. package/api/src/locales/en.ts +38 -38
  18. package/api/src/queues/auto-recharge.ts +1 -1
  19. package/api/src/queues/discount-status.ts +200 -0
  20. package/api/src/queues/subscription.ts +98 -5
  21. package/api/src/queues/usage-record.ts +1 -1
  22. package/api/src/routes/auto-recharge-configs.ts +5 -3
  23. package/api/src/routes/checkout-sessions.ts +755 -64
  24. package/api/src/routes/connect/change-payment.ts +6 -1
  25. package/api/src/routes/connect/change-plan.ts +6 -1
  26. package/api/src/routes/connect/setup.ts +6 -1
  27. package/api/src/routes/connect/shared.ts +80 -9
  28. package/api/src/routes/connect/subscribe.ts +12 -2
  29. package/api/src/routes/coupons.ts +518 -0
  30. package/api/src/routes/index.ts +4 -0
  31. package/api/src/routes/invoices.ts +44 -3
  32. package/api/src/routes/meter-events.ts +2 -1
  33. package/api/src/routes/payment-currencies.ts +1 -0
  34. package/api/src/routes/promotion-codes.ts +482 -0
  35. package/api/src/routes/subscriptions.ts +23 -2
  36. package/api/src/store/migrations/20250904-discount.ts +136 -0
  37. package/api/src/store/migrations/20250910-timestamp-fields.ts +116 -0
  38. package/api/src/store/migrations/20250916-add-description-fields.ts +30 -0
  39. package/api/src/store/models/checkout-session.ts +12 -0
  40. package/api/src/store/models/coupon.ts +144 -4
  41. package/api/src/store/models/discount.ts +23 -10
  42. package/api/src/store/models/index.ts +13 -2
  43. package/api/src/store/models/promotion-code.ts +295 -18
  44. package/api/src/store/models/types.ts +30 -1
  45. package/api/tests/libs/session.spec.ts +48 -27
  46. package/blocklet.yml +1 -1
  47. package/doc/vendor_fulfillment_system.md +38 -38
  48. package/package.json +20 -20
  49. package/src/app.tsx +2 -0
  50. package/src/components/customer/link.tsx +1 -1
  51. package/src/components/discount/discount-info.tsx +178 -0
  52. package/src/components/invoice/table.tsx +140 -48
  53. package/src/components/invoice-pdf/styles.ts +6 -0
  54. package/src/components/invoice-pdf/template.tsx +59 -33
  55. package/src/components/metadata/form.tsx +14 -5
  56. package/src/components/payment-link/actions.tsx +42 -0
  57. package/src/components/price/form.tsx +91 -65
  58. package/src/components/product/vendor-config.tsx +5 -3
  59. package/src/components/promotion/active-redemptions.tsx +534 -0
  60. package/src/components/promotion/currency-multi-select.tsx +350 -0
  61. package/src/components/promotion/currency-restrictions.tsx +117 -0
  62. package/src/components/promotion/product-select.tsx +292 -0
  63. package/src/components/promotion/promotion-code-form.tsx +534 -0
  64. package/src/components/subscription/portal/list.tsx +6 -1
  65. package/src/components/subscription/vendor-service-list.tsx +13 -2
  66. package/src/locales/en.tsx +253 -26
  67. package/src/locales/zh.tsx +222 -1
  68. package/src/pages/admin/billing/subscriptions/detail.tsx +5 -0
  69. package/src/pages/admin/products/coupons/applicable-products.tsx +166 -0
  70. package/src/pages/admin/products/coupons/create.tsx +612 -0
  71. package/src/pages/admin/products/coupons/detail.tsx +538 -0
  72. package/src/pages/admin/products/coupons/edit.tsx +127 -0
  73. package/src/pages/admin/products/coupons/index.tsx +210 -3
  74. package/src/pages/admin/products/index.tsx +22 -3
  75. package/src/pages/admin/products/products/detail.tsx +12 -2
  76. package/src/pages/admin/products/promotion-codes/actions.tsx +103 -0
  77. package/src/pages/admin/products/promotion-codes/create.tsx +235 -0
  78. package/src/pages/admin/products/promotion-codes/detail.tsx +416 -0
  79. package/src/pages/admin/products/promotion-codes/list.tsx +247 -0
  80. package/src/pages/admin/products/promotion-codes/verification-config.tsx +327 -0
  81. package/src/pages/admin/products/vendors/index.tsx +17 -5
  82. package/src/pages/customer/subscription/detail.tsx +5 -0
  83. package/vite.config.ts +4 -3
@@ -0,0 +1,349 @@
1
+ import { BN } from '@ocap/util';
2
+ import { Coupon, Discount, PaymentCurrency, PromotionCode } from '../../store/models';
3
+ import type { TLineItemExpanded } from '../../store/models';
4
+ import { getPriceUintAmountByCurrency } from '../price';
5
+ import { validCoupon, checkPromotionCodeEligibility, calculateDiscountAmount } from './coupon';
6
+ import logger from '../logger';
7
+
8
+ const getItemsTotalAmount = (lineItems: TLineItemExpanded[], currencyId: string, options?: { trialing?: boolean }) => {
9
+ let totalUnitPrice = new BN(0);
10
+
11
+ lineItems.forEach((item) => {
12
+ const price = item.upsell_price || item.price;
13
+ const unitPrice = getPriceUintAmountByCurrency(price, currencyId);
14
+
15
+ if (price?.type === 'recurring') {
16
+ if (options?.trialing) {
17
+ return;
18
+ }
19
+ if (price?.recurring?.usage_type === 'metered') {
20
+ return;
21
+ }
22
+ }
23
+
24
+ totalUnitPrice = totalUnitPrice.add(new BN(unitPrice).mul(new BN(item.quantity)));
25
+ });
26
+ return totalUnitPrice.toString();
27
+ };
28
+
29
+ const getItemAmount = (item: TLineItemExpanded, currencyId: string) => {
30
+ const price = item.upsell_price || item.price;
31
+ const unitPrice = getPriceUintAmountByCurrency(price, currencyId);
32
+ return new BN(unitPrice).mul(new BN(item.quantity)).toString();
33
+ };
34
+
35
+ /**
36
+ * Apply discounts to line items and calculate discount summary
37
+ */
38
+ export async function applyDiscountsToLineItems({
39
+ lineItems,
40
+ promotionCodeId,
41
+ couponId,
42
+ discountId,
43
+ customerId,
44
+ currency,
45
+ totalAmount,
46
+ billingContext,
47
+ }: {
48
+ lineItems: TLineItemExpanded[];
49
+ promotionCodeId?: string;
50
+ discountId?: string;
51
+ couponId: string;
52
+ customerId: string;
53
+ currency: PaymentCurrency;
54
+ totalAmount?: string; // Optional, will calculate from lineItems if not provided
55
+ billingContext?: { trialing?: boolean };
56
+ }): Promise<{
57
+ enhancedLineItems: (TLineItemExpanded & {
58
+ discountable: boolean;
59
+ discount_amounts: Array<{ amount: string; coupon: string }>;
60
+ })[];
61
+ discountSummary: {
62
+ appliedCoupon: string | null;
63
+ discountAmount: string;
64
+ totalDiscountAmount: string;
65
+ finalTotal: string;
66
+ };
67
+ notValidReason?: string;
68
+ }> {
69
+ let discount = null;
70
+ if (discountId) {
71
+ discount = await Discount.findByPk(discountId);
72
+ }
73
+
74
+ // 1. Calculate total amount from lineItems if not provided, considering billing context
75
+ const baseTotal = totalAmount ?? getItemsTotalAmount(lineItems, currency.id, billingContext);
76
+
77
+ // 2. Get coupon (required)
78
+ const coupon = await Coupon.findByPk(discount?.coupon_id || couponId);
79
+ if (!coupon) {
80
+ // 3. If couponId doesn't exist, return original items
81
+ const enhancedLineItems = lineItems.map((item) => ({
82
+ ...item,
83
+ discountable: false,
84
+ discount_amounts: [],
85
+ }));
86
+ return {
87
+ enhancedLineItems,
88
+ discountSummary: {
89
+ appliedCoupon: null,
90
+ discountAmount: '0',
91
+ totalDiscountAmount: '0',
92
+ finalTotal: baseTotal,
93
+ },
94
+ notValidReason: 'Coupon not found',
95
+ };
96
+ }
97
+
98
+ let isValid = false;
99
+ let notValidReason = '';
100
+
101
+ // 4. Validate coupon eligibility
102
+ if (promotionCodeId || discount?.promotion_code_id) {
103
+ // Get promotion code and use comprehensive validation
104
+ const promotionCode = await PromotionCode.findByPk(promotionCodeId || discount?.promotion_code_id);
105
+ if (!promotionCode) {
106
+ isValid = false;
107
+ } else {
108
+ const eligibility = await checkPromotionCodeEligibility({
109
+ promotionCode,
110
+ couponId,
111
+ customerId,
112
+ amount: baseTotal,
113
+ currencyId: currency.id,
114
+ lineItems,
115
+ });
116
+ isValid = eligibility.eligible;
117
+ notValidReason = eligibility.reason || '';
118
+ }
119
+ } else {
120
+ // Direct coupon validation
121
+ const couponValidation = await validCoupon(coupon, lineItems);
122
+ isValid = couponValidation.valid;
123
+ notValidReason = couponValidation.reason || '';
124
+ }
125
+
126
+ if (!isValid) {
127
+ // Return original items if validation fails
128
+ const enhancedLineItems = lineItems.map((item) => ({
129
+ ...item,
130
+ discountable: false,
131
+ discount_amounts: [],
132
+ }));
133
+ return {
134
+ enhancedLineItems,
135
+ discountSummary: {
136
+ appliedCoupon: null,
137
+ discountAmount: '0',
138
+ totalDiscountAmount: '0',
139
+ finalTotal: baseTotal,
140
+ },
141
+ notValidReason,
142
+ };
143
+ }
144
+
145
+ // Use common calculation logic
146
+ const result = calculateDiscountForLineItems({
147
+ lineItems,
148
+ coupon,
149
+ discount,
150
+ currency,
151
+ totalAmount,
152
+ billingContext,
153
+ });
154
+
155
+ return {
156
+ ...result,
157
+ notValidReason: undefined, // No validation error if we reach here
158
+ };
159
+ }
160
+
161
+ /**
162
+ * Common discount calculation logic
163
+ */
164
+ function calculateDiscountForLineItems({
165
+ lineItems,
166
+ coupon,
167
+ discount,
168
+ currency,
169
+ totalAmount,
170
+ billingContext,
171
+ }: {
172
+ lineItems: TLineItemExpanded[];
173
+ coupon: Coupon;
174
+ discount?: Discount | null;
175
+ currency: PaymentCurrency;
176
+ totalAmount?: string;
177
+ billingContext?: { trialing?: boolean };
178
+ }): {
179
+ enhancedLineItems: (TLineItemExpanded & {
180
+ discountable: boolean;
181
+ discount_amounts: Array<{ amount: string; coupon: string }>;
182
+ })[];
183
+ discountSummary: {
184
+ appliedCoupon: string | null;
185
+ discountAmount: string;
186
+ totalDiscountAmount: string;
187
+ finalTotal: string;
188
+ };
189
+ } {
190
+ // 1. Calculate total amount from lineItems if not provided, considering billing context
191
+ const baseTotal = totalAmount ?? getItemsTotalAmount(lineItems, currency.id, billingContext);
192
+
193
+ // 2. Get all eligible items based on coupon product restrictions
194
+ const eligibleLineItems = lineItems.filter((item) => {
195
+ if (!coupon.applies_to?.products?.length) {
196
+ return true; // No product restriction, all items eligible
197
+ }
198
+ return coupon.applies_to.products.some((productId) => item.price?.product_id === productId);
199
+ });
200
+
201
+ if (eligibleLineItems.length === 0) {
202
+ // No eligible items, return original
203
+ const enhancedLineItems = lineItems.map((item) => ({
204
+ ...item,
205
+ discountable: false,
206
+ discount_amounts: [],
207
+ }));
208
+ return {
209
+ enhancedLineItems,
210
+ discountSummary: {
211
+ appliedCoupon: null,
212
+ discountAmount: '0',
213
+ totalDiscountAmount: '0',
214
+ finalTotal: baseTotal,
215
+ },
216
+ };
217
+ }
218
+
219
+ // 3. Calculate total eligible amount (only products that qualify for the discount)
220
+ const totalEligibleAmount = eligibleLineItems.reduce((sum, item) => {
221
+ const itemAmount = getItemAmount(item, currency.id);
222
+ return new BN(sum).add(new BN(itemAmount)).toString();
223
+ }, '0');
224
+
225
+ // 4. Calculate discount amount based ONLY on eligible products, not the entire cart
226
+ const totalDiscountAmount = calculateDiscountAmount(coupon, totalEligibleAmount, currency);
227
+
228
+ // Ensure discount doesn't exceed base total
229
+ const adjustedDiscountAmount = new BN(totalDiscountAmount).gt(new BN(baseTotal)) ? baseTotal : totalDiscountAmount;
230
+
231
+ const finalTotal = new BN(baseTotal).sub(new BN(adjustedDiscountAmount));
232
+ const adjustedFinalTotal = finalTotal.lt(new BN('0')) ? '0' : finalTotal.toString();
233
+
234
+ // 5. Apply discount to line items
235
+ const enhancedLineItems = lineItems.map((item) => {
236
+ const isEligible = eligibleLineItems.includes(item);
237
+
238
+ if (!isEligible) {
239
+ return {
240
+ ...item,
241
+ discountable: false,
242
+ discount_amounts: [],
243
+ };
244
+ }
245
+
246
+ const itemAmount = getItemAmount(item, currency.id);
247
+ let itemDiscountAmount = '0';
248
+
249
+ if (coupon.percent_off > 0) {
250
+ // For percentage discounts, apply directly to each eligible item
251
+ itemDiscountAmount = new BN(itemAmount).mul(new BN(coupon.percent_off)).div(new BN(100)).toString();
252
+ } else if (coupon.amount_off && totalEligibleAmount !== '0') {
253
+ // For fixed amount discounts, distribute proportionally among eligible items
254
+ itemDiscountAmount = new BN(itemAmount)
255
+ .mul(new BN(adjustedDiscountAmount))
256
+ .div(new BN(totalEligibleAmount))
257
+ .toString();
258
+ }
259
+
260
+ return {
261
+ ...item,
262
+ discountable: true,
263
+ discount_amounts:
264
+ itemDiscountAmount !== '0' ? [{ amount: itemDiscountAmount, coupon: coupon.id, discount: discount?.id }] : [],
265
+ };
266
+ });
267
+
268
+ return {
269
+ enhancedLineItems,
270
+ discountSummary: {
271
+ appliedCoupon: coupon.id,
272
+ discountAmount: adjustedDiscountAmount,
273
+ totalDiscountAmount: adjustedDiscountAmount,
274
+ finalTotal: adjustedFinalTotal,
275
+ },
276
+ };
277
+ }
278
+
279
+ /**
280
+ * Apply discount to subscription billing cycles (skips validation for stored discounts)
281
+ */
282
+ export async function applySubscriptionDiscount({
283
+ lineItems,
284
+ discount,
285
+ currency,
286
+ totalAmount,
287
+ billingContext,
288
+ }: {
289
+ lineItems: TLineItemExpanded[];
290
+ discount: Discount;
291
+ currency: PaymentCurrency;
292
+ totalAmount?: string;
293
+ billingContext?: { trialing?: boolean };
294
+ }): Promise<{
295
+ enhancedLineItems: (TLineItemExpanded & {
296
+ discountable: boolean;
297
+ discount_amounts: Array<{ amount: string; coupon: string }>;
298
+ })[];
299
+ discountSummary: {
300
+ appliedCoupon: string | null;
301
+ discountAmount: string;
302
+ totalDiscountAmount: string;
303
+ finalTotal: string;
304
+ };
305
+ }> {
306
+ // Get coupon from stored discount
307
+ const coupon = await Coupon.findByPk(discount.coupon_id);
308
+ if (!coupon) {
309
+ logger.error('Coupon not found for stored discount', {
310
+ discountId: discount.id,
311
+ couponId: discount.coupon_id,
312
+ });
313
+
314
+ // Return original items if coupon is missing
315
+ const enhancedLineItems = lineItems.map((item) => ({
316
+ ...item,
317
+ discountable: false,
318
+ discount_amounts: [],
319
+ }));
320
+ return {
321
+ enhancedLineItems,
322
+ discountSummary: {
323
+ appliedCoupon: null,
324
+ discountAmount: '0',
325
+ totalDiscountAmount: '0',
326
+ finalTotal: totalAmount ?? getItemsTotalAmount(lineItems, currency.id, billingContext),
327
+ },
328
+ };
329
+ }
330
+
331
+ // Use common calculation logic
332
+ const result = calculateDiscountForLineItems({
333
+ lineItems,
334
+ coupon,
335
+ discount,
336
+ currency,
337
+ totalAmount,
338
+ billingContext,
339
+ });
340
+
341
+ logger.info('Applied subscription discount', {
342
+ discountId: discount.id,
343
+ originalTotal: totalAmount,
344
+ discountAmount: result.discountSummary.discountAmount,
345
+ finalTotal: result.discountSummary.finalTotal,
346
+ });
347
+
348
+ return result;
349
+ }
@@ -0,0 +1,239 @@
1
+ // NFT verification for promotion codes
2
+ // TODO: This module needs implementation for NFT-based promotion code validation
3
+
4
+ import logger from '../logger';
5
+ import { PromotionCode, Coupon } from '../../store/models';
6
+ import { validPromotionCode } from './coupon';
7
+
8
+ /**
9
+ * NFT verification config interface
10
+ */
11
+ export interface NFTConfig {
12
+ addresses?: string[];
13
+ tags?: string[];
14
+ trusted_issuers?: string[];
15
+ trusted_parents?: string[];
16
+ min_balance?: number;
17
+ }
18
+
19
+ /**
20
+ * NFT match result interface
21
+ */
22
+ export interface NFTMatchResult {
23
+ valid: boolean;
24
+ matchedNFTs: Array<{
25
+ address: string;
26
+ tag?: string;
27
+ type: 'tag_match' | 'address_match';
28
+ }>;
29
+ }
30
+
31
+ /**
32
+ * NFT verification using separated NFT config structure
33
+ * TODO: Implement proper NFT client integration
34
+ */
35
+ export function verifyNFTConfig(userDid: string, nftConfig: NFTConfig): Promise<NFTMatchResult> {
36
+ return Promise.resolve()
37
+ .then(() => {
38
+ // TODO: Implement proper NFT client integration
39
+ // This should connect to blockchain services to verify NFT ownership
40
+ logger.info('NFT verification called', { userDid, nftConfig });
41
+
42
+ // Placeholder implementation - always returns false for now
43
+ // In real implementation, this would:
44
+ // 1. Connect to blockchain client (ArcBlock, Ethereum, etc.)
45
+ // 2. Query user's NFT assets
46
+ // 3. Match against config requirements (addresses, tags, issuers, parents)
47
+ // 4. Check minimum balance requirements
48
+
49
+ return {
50
+ valid: false, // TODO: Implement actual verification
51
+ matchedNFTs: [],
52
+ };
53
+ })
54
+ .catch((error) => {
55
+ logger.error('Error verifying NFT config', {
56
+ userDid,
57
+ nftConfig,
58
+ error: error.message,
59
+ });
60
+
61
+ return {
62
+ valid: false,
63
+ matchedNFTs: [],
64
+ };
65
+ });
66
+ }
67
+
68
+ /**
69
+ * Validates NFT voucher and returns associated promotion code
70
+ * TODO: This function needs proper NFT ownership verification
71
+ */
72
+ export function validateNFTVoucher(
73
+ nftAddress: string,
74
+ userDid: string,
75
+ paymentMethodId: string
76
+ ): { valid: boolean; error?: string; nftAddress?: string; promotionCode?: any; coupon?: any; discount?: any } {
77
+ try {
78
+ logger.info('NFT voucher validation called', { nftAddress, userDid, paymentMethodId });
79
+
80
+ // TODO: Implement NFT ownership verification
81
+ // This should:
82
+ // 1. Verify user owns the NFT at the given address
83
+ // 2. Find promotion code mapped to this NFT
84
+ // 3. Return validation result
85
+
86
+ return { valid: false, error: 'NFT_VERIFICATION_NOT_IMPLEMENTED' };
87
+ } catch (error) {
88
+ logger.error('Error validating NFT voucher', {
89
+ nftAddress,
90
+ userDid,
91
+ error: error.message,
92
+ });
93
+ return { valid: false, error: error.message };
94
+ }
95
+ }
96
+
97
+ /**
98
+ * 根据用户DID匹配可用的NFT促销
99
+ * TODO: This function needs to be integrated with actual NFT verification
100
+ */
101
+ export function getNFTPromotions(
102
+ userDid: string,
103
+ currencyId: string,
104
+ amount?: string
105
+ ): Promise<
106
+ Array<{
107
+ promotion_code_id: string;
108
+ code: string;
109
+ coupon: any;
110
+ matched_nfts: Array<{
111
+ address: string;
112
+ tag?: string;
113
+ type: 'tag_match' | 'address_match';
114
+ }>;
115
+ discount_amount: string;
116
+ discount_type: 'amount_off' | 'percent_off';
117
+ }>
118
+ > {
119
+ return Promise.resolve()
120
+ .then(() => {
121
+ logger.info('NFT promotions query called', { userDid, currencyId, amount });
122
+
123
+ // TODO: Implement NFT promotion matching
124
+ // This should:
125
+ // 1. Query promotion codes with NFT verification requirements
126
+ // 2. Check user's NFT ownership against each requirement
127
+ // 3. Return matching promotions with discount information
128
+
129
+ return []; // Placeholder - return empty array until implemented
130
+ })
131
+ .catch((error) => {
132
+ logger.error('Error getting NFT promotions', { userDid, currencyId, error: error.message });
133
+ return [];
134
+ });
135
+ }
136
+
137
+ /**
138
+ * 新的促销码验证函数,支持NFT验证模式
139
+ * TODO: This function needs integration with proper NFT verification systems
140
+ */
141
+ export async function validatePromotionCodeWithNFT(
142
+ code: string,
143
+ customerId: string,
144
+ currencyId: string,
145
+ amount: string,
146
+ userDid?: string,
147
+ inputMethod: 'code' | 'nft' = 'code'
148
+ ) {
149
+ try {
150
+ logger.info('Promotion code validation with NFT support called', {
151
+ code,
152
+ customerId,
153
+ currencyId,
154
+ amount,
155
+ userDid,
156
+ inputMethod,
157
+ });
158
+
159
+ // Find promotion code
160
+ const promotionCode = await PromotionCode.findOne({
161
+ where: {
162
+ code: code.toLowerCase(),
163
+ active: true,
164
+ },
165
+ include: [
166
+ {
167
+ model: Coupon,
168
+ as: 'coupon',
169
+ required: true,
170
+ },
171
+ ],
172
+ });
173
+
174
+ if (!promotionCode) {
175
+ return { valid: false, error: 'PROMOTION_CODE_NOT_FOUND' };
176
+ }
177
+
178
+ // Basic validation using coupon.ts logic
179
+ const basicValidation = await validPromotionCode(promotionCode, {
180
+ customerId,
181
+ amount,
182
+ currencyId,
183
+ });
184
+
185
+ if (!basicValidation.valid) {
186
+ return {
187
+ valid: false,
188
+ error: basicValidation.reason || 'PROMOTION_CODE_INVALID',
189
+ };
190
+ }
191
+
192
+ // Check for NFT verification requirements
193
+ if (promotionCode.verification_type === 'nft' && promotionCode.nft_config) {
194
+ if (!userDid) {
195
+ return { valid: false, error: 'USER_AUTHENTICATION_REQUIRED' };
196
+ }
197
+
198
+ // TODO: Implement NFT verification
199
+ const nftVerification = await verifyNFTConfig(userDid, promotionCode.nft_config);
200
+
201
+ if (!nftVerification.valid) {
202
+ return { valid: false, error: 'NFT_VERIFICATION_FAILED' };
203
+ }
204
+ }
205
+
206
+ // Build success response
207
+ const { coupon } = promotionCode as any;
208
+ const discount: any = {
209
+ duration: coupon.duration,
210
+ duration_in_months: coupon.duration_in_months,
211
+ };
212
+
213
+ if (coupon.percent_off && coupon.percent_off > 0) {
214
+ discount.type = 'percent';
215
+ discount.value = coupon.percent_off;
216
+ } else if (coupon.amount_off && coupon.amount_off !== '0') {
217
+ discount.type = 'amount';
218
+ discount.value = coupon.amount_off;
219
+ discount.currency_id = currencyId;
220
+ }
221
+
222
+ return {
223
+ valid: true,
224
+ promotionCode,
225
+ coupon,
226
+ discount,
227
+ };
228
+ } catch (error) {
229
+ logger.error('Error validating promotion code with NFT', {
230
+ code,
231
+ customerId,
232
+ currencyId,
233
+ userDid,
234
+ inputMethod,
235
+ error: error.message,
236
+ });
237
+ return { valid: false, error: error.message };
238
+ }
239
+ }