payment-kit 1.20.11 → 1.20.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/api/src/crons/index.ts +8 -0
- package/api/src/index.ts +2 -0
- package/api/src/integrations/stripe/handlers/invoice.ts +63 -5
- package/api/src/integrations/stripe/handlers/payment-intent.ts +1 -0
- package/api/src/integrations/stripe/resource.ts +253 -2
- package/api/src/libs/currency.ts +31 -0
- package/api/src/libs/discount/coupon.ts +1061 -0
- package/api/src/libs/discount/discount.ts +349 -0
- package/api/src/libs/discount/nft.ts +239 -0
- package/api/src/libs/discount/redemption.ts +636 -0
- package/api/src/libs/discount/vc.ts +73 -0
- package/api/src/libs/env.ts +1 -0
- package/api/src/libs/invoice.ts +44 -10
- package/api/src/libs/math-utils.ts +6 -0
- package/api/src/libs/price.ts +43 -0
- package/api/src/libs/session.ts +242 -57
- package/api/src/libs/subscription.ts +2 -6
- package/api/src/libs/vendor-util/adapters/launcher-adapter.ts +1 -1
- package/api/src/libs/vendor-util/adapters/types.ts +1 -0
- package/api/src/libs/vendor-util/fulfillment.ts +1 -1
- package/api/src/queues/auto-recharge.ts +1 -1
- package/api/src/queues/discount-status.ts +200 -0
- package/api/src/queues/subscription.ts +98 -5
- package/api/src/queues/usage-record.ts +1 -1
- package/api/src/queues/vendors/fulfillment-coordinator.ts +1 -29
- package/api/src/queues/vendors/return-processor.ts +184 -0
- package/api/src/queues/vendors/return-scanner.ts +119 -0
- package/api/src/queues/vendors/status-check.ts +1 -1
- package/api/src/routes/auto-recharge-configs.ts +5 -3
- package/api/src/routes/checkout-sessions.ts +755 -64
- package/api/src/routes/connect/change-payment.ts +6 -1
- package/api/src/routes/connect/change-plan.ts +6 -1
- package/api/src/routes/connect/setup.ts +6 -1
- package/api/src/routes/connect/shared.ts +80 -9
- package/api/src/routes/connect/subscribe.ts +12 -2
- package/api/src/routes/coupons.ts +518 -0
- package/api/src/routes/index.ts +4 -0
- package/api/src/routes/invoices.ts +44 -3
- package/api/src/routes/meter-events.ts +2 -1
- package/api/src/routes/payment-currencies.ts +1 -0
- package/api/src/routes/promotion-codes.ts +482 -0
- package/api/src/routes/subscriptions.ts +23 -2
- package/api/src/routes/vendor.ts +89 -2
- package/api/src/store/migrations/20250904-discount.ts +136 -0
- package/api/src/store/migrations/20250910-timestamp-fields.ts +116 -0
- package/api/src/store/migrations/20250916-add-description-fields.ts +30 -0
- package/api/src/store/migrations/20250918-add-vendor-extends.ts +20 -0
- package/api/src/store/models/checkout-session.ts +17 -2
- package/api/src/store/models/coupon.ts +144 -4
- package/api/src/store/models/discount.ts +23 -10
- package/api/src/store/models/index.ts +13 -2
- package/api/src/store/models/product-vendor.ts +6 -0
- package/api/src/store/models/promotion-code.ts +295 -18
- package/api/src/store/models/types.ts +30 -1
- package/api/tests/libs/session.spec.ts +48 -27
- package/blocklet.yml +1 -1
- package/package.json +20 -20
- package/src/app.tsx +2 -0
- package/src/components/customer/link.tsx +1 -1
- package/src/components/discount/discount-info.tsx +178 -0
- package/src/components/invoice/table.tsx +140 -48
- package/src/components/invoice-pdf/styles.ts +6 -0
- package/src/components/invoice-pdf/template.tsx +59 -33
- package/src/components/metadata/form.tsx +14 -5
- package/src/components/payment-link/actions.tsx +42 -0
- package/src/components/price/form.tsx +91 -65
- package/src/components/product/vendor-config.tsx +5 -3
- package/src/components/promotion/active-redemptions.tsx +534 -0
- package/src/components/promotion/currency-multi-select.tsx +350 -0
- package/src/components/promotion/currency-restrictions.tsx +117 -0
- package/src/components/promotion/product-select.tsx +292 -0
- package/src/components/promotion/promotion-code-form.tsx +534 -0
- package/src/components/subscription/portal/list.tsx +6 -1
- package/src/components/subscription/vendor-service-list.tsx +13 -2
- package/src/locales/en.tsx +227 -0
- package/src/locales/zh.tsx +222 -1
- package/src/pages/admin/billing/subscriptions/detail.tsx +5 -0
- package/src/pages/admin/products/coupons/applicable-products.tsx +166 -0
- package/src/pages/admin/products/coupons/create.tsx +612 -0
- package/src/pages/admin/products/coupons/detail.tsx +538 -0
- package/src/pages/admin/products/coupons/edit.tsx +127 -0
- package/src/pages/admin/products/coupons/index.tsx +210 -3
- package/src/pages/admin/products/index.tsx +22 -3
- package/src/pages/admin/products/products/detail.tsx +12 -2
- package/src/pages/admin/products/promotion-codes/actions.tsx +103 -0
- package/src/pages/admin/products/promotion-codes/create.tsx +235 -0
- package/src/pages/admin/products/promotion-codes/detail.tsx +416 -0
- package/src/pages/admin/products/promotion-codes/list.tsx +247 -0
- package/src/pages/admin/products/promotion-codes/verification-config.tsx +327 -0
- package/src/pages/admin/products/vendors/index.tsx +17 -5
- package/src/pages/customer/subscription/detail.tsx +5 -0
- package/vite.config.ts +4 -3
|
@@ -0,0 +1,1061 @@
|
|
|
1
|
+
import { BN } from '@ocap/util';
|
|
2
|
+
|
|
3
|
+
import pick from 'lodash/pick';
|
|
4
|
+
import { Coupon, Customer, Discount, PromotionCode, Subscription } from '../../store/models';
|
|
5
|
+
import type { CheckoutSession, TLineItemExpanded } from '../../store/models';
|
|
6
|
+
import logger from '../logger';
|
|
7
|
+
import { emitAsync } from '../event';
|
|
8
|
+
|
|
9
|
+
export function validCoupon(coupon: Coupon, lineItems?: TLineItemExpanded[]) {
|
|
10
|
+
if (!coupon.valid) {
|
|
11
|
+
return { valid: false, reason: 'This coupon is no longer available' };
|
|
12
|
+
}
|
|
13
|
+
if (coupon.redeem_by && Math.floor(Date.now() / 1000) > coupon.redeem_by) {
|
|
14
|
+
return { valid: false, reason: 'This coupon has expired and cannot be used' };
|
|
15
|
+
}
|
|
16
|
+
if (coupon.max_redemptions && (coupon.times_redeemed ?? 0) >= coupon.max_redemptions) {
|
|
17
|
+
return { valid: false, reason: 'This coupon has been fully redeemed and is no longer available' };
|
|
18
|
+
}
|
|
19
|
+
if (lineItems && lineItems.length > 0 && coupon.applies_to?.products) {
|
|
20
|
+
const applicableProducts = coupon.applies_to?.products;
|
|
21
|
+
if (applicableProducts && applicableProducts.length > 0) {
|
|
22
|
+
const hasApplicableProduct = lineItems.some((item) => applicableProducts.includes(item.price?.product_id));
|
|
23
|
+
if (!hasApplicableProduct) {
|
|
24
|
+
return { valid: false, reason: 'This coupon cannot be applied to the items in your cart' };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return { valid: true };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function validPromotionCode(
|
|
32
|
+
promotionCode: PromotionCode,
|
|
33
|
+
{
|
|
34
|
+
customerId,
|
|
35
|
+
amount,
|
|
36
|
+
currencyId,
|
|
37
|
+
}: {
|
|
38
|
+
customerId?: string;
|
|
39
|
+
amount?: string;
|
|
40
|
+
currencyId?: string;
|
|
41
|
+
}
|
|
42
|
+
) {
|
|
43
|
+
if (!promotionCode.active) {
|
|
44
|
+
return { valid: false, reason: 'This promotion code is no longer available' };
|
|
45
|
+
}
|
|
46
|
+
if (promotionCode.expires_at && Math.floor(Date.now() / 1000) > promotionCode.expires_at) {
|
|
47
|
+
return { valid: false, reason: 'This promotion code has expired and cannot be used' };
|
|
48
|
+
}
|
|
49
|
+
if (promotionCode.max_redemptions && (promotionCode.times_redeemed ?? 0) >= promotionCode.max_redemptions) {
|
|
50
|
+
return { valid: false, reason: 'This promotion code has been fully redeemed and is no longer available' };
|
|
51
|
+
}
|
|
52
|
+
if (customerId) {
|
|
53
|
+
const customer = await Customer.findByPkOrDid(customerId);
|
|
54
|
+
if (promotionCode.verification_type === 'user_restricted' && customer?.did) {
|
|
55
|
+
if (!promotionCode.customer_dids?.includes(customer.did)) {
|
|
56
|
+
return { valid: false, reason: 'This promotion code is not available for your account' };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (promotionCode.restrictions?.first_time_transaction) {
|
|
60
|
+
const previousDiscounts = await Discount.count({
|
|
61
|
+
where: { customer_id: customerId, coupon_id: promotionCode.coupon_id },
|
|
62
|
+
});
|
|
63
|
+
if (previousDiscounts > 0) {
|
|
64
|
+
return { valid: false, reason: 'This promotion is only available for first-time purchases' };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (
|
|
69
|
+
amount &&
|
|
70
|
+
currencyId &&
|
|
71
|
+
(promotionCode.restrictions?.minimum_amount || promotionCode.restrictions?.currency_options?.[currencyId])
|
|
72
|
+
) {
|
|
73
|
+
const minimumAmount =
|
|
74
|
+
currencyId === promotionCode.restrictions?.minimum_amount_currency
|
|
75
|
+
? promotionCode.restrictions?.minimum_amount
|
|
76
|
+
: promotionCode.restrictions?.currency_options?.[currencyId]?.minimum_amount;
|
|
77
|
+
|
|
78
|
+
if (minimumAmount) {
|
|
79
|
+
const amountBN = new BN(amount);
|
|
80
|
+
const minimumBN = new BN(minimumAmount);
|
|
81
|
+
if (amountBN.lt(minimumBN)) {
|
|
82
|
+
return {
|
|
83
|
+
valid: false,
|
|
84
|
+
reason: 'This promotion requires a minimum purchase amount. Please add more items to your cart.',
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return { valid: true };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Calculate actual discount amount (not the remaining amount after discount)
|
|
93
|
+
export function calculateDiscountAmount(
|
|
94
|
+
coupon: Coupon,
|
|
95
|
+
amount: string,
|
|
96
|
+
currency: { id: string; decimal: number; symbol: string }
|
|
97
|
+
): string {
|
|
98
|
+
if (coupon.percent_off > 0) {
|
|
99
|
+
// Calculate percentage discount
|
|
100
|
+
const discountAmount = new BN(amount).mul(new BN(coupon.percent_off)).div(new BN(100));
|
|
101
|
+
return discountAmount.toString();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (coupon.amount_off) {
|
|
105
|
+
// Calculate fixed amount discount
|
|
106
|
+
const amountOff =
|
|
107
|
+
coupon.currency_id === currency.id ? coupon.amount_off : coupon.currency_options?.[currency.id]?.amount_off;
|
|
108
|
+
|
|
109
|
+
if (amountOff) {
|
|
110
|
+
// Return the smaller of discount amount or total amount
|
|
111
|
+
const discountBN = new BN(amountOff);
|
|
112
|
+
const totalBN = new BN(amount);
|
|
113
|
+
return BN.min(discountBN, totalBN).toString();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return '0';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Lock coupon and promotion code when discount is created/applied
|
|
122
|
+
*/
|
|
123
|
+
export async function lockDiscountResources(couponId: string, promotionCodeId?: string): Promise<void> {
|
|
124
|
+
try {
|
|
125
|
+
// Lock the coupon
|
|
126
|
+
if (couponId) {
|
|
127
|
+
const coupon = await Coupon.findByPk(couponId);
|
|
128
|
+
if (coupon && !coupon.locked) {
|
|
129
|
+
await coupon.update({ locked: true });
|
|
130
|
+
logger.info('Locked coupon when creating discount', {
|
|
131
|
+
couponId,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Lock the promotion code if it exists
|
|
137
|
+
if (promotionCodeId) {
|
|
138
|
+
const promotionCode = await PromotionCode.findByPk(promotionCodeId);
|
|
139
|
+
if (promotionCode && !promotionCode.locked) {
|
|
140
|
+
await promotionCode.update({ locked: true });
|
|
141
|
+
logger.info('Locked promotion code when creating discount', {
|
|
142
|
+
promotionCodeId,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
} catch (error) {
|
|
147
|
+
logger.error('Error locking discount resources', {
|
|
148
|
+
couponId,
|
|
149
|
+
promotionCodeId,
|
|
150
|
+
error: error.message,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Get discount records for a checkout session
|
|
157
|
+
* Used when creating invoices/subscriptions to include discount information
|
|
158
|
+
*/
|
|
159
|
+
export async function getDiscountRecordsForCheckout({
|
|
160
|
+
checkoutSessionId,
|
|
161
|
+
customerId,
|
|
162
|
+
}: {
|
|
163
|
+
checkoutSessionId: string;
|
|
164
|
+
customerId: string;
|
|
165
|
+
}): Promise<{
|
|
166
|
+
discountRecords: any[];
|
|
167
|
+
appliedDiscounts: string[];
|
|
168
|
+
discountBreakdown: Array<{ amount: string; discount: string }>;
|
|
169
|
+
}> {
|
|
170
|
+
const discountRecords = await Discount.findAll({
|
|
171
|
+
where: {
|
|
172
|
+
customer_id: customerId,
|
|
173
|
+
checkout_session_id: checkoutSessionId,
|
|
174
|
+
},
|
|
175
|
+
include: [
|
|
176
|
+
{ model: Coupon, as: 'coupon' },
|
|
177
|
+
{ model: PromotionCode, as: 'promotionCode', required: false },
|
|
178
|
+
],
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
if (discountRecords.length === 0) {
|
|
182
|
+
return {
|
|
183
|
+
discountRecords: [],
|
|
184
|
+
appliedDiscounts: [],
|
|
185
|
+
discountBreakdown: [],
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const appliedDiscounts = discountRecords.map((d) => d.id);
|
|
190
|
+
const discountBreakdown = discountRecords.map((d) => ({
|
|
191
|
+
amount: d.metadata?.discount_amount || '0',
|
|
192
|
+
discount: d.id,
|
|
193
|
+
}));
|
|
194
|
+
|
|
195
|
+
logger.info('Retrieved discount records for checkout session', {
|
|
196
|
+
checkoutSessionId,
|
|
197
|
+
customerId,
|
|
198
|
+
recordCount: discountRecords.length,
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
discountRecords,
|
|
203
|
+
appliedDiscounts,
|
|
204
|
+
discountBreakdown,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Get existing discount records for a checkout session
|
|
210
|
+
*/
|
|
211
|
+
async function getExistingDiscountRecords(customerId: string, checkoutSessionId: string) {
|
|
212
|
+
try {
|
|
213
|
+
const existingDiscounts = await Discount.findAll({
|
|
214
|
+
where: {
|
|
215
|
+
customer_id: customerId,
|
|
216
|
+
checkout_session_id: checkoutSessionId,
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
const existingDiscountMap = new Map();
|
|
221
|
+
existingDiscounts.forEach((discount) => {
|
|
222
|
+
const key = discount.subscription_id || 'no_subscription';
|
|
223
|
+
existingDiscountMap.set(key, discount);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
return existingDiscountMap;
|
|
227
|
+
} catch (error) {
|
|
228
|
+
logger.error('Error fetching existing discount records', {
|
|
229
|
+
customerId,
|
|
230
|
+
checkoutSessionId,
|
|
231
|
+
error: error.message,
|
|
232
|
+
});
|
|
233
|
+
throw new Error(`Failed to fetch existing discount records: ${error.message}`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Build base discount data from checkout session and coupon info
|
|
239
|
+
*/
|
|
240
|
+
function buildBaseDiscountData({
|
|
241
|
+
checkoutSession,
|
|
242
|
+
customerId,
|
|
243
|
+
coupon,
|
|
244
|
+
promotionCode,
|
|
245
|
+
discountAmount,
|
|
246
|
+
verificationData,
|
|
247
|
+
}: {
|
|
248
|
+
checkoutSession: CheckoutSession;
|
|
249
|
+
customerId: string;
|
|
250
|
+
coupon: Coupon;
|
|
251
|
+
promotionCode: PromotionCode;
|
|
252
|
+
discountAmount: string;
|
|
253
|
+
verificationData?: any;
|
|
254
|
+
}) {
|
|
255
|
+
const now = Math.floor(Date.now() / 1000);
|
|
256
|
+
let endTime: number | undefined;
|
|
257
|
+
|
|
258
|
+
if (coupon.duration === 'once') {
|
|
259
|
+
endTime = now + 60;
|
|
260
|
+
} else if (coupon.duration === 'repeating' && coupon.duration_in_months) {
|
|
261
|
+
const endDate = new Date();
|
|
262
|
+
endDate.setMonth(endDate.getMonth() + coupon.duration_in_months);
|
|
263
|
+
endTime = Math.floor(endDate.getTime() / 1000);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
livemode: coupon.livemode,
|
|
268
|
+
coupon_id: coupon.id,
|
|
269
|
+
promotion_code_id: promotionCode.id,
|
|
270
|
+
customer_id: customerId,
|
|
271
|
+
checkout_session_id: checkoutSession.id,
|
|
272
|
+
start: now,
|
|
273
|
+
end: endTime,
|
|
274
|
+
verification_method: promotionCode.verification_type,
|
|
275
|
+
verification_data: verificationData || checkoutSession.metadata?.verification_data,
|
|
276
|
+
metadata: {
|
|
277
|
+
checkout_session_id: checkoutSession.id,
|
|
278
|
+
discount_amount: discountAmount,
|
|
279
|
+
original_amount: checkoutSession.amount_subtotal,
|
|
280
|
+
final_amount: checkoutSession.amount_total,
|
|
281
|
+
applied_at: new Date().toISOString(),
|
|
282
|
+
},
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Process discount record for a single subscription
|
|
288
|
+
*/
|
|
289
|
+
async function processSubscriptionDiscount({
|
|
290
|
+
subscriptionId,
|
|
291
|
+
baseDiscountData,
|
|
292
|
+
existingDiscountMap,
|
|
293
|
+
checkoutSessionId,
|
|
294
|
+
}: {
|
|
295
|
+
subscriptionId: string;
|
|
296
|
+
baseDiscountData: any;
|
|
297
|
+
existingDiscountMap: Map<string, any>;
|
|
298
|
+
checkoutSessionId: string;
|
|
299
|
+
}): Promise<{ discountRecord: any; shouldUpdateUsage: boolean }> {
|
|
300
|
+
try {
|
|
301
|
+
const existingDiscount = existingDiscountMap.get(subscriptionId);
|
|
302
|
+
|
|
303
|
+
if (existingDiscount) {
|
|
304
|
+
// Check if coupon or promotion code has changed
|
|
305
|
+
if (
|
|
306
|
+
existingDiscount.coupon_id !== baseDiscountData.coupon_id ||
|
|
307
|
+
existingDiscount.promotion_code_id !== baseDiscountData.promotion_code_id
|
|
308
|
+
) {
|
|
309
|
+
// Update existing discount record
|
|
310
|
+
await existingDiscount.update({
|
|
311
|
+
...baseDiscountData,
|
|
312
|
+
subscription_id: subscriptionId,
|
|
313
|
+
metadata: {
|
|
314
|
+
...baseDiscountData.metadata,
|
|
315
|
+
subscription_id: subscriptionId,
|
|
316
|
+
updated_at: new Date().toISOString(),
|
|
317
|
+
},
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
logger.info('Updated existing discount record for subscription', {
|
|
321
|
+
checkoutSessionId,
|
|
322
|
+
subscriptionId,
|
|
323
|
+
discountId: existingDiscount.id,
|
|
324
|
+
oldCouponId: existingDiscount.coupon_id,
|
|
325
|
+
newCouponId: baseDiscountData.coupon_id,
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
return { discountRecord: existingDiscount, shouldUpdateUsage: true };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Same coupon/promotion code, no update needed
|
|
332
|
+
logger.debug('Discount record already exists with same coupon/promotion', {
|
|
333
|
+
checkoutSessionId,
|
|
334
|
+
subscriptionId,
|
|
335
|
+
discountId: existingDiscount.id,
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
return { discountRecord: existingDiscount, shouldUpdateUsage: false };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Create new discount record
|
|
342
|
+
const newDiscount = await Discount.create({
|
|
343
|
+
...baseDiscountData,
|
|
344
|
+
subscription_id: subscriptionId,
|
|
345
|
+
metadata: {
|
|
346
|
+
...baseDiscountData.metadata,
|
|
347
|
+
subscription_id: subscriptionId,
|
|
348
|
+
},
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
// Lock coupon and promotion code when discount is created
|
|
352
|
+
await lockDiscountResources(baseDiscountData.coupon_id, baseDiscountData.promotion_code_id);
|
|
353
|
+
|
|
354
|
+
logger.info('Created new discount record for subscription', {
|
|
355
|
+
checkoutSessionId,
|
|
356
|
+
subscriptionId,
|
|
357
|
+
discountId: newDiscount.id,
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
return { discountRecord: newDiscount, shouldUpdateUsage: true };
|
|
361
|
+
} catch (error) {
|
|
362
|
+
logger.error('Error processing subscription discount', {
|
|
363
|
+
subscriptionId,
|
|
364
|
+
checkoutSessionId,
|
|
365
|
+
error: error.message,
|
|
366
|
+
});
|
|
367
|
+
throw new Error(`Failed to process subscription discount: ${error.message}`);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Process discount record for non-subscription case
|
|
373
|
+
*/
|
|
374
|
+
async function processNonSubscriptionDiscount(
|
|
375
|
+
baseDiscountData: any,
|
|
376
|
+
existingDiscountMap: Map<string, any>,
|
|
377
|
+
checkoutSessionId: string
|
|
378
|
+
): Promise<{ discountRecord: any; shouldUpdateUsage: boolean }> {
|
|
379
|
+
try {
|
|
380
|
+
const existingDiscount = existingDiscountMap.get('no_subscription');
|
|
381
|
+
|
|
382
|
+
if (existingDiscount) {
|
|
383
|
+
if (
|
|
384
|
+
existingDiscount.coupon_id !== baseDiscountData.coupon_id ||
|
|
385
|
+
existingDiscount.promotion_code_id !== baseDiscountData.promotion_code_id
|
|
386
|
+
) {
|
|
387
|
+
// Update existing discount record
|
|
388
|
+
await existingDiscount.update({
|
|
389
|
+
...baseDiscountData,
|
|
390
|
+
metadata: {
|
|
391
|
+
...baseDiscountData.metadata,
|
|
392
|
+
updated_at: new Date().toISOString(),
|
|
393
|
+
},
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
logger.info('Updated existing discount record', {
|
|
397
|
+
checkoutSessionId,
|
|
398
|
+
discountId: existingDiscount.id,
|
|
399
|
+
oldCouponId: existingDiscount.coupon_id,
|
|
400
|
+
newCouponId: baseDiscountData.coupon_id,
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
return { discountRecord: existingDiscount, shouldUpdateUsage: true };
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
logger.debug('Discount record already exists with same coupon/promotion', {
|
|
407
|
+
checkoutSessionId,
|
|
408
|
+
discountId: existingDiscount.id,
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
return { discountRecord: existingDiscount, shouldUpdateUsage: false };
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Create new discount record
|
|
415
|
+
const newDiscount = await Discount.create(baseDiscountData);
|
|
416
|
+
|
|
417
|
+
// Lock coupon and promotion code when discount is created
|
|
418
|
+
await lockDiscountResources(baseDiscountData.coupon_id, baseDiscountData.promotion_code_id);
|
|
419
|
+
|
|
420
|
+
logger.info('Created new single discount record', {
|
|
421
|
+
checkoutSessionId,
|
|
422
|
+
discountId: newDiscount.id,
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
return { discountRecord: newDiscount, shouldUpdateUsage: true };
|
|
426
|
+
} catch (error) {
|
|
427
|
+
logger.error('Error processing non-subscription discount', {
|
|
428
|
+
checkoutSessionId,
|
|
429
|
+
error: error.message,
|
|
430
|
+
});
|
|
431
|
+
throw new Error(`Failed to process non-subscription discount: ${error.message}`);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Update usage counts for coupons and promotion codes
|
|
437
|
+
* Uses checkout session ID to prevent duplicate counting when multiple subscriptions are created
|
|
438
|
+
*/
|
|
439
|
+
async function updateUsageCounts({
|
|
440
|
+
coupon,
|
|
441
|
+
promotionCode,
|
|
442
|
+
updatedCoupons,
|
|
443
|
+
updatedPromotionCodes,
|
|
444
|
+
checkoutSessionId,
|
|
445
|
+
}: {
|
|
446
|
+
coupon: Coupon;
|
|
447
|
+
promotionCode: PromotionCode;
|
|
448
|
+
updatedCoupons: Set<string>;
|
|
449
|
+
updatedPromotionCodes: Set<string>;
|
|
450
|
+
checkoutSessionId: string;
|
|
451
|
+
}): Promise<void> {
|
|
452
|
+
try {
|
|
453
|
+
const updatePromises = [];
|
|
454
|
+
|
|
455
|
+
// Create unique keys based on checkout session to prevent duplicate counting
|
|
456
|
+
const couponSessionKey = `${coupon.id}-${checkoutSessionId}`;
|
|
457
|
+
const promotionCodeSessionKey = `${promotionCode.id}-${checkoutSessionId}`;
|
|
458
|
+
|
|
459
|
+
if (!updatedCoupons.has(couponSessionKey)) {
|
|
460
|
+
updatedCoupons.add(couponSessionKey);
|
|
461
|
+
updatePromises.push(
|
|
462
|
+
coupon.update({
|
|
463
|
+
times_redeemed: (coupon.times_redeemed || 0) + 1,
|
|
464
|
+
})
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
if (!updatedPromotionCodes.has(promotionCodeSessionKey)) {
|
|
469
|
+
updatedPromotionCodes.add(promotionCodeSessionKey);
|
|
470
|
+
updatePromises.push(
|
|
471
|
+
promotionCode.update({
|
|
472
|
+
times_redeemed: (promotionCode.times_redeemed || 0) + 1,
|
|
473
|
+
})
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
await Promise.all(updatePromises);
|
|
478
|
+
emitAsync('discount-status.queued', coupon, 'coupon', true);
|
|
479
|
+
emitAsync('discount-status.queued', promotionCode, 'promotion-code', true);
|
|
480
|
+
|
|
481
|
+
logger.debug('Updated coupon and promotion code usage counts', {
|
|
482
|
+
checkoutSessionId,
|
|
483
|
+
couponId: coupon.id,
|
|
484
|
+
promotionCodeId: promotionCode.id,
|
|
485
|
+
});
|
|
486
|
+
} catch (error) {
|
|
487
|
+
logger.error('Error updating usage counts', {
|
|
488
|
+
checkoutSessionId,
|
|
489
|
+
couponId: coupon.id,
|
|
490
|
+
promotionCodeId: promotionCode.id,
|
|
491
|
+
error: error.message,
|
|
492
|
+
});
|
|
493
|
+
throw new Error(`Failed to update usage counts: ${error.message}`);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Update subscription discount_id references after discount records are created/updated
|
|
499
|
+
*/
|
|
500
|
+
export async function updateSubscriptionDiscountReferences({
|
|
501
|
+
discountRecords,
|
|
502
|
+
subscriptionsUpdated,
|
|
503
|
+
}: {
|
|
504
|
+
discountRecords: any[];
|
|
505
|
+
subscriptionsUpdated: string[];
|
|
506
|
+
}): Promise<{ updatedSubscriptions: string[] }> {
|
|
507
|
+
if (subscriptionsUpdated.length === 0) {
|
|
508
|
+
return { updatedSubscriptions: [] };
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const updatedSubscriptions: string[] = [];
|
|
512
|
+
|
|
513
|
+
try {
|
|
514
|
+
logger.info('Updating subscription discount_id references', {
|
|
515
|
+
subscriptionsToUpdate: subscriptionsUpdated.length,
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
const subscriptionUpdatePromises = subscriptionsUpdated.map(async (subscriptionId) => {
|
|
519
|
+
try {
|
|
520
|
+
// Find the discount record for this subscription
|
|
521
|
+
const discountRecord = discountRecords.find((d) => d.subscription_id === subscriptionId);
|
|
522
|
+
if (!discountRecord) {
|
|
523
|
+
logger.warn('No discount record found for subscription', { subscriptionId });
|
|
524
|
+
return null;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Update subscription with discount_id
|
|
528
|
+
const [updateCount] = await Subscription.update(
|
|
529
|
+
{ discount_id: discountRecord.id },
|
|
530
|
+
{ where: { id: subscriptionId } }
|
|
531
|
+
);
|
|
532
|
+
|
|
533
|
+
if (updateCount > 0) {
|
|
534
|
+
logger.debug('Updated subscription discount_id', {
|
|
535
|
+
subscriptionId,
|
|
536
|
+
discountId: discountRecord.id,
|
|
537
|
+
});
|
|
538
|
+
return subscriptionId;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
logger.warn('Subscription not found for discount_id update', { subscriptionId });
|
|
542
|
+
return null;
|
|
543
|
+
} catch (error) {
|
|
544
|
+
logger.error('Failed to update subscription discount_id', {
|
|
545
|
+
subscriptionId,
|
|
546
|
+
error: error.message,
|
|
547
|
+
});
|
|
548
|
+
return null;
|
|
549
|
+
}
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
const updateResults = await Promise.all(subscriptionUpdatePromises);
|
|
553
|
+
updatedSubscriptions.push(...updateResults.filter((id) => id !== null));
|
|
554
|
+
|
|
555
|
+
logger.info('Completed subscription discount_id reference updates', {
|
|
556
|
+
requestedUpdates: subscriptionsUpdated.length,
|
|
557
|
+
successfulUpdates: updatedSubscriptions.length,
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
return { updatedSubscriptions };
|
|
561
|
+
} catch (error) {
|
|
562
|
+
logger.error('Error updating subscription discount references', {
|
|
563
|
+
subscriptionsUpdated,
|
|
564
|
+
error: error.message,
|
|
565
|
+
});
|
|
566
|
+
throw new Error(`Failed to update subscription discount references: ${error.message}`);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Create or update discount records for a completed checkout session
|
|
572
|
+
* This creates/updates the actual discount records and manages usage counts
|
|
573
|
+
* For subscriptions, creates separate discount records for each subscription
|
|
574
|
+
* Handles deduplication and updates when coupon/promotion code changes
|
|
575
|
+
*/
|
|
576
|
+
export async function createDiscountRecordsForCheckout({
|
|
577
|
+
checkoutSession,
|
|
578
|
+
customerId,
|
|
579
|
+
subscriptionIds = [],
|
|
580
|
+
}: {
|
|
581
|
+
checkoutSession: any; // CheckoutSession type
|
|
582
|
+
customerId: string;
|
|
583
|
+
subscriptionIds?: string[];
|
|
584
|
+
}): Promise<{
|
|
585
|
+
discountRecords: any[];
|
|
586
|
+
updatedCoupons: string[];
|
|
587
|
+
updatedPromotionCodes: string[];
|
|
588
|
+
subscriptionsUpdated: string[];
|
|
589
|
+
}> {
|
|
590
|
+
if (!checkoutSession.discounts?.length) {
|
|
591
|
+
return { discountRecords: [], updatedCoupons: [], updatedPromotionCodes: [], subscriptionsUpdated: [] };
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
logger.info('Creating/updating discount records for checkout session', {
|
|
595
|
+
checkoutSessionId: checkoutSession.id,
|
|
596
|
+
customerId,
|
|
597
|
+
subscriptionIds,
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
try {
|
|
601
|
+
// Get existing discount records
|
|
602
|
+
const existingDiscountMap = await getExistingDiscountRecords(customerId, checkoutSession.id);
|
|
603
|
+
|
|
604
|
+
const updatedCoupons = new Set<string>();
|
|
605
|
+
const updatedPromotionCodes = new Set<string>();
|
|
606
|
+
const allDiscountRecords: any[] = [];
|
|
607
|
+
const subscriptionsUpdated: string[] = [];
|
|
608
|
+
|
|
609
|
+
// Process each discount configuration
|
|
610
|
+
const discountProcessingPromises = checkoutSession.discounts.map(async (discount: any) => {
|
|
611
|
+
const { promotion_code: promotionCodeId, coupon: couponId, discount_amount: discountAmount } = discount;
|
|
612
|
+
|
|
613
|
+
if (!promotionCodeId || !couponId) {
|
|
614
|
+
logger.warn('Incomplete discount configuration, skipping', {
|
|
615
|
+
checkoutSessionId: checkoutSession.id,
|
|
616
|
+
hasPromotionCode: !!promotionCodeId,
|
|
617
|
+
hasCoupon: !!couponId,
|
|
618
|
+
});
|
|
619
|
+
return null;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
try {
|
|
623
|
+
// Fetch coupon and promotion code
|
|
624
|
+
const [coupon, promotionCode] = await Promise.all([
|
|
625
|
+
Coupon.findByPk(couponId),
|
|
626
|
+
PromotionCode.findByPk(promotionCodeId),
|
|
627
|
+
]);
|
|
628
|
+
|
|
629
|
+
if (!coupon || !promotionCode) {
|
|
630
|
+
logger.warn('Coupon or promotion code not found, skipping discount', {
|
|
631
|
+
couponId,
|
|
632
|
+
promotionCodeId,
|
|
633
|
+
checkoutSessionId: checkoutSession.id,
|
|
634
|
+
});
|
|
635
|
+
return null;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// Build base discount data
|
|
639
|
+
const baseDiscountData = buildBaseDiscountData({
|
|
640
|
+
checkoutSession,
|
|
641
|
+
customerId,
|
|
642
|
+
coupon,
|
|
643
|
+
promotionCode,
|
|
644
|
+
discountAmount,
|
|
645
|
+
verificationData: discount.verification_data,
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
const discountRecords: any[] = [];
|
|
649
|
+
let shouldUpdateUsageCount = false;
|
|
650
|
+
const currentSubscriptionsUpdated: string[] = [];
|
|
651
|
+
|
|
652
|
+
// Process subscriptions
|
|
653
|
+
if (subscriptionIds.length > 0) {
|
|
654
|
+
const subscriptionProcessingPromises = subscriptionIds.map(async (subscriptionId) => {
|
|
655
|
+
try {
|
|
656
|
+
const result = await processSubscriptionDiscount({
|
|
657
|
+
subscriptionId,
|
|
658
|
+
baseDiscountData,
|
|
659
|
+
existingDiscountMap,
|
|
660
|
+
checkoutSessionId: checkoutSession.id,
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
if (result.shouldUpdateUsage) {
|
|
664
|
+
currentSubscriptionsUpdated.push(subscriptionId);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
return result.discountRecord;
|
|
668
|
+
} catch (error) {
|
|
669
|
+
logger.error('Failed to process subscription discount, continuing with others', {
|
|
670
|
+
subscriptionId,
|
|
671
|
+
error: error.message,
|
|
672
|
+
});
|
|
673
|
+
return null;
|
|
674
|
+
}
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
const subscriptionResults = await Promise.all(subscriptionProcessingPromises);
|
|
678
|
+
discountRecords.push(...subscriptionResults.filter((record) => record !== null));
|
|
679
|
+
|
|
680
|
+
if (currentSubscriptionsUpdated.length > 0) {
|
|
681
|
+
shouldUpdateUsageCount = true;
|
|
682
|
+
}
|
|
683
|
+
} else {
|
|
684
|
+
// Process non-subscription case
|
|
685
|
+
try {
|
|
686
|
+
const result = await processNonSubscriptionDiscount(
|
|
687
|
+
baseDiscountData,
|
|
688
|
+
existingDiscountMap,
|
|
689
|
+
checkoutSession.id
|
|
690
|
+
);
|
|
691
|
+
|
|
692
|
+
discountRecords.push(result.discountRecord);
|
|
693
|
+
shouldUpdateUsageCount = result.shouldUpdateUsage;
|
|
694
|
+
} catch (error) {
|
|
695
|
+
logger.error('Failed to process non-subscription discount', {
|
|
696
|
+
checkoutSessionId: checkoutSession.id,
|
|
697
|
+
error: error.message,
|
|
698
|
+
});
|
|
699
|
+
throw error;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// Update usage counts if needed
|
|
704
|
+
if (shouldUpdateUsageCount) {
|
|
705
|
+
try {
|
|
706
|
+
await updateUsageCounts({
|
|
707
|
+
coupon,
|
|
708
|
+
promotionCode,
|
|
709
|
+
updatedCoupons,
|
|
710
|
+
updatedPromotionCodes,
|
|
711
|
+
checkoutSessionId: checkoutSession.id,
|
|
712
|
+
});
|
|
713
|
+
} catch (error) {
|
|
714
|
+
logger.error('Failed to update usage counts, but continuing', {
|
|
715
|
+
couponId: coupon.id,
|
|
716
|
+
promotionCodeId: promotionCode.id,
|
|
717
|
+
error: error.message,
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
return {
|
|
723
|
+
discountRecords,
|
|
724
|
+
subscriptionsUpdated: currentSubscriptionsUpdated,
|
|
725
|
+
};
|
|
726
|
+
} catch (error) {
|
|
727
|
+
logger.error('Error processing discount configuration, skipping', {
|
|
728
|
+
couponId,
|
|
729
|
+
promotionCodeId,
|
|
730
|
+
checkoutSessionId: checkoutSession.id,
|
|
731
|
+
error: error.message,
|
|
732
|
+
});
|
|
733
|
+
return null;
|
|
734
|
+
}
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
const processingResults = await Promise.all(discountProcessingPromises);
|
|
738
|
+
|
|
739
|
+
// Aggregate results
|
|
740
|
+
processingResults.forEach((result) => {
|
|
741
|
+
if (result) {
|
|
742
|
+
allDiscountRecords.push(...result.discountRecords);
|
|
743
|
+
subscriptionsUpdated.push(...result.subscriptionsUpdated);
|
|
744
|
+
}
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
logger.info('All discount records processed successfully', {
|
|
748
|
+
checkoutSessionId: checkoutSession.id,
|
|
749
|
+
customerId,
|
|
750
|
+
totalRecords: allDiscountRecords.length,
|
|
751
|
+
subscriptionCount: subscriptionIds.length,
|
|
752
|
+
subscriptionsUpdated: subscriptionsUpdated.length,
|
|
753
|
+
updatedCouponsCount: updatedCoupons.size,
|
|
754
|
+
updatedPromotionCodesCount: updatedPromotionCodes.size,
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
return {
|
|
758
|
+
discountRecords: allDiscountRecords,
|
|
759
|
+
updatedCoupons: Array.from(updatedCoupons),
|
|
760
|
+
updatedPromotionCodes: Array.from(updatedPromotionCodes),
|
|
761
|
+
subscriptionsUpdated,
|
|
762
|
+
};
|
|
763
|
+
} catch (error) {
|
|
764
|
+
logger.error('Critical error processing discount records for checkout', {
|
|
765
|
+
checkoutSessionId: checkoutSession.id,
|
|
766
|
+
customerId,
|
|
767
|
+
subscriptionIds,
|
|
768
|
+
error: error.message,
|
|
769
|
+
});
|
|
770
|
+
throw new Error(`Failed to process discount records: ${error.message}`);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// Unified promotion code eligibility check
|
|
775
|
+
export async function checkPromotionCodeEligibility({
|
|
776
|
+
promotionCode,
|
|
777
|
+
couponId,
|
|
778
|
+
customerId,
|
|
779
|
+
amount,
|
|
780
|
+
currencyId,
|
|
781
|
+
lineItems = [],
|
|
782
|
+
}: {
|
|
783
|
+
promotionCode: PromotionCode;
|
|
784
|
+
couponId: string;
|
|
785
|
+
customerId: string;
|
|
786
|
+
amount: string;
|
|
787
|
+
currencyId: string;
|
|
788
|
+
lineItems?: TLineItemExpanded[];
|
|
789
|
+
}): Promise<{ eligible: boolean; reason?: string }> {
|
|
790
|
+
// Basic promotion code checks
|
|
791
|
+
if (!promotionCode.active) {
|
|
792
|
+
return { eligible: false, reason: 'This promotion code is no longer available' };
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
if (promotionCode.expires_at && Math.floor(Date.now() / 1000) > promotionCode.expires_at) {
|
|
796
|
+
return { eligible: false, reason: 'This promotion code has expired and cannot be used' };
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
if (promotionCode.max_redemptions && (promotionCode.times_redeemed ?? 0) >= promotionCode.max_redemptions) {
|
|
800
|
+
return { eligible: false, reason: 'This promotion code has been fully redeemed and is no longer available' };
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// Get associated coupon and validate
|
|
804
|
+
const coupon = await Coupon.findByPk(couponId);
|
|
805
|
+
if (!coupon) {
|
|
806
|
+
return { eligible: false, reason: 'This promotion is no longer available' };
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// Coupon validity check
|
|
810
|
+
const couponValidation = validCoupon(coupon, lineItems);
|
|
811
|
+
if (!couponValidation.valid) {
|
|
812
|
+
return { eligible: false, reason: couponValidation.reason };
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
const promotionValidation = await validPromotionCode(promotionCode, {
|
|
816
|
+
customerId,
|
|
817
|
+
amount,
|
|
818
|
+
currencyId,
|
|
819
|
+
});
|
|
820
|
+
if (!promotionValidation.valid) {
|
|
821
|
+
return { eligible: false, reason: promotionValidation.reason };
|
|
822
|
+
}
|
|
823
|
+
return { eligible: true };
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// Validate discount for billing cycles (subscription invoices)
|
|
827
|
+
export async function validateDiscountForBilling({
|
|
828
|
+
discount,
|
|
829
|
+
}: {
|
|
830
|
+
discount: Discount;
|
|
831
|
+
subscriptionId?: string;
|
|
832
|
+
}): Promise<{ valid: boolean; reason?: string; shouldRemove?: boolean }> {
|
|
833
|
+
const coupon = await Coupon.findByPk(discount.coupon_id);
|
|
834
|
+
if (!coupon) {
|
|
835
|
+
return {
|
|
836
|
+
valid: false,
|
|
837
|
+
reason: 'This discount is no longer available',
|
|
838
|
+
shouldRemove: true,
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
const now = Math.floor(Date.now() / 1000);
|
|
843
|
+
|
|
844
|
+
// Check discount duration logic
|
|
845
|
+
switch (coupon.duration) {
|
|
846
|
+
case 'once':
|
|
847
|
+
// One-time discounts should not apply to recurring billing
|
|
848
|
+
return {
|
|
849
|
+
valid: false,
|
|
850
|
+
reason: 'One-time discount cannot be applied to subscription billing',
|
|
851
|
+
shouldRemove: true,
|
|
852
|
+
};
|
|
853
|
+
|
|
854
|
+
case 'repeating':
|
|
855
|
+
if (!discount.end) {
|
|
856
|
+
return {
|
|
857
|
+
valid: false,
|
|
858
|
+
reason: 'Repeating discount missing end date',
|
|
859
|
+
shouldRemove: true,
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
if (now > Number(discount.end)) {
|
|
864
|
+
return {
|
|
865
|
+
valid: false,
|
|
866
|
+
reason: 'Repeating discount has expired',
|
|
867
|
+
shouldRemove: true,
|
|
868
|
+
};
|
|
869
|
+
}
|
|
870
|
+
break;
|
|
871
|
+
|
|
872
|
+
case 'forever':
|
|
873
|
+
// Forever discounts are always valid
|
|
874
|
+
break;
|
|
875
|
+
|
|
876
|
+
default:
|
|
877
|
+
return {
|
|
878
|
+
valid: false,
|
|
879
|
+
reason: `Unknown coupon duration: ${coupon.duration}`,
|
|
880
|
+
shouldRemove: true,
|
|
881
|
+
};
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
return { valid: true };
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// Get all valid discounts for a subscription's billing period
|
|
888
|
+
export async function getValidDiscountsForSubscriptionBilling({
|
|
889
|
+
subscriptionId,
|
|
890
|
+
customerId,
|
|
891
|
+
}: {
|
|
892
|
+
subscriptionId: string;
|
|
893
|
+
customerId: string;
|
|
894
|
+
}): Promise<{
|
|
895
|
+
validDiscounts: Discount[];
|
|
896
|
+
expiredDiscounts: Discount[];
|
|
897
|
+
}> {
|
|
898
|
+
const discounts = await Discount.findAll({
|
|
899
|
+
where: {
|
|
900
|
+
customer_id: customerId,
|
|
901
|
+
subscription_id: subscriptionId,
|
|
902
|
+
},
|
|
903
|
+
include: [
|
|
904
|
+
{ model: Coupon, as: 'coupon' },
|
|
905
|
+
{ model: PromotionCode, as: 'promotionCode' },
|
|
906
|
+
],
|
|
907
|
+
});
|
|
908
|
+
|
|
909
|
+
const validDiscounts: Discount[] = [];
|
|
910
|
+
const expiredDiscounts: Discount[] = [];
|
|
911
|
+
|
|
912
|
+
logger.info('Found discounts for subscription billing', {
|
|
913
|
+
subscriptionId,
|
|
914
|
+
discountCount: discounts.map((d) => d.id),
|
|
915
|
+
});
|
|
916
|
+
|
|
917
|
+
await Promise.all(
|
|
918
|
+
discounts.map(async (discount) => {
|
|
919
|
+
const validation = await validateDiscountForBilling({
|
|
920
|
+
discount,
|
|
921
|
+
subscriptionId,
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
if (validation.valid) {
|
|
925
|
+
validDiscounts.push(discount);
|
|
926
|
+
} else {
|
|
927
|
+
expiredDiscounts.push(discount);
|
|
928
|
+
logger.info('Expired discount for subscription billing', {
|
|
929
|
+
subscriptionId,
|
|
930
|
+
discountId: discount.id,
|
|
931
|
+
reason: validation.reason,
|
|
932
|
+
});
|
|
933
|
+
}
|
|
934
|
+
})
|
|
935
|
+
);
|
|
936
|
+
|
|
937
|
+
return { validDiscounts, expiredDiscounts };
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
/**
|
|
941
|
+
* Expand line items with complete coupon information
|
|
942
|
+
* Similar to Price.expand but for coupon information
|
|
943
|
+
*/
|
|
944
|
+
export async function expandLineItemsWithCouponInfo(
|
|
945
|
+
lineItems: TLineItemExpanded[],
|
|
946
|
+
sessionDiscounts?: any[],
|
|
947
|
+
currencyId?: string
|
|
948
|
+
): Promise<TLineItemExpanded[]> {
|
|
949
|
+
if (!sessionDiscounts?.length) {
|
|
950
|
+
return lineItems;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
const enhancedLineItems = await Promise.all(
|
|
954
|
+
lineItems.map(async (item) => {
|
|
955
|
+
if (!item.discount_amounts?.length) {
|
|
956
|
+
return item;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
const enhancedDiscountAmounts = await Promise.all(
|
|
960
|
+
item.discount_amounts.map(async (discountAmount: any) => {
|
|
961
|
+
let couponInfo = null;
|
|
962
|
+
let promotionCodeInfo = null;
|
|
963
|
+
|
|
964
|
+
// Find the discount in checkout session discounts to get IDs
|
|
965
|
+
const sessionDiscount = sessionDiscounts.find((d: any) => d.coupon || d.promotion_code);
|
|
966
|
+
if (sessionDiscount) {
|
|
967
|
+
if (sessionDiscount.coupon) {
|
|
968
|
+
const coupon = await Coupon.findByPk(sessionDiscount.coupon);
|
|
969
|
+
if (coupon) {
|
|
970
|
+
couponInfo = {
|
|
971
|
+
object: 'coupon',
|
|
972
|
+
id: coupon.id,
|
|
973
|
+
name: coupon.name,
|
|
974
|
+
amount_off: coupon.amount_off,
|
|
975
|
+
percent_off: coupon.percent_off,
|
|
976
|
+
currency: coupon.currency_id,
|
|
977
|
+
duration: coupon.duration,
|
|
978
|
+
duration_in_months: coupon.duration_in_months,
|
|
979
|
+
has_applies_to_products: true,
|
|
980
|
+
};
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
if (sessionDiscount.promotion_code) {
|
|
985
|
+
const promotionCode = await PromotionCode.findByPk(sessionDiscount.promotion_code);
|
|
986
|
+
if (promotionCode) {
|
|
987
|
+
promotionCodeInfo = {
|
|
988
|
+
object: 'promotion_code',
|
|
989
|
+
id: promotionCode.id,
|
|
990
|
+
code: promotionCode.code,
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
return {
|
|
997
|
+
...discountAmount,
|
|
998
|
+
coupon: couponInfo,
|
|
999
|
+
promotion_code: promotionCodeInfo,
|
|
1000
|
+
currency: currencyId,
|
|
1001
|
+
};
|
|
1002
|
+
})
|
|
1003
|
+
);
|
|
1004
|
+
|
|
1005
|
+
return {
|
|
1006
|
+
...item,
|
|
1007
|
+
discount_amounts: enhancedDiscountAmounts,
|
|
1008
|
+
};
|
|
1009
|
+
})
|
|
1010
|
+
);
|
|
1011
|
+
|
|
1012
|
+
return enhancedLineItems;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
/**
|
|
1016
|
+
* Expand discounts with complete coupon and promotion code information
|
|
1017
|
+
*/
|
|
1018
|
+
export async function expandDiscountsWithDetails(discounts?: any[]): Promise<any[]> {
|
|
1019
|
+
if (!discounts?.length) {
|
|
1020
|
+
return [];
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
const enhancedDiscounts = await Promise.all(
|
|
1024
|
+
discounts.map(async (discount) => {
|
|
1025
|
+
let couponInfo = null;
|
|
1026
|
+
let promotionCodeInfo = null;
|
|
1027
|
+
|
|
1028
|
+
if (discount.coupon) {
|
|
1029
|
+
const coupon = await Coupon.findByPk(discount.coupon);
|
|
1030
|
+
if (coupon) {
|
|
1031
|
+
couponInfo = pick(coupon, [
|
|
1032
|
+
'id',
|
|
1033
|
+
'name',
|
|
1034
|
+
'percent_off',
|
|
1035
|
+
'amount_off',
|
|
1036
|
+
'valid',
|
|
1037
|
+
'duration',
|
|
1038
|
+
'duration_in_months',
|
|
1039
|
+
'currency_id',
|
|
1040
|
+
'currency_options',
|
|
1041
|
+
]);
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
if (discount.promotion_code) {
|
|
1046
|
+
const promotionCode = await PromotionCode.findByPk(discount.promotion_code);
|
|
1047
|
+
if (promotionCode) {
|
|
1048
|
+
promotionCodeInfo = pick(promotionCode, ['id', 'code']);
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
return {
|
|
1053
|
+
...discount,
|
|
1054
|
+
coupon_details: couponInfo,
|
|
1055
|
+
promotion_code_details: promotionCodeInfo,
|
|
1056
|
+
};
|
|
1057
|
+
})
|
|
1058
|
+
);
|
|
1059
|
+
|
|
1060
|
+
return enhancedDiscounts;
|
|
1061
|
+
}
|