payment-kit 1.13.284 → 1.13.286

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/.eslintrc.js CHANGED
@@ -13,5 +13,6 @@ module.exports = {
13
13
  'import/prefer-default-export': 'off',
14
14
  'react-hooks/exhaustive-deps': 'off',
15
15
  'jsx-a11y/no-noninteractive-element-interactions': 'off',
16
+ '@typescript-eslint/indent': 'off',
16
17
  },
17
18
  };
@@ -143,10 +143,7 @@ export async function checkStakeRevokeTx() {
143
143
  return;
144
144
  }
145
145
 
146
- const address = toStakeAddress(customer.did, wallet.address);
147
- if (t.tx.itxJson.address !== address) {
148
- return;
149
- }
146
+ const { address } = t.tx.itxJson;
150
147
 
151
148
  // Check related subscriptions in the stake
152
149
  const subscriptions = await Subscription.findAll({
@@ -163,13 +160,21 @@ export async function checkStakeRevokeTx() {
163
160
  });
164
161
 
165
162
  const { state: stake } = await client.getStakeState({ address });
166
- const data = JSON.parse(stake.data?.value || '{}');
167
- subscriptions
168
- .filter((s) => s.isActive())
169
- .filter((s) => data[s.id])
170
- .forEach((s) => {
171
- events.emit('customer.stake.revoked', { subscriptionId: s.id, tx: t });
172
- });
163
+ if (stake.nonce) {
164
+ subscriptions
165
+ .filter((s) => s.isActive())
166
+ .forEach((s) => {
167
+ events.emit('customer.stake.revoked', { subscriptionId: s.id, tx: t });
168
+ });
169
+ } else {
170
+ const data = JSON.parse(stake.data?.value || '{}');
171
+ subscriptions
172
+ .filter((s) => s.isActive())
173
+ .filter((s) => data[s.id])
174
+ .forEach((s) => {
175
+ events.emit('customer.stake.revoked', { subscriptionId: s.id, tx: t });
176
+ });
177
+ }
173
178
  })
174
179
  );
175
180
  }
@@ -15,6 +15,7 @@ import {
15
15
  PaymentMethod,
16
16
  Price,
17
17
  PriceRecurring,
18
+ Refund,
18
19
  Subscription,
19
20
  SubscriptionItem,
20
21
  SubscriptionUpdateItem,
@@ -632,3 +633,75 @@ export async function onSubscriptionUpdateConnected(subscriptionId: string) {
632
633
  });
633
634
  }
634
635
  }
636
+
637
+ export async function getRemainingStakes(subscriptionIds: string[], subscriptionInitStakes: { [key: string]: string }) {
638
+ if (!subscriptionIds || subscriptionIds.length === 0) {
639
+ return '0';
640
+ }
641
+ let total = new BN('0');
642
+ await Promise.all(
643
+ subscriptionIds.map(async (subscriptionId) => {
644
+ const refund = await Refund.findOne({
645
+ where: { subscription_id: subscriptionId, status: 'succeeded', type: 'stake_return' },
646
+ });
647
+ if (!refund) {
648
+ // this subscription not return stake
649
+ total = total.add(new BN(subscriptionInitStakes[subscriptionId] || '0'));
650
+ }
651
+ })
652
+ );
653
+ return total.toString();
654
+ }
655
+
656
+ export async function getSubscriptionStakeReturnSetup(
657
+ subscription: Subscription,
658
+ address: string,
659
+ paymentMethod: PaymentMethod
660
+ ) {
661
+ const client = paymentMethod.getOcapClient();
662
+ const { state } = await client.getStakeState({ address });
663
+ const currency = await PaymentCurrency.findByPk(subscription.currency_id);
664
+ if (!state.tokens || !currency) {
665
+ return {
666
+ total: '0',
667
+ return_amount: '0',
668
+ sender: '',
669
+ };
670
+ }
671
+ const total = new BN(state.tokens.find((x: any) => x.address === currency.contract)?.value || '0');
672
+ const [summary] = await Invoice.getUncollectibleAmount({
673
+ subscriptionId: subscription.id,
674
+ currencyId: subscription.currency_id,
675
+ customerId: subscription.customer_id,
676
+ });
677
+ const subscriptionInitStakes = JSON.parse(state.data?.value || '{}');
678
+ const initStake = subscriptionInitStakes[subscription.id];
679
+ const uncollectibleAmountBN = new BN(summary?.[subscription.currency_id] || '0');
680
+ const lastInvoice = await Invoice.findByPk(subscription.latest_invoice_id);
681
+ if (state.nonce) {
682
+ const returnStake = total.sub(uncollectibleAmountBN);
683
+ return {
684
+ total: total.toString(),
685
+ return_amount: returnStake.lt(new BN(0)) ? '0' : returnStake.toString(),
686
+ sender: state.sender,
687
+ lastInvoice,
688
+ };
689
+ }
690
+ const getReturnState = async () => {
691
+ const initStakeBN = new BN(initStake);
692
+ const otherSubscriptionIds = Object.keys(subscriptionInitStakes).filter(
693
+ (k) => k !== 'appId' && k !== subscription.id
694
+ );
695
+ const remainingStakes = await getRemainingStakes(otherSubscriptionIds, subscriptionInitStakes);
696
+ const actualStakeBN = new BN(total).sub(new BN(remainingStakes));
697
+ const minStakeBN = initStakeBN.lt(actualStakeBN) ? initStakeBN : actualStakeBN;
698
+ return minStakeBN.sub(uncollectibleAmountBN);
699
+ };
700
+ const returnStake = await getReturnState();
701
+ return {
702
+ total: initStake,
703
+ return_amount: returnStake.lt(new BN(0)) ? '0' : returnStake.toString(),
704
+ sender: state.sender,
705
+ lastInvoice,
706
+ };
707
+ }
@@ -1,3 +1,5 @@
1
+ import { toStakeAddress } from '@arcblock/did-util';
2
+
1
3
  import { sendErc20ToUser } from '../integrations/ethereum/token';
