payment-kit 1.15.20 → 1.15.21
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 +2 -0
- package/api/src/integrations/arcblock/stake.ts +27 -0
- 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/subscription.ts +107 -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/subscriptions.ts +41 -36
- 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/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
|
@@ -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
|
});
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
checkUsageReportEmpty,
|
|
17
17
|
getSubscriptionCycleAmount,
|
|
18
18
|
getSubscriptionCycleSetup,
|
|
19
|
+
getSubscriptionRefundSetup,
|
|
19
20
|
getSubscriptionStakeAddress,
|
|
20
21
|
getSubscriptionStakeReturnSetup,
|
|
21
22
|
getSubscriptionStakeSlashSetup,
|
|
@@ -29,6 +30,7 @@ import { Price } from '../store/models/price';
|
|
|
29
30
|
import { Subscription } from '../store/models/subscription';
|
|
30
31
|
import { SubscriptionItem } from '../store/models/subscription-item';
|
|
31
32
|
import { invoiceQueue } from './invoice';
|
|
33
|
+
import { SubscriptionWillCanceledSchedule } from '../crons/subscription-will-canceled';
|
|
32
34
|
|
|
33
35
|
type SubscriptionJob = {
|
|
34
36
|
subscriptionId: string;
|
|
@@ -227,7 +229,10 @@ const handleSubscriptionBeforeCancel = async (subscription: Subscription) => {
|
|
|
227
229
|
|
|
228
230
|
// persist invoice id
|
|
229
231
|
await subscription.update({ latest_invoice_id: invoice.id });
|
|
230
|
-
logger.info('Subscription updated before cancel', {
|
|
232
|
+
logger.info('Subscription updated before cancel', {
|
|
233
|
+
subscription: subscription.id,
|
|
234
|
+
latestInvoiceId: invoice.id,
|
|
235
|
+
});
|
|
231
236
|
}
|
|
232
237
|
};
|
|
233
238
|
|
|
@@ -256,6 +261,9 @@ const handleSubscriptionWhenActive = async (subscription: Subscription) => {
|
|
|
256
261
|
const now = dayjs().unix();
|
|
257
262
|
if (subscription.trial_end && subscription.trial_end <= now) {
|
|
258
263
|
await subscription.update({ status: 'active' });
|
|
264
|
+
logger.info('Subscription status updated from trialing to active', {
|
|
265
|
+
subscription: subscription.id,
|
|
266
|
+
});
|
|
259
267
|
}
|
|
260
268
|
}
|
|
261
269
|
|
|
@@ -438,6 +446,11 @@ const handleStakeSlashAfterCancel = async (subscription: Subscription) => {
|
|
|
438
446
|
},
|
|
439
447
|
},
|
|
440
448
|
});
|
|
449
|
+
logger.info('PaymentIntent updated after stake slash', {
|
|
450
|
+
subscription: subscription.id,
|
|
451
|
+
paymentIntent: paymentIntent.id,
|
|
452
|
+
status: 'succeeded',
|
|
453
|
+
});
|
|
441
454
|
await invoice.update({
|
|
442
455
|
paid: true,
|
|
443
456
|
status: 'paid',
|
|
@@ -447,6 +460,12 @@ const handleStakeSlashAfterCancel = async (subscription: Subscription) => {
|
|
|
447
460
|
attempted: true,
|
|
448
461
|
status_transitions: { ...invoice.status_transitions, paid_at: dayjs().unix() },
|
|
449
462
|
});
|
|
463
|
+
|
|
464
|
+
logger.info('Invoice updated after stake slash', {
|
|
465
|
+
subscription: subscription.id,
|
|
466
|
+
invoice: invoice.id,
|
|
467
|
+
status: 'paid',
|
|
468
|
+
});
|
|
450
469
|
};
|
|
451
470
|
|
|
452
471
|
const ensureReturnStake = async (subscription: Subscription) => {
|
|
@@ -626,7 +645,11 @@ const slashStakeOnCancel = async (subscription: Subscription) => {
|
|
|
626
645
|
subscription_id: subscription.id,
|
|
627
646
|
} as unknown as Invoice,
|
|
628
647
|
});
|
|
629
|
-
logger.info('
|
|
648
|
+
logger.info('New invoice created for stake slash', {
|
|
649
|
+
subscription: subscription.id,
|
|
650
|
+
invoice: newInvoice.id,
|
|
651
|
+
amount: result.return_amount,
|
|
652
|
+
});
|
|
630
653
|
invoiceQueue.push({
|
|
631
654
|
id: newInvoice.id,
|
|
632
655
|
job: { invoiceId: newInvoice.id, retryOnError: true },
|
|
@@ -635,6 +658,58 @@ const slashStakeOnCancel = async (subscription: Subscription) => {
|
|
|
635
658
|
}
|
|
636
659
|
};
|
|
637
660
|
|
|
661
|
+
const ensureRefundOnCancel = async (subscription: Subscription) => {
|
|
662
|
+
// @ts-ignore
|
|
663
|
+
const { refund } = subscription.cancelation_details;
|
|
664
|
+
if (!['last', 'proration'].includes(refund)) {
|
|
665
|
+
logger.warn('Refund skipped because refund type is not last or proration', {
|
|
666
|
+
subscription: subscription.id,
|
|
667
|
+
refund,
|
|
668
|
+
});
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
const result = await getSubscriptionRefundSetup(subscription, subscription.cancel_at);
|
|
672
|
+
if (result.unused === '0') {
|
|
673
|
+
logger.warn('Refund skipped because unused amount is 0', {
|
|
674
|
+
subscription: subscription.id,
|
|
675
|
+
unused: result.unused,
|
|
676
|
+
});
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
const item = await Refund.create({
|
|
680
|
+
type: 'refund',
|
|
681
|
+
livemode: subscription.livemode,
|
|
682
|
+
amount: refund === 'last' ? result.total : result.unused,
|
|
683
|
+
description: 'refund_transfer_on_subscription_cancel',
|
|
684
|
+
status: 'pending',
|
|
685
|
+
reason: 'requested_by_admin',
|
|
686
|
+
currency_id: subscription.currency_id,
|
|
687
|
+
customer_id: subscription.customer_id,
|
|
688
|
+
payment_method_id: subscription.default_payment_method_id,
|
|
689
|
+
payment_intent_id: result.lastInvoice.payment_intent_id as string,
|
|
690
|
+
invoice_id: result.lastInvoice.id,
|
|
691
|
+
subscription_id: subscription.id,
|
|
692
|
+
attempt_count: 0,
|
|
693
|
+
attempted: false,
|
|
694
|
+
next_attempt: 0,
|
|
695
|
+
last_attempt_error: null,
|
|
696
|
+
starting_balance: '0',
|
|
697
|
+
ending_balance: '0',
|
|
698
|
+
starting_token_balance: {},
|
|
699
|
+
ending_token_balance: {},
|
|
700
|
+
metadata: {
|
|
701
|
+
requested_by: subscription.cancelation_details?.requested_by,
|
|
702
|
+
unused_period_start: refund === 'last' ? subscription.current_period_start : subscription.cancel_at,
|
|
703
|
+
unused_period_end: subscription.current_period_end,
|
|
704
|
+
},
|
|
705
|
+
});
|
|
706
|
+
logger.info('Created refund for canceled subscription', {
|
|
707
|
+
subscription: subscription.id,
|
|
708
|
+
refund: item.toJSON(),
|
|
709
|
+
refundType: refund,
|
|
710
|
+
});
|
|
711
|
+
};
|
|
712
|
+
|
|
638
713
|
// generate invoice for subscription periodically
|
|
639
714
|
export const handleSubscription = async (job: SubscriptionJob) => {
|
|
640
715
|
logger.info('handle subscription', job);
|
|
@@ -793,6 +868,25 @@ export const returnStakeQueue = createQueue({
|
|
|
793
868
|
},
|
|
794
869
|
});
|
|
795
870
|
|
|
871
|
+
export const subscriptionCancelRefund = createQueue({
|
|
872
|
+
name: 'subscription-cancel-refund',
|
|
873
|
+
onJob: async (job) => {
|
|
874
|
+
const { subscriptionId } = job;
|
|
875
|
+
const subscription = await Subscription.findByPk(subscriptionId);
|
|
876
|
+
if (!subscription) {
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
await ensureRefundOnCancel(subscription);
|
|
880
|
+
},
|
|
881
|
+
options: {
|
|
882
|
+
concurrency: 1,
|
|
883
|
+
maxRetries: 5,
|
|
884
|
+
retryDelay: 1000,
|
|
885
|
+
maxTimeout: 60000,
|
|
886
|
+
enableScheduledJob: true,
|
|
887
|
+
},
|
|
888
|
+
});
|
|
889
|
+
|
|
796
890
|
export async function addSubscriptionJob(
|
|
797
891
|
subscription: Subscription,
|
|
798
892
|
action: 'cycle' | 'cancel' | 'resume',
|
|
@@ -831,6 +925,7 @@ subscriptionQueue.on('failed', ({ id, job, error }) => {
|
|
|
831
925
|
|
|
832
926
|
events.on('customer.subscription.past_due', async (subscription: Subscription) => {
|
|
833
927
|
await subscriptionQueue.delete(subscription.id);
|
|
928
|
+
await new SubscriptionWillCanceledSchedule().reScheduleSubscriptionTasks([subscription]);
|
|
834
929
|
await addSubscriptionJob(subscription, 'cancel', true, subscription.cancel_at || subscription.current_period_end);
|
|
835
930
|
logger.info('subscription cancel job scheduled after past_due', {
|
|
836
931
|
subscription: subscription.id,
|
|
@@ -852,6 +947,15 @@ events.on('customer.subscription.deleted', (subscription: Subscription) => {
|
|
|
852
947
|
});
|
|
853
948
|
// FIXME: ensure invoices that are open or uncollectible are voided
|
|
854
949
|
|
|
950
|
+
if (
|
|
951
|
+
subscription.cancelation_details?.refund &&
|
|
952
|
+
['last', 'proration'].includes(subscription.cancelation_details.refund)
|
|
953
|
+
) {
|
|
954
|
+
subscriptionCancelRefund.push({
|
|
955
|
+
id: `subscription-cancel-refund-${subscription.id}`,
|
|
956
|
+
job: { subscriptionId: subscription.id },
|
|
957
|
+
});
|
|
958
|
+
}
|
|
855
959
|
if (subscription.cancelation_details?.slash_stake) {
|
|
856
960
|
slashStakeQueue.push({
|
|
857
961
|
id: `slash-stake-${subscription.id}`,
|
|
@@ -890,5 +994,6 @@ events.on('customer.stake.revoked', async ({ subscriptionId, tx }: { subscriptio
|
|
|
890
994
|
comment: `Revoked by ${tx.tx.from} with tx ${tx.hash}`,
|
|
891
995
|
},
|
|
892
996
|
});
|
|
997
|
+
await new SubscriptionWillCanceledSchedule().reScheduleSubscriptionTasks([subscription]);
|
|
893
998
|
await addSubscriptionJob(subscription, 'cancel', true, subscription.current_period_end);
|
|
894
999
|
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import type { Transaction } from '@ocap/client';
|
|
2
|
+
import { fromAddress } from '@ocap/wallet';
|
|
3
|
+
import { fromTokenToUnit, toBase58 } from '@ocap/util';
|
|
4
|
+
import { encodeTransferItx } from 'api/src/integrations/ethereum/token';
|
|
5
|
+
import { executeEvmTransaction, waitForEvmTxConfirm } from 'api/src/integrations/ethereum/tx';
|
|
6
|
+
import type { CallbackArgs } from '../../libs/auth';
|
|
7
|
+
import { getGasPayerExtra } from '../../libs/payment';
|
|
8
|
+
import { getTxMetadata } from '../../libs/util';
|
|
9
|
+
import { ensureSubscriptionRecharge, getAuthPrincipalClaim } from './shared';
|
|
10
|
+
import logger from '../../libs/logger';
|
|
11
|
+
|
|
12
|
+
export default {
|
|
13
|
+
action: 'recharge',
|
|
14
|
+
authPrincipal: false,
|
|
15
|
+
claims: {
|
|
16
|
+
authPrincipal: async ({ extraParams }: CallbackArgs) => {
|
|
17
|
+
const { paymentMethod } = await ensureSubscriptionRecharge(extraParams.subscriptionId);
|
|
18
|
+
return getAuthPrincipalClaim(paymentMethod, 'recharge');
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
onConnect: async ({ userDid, userPk, extraParams }: CallbackArgs) => {
|
|
22
|
+
const { subscriptionId } = extraParams;
|
|
23
|
+
let { amount } = extraParams;
|
|
24
|
+
const { paymentMethod, paymentCurrency, receiverAddress } = await ensureSubscriptionRecharge(subscriptionId);
|
|
25
|
+
amount = fromTokenToUnit(amount, paymentCurrency.decimal).toString();
|
|
26
|
+
if (paymentMethod.type === 'arcblock') {
|
|
27
|
+
const tokens = [{ address: paymentCurrency.contract as string, value: amount }];
|
|
28
|
+
// @ts-ignore
|
|
29
|
+
const itx: TransferV3Tx = {
|
|
30
|
+
outputs: [{ owner: receiverAddress, tokens, assets: [] }],
|
|
31
|
+
data: getTxMetadata({
|
|
32
|
+
rechargeCustomerId: receiverAddress,
|
|
33
|
+
subscriptionId,
|
|
34
|
+
currencyId: paymentCurrency.id,
|
|
35
|
+
amount,
|
|
36
|
+
action: 'recharge',
|
|
37
|
+
}),
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const claims: { [key: string]: object } = {
|
|
41
|
+
prepareTx: {
|
|
42
|
+
type: 'TransferV3Tx',
|
|
43
|
+
description: `Recharge account for ${receiverAddress}`,
|
|
44
|
+
partialTx: { from: userDid, pk: userPk, itx },
|
|
45
|
+
requirement: { tokens },
|
|
46
|
+
chainInfo: {
|
|
47
|
+
host: paymentMethod.settings?.arcblock?.api_host as string,
|
|
48
|
+
id: paymentMethod.settings?.arcblock?.chain_id as string,
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
return claims;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (paymentMethod.type === 'ethereum') {
|
|
57
|
+
return {
|
|
58
|
+
signature: {
|
|
59
|
+
type: 'eth:transaction',
|
|
60
|
+
data: toBase58(
|
|
61
|
+
Buffer.from(
|
|
62
|
+
JSON.stringify({
|
|
63
|
+
network: paymentMethod.settings?.ethereum?.chain_id,
|
|
64
|
+
tx: encodeTransferItx(receiverAddress, amount, paymentCurrency.contract as string),
|
|
65
|
+
}),
|
|
66
|
+
'utf-8'
|
|
67
|
+
)
|
|
68
|
+
),
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
throw new Error(`Payment method ${paymentMethod.type} not supported`);
|
|
74
|
+
},
|
|
75
|
+
onAuth: async ({ request, userDid, claims, extraParams }: CallbackArgs) => {
|
|
76
|
+
const { subscriptionId } = extraParams;
|
|
77
|
+
const { paymentMethod, paymentCurrency, receiverAddress } = await ensureSubscriptionRecharge(subscriptionId);
|
|
78
|
+
let { amount } = extraParams;
|
|
79
|
+
amount = fromTokenToUnit(amount, paymentCurrency.decimal).toString();
|
|
80
|
+
|
|
81
|
+
if (paymentMethod.type === 'arcblock') {
|
|
82
|
+
try {
|
|
83
|
+
const client = paymentMethod.getOcapClient();
|
|
84
|
+
const claim = claims.find((x) => x.type === 'prepareTx');
|
|
85
|
+
|
|
86
|
+
const tx: Partial<Transaction> = client.decodeTx(claim.finalTx);
|
|
87
|
+
if (claim.delegator && claim.from) {
|
|
88
|
+
tx.delegator = claim.delegator;
|
|
89
|
+
tx.from = claim.from;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// @ts-ignore
|
|
93
|
+
const { buffer } = await client.encodeTransferV3Tx({ tx });
|
|
94
|
+
const txHash = await client.sendTransferV3Tx(
|
|
95
|
+
// @ts-ignore
|
|
96
|
+
{ tx, wallet: fromAddress(userDid) },
|
|
97
|
+
getGasPayerExtra(buffer, client.pickGasPayerHeaders(request))
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
logger.info('Recharge successful', {
|
|
101
|
+
receiverAddress,
|
|
102
|
+
amount,
|
|
103
|
+
subscriptionId,
|
|
104
|
+
paymentMethod: paymentMethod.type,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
return { hash: txHash };
|
|
108
|
+
} catch (err) {
|
|
109
|
+
console.error(err);
|
|
110
|
+
logger.error('Failed to finalize recharge on arcblock', { receiverAddress, error: err });
|
|
111
|
+
throw err;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (paymentMethod.type === 'ethereum') {
|
|
116
|
+
try {
|
|
117
|
+
const paymentDetails = await executeEvmTransaction('transfer', userDid, claims, paymentMethod);
|
|
118
|
+
waitForEvmTxConfirm(
|
|
119
|
+
paymentMethod.getEvmClient(),
|
|
120
|
+
Number(paymentDetails.block_height),
|
|
121
|
+
paymentMethod.confirmation.block
|
|
122
|
+
)
|
|
123
|
+
.then(() => {
|
|
124
|
+
logger.info('Recharge successful', {
|
|
125
|
+
receiverAddress,
|
|
126
|
+
amount,
|
|
127
|
+
subscriptionId,
|
|
128
|
+
paymentMethod: paymentMethod.type,
|
|
129
|
+
});
|
|
130
|
+
})
|
|
131
|
+
.catch(console.error);
|
|
132
|
+
|
|
133
|
+
return { hash: paymentDetails.tx_hash };
|
|
134
|
+
} catch (err) {
|
|
135
|
+
console.error(err);
|
|
136
|
+
logger.error('Failed to finalize recharge on ethereum', { receiverAddress, error: err });
|
|
137
|
+
throw err;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
throw new Error(`Payment method ${paymentMethod.type} not supported`);
|
|
142
|
+
},
|
|
143
|
+
};
|
|
@@ -606,6 +606,31 @@ export async function ensureInvoiceForCollect(invoiceId: string) {
|
|
|
606
606
|
};
|
|
607
607
|
}
|
|
608
608
|
|
|
609
|
+
export async function ensureSubscriptionRecharge(subscriptionId: string) {
|
|
610
|
+
const subscription = await Subscription.findByPk(subscriptionId);
|
|
611
|
+
if (!subscription) {
|
|
612
|
+
throw new Error(`Subscription ${subscriptionId} not found`);
|
|
613
|
+
}
|
|
614
|
+
const paymentCurrency = await PaymentCurrency.findByPk(subscription.currency_id);
|
|
615
|
+
if (!paymentCurrency) {
|
|
616
|
+
throw new Error(`Currency ${subscription.currency_id} not found`);
|
|
617
|
+
}
|
|
618
|
+
const paymentMethod = await PaymentMethod.findByPk(paymentCurrency.payment_method_id);
|
|
619
|
+
if (!paymentMethod) {
|
|
620
|
+
throw new Error(`Payment method ${paymentCurrency.payment_method_id} not found`);
|
|
621
|
+
}
|
|
622
|
+
// @ts-ignore
|
|
623
|
+
const receiverAddress = subscription?.payment_details?.[paymentMethod.type]?.payer;
|
|
624
|
+
if (!receiverAddress) {
|
|
625
|
+
throw new Error(`Receiver address not found for subscription ${subscriptionId}`);
|
|
626
|
+
}
|
|
627
|
+
return {
|
|
628
|
+
paymentCurrency: paymentCurrency as PaymentCurrency,
|
|
629
|
+
paymentMethod: paymentMethod as PaymentMethod,
|
|
630
|
+
receiverAddress,
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
|
|
609
634
|
export function getAuthPrincipalClaim(method: PaymentMethod, action: string) {
|
|
610
635
|
let chainInfo = { type: 'none', id: 'none', host: 'none' };
|
|
611
636
|
if (method.type === 'arcblock') {
|
|
@@ -96,8 +96,8 @@ router.get('/me', user(), async (req, res) => {
|
|
|
96
96
|
} else {
|
|
97
97
|
const [summary, stake, token] = await Promise.all([
|
|
98
98
|
doc.getSummary(),
|
|
99
|
-
getStakeSummaryByDid(doc.did, doc.livemode),
|
|
100
|
-
getTokenSummaryByDid(doc.did, doc.livemode),
|
|
99
|
+
getStakeSummaryByDid(doc.did, req?.livemode || doc.livemode),
|
|
100
|
+
getTokenSummaryByDid(doc.did, req?.livemode || doc.livemode),
|
|
101
101
|
]);
|
|
102
102
|
res.json({ ...doc.toJSON(), summary: { ...summary, stake, token } });
|
|
103
103
|
}
|