payment-kit 1.20.20 → 1.20.21
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/integrations/stripe/resource.ts +1 -1
- package/api/src/libs/discount/coupon.ts +41 -73
- package/api/src/libs/invoice.ts +17 -0
- package/api/src/libs/notification/template/subscription-renew-failed.ts +22 -1
- package/api/src/libs/notification/template/subscription-will-renew.ts +22 -0
- package/api/src/locales/en.ts +1 -0
- package/api/src/locales/zh.ts +1 -0
- package/api/src/queues/checkout-session.ts +2 -2
- package/api/src/routes/checkout-sessions.ts +84 -0
- package/api/src/routes/connect/collect-batch.ts +2 -2
- package/api/src/routes/connect/pay.ts +1 -1
- package/api/src/store/migrations/20250926-change-customer-did-unique.ts +49 -0
- package/api/src/store/models/customer.ts +1 -0
- package/api/tests/libs/coupon.spec.ts +219 -0
- package/api/tests/libs/discount.spec.ts +250 -0
- package/blocklet.yml +1 -1
- package/package.json +7 -7
- package/src/components/discount/discount-info.tsx +0 -1
- package/src/components/invoice/action.tsx +26 -0
- package/src/components/invoice/table.tsx +2 -9
- package/src/components/invoice-pdf/styles.ts +2 -0
- package/src/components/invoice-pdf/template.tsx +44 -12
- package/src/components/metadata/list.tsx +1 -0
- package/src/components/subscription/metrics.tsx +7 -3
- package/src/locales/en.tsx +7 -0
- package/src/locales/zh.tsx +7 -0
- package/src/pages/admin/billing/subscriptions/detail.tsx +11 -3
- package/src/pages/admin/products/coupons/applicable-products.tsx +20 -37
- package/src/pages/customer/invoice/detail.tsx +1 -1
- package/src/pages/customer/subscription/detail.tsx +12 -3
|
@@ -579,7 +579,7 @@ async function createStripeSubscriptionCoupon(
|
|
|
579
579
|
id: existingCouponId, // Use predictable ID
|
|
580
580
|
name: `sub_coupon_${localCoupon.id}`,
|
|
581
581
|
duration: localCoupon.duration as 'once' | 'forever' | 'repeating',
|
|
582
|
-
|
|
582
|
+
redeem_by: Math.floor((new Date().getTime() + 24 * 60 * 60 * 1000) / 1000),
|
|
583
583
|
metadata: {
|
|
584
584
|
appPid: env.appPid,
|
|
585
585
|
local_coupon_id: localCoupon.id,
|
|
@@ -264,7 +264,7 @@ function buildBaseDiscountData({
|
|
|
264
264
|
checkoutSession: CheckoutSession;
|
|
265
265
|
customerId: string;
|
|
266
266
|
coupon: Coupon;
|
|
267
|
-
promotionCode
|
|
267
|
+
promotionCode?: PromotionCode;
|
|
268
268
|
discountAmount: string;
|
|
269
269
|
verificationData?: any;
|
|
270
270
|
}) {
|
|
@@ -282,18 +282,18 @@ function buildBaseDiscountData({
|
|
|
282
282
|
return {
|
|
283
283
|
livemode: coupon.livemode,
|
|
284
284
|
coupon_id: coupon.id,
|
|
285
|
-
promotion_code_id: promotionCode
|
|
285
|
+
promotion_code_id: promotionCode?.id,
|
|
286
286
|
customer_id: customerId,
|
|
287
287
|
checkout_session_id: checkoutSession.id,
|
|
288
288
|
start: now,
|
|
289
289
|
end: endTime,
|
|
290
|
-
verification_method: promotionCode
|
|
290
|
+
verification_method: promotionCode?.verification_type,
|
|
291
291
|
verification_data: verificationData || checkoutSession.metadata?.verification_data,
|
|
292
292
|
metadata: {
|
|
293
|
-
checkout_session_id: checkoutSession.id,
|
|
294
293
|
discount_amount: discountAmount,
|
|
295
294
|
original_amount: checkoutSession.amount_subtotal,
|
|
296
295
|
final_amount: checkoutSession.amount_total,
|
|
296
|
+
currency_id: checkoutSession.currency_id,
|
|
297
297
|
applied_at: new Date().toISOString(),
|
|
298
298
|
},
|
|
299
299
|
};
|
|
@@ -317,41 +317,23 @@ async function processSubscriptionDiscount({
|
|
|
317
317
|
const existingDiscount = existingDiscountMap.get(subscriptionId);
|
|
318
318
|
|
|
319
319
|
if (existingDiscount) {
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
existingDiscount.coupon_id !== baseDiscountData.coupon_id ||
|
|
323
|
-
existingDiscount.promotion_code_id !== baseDiscountData.promotion_code_id
|
|
324
|
-
) {
|
|
325
|
-
// Update existing discount record
|
|
326
|
-
await existingDiscount.update({
|
|
327
|
-
...baseDiscountData,
|
|
328
|
-
subscription_id: subscriptionId,
|
|
329
|
-
metadata: {
|
|
330
|
-
...baseDiscountData.metadata,
|
|
331
|
-
subscription_id: subscriptionId,
|
|
332
|
-
updated_at: new Date().toISOString(),
|
|
333
|
-
},
|
|
334
|
-
});
|
|
335
|
-
|
|
336
|
-
logger.info('Updated existing discount record for subscription', {
|
|
337
|
-
checkoutSessionId,
|
|
338
|
-
subscriptionId,
|
|
339
|
-
discountId: existingDiscount.id,
|
|
340
|
-
oldCouponId: existingDiscount.coupon_id,
|
|
341
|
-
newCouponId: baseDiscountData.coupon_id,
|
|
342
|
-
});
|
|
320
|
+
const couponChanged = existingDiscount.coupon_id !== baseDiscountData.coupon_id;
|
|
321
|
+
const promoChanged = existingDiscount.promotion_code_id !== baseDiscountData.promotion_code_id;
|
|
343
322
|
|
|
344
|
-
|
|
345
|
-
|
|
323
|
+
const updated = await existingDiscount.update({
|
|
324
|
+
...baseDiscountData,
|
|
325
|
+
subscription_id: subscriptionId,
|
|
326
|
+
});
|
|
346
327
|
|
|
347
|
-
|
|
348
|
-
logger.debug('Discount record already exists with same coupon/promotion', {
|
|
328
|
+
logger.info('Upserted discount record for subscription', {
|
|
349
329
|
checkoutSessionId,
|
|
350
330
|
subscriptionId,
|
|
351
|
-
discountId:
|
|
331
|
+
discountId: updated.id,
|
|
332
|
+
couponChanged,
|
|
333
|
+
promoChanged,
|
|
352
334
|
});
|
|
353
335
|
|
|
354
|
-
return { discountRecord:
|
|
336
|
+
return { discountRecord: updated, shouldUpdateUsage: couponChanged || promoChanged };
|
|
355
337
|
}
|
|
356
338
|
|
|
357
339
|
// Create new discount record
|
|
@@ -397,35 +379,21 @@ async function processNonSubscriptionDiscount(
|
|
|
397
379
|
const existingDiscount = existingDiscountMap.get('no_subscription');
|
|
398
380
|
|
|
399
381
|
if (existingDiscount) {
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
existingDiscount.promotion_code_id !== baseDiscountData.promotion_code_id
|
|
403
|
-
) {
|
|
404
|
-
// Update existing discount record
|
|
405
|
-
await existingDiscount.update({
|
|
406
|
-
...baseDiscountData,
|
|
407
|
-
metadata: {
|
|
408
|
-
...baseDiscountData.metadata,
|
|
409
|
-
updated_at: new Date().toISOString(),
|
|
410
|
-
},
|
|
411
|
-
});
|
|
412
|
-
|
|
413
|
-
logger.info('Updated existing discount record', {
|
|
414
|
-
checkoutSessionId,
|
|
415
|
-
discountId: existingDiscount.id,
|
|
416
|
-
oldCouponId: existingDiscount.coupon_id,
|
|
417
|
-
newCouponId: baseDiscountData.coupon_id,
|
|
418
|
-
});
|
|
382
|
+
const couponChanged = existingDiscount.coupon_id !== baseDiscountData.coupon_id;
|
|
383
|
+
const promoChanged = existingDiscount.promotion_code_id !== baseDiscountData.promotion_code_id;
|
|
419
384
|
|
|
420
|
-
|
|
421
|
-
|
|
385
|
+
const updated = await existingDiscount.update({
|
|
386
|
+
...baseDiscountData,
|
|
387
|
+
});
|
|
422
388
|
|
|
423
|
-
logger.
|
|
389
|
+
logger.info('Upserted single discount record', {
|
|
424
390
|
checkoutSessionId,
|
|
425
|
-
discountId:
|
|
391
|
+
discountId: updated.id,
|
|
392
|
+
couponChanged,
|
|
393
|
+
promoChanged,
|
|
426
394
|
});
|
|
427
395
|
|
|
428
|
-
return { discountRecord:
|
|
396
|
+
return { discountRecord: updated, shouldUpdateUsage: couponChanged || promoChanged };
|
|
429
397
|
}
|
|
430
398
|
|
|
431
399
|
// Create new discount record
|
|
@@ -464,17 +432,17 @@ async function updateUsageCounts({
|
|
|
464
432
|
checkoutSessionId,
|
|
465
433
|
}: {
|
|
466
434
|
coupon: Coupon;
|
|
467
|
-
promotionCode
|
|
435
|
+
promotionCode?: PromotionCode;
|
|
468
436
|
updatedCoupons: Set<string>;
|
|
469
437
|
updatedPromotionCodes: Set<string>;
|
|
470
438
|
checkoutSessionId: string;
|
|
471
439
|
}): Promise<void> {
|
|
472
440
|
try {
|
|
473
|
-
const updatePromises = []
|
|
441
|
+
const updatePromises = [] as Array<Promise<any>>;
|
|
474
442
|
|
|
475
443
|
// Create unique keys based on checkout session to prevent duplicate counting
|
|
476
444
|
const couponSessionKey = `${coupon.id}-${checkoutSessionId}`;
|
|
477
|
-
const promotionCodeSessionKey = `${promotionCode.id}-${checkoutSessionId}
|
|
445
|
+
const promotionCodeSessionKey = promotionCode ? `${promotionCode.id}-${checkoutSessionId}` : '';
|
|
478
446
|
|
|
479
447
|
if (!updatedCoupons.has(couponSessionKey)) {
|
|
480
448
|
updatedCoupons.add(couponSessionKey);
|
|
@@ -485,7 +453,7 @@ async function updateUsageCounts({
|
|
|
485
453
|
);
|
|
486
454
|
}
|
|
487
455
|
|
|
488
|
-
if (!updatedPromotionCodes.has(promotionCodeSessionKey)) {
|
|
456
|
+
if (promotionCode && promotionCodeSessionKey && !updatedPromotionCodes.has(promotionCodeSessionKey)) {
|
|
489
457
|
updatedPromotionCodes.add(promotionCodeSessionKey);
|
|
490
458
|
updatePromises.push(
|
|
491
459
|
promotionCode.update({
|
|
@@ -496,18 +464,20 @@ async function updateUsageCounts({
|
|
|
496
464
|
|
|
497
465
|
await Promise.all(updatePromises);
|
|
498
466
|
emitAsync('discount-status.queued', coupon, 'coupon', true);
|
|
499
|
-
|
|
467
|
+
if (promotionCode) {
|
|
468
|
+
emitAsync('discount-status.queued', promotionCode, 'promotion-code', true);
|
|
469
|
+
}
|
|
500
470
|
|
|
501
471
|
logger.debug('Updated coupon and promotion code usage counts', {
|
|
502
472
|
checkoutSessionId,
|
|
503
473
|
couponId: coupon.id,
|
|
504
|
-
promotionCodeId: promotionCode
|
|
474
|
+
promotionCodeId: promotionCode?.id,
|
|
505
475
|
});
|
|
506
476
|
} catch (error) {
|
|
507
477
|
logger.error('Error updating usage counts', {
|
|
508
478
|
checkoutSessionId,
|
|
509
479
|
couponId: coupon.id,
|
|
510
|
-
promotionCodeId: promotionCode
|
|
480
|
+
promotionCodeId: promotionCode?.id,
|
|
511
481
|
error: error.message,
|
|
512
482
|
});
|
|
513
483
|
throw new Error(`Failed to update usage counts: ${error.message}`);
|
|
@@ -630,10 +600,9 @@ export async function createDiscountRecordsForCheckout({
|
|
|
630
600
|
const discountProcessingPromises = checkoutSession.discounts.map(async (discount: any) => {
|
|
631
601
|
const { promotion_code: promotionCodeId, coupon: couponId, discount_amount: discountAmount } = discount;
|
|
632
602
|
|
|
633
|
-
if (!
|
|
603
|
+
if (!couponId) {
|
|
634
604
|
logger.warn('Incomplete discount configuration, skipping', {
|
|
635
605
|
checkoutSessionId: checkoutSession.id,
|
|
636
|
-
hasPromotionCode: !!promotionCodeId,
|
|
637
606
|
hasCoupon: !!couponId,
|
|
638
607
|
});
|
|
639
608
|
return null;
|
|
@@ -643,13 +612,12 @@ export async function createDiscountRecordsForCheckout({
|
|
|
643
612
|
// Fetch coupon and promotion code
|
|
644
613
|
const [coupon, promotionCode] = await Promise.all([
|
|
645
614
|
Coupon.findByPk(couponId),
|
|
646
|
-
PromotionCode.findByPk(promotionCodeId),
|
|
615
|
+
promotionCodeId ? PromotionCode.findByPk(promotionCodeId) : Promise.resolve(null),
|
|
647
616
|
]);
|
|
648
617
|
|
|
649
|
-
if (!coupon
|
|
650
|
-
logger.warn('Coupon
|
|
618
|
+
if (!coupon) {
|
|
619
|
+
logger.warn('Coupon not found, skipping discount', {
|
|
651
620
|
couponId,
|
|
652
|
-
promotionCodeId,
|
|
653
621
|
checkoutSessionId: checkoutSession.id,
|
|
654
622
|
});
|
|
655
623
|
return null;
|
|
@@ -660,7 +628,7 @@ export async function createDiscountRecordsForCheckout({
|
|
|
660
628
|
checkoutSession,
|
|
661
629
|
customerId,
|
|
662
630
|
coupon,
|
|
663
|
-
promotionCode,
|
|
631
|
+
promotionCode: promotionCode || undefined,
|
|
664
632
|
discountAmount,
|
|
665
633
|
verificationData: discount.verification_data,
|
|
666
634
|
});
|
|
@@ -725,7 +693,7 @@ export async function createDiscountRecordsForCheckout({
|
|
|
725
693
|
try {
|
|
726
694
|
await updateUsageCounts({
|
|
727
695
|
coupon,
|
|
728
|
-
promotionCode,
|
|
696
|
+
promotionCode: promotionCode || undefined,
|
|
729
697
|
updatedCoupons,
|
|
730
698
|
updatedPromotionCodes,
|
|
731
699
|
checkoutSessionId: checkoutSession.id,
|
|
@@ -740,7 +708,7 @@ export async function createDiscountRecordsForCheckout({
|
|
|
740
708
|
} catch (error) {
|
|
741
709
|
logger.error('Failed to update usage counts, but continuing', {
|
|
742
710
|
couponId: coupon.id,
|
|
743
|
-
promotionCodeId: promotionCode
|
|
711
|
+
promotionCodeId: promotionCode?.id,
|
|
744
712
|
error: error.message,
|
|
745
713
|
});
|
|
746
714
|
}
|
package/api/src/libs/invoice.ts
CHANGED
|
@@ -1386,3 +1386,20 @@ export const migrateSubscriptionPaymentMethodInvoice = async (
|
|
|
1386
1386
|
throw error;
|
|
1387
1387
|
}
|
|
1388
1388
|
};
|
|
1389
|
+
|
|
1390
|
+
export async function destroyExistingInvoice(invoice: Invoice) {
|
|
1391
|
+
const paymentMethod = await PaymentMethod.findByPk(invoice.default_payment_method_id);
|
|
1392
|
+
try {
|
|
1393
|
+
if (paymentMethod?.type === 'stripe' && invoice.metadata?.stripe_id) {
|
|
1394
|
+
const client = paymentMethod.getStripeClient();
|
|
1395
|
+
await client.invoices.del(invoice.metadata.stripe_id);
|
|
1396
|
+
}
|
|
1397
|
+
} catch (error) {
|
|
1398
|
+
logger.error('Failed to destroy invoice on stripe', {
|
|
1399
|
+
error: error.message,
|
|
1400
|
+
invoiceId: invoice.id,
|
|
1401
|
+
});
|
|
1402
|
+
}
|
|
1403
|
+
await InvoiceItem.destroy({ where: { invoice_id: invoice.id } });
|
|
1404
|
+
await invoice.destroy();
|
|
1405
|
+
}
|
|
@@ -20,7 +20,7 @@ import { PaymentCurrency } from '../../../store/models/payment-currency';
|
|
|
20
20
|
import { getCustomerInvoicePageUrl } from '../../invoice';
|
|
21
21
|
import { SufficientForPaymentResult, getPaymentDetail } from '../../payment';
|
|
22
22
|
import { getMainProductName } from '../../product';
|
|
23
|
-
import { getCustomerSubscriptionPageUrl } from '../../subscription';
|
|
23
|
+
import { getCustomerSubscriptionPageUrl, getSubscriptionPaymentAddress } from '../../subscription';
|
|
24
24
|
import { formatTime, getPrettyMsI18nLocale } from '../../time';
|
|
25
25
|
import { formatCurrencyInfo, getExplorerLink, getSubscriptionNotificationCustomActions } from '../../util';
|
|
26
26
|
import type { BaseEmailTemplate, BaseEmailTemplateType } from './base';
|
|
@@ -47,6 +47,7 @@ interface SubscriptionRenewFailedEmailTemplateContext {
|
|
|
47
47
|
viewInvoiceLink: string;
|
|
48
48
|
viewTxHashLink: string | undefined;
|
|
49
49
|
customActions: any[];
|
|
50
|
+
payer: string;
|
|
50
51
|
}
|
|
51
52
|
|
|
52
53
|
export class SubscriptionRenewFailedEmailTemplate
|
|
@@ -166,6 +167,8 @@ export class SubscriptionRenewFailedEmailTemplate
|
|
|
166
167
|
viewInvoiceLink,
|
|
167
168
|
viewTxHashLink,
|
|
168
169
|
customActions,
|
|
170
|
+
payer:
|
|
171
|
+
paymentMethod?.type !== 'stripe' && getSubscriptionPaymentAddress(subscription, paymentMethod?.type as string),
|
|
169
172
|
};
|
|
170
173
|
}
|
|
171
174
|
|
|
@@ -185,6 +188,7 @@ export class SubscriptionRenewFailedEmailTemplate
|
|
|
185
188
|
viewInvoiceLink,
|
|
186
189
|
viewTxHashLink,
|
|
187
190
|
customActions,
|
|
191
|
+
payer,
|
|
188
192
|
} = await this.getContext();
|
|
189
193
|
|
|
190
194
|
const template: BaseEmailTemplateType = {
|
|
@@ -230,6 +234,23 @@ export class SubscriptionRenewFailedEmailTemplate
|
|
|
230
234
|
text: productName,
|
|
231
235
|
},
|
|
232
236
|
},
|
|
237
|
+
...(payer && [
|
|
238
|
+
{
|
|
239
|
+
type: 'text',
|
|
240
|
+
data: {
|
|
241
|
+
type: 'plain',
|
|
242
|
+
color: '#9397A1',
|
|
243
|
+
text: translate('notification.common.payer', locale),
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
type: 'text',
|
|
248
|
+
data: {
|
|
249
|
+
type: 'plain',
|
|
250
|
+
text: payer,
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
]),
|
|
233
254
|
{
|
|
234
255
|
type: 'text',
|
|
235
256
|
data: {
|
|
@@ -40,6 +40,7 @@ interface SubscriptionWillRenewEmailTemplateContext {
|
|
|
40
40
|
isCreditSubscription: boolean;
|
|
41
41
|
isStripe: boolean;
|
|
42
42
|
isMetered: boolean;
|
|
43
|
+
payer: string;
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
export class SubscriptionWillRenewEmailTemplate extends BaseSubscriptionEmailTemplate<SubscriptionWillRenewEmailTemplateContext> {
|
|
@@ -159,6 +160,9 @@ export class SubscriptionWillRenewEmailTemplate extends BaseSubscriptionEmailTem
|
|
|
159
160
|
isCreditSubscription,
|
|
160
161
|
isStripe,
|
|
161
162
|
isMetered: !isPrePaid,
|
|
163
|
+
payer:
|
|
164
|
+
paymentInfoResult.paymentMethod?.type !== 'stripe' &&
|
|
165
|
+
getSubscriptionPaymentAddress(subscription, paymentInfoResult.paymentMethod!.type),
|
|
162
166
|
};
|
|
163
167
|
}
|
|
164
168
|
|
|
@@ -210,6 +214,7 @@ export class SubscriptionWillRenewEmailTemplate extends BaseSubscriptionEmailTem
|
|
|
210
214
|
isCreditSubscription,
|
|
211
215
|
isStripe,
|
|
212
216
|
isMetered,
|
|
217
|
+
payer,
|
|
213
218
|
} = context;
|
|
214
219
|
|
|
215
220
|
// 如果当前时间大于预计扣费时间,那么不发送通知
|
|
@@ -385,6 +390,23 @@ export class SubscriptionWillRenewEmailTemplate extends BaseSubscriptionEmailTem
|
|
|
385
390
|
type: 'section',
|
|
386
391
|
fields: [
|
|
387
392
|
...commonFields,
|
|
393
|
+
...(payer && [
|
|
394
|
+
{
|
|
395
|
+
type: 'text',
|
|
396
|
+
data: {
|
|
397
|
+
type: 'plain',
|
|
398
|
+
color: '#9397A1',
|
|
399
|
+
text: translate('notification.common.payer', locale),
|
|
400
|
+
},
|
|
401
|
+
},
|
|
402
|
+
{
|
|
403
|
+
type: 'text',
|
|
404
|
+
data: {
|
|
405
|
+
type: 'plain',
|
|
406
|
+
text: payer,
|
|
407
|
+
},
|
|
408
|
+
},
|
|
409
|
+
]),
|
|
388
410
|
...renewAmountFields,
|
|
389
411
|
...balanceFields,
|
|
390
412
|
...insufficientBalanceFields,
|
package/api/src/locales/en.ts
CHANGED
package/api/src/locales/zh.ts
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
} from '../store/models';
|
|
23
23
|
import { getCheckoutSessionSubscriptionIds } from '../libs/session';
|
|
24
24
|
import { rollbackDiscountUsageForCheckoutSession } from '../libs/discount/discount';
|
|
25
|
+
import { destroyExistingInvoice } from '../libs/invoice';
|
|
25
26
|
|
|
26
27
|
type CheckoutSessionJob = {
|
|
27
28
|
id: string;
|
|
@@ -147,8 +148,7 @@ events.on('checkout.session.expired', async (checkoutSession: CheckoutSession) =
|
|
|
147
148
|
// Do some reverse lookup if invoice is not related to checkout session
|
|
148
149
|
const invoice = await Invoice.findOne({ where: { checkout_session_id: checkoutSession.id } });
|
|
149
150
|
if (invoice) {
|
|
150
|
-
await
|
|
151
|
-
await Invoice.destroy({ where: { id: invoice.id } });
|
|
151
|
+
await destroyExistingInvoice(invoice);
|
|
152
152
|
logger.info('Invoice and InvoiceItem for checkout session deleted on expire', {
|
|
153
153
|
checkoutSession: checkoutSession.id,
|
|
154
154
|
invoice: invoice.id,
|
|
@@ -107,6 +107,7 @@ import {
|
|
|
107
107
|
} from '../libs/discount/coupon';
|
|
108
108
|
import { rollbackDiscountUsageForCheckoutSession, applyDiscountsToLineItems } from '../libs/discount/discount';
|
|
109
109
|
import { formatToShortUrl } from '../libs/url';
|
|
110
|
+
import { destroyExistingInvoice } from '../libs/invoice';
|
|
110
111
|
|
|
111
112
|
const router = Router();
|
|
112
113
|
|
|
@@ -1200,6 +1201,76 @@ router.get('/:id', auth, async (req, res) => {
|
|
|
1200
1201
|
}
|
|
1201
1202
|
});
|
|
1202
1203
|
|
|
1204
|
+
// abort stripe subscription(s) created during an incomplete checkout session
|
|
1205
|
+
router.post('/:id/abort-stripe', user, ensureCheckoutSessionOpen, async (req, res) => {
|
|
1206
|
+
try {
|
|
1207
|
+
const checkoutSession = req.doc as CheckoutSession;
|
|
1208
|
+
|
|
1209
|
+
if (checkoutSession.status === 'complete') {
|
|
1210
|
+
return res.status(400).json({ error: 'Checkout session already completed' });
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
// cancel stripe subscriptions if any
|
|
1214
|
+
const canceledSubscriptions: string[] = [];
|
|
1215
|
+
if (['subscription', 'setup'].includes(checkoutSession.mode)) {
|
|
1216
|
+
const subscriptionIds = getCheckoutSessionSubscriptionIds(checkoutSession);
|
|
1217
|
+
const subscriptions = await Subscription.findAll({ where: { id: subscriptionIds } });
|
|
1218
|
+
|
|
1219
|
+
const cancelOps = subscriptions.map(async (sub) => {
|
|
1220
|
+
const stripeSubId = sub.payment_details?.stripe?.subscription_id;
|
|
1221
|
+
if (!stripeSubId) {
|
|
1222
|
+
return null;
|
|
1223
|
+
}
|
|
1224
|
+
const method = await PaymentMethod.findByPk(sub.default_payment_method_id);
|
|
1225
|
+
if (!method || method.type !== 'stripe') {
|
|
1226
|
+
return null;
|
|
1227
|
+
}
|
|
1228
|
+
const client = method.getStripeClient();
|
|
1229
|
+
try {
|
|
1230
|
+
await client.subscriptions.cancel(stripeSubId);
|
|
1231
|
+
await sub.update({
|
|
1232
|
+
payment_details: omit(sub.payment_details || {}, 'stripe'),
|
|
1233
|
+
payment_settings: {
|
|
1234
|
+
payment_method_options: omit(sub.payment_settings?.payment_method_options || {}, 'stripe'),
|
|
1235
|
+
payment_method_types: sub.payment_settings?.payment_method_types || [],
|
|
1236
|
+
},
|
|
1237
|
+
});
|
|
1238
|
+
canceledSubscriptions.push(sub.id);
|
|
1239
|
+
} catch (err) {
|
|
1240
|
+
logger.error('Failed to cancel stripe subscription for checkout abort', {
|
|
1241
|
+
checkoutSessionId: checkoutSession.id,
|
|
1242
|
+
subscriptionId: sub.id,
|
|
1243
|
+
error: err.message,
|
|
1244
|
+
});
|
|
1245
|
+
}
|
|
1246
|
+
return sub.id;
|
|
1247
|
+
});
|
|
1248
|
+
await Promise.all(cancelOps);
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
// remove related invoice if created
|
|
1252
|
+
try {
|
|
1253
|
+
const existInvoice = await Invoice.findOne({ where: { checkout_session_id: checkoutSession.id } });
|
|
1254
|
+
if (existInvoice && existInvoice.status === 'paid') {
|
|
1255
|
+
await destroyExistingInvoice(existInvoice);
|
|
1256
|
+
}
|
|
1257
|
+
} catch (error: any) {
|
|
1258
|
+
logger.error('Failed to destroy invoice on checkout abort', {
|
|
1259
|
+
checkoutSessionId: checkoutSession.id,
|
|
1260
|
+
error: error.message,
|
|
1261
|
+
});
|
|
1262
|
+
}
|
|
1263
|
+
res.json({ checkoutSessionId: checkoutSession.id, canceledSubscriptions });
|
|
1264
|
+
} catch (err: any) {
|
|
1265
|
+
logger.error('Error aborting stripe for checkout session', {
|
|
1266
|
+
sessionId: req.params.id,
|
|
1267
|
+
error: err.message,
|
|
1268
|
+
stack: err.stack,
|
|
1269
|
+
});
|
|
1270
|
+
res.status(500).json({ error: err.message });
|
|
1271
|
+
}
|
|
1272
|
+
});
|
|
1273
|
+
|
|
1203
1274
|
// for checkout page
|
|
1204
1275
|
router.get('/retrieve/:id', user, async (req, res) => {
|
|
1205
1276
|
const doc = await CheckoutSession.findByPk(req.params.id);
|
|
@@ -1436,6 +1507,19 @@ router.put('/:id/submit', user, ensureCheckoutSessionOpen, async (req, res) => {
|
|
|
1436
1507
|
amount: checkoutSession.amount_subtotal,
|
|
1437
1508
|
});
|
|
1438
1509
|
|
|
1510
|
+
// destroy exist invoice if currency is not aligned
|
|
1511
|
+
try {
|
|
1512
|
+
const existInvoice = await Invoice.findOne({ where: { checkout_session_id: checkoutSession.id } });
|
|
1513
|
+
if (existInvoice && existInvoice.currency_id !== paymentCurrency.id) {
|
|
1514
|
+
await destroyExistingInvoice(existInvoice);
|
|
1515
|
+
}
|
|
1516
|
+
} catch (err) {
|
|
1517
|
+
logger.error('Failed to destroy exist invoice', {
|
|
1518
|
+
error: err.message,
|
|
1519
|
+
checkoutSessionId: checkoutSession.id,
|
|
1520
|
+
});
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1439
1523
|
// create or update payment intent
|
|
1440
1524
|
let paymentIntent: PaymentIntent | null = null;
|
|
1441
1525
|
if (checkoutSession.mode === 'payment') {
|
|
@@ -31,7 +31,7 @@ export default {
|
|
|
31
31
|
},
|
|
32
32
|
onConnect: async ({ userDid, userPk, extraParams }: CallbackArgs) => {
|
|
33
33
|
const { subscriptionId, currencyId, customerId } = extraParams;
|
|
34
|
-
const { amount, invoices, paymentCurrency, paymentMethod } = await ensureSubscriptionForCollectBatch(
|
|
34
|
+
const { amount, invoices, paymentCurrency, paymentMethod, subscription } = await ensureSubscriptionForCollectBatch(
|
|
35
35
|
subscriptionId,
|
|
36
36
|
currencyId,
|
|
37
37
|
customerId
|
|
@@ -52,7 +52,7 @@ export default {
|
|
|
52
52
|
const claims: { [key: string]: object } = {
|
|
53
53
|
prepareTx: {
|
|
54
54
|
type: 'TransferV3Tx',
|
|
55
|
-
description: `Pay all past due invoices for ${
|
|
55
|
+
description: `Pay all past due invoices for subscription ${subscription?.description}`,
|
|
56
56
|
partialTx: { from: userDid, pk: userPk, itx },
|
|
57
57
|
requirement: { tokens },
|
|
58
58
|
chainInfo: {
|
|
@@ -54,7 +54,7 @@ export default {
|
|
|
54
54
|
return {
|
|
55
55
|
prepareTx: {
|
|
56
56
|
type: 'TransferV3Tx',
|
|
57
|
-
description:
|
|
57
|
+
description: 'Approve the transfer to complete payment',
|
|
58
58
|
partialTx: { from: userDid, pk: userPk, itx },
|
|
59
59
|
requirement: { tokens },
|
|
60
60
|
chainInfo: {
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/* eslint-disable no-console */
|
|
2
|
+
import { DataTypes, QueryTypes } from 'sequelize';
|
|
3
|
+
import { Migration } from '../migrate';
|
|
4
|
+
|
|
5
|
+
export const up: Migration = async ({ context }) => {
|
|
6
|
+
try {
|
|
7
|
+
await context.changeColumn('customers', 'did', {
|
|
8
|
+
type: DataTypes.STRING(40),
|
|
9
|
+
allowNull: false,
|
|
10
|
+
unique: true,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
console.log('Successfully added unique constraint to customers.did');
|
|
14
|
+
} catch (error) {
|
|
15
|
+
try {
|
|
16
|
+
const duplicates = await context.sequelize.query(
|
|
17
|
+
`SELECT did, COUNT(*) as count
|
|
18
|
+
FROM customers
|
|
19
|
+
GROUP BY did
|
|
20
|
+
HAVING COUNT(*) > 1
|
|
21
|
+
LIMIT 5`,
|
|
22
|
+
{ type: QueryTypes.SELECT }
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
if (duplicates.length > 0) {
|
|
26
|
+
console.warn('Found duplicate DIDs in customers table (showing first 5):', duplicates);
|
|
27
|
+
console.warn('Skipping unique constraint addition. Please clean up duplicate data manually if needed.');
|
|
28
|
+
} else {
|
|
29
|
+
console.error('Failed to add unique constraint for unknown reason:', error.message);
|
|
30
|
+
}
|
|
31
|
+
} catch (checkError) {
|
|
32
|
+
console.error('Failed to check for duplicates:', checkError.message);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const down: Migration = async ({ context }) => {
|
|
38
|
+
try {
|
|
39
|
+
await context.changeColumn('customers', 'did', {
|
|
40
|
+
type: DataTypes.STRING(40),
|
|
41
|
+
allowNull: false,
|
|
42
|
+
unique: false,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
console.log('Successfully removed unique constraint from customers.did');
|
|
46
|
+
} catch (error) {
|
|
47
|
+
console.warn('Failed to remove unique constraint from customers.did:', error.message);
|
|
48
|
+
}
|
|
49
|
+
};
|