payment-kit 1.22.29 → 1.22.31

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.
@@ -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) => {
@@ -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
 
@@ -14,6 +14,29 @@ import { WebhookAttempt } from '../store/models/webhook-attempt';
14
14
  import { WebhookEndpoint } from '../store/models/webhook-endpoint';
15
15
  import { addNotificationJob } from './notification';
16
16
 
17
+ async function decrementPendingWebhooks(event: Event, webhookId: string) {
18
+ const existingSuccessCount = await WebhookAttempt.count({
19
+ where: {
20
+ event_id: event.id,
21
+ webhook_endpoint_id: webhookId,
22
+ status: 'succeeded',
23
+ },
24
+ });
25
+
26
+ if (existingSuccessCount === 0) {
27
+ if (event.pending_webhooks > 0) {
28
+ await event.decrement('pending_webhooks');
29
+ logger.info('pending_webhooks decremented', { eventId: event.id, newCount: event.pending_webhooks, webhookId });
30
+ } else {
31
+ logger.warn('Attempted to decrement pending_webhooks below 0', {
32
+ eventId: event.id,
33
+ currentCount: event.pending_webhooks,
34
+ webhookId,
35
+ });
36
+ }
37
+ }
38
+ }
39
+
17
40
  type WebhookJob = {
18
41
  eventId: string;
19
42
  webhookId: string;
@@ -80,8 +103,7 @@ export const handleWebhook = async (job: WebhookJob) => {
80
103
  });
81
104
  logger.info('WebhookAttempt created successfully', { eventId: event.id, webhookId: webhook.id });
82
105
 
83
- await event.decrement('pending_webhooks');
84
- logger.info('pending_webhooks decremented', { eventId: event.id, newCount: event.pending_webhooks });
106
+ await decrementPendingWebhooks(event, webhook.id);
85
107
 
86
108
  logger.info('webhook attempt success', { ...job, retryCount });
87
109
  } catch (err: any) {
@@ -110,20 +132,13 @@ export const handleWebhook = async (job: WebhookJob) => {
110
132
 
111
133
  if (retryCount < MAX_RETRY_COUNT) {
112
134
  process.nextTick(() => {
113
- webhookQueue.push({
114
- id: getWebhookJobId(event.id, webhook.id),
115
- job: { eventId: event.id, webhookId: webhook.id },
116
- runAt: getNextRetry(retryCount),
135
+ addWebhookJob(event.id, webhook.id, {
136
+ runAt: getNextRetry(retryCount, event.created_at),
117
137
  persist: false,
118
138
  });
119
- logger.info('scheduled webhook job', { ...job, retryCount });
120
139
  });
121
140
  } else {
122
- await event.decrement('pending_webhooks');
123
- logger.info('Max retries reached, pending_webhooks decremented', {
124
- eventId: event.id,
125
- newCount: event.pending_webhooks,
126
- });
141
+ await decrementPendingWebhooks(event, webhook.id);
127
142
  }
128
143
  }
129
144
  };
@@ -183,3 +198,36 @@ export const webhookQueue = createQueue<WebhookJob>({
183
198
  webhookQueue.on('failed', ({ id, job, error }) => {
184
199
  logger.error('webhook job failed', { id, job, error });
185
200
  });
201
+
202
+ export async function addWebhookJob(
203
+ eventId: string,
204
+ webhookId: string,
205
+ options: {
206
+ runAt?: number;
207
+ persist?: boolean;
208
+ replace?: boolean;
209
+ } = {}
210
+ ) {
211
+ const { runAt, persist = false, replace = true } = options;
212
+ const jobId = getWebhookJobId(eventId, webhookId);
213
+ const exist = await webhookQueue.get(jobId);
214
+
215
+ if (exist && !replace) {
216
+ logger.info('Webhook job already exists, skipping', { eventId, webhookId, jobId });
217
+ return false;
218
+ }
219
+
220
+ if (exist) {
221
+ await webhookQueue.delete(jobId);
222
+ }
223
+
224
+ webhookQueue.push({
225
+ id: jobId,
226
+ job: { eventId, webhookId },
227
+ runAt,
228
+ persist,
229
+ });
230
+
231
+ logger.info('Webhook job added to queue', { eventId, webhookId, jobId, runAt, persist });
232
+ return true;
233
+ }
@@ -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;
@@ -1,6 +1,6 @@
1
1
  import { Router } from 'express';
2
2
  import Joi from 'joi';
3
- import { Op, QueryTypes } from 'sequelize';
3
+ import { Op, QueryTypes, Sequelize } from 'sequelize';
4
4
 
5
5
  import { fromTokenToUnit } from '@ocap/util';
6
6
  import { createListParamSchema, getOrder, getWhereFromKvQuery, MetadataSchema } from '../libs/api';
@@ -95,7 +95,10 @@ router.get('/', authMine, async (req, res) => {
95
95
  }
96
96
 
97
97
  if (query.customer_id) {
98
- where['payload.customer_id'] = query.customer_id;
98
+ where[Op.and] = where[Op.and] || [];
99
+ where[Op.and].push(
100
+ Sequelize.where(Sequelize.fn('json_extract', Sequelize.col('payload'), '$.customer_id'), query.customer_id)
101
+ );
99
102
  }
100
103
 
101
104
  if (query.start || query.end) {
@@ -171,7 +174,7 @@ router.get('/stats', authMine, async (req, res) => {
171
174
  };
172
175
 
173
176
  if (customerId) {
174
- whereClause += " AND payload->>'customer_id' = :customerId";
177
+ whereClause += " AND json_extract(payload, '$.customer_id') = :customerId";
175
178
  replacements.customerId = customerId;
176
179
  }
177
180
 
@@ -0,0 +1,129 @@
1
+ /* eslint-disable no-console */
2
+ import { createIndexIfNotExists, type Migration } from '../migrate';
3
+
4
+ export const up: Migration = async ({ context }) => {
5
+ console.log('🚀 Starting database performance optimization...');
6
+
7
+ // meter_events
8
+ await createIndexIfNotExists(context, 'meter_events', ['status', 'created_at'], 'idx_meter_events_status_created');
9
+ await createIndexIfNotExists(
10
+ context,
11
+ 'meter_events',
12
+ ['event_name', 'livemode', 'created_at'],
13
+ 'idx_meter_events_event_livemode_created'
14
+ );
15
+
16
+ await context.sequelize.query(
17
+ "CREATE INDEX IF NOT EXISTS idx_meter_events_customer_status ON meter_events(json_extract(payload, '$.customer_id'), status, livemode, created_at)"
18
+ );
19
+ await context.sequelize.query(
20
+ "CREATE INDEX IF NOT EXISTS idx_meter_events_subscription_status ON meter_events(json_extract(payload, '$.subscription_id'), status, livemode, created_at)"
21
+ );
22
+
23
+ // credit_transactions
24
+ await createIndexIfNotExists(context, 'credit_transactions', ['created_at'], 'idx_credit_transactions_created');
25
+ await createIndexIfNotExists(
26
+ context,
27
+ 'credit_transactions',
28
+ ['customer_id', 'created_at'],
29
+ 'idx_credit_transactions_customer_created'
30
+ );
31
+ await createIndexIfNotExists(
32
+ context,
33
+ 'credit_transactions',
34
+ ['meter_id', 'created_at'],
35
+ 'idx_credit_transactions_meter_created'
36
+ );
37
+ await createIndexIfNotExists(
38
+ context,
39
+ 'credit_transactions',
40
+ ['meter_event_name', 'created_at'],
41
+ 'idx_credit_transactions_event_created'
42
+ );
43
+ await createIndexIfNotExists(
44
+ context,
45
+ 'credit_transactions',
46
+ ['subscription_id', 'created_at'],
47
+ 'idx_credit_transactions_subscription_created'
48
+ );
49
+
50
+ // credit_grants
51
+ await createIndexIfNotExists(
52
+ context,
53
+ 'credit_grants',
54
+ ['customer_id', 'currency_id', 'status'],
55
+ 'idx_credit_grants_customer_currency_status'
56
+ );
57
+ await createIndexIfNotExists(context, 'credit_grants', ['customer_id'], 'idx_credit_grants_customer');
58
+ await createIndexIfNotExists(context, 'credit_grants', ['currency_id'], 'idx_credit_grants_currency');
59
+ await createIndexIfNotExists(context, 'credit_grants', ['status'], 'idx_credit_grants_status');
60
+
61
+ // customers
62
+ await createIndexIfNotExists(context, 'customers', ['did'], 'idx_customers_did');
63
+
64
+ // subscriptions
65
+ await createIndexIfNotExists(context, 'subscriptions', ['customer_id'], 'idx_subscriptions_customer_id');
66
+
67
+ // meters
68
+ await createIndexIfNotExists(context, 'meters', ['livemode', 'status'], 'idx_meters_livemode_status');
69
+
70
+ // events
71
+ await createIndexIfNotExists(context, 'events', ['type', 'created_at'], 'idx_events_type_created');
72
+ await createIndexIfNotExists(
73
+ context,
74
+ 'events',
75
+ ['object_type', 'object_id', 'created_at'],
76
+ 'idx_events_object_type_id_created'
77
+ );
78
+ await createIndexIfNotExists(context, 'events', ['object_id', 'created_at'], 'idx_events_object_id_created');
79
+
80
+ // jobs
81
+ await createIndexIfNotExists(context, 'jobs', ['queue', 'will_run_at'], 'idx_jobs_queue_run_at');
82
+ await createIndexIfNotExists(context, 'jobs', ['cancelled', 'will_run_at'], 'idx_jobs_cancelled_run_at');
83
+
84
+ // invoices
85
+ await createIndexIfNotExists(context, 'invoices', ['status'], 'idx_invoices_status');
86
+
87
+ // checkout_sessions
88
+ await createIndexIfNotExists(
89
+ context,
90
+ 'checkout_sessions',
91
+ ['payment_link_id', 'status', 'livemode'],
92
+ 'idx_checkout_sessions_payment_link_status_livemode'
93
+ );
94
+
95
+ console.log('✅ Performance optimization completed');
96
+ };
97
+
98
+ export const down: Migration = async ({ context }) => {
99
+ console.log('🔄 Rolling back performance optimization...');
100
+
101
+ await context.removeIndex('meter_events', 'idx_meter_events_status_created');
102
+ await context.removeIndex('meter_events', 'idx_meter_events_event_livemode_created');
103
+ await context.removeIndex('meter_events', 'idx_meter_events_customer_status');
104
+ await context.removeIndex('meter_events', 'idx_meter_events_subscription_status');
105
+
106
+ await context.removeIndex('credit_transactions', 'idx_credit_transactions_created');
107
+ await context.removeIndex('credit_transactions', 'idx_credit_transactions_customer_created');
108
+ await context.removeIndex('credit_transactions', 'idx_credit_transactions_meter_created');
109
+ await context.removeIndex('credit_transactions', 'idx_credit_transactions_event_created');
110
+ await context.removeIndex('credit_transactions', 'idx_credit_transactions_subscription_created');
111
+
112
+ await context.removeIndex('credit_grants', 'idx_credit_grants_customer_currency_status');
113
+ await context.removeIndex('credit_grants', 'idx_credit_grants_customer');
114
+ await context.removeIndex('credit_grants', 'idx_credit_grants_currency');
115
+ await context.removeIndex('credit_grants', 'idx_credit_grants_status');
116
+
117
+ await context.removeIndex('customers', 'idx_customers_did');
118
+ await context.removeIndex('subscriptions', 'idx_subscriptions_customer_id');
119
+ await context.removeIndex('meters', 'idx_meters_livemode_status');
120
+ await context.removeIndex('events', 'idx_events_type_created');
121
+ await context.removeIndex('events', 'idx_events_object_type_id_created');
122
+ await context.removeIndex('events', 'idx_events_object_id_created');
123
+ await context.removeIndex('jobs', 'idx_jobs_queue_run_at');
124
+ await context.removeIndex('jobs', 'idx_jobs_cancelled_run_at');
125
+ await context.removeIndex('invoices', 'idx_invoices_status');
126
+ await context.removeIndex('checkout_sessions', 'idx_checkout_sessions_payment_link_status_livemode');
127
+
128
+ console.log('✅ Rollback completed');
129
+ };
@@ -7,6 +7,7 @@ import {
7
7
  Model,
8
8
  Op,
9
9
  QueryTypes,
10
+ Sequelize,
10
11
  WhereOptions,
11
12
  } from 'sequelize';
12
13
  import type { LiteralUnion } from 'type-fest';
@@ -419,11 +420,18 @@ export class MeterEvent extends Model<InferAttributes<MeterEvent>, InferCreation
419
420
  if (status) {
420
421
  where.status = Array.isArray(status) ? { [Op.in]: status } : status;
421
422
  }
423
+
422
424
  if (subscriptionId) {
423
- where['payload.subscription_id'] = subscriptionId;
425
+ where[Op.and] = where[Op.and] || [];
426
+ where[Op.and].push(
427
+ Sequelize.where(Sequelize.fn('json_extract', Sequelize.col('payload'), '$.subscription_id'), subscriptionId)
428
+ );
424
429
  }
425
430
  if (customerId) {
426
- where['payload.customer_id'] = customerId;
431
+ where[Op.and] = where[Op.and] || [];
432
+ where[Op.and].push(
433
+ Sequelize.where(Sequelize.fn('json_extract', Sequelize.col('payload'), '$.customer_id'), customerId)
434
+ );
427
435
  }
428
436
 
429
437
  return this._getPendingAmounts(where, currencyId);
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.29
17
+ version: 1.22.31
18
18
  logo: logo.png
19
19
  files:
20
20
  - dist
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "payment-kit",
3
- "version": "1.22.29",
3
+ "version": "1.22.31",
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,35 +44,35 @@
44
44
  ]
45
45
  },
46
46
  "dependencies": {
47
- "@abtnode/cron": "^1.17.4-beta-20251204-152224-243ff54f",
48
- "@arcblock/did": "^1.27.13",
47
+ "@abtnode/cron": "^1.17.4",
48
+ "@arcblock/did": "^1.27.14",
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.13",
52
- "@arcblock/jwt": "^1.27.13",
51
+ "@arcblock/did-util": "^1.27.14",
52
+ "@arcblock/jwt": "^1.27.14",
53
53
  "@arcblock/react-hooks": "^3.2.11",
54
54
  "@arcblock/ux": "^3.2.11",
55
- "@arcblock/validator": "^1.27.13",
56
- "@blocklet/did-space-js": "^1.2.8",
55
+ "@arcblock/validator": "^1.27.14",
56
+ "@blocklet/did-space-js": "^1.2.9",
57
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.29",
61
- "@blocklet/payment-react": "1.22.29",
62
- "@blocklet/payment-vendor": "1.22.29",
63
- "@blocklet/sdk": "^1.17.4-beta-20251204-152224-243ff54f",
58
+ "@blocklet/js-sdk": "^1.17.4",
59
+ "@blocklet/logger": "^1.17.4",
60
+ "@blocklet/payment-broker-client": "1.22.31",
61
+ "@blocklet/payment-react": "1.22.31",
62
+ "@blocklet/payment-vendor": "1.22.31",
63
+ "@blocklet/sdk": "^1.17.4",
64
64
  "@blocklet/ui-react": "^3.2.11",
65
- "@blocklet/uploader": "^0.3.13",
66
- "@blocklet/xss": "^0.3.11",
65
+ "@blocklet/uploader": "^0.3.14",
66
+ "@blocklet/xss": "^0.3.12",
67
67
  "@mui/icons-material": "^7.1.2",
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.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",
71
+ "@ocap/asset": "^1.27.14",
72
+ "@ocap/client": "^1.27.14",
73
+ "@ocap/mcrypto": "^1.27.14",
74
+ "@ocap/util": "^1.27.14",
75
+ "@ocap/wallet": "^1.27.14",
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.4-beta-20251204-152224-243ff54f",
130
+ "@abtnode/types": "^1.17.4",
131
131
  "@arcblock/eslint-config-ts": "^0.3.3",
132
- "@blocklet/payment-types": "1.22.29",
132
+ "@blocklet/payment-types": "1.22.31",
133
133
  "@types/cookie-parser": "^1.4.9",
134
134
  "@types/cors": "^2.8.19",
135
135
  "@types/debug": "^4.1.12",
@@ -176,5 +176,5 @@
176
176
  "parser": "typescript"
177
177
  }
178
178
  },
179
- "gitHead": "8438b592c3709dc2ae5aeceb0cd883277de3d646"
179
+ "gitHead": "fc03087134d309474256b122eba125e4a01dd6c0"
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 { api, formatTime } from '@blocklet/payment-react';
3
+ import Toast from '@arcblock/ux/lib/Toast';
4
+ import { api, formatTime, ConfirmDialog } 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,8 @@ import {
17
18
  Stack,
18
19
  Typography,
19
20
  } from '@mui/material';
20
- import { useInfiniteScroll } from 'ahooks';
21
+ import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
22
+ import { useInfiniteScroll, useRequest, useSetState } from 'ahooks';
21
23
  import React, { useEffect, useState } from 'react';
22
24
 
23
25
  import { isEmpty } from 'lodash';
@@ -51,7 +53,8 @@ type Props = {
51
53
  };
52
54
 
53
55
  export default function WebhookAttempts({ event_id = '', webhook_endpoint_id = '', event = undefined }: Props) {
54
- const { data, loadMore, loadingMore, loading } = useInfiniteScroll<Paginated<TWebhookAttemptExpanded>>(
56
+ const { t } = useLocaleContext();
57
+ const { data, loadMore, loadingMore, loading, reload } = useInfiniteScroll<Paginated<TWebhookAttemptExpanded>>(
55
58
  (d) => {
56
59
  const size = 15;
57
60
  const page = d ? Math.ceil(d.list.length / size) + 1 : 1;
@@ -68,8 +71,27 @@ export default function WebhookAttempts({ event_id = '', webhook_endpoint_id = '
68
71
  const [selected, setSelected] = useState<
69
72
  (TWebhookAttemptExpanded & { event: TEvent & { requestInfo?: RequestInfo } }) | null
70
73
  >(null);
74
+
75
+ const [state, setState] = useSetState({
76
+ retryEventId: '',
77
+ });
78
+
71
79
  const groupedAttempts = groupAttemptsByDate(attempts);
72
80
 
81
+ const { loading: retrying, run: retryWebhook } = useRequest(
82
+ (eventId: string) => api.post(`/api/events/${eventId}/retry-webhooks`).then((res) => res.data),
83
+ {
84
+ manual: true,
85
+ onSuccess: (result) => {
86
+ Toast.success(result.message || t('admin.event.retryOptions.success'));
87
+ reload();
88
+ },
89
+ onError: (err: any) => {
90
+ Toast.error(err.response?.data?.error || t('admin.event.retryOptions.error'));
91
+ },
92
+ }
93
+ );
94
+
73
95
  useEffect(() => {
74
96
  if (!selected && data?.list.length) {
75
97
  setSelected(data.list[0] as TWebhookAttemptExpanded);
@@ -80,6 +102,17 @@ export default function WebhookAttempts({ event_id = '', webhook_endpoint_id = '
80
102
  setSelected(attempt);
81
103
  };
82
104
 
105
+ const handleRetryClick = (eventId: string) => {
106
+ setState({ retryEventId: eventId });
107
+ };
108
+
109
+ const handleConfirmRetry = () => {
110
+ if (state.retryEventId) {
111
+ retryWebhook(state.retryEventId);
112
+ setState({ retryEventId: '' });
113
+ }
114
+ };
115
+
83
116
  if (loading) {
84
117
  return <CircularProgress />;
85
118
  }
@@ -94,7 +127,7 @@ export default function WebhookAttempts({ event_id = '', webhook_endpoint_id = '
94
127
  sx={{
95
128
  color: 'text.secondary',
96
129
  }}>
97
- No Attempt
130
+ {t('admin.event.noAttempts')}
98
131
  </Typography>
99
132
  ) : (
100
133
  <>
@@ -140,7 +173,7 @@ export default function WebhookAttempts({ event_id = '', webhook_endpoint_id = '
140
173
  </List>
141
174
  {hasMore && (
142
175
  <Button variant="text" type="button" color="inherit" onClick={loadMore} disabled={loadingMore}>
143
- {loadingMore ? 'Loading more...' : 'Load more'}
176
+ {loadingMore ? t('common.loading') : t('common.loadMore')}
144
177
  </Button>
145
178
  )}
146
179
  {!hasMore && (
@@ -148,7 +181,7 @@ export default function WebhookAttempts({ event_id = '', webhook_endpoint_id = '
148
181
  sx={{
149
182
  color: 'text.secondary',
150
183
  }}>
151
- No more data
184
+ {t('common.noMoreData')}
152
185
  </Typography>
153
186
  )}
154
187
  </>
@@ -161,9 +194,31 @@ export default function WebhookAttempts({ event_id = '', webhook_endpoint_id = '
161
194
  }}>
162
195
  {selected && (
163
196
  <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>
197
+ <Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center' }}>
198
+ <Typography variant="h6">{event_id ? selected.endpoint.url : selected.event.type}</Typography>
199
+ <Button
200
+ variant="outlined"
201
+ size="small"
202
+ startIcon={<RefreshOutlined />}
203
+ onClick={() => handleRetryClick(selected.event_id)}
204
+ disabled={retrying}>
205
+ {retrying ? t('admin.event.retrying') : t('admin.event.retry')}
206
+ </Button>
207
+ </Stack>
208
+ {state.retryEventId && (
209
+ <ConfirmDialog
210
+ onConfirm={handleConfirmRetry}
211
+ onCancel={() => setState({ retryEventId: '' })}
212
+ title={t('admin.event.retryOptions.title')}
213
+ message={t('admin.event.retryOptions.confirmMessage')}
214
+ loading={retrying}
215
+ color="primary"
216
+ />
217
+ )}
165
218
  <Box>
166
- <Typography variant="h6">Response ({selected.response_status})</Typography>
219
+ <Typography variant="h6">
220
+ {t('admin.event.response')} ({selected.response_status})
221
+ </Typography>
167
222
  {/* @ts-ignore */}
168
223
  <CodeBlock language="json">{JSON.stringify(selected.response_body, null, 2)}</CodeBlock>
169
224
  </Box>
@@ -174,7 +229,7 @@ export default function WebhookAttempts({ event_id = '', webhook_endpoint_id = '
174
229
  sx={{
175
230
  alignItems: 'center',
176
231
  }}>
177
- <Typography variant="h6">Request</Typography>
232
+ <Typography variant="h6">{t('admin.event.request')}</Typography>
178
233
  <RequestInfoPopper
179
234
  // @ts-ignore
180
235
  requestInfo={selected?.event?.requestInfo}
@@ -194,7 +249,7 @@ export default function WebhookAttempts({ event_id = '', webhook_endpoint_id = '
194
249
  sx={{
195
250
  alignItems: 'center',
196
251
  }}>
197
- <Typography variant="h6">Event Data</Typography>
252
+ <Typography variant="h6">{t('admin.event.eventData')}</Typography>
198
253
  <RequestInfoPopper
199
254
  // @ts-ignore
200
255
  requestInfo={event.requestInfo}
@@ -31,6 +31,8 @@ export default flat({
31
31
  latinOnly:
32
32
  'Must contain at least one letter and cannot include Chinese characters or special characters such as <, >, ", \' or \\',
33
33
  loading: 'Loading...',
34
+ loadMore: 'Load more',
35
+ noMoreData: 'No more data',
34
36
  rechargeTime: 'Recharge Time',
35
37
  submit: 'Submit',
36
38
  custom: 'Custom',
@@ -1217,6 +1219,19 @@ export default flat({
1217
1219
  webhooks: 'Webhook Attempts',
1218
1220
  type: 'Type',
1219
1221
  pendingWebhooks: 'Pending Webhooks',
1222
+ noAttempts: 'No Attempt',
1223
+ retry: 'Retry',
1224
+ retrying: 'Retrying...',
1225
+ retryOptions: {
1226
+ title: 'Retry Webhook',
1227
+ confirmMessage:
1228
+ 'Are you sure you want to retry this webhook? This will send another request to the webhook endpoint.',
1229
+ success: 'Webhook scheduled for retry',
1230
+ error: 'Failed to retry webhook',
1231
+ },
1232
+ response: 'Response',
1233
+ request: 'Request',
1234
+ eventData: 'Event Data',
1220
1235
  },
1221
1236
  invoice: {
1222
1237
  view: 'View invoice',
@@ -29,6 +29,8 @@ export default flat({
29
29
  invalidCharacters: '无效字符',
30
30
  latinOnly: '至少包含一个字母,并且不能包含中文字符和特殊字符如 <, >、"、’ 或 \\',
31
31
  loading: '加载中...',
32
+ loadMore: '加载更多',
33
+ noMoreData: '没有更多数据',
32
34
  rechargeTime: '充值时间',
33
35
  submit: '提交',
34
36
  custom: '自定义',
@@ -1171,6 +1173,18 @@ export default flat({
1171
1173
  webhooks: '钩子调用',
1172
1174
  type: '类型',
1173
1175
  pendingWebhooks: '待处理的钩子',
1176
+ noAttempts: '无调用记录',
1177
+ retry: '重试',
1178
+ retrying: '重试中...',
1179
+ retryOptions: {
1180
+ title: '重试 Webhook',
1181
+ confirmMessage: '确定要重试此 webhook 吗?这将向 webhook 端点发送另一个请求。',
1182
+ success: 'Webhook 已安排重试',
1183
+ error: '重试 webhook 失败',
1184
+ },
1185
+ response: '响应',
1186
+ request: '请求',
1187
+ eventData: '事件数据',
1174
1188
  events: {
1175
1189
  dailyEventCount: '每日事件数量',
1176
1190
  dailyTotalValue: '每日总使用量',