payment-kit 1.25.7 → 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.
@@ -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(18).required(),
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: req.body.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.message, request: req.body });
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
  });
@@ -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
@@ -14,7 +14,7 @@ repository:
14
14
  type: git
15
15
  url: git+https://github.com/blocklet/payment-kit.git
16
16
  specVersion: 1.2.8
17
- version: 1.25.7
17
+ version: 1.25.8
18
18
  logo: logo.png
19
19
  files:
20
20
  - dist
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "payment-kit",
3
- "version": "1.25.7",
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.7",
63
- "@blocklet/payment-react": "1.25.7",
64
- "@blocklet/payment-vendor": "1.25.7",
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.7",
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": "0aa950a5d3f01a150ec21c66e2efeaf81ee1cc69"
182
+ "gitHead": "b40c46bcf52d4c1758bb10eb4c4583355960e18f"
183
183
  }