payment-kit 1.16.17 → 1.16.18

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 (66) hide show
  1. package/api/src/crons/index.ts +1 -1
  2. package/api/src/hooks/pre-start.ts +2 -0
  3. package/api/src/index.ts +2 -0
  4. package/api/src/integrations/arcblock/stake.ts +7 -1
  5. package/api/src/integrations/stripe/resource.ts +1 -1
  6. package/api/src/libs/env.ts +12 -0
  7. package/api/src/libs/event.ts +8 -0
  8. package/api/src/libs/invoice.ts +585 -3
  9. package/api/src/libs/notification/template/subscription-succeeded.ts +1 -2
  10. package/api/src/libs/notification/template/subscription-trial-will-end.ts +2 -2
  11. package/api/src/libs/notification/template/subscription-will-renew.ts +6 -2
  12. package/api/src/libs/notification/template/subscription.overdraft-protection.exhausted.ts +139 -0
  13. package/api/src/libs/overdraft-protection.ts +85 -0
  14. package/api/src/libs/payment.ts +1 -65
  15. package/api/src/libs/queue/index.ts +0 -1
  16. package/api/src/libs/subscription.ts +532 -2
  17. package/api/src/libs/util.ts +4 -0
  18. package/api/src/locales/en.ts +5 -0
  19. package/api/src/locales/zh.ts +5 -0
  20. package/api/src/queues/event.ts +3 -2
  21. package/api/src/queues/invoice.ts +28 -3
  22. package/api/src/queues/notification.ts +25 -3
  23. package/api/src/queues/payment.ts +154 -3
  24. package/api/src/queues/refund.ts +2 -2
  25. package/api/src/queues/subscription.ts +215 -4
  26. package/api/src/queues/webhook.ts +1 -0
  27. package/api/src/routes/connect/change-payment.ts +1 -1
  28. package/api/src/routes/connect/change-plan.ts +1 -1
  29. package/api/src/routes/connect/overdraft-protection.ts +120 -0
  30. package/api/src/routes/connect/recharge.ts +2 -1
  31. package/api/src/routes/connect/setup.ts +1 -1
  32. package/api/src/routes/connect/shared.ts +117 -350
  33. package/api/src/routes/connect/subscribe.ts +1 -1
  34. package/api/src/routes/customers.ts +2 -2
  35. package/api/src/routes/invoices.ts +9 -4
  36. package/api/src/routes/subscriptions.ts +172 -2
  37. package/api/src/store/migrate.ts +9 -10
  38. package/api/src/store/migrations/20240905-index.ts +95 -60
  39. package/api/src/store/migrations/20241203-overdraft-protection.ts +25 -0
  40. package/api/src/store/migrations/20241216-update-overdraft-protection.ts +30 -0
  41. package/api/src/store/models/customer.ts +2 -2
  42. package/api/src/store/models/invoice.ts +7 -0
  43. package/api/src/store/models/lock.ts +7 -0
  44. package/api/src/store/models/subscription.ts +15 -0
  45. package/api/src/store/sequelize.ts +6 -1
  46. package/blocklet.yml +1 -1
  47. package/package.json +23 -23
  48. package/src/components/customer/overdraft-protection.tsx +367 -0
  49. package/src/components/event/list.tsx +3 -4
  50. package/src/components/subscription/actions/cancel.tsx +3 -0
  51. package/src/components/subscription/portal/actions.tsx +324 -77
  52. package/src/components/uploader.tsx +31 -26
  53. package/src/env.d.ts +1 -0
  54. package/src/hooks/subscription.ts +30 -0
  55. package/src/libs/env.ts +4 -0
  56. package/src/locales/en.tsx +41 -0
  57. package/src/locales/zh.tsx +37 -0
  58. package/src/pages/admin/billing/invoices/detail.tsx +16 -15
  59. package/src/pages/customer/index.tsx +7 -2
  60. package/src/pages/customer/invoice/detail.tsx +29 -5
  61. package/src/pages/customer/invoice/past-due.tsx +18 -4
  62. package/src/pages/customer/recharge.tsx +2 -4
  63. package/src/pages/customer/subscription/change-payment.tsx +7 -1
  64. package/src/pages/customer/subscription/detail.tsx +69 -51
  65. package/tsconfig.json +0 -5
  66. package/api/tests/libs/payment.spec.ts +0 -168
