payment-kit 1.20.5 → 1.20.6

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 (39) hide show
  1. package/api/src/crons/index.ts +11 -3
  2. package/api/src/index.ts +18 -14
  3. package/api/src/libs/adapters/launcher-adapter.ts +177 -0
  4. package/api/src/libs/env.ts +7 -0
  5. package/api/src/libs/url.ts +77 -0
  6. package/api/src/libs/vendor-adapter-factory.ts +22 -0
  7. package/api/src/libs/vendor-adapter.ts +109 -0
  8. package/api/src/libs/vendor-fulfillment.ts +321 -0
  9. package/api/src/queues/payment.ts +14 -10
  10. package/api/src/queues/payout.ts +1 -0
  11. package/api/src/queues/vendor/vendor-commission.ts +192 -0
  12. package/api/src/queues/vendor/vendor-fulfillment-coordinator.ts +627 -0
  13. package/api/src/queues/vendor/vendor-fulfillment.ts +97 -0
  14. package/api/src/queues/vendor/vendor-status-check.ts +179 -0
  15. package/api/src/routes/checkout-sessions.ts +3 -0
  16. package/api/src/routes/index.ts +2 -0
  17. package/api/src/routes/products.ts +72 -1
  18. package/api/src/routes/vendor.ts +526 -0
  19. package/api/src/store/migrations/20250820-add-product-vendor.ts +102 -0
  20. package/api/src/store/migrations/20250822-add-vendor-config-to-products.ts +56 -0
  21. package/api/src/store/models/checkout-session.ts +84 -18
  22. package/api/src/store/models/index.ts +3 -0
  23. package/api/src/store/models/payout.ts +11 -0
  24. package/api/src/store/models/product-vendor.ts +118 -0
  25. package/api/src/store/models/product.ts +15 -0
  26. package/blocklet.yml +8 -2
  27. package/doc/vendor_fulfillment_system.md +931 -0
  28. package/package.json +5 -4
  29. package/src/components/collapse.tsx +1 -0
  30. package/src/components/product/edit.tsx +9 -0
  31. package/src/components/product/form.tsx +11 -0
  32. package/src/components/product/vendor-config.tsx +249 -0
  33. package/src/components/vendor/actions.tsx +145 -0
  34. package/src/locales/en.tsx +89 -0
  35. package/src/locales/zh.tsx +89 -0
  36. package/src/pages/admin/products/index.tsx +11 -1
  37. package/src/pages/admin/products/products/detail.tsx +79 -2
  38. package/src/pages/admin/products/vendors/create.tsx +418 -0
  39. package/src/pages/admin/products/vendors/index.tsx +313 -0
