payment-kit 1.26.1 → 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.
@@ -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
- if (promotionCode.verification_type === 'user_restricted' && customer?.did) {
64
- if (!promotionCode.customer_dids?.includes(customer.did)) {
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 previousDiscounts = await Discount.count({
70
- where: { customer_id: customerId, coupon_id: promotionCode.coupon_id },
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
- if (!promotionCode.active) {
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 && (promotionCode.times_redeemed ?? 0) >= promotionCode.max_redemptions) {
795
- return { eligible: false, reason: 'This promotion code has been fully redeemed and is no longer available' };
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
 
@@ -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;
@@ -40,7 +40,7 @@ import {
40
40
  } from './subscription';
41
41
  import logger from './logger';
42
42
  import { ensureOverdraftProtectionPrice } from './overdraft-protection';
43
- import { CHARGE_SUPPORTED_CHAIN_TYPES } from './constants';
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
- if (CHARGE_SUPPORTED_CHAIN_TYPES.includes(paymentMethod.type)) {
160
- switch (invoice.billing_reason) {
161
- case 'subscription_cancel':
162
- filterFunc = (item: any) => item.price?.recurring?.usage_type === 'metered';
163
- break;
164
- case 'subscription_cycle':
165
- filterFunc = (item: any) => item.price?.type === 'recurring';
166
- offset = setup.cycle / 1000;
167
- break;
168
- default:
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/settings/exchange-rate-providers', {
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
- const REPRESENTATIVE_TOKENS = ['ABT', 'ETH', 'USDC'];
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' && subscription.cancelation_details?.reason === 'payment_failed') {
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
- if (!customer) {
4140
- return res.status(400).json({ error: 'Customer not found' });
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: customer.id,
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 || !foundPromotionCode.active) {
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 || !coupon.valid) {
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
@@ -14,7 +14,7 @@ repository:
14
14
  type: git
15
15
  url: git+https://github.com/blocklet/payment-kit.git
16
16
  specVersion: 1.2.8
17
- version: 1.26.1
17
+ version: 1.26.3
18
18
  logo: logo.png
19
19
  files:
20
20
  - dist
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "payment-kit",
3
- "version": "1.26.1",
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.1",
63
- "@blocklet/payment-react": "1.26.1",
64
- "@blocklet/payment-vendor": "1.26.1",
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.1",
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": "1ba42f376f040b1214d992420cda37053fc14288"
182
+ "gitHead": "18c5d045139c572b52465e15c4c63b3e327efab5"
183
183
  }