@@ -65,6 +65,10 @@ import {
65
65
  BillingDiscrepancyEmailTemplate,
66
66
  BillingDiscrepancyEmailTemplateOptions,
67
67
  } from '../libs/notification/template/billing-discrepancy';
68
+ import {
69
+ OverdraftProtectionExhaustedEmailTemplate,
70
+ OverdraftProtectionExhaustedEmailTemplateOptions,
71
+ } from '../libs/notification/template/subscription.overdraft-protection.exhausted';
68
72
 
69
73
  export type NotificationQueueJobOptions = any;
70
74
 
@@ -75,7 +79,8 @@ export type NotificationQueueJobType =
75
79
  | 'customer.subscription.will_canceled'
76
80
  | 'customer.reward.succeeded'
77
81
  | 'usage.report.empty'
78
- | 'billing.discrepancy';
82
+ | 'billing.discrepancy'
83
+ | 'subscription.overdraftProtection.exhausted';
79
84
 
80
85
  export type NotificationQueueJob = {
81
86
  type: NotificationQueueJobType;
@@ -130,6 +135,11 @@ function getNotificationTemplate(job: NotificationQueueJob): BaseEmailTemplate {
130
135
  job.options as SubscriptionStakeSlashSucceededEmailTemplateOptions
131
136
  );
132
137
  }
138
+ if (job.type === 'subscription.overdraftProtection.exhausted') {
139
+ return new OverdraftProtectionExhaustedEmailTemplate(
140
+ job.options as OverdraftProtectionExhaustedEmailTemplateOptions
141
+ );
142
+ }
133
143
 
134
144
  throw new Error(`Unknown job type: ${job.type}`);
135
145
  }
@@ -234,8 +244,8 @@ export async function startNotificationQueue() {
234
244
  });
235
245
  });
236
246
 
