payment-kit 1.20.12 → 1.20.14

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 +8 -0
  2. package/api/src/libs/env.ts +1 -0
  3. package/api/src/libs/vendor-util/adapters/launcher-adapter.ts +1 -1
  4. package/api/src/libs/vendor-util/adapters/types.ts +1 -0
  5. package/api/src/libs/vendor-util/fulfillment.ts +1 -1
  6. package/api/src/queues/vendors/fulfillment-coordinator.ts +1 -29
  7. package/api/src/queues/vendors/return-processor.ts +184 -0
  8. package/api/src/queues/vendors/return-scanner.ts +119 -0
  9. package/api/src/queues/vendors/status-check.ts +1 -1
  10. package/api/src/routes/checkout-sessions.ts +15 -2
  11. package/api/src/routes/coupons.ts +7 -0
  12. package/api/src/routes/credit-grants.ts +8 -1
  13. package/api/src/routes/credit-transactions.ts +153 -13
  14. package/api/src/routes/invoices.ts +35 -1
  15. package/api/src/routes/meter-events.ts +31 -3
  16. package/api/src/routes/meters.ts +4 -0
  17. package/api/src/routes/payment-currencies.ts +2 -1
  18. package/api/src/routes/promotion-codes.ts +2 -2
  19. package/api/src/routes/subscription-items.ts +4 -0
  20. package/api/src/routes/vendor.ts +89 -2
  21. package/api/src/routes/webhook-endpoints.ts +4 -0
  22. package/api/src/store/migrations/20250918-add-vendor-extends.ts +20 -0
  23. package/api/src/store/migrations/20250919-add-source-data.ts +20 -0
  24. package/api/src/store/models/checkout-session.ts +5 -2
  25. package/api/src/store/models/credit-transaction.ts +5 -0
  26. package/api/src/store/models/meter-event.ts +22 -12
  27. package/api/src/store/models/product-vendor.ts +6 -0
  28. package/api/src/store/models/types.ts +18 -0
  29. package/blocklet.yml +1 -1
  30. package/package.json +5 -5
  31. package/src/components/customer/credit-overview.tsx +1 -1
  32. package/src/components/customer/related-credit-grants.tsx +194 -0
  33. package/src/components/meter/add-usage-dialog.tsx +8 -0
  34. package/src/components/meter/events-list.tsx +93 -96
  35. package/src/components/product/form.tsx +0 -1
  36. package/src/locales/en.tsx +9 -0
  37. package/src/locales/zh.tsx +9 -0
  38. package/src/pages/admin/billing/invoices/detail.tsx +21 -2
  39. package/src/pages/customer/invoice/detail.tsx +11 -2
@@ -19,6 +19,7 @@ import {
19
19
  stripeSubscriptionCronTime,
20
20
  subscriptionCronTime,
21
21
  vendorStatusCheckCronTime,
22
+ vendorReturnScanCronTime,
22
23
  } from '../libs/env';
23
24
  import logger from '../libs/logger';
24
25
  import { startCreditConsumeQueue } from '../queues/credit-consume';
@@ -31,6 +32,7 @@ import { createPaymentStat } from './payment-stat';
31
32
  import { SubscriptionTrialWillEndSchedule } from './subscription-trial-will-end';
32
33
  import { SubscriptionWillCanceledSchedule } from './subscription-will-canceled';
33
34
  import { SubscriptionWillRenewSchedule } from './subscription-will-renew';
35
+ import { scheduleVendorReturnScan } from '../queues/vendors/return-scanner';
34
36
 
