payment-kit 1.18.48 → 1.18.50

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.
@@ -1119,3 +1119,234 @@ export async function retryUncollectibleInvoices(options: {
1119
1119
  logger.info('Released retry uncollectible lock', { lockKey });
1120
1120
  }
1121
1121
  }
1122
+
1123
+ /**
1124
+ * Migrate billing when a subscription's payment method changes.
1125
+ * Steps:
1126
+ * 1. Check if all subscription items are prepaid (licensed recurring).
1127
+ * 2. If so, check if there is an unpaid invoice for the current period with the old payment method.
1128
+ * 3. If such an invoice exists, void the old invoice and cancel its payment intent if present.
1129
+ * 4. Create a new invoice for the current period with the new payment method.
1130
+ * 5. If any step fails, throw an error and log details.
1131
+ *
1132
+ * @param subscription Subscription instance
1133
+ * @param oldCurrencyId Old payment currency ID
1134
+ * @param newCurrencyId New payment currency ID
1135
+ * @returns Migration result: { migrated, oldInvoice, newInvoice }
1136
+ */
1137
+ export const migrateSubscriptionPaymentMethodInvoice = async (
1138
+ subscription: Subscription,
1139
+ oldCurrencyId: string,
1140
+ newCurrencyId: string
1141
+ ) => {
1142
+ // 1. Check if all subscription items are prepaid (licensed recurring)
1143
+ const subscriptionItems = await SubscriptionItem.findAll({
1144
+ where: { subscription_id: subscription.id },
1145
+ include: [{ model: Price, as: 'price' }],
1146
+ });
1147
+
1148
+ const subscriptionItemsExpanded = await Price.expand(subscriptionItems.map((x) => x.toJSON()));
1149
+ const isPrepaid = subscriptionItemsExpanded.every((item: TLineItemExpanded) => {
1150
+ const price = getSubscriptionItemPrice(item);
1151
+ return price.type === 'recurring' && price.recurring?.usage_type === 'licensed';
1152
+ });
1153
+
1154
+ if (!isPrepaid) {
1155
+ logger.info('Skip billing migration for non-prepaid items', {
1156
+ subscriptionId: subscription.id,
1157
+ });
1158
+ return { migrated: false, reason: 'has_non_prepaid_items' };
1159
+ }
1160
+
1161
+ // 2. Find unpaid invoice for the current period with the old payment method
1162
+ const currentPeriodInvoice = await Invoice.findOne({
1163
+ where: {
1164
+ subscription_id: subscription.id,
1165
+ billing_reason: 'subscription_cycle',
1166
+ status: ['open', 'uncollectible'],
1167
+ currency_id: oldCurrencyId,
1168
+ period_start: {
1169
+ [Op.gte]: subscription.current_period_start,
1170
+ [Op.lt]: subscription.current_period_end,
1171
+ },
1172
+ },
1173
+ order: [['created_at', 'DESC']],
1174
+ });
1175
+
1176
+ let voidedInvoice: Invoice | null = null;
1177
+
1178
+ if (!currentPeriodInvoice) {
1179
+ voidedInvoice = await Invoice.findOne({
1180
+ where: {
1181
+ subscription_id: subscription.id,
1182
+ billing_reason: 'subscription_cycle',
1183
+ status: 'void',
1184
+ currency_id: oldCurrencyId,
1185
+ period_start: {
1186
+ [Op.gte]: subscription.current_period_start,
1187
+ [Op.lt]: subscription.current_period_end,
1188
+ },
1189
+ },
1190
+ order: [['created_at', 'DESC']],
1191
+ });
1192
+ if (!voidedInvoice) {
1193
+ logger.info('Skip billing migration for no unpaid invoice', {
1194
+ subscriptionId: subscription.id,
1195
+ periodStart: subscription.current_period_start,
1196
+ periodEnd: subscription.current_period_end,
1197
+ oldCurrencyId,
1198
+ });
1199
+ return { migrated: false, reason: 'no_unpaid_invoice' };
1200
+ }
1201
+ }
1202
+
1203
+ // 3. Get old and new payment method/currency
1204
+ const oldPaymentCurrency = await PaymentCurrency.findByPk(oldCurrencyId);
1205
+ if (!oldPaymentCurrency) {
1206
+ throw new Error(`Payment currency ${oldCurrencyId} not found`);
1207
+ }
1208
+ const oldPaymentMethod = await PaymentMethod.findByPk(oldPaymentCurrency.payment_method_id);
1209
+ if (!oldPaymentMethod) {
1210
+ throw new Error(`Payment method for currency ${oldCurrencyId} not found`);
1211
+ }
1212
+
1213
+ const newPaymentCurrency = await PaymentCurrency.findByPk(newCurrencyId);
1214
+ if (!newPaymentCurrency) {
1215
+ throw new Error(`Payment currency ${newCurrencyId} not found`);
1216
+ }
1217
+ const newPaymentMethod = await PaymentMethod.findByPk(newPaymentCurrency.payment_method_id);
1218
+ if (!newPaymentMethod) {
1219
+ throw new Error(`Payment method for currency ${newCurrencyId} not found`);
1220
+ }
1221
+
1222
+ // 4. Stripe payment method is not supported for migration
1223
+ if (newPaymentMethod.type === 'stripe') {
1224
+ logger.info('Skip billing migration for stripe payment method', {
1225
+ subscriptionId: subscription.id,
1226
+ });
1227
+ return { migrated: false, reason: 'stripe_payment_method' };
1228
+ }
1229
+
1230
+ try {
1231
+ const customer = await Customer.findByPk(subscription.customer_id);
1232
+ if (!customer) {
1233
+ throw new Error(`Customer ${subscription.customer_id} not found`);
1234
+ }
1235
+
1236
+ const cancelOldInvoice = async (invoice: Invoice) => {
1237
+ try {
1238
+ if (invoice.payment_intent_id) {
1239
+ const paymentIntent = await PaymentIntent.findByPk(invoice.payment_intent_id);
1240
+ if (paymentIntent && paymentIntent.status !== 'canceled') {
1241
+ await paymentIntent.update({
1242
+ status: 'canceled',
1243
+ canceled_at: dayjs().unix(),
1244
+ cancellation_reason: 'void_invoice',
1245
+ });
1246
+ }
1247
+ }
1248
+
1249
+ if (oldPaymentMethod.type === 'stripe' && invoice.metadata?.stripe_id) {
1250
+ const client = oldPaymentMethod.getStripeClient();
1251
+ await client.invoices.voidInvoice(invoice.metadata.stripe_id);
1252
+ }
1253
+
1254
+ await invoice.update({
1255
+ status: 'void',
1256
+ status_transitions: {
1257
+ ...(invoice.status_transitions || {}),
1258
+ voided_at: dayjs().unix(),
1259
+ },
1260
+ });
1261
+
1262
+ logger.info('Successfully voided old invoice for payment method change', {
1263
+ subscriptionId: subscription.id,
1264
+ oldInvoice: invoice.id,
1265
+ oldCurrency: oldCurrencyId,
1266
+ newCurrency: newCurrencyId,
1267
+ });
1268
+ } catch (error) {
1269
+ logger.error('Failed to void old invoice', {
1270
+ subscription: subscription.id,
1271
+ invoiceId: invoice.id,
1272
+ error: error.message,
1273
+ });
1274
+ throw error;
1275
+ }
1276
+ };
1277
+
1278
+ const createNewInvoice = async () => {
1279
+ const preInvoice = currentPeriodInvoice || voidedInvoice;
1280
+ if (!preInvoice) {
1281
+ throw new Error('No unpaid invoice found');
1282
+ }
1283
+ const metadata: Record<string, any> = {
1284
+ prev_invoice_id: preInvoice.id,
1285
+ };
1286
+ const amount = getSubscriptionCycleAmount(subscriptionItemsExpanded, newCurrencyId);
1287
+
1288
+ const { invoice } = await ensureInvoiceAndItems({
1289
+ customer,
1290
+ currency: newPaymentCurrency,
1291
+ subscription,
1292
+ trialing: subscription.status === 'trialing',
1293
+ metered: false,
1294
+ lineItems: subscriptionItemsExpanded,
1295
+ applyCredit: false,
1296
+ props: {
1297
+ status: 'open',
1298
+ total: amount.total,
1299
+ livemode: subscription.livemode,
1300
+ description: 'Subscription cycle',
1301
+ statement_descriptor: preInvoice.statement_descriptor,
1302
+ period_start: preInvoice.period_start,
1303
+ period_end: preInvoice.period_end,
1304
+ auto_advance: true,
1305
+ billing_reason: 'subscription_cycle',
1306
+ currency_id: newCurrencyId,
1307
+ default_payment_method_id: newPaymentMethod.id,
1308
+ custom_fields: preInvoice.custom_fields || [],
1309
+ footer: preInvoice.footer || '',
1310
+ payment_settings: subscription.payment_settings,
1311
+ metadata,
1312
+ } as Invoice,
1313
+ });
1314
+ return invoice;
1315
+ };
1316
+
1317
+ // 5. Cancel old invoice, then create new invoice
1318
+ if (currentPeriodInvoice) {
1319
+ await cancelOldInvoice(currentPeriodInvoice);
1320
+ }
1321
+
1322
+ const invoice = await createNewInvoice();
1323
+ if (invoice) {
1324
+ await emitAsync('invoice.queued', invoice.id, { invoiceId: invoice.id, retryOnError: true }, { sync: false });
1325
+ logger.info('Successfully queued new invoice for payment method change', {
1326
+ subscriptionId: subscription.id,
1327
+ invoiceId: invoice.id,
1328
+ });
1329
+ }
1330
+
1331
+ logger.info('Successfully migrated invoice for payment method change', {
1332
+ subscriptionId: subscription.id,
1333
+ oldInvoiceId: currentPeriodInvoice?.id || voidedInvoice?.id,
1334
+ oldCurrency: oldCurrencyId,
1335
+ newCurrency: newCurrencyId,
1336
+ newInvoiceId: invoice.id,
1337
+ });
1338
+ return {
1339
+ migrated: true,
1340
+ oldInvoice: currentPeriodInvoice,
1341
+ newInvoice: invoice,
1342
+ };
1343
+ } catch (error) {
1344
+ logger.error('Failed to migrate invoice for payment method change', {
1345
+ subscriptionId: subscription.id,
1346
+ oldCurrencyId,
1347
+ newCurrencyId,
1348
+ error: error.message,
1349
+ });
1350
+ throw error;
1351
+ }
1352
+ };
@@ -77,9 +77,10 @@ export function authenticate<T extends Model>({ component, roles, record, mine,
77
77
  }
78
78
 
79
79
  if (req.headers['x-user-did']) {
80
+ const role = (<string>req.headers['x-user-role'] || '').replace('blocklet-', '');
80
81
  req.user = {
81
82
  did: <string>req.headers['x-user-did'],
82
- role: <string>req.headers['x-user-role'],
83
+ role,
83
84
  provider: <string>req.headers['x-user-provider'],
84
85
  fullName: decodeURIComponent(<string>req.headers['x-user-fullname']),
85
86
  walletOS: <string>req.headers['x-user-wallet-os'],
@@ -846,10 +846,8 @@ export const handlePayment = async (job: PaymentJob) => {
846
846
  wallet,
847
847
  delegator: result.delegator,
848
848
  });
849
- logger.info('PaymentIntent signed', { signed });
850
849
  // @ts-ignore
851
850
  const { buffer } = await client.encodeTransferV2Tx({ tx: signed });
852
- logger.info('PaymentIntent buffer', { buffer, gas: getGasPayerExtra(buffer) });
853
851
  const txHash = await client.sendTransferV2Tx(
854
852
  // @ts-ignore
855
853
  { tx: signed, wallet, delegator: result.delegator },
@@ -26,7 +26,7 @@ import {
26
26
  shouldCancelSubscription,
27
27
  slashOverdraftProtectionStake,
28
28
  } from '../libs/subscription';
29
- import { ensureInvoiceAndItems } from '../libs/invoice';
29
+ import { ensureInvoiceAndItems, migrateSubscriptionPaymentMethodInvoice } from '../libs/invoice';
30
30
  import { PaymentCurrency, PaymentIntent, PaymentMethod, Refund, SetupIntent, UsageRecord } from '../store/models';
31
31
  import { Customer } from '../store/models/customer';
32
32
  import { Invoice } from '../store/models/invoice';
@@ -1280,5 +1280,28 @@ events.on('setup_intent.succeeded', async (setupIntent: SetupIntent) => {
1280
1280
  logger.error('create return overdraft protection stake job failed', { error, subscription: subscription.id });
1281
1281
  }
1282
1282
  }
1283
+
1284
+ try {
1285
+ const migrationResult = await migrateSubscriptionPaymentMethodInvoice(
1286
+ subscription,
1287
+ setupIntent.metadata?.from_currency,
1288
+ setupIntent.metadata?.to_currency
1289
+ );
1290
+ if (migrationResult.migrated) {
1291
+ logger.info('Subscription payment method billing migration completed', {
1292
+ subscription: subscription.id,
1293
+ migrated: migrationResult.migrated,
1294
+ oldInvoice: migrationResult.oldInvoice?.id,
1295
+ newInvoice: migrationResult.newInvoice?.id,
1296
+ });
1297
+ }
1298
+ } catch (error) {
1299
+ logger.error('Failed to migrate billing for payment method change', {
1300
+ subscription: subscription.id,
1301
+ fromCurrency: setupIntent.metadata?.from_currency,
1302
+ toCurrency: setupIntent.metadata?.to_currency,
1303
+ error,
1304
+ });
1305
+ }
1283
1306
  }
1284
1307
  });
