payment-kit 1.18.45 → 1.18.47
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/libs/pagination.ts +403 -0
- package/api/src/routes/invoices.ts +295 -103
- package/api/tests/libs/pagination.spec.ts +549 -0
- package/blocklet.yml +1 -1
- package/package.json +10 -10
- package/src/components/subscription/portal/list.tsx +36 -26
- package/src/libs/util.ts +11 -0
- package/src/pages/admin/billing/subscriptions/detail.tsx +2 -10
- package/src/pages/customer/invoice/past-due.tsx +1 -0
|
@@ -23,6 +23,7 @@ import { Product } from '../store/models/product';
|
|
|
23
23
|
import { Subscription } from '../store/models/subscription';
|
|
24
24
|
import { getReturnStakeInvoices, getStakingInvoices, retryUncollectibleInvoices } from '../libs/invoice';
|
|
25
25
|
import { CheckoutSession, PaymentLink, TInvoiceExpanded } from '../store/models';
|
|
26
|
+
import { mergePaginate, defaultTimeOrderBy, getCachedOrFetch, DataSource } from '../libs/pagination';
|
|
26
27
|
import logger from '../libs/logger';
|
|
27
28
|
|
|
28
29
|
const router = Router();
|
|
@@ -38,6 +39,99 @@ const authPortal = authenticate<Invoice>({
|
|
|
38
39
|
},
|
|
39
40
|
});
|
|
40
41
|
|
|
42
|
+
// Helper function: Get staking invoices by subscription ID with error handling
|
|
43
|
+
async function getStakingInvoicesById(subscriptionId: string) {
|
|
44
|
+
try {
|
|
45
|
+
const subscription = await Subscription.findByPk(subscriptionId);
|
|
46
|
+
if (!subscription) return [];
|
|
47
|
+
return await getStakingInvoices(subscription);
|
|
48
|
+
} catch (error) {
|
|
49
|
+
logger.error('Failed to get staking invoices by ID', { error, subscriptionId });
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Helper function: Get return stake invoices by subscription ID with error handling
|
|
55
|
+
async function getReturnStakeInvoicesById(subscriptionId: string) {
|
|
56
|
+
try {
|
|
57
|
+
const subscription = await Subscription.findByPk(subscriptionId);
|
|
58
|
+
if (!subscription) return [];
|
|
59
|
+
return await getReturnStakeInvoices(subscription);
|
|
60
|
+
} catch (error) {
|
|
61
|
+
logger.error('Failed to get return stake invoices by ID', { error, subscriptionId });
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Helper function: Create data sources for recover-from subscriptions (with cache)
|
|
67
|
+
function createRecoverFromSubscriptionSources(
|
|
68
|
+
recoverFromSubscriptionIds: string[],
|
|
69
|
+
include_staking: boolean | undefined,
|
|
70
|
+
include_return_staking: boolean | undefined
|
|
71
|
+
): DataSource<Invoice>[] {
|
|
72
|
+
const sources: DataSource<Invoice>[] = [];
|
|
73
|
+
|
|
74
|
+
for (const id of recoverFromSubscriptionIds) {
|
|
75
|
+
if (include_staking) {
|
|
76
|
+
sources.push({
|
|
77
|
+
count: async () => {
|
|
78
|
+
try {
|
|
79
|
+
const invoices = await getCachedOrFetch(`staking:${id}`, () => getStakingInvoicesById(id));
|
|
80
|
+
return invoices.length;
|
|
81
|
+
} catch (error) {
|
|
82
|
+
logger.error('Failed to count cached staking invoices', { error, subscriptionId: id });
|
|
83
|
+
return 0;
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
fetch: async (limit: number, offset: number = 0) => {
|
|
87
|
+
try {
|
|
88
|
+
const invoices = await getCachedOrFetch(`staking:${id}`, () => getStakingInvoicesById(id));
|
|
89
|
+
return invoices.slice(offset, offset + limit);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
logger.error('Failed to fetch cached staking invoices', { error, subscriptionId: id });
|
|
92
|
+
return [];
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
meta: {
|
|
96
|
+
type: 'cached' as const,
|
|
97
|
+
estimatedSize: 50,
|
|
98
|
+
cacheable: true,
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (include_return_staking) {
|
|
104
|
+
sources.push({
|
|
105
|
+
count: async () => {
|
|
106
|
+
try {
|
|
107
|
+
const invoices = await getCachedOrFetch(`return-staking:${id}`, () => getReturnStakeInvoicesById(id));
|
|
108
|
+
return invoices.length;
|
|
109
|
+
} catch (error) {
|
|
110
|
+
logger.error('Failed to count cached return stake invoices', { error, subscriptionId: id });
|
|
111
|
+
return 0;
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
fetch: async (limit: number, offset: number = 0) => {
|
|
115
|
+
try {
|
|
116
|
+
const invoices = await getCachedOrFetch(`return-staking:${id}`, () => getReturnStakeInvoicesById(id));
|
|
117
|
+
return invoices.slice(offset, offset + limit);
|
|
118
|
+
} catch (error) {
|
|
119
|
+
logger.error('Failed to fetch cached return stake invoices', { error, subscriptionId: id });
|
|
120
|
+
return [];
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
meta: {
|
|
124
|
+
type: 'cached' as const,
|
|
125
|
+
estimatedSize: 50,
|
|
126
|
+
cacheable: true,
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return sources;
|
|
133
|
+
}
|
|
134
|
+
|
|
41
135
|
const schema = createListParamSchema<{
|
|
42
136
|
status?: string;
|
|
43
137
|
customer_id?: string;
|
|
@@ -61,122 +155,220 @@ const schema = createListParamSchema<{
|
|
|
61
155
|
include_overdraft_protection: Joi.boolean().default(true),
|
|
62
156
|
include_recovered_from: Joi.boolean().empty(false),
|
|
63
157
|
});
|
|
158
|
+
|
|
64
159
|
router.get('/', authMine, async (req, res) => {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
160
|
+
try {
|
|
161
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
162
|
+
const {
|
|
163
|
+
page,
|
|
164
|
+
pageSize,
|
|
165
|
+
livemode,
|
|
166
|
+
status,
|
|
167
|
+
ignore_zero,
|
|
168
|
+
include_staking,
|
|
169
|
+
include_return_staking,
|
|
170
|
+
include_overdraft_protection = true,
|
|
171
|
+
...query
|
|
172
|
+
} = await schema.validateAsync(req.query, {
|
|
173
|
+
stripUnknown: false,
|
|
174
|
+
allowUnknown: true,
|
|
175
|
+
});
|
|
81
176
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
.
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
where.total = { [Op.ne]: '0' };
|
|
90
|
-
}
|
|
91
|
-
if (query.customer_id) {
|
|
92
|
-
where.customer_id = query.customer_id;
|
|
93
|
-
}
|
|
94
|
-
if (query.currency_id) {
|
|
95
|
-
where.currency_id = query.currency_id;
|
|
96
|
-
}
|
|
97
|
-
if (query.customer_did && isValid(query.customer_did)) {
|
|
98
|
-
const customer = await Customer.findOne({ where: { did: query.customer_did } });
|
|
99
|
-
if (customer) {
|
|
100
|
-
where.customer_id = customer.id;
|
|
101
|
-
} else {
|
|
102
|
-
res.json({ count: 0, list: [], paging: { page, pageSize } });
|
|
103
|
-
return;
|
|
177
|
+
const where = getWhereFromKvQuery(query.q);
|
|
178
|
+
|
|
179
|
+
if (status) {
|
|
180
|
+
where.status = status
|
|
181
|
+
.split(',')
|
|
182
|
+
.map((x) => x.trim())
|
|
183
|
+
.filter(Boolean);
|
|
104
184
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
where.
|
|
185
|
+
if (ignore_zero) {
|
|
186
|
+
where.total = { [Op.ne]: '0' };
|
|
187
|
+
}
|
|
188
|
+
if (query.customer_id) {
|
|
189
|
+
where.customer_id = query.customer_id;
|
|
190
|
+
}
|
|
191
|
+
if (query.currency_id) {
|
|
192
|
+
where.currency_id = query.currency_id;
|
|
193
|
+
}
|
|
194
|
+
if (query.customer_did && isValid(query.customer_did)) {
|
|
195
|
+
const customer = await Customer.findOne({ where: { did: query.customer_did } });
|
|
196
|
+
if (customer) {
|
|
197
|
+
where.customer_id = customer.id;
|
|
198
|
+
} else {
|
|
199
|
+
res.json({ count: 0, list: [], paging: { page, pageSize } });
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if (query.subscription_id) {
|
|
204
|
+
if (query.include_recovered_from) {
|
|
205
|
+
where.subscription_id = [query.subscription_id].concat(
|
|
206
|
+
await Subscription.getFromSubscriptions(query.subscription_id)
|
|
207
|
+
);
|
|
208
|
+
} else {
|
|
209
|
+
where.subscription_id = query.subscription_id;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (typeof livemode === 'boolean') {
|
|
213
|
+
where.livemode = livemode;
|
|
113
214
|
}
|
|
114
|
-
}
|
|
115
|
-
if (typeof livemode === 'boolean') {
|
|
116
|
-
where.livemode = livemode;
|
|
117
|
-
}
|
|
118
215
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
216
|
+
Object.keys(query)
|
|
217
|
+
.filter((x) => x.startsWith('metadata.'))
|
|
218
|
+
.forEach((key: string) => {
|
|
219
|
+
// @ts-ignore
|
|
220
|
+
where[key] = query[key];
|
|
221
|
+
});
|
|
125
222
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
{
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
223
|
+
const excludeBillingReasons = ['recharge'];
|
|
224
|
+
if (!include_overdraft_protection) {
|
|
225
|
+
excludeBillingReasons.push('stake_overdraft_protection');
|
|
226
|
+
}
|
|
227
|
+
if (!!(include_staking && query.subscription_id) || !include_staking) {
|
|
228
|
+
excludeBillingReasons.push('stake');
|
|
229
|
+
}
|
|
230
|
+
if (excludeBillingReasons.length > 0) {
|
|
231
|
+
where.billing_reason = { [Op.notIn]: excludeBillingReasons };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Build data sources with proper metadata
|
|
235
|
+
const sources: DataSource<Invoice>[] = [];
|
|
236
|
+
|
|
237
|
+
// Primary data source: Invoice database records
|
|
238
|
+
sources.push({
|
|
239
|
+
count: () => {
|
|
240
|
+
return Invoice.count({ where });
|
|
241
|
+
},
|
|
242
|
+
fetch: async (limit: number, offset: number = 0) => {
|
|
243
|
+
const result = await Invoice.findAll({
|
|
244
|
+
where,
|
|
245
|
+
order: getOrder(req.query, [['created_at', query.o === 'asc' ? 'ASC' : 'DESC']]),
|
|
246
|
+
limit,
|
|
247
|
+
offset,
|
|
248
|
+
include: [
|
|
249
|
+
{ model: PaymentCurrency, as: 'paymentCurrency' },
|
|
250
|
+
{ model: PaymentMethod, as: 'paymentMethod' },
|
|
251
|
+
{ model: Subscription, as: 'subscription', attributes: ['id', 'description'] },
|
|
252
|
+
{ model: Customer, as: 'customer' },
|
|
253
|
+
],
|
|
254
|
+
});
|
|
255
|
+
return result;
|
|
256
|
+
},
|
|
257
|
+
meta: {
|
|
258
|
+
type: 'database' as const,
|
|
259
|
+
cacheable: false,
|
|
260
|
+
},
|
|
149
261
|
});
|
|
150
262
|
|
|
151
|
-
//
|
|
152
|
-
let
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
263
|
+
// Handle subscription_id array
|
|
264
|
+
let subscriptionIds: string[] = [];
|
|
265
|
+
if (Array.isArray(where.subscription_id)) {
|
|
266
|
+
subscriptionIds = where.subscription_id;
|
|
267
|
+
} else if (where.subscription_id) {
|
|
268
|
+
subscriptionIds = [where.subscription_id];
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Process active subscription (no cache) - only if staking options are enabled
|
|
272
|
+
const activeSubscriptionId = subscriptionIds[0];
|
|
273
|
+
if (include_staking && activeSubscriptionId) {
|
|
274
|
+
const subscription = await Subscription.findByPk(activeSubscriptionId);
|
|
275
|
+
if (subscription?.payment_details?.arcblock?.staking?.tx_hash) {
|
|
276
|
+
// Add staking invoices source
|
|
277
|
+
if (include_staking) {
|
|
278
|
+
sources.push({
|
|
279
|
+
count: async () => {
|
|
280
|
+
try {
|
|
281
|
+
const invoices = await getStakingInvoices(subscription);
|
|
282
|
+
return invoices.length;
|
|
283
|
+
} catch (error) {
|
|
284
|
+
logger.error('Failed to count staking invoices', { error, subscriptionId: activeSubscriptionId });
|
|
285
|
+
return 0;
|
|
286
|
+
}
|
|
287
|
+
},
|
|
288
|
+
fetch: async (limit: number, offset: number = 0) => {
|
|
289
|
+
try {
|
|
290
|
+
const invoices = await getStakingInvoices(subscription);
|
|
291
|
+
return invoices.slice(offset, offset + limit);
|
|
292
|
+
} catch (error) {
|
|
293
|
+
logger.error('Failed to fetch staking invoices', { error, subscriptionId: activeSubscriptionId });
|
|
294
|
+
return [];
|
|
295
|
+
}
|
|
296
|
+
},
|
|
297
|
+
meta: {
|
|
298
|
+
type: 'computed' as const,
|
|
299
|
+
estimatedSize: 50, // Most staking invoices are small
|
|
300
|
+
cacheable: true,
|
|
301
|
+
},
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Add return stake invoices source
|
|
306
|
+
if (include_return_staking) {
|
|
307
|
+
sources.push({
|
|
308
|
+
count: async () => {
|
|
309
|
+
try {
|
|
310
|
+
const invoices = await getReturnStakeInvoices(subscription);
|
|
311
|
+
return invoices.length;
|
|
312
|
+
} catch (error) {
|
|
313
|
+
logger.error('Failed to count return stake invoices', { error, subscriptionId: activeSubscriptionId });
|
|
314
|
+
return 0;
|
|
315
|
+
}
|
|
316
|
+
},
|
|
317
|
+
fetch: async (limit: number, offset: number = 0) => {
|
|
318
|
+
try {
|
|
319
|
+
const invoices = await getReturnStakeInvoices(subscription);
|
|
320
|
+
return invoices.slice(offset, offset + limit);
|
|
321
|
+
} catch (error) {
|
|
322
|
+
logger.error('Failed to fetch return stake invoices', { error, subscriptionId: activeSubscriptionId });
|
|
323
|
+
return [];
|
|
324
|
+
}
|
|
325
|
+
},
|
|
326
|
+
meta: {
|
|
327
|
+
type: 'computed' as const,
|
|
328
|
+
estimatedSize: 50, // Return stake invoices are usually fewer
|
|
329
|
+
cacheable: true,
|
|
330
|
+
},
|
|
331
|
+
});
|
|
170
332
|
}
|
|
171
|
-
} catch (err) {
|
|
172
|
-
console.error('Failed to include staking record in invoice list', err);
|
|
173
333
|
}
|
|
174
334
|
}
|
|
175
335
|
|
|
176
|
-
|
|
336
|
+
// Process recover-from subscriptions (with cache) - only if staking options are enabled
|
|
337
|
+
const recoverFromSubscriptionIds = subscriptionIds.slice(1);
|
|
338
|
+
if (include_staking && recoverFromSubscriptionIds.length > 0) {
|
|
339
|
+
const recoverFromSources = createRecoverFromSubscriptionSources(
|
|
340
|
+
recoverFromSubscriptionIds,
|
|
341
|
+
include_staking,
|
|
342
|
+
include_return_staking
|
|
343
|
+
);
|
|
344
|
+
sources.push(...recoverFromSources);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Use generic pagination with intelligent strategy
|
|
348
|
+
const result = await mergePaginate(
|
|
349
|
+
sources,
|
|
350
|
+
{ page: Number(page), pageSize: Number(pageSize) },
|
|
351
|
+
defaultTimeOrderBy(query.o === 'asc' ? 'asc' : 'desc')
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
res.json({
|
|
355
|
+
count: result.total,
|
|
356
|
+
list: result.data,
|
|
357
|
+
paging: result.paging,
|
|
358
|
+
});
|
|
177
359
|
} catch (err) {
|
|
178
|
-
logger.error(
|
|
179
|
-
|
|
360
|
+
logger.error('Failed to fetch invoices', {
|
|
361
|
+
error: err,
|
|
362
|
+
page: req.query.page,
|
|
363
|
+
pageSize: req.query.pageSize,
|
|
364
|
+
include_staking: req.query.include_staking,
|
|
365
|
+
subscription_id: req.query.subscription_id,
|
|
366
|
+
});
|
|
367
|
+
res.json({
|
|
368
|
+
count: 0,
|
|
369
|
+
list: [],
|
|
370
|
+
paging: { page: Number(req.query.page || 1), pageSize: Number(req.query.pageSize || 10) },
|
|
371
|
+
});
|
|
180
372
|
}
|
|
181
373
|
});
|
|
182
374
|
|