35
37
  function init() {
36
38
  Cron.init({
@@ -123,6 +125,12 @@ function init() {
123
125
  fn: () => startVendorStatusCheckSchedule(),
124
126
  options: { runOnInit: false },
125
127
  },
128
+ {
129
+ name: 'vendor.return.scan',
130
+ time: vendorReturnScanCronTime,
131
+ fn: () => scheduleVendorReturnScan(),
132
+ options: { runOnInit: false },
133
+ },
126
134
  ],
127
135
  onError: (error: Error, name: string) => {
128
136
  logger.error('run job failed', { name, error });
@@ -15,6 +15,7 @@ export const meteringSubscriptionDetectionCronTime: string =
15
15
  export const depositVaultCronTime: string = process.env.DEPOSIT_VAULT_CRON_TIME || '0 */5 * * * *'; // 默认每 5 min 执行一次
16
16
  export const creditConsumptionCronTime: string = process.env.CREDIT_CONSUMPTION_CRON_TIME || '0 */10 * * * *'; // 默认每 10 min 执行一次
17
17
  export const vendorStatusCheckCronTime: string = process.env.VENDOR_STATUS_CHECK_CRON_TIME || '0 */10 * * * *'; // 默认每 10 min 执行一次
18
+ export const vendorReturnScanCronTime: string = process.env.VENDOR_RETURN_SCAN_CRON_TIME || '0 */10 * * * *'; // 默认每 10 min 执行一次
18
19
  export const vendorTimeoutMinutes: number = process.env.VENDOR_TIMEOUT_MINUTES
19
20
  ? +process.env.VENDOR_TIMEOUT_MINUTES
20
21
  : 10; // 默认 10 分钟超时
@@ -1,4 +1,4 @@
1
- import { VendorAuth } from '@blocklet/payment-vendor';
1
+ import { Auth as VendorAuth } from '@blocklet/payment-vendor';
2
2
 
3
3
  import { joinURL } from 'ufo';
4
4
  import { ProductVendor } from '../../../store/models';
@@ -73,6 +73,7 @@ export interface ReturnRequestParams {
73
73
  export interface ReturnRequestResult {
74
74
  status: 'requested' | 'accepted' | 'rejected' | 'failed';
75
75
  message?: string;
76
+ success?: boolean;
76
77
  }
77
78
 
78
79
  export interface CheckOrderStatusParams extends Record<string, any> {}
@@ -206,7 +206,7 @@ export class VendorFulfillmentService {
206
206
  } catch (error: any) {
207
207
  logger.error('Failed to get vendor adapter', {
208
208
  vendorKey,
209
- error: error.message,
209
+ error,
210
210
  });
211
211
  throw error;
212
212
  }
@@ -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';
@@ -668,7 +668,9 @@ const getBeneficiaryName = async (beneficiary: PaymentBeneficiary) => {
668
668
  return beneficiary.name || (await getUserOrAppInfo(beneficiary.address || ''))?.name || beneficiary.address;
669
669
  };
670
670
 
671
- export async function getCrossSellItem(checkoutSession: CheckoutSession) {
671
+ export async function getCrossSellItem(
672
+ checkoutSession: CheckoutSession
673
+ ): Promise<{ error?: string } | (TPriceExpanded & { product: any; error?: string })> {
672
674
  // FIXME: perhaps we can support cross sell even if the current session have multiple items
673
675
  if (checkoutSession.line_items.length > 1) {
674
676
  return { error: 'Cross sell not supported for checkoutSession with multiple line items' };
@@ -2334,8 +2336,12 @@ router.put('/:id/expire', auth, ensureCheckoutSessionOpen, async (req, res) => {
2334
2336
  router.get('/:id/cross-sell', user, ensureCheckoutSessionOpen, async (req, res) => {
2335
2337
  try {
2336
2338
  const checkoutSession = req.doc as CheckoutSession;
2339
+ const skipError = req.query.skipError === 'true';
2337
2340
  const result = await getCrossSellItem(checkoutSession);
2338
- // @ts-ignore
2341
+
2342
+ if (skipError && result.error) {
2343
+ return res.status(200).json(result);
2344
+ }
2339
2345
  return res.status(result.error ? 400 : 200).json(result);
2340
2346
  } catch (err) {
2341
2347
  logger.error(err);
@@ -2633,6 +2639,10 @@ router.post('/:id/recalculate-promotion', user, ensureCheckoutSessionOpen, async
2633
2639
  return res.status(400).json({ error: 'Coupon no longer valid' });
2634
2640
  }
2635
2641
 
2642
+ const now = dayjs().unix();
2643
+ const trialSetup = getSubscriptionTrialSetup(checkoutSession.subscription_data as any, currency.id);
2644
+ const isTrialing = trialSetup.trialInDays > 0 || trialSetup.trialEnd > now;
2645
+
2636
2646
  // Apply discount with new currency
2637
2647
  const discountResult = await applyDiscountsToLineItems({
2638
2648
  lineItems: expandedItems,
@@ -2640,6 +2650,9 @@ router.post('/:id/recalculate-promotion', user, ensureCheckoutSessionOpen, async
2640
2650
  couponId,
2641
2651
  customerId: customer.id,
2642
2652
  currency,
2653
+ billingContext: {
2654
+ trialing: isTrialing,
2655
+ },
2643
2656
  });
2644
2657
 
2645
2658
  // Check if discount can still be applied with the new currency
@@ -373,6 +373,13 @@ router.put('/:id', auth, async (req, res) => {
373
373
  return res.status(404).json({ error: 'Coupon not found' });
374
374
  }
375
375
 
376
+ if (req.body.metadata) {
377
+ const { error: metadataError } = MetadataSchema.validate(req.body.metadata);
378
+ if (metadataError) {
379
+ return res.status(400).json({ error: `metadata invalid: ${metadataError.message}` });
380
+ }
381
+ }
382
+
376
383
  if (coupon.locked) {
377
384
  const allowedUpdates = pick(value, ['name', 'metadata', 'description']);
378
385
  if (Object.keys(allowedUpdates).length === 0) {
@@ -11,6 +11,7 @@ import { CreditGrant, Customer, PaymentCurrency, Price, Subscription } from '../
11
11
  import { createCreditGrant } from '../libs/credit-grant';
12
12
  import { getMeterPriceIdsFromSubscription } from '../libs/subscription';
13
13
  import { blocklet } from '../libs/auth';
14
+ import { formatMetadata } from '../libs/util';
14
15
 
15
16
  const router = Router();
16
17
  const auth = authenticate<CreditGrant>({ component: true, roles: ['owner', 'admin'] });
@@ -264,7 +265,13 @@ router.put('/:id', auth, async (req, res) => {
264
265
  if (error) {
265
266
  return res.status(400).json({ error: `Credit grant update request invalid: ${error.message}` });
266
267
  }
267
- await creditGrant.update({ metadata: req.body.metadata });
268
+ if (req.body.metadata) {
269
+ const { error: metadataError } = MetadataSchema.validate(req.body.metadata);
270
+ if (metadataError) {
271
+ return res.status(400).json({ error: `metadata invalid: ${metadataError.message}` });
272
+ }
273
+ }
274
+ await creditGrant.update({ metadata: formatMetadata(req.body.metadata) });
268
275
  return res.json({ success: true });
269
276
  });
270
277