payment-kit 1.13.91 → 1.13.92

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.
@@ -1,7 +1,8 @@
1
1
  import Cron from '@abtnode/cron';
2
2
 
3
- import { notificationCronTime } from '../libs/env';
3
+ import { notificationCronTime, subscriptionCronTime } from '../libs/env';
4
4
  import logger from '../libs/logger';
5
+ import { startSubscriptionQueue } from '../queues/subscription';
5
6
  import { SubscriptionTrailWillEndSchedule } from './subscription-trail-will-end';
6
7
  import { SubscriptionWillRenewSchedule } from './subscription-will-renew';
7
8
 
@@ -21,6 +22,12 @@ function init() {
21
22
  fn: () => new SubscriptionTrailWillEndSchedule().run(),
22
23
  options: { runOnInit: true },
23
24
  },
25
+ {
26
+ name: 'subscription.schedule.retry',
27
+ time: subscriptionCronTime,
28
+ fn: startSubscriptionQueue,
29
+ options: { runOnInit: false },
30
+ },
24
31
  ],
25
32
  onError: (error: Error, name: string) => {
26
33
  logger.error('run job failed', { name, error: error.message, stack: error.stack });
@@ -1,5 +1,6 @@
1
1
  import env from '@blocklet/sdk/lib/env';
2
2
 
3
+ export const subscriptionCronTime: string = process.env.SUBSCRIPTION_CRON_TIME || '0 */30 * * * *'; // 默认每 30 min 行一次
3
4
  export const notificationCronTime: string = process.env.NOTIFICATION_CRON_TIME || '0 5 */6 * * *'; // 默认每6个小时执行一次
4
5
  export const notificationCronConcurrency: number = Number(process.env.NOTIFICATION_CRON_CONCURRENCY) || 8; // 默认并发数为 8
5
6
 
@@ -20,116 +20,41 @@ type SubscriptionJob = {
20
20
 
21
21
  const EXPECTED_SUBSCRIPTION_STATUS = ['trialing', 'active', 'paused', 'past_due'];
22
22
 
23
- // generate invoice for subscription periodically
24
- export const handleSubscription = async (job: SubscriptionJob) => {
25
- logger.info('handle subscription', job);
26
-
27
- const subscription = await Subscription.findByPk(job.subscriptionId);
28
- if (!subscription) {
29
- logger.warn(`Subscription not found: ${job.subscriptionId}`);
30
- return;
31
- }
32
- if (EXPECTED_SUBSCRIPTION_STATUS.includes(subscription.status) === false) {
33
- logger.warn(`Subscription status not expected: ${job.subscriptionId}`);
34
- return;
35
- }
36
- const supportAutoCharge = await PaymentMethod.supportAutoCharge(subscription.default_payment_method_id);
37
- if (supportAutoCharge === false) {
38
- logger.warn(`Subscription does not support auto charge: ${job.subscriptionId}`);
39
- return;
40
- }
41
-
42
- const now = dayjs().unix();
43
-
44
- // Do we need to cancel the subscription
45
- if (subscription.isImmutable() === false) {
46
- if (subscription.cancel_at_period_end) {
47
- await subscription.update({ status: 'canceled', canceled_at: now });
48
- logger.warn(`Subscription canceled on period end: ${job.subscriptionId}`);
49
- return;
50
- }
51
- if (subscription.cancel_at && subscription.cancel_at <= now) {
52
- await subscription.update({ status: 'canceled', canceled_at: now });
53
- logger.warn(`Subscription canceled on schedule: ${job.subscriptionId}`);
54
- return;
55
- }
56
- }
57
-
58
- // Do we need to resume the subscription
59
- if (subscription.pause_collection?.resumes_at && subscription.pause_collection?.resumes_at <= now) {
60
- await subscription.update({ status: 'active', pause_collection: undefined });
61
- logger.warn(`Subscription resumed as scheduled: ${job.subscriptionId}`);
62
- }
63
-
64
- // can we create new invoice for this subscription?
65
- if (subscription.status === 'trialing' && subscription.trail_end && subscription.trail_end > now) {
66
- logger.warn(`Subscription trialing period not ended: ${job.subscriptionId}`);
67
- subscriptionQueue.push({
68
- id: subscription.id,
69
- job: { subscriptionId: subscription.id, action: 'cycle' },
70
- runAt: subscription.trail_end,
71
- });
72
- return;
73
- }
74
- if (subscription.status === 'active' && subscription.current_period_end > now) {
75
- logger.warn(`Subscription current period not ended: ${job.subscriptionId}`);
76
- subscriptionQueue.push({
77
- id: subscription.id,
78
- job: { subscriptionId: subscription.id, action: 'cycle' },
79
- runAt: subscription.current_period_end,
80
- });
81
- return;
82
- }
83
-
84
- if (subscription.isActive() === false) {
85
- logger.warn(`Subscription not active: ${job.subscriptionId}, so new invoice is skipped`);
86
- return;
87
- }
88
-
23
+ const handleSubscriptionInvoice = async (
24
+ subscription: Subscription,
25
+ filter: (x: any) => boolean,
26
+ status: string,
27
+ reason: 'cycle' | 'cancel',
28
+ start: number,
29
+ end: number,
30
+ offset: number
31
+ ) => {
89
32
  // Do we still have the customer
90
33
  const customer = await Customer.findByPk(subscription.customer_id);
91
34
  if (!customer) {
92
35
  logger.warn(`Customer ${subscription.customer_id} not found for subscription: ${subscription.id}`);
93
- return;
36
+ return null;
94
37
  }
95
38
 
96
39
  // Do we still have the currency
97
40
  const currency = await PaymentCurrency.findByPk(subscription.currency_id);
98
41
  if (!currency) {
99
42
  logger.warn(`Currency ${subscription.currency_id} not found for subscription: ${subscription.id}`);
100
- return;
43
+ return null;
101
44
  }
102
45
 
103
- // get setup for next subscription period
104
- const previousPeriodEnd =
105
- subscription.status === 'trialing' ? subscription.trail_end : subscription.current_period_end;
106
- const setup = getSubscriptionCycleSetup(subscription.pending_invoice_item_interval, previousPeriodEnd as number);
107
-
108
46
  // check if invoice already created
109
47
  const exist = await Invoice.findOne({
110
48
  where: {
111
49
  subscription_id: subscription.id,
112
- period_start: setup.period.start,
113
- period_end: setup.period.end,
50
+ period_start: start,
51
+ period_end: end,
52
+ billing_reason: `subscription_${reason}`,
114
53
  },
115
54
  });
116
55
  if (exist) {
117
- logger.warn(`Invoice already created for subscription ${subscription.id} for next billing cycle: ${exist.id}`);
118
- return;
119
- }
120
-
121
- // set invoice status if subscription paused
122
- let status = 'open';
123
- if (subscription.pause_collection) {
124
- if (subscription.pause_collection.behavior === 'mark_uncollectible') {
125
- status = 'uncollectible';
126
- }
127
- if (subscription.pause_collection.behavior === 'void') {
128
- status = 'void';
129
- }
130
- if (subscription.pause_collection.behavior === 'keep_as_draft') {
131
- status = 'draft';
132
- }
56
+ logger.warn(`Invoice already created for subscription ${subscription.id} for ${reason}: ${exist.id}`);
57
+ return null;
133
58
  }
134
59
 
135
60
  // expand subscription items
@@ -141,16 +66,14 @@ export const handleSubscription = async (job: SubscriptionJob) => {
141
66
 
142
67
  // get usage summaries for this billing cycle
143
68
  expandedItems = await Promise.all(
144
- expandedItems.map(async (x: any) => {
69
+ expandedItems.filter(filter).map(async (x: any) => {
145
70
  // For metered billing, we need to get usage summary for this billing cycle
146
71
  // @link https://stripe.com/docs/products-prices/pricing-models#usage-types
147
72
  if (x.price.recurring?.usage_type === 'metered') {
148
- const duration = setup.cycle / 1000;
149
73
  const rawQuantity = await UsageRecord.getSummary(
150
74
  x.id,
151
- // FIXME: this causes inconsistency when subscription is paused or billing_cycle_anchor reset
152
- setup.period.start - duration,
153
- setup.period.end - duration,
75
+ start - offset,
76
+ end - offset,
154
77
  x.price.recurring?.aggregate_usage
155
78
  );
156
79
  if (x.price.transform_quantity) {
@@ -169,8 +92,8 @@ export const handleSubscription = async (job: SubscriptionJob) => {
169
92
  transformQuantity: x.price.transform_quantity,
170
93
  rawQuantity,
171
94
  quantity: x.quantity,
172
- start: setup.period.start - duration,
173
- end: setup.period.end - duration,
95
+ start: start - offset,
96
+ end: end - offset,
174
97
  usage: x.price.recurring?.aggregate_usage,
175
98
  });
176
99
 
@@ -182,6 +105,9 @@ export const handleSubscription = async (job: SubscriptionJob) => {
182
105
  return x;
183
106
  })
184
107
  );
108
+ if (expandedItems.length === 0) {
109
+ return null;
110
+ }
185
111
 
186
112
  const amount = getSubscriptionCycleAmount(expandedItems, currency.id);
187
113
 
@@ -193,13 +119,13 @@ export const handleSubscription = async (job: SubscriptionJob) => {
193
119
  lineItems: expandedItems,
194
120
  props: {
195
121
  livemode: subscription.livemode,
196
- description: 'Subscription cycle',
122
+ description: `Subscription ${reason}`,
197
123
  statement_descriptor: getStatementDescriptor(expandedItems),
198
- period_start: setup.period.start,
199
- period_end: setup.period.end,
124
+ period_start: start,
125
+ period_end: end,
200
126
  auto_advance: true,
201
127
  status,
202
- billing_reason: 'subscription_cycle',
128
+ billing_reason: `subscription_${reason}`,
203
129
  currency_id: subscription.currency_id,
204
130
  total: amount.total,
205
131
  payment_settings: subscription.payment_settings,
@@ -208,17 +134,74 @@ export const handleSubscription = async (job: SubscriptionJob) => {
208
134
  } as Invoice,
209
135
  });
210
136
 
211
- // schedule invoice job
212
- invoiceQueue.push({ id: invoice.id, job: { invoiceId: invoice.id, retryOnError: true } });
213
- logger.info(`Invoice job scheduled for new billing cycle: ${invoice.id}`);
137
+ return invoice;
138
+ };
139
+
140
+ const handleSubscriptionBeforeCancel = async (subscription: Subscription) => {
141
+ const invoice = await handleSubscriptionInvoice(
142
+ subscription,
143
+ (x) => x.price.recurring?.usage_type === 'metered', // include only metered items
144
+ 'open',
145
+ 'cancel',
146
+ subscription.current_period_start as number,
147
+ subscription.current_period_end as number,
148
+ 0
149
+ );
214
150
 
215
- // persist invoice id
216
- await subscription.update({
217
- latest_invoice_id: invoice.id,
218
- current_period_start: setup.period.start,
219
- current_period_end: setup.period.end,
220
- });
221
- logger.info(`Subscription updated for new billing cycle: ${subscription.id}`);
151
+ if (invoice) {
152
+ // schedule invoice job
153
+ invoiceQueue.push({ id: invoice.id, job: { invoiceId: invoice.id, retryOnError: true } });
154
+ logger.info(`Invoice job scheduled before cancel: ${invoice.id}`);
155
+
156
+ // persist invoice id
157
+ await subscription.update({ latest_invoice_id: invoice.id });
158
+ logger.info(`Subscription updated before cancel: ${subscription.id}`);
159
+ }
160
+ };
161
+
162
+ const handleSubscriptionWhenActive = async (subscription: Subscription) => {
163
+ // get setup for next subscription period
164
+ const previousPeriodEnd =
165
+ subscription.status === 'trialing' ? subscription.trail_end : subscription.current_period_end;
166
+ const setup = getSubscriptionCycleSetup(subscription.pending_invoice_item_interval, previousPeriodEnd as number);
167
+
168
+ // set invoice status if subscription paused
169
+ let status = 'open';
170
+ if (subscription.pause_collection) {
171
+ if (subscription.pause_collection.behavior === 'mark_uncollectible') {
172
+ status = 'uncollectible';
173
+ }
174
+ if (subscription.pause_collection.behavior === 'void') {
175
+ status = 'void';
176
+ }
177
+ if (subscription.pause_collection.behavior === 'keep_as_draft') {
178
+ status = 'draft';
179
+ }
180
+ }
181
+
182
+ const invoice = await handleSubscriptionInvoice(
183
+ subscription,
184
+ () => true, // include all items
185
+ status,
186
+ 'cycle',
187
+ setup.period.start,
188
+ setup.period.end,
189
+ setup.cycle / 1000
190
+ );
191
+
192
+ if (invoice) {
193
+ // schedule invoice job
194
+ invoiceQueue.push({ id: invoice.id, job: { invoiceId: invoice.id, retryOnError: true } });
195
+ logger.info(`Invoice job scheduled for new billing cycle: ${invoice.id}`);
196
+
197
+ // persist invoice id
198
+ await subscription.update({
199
+ latest_invoice_id: invoice.id,
200
+ current_period_start: setup.period.start,
201
+ current_period_end: setup.period.end,
202
+ });
203
+ logger.info(`Subscription updated for new billing cycle: ${subscription.id}`);
204
+ }
222
205
 
223
206
  // schedule next billing cycle if we are not in terminal state
224
207
  if (subscription.isActive()) {
@@ -231,6 +214,77 @@ export const handleSubscription = async (job: SubscriptionJob) => {
231
214
  }
232
215
  };
233
216
 
217
+ // generate invoice for subscription periodically
218
+ export const handleSubscription = async (job: SubscriptionJob) => {
219
+ logger.info('handle subscription', job);
220
+
221
+ const subscription = await Subscription.findByPk(job.subscriptionId);
222
+ if (!subscription) {
223
+ logger.warn(`Subscription not found: ${job.subscriptionId}`);
224
+ return;
225
+ }
226
+ if (EXPECTED_SUBSCRIPTION_STATUS.includes(subscription.status) === false) {
227
+ logger.warn(`Subscription status not expected: ${job.subscriptionId}`);
228
+ return;
229
+ }
230
+ const supportAutoCharge = await PaymentMethod.supportAutoCharge(subscription.default_payment_method_id);
231
+ if (supportAutoCharge === false) {
232
+ logger.warn(`Subscription does not support auto charge: ${job.subscriptionId}`);
233
+ return;
234
+ }
235
+
236
+ const now = dayjs().unix();
237
+
238
+ // Do we need to cancel the subscription
239
+ if (subscription.isImmutable() === false) {
240
+ if (subscription.cancel_at_period_end) {
241
+ await handleSubscriptionBeforeCancel(subscription);
242
+ await subscription.update({ status: 'canceled', canceled_at: now });
243
+ logger.warn(`Subscription canceled on period end: ${job.subscriptionId}`);
244
+ return;
245
+ }
246
+ if (subscription.cancel_at && subscription.cancel_at <= now) {
247
+ await handleSubscriptionBeforeCancel(subscription);
248
+ await subscription.update({ status: 'canceled', canceled_at: now });
249
+ logger.warn(`Subscription canceled on schedule: ${job.subscriptionId}`);
250
+ return;
251
+ }
252
+ }
253
+
254
+ // Do we need to resume the subscription
255
+ if (subscription.pause_collection?.resumes_at && subscription.pause_collection?.resumes_at <= now) {
256
+ await subscription.update({ status: 'active', pause_collection: undefined });
257
+ logger.warn(`Subscription resumed as scheduled: ${job.subscriptionId}`);
258
+ }
259
+
260
+ // can we create new invoice for this subscription?
261
+ if (subscription.status === 'trialing' && subscription.trail_end && subscription.trail_end > now) {
262
+ logger.warn(`Subscription trialing period not ended: ${job.subscriptionId}`);
263
+ subscriptionQueue.push({
264
+ id: subscription.id,
265
+ job: { subscriptionId: subscription.id, action: 'cycle' },
266
+ runAt: subscription.trail_end,
267
+ });
268
+ return;
269
+ }
270
+ if (subscription.status === 'active' && subscription.current_period_end > now) {
271
+ logger.warn(`Subscription current period not ended: ${job.subscriptionId}`);
272
+ subscriptionQueue.push({
273
+ id: subscription.id,
274
+ job: { subscriptionId: subscription.id, action: 'cycle' },
275
+ runAt: subscription.current_period_end,
276
+ });
277
+ return;
278
+ }
279
+
280
+ if (subscription.isActive() === false) {
281
+ logger.warn(`Subscription not active: ${job.subscriptionId}, so new invoice is skipped`);
282
+ return;
283
+ }
284
+
285
+ await handleSubscriptionWhenActive(subscription);
286
+ };
287
+
234
288
  export const subscriptionQueue = createQueue<SubscriptionJob>({
235
289
  name: 'subscription',
236
290
  onJob: handleSubscription,
@@ -42,6 +42,7 @@ export class Invoice extends Model<InferAttributes<Invoice>, InferCreationAttrib
42
42
  | 'subscription_cycle'
43
43
  | 'subscription_update'
44
44
  | 'subscription_threshold'
45
+ | 'subscription_cancel'
45
46
  | 'subscription'
46
47
  | 'manual'
47
48
  | 'upcoming',
@@ -275,6 +276,7 @@ export class Invoice extends Model<InferAttributes<Invoice>, InferCreationAttrib
275
276
  'subscription_cycle',
276
277
  'subscription_update',
277
278
  'subscription_threshold',
279
+ 'subscription_cancel',
278
280
  'subscription',
279
281
  'manual',
280
282
  'upcoming'
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.13.91
17
+ version: 1.13.92
18
18
  logo: logo.png
19
19
  files:
20
20
  - dist
@@ -49,6 +49,8 @@ scripts:
49
49
  environments: []
50
50
  capabilities:
51
51
  navigation: true
52
+ clusterMode: false
53
+ component: true
52
54
  screenshots:
53
55
  - 1-subscription.png
54
56
  - 2-customer-1.png
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "payment-kit",
3
- "version": "1.13.91",
3
+ "version": "1.13.92",
4
4
  "scripts": {
5
5
  "dev": "COMPONENT_STORE_URL=https://test.store.blocklet.dev blocklet dev",
6
6
  "eject": "vite eject",
@@ -45,14 +45,14 @@
45
45
  "@abtnode/cron": "1.16.21",
46
46
  "@arcblock/did": "^1.18.108",
47
47
  "@arcblock/did-auth-storage-nedb": "^1.7.1",
48
- "@arcblock/did-connect": "^2.9.6",
48
+ "@arcblock/did-connect": "^2.9.9",
49
49
  "@arcblock/did-util": "^1.18.108",
50
50
  "@arcblock/jwt": "^1.18.108",
51
- "@arcblock/ux": "^2.9.6",
51
+ "@arcblock/ux": "^2.9.9",
52
52
  "@blocklet/logger": "1.16.21",
53
53
  "@blocklet/sdk": "1.16.21",
54
- "@blocklet/ui-react": "^2.9.6",
55
- "@blocklet/uploader": "^0.0.62",
54
+ "@blocklet/ui-react": "^2.9.9",
55
+ "@blocklet/uploader": "^0.0.64",
56
56
  "@mui/icons-material": "^5.14.19",
57
57
  "@mui/lab": "^5.0.0-alpha.155",
58
58
  "@mui/material": "^5.14.20",
@@ -110,7 +110,7 @@
110
110
  "@abtnode/types": "1.16.21",
111
111
  "@arcblock/eslint-config": "^0.2.4",
112
112
  "@arcblock/eslint-config-ts": "^0.2.4",
113
- "@did-pay/types": "1.13.91",
113
+ "@did-pay/types": "1.13.92",
114
114
  "@types/cookie-parser": "^1.4.6",
115
115
  "@types/cors": "^2.8.17",
116
116
  "@types/dotenv-flow": "^3.3.3",
@@ -149,5 +149,5 @@
149
149
  "parser": "typescript"
150
150
  }
151
151
  },
152
- "gitHead": "970a705a07b5b34cb04f54c5e76797c96c500d81"
152
+ "gitHead": "1e2c450bea035a3c6410849f3fcac790b92da38c"
153
153
  }