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.
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/env.ts +7 -0
  4. package/api/src/libs/url.ts +77 -0
  5. package/api/src/libs/vendor/adapters/factory.ts +40 -0
  6. package/api/src/libs/vendor/adapters/launcher-adapter.ts +179 -0
  7. package/api/src/libs/vendor/adapters/types.ts +91 -0
  8. package/api/src/libs/vendor/fulfillment.ts +317 -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/commission.ts +192 -0
  12. package/api/src/queues/vendor/fulfillment-coordinator.ts +625 -0
  13. package/api/src/queues/vendor/fulfillment.ts +98 -0
  14. package/api/src/queues/vendor/status-check.ts +178 -0
  15. package/api/src/routes/checkout-sessions.ts +12 -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 +527 -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 +929 -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,98 @@
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 './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
+ checkoutSessionId,
32
+ amount_total: checkoutSession.amount_total,
33
+ customer_id: checkoutSession.customer_id || '',
34
+ payment_intent_id: checkoutSession.payment_intent_id || '',
35
+ currency_id: checkoutSession.currency_id,
36
+ customer_did: checkoutSession.customer_did || '',
37
+ };
38
+ const fulfillmentResult = await VendorFulfillmentService.fulfillSingleVendorOrder(orderInfo, vendorConfig);
39
+
40
+ logger.info('Vendor fulfillment has been sent', {
41
+ vendorId,
42
+ orderId: fulfillmentResult.orderId,
43
+ status: fulfillmentResult.status,
44
+ });
45
+
46
+ await updateVendorFulfillmentStatus(checkoutSessionId, paymentIntentId, vendorId, 'sent', {
47
+ orderId: fulfillmentResult.orderId,
48
+ commissionAmount: fulfillmentResult.commissionAmount,
49
+ serviceUrl: fulfillmentResult.serviceUrl,
50
+ });
51
+ } catch (error: any) {
52
+ logger.error('Vendor fulfillment failed', {
53
+ vendorId,
54
+ checkoutSessionId,
55
+ error: error.message,
56
+ });
57
+
58
+ await updateVendorFulfillmentStatus(checkoutSessionId, paymentIntentId, vendorId, 'failed', {
59
+ lastError: error.message,
60
+ });
61
+
62
+ throw error;
63
+ }
64
+ };
65
+
66
+ export const vendorFulfillmentQueue = createQueue<VendorFulfillmentJob>({
67
+ name: 'vendor-fulfillment',
68
+ onJob: handleVendorFulfillment,
69
+ options: {
70
+ concurrency: 1,
71
+ maxRetries: 3,
72
+ enableScheduledJob: true,
73
+ },
74
+ });
75
+
76
+ export const startVendorFulfillmentQueue = () => {
77
+ logger.debug('startVendorFulfillmentQueue');
78
+ return Promise.resolve();
79
+ };
80
+
81
+ events.on('vendor.fulfillment.queued', async (id, job) => {
82
+ try {
83
+ const exist = await vendorFulfillmentQueue.get(id);
84
+ if (!exist) {
85
+ vendorFulfillmentQueue.push({
86
+ id,
87
+ job,
88
+ });
89
+ } else {
90
+ logger.info('Vendor fulfillment job already exists, skipping', { id });
91
+ }
92
+ } catch (error: any) {
93
+ logger.error('Failed to handle vendor fulfillment queue event', {
94
+ id,
95
+ error: error.message,
96
+ });
97
+ }
98
+ });
@@ -0,0 +1,178 @@
1
+ import { joinURL } from 'ufo';
2
+ import createQueue from '../../libs/queue';
3
+ import { getBlockletJson } from '../../libs/util';
4
+ import { CheckoutSession } from '../../store/models/checkout-session';
5
+ import { ProductVendor } from '../../store/models';
6
+ import { fulfillmentCoordinatorQueue } from './fulfillment-coordinator';
7
+ import logger from '../../libs/logger';
8
+ import { vendorTimeoutMinutes } from '../../libs/env';
9
+ import { VendorFulfillmentService } from '../../libs/vendor/fulfillment';
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
+ const adapter = VendorFulfillmentService.getVendorAdapter(vendor.vendor_key);
113
+ const result = await adapter.checkOrderStatus({ appUrl: vendor.app_url });
114
+
115
+ if (result.status === 'completed') {
116
+ vendor.status = 'completed';
117
+ vendor.lastAttemptAt = new Date().toISOString();
118
+ } else if (result.status === 'failed') {
119
+ vendor.status = 'failed';
120
+ vendor.lastAttemptAt = new Date().toISOString();
121
+ } else if (result.status === 'processing') {
122
+ vendor.status = 'processing';
123
+ vendor.lastAttemptAt = new Date().toISOString();
124
+ }
125
+ } catch (error) {
126
+ logger.warn('Failed to check vendor status', {
127
+ checkoutSessionId,
128
+ vendorId,
129
+ error: error.message,
130
+ });
131
+ }
132
+
133
+ const vendorInfo = checkoutSession?.vendor_info?.map((v) => {
134
+ if (v.vendor_id === vendorId) {
135
+ v.app_url = vendor.app_url || '';
136
+ vendor.app_url = vendor.app_url || '';
137
+ vendor.status = vendor.status || 'sent';
138
+ }
139
+ return v;
140
+ });
141
+
142
+ logger.info('update vendor info', { vendorInfo });
143
+
144
+ await CheckoutSession.update(
145
+ {
146
+ vendor_info: vendorInfo,
147
+ },
148
+ { where: { id: checkoutSessionId } }
149
+ );
150
+
151
+ if (vendor.status === 'completed' || vendor.status === 'failed') {
152
+ fulfillmentCoordinatorQueue.push({
153
+ id: `fulfillment-coordinator-${checkoutSessionId}-${vendorId}`,
154
+ job: {
155
+ checkoutSessionId,
156
+ paymentIntentId: checkoutSession?.payment_intent_id || '',
157
+ triggeredBy: 'vendor-status-check',
158
+ },
159
+ });
160
+ }
161
+
162
+ return { status: vendor.status };
163
+ };
164
+
165
+ type VendorStatusCheckJob = {
166
+ checkoutSessionId: string;
167
+ vendorId: string;
168
+ };
169
+
170
+ export const vendorStatusCheckQueue = createQueue<VendorStatusCheckJob>({
171
+ name: 'vendor-status-check',
172
+ onJob: handleVendorStatusCheck,
173
+ options: {
174
+ concurrency: 1,
175
+ maxRetries: 3,
176
+ enableScheduledJob: true,
177
+ },
178
+ });
@@ -94,6 +94,7 @@ import { CHARGE_SUPPORTED_CHAIN_TYPES } from '../libs/constants';
94
94
  import { blocklet } from '../libs/auth';
