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
|
@@ -43,6 +43,8 @@ import {
|
|
|
43
43
|
getSubscriptionLineItems,
|
|
44
44
|
isCreditMeteredLineItems,
|
|
45
45
|
validatePaymentAmounts,
|
|
46
|
+
processCheckoutSessionDiscounts,
|
|
47
|
+
getCheckoutSessionAmounts,
|
|
46
48
|
} from '../libs/session';
|
|
47
49
|
import { getDaysUntilCancel, getDaysUntilDue, getSubscriptionTrialSetup } from '../libs/subscription';
|
|
48
50
|
import {
|
|
@@ -65,6 +67,8 @@ import {
|
|
|
65
67
|
type TProductExpanded,
|
|
66
68
|
type TLineItemExpanded,
|
|
67
69
|
Invoice,
|
|
70
|
+
Coupon,
|
|
71
|
+
PromotionCode,
|
|
68
72
|
} from '../store/models';
|
|
69
73
|
import { CheckoutSession } from '../store/models/checkout-session';
|
|
70
74
|
import { Customer } from '../store/models/customer';
|
|
@@ -94,7 +98,15 @@ import { CHARGE_SUPPORTED_CHAIN_TYPES } from '../libs/constants';
|
|
|
94
98
|
import { blocklet } from '../libs/auth';
|
|
95
99
|
import { addSubscriptionJob } from '../queues/subscription';
|
|
96
100
|
import { updateDataConcurrency } from '../libs/env';
|
|
101
|
+
import {
|
|
102
|
+
expandLineItemsWithCouponInfo,
|
|
103
|
+
expandDiscountsWithDetails,
|
|
104
|
+
checkPromotionCodeEligibility,
|
|
105
|
+
createDiscountRecordsForCheckout,
|
|
106
|
+
updateSubscriptionDiscountReferences,
|
|
107
|
+
} from '../libs/discount/coupon';
|
|
97
108
|
import { formatToShortUrl } from '../libs/url';
|
|
109
|
+
import { applyDiscountsToLineItems } from '../libs/discount/discount';
|
|
98
110
|
|
|
99
111
|
const router = Router();
|
|
100
112
|
|
|
@@ -184,7 +196,87 @@ export async function validatePaymentSettings(paymentMethodId: string, paymentCu
|
|
|
184
196
|
}
|
|
185
197
|
|
|
186
198
|
/**
|
|
187
|
-
*
|
|
199
|
+
* Validate promotion codes are still valid during checkout submission
|
|
200
|
+
* Uses unified eligibility check to avoid duplicate validation
|
|
201
|
+
*/
|
|
202
|
+
async function validatePromotionCodesOnSubmit(
|
|
203
|
+
checkoutSession: CheckoutSession,
|
|
204
|
+
{
|
|
205
|
+
lineItems,
|
|
206
|
+
customerId,
|
|
207
|
+
amount,
|
|
208
|
+
}: {
|
|
209
|
+
lineItems: TLineItemExpanded[];
|
|
210
|
+
customerId: string;
|
|
211
|
+
amount?: string;
|
|
212
|
+
}
|
|
213
|
+
) {
|
|
214
|
+
if (!checkoutSession.discounts?.length) {
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
logger.info('Validating promotion codes on checkout submission', {
|
|
219
|
+
checkoutSessionId: checkoutSession.id,
|
|
220
|
+
discountCount: checkoutSession.discounts.length,
|
|
221
|
+
customerId,
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const validationPromises = checkoutSession.discounts.map(async (discount, index) => {
|
|
225
|
+
const { promotion_code: promotionCodeId, coupon: couponId } = discount;
|
|
226
|
+
|
|
227
|
+
if (!promotionCodeId || !couponId) {
|
|
228
|
+
logger.warn('Incomplete discount configuration', {
|
|
229
|
+
checkoutSessionId: checkoutSession.id,
|
|
230
|
+
discountIndex: index,
|
|
231
|
+
hasPromotionCode: !!promotionCodeId,
|
|
232
|
+
hasCoupon: !!couponId,
|
|
233
|
+
});
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Fetch promotion code
|
|
238
|
+
const promotionCode = await PromotionCode.findByPk(promotionCodeId);
|
|
239
|
+
if (!promotionCode) {
|
|
240
|
+
throw new CustomError(400, 'Promotion code not found');
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Use unified eligibility check - this includes all validations
|
|
244
|
+
const eligibilityCheck = await checkPromotionCodeEligibility({
|
|
245
|
+
promotionCode,
|
|
246
|
+
couponId,
|
|
247
|
+
customerId: customerId || checkoutSession.customer_id || '',
|
|
248
|
+
amount: amount || checkoutSession.amount_total,
|
|
249
|
+
currencyId: checkoutSession.currency_id || '',
|
|
250
|
+
lineItems,
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
if (!eligibilityCheck.eligible) {
|
|
254
|
+
logger.warn('Promotion code validation failed', {
|
|
255
|
+
checkoutSessionId: checkoutSession.id,
|
|
256
|
+
promotionCodeId,
|
|
257
|
+
couponId,
|
|
258
|
+
reason: eligibilityCheck.reason,
|
|
259
|
+
});
|
|
260
|
+
throw new CustomError(400, eligibilityCheck.reason || 'Promotion code is no longer valid');
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
logger.debug('Promotion code validation passed', {
|
|
264
|
+
checkoutSessionId: checkoutSession.id,
|
|
265
|
+
promotionCodeId,
|
|
266
|
+
couponId,
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
await Promise.all(validationPromises);
|
|
271
|
+
|
|
272
|
+
logger.info('All promotion codes validated successfully', {
|
|
273
|
+
checkoutSessionId: checkoutSession.id,
|
|
274
|
+
validatedCount: checkoutSession.discounts.length,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Calculate and update payment amount for checkout session
|
|
188
280
|
*/
|
|
189
281
|
export async function calculateAndUpdateAmount(
|
|
190
282
|
checkoutSession: CheckoutSession,
|
|
@@ -204,7 +296,38 @@ export async function calculateAndUpdateAmount(
|
|
|
204
296
|
trialEnd = trialSetup.trialEnd;
|
|
205
297
|
}
|
|
206
298
|
|
|
207
|
-
|
|
299
|
+
// Prepare discount configuration for getCheckoutAmount
|
|
300
|
+
let discountConfig;
|
|
301
|
+
if (checkoutSession.discounts && checkoutSession.discounts.length > 0) {
|
|
302
|
+
const firstDiscount = checkoutSession.discounts[0];
|
|
303
|
+
if (firstDiscount?.coupon) {
|
|
304
|
+
discountConfig = {
|
|
305
|
+
couponId: firstDiscount.coupon,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
if (firstDiscount?.promotion_code) {
|
|
309
|
+
discountConfig = {
|
|
310
|
+
...discountConfig,
|
|
311
|
+
promotionCodeId: firstDiscount.promotion_code,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
if (checkoutSession.customer_id) {
|
|
315
|
+
discountConfig = {
|
|
316
|
+
...discountConfig,
|
|
317
|
+
customerId: checkoutSession.customer_id,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
logger.info('Getting checkout amount', {
|
|
323
|
+
discountConfig,
|
|
324
|
+
});
|
|
325
|
+
const amount = await getCheckoutAmount(
|
|
326
|
+
lineItems,
|
|
327
|
+
paymentCurrencyId,
|
|
328
|
+
trialInDays > 0 || trialEnd > now,
|
|
329
|
+
discountConfig
|
|
330
|
+
);
|
|
208
331
|
|
|
209
332
|
await checkoutSession.update({
|
|
210
333
|
amount_subtotal: amount.subtotal,
|
|
@@ -216,11 +339,22 @@ export async function calculateAndUpdateAmount(
|
|
|
216
339
|
},
|
|
217
340
|
});
|
|
218
341
|
|
|
219
|
-
|
|
220
|
-
|
|
342
|
+
logger.info('Amount calculated', {
|
|
343
|
+
checkoutSessionId: checkoutSession.id,
|
|
344
|
+
amount,
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
if (checkoutSession.mode === 'payment' && amount.total < 0) {
|
|
348
|
+
throw new Error('Payment amount should be greater or equal to 0');
|
|
221
349
|
}
|
|
222
350
|
|
|
223
|
-
return {
|
|
351
|
+
return {
|
|
352
|
+
lineItems: amount.processedItems || lineItems,
|
|
353
|
+
amount,
|
|
354
|
+
trialInDays,
|
|
355
|
+
trialEnd,
|
|
356
|
+
now,
|
|
357
|
+
};
|
|
224
358
|
}
|
|
225
359
|
|
|
226
360
|
/**
|
|
@@ -399,6 +533,14 @@ export const formatCheckoutSession = async (payload: any, throwOnEmptyItems = tr
|
|
|
399
533
|
submit_type: 'pay',
|
|
400
534
|
cross_sell_behavior: 'auto',
|
|
401
535
|
enable_subscription_grouping: false,
|
|
536
|
+
// Ensure required amount fields are never null
|
|
537
|
+
amount_subtotal: '0',
|
|
538
|
+
amount_total: '0',
|
|
539
|
+
total_details: {
|
|
540
|
+
amount_discount: '0',
|
|
541
|
+
amount_shipping: '0',
|
|
542
|
+
amount_tax: '0',
|
|
543
|
+
},
|
|
402
544
|
},
|
|
403
545
|
pick(payload, [
|
|
404
546
|
'currency_id',
|
|
@@ -422,6 +564,8 @@ export const formatCheckoutSession = async (payload: any, throwOnEmptyItems = tr
|
|
|
422
564
|
'client_reference_id',
|
|
423
565
|
'after_expiration',
|
|
424
566
|
'enable_subscription_grouping',
|
|
567
|
+
'promotion_code',
|
|
568
|
+
'discounts',
|
|
425
569
|
])
|
|
426
570
|
);
|
|
427
571
|
|
|
@@ -502,23 +646,6 @@ export const formatCheckoutSession = async (payload: any, throwOnEmptyItems = tr
|
|
|
502
646
|
});
|
|
503
647
|
};
|
|
504
648
|
|
|
505
|
-
export async function getCheckoutSessionAmounts(checkoutSession: CheckoutSession) {
|
|
506
|
-
const now = dayjs().unix();
|
|
507
|
-
const items = await Price.expand(checkoutSession.line_items);
|
|
508
|
-
const trialInDays = Number(checkoutSession.subscription_data?.trial_period_days || 0);
|
|
509
|
-
const trialEnd = Number(checkoutSession.subscription_data?.trial_end || 0);
|
|
510
|
-
const amount = getCheckoutAmount(items, checkoutSession.currency_id, trialInDays > 0 || trialEnd > now);
|
|
511
|
-
return {
|
|
512
|
-
amount_subtotal: amount.subtotal,
|
|
513
|
-
amount_total: amount.total,
|
|
514
|
-
total_details: {
|
|
515
|
-
amount_discount: amount.discount,
|
|
516
|
-
amount_shipping: amount.shipping,
|
|
517
|
-
amount_tax: amount.tax,
|
|
518
|
-
},
|
|
519
|
-
};
|
|
520
|
-
}
|
|
521
|
-
|
|
522
649
|
export async function ensureCheckoutSessionOpen(req: Request, res: Response, next: NextFunction) {
|
|
523
650
|
const doc = await CheckoutSession.findByPk(req.params.id);
|
|
524
651
|
if (!doc) {
|
|
@@ -635,7 +762,20 @@ async function processSubscriptionFastCheckout({
|
|
|
635
762
|
const subscriptionAmounts = await pAll(
|
|
636
763
|
subscriptions.map((sub) => async () => {
|
|
637
764
|
const subItems = await getSubscriptionLineItems(sub, lineItems, primarySubscription);
|
|
638
|
-
|
|
765
|
+
const discountConfig = {
|
|
766
|
+
promotionCodeId: checkoutSession.discounts?.[0]?.promotion_code,
|
|
767
|
+
couponId: checkoutSession.discounts?.[0]?.coupon,
|
|
768
|
+
customerId: checkoutSession.customer_id,
|
|
769
|
+
};
|
|
770
|
+
const amount = await getFastCheckoutAmount({
|
|
771
|
+
items: subItems,
|
|
772
|
+
mode: 'subscription',
|
|
773
|
+
currencyId: paymentCurrency.id,
|
|
774
|
+
trialing: trialEnd > now,
|
|
775
|
+
minimumCycle: 1,
|
|
776
|
+
discountConfig,
|
|
777
|
+
});
|
|
778
|
+
return amount;
|
|
639
779
|
}),
|
|
640
780
|
{ concurrency: updateDataConcurrency }
|
|
641
781
|
);
|
|
@@ -689,10 +829,11 @@ async function processSubscriptionFastCheckout({
|
|
|
689
829
|
);
|
|
690
830
|
if (paymentCurrency.isCredit()) {
|
|
691
831
|
// skip invoice creation for credit subscriptions
|
|
692
|
-
checkoutSession.update({
|
|
832
|
+
await checkoutSession.update({
|
|
693
833
|
status: 'complete',
|
|
694
834
|
payment_status: 'paid',
|
|
695
835
|
});
|
|
836
|
+
|
|
696
837
|
await pAll(
|
|
697
838
|
subscriptions.map((sub) => async () => {
|
|
698
839
|
await sub.update({
|
|
@@ -783,6 +924,8 @@ router.post('/', authLogin, async (req, res) => {
|
|
|
783
924
|
// Customer permission validation and createMine handling
|
|
784
925
|
const { create_mine: createMine } = req.body;
|
|
785
926
|
const currentUserDid = req.user?.did;
|
|
927
|
+
|
|
928
|
+
const isAdmin = ['owner', 'admin'].includes(req.user?.role as string);
|
|
786
929
|
// Handle createMine parameter
|
|
787
930
|
if (createMine === true) {
|
|
788
931
|
if (!currentUserDid) {
|
|
@@ -798,7 +941,7 @@ router.post('/', authLogin, async (req, res) => {
|
|
|
798
941
|
...(raw.metadata || {}),
|
|
799
942
|
createdBy: currentUserDid,
|
|
800
943
|
};
|
|
801
|
-
} else if (!
|
|
944
|
+
} else if (!isAdmin) {
|
|
802
945
|
return res.status(403).json({ error: 'Not authorized to perform this action' });
|
|
803
946
|
}
|
|
804
947
|
|
|
@@ -811,7 +954,30 @@ router.post('/', authLogin, async (req, res) => {
|
|
|
811
954
|
}
|
|
812
955
|
}
|
|
813
956
|
|
|
957
|
+
// Process discounts before creating checkout session
|
|
958
|
+
let processedDiscounts: any[] = [];
|
|
959
|
+
if (req.body.discounts && Array.isArray(req.body.discounts)) {
|
|
960
|
+
if (!isAdmin) {
|
|
961
|
+
return res.status(403).json({ error: 'Not allowed to apply discounts' });
|
|
962
|
+
}
|
|
963
|
+
try {
|
|
964
|
+
processedDiscounts = await processCheckoutSessionDiscounts(raw as any, req.body.discounts);
|
|
965
|
+
} catch (discountError) {
|
|
966
|
+
logger.error('Discount processing failed during checkout session creation', {
|
|
967
|
+
error: discountError.message,
|
|
968
|
+
discounts: req.body.discounts,
|
|
969
|
+
});
|
|
970
|
+
return res.status(400).json({ error: discountError.message });
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// Include processed discounts in the checkout session data
|
|
975
|
+
if (processedDiscounts.length > 0) {
|
|
976
|
+
raw.discounts = processedDiscounts;
|
|
977
|
+
}
|
|
978
|
+
|
|
814
979
|
const doc = await CheckoutSession.create(raw as any);
|
|
980
|
+
|
|
815
981
|
let url = getUrl(`/checkout/${doc.submit_type}/${doc.id}`);
|
|
816
982
|
if (createMine && currentUserDid) {
|
|
817
983
|
url = withQuery(url, getConnectQueryParam({ userDid: currentUserDid }));
|
|
@@ -1063,11 +1229,24 @@ router.get('/retrieve/:id', user, async (req, res) => {
|
|
|
1063
1229
|
// @ts-ignore
|
|
1064
1230
|
doc.line_items = await Price.expand(doc.line_items, { upsell: true });
|
|
1065
1231
|
|
|
1232
|
+
// Enhance line items with coupon information if discounts are applied
|
|
1233
|
+
if (doc.discounts?.length) {
|
|
1234
|
+
doc.line_items = await expandLineItemsWithCouponInfo(
|
|
1235
|
+
doc.line_items as TLineItemExpanded[],
|
|
1236
|
+
doc.discounts,
|
|
1237
|
+
doc.currency_id
|
|
1238
|
+
);
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
// Expand discounts with complete details
|
|
1242
|
+
const enhancedDiscounts = await expandDiscountsWithDetails(doc.discounts);
|
|
1243
|
+
|
|
1066
1244
|
// check payment intent
|
|
1067
1245
|
const paymentIntent = doc.payment_intent_id ? await PaymentIntent.findByPk(doc.payment_intent_id) : null;
|
|
1068
1246
|
res.json({
|
|
1069
1247
|
checkoutSession: {
|
|
1070
1248
|
...doc.toJSON(),
|
|
1249
|
+
discounts: enhancedDiscounts,
|
|
1071
1250
|
subscriptions,
|
|
1072
1251
|
},
|
|
1073
1252
|
paymentMethods: await getPaymentMethods(doc),
|
|
@@ -1116,32 +1295,6 @@ router.put('/:id/submit', user, ensureCheckoutSessionOpen, async (req, res) => {
|
|
|
1116
1295
|
);
|
|
1117
1296
|
await checkoutSession.update({ currency_id: paymentCurrency.id });
|
|
1118
1297
|
|
|
1119
|
-
// calculate amount and update checkout session
|
|
1120
|
-
const { lineItems, trialInDays, trialEnd, now } = await calculateAndUpdateAmount(
|
|
1121
|
-
checkoutSession,
|
|
1122
|
-
paymentCurrency.id,
|
|
1123
|
-
true
|
|
1124
|
-
);
|
|
1125
|
-
|
|
1126
|
-
// Validate payment amounts meet minimum requirements
|
|
1127
|
-
if (paymentMethod.type === 'stripe') {
|
|
1128
|
-
const result = validatePaymentAmounts(lineItems, paymentCurrency, checkoutSession);
|
|
1129
|
-
if (!result.valid) {
|
|
1130
|
-
return res.status(400).json({ error: result.error });
|
|
1131
|
-
}
|
|
1132
|
-
}
|
|
1133
|
-
|
|
1134
|
-
// Validate checkout session ownership if it was created for a specific customer
|
|
1135
|
-
if (checkoutSession.customer_did && checkoutSession.metadata?.createdBy) {
|
|
1136
|
-
const createdByDid = checkoutSession.metadata.createdBy;
|
|
1137
|
-
if (createdByDid !== req.user.did) {
|
|
1138
|
-
return res.status(403).json({
|
|
1139
|
-
error:
|
|
1140
|
-
"It's not allowed to submit checkout sessions created by other users, please create your own checkout session",
|
|
1141
|
-
});
|
|
1142
|
-
}
|
|
1143
|
-
}
|
|
1144
|
-
|
|
1145
1298
|
let customer = await Customer.findOne({ where: { did: req.user.did } });
|
|
1146
1299
|
if (!customer) {
|
|
1147
1300
|
const { user: userInfo } = await blocklet.getUser(req.user.did);
|
|
@@ -1248,6 +1401,39 @@ router.put('/:id/submit', user, ensureCheckoutSessionOpen, async (req, res) => {
|
|
|
1248
1401
|
|
|
1249
1402
|
await checkoutSession.update({ customer_id: customer.id, customer_did: req.user.did });
|
|
1250
1403
|
|
|
1404
|
+
// Verify and recalculate amount with current discounts
|
|
1405
|
+
const { lineItems, trialInDays, trialEnd, now } = await calculateAndUpdateAmount(
|
|
1406
|
+
checkoutSession,
|
|
1407
|
+
paymentCurrency.id,
|
|
1408
|
+
true
|
|
1409
|
+
);
|
|
1410
|
+
|
|
1411
|
+
// Validate payment amounts meet minimum requirements
|
|
1412
|
+
if (paymentMethod.type === 'stripe') {
|
|
1413
|
+
const result = await validatePaymentAmounts(lineItems, paymentCurrency, checkoutSession);
|
|
1414
|
+
if (!result.valid) {
|
|
1415
|
+
return res.status(400).json({ error: result.error });
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
// Validate checkout session ownership if it was created for a specific customer
|
|
1420
|
+
if (checkoutSession.customer_did && checkoutSession.metadata?.createdBy) {
|
|
1421
|
+
const createdByDid = checkoutSession.metadata.createdBy;
|
|
1422
|
+
if (createdByDid !== req.user.did) {
|
|
1423
|
+
return res.status(403).json({
|
|
1424
|
+
error:
|
|
1425
|
+
"It's not allowed to submit checkout sessions created by other users, please create your own checkout session",
|
|
1426
|
+
});
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
// Validate promotion codes are still valid during submission
|
|
1431
|
+
await validatePromotionCodesOnSubmit(checkoutSession, {
|
|
1432
|
+
lineItems,
|
|
1433
|
+
customerId: req.user.did,
|
|
1434
|
+
amount: checkoutSession.amount_subtotal,
|
|
1435
|
+
});
|
|
1436
|
+
|
|
1251
1437
|
// create or update payment intent
|
|
1252
1438
|
let paymentIntent: PaymentIntent | null = null;
|
|
1253
1439
|
if (checkoutSession.mode === 'payment') {
|
|
@@ -1260,6 +1446,30 @@ router.put('/:id/submit', user, ensureCheckoutSessionOpen, async (req, res) => {
|
|
|
1260
1446
|
customer.email
|
|
1261
1447
|
);
|
|
1262
1448
|
paymentIntent = result.paymentIntent;
|
|
1449
|
+
|
|
1450
|
+
// Create discount records for payment mode immediately after payment intent creation
|
|
1451
|
+
if (checkoutSession.discounts?.length) {
|
|
1452
|
+
try {
|
|
1453
|
+
const discountResult = await createDiscountRecordsForCheckout({
|
|
1454
|
+
checkoutSession,
|
|
1455
|
+
customerId: customer.id,
|
|
1456
|
+
subscriptionIds: [], // No subscriptions for payment mode
|
|
1457
|
+
});
|
|
1458
|
+
|
|
1459
|
+
logger.info('Created discount records for checkout session payment', {
|
|
1460
|
+
checkoutSessionId: checkoutSession.id,
|
|
1461
|
+
paymentIntentId: paymentIntent?.id,
|
|
1462
|
+
discountRecordCount: discountResult.discountRecords.length,
|
|
1463
|
+
newRecords: discountResult.subscriptionsUpdated.length,
|
|
1464
|
+
});
|
|
1465
|
+
} catch (discountError) {
|
|
1466
|
+
logger.error('Failed to create discount records for checkout session payment', {
|
|
1467
|
+
checkoutSessionId: checkoutSession.id,
|
|
1468
|
+
paymentIntentId: paymentIntent?.id,
|
|
1469
|
+
error: discountError.message,
|
|
1470
|
+
});
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1263
1473
|
}
|
|
1264
1474
|
|
|
1265
1475
|
// SetupIntent processing
|
|
@@ -1350,14 +1560,77 @@ router.put('/:id/submit', user, ensureCheckoutSessionOpen, async (req, res) => {
|
|
|
1350
1560
|
primarySubscription: subscription ? (subscription as any).id : null,
|
|
1351
1561
|
enableSubscriptionGrouping: checkoutSession.enable_subscription_grouping,
|
|
1352
1562
|
});
|
|
1563
|
+
|
|
1564
|
+
// Create discount records immediately after subscriptions are created
|
|
1565
|
+
if (checkoutSession.discounts?.length) {
|
|
1566
|
+
try {
|
|
1567
|
+
const discountResult = await createDiscountRecordsForCheckout({
|
|
1568
|
+
checkoutSession,
|
|
1569
|
+
customerId: customer.id,
|
|
1570
|
+
subscriptionIds: subscriptions.map((s) => s.id),
|
|
1571
|
+
});
|
|
1572
|
+
|
|
1573
|
+
// Update subscription discount_id references
|
|
1574
|
+
if (discountResult.subscriptionsUpdated.length > 0) {
|
|
1575
|
+
try {
|
|
1576
|
+
const subscriptionUpdateResult = await updateSubscriptionDiscountReferences({
|
|
1577
|
+
discountRecords: discountResult.discountRecords,
|
|
1578
|
+
subscriptionsUpdated: discountResult.subscriptionsUpdated,
|
|
1579
|
+
});
|
|
1580
|
+
|
|
1581
|
+
logger.debug('Updated subscription discount_id references', {
|
|
1582
|
+
updatedCount: subscriptionUpdateResult.updatedSubscriptions.length,
|
|
1583
|
+
updatedSubscriptions: subscriptionUpdateResult.updatedSubscriptions,
|
|
1584
|
+
});
|
|
1585
|
+
} catch (subscriptionUpdateError) {
|
|
1586
|
+
logger.error('Failed to update subscription discount_id references', {
|
|
1587
|
+
checkoutSessionId: checkoutSession.id,
|
|
1588
|
+
subscriptionsUpdated: discountResult.subscriptionsUpdated,
|
|
1589
|
+
error: subscriptionUpdateError.message,
|
|
1590
|
+
});
|
|
1591
|
+
throw subscriptionUpdateError;
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
logger.info('Created discount records for checkout session subscriptions', {
|
|
1596
|
+
checkoutSessionId: checkoutSession.id,
|
|
1597
|
+
discountRecordCount: discountResult.discountRecords.length,
|
|
1598
|
+
subscriptionIds: subscriptions.map((s) => s.id),
|
|
1599
|
+
subscriptionsUpdated: discountResult.subscriptionsUpdated,
|
|
1600
|
+
});
|
|
1601
|
+
} catch (discountError) {
|
|
1602
|
+
logger.error('Failed to create discount records for checkout session', {
|
|
1603
|
+
checkoutSessionId: checkoutSession.id,
|
|
1604
|
+
error: discountError.message,
|
|
1605
|
+
});
|
|
1606
|
+
return res
|
|
1607
|
+
.status(400)
|
|
1608
|
+
.json({ error: 'Failed to create discount records for checkout session, please try again later' });
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1353
1611
|
}
|
|
1354
1612
|
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1613
|
+
// Prepare discount configuration
|
|
1614
|
+
let discountConfig;
|
|
1615
|
+
if (checkoutSession.discounts && checkoutSession.discounts.length > 0) {
|
|
1616
|
+
const firstDiscount = checkoutSession.discounts[0];
|
|
1617
|
+
if (firstDiscount?.coupon && checkoutSession.customer_id) {
|
|
1618
|
+
discountConfig = {
|
|
1619
|
+
promotionCodeId: firstDiscount.promotion_code,
|
|
1620
|
+
couponId: firstDiscount.coupon,
|
|
1621
|
+
customerId: checkoutSession.customer_id,
|
|
1622
|
+
};
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
const fastCheckoutAmount = await getFastCheckoutAmount({
|
|
1627
|
+
items: lineItems,
|
|
1628
|
+
mode: checkoutSession.mode,
|
|
1629
|
+
currencyId: paymentCurrency.id,
|
|
1630
|
+
trialing: trialInDays > 0 || trialEnd > now,
|
|
1631
|
+
minimumCycle: 1,
|
|
1632
|
+
discountConfig,
|
|
1633
|
+
});
|
|
1361
1634
|
const paymentSettings = {
|
|
1362
1635
|
payment_method_types: checkoutSession.payment_method_types,
|
|
1363
1636
|
payment_method_options: {
|
|
@@ -1734,12 +2007,20 @@ router.post('/:id/fast-checkout-confirm', user, ensureCheckoutSessionOpen, async
|
|
|
1734
2007
|
paymentIntent = result.paymentIntent;
|
|
1735
2008
|
}
|
|
1736
2009
|
|
|
1737
|
-
const
|
|
1738
|
-
|
|
1739
|
-
checkoutSession.
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
2010
|
+
const discountConfig = {
|
|
2011
|
+
promotionCodeId: checkoutSession.discounts?.[0]?.promotion_code,
|
|
2012
|
+
couponId: checkoutSession.discounts?.[0]?.coupon,
|
|
2013
|
+
customerId: checkoutSession.customer_id,
|
|
2014
|
+
};
|
|
2015
|
+
|
|
2016
|
+
const fastCheckoutAmount = await getFastCheckoutAmount({
|
|
2017
|
+
items: lineItems,
|
|
2018
|
+
mode: checkoutSession.mode,
|
|
2019
|
+
currencyId: paymentCurrency.id,
|
|
2020
|
+
trialing: trialInDays > 0 || trialEnd > now,
|
|
2021
|
+
minimumCycle: 1,
|
|
2022
|
+
discountConfig,
|
|
2023
|
+
});
|
|
1743
2024
|
|
|
1744
2025
|
const paymentSettings = {
|
|
1745
2026
|
payment_method_types: checkoutSession.payment_method_types,
|
|
@@ -1800,10 +2081,11 @@ router.post('/:id/fast-checkout-confirm', user, ensureCheckoutSessionOpen, async
|
|
|
1800
2081
|
...paymentSettings,
|
|
1801
2082
|
});
|
|
1802
2083
|
}
|
|
1803
|
-
checkoutSession.update({
|
|
2084
|
+
await checkoutSession.update({
|
|
1804
2085
|
status: 'complete',
|
|
1805
2086
|
payment_status: 'paid',
|
|
1806
2087
|
});
|
|
2088
|
+
|
|
1807
2089
|
await pAll(
|
|
1808
2090
|
subscriptions.map((sub) => async () => {
|
|
1809
2091
|
await sub.update({
|
|
@@ -2133,6 +2415,415 @@ router.delete('/:id/cross-sell', user, ensureCheckoutSessionOpen, async (req, re
|
|
|
2133
2415
|
}
|
|
2134
2416
|
});
|
|
2135
2417
|
|
|
2418
|
+
// Apply promotion code discount (preview only - doesn't create actual discount record)
|
|
2419
|
+
router.post('/:id/apply-promotion', user, ensureCheckoutSessionOpen, async (req, res) => {
|
|
2420
|
+
try {
|
|
2421
|
+
const checkoutSession = req.doc as CheckoutSession;
|
|
2422
|
+
const { promotion_code: promotionCode, currency_id: currencyId } = req.body;
|
|
2423
|
+
|
|
2424
|
+
if (!promotionCode) {
|
|
2425
|
+
return res.status(400).json({ error: 'Promotion code is required' });
|
|
2426
|
+
}
|
|
2427
|
+
|
|
2428
|
+
if (!req.user) {
|
|
2429
|
+
return res.status(403).json({ error: 'Authentication required' });
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2432
|
+
// Find customer by DID
|
|
2433
|
+
const customer = await Customer.findOne({ where: { did: req.user.did } });
|
|
2434
|
+
if (!customer) {
|
|
2435
|
+
return res.status(400).json({ error: 'Customer not found' });
|
|
2436
|
+
}
|
|
2437
|
+
|
|
2438
|
+
// Get currency
|
|
2439
|
+
const curCurrencyId = currencyId || checkoutSession.currency_id;
|
|
2440
|
+
if (!curCurrencyId) {
|
|
2441
|
+
return res.status(400).json({ error: 'Currency not found in checkout session' });
|
|
2442
|
+
}
|
|
2443
|
+
|
|
2444
|
+
const currency = await PaymentCurrency.findByPk(curCurrencyId);
|
|
2445
|
+
if (!currency) {
|
|
2446
|
+
return res.status(400).json({ error: 'Currency not found' });
|
|
2447
|
+
}
|
|
2448
|
+
|
|
2449
|
+
const foundPromotionCode = (await PromotionCode.findOne({
|
|
2450
|
+
where: { code: promotionCode },
|
|
2451
|
+
include: [
|
|
2452
|
+
{
|
|
2453
|
+
model: Coupon,
|
|
2454
|
+
as: 'coupon',
|
|
2455
|
+
attributes: ['id', 'name', 'duration', 'duration_in_months'],
|
|
2456
|
+
},
|
|
2457
|
+
],
|
|
2458
|
+
})) as PromotionCode & { coupon: Coupon };
|
|
2459
|
+
|
|
2460
|
+
if (!foundPromotionCode || !foundPromotionCode.active) {
|
|
2461
|
+
return res.status(400).json({ error: 'Promotion code not found or inactive' });
|
|
2462
|
+
}
|
|
2463
|
+
|
|
2464
|
+
// Validate subscription grouping + repeating coupon combination
|
|
2465
|
+
if (checkoutSession.enable_subscription_grouping) {
|
|
2466
|
+
if (foundPromotionCode?.coupon?.duration === 'repeating') {
|
|
2467
|
+
return res.status(400).json({
|
|
2468
|
+
error:
|
|
2469
|
+
'Repeating coupons cannot be used with subscription grouping. This limitation ensures proper discount management across multiple subscriptions in a single purchase.',
|
|
2470
|
+
});
|
|
2471
|
+
}
|
|
2472
|
+
}
|
|
2473
|
+
|
|
2474
|
+
const checkoutItems = checkoutSession.line_items || [];
|
|
2475
|
+
// Get line items
|
|
2476
|
+
const expandedItems = await Price.expand(checkoutSession.line_items || [], { product: true, upsell: true });
|
|
2477
|
+
|
|
2478
|
+
// Calculate trial status for discount application (same logic as in calculateAndUpdateAmount)
|
|
2479
|
+
const now = dayjs().unix();
|
|
2480
|
+
const trialSetup = getSubscriptionTrialSetup(checkoutSession.subscription_data as any, currency.id);
|
|
2481
|
+
const isTrialing = trialSetup.trialInDays > 0 || trialSetup.trialEnd > now;
|
|
2482
|
+
|
|
2483
|
+
// Apply discount using our new function
|
|
2484
|
+
const discountResult = await applyDiscountsToLineItems({
|
|
2485
|
+
lineItems: expandedItems,
|
|
2486
|
+
promotionCodeId: foundPromotionCode.id,
|
|
2487
|
+
couponId: foundPromotionCode.coupon_id,
|
|
2488
|
+
customerId: customer.id,
|
|
2489
|
+
currency,
|
|
2490
|
+
billingContext: {
|
|
2491
|
+
trialing: isTrialing,
|
|
2492
|
+
},
|
|
2493
|
+
});
|
|
2494
|
+
|
|
2495
|
+
if (!discountResult.discountSummary.appliedCoupon) {
|
|
2496
|
+
return res
|
|
2497
|
+
.status(400)
|
|
2498
|
+
.json({ error: discountResult.notValidReason || 'Promotion code cannot be applied to this order' });
|
|
2499
|
+
}
|
|
2500
|
+
|
|
2501
|
+
// Create discount configuration for checkout session
|
|
2502
|
+
const coupon = await Coupon.findByPk(foundPromotionCode.coupon_id);
|
|
2503
|
+
const discountConfig = {
|
|
2504
|
+
promotion_code: foundPromotionCode.id,
|
|
2505
|
+
coupon: coupon?.id,
|
|
2506
|
+
discount_amount: discountResult.discountSummary.totalDiscountAmount,
|
|
2507
|
+
verification_method: 'code',
|
|
2508
|
+
verification_data: { code: promotionCode },
|
|
2509
|
+
};
|
|
2510
|
+
|
|
2511
|
+
// Update checkout session with discount preview (without detailed coupon info in line_items)
|
|
2512
|
+
await checkoutSession.update({
|
|
2513
|
+
discounts: [discountConfig],
|
|
2514
|
+
currency_id: curCurrencyId,
|
|
2515
|
+
line_items: discountResult.enhancedLineItems.map((item: TLineItemExpanded) => {
|
|
2516
|
+
const cur = checkoutItems.find((x) => x.price_id === item.price_id) as LineItem;
|
|
2517
|
+
return {
|
|
2518
|
+
...cur,
|
|
2519
|
+
discountable: item.discountable,
|
|
2520
|
+
discount_amounts: item.discount_amounts,
|
|
2521
|
+
};
|
|
2522
|
+
}),
|
|
2523
|
+
amount_subtotal: checkoutSession.amount_total, // Original amount
|
|
2524
|
+
amount_total: discountResult.discountSummary.finalTotal, // Discounted amount
|
|
2525
|
+
total_details: {
|
|
2526
|
+
...checkoutSession.total_details,
|
|
2527
|
+
amount_discount: discountResult.discountSummary.totalDiscountAmount,
|
|
2528
|
+
},
|
|
2529
|
+
metadata: {
|
|
2530
|
+
...checkoutSession.metadata,
|
|
2531
|
+
promotion_code: promotionCode,
|
|
2532
|
+
},
|
|
2533
|
+
});
|
|
2534
|
+
|
|
2535
|
+
// Create enhanced line items with complete coupon information for response
|
|
2536
|
+
const enhancedLineItemsWithCoupon = await expandLineItemsWithCouponInfo(
|
|
2537
|
+
discountResult.enhancedLineItems,
|
|
2538
|
+
[discountConfig],
|
|
2539
|
+
currency.id
|
|
2540
|
+
);
|
|
2541
|
+
|
|
2542
|
+
logger.info('Promotion code applied to checkout session', {
|
|
2543
|
+
sessionId: checkoutSession.id,
|
|
2544
|
+
promotionCode,
|
|
2545
|
+
originalAmount: checkoutSession.amount_subtotal,
|
|
2546
|
+
discountAmount: discountResult.discountSummary.totalDiscountAmount,
|
|
2547
|
+
finalAmount: discountResult.discountSummary.finalTotal,
|
|
2548
|
+
});
|
|
2549
|
+
|
|
2550
|
+
// Expand discounts with complete details for response
|
|
2551
|
+
const enhancedDiscounts = await expandDiscountsWithDetails([discountConfig]);
|
|
2552
|
+
|
|
2553
|
+
res.json({
|
|
2554
|
+
...checkoutSession.toJSON(),
|
|
2555
|
+
line_items: enhancedLineItemsWithCoupon,
|
|
2556
|
+
discounts: enhancedDiscounts,
|
|
2557
|
+
discount_applied: true,
|
|
2558
|
+
});
|
|
2559
|
+
} catch (err) {
|
|
2560
|
+
logger.error('Error applying promotion code', {
|
|
2561
|
+
sessionId: req.params.id,
|
|
2562
|
+
error: err.message,
|
|
2563
|
+
stack: err.stack,
|
|
2564
|
+
});
|
|
2565
|
+
res.status(400).json({ error: err.message });
|
|
2566
|
+
}
|
|
2567
|
+
});
|
|
2568
|
+
|
|
2569
|
+
// Recalculate promotion code discount when currency changes
|
|
2570
|
+
router.post('/:id/recalculate-promotion', user, ensureCheckoutSessionOpen, async (req, res) => {
|
|
2571
|
+
try {
|
|
2572
|
+
const checkoutSession = req.doc as CheckoutSession;
|
|
2573
|
+
const { currency_id: newCurrencyId } = req.body;
|
|
2574
|
+
|
|
2575
|
+
if (!newCurrencyId) {
|
|
2576
|
+
return res.status(400).json({ error: 'Currency ID is required' });
|
|
2577
|
+
}
|
|
2578
|
+
|
|
2579
|
+
if (!req.user) {
|
|
2580
|
+
return res.status(403).json({ error: 'Authentication required' });
|
|
2581
|
+
}
|
|
2582
|
+
|
|
2583
|
+
// Check if there are existing discounts to recalculate
|
|
2584
|
+
if (!checkoutSession.discounts?.length) {
|
|
2585
|
+
return res.json({
|
|
2586
|
+
...checkoutSession.toJSON(),
|
|
2587
|
+
discounts: [],
|
|
2588
|
+
discount_applied: false,
|
|
2589
|
+
message: 'No promotion codes to recalculate',
|
|
2590
|
+
});
|
|
2591
|
+
}
|
|
2592
|
+
|
|
2593
|
+
// Find customer by DID
|
|
2594
|
+
const customer = await Customer.findOne({ where: { did: req.user.did } });
|
|
2595
|
+
if (!customer) {
|
|
2596
|
+
return res.status(400).json({ error: 'Customer not found' });
|
|
2597
|
+
}
|
|
2598
|
+
|
|
2599
|
+
// Get new currency
|
|
2600
|
+
const currency = await PaymentCurrency.findByPk(newCurrencyId);
|
|
2601
|
+
if (!currency) {
|
|
2602
|
+
return res.status(400).json({ error: 'Currency not found' });
|
|
2603
|
+
}
|
|
2604
|
+
|
|
2605
|
+
const checkoutItems = checkoutSession.line_items || [];
|
|
2606
|
+
// Get line items
|
|
2607
|
+
const expandedItems = await Price.expand(checkoutSession.line_items || [], { product: true, upsell: true });
|
|
2608
|
+
|
|
2609
|
+
// Get the first discount (assuming only one promotion code at a time)
|
|
2610
|
+
const existingDiscount = checkoutSession.discounts[0];
|
|
2611
|
+
if (!existingDiscount) {
|
|
2612
|
+
return res.status(400).json({ error: 'No discount found' });
|
|
2613
|
+
}
|
|
2614
|
+
|
|
2615
|
+
const promotionCodeId = existingDiscount.promotion_code;
|
|
2616
|
+
const couponId = existingDiscount.coupon;
|
|
2617
|
+
|
|
2618
|
+
if (!promotionCodeId || !couponId) {
|
|
2619
|
+
return res.status(400).json({ error: 'Invalid discount configuration' });
|
|
2620
|
+
}
|
|
2621
|
+
|
|
2622
|
+
// Validate promotion code and coupon still exist and are active
|
|
2623
|
+
const [foundPromotionCode, coupon] = await Promise.all([
|
|
2624
|
+
PromotionCode.findByPk(promotionCodeId),
|
|
2625
|
+
Coupon.findByPk(couponId),
|
|
2626
|
+
]);
|
|
2627
|
+
|
|
2628
|
+
if (!foundPromotionCode || !foundPromotionCode.active) {
|
|
2629
|
+
return res.status(400).json({ error: 'Promotion code no longer active' });
|
|
2630
|
+
}
|
|
2631
|
+
|
|
2632
|
+
if (!coupon || !coupon.valid) {
|
|
2633
|
+
return res.status(400).json({ error: 'Coupon no longer valid' });
|
|
2634
|
+
}
|
|
2635
|
+
|
|
2636
|
+
// Apply discount with new currency
|
|
2637
|
+
const discountResult = await applyDiscountsToLineItems({
|
|
2638
|
+
lineItems: expandedItems,
|
|
2639
|
+
promotionCodeId,
|
|
2640
|
+
couponId,
|
|
2641
|
+
customerId: customer.id,
|
|
2642
|
+
currency,
|
|
2643
|
+
});
|
|
2644
|
+
|
|
2645
|
+
// Check if discount can still be applied with the new currency
|
|
2646
|
+
if (!discountResult.discountSummary.appliedCoupon) {
|
|
2647
|
+
// Calculate original total without discount (finalTotal when no discount is applied)
|
|
2648
|
+
const originalTotal = discountResult.discountSummary.finalTotal;
|
|
2649
|
+
|
|
2650
|
+
// Remove discount if it can't be applied with new currency
|
|
2651
|
+
await checkoutSession.update({
|
|
2652
|
+
discounts: [],
|
|
2653
|
+
line_items: expandedItems.map((item) => {
|
|
2654
|
+
const cur = checkoutItems.find((x) => x.price_id === item.price_id) as LineItem;
|
|
2655
|
+
return {
|
|
2656
|
+
...cur,
|
|
2657
|
+
};
|
|
2658
|
+
}),
|
|
2659
|
+
currency_id: newCurrencyId,
|
|
2660
|
+
amount_subtotal: originalTotal,
|
|
2661
|
+
amount_total: originalTotal,
|
|
2662
|
+
total_details: {
|
|
2663
|
+
...checkoutSession.total_details,
|
|
2664
|
+
amount_discount: '0',
|
|
2665
|
+
},
|
|
2666
|
+
metadata: {
|
|
2667
|
+
...omit(checkoutSession.metadata, ['promotion_code']),
|
|
2668
|
+
},
|
|
2669
|
+
});
|
|
2670
|
+
|
|
2671
|
+
logger.info('Promotion code removed due to currency incompatibility', {
|
|
2672
|
+
sessionId: checkoutSession.id,
|
|
2673
|
+
oldCurrencyId: checkoutSession.currency_id,
|
|
2674
|
+
newCurrencyId,
|
|
2675
|
+
promotionCode: foundPromotionCode.code,
|
|
2676
|
+
});
|
|
2677
|
+
|
|
2678
|
+
return res.json({
|
|
2679
|
+
...checkoutSession.toJSON(),
|
|
2680
|
+
line_items: expandedItems,
|
|
2681
|
+
discounts: [],
|
|
2682
|
+
discount_applied: false,
|
|
2683
|
+
discount_removed_reason: 'currency_incompatible',
|
|
2684
|
+
});
|
|
2685
|
+
}
|
|
2686
|
+
|
|
2687
|
+
// Create updated discount configuration
|
|
2688
|
+
const discountConfig = {
|
|
2689
|
+
promotion_code: promotionCodeId,
|
|
2690
|
+
coupon: couponId,
|
|
2691
|
+
discount_amount: discountResult.discountSummary.totalDiscountAmount,
|
|
2692
|
+
verification_method: existingDiscount.verification_method,
|
|
2693
|
+
verification_data: existingDiscount.verification_data,
|
|
2694
|
+
};
|
|
2695
|
+
|
|
2696
|
+
// Update checkout session with recalculated discount
|
|
2697
|
+
await checkoutSession.update({
|
|
2698
|
+
discounts: [discountConfig],
|
|
2699
|
+
line_items: discountResult.enhancedLineItems.map((item: TLineItemExpanded) => {
|
|
2700
|
+
const cur = checkoutItems.find((x) => x.price_id === item.price_id) as LineItem;
|
|
2701
|
+
return {
|
|
2702
|
+
...cur,
|
|
2703
|
+
discountable: item.discountable,
|
|
2704
|
+
discount_amounts: item.discount_amounts,
|
|
2705
|
+
};
|
|
2706
|
+
}),
|
|
2707
|
+
currency_id: newCurrencyId,
|
|
2708
|
+
amount_subtotal: checkoutSession.amount_total, // Original amount
|
|
2709
|
+
amount_total: discountResult.discountSummary.finalTotal, // Discounted amount
|
|
2710
|
+
total_details: {
|
|
2711
|
+
...checkoutSession.total_details,
|
|
2712
|
+
amount_discount: discountResult.discountSummary.totalDiscountAmount,
|
|
2713
|
+
},
|
|
2714
|
+
});
|
|
2715
|
+
|
|
2716
|
+
// Create enhanced line items with complete coupon information for response
|
|
2717
|
+
const enhancedLineItemsWithCoupon = await expandLineItemsWithCouponInfo(
|
|
2718
|
+
discountResult.enhancedLineItems,
|
|
2719
|
+
[discountConfig],
|
|
2720
|
+
currency.id
|
|
2721
|
+
);
|
|
2722
|
+
|
|
2723
|
+
logger.info('Promotion code recalculated for new currency', {
|
|
2724
|
+
sessionId: checkoutSession.id,
|
|
2725
|
+
oldCurrencyId: checkoutSession.currency_id,
|
|
2726
|
+
newCurrencyId,
|
|
2727
|
+
promotionCode: foundPromotionCode.code,
|
|
2728
|
+
oldDiscountAmount: existingDiscount?.discount_amount || '0',
|
|
2729
|
+
newDiscountAmount: discountResult.discountSummary.totalDiscountAmount,
|
|
2730
|
+
});
|
|
2731
|
+
|
|
2732
|
+
// Expand discounts with complete details for response
|
|
2733
|
+
const enhancedDiscounts = await expandDiscountsWithDetails([discountConfig]);
|
|
2734
|
+
|
|
2735
|
+
res.json({
|
|
2736
|
+
...checkoutSession.toJSON(),
|
|
2737
|
+
line_items: enhancedLineItemsWithCoupon,
|
|
2738
|
+
discounts: enhancedDiscounts,
|
|
2739
|
+
discount_applied: true,
|
|
2740
|
+
discount_recalculated: true,
|
|
2741
|
+
});
|
|
2742
|
+
} catch (err) {
|
|
2743
|
+
logger.error('Error recalculating promotion code', {
|
|
2744
|
+
sessionId: req.params.id,
|
|
2745
|
+
error: err.message,
|
|
2746
|
+
stack: err.stack,
|
|
2747
|
+
});
|
|
2748
|
+
res.status(400).json({ error: err.message });
|
|
2749
|
+
}
|
|
2750
|
+
});
|
|
2751
|
+
|
|
2752
|
+
// Remove promotion code discount (remove preview)
|
|
2753
|
+
router.delete('/:id/remove-promotion', user, ensureCheckoutSessionOpen, async (req, res) => {
|
|
2754
|
+
try {
|
|
2755
|
+
const checkoutSession = req.doc as CheckoutSession;
|
|
2756
|
+
|
|
2757
|
+
if (checkoutSession.status !== 'open') {
|
|
2758
|
+
return res.status(400).json({ error: 'Checkout session is not open' });
|
|
2759
|
+
}
|
|
2760
|
+
|
|
2761
|
+
// Get currency
|
|
2762
|
+
const currency = await PaymentCurrency.findByPk(checkoutSession.currency_id);
|
|
2763
|
+
if (!currency) {
|
|
2764
|
+
return res.status(400).json({ error: 'Currency not found' });
|
|
2765
|
+
}
|
|
2766
|
+
|
|
2767
|
+
// Get original line items without discount information
|
|
2768
|
+
const originalItems = await Price.expand(checkoutSession.line_items || [], { product: true, upsell: true });
|
|
2769
|
+
|
|
2770
|
+
// Calculate trial status for accurate original amount calculation
|
|
2771
|
+
const now = dayjs().unix();
|
|
2772
|
+
const trialSetup = getSubscriptionTrialSetup(checkoutSession.subscription_data as any, currency.id);
|
|
2773
|
+
const isTrialing = trialSetup.trialInDays > 0 || trialSetup.trialEnd > now;
|
|
2774
|
+
|
|
2775
|
+
// Calculate original amounts without any discounts
|
|
2776
|
+
const originalResult = await applyDiscountsToLineItems({
|
|
2777
|
+
lineItems: originalItems,
|
|
2778
|
+
couponId: 'dummy', // Won't be found, so no discount applied
|
|
2779
|
+
customerId: 'dummy',
|
|
2780
|
+
currency,
|
|
2781
|
+
billingContext: {
|
|
2782
|
+
trialing: isTrialing,
|
|
2783
|
+
},
|
|
2784
|
+
});
|
|
2785
|
+
|
|
2786
|
+
// Update checkout session to remove discount
|
|
2787
|
+
await checkoutSession.update({
|
|
2788
|
+
discounts: [],
|
|
2789
|
+
line_items: originalResult.enhancedLineItems.map((item: TLineItemExpanded) => ({
|
|
2790
|
+
price_id: item.price_id,
|
|
2791
|
+
quantity: item.quantity,
|
|
2792
|
+
custom_amount: item.custom_amount,
|
|
2793
|
+
upsell_price_id: item.upsell_price_id,
|
|
2794
|
+
cross_sell: item.cross_sell,
|
|
2795
|
+
adjustable_quantity: item.adjustable_quantity,
|
|
2796
|
+
})),
|
|
2797
|
+
amount_subtotal: originalResult.discountSummary.finalTotal,
|
|
2798
|
+
amount_total: originalResult.discountSummary.finalTotal,
|
|
2799
|
+
total_details: {
|
|
2800
|
+
...checkoutSession.total_details,
|
|
2801
|
+
amount_discount: '0',
|
|
2802
|
+
},
|
|
2803
|
+
metadata: {
|
|
2804
|
+
...omit(checkoutSession.metadata, ['promotion_code']),
|
|
2805
|
+
},
|
|
2806
|
+
});
|
|
2807
|
+
|
|
2808
|
+
logger.info('Promotion code removed from checkout session', {
|
|
2809
|
+
sessionId: checkoutSession.id,
|
|
2810
|
+
});
|
|
2811
|
+
|
|
2812
|
+
res.json({
|
|
2813
|
+
...checkoutSession.toJSON(),
|
|
2814
|
+
line_items: originalResult.enhancedLineItems,
|
|
2815
|
+
discount_applied: false,
|
|
2816
|
+
});
|
|
2817
|
+
} catch (err) {
|
|
2818
|
+
logger.error('Error removing promotion code', {
|
|
2819
|
+
sessionId: req.params.id,
|
|
2820
|
+
error: err.message,
|
|
2821
|
+
stack: err.stack,
|
|
2822
|
+
});
|
|
2823
|
+
res.status(500).json({ error: err.message });
|
|
2824
|
+
}
|
|
2825
|
+
});
|
|
2826
|
+
|
|
2136
2827
|
// change payment amount
|
|
2137
2828
|
router.put('/:id/amount', ensureCheckoutSessionOpen, async (req, res) => {
|
|
2138
2829
|
try {
|