payment-kit 1.18.46 → 1.18.48

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 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';
@@ -0,0 +1,406 @@
1
+ import logger from './logger';
2
+
3
+ // In-memory cache with TTL support for simulated data
4
+ const cache = new Map<string, { data: any; expireTime: number }>();
5
+ const DEFAULT_TTL = 60 * 60 * 6 * 1000; // 6 hours
6
+
7
+ /**
8
+ * Fetches data from cache if available and not expired, otherwise fetches fresh data
9
+ * Implements lazy cache cleanup to prevent memory leaks
10
+ */
11
+ export async function getCachedOrFetch<T>(key: string, fetcher: () => Promise<T>, ttl?: number): Promise<T> {
12
+ const now = Date.now();
13
+ const cached = cache.get(key);
14
+
15
+ // Return cached data if valid
16
+ if (cached && now < cached.expireTime) {
17
+ return cached.data;
18
+ }
19
+
20
+ // Fetch and cache new data
21
+ const result = await fetcher();
22
+ cache.set(key, {
23
+ data: result,
24
+ expireTime: now + (ttl || DEFAULT_TTL),
25
+ });
26
+
27
+ // Lazy cleanup: Randomly clean expired entries to prevent memory leaks
28
+ if (Math.random() < 0.1) {
29
+ // 10% chance to trigger cleanup
30
+ for (const [k, v] of cache.entries()) {
31
+ if (now > v.expireTime) {
32
+ cache.delete(k);
33
+ }
34
+ }
35
+ }
36
+
37
+ return result;
38
+ }
39
+
40
+ export interface PaginationOptions {
41
+ page: number;
42
+ pageSize?: number; // Make optional to support pageSize = 0
43
+ }
44
+
45
+ export interface PaginatedResult<T> {
46
+ total: number;
47
+ data: T[];
48
+ paging: {
49
+ page: number;
50
+ pageSize: number;
51
+ totalPages: number;
52
+ };
53
+ }
54
+
55
+ // Generic data source interface
56
+ export interface DataSource<T> {
57
+ count: () => Promise<number>;
58
+ fetch: (limit: number, offset?: number) => Promise<T[]>;
59
+ // Optional metadata for optimization hints
60
+ meta?: {
61
+ type?: 'database' | 'cached' | 'computed';
62
+ estimatedSize?: number;
63
+ cacheable?: boolean;
64
+ };
65
+ }
66
+
67
+ /**
68
+ * Merges multiple sorted arrays efficiently
69
+ * Handles undefined values and provides type safety
70
+ */
71
+ function mergeSortedArrays<T>(arrays: T[][], orderBy: (a: T, b: T) => number, offset: number, limit: number): T[] {
72
+ // Initialize pointers for each array
73
+ const pointers = new Array(arrays.length).fill(0);
74
+ const result: T[] = [];
75
+ let skipped = 0;
76
+
77
+ while (result.length < limit) {
78
+ // Find the next item to process
79
+ let bestArrayIndex = -1;
80
+ let bestItem: T | undefined;
81
+
82
+ for (let i = 0; i < arrays.length; i++) {
83
+ const array = arrays[i];
84
+ const pointer = pointers[i];
85
+
86
+ if (!array || pointer >= array.length) {
87
+ // Array is undefined or exhausted
88
+ // eslint-disable-next-line no-continue
89
+ continue;
90
+ }
91
+
92
+ const currentItem = array[pointer];
93
+ if (currentItem === undefined) {
94
+ // Safety check for undefined items
95
+ // eslint-disable-next-line no-continue
96
+ continue;
97
+ }
98
+
99
+ if (bestItem === undefined || orderBy(currentItem, bestItem) < 0) {
100
+ bestItem = currentItem;
101
+ bestArrayIndex = i;
102
+ }
103
+ }
104
+
105
+ // No more items available
106
+ if (bestArrayIndex === -1 || bestItem === undefined) break;
107
+
108
+ // Advance the pointer for the selected array
109
+ pointers[bestArrayIndex]++;
110
+
111
+ // Apply pagination logic
112
+ if (skipped < offset) {
113
+ skipped++;
114
+ } else {
115
+ result.push(bestItem);
116
+ }
117
+ }
118
+
119
+ return result;
120
+ }
121
+
122
+ /**
123
+ * Estimates the optimal fetch strategy for data sources
124
+ */
125
+ function calculateFetchStrategy<T>(
126
+ sources: DataSource<T>[],
127
+ options: PaginationOptions,
128
+ totalCounts: number[]
129
+ ): { fetchLimit: number; fetchOffset: number }[] {
130
+ const { page, pageSize = 0 } = options;
131
+ const offset = (page - 1) * pageSize;
132
+ const total = totalCounts.reduce((sum, count) => sum + count, 0);
133
+
134
+ return sources.map((source, index) => {
135
+ const sourceCount = totalCounts[index] ?? 0;
136
+ const sourceMeta = source.meta;
137
+
138
+ // Handle pageSize = 0: fetch all data
139
+ if (!pageSize) {
140
+ return { fetchLimit: Math.max(sourceCount, 1000), fetchOffset: 0 };
141
+ }
142
+
143
+ // For database sources with multiple sources, use conservative strategy
144
+ if (sourceMeta?.type === 'database') {
145
+ if (sources.length > 1) {
146
+ // For multi-source scenarios, we need more data to ensure correct merging
147
+ // Especially for later pages, estimation can be inaccurate
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
150
+ const fetchLimit = Math.min(
151
+ sourceCount,
152
+ Math.max(pageSize * bufferMultiplier, Math.ceil(sourceCount * minDataRatio))
153
+ );
154
+ return { fetchLimit, fetchOffset: 0 };
155
+ }
156
+ // Single database source can use precise offset
157
+ const estimatedRatio = total > 0 ? sourceCount / total : 0;
158
+ const estimatedOffset = Math.max(0, Math.floor(offset * estimatedRatio) - pageSize);
159
+ const fetchLimit = Math.min(pageSize * 3, Math.max(pageSize, sourceCount - estimatedOffset));
160
+ return { fetchLimit, fetchOffset: estimatedOffset };
161
+ }
162
+
163
+ // For cached/computed sources, always fetch more data to ensure accuracy
164
+ if (sourceMeta?.type === 'cached' || sourceMeta?.type === 'computed') {
165
+ // For multi-source, be more conservative
166
+ if (sources.length > 1) {
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)));
169
+ return { fetchLimit, fetchOffset: 0 };
170
+ }
171
+ const bufferSize = Math.min(sourceMeta.estimatedSize ?? sourceCount, pageSize * 2);
172
+ return { fetchLimit: Math.max(pageSize, bufferSize), fetchOffset: 0 };
173
+ }
174
+
175
+ // Default strategy: more conservative for multi-source
176
+ if (sources.length > 1) {
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)));
179
+ return { fetchLimit, fetchOffset: 0 };
180
+ }
181
+ const fetchLimit = Math.min(pageSize * 2, sourceCount);
182
+ return { fetchLimit: Math.max(pageSize, fetchLimit), fetchOffset: 0 };
183
+ });
184
+ }
185
+
186
+ /**
187
+ * Enhanced merge pagination with intelligent fetching strategy
188
+ * Supports multiple data sources with different characteristics
189
+ */
190
+ export async function mergePaginate<T>(
191
+ sources: DataSource<T>[],
192
+ options: PaginationOptions,
193
+ orderBy: (a: T, b: T) => number
194
+ ): Promise<PaginatedResult<T>> {
195
+ const page = Math.max(1, options.page || 1);
196
+ const pageSize = options.pageSize ?? 0;
197
+ const offset = (page - 1) * pageSize;
198
+
199
+ try {
200
+ // Get total counts from all sources with error handling
201
+ const totalCounts = await Promise.all(
202
+ sources.map(async (source, index) => {
203
+ try {
204
+ return await source.count();
205
+ } catch (error) {
206
+ logger.error('Failed to get count from data source', { error, sourceIndex: index });
207
+ return 0;
208
+ }
209
+ })
210
+ );
211
+
212
+ const total = totalCounts.reduce((sum, count) => sum + count, 0);
213
+
214
+ // Handle pageSize = 0: return all data
215
+ if (!pageSize) {
216
+ const allData = await Promise.all(
217
+ sources.map(async (source, index) => {
218
+ try {
219
+ const sourceTotal = totalCounts[index] ?? 0;
220
+ const fetchLimit = Math.max(sourceTotal, 1000);
221
+ return await source.fetch(fetchLimit, 0);
222
+ } catch (error) {
223
+ logger.error('Failed to fetch all data from source', { error, sourceIndex: index });
224
+ return [];
225
+ }
226
+ })
227
+ ).then((arrays) => arrays.flat());
228
+
229
+ return {
230
+ total,
231
+ data: allData.sort(orderBy),
232
+ paging: {
233
+ page,
234
+ pageSize: 0,
235
+ totalPages: 1,
236
+ },
237
+ };
238
+ }
239
+
240
+ // Fast path: Single data source optimization
241
+ if (sources.length === 1) {
242
+ const source = sources[0];
243
+ if (!source) {
244
+ return {
245
+ total: 0,
246
+ data: [],
247
+ paging: {
248
+ page,
249
+ pageSize,
250
+ totalPages: 0,
251
+ },
252
+ };
253
+ }
254
+
255
+ try {
256
+ // For single source, we need to get enough data to sort correctly
257
+ // We can't just fetch the page directly because sorting might change the order
258
+ const sourceTotal = totalCounts[0] ?? 0;
259
+
260
+ // For database sources, we can try to be smart about fetching
261
+ if (source.meta?.type === 'database') {
262
+ // For database sources, assume they can handle sorting at the database level
263
+ // and fetch the exact page we need
264
+ const data = await source.fetch(pageSize, offset);
265
+ return {
266
+ total,
267
+ data,
268
+ paging: {
269
+ page,
270
+ pageSize,
271
+ totalPages: Math.ceil(total / pageSize),
272
+ },
273
+ };
274
+ }
275
+ // For other sources (cached, computed), fetch all data and sort
276
+ const allData = await source.fetch(sourceTotal, 0);
277
+ const sortedData = allData.sort(orderBy);
278
+ const pageData = sortedData.slice(offset, offset + pageSize);
279
+
280
+ return {
281
+ total,
282
+ data: pageData,
283
+ paging: {
284
+ page,
285
+ pageSize,
286
+ totalPages: Math.ceil(total / pageSize),
287
+ },
288
+ };
289
+ } catch (error) {
290
+ logger.error('Failed to fetch data from single source', { error });
291
+ return {
292
+ total: 0,
293
+ data: [],
294
+ paging: {
295
+ page,
296
+ pageSize,
297
+ totalPages: 0,
298
+ },
299
+ };
300
+ }
301
+ }
302
+
303
+ // Calculate optimal fetch strategy for each source
304
+ const fetchStrategies = calculateFetchStrategy(sources, { page, pageSize }, totalCounts);
305
+
306
+ // Fetch data from all sources
307
+ const dataArrays = await Promise.all(
308
+ sources.map(async (source, index) => {
309
+ try {
310
+ const strategy = fetchStrategies[index];
311
+ if (!strategy) {
312
+ logger.warn('No fetch strategy found for source', { sourceIndex: index });
313
+ return [];
314
+ }
315
+
316
+ const data = await source.fetch(strategy.fetchLimit, strategy.fetchOffset);
317
+
318
+ // Only sort non-database sources (database sources are assumed to be pre-sorted)
319
+ if (source.meta?.type === 'database') {
320
+ // Database sources are assumed to handle sorting at the database level
321
+ return data;
322
+ }
323
+ // Non-database sources need to be sorted in memory
324
+ return data.sort(orderBy);
325
+ } catch (error) {
326
+ logger.error('Failed to fetch data from source', { error, sourceIndex: index });
327
+ return [];
328
+ }
329
+ })
330
+ );
331
+
332
+ // Merge all sorted arrays efficiently
333
+ const paged = mergeSortedArrays(dataArrays, orderBy, offset, pageSize);
334
+
335
+ return {
336
+ total,
337
+ data: paged,
338
+ paging: {
339
+ page,
340
+ pageSize,
341
+ totalPages: Math.ceil(total / pageSize),
342
+ },
343
+ };
344
+ } catch (error) {
345
+ logger.error('Failed to merge paginate data', { error, options });
346
+ return {
347
+ total: 0,
348
+ data: [],
349
+ paging: {
350
+ page,
351
+ pageSize,
352
+ totalPages: 0,
353
+ },
354
+ };
355
+ }
356
+ }
357
+
358
+ /**
359
+ * Creates a time-based sorting function with optional secondary sort
360
+ * Extended to support custom secondary field and maintains backward compatibility
361
+ *
362
+ * @param order - Sort direction ('asc' or 'desc')
363
+ * @param secondaryField - Secondary sort field (defaults to 'id')
364
+ * @returns Sorting function that compares timestamps and secondary field
365
+ */
366
+ export function defaultTimeOrderBy(
367
+ order: 'asc' | 'desc' = 'desc', // Keep original default
368
+ secondaryField: string = 'id'
369
+ ): (a: any, b: any) => number {
370
+ return (a, b) => {
371
+ const dateA = new Date(a.created_at).getTime();
372
+ const dateB = new Date(b.created_at).getTime();
373
+
374
+ // First compare by timestamp
375
+ if (dateA !== dateB) {
376
+ return order === 'asc' ? dateA - dateB : dateB - dateA;
377
+ }
378
+
379
+ // If timestamps are equal, compare by secondary field
380
+ const aValue = a[secondaryField];
381
+ const bValue = b[secondaryField];
382
+
383
+ // If secondary field is undefined or not comparable, use id as fallback
384
+ if (aValue === undefined || bValue === undefined || typeof aValue !== typeof bValue) {
385
+ // Use ID as secondary sort key when timestamps are equal - original behavior
386
+ if (a.id && b.id) {
387
+ return order === 'asc' ? a.id.localeCompare(b.id) : b.id.localeCompare(a.id);
388
+ }
389
+ return 0;
390
+ }
391
+
392
+ // Compare based on field type
393
+ if (typeof aValue === 'string') {
394
+ return order === 'asc' ? aValue.localeCompare(bValue) : bValue.localeCompare(aValue);
395
+ }
396
+ if (typeof aValue === 'number') {
397
+ return order === 'asc' ? aValue - bValue : bValue - aValue;
398
+ }
399
+
400
+ // For unsupported types, fallback to id comparison
401
+ if (a.id && b.id) {
402
+ return order === 'asc' ? a.id.localeCompare(b.id) : b.id.localeCompare(a.id);
403
+ }
404
+ return 0;
405
+ };
406
+ }
@@ -904,7 +904,7 @@ export async function checkRemainingStake(
904
904
  }
905
905
 
906
906
  return {
907
- enough: total.gte(new BN(amount)),
907
+ enough: total.gte(new BN(amount || '0')),
908
908
  staked,
909
909
  revoked,
910
910
  };
@@ -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
+ }