95
95
  import { addSubscriptionJob } from '../queues/subscription';
96
96
  import { updateDataConcurrency } from '../libs/env';
97
+ import { formatToShortUrl } from '../libs/url';
97
98
 
98
99
  const router = Router();
99
100
 
@@ -824,6 +825,7 @@ router.post('/', authLogin, async (req, res) => {
824
825
  });
825
826
 
826
827
  export async function startCheckoutSessionFromPaymentLink(id: string, req: Request, res: Response) {
828
+ const { metadata, needShortUrl = false } = req.body;
827
829
  try {
828
830
  const link = await PaymentLink.findByPk(id);
829
831
  if (!link) {
@@ -942,6 +944,7 @@ export async function startCheckoutSessionFromPaymentLink(id: string, req: Reque
942
944
  raw.metadata = {
943
945
  ...link.metadata,
944
946
  ...getDataObjectFromQuery(req.query),
947
+ ...metadata,
945
948
  days_until_due: getDaysUntilDue(req.query),
946
949
  days_until_cancel: getDaysUntilCancel(req.query),
947
950
  passport: await checkPassportForPaymentLink(link),
@@ -952,6 +955,7 @@ export async function startCheckoutSessionFromPaymentLink(id: string, req: Reque
952
955
  raw.metadata = {
953
956
  ...link.metadata,
954
957
  ...getDataObjectFromQuery(req.query),
958
+ ...metadata,
955
959
  days_until_due: getDaysUntilDue(req.query),
956
960
  days_until_cancel: getDaysUntilCancel(req.query),
957
961
  passport: await checkPassportForPaymentLink(link),
@@ -977,7 +981,15 @@ export async function startCheckoutSessionFromPaymentLink(id: string, req: Reque
977
981
  doc.line_items = await Price.expand(updatedItems, { upsell: true });
978
982
  }
979
983
 
984
+ let paymentUrl = getUrl(`/checkout/pay/${doc.id}`);
985
+ if (needShortUrl) {
986
+ const validUntil = dayjs().add(20, 'minutes').format('YYYY-MM-DDTHH:mm:ss+00:00');
987
+ const maxVisits = 5;
988
+ paymentUrl = await formatToShortUrl({ url: paymentUrl, validUntil, maxVisits });
989
+ }
990
+
980
991
  res.json({
992
+ paymentUrl,
981
993
  checkoutSession: doc.toJSON(),
982
994
  paymentMethods: await getPaymentMethods(doc),
983
995
  paymentLink: 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}` });