payment-kit 1.15.20 → 1.15.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/api/src/crons/base.ts +69 -7
- package/api/src/crons/subscription-trial-will-end.ts +20 -5
- package/api/src/crons/subscription-will-canceled.ts +22 -6
- package/api/src/crons/subscription-will-renew.ts +13 -4
- package/api/src/index.ts +4 -1
- package/api/src/integrations/arcblock/stake.ts +27 -0
- package/api/src/libs/audit.ts +4 -1
- package/api/src/libs/context.ts +48 -0
- package/api/src/libs/invoice.ts +2 -2
- package/api/src/libs/middleware.ts +39 -1
- package/api/src/libs/notification/template/subscription-canceled.ts +4 -0
- package/api/src/libs/notification/template/subscription-trial-will-end.ts +12 -34
- package/api/src/libs/notification/template/subscription-will-canceled.ts +82 -48
- package/api/src/libs/notification/template/subscription-will-renew.ts +16 -45
- package/api/src/libs/time.ts +13 -0
- package/api/src/libs/util.ts +17 -0
- package/api/src/locales/en.ts +12 -2
- package/api/src/locales/zh.ts +11 -2
- package/api/src/queues/checkout-session.ts +15 -0
- package/api/src/queues/event.ts +13 -4
- package/api/src/queues/invoice.ts +21 -3
- package/api/src/queues/payment.ts +3 -0
- package/api/src/queues/refund.ts +3 -0
- package/api/src/queues/subscription.ts +107 -2
- package/api/src/queues/usage-record.ts +4 -0
- package/api/src/queues/webhook.ts +9 -0
- package/api/src/routes/checkout-sessions.ts +40 -2
- package/api/src/routes/connect/recharge.ts +143 -0
- package/api/src/routes/connect/shared.ts +25 -0
- package/api/src/routes/customers.ts +2 -2
- package/api/src/routes/donations.ts +5 -1
- package/api/src/routes/events.ts +9 -4
- package/api/src/routes/payment-links.ts +40 -20
- package/api/src/routes/prices.ts +17 -4
- package/api/src/routes/products.ts +21 -2
- package/api/src/routes/refunds.ts +20 -3
- package/api/src/routes/subscription-items.ts +39 -2
- package/api/src/routes/subscriptions.ts +77 -40
- package/api/src/routes/usage-records.ts +29 -0
- package/api/src/store/models/event.ts +1 -0
- package/api/src/store/models/subscription.ts +2 -0
- package/api/tests/libs/time.spec.ts +54 -0
- package/blocklet.yml +1 -1
- package/package.json +19 -19
- package/src/app.tsx +10 -0
- package/src/components/subscription/actions/cancel.tsx +30 -9
- package/src/components/subscription/actions/index.tsx +11 -3
- package/src/components/webhook/attempts.tsx +122 -3
- package/src/locales/en.tsx +13 -0
- package/src/locales/zh.tsx +13 -0
- package/src/pages/customer/recharge.tsx +417 -0
- package/src/pages/customer/subscription/detail.tsx +38 -20
|
@@ -12,7 +12,7 @@ import { getCustomerInvoicePageUrl } from '../../invoice';
|
|
|
12
12
|
import logger from '../../logger';
|
|
13
13
|
import { getMainProductName } from '../../product';
|
|
14
14
|
import { getCustomerSubscriptionPageUrl } from '../../subscription';
|
|
15
|
-
import { formatTime } from '../../time';
|
|
15
|
+
import { formatTime, getSimplifyDuration } from '../../time';
|
|
16
16
|
import type { BaseEmailTemplate, BaseEmailTemplateType } from './base';
|
|
17
17
|
import { getSubscriptionNotificationCustomActions } from '../../util';
|
|
18
18
|
|
|
@@ -27,7 +27,8 @@ interface SubscriptionWillCanceledEmailTemplateContext {
|
|
|
27
27
|
locale: string;
|
|
28
28
|
productName: string;
|
|
29
29
|
at: string;
|
|
30
|
-
|
|
30
|
+
cancelReason: string;
|
|
31
|
+
body: string;
|
|
31
32
|
|
|
32
33
|
userDid: string;
|
|
33
34
|
paymentInfo: string;
|
|
@@ -35,6 +36,7 @@ interface SubscriptionWillCanceledEmailTemplateContext {
|
|
|
35
36
|
viewSubscriptionLink: string;
|
|
36
37
|
viewInvoiceLink: string;
|
|
37
38
|
customActions: any[];
|
|
39
|
+
needRenew: boolean;
|
|
38
40
|
}
|
|
39
41
|
|
|
40
42
|
export class SubscriptionWillCanceledEmailTemplate
|
|
@@ -56,8 +58,19 @@ export class SubscriptionWillCanceledEmailTemplate
|
|
|
56
58
|
if (!subscription) {
|
|
57
59
|
throw new Error(`Subscription(${this.options.subscriptionId}) not found`);
|
|
58
60
|
}
|
|
59
|
-
if (subscription.
|
|
60
|
-
throw new Error(`Subscription(${this.options.subscriptionId})
|
|
61
|
+
if (subscription.isImmutable()) {
|
|
62
|
+
throw new Error(`Subscription(${this.options.subscriptionId}) is immutable, no need to send notification`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const now = dayjs().unix();
|
|
66
|
+
const cancelAt = subscription.cancel_at || subscription.current_period_end;
|
|
67
|
+
if (!subscription.cancel_at && !subscription.cancel_at_period_end) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
`Subscription(${this.options.subscriptionId}) is not scheduled to cancel, no need to send notification`
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
if (cancelAt <= now) {
|
|
73
|
+
throw new Error(`Subscription(${this.options.subscriptionId}) is already canceled, no need to send notification`);
|
|
61
74
|
}
|
|
62
75
|
|
|
63
76
|
const customer = await Customer.findByPk(subscription.customer_id);
|
|
@@ -75,12 +88,39 @@ export class SubscriptionWillCanceledEmailTemplate
|
|
|
75
88
|
const userDid = customer.did;
|
|
76
89
|
const locale = await getUserLocale(userDid);
|
|
77
90
|
const productName = await getMainProductName(subscription.id);
|
|
78
|
-
const at: string = formatTime(
|
|
79
|
-
const willCancelDuration: string =
|
|
80
|
-
locale === 'en' ? this.getWillCancelDuration(locale) : this.getWillCancelDuration(locale).split(' ').join('');
|
|
81
|
-
|
|
91
|
+
const at: string = formatTime(cancelAt * 1000);
|
|
92
|
+
const willCancelDuration: string = getSimplifyDuration((cancelAt - now) * 1000, locale);
|
|
82
93
|
const paymentInfo: string = `${fromUnitToToken(+invoice.total, paymentCurrency.decimal)} ${paymentCurrency.symbol}`;
|
|
83
94
|
|
|
95
|
+
let body: string = translate('notification.subscriptWillCanceled.body', locale, {
|
|
96
|
+
productName,
|
|
97
|
+
willCancelDuration,
|
|
98
|
+
at,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
let needRenew = false;
|
|
102
|
+
const reasonMap = {
|
|
103
|
+
cancellation_requested: 'customerCanceled',
|
|
104
|
+
payment_failed: 'paymentFailed',
|
|
105
|
+
stake_revoked: 'stakeRevoked',
|
|
106
|
+
} as const;
|
|
107
|
+
|
|
108
|
+
const cancelReason = translate(
|
|
109
|
+
`notification.subscriptWillCanceled.${reasonMap[subscription.cancelation_details?.reason as keyof typeof reasonMap] || 'adminCanceled'}`,
|
|
110
|
+
locale,
|
|
111
|
+
{
|
|
112
|
+
canceled_at: formatTime(subscription.canceled_at ? subscription.canceled_at * 1000 : dayjs().unix()),
|
|
113
|
+
}
|
|
114
|
+
);
|
|
115
|
+
if (subscription.status === 'past_due' || subscription.cancelation_details?.reason === 'payment_failed') {
|
|
116
|
+
body = translate('notification.subscriptWillCanceled.pastDue', locale, {
|
|
117
|
+
productName,
|
|
118
|
+
willCancelDuration,
|
|
119
|
+
at,
|
|
120
|
+
});
|
|
121
|
+
needRenew = true;
|
|
122
|
+
}
|
|
123
|
+
|
|
84
124
|
const viewSubscriptionLink = getCustomerSubscriptionPageUrl({
|
|
85
125
|
subscriptionId: subscription.id,
|
|
86
126
|
locale,
|
|
@@ -102,7 +142,8 @@ export class SubscriptionWillCanceledEmailTemplate
|
|
|
102
142
|
locale,
|
|
103
143
|
productName,
|
|
104
144
|
at,
|
|
105
|
-
|
|
145
|
+
body,
|
|
146
|
+
cancelReason,
|
|
106
147
|
|
|
107
148
|
userDid,
|
|
108
149
|
paymentInfo,
|
|
@@ -110,41 +151,18 @@ export class SubscriptionWillCanceledEmailTemplate
|
|
|
110
151
|
viewSubscriptionLink,
|
|
111
152
|
viewInvoiceLink,
|
|
112
153
|
customActions,
|
|
154
|
+
needRenew,
|
|
113
155
|
};
|
|
114
156
|
}
|
|
115
157
|
|
|
116
|
-
getWillCancelDuration(locale: string): string {
|
|
117
|
-
if (this.options.willCancelUnit === 'M') {
|
|
118
|
-
if (this.options.willCancelValue > 1) {
|
|
119
|
-
return `${this.options.willCancelValue} ${translate('notification.common.months', locale)}`;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
return `${this.options.willCancelValue} ${translate('notification.common.month', locale)}`;
|
|
123
|
-
}
|
|
124
|
-
if (this.options.willCancelUnit === 'd') {
|
|
125
|
-
if (this.options.willCancelValue > 1) {
|
|
126
|
-
return `${this.options.willCancelValue} ${translate('notification.common.days', locale)}`;
|
|
127
|
-
}
|
|
128
|
-
return `${this.options.willCancelValue} ${translate('notification.common.day', locale)}`;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
if (this.options.willCancelUnit === 'm') {
|
|
132
|
-
if (this.options.willCancelValue > 1) {
|
|
133
|
-
return `${this.options.willCancelValue} ${translate('notification.common.minutes', locale)}`;
|
|
134
|
-
}
|
|
135
|
-
return `${this.options.willCancelValue} ${translate('notification.common.minute', locale)}`;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
return `${this.options.willCancelValue} ${this.options.willCancelUnit}`;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
158
|
async getTemplate(): Promise<BaseEmailTemplateType | null> {
|
|
142
159
|
const {
|
|
143
160
|
locale,
|
|
144
161
|
productName,
|
|
145
162
|
at,
|
|
146
|
-
|
|
147
|
-
|
|
163
|
+
body,
|
|
164
|
+
cancelReason,
|
|
165
|
+
needRenew,
|
|
148
166
|
userDid,
|
|
149
167
|
paymentInfo,
|
|
150
168
|
|
|
@@ -162,11 +180,7 @@ export class SubscriptionWillCanceledEmailTemplate
|
|
|
162
180
|
title: `${translate('notification.subscriptWillCanceled.title', locale, {
|
|
163
181
|
productName,
|
|
164
182
|
})}`,
|
|
165
|
-
body
|
|
166
|
-
productName,
|
|
167
|
-
willCancelDuration,
|
|
168
|
-
at,
|
|
169
|
-
}),
|
|
183
|
+
body,
|
|
170
184
|
// @ts-expect-error
|
|
171
185
|
attachments: [
|
|
172
186
|
{
|
|
@@ -202,19 +216,38 @@ export class SubscriptionWillCanceledEmailTemplate
|
|
|
202
216
|
text: productName,
|
|
203
217
|
},
|
|
204
218
|
},
|
|
219
|
+
...(needRenew
|
|
220
|
+
? [
|
|
221
|
+
{
|
|
222
|
+
type: 'text',
|
|
223
|
+
data: {
|
|
224
|
+
type: 'plain',
|
|
225
|
+
color: '#9397A1',
|
|
226
|
+
text: translate('notification.common.paymentAmount', locale),
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
type: 'text',
|
|
231
|
+
data: {
|
|
232
|
+
type: 'plain',
|
|
233
|
+
text: paymentInfo,
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
]
|
|
237
|
+
: []),
|
|
205
238
|
{
|
|
206
239
|
type: 'text',
|
|
207
240
|
data: {
|
|
208
241
|
type: 'plain',
|
|
209
242
|
color: '#9397A1',
|
|
210
|
-
text: translate('notification.
|
|
243
|
+
text: translate('notification.subscriptWillCanceled.cancelReason', locale),
|
|
211
244
|
},
|
|
212
245
|
},
|
|
213
246
|
{
|
|
214
247
|
type: 'text',
|
|
215
248
|
data: {
|
|
216
249
|
type: 'plain',
|
|
217
|
-
text:
|
|
250
|
+
text: cancelReason,
|
|
218
251
|
},
|
|
219
252
|
},
|
|
220
253
|
].filter(Boolean),
|
|
@@ -227,11 +260,12 @@ export class SubscriptionWillCanceledEmailTemplate
|
|
|
227
260
|
title: translate('notification.common.viewSubscription', locale),
|
|
228
261
|
link: viewSubscriptionLink,
|
|
229
262
|
},
|
|
230
|
-
viewInvoiceLink &&
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
263
|
+
viewInvoiceLink &&
|
|
264
|
+
needRenew && {
|
|
265
|
+
name: translate('notification.common.renewNow', locale),
|
|
266
|
+
title: translate('notification.common.renewNow', locale),
|
|
267
|
+
link: viewInvoiceLink,
|
|
268
|
+
},
|
|
235
269
|
...customActions,
|
|
236
270
|
].filter(Boolean),
|
|
237
271
|
};
|
|
@@ -21,8 +21,8 @@ import {
|
|
|
21
21
|
import { getPaymentAmountForCycleSubscription, type PaymentDetail } from '../../payment';
|
|
22
22
|
import { getMainProductName } from '../../product';
|
|
23
23
|
import { getCustomerSubscriptionPageUrl } from '../../subscription';
|
|
24
|
-
import { formatTime, getPrettyMsI18nLocale } from '../../time';
|
|
25
|
-
import {
|
|
24
|
+
import { formatTime, getPrettyMsI18nLocale, getSimplifyDuration } from '../../time';
|
|
25
|
+
import { getCustomerRechargeLink, getSubscriptionNotificationCustomActions } from '../../util';
|
|
26
26
|
import type { BaseEmailTemplate, BaseEmailTemplateType } from './base';
|
|
27
27
|
|
|
28
28
|
export interface SubscriptionWillRenewEmailTemplateOptions {
|
|
@@ -92,9 +92,7 @@ export class SubscriptionWillRenewEmailTemplate
|
|
|
92
92
|
const locale = await getUserLocale(userDid);
|
|
93
93
|
const productName = await getMainProductName(subscription.id);
|
|
94
94
|
const at: string = formatTime(invoice.period_end * 1000);
|
|
95
|
-
const willRenewDuration
|
|
96
|
-
locale === 'en' ? this.getWillRenewDuration(locale) : this.getWillRenewDuration(locale).split(' ').join('');
|
|
97
|
-
|
|
95
|
+
const willRenewDuration = getSimplifyDuration((invoice.period_end - dayjs().unix()) * 1000, locale);
|
|
98
96
|
// const upcomingInvoiceAmount = await getUpcomingInvoiceAmount(subscription.id);
|
|
99
97
|
// const amount: string = fromUnitToToken(+upcomingInvoiceAmount.amount, upcomingInvoiceAmount.currency?.decimal);
|
|
100
98
|
// const paymentDetail: PaymentDetail = await getPaymentDetail(userDid, invoice, amount);
|
|
@@ -135,16 +133,12 @@ export class SubscriptionWillRenewEmailTemplate
|
|
|
135
133
|
userDid,
|
|
136
134
|
});
|
|
137
135
|
const paymentMethod: PaymentMethod | null = await PaymentMethod.findByPk(paymentCurrency.payment_method_id);
|
|
138
|
-
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
queryParams: {
|
|
145
|
-
action: 'recharge',
|
|
146
|
-
},
|
|
147
|
-
})!;
|
|
136
|
+
|
|
137
|
+
const addFundsLink: string = getCustomerRechargeLink({
|
|
138
|
+
locale,
|
|
139
|
+
userDid,
|
|
140
|
+
subscriptionId: subscription.id,
|
|
141
|
+
});
|
|
148
142
|
|
|
149
143
|
const customActions = getSubscriptionNotificationCustomActions(
|
|
150
144
|
subscription,
|
|
@@ -213,31 +207,6 @@ export class SubscriptionWillRenewEmailTemplate
|
|
|
213
207
|
return currentPeriodEnd > expectedCurrentPeriodEnd;
|
|
214
208
|
}
|
|
215
209
|
|
|
216
|
-
getWillRenewDuration(locale: string): string {
|
|
217
|
-
if (this.options.willRenewUnit === 'M') {
|
|
218
|
-
if (this.options.willRenewValue > 1) {
|
|
219
|
-
return `${this.options.willRenewValue} ${translate('notification.common.months', locale)}`;
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
return `${this.options.willRenewValue} ${translate('notification.common.month', locale)}`;
|
|
223
|
-
}
|
|
224
|
-
if (this.options.willRenewUnit === 'd') {
|
|
225
|
-
if (this.options.willRenewValue > 1) {
|
|
226
|
-
return `${this.options.willRenewValue} ${translate('notification.common.days', locale)}`;
|
|
227
|
-
}
|
|
228
|
-
return `${this.options.willRenewValue} ${translate('notification.common.day', locale)}`;
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
if (this.options.willRenewUnit === 'm') {
|
|
232
|
-
if (this.options.willRenewValue > 1) {
|
|
233
|
-
return `${this.options.willRenewValue} ${translate('notification.common.minutes', locale)}`;
|
|
234
|
-
}
|
|
235
|
-
return `${this.options.willRenewValue} ${translate('notification.common.minute', locale)}`;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
return `${this.options.willRenewValue} ${this.options.willRenewUnit}`;
|
|
239
|
-
}
|
|
240
|
-
|
|
241
210
|
async getTemplate(): Promise<BaseEmailTemplateType | null> {
|
|
242
211
|
const {
|
|
243
212
|
locale,
|
|
@@ -421,11 +390,13 @@ export class SubscriptionWillRenewEmailTemplate
|
|
|
421
390
|
].filter(Boolean),
|
|
422
391
|
// @ts-ignore
|
|
423
392
|
actions: [
|
|
424
|
-
!canPay &&
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
393
|
+
!canPay &&
|
|
394
|
+
!isStripe &&
|
|
395
|
+
addFundsLink && {
|
|
396
|
+
name: translate('notification.common.addFunds', locale),
|
|
397
|
+
title: translate('notification.common.addFunds', locale),
|
|
398
|
+
link: addFundsLink,
|
|
399
|
+
},
|
|
429
400
|
{
|
|
430
401
|
name: translate('notification.common.viewSubscription', locale),
|
|
431
402
|
title: translate('notification.common.viewSubscription', locale),
|
package/api/src/libs/time.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { LiteralUnion } from 'type-fest';
|
|
2
2
|
|
|
3
|
+
import prettyMsI18n from 'pretty-ms-i18n';
|
|
3
4
|
import dayjs from './dayjs';
|
|
4
5
|
|
|
5
6
|
export function formatTime(time: dayjs.ConfigType): string {
|
|
@@ -15,3 +16,15 @@ export function getPrettyMsI18nLocale(
|
|
|
15
16
|
|
|
16
17
|
return 'en';
|
|
17
18
|
}
|
|
19
|
+
|
|
20
|
+
export function getSimplifyDuration(ms: number, locale: string): string {
|
|
21
|
+
const options = {
|
|
22
|
+
locale: getPrettyMsI18nLocale(locale),
|
|
23
|
+
compact: true,
|
|
24
|
+
verbose: true,
|
|
25
|
+
};
|
|
26
|
+
if (ms < 1000 && ms >= 0) {
|
|
27
|
+
options.verbose = false;
|
|
28
|
+
}
|
|
29
|
+
return prettyMsI18n(ms, options);
|
|
30
|
+
}
|
package/api/src/libs/util.ts
CHANGED
|
@@ -292,6 +292,23 @@ export function getCustomerProfileUrl({
|
|
|
292
292
|
);
|
|
293
293
|
}
|
|
294
294
|
|
|
295
|
+
export function getCustomerRechargeLink({
|
|
296
|
+
locale = 'en',
|
|
297
|
+
userDid,
|
|
298
|
+
subscriptionId,
|
|
299
|
+
}: {
|
|
300
|
+
locale: LiteralUnion<'en' | 'zh', string>;
|
|
301
|
+
userDid: string;
|
|
302
|
+
subscriptionId: string;
|
|
303
|
+
}) {
|
|
304
|
+
return getUrl(
|
|
305
|
+
withQuery(`customer/subscription/${subscriptionId}/recharge`, {
|
|
306
|
+
locale,
|
|
307
|
+
...getConnectQueryParam({ userDid }),
|
|
308
|
+
})
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
295
312
|
export async function getOwnerDid() {
|
|
296
313
|
try {
|
|
297
314
|
const { user } = await blocklet.getOwner();
|
package/api/src/locales/en.ts
CHANGED
|
@@ -153,9 +153,18 @@ export default flat({
|
|
|
153
153
|
},
|
|
154
154
|
|
|
155
155
|
subscriptWillCanceled: {
|
|
156
|
-
title: '{productName} subscription is about to be
|
|
157
|
-
|
|
156
|
+
title: '{productName} subscription is about to be cancelled ',
|
|
157
|
+
pastDue:
|
|
158
|
+
'Your subscription {productName} will be automatically unsubscribed by the system after {at} (after {willCancelDuration}) due to a long period of failure to automatically complete the automatic payment. Please handle the problem of automatic payment manually in time, so as not to affect the use. If you have any questions, please feel free to contact us.',
|
|
159
|
+
body: 'Your subscription to {productName} will be automatically canceled on {at} ({willCancelDuration} later). If you have any questions, please feel free to contact us.',
|
|
158
160
|
renewAmount: 'deduction amount ',
|
|
161
|
+
cancelReason: 'Cancel reason',
|
|
162
|
+
revokeStake: 'Revoke stake',
|
|
163
|
+
adminCanceled: 'Admin canceled',
|
|
164
|
+
customerCanceled: 'User-initiated cancellation',
|
|
165
|
+
paymentDisputed: 'Payment disputed',
|
|
166
|
+
paymentFailed: 'Payment failed',
|
|
167
|
+
stakeRevoked: 'Stake revoked',
|
|
159
168
|
},
|
|
160
169
|
|
|
161
170
|
subscriptionCanceled: {
|
|
@@ -173,6 +182,7 @@ export default flat({
|
|
|
173
182
|
customerCanceledAndStakeReturned:
|
|
174
183
|
'User-initiated cancellation, the stake will be returned later, please check for the stake return email',
|
|
175
184
|
paymentFailed: 'Payment failed',
|
|
185
|
+
stakeRevoked: 'Stake revoked',
|
|
176
186
|
},
|
|
177
187
|
},
|
|
178
188
|
});
|
package/api/src/locales/zh.ts
CHANGED
|
@@ -150,9 +150,17 @@ export default flat({
|
|
|
150
150
|
},
|
|
151
151
|
|
|
152
152
|
subscriptWillCanceled: {
|
|
153
|
-
title: '{productName}
|
|
154
|
-
|
|
153
|
+
title: '{productName} 订阅即将取消',
|
|
154
|
+
pastDue:
|
|
155
|
+
'由于长时间未能自动完成扣费,您订阅的 {productName} 将于 {at} ({willCancelDuration}后) 被系统自动取消订阅。请您及时手动处理扣费问题,以免影响使用。如有任何疑问,请随时与我们联系。',
|
|
156
|
+
body: '您订阅的 {productName} 将于 {at} ({willCancelDuration}后) 被系统取消订阅,如有疑问,请随时与我们联系。',
|
|
155
157
|
renewAmount: '扣费金额',
|
|
158
|
+
cancelReason: '取消原因',
|
|
159
|
+
adminCanceled: '管理员取消',
|
|
160
|
+
customerCanceled: '用户于 {canceled_at} 主动取消',
|
|
161
|
+
paymentDisputed: '扣费争议',
|
|
162
|
+
paymentFailed: '扣费失败',
|
|
163
|
+
stakeRevoked: '撤销质押',
|
|
156
164
|
},
|
|
157
165
|
|
|
158
166
|
subscriptionCanceled: {
|
|
@@ -168,6 +176,7 @@ export default flat({
|
|
|
168
176
|
customerCanceled: '用户主动取消',
|
|
169
177
|
customerCanceledAndStakeReturned: '用户主动取消, 押金会在稍后退还, 请留意后续的质押退还邮件',
|
|
170
178
|
paymentFailed: '扣费失败',
|
|
179
|
+
stakeRevoked: '撤销质押',
|
|
171
180
|
},
|
|
172
181
|
},
|
|
173
182
|
});
|
|
@@ -39,10 +39,15 @@ export const checkoutSessionQueue = createQueue<CheckoutSessionJob>({
|
|
|
39
39
|
export async function handleCheckoutSessionJob(job: CheckoutSessionJob): Promise<void> {
|
|
40
40
|
const checkoutSession = await CheckoutSession.findByPk(job.id);
|
|
41
41
|
if (!checkoutSession) {
|
|
42
|
+
logger.warn('CheckoutSession not found', { id: job.id });
|
|
42
43
|
return;
|
|
43
44
|
}
|
|
44
45
|
if (job.action === 'expire') {
|
|
45
46
|
if (checkoutSession.status !== 'open') {
|
|
47
|
+
logger.info('Skip expire CheckoutSession since status is not open', {
|
|
48
|
+
checkoutSession: checkoutSession.id,
|
|
49
|
+
status: checkoutSession.status,
|
|
50
|
+
});
|
|
46
51
|
return;
|
|
47
52
|
}
|
|
48
53
|
if (checkoutSession.payment_status === 'paid') {
|
|
@@ -257,15 +262,25 @@ events.on(
|
|
|
257
262
|
async ({ checkoutSessionId, paymentIntentId }: { checkoutSessionId: string; paymentIntentId: string }) => {
|
|
258
263
|
const checkoutSession = await CheckoutSession.findByPk(checkoutSessionId);
|
|
259
264
|
if (!checkoutSession) {
|
|
265
|
+
logger.warn('CheckoutSession not found for pending invoice', { checkoutSessionId });
|
|
260
266
|
return;
|
|
261
267
|
}
|
|
262
268
|
if (checkoutSession.invoice_id) {
|
|
269
|
+
logger.info('Invoice already exists for checkout session', {
|
|
270
|
+
checkoutSessionId,
|
|
271
|
+
invoiceId: checkoutSession.invoice_id,
|
|
272
|
+
});
|
|
263
273
|
return;
|
|
264
274
|
}
|
|
265
275
|
if (checkoutSession.mode !== 'payment') {
|
|
276
|
+
logger.info('Skipping invoice creation for non-payment mode', {
|
|
277
|
+
checkoutSessionId,
|
|
278
|
+
mode: checkoutSession.mode,
|
|
279
|
+
});
|
|
266
280
|
return;
|
|
267
281
|
}
|
|
268
282
|
if (!checkoutSession.invoice_creation?.enabled) {
|
|
283
|
+
logger.info('Invoice creation not enabled for checkout session', { checkoutSessionId });
|
|
269
284
|
return;
|
|
270
285
|
}
|
|
271
286
|
|
package/api/src/queues/event.ts
CHANGED
|
@@ -14,16 +14,16 @@ type EventJob = {
|
|
|
14
14
|
};
|
|
15
15
|
|
|
16
16
|
export const handleEvent = async (job: EventJob) => {
|
|
17
|
-
logger.info('handle event', job);
|
|
17
|
+
logger.info('Starting to handle event', job);
|
|
18
18
|
|
|
19
19
|
const event = await Event.findByPk(job.eventId);
|
|
20
20
|
if (!event) {
|
|
21
|
-
logger.warn('
|
|
21
|
+
logger.warn('Event not found', job);
|
|
22
22
|
return;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
if (!event.pending_webhooks) {
|
|
26
|
-
logger.warn('
|
|
26
|
+
logger.warn('Event already processed', job);
|
|
27
27
|
return;
|
|
28
28
|
}
|
|
29
29
|
|
|
@@ -36,6 +36,8 @@ export const handleEvent = async (job: EventJob) => {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
await event.update({ pending_webhooks: eventWebhooks.length });
|
|
39
|
+
logger.info(`Updated event ${event.id} with ${eventWebhooks.length} pending webhooks`);
|
|
40
|
+
|
|
39
41
|
eventWebhooks.forEach(async (webhook) => {
|
|
40
42
|
const attemptCount = await WebhookAttempt.count({
|
|
41
43
|
where: {
|
|
@@ -53,7 +55,7 @@ export const handleEvent = async (job: EventJob) => {
|
|
|
53
55
|
const jobId = getWebhookJobId(event.id, webhook.id);
|
|
54
56
|
const exist = await webhookQueue.get(jobId);
|
|
55
57
|
if (!exist) {
|
|
56
|
-
logger.info(
|
|
58
|
+
logger.info(`Scheduling attempt for event ${event.id} and webhook ${webhook.id}`, job);
|
|
57
59
|
webhookQueue.push({
|
|
58
60
|
id: jobId,
|
|
59
61
|
job: { eventId: event.id, webhookId: webhook.id },
|
|
@@ -61,6 +63,8 @@ export const handleEvent = async (job: EventJob) => {
|
|
|
61
63
|
}
|
|
62
64
|
}
|
|
63
65
|
});
|
|
66
|
+
|
|
67
|
+
logger.info(`Finished handling event ${job.eventId}`);
|
|
64
68
|
};
|
|
65
69
|
|
|
66
70
|
export const eventQueue = createQueue<EventJob>({
|
|
@@ -80,12 +84,17 @@ export const startEventQueue = async () => {
|
|
|
80
84
|
attributes: ['id'],
|
|
81
85
|
});
|
|
82
86
|
|
|
87
|
+
logger.info(`Found ${docs.length} events with pending webhooks`);
|
|
88
|
+
|
|
83
89
|
docs.forEach(async (x) => {
|
|
84
90
|
const exist = await eventQueue.get(x.id);
|
|
85
91
|
if (!exist) {
|
|
92
|
+
logger.info(`Pushing event ${x.id} to queue`);
|
|
86
93
|
eventQueue.push({ id: x.id, job: { eventId: x.id } });
|
|
87
94
|
}
|
|
88
95
|
});
|
|
96
|
+
|
|
97
|
+
logger.info('Finished starting event queue');
|
|
89
98
|
};
|
|
90
99
|
|
|
91
100
|
eventQueue.on('failed', ({ id, job, error }) => {
|
|
@@ -57,6 +57,7 @@ export const handleInvoice = async (job: InvoiceJob) => {
|
|
|
57
57
|
attempted: true,
|
|
58
58
|
status_transitions: { ...invoice.status_transitions, paid_at: dayjs().unix() },
|
|
59
59
|
});
|
|
60
|
+
logger.info('Invoice updated to paid status', { invoiceId: invoice.id });
|
|
60
61
|
|
|
61
62
|
if (invoice.subscription_id) {
|
|
62
63
|
const subscription = await Subscription.findByPk(invoice.subscription_id);
|
|
@@ -118,6 +119,11 @@ export const handleInvoice = async (job: InvoiceJob) => {
|
|
|
118
119
|
paymentIntent = await PaymentIntent.findByPk(invoice.payment_intent_id);
|
|
119
120
|
if (paymentIntent && paymentIntent.isImmutable() === false) {
|
|
120
121
|
await paymentIntent.update({ status: 'requires_capture', customer_id: invoice.customer_id });
|
|
122
|
+
logger.info('PaymentIntent updated for invoice', {
|
|
123
|
+
invoiceId: invoice.id,
|
|
124
|
+
paymentIntentId: paymentIntent.id,
|
|
125
|
+
newStatus: 'requires_capture',
|
|
126
|
+
});
|
|
121
127
|
}
|
|
122
128
|
} else {
|
|
123
129
|
const descriptionMap: any = {
|
|
@@ -149,7 +155,11 @@ export const handleInvoice = async (job: InvoiceJob) => {
|
|
|
149
155
|
metadata: {},
|
|
150
156
|
});
|
|
151
157
|
await invoice.update({ payment_intent_id: paymentIntent.id });
|
|
152
|
-
logger.info('PaymentIntent created for invoice', {
|
|
158
|
+
logger.info('PaymentIntent created for invoice', {
|
|
159
|
+
invoiceId: invoice.id,
|
|
160
|
+
paymentIntentId: paymentIntent.id,
|
|
161
|
+
amount: paymentIntent.amount,
|
|
162
|
+
});
|
|
153
163
|
|
|
154
164
|
if (invoice.checkout_session_id) {
|
|
155
165
|
const checkoutSession = await CheckoutSession.findByPk(invoice.checkout_session_id);
|
|
@@ -246,15 +256,23 @@ invoiceQueue.on('failed', ({ id, job, error }) => {
|
|
|
246
256
|
events.on('invoice.paid', async ({ id: invoiceId }) => {
|
|
247
257
|
const invoice = await Invoice.findByPk(invoiceId);
|
|
248
258
|
if (!invoice) {
|
|
249
|
-
logger.error('Invoice not found', { invoiceId });
|
|
259
|
+
logger.error('Invoice not found for paid event', { invoiceId });
|
|
250
260
|
return;
|
|
251
261
|
}
|
|
262
|
+
logger.info('Processing paid invoice', { invoiceId, billingReason: invoice.billing_reason });
|
|
263
|
+
|
|
252
264
|
const checkBillingReason = ['subscription_cycle', 'subscription_cancel'];
|
|
253
265
|
if (checkBillingReason.includes(invoice.billing_reason)) {
|
|
254
266
|
const shouldPayTotal = await getInvoiceShouldPayTotal(invoice);
|
|
255
267
|
if (shouldPayTotal !== invoice.total) {
|
|
256
268
|
createEvent('Invoice', 'billing.discrepancy', invoice);
|
|
257
|
-
logger.
|
|
269
|
+
logger.warn('Billing discrepancy detected', {
|
|
270
|
+
invoiceId,
|
|
271
|
+
shouldPayTotal,
|
|
272
|
+
invoiceTotal: invoice.total,
|
|
273
|
+
});
|
|
274
|
+
} else {
|
|
275
|
+
logger.info('Invoice paid successfully with correct amount', { invoiceId, total: invoice.total });
|
|
258
276
|
}
|
|
259
277
|
}
|
|
260
278
|
});
|
|
@@ -577,6 +577,9 @@ export const handlePayment = async (job: PaymentJob) => {
|
|
|
577
577
|
let result;
|
|
578
578
|
try {
|
|
579
579
|
await paymentIntent.update({ status: 'processing', last_payment_error: null });
|
|
580
|
+
logger.info('PaymentIntent status updated to processing', {
|
|
581
|
+
paymentIntentId: paymentIntent.id,
|
|
582
|
+
});
|
|
580
583
|
if (paymentMethod.type === 'arcblock') {
|
|
581
584
|
if (invoice?.billing_reason === 'slash_stake') {
|
|
582
585
|
await handleStakeSlash(invoice, paymentIntent, paymentMethod, customer, paymentCurrency);
|
package/api/src/queues/refund.ts
CHANGED
|
@@ -175,6 +175,7 @@ const handleRefundJob = async (
|
|
|
175
175
|
},
|
|
176
176
|
},
|
|
177
177
|
});
|
|
178
|
+
logger.info('Refund status updated to succeeded', { id: refund.id, txHash });
|
|
178
179
|
}
|
|
179
180
|
|
|
180
181
|
if (paymentMethod.type === 'ethereum') {
|
|
@@ -376,6 +377,7 @@ const handleStakeReturnJob = async (
|
|
|
376
377
|
},
|
|
377
378
|
},
|
|
378
379
|
});
|
|
380
|
+
logger.info('Stake return refund status updated to succeeded', { id: refund.id, txHash });
|
|
379
381
|
}
|
|
380
382
|
} catch (err: any) {
|
|
381
383
|
logger.error('stake return failed', { error: err, id: refund.id });
|
|
@@ -423,6 +425,7 @@ export const startRefundQueue = async () => {
|
|
|
423
425
|
const exist = await refundQueue.get(x.id);
|
|
424
426
|
if (!exist) {
|
|
425
427
|
refundQueue.push({ id: x.id, job: { refundId: x.id } });
|
|
428
|
+
logger.info('Re-queued pending refund', { id: x.id });
|
|
426
429
|
}
|
|
427
430
|
});
|
|
428
431
|
};
|