payment-kit 1.20.10 → 1.20.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -24
- package/api/src/index.ts +2 -0
- package/api/src/integrations/stripe/handlers/invoice.ts +63 -5
- package/api/src/integrations/stripe/handlers/payment-intent.ts +1 -0
- package/api/src/integrations/stripe/resource.ts +253 -2
- package/api/src/libs/currency.ts +31 -0
- package/api/src/libs/discount/coupon.ts +1061 -0
- package/api/src/libs/discount/discount.ts +349 -0
- package/api/src/libs/discount/nft.ts +239 -0
- package/api/src/libs/discount/redemption.ts +636 -0
- package/api/src/libs/discount/vc.ts +73 -0
- package/api/src/libs/invoice.ts +50 -16
- package/api/src/libs/math-utils.ts +6 -0
- package/api/src/libs/price.ts +43 -0
- package/api/src/libs/session.ts +242 -57
- package/api/src/libs/subscription.ts +2 -6
- package/api/src/locales/en.ts +38 -38
- package/api/src/queues/auto-recharge.ts +1 -1
- package/api/src/queues/discount-status.ts +200 -0
- package/api/src/queues/subscription.ts +98 -5
- package/api/src/queues/usage-record.ts +1 -1
- package/api/src/routes/auto-recharge-configs.ts +5 -3
- package/api/src/routes/checkout-sessions.ts +755 -64
- package/api/src/routes/connect/change-payment.ts +6 -1
- package/api/src/routes/connect/change-plan.ts +6 -1
- package/api/src/routes/connect/setup.ts +6 -1
- package/api/src/routes/connect/shared.ts +80 -9
- package/api/src/routes/connect/subscribe.ts +12 -2
- package/api/src/routes/coupons.ts +518 -0
- package/api/src/routes/index.ts +4 -0
- package/api/src/routes/invoices.ts +44 -3
- package/api/src/routes/meter-events.ts +2 -1
- package/api/src/routes/payment-currencies.ts +1 -0
- package/api/src/routes/promotion-codes.ts +482 -0
- package/api/src/routes/subscriptions.ts +23 -2
- package/api/src/store/migrations/20250904-discount.ts +136 -0
- package/api/src/store/migrations/20250910-timestamp-fields.ts +116 -0
- package/api/src/store/migrations/20250916-add-description-fields.ts +30 -0
- package/api/src/store/models/checkout-session.ts +12 -0
- package/api/src/store/models/coupon.ts +144 -4
- package/api/src/store/models/discount.ts +23 -10
- package/api/src/store/models/index.ts +13 -2
- package/api/src/store/models/promotion-code.ts +295 -18
- package/api/src/store/models/types.ts +30 -1
- package/api/tests/libs/session.spec.ts +48 -27
- package/blocklet.yml +1 -1
- package/doc/vendor_fulfillment_system.md +38 -38
- package/package.json +20 -20
- package/src/app.tsx +2 -0
- package/src/components/customer/link.tsx +1 -1
- package/src/components/discount/discount-info.tsx +178 -0
- package/src/components/invoice/table.tsx +140 -48
- package/src/components/invoice-pdf/styles.ts +6 -0
- package/src/components/invoice-pdf/template.tsx +59 -33
- package/src/components/metadata/form.tsx +14 -5
- package/src/components/payment-link/actions.tsx +42 -0
- package/src/components/price/form.tsx +91 -65
- package/src/components/product/vendor-config.tsx +5 -3
- package/src/components/promotion/active-redemptions.tsx +534 -0
- package/src/components/promotion/currency-multi-select.tsx +350 -0
- package/src/components/promotion/currency-restrictions.tsx +117 -0
- package/src/components/promotion/product-select.tsx +292 -0
- package/src/components/promotion/promotion-code-form.tsx +534 -0
- package/src/components/subscription/portal/list.tsx +6 -1
- package/src/components/subscription/vendor-service-list.tsx +13 -2
- package/src/locales/en.tsx +253 -26
- package/src/locales/zh.tsx +222 -1
- package/src/pages/admin/billing/subscriptions/detail.tsx +5 -0
- package/src/pages/admin/products/coupons/applicable-products.tsx +166 -0
- package/src/pages/admin/products/coupons/create.tsx +612 -0
- package/src/pages/admin/products/coupons/detail.tsx +538 -0
- package/src/pages/admin/products/coupons/edit.tsx +127 -0
- package/src/pages/admin/products/coupons/index.tsx +210 -3
- package/src/pages/admin/products/index.tsx +22 -3
- package/src/pages/admin/products/products/detail.tsx +12 -2
- package/src/pages/admin/products/promotion-codes/actions.tsx +103 -0
- package/src/pages/admin/products/promotion-codes/create.tsx +235 -0
- package/src/pages/admin/products/promotion-codes/detail.tsx +416 -0
- package/src/pages/admin/products/promotion-codes/list.tsx +247 -0
- package/src/pages/admin/products/promotion-codes/verification-config.tsx +327 -0
- package/src/pages/admin/products/vendors/index.tsx +17 -5
- package/src/pages/customer/subscription/detail.tsx +5 -0
- package/vite.config.ts +4 -3
package/api/src/locales/en.ts
CHANGED
|
@@ -54,45 +54,45 @@ export default flat({
|
|
|
54
54
|
|
|
55
55
|
billingDiscrepancy: {
|
|
56
56
|
title: '{productName} billing discrepancy',
|
|
57
|
-
body: '
|
|
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
|
|
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}
|
|
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
|
|
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
|
|
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!
|
|
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}
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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}
|
|
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
|
|
130
|
-
noDelegation: 'Your DID Wallet has not been authorized
|
|
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
|
|
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
|
|
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
|
|
137
|
-
noEnoughAllowance: 'The deduction amount exceeds the single transfer limit
|
|
138
|
-
noToken: 'Your account has no tokens
|
|
139
|
-
noEnoughToken: 'Your account token balance is {balance}, insufficient for {price}
|
|
140
|
-
noSupported: '
|
|
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
|
|
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
|
|
150
|
-
noDelegation: 'Your DID Wallet has not been authorized
|
|
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
|
|
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
|
|
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
|
|
157
|
-
noEnoughAllowance: 'The deduction amount exceeds the single transfer limit
|
|
158
|
-
noToken: 'Your account has no tokens
|
|
159
|
-
noEnoughToken: 'Your account token balance is {balance}, insufficient for {price}
|
|
160
|
-
noSupported: '
|
|
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: '
|
|
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
|
-
|
|
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
|
|
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: '
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
216
|
-
|
|
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/
|
|
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 {
|
|
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(
|
|
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
|
-
|
|
360
|
+
trimDecimals(dailyLimits.max_amount || '0', rechargeCurrency.decimal),
|
|
359
361
|
rechargeCurrency.decimal
|
|
360
362
|
).toString(),
|
|
361
363
|
};
|