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.
@@ -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.statement_descriptor_suffix = '';
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
- statement_descriptor_suffix: '',
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.status !== 'active') {
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
- const invoice = (await Invoice.findByPk(subscription.latest_invoice_id)) as Invoice;
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(invoice.period_start * 1000);
94
- const currentPeriodEnd: string = formatTime(invoice.period_end * 1000);
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
- type: 'text',
215
- data: {
216
- type: 'plain',
217
- color: '#9397A1',
218
- text: translate('notification.common.paymentAmount', locale),
219
- },
220
- },
221
- {
222
- type: 'text',
223
- data: {
224
- type: 'plain',
225
- text: paymentInfo,
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
- viewTxHashLink && {
259
- name: translate('notification.common.viewTxHash', locale),
260
- title: translate('notification.common.viewTxHash', locale),
261
- link: viewTxHashLink,
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
- if (persist) {
55
- try {
56
- const cancelled = await store.isCancelled(id);
57
- if (cancelled) {
58
- cb(null, CANCELLED);
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
- } catch (err) {
62
- cb(err);
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
- try {
68
- const result = await tryWithTimeout(() => onJob(job), maxTimeout);
69
- cb(null, result);
70
- } catch (err) {
71
- cb(err);
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({ where: { queue, delay: -1 }, order: [['created_at', 'ASC']], transaction: null });
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 exist = await Job.findOne({ where: { queue, id }, transaction: null });
35
- if (exist) {
36
- throw new CustomError('JOB_DUPLICATE', `Job ${queue}#${id} already exist`);
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
- return Job.create({ id, job, queue, retry_count: 1, cancelled: false, ...attrs });
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
- await invoice.update({
526
- starting_token_balance: creditResult.starting,
527
- ending_token_balance: creditResult.ending,
528
- });
529
- logger.info('customer credit applied to invoice after proration', {
530
- subscription: subscription.id,
531
- appliedCredit,
532
- creditResult,
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
- await invoice.update({
538
- starting_token_balance: creditResult.starting,
539
- ending_token_balance: creditResult.ending,
540
- });
541
- logger.info('subscription proration credit applied to customer', {
542
- subscription: subscription.id,
543
- newCredit,
544
- creditResult,
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 invoices = await Invoice.findAll({
199
- where: {
200
- status: 'open',
201
- collection_method: 'charge_automatically',
202
- amount_remaining: { [Op.gt]: '0' },
203
- },
204
- });
205
-
206
- invoices.forEach(async (x) => {
207
- const supportAutoCharge = await PaymentMethod.supportAutoCharge(x.default_payment_method_id);
208
- if (supportAutoCharge === false) {
209
- return;
210
- }
211
- const exist = await invoiceQueue.get(x.id);
212
- if (!exist) {
213
- invoiceQueue.push({ id: x.id, job: { invoiceId: x.id, retryOnError: true } });
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
- await batchHandleStripeInvoices();
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 subscriptions = await Subscription.findAll({
693
- where: {
694
- status: EXPECTED_SUBSCRIPTION_STATUS,
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
- subscriptions.forEach(async (x) => {
699
- const supportAutoCharge = await PaymentMethod.supportAutoCharge(x.default_payment_method_id);
700
- if (supportAutoCharge === false) {
701
- return;
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
- await batchHandleStripeSubscriptions();
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 (state && firstInvoice) {
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 = await PaymentCurrency.findOne({
146
- where: { payment_method_id: method.id, is_base_currency: true },
147
- });
148
-
149
- let stakeAmount = data[subscription.id];
150
- if (state.nonce) {
151
- stakeAmount = state.tokens?.find((x: any) => x.address === currency?.contract)?.value;
152
- // stakeAmount should not be zero if nonce exist
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' || doc.pause_collection === null) {
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
- updates.current_period_start = setup.period.start;
857
- updates.current_period_end = setup.period.end;
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: setup.amount.setup,
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
@@ -14,7 +14,7 @@ repository:
14
14
  type: git
15
15
  url: git+https://github.com/blocklet/payment-kit.git
16
16
  specVersion: 1.2.8
17
- version: 1.15.15
17
+ version: 1.15.17
18
18
  logo: logo.png
19
19
  files:
20
20
  - dist
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "payment-kit",
3
- "version": "1.15.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.39",
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.39",
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.15",
55
+ "@blocklet/payment-react": "1.15.17",
56
56
  "@blocklet/sdk": "^1.16.32",
57
- "@blocklet/ui-react": "^2.10.39",
58
- "@blocklet/uploader": "^0.1.40",
59
- "@blocklet/xss": "^0.1.7",
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.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.8",
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": "a4661fedeb5c77d1c89736f9030f89cb2fc03474"
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;
@@ -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
  });