@@ -0,0 +1,97 @@
1
+ import { events } from '../../libs/event';
2
+ import logger from '../../libs/logger';
3
+ import createQueue from '../../libs/queue';
4
+ import { VendorFulfillmentService } from '../../libs/vendor-fulfillment';
5
+ import { CheckoutSession } from '../../store/models/checkout-session';
6
+ import { updateVendorFulfillmentStatus } from './vendor-fulfillment-coordinator';
7
+
8
+ type VendorFulfillmentJob = {
9
+ checkoutSessionId: string;
10
+ paymentIntentId: string;
11
+ vendorId: string;
12
+ vendorConfig: any;
13
+ retryOnError?: boolean;
14
+ };
15
+
16
+ export const handleVendorFulfillment = async (job: VendorFulfillmentJob) => {
17
+ logger.debug('Raw job received in handleVendorFulfillment', {
18
+ job,
19
+ jobKeys: Object.keys(job),
20
+ });
21
+
22
+ const { checkoutSessionId, paymentIntentId, vendorId, vendorConfig } = job;
23
+
24
+ try {
25
+ const checkoutSession = await CheckoutSession.findByPk(checkoutSessionId);
26
+ if (!checkoutSession) {
27
+ throw new Error(`CheckoutSession not found: ${checkoutSessionId}`);
28
+ }
29
+
30
+ const orderInfo = {
31
+ amount_total: checkoutSession.amount_total,
32
+ customer_id: checkoutSession.customer_id || '',
33
+ payment_intent_id: checkoutSession.payment_intent_id || '',
34
+ currency_id: checkoutSession.currency_id,
35
+ customer_did: checkoutSession.customer_did || '',
36
+ };
37
+ const fulfillmentResult = await VendorFulfillmentService.fulfillSingleVendorOrder(orderInfo, vendorConfig);
38
+
39
+ logger.info('Vendor fulfillment has been sent', {
40
+ vendorId,
41
+ orderId: fulfillmentResult.orderId,
42
+ status: fulfillmentResult.status,
43
+ });
44
+
45
+ await updateVendorFulfillmentStatus(checkoutSessionId, paymentIntentId, vendorId, 'sent', {
46
+ orderId: fulfillmentResult.orderId,
47
+ commissionAmount: fulfillmentResult.commissionAmount,
48
+ serviceUrl: fulfillmentResult.serviceUrl,
49
+ });
50
+ } catch (error: any) {
51
+ logger.error('Vendor fulfillment failed', {
52
+ vendorId,
53
+ checkoutSessionId,
54
+ error: error.message,
55
+ });
56
+
57
+ await updateVendorFulfillmentStatus(checkoutSessionId, paymentIntentId, vendorId, 'failed', {
58
+ lastError: error.message,
59
+ });
60
+
61
+ throw error;
62
+ }
63
+ };
64
+
65
+ export const vendorFulfillmentQueue = createQueue<VendorFulfillmentJob>({
66
+ name: 'vendor-fulfillment',
67
+ onJob: handleVendorFulfillment,
68
+ options: {
69
+ concurrency: 1,
70
+ maxRetries: 3,
71
+ enableScheduledJob: true,
72
+ },
73
+ });
74
+
75
+ export const startVendorFulfillmentQueue = () => {
76
+ logger.debug('startVendorFulfillmentQueue');
77
+ return Promise.resolve();
78
+ };
79
+
80
+ events.on('vendor.fulfillment.queued', async (id, job) => {
81
+ try {
82
+ const exist = await vendorFulfillmentQueue.get(id);
83
+ if (!exist) {
84
+ vendorFulfillmentQueue.push({
85
+ id,
86
+ job,
87
+ });
88
+ } else {
89
+ logger.info('Vendor fulfillment job already exists, skipping', { id });
90
+ }
91
+ } catch (error: any) {
92
+ logger.error('Failed to handle vendor fulfillment queue event', {
93
+ id,
94
+ error: error.message,
95
+ });
96
+ }
97
+ });
@@ -0,0 +1,179 @@
1
+ import { joinURL } from 'ufo';
2
+ import createQueue from '../../libs/queue';
3
+ import { getBlockletJson } from '../../libs/util';
4
+ import { VendorAdapterFactory } from '../../libs/vendor-adapter-factory';
5
+ import { CheckoutSession } from '../../store/models/checkout-session';
6
+ import { ProductVendor } from '../../store/models';
7
+ import { fulfillmentCoordinatorQueue } from './vendor-fulfillment-coordinator';
8
+ import logger from '../../libs/logger';
9
+ import { vendorTimeoutMinutes } from '../../libs/env';
10
+
11
+ export const startVendorStatusCheckSchedule = async () => {
12
+ const checkoutSessions = await CheckoutSession.findAll({
13
+ where: { fulfillment_status: 'sent' },
14
+ });
15
+
16
+ if (!checkoutSessions.length) {
17
+ return;
18
+ }
19
+
20
+ for (const checkoutSession of checkoutSessions) {
21
+ const vendorInfo = checkoutSession.vendor_info;
22
+ if (!vendorInfo?.length) {
23
+ return;
24
+ }
25
+
26
+ for (const vendor of vendorInfo) {
27
+ if (vendor.status === 'sent') {
28
+ vendorStatusCheckQueue.push({
29
+ id: `vendor-status-check-${checkoutSession.id}-${vendor.vendor_id}`,
30
+ job: {
31
+ checkoutSessionId: checkoutSession.id,
32
+ vendorId: vendor.vendor_id,
33
+ },
34
+ });
35
+ }
36
+ }
37
+ }
38
+ };
39
+
40
+ export const handleVendorStatusCheck = async (job: VendorStatusCheckJob) => {
41
+ const { checkoutSessionId, vendorId } = job;
42
+ logger.info('handleVendorStatusCheck', { checkoutSessionId, vendorId });
43
+
44
+ const checkoutSession = await CheckoutSession.findByPk(checkoutSessionId, {
45
+ attributes: ['vendor_info', 'payment_intent_id'],
46
+ });
47
+
48
+ const vendor = checkoutSession?.vendor_info?.find((v) => v.vendor_id === vendorId);
49
+ if (!vendor) {
50
+ logger.error('vendor not found[handleVendorStatusCheck]', { checkoutSessionId, vendorId });
51
+ return { status: 'fail' };
52
+ }
53
+
54
+ const TIMEOUT_THRESHOLD = vendorTimeoutMinutes * 60 * 1000;
55
+ const now = Date.now();
56
+
57
+ if (vendor.lastAttemptAt) {
58
+ const timeSinceLastAttempt = now - new Date(vendor.lastAttemptAt).getTime();
59
+
60
+ if (timeSinceLastAttempt > TIMEOUT_THRESHOLD) {
61
+ logger.warn('Vendor timeout detected during status check', {
62
+ checkoutSessionId,
63
+ vendorId,
64
+ status: vendor.status,
65
+ timeSinceLastAttempt: `${Math.floor(timeSinceLastAttempt / 1000)}s`,
66
+ timeoutThreshold: `${vendorTimeoutMinutes}min (${Math.floor(TIMEOUT_THRESHOLD / 1000)}s)`,
67
+ });
68
+
69
+ vendor.status = 'failed';
70
+ vendor.error_message = `Timeout: ${vendor.status} status exceeded ${vendorTimeoutMinutes} minutes`;
71
+ vendor.lastAttemptAt = new Date().toISOString();
72
+
73
+ const updatedVendorInfo = checkoutSession?.vendor_info?.map((v) => {
74
+ if (v.vendor_id === vendorId) {
75
+ return { ...v, ...vendor };
76
+ }
77
+ return v;
78
+ });
79
+
80
+ await CheckoutSession.update({ vendor_info: updatedVendorInfo }, { where: { id: checkoutSessionId } });
81
+
82
+ fulfillmentCoordinatorQueue.push({
83
+ id: `fulfillment-coordinator-${checkoutSessionId}-${vendorId}`,
84
+ job: {
85
+ checkoutSessionId,
86
+ paymentIntentId: checkoutSession?.payment_intent_id || '',
87
+ triggeredBy: 'vendor-status-check-timeout',
88
+ },
89
+ });
90
+
91
+ return { status: 'failed', reason: 'timeout' };
92
+ }
93
+ }
94
+
95
+ try {
96
+ if (!vendor.app_url) {
97
+ const productVendor = await ProductVendor.findByPk(vendorId);
98
+ const url = productVendor?.app_url;
99
+ logger.info('found vendor url', { url, productVendor });
100
+
101
+ if (url) {
102
+ const vendorMeta = await getBlockletJson(url);
103
+ const mountPoint = vendorMeta.componentMountPoints?.find((x: any) => x.appId === vendorId)?.mountPoint;
104
+ const serverStatusUrl = joinURL(url, mountPoint, '/api/vendor/status', vendor.order_id);
105
+
106
+ const result = await fetch(serverStatusUrl);
107
+ const data = await result.json();
108
+ vendor.app_url = data?.appUrl || '';
109
+ }
110
+ }
111
+
112
+ logger.info('checkOrderStatus', { vendor });
113
+ const adapter = await VendorAdapterFactory.create(vendor.vendor_key);
114
+ const result = await adapter.checkOrderStatus({ appUrl: vendor.app_url });
115
+
116
+ if (result.status === 'completed') {
117
+ vendor.status = 'completed';
118
+ vendor.lastAttemptAt = new Date().toISOString();
119
+ } else if (result.status === 'failed') {
120
+ vendor.status = 'failed';
121
+ vendor.lastAttemptAt = new Date().toISOString();
122
+ } else if (result.status === 'processing') {
123
+ vendor.status = 'processing';
124
+ vendor.lastAttemptAt = new Date().toISOString();
125
+ }
126
+ } catch (error) {
127
+ logger.warn('Failed to check vendor status', {
128
+ checkoutSessionId,
129
+ vendorId,
130
+ error: error.message,
131
+ });
132
+ }
133
+
134
+ const vendorInfo = checkoutSession?.vendor_info?.map((v) => {
135
+ if (v.vendor_id === vendorId) {
136
+ v.app_url = vendor.app_url || '';
137
+ vendor.app_url = vendor.app_url || '';
138
+ vendor.status = vendor.status || 'sent';
139
+ }
140
+ return v;
141
+ });
142
+
143
+ logger.info('update vendor info', { vendorInfo });
144
+
145
+ await CheckoutSession.update(
146
+ {
147
+ vendor_info: vendorInfo,
148
+ },
149
+ { where: { id: checkoutSessionId } }
150
+ );
151
+
152
+ if (vendor.status === 'completed' || vendor.status === 'failed') {
153
+ fulfillmentCoordinatorQueue.push({
154
+ id: `fulfillment-coordinator-${checkoutSessionId}-${vendorId}`,
155
+ job: {
156
+ checkoutSessionId,
157
+ paymentIntentId: checkoutSession?.payment_intent_id || '',
158
+ triggeredBy: 'vendor-status-check',
159
+ },
160
+ });
161
+ }
162
+
163
+ return { status: vendor.status };
164
+ };
165
+
166
+ type VendorStatusCheckJob = {
167
+ checkoutSessionId: string;
168
+ vendorId: string;
169
+ };
170
+
171
+ export const vendorStatusCheckQueue = createQueue<VendorStatusCheckJob>({
172
+ name: 'vendor-status-check',
173
+ onJob: handleVendorStatusCheck,
174
+ options: {
175
+ concurrency: 1,
176
+ maxRetries: 3,
177
+ enableScheduledJob: true,
178
+ },
179
+ });
@@ -824,6 +824,7 @@ router.post('/', authLogin, async (req, res) => {
824
824
  });