@@ -89,7 +89,7 @@ import { addSubscriptionJob } from '../queues/subscription';
89
89
 
90
90
  const router = Router();
91
91
 
92
- const user = sessionMiddleware();
92
+ const user = sessionMiddleware({ accessKey: true });
93
93
  const auth = authenticate<CheckoutSession>({ component: true, roles: ['owner', 'admin'] });
94
94
 
95
95
  const getPaymentMethods = async (doc: CheckoutSession) => {
@@ -13,6 +13,8 @@ import {
13
13
  import { ensureStakeInvoice } from '../../libs/invoice';
14
14
  import { EVM_CHAIN_TYPES } from '../../libs/constants';
15
15
  import logger from '../../libs/logger';
16
+ import { getFastCheckoutAmount } from '../../libs/session';
17
+ import { isDelegationSufficientForPayment } from '../../libs/payment';
16
18
 
17
19
  export default {
18
20
  action: 'change-payment',
@@ -26,41 +28,53 @@ export default {
26
28
  },
27
29
  onConnect: async ({ userDid, userPk, extraParams }: CallbackArgs) => {
28
30
  const { subscriptionId } = extraParams;
29
- const { subscription, paymentMethod, paymentCurrency } = await ensureChangePaymentContext(subscriptionId);
31
+ const { subscription, paymentMethod, paymentCurrency, customer } = await ensureChangePaymentContext(subscriptionId);
30
32
 
31
33
  const claimsList: any[] = [];
32
34
  // @ts-ignore
33
35
  const items = subscription!.items as TLineItemExpanded[];
34
36
  const trialing = true;
35
37
  const billingThreshold = Number(subscription.billing_thresholds?.amount_gte || 0);
38
+ const fastCheckoutAmount = getFastCheckoutAmount(items, 'subscription', paymentCurrency.id, false);
36
39
 
37
40
  if (paymentMethod.type === 'arcblock') {
38
- claimsList.push({
39
- signature: await getDelegationTxClaim({
40
- mode: 'setup',
41
- userDid,
42
- userPk,
43
- nonce: `change-method-${subscription.id}`,
44
- data: getTxMetadata({ subscriptionId: subscription.id }),
45
- paymentCurrency,
46
- paymentMethod,
47
- trialing,
48
- billingThreshold,
49
- items,
50
- }),
51
- });
52
-
53
- claimsList.push({
54
- prepareTx: await getStakeTxClaim({
55
- userDid,
56
- userPk,
57
- paymentCurrency,
58
- paymentMethod,
59
- items,
60
- subscription,
61
- }),
41
+ const delegation = await isDelegationSufficientForPayment({
42
+ paymentMethod,
43
+ paymentCurrency,
44
+ userDid: customer!.did,
45
+ amount: fastCheckoutAmount,
62
46
  });
47
+ const needDelegation = delegation.sufficient === false;
48
+ const noStake = subscription.billing_thresholds?.no_stake;
49
+ if (needDelegation || noStake) {
50
+ claimsList.push({
51
+ signature: await getDelegationTxClaim({
52
+ mode: 'setup',
53
+ userDid,
54
+ userPk,
55
+ nonce: `change-method-${subscription.id}`,
56
+ data: getTxMetadata({ subscriptionId: subscription.id }),
57
+ paymentCurrency,
58
+ paymentMethod,
59
+ trialing,
60
+ billingThreshold,
61
+ items,
62
+ }),
63
+ });
64
+ }
63
65
 
66
+ if (!noStake) {
67
+ claimsList.push({
68
+ prepareTx: await getStakeTxClaim({
69
+ userDid,
70
+ userPk,
71
+ paymentCurrency,
72
+ paymentMethod,
73
+ items,
74
+ subscription,
75
+ }),
76
+ });
77
+ }
64
78
  return claimsList;
65
79
  }
66
80
 
@@ -95,6 +109,8 @@ export default {
95
109
  const { setupIntent, subscription, paymentMethod, paymentCurrency, customer } =
96
110
  await ensureChangePaymentContext(subscriptionId);
97
111
 
112
+ const noStake = subscription.billing_thresholds?.no_stake;
113
+
98
114
  const result = request?.context?.store?.result || [];
99
115
  result.push({
100
116
  step,
@@ -106,7 +122,8 @@ export default {
106
122
 
107
123
  // 判断是否为最后一步
108
124
  const staking = result.find((x: any) => x.claim?.type === 'prepareTx' && x.claim?.meta?.purpose === 'staking');
109
- const isFinalStep = (paymentMethod.type === 'arcblock' && staking) || paymentMethod.type !== 'arcblock';
125
+ const isFinalStep =
126
+ (paymentMethod.type === 'arcblock' && (staking || noStake)) || paymentMethod.type !== 'arcblock';
110
127
 
111
128
  if (!isFinalStep) {
112
129
  await updateSession({
@@ -181,25 +198,28 @@ export default {
181
198
  subscription?.id,
182
199
  paymentCurrency.contract
183
200
  );
184
- await ensureStakeInvoice(
185
- {
186
- total: stakingAmount,
187
- description: 'Stake for subscription payment change',
188
- currency_id: paymentCurrency.id,
189
- metadata: {
190
- payment_details: {
191
- arcblock: {
192
- tx_hash: paymentDetails?.staking?.tx_hash,
193
- payer: paymentDetails?.payer,
194
- address: paymentDetails?.staking?.address,
201
+ if (stakingAmount && stakingAmount !== '0') {
202
+ await ensureStakeInvoice(
203
+ {
204
+ total: stakingAmount,
205
+ description: 'Stake for subscription payment change',
206
+ currency_id: paymentCurrency.id,
207
+ metadata: {
208
+ payment_details: {
209
+ arcblock: {
210
+ tx_hash: paymentDetails?.staking?.tx_hash,
211
+ payer: paymentDetails?.payer,
212
+ address: paymentDetails?.staking?.address,
213
+ },
195
214
  },
196
215
  },
197
216
  },
198
- },
199
- subscription!,
200
- paymentMethod,
201
- customer!
202
- );
217
+ subscription!,
218
+ paymentMethod,
219
+ customer!
220
+ );
221
+ }
222
+
203
223
  await afterTxExecution(paymentDetails);
204
224
  return { hash: paymentDetails.tx_hash };
205
225
  }
@@ -96,7 +96,7 @@ router.get('/search', auth, async (req, res) => {
96
96
  });
97
97
 
98
98
  // eslint-disable-next-line consistent-return
99
- router.get('/me', sessionMiddleware(), async (req, res) => {
99
+ router.get('/me', sessionMiddleware({ accessKey: true }), async (req, res) => {
100
100
  if (!req.user) {
101
101
  return res.status(403).json({ error: 'Unauthorized' });
102
102
  }
@@ -282,7 +282,7 @@ router.get('/:id/overdue/invoices', sessionMiddleware(), async (req, res) => {
282
282
  }
283
283
  });
284
284
 
285
- router.get('/recharge', sessionMiddleware(), async (req, res) => {
285
+ router.get('/recharge', sessionMiddleware({ accessKey: true }), async (req, res) => {
286
286
  if (!req.user) {
287
287
  return res.status(403).json({ error: 'Unauthorized' });
288
288
  }
@@ -345,7 +345,7 @@ router.get('/recharge', sessionMiddleware(), async (req, res) => {
345
345
  });
346
346
 
347
347
  // get address token
348
- router.get('/payer-token', sessionMiddleware(), async (req, res) => {
348
+ router.get('/payer-token', sessionMiddleware({ accessKey: true }), async (req, res) => {
349
349
  if (!req.user) {
350
350
  return res.status(403).json({ error: 'Unauthorized' });
351
351
  }
@@ -439,7 +439,7 @@ const updatePreferenceSchema = Joi.object({
439
439
  }).optional(),
440
440
  }).unknown(false);
441
441
 
442
- router.put('/preference', sessionMiddleware(), async (req, res) => {
442
+ router.put('/preference', sessionMiddleware({ accessKey: true }), async (req, res) => {
443
443
  try {
444
444
  if (!req.user) {
445
445
  return res.status(403).json({ error: 'Unauthorized' });
@@ -25,6 +25,8 @@ import { getReturnStakeInvoices, getStakingInvoices, retryUncollectibleInvoices
25
25
  import { CheckoutSession, PaymentLink, TInvoiceExpanded } from '../store/models';
26
26
  import { mergePaginate, defaultTimeOrderBy, getCachedOrFetch, DataSource } from '../libs/pagination';
27
27
  import logger from '../libs/logger';
28
+ import { returnOverdraftProtectionQueue, returnStakeQueue } from '../queues/subscription';
29
+ import { checkRemainingStake } from '../libs/subscription';
28
30
 
29
31
  const router = Router();
30
32
  const authAdmin = authenticate<Subscription>({ component: true, roles: ['owner', 'admin'] });
@@ -495,6 +497,77 @@ router.get('/retry-uncollectible', authAdmin, async (req, res) => {
495
497
  }
496
498
  });
497
499
 
500
+ router.get('/:id/return-stake', authAdmin, async (req, res) => {
501
+ const doc = await Invoice.findByPk(req.params.id as string);
502
+ if (!doc) {
503
+ return res.status(404).json({ error: 'Invoice not found' });
504
+ }
505
+ if (!['stake', 'stake_overdraft_protection'].includes(doc.billing_reason)) {
506
+ return res.status(400).json({ error: 'Invoice is not a stake invoice' });
507
+ }
508
+ const paymentCurrency = await PaymentCurrency.findByPk(doc.currency_id);
509
+ if (!paymentCurrency) {
510
+ return res.status(400).json({ error: 'Payment currency not found' });
511
+ }
512
+ const paymentMethod = await PaymentMethod.findByPk(doc.default_payment_method_id);
513
+ if (!paymentMethod) {
514
+ return res.status(400).json({ error: 'Payment method not found' });
515
+ }
516
+ const stakingAddress = doc.metadata?.payment_details?.arcblock?.address;
517
+ if (!stakingAddress) {
518
+ return res.status(400).json({ error: 'Staking address not found' });
519
+ }
520
+ const { staked } = await checkRemainingStake(paymentMethod, paymentCurrency, stakingAddress, '0');
521
+ return res.json(staked);
522
+ });
523
+ router.post('/:id/return-stake', authAdmin, async (req, res) => {
524
+ const doc = await Invoice.findByPk(req.params.id as string);
525
+ if (!doc) {
526
+ return res.status(404).json({ error: 'Invoice not found' });
527
+ }
528
+ if (!['stake', 'stake_overdraft_protection'].includes(doc.billing_reason)) {
529
+ return res.status(400).json({ error: 'Invoice is not a stake invoice' });
530
+ }
531
+ if (doc.status !== 'paid') {
532
+ return res.status(400).json({ error: 'Invoice is not paid' });
533
+ }
534
+ const paymentMethod = await PaymentMethod.findByPk(doc.default_payment_method_id);
535
+ if (!paymentMethod) {
536
+ return res.status(400).json({ error: 'Payment method not found' });
537
+ }
538
+ if (paymentMethod.type !== 'arcblock') {
539
+ return res.status(400).json({ error: 'Can only return stake for arcblock payment method' });
540
+ }
541
+ const subscription = await Subscription.findByPk(doc.subscription_id);
542
+ if (!subscription) {
543
+ return res.status(400).json({ error: 'Subscription not found' });
544
+ }
545
+ try {
546
+ if (doc.billing_reason === 'stake') {
547
+ await returnStakeQueue.pushAndWait({
548
+ id: `return-stake-${subscription.id}-${doc.id}`,
549
+ job: {
550
+ subscriptionId: subscription.id,
551
+ stakingAddress: doc.metadata?.payment_details?.arcblock?.address,
552
+ paymentCurrencyId: doc.currency_id,
553
+ },
554
+ });
555
+ return res.json({ success: true, subscriptionId: subscription.id });
556
+ }
557
+ if (doc.billing_reason === 'stake_overdraft_protection' && subscription.status === 'canceled') {
558
+ await returnOverdraftProtectionQueue.pushAndWait({
559
+ id: `return-overdraft-protection-${subscription.id}`,
560
+ job: { subscriptionId: subscription.id },
561
+ });
562
+ return res.json({ success: true, subscriptionId: subscription.id });
563
+ }
564
+ return res.json({ success: false, error: 'Subscription is not canceled' });
565
+ } catch (error) {
566
+ logger.error('Failed to return stake', { error, subscriptionId: subscription.id, invoiceId: doc.id });
567
+ return res.status(400).json({ error: 'Failed to return stake' });
568
+ }
569
+ });
570
+
498
571
  router.get('/:id', authPortal, async (req, res) => {
499
572
  try {
500
573
  const doc = (await Invoice.findOne({
@@ -554,8 +627,8 @@ router.get('/:id', authPortal, async (req, res) => {
554
627
  const prices = (await Price.findAll()).map((x) => x.toJSON());
555
628
  // @ts-ignore
556
629
  expandLineItems(json.lines, products, prices);
557
- if (doc.metadata?.invoice_id) {
558
- const relatedInvoice = await Invoice.findByPk(doc.metadata.invoice_id, {
630
+ if (doc.metadata?.invoice_id || doc.metadata?.prev_invoice_id) {
631
+ const relatedInvoice = await Invoice.findByPk(doc.metadata.invoice_id || doc.metadata.prev_invoice_id, {
559
632
  attributes: ['id', 'number', 'status', 'billing_reason'],
560
633
  });
561
634
  return res.json({ ...json, relatedInvoice, paymentLink, checkoutSession });
@@ -117,7 +117,7 @@ const mineRecordPaginationSchema = createListParamSchema<{
117
117
  currency_id: Joi.string().empty(''),
118
118
  status: Joi.string().empty(''),
119
119
  });
120
- router.get('/mine', sessionMiddleware(), async (req, res) => {
120
+ router.get('/mine', sessionMiddleware({ accessKey: true }), async (req, res) => {
121
121
  try {
122
122
  const {
123
123
  page,
@@ -55,7 +55,11 @@ import { Subscription, TSubscription } from '../store/models/subscription';
55
55
  import { SubscriptionItem } from '../store/models/subscription-item';
56
56
  import type { LineItem, ServiceAction, SubscriptionUpdateItem } from '../store/models/types';
57
57
  import { UsageRecord } from '../store/models/usage-record';
58
- import { cleanupInvoiceAndItems, ensureInvoiceAndItems } from '../libs/invoice';
58
+ import {
59
+ cleanupInvoiceAndItems,
60
+ ensureInvoiceAndItems,
61
+ migrateSubscriptionPaymentMethodInvoice,
62
+ } from '../libs/invoice';
59
63
  import { createUsageRecordQueryFn } from './usage-records';
60
64
  import { SubscriptionWillCanceledSchedule } from '../crons/subscription-will-canceled';
61
65
  import { getTokenByAddress } from '../integrations/arcblock/stake';
@@ -447,7 +451,7 @@ router.get('/:id/recover-info', authPortal, async (req, res) => {
447
451
  try {
448
452
  const { revoked } = await checkRemainingStake(paymentMethod, paymentCurrency, address, '0');
449
453
  const cancelReason = doc.cancelation_details?.reason;
450
- if (revoked && revoked !== '0' && cancelReason === 'stake_revoked') {
454
+ if (revoked && revoked.value !== '0' && cancelReason === 'stake_revoked') {
451
455
  needStake = true;
452
456
  revokedStake = revoked;
453
457
  }
@@ -496,7 +500,7 @@ router.put('/:id/recover', authPortal, async (req, res) => {
496
500
  try {
497
501
  const { revoked } = await checkRemainingStake(paymentMethod, paymentCurrency, address, '0');
498
502
  const cancelReason = doc.cancelation_details?.reason;
499
- if (revoked && revoked !== '0' && cancelReason === 'stake_revoked') {
503
+ if (revoked && revoked.value !== '0' && cancelReason === 'stake_revoked') {
500
504
  return res.json({
501
505
  needStake: true,
502
506
  subscription: doc,
@@ -1449,7 +1453,7 @@ router.post('/:id/change-payment', authPortal, async (req, res) => {
1449
1453
  if (!subscription) {
1450
1454
  return res.status(404).json({ error: `Subscription ${req.params.id} not found when change payment` });
1451
1455
  }
1452
- if (subscription.isActive() === false) {
1456
+ if (['active', 'trialing', 'past_due'].includes(subscription.status) === false) {
1453
1457
  return res.status(400).json({ error: `Subscription ${req.params.id} not active when change payment` });
1454
1458
  }
1455
1459
  const paymentCurrency = await PaymentCurrency.findByPk(req.body.payment_currency);
@@ -1655,9 +1659,10 @@ router.post('/:id/change-payment', authPortal, async (req, res) => {
1655
1659
  userDid: customer!.did,
1656
1660
  amount: getFastCheckoutAmount(lineItems, 'subscription', paymentCurrency.id, false),
1657
1661
  });
1658
- if (paymentMethod.type === 'arcblock' && delegation.sufficient) {
1662
+ const noStake = subscription.billing_thresholds?.no_stake;
1663
+ if (paymentMethod.type === 'arcblock' && delegation.sufficient && !noStake) {
1659
1664
  delegation.sufficient = false;
1660
- delegation.reason = 'NO_ENOUGH_TOKEN';
1665
+ delegation.reason = 'WAIT_STAKE';
1661
1666
  }
1662
1667
  if (delegation.sufficient) {
1663
1668
  await setupIntent.update({
@@ -2216,4 +2221,30 @@ router.get('/:id/unpaid-invoices', authPortal, async (req, res) => {
2216
2221
  const count = await getSubscriptionUnpaidInvoicesCount(subscription);
2217
2222
  return res.json({ count });
2218
2223
  });
2224
+
2225
+ router.get('/:id/change-payment/migrate-invoice', auth, async (req, res) => {
2226
+ const subscription = await Subscription.findByPk(req.params.id);
2227
+ if (!subscription) {
2228
+ return res.status(404).json({ error: 'Subscription not found' });
2229
+ }
2230
+ const context = subscription.metadata.changePayment || {};
2231
+ if (!context.setup_intent_id) {
2232
+ return res.status(404).json({ error: 'Subscription change payment context not found' });
2233
+ }
2234
+ const setupIntent = await SetupIntent.findByPk(context.setup_intent_id);
2235
+ if (!setupIntent) {
2236
+ return res.status(404).json({ error: 'Setup intent not found' });
2237
+ }
2238
+ try {
2239
+ const migrationResult = await migrateSubscriptionPaymentMethodInvoice(
2240
+ subscription,
2241
+ setupIntent.metadata?.from_currency,
2242
+ setupIntent.metadata?.to_currency
2243
+ );
2244
+ return res.json(migrationResult);
2245
+ } catch (error) {
2246
+ logger.error(error);
2247
+ return res.status(400).json({ error: error.message });
2248
+ }
2249
+ });
2219
2250
  export default router;
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.18.48
17
+ version: 1.18.50
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.18.48",
3
+ "version": "1.18.50",
4
4
  "scripts": {
5
5
  "dev": "blocklet dev --open",
6
6
  "eject": "vite eject",
@@ -55,7 +55,7 @@
55
55
  "@blocklet/did-space-js": "^1.0.56",
56
56
  "@blocklet/js-sdk": "^1.16.43",
57
57
  "@blocklet/logger": "^1.16.43",
58
- "@blocklet/payment-react": "1.18.48",
58
+ "@blocklet/payment-react": "1.18.50",
59
59
  "@blocklet/sdk": "^1.16.43",
60
60
  "@blocklet/ui-react": "^2.13.54",
61
61
  "@blocklet/uploader": "^0.1.93",
@@ -123,7 +123,7 @@
123
123
  "devDependencies": {
124
124
  "@abtnode/types": "^1.16.43",
125
125
  "@arcblock/eslint-config-ts": "^0.3.3",
126
- "@blocklet/payment-types": "1.18.48",
126
+ "@blocklet/payment-types": "1.18.50",
127
127
  "@types/cookie-parser": "^1.4.7",
128
128
  "@types/cors": "^2.8.17",
129
129
  "@types/debug": "^4.1.12",
@@ -169,5 +169,5 @@
169
169
  "parser": "typescript"
170
170
  }
171
171
  },
172
- "gitHead": "4042dc2996b11ab6ccf6a973af53042cff913474"
172
+ "gitHead": "1b7529c51c5ef7d65a288e852e971fbfb281a9bb"
173
173
  }
@@ -2,7 +2,7 @@ import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
2
2
  import Toast from '@arcblock/ux/lib/Toast';
3
3
  import { ConfirmDialog, api, formatError } from '@blocklet/payment-react';
4
4
  import type { TInvoiceExpanded } from '@blocklet/payment-types';
5
- import { useSetState } from 'ahooks';
5
+ import { useRequest, useSetState } from 'ahooks';
6
6
  import { useNavigate } from 'react-router-dom';
7
7
  import type { LiteralUnion } from 'type-fest';
8
8
 
@@ -23,6 +23,10 @@ InvoiceActions.defaultProps = {
23
23
  mode: 'admin',
24
24
  };
25
25
 
26
+ const fetchStakingData = (id: string): Promise<{ value: string }> => {
27
+ return api.get(`/api/invoices/${id}/return-stake`).then((res: any) => res.data);
28
+ };
29
+
26
30
  export default function InvoiceActions({ data, variant, onChange, mode }: Props) {
27
31
  const { t } = useLocaleContext();
28
32
  const navigate = useNavigate();
@@ -31,11 +35,39 @@ export default function InvoiceActions({ data, variant, onChange, mode }: Props)
31
35
  loading: false,
32
36
  });
33
37
 
38
+ const isStakeInvoice = ['stake', 'stake_overdraft_protection'].includes(data.billing_reason);
39
+ const hasPaid = data.status === 'paid';
40
+ const isAdmin = mode === 'admin';
41
+ const showReturnStake = isAdmin && isStakeInvoice && hasPaid && data.paymentMethod?.type === 'arcblock';
42
+
43
+ const {
44
+ data: stakeResult = {
45
+ value: '0',
46
+ },
47
+ runAsync: fetchStakeResultAsync,
48
+ } = useRequest(
49
+ () => {
50
+ if (showReturnStake) {
51
+ return fetchStakingData(data.id);
52
+ }
53
+ return Promise.resolve({ value: '0' });
54
+ },
55
+ {
56
+ manual: true,
57
+ }
58
+ );
59
+
34
60
  const handleAction = async () => {
35
61
  try {
36
62
  setState({ loading: true });
37
- await api.put(`/api/invoices/${data.id}/xxx`).then((res) => res.data);
38
- Toast.success(t('common.saved'));
63
+ if (state.action === 'return-stake') {
64
+ const result = await api.post(`/api/invoices/${data.id}/return-stake`).then((res) => res.data);
65
+ if (result.success) {
66
+ Toast.success(t('admin.invoice.returnStake.success'));
67
+ } else {
68
+ Toast.error(result.error);
69
+ }
70
+ }
39
71
  onChange(state.action);
40
72
  } catch (err) {
41
73
  console.error(err);
@@ -45,8 +77,6 @@ export default function InvoiceActions({ data, variant, onChange, mode }: Props)
45
77
  }
46
78
  };
47
79
 
48
- const isAdmin = mode === 'admin';
49
-
50
80
  const actions = [
51
81
  isAdmin && {
52
82
  label: t('admin.invoice.edit'),
@@ -61,6 +91,14 @@ export default function InvoiceActions({ data, variant, onChange, mode }: Props)
61
91
  disabled: data.status !== 'draft',
62
92
  divider: true,
63
93
  },
94
+ showReturnStake &&
95
+ stakeResult &&
96
+ stakeResult.value !== '0' && {
97
+ label: t('admin.invoice.returnStake.title'),
98
+ handler: () => setState({ action: 'return-stake' }),
99
+ color: 'primary',
100
+ divider: true,
101
+ },
64
102
  {
65
103
  label: t('admin.customer.view'),
66
104
  handler: () => {
@@ -90,13 +128,13 @@ export default function InvoiceActions({ data, variant, onChange, mode }: Props)
90
128
 
91
129
  return (
92
130
  <ClickBoundary>
93
- <Actions variant={variant} actions={actions as any} />
94
- {state.action === 'xxx' && (
131
+ <Actions variant={variant} actions={actions as any} onOpenCallback={fetchStakeResultAsync} />
132
+ {state.action === 'return-stake' && (
95
133
  <ConfirmDialog
96
134
  onConfirm={handleAction}
97
135
  onCancel={() => setState({ action: '' })}
98
- title={t('admin.invoice.edit')}
99
- message={t('admin.invoice.edit')}
136
+ title={t('admin.invoice.returnStake.title')}
137
+ message={t('admin.invoice.returnStake.tip')}
100
138
  loading={state.loading}
101
139
  />
102
140
  )}
package/src/libs/util.ts CHANGED
@@ -227,7 +227,7 @@ export const debounce = (fun: Function, wait: number) => {
227
227
 
228
228
  export function canChangePaymentMethod(subscription: TSubscriptionExpanded) {
229
229
  return (
230
- ['active', 'trialing'].includes(subscription.status) &&
230
+ ['active', 'trialing', 'past_due'].includes(subscription.status) &&
231
231
  subscription.items.every((x) => getPriceCurrencyOptions(x.price).length > 1)
232
232
  );
233
233
  }
@@ -518,6 +518,11 @@ export default flat({
518
518
  download: 'Download PDF',
519
519
  edit: 'Edit Invoice',
520
520
  duplicate: 'Duplicate Invoice',
521
+ returnStake: {
522
+ title: 'Return Stake',
523
+ tip: 'Are you sure you want to return the stake? This action will return the stake to the customer.',
524
+ success: 'Stake return application has been successfully created',
525
+ },
521
526
  },
522
527
  subscription: {
523
528
  view: 'View subscription',
@@ -507,6 +507,11 @@ export default flat({
507
507
  edit: '编辑账单',
508
508
  duplicate: '复制账单',
509
509
  attention: '未完成的账单',
510
+ returnStake: {
511
+ title: '退还质押',
512
+ tip: '您确定要退还质押吗?此操作将退还质押给客户。',
513
+ success: '质押退还申请已提交',
514
+ },
510
515
  },
511
516
  subscription: {
512
517
  view: '查看订阅',
@@ -203,9 +203,12 @@ function CustomerSubscriptionChangePayment({ subscription, customer, onComplete
203
203
  };
204
204
 
205
205
  const onConfirm = async () => {
206
- const result = await checkUnpaidInvoices();
207
- if (result) {
208
- return;
206
+ // only check unpaid invoices when overdraft protection is enabled
207
+ if (subscription.overdraft_protection?.enabled) {
208
+ const result = await checkUnpaidInvoices();
209
+ if (result) {
210
+ return;
211
+ }
209
212
  }
210
213
  handleSubmit(onSubmit)();
211
214
  };
@@ -239,7 +242,7 @@ function CustomerSubscriptionChangePayment({ subscription, customer, onComplete
239
242
  currency={findCurrency(settings.paymentMethods, selectedCurrencyId) as TPaymentCurrency}
240
243
  trialInDays={0}
241
244
  billingThreshold={0}
242
- showStaking={method.type === 'arcblock'}
245
+ showStaking={method.type === 'arcblock' && !subscription.billing_thresholds?.no_stake}
243
246
  />
244
247
  </Stack>
245
248
  <Stack direction="column" spacing={2}>
@@ -516,9 +516,12 @@ export default function CustomerSubscriptionDetail() {
516
516
  sx={{ color: 'text.link' }}
517
517
  size="small"
518
518
  onClick={async () => {
519
- const result = await checkUnpaidInvoices();
520
- if (result) {
521
- return;
519
+ // only check unpaid invoices when overdraft protection is enabled
520
+ if (data?.overdraft_protection?.enabled) {
521
+ const result = await checkUnpaidInvoices();
522
+ if (result) {
523
+ return;
524
+ }
522
525
  }
523
526
  navigate(`/customer/subscription/${data.id}/change-payment`);
524
527
  }}>