payment-kit 1.26.2 → 1.26.3
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/libs/discount/coupon.ts +69 -9
- package/api/src/libs/discount/discount.ts +18 -0
- package/api/src/libs/env.ts +5 -0
- package/api/src/libs/invoice.ts +11 -13
- package/api/src/libs/notification/template/exchange-rate-alert.ts +1 -1
- package/api/src/queues/exchange-rate-health.ts +2 -1
- package/api/src/queues/payment.ts +2 -6
- package/api/src/routes/checkout-sessions.ts +124 -14
- package/api/tests/libs/coupon.spec.ts +57 -0
- package/blocklet.yml +1 -1
- package/package.json +6 -6
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { BN, fromUnitToToken } from '@ocap/util';
|
|
2
|
+
import { Op } from 'sequelize';
|
|
2
3
|
|
|
3
4
|
import pick from 'lodash/pick';
|
|
4
5
|
import {
|
|
@@ -43,10 +44,12 @@ export async function validPromotionCode(
|
|
|
43
44
|
customerId,
|
|
44
45
|
amount,
|
|
45
46
|
currencyId,
|
|
47
|
+
checkoutSessionId,
|
|
46
48
|
}: {
|
|
47
49
|
customerId?: string;
|
|
48
50
|
amount?: string;
|
|
49
51
|
currencyId?: string;
|
|
52
|
+
checkoutSessionId?: string;
|
|
50
53
|
}
|
|
51
54
|
) {
|
|
52
55
|
if (!promotionCode.active) {
|
|
@@ -60,15 +63,21 @@ export async function validPromotionCode(
|
|
|
60
63
|
}
|
|
61
64
|
if (customerId) {
|
|
62
65
|
const customer = await Customer.findByPkOrDid(customerId);
|
|
63
|
-
|
|
64
|
-
|
|
66
|
+
// For user_restricted promo codes, check DID against allowed list
|
|
67
|
+
// customer?.did works when Customer record exists; customerId itself may be a DID for new users
|
|
68
|
+
const customerDid = customer?.did || customerId;
|
|
69
|
+
if (promotionCode.verification_type === 'user_restricted' && customerDid) {
|
|
70
|
+
if (!promotionCode.customer_dids?.includes(customerDid)) {
|
|
65
71
|
return { valid: false, reason: 'This promotion code is not available for your account' };
|
|
66
72
|
}
|
|
67
73
|
}
|
|
68
74
|
if (promotionCode.restrictions?.first_time_transaction) {
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
75
|
+
const whereClause: any = { customer_id: customerId, coupon_id: promotionCode.coupon_id };
|
|
76
|
+
// Exclude discount records from the current checkout session (re-submit scenario)
|
|
77
|
+
if (checkoutSessionId) {
|
|
78
|
+
whereClause.checkout_session_id = { [Op.ne]: checkoutSessionId };
|
|
79
|
+
}
|
|
80
|
+
const previousDiscounts = await Discount.count({ where: whereClause });
|
|
72
81
|
if (previousDiscounts > 0) {
|
|
73
82
|
return { valid: false, reason: 'This promotion is only available for first-time purchases' };
|
|
74
83
|
}
|
|
@@ -774,6 +783,7 @@ export async function checkPromotionCodeEligibility({
|
|
|
774
783
|
amount,
|
|
775
784
|
currencyId,
|
|
776
785
|
lineItems = [],
|
|
786
|
+
checkoutSessionId,
|
|
777
787
|
}: {
|
|
778
788
|
promotionCode: PromotionCode;
|
|
779
789
|
couponId: string;
|
|
@@ -781,9 +791,27 @@ export async function checkPromotionCodeEligibility({
|
|
|
781
791
|
amount: string;
|
|
782
792
|
currencyId: string;
|
|
783
793
|
lineItems?: TLineItemExpanded[];
|
|
794
|
+
checkoutSessionId?: string;
|
|
784
795
|
}): Promise<{ eligible: boolean; reason?: string }> {
|
|
796
|
+
// When re-submitting (e.g. user closed Stripe modal and retries), the current checkout session
|
|
797
|
+
// may already have confirmed Discount records that incremented times_redeemed.
|
|
798
|
+
// We need to exclude the current session's usage from max_redemptions checks to avoid
|
|
799
|
+
// falsely rejecting the promo code that this session itself already consumed.
|
|
800
|
+
let currentSessionUsage = 0;
|
|
801
|
+
if (checkoutSessionId) {
|
|
802
|
+
currentSessionUsage = await Discount.count({
|
|
803
|
+
where: {
|
|
804
|
+
checkout_session_id: checkoutSessionId,
|
|
805
|
+
promotion_code_id: promotionCode.id,
|
|
806
|
+
confirmed: true,
|
|
807
|
+
},
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
|
|
785
811
|
// Basic promotion code checks
|
|
786
|
-
|
|
812
|
+
// When re-submitting, the discount-status queue may have already deactivated the promo code
|
|
813
|
+
// because this session's own usage pushed it past max_redemptions. Skip active check in that case.
|
|
814
|
+
if (!promotionCode.active && currentSessionUsage === 0) {
|
|
787
815
|
return { eligible: false, reason: 'This promotion code is no longer available' };
|
|
788
816
|
}
|
|
789
817
|
|
|
@@ -791,8 +819,11 @@ export async function checkPromotionCodeEligibility({
|
|
|
791
819
|
return { eligible: false, reason: 'This promotion code has expired and cannot be used' };
|
|
792
820
|
}
|
|
793
821
|
|
|
794
|
-
if (promotionCode.max_redemptions
|
|
795
|
-
|
|
822
|
+
if (promotionCode.max_redemptions) {
|
|
823
|
+
const effectiveRedeemed = (promotionCode.times_redeemed ?? 0) - currentSessionUsage;
|
|
824
|
+
if (effectiveRedeemed >= promotionCode.max_redemptions) {
|
|
825
|
+
return { eligible: false, reason: 'This promotion code has been fully redeemed and is no longer available' };
|
|
826
|
+
}
|
|
796
827
|
}
|
|
797
828
|
|
|
798
829
|
// Get associated coupon and validate
|
|
@@ -801,17 +832,46 @@ export async function checkPromotionCodeEligibility({
|
|
|
801
832
|
return { eligible: false, reason: 'This promotion is no longer available' };
|
|
802
833
|
}
|
|
803
834
|
|
|
804
|
-
// Coupon validity check
|
|
835
|
+
// Coupon validity check — adjust temporarily to exclude current session's usage
|
|
836
|
+
const originalCouponValid = coupon.valid;
|
|
837
|
+
if (currentSessionUsage > 0) {
|
|
838
|
+
if (!coupon.valid) coupon.valid = true;
|
|
839
|
+
if (coupon.times_redeemed && coupon.times_redeemed > 0) {
|
|
840
|
+
coupon.times_redeemed -= currentSessionUsage;
|
|
841
|
+
}
|
|
842
|
+
}
|
|
805
843
|
const couponValidation = validCoupon(coupon, lineItems);
|
|
844
|
+
if (currentSessionUsage > 0) {
|
|
845
|
+
coupon.valid = originalCouponValid;
|
|
846
|
+
if (coupon.times_redeemed != null) {
|
|
847
|
+
coupon.times_redeemed += currentSessionUsage;
|
|
848
|
+
}
|
|
849
|
+
}
|
|
806
850
|
if (!couponValidation.valid) {
|
|
807
851
|
return { eligible: false, reason: couponValidation.reason };
|
|
808
852
|
}
|
|
809
853
|
|
|
854
|
+
// Adjust promotionCode temporarily for validPromotionCode's checks
|
|
855
|
+
// Re-submit scenario: restore active + adjust times_redeemed to exclude current session's usage
|
|
856
|
+
const originalActive = promotionCode.active;
|
|
857
|
+
if (currentSessionUsage > 0) {
|
|
858
|
+
if (!promotionCode.active) promotionCode.active = true;
|
|
859
|
+
if (promotionCode.times_redeemed && promotionCode.times_redeemed > 0) {
|
|
860
|
+
promotionCode.times_redeemed -= currentSessionUsage;
|
|
861
|
+
}
|
|
862
|
+
}
|
|
810
863
|
const promotionValidation = await validPromotionCode(promotionCode, {
|
|
811
864
|
customerId,
|
|
812
865
|
amount,
|
|
813
866
|
currencyId,
|
|
867
|
+
checkoutSessionId,
|
|
814
868
|
});
|
|
869
|
+
if (currentSessionUsage > 0) {
|
|
870
|
+
promotionCode.active = originalActive;
|
|
871
|
+
if (promotionCode.times_redeemed != null) {
|
|
872
|
+
promotionCode.times_redeemed += currentSessionUsage;
|
|
873
|
+
}
|
|
874
|
+
}
|
|
815
875
|
if (!promotionValidation.valid) {
|
|
816
876
|
return { eligible: false, reason: promotionValidation.reason };
|
|
817
877
|
}
|
|
@@ -239,6 +239,24 @@ function calculateDiscountForLineItems({
|
|
|
239
239
|
// 4. Calculate discount amount based ONLY on eligible products, not the entire cart
|
|
240
240
|
const totalDiscountAmount = calculateDiscountAmount(coupon, totalEligibleAmount, currency);
|
|
241
241
|
|
|
242
|
+
// Fixed amount coupon with no matching currency — discount is 0, treat as not applicable
|
|
243
|
+
if (coupon.amount_off && (!coupon.percent_off || coupon.percent_off <= 0) && totalDiscountAmount === '0') {
|
|
244
|
+
const enhancedLineItems = lineItems.map((item) => ({
|
|
245
|
+
...item,
|
|
246
|
+
discountable: false,
|
|
247
|
+
discount_amounts: [],
|
|
248
|
+
}));
|
|
249
|
+
return {
|
|
250
|
+
enhancedLineItems,
|
|
251
|
+
discountSummary: {
|
|
252
|
+
appliedCoupon: null,
|
|
253
|
+
discountAmount: '0',
|
|
254
|
+
totalDiscountAmount: '0',
|
|
255
|
+
finalTotal: baseTotal,
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
242
260
|
// Ensure discount doesn't exceed base total
|
|
243
261
|
const adjustedDiscountAmount = new BN(totalDiscountAmount).gt(new BN(baseTotal)) ? baseTotal : totalDiscountAmount;
|
|
244
262
|
|
package/api/src/libs/env.ts
CHANGED
|
@@ -47,6 +47,11 @@ export const updateDataConcurrency: number = process.env.UPDATE_DATA_CONCURRENCY
|
|
|
47
47
|
? +process.env.UPDATE_DATA_CONCURRENCY
|
|
48
48
|
: 5; // 默认并发数为 5
|
|
49
49
|
|
|
50
|
+
// When set to 'true' or '1', the system stops accepting new orders.
|
|
51
|
+
// Existing checkout sessions can still be viewed but new submissions will be rejected.
|
|
52
|
+
export const stopAcceptingOrders: boolean =
|
|
53
|
+
process.env.PAYMENT_KIT_STOP_ACCEPTING_ORDERS === 'true' || process.env.PAYMENT_KIT_STOP_ACCEPTING_ORDERS === '1';
|
|
54
|
+
|
|
50
55
|
export const exchangeRateCacheTTLSeconds: number = process.env.EXCHANGE_RATE_CACHE_TTL_SECONDS
|
|
51
56
|
? +process.env.EXCHANGE_RATE_CACHE_TTL_SECONDS
|
|
52
57
|
: 10 * 60;
|
package/api/src/libs/invoice.ts
CHANGED
|
@@ -40,7 +40,7 @@ import {
|
|
|
40
40
|
} from './subscription';
|
|
41
41
|
import logger from './logger';
|
|
42
42
|
import { ensureOverdraftProtectionPrice } from './overdraft-protection';
|
|
43
|
-
|
|
43
|
+
|
|
44
44
|
import { emitAsync } from './event';
|
|
45
45
|
import { getPriceUintAmountByCurrency } from './price';
|
|
46
46
|
import { generateQuotesForInvoiceItems } from './invoice-quote';
|
|
@@ -156,18 +156,16 @@ export async function getInvoiceShouldPayTotal(invoice: Invoice) {
|
|
|
156
156
|
const setup = getSubscriptionCycleSetup(subscription.pending_invoice_item_interval, invoice.period_start);
|
|
157
157
|
let offset = 0;
|
|
158
158
|
let filterFunc = (item: any) => item;
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
filterFunc = () => true;
|
|
170
|
-
}
|
|
159
|
+
switch (invoice.billing_reason) {
|
|
160
|
+
case 'subscription_cancel':
|
|
161
|
+
filterFunc = (item: any) => item.price?.recurring?.usage_type === 'metered';
|
|
162
|
+
break;
|
|
163
|
+
case 'subscription_cycle':
|
|
164
|
+
filterFunc = (item: any) => item.price?.type === 'recurring';
|
|
165
|
+
offset = setup.cycle / 1000;
|
|
166
|
+
break;
|
|
167
|
+
default:
|
|
168
|
+
filterFunc = () => true;
|
|
171
169
|
}
|
|
172
170
|
const previousPeriodStart = setup.period.start - offset;
|
|
173
171
|
const previousPeriodEnd = setup.period.end - offset;
|
|
@@ -59,7 +59,7 @@ export class ExchangeRateAlertEmailTemplate implements BaseEmailTemplate<Exchang
|
|
|
59
59
|
const locale = await getUserLocale(userDid);
|
|
60
60
|
|
|
61
61
|
const viewProvidersLink = getUrl(
|
|
62
|
-
withQuery('admin/
|
|
62
|
+
withQuery('admin/products/exchange-rate-providers', {
|
|
63
63
|
locale,
|
|
64
64
|
...getConnectQueryParam({ userDid }),
|
|
65
65
|
})
|
|
@@ -5,7 +5,8 @@ import { getExchangeRateService } from '../libs/exchange-rate';
|
|
|
5
5
|
import { createEvent } from '../libs/audit';
|
|
6
6
|
|
|
7
7
|
// Representative tokens to test for health checks
|
|
8
|
-
|
|
8
|
+
// Note: USDC/USD are stablecoins pegged to USD, they don't need exchange rate provider checks
|
|
9
|
+
const REPRESENTATIVE_TOKENS = ['ABT', 'ETH'];
|
|
9
10
|
|
|
10
11
|
interface HealthCheckJob {
|
|
11
12
|
type: 'health_check';
|
|
@@ -130,12 +130,8 @@ export async function updateSubscriptionOnPaymentSuccess(
|
|
|
130
130
|
return;
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
-
// Handle past_due subscription recovery
|
|
134
|
-
if (subscription.status === 'past_due'
|
|
135
|
-
await handlePastDueSubscriptionRecovery(subscription, paymentIntent);
|
|
136
|
-
return;
|
|
137
|
-
}
|
|
138
|
-
if (subscription.status === 'past_due' && subscription.cancelation_details?.reason === 'insufficient_credit') {
|
|
133
|
+
// Handle past_due subscription recovery (payment_failed, insufficient_credit, slippage_below_threshold, etc.)
|
|
134
|
+
if (subscription.status === 'past_due') {
|
|
139
135
|
await handlePastDueSubscriptionRecovery(subscription, paymentIntent);
|
|
140
136
|
return;
|
|
141
137
|
}
|
|
@@ -79,6 +79,7 @@ import {
|
|
|
79
79
|
Invoice,
|
|
80
80
|
Coupon,
|
|
81
81
|
PromotionCode,
|
|
82
|
+
Discount,
|
|
82
83
|
} from '../store/models';
|
|
83
84
|
import type { ChainType } from '../store/models/types';
|
|
84
85
|
import { CheckoutSession } from '../store/models/checkout-session';
|
|
@@ -109,7 +110,7 @@ import { handleStripeSubscriptionSucceed } from '../integrations/stripe/handlers
|
|
|
109
110
|
import { CHARGE_SUPPORTED_CHAIN_TYPES } from '../libs/constants';
|
|
110
111
|
import { blocklet } from '../libs/auth';
|
|
111
112
|
import { addSubscriptionJob } from '../queues/subscription';
|
|
112
|
-
import { updateDataConcurrency } from '../libs/env';
|
|
113
|
+
import { updateDataConcurrency, stopAcceptingOrders } from '../libs/env';
|
|
113
114
|
import {
|
|
114
115
|
expandLineItemsWithCouponInfo,
|
|
115
116
|
expandDiscountsWithDetails,
|
|
@@ -313,6 +314,8 @@ async function validatePromotionCodesOnSubmit(
|
|
|
313
314
|
}
|
|
314
315
|
|
|
315
316
|
// Use unified eligibility check - this includes all validations
|
|
317
|
+
// Pass checkoutSessionId so re-submit after Stripe modal cancel won't falsely reject
|
|
318
|
+
// promo codes whose max_redemptions were already consumed by this same session
|
|
316
319
|
const eligibilityCheck = await checkPromotionCodeEligibility({
|
|
317
320
|
promotionCode,
|
|
318
321
|
couponId,
|
|
@@ -320,6 +323,7 @@ async function validatePromotionCodesOnSubmit(
|
|
|
320
323
|
amount: amount || checkoutSession.amount_total,
|
|
321
324
|
currencyId: checkoutSession.currency_id || '',
|
|
322
325
|
lineItems,
|
|
326
|
+
checkoutSessionId: checkoutSession.id,
|
|
323
327
|
});
|
|
324
328
|
|
|
325
329
|
if (!eligibilityCheck.eligible) {
|
|
@@ -347,6 +351,58 @@ async function validatePromotionCodesOnSubmit(
|
|
|
347
351
|
});
|
|
348
352
|
}
|
|
349
353
|
|
|
354
|
+
/**
|
|
355
|
+
* Recover checkout session discount config from confirmed discount records.
|
|
356
|
+
* This handles edge cases where checkout_session.discounts was unexpectedly cleared
|
|
357
|
+
* while discount usage has already been recorded for the same open session.
|
|
358
|
+
*/
|
|
359
|
+
async function recoverDiscountConfigFromRecords(checkoutSession: CheckoutSession) {
|
|
360
|
+
if (checkoutSession.discounts?.length) {
|
|
361
|
+
return checkoutSession.discounts;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const latestDiscountRecord = await Discount.findOne({
|
|
365
|
+
where: {
|
|
366
|
+
checkout_session_id: checkoutSession.id,
|
|
367
|
+
confirmed: true,
|
|
368
|
+
},
|
|
369
|
+
order: [['updated_at', 'DESC']],
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
if (!latestDiscountRecord) {
|
|
373
|
+
return checkoutSession.discounts || [];
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const recordCurrencyId = latestDiscountRecord.metadata?.currency_id;
|
|
377
|
+
// Avoid restoring a discount that was intentionally removed after currency switch.
|
|
378
|
+
if (recordCurrencyId && checkoutSession.currency_id && recordCurrencyId !== checkoutSession.currency_id) {
|
|
379
|
+
return checkoutSession.discounts || [];
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const recoveredDiscount = {
|
|
383
|
+
promotion_code: latestDiscountRecord.promotion_code_id,
|
|
384
|
+
coupon: latestDiscountRecord.coupon_id,
|
|
385
|
+
discount_amount: latestDiscountRecord.metadata?.discount_amount,
|
|
386
|
+
verification_method: latestDiscountRecord.verification_method,
|
|
387
|
+
verification_data: latestDiscountRecord.verification_data,
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
const recoveredDiscounts = [recoveredDiscount];
|
|
391
|
+
await checkoutSession.update({
|
|
392
|
+
discounts: recoveredDiscounts,
|
|
393
|
+
});
|
|
394
|
+
checkoutSession.discounts = recoveredDiscounts as any;
|
|
395
|
+
|
|
396
|
+
logger.info('Recovered checkout session discounts from discount records', {
|
|
397
|
+
checkoutSessionId: checkoutSession.id,
|
|
398
|
+
discountRecordId: latestDiscountRecord.id,
|
|
399
|
+
couponId: latestDiscountRecord.coupon_id,
|
|
400
|
+
promotionCodeId: latestDiscountRecord.promotion_code_id,
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
return recoveredDiscounts;
|
|
404
|
+
}
|
|
405
|
+
|
|
350
406
|
/**
|
|
351
407
|
* Calculate and update payment amount for checkout session
|
|
352
408
|
*/
|
|
@@ -1515,6 +1571,11 @@ router.get('/retrieve/:id', user, async (req, res) => {
|
|
|
1515
1571
|
// @ts-ignore
|
|
1516
1572
|
doc.line_items = await Price.expand(rawLineItems, { upsell: true });
|
|
1517
1573
|
|
|
1574
|
+
// Recover discount config for open sessions if checkout_session.discounts was unexpectedly cleared.
|
|
1575
|
+
if (!doc.discounts?.length && doc.status === 'open' && doc.payment_status !== 'paid') {
|
|
1576
|
+
await recoverDiscountConfigFromRecords(doc);
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1518
1579
|
// Enhance line items with coupon information if discounts are applied
|
|
1519
1580
|
if (doc.discounts?.length) {
|
|
1520
1581
|
doc.line_items = await expandLineItemsWithCouponInfo(
|
|
@@ -1628,6 +1689,7 @@ router.get('/retrieve/:id', user, async (req, res) => {
|
|
|
1628
1689
|
paymentLink: doc.payment_link_id ? await PaymentLink.findByPk(doc.payment_link_id) : null,
|
|
1629
1690
|
paymentIntent,
|
|
1630
1691
|
customer: req.user ? await Customer.findOne({ where: { did: req.user.did } }) : null,
|
|
1692
|
+
...(stopAcceptingOrders ? { stopAcceptingOrders: true } : {}),
|
|
1631
1693
|
});
|
|
1632
1694
|
});
|
|
1633
1695
|
|
|
@@ -2490,6 +2552,14 @@ router.put('/:id/submit', user, ensureCheckoutSessionOpen, async (req, res) => {
|
|
|
2490
2552
|
return res.status(403).json({ code: 'REQUIRE_LOGIN', error: 'Please login to continue' });
|
|
2491
2553
|
}
|
|
2492
2554
|
|
|
2555
|
+
// Check if system is accepting new orders
|
|
2556
|
+
if (stopAcceptingOrders) {
|
|
2557
|
+
return res.status(422).json({
|
|
2558
|
+
code: 'STOP_ACCEPTING_ORDERS',
|
|
2559
|
+
error: 'We are not accepting new orders at this time. Please try again later.',
|
|
2560
|
+
});
|
|
2561
|
+
}
|
|
2562
|
+
|
|
2493
2563
|
// Idempotency check: If already complete/paid, return existing state
|
|
2494
2564
|
if (checkoutSession.status === 'complete' || checkoutSession.payment_status === 'paid') {
|
|
2495
2565
|
await checkoutSession.reload();
|
|
@@ -3515,6 +3585,13 @@ router.post('/:id/fast-checkout-confirm', user, ensureCheckoutSessionOpen, async
|
|
|
3515
3585
|
return res.status(403).json({ code: 'REQUIRE_LOGIN', error: 'Please login to continue' });
|
|
3516
3586
|
}
|
|
3517
3587
|
|
|
3588
|
+
if (stopAcceptingOrders) {
|
|
3589
|
+
return res.status(422).json({
|
|
3590
|
+
code: 'STOP_ACCEPTING_ORDERS',
|
|
3591
|
+
error: 'We are not accepting new orders at this time. Please try again later.',
|
|
3592
|
+
});
|
|
3593
|
+
}
|
|
3594
|
+
|
|
3518
3595
|
const checkoutSession = req.doc as CheckoutSession;
|
|
3519
3596
|
|
|
3520
3597
|
if (checkoutSession.line_items) {
|
|
@@ -4134,11 +4211,10 @@ router.post('/:id/apply-promotion', user, ensureCheckoutSessionOpen, async (req,
|
|
|
4134
4211
|
return res.status(403).json({ error: 'Authentication required' });
|
|
4135
4212
|
}
|
|
4136
4213
|
|
|
4137
|
-
// Find customer by DID
|
|
4214
|
+
// Find customer by DID (may not exist yet for new users who haven't submitted)
|
|
4138
4215
|
const customer = await Customer.findOne({ where: { did: req.user.did } });
|
|
4139
|
-
|
|
4140
|
-
|
|
4141
|
-
}
|
|
4216
|
+
// Use customer.id if exists, otherwise fall back to DID for promo validation
|
|
4217
|
+
const customerId = customer?.id || req.user.did;
|
|
4142
4218
|
|
|
4143
4219
|
// Get currency
|
|
4144
4220
|
const curCurrencyId = currencyId || checkoutSession.currency_id;
|
|
@@ -4200,7 +4276,7 @@ router.post('/:id/apply-promotion', user, ensureCheckoutSessionOpen, async (req,
|
|
|
4200
4276
|
lineItems: itemsWithQuotes,
|
|
4201
4277
|
promotionCodeId: foundPromotionCode.id,
|
|
4202
4278
|
couponId: foundPromotionCode.coupon_id,
|
|
4203
|
-
customerId
|
|
4279
|
+
customerId,
|
|
4204
4280
|
currency,
|
|
4205
4281
|
billingContext: {
|
|
4206
4282
|
trialing: isTrialing,
|
|
@@ -4295,6 +4371,11 @@ router.post('/:id/recalculate-promotion', user, ensureCheckoutSessionOpen, async
|
|
|
4295
4371
|
return res.status(403).json({ error: 'Authentication required' });
|
|
4296
4372
|
}
|
|
4297
4373
|
|
|
4374
|
+
// Try recovering discounts from confirmed records before deciding there is nothing to recalculate.
|
|
4375
|
+
if (!checkoutSession.discounts?.length) {
|
|
4376
|
+
await recoverDiscountConfigFromRecords(checkoutSession);
|
|
4377
|
+
}
|
|
4378
|
+
|
|
4298
4379
|
// Check if there are existing discounts to recalculate
|
|
4299
4380
|
if (!checkoutSession.discounts?.length) {
|
|
4300
4381
|
return res.json({
|
|
@@ -4305,12 +4386,6 @@ router.post('/:id/recalculate-promotion', user, ensureCheckoutSessionOpen, async
|
|
|
4305
4386
|
});
|
|
4306
4387
|
}
|
|
4307
4388
|
|
|
4308
|
-
// Find customer by DID
|
|
4309
|
-
const customer = await Customer.findOne({ where: { did: req.user.did } });
|
|
4310
|
-
if (!customer) {
|
|
4311
|
-
return res.status(400).json({ error: 'Customer not found' });
|
|
4312
|
-
}
|
|
4313
|
-
|
|
4314
4389
|
// Get new currency
|
|
4315
4390
|
const currency = await PaymentCurrency.findByPk(newCurrencyId);
|
|
4316
4391
|
if (!currency) {
|
|
@@ -4320,6 +4395,10 @@ router.post('/:id/recalculate-promotion', user, ensureCheckoutSessionOpen, async
|
|
|
4320
4395
|
const checkoutItems = checkoutSession.line_items || [];
|
|
4321
4396
|
// Get line items - no need for quote data since frontend calculates discount amounts
|
|
4322
4397
|
const expandedItems = await Price.expand(checkoutSession.line_items || [], { product: true, upsell: true });
|
|
4398
|
+
const supportedCurrencyIds = getSupportedPaymentCurrencies(expandedItems as any[]);
|
|
4399
|
+
if (!supportedCurrencyIds.includes(newCurrencyId)) {
|
|
4400
|
+
return res.status(400).json({ error: 'Currency not supported for this checkout session' });
|
|
4401
|
+
}
|
|
4323
4402
|
|
|
4324
4403
|
// Get the first discount (assuming only one promotion code at a time)
|
|
4325
4404
|
const existingDiscount = checkoutSession.discounts[0];
|
|
@@ -4340,11 +4419,29 @@ router.post('/:id/recalculate-promotion', user, ensureCheckoutSessionOpen, async
|
|
|
4340
4419
|
Coupon.findByPk(couponId),
|
|
4341
4420
|
]);
|
|
4342
4421
|
|
|
4343
|
-
if (!foundPromotionCode
|
|
4422
|
+
if (!foundPromotionCode) {
|
|
4423
|
+
return res.status(400).json({ error: 'Promotion code not found' });
|
|
4424
|
+
}
|
|
4425
|
+
if (!coupon) {
|
|
4426
|
+
return res.status(400).json({ error: 'Coupon not found' });
|
|
4427
|
+
}
|
|
4428
|
+
|
|
4429
|
+
// Re-submit scenario: discount-status queue may have deactivated the promo/coupon
|
|
4430
|
+
// because this session's own usage pushed it past max_redemptions.
|
|
4431
|
+
// Exclude current session's confirmed discount records before checking active/valid.
|
|
4432
|
+
const currentSessionUsage = await Discount.count({
|
|
4433
|
+
where: {
|
|
4434
|
+
checkout_session_id: checkoutSession.id,
|
|
4435
|
+
promotion_code_id: foundPromotionCode.id,
|
|
4436
|
+
confirmed: true,
|
|
4437
|
+
},
|
|
4438
|
+
});
|
|
4439
|
+
|
|
4440
|
+
if (!foundPromotionCode.active && currentSessionUsage === 0) {
|
|
4344
4441
|
return res.status(400).json({ error: 'Promotion code no longer active' });
|
|
4345
4442
|
}
|
|
4346
4443
|
|
|
4347
|
-
if (!coupon
|
|
4444
|
+
if (!coupon.valid && currentSessionUsage === 0) {
|
|
4348
4445
|
return res.status(400).json({ error: 'Coupon no longer valid' });
|
|
4349
4446
|
}
|
|
4350
4447
|
|
|
@@ -4355,6 +4452,10 @@ router.post('/:id/recalculate-promotion', user, ensureCheckoutSessionOpen, async
|
|
|
4355
4452
|
coupon.currency_options?.[currency.id]?.amount_off; // Has currency option
|
|
4356
4453
|
|
|
4357
4454
|
if (!canApplyWithCurrency) {
|
|
4455
|
+
// Rollback previously confirmed discount usage/records for this open checkout session.
|
|
4456
|
+
// This resets coupon/promotion counters when discount becomes inapplicable.
|
|
4457
|
+
await rollbackDiscountUsageForCheckoutSession(checkoutSession.id);
|
|
4458
|
+
|
|
4358
4459
|
// Remove discount if it can't be applied with new currency
|
|
4359
4460
|
await checkoutSession.update({
|
|
4360
4461
|
discounts: [],
|
|
@@ -4887,6 +4988,15 @@ router.put('/:id/switch-currency', user, ensureCheckoutSessionOpen, async (req,
|
|
|
4887
4988
|
return res.status(400).json({ error: 'Currency not found' });
|
|
4888
4989
|
}
|
|
4889
4990
|
|
|
4991
|
+
const expandedItemsForSupportCheck = await Price.expand(checkoutSession.line_items || [], {
|
|
4992
|
+
product: true,
|
|
4993
|
+
upsell: true,
|
|
4994
|
+
});
|
|
4995
|
+
const supportedCurrencyIds = getSupportedPaymentCurrencies(expandedItemsForSupportCheck as any[]);
|
|
4996
|
+
if (!supportedCurrencyIds.includes(newCurrencyId)) {
|
|
4997
|
+
return res.status(400).json({ error: 'Currency not supported for this checkout session' });
|
|
4998
|
+
}
|
|
4999
|
+
|
|
4890
5000
|
const oldCurrencyId = checkoutSession.currency_id;
|
|
4891
5001
|
const currencyChanged = oldCurrencyId && oldCurrencyId !== newCurrencyId;
|
|
4892
5002
|
|
|
@@ -77,6 +77,26 @@ describe('libs/discount/coupon.ts', () => {
|
|
|
77
77
|
});
|
|
78
78
|
});
|
|
79
79
|
|
|
80
|
+
it('allows first_time_transaction re-submit by excluding current session', async () => {
|
|
81
|
+
jest.spyOn(Customer, 'findByPkOrDid').mockResolvedValue({ did: 'did:user:1' } as any);
|
|
82
|
+
// When checkoutSessionId is provided, the Discount.count query excludes current session
|
|
83
|
+
jest.spyOn(Discount, 'count').mockResolvedValue(0 as any);
|
|
84
|
+
const pc: any = {
|
|
85
|
+
active: true,
|
|
86
|
+
restrictions: { first_time_transaction: true },
|
|
87
|
+
coupon_id: 'c1',
|
|
88
|
+
};
|
|
89
|
+
await expect(validPromotionCode(pc, { customerId: 'c1', checkoutSessionId: 'cs_123' })).resolves.toEqual({
|
|
90
|
+
valid: true,
|
|
91
|
+
});
|
|
92
|
+
// Verify the count query was called with Op.ne exclusion
|
|
93
|
+
expect(Discount.count).toHaveBeenCalledWith({
|
|
94
|
+
where: expect.objectContaining({
|
|
95
|
+
checkout_session_id: expect.any(Object),
|
|
96
|
+
}),
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
80
100
|
it('checks minimum amount by currency', async () => {
|
|
81
101
|
jest.spyOn(PaymentCurrency, 'findByPk').mockResolvedValue({ id: 'usd', decimal: 2, symbol: 'USD' } as any);
|
|
82
102
|
const pc: any = {
|
|
@@ -123,6 +143,43 @@ describe('libs/discount/coupon.ts', () => {
|
|
|
123
143
|
});
|
|
124
144
|
expect(result.eligible).toBe(true);
|
|
125
145
|
});
|
|
146
|
+
|
|
147
|
+
it('allows re-submit when max_redemptions reached by current session', async () => {
|
|
148
|
+
// Scenario: promo has max_redemptions=1, times_redeemed=1 (from this session's first submit)
|
|
149
|
+
// User closed Stripe modal and re-submits — should NOT be rejected
|
|
150
|
+
jest.spyOn(Discount, 'count').mockResolvedValue(1 as any); // current session has 1 confirmed discount
|
|
151
|
+
jest.spyOn(Coupon, 'findByPk').mockResolvedValue({ valid: true, times_redeemed: 1, max_redemptions: 1 } as any);
|
|
152
|
+
jest.spyOn(Customer, 'findByPkOrDid').mockResolvedValue({ did: 'did:user:1' } as any);
|
|
153
|
+
const couponModule = await import('../../src/libs/discount/coupon');
|
|
154
|
+
jest.spyOn(couponModule, 'validCoupon').mockReturnValue({ valid: true } as any);
|
|
155
|
+
|
|
156
|
+
const result = await checkPromotionCodeEligibility({
|
|
157
|
+
promotionCode: { id: 'pc1', active: true, max_redemptions: 1, times_redeemed: 1 } as any,
|
|
158
|
+
couponId: 'c1',
|
|
159
|
+
customerId: 'u1',
|
|
160
|
+
amount: '100',
|
|
161
|
+
currencyId: 'usd',
|
|
162
|
+
checkoutSessionId: 'cs_123',
|
|
163
|
+
});
|
|
164
|
+
expect(result.eligible).toBe(true);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('rejects when max_redemptions reached by other sessions', async () => {
|
|
168
|
+
// Scenario: promo has max_redemptions=1, times_redeemed=1, but NOT from current session
|
|
169
|
+
jest.spyOn(Discount, 'count').mockResolvedValue(0 as any); // current session has no confirmed discount
|
|
170
|
+
jest.spyOn(Coupon, 'findByPk').mockResolvedValue({ valid: true } as any);
|
|
171
|
+
|
|
172
|
+
const result = await checkPromotionCodeEligibility({
|
|
173
|
+
promotionCode: { id: 'pc1', active: true, max_redemptions: 1, times_redeemed: 1 } as any,
|
|
174
|
+
couponId: 'c1',
|
|
175
|
+
customerId: 'u1',
|
|
176
|
+
amount: '100',
|
|
177
|
+
currencyId: 'usd',
|
|
178
|
+
checkoutSessionId: 'cs_456',
|
|
179
|
+
});
|
|
180
|
+
expect(result.eligible).toBe(false);
|
|
181
|
+
expect(result.reason).toContain('fully redeemed');
|
|
182
|
+
});
|
|
126
183
|
});
|
|
127
184
|
|
|
128
185
|
describe('validateDiscountForBilling & getValidDiscountsForSubscriptionBilling', () => {
|
package/blocklet.yml
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "payment-kit",
|
|
3
|
-
"version": "1.26.
|
|
3
|
+
"version": "1.26.3",
|
|
4
4
|
"scripts": {
|
|
5
5
|
"dev": "blocklet dev --open",
|
|
6
6
|
"prelint": "npm run types",
|
|
@@ -59,9 +59,9 @@
|
|
|
59
59
|
"@blocklet/error": "^0.3.5",
|
|
60
60
|
"@blocklet/js-sdk": "^1.17.8-beta-20260104-120132-cb5b1914",
|
|
61
61
|
"@blocklet/logger": "^1.17.8-beta-20260104-120132-cb5b1914",
|
|
62
|
-
"@blocklet/payment-broker-client": "1.26.
|
|
63
|
-
"@blocklet/payment-react": "1.26.
|
|
64
|
-
"@blocklet/payment-vendor": "1.26.
|
|
62
|
+
"@blocklet/payment-broker-client": "1.26.3",
|
|
63
|
+
"@blocklet/payment-react": "1.26.3",
|
|
64
|
+
"@blocklet/payment-vendor": "1.26.3",
|
|
65
65
|
"@blocklet/sdk": "^1.17.8-beta-20260104-120132-cb5b1914",
|
|
66
66
|
"@blocklet/ui-react": "^3.5.1",
|
|
67
67
|
"@blocklet/uploader": "^0.3.19",
|
|
@@ -132,7 +132,7 @@
|
|
|
132
132
|
"devDependencies": {
|
|
133
133
|
"@abtnode/types": "^1.17.8-beta-20260104-120132-cb5b1914",
|
|
134
134
|
"@arcblock/eslint-config-ts": "^0.3.3",
|
|
135
|
-
"@blocklet/payment-types": "1.26.
|
|
135
|
+
"@blocklet/payment-types": "1.26.3",
|
|
136
136
|
"@types/cookie-parser": "^1.4.9",
|
|
137
137
|
"@types/cors": "^2.8.19",
|
|
138
138
|
"@types/debug": "^4.1.12",
|
|
@@ -179,5 +179,5 @@
|
|
|
179
179
|
"parser": "typescript"
|
|
180
180
|
}
|
|
181
181
|
},
|
|
182
|
-
"gitHead": "
|
|
182
|
+
"gitHead": "18c5d045139c572b52465e15c4c63b3e327efab5"
|
|
183
183
|
}
|