237
- events.on('customer.subscription.renew_failed', async (subscription: Subscription) => {
238
- const invoice = await Invoice.findByPk(subscription.latest_invoice_id);
247
+ events.on('customer.subscription.renew_failed', async (subscription: Subscription, { invoiceId }) => {
248
+ const invoice = await Invoice.findByPk(invoiceId || subscription.latest_invoice_id);
239
249
 
240
250
  logger.info('events.on', 'customer.subscription.renew_failed', {
241
251
  subscriptionId: subscription.id,
@@ -317,4 +327,16 @@ export async function startNotificationQueue() {
317
327
  },
318
328
  });
319
329
  });
330
+
331
+ events.on('subscription.overdraft_protection.exhausted', (subscription: Subscription) => {
332
+ notificationQueue.push({
333
+ id: `subscription.overdraftProtection.exhausted.${subscription.id}`,
334
+ job: {
335
+ type: 'subscription.overdraftProtection.exhausted',
336
+ options: {
337
+ subscriptionId: subscription.id,
338
+ },
339
+ },
340
+ });
341
+ });
320
342
  }
@@ -1,5 +1,6 @@
1
1
  import isEmpty from 'lodash/isEmpty';
2
2
 
3
+ import { BN } from '@ocap/util';
3
4
  import { ensureStakedForGas } from '../integrations/arcblock/stake';
4
5
  import { transferErc20FromUser } from '../integrations/ethereum/token';
5
6
  import { createEvent } from '../libs/audit';
@@ -9,7 +10,6 @@ import CustomError from '../libs/error';
9
10
  import { events } from '../libs/event';
10
11
  import logger from '../libs/logger';
11
12
  import { getGasPayerExtra, isDelegationSufficientForPayment } from '../libs/payment';
12
- import createQueue from '../libs/queue';
13
13
  import {
14
14
  checkRemainingStake,
15
15
  getDaysUntilCancel,
@@ -19,6 +19,7 @@ import {
19
19
  getMinRetryMail,
20
20
  getSubscriptionCreateSetup,
21
21
  getSubscriptionStakeAddress,
22
+ isSubscriptionOverdraftProtectionEnabled,
22
23
  shouldCancelSubscription,
23
24
  } from '../libs/subscription';
24
25
  import { MAX_RETRY_COUNT, MIN_RETRY_MAIL, getNextRetry } from '../libs/util';
@@ -34,6 +35,10 @@ import { Subscription } from '../store/models/subscription';
34
35
  import { SubscriptionItem } from '../store/models/subscription-item';
35
36
  import type { PaymentError, PaymentSettings } from '../store/models/types';
36
37
  import { notificationQueue } from './notification';
38
+ import { ensureOverdraftProtectionInvoiceAndItems } from '../libs/invoice';
39
+ import { Lock } from '../store/models';
40
+ import { ensureOverdraftProtectionPrice } from '../libs/overdraft-protection';
41
+ import createQueue from '../libs/queue';
37
42
 
38
43
  type PaymentJob = {
39
44
  paymentIntentId: string;
@@ -240,6 +245,59 @@ export const handlePaymentSucceed = async (
240
245
  }
241
246
  };
242
247
 
248
+ export const doOverdraftProtection = async (
249
+ paymentIntent: PaymentIntent,
250
+ subscription: Subscription,
251
+ customer: Customer,
252
+ relatedInvoice?: Invoice
253
+ ) => {
254
+ const { enabled } = await isSubscriptionOverdraftProtectionEnabled(subscription);
255
+ if (!enabled) {
256
+ logger.warn('overdraft protection is not enabled', {
257
+ subscription: subscription.id,
258
+ });
259
+ return;
260
+ }
261
+ const existProtectedInvoice = await Invoice.findOne({
262
+ where: {
263
+ subscription_id: subscription.id,
264
+ billing_reason: 'overdraft_protection',
265
+ 'metadata.payment_intent_id': paymentIntent.id,
266
+ },
267
+ });
268
+ if (existProtectedInvoice) {
269
+ logger.info('overdraft protection invoice already exists', {
270
+ invoice: existProtectedInvoice.id,
271
+ });
272
+ return;
273
+ }
274
+ try {
275
+ const { invoice, items } = await ensureOverdraftProtectionInvoiceAndItems({
276
+ customer,
277
+ subscription,
278
+ paymentIntent,
279
+ props: {
280
+ period_start: subscription.current_period_start,
281
+ period_end: subscription.current_period_end,
282
+ metadata: {
283
+ payment_intent_id: paymentIntent.id,
284
+ invoice_id: relatedInvoice?.id || '',
285
+ },
286
+ },
287
+ });
288
+ logger.info('ensure overdraft protection invoice and items done', {
289
+ invoice: invoice.id,
290
+ items: items.map((x) => x.id),
291
+ });
292
+ } catch (error) {
293
+ logger.error('ensure overdraft protection invoice and items failed', {
294
+ error,
295
+ subscription: subscription.id,
296
+ });
297
+ throw error;
298
+ }
299
+ };
300
+
243
301
  type Updates = {
244
302
  retry: {
245
303
  payment: Partial<PaymentIntent>;
@@ -323,6 +381,49 @@ export const handlePaymentFailed = async (
323
381
  return updates.terminate;
324
382
  }
325
383
 
384
+ if (invoice.billing_reason === 'overdraft_protection') {
385
+ // overdraft protection invoice is always terminated
386
+ return updates.terminate;
387
+ }
388
+ // check overdraft protection, if protected, no need to check due
389
+ const customer = await Customer.findByPk(invoice.customer_id);
390
+
391
+ const { enabled: enableOverdraftProtection, unused: unusedAmount } =
392
+ await isSubscriptionOverdraftProtectionEnabled(subscription);
393
+ const { price } = await ensureOverdraftProtectionPrice(subscription.livemode);
394
+ const invoicePrice = (price.currency_options || []).find((x: any) => x.currency_id === paymentIntent?.currency_id);
395
+
396
+ if (subscription.overdraft_protection?.enabled && !enableOverdraftProtection) {
397
+ // overdraft protection is enabled but not enough
398
+ const lockKey = `${subscription.id}-${paymentIntent.currency_id}-overdraft-protection-exhausted`;
399
+ const isLock = await Lock.isLocked(lockKey);
400
+ if (!isLock) {
401
+ await Lock.acquire(lockKey, subscription.current_period_end);
402
+ createEvent('Subscription', 'subscription.overdraft_protection.exhausted', subscription).catch(console.error);
403
+ }
404
+ }
405
+
406
+ if (
407
+ enableOverdraftProtection &&
408
+ unusedAmount !== '0' &&
409
+ new BN(unusedAmount).gte(new BN(invoicePrice?.unit_amount || '0'))
410
+ ) {
411
+ logger.info('do overdraft protection', {
412
+ subscription: subscription.id,
413
+ payment: paymentIntent.id,
414
+ unusedAmount,
415
+ });
416
+ try {
417
+ await doOverdraftProtection(paymentIntent, subscription, customer!, invoice);
418
+ return updates.terminate;
419
+ } catch (err) {
420
+ logger.error('do overdraft protection failed', {
421
+ error: err,
422
+ subscription: subscription.id,
423
+ });
424
+ }
425
+ }
426
+
326
427
  if (subscription.isImmutable()) {
327
428
  logger.info('Subscription is immutable, no need to check due', { subscription: subscription.id });
328
429
  return updates.terminate;
@@ -543,6 +644,32 @@ export const handlePayment = async (job: PaymentJob) => {
543
644
 
544
645
  const invoice = await Invoice.findByPk(paymentIntent.invoice_id);
545
646
 
647
+ if (invoice?.status === 'void') {
648
+ await paymentIntent.update({
649
+ status: 'canceled',
650
+ canceled_at: dayjs().unix(),
651
+ cancellation_reason: 'void_invoice',
652
+ });
653
+ logger.info('PaymentIntent capture skipped because invoice is void', { id: paymentIntent.id });
654
+ return;
655
+ }
656
+
657
+ if (invoice && invoice.subscription_id) {
658
+ const subscription = await Subscription.findByPk(invoice.subscription_id);
659
+ if (
660
+ subscription &&
661
+ subscription.isActive() &&
662
+ subscription.overdraft_protection?.enabled &&
663
+ invoice?.billing_reason === 'overdraft_protection'
664
+ ) {
665
+ logger.info('PaymentIntent capture skipped because of overdraft protection', {
666
+ id: paymentIntent.id,
667
+ subscription: subscription.id,
668
+ });
669
+ return;
670
+ }
671
+ }
672
+
546
673
  // check max retry before doing any hard work
547
674
  if (invoice && invoice.attempt_count >= MAX_RETRY_COUNT) {
548
675
  logger.info('PaymentIntent capture aborted since max retry exceeded', { id: paymentIntent.id });
@@ -740,7 +867,9 @@ export const handlePayment = async (job: PaymentJob) => {
740
867
  invoiceId: invoice.id,
741
868
  });
742
869
  if (!subscription.isImmutable()) {
743
- createEvent('Subscription', 'customer.subscription.renew_failed', subscription);
870
+ createEvent('Subscription', 'customer.subscription.renew_failed', subscription, {
871
+ invoiceId: invoice.id,
872
+ });
744
873
  }
745
874
  }
746
875
  }
@@ -773,7 +902,6 @@ export const paymentQueue = createQueue<PaymentJob>({
773
902
  });
774
903
 
775
904
  export const startPaymentQueue = async () => {
776
- // Restore previous payments
777
905
  const payments = await PaymentIntent.findAll({
778
906
  where: {
779
907
  status: ['requires_capture', 'processing'],
@@ -804,3 +932,26 @@ events.on('payment_intent.succeeded', async (paymentIntent: PaymentIntent) => {
804
932
  ensureStakedForGas();
805
933
  }
806
934
  });
935
+
936
+ events.on('payment.queued', async (id, job, args = {}) => {
937
+ const { sync, ...extraArgs } = args;
938
+ if (sync) {
939
+ try {
940
+ await paymentQueue.pushAndWait({
941
+ id,
942
+ job,
943
+ ...extraArgs,
944
+ });
945
+ events.emit('payment.queued.done');
946
+ } catch (error) {
947
+ logger.error('Error in payment.queued', { id, job, error });
948
+ events.emit('payment.queued.error', error);
949
+ }
950
+ return;
951
+ }
952
+ paymentQueue.push({
953
+ id,
954
+ job,
955
+ ...extraArgs,
956
+ });
957
+ });
@@ -347,13 +347,13 @@ const handleStakeReturnJob = async (
347
347
  tokens: [{ address: paymentCurrency.contract, value: refund.amount }],
348
348
  },
349
349
  ],
350
- message: 'stake_return_on_subscription_cancel',
350
+ message: refund.description || 'stake_return_on_subscription_cancel',
351
351
  data: {
352
352
  typeUrl: 'json',
353
353
  // @ts-ignore
354
354
  value: {
355
355
  appId: wallet.address,
356
- reason: 'subscription_cancel',
356
+ reason: refund?.metadata?.reason || 'subscription_cancel',
357
357
  subscriptionId: refund.subscription_id,
358
358
  },
359
359
  },
@@ -21,9 +21,12 @@ import {
21
21
  getSubscriptionStakeAddress,
22
22
  getSubscriptionStakeReturnSetup,
23
23
  getSubscriptionStakeSlashSetup,
24
+ isSubscriptionOverdraftProtectionEnabled,
25
+ returnOverdraftProtectionStake,
24
26
  shouldCancelSubscription,
27
+ slashOverdraftProtectionStake,
25
28
  } from '../libs/subscription';
26
- import { ensureInvoiceAndItems } from '../routes/connect/shared';
29
+ import { ensureInvoiceAndItems } from '../libs/invoice';
27
30
  import { PaymentCurrency, PaymentIntent, PaymentMethod, Refund, SetupIntent, UsageRecord } from '../store/models';
28
31
  import { Customer } from '../store/models/customer';
29
32
  import { Invoice } from '../store/models/invoice';
@@ -87,7 +90,18 @@ const doHandleSubscriptionInvoice = async ({
87
90
  billing_reason: `subscription_${reason}`,
88
91
  },
89
92
  });
90
- if (previous && previous.isImmutable() === false) {
93
+ let existOverdraftProtection;
94
+ if (previous) {
95
+ existOverdraftProtection = await Invoice.findOne({
96
+ where: {
97
+ subscription_id: subscription.id,
98
+ billing_reason: 'overdraft_protection',
99
+ 'metadata.invoice_id': previous.id,
100
+ },
101
+ });
102
+ }
103
+
104
+ if (previous && previous.isImmutable() === false && !existOverdraftProtection) {
91
105
  logger.warn(`Invoice for current period ${previous.id} not paid for subscription ${subscription.id}`);
92
106
  return null;
93
107
  }
@@ -747,6 +761,102 @@ const ensureRefundOnCancel = async (subscription: Subscription) => {
747
761
  });
748
762
  };
749
763
 
764
+ const ensureReturnOverdraftProtectionStake = async (subscription: Subscription, paymentCurrencyId?: string) => {
765
+ const { enabled, remaining, revokedStake } = await isSubscriptionOverdraftProtectionEnabled(
766
+ subscription,
767
+ paymentCurrencyId
768
+ );
769
+ if (!enabled && remaining === '0' && revokedStake === '0') {
770
+ logger.info('Return overdraft protection stake skipped because no remaining', {
771
+ subscription: subscription.id,
772
+ remaining,
773
+ revokedStake,
774
+ });
775
+ return;
776
+ }
777
+ const paymentCurrency = await PaymentCurrency.findByPk(paymentCurrencyId || subscription.currency_id);
778
+ if (!paymentCurrency) {
779
+ logger.warn('Return overdraft protection stake skipped because currency not found', {
780
+ subscription: subscription.id,
781
+ currency: subscription.currency_id,
782
+ });
783
+ return;
784
+ }
785
+ const paymentMethod = await PaymentMethod.findByPk(paymentCurrency.payment_method_id);
786
+ if (!paymentMethod) {
787
+ logger.warn('Return overdraft protection stake skipped because payment method not found', {
788
+ subscription: subscription.id,
789
+ paymentMethod: paymentCurrency.payment_method_id,
790
+ });
791
+ return;
792
+ }
793
+ if (paymentMethod?.type !== 'arcblock') {
794
+ logger.info('Return overdraft protection stake skipped because payment method is not arcblock', {
795
+ subscription: subscription.id,
796
+ paymentMethod: paymentMethod?.type,
797
+ });
798
+ return;
799
+ }
800
+ await returnOverdraftProtectionStake(subscription, paymentCurrencyId);
801
+ logger.info('Overdraft protection stake returned', { subscription: subscription.id });
802
+ if (subscription.overdraft_protection?.enabled) {
803
+ await subscription.update({
804
+ // @ts-ignore
805
+ overdraft_protection: {
806
+ ...(subscription.overdraft_protection || {}),
807
+ enabled: false,
808
+ },
809
+ });
810
+ logger.info('Overdraft protection disabled', { subscription: subscription.id });
811
+ }
812
+ };
813
+
814
+ const ensureSlashOverdraftProtectionStake = async (subscription: Subscription) => {
815
+ const { remaining, revokedStake } = await isSubscriptionOverdraftProtectionEnabled(subscription);
816
+ if (remaining === '0' && revokedStake === '0') {
817
+ logger.info('Slash overdraft protection stake skipped because no remaining', {
818
+ subscription: subscription.id,
819
+ remaining,
820
+ revokedStake,
821
+ });
822
+ return;
823
+ }
824
+ const paymentCurrency = await PaymentCurrency.findByPk(subscription.currency_id);
825
+ if (!paymentCurrency) {
826
+ logger.warn('Slash overdraft protection stake skipped because currency not found', {
827
+ subscription: subscription.id,
828
+ currency: subscription.currency_id,
829
+ });
830
+ return;
831
+ }
832
+ const paymentMethod = await PaymentMethod.findByPk(paymentCurrency.payment_method_id);
833
+ if (!paymentMethod) {
834
+ logger.warn('Slash overdraft protection stake skipped because payment method not found', {
835
+ subscription: subscription.id,
836
+ paymentMethod: paymentCurrency.payment_method_id,
837
+ });
838
+ return;
839
+ }
840
+ if (paymentMethod?.type !== 'arcblock') {
841
+ logger.info('Slash overdraft protection stake skipped because payment method is not arcblock', {
842
+ subscription: subscription.id,
843
+ paymentMethod: paymentMethod?.type,
844
+ });
845
+ return;
846
+ }
847
+ await slashOverdraftProtectionStake(subscription);
848
+ logger.info('Overdraft protection stake slashed', { subscription: subscription.id });
849
+ if (subscription.overdraft_protection?.enabled) {
850
+ await subscription.update({
851
+ // @ts-ignore
852
+ overdraft_protection: {
853
+ ...(subscription.overdraft_protection || {}),
854
+ enabled: false,
855
+ },
856
+ });
857
+ }
858
+ };
859
+
750
860
  // generate invoice for subscription periodically
751
861
  export const handleSubscription = async (job: SubscriptionJob) => {
752
862
  logger.info('handle subscription', job);
@@ -780,6 +890,12 @@ export const handleSubscription = async (job: SubscriptionJob) => {
780
890
  });
781
891
 
782
892
  if (previousStatus === 'past_due' && job.action === 'cancel') {
893
+ // slash overdraft protection stake
894
+ await slashOverdraftProtectionQueue.pushAndWait({
895
+ id: `slash-overdraft-protection-${subscription.id}`,
896
+ job: { subscriptionId: subscription.id },
897
+ });
898
+ logger.info('Overdraft protection stake slash job scheduled', { subscription: subscription.id });
783
899
  await handleStakeSlashAfterCancel(subscription);
784
900
  }
785
901
  return;
@@ -924,6 +1040,44 @@ export const subscriptionCancelRefund = createQueue({
924
1040
  },
925
1041
  });
926
1042
 
1043
+ export const returnOverdraftProtectionQueue = createQueue({
1044
+ name: 'returnOverdraftProtection',
1045
+ onJob: async (job) => {
1046
+ const { subscriptionId, paymentCurrencyId } = job;
1047
+ const subscription = await Subscription.findByPk(subscriptionId);
1048
+ if (!subscription) {
1049
+ return;
1050
+ }
1051
+ await ensureReturnOverdraftProtectionStake(subscription, paymentCurrencyId);
1052
+ },
1053
+ options: {
1054
+ concurrency: 1,
1055
+ maxRetries: 5,
1056
+ retryDelay: 1000,
1057
+ maxTimeout: 60000,
1058
+ enableScheduledJob: true,
1059
+ },
1060
+ });
1061
+
1062
+ export const slashOverdraftProtectionQueue = createQueue({
1063
+ name: 'slashOverdraftProtection',
1064
+ onJob: async (job) => {
1065
+ const { subscriptionId } = job;
1066
+ const subscription = await Subscription.findByPk(subscriptionId);
1067
+ if (!subscription) {
1068
+ return;
1069
+ }
1070
+ await ensureSlashOverdraftProtectionStake(subscription);
1071
+ },
1072
+ options: {
1073
+ concurrency: 1,
1074
+ maxRetries: 5,
1075
+ retryDelay: 1000,
1076
+ maxTimeout: 60000,
1077
+ enableScheduledJob: true,
1078
+ },
1079
+ });
1080
+
927
1081
  export async function addSubscriptionJob(
928
1082
  subscription: Subscription,
929
1083
  action: 'cycle' | 'cancel' | 'resume',
@@ -978,12 +1132,11 @@ events.on('customer.subscription.recovered', async (subscription: Subscription)
978
1132
  await handleSubscriptionAfterRecover(doc!);
979
1133
  });
980
1134
 
981
- events.on('customer.subscription.deleted', (subscription: Subscription) => {
1135
+ events.on('customer.subscription.deleted', async (subscription: Subscription) => {
982
1136
  ensurePassportRevoked(subscription).catch((err) => {
983
1137
  logger.error('ensurePassportRevoked failed', { error: err, subscription: subscription.id });
984
1138
  });
985
1139
  // FIXME: ensure invoices that are open or uncollectible are voided
986
-
987
1140
  if (
988
1141
  subscription.cancelation_details?.refund &&
989
1142
  ['last', 'proration'].includes(subscription.cancelation_details.refund)
@@ -993,6 +1146,15 @@ events.on('customer.subscription.deleted', (subscription: Subscription) => {
993
1146
  job: { subscriptionId: subscription.id },
994
1147
  });
995
1148
  }
1149
+ const { remaining, revokedStake } = await isSubscriptionOverdraftProtectionEnabled(subscription);
1150
+ if (remaining !== '0' || revokedStake !== '0') {
1151
+ // need return overdraft protection stake
1152
+ await returnOverdraftProtectionQueue.pushAndWait({
1153
+ id: `return-overdraft-protection-${subscription.id}`,
1154
+ job: { subscriptionId: subscription.id },
1155
+ });
1156
+ logger.info('Overdraft protection stake return job scheduled', { subscription: subscription.id });
1157
+ }
996
1158
  if (subscription.cancelation_details?.slash_stake) {
997
1159
  slashStakeQueue.push({
998
1160
  id: `slash-stake-${subscription.id}`,
@@ -1016,6 +1178,25 @@ events.on('customer.stake.revoked', async ({ subscriptionId, tx }: { subscriptio
1016
1178
  if (!subscription) {
1017
1179
  return;
1018
1180
  }
1181
+
1182
+ const { address } = tx.tx.itxJson;
1183
+ if (address === subscription.overdraft_protection?.payment_details?.arcblock?.staking?.address) {
1184
+ // revoke overdraft protection stake
1185
+ await subscription.update({
1186
+ // @ts-ignore
1187
+ overdraft_protection: {
1188
+ ...(subscription.overdraft_protection || {}),
1189
+ enabled: false,
1190
+ },
1191
+ });
1192
+ slashOverdraftProtectionQueue.push({
1193
+ id: `slash-overdraft-protection-${subscription.id}`,
1194
+ job: { subscriptionId: subscription.id },
1195
+ });
1196
+ logger.info('Overdraft protection stake slash job scheduled', { subscription: subscription.id });
1197
+ return;
1198
+ }
1199
+
1019
1200
  if (subscription.isActive() === false) {
1020
1201
  return;
1021
1202
  }
@@ -1030,7 +1211,18 @@ events.on('customer.stake.revoked', async ({ subscriptionId, tx }: { subscriptio
1030
1211
  feedback: 'other',
1031
1212
  comment: `Revoked by ${tx.tx.from} with tx ${tx.hash}`,
1032
1213
  },
1214
+ // 关闭透支保护
1215
+ // @ts-ignore
1216
+ overdraft_protection: {
1217
+ ...(subscription.overdraft_protection || {}),
1218
+ enabled: false,
1219
+ },
1220
+ });
1221
+ slashOverdraftProtectionQueue.push({
1222
+ id: `slash-overdraft-protection-${subscription.id}`,
1223
+ job: { subscriptionId: subscription.id },
1033
1224
  });
1225
+ logger.info('Overdraft protection stake slash job scheduled', { subscription: subscription.id });
1034
1226
  await new SubscriptionWillCanceledSchedule().reScheduleSubscriptionTasks([subscription]);
1035
1227
  await addSubscriptionJob(subscription, 'cancel', true, subscription.current_period_end);
1036
1228
  });
@@ -1069,5 +1261,24 @@ events.on('setup_intent.succeeded', async (setupIntent: SetupIntent) => {
1069
1261
  paymentCurrencyId: setupIntent.metadata?.from_currency,
1070
1262
  });
1071
1263
  }
1264
+ const { remaining, revokedStake } = await isSubscriptionOverdraftProtectionEnabled(
1265
+ subscription,
1266
+ setupIntent.metadata?.from_currency
1267
+ );
1268
+
1269
+ if (remaining !== '0' || revokedStake !== '0') {
1270
+ try {
1271
+ returnOverdraftProtectionQueue.push({
1272
+ id: `return-overdraft-protection-${subscription.id}`,
1273
+ job: { subscriptionId: subscription.id, paymentCurrencyId: setupIntent.metadata?.from_currency },
1274
+ });
1275
+ logger.info('Overdraft protection stake return job scheduled', {
1276
+ subscription: subscription.id,
1277
+ paymentCurrencyId: setupIntent.metadata?.from_currency,
1278
+ });
1279
+ } catch (error) {
1280
+ logger.error('create return overdraft protection stake job failed', { error, subscription: subscription.id });
1281
+ }
1282
+ }
1072
1283
  }
1073
1284
  });
@@ -103,6 +103,7 @@ export const handleWebhook = async (job: WebhookJob) => {
103
103
  id: getWebhookJobId(event.id, webhook.id),
104
104
  job: { eventId: event.id, webhookId: webhook.id },
105
105
  runAt: getNextRetry(retryCount),
106
+ persist: false,
106
107
  });
107
108
  logger.info('scheduled webhook job', { ...job, retryCount });
108
109
  });
@@ -4,13 +4,13 @@ import { getTxMetadata } from '../../libs/util';
4
4
  import { Lock, type TLineItemExpanded } from '../../store/models';
5
5
  import {
6
6
  ensureChangePaymentContext,
7
- ensureStakeInvoice,
8
7
  executeOcapTransactions,
9
8
  getAuthPrincipalClaim,
10
9
  getDelegationTxClaim,
11
10
  getStakeTxClaim,
12
11
  updateStripeSubscriptionAfterChangePayment,
13
12
  } from './shared';
13
+ import { ensureStakeInvoice } from '../../libs/invoice';
14
14
 
15
15
  export default {
16
16
  action: 'change-payment',
@@ -8,13 +8,13 @@ import { invoiceQueue } from '../../queues/invoice';
8
8
  import { addSubscriptionJob, subscriptionQueue } from '../../queues/subscription';
9
9
  import type { TLineItemExpanded } from '../../store/models';
10
10
  import {
11
- ensureStakeInvoice,
12
11
  ensureSubscription,
13
12
  executeOcapTransactions,
14
13
  getAuthPrincipalClaim,
15
14
  getDelegationTxClaim,
16
15
  getStakeTxClaim,
17
16
  } from './shared';
17
+ import { ensureStakeInvoice } from '../../libs/invoice';
18
18
 
19
19
  export default {
20
20
  action: 'change-plan',