Skip to content

Commit 35f5967

Browse files
authored
Merge pull request #118 from Noble-Devbase/feat/budgetservice-analytics-service
budget service
2 parents 7c7e8b3 + 0a08ae5 commit 35f5967

15 files changed

Lines changed: 715 additions & 38 deletions

‎dist/tsconfig.tsbuildinfo‎

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

‎src/analytics-system/analytics-system.controller.ts‎

Lines changed: 0 additions & 9 deletions
This file was deleted.

‎src/analytics-system/analytics-system.module.ts‎

Lines changed: 0 additions & 6 deletions
This file was deleted.

‎src/analytics-system/analytics-system.service.ts‎

Lines changed: 0 additions & 7 deletions
This file was deleted.
Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,57 @@
1-
import { Controller, Get } from '@nestjs/common';
1+
import { Controller, Get, Query, Req, UseGuards } from '@nestjs/common';
22
import { AnalyticsService } from './analytics.service';
3+
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
4+
35
/** Exposes the analytics API surface. */
46
@Controller('analytics')
57
export class AnalyticsController {
68
constructor(private readonly service: AnalyticsService) {}
9+
710
/** Reports module availability for operations and smoke tests. */
8-
@Get('status') status(): { module: string; status: string } { return this.service.status(); }
11+
@Get('status')
12+
status(): { module: string; status: string } {
13+
return this.service.status();
14+
}
15+
16+
/**
17+
* GET /analytics/trend?asset=USDC&weeks=8
18+
* Returns weekly spending buckets for the given asset.
19+
*/
20+
@UseGuards(JwtAuthGuard)
21+
@Get('trend')
22+
async getSpendingTrend(
23+
@Req() req: any,
24+
@Query('asset') asset: string,
25+
@Query('weeks') weeks?: string,
26+
) {
27+
const userId = req.user.sub;
28+
return this.service.getSpendingTrend(userId, asset, weeks ? Number(weeks) : 8);
29+
}
30+
31+
/**
32+
* GET /analytics/category-breakdown?asset=XLM&since=2026-01-01
33+
* Returns per-category spending totals with percentages.
34+
*/
35+
@UseGuards(JwtAuthGuard)
36+
@Get('category-breakdown')
37+
async getCategoryBreakdown(
38+
@Req() req: any,
39+
@Query('asset') asset: string,
40+
@Query('since') since?: string,
41+
) {
42+
const userId = req.user.sub;
43+
const sinceDate = since ? new Date(since) : new Date(0);
44+
return this.service.getCategoryBreakdown(userId, asset, sinceDate);
45+
}
46+
47+
/**
48+
* GET /analytics/budget-vs-actual
49+
* Returns per-budget variance comparing budgeted amounts to actual spend.
50+
*/
51+
@UseGuards(JwtAuthGuard)
52+
@Get('budget-vs-actual')
53+
async getBudgetVsActual(@Req() req: any) {
54+
const userId = req.user.sub;
55+
return this.service.getBudgetVsActual(userId);
56+
}
957
}

