payment-kit 1.22.28 → 1.22.30

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/README.md CHANGED
@@ -25,11 +25,20 @@ The decentralized Stripe for the Blocklet platform.
25
25
  - Copy the `BLOCKLET_DEV_APP_DID`
26
26
  - Transfer 2 TBA from your DID Wallet to the copied address
27
27
 
28
+ ### Configure Stripe
29
+
30
+ Before using Stripe as a payment method, you need to configure the webhook secret:
31
+
32
+ 1. Set the `STRIPE_WEBHOOK_SECRET` environment variable in your Blocklet settings
33
+ - Add the environment variable `STRIPE_WEBHOOK_SECRET` with your Stripe webhook signing secret
34
+ - You can find this in your Stripe Dashboard > Developers > Webhooks > Signing Secret
35
+
28
36
  ### Debug Stripe
29
37
 
30
38
  1. Install and log in following the instructions at: https://stripe.com/docs/stripe-cli
31
39
  2. Start your local Payment Kit server and note its port
32
40
  3. Run `stripe listen --forward-to http://127.0.0.1:8188/api/integrations/stripe/webhook --log-level=debug --latest`
41
+ 4. Copy the webhook signing secret from the CLI output and set it as `STRIPE_WEBHOOK_SECRET` environment variable
33
42
 
34
43
  ### Test Stripe
35
44
 
@@ -19,6 +19,12 @@ export const vendorReturnScanCronTime: string = process.env.VENDOR_RETURN_SCAN_C
19
19
  export const vendorTimeoutMinutes: number = process.env.VENDOR_TIMEOUT_MINUTES
20
20
  ? +process.env.VENDOR_TIMEOUT_MINUTES
21
21
  : 10; // 默认 10 分钟超时
22
+ export const webhookAlertWindowMinutes: number = process.env.WEBHOOK_ALERT_WINDOW_MINUTES
23
+ ? +process.env.WEBHOOK_ALERT_WINDOW_MINUTES
24
+ : 10; // webhook 连续失败告警时间窗口,默认 10 分钟
25
+ export const webhookAlertMinFailures: number = process.env.WEBHOOK_ALERT_MIN_FAILURES
26
+ ? +process.env.WEBHOOK_ALERT_MIN_FAILURES
27
+ : 3; // webhook 触发告警的最小失败次数,默认 3 次
22
28
 
23
29
  export const shortUrlApiKey: string = process.env.SHORT_URL_API_KEY || '';
24
30
  export const shortUrlDomain: string = process.env.SHORT_URL_DOMAIN || 's.abtnet.io';
@@ -0,0 +1,140 @@
1
+ import { getUrl } from '@blocklet/sdk/lib/component';
2
+ import { withQuery } from 'ufo';
3
+ import { getConnectQueryParam, getOwnerDid } from '../../util';
4
+ import { translate } from '../../../locales';
5
+ import { WebhookEndpoint } from '../../../store/models';
6
+ import type { BaseEmailTemplate, BaseEmailTemplateType } from './base';
7
+ import { getUserLocale } from '../../../integrations/blocklet/notification';
8
+
9
+ export interface WebhookEndpointFailedEmailTemplateOptions {
10
+ webhookEndpointId: string;
11
+ failedCount: number;
12
+ }
13
+
14
+ interface WebhookEndpointFailedEmailTemplateContext {
15
+ locale: string;
16
+ userDid: string;
17
+ webhookDescription: string;
18
+ failedCount: number;
19
+ webhookEndpointId: string;
20
+ viewWebhookLink: string;
21
+ }
22
+
23
+ // eslint-disable-next-line prettier/prettier
24
+ export class WebhookEndpointFailedEmailTemplate implements BaseEmailTemplate<WebhookEndpointFailedEmailTemplateContext> {
25
+ options: WebhookEndpointFailedEmailTemplateOptions;
26
+
27
+ constructor(options: WebhookEndpointFailedEmailTemplateOptions) {
28
+ this.options = options;
29
+ }
30
+
31
+ async getContext(): Promise<WebhookEndpointFailedEmailTemplateContext> {
32
+ const { webhookEndpointId, failedCount } = this.options;
33
+
34
+ const webhookEndpoint = await WebhookEndpoint.findByPk(webhookEndpointId);
35
+ if (!webhookEndpoint) {
36
+ throw new Error(`WebhookEndpoint not found: ${webhookEndpointId}`);
37
+ }
38
+
39
+ const userDid = await getOwnerDid();
40
+ if (!userDid) {
41
+ throw new Error('get owner did failed');
42
+ }
43
+
44
+ const locale = await getUserLocale(userDid);
45
+
46
+ const viewWebhookLink = getUrl(
47
+ withQuery(`admin/developers/${webhookEndpointId}`, {
48
+ locale,
49
+ ...getConnectQueryParam({ userDid }),
50
+ })
51
+ );
52
+
53
+ return {
54
+ userDid,
55
+ locale,
56
+ webhookDescription: webhookEndpoint.description || '',
57
+ failedCount,
58
+ webhookEndpointId,
59
+ viewWebhookLink,
60
+ };
61
+ }
62
+
63
+ async getTemplate(): Promise<BaseEmailTemplateType> {
64
+ const { locale, webhookDescription, failedCount, webhookEndpointId, viewWebhookLink } = await this.getContext();
65
+
66
+ const template: BaseEmailTemplateType = {
67
+ title: translate('notification.webhookEndpointFailed.title', locale, {
68
+ failedCount,
69
+ }),
70
+ body: translate('notification.webhookEndpointFailed.body', locale, {
71
+ failedCount,
72
+ }),
73
+ attachments: [
74
+ {
75
+ type: 'section',
76
+ fields: [
77
+ {
78
+ type: 'text',
79
+ data: {
80
+ type: 'plain',
81
+ color: '#9397A1',
82
+ text: translate('notification.common.webhookEndpointId', locale),
83
+ },
84
+ },
85
+ {
86
+ type: 'text',
87
+ data: {
88
+ type: 'plain',
89
+ text: webhookEndpointId,
90
+ },
91
+ },
92
+ webhookDescription
93
+ ? {
94
+ type: 'text',
95
+ data: {
96
+ type: 'plain',
97
+ color: '#9397A1',
98
+ text: translate('notification.common.description', locale),
99
+ },
100
+ }
101
+ : null,
102
+ webhookDescription
103
+ ? {
104
+ type: 'text',
105
+ data: {
106
+ type: 'plain',
107
+ text: webhookDescription,
108
+ },
109
+ }
110
+ : null,
111
+ {
112
+ type: 'text',
113
+ data: {
114
+ type: 'plain',
115
+ color: '#9397A1',
116
+ text: translate('notification.common.failedCount', locale),
117
+ },
118
+ },
119
+ {
120
+ type: 'text',
121
+ data: {
122
+ type: 'plain',
123
+ text: failedCount.toString(),
124
+ },
125
+ },
126
+ ].filter(Boolean),
127
+ },
128
+ ],
129
+ actions: [
130
+ {
131
+ name: translate('notification.common.viewWebhook', locale),
132
+ title: translate('notification.common.viewWebhook', locale),
133
+ link: viewWebhookLink,
134
+ },
135
+ ].filter(Boolean),
136
+ };
137
+
138
+ return template;
139
+ }
140
+ }
@@ -163,10 +163,10 @@ export function tryWithTimeout(asyncFn: Function, timeout = 5000) {
163
163
  }
