payment-kit 1.20.5 → 1.20.7
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 +11 -3
- package/api/src/index.ts +18 -14
- package/api/src/libs/env.ts +7 -0
- package/api/src/libs/url.ts +77 -0
- package/api/src/libs/vendor/adapters/factory.ts +40 -0
- package/api/src/libs/vendor/adapters/launcher-adapter.ts +179 -0
- package/api/src/libs/vendor/adapters/types.ts +91 -0
- package/api/src/libs/vendor/fulfillment.ts +317 -0
- package/api/src/queues/payment.ts +14 -10
- package/api/src/queues/payout.ts +1 -0
- package/api/src/queues/vendor/commission.ts +192 -0
- package/api/src/queues/vendor/fulfillment-coordinator.ts +625 -0
- package/api/src/queues/vendor/fulfillment.ts +98 -0
- package/api/src/queues/vendor/status-check.ts +178 -0
- package/api/src/routes/checkout-sessions.ts +12 -0
- package/api/src/routes/index.ts +2 -0
- package/api/src/routes/products.ts +72 -1
- package/api/src/routes/vendor.ts +527 -0
- package/api/src/store/migrations/20250820-add-product-vendor.ts +102 -0
- package/api/src/store/migrations/20250822-add-vendor-config-to-products.ts +56 -0
- package/api/src/store/models/checkout-session.ts +84 -18
- package/api/src/store/models/index.ts +3 -0
- package/api/src/store/models/payout.ts +11 -0
- package/api/src/store/models/product-vendor.ts +118 -0
- package/api/src/store/models/product.ts +15 -0
- package/blocklet.yml +8 -2
- package/doc/vendor_fulfillment_system.md +929 -0
- package/package.json +5 -4
- package/src/components/collapse.tsx +1 -0
- package/src/components/product/edit.tsx +9 -0
- package/src/components/product/form.tsx +11 -0
- package/src/components/product/vendor-config.tsx +249 -0
- package/src/components/vendor/actions.tsx +145 -0
- package/src/locales/en.tsx +89 -0
- package/src/locales/zh.tsx +89 -0
- package/src/pages/admin/products/index.tsx +11 -1
- package/src/pages/admin/products/products/detail.tsx +79 -2
- package/src/pages/admin/products/vendors/create.tsx +418 -0
- package/src/pages/admin/products/vendors/index.tsx +313 -0
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import { BN } from '@ocap/util';
|
|
2
|
+
import { CheckoutSession } from '../../store/models/checkout-session';
|
|
3
|
+
import { Product } from '../../store/models/product';
|
|
4
|
+
import { ProductVendor } from '../../store/models/product-vendor';
|
|
5
|
+
import { Payout } from '../../store/models/payout';
|
|
6
|
+
import { PaymentIntent } from '../../store/models/payment-intent';
|
|
7
|
+
import { Price } from '../../store/models/price';
|
|
8
|
+
import { calculateVendorCommission, VendorAdapterFactory } from './adapters/factory';
|
|
9
|
+
import { Customer } from '../../store/models';
|
|
10
|
+
import logger from '../logger';
|
|
11
|
+
|
|
12
|
+
export interface VendorFulfillmentResult {
|
|
13
|
+
vendorId: string;
|
|
14
|
+
vendorKey: string;
|
|
15
|
+
orderId: string;
|
|
16
|
+
status: 'pending' | 'processing' | 'completed' | 'failed';
|
|
17
|
+
commissionAmount: string;
|
|
18
|
+
commissionType: 'percentage' | 'fixed_amount';
|
|
19
|
+
commissionRate: number;
|
|
20
|
+
productId?: string; // Add product ID for multi-product tracking
|
|
21
|
+
errorMessage?: string;
|
|
22
|
+
serviceUrl?: string;
|
|
23
|
+
estimatedTime?: number;
|
|
24
|
+
vendorOrderId?: string;
|
|
25
|
+
progress?: number;
|
|
26
|
+
orderDetails?: any;
|
|
27
|
+
installationInfo?: any;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface MultiVendorFulfillmentResult {
|
|
31
|
+
checkoutSessionId: string;
|
|
32
|
+
success: boolean;
|
|
33
|
+
results: VendorFulfillmentResult[];
|
|
34
|
+
totalCommissionAmount: string;
|
|
35
|
+
errorMessage?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class VendorFulfillmentService {
|
|
39
|
+
// Execute single vendor fulfillment (public interface for coordinator system)
|
|
40
|
+
static async fulfillSingleVendorOrder(
|
|
41
|
+
orderInfo: {
|
|
42
|
+
checkoutSessionId: string;
|
|
43
|
+
amount_total: string;
|
|
44
|
+
customer_id: string;
|
|
45
|
+
payment_intent_id: string | null;
|
|
46
|
+
currency_id: string;
|
|
47
|
+
customer_did: string;
|
|
48
|
+
},
|
|
49
|
+
vendorConfig: any
|
|
50
|
+
): Promise<VendorFulfillmentResult> {
|
|
51
|
+
try {
|
|
52
|
+
const vendor = await ProductVendor.findByPk(vendorConfig.vendor_id);
|
|
53
|
+
|
|
54
|
+
if (!vendor) {
|
|
55
|
+
throw new Error(`Vendor not found: ${vendorConfig.vendor_id}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const adapter = this.getVendorAdapter(vendor.vendor_key);
|
|
59
|
+
|
|
60
|
+
// Calculate commission amount
|
|
61
|
+
const commissionRate = vendorConfig.commission_rate || vendor.default_commission_rate;
|
|
62
|
+
const commissionType = vendorConfig.commission_type || vendor.default_commission_type;
|
|
63
|
+
const commissionAmount = calculateVendorCommission(
|
|
64
|
+
orderInfo.amount_total,
|
|
65
|
+
commissionRate,
|
|
66
|
+
commissionType,
|
|
67
|
+
vendorConfig.amount
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const userEmail = (await Customer.findByPk(orderInfo.customer_id))?.email || '';
|
|
71
|
+
|
|
72
|
+
const fulfillmentResult = await adapter.fulfillOrder({
|
|
73
|
+
checkoutSessionId: orderInfo.checkoutSessionId,
|
|
74
|
+
productCode: vendorConfig.vendor_id,
|
|
75
|
+
customerId: orderInfo.customer_id || '',
|
|
76
|
+
quantity: 1,
|
|
77
|
+
paymentIntentId: orderInfo.payment_intent_id || '',
|
|
78
|
+
amount: orderInfo.amount_total,
|
|
79
|
+
currency: orderInfo.currency_id,
|
|
80
|
+
|
|
81
|
+
description: 'This is build a instance for Third Party',
|
|
82
|
+
userInfo: {
|
|
83
|
+
userDid: orderInfo.customer_did!,
|
|
84
|
+
email: userEmail,
|
|
85
|
+
description: 'This is build a instance for Third Party',
|
|
86
|
+
},
|
|
87
|
+
deliveryParams: {
|
|
88
|
+
blockletMetaUrl: vendor.metadata?.blockletMetaUrl,
|
|
89
|
+
customParams: vendorConfig.custom_params,
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
logger.info('Single vendor fulfillment completed', {
|
|
94
|
+
vendorId: vendorConfig.vendor_id,
|
|
95
|
+
orderId: fulfillmentResult.orderId,
|
|
96
|
+
status: fulfillmentResult.status,
|
|
97
|
+
commissionAmount,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
vendorId: vendorConfig.vendor_id,
|
|
102
|
+
vendorKey: vendorConfig.vendor_id,
|
|
103
|
+
orderId: fulfillmentResult.orderId,
|
|
104
|
+
status: fulfillmentResult.status,
|
|
105
|
+
commissionAmount,
|
|
106
|
+
commissionType,
|
|
107
|
+
commissionRate,
|
|
108
|
+
errorMessage: fulfillmentResult.message,
|
|
109
|
+
serviceUrl: fulfillmentResult.serviceUrl,
|
|
110
|
+
estimatedTime: fulfillmentResult.estimatedTime,
|
|
111
|
+
vendorOrderId: fulfillmentResult.vendorOrderId,
|
|
112
|
+
progress: fulfillmentResult.progress,
|
|
113
|
+
orderDetails: fulfillmentResult.orderDetails,
|
|
114
|
+
installationInfo: fulfillmentResult.installationInfo,
|
|
115
|
+
};
|
|
116
|
+
} catch (error: any) {
|
|
117
|
+
logger.error('Single vendor fulfillment failed', {
|
|
118
|
+
vendorId: vendorConfig.vendor_id,
|
|
119
|
+
error: error.message,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Calculate commission data (no fulfillment, only calculate commission)
|
|
127
|
+
private static async calculateCommissionData(checkoutSessionId: string): Promise<VendorFulfillmentResult[]> {
|
|
128
|
+
try {
|
|
129
|
+
logger.info('Calculating commission data for checkout session', { checkoutSessionId });
|
|
130
|
+
|
|
131
|
+
const checkoutSession = await CheckoutSession.findByPk(checkoutSessionId);
|
|
132
|
+
if (!checkoutSession) {
|
|
133
|
+
throw new Error(`CheckoutSession not found: ${checkoutSessionId}`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const priceIds = checkoutSession.line_items.map((item: any) => item.price_id).filter(Boolean);
|
|
137
|
+
|
|
138
|
+
if (priceIds.length === 0) {
|
|
139
|
+
throw new Error(`No price IDs found in checkout session line items: ${checkoutSessionId}`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Find corresponding product_ids through price_ids
|
|
143
|
+
const prices = await Price.findAll({
|
|
144
|
+
where: { id: priceIds },
|
|
145
|
+
attributes: ['id', 'product_id'],
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// Create a map of price_id to line_item for amount calculation
|
|
149
|
+
const priceToLineItemMap = new Map();
|
|
150
|
+
checkoutSession.line_items.forEach((item: any) => {
|
|
151
|
+
if (item.price_id) {
|
|
152
|
+
priceToLineItemMap.set(item.price_id, item);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const productIds = prices.map((price: any) => price.product_id).filter(Boolean);
|
|
157
|
+
|
|
158
|
+
if (productIds.length === 0) {
|
|
159
|
+
throw new Error(`No product IDs found from prices: ${checkoutSessionId}`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Get all products with vendor configurations
|
|
163
|
+
const products = await Product.findAll({
|
|
164
|
+
where: { id: productIds },
|
|
165
|
+
attributes: ['id', 'vendor_config'],
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const commissionResults: VendorFulfillmentResult[] = [];
|
|
169
|
+
|
|
170
|
+
// Process each product separately
|
|
171
|
+
for (const product of products) {
|
|
172
|
+
if (!product.vendor_config || product.vendor_config.length === 0) {
|
|
173
|
+
logger.debug('No vendor configuration found for product', {
|
|
174
|
+
productId: product.id,
|
|
175
|
+
checkoutSessionId,
|
|
176
|
+
});
|
|
177
|
+
// eslint-disable-next-line no-continue
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Find all prices for this product
|
|
182
|
+
const productPrices = prices.filter((price: any) => price.product_id === product.id);
|
|
183
|
+
|
|
184
|
+
// Calculate total amount for this product across all line items
|
|
185
|
+
let productTotalAmount = '0';
|
|
186
|
+
for (const price of productPrices) {
|
|
187
|
+
const lineItem = priceToLineItemMap.get(price.id);
|
|
188
|
+
if (lineItem && lineItem.amount_total) {
|
|
189
|
+
productTotalAmount = new BN(productTotalAmount).add(new BN(lineItem.amount_total || '0')).toString();
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Calculate commission for each vendor of this product
|
|
194
|
+
for (const vendorConfig of product.vendor_config) {
|
|
195
|
+
const commissionAmount = calculateVendorCommission(
|
|
196
|
+
productTotalAmount, // Use product-specific amount instead of total order amount
|
|
197
|
+
vendorConfig.commission_rate || 0,
|
|
198
|
+
vendorConfig.commission_type || 'percentage',
|
|
199
|
+
vendorConfig.amount
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
commissionResults.push({
|
|
203
|
+
vendorId: vendorConfig.vendor_key || vendorConfig.vendor_id,
|
|
204
|
+
vendorKey: vendorConfig.vendor_key || vendorConfig.vendor_id,
|
|
205
|
+
orderId: `commission_${checkoutSessionId}_${product.id}_${vendorConfig.vendor_id}`,
|
|
206
|
+
status: 'completed',
|
|
207
|
+
commissionAmount,
|
|
208
|
+
commissionType: vendorConfig.commission_type || 'percentage',
|
|
209
|
+
commissionRate: vendorConfig.commission_rate || 0,
|
|
210
|
+
productId: product.id, // Add product ID for tracking
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (commissionResults.length === 0) {
|
|
216
|
+
logger.warn('No vendor configurations found for any products', {
|
|
217
|
+
productIds,
|
|
218
|
+
checkoutSessionId,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
logger.info('Commission data calculated', {
|
|
223
|
+
checkoutSessionId,
|
|
224
|
+
commissionCount: commissionResults.length,
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
return commissionResults;
|
|
228
|
+
} catch (error: any) {
|
|
229
|
+
logger.error('Failed to calculate commission data', {
|
|
230
|
+
checkoutSessionId,
|
|
231
|
+
error: error.message,
|
|
232
|
+
});
|
|
233
|
+
throw error;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
static async createVendorPayouts(
|
|
238
|
+
checkoutSessionId: string,
|
|
239
|
+
fulfillmentResults?: VendorFulfillmentResult[]
|
|
240
|
+
): Promise<void> {
|
|
241
|
+
try {
|
|
242
|
+
const checkoutSession = await CheckoutSession.findByPk(checkoutSessionId);
|
|
243
|
+
if (!checkoutSession) {
|
|
244
|
+
throw new Error(`CheckoutSession not found: ${checkoutSessionId}`);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
let paymentMethodId = '';
|
|
248
|
+
if (checkoutSession.payment_intent_id) {
|
|
249
|
+
const paymentIntent = await PaymentIntent.findByPk(checkoutSession.payment_intent_id);
|
|
250
|
+
if (paymentIntent) {
|
|
251
|
+
paymentMethodId = paymentIntent.payment_method_id;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// If fulfillmentResults not provided, calculate commission info
|
|
256
|
+
let commissionData = fulfillmentResults;
|
|
257
|
+
if (!commissionData) {
|
|
258
|
+
commissionData = await this.calculateCommissionData(checkoutSessionId);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const payoutPromises = commissionData
|
|
262
|
+
.filter((result) => result.status !== 'failed' && new BN(result.commissionAmount).gt(new BN('0')))
|
|
263
|
+
.map(async (result) => {
|
|
264
|
+
const vendor = await ProductVendor.findByPk(result.vendorId);
|
|
265
|
+
|
|
266
|
+
const appPid = vendor?.app_pid;
|
|
267
|
+
const destination = appPid || vendor?.metadata?.wallet_address || vendor?.metadata?.destination || '';
|
|
268
|
+
|
|
269
|
+
await Payout.create({
|
|
270
|
+
livemode: checkoutSession.livemode,
|
|
271
|
+
automatic: true,
|
|
272
|
+
description: `Vendor commission for ${result.vendorId}${appPid ? ` (${appPid})` : ''}`,
|
|
273
|
+
destination,
|
|
274
|
+
amount: result.commissionAmount,
|
|
275
|
+
currency_id: checkoutSession.currency_id,
|
|
276
|
+
customer_id: checkoutSession.customer_id || '',
|
|
277
|
+
payment_intent_id: checkoutSession.payment_intent_id || '',
|
|
278
|
+
payment_method_id: paymentMethodId,
|
|
279
|
+
status: 'pending',
|
|
280
|
+
attempt_count: 0,
|
|
281
|
+
attempted: false,
|
|
282
|
+
vendor_info: {
|
|
283
|
+
vendor_id: result.vendorId,
|
|
284
|
+
app_pid: appPid,
|
|
285
|
+
order_id: result.orderId,
|
|
286
|
+
commission_amount: result.commissionAmount,
|
|
287
|
+
},
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
await Promise.all(payoutPromises);
|
|
292
|
+
|
|
293
|
+
logger.info('Vendor payouts created', {
|
|
294
|
+
checkoutSessionId,
|
|
295
|
+
payoutCount: commissionData.filter((r) => r.status !== 'failed').length,
|
|
296
|
+
});
|
|
297
|
+
} catch (error: any) {
|
|
298
|
+
logger.error('Failed to create vendor payouts', {
|
|
299
|
+
checkoutSessionId,
|
|
300
|
+
error: error.message,
|
|
301
|
+
});
|
|
302
|
+
throw error;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
static getVendorAdapter(vendorKey: string) {
|
|
307
|
+
try {
|
|
308
|
+
return VendorAdapterFactory.create(vendorKey);
|
|
309
|
+
} catch (error: any) {
|
|
310
|
+
logger.error('Failed to get vendor adapter', {
|
|
311
|
+
vendorKey,
|
|
312
|
+
error: error.message,
|
|
313
|
+
});
|
|
314
|
+
throw error;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
@@ -26,11 +26,13 @@ import { MAX_RETRY_COUNT, MIN_RETRY_MAIL, getNextRetry } from '../libs/util';
|
|
|
26
26
|
import { CheckoutSession } from '../store/models/checkout-session';
|
|
27
27
|
import { Customer } from '../store/models/customer';
|
|
28
28
|
import { Invoice } from '../store/models/invoice';
|
|
29
|
+
|
|
29
30
|
import { PaymentCurrency } from '../store/models/payment-currency';
|
|
30
31
|
import { PaymentIntent } from '../store/models/payment-intent';
|
|
31
32
|
import { PaymentMethod } from '../store/models/payment-method';
|
|
32
33
|
import { Payout } from '../store/models/payout';
|
|
33
34
|
import { Price } from '../store/models/price';
|
|
35
|
+
|
|
34
36
|
import { Subscription } from '../store/models/subscription';
|
|
35
37
|
import { SubscriptionItem } from '../store/models/subscription-item';
|
|
36
38
|
import type { EVMChainType, PaymentError, PaymentSettings } from '../store/models/types';
|
|
@@ -371,13 +373,11 @@ export const handlePaymentSucceed = async (
|
|
|
371
373
|
);
|
|
372
374
|
}
|
|
373
375
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
});
|
|
380
|
-
}
|
|
376
|
+
// Trigger vendor commission queue
|
|
377
|
+
events.emit('vendor.commission.queued', `vendor-commission-${paymentIntent.id}`, {
|
|
378
|
+
paymentIntentId: paymentIntent.id,
|
|
379
|
+
retryOnError: true,
|
|
380
|
+
});
|
|
381
381
|
|
|
382
382
|
let invoice;
|
|
383
383
|
if (paymentIntent.invoice_id) {
|
|
@@ -929,19 +929,21 @@ export const handlePayment = async (job: PaymentJob) => {
|
|
|
929
929
|
|
|
930
930
|
const payer = paymentSettings?.payment_method_options.arcblock?.payer as string;
|
|
931
931
|
|
|
932
|
-
// check balance before capture with transaction
|
|
933
932
|
result = await isDelegationSufficientForPayment({
|
|
934
933
|
paymentMethod,
|
|
935
934
|
paymentCurrency,
|
|
936
935
|
userDid: payer || customer.did,
|
|
937
936
|
amount: paymentIntent.amount,
|
|
938
937
|
});
|
|
938
|
+
|
|
939
939
|
if (result.sufficient === false) {
|
|
940
|
-
logger.error('PaymentIntent capture aborted on preCheck', {
|
|
940
|
+
logger.error('PaymentIntent capture aborted on preCheck', {
|
|
941
|
+
id: paymentIntent.id,
|
|
942
|
+
result,
|
|
943
|
+
});
|
|
941
944
|
throw new CustomError(result.reason, 'payer balance or delegation not sufficient for this payment');
|
|
942
945
|
}
|
|
943
946
|
|
|
944
|
-
// do the capture
|
|
945
947
|
const signed = await client.signTransferV2Tx({
|
|
946
948
|
tx: {
|
|
947
949
|
itx: {
|
|
@@ -963,8 +965,10 @@ export const handlePayment = async (job: PaymentJob) => {
|
|
|
963
965
|
wallet,
|
|
964
966
|
delegator: result.delegator,
|
|
965
967
|
});
|
|
968
|
+
|
|
966
969
|
// @ts-ignore
|
|
967
970
|
const { buffer } = await client.encodeTransferV2Tx({ tx: signed });
|
|
971
|
+
|
|
968
972
|
const txHash = await client.sendTransferV2Tx(
|
|
969
973
|
// @ts-ignore
|
|
970
974
|
{ tx: signed, wallet, delegator: result.delegator },
|
package/api/src/queues/payout.ts
CHANGED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { events } from '../../libs/event';
|
|
2
|
+
import logger from '../../libs/logger';
|
|
3
|
+
import createQueue from '../../libs/queue';
|
|
4
|
+
import { CheckoutSession } from '../../store/models/checkout-session';
|
|
5
|
+
import { PaymentIntent } from '../../store/models/payment-intent';
|
|
6
|
+
import { Product } from '../../store/models/product';
|
|
7
|
+
import { Price } from '../../store/models/price';
|
|
8
|
+
import { depositVaultQueue } from '../payment';
|
|
9
|
+
import { startVendorFulfillment, triggerCommissionProcess, triggerCoordinatorCheck } from './fulfillment-coordinator';
|
|
10
|
+
|
|
11
|
+
type VendorCommissionJob = {
|
|
12
|
+
paymentIntentId: string;
|
|
13
|
+
retryOnError?: boolean;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
async function checkIfPaymentIntentHasVendors(
|
|
17
|
+
paymentIntent: PaymentIntent,
|
|
18
|
+
checkoutSession: CheckoutSession
|
|
19
|
+
): Promise<boolean> {
|
|
20
|
+
try {
|
|
21
|
+
// Extract price_ids from line_items, then find corresponding product_ids
|
|
22
|
+
const priceIds = checkoutSession.line_items.map((item: any) => item.price_id).filter(Boolean);
|
|
23
|
+
|
|
24
|
+
if (priceIds.length === 0) {
|
|
25
|
+
logger.warn('No price IDs found in checkout session line items', {
|
|
26
|
+
paymentIntentId: paymentIntent.id,
|
|
27
|
+
checkoutSessionId: checkoutSession.id,
|
|
28
|
+
});
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Find corresponding product_ids through price_ids
|
|
33
|
+
const prices = await Price.findAll({
|
|
34
|
+
where: { id: priceIds },
|
|
35
|
+
attributes: ['id', 'product_id'],
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const productIds = prices.map((price) => price.product_id).filter(Boolean);
|
|
39
|
+
|
|
40
|
+
if (productIds.length === 0) {
|
|
41
|
+
logger.warn('No product IDs found from prices', {
|
|
42
|
+
paymentIntentId: paymentIntent.id,
|
|
43
|
+
checkoutSessionId: checkoutSession.id,
|
|
44
|
+
priceIds,
|
|
45
|
+
});
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Get product information
|
|
50
|
+
const products = await Product.findAll({
|
|
51
|
+
where: { id: productIds },
|
|
52
|
+
attributes: ['id', 'vendor_config'],
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Check if any product has vendor configuration
|
|
56
|
+
const hasVendorConfig = products.some((product) => product.vendor_config && product.vendor_config.length > 0);
|
|
57
|
+
return hasVendorConfig;
|
|
58
|
+
} catch (error: any) {
|
|
59
|
+
logger.error('Failed to check vendor configuration', {
|
|
60
|
+
paymentIntentId: paymentIntent.id,
|
|
61
|
+
error: error.message,
|
|
62
|
+
});
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function executeDirectDepositVault(paymentIntent: PaymentIntent): Promise<void> {
|
|
68
|
+
const exist = await depositVaultQueue.get(`deposit-vault-${paymentIntent.currency_id}`);
|
|
69
|
+
if (!exist) {
|
|
70
|
+
depositVaultQueue.push({
|
|
71
|
+
id: `deposit-vault-${paymentIntent.currency_id}`,
|
|
72
|
+
job: { currencyId: paymentIntent.currency_id },
|
|
73
|
+
});
|
|
74
|
+
logger.info('Deposit vault job queued', {
|
|
75
|
+
paymentIntentId: paymentIntent.id,
|
|
76
|
+
currencyId: paymentIntent.currency_id,
|
|
77
|
+
});
|
|
78
|
+
} else {
|
|
79
|
+
logger.info('Deposit vault job already exists', {
|
|
80
|
+
paymentIntentId: paymentIntent.id,
|
|
81
|
+
currencyId: paymentIntent.currency_id,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export const handleVendorCommission = async (job: VendorCommissionJob) => {
|
|
87
|
+
logger.info('handle vendor commission', job);
|
|
88
|
+
|
|
89
|
+
let checkoutSession: CheckoutSession | null = null;
|
|
90
|
+
try {
|
|
91
|
+
const paymentIntent = await PaymentIntent.findByPk(job.paymentIntentId);
|
|
92
|
+
if (!paymentIntent) {
|
|
93
|
+
logger.warn('PaymentIntent not found', { id: job.paymentIntentId });
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Find CheckoutSession through PaymentIntent
|
|
98
|
+
checkoutSession = await CheckoutSession.findByPaymentIntentId(paymentIntent.id);
|
|
99
|
+
if (!checkoutSession) {
|
|
100
|
+
await executeDirectDepositVault(paymentIntent);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const hasVendorConfig = await checkIfPaymentIntentHasVendors(paymentIntent, checkoutSession);
|
|
105
|
+
if (!hasVendorConfig) {
|
|
106
|
+
await executeDirectDepositVault(paymentIntent);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
logger.info('Vendor configuration found, starting fulfillment process', {
|
|
111
|
+
paymentIntentId: paymentIntent.id,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
if (checkoutSession.fulfillment_status === 'completed') {
|
|
115
|
+
logger.info('CheckoutSession already completed, directly trigger commission process', {
|
|
116
|
+
checkoutSessionId: checkoutSession.id,
|
|
117
|
+
});
|
|
118
|
+
await triggerCommissionProcess(checkoutSession.id, paymentIntent.id);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
await startVendorFulfillment(checkoutSession.id, paymentIntent.id);
|
|
123
|
+
} catch (error: any) {
|
|
124
|
+
logger.error('Vendor commission decision failed, fallback to direct deposit vault', {
|
|
125
|
+
paymentIntentId: job.paymentIntentId,
|
|
126
|
+
error: error.message,
|
|
127
|
+
stack: error.stack,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
if (!checkoutSession) {
|
|
131
|
+
logger.error('CheckoutSession not found via any method[handleVendorCommission]', {
|
|
132
|
+
paymentIntentId: job.paymentIntentId,
|
|
133
|
+
});
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
triggerCoordinatorCheck(checkoutSession.id, job.paymentIntentId, 'vendor_commission_decision_failed');
|
|
139
|
+
} catch (err: any) {
|
|
140
|
+
logger.error('Failed to trigger coordinator check[handleVendorCommission]', { error: err.message });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
export const vendorCommissionQueue = createQueue<VendorCommissionJob>({
|
|
146
|
+
name: 'vendor-commission',
|
|
147
|
+
onJob: handleVendorCommission,
|
|
148
|
+
options: {
|
|
149
|
+
concurrency: 3,
|
|
150
|
+
maxRetries: 3,
|
|
151
|
+
enableScheduledJob: true,
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
export const startVendorCommissionQueue = async () => {
|
|
156
|
+
const payments = await PaymentIntent.findAll({
|
|
157
|
+
where: {
|
|
158
|
+
status: ['requires_capture', 'processing'],
|
|
159
|
+
capture_method: 'automatic',
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
payments.forEach(async (x) => {
|
|
164
|
+
const exist = await vendorCommissionQueue.get(`vendor-commission-${x.id}`);
|
|
165
|
+
if (!exist) {
|
|
166
|
+
vendorCommissionQueue.push({ id: x.id, job: { paymentIntentId: x.id, retryOnError: true } });
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
events.on('vendor.commission.queued', async (id, job, args = {}) => {
|
|
172
|
+
try {
|
|
173
|
+
const { ...extraArgs } = args;
|
|
174
|
+
const exist = await vendorCommissionQueue.get(id);
|
|
175
|
+
|
|
176
|
+
if (!exist) {
|
|
177
|
+
logger.info('Vendor commission job added successfully', { id });
|
|
178
|
+
vendorCommissionQueue.push({
|
|
179
|
+
id,
|
|
180
|
+
job,
|
|
181
|
+
...extraArgs,
|
|
182
|
+
});
|
|
183
|
+
} else {
|
|
184
|
+
logger.info('Vendor commission job already exists, skipping', { id });
|
|
185
|
+
}
|
|
186
|
+
} catch (error: any) {
|
|
187
|
+
logger.error('Failed to handle vendor commission queue event', {
|
|
188
|
+
id,
|
|
189
|
+
error: error.message,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
});
|