payment-kit 1.18.25 → 1.18.27
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/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/notification.ts +353 -11
- package/api/src/routes/customers.ts +61 -0
- 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 +15 -1
- package/api/src/store/models/types.ts +17 -1
- package/blocklet.yml +1 -1
- package/package.json +19 -19
- 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 +26 -22
- package/src/pages/customer/recharge/account.tsx +7 -7
- package/src/pages/customer/subscription/embed.tsx +1 -0
|
@@ -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
|
+
}
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
} from '../store/models';
|
|
25
25
|
import { getSubscriptionPaymentAddress, calculateRecommendedRechargeAmount } from '../libs/subscription';
|
|
26
26
|
import { expandLineItems } from '../libs/session';
|
|
27
|
+
import { handleNotificationPreferenceChange } from '../queues/notification';
|
|
27
28
|
|
|
28
29
|
const router = Router();
|
|
29
30
|
const auth = authenticate<Customer>({ component: true, roles: ['owner', 'admin'] });
|
|
@@ -181,6 +182,9 @@ router.get('/:id/overdue/invoices', sessionMiddleware(), async (req, res) => {
|
|
|
181
182
|
if (!doc) {
|
|
182
183
|
return res.status(404).json({ error: 'Customer not found' });
|
|
183
184
|
}
|
|
185
|
+
if (doc.did !== req.user.did && !['admin', 'owner'].includes(req.user?.role)) {
|
|
186
|
+
return res.status(403).json({ error: 'You are not allowed to access this customer invoices' });
|
|
187
|
+
}
|
|
184
188
|
const { rows: invoices, count } = await Invoice.findAndCountAll({
|
|
185
189
|
where: {
|
|
186
190
|
customer_id: doc.id,
|
|
@@ -367,6 +371,63 @@ router.get('/:id/summary', auth, async (req, res) => {
|
|
|
367
371
|
}
|
|
368
372
|
});
|
|
369
373
|
|
|
374
|
+
const updatePreferenceSchema = Joi.object({
|
|
375
|
+
notification: Joi.object({
|
|
376
|
+
frequency: Joi.string().valid('default', 'daily', 'weekly', 'monthly').required(),
|
|
377
|
+
schedule: Joi.object({
|
|
378
|
+
time: Joi.string().pattern(/^([01]?[0-9]|2[0-3]):[0-5][0-9]$/),
|
|
379
|
+
date: Joi.alternatives().conditional('..frequency', {
|
|
380
|
+
switch: [
|
|
381
|
+
{ is: 'weekly', then: Joi.number().min(0).max(6).required() },
|
|
382
|
+
{ is: 'monthly', then: Joi.number().min(1).max(31).required() },
|
|
383
|
+
{ is: Joi.valid('daily', 'default'), then: Joi.number().optional() },
|
|
384
|
+
],
|
|
385
|
+
}),
|
|
386
|
+
}).when('frequency', {
|
|
387
|
+
is: 'default',
|
|
388
|
+
then: Joi.optional(),
|
|
389
|
+
otherwise: Joi.required(),
|
|
390
|
+
}),
|
|
391
|
+
}).optional(),
|
|
392
|
+
}).unknown(false);
|
|
393
|
+
|
|
394
|
+
router.put('/preference', sessionMiddleware(), async (req, res) => {
|
|
395
|
+
try {
|
|
396
|
+
if (!req.user) {
|
|
397
|
+
return res.status(403).json({ error: 'Unauthorized' });
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const doc = await Customer.findByPkOrDid(req.user.did as string);
|
|
401
|
+
if (!doc) {
|
|
402
|
+
return res.status(404).json({ error: 'Customer not found' });
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const { error, value } = updatePreferenceSchema.validate(req.body);
|
|
406
|
+
if (error) {
|
|
407
|
+
return res.status(400).json({ error: error.message });
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Get old preference before update
|
|
411
|
+
const oldPreference = doc.preference?.notification;
|
|
412
|
+
|
|
413
|
+
await doc.update({ preference: value });
|
|
414
|
+
|
|
415
|
+
// Handle notification queue updates if notification preference changed
|
|
416
|
+
if (
|
|
417
|
+
oldPreference?.frequency !== value.notification?.frequency ||
|
|
418
|
+
oldPreference?.schedule?.time !== value.notification?.schedule?.time ||
|
|
419
|
+
oldPreference?.schedule?.date !== value.notification?.schedule?.date
|
|
420
|
+
) {
|
|
421
|
+
await handleNotificationPreferenceChange(doc.id, value.notification);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
return res.json(doc);
|
|
425
|
+
} catch (err) {
|
|
426
|
+
logger.error('Failed to update customer preference', err);
|
|
427
|
+
return res.status(400).json({ error: `Failed to update preference: ${err.message}` });
|
|
428
|
+
}
|
|
429
|
+
});
|
|
430
|
+
|
|
370
431
|
const updateCustomerSchema = Joi.object({
|
|
371
432
|
metadata: MetadataSchema,
|
|
372
433
|
name: Joi.string().min(2).max(30).empty(''),
|
|
@@ -1906,7 +1906,7 @@ router.get('/:id/overdue/invoices', authPortal, async (req, res) => {
|
|
|
1906
1906
|
return res.status(404).json({ error: 'Subscription not found' });
|
|
1907
1907
|
}
|
|
1908
1908
|
// @ts-ignore
|
|
1909
|
-
if (subscription.customer?.did !== req.user?.did) {
|
|
1909
|
+
if (subscription.customer?.did !== req.user?.did && !['admin', 'owner'].includes(req.user?.role)) {
|
|
1910
1910
|
return res.status(403).json({ error: 'You are not allowed to access this subscription' });
|
|
1911
1911
|
}
|
|
1912
1912
|
const { rows: invoices, count } = await Invoice.findAndCountAll({
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { DataTypes } from 'sequelize';
|
|
2
|
+
|
|
3
|
+
import { Migration, safeApplyColumnChanges } from '../migrate';
|
|
4
|
+
|
|
5
|
+
export const up: Migration = async ({ context }) => {
|
|
6
|
+
await safeApplyColumnChanges(context, {
|
|
7
|
+
customers: [
|
|
8
|
+
{
|
|
9
|
+
name: 'preference',
|
|
10
|
+
field: {
|
|
11
|
+
type: DataTypes.JSON,
|
|
12
|
+
defaultValue: JSON.stringify({
|
|
13
|
+
notification: {
|
|
14
|
+
frequency: 'monthly',
|
|
15
|
+
schedule: {
|
|
16
|
+
time: '10:00',
|
|
17
|
+
date: 1,
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
}),
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
],
|
|
24
|
+
});
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const down: Migration = async ({ context }) => {
|
|
28
|
+
await context.removeColumn('customers', 'preference');
|
|
29
|
+
};
|
|
@@ -17,7 +17,7 @@ import { createEvent } from '../../libs/audit';
|
|
|
17
17
|
import CustomError from '../../libs/error';
|
|
18
18
|
import { getLock } from '../../libs/lock';
|
|
19
19
|
import { createCodeGenerator, createIdGenerator } from '../../libs/util';
|
|
20
|
-
import type { CustomerAddress, CustomerShipping } from './types';
|
|
20
|
+
import type { CustomerAddress, CustomerPreferences, CustomerShipping } from './types';
|
|
21
21
|
|
|
22
22
|
export const nextCustomerId = createIdGenerator('cus', 14);
|
|
23
23
|
export const nextInvoicePrefix = createCodeGenerator('', 8);
|
|
@@ -65,6 +65,7 @@ export class Customer extends Model<InferAttributes<Customer>, InferCreationAttr
|
|
|
65
65
|
declare created_at: CreationOptional<Date>;
|
|
66
66
|
declare updated_at: CreationOptional<Date>;
|
|
67
67
|
declare last_sync_at?: number;
|
|
68
|
+
declare preference?: CustomerPreferences;
|
|
68
69
|
|
|
69
70
|
public static readonly GENESIS_ATTRIBUTES = {
|
|
70
71
|
id: {
|
|
@@ -234,6 +235,19 @@ export class Customer extends Model<InferAttributes<Customer>, InferCreationAttr
|
|
|
234
235
|
type: DataTypes.INTEGER,
|
|
235
236
|
allowNull: true,
|
|
236
237
|
},
|
|
238
|
+
preference: {
|
|
239
|
+
type: DataTypes.JSON,
|
|
240
|
+
allowNull: true,
|
|
241
|
+
defaultValue: {
|
|
242
|
+
notification: {
|
|
243
|
+
frequency: 'monthly',
|
|
244
|
+
schedule: {
|
|
245
|
+
time: '10:00',
|
|
246
|
+
date: 1,
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
},
|
|
237
251
|
},
|
|
238
252
|
{
|
|
239
253
|
sequelize,
|
|
@@ -442,7 +442,7 @@ export type PricingTableItem = {
|
|
|
442
442
|
// Enables user redeemable promotion codes.
|
|
443
443
|
allow_promotion_codes: boolean;
|
|
444
444
|
|
|
445
|
-
// Configuration for collecting the customer
|
|
445
|
+
// Configuration for collecting the customer's billing address.
|
|
446
446
|
billing_address_collection?: LiteralUnion<'auto' | 'required', string>;
|
|
447
447
|
|
|
448
448
|
is_highlight: boolean;
|
|
@@ -704,3 +704,19 @@ export type EventType = LiteralUnion<
|
|
|
704
704
|
export type StripeRefundReason = 'duplicate' | 'fraudulent' | 'requested_by_customer';
|
|
705
705
|
|
|
706
706
|
export type SettingType = LiteralUnion<'donate', string>;
|
|
707
|
+
|
|
708
|
+
export type NotificationFrequency = 'default' | 'daily' | 'weekly' | 'monthly';
|
|
709
|
+
export type NotificationSchedule = {
|
|
710
|
+
time: string; // HH:mm 格式
|
|
711
|
+
date?: number; // weekly: 0-6, monthly: 1-31
|
|
712
|
+
};
|
|
713
|
+
|
|
714
|
+
export type NotificationSettings = {
|
|
715
|
+
frequency: NotificationFrequency;
|
|
716
|
+
schedule?: NotificationSchedule;
|
|
717
|
+
};
|
|
718
|
+
|
|
719
|
+
export type CustomerPreferences = {
|
|
720
|
+
notification?: NotificationSettings;
|
|
721
|
+
// support more preferences
|
|
722
|
+
};
|
package/blocklet.yml
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "payment-kit",
|
|
3
|
-
"version": "1.18.
|
|
3
|
+
"version": "1.18.27",
|
|
4
4
|
"scripts": {
|
|
5
5
|
"dev": "blocklet dev --open",
|
|
6
6
|
"eject": "vite eject",
|
|
@@ -44,29 +44,29 @@
|
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
46
|
"@abtnode/cron": "^1.16.41",
|
|
47
|
-
"@arcblock/did": "^1.19.
|
|
47
|
+
"@arcblock/did": "^1.19.19",
|
|
48
48
|
"@arcblock/did-auth-storage-nedb": "^1.7.1",
|
|
49
|
-
"@arcblock/did-connect": "^2.12.
|
|
50
|
-
"@arcblock/did-util": "^1.19.
|
|
51
|
-
"@arcblock/jwt": "^1.19.
|
|
52
|
-
"@arcblock/ux": "^2.12.
|
|
53
|
-
"@arcblock/validator": "^1.19.
|
|
49
|
+
"@arcblock/did-connect": "^2.12.60",
|
|
50
|
+
"@arcblock/did-util": "^1.19.19",
|
|
51
|
+
"@arcblock/jwt": "^1.19.19",
|
|
52
|
+
"@arcblock/ux": "^2.12.60",
|
|
53
|
+
"@arcblock/validator": "^1.19.19",
|
|
54
54
|
"@blocklet/js-sdk": "^1.16.41",
|
|
55
55
|
"@blocklet/logger": "^1.16.41",
|
|
56
|
-
"@blocklet/payment-react": "1.18.
|
|
56
|
+
"@blocklet/payment-react": "1.18.27",
|
|
57
57
|
"@blocklet/sdk": "^1.16.41",
|
|
58
|
-
"@blocklet/ui-react": "^2.12.
|
|
59
|
-
"@blocklet/uploader": "^0.1.
|
|
60
|
-
"@blocklet/xss": "^0.1.
|
|
58
|
+
"@blocklet/ui-react": "^2.12.60",
|
|
59
|
+
"@blocklet/uploader": "^0.1.82",
|
|
60
|
+
"@blocklet/xss": "^0.1.31",
|
|
61
61
|
"@mui/icons-material": "^5.16.6",
|
|
62
62
|
"@mui/lab": "^5.0.0-alpha.173",
|
|
63
63
|
"@mui/material": "^5.16.6",
|
|
64
64
|
"@mui/system": "^5.16.6",
|
|
65
|
-
"@ocap/asset": "^1.19.
|
|
66
|
-
"@ocap/client": "^1.19.
|
|
67
|
-
"@ocap/mcrypto": "^1.19.
|
|
68
|
-
"@ocap/util": "^1.19.
|
|
69
|
-
"@ocap/wallet": "^1.19.
|
|
65
|
+
"@ocap/asset": "^1.19.19",
|
|
66
|
+
"@ocap/client": "^1.19.19",
|
|
67
|
+
"@ocap/mcrypto": "^1.19.19",
|
|
68
|
+
"@ocap/util": "^1.19.19",
|
|
69
|
+
"@ocap/wallet": "^1.19.19",
|
|
70
70
|
"@stripe/react-stripe-js": "^2.7.3",
|
|
71
71
|
"@stripe/stripe-js": "^2.4.0",
|
|
72
72
|
"ahooks": "^3.8.0",
|
|
@@ -121,7 +121,7 @@
|
|
|
121
121
|
"devDependencies": {
|
|
122
122
|
"@abtnode/types": "^1.16.41",
|
|
123
123
|
"@arcblock/eslint-config-ts": "^0.3.3",
|
|
124
|
-
"@blocklet/payment-types": "1.18.
|
|
124
|
+
"@blocklet/payment-types": "1.18.27",
|
|
125
125
|
"@types/cookie-parser": "^1.4.7",
|
|
126
126
|
"@types/cors": "^2.8.17",
|
|
127
127
|
"@types/debug": "^4.1.12",
|
|
@@ -151,7 +151,7 @@
|
|
|
151
151
|
"vite": "^5.3.5",
|
|
152
152
|
"vite-node": "^2.0.4",
|
|
153
153
|
"vite-plugin-babel-import": "^2.0.5",
|
|
154
|
-
"vite-plugin-blocklet": "^0.9.
|
|
154
|
+
"vite-plugin-blocklet": "^0.9.31",
|
|
155
155
|
"vite-plugin-node-polyfills": "^0.21.0",
|
|
156
156
|
"vite-plugin-svgr": "^4.2.0",
|
|
157
157
|
"vite-tsconfig-paths": "^4.3.2",
|
|
@@ -167,5 +167,5 @@
|
|
|
167
167
|
"parser": "typescript"
|
|
168
168
|
}
|
|
169
169
|
},
|
|
170
|
-
"gitHead": "
|
|
170
|
+
"gitHead": "2bbcff1991daf5de147813c598e13d755da0c989"
|
|
171
171
|
}
|