825
825
 
826
826
  export async function startCheckoutSessionFromPaymentLink(id: string, req: Request, res: Response) {
827
+ const { metadata } = req.body;
827
828
  try {
828
829
  const link = await PaymentLink.findByPk(id);
829
830
  if (!link) {
@@ -942,6 +943,7 @@ export async function startCheckoutSessionFromPaymentLink(id: string, req: Reque
942
943
  raw.metadata = {
943
944
  ...link.metadata,
944
945
  ...getDataObjectFromQuery(req.query),
946
+ ...metadata,
945
947
  days_until_due: getDaysUntilDue(req.query),
946
948
  days_until_cancel: getDaysUntilCancel(req.query),
947
949
  passport: await checkPassportForPaymentLink(link),
@@ -952,6 +954,7 @@ export async function startCheckoutSessionFromPaymentLink(id: string, req: Reque
952
954
  raw.metadata = {
953
955
  ...link.metadata,
954
956
  ...getDataObjectFromQuery(req.query),
957
+ ...metadata,
955
958
  days_until_due: getDaysUntilDue(req.query),
956
959
  days_until_cancel: getDaysUntilCancel(req.query),
957
960
  passport: await checkPassportForPaymentLink(link),
@@ -30,6 +30,7 @@ import subscriptions from './subscriptions';
30
30
  import usageRecords from './usage-records';
31
31
  import webhookAttempts from './webhook-attempts';
32
32
  import webhookEndpoints from './webhook-endpoints';
33
+ import vendor from './vendor';
33
34
 
34
35
  const router = Router();
35
36
 
@@ -81,5 +82,6 @@ router.use('/subscriptions', subscriptions);
81
82
  router.use('/usage-records', usageRecords);
82
83
  router.use('/webhook-attempts', webhookAttempts);
83
84
  router.use('/webhook-endpoints', webhookEndpoints);
85
+ router.use('/vendors', vendor);
84
86
 
85
87
  export default router;
@@ -12,6 +12,8 @@ import { formatMetadata } from '../libs/util';
12
12
  import { PaymentCurrency } from '../store/models/payment-currency';
13
13
  import { Price } from '../store/models/price';
14
14
  import { Product } from '../store/models/product';
15
+ import { ProductVendor } from '../store/models/product-vendor';
16
+
15
17
  import type { CustomUnitAmount } from '../store/models/types';
16
18
  import { checkCurrencySupportRecurring } from '../libs/product';
17
19
 
@@ -19,6 +21,16 @@ const router = Router();
19
21
 
20
22
  const auth = authenticate<Product>({ component: true, roles: ['owner', 'admin'] });
21
23
 
24
+ const VendorConfigSchema = Joi.array()
25
+ .items(
26
+ Joi.object({
27
+ vendor_id: Joi.string().required(),
28
+ commission_rate: Joi.number().min(0).max(100).required(),
29
+ commission_type: Joi.string().valid('percentage', 'fixed').required(),
30
+ }).unknown(true)
31
+ )
32
+ .optional();
33
+
22
34
  const ProductAndPriceSchema = Joi.object({
23
35
  name: Joi.string().max(64).empty('').optional(),
24
36
  type: Joi.string().valid('service', 'good', 'credit').empty('').optional(),
@@ -55,6 +67,7 @@ const ProductAndPriceSchema = Joi.object({
55
67
  }).unknown(true)
56
68
  )
57
69
  .optional(),
70
+ vendor_config: VendorConfigSchema,
58
71
  }).unknown(true);
59
72
 
60
73
  const CreditConfigSchema = Joi.object({
@@ -67,7 +80,6 @@ const CreditConfigSchema = Joi.object({
67
80
  });
68
81
 
69
82
  export async function createProductAndPrices(payload: any) {
70
- // 1. 准备 product 数据
71
83
  const raw: Partial<Product> = pick(payload, [
72
84
  'name',
73
85
  'type',
@@ -80,6 +92,36 @@ export async function createProductAndPrices(payload: any) {
80
92
  'features',
81
93
  'metadata',
82
94
  ]);
95
+
96
+ if (Array.isArray(payload.vendor_config) && payload.vendor_config.length > 0) {
97
+ // validate vendor_config data
98
+ const { error: vendorConfigError } = VendorConfigSchema.validate(payload.vendor_config);
99
+ if (vendorConfigError) {
100
+ throw new Error(`vendor_config validation failed: ${vendorConfigError.message}`);
101
+ }
102
+
103
+ const vendorConfigs = await ProductVendor.findAll({
104
+ where: {
105
+ id: payload.vendor_config.map((x: any) => x.vendor_id),
106
+ },
107
+ });
108
+
109
+ raw.vendor_config = payload.vendor_config.map((config: any) => {
110
+ const vendorConfig = vendorConfigs.find((x) => x.id === config.vendor_id);
111
+ if (!vendorConfig) {
112
+ throw new Error(`vendor ${config.vendor_id} not found`);
113
+ }
114
+ return {
115
+ vendor_id: vendorConfig.id,
116
+ vendor_key: vendorConfig.vendor_key,
117
+ name: vendorConfig.name,
118
+ description: vendorConfig.description,
119
+ commission_rate: Number(config.commission_rate),
120
+ commission_type: config.commission_type === 'fixed' ? 'fixed_amount' : config.commission_type,
121
+ custom_params: {},
122
+ };
123
+ });
124
+ }
83
125
  raw.active = true;
84
126
  raw.type = raw.type || 'service';
85
127
  raw.livemode = !!payload.livemode;
@@ -389,6 +431,35 @@ router.put('/:id', auth, async (req, res) => {
389
431
  'metadata',
390
432
  'cross_sell',
391
433
  ]);
434
+
435
+ if (Array.isArray(req.body.vendor_config)) {
436
+ const { error: vendorConfigError } = VendorConfigSchema.validate(req.body.vendor_config);
437
+ if (vendorConfigError) {
438
+ return res.status(400).json({ error: `vendor_config validation failed: ${vendorConfigError.message}` });
439
+ }
440
+
441
+ const vendorConfigs = await ProductVendor.findAll({
442
+ where: {
443
+ id: req.body.vendor_config.map((x: any) => x.vendor_id),
444
+ },
445
+ });
446
+
447
+ updates.vendor_config = req.body.vendor_config.map((config: any) => {
448
+ const vendorConfig = vendorConfigs.find((x) => x.id === config.vendor_id);
449
+ if (!vendorConfig) {
450
+ throw new Error(`vendor ${config.vendor_id} not found`);
451
+ }
452
+ return {
453
+ vendor_id: vendorConfig.id,
454
+ vendor_key: vendorConfig.vendor_key,
455
+ name: vendorConfig.name,
456
+ description: vendorConfig.description,
457
+ commission_rate: Number(config.commission_rate),
458
+ commission_type: config.commission_type === 'fixed' ? 'fixed_amount' : config.commission_type,
459
+ custom_params: {},
460
+ };
461
+ });
462
+ }
392
463
  const { error } = ProductAndPriceSchema.validate(updates);
393
464
  if (error) {
394
465
  return res.status(400).json({ error: `Product update request invalid: ${error.message}` });