payment-kit 1.18.30 → 1.18.31

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.
Files changed (51) hide show
  1. package/api/src/crons/metering-subscription-detection.ts +9 -0
  2. package/api/src/integrations/arcblock/nft.ts +1 -0
  3. package/api/src/integrations/blocklet/passport.ts +1 -1
  4. package/api/src/integrations/stripe/handlers/invoice.ts +2 -2
  5. package/api/src/integrations/stripe/handlers/setup-intent.ts +29 -1
  6. package/api/src/integrations/stripe/handlers/subscription.ts +19 -15
  7. package/api/src/integrations/stripe/resource.ts +81 -1
  8. package/api/src/libs/audit.ts +42 -0
  9. package/api/src/libs/invoice.ts +54 -7
  10. package/api/src/libs/notification/index.ts +72 -4
  11. package/api/src/libs/notification/template/base.ts +2 -0
  12. package/api/src/libs/notification/template/subscription-renew-failed.ts +1 -5
  13. package/api/src/libs/notification/template/subscription-renewed.ts +1 -5
  14. package/api/src/libs/notification/template/subscription-succeeded.ts +8 -18
  15. package/api/src/libs/notification/template/subscription-trial-start.ts +2 -10
  16. package/api/src/libs/notification/template/subscription-upgraded.ts +1 -5
  17. package/api/src/libs/payment.ts +47 -14
  18. package/api/src/libs/product.ts +1 -4
  19. package/api/src/libs/session.ts +600 -8
  20. package/api/src/libs/setting.ts +172 -0
  21. package/api/src/libs/subscription.ts +7 -69
  22. package/api/src/libs/ws.ts +5 -0
  23. package/api/src/queues/checkout-session.ts +42 -36
  24. package/api/src/queues/notification.ts +3 -2
  25. package/api/src/queues/payment.ts +33 -6
  26. package/api/src/queues/usage-record.ts +2 -10
  27. package/api/src/routes/checkout-sessions.ts +324 -187
  28. package/api/src/routes/connect/shared.ts +160 -38
  29. package/api/src/routes/connect/subscribe.ts +123 -64
  30. package/api/src/routes/payment-currencies.ts +3 -6
  31. package/api/src/routes/payment-links.ts +11 -1
  32. package/api/src/routes/payment-stats.ts +2 -2
  33. package/api/src/routes/payouts.ts +2 -1
  34. package/api/src/routes/settings.ts +45 -0
  35. package/api/src/routes/subscriptions.ts +1 -2
  36. package/api/src/store/migrations/20250408-subscription-grouping.ts +39 -0
  37. package/api/src/store/migrations/20250419-subscription-grouping.ts +69 -0
  38. package/api/src/store/models/checkout-session.ts +52 -0
  39. package/api/src/store/models/index.ts +1 -0
  40. package/api/src/store/models/payment-link.ts +6 -0
  41. package/api/src/store/models/subscription.ts +8 -6
  42. package/api/src/store/models/types.ts +31 -1
  43. package/api/tests/libs/session.spec.ts +423 -0
  44. package/api/tests/libs/subscription.spec.ts +0 -110
  45. package/blocklet.yml +3 -1
  46. package/package.json +20 -19
  47. package/scripts/sdk.js +486 -155
  48. package/src/locales/en.tsx +1 -1
  49. package/src/locales/zh.tsx +1 -1
  50. package/src/pages/admin/settings/vault-config/edit-form.tsx +1 -1
  51. package/src/pages/customer/subscription/change-payment.tsx +8 -3