2
4
  import { wallet } from '../libs/auth';
3
5
  import CustomError from '../libs/error';
@@ -93,11 +95,6 @@ export const handleRefund = async (job: RefundJob) => {
93
95
  logger.warn(`PaymentMethod not found: ${paymentCurrency.payment_method_id}`);
94
96
  return;
95
97
  }
96
- const supportAutoCharge = await PaymentMethod.supportAutoCharge(paymentCurrency.payment_method_id);
97
- if (supportAutoCharge === false) {
98
- logger.warn(`PaymentMethod does not support auto charge: ${paymentCurrency.payment_method_id}`);
99
- return;
100
- }
101
98
 
102
99
  const customer = await Customer.findByPk(refund.customer_id);
103
100
  if (!customer) {
@@ -105,6 +102,26 @@ export const handleRefund = async (job: RefundJob) => {
105
102
  return;
106
103
  }
107
104
 
105
+ if (refund?.type === 'stake_return') {
106
+ handleStakeReturnJob(job, refund, paymentCurrency, paymentMethod, customer);
107
+ } else if (paymentMethod) {
108
+ handleRefundJob(job, refund, paymentCurrency, paymentMethod, customer);
109
+ }
110
+ };
111
+
112
+ const handleRefundJob = async (
113
+ job: RefundJob,
114
+ refund: Refund,
115
+ paymentCurrency: PaymentCurrency,
116
+ paymentMethod: PaymentMethod,
117
+ customer: Customer
118
+ ) => {
119
+ const supportAutoCharge = await PaymentMethod.supportAutoCharge(paymentCurrency.payment_method_id);
120
+ if (supportAutoCharge === false) {
121
+ logger.warn(`PaymentMethod does not support auto charge: ${paymentCurrency.payment_method_id}`);
122
+ return;
123
+ }
124
+
108
125
  // try refund transfer and reschedule on error
109
126
  logger.info('Refund transfer attempt', { id: refund.id, attempt: refund.attempt_count });
110
127
  let result;
@@ -223,6 +240,94 @@ export const handleRefund = async (job: RefundJob) => {
223
240
  }
224
241
  };
225
242
 