‎src/analytics/analytics.module.ts‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,17 @@
11
import { Module } from '@nestjs/common';
2+
import { TypeOrmModule } from '@nestjs/typeorm';
23
import { AnalyticsController } from './analytics.controller';
34
import { AnalyticsService } from './analytics.service';
5+
import { TransactionEntity } from '../transactions/entities/transaction.entity';
6+
import { BudgetEntity } from '../budgets/entities/budget.entity';
7+
48
/** Registers the analytics feature. */
5-
@Module({ controllers: [AnalyticsController], providers: [AnalyticsService], exports: [AnalyticsService] })
9+
@Module({
10+
imports: [
11+
TypeOrmModule.forFeature([TransactionEntity, BudgetEntity]),
12+
],
13+
controllers: [AnalyticsController],
14+
providers: [AnalyticsService],
15+
exports: [AnalyticsService],
16+
})
617
export class AnalyticsModule {}
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { Test, TestingModule } from '@nestjs/testing';
2+
import { getRepositoryToken } from '@nestjs/typeorm';
3+
import { AnalyticsService } from './analytics.service';
4+
import { TransactionEntity } from '../transactions/entities/transaction.entity';
5+
import { BudgetEntity } from '../budgets/entities/budget.entity';
6+
7+
describe('AnalyticsService', () => {
8+
let service: AnalyticsService;
9+
let txRepo: any;
10+
let budgetRepo: any;
11+
12+
const mockQueryBuilder = {
13+
select: jest.fn().mockReturnThis(),
14+
addSelect: jest.fn().mockReturnThis(),
15+
where: jest.fn().mockReturnThis(),
16+
andWhere: jest.fn().mockReturnThis(),
17+
groupBy: jest.fn().mockReturnThis(),
18+
addGroupBy: jest.fn().mockReturnThis(),
19+
orderBy: jest.fn().mockReturnThis(),
20+
leftJoin: jest.fn().mockReturnThis(),
21+
limit: jest.fn().mockReturnThis(),
22+
getRawMany: jest.fn(),
23+
};
24+
25+
beforeEach(async () => {
26+
txRepo = {
27+
createQueryBuilder: jest.fn(() => ({ ...mockQueryBuilder })),
28+
};
29+
budgetRepo = {
30+
createQueryBuilder: jest.fn(() => ({ ...mockQueryBuilder })),
31+
};
32+
33+
const module: TestingModule = await Test.createTestingModule({
34+
providers: [
35+
AnalyticsService,
36+
{ provide: getRepositoryToken(TransactionEntity), useValue: txRepo },
37+
{ provide: getRepositoryToken(BudgetEntity), useValue: budgetRepo },
38+
],
39+
}).compile();
40+
41+
service = module.get<AnalyticsService>(AnalyticsService);
42+
});
43+
44+
afterEach(() => jest.clearAllMocks());
45+
46+
describe('status', () => {
47+
it('returns ready status', () => {
48+
expect(service.status()).toEqual({ module: 'analytics', status: 'ready' });
49+
});
50+
});
51+
52+
describe('getSpendingTrend', () => {
53+
it('returns weekly spending buckets for the given asset', async () => {
54+
const mockRows = [
55+
{ week: '2026-08-18T00:00:00.000Z', total: '150.000000000000' },
56+
{ week: '2026-08-11T00:00:00.000Z', total: '200.500000000000' },
57+
];
58+
const qb = txRepo.createQueryBuilder();
59+
qb.getRawMany.mockResolvedValue(mockRows);
60+
61+
const result = await service.getSpendingTrend('user-1', 'USDC', 2);
62+
63+
expect(result).toEqual(mockRows);
64+
expect(qb.where).toHaveBeenCalledWith('t."userId" = :userId', {
65+
userId: 'user-1',
66+
});
67+
expect(qb.andWhere).toHaveBeenCalledWith('t."asset" = :asset', {
68+
asset: 'USDC',
69+
});
70+
expect(qb.limit).toHaveBeenCalledWith(2);
71+
});
72+
73+
it('defaults to 8 weeks when weeks is not specified', async () => {
74+
const qb = txRepo.createQueryBuilder();
75+
qb.getRawMany.mockResolvedValue([]);
76+
77+
await service.getSpendingTrend('user-1', 'USDC');
78+
79+
expect(qb.limit).toHaveBeenCalledWith(8);
80+
});
81+
});
82+
83+
describe('getCategoryBreakdown', () => {
84+
it('returns category totals with percentage computed in SQL', async () => {
85+
const mockRows = [
86+
{ category: 'groceries', total: '150.00', percentage: '60.000000' },
87+
{ category: 'entertainment', total: '100.00', percentage: '40.000000' },
88+
];
89+
const qb = txRepo.createQueryBuilder();
90+
qb.getRawMany.mockResolvedValue(mockRows);
91+
92+
const result = await service.getCategoryBreakdown(
93+
'user-1',
94+
'USDC',
95+
new Date('2026-01-01'),
96+
);
97+
98+
expect(result).toEqual(mockRows);
99+
expect(result).toHaveLength(2);
100+
});
101+
102+
it('uses the since date filter', async () => {
103+
const qb = txRepo.createQueryBuilder();
104+
qb.getRawMany.mockResolvedValue([]);
105+
106+
const since = new Date('2026-06-01');
107+
await service.getCategoryBreakdown('user-1', 'XLM', since);
108+
109+
expect(qb.andWhere).toHaveBeenCalledWith(
110+
't."createdAt" >= :since',
111+
{ since },
112+
);
113+
});
114+
});
115+
116+
describe('getBudgetVsActual', () => {
117+
it('joins budgets to transactions and computes variance', async () => {
118+
const mockRows = [
119+
{ budgetName: 'Groceries', budgeted: '200.00', actual: '150.00', variance: '-50.00' },
120+
{ budgetName: 'Rent', budgeted: '500.00', actual: '500.00', variance: '0.00' },
121+
];
122+
const qb = budgetRepo.createQueryBuilder();
123+
qb.getRawMany.mockResolvedValue(mockRows);
124+
125+
const result = await service.getBudgetVsActual('user-1');
126+
127+
expect(result).toEqual(mockRows);
128+
expect(result).toHaveLength(2);
129+
expect(result[0].variance).toBe('-50.00');
130+
});
131+
132+
it('returns empty array when user has no budgets', async () => {
133+
const qb = budgetRepo.createQueryBuilder();
134+
qb.getRawMany.mockResolvedValue([]);
135+
136+
const result = await service.getBudgetVsActual('user-no-budgets');
137+
138+
expect(result).toEqual([]);
139+
});
140+
});
141+
});

