payment-kit 1.18.47 → 1.18.49
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/api/src/index.ts +2 -0
- package/api/src/libs/pagination.ts +7 -4
- package/api/src/libs/security.ts +2 -1
- package/api/src/libs/subscription.ts +1 -1
- package/api/src/routes/checkout-sessions.ts +1 -1
- package/api/src/routes/connect/re-stake.ts +116 -0
- package/api/src/routes/connect/setup.ts +5 -0
- package/api/src/routes/connect/shared.ts +79 -0
- package/api/src/routes/customers.ts +4 -4
- package/api/src/routes/payouts.ts +1 -1
- package/api/src/routes/subscriptions.ts +90 -2
- package/api/tests/libs/pagination.spec.ts +27 -0
- package/blocklet.yml +1 -1
- package/package.json +7 -7
- package/scripts/sdk.js +7 -0
- package/src/components/subscription/portal/actions.tsx +39 -57
- package/src/libs/util.ts +3 -0
package/api/src/index.ts
CHANGED
|
@@ -40,6 +40,7 @@ import subscribeHandlers from './routes/connect/subscribe';
|
|
|
40
40
|
import delegationHandlers from './routes/connect/delegation';
|
|
41
41
|
import overdraftProtectionHandlers from './routes/connect/overdraft-protection';
|
|
42
42
|
import rechargeAccountHandlers from './routes/connect/recharge-account';
|
|
43
|
+
import reStakeHandlers from './routes/connect/re-stake';
|
|
43
44
|
import { initialize } from './store/models';
|
|
44
45
|
import { sequelize } from './store/sequelize';
|
|
45
46
|
import { initUserHandler } from './integrations/blocklet/user';
|
|
@@ -79,6 +80,7 @@ handlers.attach(Object.assign({ app: router }, rechargeHandlers));
|
|
|
79
80
|
handlers.attach(Object.assign({ app: router }, rechargeAccountHandlers));
|
|
80
81
|
handlers.attach(Object.assign({ app: router }, delegationHandlers));
|
|
81
82
|
handlers.attach(Object.assign({ app: router }, overdraftProtectionHandlers));
|
|
83
|
+
handlers.attach(Object.assign({ app: router }, reStakeHandlers));
|
|
82
84
|
router.use('/api', routes);
|
|
83
85
|
|
|
84
86
|
const isProduction = process.env.BLOCKLET_MODE === 'production';
|
|
@@ -145,10 +145,11 @@ function calculateFetchStrategy<T>(
|
|
|
145
145
|
if (sources.length > 1) {
|
|
146
146
|
// For multi-source scenarios, we need more data to ensure correct merging
|
|
147
147
|
// Especially for later pages, estimation can be inaccurate
|
|
148
|
-
const bufferMultiplier = Math.max(
|
|
148
|
+
const bufferMultiplier = Math.max(3, Math.ceil(page * 1.5)); // More aggressive buffer for later pages
|
|
149
|
+
const minDataRatio = page <= 2 ? 0.6 : 0.8; // Get more data for later pages
|
|
149
150
|
const fetchLimit = Math.min(
|
|
150
151
|
sourceCount,
|
|
151
|
-
Math.max(pageSize * bufferMultiplier, Math.ceil(sourceCount *
|
|
152
|
+
Math.max(pageSize * bufferMultiplier, Math.ceil(sourceCount * minDataRatio))
|
|
152
153
|
);
|
|
153
154
|
return { fetchLimit, fetchOffset: 0 };
|
|
154
155
|
}
|
|
@@ -163,7 +164,8 @@ function calculateFetchStrategy<T>(
|
|
|
163
164
|
if (sourceMeta?.type === 'cached' || sourceMeta?.type === 'computed') {
|
|
164
165
|
// For multi-source, be more conservative
|
|
165
166
|
if (sources.length > 1) {
|
|
166
|
-
const
|
|
167
|
+
const minDataRatio = page <= 2 ? 0.7 : 0.9; // Get more data for later pages
|
|
168
|
+
const fetchLimit = Math.min(sourceCount, Math.max(pageSize * 3, Math.ceil(sourceCount * minDataRatio)));
|
|
167
169
|
return { fetchLimit, fetchOffset: 0 };
|
|
168
170
|
}
|
|
169
171
|
const bufferSize = Math.min(sourceMeta.estimatedSize ?? sourceCount, pageSize * 2);
|
|
@@ -172,7 +174,8 @@ function calculateFetchStrategy<T>(
|
|
|
172
174
|
|
|
173
175
|
// Default strategy: more conservative for multi-source
|
|
174
176
|
if (sources.length > 1) {
|
|
175
|
-
const
|
|
177
|
+
const minDataRatio = page <= 2 ? 0.6 : 0.8; // Get more data for later pages
|
|
178
|
+
const fetchLimit = Math.min(sourceCount, Math.max(pageSize * 2, Math.ceil(sourceCount * minDataRatio)));
|
|
176
179
|
return { fetchLimit, fetchOffset: 0 };
|
|
177
180
|
}
|
|
178
181
|
const fetchLimit = Math.min(pageSize * 2, sourceCount);
|
package/api/src/libs/security.ts
CHANGED
|
@@ -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
|
|
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'],
|
|
@@ -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) => {
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { CallbackArgs } from '../../libs/auth';
|
|
2
|
+
import { type TLineItemExpanded } from '../../store/models';
|
|
3
|
+
import { ensureReStakeContext, executeOcapTransactions, getAuthPrincipalClaim, getStakeTxClaim } from './shared';
|
|
4
|
+
import { ensureStakeInvoice } from '../../libs/invoice';
|
|
5
|
+
import logger from '../../libs/logger';
|
|
6
|
+
import { addSubscriptionJob, subscriptionQueue } from '../../queues/subscription';
|
|
7
|
+
import { SubscriptionWillCanceledSchedule } from '../../crons/subscription-will-canceled';
|
|
8
|
+
|
|
9
|
+
export default {
|
|
10
|
+
action: 're-stake',
|
|
11
|
+
authPrincipal: false,
|
|
12
|
+
persistentDynamicClaims: false,
|
|
13
|
+
claims: {
|
|
14
|
+
authPrincipal: async ({ extraParams }: CallbackArgs) => {
|
|
15
|
+
const { paymentMethod } = await ensureReStakeContext(extraParams.subscriptionId);
|
|
16
|
+
return getAuthPrincipalClaim(paymentMethod, 'continue');
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
onConnect: async ({ userDid, userPk, extraParams }: CallbackArgs) => {
|
|
20
|
+
const { subscriptionId } = extraParams;
|
|
21
|
+
const { subscription, paymentMethod, paymentCurrency, payerAddress } = await ensureReStakeContext(subscriptionId);
|
|
22
|
+
if (userDid !== payerAddress) {
|
|
23
|
+
throw new Error(
|
|
24
|
+
`You are not the payer for this subscription. Expected payer: ${payerAddress}, but found: ${userDid}.`
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
// @ts-ignore
|
|
28
|
+
const items = subscription!.items as TLineItemExpanded[];
|
|
29
|
+
|
|
30
|
+
if (paymentMethod.type === 'arcblock') {
|
|
31
|
+
return [
|
|
32
|
+
{
|
|
33
|
+
prepareTx: await getStakeTxClaim({
|
|
34
|
+
userDid,
|
|
35
|
+
userPk,
|
|
36
|
+
paymentCurrency,
|
|
37
|
+
paymentMethod,
|
|
38
|
+
items,
|
|
39
|
+
subscription,
|
|
40
|
+
}),
|
|
41
|
+
},
|
|
42
|
+
];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
throw new Error(`Payment method ${paymentMethod.type} not supported`);
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
onAuth: async ({ request, userDid, userPk, claims, extraParams }: CallbackArgs) => {
|
|
49
|
+
const { subscriptionId } = extraParams;
|
|
50
|
+
const { subscription, paymentMethod, paymentCurrency, customer } = await ensureReStakeContext(subscriptionId);
|
|
51
|
+
|
|
52
|
+
if (paymentMethod.type === 'arcblock') {
|
|
53
|
+
const { stakingAmount, ...paymentDetails } = await executeOcapTransactions(
|
|
54
|
+
userDid,
|
|
55
|
+
userPk,
|
|
56
|
+
claims,
|
|
57
|
+
paymentMethod,
|
|
58
|
+
request,
|
|
59
|
+
subscription?.id,
|
|
60
|
+
paymentCurrency.contract
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
// 创建质押账单
|
|
64
|
+
await ensureStakeInvoice(
|
|
65
|
+
{
|
|
66
|
+
total: stakingAmount,
|
|
67
|
+
description: 'Re-stake to resume subscription',
|
|
68
|
+
currency_id: paymentCurrency.id,
|
|
69
|
+
metadata: {
|
|
70
|
+
payment_details: {
|
|
71
|
+
arcblock: {
|
|
72
|
+
tx_hash: paymentDetails?.staking?.tx_hash,
|
|
73
|
+
payer: paymentDetails?.payer,
|
|
74
|
+
address: paymentDetails?.staking?.address,
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
subscription!,
|
|
80
|
+
paymentMethod,
|
|
81
|
+
customer!
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
await subscription.update({
|
|
85
|
+
cancel_at_period_end: false,
|
|
86
|
+
cancel_at: 0,
|
|
87
|
+
canceled_at: 0,
|
|
88
|
+
// @ts-ignore
|
|
89
|
+
cancelation_details: null,
|
|
90
|
+
payment_details: {
|
|
91
|
+
...subscription.payment_details,
|
|
92
|
+
[paymentMethod.type]: paymentDetails,
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
await new SubscriptionWillCanceledSchedule().deleteScheduleSubscriptionJobs([subscription]);
|
|
97
|
+
|
|
98
|
+
subscriptionQueue
|
|
99
|
+
.delete(`cancel-${subscription.id}`)
|
|
100
|
+
.then(() => logger.info('subscription cancel job is canceled'))
|
|
101
|
+
.catch((err) => logger.error('subscription cancel job failed to cancel', { error: err }));
|
|
102
|
+
await addSubscriptionJob(subscription, 'cycle');
|
|
103
|
+
|
|
104
|
+
logger.info('Subscription resumed with re-stake', {
|
|
105
|
+
subscriptionId: subscription.id,
|
|
106
|
+
stakingTxHash: paymentDetails?.staking?.tx_hash,
|
|
107
|
+
stakingAddress: paymentDetails?.staking?.address,
|
|
108
|
+
});
|
|
109
|
+
return {
|
|
110
|
+
hash: paymentDetails.staking?.tx_hash,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
throw new Error(`Payment method ${paymentMethod.type} not supported`);
|
|
115
|
+
},
|
|
116
|
+
};
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
getAuthPrincipalClaim,
|
|
16
16
|
getDelegationTxClaim,
|
|
17
17
|
getStakeTxClaim,
|
|
18
|
+
returnStakeForCanceledSubscription,
|
|
18
19
|
} from './shared';
|
|
19
20
|
import { ensureStakeInvoice } from '../../libs/invoice';
|
|
20
21
|
import { EVM_CHAIN_TYPES } from '../../libs/constants';
|
|
@@ -239,6 +240,10 @@ export default {
|
|
|
239
240
|
);
|
|
240
241
|
await afterTxExecution(paymentDetails);
|
|
241
242
|
|
|
243
|
+
if (subscription.recovered_from) {
|
|
244
|
+
returnStakeForCanceledSubscription(subscription.recovered_from);
|
|
245
|
+
}
|
|
246
|
+
|
|
242
247
|
return { hash: paymentDetails.tx_hash };
|
|
243
248
|
} catch (err) {
|
|
244
249
|
logger.error('Failed to finalize setup', { setupIntent: setupIntent.id, error: err });
|
|
@@ -43,6 +43,7 @@ import { SetupIntent } from '../../store/models/setup-intent';
|
|
|
43
43
|
import { Subscription } from '../../store/models/subscription';
|
|
44
44
|
import { ensureInvoiceAndItems } from '../../libs/invoice';
|
|
45
45
|
import { CHARGE_SUPPORTED_CHAIN_TYPES, EVM_CHAIN_TYPES } from '../../libs/constants';
|
|
46
|
+
import { returnStakeQueue } from '../../queues/subscription';
|
|
46
47
|
|
|
47
48
|
type Result = {
|
|
48
49
|
checkoutSession: CheckoutSession;
|
|
@@ -783,6 +784,15 @@ export async function getDelegationTxClaim({
|
|
|
783
784
|
throw new Error(`getDelegationTxClaim: Payment method ${paymentMethod.type} not supported`);
|
|
784
785
|
}
|
|
785
786
|
|
|
787
|
+
export function getStakeAmount(subscription: Subscription, paymentCurrency: PaymentCurrency, items: TLineItemExpanded[]) {
|
|
788
|
+
const billingThreshold = Number(subscription.billing_thresholds?.amount_gte || 0);
|
|
789
|
+
const minStakeAmount = Number(subscription.billing_thresholds?.stake_gte || 0);
|
|
790
|
+
const threshold = fromTokenToUnit(Math.max(billingThreshold, minStakeAmount), paymentCurrency.decimal);
|
|
791
|
+
const staking = getSubscriptionStakeSetup(items, paymentCurrency.id, threshold.toString());
|
|
792
|
+
const amount = staking.licensed.add(staking.metered).toString();
|
|
793
|
+
return amount;
|
|
794
|
+
}
|
|
795
|
+
|
|
786
796
|
export async function getStakeTxClaim({
|
|
787
797
|
userDid,
|
|
788
798
|
userPk,
|
|
@@ -1135,6 +1145,50 @@ export async function ensureChangePaymentContext(subscriptionId: string) {
|
|
|
1135
1145
|
};
|
|
1136
1146
|
}
|
|
1137
1147
|
|
|
1148
|
+
export async function ensureReStakeContext(subscriptionId: string) {
|
|
1149
|
+
const subscription = await Subscription.findByPk(subscriptionId);
|
|
1150
|
+
if (!subscription) {
|
|
1151
|
+
throw new Error(`Subscription not found: ${subscriptionId}`);
|
|
1152
|
+
}
|
|
1153
|
+
if (subscription.status === 'canceled') {
|
|
1154
|
+
throw new Error(`Subscription ${subscriptionId} not recoverable from cancellation`);
|
|
1155
|
+
}
|
|
1156
|
+
if (!subscription.cancel_at_period_end) {
|
|
1157
|
+
throw new Error(`Subscription ${subscriptionId} not recoverable from cancellation config`);
|
|
1158
|
+
}
|
|
1159
|
+
if (subscription.cancelation_details?.reason === 'payment_failed') {
|
|
1160
|
+
throw new Error(`Subscription ${subscriptionId} not recoverable from payment failed`);
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
const paymentCurrency = await PaymentCurrency.findByPk(subscription.currency_id);
|
|
1164
|
+
if (!paymentCurrency) {
|
|
1165
|
+
throw new Error(`PaymentCurrency ${subscription.currency_id} not found for subscription ${subscriptionId}`);
|
|
1166
|
+
}
|
|
1167
|
+
const paymentMethod = await PaymentMethod.findByPk(paymentCurrency.payment_method_id);
|
|
1168
|
+
if (!paymentMethod) {
|
|
1169
|
+
throw new Error(`Payment method not found for subscription ${subscriptionId}`);
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
if (paymentMethod.type !== 'arcblock') {
|
|
1173
|
+
throw new Error(`Payment method ${paymentMethod.type} not supported for subscription ${subscriptionId}`);
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
const customer = await Customer.findByPk(subscription.customer_id);
|
|
1177
|
+
if (!customer) {
|
|
1178
|
+
throw new Error(`Customer not found for subscription ${subscriptionId}`);
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
// @ts-ignore
|
|
1182
|
+
subscription.items = await expandSubscriptionItems(subscription.id);
|
|
1183
|
+
|
|
1184
|
+
return {
|
|
1185
|
+
subscription,
|
|
1186
|
+
paymentCurrency,
|
|
1187
|
+
paymentMethod,
|
|
1188
|
+
customer,
|
|
1189
|
+
payerAddress: getSubscriptionPaymentAddress(subscription, paymentMethod?.type),
|
|
1190
|
+
};
|
|
1191
|
+
}
|
|
1138
1192
|
export async function ensureSubscriptionForCollectBatch(
|
|
1139
1193
|
subscriptionId?: string,
|
|
1140
1194
|
currencyId?: string,
|
|
@@ -1370,3 +1424,28 @@ export async function updateStripeSubscriptionAfterChangePayment(setupIntent: Se
|
|
|
1370
1424
|
}
|
|
1371
1425
|
}
|
|
1372
1426
|
|
|
1427
|
+
export async function returnStakeForCanceledSubscription(subscriptionId: string) {
|
|
1428
|
+
if (!subscriptionId) {
|
|
1429
|
+
return;
|
|
1430
|
+
}
|
|
1431
|
+
try {
|
|
1432
|
+
const subscription = await Subscription.findByPk(subscriptionId);
|
|
1433
|
+
if (!subscription) {
|
|
1434
|
+
throw new Error(`Subscription ${subscriptionId} not found`);
|
|
1435
|
+
}
|
|
1436
|
+
if (subscription.status !== 'canceled') {
|
|
1437
|
+
throw new Error(`Subscription ${subscriptionId} is not canceled`);
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
if (!subscription.payment_details?.arcblock?.staking?.tx_hash) {
|
|
1441
|
+
throw new Error(`No staking transaction found in subscription ${subscriptionId}`);
|
|
1442
|
+
}
|
|
1443
|
+
returnStakeQueue.push({ id: `return-stake-${subscription.id}`, job: { subscriptionId: subscription.id } });
|
|
1444
|
+
logger.info('Subscription return stake job scheduled', {
|
|
1445
|
+
jobId: `return-stake-${subscription.id}`,
|
|
1446
|
+
subscription: subscription.id,
|
|
1447
|
+
});
|
|
1448
|
+
} catch (err) {
|
|
1449
|
+
logger.error('returnStakeForCanceledSubscription failed', { error: err, subscriptionId });
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
@@ -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' });
|
|
@@ -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,
|
|
@@ -17,6 +17,7 @@ import { isDelegationSufficientForPayment } from '../libs/payment';
|
|
|
17
17
|
import { authenticate } from '../libs/security';
|
|
18
18
|
import { expandLineItems, getFastCheckoutAmount, getSubscriptionCreateSetup, isLineItemAligned } from '../libs/session';
|
|
19
19
|
import {
|
|
20
|
+
checkRemainingStake,
|
|
20
21
|
createProration,
|
|
21
22
|
finalizeSubscriptionUpdate,
|
|
22
23
|
getPastInvoicesAmount,
|
|
@@ -33,6 +34,7 @@ import { invoiceQueue } from '../queues/invoice';
|
|
|
33
34
|
import {
|
|
34
35
|
addSubscriptionJob,
|
|
35
36
|
returnOverdraftProtectionQueue,
|
|
37
|
+
returnStakeQueue,
|
|
36
38
|
slashOverdraftProtectionQueue,
|
|
37
39
|
slashStakeQueue,
|
|
38
40
|
subscriptionQueue,
|
|
@@ -426,6 +428,42 @@ router.put('/:id/cancel', authPortal, async (req, res) => {
|
|
|
426
428
|
return res.json(subscription);
|
|
427
429
|
});
|
|
428
430
|
|
|
431
|
+
router.get('/:id/recover-info', authPortal, async (req, res) => {
|
|
432
|
+
const doc = await Subscription.findByPk(req.params.id);
|
|
433
|
+
|
|
434
|
+
if (!doc) {
|
|
435
|
+
return res.status(404).json({ error: 'Subscription not found' });
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const paymentMethod = await PaymentMethod.findByPk(doc.default_payment_method_id);
|
|
439
|
+
const paymentCurrency = await PaymentCurrency.findByPk(doc.currency_id);
|
|
440
|
+
|
|
441
|
+
let needStake = false;
|
|
442
|
+
let revokedStake = '0';
|
|
443
|
+
|
|
444
|
+
if (paymentMethod?.type === 'arcblock' && paymentCurrency) {
|
|
445
|
+
const address = doc.payment_details?.arcblock?.staking?.address;
|
|
446
|
+
if (address) {
|
|
447
|
+
try {
|
|
448
|
+
const { revoked } = await checkRemainingStake(paymentMethod, paymentCurrency, address, '0');
|
|
449
|
+
const cancelReason = doc.cancelation_details?.reason;
|
|
450
|
+
if (revoked && revoked !== '0' && cancelReason === 'stake_revoked') {
|
|
451
|
+
needStake = true;
|
|
452
|
+
revokedStake = revoked;
|
|
453
|
+
}
|
|
454
|
+
} catch (err) {
|
|
455
|
+
console.error(`Failed to check remaining stake for subscription ${doc.id}`, { error: err });
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
return res.json({
|
|
461
|
+
subscription: doc,
|
|
462
|
+
needStake,
|
|
463
|
+
revokedStake,
|
|
464
|
+
});
|
|
465
|
+
});
|
|
466
|
+
|
|
429
467
|
router.put('/:id/recover', authPortal, async (req, res) => {
|
|
430
468
|
const doc = await Subscription.findByPk(req.params.id);
|
|
431
469
|
|
|
@@ -442,13 +480,43 @@ router.put('/:id/recover', authPortal, async (req, res) => {
|
|
|
442
480
|
return res.status(400).json({ error: 'Subscription not recoverable from cancellation' });
|
|
443
481
|
}
|
|
444
482
|
|
|
483
|
+
const paymentMethod = await PaymentMethod.findByPk(doc.default_payment_method_id);
|
|
484
|
+
if (!paymentMethod) {
|
|
485
|
+
return res.status(400).json({ error: 'Payment method not found' });
|
|
486
|
+
}
|
|
487
|
+
const paymentCurrency = await PaymentCurrency.findByPk(doc.currency_id);
|
|
488
|
+
if (!paymentCurrency) {
|
|
489
|
+
return res.status(400).json({ error: 'Payment currency not found' });
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// check if need stake
|
|
493
|
+
if (paymentMethod.type === 'arcblock') {
|
|
494
|
+
const address = doc.payment_details?.arcblock?.staking?.address;
|
|
495
|
+
if (address) {
|
|
496
|
+
try {
|
|
497
|
+
const { revoked } = await checkRemainingStake(paymentMethod, paymentCurrency, address, '0');
|
|
498
|
+
const cancelReason = doc.cancelation_details?.reason;
|
|
499
|
+
if (revoked && revoked !== '0' && cancelReason === 'stake_revoked') {
|
|
500
|
+
return res.json({
|
|
501
|
+
needStake: true,
|
|
502
|
+
subscription: doc,
|
|
503
|
+
revoked,
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
} catch (err) {
|
|
507
|
+
logger.error('subscription recover failed to check remaining stake', { error: err });
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
445
512
|
if (doc.cancel_at_period_end) {
|
|
446
513
|
await updateStripeSubscription(doc, { cancel_at_period_end: false });
|
|
447
514
|
} else {
|
|
448
515
|
await updateStripeSubscription(doc, { cancel_at: null });
|
|
449
516
|
}
|
|
450
517
|
|
|
451
|
-
|
|
518
|
+
// @ts-ignore
|
|
519
|
+
await doc.update({ cancel_at_period_end: false, cancel_at: 0, canceled_at: 0, cancelation_details: null });
|
|
452
520
|
await new SubscriptionWillCanceledSchedule().deleteScheduleSubscriptionJobs([doc]);
|
|
453
521
|
// reschedule jobs
|
|
454
522
|
subscriptionQueue
|
|
@@ -457,7 +525,7 @@ router.put('/:id/recover', authPortal, async (req, res) => {
|
|
|
457
525
|
.catch((err) => logger.error('subscription cancel job failed to cancel', { error: err }));
|
|
458
526
|
await addSubscriptionJob(doc, 'cycle');
|
|
459
527
|
|
|
460
|
-
return res.json(doc);
|
|
528
|
+
return res.json({ subscription: doc });
|
|
461
529
|
});
|
|
462
530
|
|
|
463
531
|
router.put('/:id/pause', auth, async (req, res) => {
|
|
@@ -520,6 +588,26 @@ router.put('/:id/resume', auth, async (req, res) => {
|
|
|
520
588
|
return res.json(doc);
|
|
521
589
|
});
|
|
522
590
|
|
|
591
|
+
router.put('/:id/return-stake', authPortal, async (req, res) => {
|
|
592
|
+
const doc = await Subscription.findByPk(req.params.id);
|
|
593
|
+
if (!doc) {
|
|
594
|
+
return res.status(404).json({ error: 'Subscription not found' });
|
|
595
|
+
}
|
|
596
|
+
if (doc.status !== 'canceled') {
|
|
597
|
+
return res.status(400).json({ error: 'Subscription is not canceled' });
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
if (!doc.payment_details?.arcblock?.staking?.tx_hash) {
|
|
601
|
+
return res.status(400).json({ error: 'No staking transaction found in subscription' });
|
|
602
|
+
}
|
|
603
|
+
returnStakeQueue.push({ id: `return-stake-${doc.id}`, job: { subscriptionId: doc.id } });
|
|
604
|
+
logger.info('Subscription return stake job scheduled', {
|
|
605
|
+
jobId: `return-stake-${doc.id}`,
|
|
606
|
+
subscription: doc.id,
|
|
607
|
+
});
|
|
608
|
+
return res.json({ success: true, subscriptionId: doc.id });
|
|
609
|
+
});
|
|
610
|
+
|
|
523
611
|
const isValidSubscriptionItemChange = (item: SubscriptionUpdateItem) => {
|
|
524
612
|
if (item.deleted) {
|
|
525
613
|
if (!item.id) {
|
|
@@ -300,6 +300,33 @@ describe('mergePaginate', () => {
|
|
|
300
300
|
expect(result.data.map((item) => item.id)).toEqual(['id_4', 'id_2', 'id_3', 'id_1']);
|
|
301
301
|
});
|
|
302
302
|
|
|
303
|
+
it('should handle 29 records with last page showing correct 9 items', async () => {
|
|
304
|
+
const baseDate = new Date('2024-01-01T00:00:00Z');
|
|
305
|
+
|
|
306
|
+
// Create 3 data sources with uneven distribution: 10, 10, 9 records
|
|
307
|
+
const source1 = createTestDataSource(createTestItems(10, baseDate));
|
|
308
|
+
const source2 = createTestDataSource(createTestItems(10, new Date(baseDate.getTime() + 10000)));
|
|
309
|
+
const source3 = createTestDataSource(createTestItems(9, new Date(baseDate.getTime() + 20000)));
|
|
310
|
+
|
|
311
|
+
// Test the last page (page 3, should have 9 items: records 21-29)
|
|
312
|
+
const result = await mergePaginate(
|
|
313
|
+
[source1, source2, source3],
|
|
314
|
+
{ page: 3, pageSize: 10 },
|
|
315
|
+
defaultTimeOrderBy('desc')
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
expect(result.total).toBe(29);
|
|
319
|
+
expect(result.data).toHaveLength(9); // Should show all 9 remaining items, not just 2
|
|
320
|
+
expect(result.paging).toEqual({
|
|
321
|
+
page: 3,
|
|
322
|
+
pageSize: 10,
|
|
323
|
+
totalPages: 3,
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
// Verify we got the correct range of items (the oldest ones)
|
|
327
|
+
expect(result.data.every((item) => item.value < 20)).toBe(true); // These should be the oldest items
|
|
328
|
+
});
|
|
329
|
+
|
|
303
330
|
it('should handle multiple data sources with late pages correctly', async () => {
|
|
304
331
|
const baseDate = new Date('2024-01-01T00:00:00Z');
|
|
305
332
|
|
package/blocklet.yml
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "payment-kit",
|
|
3
|
-
"version": "1.18.
|
|
3
|
+
"version": "1.18.49",
|
|
4
4
|
"scripts": {
|
|
5
5
|
"dev": "blocklet dev --open",
|
|
6
6
|
"eject": "vite eject",
|
|
@@ -47,17 +47,17 @@
|
|
|
47
47
|
"@abtnode/cron": "^1.16.43",
|
|
48
48
|
"@arcblock/did": "^1.20.11",
|
|
49
49
|
"@arcblock/did-auth-storage-nedb": "^1.7.1",
|
|
50
|
-
"@arcblock/did-connect": "^2.13.
|
|
50
|
+
"@arcblock/did-connect": "^2.13.54",
|
|
51
51
|
"@arcblock/did-util": "^1.20.11",
|
|
52
52
|
"@arcblock/jwt": "^1.20.11",
|
|
53
|
-
"@arcblock/ux": "^2.13.
|
|
53
|
+
"@arcblock/ux": "^2.13.54",
|
|
54
54
|
"@arcblock/validator": "^1.20.11",
|
|
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.
|
|
58
|
+
"@blocklet/payment-react": "1.18.49",
|
|
59
59
|
"@blocklet/sdk": "^1.16.43",
|
|
60
|
-
"@blocklet/ui-react": "^2.13.
|
|
60
|
+
"@blocklet/ui-react": "^2.13.54",
|
|
61
61
|
"@blocklet/uploader": "^0.1.93",
|
|
62
62
|
"@blocklet/xss": "^0.1.36",
|
|
63
63
|
"@mui/icons-material": "^5.16.6",
|
|
@@ -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.
|
|
126
|
+
"@blocklet/payment-types": "1.18.49",
|
|
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": "
|
|
172
|
+
"gitHead": "05028e0b605756db083baa56b1faa36ef9431042"
|
|
173
173
|
}
|
package/scripts/sdk.js
CHANGED
|
@@ -539,6 +539,13 @@ const subscriptionModule = {
|
|
|
539
539
|
console.log('createBatchUsageRecords', results);
|
|
540
540
|
return results;
|
|
541
541
|
},
|
|
542
|
+
|
|
543
|
+
// 返回质押
|
|
544
|
+
async returnStake() {
|
|
545
|
+
const result = await payment.subscriptions.returnStake('sub_pWYXj2fale49qeFfEpunTmZq');
|
|
546
|
+
console.log('returnStake', result);
|
|
547
|
+
return result;
|
|
548
|
+
},
|
|
542
549
|
};
|
|
543
550
|
|
|
544
551
|
const testModules = {
|
|
@@ -5,12 +5,12 @@ import {
|
|
|
5
5
|
ConfirmDialog,
|
|
6
6
|
api,
|
|
7
7
|
formatError,
|
|
8
|
-
formatToDate,
|
|
9
8
|
getPrefix,
|
|
10
9
|
getSubscriptionAction,
|
|
11
10
|
usePaymentContext,
|
|
12
11
|
OverdueInvoicePayment,
|
|
13
12
|
formatBNStr,
|
|
13
|
+
ResumeSubscription,
|
|
14
14
|
} from '@blocklet/payment-react';
|
|
15
15
|
import type { TSubscriptionExpanded } from '@blocklet/payment-types';
|
|
16
16
|
import { Button, Link, Stack, Tooltip, Typography, Box, Alert } from '@mui/material';
|
|
@@ -27,7 +27,7 @@ import CustomerCancelForm from './cancel';
|
|
|
27
27
|
import OverdraftProtectionDialog from '../../customer/overdraft-protection';
|
|
28
28
|
import Actions from '../../actions';
|
|
29
29
|
import { useUnpaidInvoicesCheckForSubscription } from '../../../hooks/subscription';
|
|
30
|
-
import { isWillCanceled } from '../../../libs/util';
|
|
30
|
+
import { isActive, isWillCanceled } from '../../../libs/util';
|
|
31
31
|
|
|
32
32
|
interface ActionConfig {
|
|
33
33
|
key: string;
|
|
@@ -105,34 +105,26 @@ SubscriptionActions.defaultProps = {
|
|
|
105
105
|
forceShowDetailAction: false,
|
|
106
106
|
buttonSize: 'small',
|
|
107
107
|
};
|
|
108
|
-
const fetchExtraActions = async ({
|
|
109
|
-
id,
|
|
110
|
-
showExtra,
|
|
111
|
-
}: {
|
|
112
|
-
id: string;
|
|
113
|
-
showExtra: boolean;
|
|
114
|
-
}): Promise<{ changePlan: boolean; batchPay: string }> => {
|
|
115
|
-
if (!showExtra) {
|
|
116
|
-
return Promise.resolve({ changePlan: false, batchPay: '' });
|
|
117
|
-
}
|
|
118
108
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
if (!isEmpty(res.data) && Object.keys(res.data).length === 1) {
|
|
128
|
-
return Object.keys(res.data)[0] as string;
|
|
129
|
-
}
|
|
130
|
-
return '';
|
|
131
|
-
})
|
|
132
|
-
.catch(() => ''),
|
|
133
|
-
]);
|
|
109
|
+
const fetchChangePlan = async (id: string): Promise<boolean> => {
|
|
110
|
+
try {
|
|
111
|
+
const res = await api.get(`/api/subscriptions/${id}/change-plan`);
|
|
112
|
+
return !!res.data;
|
|
113
|
+
} catch {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
};
|
|
134
117
|
|
|
135
|
-
|
|
118
|
+
const fetchBatchPay = async (id: string): Promise<string> => {
|
|
119
|
+
try {
|
|
120
|
+
const res = await api.get(`/api/subscriptions/${id}/summary`);
|
|
121
|
+
if (!isEmpty(res.data) && Object.keys(res.data).length === 1) {
|
|
122
|
+
return Object.keys(res.data)[0] as string;
|
|
123
|
+
}
|
|
124
|
+
return '';
|
|
125
|
+
} catch {
|
|
126
|
+
return '';
|
|
127
|
+
}
|
|
136
128
|
};
|
|
137
129
|
|
|
138
130
|
const supportRecharge = (subscription: TSubscriptionExpanded) => {
|
|
@@ -177,7 +169,13 @@ export function SubscriptionActionsInner({
|
|
|
177
169
|
return true;
|
|
178
170
|
};
|
|
179
171
|
|
|
180
|
-
const { data:
|
|
172
|
+
const { data: changePlanAvailable = false } = useRequest(() => fetchChangePlan(subscription.id), {
|
|
173
|
+
ready: !!showExtra && isActive(subscription),
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const { data: batchPayAvailable = '' } = useRequest(() => fetchBatchPay(subscription.id), {
|
|
177
|
+
ready: !!showExtra,
|
|
178
|
+
});
|
|
181
179
|
|
|
182
180
|
const { data: upcoming = {} } = useRequest(
|
|
183
181
|
() => api.get(`/api/subscriptions/${subscription.id}/upcoming`).then((res) => res.data),
|
|
@@ -269,21 +267,6 @@ export function SubscriptionActionsInner({
|
|
|
269
267
|
}
|
|
270
268
|
};
|
|
271
269
|
|
|
272
|
-
const handleRecover = async () => {
|
|
273
|
-
try {
|
|
274
|
-
setState({ loading: true });
|
|
275
|
-
const sub = await api.put(`/api/subscriptions/${state.subscription}/recover`).then((res) => res.data);
|
|
276
|
-
setSubscription(sub);
|
|
277
|
-
Toast.success(t('common.saved'));
|
|
278
|
-
if (onChange) onChange(state.action);
|
|
279
|
-
} catch (err) {
|
|
280
|
-
console.error(err);
|
|
281
|
-
Toast.error(formatError(err));
|
|
282
|
-
} finally {
|
|
283
|
-
setState({ loading: false, action: '', subscription: '' });
|
|
284
|
-
}
|
|
285
|
-
};
|
|
286
|
-
|
|
287
270
|
const handleDelegate = () => {
|
|
288
271
|
connect.open({
|
|
289
272
|
containerEl: undefined as unknown as Element,
|
|
@@ -503,7 +486,7 @@ export function SubscriptionActionsInner({
|
|
|
503
486
|
},
|
|
504
487
|
{
|
|
505
488
|
key: 'changePlan',
|
|
506
|
-
show:
|
|
489
|
+
show: changePlanAvailable,
|
|
507
490
|
label: action?.text || t('payment.customer.changePlan.button'),
|
|
508
491
|
onClick: (e) => {
|
|
509
492
|
e?.stopPropagation();
|
|
@@ -515,7 +498,7 @@ export function SubscriptionActionsInner({
|
|
|
515
498
|
},
|
|
516
499
|
{
|
|
517
500
|
key: 'batchPay',
|
|
518
|
-
show: !!
|
|
501
|
+
show: !!batchPayAvailable,
|
|
519
502
|
label: action?.text || t('admin.subscription.batchPay.button'),
|
|
520
503
|
onClick: (e) => {
|
|
521
504
|
e?.stopPropagation();
|
|
@@ -530,7 +513,7 @@ export function SubscriptionActionsInner({
|
|
|
530
513
|
},
|
|
531
514
|
{
|
|
532
515
|
key: 'mainAction',
|
|
533
|
-
show: !!(!
|
|
516
|
+
show: !!(!batchPayAvailable && supportAction),
|
|
534
517
|
label: action?.text || t(`payment.customer.${action?.action}.button`),
|
|
535
518
|
onClick: (e) => {
|
|
536
519
|
e?.stopPropagation();
|
|
@@ -660,18 +643,17 @@ export function SubscriptionActionsInner({
|
|
|
660
643
|
/>
|
|
661
644
|
)}
|
|
662
645
|
{state.action === 'recover' && state.subscription && (
|
|
663
|
-
<
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
e.stopPropagation();
|
|
646
|
+
<ResumeSubscription
|
|
647
|
+
subscriptionId={state.subscription}
|
|
648
|
+
onResumed={(updatedSubscription) => {
|
|
667
649
|
setState({ action: '', subscription: '' });
|
|
650
|
+
onChange?.(state.action);
|
|
651
|
+
setSubscription(updatedSubscription as TSubscriptionExpanded);
|
|
652
|
+
}}
|
|
653
|
+
dialogProps={{
|
|
654
|
+
open: true,
|
|
655
|
+
onClose: () => setState({ action: '', subscription: '' }),
|
|
668
656
|
}}
|
|
669
|
-
title={t('payment.customer.recover.title')}
|
|
670
|
-
message={t('payment.customer.recover.description', {
|
|
671
|
-
date: formatToDate(subscription.current_period_end * 1000),
|
|
672
|
-
})}
|
|
673
|
-
loading={state.loading}
|
|
674
|
-
color="primary"
|
|
675
657
|
/>
|
|
676
658
|
)}
|
|
677
659
|
|
package/src/libs/util.ts
CHANGED
|
@@ -348,6 +348,9 @@ export function getAppInfo(address: string): { name: string; avatar: string; typ
|
|
|
348
348
|
return null;
|
|
349
349
|
}
|
|
350
350
|
|
|
351
|
+
export function isActive(subscription: TSubscriptionExpanded) {
|
|
352
|
+
return ['active', 'trialing'].includes(subscription.status);
|
|
353
|
+
}
|
|
351
354
|
export function isWillCanceled(subscription: TSubscriptionExpanded) {
|
|
352
355
|
const now = Date.now() / 1000;
|
|
353
356
|
if (
|