243
+ const handleStakeReturnJob = async (
244
+ job: RefundJob,
245
+ refund: Refund,
246
+ paymentCurrency: PaymentCurrency,
247
+ paymentMethod: PaymentMethod,
248
+ customer: Customer
249
+ ) => {
250
+ // try stake return and reschedule on error
251
+ logger.info('Stake return attempt', { id: refund.id, attempt: refund.attempt_count });
252
+ try {
253
+ if (paymentMethod.type === 'arcblock') {
254
+ const arcblockDetail = refund.payment_details?.arcblock;
255
+ if (!arcblockDetail) {
256
+ throw new Error('arcblockDetail info not found');
257
+ }
258
+ const client = paymentMethod.getOcapClient();
259
+ const signed = await client.signSlashStakeTx({
260
+ tx: {
261
+ itx: {
262
+ address: toStakeAddress(customer.did, wallet.address),
263
+ outputs: [
264
+ {
265
+ owner: arcblockDetail?.receiver,
266
+ tokens: [{ address: paymentCurrency.contract, value: refund.amount }],
267
+ },
268
+ ],
269
+ message: 'stake_return_on_subscription_cancel',
270
+ data: {
271
+ typeUrl: 'json',
272
+ // @ts-ignore
273
+ value: {
274
+ appId: wallet.address,
275
+ reason: 'subscription_cancel',
276
+ subscriptionId: refund.subscription_id,
277
+ },
278
+ },
279
+ },
280
+ },
281
+ wallet,
282
+ });
283
+ // @ts-ignore
284
+ const { buffer } = await client.encodeSlashStakeTx({ tx: signed });
285
+ // @ts-ignore
286
+ const txHash = await client.sendSlashStakeTx({ tx: signed, wallet }, getGasPayerExtra(buffer));
287
+ logger.info('stake return done', { id: refund.id, txHash });
288
+ await refund.update({
289
+ status: 'succeeded',
290
+ last_attempt_error: null,
291
+ payment_details: {
292
+ arcblock: {
293
+ tx_hash: txHash,
294
+ payer: wallet.address,
295
+ type: 'stake_return',
296
+ receiver: arcblockDetail?.receiver,
297
+ },
298
+ },
299
+ });
300
+ }
301
+ } catch (err: any) {
302
+ logger.error('stake return failed', { error: err, id: refund.id });
303
+
304
+ const error: PaymentError = {
305
+ type: 'card_error',
306
+ code: err.code,
307
+ message: err.message,
308
+ payment_method_id: paymentMethod.id,
309
+ payment_method_type: paymentMethod.type,
310
+ };
311
+
312
+ const updates = await handleRefundFailed(refund, error);
313
+ await refund.update(updates.refund);
314
+
315
+ // reschedule next attempt
316
+ const retryAt = updates.refund.next_attempt;
317
+ if (retryAt) {
318
+ refundQueue.push({
319
+ id: refund.id,
320
+ job: { refundId: refund.id, retryOnError: job.retryOnError },
321
+ runAt: retryAt,
322
+ });
323
+ logger.error('stake return retry scheduled', { id: refund.id, retryAt });
324
+ } else {
325
+ logger.info('stake job deleted since no retry', { id: refund.id });
326
+ refundQueue.delete(refund.id);
327
+ }
328
+ }
329
+ };
330
+
226
331
  export const refundQueue = createQueue<RefundJob>({
227
332
  name: 'refund',
228
333
  onJob: handleRefund,
@@ -105,9 +105,8 @@ export default {
105
105
 
106
106
  onAuth: async ({ request, userDid, userPk, claims, extraParams }: CallbackArgs) => {
107
107
  const { subscriptionId } = extraParams;
108
- const { subscription, setupIntent, paymentCurrency, paymentMethod } = await ensureChangePaymentContext(
109
- subscriptionId
110
- );
108
+ const { subscription, setupIntent, paymentCurrency, paymentMethod } =
109
+ await ensureChangePaymentContext(subscriptionId);
111
110
 
112
111
  const prepareTxExecution = async () => {
113
112
  await subscription?.update({
@@ -141,7 +140,14 @@ export default {
141
140
 
142
141
  if (paymentMethod.type === 'arcblock') {
143
142
  await prepareTxExecution();
144
- const paymentDetails = await executeOcapTransactions(userDid, userPk, claims, paymentMethod, request);
143
+ const paymentDetails = await executeOcapTransactions(
144
+ userDid,
145
+ userPk,
146
+ claims,
147
+ paymentMethod,
148
+ request,
149
+ subscription?.id
150
+ );
145
151
  await afterTxExecution(paymentDetails);
146
152
  return { hash: paymentDetails.tx_hash };
147
153
  }
@@ -141,7 +141,14 @@ export default {
141
141
  if (paymentMethod.type === 'arcblock') {
142
142
  await prepareTxExecution();
143
143
 
144
- const paymentDetails = await executeOcapTransactions(userDid, userPk, claims, paymentMethod, request);
144
+ const paymentDetails = await executeOcapTransactions(
145
+ userDid,
146
+ userPk,
147
+ claims,
148
+ paymentMethod,
149
+ request,
150
+ subscription?.id
151
+ );
145
152
  await afterTxExecution(paymentDetails);
146
153
 
147
154
  return { hash: paymentDetails.tx_hash };
@@ -168,7 +168,14 @@ export default {
168
168
  try {
169
169
  await prepareTxExecution();
170
170
 
171
- const paymentDetails = await executeOcapTransactions(userDid, userPk, claims, paymentMethod, request);
171
+ const paymentDetails = await executeOcapTransactions(
172
+ userDid,
173
+ userPk,
174
+ claims,
175
+ paymentMethod,
176
+ request,
177
+ subscription?.id
178
+ );
172
179
  await afterTxExecution(paymentDetails);
173
180
 
174
181
  return { hash: paymentDetails.tx_hash };
@@ -387,7 +387,7 @@ export async function ensureInvoiceAndItems({
387
387
  subscription?: Subscription;
388
388
  props: TInvoice;
389
389
  lineItems: TLineItemExpanded[];
390
- trialing: boolean; // do we have trialing
390
+ trialing: boolean; // do we have trialing
391
391
  metered: boolean; // is the quantity metered
392
392
  applyCredit?: boolean; // should we apply customer credit?
393
393
  }): Promise<{ invoice: Invoice; items: InvoiceItem[] }> {
@@ -637,13 +637,13 @@ export async function getDelegationTxClaim({
637
637
  userDid: string;
638
638
  userPk: string;
639
639
  nonce: string;
640
- mode: string;
640
+ mode: string;
641
641
  data: any;
642
642
  items: TLineItemExpanded[];
643
643
  paymentCurrency: PaymentCurrency;
644
644
  paymentMethod: PaymentMethod;
645
- trialing: boolean;
646
- billingThreshold?: number;
645
+ trialing: boolean;
646
+ billingThreshold?: number;
647
647
  }) {
648
648
  const amount = getFastCheckoutAmount(items, mode, paymentCurrency.id);
649
649
  const address = toDelegateAddress(userDid, wallet.address);
@@ -739,16 +739,16 @@ export async function getStakeTxClaim({
739
739
  if (paymentMethod.type === 'arcblock') {
740
740
  // create staking data
741
741
  const client = paymentMethod.getOcapClient();
742
- const address = toStakeAddress(userDid, wallet.address);
742
+ const address = toStakeAddress(userDid, wallet.address, subscription.id);
743
743
  const { state } = await client.getStakeState({ address });
744
744
  const data = {
745
745
  type: 'json',
746
746
  value: Object.assign(
747
747
  {
748
748
  appId: wallet.address,
749
+ subscriptionId: subscription.id,
749
750
  },
750
- JSON.parse(state?.data?.value || '{}'),
751
- { [subscription.id]: amount }
751
+ JSON.parse(state?.data?.value || '{}')
752
752
  ),
753
753
  };
754
754
 
@@ -766,6 +766,7 @@ export async function getStakeTxClaim({
766
766
  slashers: [wallet.address],
767
767
  revokeWaitingPeriod: setup.cycle.duration / 1000, // wait for at least 1 billing cycle
768
768
  message: `Stake for subscription ${subscription.id}`,
769
+ nonce: subscription.id,
769
770
  inputs: [],
770
771
  data,
771
772
  },
@@ -976,7 +977,8 @@ export async function executeOcapTransactions(
976
977
  userPk: string,
977
978
  claims: any[],
978
979
  paymentMethod: PaymentMethod,
979
- request: Request
980
+ request: Request,
981
+ subscriptionId?: string,
980
982
  ) {
981
983
  const client = paymentMethod.getOcapClient();
982
984
  const delegation = claims.find((x) => x.type === 'signature' && x.meta?.purpose === 'delegation');
@@ -1010,13 +1012,15 @@ export async function executeOcapTransactions(
1010
1012
  })
1011
1013
  );
1012
1014
 
1015
+ const nonce = subscriptionId || '';
1016
+
1013
1017
  return {
1014
1018
  tx_hash: delegationTxHash,
1015
1019
  payer: userDid,
1016
1020
  type: 'delegate',
1017
1021
  staking: {
1018
1022
  tx_hash: stakingTxHash,
1019
- address: toStakeAddress(userDid, wallet.address),
1023
+ address: toStakeAddress(userDid, wallet.address, nonce),
1020
1024
  },
1021
1025
  };
1022
1026
  }
@@ -155,7 +155,14 @@ export default {
155
155
  await prepareTxExecution();
156
156
  const { invoice } = await ensureInvoiceForCheckout({ checkoutSession, customer, subscription });
157
157
 
158
- const paymentDetails = await executeOcapTransactions(userDid, userPk, claims, paymentMethod, request);
158
+ const paymentDetails = await executeOcapTransactions(
159
+ userDid,
160
+ userPk,
161
+ claims,
162
+ paymentMethod,
163
+ request,
164
+ subscription?.id
165
+ );
159
166
  await afterTxExecution(invoice!, paymentDetails);
160
167
 
161
168
  return { hash: paymentDetails.tx_hash };
@@ -10,6 +10,7 @@ import { createListParamSchema, getWhereFromKvQuery } from '../libs/api';
10
10
  import { authenticate } from '../libs/security';
11
11
  import { expandLineItems } from '../libs/session';
12
12
  import { formatMetadata } from '../libs/util';
13
+ import { Refund } from '../store/models';
13
14
  import { Customer } from '../store/models/customer';
14
15
  import { Invoice } from '../store/models/invoice';
15
16
  import { InvoiceItem } from '../store/models/invoice-item';
@@ -166,6 +167,46 @@ router.get('/', authMine, async (req, res) => {
166
167
  },
167
168
  },
168
169
  });
170
+ const stakeRecord = await Refund.findOne({
171
+ where: { subscription_id: subscription.id, status: 'succeeded', type: 'stake_return' },
172
+ });
173
+ if (stakeRecord) {
174
+ list.unshift({
175
+ id: address as string,
176
+ status: 'paid',
177
+ description: 'Return Subscription staking',
178
+ billing_reason: 'subscription_create',
179
+ total: stakeRecord.amount,
180
+ amount_due: '0',
181
+ amount_paid: stakeRecord.amount,
182
+ amount_remaining: '0',
183
+ ...pick(last, [
184
+ 'number',
185
+ 'paid',
186
+ 'auto_advance',
187
+ 'currency_id',
188
+ 'customer_id',
189
+ 'subscription_id',
190
+ 'period_start',
191
+ 'period_end',
192
+ 'created_at',
193
+ 'updated_at',
194
+ ]),
195
+ // @ts-ignore
196
+ paymentCurrency: await PaymentCurrency.findByPk(last.currency_id),
197
+ paymentMethod: method,
198
+ // @ts-ignore
199
+ customer: await Customer.findByPk(last.customer_id),
200
+ metadata: {
201
+ payment_details: {
202
+ arcblock: {
203
+ tx_hash: stakeRecord?.payment_details?.arcblock?.tx_hash,
204
+ payer: stakeRecord?.payment_details?.arcblock?.payer,
205
+ },
206
+ },
207
+ },
208
+ });
209
+ }
169
210
  }
170
211
  }
171
212
  }
@@ -18,6 +18,7 @@ import {
18
18
  finalizeSubscriptionUpdate,
19
19
  getSubscriptionCreateSetup,
20
20
  getSubscriptionRefundSetup,
21
+ getSubscriptionStakeReturnSetup,
21
22
  getUpcomingInvoiceAmount,
22
23
  } from '../libs/subscription';
23
24
  import { MAX_SUBSCRIPTION_ITEM_COUNT, formatMetadata } from '../libs/util';
@@ -226,6 +227,7 @@ router.put('/:id/cancel', authPortal, async (req, res) => {
226
227
  feedback = 'other',
227
228
  comment = '',
228
229
  reason = 'payment_disputed',
230
+ staking = 'none',
229
231
  } = req.body;
230
232
  if (at === 'custom' && dayjs(time).unix() < dayjs().unix()) {
231
233
  return res.status(400).json({ error: 'cancel at must be a future timestamp' });
@@ -306,9 +308,10 @@ router.put('/:id/cancel', authPortal, async (req, res) => {
306
308
  const result = await getSubscriptionRefundSetup(subscription, updates.cancel_at);
307
309
  if (result.unused !== '0') {
308
310
  const item = await Refund.create({
311
+ type: 'refund',
309
312
  livemode: subscription.livemode,
310
313
  amount: refund === 'last' ? result.total : result.unused,
311
- description: 'subscription_cancel',
314
+ description: 'refund_transfer_on_subscription_cancel',
312
315
  status: 'pending',
313
316
  reason: 'requested_by_admin',
314
317
  currency_id: subscription.currency_id,
@@ -346,6 +349,66 @@ router.put('/:id/cancel', authPortal, async (req, res) => {
346
349
  }
347
350
  }
348
351
 
352
+ // trigger stake return
353
+ if (staking === 'proration') {
354
+ if (['owner', 'admin'].includes(req.user?.role as string) === false) {
355
+ return res.status(403).json({ error: 'Not authorized to perform this action' });
356
+ }
357
+ const paymentMethod = await PaymentMethod.findByPk(subscription.default_payment_method_id);
358
+ if (paymentMethod?.type !== 'arcblock') {
359
+ return res
360
+ .status(400)
361
+ .json({ error: `Stake return not supported for subscription with payment method ${paymentMethod?.type}` });
362
+ }
363
+ const address = subscription?.payment_details?.arcblock?.staking?.address ?? undefined;
364
+ if (!address) {
365
+ return res.status(400).json({ error: 'Staking not found on subscription payment detail' });
366
+ }
367
+ const result = await getSubscriptionStakeReturnSetup(subscription, address, paymentMethod);
368
+ if (result.return_amount !== '0') {
369
+ // do the stake return
370
+ const item = await Refund.create({
371
+ type: 'stake_return',
372
+ livemode: subscription.livemode,
373
+ amount: result.return_amount,
374
+ description: 'stake_return_on_subscription_cancel',
375
+ status: 'pending',
376
+ reason: 'requested_by_admin',
377
+ currency_id: subscription.currency_id,
378
+ customer_id: subscription.customer_id,
379
+ payment_method_id: subscription.default_payment_method_id,
380
+ payment_intent_id: result?.lastInvoice?.payment_intent_id as string,
381
+ subscription_id: subscription.id,
382
+ attempt_count: 0,
383
+ attempted: false,
384
+ next_attempt: 0,
385
+ last_attempt_error: null,
386
+ starting_balance: '0',
387
+ ending_balance: '0',
388
+ starting_token_balance: {},
389
+ ending_token_balance: {},
390
+ payment_details: {
391
+ // @ts-ignore
392
+ arcblock: {
393
+ receiver: result.sender,
394
+ },
395
+ },
396
+ });
397
+ logger.info('subscription cancel stake return created', {
398
+ ...req.params,
399
+ ...req.body,
400
+ ...pick(result, ['return_amount']),
401
+ item: item.toJSON(),
402
+ });
403
+ } else {
404
+ logger.info('subscription cancel stake return skipped', {
405
+ ...req.params,
406
+ ...req.body,
407
+ ...pick(result, ['return_amount']),
408
+ });
409
+ }
410
+ }
411
+
349
412
  return res.json(subscription);
350
413
  });
351
414
 
@@ -1181,6 +1244,38 @@ router.get('/:id/proration', authPortal, async (req, res) => {
1181
1244
  }
1182
1245
  });
1183
1246
 
1247
+ // Simulate stake return when subscription is canceled
1248
+ router.get('/:id/staking', authPortal, async (req, res) => {
1249
+ try {
1250
+ const subscription = await Subscription.findByPk(req.params.id);
1251
+ if (!subscription) {
1252
+ return res.status(404).json({ error: 'Subscription not found' });
1253
+ }
1254
+ if (subscription.isActive() === false) {
1255
+ return res.status(400).json({ error: 'Subscription is not active' });
1256
+ }
1257
+
1258
+ const paymentMethod = await PaymentMethod.findByPk(subscription.default_payment_method_id);
1259
+ if (paymentMethod?.type !== 'arcblock') {
1260
+ return res
1261
+ .status(400)
1262
+ .json({ error: `Stake return not supported for subscription with payment method ${paymentMethod?.type}` });
1263
+ }
1264
+ const address = subscription?.payment_details?.arcblock?.staking?.address ?? undefined;
1265
+ if (!address) {
1266
+ return res.status(400).json({ error: 'Staking not found on subscription payment detail' });
1267
+ }
1268
+ const result = await getSubscriptionStakeReturnSetup(subscription, address, paymentMethod);
1269
+ return res.json({
1270
+ return_amount: result.return_amount,
1271
+ total: result.total,
1272
+ });
1273
+ } catch (err) {
1274
+ console.error(err);
1275
+ return res.status(400).json({ error: err.message });
1276
+ }
1277
+ });
1278
+
1184
1279
  // Check payment change status
1185
1280
  router.get('/:id/change-payment', authPortal, async (req, res) => {
1186
1281
  const subscription = await Subscription.findByPk(req.params.id);
@@ -0,0 +1,23 @@
1
+ /* eslint-disable no-await-in-loop */
2
+ import { DataTypes } from 'sequelize';
3
+
4
+ import { Migration, safeApplyColumnChanges } from '../migrate';
5
+
6
+ export const up: Migration = async ({ context }) => {
7
+ await safeApplyColumnChanges(context, {
8
+ refunds: [
9
+ {
10
+ name: 'type',
11
+ field: {
12
+ type: DataTypes.ENUM('refund', 'stake_return'),
13
+ allowNull: true,
14
+ defaultValue: 'refund',
15
+ },
16
+ },
17
+ ],
18
+ });
19
+ };
20
+
21
+ export const down: Migration = async ({ context }) => {
22
+ await context.removeColumn('refunds', 'type');
23
+ };
@@ -62,6 +62,7 @@ export class Refund extends Model<InferAttributes<Refund>, InferCreationAttribut
62
62
 
63
63
  declare created_at: CreationOptional<Date>;
64
64
  declare updated_at: CreationOptional<Date>;
65
+ declare type: LiteralUnion<'refund' | 'stake_return', string>;
65
66
 
66
67
  public static readonly GENESIS_ATTRIBUTES = {
67
68
  id: {
@@ -182,6 +183,11 @@ export class Refund extends Model<InferAttributes<Refund>, InferCreationAttribut
182
183
  type: DataTypes.STRING(30),
183
184
  allowNull: true,
184
185
  },
186
+ type: {
187
+ type: DataTypes.ENUM('refund', 'stake_return'),
188
+ allowNull: true,
189
+ defaultValue: 'refund',
190
+ },
185
191
  },
186
192
  {
187
193
  sequelize,
@@ -266,7 +266,8 @@ export type PaymentDetails = {
266
266
  arcblock?: {
267
267
  tx_hash: string;
268
268
  payer: string;
269
- type?: LiteralUnion<'slash' | 'transfer' | 'delegate', string>;
269
+ type?: LiteralUnion<'slash' | 'transfer' | 'delegate' | 'stake_return', string>;
270
+ receiver?: string;
270
271
  staking?: {
271
272
  tx_hash: string;
272
273
  address: string;
package/blocklet.yml CHANGED
@@ -14,7 +14,7 @@ repository:
14
14
  type: git
15
15
  url: git+https://github.com/blocklet/payment-kit.git
16
16
  specVersion: 1.2.8
17
- version: 1.13.284
17
+ version: 1.13.286
18
18
  logo: logo.png
19
19
  files:
20
20
  - dist
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "payment-kit",
3
- "version": "1.13.284",
3
+ "version": "1.13.286",
4
4
  "scripts": {
5
5
  "dev": "blocklet dev --open",
6
6
  "eject": "vite eject",
@@ -52,7 +52,7 @@
52
52
  "@arcblock/validator": "^1.18.123",
53
53
  "@blocklet/js-sdk": "1.16.28",
54
54
  "@blocklet/logger": "1.16.28",
55
- "@blocklet/payment-react": "1.13.284",
55
+ "@blocklet/payment-react": "1.13.286",
56
56
  "@blocklet/sdk": "1.16.28",
57
57
  "@blocklet/ui-react": "^2.10.1",
58
58
  "@blocklet/uploader": "^0.1.11",
@@ -117,8 +117,8 @@
117
117
  },
118
118
  "devDependencies": {
119
119
  "@abtnode/types": "1.16.28",
120
- "@arcblock/eslint-config-ts": "^0.3.0",
121
- "@blocklet/payment-types": "1.13.284",
120
+ "@arcblock/eslint-config-ts": "^0.3.2",
121
+ "@blocklet/payment-types": "1.13.286",
122
122
  "@types/cookie-parser": "^1.4.7",
123
123
  "@types/cors": "^2.8.17",
124
124
  "@types/debug": "^4.1.12",
@@ -136,7 +136,7 @@
136
136
  "lint-staged": "^12.5.0",
137
137
  "nodemon": "^2.0.22",
138
138
  "npm-run-all": "^4.1.5",
139
- "prettier": "^2.8.8",
139
+ "prettier": "^3.3.2",
140
140
  "prettier-plugin-import-sort": "^0.0.7",
141
141
  "ts-jest": "^29.1.4",
142
142
  "ts-node": "^10.9.2",
@@ -158,5 +158,5 @@
158
158
  "parser": "typescript"
159
159
  }
160
160
  },
161
- "gitHead": "c844c5590104d35d4bffedddec6f909684bacc4b"
161
+ "gitHead": "fa41b3d6a308d9405f56717beee2f030a91ddec3"
162
162
  }
@@ -398,7 +398,9 @@ const Root = styled(Box)`
398
398
 
399
399
  .status-options {
400
400
  position: absolute;
401
- box-shadow: 0px 5px 5px -3px rgba(0, 0, 0, 0.2), 0px 8px 10px 1px rgba(0, 0, 0, 0.14),
401
+ box-shadow:
402
+ 0px 5px 5px -3px rgba(0, 0, 0, 0.2),
403
+ 0px 8px 10px 1px rgba(0, 0, 0, 0.14),
402
404
  0px 3px 14px 2px rgba(0, 0, 0, 0.12);
403
405
  padding: 0;
404
406
  background: #fff;
@@ -1,8 +1,10 @@
1
1
  import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
2
2
  import { Button, CircularProgress, Stack } from '@mui/material';
3
+ import { isObject } from 'lodash';
3
4
  import type { EventHandler } from 'react';
4
5
  import { FormProvider, useForm } from 'react-hook-form';
5
6
 
7
+ import { isObjectContent } from '../../libs/util';
6
8
  import MetadataForm from './form';
7
9
 
8
10
  export default function MetadataEditor({
@@ -20,7 +22,10 @@ export default function MetadataEditor({
20
22
  const metadata = data.metadata || {};
21
23
  const methods = useForm<any>({
22
24
  defaultValues: {
23
- metadata: Object.keys(metadata).map((key: string) => ({ key, value: metadata[key] })),
25
+ metadata: Object.keys(metadata).map((key: string) => ({
26
+ key,
27
+ value: isObject(metadata[key]) ? JSON.stringify(metadata[key]) : metadata[key],
28
+ })),
24
29
  },
25
30
  });
26
31
 
@@ -31,7 +36,16 @@ export default function MetadataEditor({
31
36
  };
32
37
  const onSubmit = () => {
33
38
  handleSubmit(async (formData: any) => {
34
- await onSave(formData);
39
+ const payload = {
40
+ ...(formData || {}),
41
+ metadata: Array.isArray(formData.metadata)
42
+ ? formData.metadata.reduce((acc: any, x: any) => {
43
+ acc[x.key] = isObjectContent(x.value) ? JSON.parse(x.value) : x.value;
44
+ return acc;
45
+ }, {})
46
+ : [],
47
+ };
48
+ await onSave(payload);
35
49
  reset();
36
50
  onCancel(null);
37
51
  })();
@@ -3,6 +3,7 @@ import { Typography } from '@mui/material';
3
3
  import isEmpty from 'lodash/isEmpty';
4
4
  import isObject from 'lodash/isObject';
5
5
 
6
+ import { isObjectContent } from '../../libs/util';
6
7
  import InfoRow from '../info-row';
7
8
 
8
9
  export default function MetadataList({ data }: { data: any }) {
@@ -12,14 +13,22 @@ export default function MetadataList({ data }: { data: any }) {
12
13
  return <Typography color="text.secondary">{t('common.metadata.empty')}</Typography>;
13
14
  }
14
15
 
16
+ const formatValue = (value: any) => {
17
+ if (isObjectContent(value)) {
18
+ return <pre>{JSON.stringify(JSON.parse(value), null, 2)}</pre>;
19
+ }
20
+ if (isObject(value)) {
21
+ return <pre>{JSON.stringify(value, null, 2)}</pre>;
22
+ }
23
+ return value;
24
+ };
25
+
15
26
  // skip non-string values
16
27
  return (
17
28
  <>
18
- {Object.keys(data || {})
19
- .filter((key) => isObject(data[key]) === false)
20
- .map((key) => (
21
- <InfoRow key={key} label={key} value={data[key]} />
22
- ))}
29
+ {Object.keys(data || {}).map((key) => (
30
+ <InfoRow key={key} label={key} value={formatValue(data[key])} />
31
+ ))}
23
32
  </>
24
33
  );
25
34
  }
@@ -11,5 +11,8 @@ const Root = styled(Box)`
11
11
  margin-top: 40px;
12
12
  position: relative;
13
13
  overflow: hidden;
14
- box-shadow: 0 20px 44px #32325d1f, 0 -1px 32px #32325d0f, 0 3px 12px #00000014;
14
+ box-shadow:
15
+ 0 20px 44px #32325d1f,
16
+ 0 -1px 32px #32325d0f,
17
+ 0 3px 12px #00000014;
15
18
  `;
@@ -141,6 +141,22 @@ export default function RefundList({ customer_id, invoice_id, subscription_id, s
141
141
  },
142
142
  },
143
143
  },
144
+ {
145
+ label: t('common.type'),
146
+ name: 'type',
147
+ width: 60,
148
+ options: {
149
+ filter: true,
150
+ customBodyRenderLite: (_: string, index: number) => {
151
+ const item = data.list[index] as TRefundExpanded;
152
+ return (
153
+ <Link to={`/admin/payments/${item.id}`}>
154
+ <Status label={t(`refund.type.${item.type}`)} />
155
+ </Link>
156
+ );
157
+ },
158
+ },
159
+ },
144
160
  {
145
161
  label: t('common.description'),
146
162
  name: 'description',
@@ -1,7 +1,7 @@
1
1
  import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
2
2
  import { api, formatAmount, formatTime } from '@blocklet/payment-react';
3
3
  import type { TSubscriptionExpanded } from '@blocklet/payment-types';
4
- import { Box, Divider, FormControlLabel, Radio, RadioGroup, Stack, TextField, Typography } from '@mui/material';
4
+ import { Box, Divider, FormControlLabel, Radio, RadioGroup, Stack, TextField, Typography, styled } from '@mui/material';
5
5
  import { useRequest } from 'ahooks';
6
6
  import { useEffect } from 'react';
7
7
  import { Controller, useFormContext, useWatch } from 'react-hook-form';
@@ -10,18 +10,24 @@ const fetchData = (id: string, time: string): Promise<{ total: string; unused: s
10
10
  return api.get(`/api/subscriptions/${id}/proration?time=${encodeURIComponent(time)}`).then((res: any) => res.data);
11
11
  };
12
12
 
13
+ const fetchStakingData = (id: string, time: string): Promise<{ return_amount: string }> => {
14
+ return api.get(`/api/subscriptions/${id}/staking?time=${encodeURIComponent(time)}`).then((res: any) => res.data);
15
+ };
16
+
13
17
  export default function SubscriptionCancelForm({ data }: { data: TSubscriptionExpanded }) {
14
18
  const { t } = useLocaleContext();
15
19
  const { control, setValue, formState } = useFormContext();
16
20
  const cancelAt = useWatch({ control, name: 'cancel.at' });
17
21
  const cancelTime = useWatch({ control, name: 'cancel.time' });
18
22
  const refundType = useWatch({ control, name: 'cancel.refund' });
23
+ const stakingType = useWatch({ control, name: 'cancel.staking' });
19
24
  const {
20
25
  loading,
21
26
  data: refund,
22
27
  refresh,
23
28
  } = useRequest(() => fetchData(data.id, cancelAt === 'custom' ? cancelTime : ''));
24
29
 
30
+ const { data: staking } = useRequest(() => fetchStakingData(data.id, cancelAt === 'custom' ? cancelTime : ''));
25
31
  useEffect(() => {
26
32
  if (data) {
27
33
  refresh();
@@ -35,9 +41,9 @@ export default function SubscriptionCancelForm({ data }: { data: TSubscriptionEx
35
41
  const { decimal, symbol } = data.paymentCurrency;
36
42
 
37
43
  return (
38
- <Box sx={{ width: 400 }}>
44
+ <Root sx={{ width: 400 }}>
39
45
  <Stack direction="row" spacing={3} alignItems="flex-start">
40
- <Typography>{t('admin.subscription.cancel.at.title')}</Typography>
46
+ <Typography className="form-title">{t('admin.subscription.cancel.at.title')}</Typography>
41
47
  <Stack>
42
48
  <RadioGroup>
43
49
  <FormControlLabel
@@ -84,7 +90,7 @@ export default function SubscriptionCancelForm({ data }: { data: TSubscriptionEx
84
90
  </Stack>
85
91
  <Divider sx={{ my: 1 }} />
86
92
  <Stack direction="row" spacing={3} alignItems="flex-start">
87
- <Typography>{t('admin.subscription.cancel.refund.title')}</Typography>
93
+ <Typography className="form-title">{t('admin.subscription.cancel.refund.title')}</Typography>
88
94
  <Stack>
89
95
  <RadioGroup>
90
96
  <FormControlLabel
@@ -118,6 +124,39 @@ export default function SubscriptionCancelForm({ data }: { data: TSubscriptionEx
118
124
  </RadioGroup>
119
125
  </Stack>
120
126
  </Stack>
121
- </Box>
127
+ {data.paymentMethod.type === 'arcblock' && (
128
+ <>
129
+ <Divider sx={{ my: 1 }} />
130
+ <Stack direction="row" spacing={3} alignItems="flex-start">
131
+ <Typography className="form-title">{t('admin.subscription.cancel.staking.title')}</Typography>
132
+ <RadioGroup>
133
+ <FormControlLabel
134
+ value="none"
135
+ disabled={loading || !staking}
136
+ onClick={() => setValue('cancel.staking', 'none')}
137
+ control={<Radio checked={stakingType === 'none'} />}
138
+ label={t('admin.subscription.cancel.staking.none')}
139
+ />
140
+ <FormControlLabel
141
+ value="proration"
142
+ disabled={loading || !staking}
143
+ onClick={() => setValue('cancel.staking', 'proration')}
144
+ control={<Radio checked={stakingType === 'proration'} />}
145
+ label={t('admin.subscription.cancel.staking.proration', {
146
+ unused: formatAmount(staking?.return_amount || '0', decimal),
147
+ symbol,
148
+ })}
149
+ />
150
+ </RadioGroup>
151
+ </Stack>
152
+ </>
153
+ )}
154
+ </Root>
122
155
  );
123
156
  }
157
+
158
+ const Root = styled(Box)`
159
+ .form-title {
160
+ width: 60px;
161
+ }
162
+ `;
@@ -150,6 +150,7 @@ export default function SubscriptionActions(props: Props) {
150
150
  at: 'now',
151
151
  time: '',
152
152
  refund: 'none',
153
+ staking: 'none',
153
154
  },
154
155
  pause: {
155
156
  type: 'never',
package/src/libs/util.ts CHANGED
@@ -14,6 +14,7 @@ import type {
14
14
  } from '@blocklet/payment-types';
15
15
  import { Hasher } from '@ocap/mcrypto';
16
16
  import { hexToNumber } from '@ocap/util';
17
+ import { isObject } from 'lodash';
17
18
  import cloneDeep from 'lodash/cloneDeep';
18
19
  import isEqual from 'lodash/isEqual';
19
20
  import { joinURL } from 'ufo';
@@ -224,3 +225,11 @@ export function goBackOrFallback(fallback: string) {
224
225
  }
225
226
  }, 500);
226
227
  }
228
+
229
+ export function isObjectContent(value: string) {
230
+ try {
231
+ return isObject(JSON.parse(value));
232
+ } catch (err) {
233
+ return false;
234
+ }
235
+ }
@@ -409,6 +409,11 @@ export default flat({
409
409
  last: 'Last payment {total}{symbol}',
410
410
  proration: 'Proration amount {unused}/{total}{symbol}',
411
411
  },
412
+ staking: {
413
+ title: 'Stake',
414
+ none: 'No return',
415
+ proration: 'Return Remaining Stake {unused}{symbol}',
416
+ },
412
417
  },
413
418
  pause: {
414
419
  title: 'Pause payment collection',
@@ -400,6 +400,11 @@ export default flat({
400
400
  last: '退款最近付款的全部 {total}{symbol}',
401
401
  proration: '退款最近付款的未使用部分 {unused}/{total}{symbol}',
402
402
  },
403
+ staking: {
404
+ title: '质押',
405
+ none: '不退还质押',
406
+ proration: '退还剩余部分 {unused}{symbol}',
407
+ },
403
408
  },
404
409
  pause: {
405
410
  title: '暂停付款',
@@ -135,6 +135,7 @@ export default function RefundDetail(props: { id: string }) {
135
135
  </Stack>
136
136
  }
137
137
  />
138
+ <InfoRow label={t('common.type')} value={t(`refund.type.${data.type}`)} />
138
139
  <InfoRow label={t('common.description')} value={data.description} />
139
140
  <InfoRow label={t('common.createdAt')} value={formatTime(data.created_at)} />
140
141
  <InfoRow label={t('common.updatedAt')} value={formatTime(data.updated_at)} />