payment-kit 1.15.19 → 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.
Files changed (32) hide show
  1. package/api/src/crons/base.ts +69 -7
  2. package/api/src/crons/subscription-trial-will-end.ts +20 -5
  3. package/api/src/crons/subscription-will-canceled.ts +22 -6
  4. package/api/src/crons/subscription-will-renew.ts +13 -4
  5. package/api/src/index.ts +2 -0
  6. package/api/src/integrations/arcblock/stake.ts +27 -0
  7. package/api/src/libs/notification/template/subscription-canceled.ts +4 -0
  8. package/api/src/libs/notification/template/subscription-trial-will-end.ts +12 -34
  9. package/api/src/libs/notification/template/subscription-will-canceled.ts +82 -48
  10. package/api/src/libs/notification/template/subscription-will-renew.ts +16 -45
  11. package/api/src/libs/time.ts +13 -0
  12. package/api/src/libs/util.ts +17 -0
  13. package/api/src/locales/en.ts +12 -2
  14. package/api/src/locales/zh.ts +11 -2
  15. package/api/src/queues/subscription.ts +108 -4
  16. package/api/src/routes/connect/recharge.ts +143 -0
  17. package/api/src/routes/connect/shared.ts +25 -0
  18. package/api/src/routes/customers.ts +2 -2
  19. package/api/src/routes/subscription-items.ts +49 -11
  20. package/api/src/routes/subscriptions.ts +41 -36
  21. package/api/src/store/models/subscription.ts +2 -0
  22. package/api/tests/libs/time.spec.ts +54 -0
  23. package/blocklet.yml +1 -1
  24. package/package.json +19 -19
  25. package/src/app.tsx +10 -0
  26. package/src/components/subscription/actions/cancel.tsx +30 -9
  27. package/src/components/subscription/actions/index.tsx +11 -3
  28. package/src/locales/en.tsx +13 -0
  29. package/src/locales/zh.tsx +13 -0
  30. package/src/pages/customer/recharge.tsx +417 -0
  31. package/src/pages/customer/subscription/detail.tsx +38 -20
  32. package/tsconfig.json +2 -2
@@ -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 { getExplorerLink, getSubscriptionNotificationCustomActions } from '../../util';
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: string =
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
- // @ts-ignore
139
- const chainHost: string | undefined = paymentMethod?.settings?.[paymentMethod.type]?.api_host;
140
- const addFundsLink: string = getExplorerLink({
141
- type: 'account',
142
- did: userDid,
143
- chainHost,
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
- name: translate('notification.common.addFunds', locale),
426
- title: translate('notification.common.addFunds', locale),
427
- link: addFundsLink,
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),
@@ -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
+ }
@@ -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();
@@ -153,9 +153,18 @@ export default flat({
153
153
  },
154
154
 
155
155
  subscriptWillCanceled: {
156
- title: '{productName} subscription is about to be automatically cancelled ',
157
- body: '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.',
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
  });
