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
@@ -54,45 +54,45 @@ export default flat({
54
54
 
55
55
  billingDiscrepancy: {
56
56
  title: '{productName} billing discrepancy',
57
- body: 'Detected billing discrepancy for {productName}, please check.',
57
+ body: 'A billing discrepancy has been detected for {productName}. Please review your billing details.',
58
58
  },
59
59
 
60
60
  sendTo: 'Sent to',
61
61
  mintNFT: {
62
62
  title: '{collection} NFT minted',
63
- message: 'A new {collection} NFT is minted and sent to your wallet, please check it out.',
63
+ message: 'A new {collection} NFT has been minted and sent to your wallet. Please check your wallet to view it.',
64
64
  },
65
65
 
66
66
  usageReportEmpty: {
67
67
  title: 'No usage report for {productName}',
68
- body: 'No usage report for {productName} detected, please check.',
68
+ body: 'No usage report has been detected for {productName}. Please verify your usage reporting configuration.',
69
69
  },
70
70
 
71
71
  meteringSubscriptionDetection: {
72
72
  title: '[{appName}] Metering subscription detection',
73
- body: 'During {startTimeStr} - {endTimeStr}, a total of {totalCount} subscriptions with usage-based billing were scanned. \nOut of these, {normalCount} were normal, while {abnormalCount} had anomalies, including {unreportedCount} unreported subscriptions and {discrepantCount} with billing discrepancies. \n\n Abnormal subscriptions:',
73
+ body: 'During {startTimeStr} - {endTimeStr}, a total of {totalCount} subscriptions with usage-based billing were scanned.\nOf these, {normalCount} were normal, while {abnormalCount} had anomalies, including {unreportedCount} unreported subscriptions and {discrepantCount} with billing discrepancies.\n\nAbnormal subscriptions:',
74
74
  healthyBody:
75
- 'During {startTimeStr} - {endTimeStr}, a total of {totalCount} subscriptions with usage-based billing were scanned, and all subscriptions were normal.',
75
+ 'During {startTimeStr} - {endTimeStr}, a total of {totalCount} subscriptions with usage-based billing were scanned, and all subscriptions were functioning normally.',
76
76
  view: 'Manage subscriptions',
77
77
  },
78
78
 
79
79
  subscriptionTrialStart: {
80
80
  title: 'Welcome to the start of your {productName} trial',
81
- body: 'Congratulations on your {productName} trial! The length of the trial is {trialDuration} and will end at {subscriptionTrialEnd}. Have fun with {productName}!',
81
+ body: 'Congratulations on starting your {productName} trial! Your trial period is {trialDuration} and will end on {subscriptionTrialEnd}. Enjoy exploring {productName}!',
82
82
  },
83
83
 
84
84
  subscriptionTrialWillEnd: {
85
85
  title: 'The {productName} trial will end soon',
86
86
  body: 'Your trial for {productName} will end in {willRenewDuration}. Please ensure your account balance is sufficient for automatic billing after the trial ends. Thank you for your support and trust!',
87
87
  unableToPayBody:
88
- 'Your trial for {productName} will end in {willRenewDuration}.Your current balance is {balance}, which is less than {price}, Please ensure your balance is sufficient for automatic billing. Thank you for your support and trust!',
88
+ 'Your trial for {productName} will end in {willRenewDuration}. Your current balance is {balance}, which is less than {price}. Please ensure your balance is sufficient for automatic billing. Thank you for your support and trust!',
89
89
  unableToPayReason:
90
- 'The estimated payment amount is {price}, but the current balance is insufficient ({balance}), please ensure that your account has enough balance to avoid payment failure.',
90
+ 'The estimated payment amount is {price}, but your current balance is insufficient ({balance}). Please ensure your account has enough balance to avoid payment failure.',
91
91
  },
92
92
 
93
93
  subscriptionSucceed: {
94
94
  title: "Congratulations! You've successfully subscribed to {productName}",
95
- body: 'Thank you for successfully subscribing to {productName} on {at}. We will be happy to provide you with excellent service, and we wish you a pleasant experience.',
95
+ body: 'Thank you for successfully subscribing to {productName} on {at}. We are happy to provide you with excellent service, and we wish you a pleasant experience.',
96
96
  },
97
97
 
98
98
  oneTimePaymentSucceeded: {
@@ -102,7 +102,7 @@ export default flat({
102
102
 
103
103
  subscriptionUpgraded: {
104
104
  title: 'Congratulations! Your subscription plan for {productName} has been successfully upgraded',
105
- body: 'Your subscription plan for {productName} has been successfully upgraded at {at}, thank you for your support, we wish you a pleasant experience!',
105
+ body: 'Your subscription plan for {productName} has been successfully upgraded on {at}. Thank you for your support, and we wish you a pleasant experience!',
106
106
  },
107
107
 
108
108
  subscriptionWillRenew: {
@@ -111,53 +111,53 @@ export default flat({
111
111
  unableToPayBody:
112
112
  'Your subscription to {productName} is scheduled for automatic payment on {at}({willRenewDuration} later). If you have any questions or need assistance, please feel free to contact us.',
113
113
  unableToPayReason:
114
- 'The estimated payment amount is {price}, but the current balance is insufficient ({balance}), please ensure that your account has enough balance to avoid payment failure.',
114
+ 'The estimated payment amount is {price}, but your current balance is insufficient ({balance}). Please ensure your account has enough balance to avoid payment failure.',
115
115
  renewAmount: 'Payment amount',
116
116
  estimatedAmountNote: 'Estimate {amount}, billed based on final usage',
117
117
  },
118
118
 
119
119
  subscriptionRenewed: {
120
120
  title: '{productName} payment successful',
121
- body: 'Payment for your subscription {productName} is successfully collected on {at}. Thanks for your continued support and trust, we wish you a pleasant journey when using this service!',
122
- noExpenseIncurred: 'No expenses incurred during the service ',
121
+ body: 'Payment for your {productName} subscription was successfully collected on {at}. Thanks for your continued support and trust. We wish you a pleasant experience using this service!',
122
+ noExpenseIncurred: 'No expenses incurred during the service period',
123
123
  },
124
124
 
125
125
  subscriptionRenewFailed: {
126
126
  title: '{productName} automatic payment failed',
127
- body: 'We are sorry to inform you that your {productName} failed to go through the automatic payment on {at}. If you have any questions, please contact us in time. Thank you!',
127
+ body: 'We are sorry to inform you that your {productName} automatic payment failed on {at}. If you have any questions, please contact us promptly. Thank you!',
128
128
  reason: {
129
- noDidWallet: 'You have not bound DID Wallet, please bind DID Wallet to ensure sufficient balance',
130
- noDelegation: 'Your DID Wallet has not been authorized, please update authorization',
129
+ noDidWallet: 'You have not connected a DID Wallet. Please connect your DID Wallet to ensure sufficient balance',
130
+ noDelegation: 'Your DID Wallet has not been authorized. Please update authorization',
131
131
  noTransferPermission:
132
- 'Your DID Wallet has not granted transfer permission to the application, please update authorization',
132
+ 'Your DID Wallet has not granted transfer permission to the application. Please update authorization',
133
133
  noTokenPermission:
134
- 'Your DID Wallet has not granted token transfer permission to the application, please update authorization',
134
+ 'Your DID Wallet has not granted token transfer permission to the application. Please update authorization',
135
135
  noTransferTo:
136
- 'Your DID Wallet has not granted the application automatic payment permission, please update authorization',
137
- noEnoughAllowance: 'The deduction amount exceeds the single transfer limit, please update authorization',
138
- noToken: 'Your account has no tokens, please add funds',
139
- noEnoughToken: 'Your account token balance is {balance}, insufficient for {price}, please add funds',
140
- noSupported: 'It is not supported to automatically pay with tokens, please check your package',
136
+ 'Your DID Wallet has not granted automatic payment permission to the application. Please update authorization',
137
+ noEnoughAllowance: 'The deduction amount exceeds the single transfer limit. Please update authorization',
138
+ noToken: 'Your account has no tokens. Please add funds',
139
+ noEnoughToken: 'Your account token balance is {balance}, which is insufficient for {price}. Please add funds',
140
+ noSupported: 'Automatic payment with tokens is not supported. Please check your package',
141
141
  txSendFailed: 'Failed to send automatic payment transaction',
142
142
  },
143
143
  },
144
144
 
145
145
  autoRechargeFailed: {
146
146
  title: 'Auto Top-Up payment failed',
147
- body: 'We are sorry to inform you that your {creditCurrencyName} auto top-up failed to go through the automatic payment on {at}. If you have any questions, please contact us in time. Thank you!',
147
+ body: 'We are sorry to inform you that your {creditCurrencyName} auto top-up automatic payment failed on {at}. If you have any questions, please contact us promptly. Thank you!',
148
148
  reason: {
149
- noDidWallet: 'You have not bound DID Wallet, please bind DID Wallet to ensure sufficient balance',
150
- noDelegation: 'Your DID Wallet has not been authorized, please update authorization',
149
+ noDidWallet: 'You have not connected a DID Wallet. Please connect your DID Wallet to ensure sufficient balance',
150
+ noDelegation: 'Your DID Wallet has not been authorized. Please update authorization',
151
151
  noTransferPermission:
152
- 'Your DID Wallet has not granted transfer permission to the application, please update authorization',
152
+ 'Your DID Wallet has not granted transfer permission to the application. Please update authorization',
153
153
  noTokenPermission:
154
- 'Your DID Wallet has not granted token transfer permission to the application, please update authorization',
154
+ 'Your DID Wallet has not granted token transfer permission to the application. Please update authorization',
155
155
  noTransferTo:
156
- 'Your DID Wallet has not granted the application automatic payment permission, please update authorization',
157
- noEnoughAllowance: 'The deduction amount exceeds the single transfer limit, please update authorization',
158
- noToken: 'Your account has no tokens, please add funds',
159
- noEnoughToken: 'Your account token balance is {balance}, insufficient for {price}, please add funds',
160
- noSupported: 'It is not supported to automatically pay with tokens, please check your package',
156
+ 'Your DID Wallet has not granted automatic payment permission to the application. Please update authorization',
157
+ noEnoughAllowance: 'The deduction amount exceeds the single transfer limit. Please update authorization',
158
+ noToken: 'Your account has no tokens. Please add funds',
159
+ noEnoughToken: 'Your account token balance is {balance}, which is insufficient for {price}. Please add funds',
160
+ noSupported: 'Automatic payment with tokens is not supported. Please check your package',
161
161
  txSendFailed: 'Failed to send automatic payment transaction',
162
162
  },
163
163
  },
@@ -187,7 +187,7 @@ export default flat({
187
187
 
188
188
  customerRewardSucceeded: {
189
189
  title: 'Thanks for your reward of {amount}',
190
- body: 'Thanks for your reward on {at}, the amount of reward is {amount}. Your support is our driving force, thanks for your generous support!',
190
+ body: 'Thank you for your reward on {at}. The reward amount is {amount}. Your support is our driving force. Thank you for your generous support!',
191
191
  received: '{address} has received {amount}',
192
192
  viewDetail: 'View Reference',
193
193
  },
@@ -204,14 +204,14 @@ export default flat({
204
204
  },
205
205
  sender: 'Payer',
206
206
  viewDetail: 'View Details',
207
- sended: '{address} has sent {amount}',
207
+ sent: '{address} has sent {amount}',
208
208
  },
209
209
  subscriptWillCanceled: {
210
- title: '{productName} subscription is about to be cancelled ',
210
+ title: '{productName} subscription is about to be cancelled',
211
211
  pastDue:
212
- 'Your subscription {productName} will be automatically unsubscribed by the system after {at} (after {willCancelDuration}) due to a long period of failure to automatically complete the automatic payment. Please handle the problem of automatic payment manually in time, so as not to affect the use. If you have any questions, please feel free to contact us.',
212
+ 'Your {productName} subscription will be automatically cancelled by the system after {at} ({willCancelDuration} later) due to repeated automatic payment failures. Please resolve the automatic payment issue manually to avoid service interruption. If you have any questions, please feel free to contact us.',
213
213
  body: 'Your subscription to {productName} will be automatically canceled on {at} ({willCancelDuration} later). If you have any questions, please feel free to contact us.',
214
- renewAmount: 'deduction amount ',
214
+ renewAmount: 'Deduction amount',
215
215
  cancelReason: 'Cancel reason',
216
216
  revokeStake: 'Revoke stake',
217
217
  adminCanceled: 'Admin canceled',
@@ -14,11 +14,11 @@ import {
14
14
  TPriceExpanded,
15
15
  } from '../store/models';
16
16
  import logger from '../libs/logger';
17
- import { getPriceUintAmountByCurrency } from '../libs/session';
18
17
  import { createStripeInvoiceForAutoRecharge } from '../integrations/stripe/resource';
19
18
  import { ensureInvoiceAndItems } from '../libs/invoice';
20
19
  import dayjs from '../libs/dayjs';
21
20
  import { invoiceQueue } from './invoice';
21
+ import { getPriceUintAmountByCurrency } from '../libs/price';
22
22
 
23
23
  export interface AutoRechargeJobData {
24
24
  customer_id: string;
@@ -0,0 +1,200 @@
1
+ import pAll from 'p-all';
2
+ import createQueue from '../libs/queue';
3
+ import { Coupon, PromotionCode } from '../store/models';
4
+ import logger from '../libs/logger';
5
+ import { events } from '../libs/event';
6
+ import { validCoupon, validPromotionCode } from '../libs/discount/coupon';
7
+
8
+ export type DiscountType = 'coupon' | 'promotion-code';
9
+
10
+ export interface DiscountStatusJobData {
11
+ type: DiscountType;
12
+ id: string;
13
+ }
14
+
15
+ /**
16
+ * Process discount status check for both coupons and promotion codes
17
+ */
18
+ export async function processDiscountStatus(job: DiscountStatusJobData) {
19
+ logger.info('Processing discount status check job', { job });
20
+ const { type, id } = job;
21
+
22
+ if (type === 'coupon') {
23
+ await processCouponStatus(id);
24
+ } else if (type === 'promotion-code') {
25
+ await processPromotionCodeStatus(id);
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Process coupon status check
31
+ */
32
+ async function processCouponStatus(couponId: string) {
33
+ const coupon = await Coupon.findByPk(couponId);
34
+ if (!coupon) {
35
+ return;
36
+ }
37
+
38
+ // Skip if already invalid
39
+ if (!coupon.valid) {
40
+ logger.info('Coupon is already invalid, skipping', { couponId });
41
+ return;
42
+ }
43
+
44
+ // Use the existing validCoupon function to check validity
45
+ const validation = validCoupon(coupon);
46
+
47
+ if (!validation.valid) {
48
+ // Mark coupon as invalid
49
+ await coupon.update({ valid: false, metadata: { ...coupon.metadata, invalid_reason: validation.reason } });
50
+ logger.info('Coupon marked as invalid', {
51
+ couponId,
52
+ reason: validation.reason,
53
+ redeem_by: coupon.redeem_by,
54
+ max_redemptions: coupon.max_redemptions,
55
+ times_redeemed: coupon.times_redeemed,
56
+ });
57
+ const promotionCodes = await PromotionCode.findAll({ where: { coupon_id: couponId, active: true } });
58
+ await pAll(
59
+ promotionCodes.map(
60
+ (promotionCode) => () =>
61
+ promotionCode.update({
62
+ active: false,
63
+ metadata: { ...promotionCode.metadata, invalid_reason: validation.reason },
64
+ })
65
+ ),
66
+ { concurrency: 5 }
67
+ );
68
+ logger.info('Promotion codes marked as inactive', {
69
+ promotionCodeIds: promotionCodes.map((pc) => pc.id),
70
+ reason: validation.reason,
71
+ });
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Process promotion code status check
77
+ */
78
+ async function processPromotionCodeStatus(promotionCodeId: string) {
79
+ const promotionCode = await PromotionCode.findByPk(promotionCodeId);
80
+ if (!promotionCode) {
81
+ logger.warn('Promotion code not found for status check', { promotionCodeId });
82
+ return;
83
+ }
84
+
85
+ // Skip if already inactive
86
+ if (!promotionCode.active) {
87
+ logger.info('Promotion code is already inactive, skipping', { promotionCodeId });
88
+ return;
89
+ }
90
+
91
+ // Use the existing validPromotionCode function to check validity
92
+ const validation = await validPromotionCode(promotionCode, {});
93
+
94
+ if (!validation.valid) {
95
+ // Mark promotion code as inactive
96
+ await promotionCode.update({
97
+ active: false,
98
+ metadata: { ...promotionCode.metadata, invalid_reason: validation.reason },
99
+ });
100
+ logger.info('Promotion code marked as inactive', {
101
+ promotionCodeId,
102
+ reason: validation.reason,
103
+ expires_at: promotionCode.expires_at,
104
+ max_redemptions: promotionCode.max_redemptions,
105
+ times_redeemed: promotionCode.times_redeemed,
106
+ });
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Schedule discount status monitoring jobs
112
+ */
113
+ export async function addDiscountStatusJob(
114
+ record: Coupon | PromotionCode,
115
+ type: DiscountType = 'coupon',
116
+ replace: boolean = false,
117
+ options: { delay?: number; runAt?: number } = {}
118
+ ) {
119
+ const { id } = record;
120
+ const jobId = `discount-status-${type}-${id}`;
121
+
122
+ // Remove existing job if present
123
+ const existingJob = await discountStatusQueue.get(jobId);
124
+ if (existingJob && !replace) {
125
+ logger.info('Discount job already exists, skipping', { type, id, jobId });
126
+ return;
127
+ }
128
+ if (existingJob && replace) {
129
+ await discountStatusQueue.delete(jobId);
130
+ logger.info('Removed existing discount status job for update', { type, id, jobId });
131
+ }
132
+ discountStatusQueue.push({ id: jobId, job: { type, id }, ...options });
133
+ }
134
+
135
+ // Create unified discount status queue
136
+ export const discountStatusQueue = createQueue<DiscountStatusJobData>({
137
+ name: 'discount-status',
138
+ onJob: processDiscountStatus,
139
+ options: {
140
+ concurrency: 5,
141
+ maxRetries: 3,
142
+ enableScheduledJob: true,
143
+ },
144
+ });
145
+
146
+ export const startDiscountStatusQueue = async () => {
147
+ logger.info('Starting discount status queue...');
148
+
149
+ const coupons = await Coupon.findAll({ where: { valid: true } });
150
+ const promotionCodes = await PromotionCode.findAll({ where: { active: true } });
151
+ await pAll(
152
+ coupons.map(
153
+ (coupon) => () =>
154
+ addDiscountStatusJob(coupon, 'coupon', false, {
155
+ runAt: coupon.redeem_by,
156
+ })
157
+ ),
158
+ { concurrency: 5 }
159
+ );
160
+ await pAll(
161
+ promotionCodes.map(
162
+ (promotionCode) => () =>
163
+ addDiscountStatusJob(promotionCode, 'promotion-code', false, {
164
+ runAt: promotionCode.expires_at,
165
+ })
166
+ ),
167
+ { concurrency: 5 }
168
+ );
169
+ };
170
+
171
+ events.on('coupon.created', (coupon: Coupon) => {
172
+ logger.info('Received coupon.created event', { couponId: coupon.id });
173
+ addDiscountStatusJob(coupon, 'coupon', true, {
174
+ runAt: coupon.redeem_by,
175
+ });
176
+ });
177
+
178
+ events.on('promotion-code.created', (promotionCode: PromotionCode) => {
179
+ addDiscountStatusJob(promotionCode, 'promotion-code', true, {
180
+ runAt: promotionCode.expires_at,
181
+ });
182
+ });
183
+
184
+ events.on('promotion-code.updated', (promotionCode: PromotionCode) => {
185
+ if (promotionCode.active) {
186
+ addDiscountStatusJob(promotionCode, 'promotion-code', true, {
187
+ runAt: promotionCode.expires_at,
188
+ });
189
+ }
190
+ });
191
+
192
+ events.on('discount-status.queued', (record, type, replace) => {
193
+ try {
194
+ addDiscountStatusJob(record, type, replace);
195
+ events.emit('discount-status.queued.done', record, type, replace);
196
+ } catch (error) {
197
+ logger.error('Error in discount-status.queued', { recordId: record.id, type, replace });
198
+ events.emit('discount-status.queued.error', record, type, replace);
199
+ }
200
+ });
@@ -1,5 +1,6 @@
1
1
  import type { LiteralUnion } from 'type-fest';
2
2
 
3
+ import { BN } from '@ocap/util';
3
4
  import { Op } from 'sequelize';
4
5
  import { createEvent } from '../libs/audit';
5
6
  import { ensurePassportRevoked } from '../integrations/blocklet/passport';
@@ -34,8 +35,10 @@ import { Invoice } from '../store/models/invoice';
34
35
  import { Price } from '../store/models/price';
35
36
  import { Subscription } from '../store/models/subscription';
36
37
  import { SubscriptionItem } from '../store/models/subscription-item';
38
+ import { getValidDiscountsForSubscriptionBilling } from '../libs/discount/coupon';
37
39
  import { invoiceQueue } from './invoice';
38
40
  import { SubscriptionWillCanceledSchedule } from '../crons/subscription-will-canceled';
41
+ import { applySubscriptionDiscount } from '../libs/discount/discount';
39
42
 
40
43
  type SubscriptionJob = {
41
44
  subscriptionId: string;
@@ -190,7 +193,90 @@ const doHandleSubscriptionInvoice = async ({
190
193
  return null;
191
194
  }
192
195
 
193
- const amount = getSubscriptionCycleAmount(expandedItems, currency.id);
196
+ // Get valid discounts for this subscription billing period
197
+ const { validDiscounts, expiredDiscounts } = await getValidDiscountsForSubscriptionBilling({
198
+ subscriptionId: subscription.id,
199
+ customerId: subscription.customer_id,
200
+ });
201
+
202
+ logger.info('Valid discounts for subscription billing', {
203
+ subscriptionId: subscription.id,
204
+ validDiscounts: validDiscounts.map((d) => d.id),
205
+ });
206
+
207
+ // Log expired discounts for monitoring
208
+ if (expiredDiscounts.length > 0) {
209
+ logger.info('Found expired discounts during subscription billing', {
210
+ subscriptionId: subscription.id,
211
+ expiredDiscounts: expiredDiscounts.map((d) => d.id),
212
+ });
213
+ }
214
+
215
+ // Calculate subscription amount with discounts
216
+ const baseAmount = await getSubscriptionCycleAmount(expandedItems, currency.id);
217
+
218
+ // Apply discounts - simplified to use only one discount (first valid one)
219
+ const firstValidDiscount = validDiscounts?.[0] || null;
220
+ logger.info('First valid discount', {
221
+ subscriptionId: subscription.id,
222
+ firstValidDiscount: firstValidDiscount?.id,
223
+ });
224
+ let enhancedLineItems = expandedItems;
225
+ let discountSummary: {
226
+ appliedCoupon: string | null;
227
+ discountAmount: string;
228
+ totalDiscountAmount: string;
229
+ finalTotal: string;
230
+ } = {
231
+ appliedCoupon: null,
232
+ discountAmount: '0',
233
+ totalDiscountAmount: '0',
234
+ finalTotal: baseAmount.total,
235
+ };
236
+
237
+ if (firstValidDiscount) {
238
+ // Check if subscription is still in trial period for accurate discount calculation
239
+ const now = Math.floor(Date.now() / 1000);
240
+ const isTrialing = !!(subscription.trial_end && subscription.trial_end > now);
241
+
242
+ const discountApplication = await applySubscriptionDiscount({
243
+ lineItems: expandedItems,
244
+ discount: firstValidDiscount,
245
+ currency,
246
+ totalAmount: baseAmount.total,
247
+ billingContext: {
248
+ trialing: isTrialing,
249
+ },
250
+ });
251
+
252
+ logger.info('Discount application', {
253
+ subscriptionId: subscription.id,
254
+ discountApplication,
255
+ });
256
+
257
+ enhancedLineItems = discountApplication.enhancedLineItems;
258
+ discountSummary = discountApplication.discountSummary;
259
+ }
260
+
261
+ const { appliedCoupon, totalDiscountAmount, finalTotal } = discountSummary;
262
+
263
+ // Additional safety check to ensure final total is never negative
264
+ const safeFinalTotal = new BN(finalTotal || '0').lt(new BN('0')) ? '0' : finalTotal || '0';
265
+
266
+ // Prepare data for invoice creation
267
+ const appliedDiscounts = appliedCoupon && firstValidDiscount ? [firstValidDiscount.id] : [];
268
+ const discountBreakdownForInvoice =
269
+ appliedCoupon && firstValidDiscount ? [{ amount: totalDiscountAmount, discount: firstValidDiscount.id }] : [];
270
+
271
+ if (validDiscounts.length > 0) {
272
+ logger.info('Subscription billing discount calculation completed', {
273
+ subscriptionId: subscription.id,
274
+ originalTotal: baseAmount.total,
275
+ totalDiscount: totalDiscountAmount,
276
+ finalTotal,
277
+ appliedDiscounts: appliedDiscounts.length,
278
+ });
279
+ }
194
280
 
195
281
  const { invoice } = await ensureInvoiceAndItems({
196
282
  customer,
@@ -198,7 +284,7 @@ const doHandleSubscriptionInvoice = async ({
198
284
  subscription,
199
285
  trialing: false,
200
286
  metered: true,
201
- lineItems: expandedItems,
287
+ lineItems: enhancedLineItems,
202
288
  props: {
203
289
  livemode: subscription.livemode,
204
290
  description: `Subscription ${reason}`,
@@ -209,11 +295,18 @@ const doHandleSubscriptionInvoice = async ({
209
295
  status,
210
296
  billing_reason: `subscription_${reason}`,
211
297
  currency_id: subscription.currency_id,
212
- total: amount.total,
298
+ // Set correct subtotal (original amount) and total (after discount)
299
+ subtotal: baseAmount.total,
300
+ total: safeFinalTotal,
213
301
  payment_settings: subscription.payment_settings,
214
302
  default_payment_method_id: subscription.default_payment_method_id,
215
- metadata,
216
- } as Invoice,
303
+ // Discount information for Invoice
304
+ discounts: appliedDiscounts,
305
+ total_discount_amounts: discountBreakdownForInvoice,
306
+ metadata: {
307
+ ...metadata,
308
+ },
309
+ } as unknown as Invoice,
217
310
  });
218
311
 
219
312
  logger.info('Invoice created for subscription', { invoice: invoice.id, subscription: subscription.id });
@@ -4,7 +4,7 @@ import dayjs from '../libs/dayjs';
4
4
  import { getLock } from '../libs/lock';
5
5
  import logger from '../libs/logger';
6
6
  import createQueue from '../libs/queue';
7
- import { getPriceUintAmountByCurrency } from '../libs/session';
7
+ import { getPriceUintAmountByCurrency } from '../libs/price';
8
8
  import { Invoice, PaymentCurrency, Price, SubscriptionItem, TLineItemExpanded, UsageRecord } from '../store/models';
9
9
  import { Subscription } from '../store/models/subscription';
10
10
  import { invoiceQueue } from './invoice';
@@ -5,6 +5,7 @@ import { CustomError } from '@blocklet/error';
5
5
  import { Op } from 'sequelize';
6
6
  import sessionMiddleware from '@blocklet/sdk/lib/middlewares/session';
7
7
  import { BN, fromTokenToUnit, fromUnitToToken } from '@ocap/util';
8
+ import { trimDecimals } from '../libs/math-utils';
8
9
  import {
9
10
  AutoRechargeConfig,
10
11
  Customer,
@@ -16,7 +17,8 @@ import {
16
17
  TPriceExpanded,
17
18
  } from '../store/models';
18
19
  import { isDelegationSufficientForPayment } from '../libs/payment';
19
- import { getPriceUintAmountByCurrency, validateStripePaymentAmounts } from '../libs/session';
20
+ import { validateStripePaymentAmounts } from '../libs/session';
21
+ import { getPriceUintAmountByCurrency } from '../libs/price';
20
22
  import { ensureStripeSetupIntentForAutoRecharge } from '../integrations/stripe/resource';
21
23
  import logger from '../libs/logger';
22
24
 
@@ -349,13 +351,13 @@ router.post('/submit', async (req, res) => {
349
351
  }
350
352
  let { threshold, daily_limits: dailyLimits } = configData;
351
353
  if (threshold) {
352
- threshold = fromTokenToUnit(parseFloat(threshold).toFixed(currency.decimal), currency.decimal).toString();
354
+ threshold = fromTokenToUnit(trimDecimals(threshold, currency.decimal), currency.decimal).toString();
353
355
  }
354
356
  if (dailyLimits) {
355
357
  dailyLimits = {
356
358
  max_attempts: dailyLimits.max_attempts || 0,
357
359
  max_amount: fromTokenToUnit(
358
- parseFloat(dailyLimits.max_amount || '0').toFixed(rechargeCurrency.decimal),
360
+ trimDecimals(dailyLimits.max_amount || '0', rechargeCurrency.decimal),
359
361
  rechargeCurrency.decimal
360
362
  ).toString(),
361
363
  };