payment-kit 1.13.136 → 1.13.138

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 (39) hide show
  1. package/api/src/crons/base.ts +7 -33
  2. package/api/src/crons/index.ts +7 -0
  3. package/api/src/crons/interface/base.ts +17 -0
  4. package/api/src/crons/subscription-trail-will-end.ts +29 -1
  5. package/api/src/crons/subscription-will-canceled.ts +48 -0
  6. package/api/src/crons/subscription-will-renew.ts +29 -2
  7. package/api/src/libs/invoice.ts +20 -6
  8. package/api/src/libs/notification/template/one-time-payment-succeeded.ts +245 -0
  9. package/api/src/libs/notification/template/subscription-cacceled.ts +241 -0
  10. package/api/src/libs/notification/template/subscription-refund-succeeded.ts +286 -0
  11. package/api/src/libs/notification/template/subscription-renew-failed.ts +17 -6
  12. package/api/src/libs/notification/template/subscription-renewed.ts +27 -6
  13. package/api/src/libs/notification/template/subscription-succeeded.ts +14 -5
  14. package/api/src/libs/notification/template/subscription-trial-start.ts +13 -4
  15. package/api/src/libs/notification/template/subscription-trial-will-end.ts +7 -3
  16. package/api/src/libs/notification/template/subscription-upgraded.ts +261 -0
  17. package/api/src/libs/notification/template/subscription-will-canceled.ts +225 -0
  18. package/api/src/libs/notification/template/subscription-will-renew.ts +30 -3
  19. package/api/src/libs/product.ts +24 -0
  20. package/api/src/libs/queue/index.ts +2 -0
  21. package/api/src/libs/queue/store.ts +1 -1
  22. package/api/src/libs/security.ts +1 -1
  23. package/api/src/libs/subscription.ts +19 -3
  24. package/api/src/libs/util.ts +33 -0
  25. package/api/src/locales/en.ts +38 -4
  26. package/api/src/locales/zh.ts +36 -2
  27. package/api/src/queues/notification.ts +91 -2
  28. package/api/src/routes/connect/setup.ts +2 -2
  29. package/api/src/routes/connect/shared.ts +2 -2
  30. package/api/src/store/models/subscription.ts +5 -0
  31. package/api/src/store/models/types.ts +3 -0
  32. package/blocklet.yml +1 -1
  33. package/package.json +44 -44
  34. package/src/contexts/session.ts +2 -2
  35. package/src/pages/admin/payments/links/create.tsx +1 -1
  36. package/src/pages/admin/products/pricing-tables/create.tsx +1 -1
  37. package/src/pages/customer/invoice.tsx +12 -3
  38. package/src/pages/customer/subscription/update.tsx +1 -1
  39. package/api/src/crons/interface/diff.ts +0 -9
@@ -4,26 +4,17 @@ import pAll from 'p-all';
4
4
 
5
5
  import { notificationCronConcurrency } from '../libs/env';
6
6
  import logger from '../libs/logger';
7
- import type { SubscriptionTrialWillEndEmailTemplateOptions } from '../libs/notification/template/subscription-trial-will-end';
8
- import { NotificationQueueJob, notificationQueue } from '../queues/notification';
7
+ import { NotificationQueueJobType, notificationQueue } from '../queues/notification';
9
8
  import type { Subscription } from '../store/models';
10
- import type { Diff } from './interface/diff';
9
+ import type { Diff, Task } from './interface/base';
11
10
 
12
11
  export interface BaseSubscriptionScheduleNotificationSubscription extends Subscription {
13
12
  diffs: Diff[];
14
13
  }
15
14
 
16
- export type BaseSubscriptionScheduleNotificationEventType =
17
- | 'customer.subscription.trial_will_end'
18
- | 'customer.subscription.will_renew';
15
+ export type BaseSubscriptionScheduleNotificationEventType = NotificationQueueJobType;
19
16
 
