payment-kit 1.15.16 → 1.15.18
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/integrations/stripe/handlers/invoice.ts +20 -0
- package/api/src/integrations/stripe/resource.ts +2 -2
- package/api/src/libs/audit.ts +1 -1
- package/api/src/libs/invoice.ts +81 -1
- package/api/src/libs/notification/template/billing-discrepancy.ts +223 -0
- package/api/src/libs/notification/template/subscription-canceled.ts +11 -0
- package/api/src/libs/notification/template/subscription-refund-succeeded.ts +10 -2
- package/api/src/libs/notification/template/subscription-renew-failed.ts +10 -2
- package/api/src/libs/notification/template/subscription-renewed.ts +11 -3
- package/api/src/libs/notification/template/subscription-stake-slash-succeeded.ts +11 -1
- package/api/src/libs/notification/template/subscription-succeeded.ts +11 -1
- package/api/src/libs/notification/template/subscription-trial-start.ts +11 -0
- package/api/src/libs/notification/template/subscription-trial-will-end.ts +17 -0
- package/api/src/libs/notification/template/subscription-upgraded.ts +51 -26
- package/api/src/libs/notification/template/subscription-will-canceled.ts +16 -0
- package/api/src/libs/notification/template/subscription-will-renew.ts +15 -3
- package/api/src/libs/notification/template/usage-report-empty.ts +158 -0
- package/api/src/libs/queue/index.ts +69 -19
- package/api/src/libs/queue/store.ts +28 -5
- package/api/src/libs/subscription.ts +129 -19
- package/api/src/libs/util.ts +30 -0
- package/api/src/locales/en.ts +13 -0
- package/api/src/locales/zh.ts +13 -0
- package/api/src/queues/invoice.ts +58 -20
- package/api/src/queues/notification.ts +43 -1
- package/api/src/queues/payment.ts +5 -1
- package/api/src/queues/subscription.ts +64 -15
- package/api/src/routes/checkout-sessions.ts +26 -0
- package/api/src/routes/invoices.ts +11 -31
- package/api/src/routes/subscriptions.ts +43 -7
- package/api/src/store/models/checkout-session.ts +2 -0
- package/api/src/store/models/job.ts +4 -0
- package/api/src/store/models/types.ts +22 -4
- package/api/src/store/models/usage-record.ts +5 -1
- package/api/tests/libs/subscription.spec.ts +154 -0
- package/api/tests/libs/util.spec.ts +135 -0
- package/blocklet.yml +1 -1
- package/package.json +10 -10
- package/scripts/sdk.js +37 -3
- package/src/components/invoice/list.tsx +0 -1
- package/src/components/invoice/table.tsx +7 -2
- package/src/components/subscription/items/index.tsx +26 -7
- package/src/components/subscription/items/usage-records.tsx +21 -10
- package/src/components/subscription/portal/actions.tsx +16 -14
- package/src/libs/util.ts +51 -0
- package/src/locales/en.tsx +2 -0
- package/src/locales/zh.tsx +2 -0
- package/src/pages/admin/billing/subscriptions/detail.tsx +1 -1
- package/src/pages/customer/subscription/change-plan.tsx +1 -1
- package/src/pages/customer/subscription/embed.tsx +16 -14
- package/vite-server.config.ts +8 -0
|
@@ -5,9 +5,12 @@ import EventEmitter from 'events';
|
|
|
5
5
|
import fastq from 'fastq';
|
|
6
6
|
import { nanoid } from 'nanoid';
|
|
7
7
|
|
|
8
|
+
import { AsyncLocalStorage } from 'async_hooks';
|
|
8
9
|
import logger from '../logger';
|
|
9
10
|
import { sleep, tryWithTimeout } from '../util';
|
|
10
11
|
import createQueueStore from './store';
|
|
12
|
+
import { Job } from '../../store/models/job';
|
|
13
|
+
import { sequelize } from '../../store/sequelize';
|
|
11
14
|
|
|
12
15
|
const CANCELLED = '__CANCELLED__';
|
|
13
16
|
const MIN_DELAY = process.env.NODE_ENV === 'test' ? 2 : 8;
|
|
@@ -50,26 +53,56 @@ export default function createQueue<T = any>({ name, onJob, options = defaults }
|
|
|
50
53
|
const getJobId = (id: string | undefined, job: any) => id || (options.id ? options.id(job) : nanoid()) || nanoid();
|
|
51
54
|
|
|
52
55
|
const queueEvents = new EventEmitter();
|
|
56
|
+
const asyncLocalStorage = new AsyncLocalStorage();
|
|
57
|
+
const runningJobs = new Set();
|
|
58
|
+
const cancelledJobs = new Set();
|
|
59
|
+
|
|
60
|
+
const ensureUniqueExecution = async (id: string, fn: () => Promise<any>) => {
|
|
61
|
+
if (runningJobs.has(id)) {
|
|
62
|
+
logger.warn('Job already running, skipping', { id });
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
runningJobs.add(id);
|
|
67
|
+
try {
|
|
68
|
+
await asyncLocalStorage.run({ id }, fn);
|
|
69
|
+
} finally {
|
|
70
|
+
runningJobs.delete(id);
|
|
71
|
+
logger.info(`Running job deleted: ${id}`);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
53
75
|
const queue = fastq(async ({ job, id, persist }: { job: any; id: string; persist: boolean }, cb: Function) => {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
76
|
+
logger.info('execute job', { id, job, persist });
|
|
77
|
+
await ensureUniqueExecution(id, async () => {
|
|
78
|
+
if (persist) {
|
|
79
|
+
try {
|
|
80
|
+
const cancelled = await store.isCancelled(id);
|
|
81
|
+
if (cancelled) {
|
|
82
|
+
cb(null, CANCELLED);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
} catch (err) {
|
|
86
|
+
cb(err);
|
|
59
87
|
return;
|
|
60
88
|
}
|
|
61
|
-
}
|
|
62
|
-
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (cancelledJobs.has(id)) {
|
|
92
|
+
cb(null, CANCELLED);
|
|
93
|
+
logger.info(`Job skipped because it is cancelled: ${id}`);
|
|
94
|
+
cancelledJobs.delete(id);
|
|
63
95
|
return;
|
|
64
96
|
}
|
|
65
|
-
}
|
|
66
97
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
98
|
+
try {
|
|
99
|
+
const result = await tryWithTimeout(() => onJob(job), maxTimeout);
|
|
100
|
+
logger.info('job finished', { id, result });
|
|
101
|
+
cb(null, result);
|
|
102
|
+
} catch (err) {
|
|
103
|
+
cb(err);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
73
106
|
// @ts-ignore
|
|
74
107
|
}, concurrency);
|
|
75
108
|
|
|
@@ -109,7 +142,8 @@ export default function createQueue<T = any>({ name, onJob, options = defaults }
|
|
|
109
142
|
store
|
|
110
143
|
.addJob(jobId, job, attrs)
|
|
111
144
|
.then(() => {
|
|
112
|
-
emit('queued', { id: jobId, job, attrs });
|
|
145
|
+
emit('queued', { id: jobId, job, attrs, persist });
|
|
146
|
+
logger.info('delayed or scheduled job queued', { id: jobId, job, attrs, persist });
|
|
113
147
|
})
|
|
114
148
|
.catch((err) => {
|
|
115
149
|
console.error(err);
|
|
@@ -157,7 +191,7 @@ export default function createQueue<T = any>({ name, onJob, options = defaults }
|
|
|
157
191
|
logger.info('retry job', { id: jobId, count: doc.retry_count + 1 });
|
|
158
192
|
setTimeout(() => {
|
|
159
193
|
emit('retry', { id: jobId, job, doc });
|
|
160
|
-
queue.unshift({ id: jobId, job }, onJobComplete);
|
|
194
|
+
queue.unshift({ id: jobId, job, persist }, onJobComplete);
|
|
161
195
|
}, retryDelay);
|
|
162
196
|
// eslint-disable-next-line no-shadow, @typescript-eslint/no-shadow
|
|
163
197
|
} catch (err: any) {
|
|
@@ -171,6 +205,9 @@ export default function createQueue<T = any>({ name, onJob, options = defaults }
|
|
|
171
205
|
setImmediate(() => {
|
|
172
206
|
emit('queued', { id: jobId, job, persist });
|
|
173
207
|
logger.info('queue job', { id: jobId, job });
|
|
208
|
+
if (cancelledJobs.has(jobId)) {
|
|
209
|
+
cancelledJobs.delete(jobId);
|
|
210
|
+
}
|
|
174
211
|
queue.push({ id: jobId, job, persist }, onJobComplete);
|
|
175
212
|
});
|
|
176
213
|
|
|
@@ -208,6 +245,7 @@ export default function createQueue<T = any>({ name, onJob, options = defaults }
|
|
|
208
245
|
|
|
209
246
|
const cancel = async (id: string) => {
|
|
210
247
|
const doc = await store.updateJob(id, { cancelled: true });
|
|
248
|
+
cancelledJobs.add(id);
|
|
211
249
|
return doc ? doc.job : null;
|
|
212
250
|
};
|
|
213
251
|
|
|
@@ -219,18 +257,26 @@ export default function createQueue<T = any>({ name, onJob, options = defaults }
|
|
|
219
257
|
const deleteJob = async (id: string): Promise<boolean> => {
|
|
220
258
|
const exists = await getJob(id);
|
|
221
259
|
|
|
222
|
-
if (exists) {
|
|
260
|
+
if (!exists) {
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
try {
|
|
223
264
|
await cancel(id);
|
|
224
265
|
await store.deleteJob(id);
|
|
266
|
+
logger.info(`Job deleted successfully: ${id}`);
|
|
225
267
|
return true;
|
|
268
|
+
} catch (error) {
|
|
269
|
+
console.error(`Error during job deletion process for ${id}:`, error);
|
|
270
|
+
return false;
|
|
226
271
|
}
|
|
227
|
-
|
|
228
|
-
return false;
|
|
229
272
|
};
|
|
230
273
|
|
|
231
274
|
// Populate the queue on startup
|
|
232
275
|
process.nextTick(async () => {
|
|
233
276
|
try {
|
|
277
|
+
if (!Job.isInitialized()) {
|
|
278
|
+
Job.initialize(sequelize);
|
|
279
|
+
}
|
|
234
280
|
const jobs = await store.getJobs();
|
|
235
281
|
logger.info(`${name} jobs to populate`, { count: jobs.length });
|
|
236
282
|
jobs.forEach((x) => {
|
|
@@ -260,6 +306,10 @@ export default function createQueue<T = any>({ name, onJob, options = defaults }
|
|
|
260
306
|
if (x.job && x.id) {
|
|
261
307
|
// fix: https://github.com/blocklet/payment-kit/issues/287
|
|
262
308
|
await cancel(x.id);
|
|
309
|
+
logger.info('reschedule delayed or scheduled job', {
|
|
310
|
+
id: x.id,
|
|
311
|
+
job: x.job,
|
|
312
|
+
});
|
|
263
313
|
push({ job: x.job, id: x.id, persist: false });
|
|
264
314
|
} else {
|
|
265
315
|
logger.info('skip invalid job from db', { job: x });
|
|
@@ -13,7 +13,11 @@ export default function createQueueStore(queue: string) {
|
|
|
13
13
|
return Job.findOne({ where: { queue, id }, transaction: null });
|
|
14
14
|
},
|
|
15
15
|
getJobs(): Promise<TJob[]> {
|
|
16
|
-
return Job.findAll({
|
|
16
|
+
return Job.findAll({
|
|
17
|
+
where: { queue, delay: -1, cancelled: false },
|
|
18
|
+
order: [['created_at', 'ASC']],
|
|
19
|
+
transaction: null,
|
|
20
|
+
});
|
|
17
21
|
},
|
|
18
22
|
getScheduledJobs(): Promise<TJob[]> {
|
|
19
23
|
return Job.findAll({
|
|
@@ -31,12 +35,31 @@ export default function createQueueStore(queue: string) {
|
|
|
31
35
|
return job.update(updates);
|
|
32
36
|
},
|
|
33
37
|
async addJob(id: string, job: any, attrs: Partial<TJob> = {}): Promise<TJob> {
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
38
|
+
const existingJob = await Job.findOne({ where: { id } });
|
|
39
|
+
|
|
40
|
+
if (existingJob) {
|
|
41
|
+
throw new CustomError(
|
|
42
|
+
'JOB_DUPLICATE',
|
|
43
|
+
`Job with id ${id} already exists ${existingJob.queue === queue ? 'in the same queue' : `in queue ${existingJob.queue}`}`
|
|
44
|
+
);
|
|
37
45
|
}
|
|
38
46
|
|
|
39
|
-
|
|
47
|
+
try {
|
|
48
|
+
const newJob = await Job.create({
|
|
49
|
+
id,
|
|
50
|
+
job,
|
|
51
|
+
queue,
|
|
52
|
+
retry_count: 1,
|
|
53
|
+
cancelled: false,
|
|
54
|
+
...attrs,
|
|
55
|
+
});
|
|
56
|
+
return newJob;
|
|
57
|
+
} catch (error) {
|
|
58
|
+
if (error.name === 'SequelizeUniqueConstraintError') {
|
|
59
|
+
throw new CustomError('JOB_DUPLICATE', `Job with id ${id} was created concurrently`);
|
|
60
|
+
}
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
40
63
|
},
|
|
41
64
|
async deleteJob(id: string): Promise<boolean> {
|
|
42
65
|
const data = await Job.destroy({ where: { queue, id } });
|
|
@@ -49,6 +49,40 @@ export function getCustomerSubscriptionPageUrl({
|
|
|
49
49
|
);
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
export function getAdminSubscriptionPageUrl({
|
|
53
|
+
subscriptionId,
|
|
54
|
+
locale = 'en',
|
|
55
|
+
userDid,
|
|
56
|
+
}: {
|
|
57
|
+
subscriptionId: string;
|
|
58
|
+
locale: LiteralUnion<'en' | 'zh', string>;
|
|
59
|
+
userDid: string;
|
|
60
|
+
}) {
|
|
61
|
+
return component.getUrl(
|
|
62
|
+
withQuery(`admin/billing/${subscriptionId}`, {
|
|
63
|
+
locale,
|
|
64
|
+
...getConnectQueryParam({ userDid }),
|
|
65
|
+
})
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function getAdminInvoicePageUrl({
|
|
70
|
+
invoiceId,
|
|
71
|
+
locale = 'en',
|
|
72
|
+
userDid,
|
|
73
|
+
}: {
|
|
74
|
+
invoiceId: string;
|
|
75
|
+
locale: LiteralUnion<'en' | 'zh', string>;
|
|
76
|
+
userDid: string;
|
|
77
|
+
}) {
|
|
78
|
+
return component.getUrl(
|
|
79
|
+
withQuery(`admin/billing/${invoiceId}`, {
|
|
80
|
+
locale,
|
|
81
|
+
...getConnectQueryParam({ userDid }),
|
|
82
|
+
})
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
52
86
|
export function parseIntegerConfig(alternatives: any[], defaultValue: number) {
|
|
53
87
|
for (const raw of alternatives) {
|
|
54
88
|
const days = parseInt(raw, 10);
|
|
@@ -473,7 +507,7 @@ export async function finalizeSubscriptionUpdate({
|
|
|
473
507
|
}: {
|
|
474
508
|
subscription: Subscription;
|
|
475
509
|
customer: Customer;
|
|
476
|
-
invoice: Invoice;
|
|
510
|
+
invoice: Invoice | null;
|
|
477
511
|
paymentCurrency: PaymentCurrency;
|
|
478
512
|
appliedCredit: string;
|
|
479
513
|
newCredit: string;
|
|
@@ -522,27 +556,31 @@ export async function finalizeSubscriptionUpdate({
|
|
|
522
556
|
// update customer credits
|
|
523
557
|
if (appliedCredit !== '0') {
|
|
524
558
|
const creditResult = await customer.decreaseTokenBalance(paymentCurrency.id, appliedCredit);
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
559
|
+
if (invoice) {
|
|
560
|
+
await invoice.update({
|
|
561
|
+
starting_token_balance: creditResult.starting,
|
|
562
|
+
ending_token_balance: creditResult.ending,
|
|
563
|
+
});
|
|
564
|
+
logger.info('customer credit applied to invoice after proration', {
|
|
565
|
+
subscription: subscription.id,
|
|
566
|
+
appliedCredit,
|
|
567
|
+
creditResult,
|
|
568
|
+
});
|
|
569
|
+
}
|
|
534
570
|
}
|
|
535
571
|
if (newCredit !== '0') {
|
|
536
572
|
const creditResult = await customer.increaseTokenBalance(paymentCurrency.id, newCredit);
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
subscription
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
573
|
+
if (invoice) {
|
|
574
|
+
await invoice.update({
|
|
575
|
+
starting_token_balance: creditResult.starting,
|
|
576
|
+
ending_token_balance: creditResult.ending,
|
|
577
|
+
});
|
|
578
|
+
logger.info('subscription proration credit applied to customer', {
|
|
579
|
+
subscription: subscription.id,
|
|
580
|
+
newCredit,
|
|
581
|
+
creditResult,
|
|
582
|
+
});
|
|
583
|
+
}
|
|
546
584
|
}
|
|
547
585
|
|
|
548
586
|
// lock for next update
|
|
@@ -866,3 +904,75 @@ export async function getSubscriptionStakeCancellation(
|
|
|
866
904
|
}
|
|
867
905
|
return cancellation;
|
|
868
906
|
}
|
|
907
|
+
|
|
908
|
+
export async function getSubscriptionStakeAmountSetup(subscription: Subscription, paymentMethod: PaymentMethod) {
|
|
909
|
+
if (paymentMethod.type !== 'arcblock') {
|
|
910
|
+
return null;
|
|
911
|
+
}
|
|
912
|
+
const txHash = subscription?.payment_details?.arcblock?.staking?.tx_hash;
|
|
913
|
+
if (!txHash) {
|
|
914
|
+
return null;
|
|
915
|
+
}
|
|
916
|
+
const client = paymentMethod.getOcapClient();
|
|
917
|
+
const { info } = await client.getTx({ hash: txHash });
|
|
918
|
+
if (!info) {
|
|
919
|
+
return null;
|
|
920
|
+
}
|
|
921
|
+
// @ts-ignore
|
|
922
|
+
const inputs = info?.tx?.itxJson?.inputs || [];
|
|
923
|
+
if (!inputs || inputs.length === 0) {
|
|
924
|
+
logger.info('getSubscriptionStakeAmountSetup failed, no inputs', { txHash, info });
|
|
925
|
+
return null;
|
|
926
|
+
}
|
|
927
|
+
const amountRes: { [key: string]: BN } = {};
|
|
928
|
+
// calculate stake amount for each address
|
|
929
|
+
inputs.forEach((input: any) => {
|
|
930
|
+
const { tokens } = input;
|
|
931
|
+
tokens.forEach((token: any) => {
|
|
932
|
+
const { address, value } = token;
|
|
933
|
+
if (amountRes[address]) {
|
|
934
|
+
amountRes[address] = amountRes[address].add(new BN(value));
|
|
935
|
+
} else {
|
|
936
|
+
amountRes[address] = new BN(value);
|
|
937
|
+
}
|
|
938
|
+
});
|
|
939
|
+
});
|
|
940
|
+
Object.keys(amountRes).forEach((address) => {
|
|
941
|
+
amountRes[address] = amountRes[address].toString();
|
|
942
|
+
});
|
|
943
|
+
logger.info('get subscription stake amount setup success', { txHash, amountRes });
|
|
944
|
+
return amountRes;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// check if usage report is empty
|
|
948
|
+
export async function checkUsageReportEmpty(
|
|
949
|
+
subscription: Subscription,
|
|
950
|
+
usageReportStart: number,
|
|
951
|
+
usageReportEnd: number
|
|
952
|
+
) {
|
|
953
|
+
const subscriptionItems = await SubscriptionItem.findAll({ where: { subscription_id: subscription.id } });
|
|
954
|
+
const expandedItems = await Price.expand(
|
|
955
|
+
subscriptionItems.map((x) => ({ id: x.id, price_id: x.price_id, quantity: x.quantity })),
|
|
956
|
+
{ product: true }
|
|
957
|
+
);
|
|
958
|
+
const meteredItems = expandedItems.filter((x: any) => x?.price?.recurring?.usage_type === 'metered');
|
|
959
|
+
if (meteredItems.length === 0) {
|
|
960
|
+
return false;
|
|
961
|
+
}
|
|
962
|
+
const usageReportEmpty = await Promise.all(
|
|
963
|
+
meteredItems.map(async (x: any) => {
|
|
964
|
+
const usageRecords = await UsageRecord.findAll({
|
|
965
|
+
where: {
|
|
966
|
+
subscription_item_id: x.id,
|
|
967
|
+
billed: false,
|
|
968
|
+
timestamp: {
|
|
969
|
+
[Op.gt]: usageReportStart,
|
|
970
|
+
[Op.lte]: usageReportEnd,
|
|
971
|
+
},
|
|
972
|
+
},
|
|
973
|
+
});
|
|
974
|
+
return usageRecords.length === 0;
|
|
975
|
+
})
|
|
976
|
+
);
|
|
977
|
+
return usageReportEmpty.every(Boolean);
|
|
978
|
+
}
|
package/api/src/libs/util.ts
CHANGED
|
@@ -10,6 +10,8 @@ import { joinURL, withQuery } from 'ufo';
|
|
|
10
10
|
|
|
11
11
|
import dayjs from './dayjs';
|
|
12
12
|
import { blocklet, wallet } from './auth';
|
|
13
|
+
import type { Subscription } from '../store/models';
|
|
14
|
+
import logger from './logger';
|
|
13
15
|
|
|
14
16
|
export const OCAP_PAYMENT_TX_TYPE = 'fg:t:transfer_v2';
|
|
15
17
|
|
|
@@ -289,3 +291,31 @@ export function getCustomerProfileUrl({
|
|
|
289
291
|
})
|
|
290
292
|
);
|
|
291
293
|
}
|
|
294
|
+
|
|
295
|
+
export async function getOwnerDid() {
|
|
296
|
+
try {
|
|
297
|
+
const { user } = await blocklet.getOwner();
|
|
298
|
+
return user?.did;
|
|
299
|
+
} catch (error) {
|
|
300
|
+
logger.error('getOwnerDid error', error);
|
|
301
|
+
return undefined;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function getSubscriptionNotificationCustomActions(
|
|
306
|
+
subscription: Subscription,
|
|
307
|
+
eventType: string,
|
|
308
|
+
locale: string
|
|
309
|
+
) {
|
|
310
|
+
if (!subscription || !subscription?.service_actions || !subscription?.service_actions?.length) {
|
|
311
|
+
return [];
|
|
312
|
+
}
|
|
313
|
+
const actions = subscription.service_actions?.filter(
|
|
314
|
+
(x: any) => x?.type === 'notification' && x?.triggerEvents?.includes(eventType)
|
|
315
|
+
);
|
|
316
|
+
return actions?.map((x: any) => ({
|
|
317
|
+
name: x?.name || x?.text?.[locale],
|
|
318
|
+
title: x?.text?.[locale],
|
|
319
|
+
link: x?.link,
|
|
320
|
+
}));
|
|
321
|
+
}
|
package/api/src/locales/en.ts
CHANGED
|
@@ -45,6 +45,14 @@ export default flat({
|
|
|
45
45
|
qty: '{count} unit',
|
|
46
46
|
failReason: 'Failure reason',
|
|
47
47
|
balanceReminder: 'Balance reminder',
|
|
48
|
+
subscriptionId: 'Subscription ID',
|
|
49
|
+
shouldPayAmount: 'Should pay amount',
|
|
50
|
+
billedAmount: 'Billed amount',
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
billingDiscrepancy: {
|
|
54
|
+
title: '{productName} billing discrepancy',
|
|
55
|
+
body: 'Detected billing discrepancy for {productName}, please check.',
|
|
48
56
|
},
|
|
49
57
|
|
|
50
58
|
sendTo: 'Sent to',
|
|
@@ -53,6 +61,11 @@ export default flat({
|
|
|
53
61
|
message: 'A new {collection} NFT is minted and sent to your wallet, please check it out.',
|
|
54
62
|
},
|
|
55
63
|
|
|
64
|
+
usageReportEmpty: {
|
|
65
|
+
title: 'No usage report for {productName}',
|
|
66
|
+
body: 'No usage report for {productName} detected, please check.',
|
|
67
|
+
},
|
|
68
|
+
|
|
56
69
|
subscriptionTrialStart: {
|
|
57
70
|
title: 'Welcome to the start of your {productName} trial',
|
|
58
71
|
body: 'Congratulations on your {productName} trial! The length of the trial is {trialDuration} and will end at {subscriptionTrialEnd}. Have fun with {productName}!',
|
package/api/src/locales/zh.ts
CHANGED
|
@@ -45,6 +45,9 @@ export default flat({
|
|
|
45
45
|
qty: '{count} 件',
|
|
46
46
|
failReason: '失败原因',
|
|
47
47
|
balanceReminder: '余额提醒',
|
|
48
|
+
subscriptionId: '订阅 ID',
|
|
49
|
+
shouldPayAmount: '应收金额',
|
|
50
|
+
billedAmount: '实缴金额',
|
|
48
51
|
},
|
|
49
52
|
|
|
50
53
|
sendTo: '发送给',
|
|
@@ -53,6 +56,16 @@ export default flat({
|
|
|
53
56
|
message: '{collection} NFT 已经铸造完成并发送到你的钱包,请查收',
|
|
54
57
|
},
|
|
55
58
|
|
|
59
|
+
usageReportEmpty: {
|
|
60
|
+
title: '{productName} 未上报用量',
|
|
61
|
+
body: '检测到 {productName} 未上报用量,请留意。',
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
billingDiscrepancy: {
|
|
65
|
+
title: '{productName} 账单金额核算不一致',
|
|
66
|
+
body: '检测到 {productName} 账单金额核算不一致,请留意。',
|
|
67
|
+
},
|
|
68
|
+
|
|
56
69
|
subscriptionTrialStart: {
|
|
57
70
|
title: '欢迎开始您的 {productName} 试用之旅',
|
|
58
71
|
body: '恭喜您获得了 {productName} 的试用资格!试用期时长为 {trialDuration},将于 {subscriptionTrialEnd} 结束。祝您使用愉快!',
|
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
import { Op } from 'sequelize';
|
|
2
2
|
|
|
3
|
+
import { getInvoiceShouldPayTotal } from '@api/libs/invoice';
|
|
3
4
|
import { batchHandleStripeInvoices } from '../integrations/stripe/resource';
|
|
4
5
|
import { createEvent } from '../libs/audit';
|
|
5
6
|
import dayjs from '../libs/dayjs';
|
|
6
7
|
import logger from '../libs/logger';
|
|
7
8
|
import createQueue from '../libs/queue';
|
|
8
|
-
import { PaymentMethod } from '../store/models';
|
|
9
|
+
import { PaymentMethod, Invoice } from '../store/models';
|
|
9
10
|
import { CheckoutSession } from '../store/models/checkout-session';
|
|
10
|
-
import { Invoice } from '../store/models/invoice';
|
|
11
11
|
import { PaymentIntent } from '../store/models/payment-intent';
|
|
12
12
|
import { Subscription } from '../store/models/subscription';
|
|
13
13
|
import { paymentQueue } from './payment';
|
|
14
14
|
|
|
15
|
+
import { getLock } from '../libs/lock';
|
|
16
|
+
import { events } from '../libs/event';
|
|
17
|
+
|
|
15
18
|
type InvoiceJob = {
|
|
16
19
|
invoiceId: string;
|
|
17
20
|
justCreate?: boolean;
|
|
@@ -195,28 +198,63 @@ export const invoiceQueue = createQueue<InvoiceJob>({
|
|
|
195
198
|
});
|
|
196
199
|
|
|
197
200
|
export const startInvoiceQueue = async () => {
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
201
|
+
const lock = getLock('startInvoiceQueue');
|
|
202
|
+
if (lock.locked) {
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
await lock.acquire();
|
|
208
|
+
const invoices = await Invoice.findAll({
|
|
209
|
+
where: {
|
|
210
|
+
status: 'open',
|
|
211
|
+
collection_method: 'charge_automatically',
|
|
212
|
+
amount_remaining: { [Op.gt]: '0' },
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const results = await Promise.allSettled(
|
|
217
|
+
invoices.map(async (x) => {
|
|
218
|
+
const supportAutoCharge = await PaymentMethod.supportAutoCharge(x.default_payment_method_id);
|
|
219
|
+
if (supportAutoCharge === false) {
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
const exist = await invoiceQueue.get(x.id);
|
|
223
|
+
if (!exist) {
|
|
224
|
+
await invoiceQueue.push({ id: x.id, job: { invoiceId: x.id, retryOnError: true } });
|
|
225
|
+
}
|
|
226
|
+
})
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
const failed = results.filter((r) => r.status === 'rejected').length;
|
|
230
|
+
if (failed > 0) {
|
|
231
|
+
logger.warn(`Failed to process ${failed} invoices in startInvoiceQueue`);
|
|
214
232
|
}
|
|
215
|
-
});
|
|
216
233
|
|
|
217
|
-
|
|
234
|
+
await batchHandleStripeInvoices();
|
|
235
|
+
} catch (error) {
|
|
236
|
+
logger.error('Error in startInvoiceQueue:', error);
|
|
237
|
+
} finally {
|
|
238
|
+
lock.release();
|
|
239
|
+
}
|
|
218
240
|
};
|
|
219
241
|
|
|
220
242
|
invoiceQueue.on('failed', ({ id, job, error }) => {
|
|
221
243
|
logger.error('Invoice job failed', { id, job, error });
|
|
222
244
|
});
|
|
245
|
+
|
|
246
|
+
events.on('invoice.paid', async ({ id: invoiceId }) => {
|
|
247
|
+
const invoice = await Invoice.findByPk(invoiceId);
|
|
248
|
+
if (!invoice) {
|
|
249
|
+
logger.error('Invoice not found', { invoiceId });
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
const checkBillingReason = ['subscription_cycle', 'subscription_cancel'];
|
|
253
|
+
if (checkBillingReason.includes(invoice.billing_reason)) {
|
|
254
|
+
const shouldPayTotal = await getInvoiceShouldPayTotal(invoice);
|
|
255
|
+
if (shouldPayTotal !== invoice.total) {
|
|
256
|
+
createEvent('Invoice', 'billing.discrepancy', invoice);
|
|
257
|
+
logger.info('create billing discrepancy event', { invoiceId, shouldPayTotal, invoiceTotal: invoice.total });
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
});
|
|
@@ -57,6 +57,14 @@ import {
|
|
|
57
57
|
} from '../libs/notification/template/subscription-stake-slash-succeeded';
|
|
58
58
|
import createQueue from '../libs/queue';
|
|
59
59
|
import { CheckoutSession, EventType, Invoice, PaymentLink, Refund, Subscription } from '../store/models';
|
|
60
|
+
import {
|
|
61
|
+
UsageReportEmptyEmailTemplate,
|
|
62
|
+
UsageReportEmptyEmailTemplateOptions,
|
|
63
|
+
} from '../libs/notification/template/usage-report-empty';
|
|
64
|
+
import {
|
|
65
|
+
BillingDiscrepancyEmailTemplate,
|
|
66
|
+
BillingDiscrepancyEmailTemplateOptions,
|
|
67
|
+
} from '../libs/notification/template/billing-discrepancy';
|
|
60
68
|
|
|
61
69
|
export type NotificationQueueJobOptions = any;
|
|
62
70
|
|
|
@@ -65,7 +73,9 @@ export type NotificationQueueJobType =
|
|
|
65
73
|
| 'customer.subscription.will_renew'
|
|
66
74
|
| 'customer.subscription.trial_will_end'
|
|
67
75
|
| 'customer.subscription.will_canceled'
|
|
68
|
-
| 'customer.reward.succeeded'
|
|
76
|
+
| 'customer.reward.succeeded'
|
|
77
|
+
| 'usage.report.empty'
|
|
78
|
+
| 'billing.discrepancy';
|
|
69
79
|
|
|
70
80
|
export type NotificationQueueJob = {
|
|
71
81
|
type: NotificationQueueJobType;
|
|
@@ -73,6 +83,12 @@ export type NotificationQueueJob = {
|
|
|
73
83
|
};
|
|
74
84
|
|
|
75
85
|
function getNotificationTemplate(job: NotificationQueueJob): BaseEmailTemplate {
|
|
86
|
+
if (job.type === 'usage.report.empty') {
|
|
87
|
+
return new UsageReportEmptyEmailTemplate(job.options as UsageReportEmptyEmailTemplateOptions);
|
|
88
|
+
}
|
|
89
|
+
if (job.type === 'billing.discrepancy') {
|
|
90
|
+
return new BillingDiscrepancyEmailTemplate(job.options as BillingDiscrepancyEmailTemplateOptions);
|
|
91
|
+
}
|
|
76
92
|
if (job.type === 'customer.subscription.started') {
|
|
77
93
|
return new SubscriptionSucceededEmailTemplate(job.options as SubscriptionSucceededEmailTemplateOptions);
|
|
78
94
|
}
|
|
@@ -275,4 +291,30 @@ export async function startNotificationQueue() {
|
|
|
275
291
|
},
|
|
276
292
|
});
|
|
277
293
|
});
|
|
294
|
+
|
|
295
|
+
events.on('usage.report.empty', (subscription: Subscription, { usageReportStart, usageReportEnd }) => {
|
|
296
|
+
notificationQueue.push({
|
|
297
|
+
id: `usage.report.empty.${subscription.id}`,
|
|
298
|
+
job: {
|
|
299
|
+
type: 'usage.report.empty',
|
|
300
|
+
options: {
|
|
301
|
+
subscriptionId: subscription.id,
|
|
302
|
+
usageReportStart,
|
|
303
|
+
usageReportEnd,
|
|
304
|
+
},
|
|
305
|
+
},
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
events.on('billing.discrepancy', (invoice: Invoice) => {
|
|
310
|
+
notificationQueue.push({
|
|
311
|
+
id: `billing.discrepancy.${invoice.id}`,
|
|
312
|
+
job: {
|
|
313
|
+
type: 'billing.discrepancy',
|
|
314
|
+
options: {
|
|
315
|
+
invoiceId: invoice.id,
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
});
|
|
319
|
+
});
|
|
278
320
|
}
|
|
@@ -210,7 +210,7 @@ export const handlePaymentSucceed = async (
|
|
|
210
210
|
}
|
|
211
211
|
}
|
|
212
212
|
|
|
213
|
-
if (triggerRenew) {
|
|
213
|
+
if (triggerRenew && invoice.billing_reason !== 'subscription_update') {
|
|
214
214
|
if (invoice.billing_reason === 'subscription_cycle' || paymentIntent.capture_method === 'manual') {
|
|
215
215
|
createEvent('Subscription', 'customer.subscription.renewed', subscription).catch(console.error);
|
|
216
216
|
}
|
|
@@ -323,6 +323,10 @@ export const handlePaymentFailed = async (
|
|
|
323
323
|
return updates.terminate;
|
|
324
324
|
}
|
|
325
325
|
|
|
326
|
+
if (subscription.isImmutable()) {
|
|
327
|
+
logger.info('Subscription is immutable, no need to check due', { subscription: subscription.id });
|
|
328
|
+
return updates.terminate;
|
|
329
|
+
}
|
|
326
330
|
// check days until cancel
|
|
327
331
|
const dueUnit = getDueUnit(interval);
|
|
328
332
|
const daysUntilCancel = getDaysUntilCancel(subscription);
|