payment-kit 1.20.11 → 1.20.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/api/src/crons/index.ts +8 -0
  2. package/api/src/index.ts +2 -0
  3. package/api/src/integrations/stripe/handlers/invoice.ts +63 -5
  4. package/api/src/integrations/stripe/handlers/payment-intent.ts +1 -0
  5. package/api/src/integrations/stripe/resource.ts +253 -2
  6. package/api/src/libs/currency.ts +31 -0
  7. package/api/src/libs/discount/coupon.ts +1061 -0
  8. package/api/src/libs/discount/discount.ts +349 -0
  9. package/api/src/libs/discount/nft.ts +239 -0
  10. package/api/src/libs/discount/redemption.ts +636 -0
  11. package/api/src/libs/discount/vc.ts +73 -0
  12. package/api/src/libs/env.ts +1 -0
  13. package/api/src/libs/invoice.ts +44 -10
  14. package/api/src/libs/math-utils.ts +6 -0
  15. package/api/src/libs/price.ts +43 -0
  16. package/api/src/libs/session.ts +242 -57
  17. package/api/src/libs/subscription.ts +2 -6
  18. package/api/src/libs/vendor-util/adapters/launcher-adapter.ts +1 -1
  19. package/api/src/libs/vendor-util/adapters/types.ts +1 -0
  20. package/api/src/libs/vendor-util/fulfillment.ts +1 -1
  21. package/api/src/queues/auto-recharge.ts +1 -1
  22. package/api/src/queues/discount-status.ts +200 -0
  23. package/api/src/queues/subscription.ts +98 -5
  24. package/api/src/queues/usage-record.ts +1 -1
  25. package/api/src/queues/vendors/fulfillment-coordinator.ts +1 -29
  26. package/api/src/queues/vendors/return-processor.ts +184 -0
  27. package/api/src/queues/vendors/return-scanner.ts +119 -0
  28. package/api/src/queues/vendors/status-check.ts +1 -1
  29. package/api/src/routes/auto-recharge-configs.ts +5 -3
  30. package/api/src/routes/checkout-sessions.ts +755 -64
  31. package/api/src/routes/connect/change-payment.ts +6 -1
  32. package/api/src/routes/connect/change-plan.ts +6 -1
  33. package/api/src/routes/connect/setup.ts +6 -1
  34. package/api/src/routes/connect/shared.ts +80 -9
  35. package/api/src/routes/connect/subscribe.ts +12 -2
  36. package/api/src/routes/coupons.ts +518 -0
  37. package/api/src/routes/index.ts +4 -0
  38. package/api/src/routes/invoices.ts +44 -3
  39. package/api/src/routes/meter-events.ts +2 -1
  40. package/api/src/routes/payment-currencies.ts +1 -0
  41. package/api/src/routes/promotion-codes.ts +482 -0
  42. package/api/src/routes/subscriptions.ts +23 -2
  43. package/api/src/routes/vendor.ts +89 -2
  44. package/api/src/store/migrations/20250904-discount.ts +136 -0
  45. package/api/src/store/migrations/20250910-timestamp-fields.ts +116 -0
  46. package/api/src/store/migrations/20250916-add-description-fields.ts +30 -0
  47. package/api/src/store/migrations/20250918-add-vendor-extends.ts +20 -0
  48. package/api/src/store/models/checkout-session.ts +17 -2
  49. package/api/src/store/models/coupon.ts +144 -4
  50. package/api/src/store/models/discount.ts +23 -10
  51. package/api/src/store/models/index.ts +13 -2
  52. package/api/src/store/models/product-vendor.ts +6 -0
  53. package/api/src/store/models/promotion-code.ts +295 -18
  54. package/api/src/store/models/types.ts +30 -1
  55. package/api/tests/libs/session.spec.ts +48 -27
  56. package/blocklet.yml +1 -1
  57. package/package.json +20 -20
  58. package/src/app.tsx +2 -0
  59. package/src/components/customer/link.tsx +1 -1
  60. package/src/components/discount/discount-info.tsx +178 -0
  61. package/src/components/invoice/table.tsx +140 -48
  62. package/src/components/invoice-pdf/styles.ts +6 -0
  63. package/src/components/invoice-pdf/template.tsx +59 -33
  64. package/src/components/metadata/form.tsx +14 -5
  65. package/src/components/payment-link/actions.tsx +42 -0
  66. package/src/components/price/form.tsx +91 -65
  67. package/src/components/product/vendor-config.tsx +5 -3
  68. package/src/components/promotion/active-redemptions.tsx +534 -0
  69. package/src/components/promotion/currency-multi-select.tsx +350 -0
  70. package/src/components/promotion/currency-restrictions.tsx +117 -0
  71. package/src/components/promotion/product-select.tsx +292 -0
  72. package/src/components/promotion/promotion-code-form.tsx +534 -0
  73. package/src/components/subscription/portal/list.tsx +6 -1
  74. package/src/components/subscription/vendor-service-list.tsx +13 -2
  75. package/src/locales/en.tsx +227 -0
  76. package/src/locales/zh.tsx +222 -1
  77. package/src/pages/admin/billing/subscriptions/detail.tsx +5 -0
  78. package/src/pages/admin/products/coupons/applicable-products.tsx +166 -0
  79. package/src/pages/admin/products/coupons/create.tsx +612 -0
  80. package/src/pages/admin/products/coupons/detail.tsx +538 -0
  81. package/src/pages/admin/products/coupons/edit.tsx +127 -0
  82. package/src/pages/admin/products/coupons/index.tsx +210 -3
  83. package/src/pages/admin/products/index.tsx +22 -3
  84. package/src/pages/admin/products/products/detail.tsx +12 -2
  85. package/src/pages/admin/products/promotion-codes/actions.tsx +103 -0
  86. package/src/pages/admin/products/promotion-codes/create.tsx +235 -0
  87. package/src/pages/admin/products/promotion-codes/detail.tsx +416 -0
  88. package/src/pages/admin/products/promotion-codes/list.tsx +247 -0
  89. package/src/pages/admin/products/promotion-codes/verification-config.tsx +327 -0
  90. package/src/pages/admin/products/vendors/index.tsx +17 -5
  91. package/src/pages/customer/subscription/detail.tsx +5 -0
  92. package/vite.config.ts +4 -3