@@ -0,0 +1,172 @@
1
+ import { Op } from 'sequelize';
2
+ import { CheckoutSession, Invoice, Setting, Subscription } from '../store/models';
3
+ import type { EventType, NotificationSetting } from '../store/models/types';
4
+ import logger from './logger';
5
+
6
+ const notificationSettingKey = 'notification_settings';
7
+ const settingIdKey = 'setting_id';
8
+
9
+ export const getSettingForNotification = async (settingKey: string | null): Promise<NotificationSetting | null> => {
10
+ if (!settingKey) {
11
+ return null;
12
+ }
13
+ try {
14
+ const setting = await Setting.findOne({
15
+ where: {
16
+ type: 'notification',
17
+ [Op.or]: [
18
+ {
19
+ id: settingKey,
20
+ },
21
+ {
22
+ component_did: settingKey,
23
+ },
24
+ {
25
+ mount_location: settingKey,
26
+ },
27
+ ],
28
+ },
29
+ });
30
+ return (setting?.settings || null) as NotificationSetting | null;
31
+ } catch (error) {
32
+ logger.error('getSettingForNotification error', error);
33
+ return null;
34
+ }
35
+ };
36
+
37
+ export function getDirectNotificationSetting(obj: {
38
+ metadata?: Record<string, any>;
39
+ subscription_data?: Record<string, any>;
40
+ }): NotificationSetting | null {
41
+ if (obj?.metadata?.[notificationSettingKey]) {
42
+ return obj.metadata[notificationSettingKey];
43
+ }
44
+
45
+ if (obj?.subscription_data?.[notificationSettingKey]) {
46
+ return obj.subscription_data[notificationSettingKey];
47
+ }
48
+
49
+ return null;
50
+ }
51
+
52
+ export function getSettingIdFromObject(obj: { metadata?: Record<string, any> }): string | null {
53
+ return obj?.metadata?.[settingIdKey] || null;
54
+ }
55
+
56
+ export async function getNotificationSettingFromSubscription(
57
+ subscription: Subscription
58
+ ): Promise<NotificationSetting | null> {
59
+ if (!subscription) {
60
+ return null;
61
+ }
62
+ const directSetting = getDirectNotificationSetting(subscription);
63
+ if (directSetting) {
64
+ return directSetting;
65
+ }
66
+
67
+ const settingId = getSettingIdFromObject(subscription);
68
+ if (settingId) {
69
+ return getSettingForNotification(settingId);
70
+ }
71
+
72
+ const checkoutSession = await CheckoutSession.findBySubscriptionId(subscription.id);
73
+ return checkoutSession ? getNotificationSettingFromCheckoutSession(checkoutSession) : null;
74
+ }
75
+
76
+ export async function getNotificationSettingFromCheckoutSession(
77
+ checkoutSession: CheckoutSession
78
+ ): Promise<NotificationSetting | null> {
79
+ if (!checkoutSession) {
80
+ return null;
81
+ }
82
+ const directSetting = getDirectNotificationSetting(checkoutSession);
83
+ if (directSetting) {
84
+ return directSetting;
85
+ }
86
+ const settingId = getSettingIdFromObject(checkoutSession);
87
+ if (settingId) {
88
+ const settings = await getSettingForNotification(settingId);
89
+ return settings || null;
90
+ }
91
+ return null;
92
+ }
93
+
94
+ export async function getNotificationSettingFromInvoice(invoice: Invoice): Promise<NotificationSetting | null> {
95
+ if (!invoice) {
96
+ return null;
97
+ }
98
+ const directSetting = getDirectNotificationSetting(invoice);
99
+ if (directSetting) {
100
+ return directSetting;
101
+ }
102
+
103
+ const settingId = getSettingIdFromObject(invoice);
104
+ if (settingId) {
105
+ return getSettingForNotification(settingId);
106
+ }
107
+
108
+ if (invoice.subscription_id) {
109
+ const subscription = await Subscription.findByPk(invoice.subscription_id);
110
+ if (subscription) {
111
+ return getNotificationSettingFromSubscription(subscription);
112
+ }
113
+ }
114
+
115
+ if (invoice.checkout_session_id) {
116
+ const checkoutSession = await CheckoutSession.findByPk(invoice.checkout_session_id);
117
+ if (checkoutSession) {
118
+ return getNotificationSettingFromCheckoutSession(checkoutSession);
119
+ }
120
+ }
121
+ return null;
122
+ }
123
+
124
+ export type NotificationSettingsProps = {
125
+ subscription?: Subscription;
126
+ invoice?: Invoice;
127
+ checkoutSession?: CheckoutSession;
128
+ };
129
+ export async function getNotificationSettings({
130
+ subscription,
131
+ invoice,
132
+ checkoutSession,
133
+ }: NotificationSettingsProps): Promise<NotificationSetting | null> {
134
+ if (subscription) {
135
+ const settings = await getNotificationSettingFromSubscription(subscription);
136
+ return settings || null;
137
+ }
138
+
139
+ if (checkoutSession) {
140
+ const settings = await getNotificationSettingFromCheckoutSession(checkoutSession);
141
+ return settings || null;
142
+ }
143
+
144
+ if (invoice) {
145
+ const settings = await getNotificationSettingFromInvoice(invoice);
146
+ return settings || null;
147
+ }
148
+
149
+ return null;
150
+ }
151
+
152
+ export function shouldSendSystemNotification(eventType: string, settings?: NotificationSetting | null): boolean {
153
+ if (!settings) {
154
+ return true;
155
+ }
156
+
157
+ const { include_events: includeEvents, exclude_events: excludeEvents, self_handle: selfHandle } = settings;
158
+ if (!selfHandle) {
159
+ // if self_handle is false, then the notification is sent by the system
160
+ return true;
161
+ }
162
+ if (includeEvents && includeEvents.length > 0) {
163
+ const isIncluded = includeEvents.includes(eventType as EventType);
164
+ return !isIncluded;
165
+ }
166
+
167
+ if (excludeEvents && excludeEvents.length > 0) {
168
+ const isExcluded = excludeEvents.includes(eventType as EventType);
169
+ return isExcluded;
170
+ }
171
+ return !selfHandle;
172
+ }
@@ -30,7 +30,12 @@ import { createEvent } from './audit';
30
30
  import dayjs from './dayjs';
