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 +2 -0
- package/api/src/libs/pagination.ts +406 -0
- package/api/src/libs/subscription.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/invoices.ts +295 -103
- package/api/src/routes/subscriptions.ts +90 -2
- package/api/tests/libs/pagination.spec.ts +576 -0
- package/blocklet.yml +1 -1
- package/package.json +10 -10
- package/scripts/sdk.js +7 -0
- package/src/components/subscription/portal/actions.tsx +39 -57
- package/src/components/subscription/portal/list.tsx +36 -26
- package/src/libs/util.ts +14 -0
- package/src/pages/admin/billing/subscriptions/detail.tsx +2 -10
- package/src/pages/customer/invoice/past-due.tsx +1 -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';
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|