@@ -0,0 +1,200 @@
1
+ import pAll from 'p-all';
2
+ import createQueue from '../libs/queue';
3
+ import { Coupon, PromotionCode } from '../store/models';
4
+ import logger from '../libs/logger';
5
+ import { events } from '../libs/event';
6
+ import { validCoupon, validPromotionCode } from '../libs/discount/coupon';
7
+
8
+ export type DiscountType = 'coupon' | 'promotion-code';
9
+
10
+ export interface DiscountStatusJobData {
11
+ type: DiscountType;
12
+ id: string;
13
+ }
14
+
15
+ /**
16
+ * Process discount status check for both coupons and promotion codes
17
+ */
18
+ export async function processDiscountStatus(job: DiscountStatusJobData) {
19
+ logger.info('Processing discount status check job', { job });
20
+ const { type, id } = job;
21
+
22
+ if (type === 'coupon') {
23
+ await processCouponStatus(id);
24
+ } else if (type === 'promotion-code') {
25
+ await processPromotionCodeStatus(id);
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Process coupon status check
31
+ */
32
+ async function processCouponStatus(couponId: string) {
33
+ const coupon = await Coupon.findByPk(couponId);
34
+ if (!coupon) {
35
+ return;
36
+ }
37
+
38
+ // Skip if already invalid
39
+ if (!coupon.valid) {
40
+ logger.info('Coupon is already invalid, skipping', { couponId });
41
+ return;
42
+ }
43
+
44
+ // Use the existing validCoupon function to check validity
45
+ const validation = validCoupon(coupon);
46
+
47
+ if (!validation.valid) {
48
+ // Mark coupon as invalid
49
+ await coupon.update({ valid: false, metadata: { ...coupon.metadata, invalid_reason: validation.reason } });
50
+ logger.info('Coupon marked as invalid', {
51
+ couponId,
52
+ reason: validation.reason,
53
+ redeem_by: coupon.redeem_by,
54
+ max_redemptions: coupon.max_redemptions,
55
+ times_redeemed: coupon.times_redeemed,
56
+ });
57
+ const promotionCodes = await PromotionCode.findAll({ where: { coupon_id: couponId, active: true } });
58
+ await pAll(
59
+ promotionCodes.map(
60
+ (promotionCode) => () =>
61
+ promotionCode.update({
62
+ active: false,
63
+ metadata: { ...promotionCode.metadata, invalid_reason: validation.reason },
64
+ })
65
+ ),
66
+ { concurrency: 5 }
67
+ );
68
+ logger.info('Promotion codes marked as inactive', {
69
+ promotionCodeIds: promotionCodes.map((pc) => pc.id),
70
+ reason: validation.reason,
71
+ });
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Process promotion code status check
77
+ */
78
+ async function processPromotionCodeStatus(promotionCodeId: string) {
79
+ const promotionCode = await PromotionCode.findByPk(promotionCodeId);
80
+ if (!promotionCode) {
81
+ logger.warn('Promotion code not found for status check', { promotionCodeId });
82
+ return;
83
+ }
84
+
85
+ // Skip if already inactive
86
+ if (!promotionCode.active) {
87
+ logger.info('Promotion code is already inactive, skipping', { promotionCodeId });
88
+ return;
89
+ }
90
+
91
+ // Use the existing validPromotionCode function to check validity
92
+ const validation = await validPromotionCode(promotionCode, {});
93
+
94
+ if (!validation.valid) {
95
+ // Mark promotion code as inactive
96
+ await promotionCode.update({
97
+ active: false,
98
+ metadata: { ...promotionCode.metadata, invalid_reason: validation.reason },
99
+ });
100
+ logger.info('Promotion code marked as inactive', {
101
+ promotionCodeId,
102
+ reason: validation.reason,
103
+ expires_at: promotionCode.expires_at,
104
+ max_redemptions: promotionCode.max_redemptions,
105
+ times_redeemed: promotionCode.times_redeemed,
106
+ });
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Schedule discount status monitoring jobs
112
+ */
113
+ export async function addDiscountStatusJob(
114
+ record: Coupon | PromotionCode,
115
+ type: DiscountType = 'coupon',
116
+ replace: boolean = false,
117
+ options: { delay?: number; runAt?: number } = {}
118
+ ) {
119
+ const { id } = record;
120
+ const jobId = `discount-status-${type}-${id}`;
121
+
122
+ // Remove existing job if present
123
+ const existingJob = await discountStatusQueue.get(jobId);
124
+ if (existingJob && !replace) {
125
+ logger.info('Discount job already exists, skipping', { type, id, jobId });
126
+ return;
127
+ }
128
+ if (existingJob && replace) {
129
+ await discountStatusQueue.delete(jobId);
130
+ logger.info('Removed existing discount status job for update', { type, id, jobId });
131
+ }
132
+ discountStatusQueue.push({ id: jobId, job: { type, id }, ...options });
133
+ }
134
+
135
+ // Create unified discount status queue
136
+ export const discountStatusQueue = createQueue<DiscountStatusJobData>({
137
+ name: 'discount-status',
138
+ onJob: processDiscountStatus,
139
+ options: {
140
+ concurrency: 5,
141
+ maxRetries: 3,
142
+ enableScheduledJob: true,
143
+ },
144
+ });
145
+
146
+ export const startDiscountStatusQueue = async () => {
147
+ logger.info('Starting discount status queue...');
148
+
149
+ const coupons = await Coupon.findAll({ where: { valid: true } });
150
+ const promotionCodes = await PromotionCode.findAll({ where: { active: true } });
151
+ await pAll(
152
+ coupons.map(
153
+ (coupon) => () =>
154
+ addDiscountStatusJob(coupon, 'coupon', false, {
155
+ runAt: coupon.redeem_by,
156
+ })
157
+ ),
158
+ { concurrency: 5 }
159
+ );
160
+ await pAll(
161
+ promotionCodes.map(
162
+ (promotionCode) => () =>
163
+ addDiscountStatusJob(promotionCode, 'promotion-code', false, {
164
+ runAt: promotionCode.expires_at,
165
+ })
166
+ ),
167
+ { concurrency: 5 }
168
+ );
169
+ };
170
+
171
+ events.on('coupon.created', (coupon: Coupon) => {
172
+ logger.info('Received coupon.created event', { couponId: coupon.id });
173
+ addDiscountStatusJob(coupon, 'coupon', true, {
174
+ runAt: coupon.redeem_by,
175
+ });
176
+ });
177
+
178
+ events.on('promotion-code.created', (promotionCode: PromotionCode) => {
179
+ addDiscountStatusJob(promotionCode, 'promotion-code', true, {
180
+ runAt: promotionCode.expires_at,
181
+ });
182
+ });
183
+
184
+ events.on('promotion-code.updated', (promotionCode: PromotionCode) => {
185
+ if (promotionCode.active) {
186
+ addDiscountStatusJob(promotionCode, 'promotion-code', true, {
187
+ runAt: promotionCode.expires_at,
188
+ });
189
+ }
190
+ });
191
+
192
+ events.on('discount-status.queued', (record, type, replace) => {
193
+ try {
194
+ addDiscountStatusJob(record, type, replace);
195
+ events.emit('discount-status.queued.done', record, type, replace);
196
+ } catch (error) {
197
+ logger.error('Error in discount-status.queued', { recordId: record.id, type, replace });
198
+ events.emit('discount-status.queued.error', record, type, replace);
199
+ }
200
+ });
@@ -1,5 +1,6 @@
1
1
  import type { LiteralUnion } from 'type-fest';
2
2
 
3
+ import { BN } from '@ocap/util';
3
4
  import { Op } from 'sequelize';
4
5
  import { createEvent } from '../libs/audit';
5
6
  import { ensurePassportRevoked } from '../integrations/blocklet/passport';
@@ -34,8 +35,10 @@ import { Invoice } from '../store/models/invoice';
34
35
  import { Price } from '../store/models/price';
35
36
  import { Subscription } from '../store/models/subscription';
36
37
  import { SubscriptionItem } from '../store/models/subscription-item';
38
+ import { getValidDiscountsForSubscriptionBilling } from '../libs/discount/coupon';
37
39
  import { invoiceQueue } from './invoice';
38
40
  import { SubscriptionWillCanceledSchedule } from '../crons/subscription-will-canceled';
41
+ import { applySubscriptionDiscount } from '../libs/discount/discount';
39
42
 
40
43
  type SubscriptionJob = {
41
44
  subscriptionId: string;
@@ -190,7 +193,90 @@ const doHandleSubscriptionInvoice = async ({
190
193
  return null;
191
194
  }
192
195
 
193
- const amount = getSubscriptionCycleAmount(expandedItems, currency.id);
196
+ // Get valid discounts for this subscription billing period
197
+ const { validDiscounts, expiredDiscounts } = await getValidDiscountsForSubscriptionBilling({
198
+ subscriptionId: subscription.id,
199
+ customerId: subscription.customer_id,
200
+ });
201
+
202
+ logger.info('Valid discounts for subscription billing', {
203
+ subscriptionId: subscription.id,
204
+ validDiscounts: validDiscounts.map((d) => d.id),
205
+ });
206
+
207
+ // Log expired discounts for monitoring
208
+ if (expiredDiscounts.length > 0) {
209
+ logger.info('Found expired discounts during subscription billing', {
210
+ subscriptionId: subscription.id,
211
+ expiredDiscounts: expiredDiscounts.map((d) => d.id),
212
+ });
213
+ }
214
+
215
+ // Calculate subscription amount with discounts
216
+ const baseAmount = await getSubscriptionCycleAmount(expandedItems, currency.id);
217
+
218
+ // Apply discounts - simplified to use only one discount (first valid one)
219
+ const firstValidDiscount = validDiscounts?.[0] || null;
220
+ logger.info('First valid discount', {
221
+ subscriptionId: subscription.id,
222
+ firstValidDiscount: firstValidDiscount?.id,
223
+ });
224
+ let enhancedLineItems = expandedItems;
225
+ let discountSummary: {
226
+ appliedCoupon: string | null;
227
+ discountAmount: string;
228
+ totalDiscountAmount: string;
229
+ finalTotal: string;
230
+ } = {
231
+ appliedCoupon: null,
232
+ discountAmount: '0',
233
+ totalDiscountAmount: '0',
234
+ finalTotal: baseAmount.total,
235
+ };
236
+
237
+ if (firstValidDiscount) {
238
+ // Check if subscription is still in trial period for accurate discount calculation
239
+ const now = Math.floor(Date.now() / 1000);
240
+ const isTrialing = !!(subscription.trial_end && subscription.trial_end > now);
241
+
242
+ const discountApplication = await applySubscriptionDiscount({
243
+ lineItems: expandedItems,
244
+ discount: firstValidDiscount,
245
+ currency,
246
+ totalAmount: baseAmount.total,
247
+ billingContext: {
248
+ trialing: isTrialing,
249
+ },
250
+ });
251
+
252
+ logger.info('Discount application', {
253
+ subscriptionId: subscription.id,
254
+ discountApplication,
255
+ });
256
+
257
+ enhancedLineItems = discountApplication.enhancedLineItems;
258
+ discountSummary = discountApplication.discountSummary;
259
+ }
260
+
261
+ const { appliedCoupon, totalDiscountAmount, finalTotal } = discountSummary;
262
+
263
+ // Additional safety check to ensure final total is never negative
264
+ const safeFinalTotal = new BN(finalTotal || '0').lt(new BN('0')) ? '0' : finalTotal || '0';
265
+
266
+ // Prepare data for invoice creation
267
+ const appliedDiscounts = appliedCoupon && firstValidDiscount ? [firstValidDiscount.id] : [];
268
+ const discountBreakdownForInvoice =
269
+ appliedCoupon && firstValidDiscount ? [{ amount: totalDiscountAmount, discount: firstValidDiscount.id }] : [];
270
+
271
+ if (validDiscounts.length > 0) {
272
+ logger.info('Subscription billing discount calculation completed', {
273
+ subscriptionId: subscription.id,
274
+ originalTotal: baseAmount.total,
275
+ totalDiscount: totalDiscountAmount,
276
+ finalTotal,
277
+ appliedDiscounts: appliedDiscounts.length,
278
+ });
279
+ }
194
280
 
195
281
  const { invoice } = await ensureInvoiceAndItems({
196
282
  customer,
@@ -198,7 +284,7 @@ const doHandleSubscriptionInvoice = async ({
198
284
  subscription,
199
285
  trialing: false,
200
286
  metered: true,
201
- lineItems: expandedItems,
287
+ lineItems: enhancedLineItems,
202
288
  props: {
203
289
  livemode: subscription.livemode,
204
290
  description: `Subscription ${reason}`,
@@ -209,11 +295,18 @@ const doHandleSubscriptionInvoice = async ({
209
295
  status,
210
296
  billing_reason: `subscription_${reason}`,
211
297
  currency_id: subscription.currency_id,
212
- total: amount.total,
298
+ // Set correct subtotal (original amount) and total (after discount)
299
+ subtotal: baseAmount.total,
300
+ total: safeFinalTotal,
213
301
  payment_settings: subscription.payment_settings,
214
302
  default_payment_method_id: subscription.default_payment_method_id,
215
- metadata,
216
- } as Invoice,
303
+ // Discount information for Invoice
304
+ discounts: appliedDiscounts,
305
+ total_discount_amounts: discountBreakdownForInvoice,
306
+ metadata: {
307
+ ...metadata,
308
+ },
309
+ } as unknown as Invoice,
217
310
  });
218
311
 
219
312
  logger.info('Invoice created for subscription', { invoice: invoice.id, subscription: subscription.id });
@@ -4,7 +4,7 @@ import dayjs from '../libs/dayjs';
4
4
  import { getLock } from '../libs/lock';
5
5
  import logger from '../libs/logger';
6
6
  import createQueue from '../libs/queue';
7
- import { getPriceUintAmountByCurrency } from '../libs/session';
7
+ import { getPriceUintAmountByCurrency } from '../libs/price';
8
8
  import { Invoice, PaymentCurrency, Price, SubscriptionItem, TLineItemExpanded, UsageRecord } from '../store/models';
9
9
  import { Subscription } from '../store/models/subscription';
10
10
  import { invoiceQueue } from './invoice';
@@ -13,35 +13,7 @@ import { Refund } from '../../store/models/refund';
13
13
  import { sequelize } from '../../store/sequelize';
14
14
  import { depositVaultQueue } from '../payment';
15
15
 
16
- type VendorInfo = {
17
- vendor_id: string;
18
- vendor_key: string;
19
- order_id: string;
20
- status:
21
- | 'pending'
22
- | 'processing'
23
- | 'completed'
24
- | 'failed'
25
- | 'cancelled'
26
- | 'max_retries_exceeded'
27
- | 'return_requested'
28
- | 'sent';
29
- service_url?: string;
30
- error_message?: string;
31
- amount: string;
32
-
33
- attempts?: number;
34
- lastAttemptAt?: string;
35
- completedAt?: string;
36
- commissionAmount?: string;
37
-
38
- returnRequest?: {
39
- reason: string;
40
- requestedAt: string;
41
- status: 'pending' | 'accepted' | 'rejected';
42
- returnDetails?: string;
43
- };
44
- };
16
+ export type VendorInfo = NonNullable<CheckoutSession['vendor_info']>[number];
45
17
 
46
18
  interface CoordinatorJob {
47
19
  checkoutSessionId: string;
@@ -0,0 +1,184 @@
1
+ import logger from '../../libs/logger';
2
+ import createQueue from '../../libs/queue';
3
+ import { VendorFulfillmentService } from '../../libs/vendor-util/fulfillment';
4
+ import { CheckoutSession } from '../../store/models';
5
+ import { VendorInfo } from './fulfillment-coordinator';
6
+
7
+ type ReturnProcessorJob = {
8
+ checkoutSessionId: string;
9
+ };
10
+
11
+ export const vendorReturnProcessorQueue = createQueue<ReturnProcessorJob>({
12
+ name: 'vendor-return-processor',
13
+ onJob: handleReturnProcessorJob,
14
+ options: {
15
+ concurrency: 1,
16
+ maxRetries: 2,
17
+ retryDelay: 30 * 1000,
18
+ },
19
+ });
20
+
21
+ async function handleReturnProcessorJob(job: ReturnProcessorJob): Promise<void> {
22
+ const { checkoutSessionId } = job;
23
+
24
+ logger.info('Starting vendor return processor job', {
25
+ checkoutSessionId,
26
+ });
27
+
28
+ try {
29
+ const checkoutSession = await CheckoutSession.findByPk(checkoutSessionId);
30
+
31
+ if (!checkoutSession) {
32
+ logger.warn('CheckoutSession not found', { checkoutSessionId });
33
+ return;
34
+ }
35
+
36
+ const vendorInfoList = checkoutSession.vendor_info as VendorInfo[];
37
+ let hasChanges = false;
38
+
39
+ let i = -1;
40
+ for (const vendor of vendorInfoList) {
41
+ i++;
42
+ // Only process vendors with 'completed' status
43
+ if (vendor.status !== 'completed') {
44
+ logger.info('Skipping vendor return because status is not completed', {
45
+ checkoutSessionId,
46
+ vendorId: vendor.vendor_id,
47
+ orderId: vendor.order_id,
48
+ status: vendor.status,
49
+ });
50
+ // eslint-disable-next-line no-continue
51
+ continue;
52
+ }
53
+
54
+ try {
55
+ logger.info('Processing vendor return', {
56
+ checkoutSessionId,
57
+ vendorId: vendor.vendor_id,
58
+ orderId: vendor.order_id,
59
+ });
60
+
61
+ // eslint-disable-next-line no-await-in-loop
62
+ const returnResult = await callVendorReturn(vendor, checkoutSession);
63
+
64
+ if (returnResult.success) {
65
+ // Return successful, update status to 'returned'
66
+ vendorInfoList[i] = {
67
+ ...vendor,
68
+ status: 'returned',
69
+ lastAttemptAt: new Date().toISOString(),
70
+ };
71
+ hasChanges = true;
72
+
73
+ logger.info('Vendor return successful', {
74
+ checkoutSessionId,
75
+ vendorId: vendor.vendor_id,
76
+ orderId: vendor.order_id,
77
+ });
78
+ } else {
79
+ // Return failed, keep 'completed' status for next scan retry
80
+ vendorInfoList[i] = {
81
+ ...vendor,
82
+ lastAttemptAt: new Date().toISOString(),
83
+ error_message: returnResult.message || 'Return request failed',
84
+ };
85
+
86
+ logger.warn('Vendor return failed', {
87
+ checkoutSessionId,
88
+ vendorId: vendor.vendor_id,
89
+ orderId: vendor.order_id,
90
+ error: returnResult.message,
91
+ });
92
+ }
93
+ } catch (error: any) {
94
+ logger.error('Error processing vendor return', {
95
+ checkoutSessionId,
96
+ vendorId: vendor.vendor_id,
97
+ orderId: vendor.order_id,
98
+ error: error.message,
99
+ });
100
+
101
+ // Record error but keep status unchanged for retry
102
+ vendorInfoList[i] = {
103
+ ...vendor,
104
+ lastAttemptAt: new Date().toISOString(),
105
+ error_message: error.message,
106
+ };
107
+ hasChanges = true;
108
+ }
109
+ }
110
+
111
+ // Update vendor_info if there are changes
112
+ if (hasChanges) {
113
+ await checkoutSession.update({ vendor_info: vendorInfoList });
114
+ }
115
+
116
+ // Check if all vendors have been returned
117
+ const allReturned = vendorInfoList.every((vendor) => vendor.status === 'returned');
118
+
119
+ if (allReturned && checkoutSession.fulfillment_status !== 'returned') {
120
+ await checkoutSession.update({ fulfillment_status: 'returned' });
121
+
122
+ logger.info('All vendors returned, updated fulfillment status to returned', {
123
+ checkoutSessionId,
124
+ totalVendors: vendorInfoList.length,
125
+ });
126
+ }
127
+
128
+ logger.info('Vendor return processor job completed', {
129
+ checkoutSessionId,
130
+ totalVendors: vendorInfoList.length,
131
+ allReturned,
132
+ hasChanges,
133
+ });
134
+ } catch (error: any) {
135
+ logger.error('Vendor return processor job failed', {
136
+ checkoutSessionId,
137
+ error,
138
+ });
139
+ throw error;
140
+ }
141
+ }
142
+
143
+ async function callVendorReturn(
144
+ vendor: VendorInfo,
145
+ checkoutSession: CheckoutSession
146
+ ): Promise<{ success: boolean; message?: string }> {
147
+ try {
148
+ const vendorAdapter = await VendorFulfillmentService.getVendorAdapter(vendor.vendor_key);
149
+
150
+ if (!vendorAdapter) {
151
+ return {
152
+ success: false,
153
+ message: `No adapter found for vendor: ${vendor.vendor_id}`,
154
+ };
155
+ }
156
+
157
+ const returnResult = await vendorAdapter.requestReturn({
158
+ orderId: vendor.order_id,
159
+ reason: 'Subscription canceled',
160
+ paymentIntentId: checkoutSession.payment_intent_id || '',
161
+ customParams: {
162
+ checkoutSessionId: checkoutSession.id,
163
+ subscriptionId: checkoutSession.subscription_id,
164
+ vendorKey: vendor.vendor_key,
165
+ },
166
+ });
167
+
168
+ return {
169
+ success: returnResult.success || false,
170
+ message: returnResult.message,
171
+ };
172
+ } catch (error: any) {
173
+ logger.error('Failed to call vendor return API', {
174
+ vendorId: vendor.vendor_id,
175
+ orderId: vendor.order_id,
176
+ error: error.message,
177
+ });
178
+
179
+ return {
180
+ success: false,
181
+ message: error.message,
182
+ };
183
+ }
184
+ }
@@ -0,0 +1,119 @@
1
+ import { Op } from 'sequelize';
2
+ import logger from '../../libs/logger';
3
+ import createQueue from '../../libs/queue';
4
+ import { CheckoutSession, Subscription } from '../../store/models';
5
+ import { vendorReturnProcessorQueue } from './return-processor';
6
+ import { VendorInfo } from './fulfillment-coordinator';
7
+
8
+ export const vendorReturnScannerQueue = createQueue({
9
+ name: 'vendor-return-scanner',
10
+ onJob: handleReturnScannerJob,
11
+ options: {
12
+ concurrency: 1,
13
+ maxRetries: 3,
14
+ retryDelay: 60 * 1000,
15
+ },
16
+ });
17
+
18
+ async function handleReturnScannerJob(): Promise<void> {
19
+ try {
20
+ const sessionsNeedingReturn = await findSessionsNeedingVendorReturn();
21
+ if (sessionsNeedingReturn.length === 0) {
22
+ logger.info('No checkout sessions needing vendor return');
23
+ return;
24
+ }
25
+
26
+ logger.info('Found checkout sessions needing vendor return', {
27
+ count: sessionsNeedingReturn.length,
28
+ sessionIds: sessionsNeedingReturn.map((s) => s.id),
29
+ });
30
+
31
+ for (const session of sessionsNeedingReturn) {
32
+ const id = `vendor-return-process-${session.id}`;
33
+ // eslint-disable-next-line no-await-in-loop
34
+ const exists = await vendorReturnProcessorQueue.get(id);
35
+ if (!exists) {
36
+ vendorReturnProcessorQueue.push({
37
+ id,
38
+ job: {
39
+ checkoutSessionId: session.id,
40
+ },
41
+ });
42
+ }
43
+ }
44
+ } catch (error: any) {
45
+ logger.error('Vendor return scanner job failed', {
46
+ error,
47
+ });
48
+ throw error;
49
+ }
50
+ }
51
+
52
+ async function findSessionsNeedingVendorReturn(): Promise<CheckoutSession[]> {
53
+ try {
54
+ // First, find canceled subscriptions
55
+ const canceledSubscriptions = await Subscription.findAll({
56
+ where: { status: 'canceled' },
57
+ attributes: ['id'],
58
+ });
59
+
60
+ const canceledSubscriptionIds = canceledSubscriptions.map((sub) => sub.id);
61
+
62
+ // Find checkout sessions with completed fulfillment and canceled subscriptions
63
+ const readyToReturnSessions = await CheckoutSession.findAll({
64
+ where: {
65
+ fulfillment_status: 'completed',
66
+ subscription_id: { [Op.in]: canceledSubscriptionIds },
67
+ },
68
+ order: [['updated_at', 'DESC']],
69
+ limit: 100,
70
+ });
71
+
72
+ // Find checkout sessions already in returning status
73
+ const returningSessions = await CheckoutSession.findAll({
74
+ where: {
75
+ fulfillment_status: 'returning',
76
+ subscription_id: { [Op.ne]: null as any },
77
+ },
78
+ order: [['updated_at', 'DESC']],
79
+ limit: 100,
80
+ });
81
+
82
+ // Update canceled sessions to returning status
83
+ if (readyToReturnSessions.length > 0) {
84
+ await CheckoutSession.update(
85
+ {
86
+ fulfillment_status: 'returning',
87
+ },
88
+ {
89
+ where: {
90
+ id: { [Op.in]: readyToReturnSessions.map((s) => s.id) },
91
+ },
92
+ }
93
+ );
94
+ }
95
+
96
+ const sessions = [...readyToReturnSessions, ...returningSessions];
97
+
98
+ // Filter sessions that have vendors needing return
99
+ const filteredSessions = sessions.filter((session) => {
100
+ const vendorInfoList = session.vendor_info as VendorInfo[];
101
+
102
+ if (!vendorInfoList || vendorInfoList.length === 0) {
103
+ return false;
104
+ }
105
+ const hasVendorNeedingReturn = vendorInfoList.some((vendor) => vendor.status === 'completed');
106
+ return hasVendorNeedingReturn;
107
+ });
108
+
109
+ return filteredSessions;
110
+ } catch (error: any) {
111
+ logger.error('Failed to find sessions needing vendor return', { error });
112
+ throw error;
113
+ }
114
+ }
115
+
116
+ export function scheduleVendorReturnScan(): void {
117
+ const scanId = `scan-${Date.now()}`;
118
+ vendorReturnScannerQueue.push({ id: scanId, job: {} });
119
+ }
@@ -1,5 +1,5 @@
1
1
  import { joinURL } from 'ufo';
2
- import { VendorAuth } from '@blocklet/payment-vendor';
2
+ import { Auth as VendorAuth } from '@blocklet/payment-vendor';
3
3
  import createQueue from '../../libs/queue';
4
4
  import { CheckoutSession } from '../../store/models/checkout-session';
5
5
  import { ProductVendor } from '../../store/models';