31
31
  import env from './env';
32
32
  import logger from './logger';
33
- import { getPriceCurrencyOptions, getPriceUintAmountByCurrency, getRecurringPeriod } from './session';
33
+ import {
34
+ getPriceCurrencyOptions,
35
+ getPriceUintAmountByCurrency,
36
+ getRecurringPeriod,
37
+ getSubscriptionCreateSetup,
38
+ } from './session';
34
39
  import { getConnectQueryParam, getCustomerStakeAddress } from './util';
35
40
  import { wallet } from './auth';
36
41
  import { getGasPayerExtra } from './payment';
@@ -184,66 +189,6 @@ export function getSubscriptionStakeSetup(items: TLineItemExpanded[], currencyId
184
189
  return staking;
185
190
  }
186
191
 
187
- export function getSubscriptionCreateSetup(
188
- items: TLineItemExpanded[],
189
- currencyId: string,
190
- trialInDays = 0,
191
- trialEnd = 0
192
- ) {
193
- let setup = new BN(0);
194
-
195
- items.forEach((x) => {
196
- const price = getSubscriptionItemPrice(x);
197
- const unit = getPriceUintAmountByCurrency(price, currencyId);
198
- const amount = new BN(unit).mul(new BN(x.quantity));
199
- if (price.type === 'recurring') {
200
- if (price.recurring?.usage_type === 'licensed') {
201
- setup = setup.add(amount);
202
- }
203
- }
204
- if (price.type === 'one_time') {
205
- setup = setup.add(amount);
206
- }
207
- });
208
-
209
- const now = dayjs().unix();
210
- const item = items.find((x) => getSubscriptionItemPrice(x).type === 'recurring');
211
- const recurring = (item?.upsell_price || item?.price)?.recurring as PriceRecurring;
212
- const cycle = getRecurringPeriod(recurring);
213
-
214
- let trialStartAt = 0;
215
- let trialEndAt = 0;
216
- if (+trialEnd && trialEnd > now) {
217
- trialStartAt = now;
218
- trialEndAt = trialEnd;
219
- } else if (trialInDays) {
220
- trialStartAt = now;
221
- trialEndAt = dayjs().add(trialInDays, 'day').unix();
222
- }
223
-
224
- const periodStart = trialStartAt || now;
225
- const periodEnd = trialEndAt || dayjs().add(cycle, 'millisecond').unix();
226
-
227
- return {
228
- recurring,
229
- cycle: {
230
- duration: cycle,
231
- anchor: periodEnd,
232
- },
233
- trial: {
234
- start: trialStartAt,
235
- end: trialEndAt,
236
- },
237
- period: {
238
- start: periodStart,
239
- end: periodEnd,
240
- },
241
- amount: {
242
- setup: setup.toString(),
243
- },
244
- };
245
- }
246
-
247
192
  export function getSubscriptionCycleSetup(recurring: PriceRecurring, previousPeriodEnd: number) {
248
193
  const cycle = getRecurringPeriod(recurring);
249
194
 
@@ -262,7 +207,7 @@ export function getSubscriptionCycleAmount(items: TLineItemExpanded[], currencyI
262
207
 
263
208
  items.forEach((x) => {
264
209
  amount = amount.add(
265
- new BN(getPriceUintAmountByCurrency(getSubscriptionItemPrice(x), currencyId)).mul(new BN(x.quantity))
210
+ new BN(getPriceUintAmountByCurrency(getSubscriptionItemPrice(x) as any, currencyId)).mul(new BN(x.quantity))
266
211
  );
267
212
  });
268
213
 
@@ -458,13 +403,6 @@ export function shouldCancelSubscription(
458
403
  return false;
459
404
  }
460
405
 
461
- export function formatSubscriptionProduct(items: TLineItemExpanded[], maxLength = 3) {
462
- const names = items.map((x) => x.price?.product?.name).filter(Boolean);
463
- return (
464
- names.slice(0, maxLength).join(', ') + (names.length > maxLength ? ` and ${names.length - maxLength} more` : '')
465
- );
466
- }
467
-
468
406
  export async function canChangePaymentMethod(subscriptionId: string) {
469
407
  const item = await SubscriptionItem.findOne({ where: { subscription_id: subscriptionId } });
470
408
  const expanded = await Price.findOne({ where: { id: item!.price_id } });
@@ -88,4 +88,9 @@ export function initEventBroadcast() {
88
88
  events.on('customer.subscription.trial_end', (data: Subscription, extraParams?: Record<string, any>) => {
89
89
  broadcast('customer.subscription.trial_end', data, extraParams);
90
90
  });
91
+
92
+ // notification events
93
+ events.on('manual.notification', (data: Record<string, any>) => {
94
+ broadcast('manual.notification', data);
95
+ });
91
96
  }
@@ -20,6 +20,7 @@ import {
20
20
  Subscription,
21
21
  SubscriptionItem,
22
22
  } from '../store/models';
23
+ import { getCheckoutSessionSubscriptionIds } from '../libs/session';
23
24
 
24
25
  type CheckoutSessionJob = {
25
26
  id: string;
@@ -194,44 +195,49 @@ events.on('checkout.session.expired', async (checkoutSession: CheckoutSession) =
194
195
  });
195
196
  }
196
197
 
197
- if (checkoutSession.subscription_id) {
198
- const subscription = await Subscription.findByPk(checkoutSession.subscription_id);
199
- const stripeSubscriptionId = subscription?.payment_details?.stripe?.subscription_id;
200
- if (subscription && stripeSubscriptionId) {
201
- const method = await PaymentMethod.findByPk(subscription.default_payment_method_id);
202
- if (method?.type === 'stripe') {
203
- const client = method.getStripeClient();
204
- try {
205
- await client.subscriptions.cancel(stripeSubscriptionId, {
206
- prorate: false,
207
- invoice_now: false,
208
- cancellation_details: {
209
- comment: 'checkout_session_expired',
210
- feedback: 'unused',
211
- },
212
- });
213
- logger.info('Stripe Subscription for checkout session canceled on expire', {
214
- checkoutSession: checkoutSession.id,
215
- subscription: checkoutSession.subscription_id,
216
- stripeSubscription: stripeSubscriptionId,
217
- });
218
- } catch (err) {
219
- logger.error('Stripe Subscription for checkout session cancel failed on expire', {
220
- checkoutSession: checkoutSession.id,
221
- subscription: checkoutSession.subscription_id,
222
- stripeSubscription: stripeSubscriptionId,
223
- error: err,
224
- });
198
+ const subscriptionIds = getCheckoutSessionSubscriptionIds(checkoutSession);
199
+ if (subscriptionIds.length > 0) {
200
+ const subscriptions = await Subscription.findAll({ where: { id: { [Op.in]: subscriptionIds } } });
201
+ await Promise.all(
202
+ subscriptions.map(async (subscription) => {
203
+ const stripeSubscriptionId = subscription?.payment_details?.stripe?.subscription_id;
204
+ if (subscription && stripeSubscriptionId) {
205
+ const method = await PaymentMethod.findByPk(subscription.default_payment_method_id);
206
+ if (method?.type === 'stripe') {
207
+ const client = method.getStripeClient();
208
+ try {
209
+ await client.subscriptions.cancel(stripeSubscriptionId, {
210
+ prorate: false,
211
+ invoice_now: false,
212
+ cancellation_details: {
213
+ comment: 'checkout_session_expired',
214
+ feedback: 'unused',
215
+ },
216
+ });
217
+ logger.info('Stripe Subscription for checkout session canceled on expire', {
218
+ checkoutSession: checkoutSession.id,
219
+ subscription: checkoutSession.subscription_id,
220
+ stripeSubscription: stripeSubscriptionId,
221
+ });
222
+ } catch (err) {
223
+ logger.error('Stripe Subscription for checkout session cancel failed on expire', {
224
+ checkoutSession: checkoutSession.id,
225
+ subscription: checkoutSession.subscription_id,
226
+ stripeSubscription: stripeSubscriptionId,
227
+ error: err,
228
+ });
229
+ }
230
+ }
225
231
  }
226
- }
227
- }
228
232
 
229
- await SubscriptionItem.destroy({ where: { subscription_id: checkoutSession.subscription_id } });
230
- await Subscription.destroy({ where: { id: checkoutSession.subscription_id } });
231
- logger.info('Subscription and SubscriptionItem for checkout session deleted on expire', {
232
- checkoutSession: checkoutSession.id,
233
- subscription: checkoutSession.subscription_id,
234
- });
233
+ await SubscriptionItem.destroy({ where: { subscription_id: subscription.id } });
234
+ await Subscription.destroy({ where: { id: subscription.id } });
235
+ logger.info('Subscription and SubscriptionItem for checkout session deleted on expire', {
236
+ checkoutSession: checkoutSession.id,
237
+ subscription: subscription.id,
238
+ });
239
+ })
240
+ );
235
241
  }
236
242
 
237
243
  // update price lock status
@@ -235,7 +235,8 @@ function getNotificationTemplate(job: NotificationQueueJob): BaseEmailTemplate {
235
235
  async function handleNotificationJob(job: NotificationQueueJob): Promise<void> {
236
236
  try {
237
237
  const template = getNotificationTemplate(job);
238
- await new Notification(template).send();
238
+
239
+ await new Notification(template, job.type).send();
239
240
  logger.info('handleImmediateNotificationJob.success', { job });
240
241
  } catch (error) {
241
242
  logger.error('handleImmediateNotificationJob.error', error);
@@ -384,7 +385,7 @@ function getAggregatedNotificationTemplate(job: AggregatedNotificationJob): Base
384
385
  async function handleAggregatedNotificationJob(job: AggregatedNotificationJob): Promise<void> {
385
386
  try {
386
387
  const template = await getAggregatedNotificationTemplate(job);
387
- await new Notification(template).send();
388
+ await new Notification(template, job.type).send();
388
389
  logger.info('handleAggregatedNotificationJob.success', { job });
389
390
  } catch (error) {
390
391
  logger.error('handleAggregatedNotificationJob.error', error);
@@ -18,7 +18,6 @@ import {
18
18
  getDueUnit,
19
19
  getMaxRetryCount,
20
20
  getMinRetryMail,
21
- getSubscriptionCreateSetup,
22
21
  getSubscriptionStakeAddress,
23
22
  isSubscriptionOverdraftProtectionEnabled,
24
23
  shouldCancelSubscription,
@@ -41,6 +40,7 @@ import { Lock } from '../store/models';
41
40
  import { ensureOverdraftProtectionPrice } from '../libs/overdraft-protection';
42
41
  import createQueue from '../libs/queue';
43
42
  import { CHARGE_SUPPORTED_CHAIN_TYPES, EVM_CHAIN_TYPES } from '../libs/constants';
43
+ import { getCheckoutSessionSubscriptionIds, getSubscriptionCreateSetup } from '../libs/session';
44
44
 
45
45
  type PaymentJob = {
46
46
  paymentIntentId: string;
@@ -232,6 +232,19 @@ export const handlePaymentSucceed = async (
232
232
  if (isEmpty(result)) {
233
233
  // reset billing cycle anchor and cancel_* if we are recovering from payment failed
234
234
  if (subscription.cancel_at && subscription.cancel_at !== subscription.current_period_end) {
235
+ const now = dayjs().unix();
236
+ if (now <= subscription.current_period_end) {
237
+ // if payment succeeds before current_period_end, we should active this subscription
238
+ await subscription.update({
239
+ status: 'active',
240
+ cancel_at: 0,
241
+ cancel_at_period_end: false,
242
+ // @ts-ignore
243
+ cancelation_details: null,
244
+ });
245
+ logger.info(`Subscription ${subscription.id} recovered on payment done ${paymentIntent.id}: cancel rest`);
246
+ return;
247
+ }
235
248
  const subscriptionItems = await SubscriptionItem.findAll({ where: { subscription_id: subscription.id } });
236
249
  const lineItems = await Price.expand(subscriptionItems.map((x) => x.toJSON()));
237
250
  const setup = getSubscriptionCreateSetup(lineItems, subscription.currency_id, 0);
@@ -288,11 +301,24 @@ export const handlePaymentSucceed = async (
288
301
  checkoutSessionId: checkoutSession.id,
289
302
  });
290
303
  });
291
- await checkoutSession.update({
292
- status: 'complete',
293
- payment_status: 'paid',
294
- payment_details: paymentIntent.payment_details,
295
- });
304
+ if (['subscription', 'setup'].includes(checkoutSession.mode) && invoice.subscription_id) {
305
+ await checkoutSession.increment('success_subscription_count', { by: 1 });
306
+ await checkoutSession.reload();
307
+ const subscriptionIds = getCheckoutSessionSubscriptionIds(checkoutSession);
308
+ if (checkoutSession.success_subscription_count === subscriptionIds.length) {
309
+ await checkoutSession.update({
310
+ status: 'complete',
311
+ payment_status: 'paid',
312
+ payment_details: paymentIntent.payment_details,
313
+ });
314
+ }
315
+ } else {
316
+ await checkoutSession.update({
317
+ status: 'complete',
318
+ payment_status: 'paid',
319
+ payment_details: paymentIntent.payment_details,
320
+ });
321
+ }
296
322
  logger.info(`CheckoutSession ${checkoutSession.id} updated on payment done ${paymentIntent.id}`);
297
323
  }
298
324
  }
@@ -510,6 +536,7 @@ export const handlePaymentFailed = async (
510
536
  logger.warn('Subscription moved to past_due after payment failed', {
511
537
  subscription: subscription.id,
512
538
  payment: paymentIntent.id,
539
+ cancelUpdates,
513
540
  gracePeriodStart,
514
541
  graceDuration,
515
542
  dueUnit,
@@ -5,15 +5,7 @@ import { getLock } from '../libs/lock';
5
5
  import logger from '../libs/logger';
6
6
  import createQueue from '../libs/queue';
7
7
  import { getPriceUintAmountByCurrency } from '../libs/session';
8
- import {
9
- Invoice,
10
- PaymentCurrency,
11
- Price,
12
- SubscriptionItem,
13
- TLineItemExpanded,
14
- TPrice,
15
- UsageRecord,
16
- } from '../store/models';
8
+ import { Invoice, PaymentCurrency, Price, SubscriptionItem, TLineItemExpanded, UsageRecord } from '../store/models';
17
9
  import { Subscription } from '../store/models/subscription';
18
10
  import { invoiceQueue } from './invoice';
19
11
  import { handleSubscriptionInvoice } from './subscription';
@@ -103,7 +95,7 @@ export const doHandleUsageRecord = async (job: UsageRecordJob) => {
103
95
  });
104
96
  // @ts-ignore
105
97
  const quantity = expanded?.price.transformQuantity(rawQuantity);
106
- const unitAmount = getPriceUintAmountByCurrency(expanded?.price as TPrice, subscription.currency_id);
98
+ const unitAmount = expanded?.price ? getPriceUintAmountByCurrency(expanded?.price, subscription.currency_id) : 0;
107
99
  const totalAmount = new BN(quantity).mul(new BN(unitAmount));
108
100
  const threshold = fromTokenToUnit(subscription.billing_thresholds.amount_gte, currency.decimal);
109
101
  logger.info('SubscriptionItem Usage check', {