payment-kit 1.22.29 → 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.
@@ -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
 
@@ -110,13 +110,11 @@ export const handleWebhook = async (job: WebhookJob) => {
110
110
 
111
111
  if (retryCount < MAX_RETRY_COUNT) {
112
112
  process.nextTick(() => {
113
- webhookQueue.push({
114
- id: getWebhookJobId(event.id, webhook.id),
115
- job: { eventId: event.id, webhookId: webhook.id },
116
- runAt: getNextRetry(retryCount),
113
+ addWebhookJob(event.id, webhook.id, {
114
+ runAt: getNextRetry(retryCount, event.created_at),
117
115
  persist: false,
116
+ skipExistCheck: true,
118
117
  });
119
- logger.info('scheduled webhook job', { ...job, retryCount });
120
118
  });
121
119
  } else {
122
120
  await event.decrement('pending_webhooks');
@@ -183,3 +181,34 @@ export const webhookQueue = createQueue<WebhookJob>({
183
181
  webhookQueue.on('failed', ({ id, job, error }) => {
184
182
  logger.error('webhook job failed', { id, job, error });
185
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.29
17
+ version: 1.22.30
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.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",
@@ -57,9 +57,9 @@
57
57
  "@blocklet/error": "^0.3.4",
58
58
  "@blocklet/js-sdk": "^1.17.4-beta-20251204-152224-243ff54f",
59
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",
60
+ "@blocklet/payment-broker-client": "1.22.30",
61
+ "@blocklet/payment-react": "1.22.30",
62
+ "@blocklet/payment-vendor": "1.22.30",
63
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",
@@ -129,7 +129,7 @@
129
129
  "devDependencies": {
130
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.29",
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",
@@ -176,5 +176,5 @@
176
176
  "parser": "typescript"
177
177
  }
178
178
  },
179
- "gitHead": "8438b592c3709dc2ae5aeceb0cd883277de3d646"
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 */}