‎src/analytics/analytics.service.ts‎

Lines changed: 109 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,113 @@
11
import { Injectable } from '@nestjs/common';
2-
/** Provides the analytics application capability. */
2+
import { InjectRepository } from '@nestjs/typeorm';
3+
import { Repository } from 'typeorm';
4+
import { TransactionEntity } from '../transactions/entities/transaction.entity';
5+
import { BudgetEntity } from '../budgets/entities/budget.entity';
6+
7+
export interface SpendingTrendRow {
8+
week: string;
9+
total: string;
10+
}
11+
12+
export interface CategoryBreakdownRow {
13+
category: string;
14+
total: string;
15+
percentage: string;
16+
}
17+
18+
export interface BudgetVsActualRow {
19+
budgetName: string;
20+
budgeted: string;
21+
actual: string;
22+
variance: string;
23+
}
24+
25+
/** Provides real analytics queries against the transactions and budgets tables. */
326
@Injectable()
427
export class AnalyticsService {
5-
/** Returns a stable service health payload for this capability. */
6-
status(): { module: string; status: string } { return { module: 'analytics', status: 'ready' }; }
28+
constructor(
29+
@InjectRepository(TransactionEntity)
30+
private readonly transactionsRepository: Repository<TransactionEntity>,
31+
@InjectRepository(BudgetEntity)
32+
private readonly budgetsRepository: Repository<BudgetEntity>,
33+
) {}
34+
35+
/** Reports module availability for operations and smoke tests. */
36+
status(): { module: string; status: string } {
37+
return { module: 'analytics', status: 'ready' };
38+
}
39+
40+
/**
41+
* Groups spending into weekly buckets using PostgreSQL date_trunc('week').
42+
* Returns the most recent `weeks` weeks with aggregated totals.
43+
*/
44+
async getSpendingTrend(
45+
userId: string,
46+
asset: string,
47+
weeks: number = 8,
48+
): Promise<SpendingTrendRow[]> {
49+
return this.transactionsRepository
50+
.createQueryBuilder('t')
51+
.select("date_trunc('week', t.\"createdAt\")", 'week')
52+
.addSelect('SUM(t.amount)', 'total')
53+
.where('t."userId" = :userId', { userId })
54+
.andWhere('t."asset" = :asset', { asset })
55+
.groupBy("date_trunc('week', t.\"createdAt\")")
56+
.orderBy('week', 'DESC')
57+
.limit(weeks)
58+
.getRawMany<SpendingTrendRow>();
59+
}
60+
61+
/**
62+
* Sums spending by category and computes each category's percentage of the
63+
* grand total in SQL for accuracy.
64+
*/
65+
async getCategoryBreakdown(
66+
userId: string,
67+
asset: string,
68+
since: Date,
69+
): Promise<CategoryBreakdownRow[]> {
70+
return this.transactionsRepository
71+
.createQueryBuilder('t')
72+
.select('t."category"', 'category')
73+
.addSelect('SUM(t.amount)', 'total')
74+
.addSelect(
75+
`ROUND(SUM(t.amount) * 100 / NULLIF(SUM(SUM(t.amount)) OVER (), 0), 6)::text`,
76+
'percentage',
77+
)
78+
.where('t."userId" = :userId', { userId })
79+
.andWhere('t."asset" = :asset', { asset })
80+
.andWhere('t."createdAt" >= :since', { since })
81+
.groupBy('t."category"')
82+
.orderBy('total', 'DESC')
83+
.getRawMany<CategoryBreakdownRow>();
84+
}
85+
86+
/**
87+
* Joins budgets to transactions on category and asset, computing variance
88+
* for each budget: variance = actual_spend - budgeted_amount.
89+
* Negative variance means underspend; positive means overspend.
90+
*/
91+
async getBudgetVsActual(userId: string): Promise<BudgetVsActualRow[]> {
92+
return this.budgetsRepository
93+
.createQueryBuilder('b')
94+
.select('b."name"', 'budgetName')
95+
.addSelect('b.amount', 'budgeted')
96+
.addSelect('COALESCE(SUM(t.amount), 0)::text', 'actual')
97+
.addSelect(
98+
'(COALESCE(SUM(t.amount), 0) - b.amount)::text',
99+
'variance',
100+
)
101+
.leftJoin(
102+
TransactionEntity,
103+
't',
104+
't."userId" = b."userId" AND t."category" = b."category" AND t."asset" = b."asset"',
105+
)
106+
.where('b."userId" = :userId', { userId })
107+
.groupBy('b.id')
108+
.addGroupBy('b."name"')
109+
.addGroupBy('b.amount')
110+
.orderBy('b."name"', 'ASC')
111+
.getRawMany<BudgetVsActualRow>();
112+
}
7113
}