164
164
 
165
165
  // simple exponential delay: 2^retryCount
166
- export const getNextRetry = (retryCount: number) => {
166
+ export const getNextRetry = (retryCount: number, eventCreatedAt?: Date) => {
167
167
  const delay = 2 ** retryCount;
168
- const now = dayjs().unix();
169
- return now + delay;
168
+ const baseTime = eventCreatedAt ? dayjs(eventCreatedAt).unix() : dayjs().unix();
169
+ return baseTime + delay;
170
170
  };
171
171
 
172
172
  export const getWebhookJobId = (eventId: string, webhookId: string) => {
@@ -53,6 +53,11 @@ export default flat({
53
53
  reloadCredits: 'Reload Credits',
54
54
  invoiceNumber: 'Invoice Number',
55
55
  payer: 'Payer',
56
+ webhookEndpointId: 'Webhook Endpoint ID',
57
+ webhookUrl: 'Webhook URL',
58
+ description: 'Description',
59
+ failedCount: 'Failed Count',
60
+ viewWebhook: 'View Webhook',
56
61
  },
57
62
 
58
63
  billingDiscrepancy: {
@@ -60,6 +65,11 @@ export default flat({
60
65
  body: 'A billing discrepancy has been detected for {productName}. Please review your billing details.',
61
66
  },
62
67
 
68
+ webhookEndpointFailed: {
69
+ title: 'Webhook endpoint alert: {webhookUrl}',
70
+ body: 'Your webhook endpoint has failed {failedCount} times consecutively. Please check the webhook details and ensure the endpoint is accessible and functioning correctly.',
71
+ },
72
+
63
73
  sendTo: 'Sent to',
64
74
  mintNFT: {
65
75
  title: '{collection} NFT minted',
@@ -53,6 +53,11 @@ export default flat({
53
53
  manageCredit: '管理额度',
54
54
  invoiceNumber: '账单编号',
55
55
  payer: '付款方',
56
+ webhookEndpointId: 'Webhook 端点 ID',
57
+ webhookUrl: 'Webhook URL',
58
+ description: '描述',
59
+ failedCount: '失败次数',
60
+ viewWebhook: '查看 Webhook',
56
61
  },
57
62
 
58
63
  sendTo: '发送给',
@@ -71,6 +76,11 @@ export default flat({
71
76
  body: '检测到 {productName} 账单金额核算不一致,请留意。',
72
77
  },
73
78
 
79
+ webhookEndpointFailed: {
80
+ title: 'Webhook 端点警告: {webhookUrl}',
81
+ body: '您的 Webhook 端点已连续失败 {failedCount} 次,请查看详情并确保端点可正常访问和工作。',
82
+ },
83
+
74
84
  meteringSubscriptionDetection: {
75
85
  title: '[{appName}] 按量计费订阅检测',
76
86
  body: '在 {startTimeStr} - {endTimeStr} 期间,共扫描了 {totalCount} 份按量计费的订阅,其中 {normalCount} 份为正常订阅,{abnormalCount} 份存在异常,包括 {unreportedCount} 份未上报的订阅和 {discrepantCount} 份账单核算有问题的订阅。\n\n 异常订阅:',
@@ -3,11 +3,10 @@ import { Op } from 'sequelize';
3
3
  import { events } from '../libs/event';
4
4
  import logger from '../libs/logger';
5
5
  import createQueue from '../libs/queue';
6
- import { getWebhookJobId } from '../libs/util';
7
6
  import { Event } from '../store/models/event';
8
7
  import { WebhookAttempt } from '../store/models/webhook-attempt';
9
8
  import { WebhookEndpoint } from '../store/models/webhook-endpoint';
10
- import { webhookQueue } from './webhook';
9
+ import { addWebhookJob } from './webhook';
11
10
 
12
11
  type EventJob = {
13
12
  eventId: string;
@@ -52,16 +51,8 @@ export const handleEvent = async (job: EventJob) => {
52
51
 
53
52
  // we should only push webhook if it's not successfully attempted before
54
53
  if (attemptCount === 0) {
55
- const jobId = getWebhookJobId(event.id, webhook.id);
56
- const exist = await webhookQueue.get(jobId);
57
- if (!exist) {
58
- logger.info(`Scheduling attempt for event ${event.id} and webhook ${webhook.id}`, job);
59
- webhookQueue.push({
60
- id: jobId,
61
- job: { eventId: event.id, webhookId: webhook.id },
62
- persist: false,
63
- });
64
- }
54
+ logger.info(`Scheduling attempt for event ${event.id} and webhook ${webhook.id}`, job);
55
+ await addWebhookJob(event.id, webhook.id, { persist: false });
65
56
  }
66
57
  });
67
58
 
@@ -110,6 +110,10 @@ import {
110
110
  AggregatedSubscriptionRenewedEmailTemplate,
111
111
  AggregatedSubscriptionRenewedEmailTemplateOptions,
112
112
  } from '../libs/notification/template/aggregated-subscription-renewed';
113
+ import {
114
+ WebhookEndpointFailedEmailTemplate,
115
+ WebhookEndpointFailedEmailTemplateOptions,
116
+ } from '../libs/notification/template/webhook-endpoint-failed';
113
117
  import type { TJob } from '../store/models/job';
114
118
 
115
119
  export type NotificationQueueJobOptions = any;
@@ -134,7 +138,8 @@ export type NotificationQueueJobType =
134
138
  | 'customer.credit.insufficient'
135
139
  | 'customer.credit_grant.granted'
136
140
  | 'customer.credit.low_balance'
137
- | 'customer.auto_recharge.failed';
141
+ | 'customer.auto_recharge.failed'
142
+ | 'webhook.endpoint.failed';
138
143
 
139
144
  export type NotificationQueueJob = {
140
145
  type: NotificationQueueJobType;
@@ -280,6 +285,10 @@ async function getNotificationTemplate(job: NotificationQueueJob): Promise<BaseE
280
285
  return new CustomerAutoRechargeFailedEmailTemplate(job.options as CustomerAutoRechargeFailedEmailTemplateOptions);
281
286
  }
282
287
 
288
+ if (job.type === 'webhook.endpoint.failed') {
289
+ return new WebhookEndpointFailedEmailTemplate(job.options as WebhookEndpointFailedEmailTemplateOptions);
290
+ }
291
+
283
292
  throw new Error(`Unknown job type: ${job.type}`);
284
293
  }
285
294
 
@@ -1,15 +1,18 @@
1
- import { sign } from '@blocklet/sdk/lib/util/verify-sign';
2
- import axios, { AxiosError } from 'axios';
1
+ import componentApi from '@blocklet/sdk/lib/util/component-api';
2
+ import { AxiosError } from 'axios';
3
+ import { Op } from 'sequelize';
3
4
 
4
5
  import { wallet } from '../libs/auth';
5
6
  import logger from '../libs/logger';
6
7
  import createQueue from '../libs/queue';
7
8
  import { MAX_RETRY_COUNT, getNextRetry, getWebhookJobId } from '../libs/util';
9
+ import { webhookAlertWindowMinutes, webhookAlertMinFailures } from '../libs/env';
8
10
  import { Customer } from '../store/models/customer';
9
11
  import { Event } from '../store/models/event';
10
12
  import { PaymentCurrency } from '../store/models/payment-currency';
11
13
  import { WebhookAttempt } from '../store/models/webhook-attempt';
12
14
  import { WebhookEndpoint } from '../store/models/webhook-endpoint';
15
+ import { addNotificationJob } from './notification';
13
16
 
14
17
  type WebhookJob = {
15
18
  eventId: string;
@@ -55,7 +58,7 @@ export const handleWebhook = async (job: WebhookJob) => {
55
58
  }
56
59
 
57
60
  // verify similar to component call, but supports external urls
58
- const result = await axios({
61
+ const result = await componentApi.request({
59
62
  url: webhook.url,
60
63
  method: 'POST',
61
64
  timeout: 60 * 1000,
@@ -63,8 +66,6 @@ export const handleWebhook = async (job: WebhookJob) => {
63
66
  headers: {
64
67
  'x-app-id': wallet.address,
65
68
  'x-app-pk': wallet.publicKey,
66
- 'x-component-sig': await sign(json),
67
- 'x-component-did': process.env.BLOCKLET_COMPONENT_DID as string,
68
69
  },
69
70
  });
70
71
 
@@ -85,27 +86,35 @@ export const handleWebhook = async (job: WebhookJob) => {
85
86
  logger.info('webhook attempt success', { ...job, retryCount });
86
87
  } catch (err: any) {
87
88
  logger.warn('webhook attempt error', { ...job, retryCount, message: err.message });
89
+ const errorStatus = (err as AxiosError).response?.status || 500;
90
+
88
91
  await WebhookAttempt.create({
89
92
  livemode: event.livemode,
90
93
  event_id: event.id,
91
94
  webhook_endpoint_id: webhook.id,
92
95
  status: 'failed',
93
- response_status: (err as AxiosError).response?.status || 500,
96
+ response_status: errorStatus,
94
97
  response_body: (err as AxiosError).response?.data || {},
95
98
  retry_count: retryCount,
96
99
  });
97
100
  logger.info('Failed WebhookAttempt created', { eventId: event.id, webhookId: webhook.id });
98
101
 
99
- // reschedule next attempt
102
+ try {
103
+ await checkAndNotifyWebhookFailures(webhook.id);
104
+ } catch (notifyError: any) {
105
+ logger.error('Failed to check and notify webhook failures', {
106
+ webhookId: webhook.id,
107
+ error: notifyError.message,
108
+ });
109
+ }
110
+
100
111
  if (retryCount < MAX_RETRY_COUNT) {
101
112
  process.nextTick(() => {
102
- webhookQueue.push({
103
- id: getWebhookJobId(event.id, webhook.id),
104
- job: { eventId: event.id, webhookId: webhook.id },
105
- runAt: getNextRetry(retryCount),
113
+ addWebhookJob(event.id, webhook.id, {
114
+ runAt: getNextRetry(retryCount, event.created_at),
106
115
  persist: false,
116
+ skipExistCheck: true,
107
117
  });
108
- logger.info('scheduled webhook job', { ...job, retryCount });
109
118
  });
110
119
  } else {
111
120
  await event.decrement('pending_webhooks');
@@ -117,6 +126,48 @@ export const handleWebhook = async (job: WebhookJob) => {
117
126
  }
118
127
  };
119
128
 
129
+ // Alert if webhook continuously fails within configured time window
130
+ async function checkAndNotifyWebhookFailures(webhookId: string) {
131
+ const alertWindowStart = new Date(Date.now() - webhookAlertWindowMinutes * 60 * 1000);
132
+
133
+ const recentAttempts = await WebhookAttempt.findAll({
134
+ where: {
135
+ webhook_endpoint_id: webhookId,
136
+ created_at: { [Op.gte]: alertWindowStart },
137
+ },
138
+ order: [['created_at', 'DESC']],
139
+ attributes: ['status'],
140
+ });
141
+
142
+ const hasSuccessInWindow = recentAttempts.some((attempt) => attempt.status === 'succeeded');
143
+ if (hasSuccessInWindow) {
144
+ return;
145
+ }
146
+
147
+ const failedCountInWindow = recentAttempts.filter((attempt) => attempt.status === 'failed').length;
148
+ if (failedCountInWindow < webhookAlertMinFailures) {
149
+ return;
150
+ }
151
+
152
+ addNotificationJob(
153
+ 'webhook.endpoint.failed',
154
+ {
155
+ webhookEndpointId: webhookId,
156
+ failedCount: failedCountInWindow,
157
+ },
158
+ [webhookId],
159
+ true,
160
+ 24 * 3600
161
+ );
162
+
163
+ logger.info('Notification job added for webhook consecutive failures', {
164
+ webhookId,
165
+ failedCountInWindow,
166
+ alertWindowMinutes: webhookAlertWindowMinutes,
167
+ minFailures: webhookAlertMinFailures,
168
+ });
169
+ }
170
+
120
171
  export const webhookQueue = createQueue<WebhookJob>({
121
172
  name: 'webhook',
122
173
  onJob: handleWebhook,
@@ -130,3 +181,34 @@ export const webhookQueue = createQueue<WebhookJob>({
130
181
  webhookQueue.on('failed', ({ id, job, error }) => {
131
182
  logger.error('webhook job failed', { id, job, error });
132
183
  });
184
+
185
+ export async function addWebhookJob(
186
+ eventId: string,
187
+ webhookId: string,
188
+ options: {
189
+ runAt?: number;
190
+ persist?: boolean;
191
+ skipExistCheck?: boolean;
192
+ } = {}
193
+ ) {
194
+ const { runAt, persist = false, skipExistCheck = false } = options;
195
+ const jobId = getWebhookJobId(eventId, webhookId);
196
+
197
+ if (!skipExistCheck) {
198
+ const exist = await webhookQueue.get(jobId);
199
+ if (exist) {
200
+ logger.info('Webhook job already exists, skipping', { eventId, webhookId, jobId });
201
+ return false;
202
+ }
203
+ }
204
+
205
+ webhookQueue.push({
206
+ id: jobId,
207
+ job: { eventId, webhookId },
208
+ runAt,
209
+ persist,
210
+ });
211
+
212
+ logger.info('Webhook job added to queue', { eventId, webhookId, jobId, runAt, persist });
213
+ return true;
214
+ }
@@ -1,12 +1,16 @@
1
1
  import { Router } from 'express';
2
2
  import Joi from 'joi';
3
3
  import type { WhereOptions } from 'sequelize';
4
+ import { Op } from 'sequelize';
4
5
 
5
6
  import { createListParamSchema, getOrder } from '../libs/api';
6
7
  import { authenticate } from '../libs/security';
7
8
  import { Event } from '../store/models/event';
8
9
  import { blocklet } from '../libs/auth';
9
10
  import logger from '../libs/logger';
11
+ import { addWebhookJob } from '../queues/webhook';
12
+ import { WebhookEndpoint } from '../store/models/webhook-endpoint';
13
+ import { Subscription } from '../store/models/subscription';
10
14
 
11
15
  const router = Router();
12
16
  const auth = authenticate<Event>({ component: true, roles: ['owner', 'admin'] });
@@ -55,6 +59,157 @@ router.get('/', auth, async (req, res) => {
55
59
  }
56
60
  });
57
61
 
62
+ const retryWebhooksSchema = createListParamSchema<{
63
+ eventType?: string;
64
+ objectType?: string;
65
+ objectId?: string;
66
+ objectIds?: string;
67
+ eventIds?: string;
68
+ subscriptionStatus?: string;
69
+ latestOnly?: boolean;
70
+ }>({
71
+ eventType: Joi.string().empty(''),
72
+ objectType: Joi.string().empty(''),
73
+ objectId: Joi.string().empty(''),
74
+ objectIds: Joi.string().empty(''),
75
+ eventIds: Joi.string().empty(''),
76
+ subscriptionStatus: Joi.string().empty(''),
77
+ latestOnly: Joi.boolean().optional(),
78
+ });
79
+
80
+ router.get('/retry-webhooks', auth, async (req, res) => {
81
+ try {
82
+ const { eventType, objectType, objectId, objectIds, eventIds, subscriptionStatus, latestOnly } =
83
+ await retryWebhooksSchema.validateAsync(req.query, {
84
+ stripUnknown: true,
85
+ });
86
+
87
+ const where: WhereOptions<Event> = { livemode: req.livemode };
88
+ let targetObjectIds: string[] = [];
89
+
90
+ // Handle subscription status filter
91
+ if (subscriptionStatus) {
92
+ const subscriptions = await Subscription.findAll({
93
+ where: {
94
+ status: subscriptionStatus,
95
+ livemode: req.livemode,
96
+ },
97
+ attributes: ['id'],
98
+ });
99
+
100
+ if (subscriptions.length === 0) {
101
+ return res.json({
102
+ message: `No subscriptions found with status: ${subscriptionStatus}`,
103
+ scheduled: 0,
104
+ eventsProcessed: 0,
105
+ });
106
+ }
107
+
108
+ targetObjectIds = subscriptions.map((sub) => sub.id);
109
+ where.object_type = 'subscription';
110
+ where.object_id = { [Op.in]: targetObjectIds };
111
+
112
+ logger.info(`Found ${targetObjectIds.length} subscriptions with status: ${subscriptionStatus}`);
113
+ }
114
+
115
+ // Handle explicit object filters
116
+ if (objectType) {
117
+ where.object_type = objectType;
118
+ }
119
+
120
+ if (objectId) {
121
+ where.object_id = objectId;
122
+ }
123
+
124
+ if (objectIds) {
125
+ const ids = objectIds
126
+ .split(',')
127
+ .map((x) => x.trim())
128
+ .filter(Boolean);
129
+ where.object_id = ids.length > 1 ? { [Op.in]: ids } : ids[0];
130
+ }
131
+
132
+ if (eventType) {
133
+ where.type = eventType;
134
+ }
135
+
136
+ if (eventIds) {
137
+ const ids = eventIds
138
+ .split(',')
139
+ .map((x) => x.trim())
140
+ .filter(Boolean);
141
+ where.id = ids.length > 1 ? { [Op.in]: ids } : ids[0];
142
+ }
143
+
144
+ let events = await Event.findAll({
145
+ where,
146
+ order: [['created_at', 'DESC']],
147
+ });
148
+
149
+ if (events.length === 0) {
150
+ return res.json({
151
+ message: 'No events found matching the criteria',
152
+ scheduled: 0,
153
+ eventsProcessed: 0,
154
+ });
155
+ }
156
+
157
+ // If latestOnly is true, group by object_id and keep only the latest event for each
158
+ if (latestOnly) {
159
+ const eventsByObject = new Map<string, Event>();
160
+ events.forEach((event) => {
161
+ if (!eventsByObject.has(event.object_id)) {
162
+ eventsByObject.set(event.object_id, event);
163
+ }
164
+ });
165
+ events = Array.from(eventsByObject.values());
166
+ logger.info(`Filtered to ${events.length} latest events (from ${eventsByObject.size} unique objects)`);
167
+ }
168
+
169
+ const webhooks = await WebhookEndpoint.findAll({
170
+ where: { status: 'enabled', livemode: req.livemode },
171
+ });
172
+
173
+ if (webhooks.length === 0) {
174
+ return res.json({
175
+ message: 'No enabled webhook endpoints found',
176
+ scheduled: 0,
177
+ eventsProcessed: events.length,
178
+ });
179
+ }
180
+
181
+ let scheduled = 0;
182
+ // eslint-disable-next-line no-restricted-syntax
183
+ for (const event of events) {
184
+ const eventWebhooks = webhooks.filter((webhook) => webhook.enabled_events.includes(event.type));
185
+
186
+ // eslint-disable-next-line no-restricted-syntax
187
+ for (const webhook of eventWebhooks) {
188
+ // eslint-disable-next-line no-await-in-loop
189
+ const added = await addWebhookJob(event.id, webhook.id, { persist: false });
190
+ if (added) {
191
+ scheduled += 1;
192
+ }
193
+ }
194
+ }
195
+
196
+ logger.info('Batch webhook retry completed', {
197
+ eventsProcessed: events.length,
198
+ webhooksScheduled: scheduled,
199
+ criteria: { eventType, objectType, objectId, subscriptionStatus, latestOnly },
200
+ });
201
+
202
+ return res.json({
203
+ message: `Successfully scheduled ${scheduled} webhooks for retry across ${events.length} events`,
204
+ scheduled,
205
+ eventsProcessed: events.length,
206
+ });
207
+ } catch (err: any) {
208
+ logger.error('Failed to batch retry webhooks', err);
209
+ return res.status(500).json({ error: `Failed to retry webhooks: ${err.message}` });
210
+ }
211
+ });
212
+
58
213
  router.get('/:id', auth, async (req, res) => {
59
214
  try {
60
215
  const doc = await Event.findOne({
@@ -77,4 +232,45 @@ router.get('/:id', auth, async (req, res) => {
77
232
  }
78
233
  });
79
234
 
235
+ router.post('/:id/retry-webhooks', auth, async (req, res) => {
236
+ try {
237
+ const event = await Event.findOne({
238
+ where: { id: req.params.id },
239
+ });
240
+
241
+ if (!event) {
242
+ return res.status(404).json({ error: 'Event not found' });
243
+ }
244
+
245
+ const webhooks = await WebhookEndpoint.findAll({
246
+ where: { status: 'enabled', livemode: event.livemode },
247
+ });
248
+ const eventWebhooks = webhooks.filter((webhook) => webhook.enabled_events.includes(event.type));
249
+
250
+ if (eventWebhooks.length === 0) {
251
+ return res.json({ message: 'No enabled webhook endpoints found for this event type', scheduled: 0 });
252
+ }
253
+
254
+ let scheduled = 0;
255
+ // eslint-disable-next-line no-restricted-syntax
256
+ for (const webhook of eventWebhooks) {
257
+ // eslint-disable-next-line no-await-in-loop
258
+ const added = await addWebhookJob(event.id, webhook.id, { persist: false });
259
+ if (added) {
260
+ scheduled += 1;
261
+ logger.info('Manually scheduled webhook retry', { eventId: event.id, webhookId: webhook.id });
262
+ }
263
+ }
264
+
265
+ return res.json({
266
+ message: `Successfully scheduled ${scheduled} webhooks for retry`,
267
+ scheduled,
268
+ total: eventWebhooks.length,
269
+ });
270
+ } catch (err: any) {
271
+ logger.error('Failed to retry webhooks for event', err);
272
+ return res.status(500).json({ error: `Failed to retry webhooks: ${err.message}` });
273
+ }
274
+ });
275
+
80
276
  export default router;
package/blocklet.yml CHANGED
@@ -14,7 +14,7 @@ repository:
14
14
  type: git
15
15
  url: git+https://github.com/blocklet/payment-kit.git
16
16
  specVersion: 1.2.8
17
- version: 1.22.28
17
+ version: 1.22.30
18
18
  logo: logo.png
19
19
  files:
20
20
  - dist
@@ -79,6 +79,12 @@ environments:
79
79
  default: s.abtnet.io
80
80
  secure: false
81
81
  shared: false
82
+ - name: STRIPE_WEBHOOK_SECRET
83
+ description: Stripe webhook secret
84
+ required: false
85
+ default: ''
86
+ secure: true
87
+ shared: false
82
88
  capabilities:
83
89
  navigation: true
84
90
  clusterMode: false
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "payment-kit",
3
- "version": "1.22.28",
3
+ "version": "1.22.30",
4
4
  "scripts": {
5
5
  "dev": "blocklet dev --open",
6
6
  "lint": "tsc --noEmit && eslint src api/src --ext .mjs,.js,.jsx,.ts,.tsx",
@@ -44,23 +44,23 @@
44
44
  ]
45
45
  },
46
46
  "dependencies": {
47
- "@abtnode/cron": "^1.17.3",
48
- "@arcblock/did": "^1.27.12",
47
+ "@abtnode/cron": "^1.17.4-beta-20251204-152224-243ff54f",
48
+ "@arcblock/did": "^1.27.13",
49
49
  "@arcblock/did-connect-react": "^3.2.11",
50
50
  "@arcblock/did-connect-storage-nedb": "^1.8.0",
51
- "@arcblock/did-util": "^1.27.12",
52
- "@arcblock/jwt": "^1.27.12",
51
+ "@arcblock/did-util": "^1.27.13",
52
+ "@arcblock/jwt": "^1.27.13",
53
53
  "@arcblock/react-hooks": "^3.2.11",
54
54
  "@arcblock/ux": "^3.2.11",
55
- "@arcblock/validator": "^1.27.12",
56
- "@blocklet/did-space-js": "^1.2.7",
57
- "@blocklet/error": "^0.3.3",
58
- "@blocklet/js-sdk": "^1.17.3",
59
- "@blocklet/logger": "^1.17.3",
60
- "@blocklet/payment-broker-client": "1.22.28",
61
- "@blocklet/payment-react": "1.22.28",
62
- "@blocklet/payment-vendor": "1.22.28",
63
- "@blocklet/sdk": "^1.17.3",
55
+ "@arcblock/validator": "^1.27.13",
56
+ "@blocklet/did-space-js": "^1.2.8",
57
+ "@blocklet/error": "^0.3.4",
58
+ "@blocklet/js-sdk": "^1.17.4-beta-20251204-152224-243ff54f",
59
+ "@blocklet/logger": "^1.17.4-beta-20251204-152224-243ff54f",
60
+ "@blocklet/payment-broker-client": "1.22.30",
61
+ "@blocklet/payment-react": "1.22.30",
62
+ "@blocklet/payment-vendor": "1.22.30",
63
+ "@blocklet/sdk": "^1.17.4-beta-20251204-152224-243ff54f",
64
64
  "@blocklet/ui-react": "^3.2.11",
65
65
  "@blocklet/uploader": "^0.3.13",
66
66
  "@blocklet/xss": "^0.3.11",
@@ -68,11 +68,11 @@
68
68
  "@mui/lab": "7.0.0-beta.14",
69
69
  "@mui/material": "^7.1.2",
70
70
  "@mui/system": "^7.1.1",
71
- "@ocap/asset": "^1.27.12",
72
- "@ocap/client": "^1.27.12",
73
- "@ocap/mcrypto": "^1.27.12",
74
- "@ocap/util": "^1.27.12",
75
- "@ocap/wallet": "^1.27.12",
71
+ "@ocap/asset": "^1.27.13",
72
+ "@ocap/client": "^1.27.13",
73
+ "@ocap/mcrypto": "^1.27.13",
74
+ "@ocap/util": "^1.27.13",
75
+ "@ocap/wallet": "^1.27.13",
76
76
  "@stripe/react-stripe-js": "^2.9.0",
77
77
  "@stripe/stripe-js": "^2.4.0",
78
78
  "ahooks": "^3.8.5",
@@ -127,9 +127,9 @@
127
127
  "web3": "^4.16.0"
128
128
  },
129
129
  "devDependencies": {
130
- "@abtnode/types": "^1.17.3",
130
+ "@abtnode/types": "^1.17.4-beta-20251204-152224-243ff54f",
131
131
  "@arcblock/eslint-config-ts": "^0.3.3",
132
- "@blocklet/payment-types": "1.22.28",
132
+ "@blocklet/payment-types": "1.22.30",
133
133
  "@types/cookie-parser": "^1.4.9",
134
134
  "@types/cors": "^2.8.19",
135
135
  "@types/debug": "^4.1.12",
@@ -160,7 +160,7 @@
160
160
  "vite": "^7.0.0",
161
161
  "vite-node": "^3.2.4",
162
162
  "vite-plugin-babel-import": "^2.0.5",
163
- "vite-plugin-blocklet": "^0.12.3",
163
+ "vite-plugin-blocklet": "^0.12.4",
164
164
  "vite-plugin-node-polyfills": "^0.23.0",
165
165
  "vite-plugin-svgr": "^4.3.0",
166
166
  "vite-tsconfig-paths": "^5.1.4",
@@ -176,5 +176,5 @@
176
176
  "parser": "typescript"
177
177
  }
178
178
  },
179
- "gitHead": "b3c8797e5ffe68ae3a0de1962b1d076a9d2fef33"
179
+ "gitHead": "78cac71748e6836069582ed293945b41b7c7af66"
180
180
  }
@@ -4,11 +4,12 @@ import type { ReactNode } from 'react';
4
4
  type Props = {
5
5
  title: string | ReactNode;
6
6
  children?: ReactNode;
7
+ action?: ReactNode;
7
8
  mb?: number;
8
9
  mt?: number;
9
10
  };
10
11
 
11
- export default function SectionHeader({ title, children = null, mb = 1.5, mt = 1.5 }: Props) {
12
+ export default function SectionHeader({ title, children = null, action = null, mb = 1.5, mt = 1.5 }: Props) {
12
13
  return (
13
14
  <Stack
14
15
  className="section-header"
@@ -33,7 +34,7 @@ export default function SectionHeader({ title, children = null, mb = 1.5, mt = 1
33
34
  component="div">
34
35
  {title}
35
36
  </Typography>
36
- {children}
37
+ {action || children}
37
38
  </Stack>
38
39
  );
39
40
  }
@@ -1,8 +1,9 @@
1
1
  /* eslint-disable react/no-unstable-nested-components */
2
2
  import CodeBlock from '@arcblock/ux/lib/CodeBlock';
3
+ import Toast from '@arcblock/ux/lib/Toast';
3
4
  import { api, formatTime } from '@blocklet/payment-react';
4
5
  import type { Paginated, TEvent, TWebhookAttemptExpanded } from '@blocklet/payment-types';
5
- import { CheckCircleOutlined, ErrorOutlined } from '@mui/icons-material';
6
+ import { CheckCircleOutlined, ErrorOutlined, RefreshOutlined } from '@mui/icons-material';
6
7
  import {
7
8
  Box,
8
9
  Button,
@@ -17,7 +18,7 @@ import {
17
18
  Stack,
18
19
  Typography,
19
20
  } from '@mui/material';
20
- import { useInfiniteScroll } from 'ahooks';
21
+ import { useInfiniteScroll, useRequest } from 'ahooks';
21
22
  import React, { useEffect, useState } from 'react';
22
23
 
23
24
  import { isEmpty } from 'lodash';
@@ -51,7 +52,7 @@ type Props = {
51
52
  };
52
53
 
53
54
  export default function WebhookAttempts({ event_id = '', webhook_endpoint_id = '', event = undefined }: Props) {
54
- const { data, loadMore, loadingMore, loading } = useInfiniteScroll<Paginated<TWebhookAttemptExpanded>>(
55
+ const { data, loadMore, loadingMore, loading, reload } = useInfiniteScroll<Paginated<TWebhookAttemptExpanded>>(
55
56
  (d) => {
56
57
  const size = 15;
57
58
  const page = d ? Math.ceil(d.list.length / size) + 1 : 1;
@@ -70,6 +71,20 @@ export default function WebhookAttempts({ event_id = '', webhook_endpoint_id = '
70
71
  >(null);
71
72
  const groupedAttempts = groupAttemptsByDate(attempts);
72
73
 
74
+ const { loading: retrying, run: retryWebhook } = useRequest(
75
+ (eventId: string) => api.post(`/api/events/${eventId}/retry-webhooks`).then((res) => res.data),
76
+ {
77
+ manual: true,
78
+ onSuccess: (result) => {
79
+ Toast.success(result.message || 'Webhook scheduled for retry');
80
+ reload();
81
+ },
82
+ onError: (err: any) => {
83
+ Toast.error(err.response?.data?.error || 'Failed to retry webhook');
84
+ },
85
+ }
86
+ );
87
+
73
88
  useEffect(() => {
74
89
  if (!selected && data?.list.length) {
75
90
  setSelected(data.list[0] as TWebhookAttemptExpanded);
@@ -161,7 +176,17 @@ export default function WebhookAttempts({ event_id = '', webhook_endpoint_id = '
161
176
  }}>
162
177
  {selected && (
163
178
  <Stack direction="column" spacing={2} sx={{ pt: 3, pl: 3, borderLeft: '1px solid', borderColor: 'divider' }}>
164
- <Typography variant="h6">{event_id ? selected.endpoint.url : selected.event.type}</Typography>
179
+ <Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center' }}>
180
+ <Typography variant="h6">{event_id ? selected.endpoint.url : selected.event.type}</Typography>
181
+ <Button
182
+ variant="outlined"
183
+ size="small"
184
+ startIcon={<RefreshOutlined />}
185
+ onClick={() => retryWebhook(selected.event_id)}
186
+ disabled={retrying}>
187
+ {retrying ? 'Retrying...' : 'Retry'}
188
+ </Button>
189
+ </Stack>
165
190
  <Box>
166
191
  <Typography variant="h6">Response ({selected.response_status})</Typography>
167
192
  {/* @ts-ignore */}
@@ -1119,6 +1119,9 @@ export default flat({
1119
1119
  label: 'Webhook Signing Secret',
1120
1120
  tip: 'Webhook Signing Secret, See Dashboard > Developers > Webhooks > Signing Secret',
1121
1121
  },
1122
+ webhookHint:
1123
+ 'If webhook events are not received, manually configure STRIPE_WEBHOOK_SECRET in environment variables',
1124
+ configureEnv: 'Go to Settings',
1122
1125
  },
1123
1126
  arcblock: {
1124
1127
  chain_id: {
@@ -1074,6 +1074,8 @@ export default flat({
1074
1074
  label: '钩子签名密钥',
1075
1075
  tip: '钩子签名密钥,请查看仪表板 > 开发人员 > 钩子 > 签名密钥',
1076
1076
  },
1077
+ webhookHint: '若无法接收 Webhook 事件,可手动在环境变量配置 STRIPE_WEBHOOK_SECRET',
1078
+ configureEnv: '前往配置',
1077
1079
  },
1078
1080
  arcblock: {
1079
1081
  chain_id: {
@@ -10,16 +10,14 @@ const EventDetail = React.lazy(() => import('./events/detail'));
10
10
  const WebhookDetail = React.lazy(() => import('./webhooks/detail'));
11
11
 
12
12
  const pages = {
13
- overview: React.lazy(() => import('./overview')),
14
13
  webhooks: React.lazy(() => import('./webhooks')),
15
14
  events: React.lazy(() => import('./events')),
16
- logs: React.lazy(() => import('./logs')),
17
15
  };
18
16
 
19
17
  export default function DevelopersIndex() {
20
18
  const navigate = useNavigate();
21
19
  const { t } = useLocaleContext();
22
- const { page = 'overview' } = useParams();
20
+ const { page = 'webhooks' } = useParams();
23
21
  const { startTransition } = useTransitionContext();
24
22
 
25
23
  if (page.startsWith('evt_')) {
@@ -37,12 +35,10 @@ export default function DevelopersIndex() {
37
35
  };
38
36
 
39
37
  // @ts-ignore
40
- const TabComponent = pages[page] || pages.overview;
38
+ const TabComponent = pages[page] || pages.webhooks;
41
39
  const tabs = [
42
- { label: t('admin.overview'), value: 'overview' },
43
40
  { label: t('admin.webhooks'), value: 'webhooks' },
44
41
  { label: t('admin.events.title'), value: 'events' },
45
- { label: t('admin.logs'), value: 'logs' },
46
42
  ];
47
43
 
48
44
  return (
@@ -498,7 +498,51 @@ export default function PaymentMethods() {
498
498
  />
499
499
  )}
500
500
 
501
- <InfoRow label={t('admin.paymentMethod.props.confirmation')} value={method.confirmation.type} />
501
+ <InfoRow
502
+ label={t('admin.paymentMethod.props.confirmation')}
503
+ value={
504
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
505
+ <Typography variant="body2">{method.confirmation.type}</Typography>
506
+ {method.type === 'stripe' && method.confirmation.type === 'callback' && (
507
+ <Tooltip
508
+ title={
509
+ <Typography variant="body2" component="span">
510
+ {t('admin.paymentMethod.stripe.webhookHint')}{' '}
511
+ <Typography
512
+ variant="body2"
513
+ component="a"
514
+ href="/.well-known/service/admin/overview/components"
515
+ target="_blank"
516
+ rel="noopener noreferrer"
517
+ sx={{
518
+ color: 'primary.light',
519
+ textDecoration: 'underline',
520
+ '&:hover': {
521
+ color: 'primary.main',
522
+ },
523
+ }}>
524
+ {t('admin.paymentMethod.stripe.configureEnv')}
525
+ </Typography>
526
+ </Typography>
527
+ }
528
+ placement="right"
529
+ arrow>
530
+ <InfoOutlined
531
+ sx={{
532
+ fontSize: 16,
533
+ color: 'info.main',
534
+ cursor: 'help',
535
+ transition: 'color 0.2s',
536
+ '&:hover': {
537
+ color: 'info.dark',
538
+ },
539
+ }}
540
+ />
541
+ </Tooltip>
542
+ )}
543
+ </Box>
544
+ }
545
+ />
502
546
  <InfoRow
503
547
  label={t('admin.paymentMethod.props.recurring')}
504
548
  value={method.features.recurring ? t('common.yes') : t('common.no')}