payment-kit 1.15.15 → 1.15.17
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/resource.ts +2 -2
- package/api/src/libs/notification/template/subscription-renewed.ts +1 -1
- package/api/src/libs/notification/template/subscription-trial-will-end.ts +6 -0
- package/api/src/libs/notification/template/subscription-upgraded.ts +40 -25
- package/api/src/libs/notification/template/subscription-will-canceled.ts +6 -0
- package/api/src/libs/notification/template/subscription-will-renew.ts +5 -2
- package/api/src/libs/queue/index.ts +69 -19
- package/api/src/libs/queue/store.ts +28 -5
- package/api/src/libs/subscription.ts +62 -19
- package/api/src/queues/invoice.ts +40 -20
- package/api/src/queues/payment.ts +5 -1
- package/api/src/queues/subscription.ts +43 -13
- package/api/src/routes/invoices.ts +11 -31
- package/api/src/routes/subscriptions.ts +39 -5
- package/api/src/store/models/job.ts +4 -0
- package/api/tests/libs/subscription.spec.ts +97 -0
- package/blocklet.yml +1 -1
- package/package.json +10 -10
- package/src/pages/customer/subscription/change-plan.tsx +1 -1
- package/vite-server.config.ts +8 -0
|
@@ -44,7 +44,7 @@ export async function ensureStripeProduct(internal: Product, method: PaymentMeth
|
|
|
44
44
|
attrs.unit_label = internal.unit_label;
|
|
45
45
|
}
|
|
46
46
|
if (internal.statement_descriptor) {
|
|
47
|
-
attrs.
|
|
47
|
+
attrs.statement_descriptor = '';
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
const product = await client.products.create(attrs);
|
|
@@ -176,7 +176,7 @@ export async function ensureStripePaymentIntent(
|
|
|
176
176
|
enabled: true,
|
|
177
177
|
allow_redirects: 'never',
|
|
178
178
|
},
|
|
179
|
-
|
|
179
|
+
statement_descriptor: '',
|
|
180
180
|
metadata: {
|
|
181
181
|
appPid: env.appPid,
|
|
182
182
|
id: internal.id,
|
|
@@ -93,7 +93,7 @@ export class SubscriptionRenewedEmailTemplate implements BaseEmailTemplate<Subsc
|
|
|
93
93
|
const userDid: string = customer.did;
|
|
94
94
|
const locale = await getUserLocale(userDid);
|
|
95
95
|
const productName = await getMainProductName(subscription.id);
|
|
96
|
-
const at: string = formatTime(Date.now());
|
|
96
|
+
const at: string = formatTime((invoice?.status_transitions?.paid_at ?? Math.floor(Date.now() / 1000)) * 1000);
|
|
97
97
|
|
|
98
98
|
const hasNft: boolean = checkoutSession?.nft_mint_status === 'minted';
|
|
99
99
|
const nftMintItem: NftMintItem | undefined = hasNft
|
|
@@ -13,6 +13,7 @@ import { getMainProductName } from '../../product';
|
|
|
13
13
|
import { getCustomerSubscriptionPageUrl } from '../../subscription';
|
|
14
14
|
import { formatTime, getPrettyMsI18nLocale } from '../../time';
|
|
15
15
|
import type { BaseEmailTemplate, BaseEmailTemplateType } from './base';
|
|
16
|
+
import dayjs from '../../dayjs';
|
|
16
17
|
|
|
17
18
|
export interface SubscriptionTrialWillEndEmailTemplateOptions {
|
|
18
19
|
subscriptionId: string;
|
|
@@ -164,6 +165,11 @@ export class SubscriptionTrialWilEndEmailTemplate
|
|
|
164
165
|
viewSubscriptionLink,
|
|
165
166
|
} = await this.getContext();
|
|
166
167
|
|
|
168
|
+
// 如果当前时间大于试用结束时间,那么不发送通知
|
|
169
|
+
if (dayjs().utc().isAfter(dayjs.utc(at))) {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
|
|
167
173
|
const canPay: boolean = paymentDetail.balance >= paymentDetail.price;
|
|
168
174
|
if (canPay && !this.options.required) {
|
|
169
175
|
// 当余额足够支付并且本封邮件不是必须发送时,可以不发送邮件
|
|
@@ -40,6 +40,7 @@ interface SubscriptionUpgradedEmailTemplateContext {
|
|
|
40
40
|
viewSubscriptionLink: string;
|
|
41
41
|
viewInvoiceLink: string;
|
|
42
42
|
viewTxHashLink: string | undefined;
|
|
43
|
+
skipInvoice: boolean;
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
export class SubscriptionUpgradedEmailTemplate implements BaseEmailTemplate<SubscriptionUpgradedEmailTemplateContext> {
|
|
@@ -54,7 +55,7 @@ export class SubscriptionUpgradedEmailTemplate implements BaseEmailTemplate<Subs
|
|
|
54
55
|
if (!subscription) {
|
|
55
56
|
throw new Error(`Subscription not found: ${this.options.subscriptionId}`);
|
|
56
57
|
}
|
|
57
|
-
if (subscription.
|
|
58
|
+
if (!subscription.isActive()) {
|
|
58
59
|
throw new Error(`Subscription not active: ${this.options.subscriptionId}`);
|
|
59
60
|
}
|
|
60
61
|
|
|
@@ -63,7 +64,10 @@ export class SubscriptionUpgradedEmailTemplate implements BaseEmailTemplate<Subs
|
|
|
63
64
|
throw new Error(`Customer not found: ${subscription.customer_id}`);
|
|
64
65
|
}
|
|
65
66
|
|
|
66
|
-
|
|
67
|
+
// invoice应该是最新的subscription_update invoice
|
|
68
|
+
const invoiceId = subscription.pending_update?.updates?.latest_invoice_id || subscription.latest_invoice_id;
|
|
69
|
+
const invoice = (await Invoice.findByPk(invoiceId)) as Invoice;
|
|
70
|
+
const skipInvoice = subscription.status === 'trialing' || invoice.billing_reason !== 'subscription_update';
|
|
67
71
|
const paymentIntent = await PaymentIntent.findByPk(invoice.payment_intent_id);
|
|
68
72
|
const paymentCurrency = (await PaymentCurrency.findOne({
|
|
69
73
|
where: {
|
|
@@ -90,8 +94,12 @@ export class SubscriptionUpgradedEmailTemplate implements BaseEmailTemplate<Subs
|
|
|
90
94
|
const nftMintItem: NftMintItem | undefined = hasNft
|
|
91
95
|
? checkoutSession?.nft_mint_details?.[checkoutSession?.nft_mint_details?.type as 'arcblock' | 'ethereum']
|
|
92
96
|
: undefined;
|
|
93
|
-
const currentPeriodStart: string = formatTime(
|
|
94
|
-
|
|
97
|
+
const currentPeriodStart: string = formatTime(
|
|
98
|
+
skipInvoice ? subscription.current_period_start * 1000 : invoice.period_start * 1000
|
|
99
|
+
);
|
|
100
|
+
const currentPeriodEnd: string = formatTime(
|
|
101
|
+
skipInvoice ? subscription.current_period_end * 1000 : invoice.period_end * 1000
|
|
102
|
+
);
|
|
95
103
|
const duration: string = prettyMsI18n(
|
|
96
104
|
new Date(currentPeriodEnd).getTime() - new Date(currentPeriodStart).getTime(),
|
|
97
105
|
{
|
|
@@ -139,6 +147,7 @@ export class SubscriptionUpgradedEmailTemplate implements BaseEmailTemplate<Subs
|
|
|
139
147
|
viewSubscriptionLink,
|
|
140
148
|
viewInvoiceLink,
|
|
141
149
|
viewTxHashLink,
|
|
150
|
+
skipInvoice,
|
|
142
151
|
};
|
|
143
152
|
}
|
|
144
153
|
|
|
@@ -157,6 +166,7 @@ export class SubscriptionUpgradedEmailTemplate implements BaseEmailTemplate<Subs
|
|
|
157
166
|
viewSubscriptionLink,
|
|
158
167
|
viewInvoiceLink,
|
|
159
168
|
viewTxHashLink,
|
|
169
|
+
skipInvoice,
|
|
160
170
|
} = await this.getContext();
|
|
161
171
|
|
|
162
172
|
const template: BaseEmailTemplateType = {
|
|
@@ -210,21 +220,25 @@ export class SubscriptionUpgradedEmailTemplate implements BaseEmailTemplate<Subs
|
|
|
210
220
|
text: productName,
|
|
211
221
|
},
|
|
212
222
|
},
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
223
|
+
...(skipInvoice
|
|
224
|
+
? []
|
|
225
|
+
: [
|
|
226
|
+
{
|
|
227
|
+
type: 'text',
|
|
228
|
+
data: {
|
|
229
|
+
type: 'plain',
|
|
230
|
+
color: '#9397A1',
|
|
231
|
+
text: translate('notification.common.paymentAmount', locale),
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
type: 'text',
|
|
236
|
+
data: {
|
|
237
|
+
type: 'plain',
|
|
238
|
+
text: paymentInfo,
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
]),
|
|
228
242
|
{
|
|
229
243
|
type: 'text',
|
|
230
244
|
data: {
|
|
@@ -250,16 +264,17 @@ export class SubscriptionUpgradedEmailTemplate implements BaseEmailTemplate<Subs
|
|
|
250
264
|
title: translate('notification.common.viewSubscription', locale),
|
|
251
265
|
link: viewSubscriptionLink,
|
|
252
266
|
},
|
|
253
|
-
{
|
|
267
|
+
!skipInvoice && {
|
|
254
268
|
name: translate('notification.common.viewInvoice', locale),
|
|
255
269
|
title: translate('notification.common.viewInvoice', locale),
|
|
256
270
|
link: viewInvoiceLink,
|
|
257
271
|
},
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
272
|
+
!skipInvoice &&
|
|
273
|
+
viewTxHashLink && {
|
|
274
|
+
name: translate('notification.common.viewTxHash', locale),
|
|
275
|
+
title: translate('notification.common.viewTxHash', locale),
|
|
276
|
+
link: viewTxHashLink,
|
|
277
|
+
},
|
|
263
278
|
].filter(Boolean),
|
|
264
279
|
};
|
|
265
280
|
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { fromUnitToToken } from '@ocap/util';
|
|
4
4
|
import type { ManipulateType } from 'dayjs';
|
|
5
5
|
|
|
6
|
+
import dayjs from '../../dayjs';
|
|
6
7
|
import { getUserLocale } from '../../../integrations/blocklet/notification';
|
|
7
8
|
import { translate } from '../../../locales';
|
|
8
9
|
import { Customer, Invoice, Subscription } from '../../../store/models';
|
|
@@ -143,6 +144,11 @@ export class SubscriptionWillCanceledEmailTemplate
|
|
|
143
144
|
viewInvoiceLink,
|
|
144
145
|
} = await this.getContext();
|
|
145
146
|
|
|
147
|
+
// 如果当前时间大于订阅终止时间,那么不发送通知
|
|
148
|
+
if (dayjs().utc().isAfter(dayjs.utc(at))) {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
|
|
146
152
|
const template: BaseEmailTemplateType = {
|
|
147
153
|
title: `${translate('notification.subscriptWillCanceled.title', locale, {
|
|
148
154
|
productName,
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/* eslint-disable @typescript-eslint/brace-style */
|
|
2
2
|
/* eslint-disable @typescript-eslint/indent */
|
|
3
3
|
import type { ManipulateType } from 'dayjs';
|
|
4
|
-
import dayjs from 'dayjs';
|
|
5
4
|
import prettyMsI18n from 'pretty-ms-i18n';
|
|
6
5
|
import type { LiteralUnion } from 'type-fest';
|
|
7
6
|
|
|
8
7
|
import { fromUnitToToken } from '@ocap/util';
|
|
8
|
+
import dayjs from '../../dayjs';
|
|
9
9
|
import { getTokenSummaryByDid } from '../../../integrations/arcblock/stake';
|
|
10
10
|
import { getUserLocale } from '../../../integrations/blocklet/notification';
|
|
11
11
|
import { translate } from '../../../locales';
|
|
@@ -251,6 +251,10 @@ export class SubscriptionWillRenewEmailTemplate
|
|
|
251
251
|
paymentMethod,
|
|
252
252
|
} = await this.getContext();
|
|
253
253
|
|
|
254
|
+
// 如果当前时间大于预计扣费时间,那么不发送通知
|
|
255
|
+
if (dayjs().utc().isAfter(dayjs.utc(at))) {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
254
258
|
const canPay: boolean = paymentDetail.balance >= paymentDetail.price;
|
|
255
259
|
if (canPay) {
|
|
256
260
|
// 当余额足够支付并且本封邮件不是必须发送时,可以不发送邮件
|
|
@@ -260,7 +264,6 @@ export class SubscriptionWillRenewEmailTemplate
|
|
|
260
264
|
// 如果预估的价格是 0 并且货币不是 USD,那么直接不发送
|
|
261
265
|
return null;
|
|
262
266
|
}
|
|
263
|
-
|
|
264
267
|
const isStripe = paymentMethod?.type === 'stripe';
|
|
265
268
|
const template: BaseEmailTemplateType = {
|
|
266
269
|
title: `${translate('notification.subscriptionWillRenew.title', locale, {
|
|
@@ -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 } });
|
|
@@ -473,7 +473,7 @@ export async function finalizeSubscriptionUpdate({
|
|
|
473
473
|
}: {
|
|
474
474
|
subscription: Subscription;
|
|
475
475
|
customer: Customer;
|
|
476
|
-
invoice: Invoice;
|
|
476
|
+
invoice: Invoice | null;
|
|
477
477
|
paymentCurrency: PaymentCurrency;
|
|
478
478
|
appliedCredit: string;
|
|
479
479
|
newCredit: string;
|
|
@@ -522,27 +522,31 @@ export async function finalizeSubscriptionUpdate({
|
|
|
522
522
|
// update customer credits
|
|
523
523
|
if (appliedCredit !== '0') {
|
|
524
524
|
const creditResult = await customer.decreaseTokenBalance(paymentCurrency.id, appliedCredit);
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
525
|
+
if (invoice) {
|
|
526
|
+
await invoice.update({
|
|
527
|
+
starting_token_balance: creditResult.starting,
|
|
528
|
+
ending_token_balance: creditResult.ending,
|
|
529
|
+
});
|
|
530
|
+
logger.info('customer credit applied to invoice after proration', {
|
|
531
|
+
subscription: subscription.id,
|
|
532
|
+
appliedCredit,
|
|
533
|
+
creditResult,
|
|
534
|
+
});
|
|
535
|
+
}
|
|
534
536
|
}
|
|
535
537
|
if (newCredit !== '0') {
|
|
536
538
|
const creditResult = await customer.increaseTokenBalance(paymentCurrency.id, newCredit);
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
subscription
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
539
|
+
if (invoice) {
|
|
540
|
+
await invoice.update({
|
|
541
|
+
starting_token_balance: creditResult.starting,
|
|
542
|
+
ending_token_balance: creditResult.ending,
|
|
543
|
+
});
|
|
544
|
+
logger.info('subscription proration credit applied to customer', {
|
|
545
|
+
subscription: subscription.id,
|
|
546
|
+
newCredit,
|
|
547
|
+
creditResult,
|
|
548
|
+
});
|
|
549
|
+
}
|
|
546
550
|
}
|
|
547
551
|
|
|
548
552
|
// lock for next update
|
|
@@ -866,3 +870,42 @@ export async function getSubscriptionStakeCancellation(
|
|
|
866
870
|
}
|
|
867
871
|
return cancellation;
|
|
868
872
|
}
|
|
873
|
+
|
|
874
|
+
export async function getSubscriptionStakeAmountSetup(subscription: Subscription, paymentMethod: PaymentMethod) {
|
|
875
|
+
if (paymentMethod.type !== 'arcblock') {
|
|
876
|
+
return null;
|
|
877
|
+
}
|
|
878
|
+
const txHash = subscription?.payment_details?.arcblock?.staking?.tx_hash;
|
|
879
|
+
if (!txHash) {
|
|
880
|
+
return null;
|
|
881
|
+
}
|
|
882
|
+
const client = paymentMethod.getOcapClient();
|
|
883
|
+
const { info } = await client.getTx({ hash: txHash });
|
|
884
|
+
if (!info) {
|
|
885
|
+
return null;
|
|
886
|
+
}
|
|
887
|
+
// @ts-ignore
|
|
888
|
+
const inputs = info?.tx?.itxJson?.inputs || [];
|
|
889
|
+
if (!inputs || inputs.length === 0) {
|
|
890
|
+
logger.info('getSubscriptionStakeAmountSetup failed, no inputs', { txHash, info });
|
|
891
|
+
return null;
|
|
892
|
+
}
|
|
893
|
+
const amountRes: { [key: string]: BN } = {};
|
|
894
|
+
// calculate stake amount for each address
|
|
895
|
+
inputs.forEach((input: any) => {
|
|
896
|
+
const { tokens } = input;
|
|
897
|
+
tokens.forEach((token: any) => {
|
|
898
|
+
const { address, value } = token;
|
|
899
|
+
if (amountRes[address]) {
|
|
900
|
+
amountRes[address] = amountRes[address].add(new BN(value));
|
|
901
|
+
} else {
|
|
902
|
+
amountRes[address] = new BN(value);
|
|
903
|
+
}
|
|
904
|
+
});
|
|
905
|
+
});
|
|
906
|
+
Object.keys(amountRes).forEach((address) => {
|
|
907
|
+
amountRes[address] = amountRes[address].toString();
|
|
908
|
+
});
|
|
909
|
+
logger.info('get subscription stake amount setup success', { txHash, amountRes });
|
|
910
|
+
return amountRes;
|
|
911
|
+
}
|
|
@@ -5,13 +5,14 @@ import { createEvent } from '../libs/audit';
|
|
|
5
5
|
import dayjs from '../libs/dayjs';
|
|
6
6
|
import logger from '../libs/logger';
|
|
7
7
|
import createQueue from '../libs/queue';
|
|
8
|
-
import { PaymentMethod } from '../store/models';
|
|
8
|
+
import { PaymentMethod, Invoice } from '../store/models';
|
|
9
9
|
import { CheckoutSession } from '../store/models/checkout-session';
|
|
10
|
-
import { Invoice } from '../store/models/invoice';
|
|
11
10
|
import { PaymentIntent } from '../store/models/payment-intent';
|
|
12
11
|
import { Subscription } from '../store/models/subscription';
|
|
13
12
|
import { paymentQueue } from './payment';
|
|
14
13
|
|
|
14
|
+
import { getLock } from '../libs/lock';
|
|
15
|
+
|
|
15
16
|
type InvoiceJob = {
|
|
16
17
|
invoiceId: string;
|
|
17
18
|
justCreate?: boolean;
|
|
@@ -195,26 +196,45 @@ export const invoiceQueue = createQueue<InvoiceJob>({
|
|
|
195
196
|
});
|
|
196
197
|
|
|
197
198
|
export const startInvoiceQueue = async () => {
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
199
|
+
const lock = getLock('startInvoiceQueue');
|
|
200
|
+
if (lock.locked) {
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
await lock.acquire();
|
|
206
|
+
const invoices = await Invoice.findAll({
|
|
207
|
+
where: {
|
|
208
|
+
status: 'open',
|
|
209
|
+
collection_method: 'charge_automatically',
|
|
210
|
+
amount_remaining: { [Op.gt]: '0' },
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const results = await Promise.allSettled(
|
|
215
|
+
invoices.map(async (x) => {
|
|
216
|
+
const supportAutoCharge = await PaymentMethod.supportAutoCharge(x.default_payment_method_id);
|
|
217
|
+
if (supportAutoCharge === false) {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
const exist = await invoiceQueue.get(x.id);
|
|
221
|
+
if (!exist) {
|
|
222
|
+
await invoiceQueue.push({ id: x.id, job: { invoiceId: x.id, retryOnError: true } });
|
|
223
|
+
}
|
|
224
|
+
})
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
const failed = results.filter((r) => r.status === 'rejected').length;
|
|
228
|
+
if (failed > 0) {
|
|
229
|
+
logger.warn(`Failed to process ${failed} invoices in startInvoiceQueue`);
|
|
214
230
|
}
|
|
215
|
-
});
|
|
216
231
|
|
|
217
|
-
|
|
232
|
+
await batchHandleStripeInvoices();
|
|
233
|
+
} catch (error) {
|
|
234
|
+
logger.error('Error in startInvoiceQueue:', error);
|
|
235
|
+
} finally {
|
|
236
|
+
lock.release();
|
|
237
|
+
}
|
|
218
238
|
};
|
|
219
239
|
|
|
220
240
|
invoiceQueue.on('failed', ({ id, job, error }) => {
|
|
@@ -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);
|
|
@@ -177,6 +177,8 @@ const doHandleSubscriptionInvoice = async ({
|
|
|
177
177
|
} as Invoice,
|
|
178
178
|
});
|
|
179
179
|
|
|
180
|
+
logger.info('Invoice created for subscription', { invoice: invoice.id, subscription: subscription.id });
|
|
181
|
+
|
|
180
182
|
return invoice;
|
|
181
183
|
};
|
|
182
184
|
|
|
@@ -401,6 +403,7 @@ const handleStakeSlashAfterCancel = async (subscription: Subscription) => {
|
|
|
401
403
|
amount: invoice.amount_remaining,
|
|
402
404
|
address,
|
|
403
405
|
txHash,
|
|
406
|
+
invoice: invoice.id,
|
|
404
407
|
});
|
|
405
408
|
|
|
406
409
|
await paymentIntent.update({
|
|
@@ -645,7 +648,7 @@ export const handleSubscription = async (job: SubscriptionJob) => {
|
|
|
645
648
|
previousStatus,
|
|
646
649
|
});
|
|
647
650
|
|
|
648
|
-
if (previousStatus === 'past_due') {
|
|
651
|
+
if (previousStatus === 'past_due' && job.action === 'cancel') {
|
|
649
652
|
await handleStakeSlashAfterCancel(subscription);
|
|
650
653
|
}
|
|
651
654
|
return;
|
|
@@ -689,21 +692,48 @@ export const subscriptionQueue = createQueue<SubscriptionJob>({
|
|
|
689
692
|
});
|
|
690
693
|
|
|
691
694
|
export const startSubscriptionQueue = async () => {
|
|
692
|
-
const
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
695
|
+
const lock = getLock('startSubscriptionQueue');
|
|
696
|
+
if (lock.locked) {
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
logger.info('startSubscriptionQueue');
|
|
700
|
+
try {
|
|
701
|
+
await lock.acquire();
|
|
702
|
+
const subscriptions = await Subscription.findAll({
|
|
703
|
+
where: {
|
|
704
|
+
status: EXPECTED_SUBSCRIPTION_STATUS,
|
|
705
|
+
},
|
|
706
|
+
});
|
|
697
707
|
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
708
|
+
const results = await Promise.allSettled(
|
|
709
|
+
subscriptions.map(async (x) => {
|
|
710
|
+
const supportAutoCharge = await PaymentMethod.supportAutoCharge(x.default_payment_method_id);
|
|
711
|
+
if (supportAutoCharge === false) {
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
if (['past_due', 'paused'].includes(x.status)) {
|
|
715
|
+
logger.info(`skip add cycle subscription job because status is ${x.status}`, {
|
|
716
|
+
subscription: x.id,
|
|
717
|
+
action: 'cycle',
|
|
718
|
+
});
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
logger.info('add subscription job', { subscription: x.id, action: 'cycle' });
|
|
722
|
+
await addSubscriptionJob(x, 'cycle', process.env.PAYMENT_RELOAD_SUBSCRIPTION_JOBS === '1');
|
|
723
|
+
})
|
|
724
|
+
);
|
|
725
|
+
|
|
726
|
+
const failed = results.filter((r) => r.status === 'rejected').length;
|
|
727
|
+
if (failed > 0) {
|
|
728
|
+
logger.warn(`Failed to process ${failed} subscriptions in startSubscriptionQueue`);
|
|
702
729
|
}
|
|
703
|
-
await addSubscriptionJob(x, 'cycle', process.env.PAYMENT_RELOAD_SUBSCRIPTION_JOBS === '1');
|
|
704
|
-
});
|
|
705
730
|
|
|
706
|
-
|
|
731
|
+
await batchHandleStripeSubscriptions();
|
|
732
|
+
} catch (error) {
|
|
733
|
+
logger.error('Error in startSubscriptionQueue:', error);
|
|
734
|
+
} finally {
|
|
735
|
+
lock.release();
|
|
736
|
+
}
|
|
707
737
|
};
|
|
708
738
|
|
|
709
739
|
export const slashStakeQueue = createQueue({
|
|
@@ -20,6 +20,7 @@ import { PaymentMethod } from '../store/models/payment-method';
|
|
|
20
20
|
import { Price } from '../store/models/price';
|
|
21
21
|
import { Product } from '../store/models/product';
|
|
22
22
|
import { Subscription } from '../store/models/subscription';
|
|
23
|
+
import { getSubscriptionStakeAmountSetup } from '../libs/subscription';
|
|
23
24
|
|
|
24
25
|
const router = Router();
|
|
25
26
|
const authAdmin = authenticate<Subscription>({ component: true, roles: ['owner', 'admin'] });
|
|
@@ -131,44 +132,23 @@ router.get('/', authMine, async (req, res) => {
|
|
|
131
132
|
if (subscription?.payment_details?.arcblock?.staking?.tx_hash) {
|
|
132
133
|
const method = await PaymentMethod.findOne({ where: { type: 'arcblock', livemode: subscription.livemode } });
|
|
133
134
|
if (method) {
|
|
134
|
-
const client = method.getOcapClient();
|
|
135
135
|
const { address } = subscription.payment_details.arcblock.staking;
|
|
136
|
-
const { state } = await client.getStakeState({ address });
|
|
137
136
|
const firstInvoice = await Invoice.findOne({
|
|
138
137
|
where: { subscription_id: subscription.id },
|
|
139
138
|
order: [['created_at', 'ASC']],
|
|
139
|
+
include: [{ model: PaymentCurrency, as: 'paymentCurrency' }],
|
|
140
140
|
});
|
|
141
141
|
const last = query.o === 'asc' ? list?.[list.length - 1] : list?.[0];
|
|
142
|
-
if (
|
|
143
|
-
const data = JSON.parse(state.data?.value || '{}');
|
|
142
|
+
if (subscription.payment_details.arcblock.staking.tx_hash && firstInvoice) {
|
|
144
143
|
const customer = await Customer.findByPk(firstInvoice.customer_id);
|
|
145
|
-
const currency =
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
if (!Number(stakeAmount)) {
|
|
154
|
-
if (subscription.cancelation_details?.return_stake) {
|
|
155
|
-
const refund = await Refund.findOne({
|
|
156
|
-
where: { subscription_id: subscription.id, status: 'succeeded', type: 'stake_return' },
|
|
157
|
-
});
|
|
158
|
-
if (refund) {
|
|
159
|
-
stakeAmount = refund.amount;
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
if (subscription.cancelation_details?.slash_stake) {
|
|
163
|
-
const invoice = await Invoice.findOne({
|
|
164
|
-
where: { subscription_id: subscription.id, status: 'paid', billing_reason: 'slash_stake' },
|
|
165
|
-
});
|
|
166
|
-
if (invoice) {
|
|
167
|
-
stakeAmount = invoice.total;
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
}
|
|
144
|
+
const currency =
|
|
145
|
+
// @ts-ignore
|
|
146
|
+
firstInvoice?.paymentCurrency ||
|
|
147
|
+
(await PaymentCurrency.findOne({
|
|
148
|
+
where: { payment_method_id: method.id, is_base_currency: true },
|
|
149
|
+
}));
|
|
150
|
+
const stakeAmountResult = await getSubscriptionStakeAmountSetup(subscription, method);
|
|
151
|
+
const stakeAmount = stakeAmountResult?.[currency?.contract] || '0';
|
|
172
152
|
|
|
173
153
|
list.push({
|
|
174
154
|
id: address as string,
|
|
@@ -7,6 +7,7 @@ import pick from 'lodash/pick';
|
|
|
7
7
|
import uniq from 'lodash/uniq';
|
|
8
8
|
|
|
9
9
|
import { literal, OrderItem } from 'sequelize';
|
|
10
|
+
import { createEvent } from '../libs/audit';
|
|
10
11
|
import { ensureStripeCustomer, ensureStripePrice, ensureStripeSubscription } from '../integrations/stripe/resource';
|
|
11
12
|
import { createListParamSchema, getWhereFromKvQuery, getWhereFromQuery, MetadataSchema } from '../libs/api';
|
|
12
13
|
import dayjs from '../libs/dayjs';
|
|
@@ -510,7 +511,7 @@ router.put('/:id/resume', auth, async (req, res) => {
|
|
|
510
511
|
if (!doc) {
|
|
511
512
|
return res.status(404).json({ error: 'Subscription not found' });
|
|
512
513
|
}
|
|
513
|
-
if (doc.status !== 'paused'
|
|
514
|
+
if (doc.status !== 'paused' && doc.pause_collection === null) {
|
|
514
515
|
return res.status(400).json({ error: 'Subscription not paused' });
|
|
515
516
|
}
|
|
516
517
|
|
|
@@ -851,10 +852,15 @@ router.put('/:id', authPortal, async (req, res) => {
|
|
|
851
852
|
// update subscription period settings
|
|
852
853
|
// HINT: if we are adding new items, we need to reset the anchor to now
|
|
853
854
|
const setup = getSubscriptionCreateSetup(newItems, paymentCurrency.id, 0);
|
|
855
|
+
// Check if the subscription is currently in trial
|
|
856
|
+
const isInTrial =
|
|
857
|
+
subscription.status === 'trialing' && subscription.trial_end && subscription.trial_end > dayjs().unix();
|
|
854
858
|
if (newItems.some((x) => x.price.type === 'recurring' && addedItems.find((y) => y.price_id === x.price_id))) {
|
|
855
859
|
updates.pending_invoice_item_interval = setup.recurring;
|
|
856
|
-
|
|
857
|
-
|
|
860
|
+
if (!isInTrial) {
|
|
861
|
+
updates.current_period_start = setup.period.start;
|
|
862
|
+
updates.current_period_end = setup.period.end;
|
|
863
|
+
}
|
|
858
864
|
updates.billing_cycle_anchor = setup.cycle.anchor;
|
|
859
865
|
logger.info('subscription updates on reset anchor', { subscription: req.params.id, updates });
|
|
860
866
|
}
|
|
@@ -869,12 +875,30 @@ router.put('/:id', authPortal, async (req, res) => {
|
|
|
869
875
|
}
|
|
870
876
|
|
|
871
877
|
// 1. create proration
|
|
872
|
-
const { lastInvoice, due, newCredit, appliedCredit, prorations } = await createProration(
|
|
878
|
+
const { lastInvoice, due, newCredit, appliedCredit, prorations, total } = await createProration(
|
|
873
879
|
subscription,
|
|
874
880
|
setup,
|
|
875
881
|
dayjs().unix()
|
|
876
882
|
);
|
|
877
883
|
|
|
884
|
+
if ((total === '0' && isInTrial) || newCredit !== '0') {
|
|
885
|
+
// 0 amount or new credit means no need to create invoice
|
|
886
|
+
await subscription.update(updates);
|
|
887
|
+
await finalizeSubscriptionUpdate({
|
|
888
|
+
subscription,
|
|
889
|
+
customer,
|
|
890
|
+
invoice: null,
|
|
891
|
+
paymentCurrency,
|
|
892
|
+
appliedCredit,
|
|
893
|
+
newCredit,
|
|
894
|
+
addedItems,
|
|
895
|
+
deletedItems,
|
|
896
|
+
updatedItems,
|
|
897
|
+
updates,
|
|
898
|
+
});
|
|
899
|
+
await createEvent('Subscription', 'customer.subscription.upgraded', subscription).catch(console.error);
|
|
900
|
+
return res.json({ ...subscription.toJSON(), connectAction });
|
|
901
|
+
}
|
|
878
902
|
// 2. create new invoice: amount according to new subscription items
|
|
879
903
|
// 3. create new invoice items: amount according to new subscription items
|
|
880
904
|
const result = await ensureInvoiceAndItems({
|
|
@@ -894,7 +918,7 @@ router.put('/:id', authPortal, async (req, res) => {
|
|
|
894
918
|
period_end: setup.period.end,
|
|
895
919
|
auto_advance: true,
|
|
896
920
|
billing_reason: 'subscription_update',
|
|
897
|
-
total
|
|
921
|
+
total,
|
|
898
922
|
currency_id: paymentCurrency.id,
|
|
899
923
|
default_payment_method_id: subscription.default_payment_method_id,
|
|
900
924
|
custom_fields: lastInvoice.custom_fields || [],
|
|
@@ -977,6 +1001,16 @@ router.put('/:id', authPortal, async (req, res) => {
|
|
|
977
1001
|
invoice: invoice.id,
|
|
978
1002
|
});
|
|
979
1003
|
} else {
|
|
1004
|
+
await subscription.update({
|
|
1005
|
+
pending_update: {
|
|
1006
|
+
updates,
|
|
1007
|
+
appliedCredit,
|
|
1008
|
+
newCredit,
|
|
1009
|
+
addedItems,
|
|
1010
|
+
deletedItems,
|
|
1011
|
+
updatedItems,
|
|
1012
|
+
},
|
|
1013
|
+
});
|
|
980
1014
|
await invoiceQueue.pushAndWait({
|
|
981
1015
|
id: invoice.id,
|
|
982
1016
|
job: { invoiceId: invoice.id, retryOnError: false, waitForPayment: true },
|
|
@@ -70,6 +70,10 @@ export class Job extends Model<InferAttributes<Job>, InferCreationAttributes<Job
|
|
|
70
70
|
public static associate() {
|
|
71
71
|
// Do nothing
|
|
72
72
|
}
|
|
73
|
+
|
|
74
|
+
public static isInitialized(): boolean {
|
|
75
|
+
return this.sequelize !== undefined;
|
|
76
|
+
}
|
|
73
77
|
}
|
|
74
78
|
|
|
75
79
|
export type TJob = InferAttributes<Job>;
|
|
@@ -9,7 +9,9 @@ import {
|
|
|
9
9
|
getSubscriptionStakeSetup,
|
|
10
10
|
getSubscriptionTrialSetup,
|
|
11
11
|
shouldCancelSubscription,
|
|
12
|
+
getSubscriptionStakeAmountSetup,
|
|
12
13
|
} from '../../src/libs/subscription';
|
|
14
|
+
import type { PaymentMethod, Subscription } from '../../src/store/models';
|
|
13
15
|
|
|
14
16
|
describe('getDueUnit', () => {
|
|
15
17
|
it('should return 60 for recurring interval of "hour"', () => {
|
|
@@ -411,3 +413,98 @@ describe('getSubscriptionTrialSetup', () => {
|
|
|
411
413
|
expect(result).toEqual({ trialInDays: 0, trialEnd: 0 });
|
|
412
414
|
});
|
|
413
415
|
});
|
|
416
|
+
|
|
417
|
+
describe('getSubscriptionStakeAmountSetup', () => {
|
|
418
|
+
let mockSubscription: Subscription;
|
|
419
|
+
let mockPaymentMethod: PaymentMethod;
|
|
420
|
+
let mockGetOcapClient: jest.Mock;
|
|
421
|
+
let mockGetTx: jest.Mock;
|
|
422
|
+
|
|
423
|
+
beforeEach(() => {
|
|
424
|
+
mockSubscription = {
|
|
425
|
+
payment_details: {
|
|
426
|
+
arcblock: {
|
|
427
|
+
staking: {
|
|
428
|
+
tx_hash: 'mock_tx_hash',
|
|
429
|
+
},
|
|
430
|
+
},
|
|
431
|
+
},
|
|
432
|
+
} as Subscription;
|
|
433
|
+
|
|
434
|
+
mockGetTx = jest.fn();
|
|
435
|
+
mockGetOcapClient = jest.fn().mockReturnValue({
|
|
436
|
+
getTx: mockGetTx,
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
// @ts-ignore
|
|
440
|
+
mockPaymentMethod = {
|
|
441
|
+
type: 'arcblock',
|
|
442
|
+
getOcapClient: mockGetOcapClient,
|
|
443
|
+
};
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
it('should return null if payment method is not arcblock', async () => {
|
|
447
|
+
mockPaymentMethod.type = 'other';
|
|
448
|
+
const result = await getSubscriptionStakeAmountSetup(mockSubscription, mockPaymentMethod);
|
|
449
|
+
expect(result).toBeNull();
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it('should return null if tx_hash is missing', async () => {
|
|
453
|
+
// @ts-ignore
|
|
454
|
+
mockSubscription.payment_details.arcblock.staking.tx_hash = undefined;
|
|
455
|
+
const result = await getSubscriptionStakeAmountSetup(mockSubscription, mockPaymentMethod);
|
|
456
|
+
expect(result).toBeNull();
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it('should return null if getTx info is null', async () => {
|
|
460
|
+
mockGetTx.mockResolvedValue({ info: null });
|
|
461
|
+
const result = await getSubscriptionStakeAmountSetup(mockSubscription, mockPaymentMethod);
|
|
462
|
+
expect(result).toBeNull();
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it('should return null if inputs are empty', async () => {
|
|
466
|
+
mockGetTx.mockResolvedValue({
|
|
467
|
+
info: {
|
|
468
|
+
tx: {
|
|
469
|
+
itxJson: {
|
|
470
|
+
inputs: [],
|
|
471
|
+
},
|
|
472
|
+
},
|
|
473
|
+
},
|
|
474
|
+
});
|
|
475
|
+
const result = await getSubscriptionStakeAmountSetup(mockSubscription, mockPaymentMethod);
|
|
476
|
+
expect(result).toBeNull();
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
it('should calculate stake amount correctly', async () => {
|
|
480
|
+
mockGetTx.mockResolvedValue({
|
|
481
|
+
info: {
|
|
482
|
+
tx: {
|
|
483
|
+
itxJson: {
|
|
484
|
+
inputs: [
|
|
485
|
+
{
|
|
486
|
+
tokens: [
|
|
487
|
+
{ address: 'addr1', value: '100' },
|
|
488
|
+
{ address: 'addr2', value: '200' },
|
|
489
|
+
],
|
|
490
|
+
},
|
|
491
|
+
{
|
|
492
|
+
tokens: [
|
|
493
|
+
{ address: 'addr1', value: '300' },
|
|
494
|
+
{ address: 'addr3', value: '400' },
|
|
495
|
+
],
|
|
496
|
+
},
|
|
497
|
+
],
|
|
498
|
+
},
|
|
499
|
+
},
|
|
500
|
+
},
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
const result = await getSubscriptionStakeAmountSetup(mockSubscription, mockPaymentMethod);
|
|
504
|
+
expect(result).toEqual({
|
|
505
|
+
addr1: '400',
|
|
506
|
+
addr2: '200',
|
|
507
|
+
addr3: '400',
|
|
508
|
+
});
|
|
509
|
+
});
|
|
510
|
+
});
|
package/blocklet.yml
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "payment-kit",
|
|
3
|
-
"version": "1.15.
|
|
3
|
+
"version": "1.15.17",
|
|
4
4
|
"scripts": {
|
|
5
5
|
"dev": "blocklet dev --open",
|
|
6
6
|
"eject": "vite eject",
|
|
@@ -45,18 +45,18 @@
|
|
|
45
45
|
"@abtnode/cron": "^1.16.32",
|
|
46
46
|
"@arcblock/did": "^1.18.135",
|
|
47
47
|
"@arcblock/did-auth-storage-nedb": "^1.7.1",
|
|
48
|
-
"@arcblock/did-connect": "^2.10.
|
|
48
|
+
"@arcblock/did-connect": "^2.10.45",
|
|
49
49
|
"@arcblock/did-util": "^1.18.135",
|
|
50
50
|
"@arcblock/jwt": "^1.18.135",
|
|
51
|
-
"@arcblock/ux": "^2.10.
|
|
51
|
+
"@arcblock/ux": "^2.10.45",
|
|
52
52
|
"@arcblock/validator": "^1.18.135",
|
|
53
53
|
"@blocklet/js-sdk": "^1.16.32",
|
|
54
54
|
"@blocklet/logger": "^1.16.32",
|
|
55
|
-
"@blocklet/payment-react": "1.15.
|
|
55
|
+
"@blocklet/payment-react": "1.15.17",
|
|
56
56
|
"@blocklet/sdk": "^1.16.32",
|
|
57
|
-
"@blocklet/ui-react": "^2.10.
|
|
58
|
-
"@blocklet/uploader": "^0.1.
|
|
59
|
-
"@blocklet/xss": "^0.1.
|
|
57
|
+
"@blocklet/ui-react": "^2.10.45",
|
|
58
|
+
"@blocklet/uploader": "^0.1.43",
|
|
59
|
+
"@blocklet/xss": "^0.1.9",
|
|
60
60
|
"@mui/icons-material": "^5.16.6",
|
|
61
61
|
"@mui/lab": "^5.0.0-alpha.173",
|
|
62
62
|
"@mui/material": "^5.16.6",
|
|
@@ -118,7 +118,7 @@
|
|
|
118
118
|
"devDependencies": {
|
|
119
119
|
"@abtnode/types": "^1.16.32",
|
|
120
120
|
"@arcblock/eslint-config-ts": "^0.3.2",
|
|
121
|
-
"@blocklet/payment-types": "1.15.
|
|
121
|
+
"@blocklet/payment-types": "1.15.17",
|
|
122
122
|
"@types/cookie-parser": "^1.4.7",
|
|
123
123
|
"@types/cors": "^2.8.17",
|
|
124
124
|
"@types/debug": "^4.1.12",
|
|
@@ -144,7 +144,7 @@
|
|
|
144
144
|
"typescript": "^4.9.5",
|
|
145
145
|
"vite": "^5.3.5",
|
|
146
146
|
"vite-node": "^2.0.4",
|
|
147
|
-
"vite-plugin-blocklet": "^0.9.
|
|
147
|
+
"vite-plugin-blocklet": "^0.9.11",
|
|
148
148
|
"vite-plugin-node-polyfills": "^0.21.0",
|
|
149
149
|
"vite-plugin-svgr": "^4.2.0",
|
|
150
150
|
"vite-tsconfig-paths": "^4.3.2",
|
|
@@ -160,5 +160,5 @@
|
|
|
160
160
|
"parser": "typescript"
|
|
161
161
|
}
|
|
162
162
|
},
|
|
163
|
-
"gitHead": "
|
|
163
|
+
"gitHead": "746e67a4e0542f308289edd4e2d6bdd64e49e53e"
|
|
164
164
|
}
|
|
@@ -200,7 +200,7 @@ export default function CustomerSubscriptionChangePlan() {
|
|
|
200
200
|
};
|
|
201
201
|
|
|
202
202
|
const table = { ...data.table, currency: data.subscription.paymentCurrency };
|
|
203
|
-
table.items.forEach((x: any) => {
|
|
203
|
+
(table.items || []).forEach((x: any) => {
|
|
204
204
|
x.is_selected = x.price_id === state.priceId;
|
|
205
205
|
if (data.subscription.items.find((y) => y.price_id === x.price_id)) {
|
|
206
206
|
x.is_highlight = true;
|
package/vite-server.config.ts
CHANGED
|
@@ -6,6 +6,14 @@ import tsconfigPaths from 'vite-tsconfig-paths';
|
|
|
6
6
|
// https://vitejs.dev/config/
|
|
7
7
|
export default defineConfig(() => {
|
|
8
8
|
return {
|
|
9
|
+
resolve: {
|
|
10
|
+
alias: {
|
|
11
|
+
crypto: 'node:crypto',
|
|
12
|
+
},
|
|
13
|
+
},
|
|
9
14
|
plugins: [tsconfigPaths()],
|
|
15
|
+
server: {
|
|
16
|
+
hmr: false,
|
|
17
|
+
},
|
|
10
18
|
};
|
|
11
19
|
});
|