payment-kit 1.25.6 → 1.25.8
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/credit-grant.ts +133 -0
- package/api/src/routes/credit-grants.ts +57 -4
- package/api/src/routes/subscriptions.ts +161 -0
- package/api/src/store/migrations/20260129-add-grantor-did-index.ts +52 -0
- package/api/tests/libs/credit-grant.spec.ts +184 -0
- package/blocklet.yml +1 -1
- package/package.json +6 -6
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { BN } from '@ocap/util';
|
|
2
|
+
import { Op } from 'sequelize';
|
|
2
3
|
|
|
3
4
|
import { CreditGrant, Customer, PaymentCurrency, Subscription } from '../store/models';
|
|
4
5
|
import { formatMetadata } from './util';
|
|
@@ -145,3 +146,135 @@ export function calculateExpiresAt(validDurationValue: number, validDurationUnit
|
|
|
145
146
|
|
|
146
147
|
return expiresAt.unix();
|
|
147
148
|
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Get credit grant statistics with flexible filtering
|
|
152
|
+
*/
|
|
153
|
+
export async function getCreditGrantStats(params: {
|
|
154
|
+
grantedBy?: string;
|
|
155
|
+
category?: 'paid' | 'promotional';
|
|
156
|
+
currencyId: string;
|
|
157
|
+
startDate: number;
|
|
158
|
+
endDate: number;
|
|
159
|
+
timezoneOffset?: number;
|
|
160
|
+
}) {
|
|
161
|
+
const { grantedBy, category, currencyId, startDate, endDate, timezoneOffset } = params;
|
|
162
|
+
const offset = typeof timezoneOffset === 'number' ? timezoneOffset : 0;
|
|
163
|
+
|
|
164
|
+
// Fetch currency once at the start (since currencyId is required, results will only contain this currency)
|
|
165
|
+
const currency = await PaymentCurrency.findByPk(currencyId, {
|
|
166
|
+
attributes: ['id', 'name', 'symbol', 'decimal'],
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
if (!currency) {
|
|
170
|
+
return {
|
|
171
|
+
stats: {
|
|
172
|
+
currency_id: currencyId,
|
|
173
|
+
currency: null,
|
|
174
|
+
grant_count: 0,
|
|
175
|
+
total_granted: '0',
|
|
176
|
+
total_remaining: '0',
|
|
177
|
+
total_consumed: '0',
|
|
178
|
+
},
|
|
179
|
+
daily_stats: [],
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const currencyJson = currency.toJSON();
|
|
184
|
+
|
|
185
|
+
// Build where clause
|
|
186
|
+
const where: any = {
|
|
187
|
+
currency_id: currencyId,
|
|
188
|
+
created_at: {
|
|
189
|
+
[Op.gte]: new Date(startDate * 1000),
|
|
190
|
+
[Op.lte]: new Date(endDate * 1000),
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
if (grantedBy) {
|
|
195
|
+
where['metadata.granted_by'] = grantedBy;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (category) {
|
|
199
|
+
where.category = category;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const grants = (await CreditGrant.findAll({
|
|
203
|
+
where,
|
|
204
|
+
attributes: ['amount', 'remaining_amount', 'created_at'],
|
|
205
|
+
raw: true,
|
|
206
|
+
})) as any[];
|
|
207
|
+
|
|
208
|
+
const dailyMap = new Map<
|
|
209
|
+
string,
|
|
210
|
+
{
|
|
211
|
+
date: string;
|
|
212
|
+
grant_count: number;
|
|
213
|
+
total_granted: BN;
|
|
214
|
+
total_remaining: BN;
|
|
215
|
+
}
|
|
216
|
+
>();
|
|
217
|
+
|
|
218
|
+
const aggregate = {
|
|
219
|
+
grant_count: 0,
|
|
220
|
+
total_granted: new BN(0),
|
|
221
|
+
total_remaining: new BN(0),
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
grants.forEach((grant) => {
|
|
225
|
+
const date = dayjs.utc(grant.created_at).utcOffset(offset).format('YYYY-MM-DD');
|
|
226
|
+
const amount = grant.amount || '0';
|
|
227
|
+
const remainingAmount = grant.remaining_amount || '0';
|
|
228
|
+
if (!dailyMap.has(date)) {
|
|
229
|
+
dailyMap.set(date, {
|
|
230
|
+
date,
|
|
231
|
+
grant_count: 0,
|
|
232
|
+
total_granted: new BN(0),
|
|
233
|
+
total_remaining: new BN(0),
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const daily = dailyMap.get(date)!;
|
|
238
|
+
daily.grant_count += 1;
|
|
239
|
+
daily.total_granted = daily.total_granted.add(new BN(amount));
|
|
240
|
+
daily.total_remaining = daily.total_remaining.add(new BN(remainingAmount));
|
|
241
|
+
|
|
242
|
+
aggregate.grant_count += 1;
|
|
243
|
+
aggregate.total_granted = aggregate.total_granted.add(new BN(amount));
|
|
244
|
+
aggregate.total_remaining = aggregate.total_remaining.add(new BN(remainingAmount));
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
const dailyStats = Array.from(dailyMap.values())
|
|
248
|
+
.sort((a, b) => a.date.localeCompare(b.date))
|
|
249
|
+
.map((day) => {
|
|
250
|
+
const totalGranted = day.total_granted.toString();
|
|
251
|
+
const totalRemaining = day.total_remaining.toString();
|
|
252
|
+
const totalConsumed = day.total_granted.sub(day.total_remaining).toString();
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
date: day.date,
|
|
256
|
+
currency_id: currencyId,
|
|
257
|
+
grant_count: day.grant_count,
|
|
258
|
+
total_granted: totalGranted,
|
|
259
|
+
total_remaining: totalRemaining,
|
|
260
|
+
total_consumed: totalConsumed,
|
|
261
|
+
};
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
const totalGranted = aggregate.total_granted.toString();
|
|
265
|
+
const totalRemaining = aggregate.total_remaining.toString();
|
|
266
|
+
const totalConsumed = aggregate.total_granted.sub(aggregate.total_remaining).toString();
|
|
267
|
+
const statsWithConsumed = {
|
|
268
|
+
currency_id: currencyId,
|
|
269
|
+
currency: currencyJson,
|
|
270
|
+
grant_count: aggregate.grant_count,
|
|
271
|
+
total_granted: totalGranted,
|
|
272
|
+
total_remaining: totalRemaining,
|
|
273
|
+
total_consumed: totalConsumed,
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
stats: statsWithConsumed,
|
|
278
|
+
daily_stats: dailyStats,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
Product,
|
|
21
21
|
Subscription,
|
|
22
22
|
} from '../store/models';
|
|
23
|
-
import { createCreditGrant } from '../libs/credit-grant';
|
|
23
|
+
import { createCreditGrant, getCreditGrantStats } from '../libs/credit-grant';
|
|
24
24
|
import { expireGrant } from '../queues/credit-grant';
|
|
25
25
|
import { getMeterPriceIdsFromSubscription } from '../libs/subscription';
|
|
26
26
|
import { blocklet } from '../libs/auth';
|
|
@@ -48,7 +48,7 @@ const authPortal = authenticate<CreditGrant>({
|
|
|
48
48
|
const creditGrantSchema = Joi.object({
|
|
49
49
|
amount: Joi.number().required(),
|
|
50
50
|
currency_id: Joi.string().max(15).optional(),
|
|
51
|
-
customer_id: Joi.string().max(
|
|
51
|
+
customer_id: Joi.string().max(45).required(),
|
|
52
52
|
name: Joi.string().max(255).optional(),
|
|
53
53
|
category: Joi.string().valid('paid', 'promotional').required(),
|
|
54
54
|
priority: Joi.number().integer().min(0).max(100).default(50),
|
|
@@ -661,6 +661,59 @@ router.get('/verify-availability', authMine, async (req, res) => {
|
|
|
661
661
|
}
|
|
662
662
|
});
|
|
663
663
|
|
|
664
|
+
// Schema for stats endpoint
|
|
665
|
+
const statsSchema = Joi.object({
|
|
666
|
+
// Credit granted by did
|
|
667
|
+
// The did that grants the credit is not necessarily the component that send the request.
|
|
668
|
+
granted_by: Joi.string().optional(),
|
|
669
|
+
category: Joi.string().valid('paid', 'promotional').optional(),
|
|
670
|
+
currency_id: Joi.string().required(),
|
|
671
|
+
start_date: Joi.number().integer().required(),
|
|
672
|
+
end_date: Joi.number().integer().required(),
|
|
673
|
+
timezone_offset: Joi.number()
|
|
674
|
+
.integer()
|
|
675
|
+
.min(-12 * 60)
|
|
676
|
+
.max(14 * 60)
|
|
677
|
+
.optional(),
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
// Get credit grant statistics with flexible filtering
|
|
681
|
+
router.get('/stats', auth, async (req, res) => {
|
|
682
|
+
try {
|
|
683
|
+
const { error, value } = statsSchema.validate(req.query, { stripUnknown: true });
|
|
684
|
+
if (error) {
|
|
685
|
+
return res.status(400).json({ error: error.message });
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
const {
|
|
689
|
+
granted_by: grantedBy,
|
|
690
|
+
category,
|
|
691
|
+
currency_id: currencyId,
|
|
692
|
+
start_date: startDate,
|
|
693
|
+
end_date: endDate,
|
|
694
|
+
timezone_offset: timezoneOffset,
|
|
695
|
+
} = value;
|
|
696
|
+
|
|
697
|
+
if (startDate > endDate) {
|
|
698
|
+
return res.status(400).json({ error: 'start_date must be less than or equal to end_date' });
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const result = await getCreditGrantStats({
|
|
702
|
+
grantedBy,
|
|
703
|
+
category,
|
|
704
|
+
currencyId,
|
|
705
|
+
startDate,
|
|
706
|
+
endDate,
|
|
707
|
+
timezoneOffset,
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
return res.json(result);
|
|
711
|
+
} catch (err: any) {
|
|
712
|
+
logger.error('Error getting credit grant stats', { error: err.message, query: req.query });
|
|
713
|
+
return res.status(400).json({ error: err.message });
|
|
714
|
+
}
|
|
715
|
+
});
|
|
716
|
+
|
|
664
717
|
router.get('/:id', authPortal, async (req, res) => {
|
|
665
718
|
const creditGrant = (await CreditGrant.findByPk(req.params.id, {
|
|
666
719
|
include: [
|
|
@@ -745,7 +798,7 @@ router.post('/', auth, async (req, res) => {
|
|
|
745
798
|
const creditGrant = await createCreditGrant({
|
|
746
799
|
amount: unitAmount,
|
|
747
800
|
currency_id: currencyId,
|
|
748
|
-
customer_id:
|
|
801
|
+
customer_id: customer.id,
|
|
749
802
|
name: req.body.name,
|
|
750
803
|
category: req.body.category,
|
|
751
804
|
priority: req.body.priority,
|
|
@@ -764,7 +817,7 @@ router.post('/', auth, async (req, res) => {
|
|
|
764
817
|
paymentCurrency,
|
|
765
818
|
});
|
|
766
819
|
} catch (err: any) {
|
|
767
|
-
logger.error('create credit grant failed', { error: err
|
|
820
|
+
logger.error('create credit grant failed', { error: err, request: req.body });
|
|
768
821
|
return res.status(400).json({ error: err.message });
|
|
769
822
|
}
|
|
770
823
|
});
|
|
@@ -3142,4 +3142,165 @@ router.post('/:id/update-stripe-payment-method', authPortal, async (req, res) =>
|
|
|
3142
3142
|
}
|
|
3143
3143
|
});
|
|
3144
3144
|
|
|
3145
|
+
/**
|
|
3146
|
+
* Fix subscription after payment method migration from Stripe to non-Stripe
|
|
3147
|
+
* This API is used to fix legacy subscriptions that were migrated but the migration
|
|
3148
|
+
* was incomplete (missing payment_settings/payment_details updates)
|
|
3149
|
+
*
|
|
3150
|
+
* This API will:
|
|
3151
|
+
* 1. Pause the Stripe subscription (if not already paused)
|
|
3152
|
+
* 2. Update payment_settings to use arcblock
|
|
3153
|
+
* 3. Update payment_details to add arcblock info
|
|
3154
|
+
* 4. Recalculate cancel_at if needed
|
|
3155
|
+
*/
|
|
3156
|
+
router.put('/:id/fix-stripe-migration', auth, async (req, res) => {
|
|
3157
|
+
try {
|
|
3158
|
+
const subscription = await Subscription.findByPk(req.params.id);
|
|
3159
|
+
if (!subscription) {
|
|
3160
|
+
return res.status(404).json({ error: 'Subscription not found' });
|
|
3161
|
+
}
|
|
3162
|
+
|
|
3163
|
+
const stripeSubscriptionId = subscription.payment_details?.stripe?.subscription_id;
|
|
3164
|
+
if (!stripeSubscriptionId) {
|
|
3165
|
+
return res.status(400).json({ error: 'Subscription does not have Stripe subscription_id' });
|
|
3166
|
+
}
|
|
3167
|
+
|
|
3168
|
+
// Get customer for payer DID
|
|
3169
|
+
const customer = await Customer.findByPk(subscription.customer_id);
|
|
3170
|
+
if (!customer) {
|
|
3171
|
+
return res.status(404).json({ error: 'Customer not found' });
|
|
3172
|
+
}
|
|
3173
|
+
|
|
3174
|
+
// Find arcblock payment method
|
|
3175
|
+
const arcblockMethod = await PaymentMethod.findOne({
|
|
3176
|
+
where: { type: 'arcblock', livemode: subscription.livemode },
|
|
3177
|
+
});
|
|
3178
|
+
if (!arcblockMethod) {
|
|
3179
|
+
return res.status(400).json({ error: 'ArcBlock payment method not found' });
|
|
3180
|
+
}
|
|
3181
|
+
|
|
3182
|
+
// Find Stripe payment method to pause subscription
|
|
3183
|
+
const stripeMethod = await PaymentMethod.findOne({
|
|
3184
|
+
where: { type: 'stripe', livemode: subscription.livemode },
|
|
3185
|
+
});
|
|
3186
|
+
|
|
3187
|
+
// 1. Pause Stripe subscription if not already paused
|
|
3188
|
+
let stripePaused = false;
|
|
3189
|
+
if (stripeMethod) {
|
|
3190
|
+
try {
|
|
3191
|
+
const client = stripeMethod.getStripeClient();
|
|
3192
|
+
const stripeSubscription = await client.subscriptions.retrieve(stripeSubscriptionId);
|
|
3193
|
+
if (stripeSubscription && !stripeSubscription.pause_collection) {
|
|
3194
|
+
await client.subscriptions.update(stripeSubscriptionId, {
|
|
3195
|
+
pause_collection: { behavior: 'void' },
|
|
3196
|
+
});
|
|
3197
|
+
stripePaused = true;
|
|
3198
|
+
logger.info('Stripe subscription paused during fix', {
|
|
3199
|
+
subscriptionId: subscription.id,
|
|
3200
|
+
stripeSubscriptionId,
|
|
3201
|
+
});
|
|
3202
|
+
} else if (stripeSubscription?.pause_collection) {
|
|
3203
|
+
logger.info('Stripe subscription already paused', {
|
|
3204
|
+
subscriptionId: subscription.id,
|
|
3205
|
+
stripeSubscriptionId,
|
|
3206
|
+
});
|
|
3207
|
+
}
|
|
3208
|
+
} catch (stripeErr) {
|
|
3209
|
+
logger.warn('Failed to pause Stripe subscription during fix, continuing anyway', {
|
|
3210
|
+
subscriptionId: subscription.id,
|
|
3211
|
+
stripeSubscriptionId,
|
|
3212
|
+
error: stripeErr,
|
|
3213
|
+
});
|
|
3214
|
+
}
|
|
3215
|
+
}
|
|
3216
|
+
|
|
3217
|
+
const updates: Partial<TSubscription> = {};
|
|
3218
|
+
|
|
3219
|
+
// 2. Update payment_settings to use arcblock (matching change-payment behavior)
|
|
3220
|
+
updates.payment_settings = {
|
|
3221
|
+
payment_method_types: ['arcblock'],
|
|
3222
|
+
payment_method_options: {
|
|
3223
|
+
arcblock: { payer: customer.did },
|
|
3224
|
+
},
|
|
3225
|
+
};
|
|
3226
|
+
|
|
3227
|
+
// 3. Update payment_details.arcblock.payer if not already present
|
|
3228
|
+
if (!subscription.payment_details?.arcblock?.payer) {
|
|
3229
|
+
const existingArcblock = subscription.payment_details?.arcblock;
|
|
3230
|
+
updates.payment_details = {
|
|
3231
|
+
...subscription.payment_details,
|
|
3232
|
+
arcblock: {
|
|
3233
|
+
...existingArcblock,
|
|
3234
|
+
payer: customer.did,
|
|
3235
|
+
type: existingArcblock?.type || 'delegate',
|
|
3236
|
+
tx_hash: existingArcblock?.tx_hash || '',
|
|
3237
|
+
},
|
|
3238
|
+
};
|
|
3239
|
+
}
|
|
3240
|
+
|
|
3241
|
+
// 4. Update default_payment_method_id to arcblock
|
|
3242
|
+
updates.default_payment_method_id = arcblockMethod.id;
|
|
3243
|
+
|
|
3244
|
+
// 5. Recalculate cancel_at if subscription has cancelation_details but no cancel_at
|
|
3245
|
+
if (req.body.recalculate_cancel_at !== false) {
|
|
3246
|
+
if (subscription.cancelation_details && !subscription.cancel_at) {
|
|
3247
|
+
const daysUntilCancel = subscription.days_until_cancel || 0;
|
|
3248
|
+
if (daysUntilCancel > 0) {
|
|
3249
|
+
const dueUnit = 24 * 60 * 60; // 1 day in seconds
|
|
3250
|
+
updates.cancel_at = subscription.current_period_start + daysUntilCancel * dueUnit;
|
|
3251
|
+
} else {
|
|
3252
|
+
updates.cancel_at_period_end = true;
|
|
3253
|
+
updates.cancel_at = subscription.current_period_end;
|
|
3254
|
+
}
|
|
3255
|
+
}
|
|
3256
|
+
}
|
|
3257
|
+
|
|
3258
|
+
// Allow manual override of cancel_at
|
|
3259
|
+
if (typeof req.body.cancel_at === 'number') {
|
|
3260
|
+
updates.cancel_at = req.body.cancel_at;
|
|
3261
|
+
}
|
|
3262
|
+
|
|
3263
|
+
await subscription.update(updates);
|
|
3264
|
+
|
|
3265
|
+
logger.info('Subscription stripe migration fixed', {
|
|
3266
|
+
subscriptionId: subscription.id,
|
|
3267
|
+
customerDid: customer.did,
|
|
3268
|
+
updates: {
|
|
3269
|
+
...updates,
|
|
3270
|
+
payment_details: '(updated)',
|
|
3271
|
+
},
|
|
3272
|
+
stripePaused,
|
|
3273
|
+
stripeSubscriptionId,
|
|
3274
|
+
});
|
|
3275
|
+
|
|
3276
|
+
// Reload subscription to return updated data
|
|
3277
|
+
await subscription.reload();
|
|
3278
|
+
|
|
3279
|
+
return res.json({
|
|
3280
|
+
success: true,
|
|
3281
|
+
stripePaused,
|
|
3282
|
+
subscription: pick(subscription, [
|
|
3283
|
+
'id',
|
|
3284
|
+
'status',
|
|
3285
|
+
'cancel_at',
|
|
3286
|
+
'cancel_at_period_end',
|
|
3287
|
+
'canceled_at',
|
|
3288
|
+
'cancelation_details',
|
|
3289
|
+
'payment_details',
|
|
3290
|
+
'payment_settings',
|
|
3291
|
+
'default_payment_method_id',
|
|
3292
|
+
'days_until_cancel',
|
|
3293
|
+
'current_period_start',
|
|
3294
|
+
'current_period_end',
|
|
3295
|
+
]),
|
|
3296
|
+
});
|
|
3297
|
+
} catch (err) {
|
|
3298
|
+
logger.error('Failed to fix subscription stripe migration', {
|
|
3299
|
+
error: err,
|
|
3300
|
+
subscriptionId: req.params.id,
|
|
3301
|
+
});
|
|
3302
|
+
return res.status(400).json({ error: err.message });
|
|
3303
|
+
}
|
|
3304
|
+
});
|
|
3305
|
+
|
|
3145
3306
|
export default router;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { QueryInterface } from 'sequelize';
|
|
2
|
+
import type { Migration } from '../migrate';
|
|
3
|
+
|
|
4
|
+
const indexExists = async (table: string, indexName: string, queryInterface: QueryInterface) => {
|
|
5
|
+
const indexes = await queryInterface.showIndex(table);
|
|
6
|
+
return indexes && Array.isArray(indexes) && indexes.some((index: { name: string }) => index.name === indexName);
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const createIndexIfNotExists = async (
|
|
10
|
+
queryInterface: QueryInterface,
|
|
11
|
+
table: string,
|
|
12
|
+
indexName: string,
|
|
13
|
+
rawSQL: string
|
|
14
|
+
) => {
|
|
15
|
+
if (await indexExists(table, indexName, queryInterface)) {
|
|
16
|
+
/* eslint-disable no-console */
|
|
17
|
+
console.log(`Index ${indexName} already exists on ${table}, skipping...`);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
await queryInterface.sequelize.query(rawSQL);
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const up: Migration = async ({ context: queryInterface }) => {
|
|
24
|
+
try {
|
|
25
|
+
await createIndexIfNotExists(
|
|
26
|
+
queryInterface,
|
|
27
|
+
'credit_grants',
|
|
28
|
+
'idx_credit_grant_stats_by_grantor',
|
|
29
|
+
"CREATE INDEX idx_credit_grant_stats_by_grantor ON credit_grants(json_extract(metadata, '$.granted_by'), currency_id, created_at) WHERE json_extract(metadata, '$.granted_by') IS NOT NULL"
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
console.log(
|
|
33
|
+
'Successfully created partial index on metadata.granted_by, currency_id, created_at (WHERE granted_by IS NOT NULL)'
|
|
34
|
+
);
|
|
35
|
+
} catch (error) {
|
|
36
|
+
console.error('Failed to create granted_by index', error);
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const down: Migration = async ({ context: queryInterface }) => {
|
|
42
|
+
try {
|
|
43
|
+
const indexName = 'idx_credit_grant_stats_by_grantor';
|
|
44
|
+
if (await indexExists('credit_grants', indexName, queryInterface)) {
|
|
45
|
+
await queryInterface.removeIndex('credit_grants', indexName);
|
|
46
|
+
console.log(`Successfully removed index ${indexName}`);
|
|
47
|
+
}
|
|
48
|
+
} catch (error) {
|
|
49
|
+
console.error('Failed to remove granted_by index', error);
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
};
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { Op } from 'sequelize';
|
|
2
|
+
|
|
3
|
+
import { getCreditGrantStats } from '../../src/libs/credit-grant';
|
|
4
|
+
import { CreditGrant, PaymentCurrency } from '../../src/store/models';
|
|
5
|
+
|
|
6
|
+
jest.mock('../../src/libs/logger', () => ({
|
|
7
|
+
__esModule: true,
|
|
8
|
+
default: {
|
|
9
|
+
info: jest.fn(),
|
|
10
|
+
warn: jest.fn(),
|
|
11
|
+
error: jest.fn(),
|
|
12
|
+
debug: jest.fn(),
|
|
13
|
+
},
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
jest.mock('../../src/libs/subscription', () => ({
|
|
17
|
+
getMeterPriceIdsFromSubscription: jest.fn(),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
jest.mock('../../src/store/models', () => ({
|
|
21
|
+
CreditGrant: {
|
|
22
|
+
findAll: jest.fn(),
|
|
23
|
+
},
|
|
24
|
+
PaymentCurrency: {
|
|
25
|
+
findByPk: jest.fn(),
|
|
26
|
+
},
|
|
27
|
+
Customer: {
|
|
28
|
+
findByPk: jest.fn(),
|
|
29
|
+
},
|
|
30
|
+
Subscription: {
|
|
31
|
+
findByPk: jest.fn(),
|
|
32
|
+
},
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
describe('libs/credit-grant.ts', () => {
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
jest.clearAllMocks();
|
|
38
|
+
jest.restoreAllMocks();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('aggregates daily stats and totals', async () => {
|
|
42
|
+
const currencyJson = { id: 'cur_1', name: 'USD', symbol: '$', decimal: 2 };
|
|
43
|
+
(PaymentCurrency.findByPk as jest.Mock).mockResolvedValue({
|
|
44
|
+
...currencyJson,
|
|
45
|
+
toJSON: () => currencyJson,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
(CreditGrant.findAll as jest.Mock).mockResolvedValue([
|
|
49
|
+
{
|
|
50
|
+
amount: '100',
|
|
51
|
+
remaining_amount: '40',
|
|
52
|
+
created_at: new Date('2024-01-01T01:00:00Z'),
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
amount: '200',
|
|
56
|
+
remaining_amount: '200',
|
|
57
|
+
created_at: new Date('2024-01-01T10:00:00Z'),
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
amount: '50',
|
|
61
|
+
remaining_amount: '10',
|
|
62
|
+
created_at: new Date('2024-01-02T05:00:00Z'),
|
|
63
|
+
},
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
const result = await getCreditGrantStats({
|
|
67
|
+
currencyId: 'cur_1',
|
|
68
|
+
startDate: 1704067200,
|
|
69
|
+
endDate: 1704240000,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
expect(PaymentCurrency.findByPk).toHaveBeenCalledWith('cur_1', {
|
|
73
|
+
attributes: ['id', 'name', 'symbol', 'decimal'],
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const callArg = (CreditGrant.findAll as jest.Mock).mock.calls[0][0];
|
|
77
|
+
expect(callArg.attributes).toEqual(['amount', 'remaining_amount', 'created_at']);
|
|
78
|
+
expect(callArg.raw).toBe(true);
|
|
79
|
+
|
|
80
|
+
expect(result.stats).toEqual({
|
|
81
|
+
currency_id: 'cur_1',
|
|
82
|
+
currency: currencyJson,
|
|
83
|
+
grant_count: 3,
|
|
84
|
+
total_granted: '350',
|
|
85
|
+
total_remaining: '250',
|
|
86
|
+
total_consumed: '100',
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
expect(result.daily_stats).toEqual([
|
|
90
|
+
{
|
|
91
|
+
date: '2024-01-01',
|
|
92
|
+
currency_id: 'cur_1',
|
|
93
|
+
grant_count: 2,
|
|
94
|
+
total_granted: '300',
|
|
95
|
+
total_remaining: '240',
|
|
96
|
+
total_consumed: '60',
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
date: '2024-01-02',
|
|
100
|
+
currency_id: 'cur_1',
|
|
101
|
+
grant_count: 1,
|
|
102
|
+
total_granted: '50',
|
|
103
|
+
total_remaining: '10',
|
|
104
|
+
total_consumed: '40',
|
|
105
|
+
},
|
|
106
|
+
]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('groups grants by day using timezone offset', async () => {
|
|
110
|
+
const currencyJson = { id: 'cur_2', name: 'USD', symbol: '$', decimal: 2 };
|
|
111
|
+
(PaymentCurrency.findByPk as jest.Mock).mockResolvedValue({
|
|
112
|
+
...currencyJson,
|
|
113
|
+
toJSON: () => currencyJson,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
(CreditGrant.findAll as jest.Mock).mockResolvedValue([
|
|
117
|
+
{
|
|
118
|
+
amount: '10',
|
|
119
|
+
remaining_amount: '5',
|
|
120
|
+
created_at: new Date('2024-01-01T23:30:00Z'),
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
amount: '20',
|
|
124
|
+
remaining_amount: '0',
|
|
125
|
+
created_at: new Date('2024-01-02T01:00:00Z'),
|
|
126
|
+
},
|
|
127
|
+
]);
|
|
128
|
+
|
|
129
|
+
const result = await getCreditGrantStats({
|
|
130
|
+
currencyId: 'cur_2',
|
|
131
|
+
startDate: 1704067200,
|
|
132
|
+
endDate: 1704240000,
|
|
133
|
+
timezoneOffset: 480,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
expect(result.daily_stats).toEqual([
|
|
137
|
+
{
|
|
138
|
+
date: '2024-01-02',
|
|
139
|
+
currency_id: 'cur_2',
|
|
140
|
+
grant_count: 2,
|
|
141
|
+
total_granted: '30',
|
|
142
|
+
total_remaining: '5',
|
|
143
|
+
total_consumed: '25',
|
|
144
|
+
},
|
|
145
|
+
]);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('builds where clause with grantedBy and category filters', async () => {
|
|
149
|
+
const currencyJson = { id: 'cur_3', name: 'USD', symbol: '$', decimal: 2 };
|
|
150
|
+
(PaymentCurrency.findByPk as jest.Mock).mockResolvedValue({
|
|
151
|
+
...currencyJson,
|
|
152
|
+
toJSON: () => currencyJson,
|
|
153
|
+
});
|
|
154
|
+
(CreditGrant.findAll as jest.Mock).mockResolvedValue([]);
|
|
155
|
+
|
|
156
|
+
const startDate = 1704067200;
|
|
157
|
+
const endDate = 1704153600;
|
|
158
|
+
|
|
159
|
+
const result = await getCreditGrantStats({
|
|
160
|
+
currencyId: 'cur_3',
|
|
161
|
+
startDate,
|
|
162
|
+
endDate,
|
|
163
|
+
grantedBy: 'did:example:123',
|
|
164
|
+
category: 'paid',
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const callArg = (CreditGrant.findAll as jest.Mock).mock.calls[0][0];
|
|
168
|
+
expect(callArg.where.currency_id).toBe('cur_3');
|
|
169
|
+
expect(callArg.where['metadata.granted_by']).toBe('did:example:123');
|
|
170
|
+
expect(callArg.where.category).toBe('paid');
|
|
171
|
+
expect(callArg.where.created_at[Op.gte]).toEqual(new Date(startDate * 1000));
|
|
172
|
+
expect(callArg.where.created_at[Op.lte]).toEqual(new Date(endDate * 1000));
|
|
173
|
+
|
|
174
|
+
expect(result.stats).toEqual({
|
|
175
|
+
currency_id: 'cur_3',
|
|
176
|
+
currency: currencyJson,
|
|
177
|
+
grant_count: 0,
|
|
178
|
+
total_granted: '0',
|
|
179
|
+
total_remaining: '0',
|
|
180
|
+
total_consumed: '0',
|
|
181
|
+
});
|
|
182
|
+
expect(result.daily_stats).toEqual([]);
|
|
183
|
+
});
|
|
184
|
+
});
|
package/blocklet.yml
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "payment-kit",
|
|
3
|
-
"version": "1.25.
|
|
3
|
+
"version": "1.25.8",
|
|
4
4
|
"scripts": {
|
|
5
5
|
"dev": "blocklet dev --open",
|
|
6
6
|
"prelint": "npm run types",
|
|
@@ -59,9 +59,9 @@
|
|
|
59
59
|
"@blocklet/error": "^0.3.5",
|
|
60
60
|
"@blocklet/js-sdk": "^1.17.8-beta-20260104-120132-cb5b1914",
|
|
61
61
|
"@blocklet/logger": "^1.17.8-beta-20260104-120132-cb5b1914",
|
|
62
|
-
"@blocklet/payment-broker-client": "1.25.
|
|
63
|
-
"@blocklet/payment-react": "1.25.
|
|
64
|
-
"@blocklet/payment-vendor": "1.25.
|
|
62
|
+
"@blocklet/payment-broker-client": "1.25.8",
|
|
63
|
+
"@blocklet/payment-react": "1.25.8",
|
|
64
|
+
"@blocklet/payment-vendor": "1.25.8",
|
|
65
65
|
"@blocklet/sdk": "^1.17.8-beta-20260104-120132-cb5b1914",
|
|
66
66
|
"@blocklet/ui-react": "^3.4.7",
|
|
67
67
|
"@blocklet/uploader": "^0.3.19",
|
|
@@ -132,7 +132,7 @@
|
|
|
132
132
|
"devDependencies": {
|
|
133
133
|
"@abtnode/types": "^1.17.8-beta-20260104-120132-cb5b1914",
|
|
134
134
|
"@arcblock/eslint-config-ts": "^0.3.3",
|
|
135
|
-
"@blocklet/payment-types": "1.25.
|
|
135
|
+
"@blocklet/payment-types": "1.25.8",
|
|
136
136
|
"@types/cookie-parser": "^1.4.9",
|
|
137
137
|
"@types/cors": "^2.8.19",
|
|
138
138
|
"@types/debug": "^4.1.12",
|
|
@@ -179,5 +179,5 @@
|
|
|
179
179
|
"parser": "typescript"
|
|
180
180
|
}
|
|
181
181
|
},
|
|
182
|
-
"gitHead": "
|
|
182
|
+
"gitHead": "b40c46bcf52d4c1758bb10eb4c4583355960e18f"
|
|
183
183
|
}
|