20
- type Task<Options> = {
21
- id: string;
22
- job: { type: NotificationQueueJob['type']; options: Options };
23
- delay: number;
24
- };
25
-
26
- export abstract class BaseSubscriptionScheduleNotification {
17
+ export abstract class BaseSubscriptionScheduleNotification<Options extends any> {
27
18
  readonly start: number;
28
19
  readonly end: number;
29
20
  abstract eventType: BaseSubscriptionScheduleNotificationEventType;
@@ -154,30 +145,13 @@ export abstract class BaseSubscriptionScheduleNotification {
154
145
  });
155
146
  }
156
147
 
157
- async addTaskToQueue<Options extends SubscriptionTrialWillEndEmailTemplateOptions>(
158
- subscriptions: BaseSubscriptionScheduleNotificationSubscription[]
159
- ): Promise<void> {
160
- const type = this.eventType;
148
+ abstract getTask({ subscription, diff }: { subscription: Subscription; diff: Diff }): Task<Options>;
161
149
 
150
+ async addTaskToQueue(subscriptions: BaseSubscriptionScheduleNotificationSubscription[]): Promise<void> {
162
151
  const tasks: Task<Options>[] = [];
163
152
  for (const subscription of subscriptions) {
164
153
  for (const diff of subscription.diffs) {
165
- const task: Task<Options> = {
166
- id: `${subscription.id}.${type}.${diff.value}.${diff.unit}`,
167
- job: {
168
- type,
169
- options: {
170
- subscriptionId: subscription.id,
171
- willRenewValue: diff.value,
172
- willRenewUnit: diff.unit,
173
- required: !!diff.required,
174
- } as Options,
175
- },
176
- delay: dayjs(subscription.current_period_end * 1000)
177
- .subtract(diff.value, diff.unit)
178
- .diff(this.start, 's'),
179
- };
180
-
154
+ const task: Task<Options> = this.getTask({ subscription, diff });
181
155
  tasks.push(task);
182
156
  }
183
157
  }
@@ -5,6 +5,7 @@ import logger from '../libs/logger';
5
5
  import { startSubscriptionQueue } from '../queues/subscription';
6
6
  import { CheckoutSession } from '../store/models';
7
7
  import { SubscriptionTrailWillEndSchedule } from './subscription-trail-will-end';
8
+ import { SubscriptionWillCanceledSchedule } from './subscription-will-canceled';
8
9
  import { SubscriptionWillRenewSchedule } from './subscription-will-renew';
9
10
 
10
11
  function init() {
@@ -23,6 +24,12 @@ function init() {
23
24
  fn: () => new SubscriptionTrailWillEndSchedule().run(),
24
25
  options: { runOnInit: true },
25
26
  },
27
+ {
28
+ name: 'customer.subscription.will_canceled',
29
+ time: notificationCronTime,
30
+ fn: () => new SubscriptionWillCanceledSchedule().run(),
31
+ options: { runOnInit: true },
32
+ },
26
33
  {
27
34
  name: 'subscription.schedule.retry',
28
35
  time: subscriptionCronTime,
@@ -0,0 +1,17 @@
1
+ import type { ManipulateType } from 'dayjs';
2
+
3
+ import type { NotificationQueueJob } from '../../queues/notification';
4
+
5
+ export interface Diff {
6
+ value: number;
7
+ unit: ManipulateType;
8
+
9
+ // 这个 job 必须跑吗?
10
+ required?: false | true;
11
+ }
12
+
13
+ export type Task<Options> = {
14
+ id: string;
15
+ job: { type: NotificationQueueJob['type']; options: Options };
16
+ delay: number;
17
+ };
@@ -1,10 +1,13 @@
1
+ import dayjs from 'dayjs';
1
2
  /* eslint-disable no-console */
2
3
  import { Op } from 'sequelize';
3
4
 
5
+ import type { SubscriptionTrialWillEndEmailTemplateOptions } from '../libs/notification/template/subscription-trial-will-end';
4
6
  import { Subscription } from '../store/models';
5
7
  import { BaseSubscriptionScheduleNotification, BaseSubscriptionScheduleNotificationEventType } from './base';
8
+ import type { Diff, Task } from './interface/base';
6
9
 
7
- export class SubscriptionTrailWillEndSchedule extends BaseSubscriptionScheduleNotification {
10
+ export class SubscriptionTrailWillEndSchedule extends BaseSubscriptionScheduleNotification<SubscriptionTrialWillEndEmailTemplateOptions> {
8
11
  override eventType: BaseSubscriptionScheduleNotificationEventType = 'customer.subscription.trial_will_end';
9
12
 
10
13
  async getSubscriptions(): Promise<Subscription[]> {
@@ -28,4 +31,29 @@ export class SubscriptionTrailWillEndSchedule extends BaseSubscriptionScheduleNo
28
31
 
29
32
  return subscriptions;
30
33
  }
34
+
35
+ override getTask({
36
+ subscription,
37
+ diff,
38
+ }: {
39
+ subscription: Subscription;
40
+ diff: Diff;
41
+ }): Task<SubscriptionTrialWillEndEmailTemplateOptions> {
42
+ const type = this.eventType;
43
+ return {
44
+ id: `${subscription.id}.${type}.${diff.value}.${diff.unit}`,
45
+ job: {
46
+ type,
47
+ options: {
48
+ subscriptionId: subscription.id,
49
+ willRenewValue: diff.value,
50
+ willRenewUnit: diff.unit,
51
+ required: !!diff.required,
52
+ },
53
+ },
54
+ delay: dayjs(subscription.current_period_end * 1000)
55
+ .subtract(diff.value, diff.unit)
56
+ .diff(this.start, 's'),
57
+ };
58
+ }
31
59
  }
@@ -0,0 +1,48 @@
1
+ /* eslint-disable no-console */
2
+ import dayjs from 'dayjs';
3
+
4
+ import type { SubscriptionWillCanceledEmailTemplateOptions } from '../libs/notification/template/subscription-will-canceled';
5
+ import { Subscription } from '../store/models';
6
+ import { BaseSubscriptionScheduleNotification, BaseSubscriptionScheduleNotificationEventType } from './base';
7
+ import type { Diff, Task } from './interface/base';
8
+
9
+ export class SubscriptionWillCanceledSchedule extends BaseSubscriptionScheduleNotification<SubscriptionWillCanceledEmailTemplateOptions> {
10
+ override eventType: BaseSubscriptionScheduleNotificationEventType = 'customer.subscription.will_canceled';
11
+
12
+ async getSubscriptions(): Promise<Subscription[]> {
13
+ const subscriptions = await Subscription.findAll({
14
+ where: {
15
+ status: 'past_due',
16
+ },
17
+ attributes: ['id', 'current_period_start', 'current_period_end'],
18
+ raw: true,
19
+ });
20
+
21
+ return subscriptions;
22
+ }
23
+
24
+ override getTask({
25
+ subscription,
26
+ diff,
27
+ }: {
28
+ subscription: Subscription;
29
+ diff: Diff;
30
+ }): Task<SubscriptionWillCanceledEmailTemplateOptions> {
31
+ const type = this.eventType;
32
+ return {
33
+ id: `${subscription.id}.${type}.${diff.value}.${diff.unit}`,
34
+ job: {
35
+ type,
36
+ options: {
37
+ subscriptionId: subscription.id,
38
+ willCancelValue: diff.value,
39
+ willCancelUnit: diff.unit,
40
+ required: !!diff.required,
41
+ },
42
+ },
43
+ delay: dayjs(subscription.current_period_end * 1000)
44
+ .subtract(diff.value, diff.unit)
45
+ .diff(this.start, 's'),
46
+ };
47
+ }
48
+ }
@@ -1,10 +1,12 @@
1
- /* eslint-disable no-console */
1
+ import dayjs from 'dayjs';
2
2
  import { Op } from 'sequelize';
3
3
 
4
+ import type { SubscriptionWillRenewEmailTemplateOptions } from '../libs/notification/template/subscription-will-renew';
4
5
  import { Subscription } from '../store/models';
5
6
  import { BaseSubscriptionScheduleNotification, BaseSubscriptionScheduleNotificationEventType } from './base';
7
+ import type { Diff, Task } from './interface/base';
6
8
 
7
- export class SubscriptionWillRenewSchedule extends BaseSubscriptionScheduleNotification {
9
+ export class SubscriptionWillRenewSchedule extends BaseSubscriptionScheduleNotification<SubscriptionWillRenewEmailTemplateOptions> {
8
10
  override eventType: BaseSubscriptionScheduleNotificationEventType = 'customer.subscription.will_renew';
9
11
 
10
12
  async getSubscriptions(): Promise<Subscription[]> {
@@ -26,4 +28,29 @@ export class SubscriptionWillRenewSchedule extends BaseSubscriptionScheduleNotif
26
28
 
27
29
  return subscriptions;
28
30
  }
31
+
32
+ override getTask({
33
+ subscription,
34
+ diff,
35
+ }: {
36
+ subscription: Subscription;
37
+ diff: Diff;
38
+ }): Task<SubscriptionWillRenewEmailTemplateOptions> {
39
+ const type = this.eventType;
40
+ return {
41
+ id: `${subscription.id}.${type}.${diff.value}.${diff.unit}`,
42
+ job: {
43
+ type,
44
+ options: {
45
+ subscriptionId: subscription.id,
46
+ willRenewValue: diff.value,
47
+ willRenewUnit: diff.unit,
48
+ required: !!diff.required,
49
+ },
50
+ },
51
+ delay: dayjs(subscription.current_period_end * 1000)
52
+ .subtract(diff.value, diff.unit)
53
+ .diff(this.start, 's'),
54
+ };
55
+ }
29
56
  }
@@ -1,10 +1,24 @@
1
+ import querystring from 'querystring';
2
+
1
3
  import { component } from '@blocklet/sdk';
2
4
  import type { LiteralUnion } from 'type-fest';
3
5
 
4
- export function getCustomerInvoicePageUrl(
5
- invoiceId: string,
6
- locale: string = 'en',
7
- action: LiteralUnion<'pay', string> = ''
8
- ) {
9
- return component.getUrl(`customer/invoice/${invoiceId}?locale=${locale}&action=${action}`);
6
+ import { getConnectQueryParam } from './util';
7
+
8
+ export function getCustomerInvoicePageUrl({
9
+ invoiceId,
10
+ userDid,
11
+ locale = 'en',
12
+ action = '',
13
+ }: {
14
+ userDid: string;
15
+ invoiceId: string;
16
+ locale: LiteralUnion<'en' | 'zh', string>;
17
+ action?: LiteralUnion<'pay', string>;
18
+ }) {
19
+ return component.getUrl(
20
+ `customer/invoice/${invoiceId}?locale=${locale}&action=${action}&${querystring.stringify(
21
+ getConnectQueryParam({ userDid })
22
+ )}`
23
+ );
10
24
  }
@@ -0,0 +1,245 @@
1
+ /* eslint-disable @typescript-eslint/brace-style */
2
+ /* eslint-disable @typescript-eslint/indent */
3
+ import { fromUnitToToken } from '@ocap/util';
4
+ import pWaitFor from 'p-wait-for';
5
+
6
+ import { getUserLocale } from '../../../integrations/blocklet/notification';
7
+ import { translate } from '../../../locales';
8
+ import { CheckoutSession, Customer, NftMintItem, PaymentIntent, PaymentMethod } from '../../../store/models';
9
+ import { PaymentCurrency } from '../../../store/models/payment-currency';
10
+ import { getMainProductNameByCheckoutSession } from '../../product';
11
+ import { formatTime } from '../../time';
12
+ import { getExplorerLink } from '../../util';
13
+ import type { BaseEmailTemplate, BaseEmailTemplateType } from './base';
14
+
15
+ export interface OneTimePaymentSucceededEmailTemplateOptions {
16
+ checkoutSessionId: string;
17
+ }
18
+
19
+ interface OneTimePaymentSucceededEmailTemplateContext {
20
+ locale: string;
21
+ productName: string;
22
+ at: string;
23
+
24
+ nftMintItem: NftMintItem | undefined;
25
+ chainHost: string | undefined;
26
+ userDid: string;
27
+ paymentInfo: string;
28
+
29
+ viewSubscriptionLink: string;
30
+ viewInvoiceLink: string;
31
+ viewTxHashLink: string | undefined;
32
+ }
33
+
34
+ /**
35
+ * @see https://github.com/blocklet/payment-kit/issues/236#issue-2007698930
36
+ * @description
37
+ * @export
38
+ * @class OneTimePaymentSucceededEmailTemplate
39
+ * @implements {BaseEmailTemplate<OneTimePaymentSucceededEmailTemplateContext>}
40
+ */
41
+ export class OneTimePaymentSucceededEmailTemplate
42
+ implements BaseEmailTemplate<OneTimePaymentSucceededEmailTemplateContext>
43
+ {
44
+ options: OneTimePaymentSucceededEmailTemplateOptions;
45
+
46
+ constructor(options: OneTimePaymentSucceededEmailTemplateOptions) {
47
+ this.options = options;
48
+ }
49
+
50
+ async getContext(): Promise<OneTimePaymentSucceededEmailTemplateContext> {
51
+ const cs: CheckoutSession | null = await CheckoutSession.findByPk(this.options.checkoutSessionId);
52
+ if (!cs) {
53
+ throw new Error(`CheckoutSession(${this.options.checkoutSessionId}) not found`);
54
+ }
55
+ if (cs.mode !== 'payment') {
56
+ throw new Error(`CheckoutSession(${this.options.checkoutSessionId}) mode must be payment`);
57
+ }
58
+
59
+ const customer = await Customer.findByPk(cs.customer_id);
60
+ if (!customer) {
61
+ throw new Error(`Customer not found: ${cs.customer_id}`);
62
+ }
63
+
64
+ await pWaitFor(
65
+ async () => {
66
+ const checkoutSession = await CheckoutSession.findOne({
67
+ where: {
68
+ id: cs.id,
69
+ },
70
+ });
71
+
72
+ return Boolean(['disabled', 'minted', 'sent', 'error'].includes(checkoutSession?.nft_mint_status as string));
73
+ },
74
+ { timeout: 1000 * 10, interval: 1000 }
75
+ );
76
+
77
+ const checkoutSession = (await CheckoutSession.findByPk(cs.id)) as CheckoutSession;
78
+ const paymentCurrency = (await PaymentCurrency.findOne({
79
+ where: {
80
+ id: checkoutSession.currency_id,
81
+ },
82
+ })) as PaymentCurrency;
83
+
84
+ const userDid: string = customer.did;
85
+ const locale = await getUserLocale(userDid);
86
+ const productName = await getMainProductNameByCheckoutSession(checkoutSession);
87
+ const at: string = formatTime(checkoutSession.created_at);
88
+
89
+ const paymentInfo: string = `${fromUnitToToken(checkoutSession?.amount_total, paymentCurrency.decimal)} ${
90
+ paymentCurrency.symbol
91
+ }`;
92
+ const hasNft: boolean = checkoutSession?.nft_mint_status === 'minted';
93
+ const nftMintItem: NftMintItem | undefined = hasNft
94
+ ? // @ts-expect-error
95
+ checkoutSession?.nft_mint_details?.[paymentMethod.type]
96
+ : undefined;
97
+
98
+ const paymentIntent = await PaymentIntent.findByPk(checkoutSession!.payment_intent_id);
99
+ const paymentMethod: PaymentMethod | null = await PaymentMethod.findByPk(paymentIntent!.payment_method_id);
100
+ // @ts-expect-error
101
+ const chainHost: string | undefined = paymentMethod?.settings?.[paymentMethod.type]?.api_host;
102
+ const viewSubscriptionLink = '';
103
+ const viewInvoiceLink = '';
104
+
105
+ // @ts-expect-error
106
+ const txHash: string | undefined = paymentIntent?.payment_details?.[paymentMethod.type]?.tx_hash;
107
+ const viewTxHashLink: string | undefined = txHash && getExplorerLink(chainHost, txHash as string, 'tx');
108
+
109
+ return {
110
+ locale,
111
+ productName,
112
+ at,
113
+
114
+ userDid,
115
+ nftMintItem,
116
+ chainHost,
117
+ paymentInfo,
118
+
119
+ viewSubscriptionLink,
120
+ viewInvoiceLink,
121
+ viewTxHashLink,
122
+ };
123
+ }
124
+
125
+ async getTemplate(): Promise<BaseEmailTemplateType> {
126
+ const {
127
+ locale,
128
+ productName,
129
+ at,
130
+ nftMintItem,
131
+ chainHost,
132
+ userDid,
133
+ paymentInfo,
134
+ viewSubscriptionLink,
135
+ viewInvoiceLink,
136
+ viewTxHashLink,
137
+ } = await this.getContext();
138
+
139
+ const template: BaseEmailTemplateType = {
140
+ title: `${translate('notification.oneTimePaymentSucceeded.title', locale, {
141
+ productName: `(${productName})`,
142
+ })}`,
143
+ body: `${translate('notification.oneTimePaymentSucceeded.body', locale, {
144
+ at,
145
+ productName: `(${productName})`,
146
+ })}`,
147
+ // @ts-expect-error
148
+ attachments: [
149
+ nftMintItem &&
150
+ chainHost && {
151
+ type: 'asset',
152
+ data: {
153
+ chainHost,
154
+ did: nftMintItem.address,
155
+ },
156
+ },
157
+ {
158
+ type: 'section',
159
+ fields: [
160
+ {
161
+ type: 'text',
162
+ data: {
163
+ type: 'plain',
164
+ color: '#9397A1',
165
+ text: translate('notification.common.account', locale),
166
+ },
167
+ },
168
+ {
169
+ type: 'text',
170
+ data: {
171
+ type: 'plain',
172
+ text: userDid,
173
+ },
174
+ },
175
+ {
176
+ type: 'text',
177
+ data: {
178
+ type: 'plain',
179
+ color: '#9397A1',
180
+ text: translate('notification.common.product', locale),
181
+ },
182
+ },
183
+ {
184
+ type: 'text',
185
+ data: {
186
+ type: 'plain',
187
+ text: productName,
188
+ },
189
+ },
190
+ {
191
+ type: 'text',
192
+ data: {
193
+ type: 'plain',
194
+ color: '#9397A1',
195
+ text: translate('notification.common.paymentInfo', locale),
196
+ },
197
+ },
198
+ {
199
+ type: 'text',
200
+ data: {
201
+ type: 'plain',
202
+ text: paymentInfo,
203
+ },
204
+ },
205
+ {
206
+ type: 'text',
207
+ data: {
208
+ type: 'plain',
209
+ color: '#9397A1',
210
+ text: translate('notification.common.validityPeriod', locale),
211
+ },
212
+ },
213
+ {
214
+ type: 'text',
215
+ data: {
216
+ type: 'plain',
217
+ text: translate('notification.common.permanent', locale),
218
+ },
219
+ },
220
+ ].filter(Boolean),
221
+ },
222
+ ].filter(Boolean),
223
+ // @ts-ignore
224
+ actions: [
225
+ viewSubscriptionLink && {
226
+ name: 'viewSubscription',
227
+ title: translate('notification.common.viewSubscription', locale),
228
+ link: viewSubscriptionLink,
229
+ },
230
+ viewInvoiceLink && {
231
+ name: 'viewSubscription',
232
+ title: translate('notification.common.viewInvoice', locale),
233
+ link: viewInvoiceLink,
234
+ },
235
+ viewTxHashLink && {
236
+ name: 'viewTxHash',
237
+ title: translate('notification.common.viewTxHash', locale),
238
+ link: viewTxHashLink as string,
239
+ },
240
+ ].filter(Boolean),
241
+ };
242
+
243
+ return template;
244
+ }
245
+ }