payment-kit 1.18.24 → 1.18.26
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/api/src/libs/event.ts +22 -2
- package/api/src/libs/invoice.ts +142 -0
- package/api/src/libs/notification/template/aggregated-subscription-renewed.ts +165 -0
- package/api/src/libs/notification/template/one-time-payment-succeeded.ts +2 -5
- package/api/src/libs/notification/template/subscription-canceled.ts +2 -3
- package/api/src/libs/notification/template/subscription-refund-succeeded.ts +7 -4
- package/api/src/libs/notification/template/subscription-renew-failed.ts +3 -5
- package/api/src/libs/notification/template/subscription-renewed.ts +2 -2
- package/api/src/libs/notification/template/subscription-stake-slash-succeeded.ts +2 -3
- package/api/src/libs/notification/template/subscription-succeeded.ts +2 -2
- package/api/src/libs/notification/template/subscription-upgraded.ts +5 -5
- package/api/src/libs/notification/template/subscription-will-renew.ts +2 -2
- package/api/src/libs/queue/index.ts +6 -0
- package/api/src/libs/queue/store.ts +13 -1
- package/api/src/libs/util.ts +22 -1
- package/api/src/locales/en.ts +5 -0
- package/api/src/locales/zh.ts +5 -0
- package/api/src/queues/invoice.ts +21 -7
- package/api/src/queues/notification.ts +353 -11
- package/api/src/queues/payment.ts +26 -10
- package/api/src/queues/payout.ts +21 -7
- package/api/src/routes/checkout-sessions.ts +26 -12
- package/api/src/routes/connect/recharge-account.ts +13 -1
- package/api/src/routes/connect/recharge.ts +13 -1
- package/api/src/routes/connect/shared.ts +54 -36
- package/api/src/routes/customers.ts +61 -0
- package/api/src/routes/invoices.ts +51 -1
- package/api/src/routes/subscriptions.ts +1 -1
- package/api/src/store/migrations/20250328-notification-preference.ts +29 -0
- package/api/src/store/models/customer.ts +42 -1
- package/api/src/store/models/types.ts +17 -1
- package/blocklet.yml +1 -1
- package/package.json +24 -24
- package/src/components/customer/form.tsx +21 -2
- package/src/components/customer/notification-preference.tsx +428 -0
- package/src/components/layout/user.tsx +1 -1
- package/src/locales/en.tsx +30 -0
- package/src/locales/zh.tsx +30 -0
- package/src/pages/customer/index.tsx +27 -23
- package/src/pages/customer/recharge/account.tsx +19 -17
- package/src/pages/customer/subscription/embed.tsx +25 -9
package/api/src/locales/en.ts
CHANGED
|
@@ -212,5 +212,10 @@ export default flat({
|
|
|
212
212
|
title: 'Insufficient Credit for SubGuard™',
|
|
213
213
|
body: 'Your subscription to {productName} has insufficient staked credit for SubGuard™. Please increase your stake to maintain the service or disable it if no longer needed.',
|
|
214
214
|
},
|
|
215
|
+
|
|
216
|
+
aggregatedSubscriptionRenewed: {
|
|
217
|
+
title: '{count} Subscriptions Renewed',
|
|
218
|
+
body: 'During {startTime} - {endTime}, {count} subscriptions were successfully renewed, total amount: {totalAmount}.\n\nSubscription List:\n{subscriptionList}',
|
|
219
|
+
},
|
|
215
220
|
},
|
|
216
221
|
});
|
package/api/src/locales/zh.ts
CHANGED
|
@@ -206,5 +206,10 @@ export default flat({
|
|
|
206
206
|
title: '订阅守护服务额度不足',
|
|
207
207
|
body: '您订阅的 {productName} 订阅守护服务额度不足,为了避免影响您的使用,请及时充值。如不再需要订阅守护服务,可关闭该功能。',
|
|
208
208
|
},
|
|
209
|
+
|
|
210
|
+
aggregatedSubscriptionRenewed: {
|
|
211
|
+
title: '{count} 个订阅续费成功',
|
|
212
|
+
body: '在 {startTime} - {endTime} 期间,共有 {count} 个订阅成功续费,总金额:{totalAmount}。\n\n订阅清单:\n{subscriptionList}',
|
|
213
|
+
},
|
|
209
214
|
},
|
|
210
215
|
});
|
|
@@ -289,16 +289,30 @@ events.on('invoice.queued', async (id, job, args = {}) => {
|
|
|
289
289
|
job,
|
|
290
290
|
...extraArgs,
|
|
291
291
|
});
|
|
292
|
-
events.emit('invoice.queued.done');
|
|
292
|
+
events.emit('invoice.queued.done', { id });
|
|
293
293
|
} catch (error) {
|
|
294
294
|
logger.error('Error in invoice.queued', { id, job, error });
|
|
295
|
-
events.emit('invoice.queued.error', error);
|
|
295
|
+
events.emit('invoice.queued.error', { id, job, error });
|
|
296
296
|
}
|
|
297
297
|
return;
|
|
298
298
|
}
|
|
299
|
-
|
|
300
|
-
id
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
299
|
+
try {
|
|
300
|
+
const existJob = await invoiceQueue.get(id);
|
|
301
|
+
if (existJob) {
|
|
302
|
+
await invoiceQueue.delete(id);
|
|
303
|
+
logger.info('Removed existing invoice job for immediate execution', {
|
|
304
|
+
id,
|
|
305
|
+
originalRunAt: existJob.runAt,
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
invoiceQueue.push({
|
|
309
|
+
id,
|
|
310
|
+
job,
|
|
311
|
+
...extraArgs,
|
|
312
|
+
});
|
|
313
|
+
events.emit('invoice.queued.done', { id });
|
|
314
|
+
} catch (error) {
|
|
315
|
+
logger.error('Error in invoice.queued', { id, job, error });
|
|
316
|
+
events.emit('invoice.queued.error', { id, job, error });
|
|
317
|
+
}
|
|
304
318
|
});
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/* eslint-disable @typescript-eslint/indent */
|
|
2
|
+
import { Op } from 'sequelize';
|
|
2
3
|
import { events } from '../libs/event';
|
|
3
4
|
import logger from '../libs/logger';
|
|
5
|
+
import dayjs from '../libs/dayjs';
|
|
4
6
|
import { Notification } from '../libs/notification';
|
|
5
7
|
import type { BaseEmailTemplate } from '../libs/notification/template/base';
|
|
6
8
|
import {
|
|
@@ -56,7 +58,16 @@ import {
|
|
|
56
58
|
SubscriptionStakeSlashSucceededEmailTemplateOptions,
|
|
57
59
|
} from '../libs/notification/template/subscription-stake-slash-succeeded';
|
|
58
60
|
import createQueue from '../libs/queue';
|
|
59
|
-
import {
|
|
61
|
+
import {
|
|
62
|
+
CheckoutSession,
|
|
63
|
+
EventType,
|
|
64
|
+
Invoice,
|
|
65
|
+
PaymentLink,
|
|
66
|
+
Payout,
|
|
67
|
+
Refund,
|
|
68
|
+
Subscription,
|
|
69
|
+
Customer,
|
|
70
|
+
} from '../store/models';
|
|
60
71
|
import {
|
|
61
72
|
UsageReportEmptyEmailTemplate,
|
|
62
73
|
UsageReportEmptyEmailTemplateOptions,
|
|
@@ -73,9 +84,22 @@ import {
|
|
|
73
84
|
CustomerRevenueSucceededEmailTemplate,
|
|
74
85
|
CustomerRevenueSucceededEmailTemplateOptions,
|
|
75
86
|
} from '../libs/notification/template/customer-revenue-succeeded';
|
|
87
|
+
import {
|
|
88
|
+
AggregatedSubscriptionRenewedEmailTemplate,
|
|
89
|
+
AggregatedSubscriptionRenewedEmailTemplateOptions,
|
|
90
|
+
} from '../libs/notification/template/aggregated-subscription-renewed';
|
|
91
|
+
import type { TJob } from '../store/models/job';
|
|
76
92
|
|
|
77
93
|
export type NotificationQueueJobOptions = any;
|
|
78
94
|
|
|
95
|
+
interface NotificationPreference {
|
|
96
|
+
frequency: 'default' | 'daily' | 'weekly' | 'monthly';
|
|
97
|
+
schedule?: {
|
|
98
|
+
time: string; // HH:mm 格式
|
|
99
|
+
date?: number; // weekly: 0-6, monthly: 1-31
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
79
103
|
export type NotificationQueueJobType =
|
|
80
104
|
| EventType
|
|
81
105
|
| 'customer.subscription.will_renew'
|
|
@@ -91,6 +115,62 @@ export type NotificationQueueJob = {
|
|
|
91
115
|
options: NotificationQueueJobOptions;
|
|
92
116
|
};
|
|
93
117
|
|
|
118
|
+
export interface AggregatedNotificationJob {
|
|
119
|
+
type: NotificationQueueJobType;
|
|
120
|
+
options: {
|
|
121
|
+
customer_id: string;
|
|
122
|
+
items: NotificationItem[];
|
|
123
|
+
time_range: {
|
|
124
|
+
start: number;
|
|
125
|
+
end: number;
|
|
126
|
+
};
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function calculateNextNotificationTime(preference: NotificationPreference): number {
|
|
131
|
+
if (preference.frequency === 'default' || !preference.schedule) {
|
|
132
|
+
return Math.floor(Date.now() / 1000);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const now = dayjs();
|
|
136
|
+
const [hour = 10, minute = 0] = preference.schedule?.time ? preference.schedule.time.split(':').map(Number) : [10, 0];
|
|
137
|
+
let nextTime = dayjs().hour(hour).minute(minute).second(0);
|
|
138
|
+
const targetDate = preference.schedule?.date ?? 1;
|
|
139
|
+
|
|
140
|
+
switch (preference.frequency) {
|
|
141
|
+
case 'daily':
|
|
142
|
+
if (nextTime.isBefore(now)) {
|
|
143
|
+
nextTime = nextTime.add(1, 'day');
|
|
144
|
+
}
|
|
145
|
+
break;
|
|
146
|
+
|
|
147
|
+
case 'weekly':
|
|
148
|
+
nextTime = nextTime.day(targetDate);
|
|
149
|
+
if (nextTime.isBefore(now)) {
|
|
150
|
+
nextTime = nextTime.add(1, 'week');
|
|
151
|
+
}
|
|
152
|
+
break;
|
|
153
|
+
|
|
154
|
+
case 'monthly':
|
|
155
|
+
nextTime = nextTime.date(targetDate);
|
|
156
|
+
// if the date is not the same, set to the end of the month
|
|
157
|
+
if (nextTime.date() !== targetDate) {
|
|
158
|
+
nextTime = nextTime.endOf('month').hour(hour).minute(minute);
|
|
159
|
+
}
|
|
160
|
+
if (nextTime.isBefore(now)) {
|
|
161
|
+
nextTime = nextTime.add(1, 'month');
|
|
162
|
+
if (nextTime.date() !== targetDate) {
|
|
163
|
+
nextTime = nextTime.endOf('month').hour(hour).minute(minute);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
break;
|
|
167
|
+
default:
|
|
168
|
+
throw new Error(`Unknown frequency: ${preference.frequency}`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return Math.floor(nextTime.valueOf() / 1000);
|
|
172
|
+
}
|
|
173
|
+
|
|
94
174
|
function getNotificationTemplate(job: NotificationQueueJob): BaseEmailTemplate {
|
|
95
175
|
if (job.type === 'usage.report.empty') {
|
|
96
176
|
return new UsageReportEmptyEmailTemplate(job.options as UsageReportEmptyEmailTemplateOptions);
|
|
@@ -156,10 +236,158 @@ async function handleNotificationJob(job: NotificationQueueJob): Promise<void> {
|
|
|
156
236
|
try {
|
|
157
237
|
const template = getNotificationTemplate(job);
|
|
158
238
|
await new Notification(template).send();
|
|
159
|
-
logger.info('
|
|
239
|
+
logger.info('handleImmediateNotificationJob.success', { job });
|
|
160
240
|
} catch (error) {
|
|
161
|
-
logger.error('
|
|
162
|
-
|
|
241
|
+
logger.error('handleImmediateNotificationJob.error', error);
|
|
242
|
+
throw error;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const MAX_AGGREGATED_ITEMS = 1000;
|
|
247
|
+
|
|
248
|
+
interface NotificationItem<T = Record<string, any>> {
|
|
249
|
+
event_id: string;
|
|
250
|
+
occurred_at: number;
|
|
251
|
+
data: T;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Handles immediate notifications by pushing them directly to the notification queue
|
|
256
|
+
*/
|
|
257
|
+
function handleImmediateNotification(type: string, data: Record<string, any>, extraIds: string[] = []) {
|
|
258
|
+
const idParts = [type];
|
|
259
|
+
if (extraIds.length) {
|
|
260
|
+
idParts.push(...extraIds);
|
|
261
|
+
} else {
|
|
262
|
+
idParts.push(Date.now().toString());
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return notificationQueue.push({
|
|
266
|
+
id: idParts.join('.'),
|
|
267
|
+
job: {
|
|
268
|
+
type,
|
|
269
|
+
options: data,
|
|
270
|
+
},
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Handles aggregated notifications with support for:
|
|
276
|
+
* - Automatic fallback to immediate notification on error
|
|
277
|
+
* - Item limit enforcement
|
|
278
|
+
* - Time range tracking
|
|
279
|
+
*/
|
|
280
|
+
async function handleAggregatedNotification<T extends Record<string, any>>({
|
|
281
|
+
type,
|
|
282
|
+
customerId,
|
|
283
|
+
preference,
|
|
284
|
+
newItem,
|
|
285
|
+
extraIds,
|
|
286
|
+
}: {
|
|
287
|
+
type: NotificationQueueJobType;
|
|
288
|
+
customerId: string;
|
|
289
|
+
preference: NotificationPreference;
|
|
290
|
+
newItem: NotificationItem<T>;
|
|
291
|
+
extraIds: string[];
|
|
292
|
+
}) {
|
|
293
|
+
try {
|
|
294
|
+
const nextNotificationTime = calculateNextNotificationTime(preference);
|
|
295
|
+
const jobId = `${type}.${customerId}.${nextNotificationTime}`;
|
|
296
|
+
const existingJob = await aggregatedNotificationQueue.get(jobId);
|
|
297
|
+
|
|
298
|
+
if (existingJob) {
|
|
299
|
+
const updatedItems = [...(existingJob.options.items || []), newItem];
|
|
300
|
+
|
|
301
|
+
if (updatedItems.length >= MAX_AGGREGATED_ITEMS) {
|
|
302
|
+
await aggregatedNotificationQueue.push({
|
|
303
|
+
id: `${jobId}.immediate`,
|
|
304
|
+
job: {
|
|
305
|
+
type,
|
|
306
|
+
options: {
|
|
307
|
+
customer_id: customerId,
|
|
308
|
+
items: existingJob.options.items,
|
|
309
|
+
time_range: {
|
|
310
|
+
start: existingJob.options.time_range.start,
|
|
311
|
+
end: Math.floor(Date.now() / 1000),
|
|
312
|
+
},
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
});
|
|
316
|
+
logger.info('immediate aggregated notification job', { jobId });
|
|
317
|
+
|
|
318
|
+
await aggregatedNotificationQueue.update(jobId, {
|
|
319
|
+
job: {
|
|
320
|
+
type,
|
|
321
|
+
options: {
|
|
322
|
+
customer_id: customerId,
|
|
323
|
+
items: [newItem],
|
|
324
|
+
time_range: {
|
|
325
|
+
start: Math.floor(Date.now() / 1000),
|
|
326
|
+
end: nextNotificationTime,
|
|
327
|
+
},
|
|
328
|
+
},
|
|
329
|
+
},
|
|
330
|
+
runAt: nextNotificationTime,
|
|
331
|
+
});
|
|
332
|
+
logger.info('update aggregated notification job', { jobId });
|
|
333
|
+
} else {
|
|
334
|
+
await aggregatedNotificationQueue.update(jobId, {
|
|
335
|
+
job: {
|
|
336
|
+
type,
|
|
337
|
+
options: {
|
|
338
|
+
customer_id: customerId,
|
|
339
|
+
items: updatedItems,
|
|
340
|
+
time_range: {
|
|
341
|
+
start: existingJob.options.time_range.start,
|
|
342
|
+
end: nextNotificationTime,
|
|
343
|
+
},
|
|
344
|
+
},
|
|
345
|
+
},
|
|
346
|
+
runAt: nextNotificationTime,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
logger.info('updated aggregated notification job', { jobId });
|
|
350
|
+
} else {
|
|
351
|
+
// Create new aggregation task
|
|
352
|
+
await aggregatedNotificationQueue.push({
|
|
353
|
+
id: jobId,
|
|
354
|
+
job: {
|
|
355
|
+
type,
|
|
356
|
+
options: {
|
|
357
|
+
customer_id: customerId,
|
|
358
|
+
items: [newItem],
|
|
359
|
+
time_range: {
|
|
360
|
+
start: Math.floor(Date.now() / 1000),
|
|
361
|
+
end: nextNotificationTime,
|
|
362
|
+
},
|
|
363
|
+
},
|
|
364
|
+
},
|
|
365
|
+
runAt: nextNotificationTime,
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
logger.info('created aggregated notification job', { jobId });
|
|
369
|
+
} catch (error) {
|
|
370
|
+
logger.error('Failed to handle aggregated notification', { error });
|
|
371
|
+
await handleImmediateNotification(type, newItem.data, extraIds);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function getAggregatedNotificationTemplate(job: AggregatedNotificationJob): BaseEmailTemplate {
|
|
376
|
+
if (job.type === 'customer.subscription.renewed') {
|
|
377
|
+
return new AggregatedSubscriptionRenewedEmailTemplate(
|
|
378
|
+
job.options as AggregatedSubscriptionRenewedEmailTemplateOptions
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
throw new Error(`Unknown aggregated notification type: ${job.type}`);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
async function handleAggregatedNotificationJob(job: AggregatedNotificationJob): Promise<void> {
|
|
385
|
+
try {
|
|
386
|
+
const template = await getAggregatedNotificationTemplate(job);
|
|
387
|
+
await new Notification(template).send();
|
|
388
|
+
logger.info('handleAggregatedNotificationJob.success', { job });
|
|
389
|
+
} catch (error) {
|
|
390
|
+
logger.error('handleAggregatedNotificationJob.error', error);
|
|
163
391
|
throw error;
|
|
164
392
|
}
|
|
165
393
|
}
|
|
@@ -174,6 +402,16 @@ export const notificationQueue = createQueue<NotificationQueueJob>({
|
|
|
174
402
|
},
|
|
175
403
|
});
|
|
176
404
|
|
|
405
|
+
export const aggregatedNotificationQueue = createQueue<AggregatedNotificationJob>({
|
|
406
|
+
name: 'aggregated_notification',
|
|
407
|
+
onJob: handleAggregatedNotificationJob,
|
|
408
|
+
options: {
|
|
409
|
+
concurrency: 10,
|
|
410
|
+
maxRetries: 3,
|
|
411
|
+
enableScheduledJob: true,
|
|
412
|
+
},
|
|
413
|
+
});
|
|
414
|
+
|
|
177
415
|
/**
|
|
178
416
|
*
|
|
179
417
|
* @see https://team.arcblock.io/comment/discussions/0fba06ff-75b4-47d2-8c5c-f0379540ce03
|
|
@@ -239,16 +477,39 @@ export async function startNotificationQueue() {
|
|
|
239
477
|
}
|
|
240
478
|
});
|
|
241
479
|
|
|
242
|
-
events.on('customer.subscription.renewed', (subscription: Subscription) => {
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
480
|
+
events.on('customer.subscription.renewed', async (subscription: Subscription) => {
|
|
481
|
+
const customer = await Customer.findByPk(subscription.customer_id);
|
|
482
|
+
const preference = customer?.preference?.notification;
|
|
483
|
+
if (!subscription.latest_invoice_id) {
|
|
484
|
+
logger.warn('Missing latest_invoice_id for subscription', { subscriptionId: subscription.id });
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
if (!preference || preference.frequency === 'default') {
|
|
489
|
+
handleImmediateNotification(
|
|
490
|
+
'customer.subscription.renewed',
|
|
491
|
+
{
|
|
492
|
+
subscriptionId: subscription.id,
|
|
493
|
+
invoiceId: subscription.latest_invoice_id,
|
|
494
|
+
},
|
|
495
|
+
[subscription.id, subscription.latest_invoice_id]
|
|
496
|
+
);
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
await handleAggregatedNotification({
|
|
501
|
+
type: 'customer.subscription.renewed',
|
|
502
|
+
customerId: customer.id,
|
|
503
|
+
preference,
|
|
504
|
+
newItem: {
|
|
505
|
+
event_id: `${subscription.id}.${subscription.latest_invoice_id}`,
|
|
506
|
+
occurred_at: Math.floor(Date.now() / 1000),
|
|
507
|
+
data: {
|
|
248
508
|
subscriptionId: subscription.id,
|
|
249
509
|
invoiceId: subscription.latest_invoice_id,
|
|
250
|
-
}
|
|
510
|
+
},
|
|
251
511
|
},
|
|
512
|
+
extraIds: [subscription.id, subscription.latest_invoice_id],
|
|
252
513
|
});
|
|
253
514
|
});
|
|
254
515
|
|
|
@@ -362,3 +623,84 @@ export async function startNotificationQueue() {
|
|
|
362
623
|
}
|
|
363
624
|
});
|
|
364
625
|
}
|
|
626
|
+
|
|
627
|
+
export async function handleNotificationPreferenceChange(
|
|
628
|
+
customerId: string,
|
|
629
|
+
newPreference?: NotificationPreference
|
|
630
|
+
): Promise<void> {
|
|
631
|
+
// 1. get pending scheduled jobs for this customer
|
|
632
|
+
const pendingJobs = await aggregatedNotificationQueue.store.findJobs({
|
|
633
|
+
delay: { [Op.not]: -1 },
|
|
634
|
+
will_run_at: { [Op.gt]: Date.now() },
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
const customerJobs = pendingJobs.filter((job: TJob) => job.job?.options?.customer_id === customerId);
|
|
638
|
+
|
|
639
|
+
if (!customerJobs.length) {
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// 2. if changed to immediate notification, delete the old job and create a new one immediately
|
|
644
|
+
if (newPreference?.frequency === 'default') {
|
|
645
|
+
/* eslint-disable no-await-in-loop */
|
|
646
|
+
for (const job of customerJobs) {
|
|
647
|
+
try {
|
|
648
|
+
await aggregatedNotificationQueue.delete(job.id);
|
|
649
|
+
aggregatedNotificationQueue.push({
|
|
650
|
+
id: job.id,
|
|
651
|
+
job: {
|
|
652
|
+
...job.job,
|
|
653
|
+
options: {
|
|
654
|
+
...job.job.options,
|
|
655
|
+
time_range: {
|
|
656
|
+
start: job.job.options.time_range.start,
|
|
657
|
+
end: Math.floor(Date.now() / 1000),
|
|
658
|
+
},
|
|
659
|
+
},
|
|
660
|
+
},
|
|
661
|
+
});
|
|
662
|
+
} catch (error) {
|
|
663
|
+
logger.error('Failed to process immediate notification job', { error, jobId: job.id });
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
/* eslint-enable no-await-in-loop */
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// 3. for other frequency changes, recalculate the send time
|
|
671
|
+
if (newPreference) {
|
|
672
|
+
/* eslint-disable no-await-in-loop */
|
|
673
|
+
for (const job of customerJobs) {
|
|
674
|
+
try {
|
|
675
|
+
const nextTime = calculateNextNotificationTime(newPreference);
|
|
676
|
+
const oldScheduleTime = job.will_run_at ? job.will_run_at / 1000 : 0;
|
|
677
|
+
|
|
678
|
+
// if next notification time is different from the old schedule time, update the job
|
|
679
|
+
if (nextTime !== oldScheduleTime) {
|
|
680
|
+
// Keep the original job id format but update the timestamp
|
|
681
|
+
const jobIdParts = job.id.split('.');
|
|
682
|
+
const jobId = `${jobIdParts.slice(0, -1).join('.')}.${nextTime}`;
|
|
683
|
+
|
|
684
|
+
await aggregatedNotificationQueue.delete(job.id);
|
|
685
|
+
aggregatedNotificationQueue.push({
|
|
686
|
+
id: jobId,
|
|
687
|
+
job: {
|
|
688
|
+
...job.job,
|
|
689
|
+
options: {
|
|
690
|
+
...job.job.options,
|
|
691
|
+
time_range: {
|
|
692
|
+
start: job.job.options.time_range.start,
|
|
693
|
+
end: nextTime,
|
|
694
|
+
},
|
|
695
|
+
},
|
|
696
|
+
},
|
|
697
|
+
runAt: nextTime,
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
} catch (error) {
|
|
701
|
+
logger.error('Failed to process notification job', { error, jobId: job.id });
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
/* eslint-enable no-await-in-loop */
|
|
705
|
+
}
|
|
706
|
+
}
|
|
@@ -45,6 +45,8 @@ type PaymentJob = {
|
|
|
45
45
|
paymentIntentId: string;
|
|
46
46
|
paymentSettings?: PaymentSettings;
|
|
47
47
|
retryOnError?: boolean;
|
|
48
|
+
ignoreMaxRetryCheck?: boolean;
|
|
49
|
+
immediateRetry?: boolean;
|
|
48
50
|
};
|
|
49
51
|
|
|
50
52
|
type DepositVaultJob = {
|
|
@@ -721,7 +723,7 @@ export const handlePayment = async (job: PaymentJob) => {
|
|
|
721
723
|
}
|
|
722
724
|
|
|
723
725
|
// check max retry before doing any hard work
|
|
724
|
-
if (invoice && invoice.attempt_count >= MAX_RETRY_COUNT) {
|
|
726
|
+
if (invoice && invoice.attempt_count >= MAX_RETRY_COUNT && !job.ignoreMaxRetryCheck) {
|
|
725
727
|
logger.info('PaymentIntent capture aborted since max retry exceeded', { id: paymentIntent.id });
|
|
726
728
|
const updates = await handlePaymentFailed(
|
|
727
729
|
paymentIntent,
|
|
@@ -882,7 +884,7 @@ export const handlePayment = async (job: PaymentJob) => {
|
|
|
882
884
|
};
|
|
883
885
|
|
|
884
886
|
if (!job.retryOnError) {
|
|
885
|
-
//
|
|
887
|
+
// To a final state without any retry
|
|
886
888
|
await paymentIntent.update({ status: 'requires_action', last_payment_error: error });
|
|
887
889
|
if (invoice) {
|
|
888
890
|
await invoice.update({
|
|
@@ -924,7 +926,6 @@ export const handlePayment = async (job: PaymentJob) => {
|
|
|
924
926
|
}
|
|
925
927
|
}
|
|
926
928
|
}
|
|
927
|
-
|
|
928
929
|
// reschedule next attempt
|
|
929
930
|
const retryAt = updates.invoice.next_payment_attempt;
|
|
930
931
|
if (retryAt) {
|
|
@@ -993,16 +994,31 @@ events.on('payment.queued', async (id, job, args = {}) => {
|
|
|
993
994
|
job,
|
|
994
995
|
...extraArgs,
|
|
995
996
|
});
|
|
996
|
-
events.emit('payment.queued.done');
|
|
997
|
+
events.emit('payment.queued.done', { id, job });
|
|
997
998
|
} catch (error) {
|
|
998
999
|
logger.error('Error in payment.queued', { id, job, error });
|
|
999
|
-
events.emit('payment.queued.error', error);
|
|
1000
|
+
events.emit('payment.queued.error', { id, job, error });
|
|
1000
1001
|
}
|
|
1001
1002
|
return;
|
|
1002
1003
|
}
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1004
|
+
|
|
1005
|
+
try {
|
|
1006
|
+
const existingJob = await paymentQueue.get(id);
|
|
1007
|
+
if (existingJob) {
|
|
1008
|
+
await paymentQueue.delete(id);
|
|
1009
|
+
logger.info('Removed existing payment job for immediate execution', {
|
|
1010
|
+
id,
|
|
1011
|
+
originalRunAt: existingJob.runAt,
|
|
1012
|
+
});
|
|
1013
|
+
}
|
|
1014
|
+
paymentQueue.push({
|
|
1015
|
+
id,
|
|
1016
|
+
job,
|
|
1017
|
+
...extraArgs,
|
|
1018
|
+
});
|
|
1019
|
+
events.emit('payment.queued.done', { id, job });
|
|
1020
|
+
} catch (error) {
|
|
1021
|
+
logger.error('Error in payment.queued', { id, job, error });
|
|
1022
|
+
events.emit('payment.queued.error', { id, job, error });
|
|
1023
|
+
}
|
|
1008
1024
|
});
|
package/api/src/queues/payout.ts
CHANGED
|
@@ -282,16 +282,30 @@ events.on('payout.queued', async (id, job, args = {}) => {
|
|
|
282
282
|
job,
|
|
283
283
|
...extraArgs,
|
|
284
284
|
});
|
|
285
|
-
events.emit('payout.queued.done');
|
|
285
|
+
events.emit('payout.queued.done', { id, job });
|
|
286
286
|
} catch (error) {
|
|
287
287
|
logger.error('Error in payout.queued', { id, job, error });
|
|
288
|
-
events.emit('payout.queued.error', error);
|
|
288
|
+
events.emit('payout.queued.error', { id, job, error });
|
|
289
289
|
}
|
|
290
290
|
return;
|
|
291
291
|
}
|
|
292
|
-
|
|
293
|
-
id
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
292
|
+
try {
|
|
293
|
+
const existJob = await payoutQueue.get(id);
|
|
294
|
+
if (existJob) {
|
|
295
|
+
await payoutQueue.delete(id);
|
|
296
|
+
logger.info('Removed existing payout job for immediate execution', {
|
|
297
|
+
id,
|
|
298
|
+
originalRunAt: existJob.runAt,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
payoutQueue.push({
|
|
302
|
+
id,
|
|
303
|
+
job,
|
|
304
|
+
...extraArgs,
|
|
305
|
+
});
|
|
306
|
+
events.emit('payout.queued.done', { id, job });
|
|
307
|
+
} catch (error) {
|
|
308
|
+
logger.error('Error in payout.queued', { id, job, error });
|
|
309
|
+
events.emit('payout.queued.error', { id, job, error });
|
|
310
|
+
}
|
|
297
311
|
});
|
|
@@ -856,10 +856,17 @@ router.put('/:id/submit', user, ensureCheckoutSessionOpen, async (req, res) => {
|
|
|
856
856
|
});
|
|
857
857
|
logger.info('customer created on checkout session submit', { did: req.user.did, id: customer.id });
|
|
858
858
|
try {
|
|
859
|
-
await blocklet.updateUserAddress(
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
859
|
+
await blocklet.updateUserAddress(
|
|
860
|
+
{
|
|
861
|
+
did: customer.did,
|
|
862
|
+
address: Customer.formatAddressFromCustomer(customer),
|
|
863
|
+
},
|
|
864
|
+
{
|
|
865
|
+
headers: {
|
|
866
|
+
cookie: req.headers.cookie || '',
|
|
867
|
+
},
|
|
868
|
+
}
|
|
869
|
+
);
|
|
863
870
|
logger.info('updateUserAddress success', {
|
|
864
871
|
did: customer.did,
|
|
865
872
|
});
|
|
@@ -870,14 +877,14 @@ router.put('/:id/submit', user, ensureCheckoutSessionOpen, async (req, res) => {
|
|
|
870
877
|
});
|
|
871
878
|
}
|
|
872
879
|
} else {
|
|
873
|
-
const updates: Record<string,
|
|
880
|
+
const updates: Record<string, any> = {};
|
|
874
881
|
if (checkoutSession.customer_update?.name) {
|
|
875
882
|
updates.name = req.body.customer_name;
|
|
876
883
|
updates.email = req.body.customer_email;
|
|
877
884
|
updates.phone = req.body.customer_phone;
|
|
878
885
|
}
|
|
879
886
|
if (checkoutSession.customer_update?.address) {
|
|
880
|
-
updates.address = req.body.billing_address;
|
|
887
|
+
updates.address = Customer.formatUpdateAddress(req.body.billing_address, customer);
|
|
881
888
|
}
|
|
882
889
|
if (!customer.invoice_prefix) {
|
|
883
890
|
updates.invoice_prefix = Customer.getInvoicePrefix();
|
|
@@ -885,12 +892,19 @@ router.put('/:id/submit', user, ensureCheckoutSessionOpen, async (req, res) => {
|
|
|
885
892
|
|
|
886
893
|
await customer.update(updates);
|
|
887
894
|
try {
|
|
888
|
-
await blocklet.updateUserAddress(
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
895
|
+
await blocklet.updateUserAddress(
|
|
896
|
+
{
|
|
897
|
+
did: customer.did,
|
|
898
|
+
address: Customer.formatAddressFromCustomer(customer),
|
|
899
|
+
// @ts-ignore
|
|
900
|
+
phone: customer.phone,
|
|
901
|
+
},
|
|
902
|
+
{
|
|
903
|
+
headers: {
|
|
904
|
+
cookie: req.headers.cookie || '',
|
|
905
|
+
},
|
|
906
|
+
}
|
|
907
|
+
);
|
|
894
908
|
logger.info('updateUserAddress success', {
|
|
895
909
|
did: customer.did,
|
|
896
910
|
});
|
|
@@ -6,7 +6,7 @@ import { getGasPayerExtra } from '../../libs/payment';
|
|
|
6
6
|
import { getTxMetadata } from '../../libs/util';
|
|
7
7
|
import { ensureAccountRecharge, getAuthPrincipalClaim } from './shared';
|
|
8
8
|
import logger from '../../libs/logger';
|
|
9
|
-
import { ensureRechargeInvoice } from '../../libs/invoice';
|
|
9
|
+
import { ensureRechargeInvoice, retryUncollectibleInvoices } from '../../libs/invoice';
|
|
10
10
|
|
|
11
11
|
export default {
|
|
12
12
|
action: 'recharge-account',
|
|
@@ -86,6 +86,18 @@ export default {
|
|
|
86
86
|
paymentMethod,
|
|
87
87
|
customer!
|
|
88
88
|
);
|
|
89
|
+
try {
|
|
90
|
+
retryUncollectibleInvoices({
|
|
91
|
+
customerId: customer.id,
|
|
92
|
+
currencyId: paymentCurrency.id,
|
|
93
|
+
});
|
|
94
|
+
} catch (err) {
|
|
95
|
+
logger.error('Failed to retry uncollectible invoices', {
|
|
96
|
+
error: err,
|
|
97
|
+
customerId: customer.id,
|
|
98
|
+
currencyId: paymentCurrency.id,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
89
101
|
};
|
|
90
102
|
if (paymentMethod.type === 'arcblock') {
|
|
91
103
|
try {
|