@@ -150,9 +150,17 @@ export default flat({
150
150
  },
151
151
 
152
152
  subscriptWillCanceled: {
153
- title: '{productName} 订阅即将自动取消',
154
- body: '由于长时间未能自动完成扣费,您订阅的 {productName} 将于 {at} ({willCancelDuration}后) 被系统自动取消订阅。请您及时手动处理扣费问题,以免影响使用。如有任何疑问,请随时与我们联系。',
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', { invoice: invoice.id, subscription: subscription.id });
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('Invoice created for stake slash', { invoice: newInvoice.id, subscription: subscription.id });
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}`,
@@ -863,8 +967,7 @@ events.on('customer.subscription.deleted', (subscription: Subscription) => {
863
967
  });
864
968
 
865
969
  events.on('customer.subscription.upgraded', async (subscription: Subscription) => {
866
- await subscriptionQueue.delete(subscription.id);
867
- await addSubscriptionJob(subscription, 'cancel', true, subscription.current_period_end);
970
+ await addSubscriptionJob(subscription, 'cycle', true, subscription.current_period_end);
868
971
  logger.info('subscription cycle job rescheduled after upgrade', {
869
972
  subscription: subscription.id,
870
973
  runAt: subscription.current_period_end,
@@ -891,5 +994,6 @@ events.on('customer.stake.revoked', async ({ subscriptionId, tx }: { subscriptio
891
994
  comment: `Revoked by ${tx.tx.from} with tx ${tx.hash}`,
892
995
  },
893
996
  });
997
+ await new SubscriptionWillCanceledSchedule().reScheduleSubscriptionTasks([subscription]);
894
998
  await addSubscriptionJob(subscription, 'cancel', true, subscription.current_period_end);
895
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
  }
@@ -8,7 +8,9 @@ import { createListParamSchema, MetadataSchema } from '../libs/api';
8
8
  import { authenticate } from '../libs/security';
9
9
  import { expandLineItems } from '../libs/session';
10
10
  import { formatMetadata } from '../libs/util';
11
- import { Price, Product, SubscriptionItem, UsageRecord } from '../store/models';
11
+ import { Price, Product, Subscription, SubscriptionItem, UsageRecord } from '../store/models';
12
+ import { forwardUsageRecordToStripe } from '../integrations/stripe/resource';
13
+ import { usageRecordQueue } from '../queues/usage-record';
12
14
 
13
15
  const router = Router();
14
16
  const auth = authenticate<SubscriptionItem>({ component: true, roles: ['owner', 'admin'] });
@@ -164,19 +166,55 @@ router.post('/:id/add-usage-quantity', auth, async (req, res) => {
164
166
  return res.status(403).json({ error: 'add usage quantity not allowed in livemode' });
165
167
  }
166
168
 
167
- const subscriptionItem = await SubscriptionItem.findByPk(req.params.id);
168
- if (!subscriptionItem) {
169
- return res.status(404).json({ error: `SubscriptionItem(${req.params.id}) item not found` });
169
+ const quantity = Number(req.body.quantity);
170
+ if (quantity <= 0) {
171
+ return res.status(400).json({ error: 'quantity must be positive' });
170
172
  }
171
173
 
172
- await UsageRecord.create({
173
- livemode: Boolean(livemode),
174
- subscription_item_id: subscriptionItem.id,
175
- quantity: req.body.quantity,
176
- timestamp: dayjs().unix(),
177
- } as UsageRecord);
174
+ try {
175
+ const subscriptionItem = await SubscriptionItem.findByPk(req.params.id);
176
+ if (!subscriptionItem) {
177
+ return res.status(404).json({ error: `SubscriptionItem(${req.params.id}) item not found` });
178
+ }
179
+ const subscription = await Subscription.findByPk(subscriptionItem.subscription_id);
180
+ if (!subscription) {
181
+ return res.status(400).json({ error: `Subscription not found: ${subscriptionItem.subscription_id}` });
182
+ }
183
+
184
+ if (subscription.isImmutable()) {
185
+ return res.status(400).json({ error: `subscription(${subscription.id}) is immutable` });
186
+ }
187
+
188
+ const now = dayjs().unix();
189
+
190
+ if (now < subscription.current_period_start || now > subscription.current_period_end) {
191
+ return res.status(400).json({ error: 'timestamp must be within the current period' });
192
+ }
193
+
194
+ const usageRecord = await UsageRecord.create({
195
+ livemode: Boolean(livemode),
196
+ subscription_item_id: subscriptionItem.id,
197
+ quantity,
198
+ timestamp: now,
199
+ } as UsageRecord);
200
+
201
+ if (subscription.billing_thresholds?.amount_gte) {
202
+ usageRecordQueue.push({
203
+ id: `usage-${subscription.id}`,
204
+ job: { subscriptionId: subscription.id, subscriptionItemId: subscriptionItem.id },
205
+ });
206
+ }
178
207
 
179
- return res.json();
208
+ await forwardUsageRecordToStripe(subscriptionItem, {
209
+ quantity,
210
+ timestamp: now,
211
+ action: 'increment',
212
+ });
213
+ return res.json(usageRecord);
214
+ } catch (err) {
215
+ console.error(err);
216
+ return res.status(400).json({ error: `Failed to add usage quantity: ${err.message}` });
217
+ }
180
218
  });
181
219
 
182
220
  export default router;