‎src/app.module.ts‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ import { CurrencyConversionModule } from './currency-conversion/currency-convers
1616
import { NotificationModule } from './notification/notification.module';
1717
import { MailModule } from './mail/mail.module';
1818
import { AnalyticsModule } from './analytics/analytics.module';
19-
import { AnalyticsSystemModule } from './analytics-system/analytics-system.module';
2019
import { AdminModule } from './admin/admin.module';
2120
import { SettingsModule } from './settings/settings.module';
2221
import { AuditModule } from './audit/audit.module';
@@ -33,7 +32,7 @@ import { ProtectedModule } from './protected/protected.module';
3332
imports: [
3433
ConfigModule.forRoot({ isGlobal: true, load: [configuration], validationSchema: configurationValidationSchema }),
3534
TypeOrmModule.forRootAsync({ inject: [TypedConfigService], useFactory: (config: TypedConfigService) => ({ type: 'postgres', host: config.get('DB_HOST', 'localhost'), port: config.getNumber('DB_PORT', 5432), username: config.get('DB_USERNAME', 'postgres'), password: config.get('DB_PASSWORD', 'postgres'), database: config.get('DB_NAME', 'stellarspend'), autoLoadEntities: true, synchronize: false, migrationsRun: false }) }),
36-
AuthModule, UsersModule, WalletModule, BlockchainModule, TransactionsModule, BudgetsModule, BudgetAllocationModule, SavingsModule, CurrencyConversionModule, NotificationModule, MailModule, AnalyticsModule, AnalyticsSystemModule, AdminModule, SettingsModule, AuditModule, SecurityModule, CacheModule, LoggingModule, HealthModule, TranslationModule, AccessibilityModule, ProtectedModule,
35+
AuthModule, UsersModule, WalletModule, BlockchainModule, TransactionsModule, BudgetsModule, BudgetAllocationModule, SavingsModule, CurrencyConversionModule, NotificationModule, MailModule, AnalyticsModule, AdminModule, SettingsModule, AuditModule, SecurityModule, CacheModule, LoggingModule, HealthModule, TranslationModule, AccessibilityModule, ProtectedModule,
3736
],
3837
providers: [TypedConfigService],
3938
exports: [TypedConfigService],

0 commit comments

Comments
 (0)