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.
- package/api/src/crons/index.ts +8 -0
- package/api/src/index.ts +2 -0
- package/api/src/integrations/stripe/handlers/invoice.ts +63 -5
- package/api/src/integrations/stripe/handlers/payment-intent.ts +1 -0
- package/api/src/integrations/stripe/resource.ts +253 -2
- package/api/src/libs/currency.ts +31 -0
- package/api/src/libs/discount/coupon.ts +1061 -0
- package/api/src/libs/discount/discount.ts +349 -0
- package/api/src/libs/discount/nft.ts +239 -0
- package/api/src/libs/discount/redemption.ts +636 -0
- package/api/src/libs/discount/vc.ts +73 -0
- package/api/src/libs/env.ts +1 -0
- package/api/src/libs/invoice.ts +44 -10
- package/api/src/libs/math-utils.ts +6 -0
- package/api/src/libs/price.ts +43 -0
- package/api/src/libs/session.ts +242 -57
- package/api/src/libs/subscription.ts +2 -6
- package/api/src/libs/vendor-util/adapters/launcher-adapter.ts +1 -1
- package/api/src/libs/vendor-util/adapters/types.ts +1 -0
- package/api/src/libs/vendor-util/fulfillment.ts +1 -1
- package/api/src/queues/auto-recharge.ts +1 -1
- package/api/src/queues/discount-status.ts +200 -0
- package/api/src/queues/subscription.ts +98 -5
- package/api/src/queues/usage-record.ts +1 -1
- package/api/src/queues/vendors/fulfillment-coordinator.ts +1 -29
- package/api/src/queues/vendors/return-processor.ts +184 -0
- package/api/src/queues/vendors/return-scanner.ts +119 -0
- package/api/src/queues/vendors/status-check.ts +1 -1
- package/api/src/routes/auto-recharge-configs.ts +5 -3
- package/api/src/routes/checkout-sessions.ts +755 -64
- package/api/src/routes/connect/change-payment.ts +6 -1
- package/api/src/routes/connect/change-plan.ts +6 -1
- package/api/src/routes/connect/setup.ts +6 -1
- package/api/src/routes/connect/shared.ts +80 -9
- package/api/src/routes/connect/subscribe.ts +12 -2
- package/api/src/routes/coupons.ts +518 -0
- package/api/src/routes/index.ts +4 -0
- package/api/src/routes/invoices.ts +44 -3
- package/api/src/routes/meter-events.ts +2 -1
- package/api/src/routes/payment-currencies.ts +1 -0
- package/api/src/routes/promotion-codes.ts +482 -0
- package/api/src/routes/subscriptions.ts +23 -2
- package/api/src/routes/vendor.ts +89 -2
- package/api/src/store/migrations/20250904-discount.ts +136 -0
- package/api/src/store/migrations/20250910-timestamp-fields.ts +116 -0
- package/api/src/store/migrations/20250916-add-description-fields.ts +30 -0
- package/api/src/store/migrations/20250918-add-vendor-extends.ts +20 -0
- package/api/src/store/models/checkout-session.ts +17 -2
- package/api/src/store/models/coupon.ts +144 -4
- package/api/src/store/models/discount.ts +23 -10
- package/api/src/store/models/index.ts +13 -2
- package/api/src/store/models/product-vendor.ts +6 -0
- package/api/src/store/models/promotion-code.ts +295 -18
- package/api/src/store/models/types.ts +30 -1
- package/api/tests/libs/session.spec.ts +48 -27
- package/blocklet.yml +1 -1
- package/package.json +20 -20
- package/src/app.tsx +2 -0
- package/src/components/customer/link.tsx +1 -1
- package/src/components/discount/discount-info.tsx +178 -0
- package/src/components/invoice/table.tsx +140 -48
- package/src/components/invoice-pdf/styles.ts +6 -0
- package/src/components/invoice-pdf/template.tsx +59 -33
- package/src/components/metadata/form.tsx +14 -5
- package/src/components/payment-link/actions.tsx +42 -0
- package/src/components/price/form.tsx +91 -65
- package/src/components/product/vendor-config.tsx +5 -3
- package/src/components/promotion/active-redemptions.tsx +534 -0
- package/src/components/promotion/currency-multi-select.tsx +350 -0
- package/src/components/promotion/currency-restrictions.tsx +117 -0
- package/src/components/promotion/product-select.tsx +292 -0
- package/src/components/promotion/promotion-code-form.tsx +534 -0
- package/src/components/subscription/portal/list.tsx +6 -1
- package/src/components/subscription/vendor-service-list.tsx +13 -2
- package/src/locales/en.tsx +227 -0
- package/src/locales/zh.tsx +222 -1
- package/src/pages/admin/billing/subscriptions/detail.tsx +5 -0
- package/src/pages/admin/products/coupons/applicable-products.tsx +166 -0
- package/src/pages/admin/products/coupons/create.tsx +612 -0
- package/src/pages/admin/products/coupons/detail.tsx +538 -0
- package/src/pages/admin/products/coupons/edit.tsx +127 -0
- package/src/pages/admin/products/coupons/index.tsx +210 -3
- package/src/pages/admin/products/index.tsx +22 -3
- package/src/pages/admin/products/products/detail.tsx +12 -2
- package/src/pages/admin/products/promotion-codes/actions.tsx +103 -0
- package/src/pages/admin/products/promotion-codes/create.tsx +235 -0
- package/src/pages/admin/products/promotion-codes/detail.tsx +416 -0
- package/src/pages/admin/products/promotion-codes/list.tsx +247 -0
- package/src/pages/admin/products/promotion-codes/verification-config.tsx +327 -0
- package/src/pages/admin/products/vendors/index.tsx +17 -5
- package/src/pages/customer/subscription/detail.tsx +5 -0
- package/vite.config.ts +4 -3
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
216
|
-
|
|
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/
|
|
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';
|