Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion dist/tsconfig.tsbuildinfo

Large diffs are not rendered by default.

9 changes: 0 additions & 9 deletions src/analytics-system/analytics-system.controller.ts

This file was deleted.

6 changes: 0 additions & 6 deletions src/analytics-system/analytics-system.module.ts

This file was deleted.

7 changes: 0 additions & 7 deletions src/analytics-system/analytics-system.service.ts

This file was deleted.

52 changes: 50 additions & 2 deletions src/analytics/analytics.controller.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,57 @@
import { Controller, Get } from '@nestjs/common';
import { Controller, Get, Query, Req, UseGuards } from '@nestjs/common';
import { AnalyticsService } from './analytics.service';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';

/** Exposes the analytics API surface. */
@Controller('analytics')
export class AnalyticsController {
constructor(private readonly service: AnalyticsService) {}

/** Reports module availability for operations and smoke tests. */
@Get('status') status(): { module: string; status: string } { return this.service.status(); }
@Get('status')
status(): { module: string; status: string } {
return this.service.status();
}

/**
* GET /analytics/trend?asset=USDC&weeks=8
* Returns weekly spending buckets for the given asset.
*/
@UseGuards(JwtAuthGuard)
@Get('trend')
async getSpendingTrend(
@Req() req: any,
@Query('asset') asset: string,
@Query('weeks') weeks?: string,
) {
const userId = req.user.sub;
return this.service.getSpendingTrend(userId, asset, weeks ? Number(weeks) : 8);
}

/**
* GET /analytics/category-breakdown?asset=XLM&since=2026-01-01
* Returns per-category spending totals with percentages.
*/
@UseGuards(JwtAuthGuard)
@Get('category-breakdown')
async getCategoryBreakdown(
@Req() req: any,
@Query('asset') asset: string,
@Query('since') since?: string,
) {
const userId = req.user.sub;
const sinceDate = since ? new Date(since) : new Date(0);
return this.service.getCategoryBreakdown(userId, asset, sinceDate);
}

/**
* GET /analytics/budget-vs-actual
* Returns per-budget variance comparing budgeted amounts to actual spend.
*/
@UseGuards(JwtAuthGuard)
@Get('budget-vs-actual')
async getBudgetVsActual(@Req() req: any) {
const userId = req.user.sub;
return this.service.getBudgetVsActual(userId);
}
}
13 changes: 12 additions & 1 deletion src/analytics/analytics.module.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AnalyticsController } from './analytics.controller';
import { AnalyticsService } from './analytics.service';
import { TransactionEntity } from '../transactions/entities/transaction.entity';
import { BudgetEntity } from '../budgets/entities/budget.entity';

/** Registers the analytics feature. */
@Module({ controllers: [AnalyticsController], providers: [AnalyticsService], exports: [AnalyticsService] })
@Module({
imports: [
TypeOrmModule.forFeature([TransactionEntity, BudgetEntity]),
],
controllers: [AnalyticsController],
providers: [AnalyticsService],
exports: [AnalyticsService],
})
export class AnalyticsModule {}
141 changes: 141 additions & 0 deletions src/analytics/analytics.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { AnalyticsService } from './analytics.service';
import { TransactionEntity } from '../transactions/entities/transaction.entity';
import { BudgetEntity } from '../budgets/entities/budget.entity';

describe('AnalyticsService', () => {
let service: AnalyticsService;
let txRepo: any;
let budgetRepo: any;

const mockQueryBuilder = {
select: jest.fn().mockReturnThis(),
addSelect: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
groupBy: jest.fn().mockReturnThis(),
addGroupBy: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
leftJoin: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
getRawMany: jest.fn(),
};

beforeEach(async () => {
txRepo = {
createQueryBuilder: jest.fn(() => ({ ...mockQueryBuilder })),
};
budgetRepo = {
createQueryBuilder: jest.fn(() => ({ ...mockQueryBuilder })),
};

const module: TestingModule = await Test.createTestingModule({
providers: [
AnalyticsService,
{ provide: getRepositoryToken(TransactionEntity), useValue: txRepo },
{ provide: getRepositoryToken(BudgetEntity), useValue: budgetRepo },
],
}).compile();

service = module.get<AnalyticsService>(AnalyticsService);
});

afterEach(() => jest.clearAllMocks());

describe('status', () => {
it('returns ready status', () => {
expect(service.status()).toEqual({ module: 'analytics', status: 'ready' });
});
});

describe('getSpendingTrend', () => {
it('returns weekly spending buckets for the given asset', async () => {
const mockRows = [
{ week: '2026-08-18T00:00:00.000Z', total: '150.000000000000' },
{ week: '2026-08-11T00:00:00.000Z', total: '200.500000000000' },
];
const qb = txRepo.createQueryBuilder();
qb.getRawMany.mockResolvedValue(mockRows);

const result = await service.getSpendingTrend('user-1', 'USDC', 2);

expect(result).toEqual(mockRows);
expect(qb.where).toHaveBeenCalledWith('t."userId" = :userId', {
userId: 'user-1',
});
expect(qb.andWhere).toHaveBeenCalledWith('t."asset" = :asset', {
asset: 'USDC',
});
expect(qb.limit).toHaveBeenCalledWith(2);
});

it('defaults to 8 weeks when weeks is not specified', async () => {
const qb = txRepo.createQueryBuilder();
qb.getRawMany.mockResolvedValue([]);

await service.getSpendingTrend('user-1', 'USDC');

expect(qb.limit).toHaveBeenCalledWith(8);
});
});

describe('getCategoryBreakdown', () => {
it('returns category totals with percentage computed in SQL', async () => {
const mockRows = [
{ category: 'groceries', total: '150.00', percentage: '60.000000' },
{ category: 'entertainment', total: '100.00', percentage: '40.000000' },
];
const qb = txRepo.createQueryBuilder();
qb.getRawMany.mockResolvedValue(mockRows);

const result = await service.getCategoryBreakdown(
'user-1',
'USDC',
new Date('2026-01-01'),
);

expect(result).toEqual(mockRows);
expect(result).toHaveLength(2);
});

it('uses the since date filter', async () => {
const qb = txRepo.createQueryBuilder();
qb.getRawMany.mockResolvedValue([]);

const since = new Date('2026-06-01');
await service.getCategoryBreakdown('user-1', 'XLM', since);

expect(qb.andWhere).toHaveBeenCalledWith(
't."createdAt" >= :since',
{ since },
);
});
});

describe('getBudgetVsActual', () => {
it('joins budgets to transactions and computes variance', async () => {
const mockRows = [
{ budgetName: 'Groceries', budgeted: '200.00', actual: '150.00', variance: '-50.00' },
{ budgetName: 'Rent', budgeted: '500.00', actual: '500.00', variance: '0.00' },
];
const qb = budgetRepo.createQueryBuilder();
qb.getRawMany.mockResolvedValue(mockRows);

const result = await service.getBudgetVsActual('user-1');

expect(result).toEqual(mockRows);
expect(result).toHaveLength(2);
expect(result[0].variance).toBe('-50.00');
});

it('returns empty array when user has no budgets', async () => {
const qb = budgetRepo.createQueryBuilder();
qb.getRawMany.mockResolvedValue([]);

const result = await service.getBudgetVsActual('user-no-budgets');

expect(result).toEqual([]);
});
});
});
112 changes: 109 additions & 3 deletions src/analytics/analytics.service.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,113 @@
import { Injectable } from '@nestjs/common';
/** Provides the analytics application capability. */
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { TransactionEntity } from '../transactions/entities/transaction.entity';
import { BudgetEntity } from '../budgets/entities/budget.entity';

export interface SpendingTrendRow {
week: string;
total: string;
}

export interface CategoryBreakdownRow {
category: string;
total: string;
percentage: string;
}

export interface BudgetVsActualRow {
budgetName: string;
budgeted: string;
actual: string;
variance: string;
}

/** Provides real analytics queries against the transactions and budgets tables. */
@Injectable()
export class AnalyticsService {
/** Returns a stable service health payload for this capability. */
status(): { module: string; status: string } { return { module: 'analytics', status: 'ready' }; }
constructor(
@InjectRepository(TransactionEntity)
private readonly transactionsRepository: Repository<TransactionEntity>,
@InjectRepository(BudgetEntity)
private readonly budgetsRepository: Repository<BudgetEntity>,
) {}

/** Reports module availability for operations and smoke tests. */
status(): { module: string; status: string } {
return { module: 'analytics', status: 'ready' };
}

/**
* Groups spending into weekly buckets using PostgreSQL date_trunc('week').
* Returns the most recent `weeks` weeks with aggregated totals.
*/
async getSpendingTrend(
userId: string,
asset: string,
weeks: number = 8,
): Promise<SpendingTrendRow[]> {
return this.transactionsRepository
.createQueryBuilder('t')
.select("date_trunc('week', t.\"createdAt\")", 'week')
.addSelect('SUM(t.amount)', 'total')
.where('t."userId" = :userId', { userId })
.andWhere('t."asset" = :asset', { asset })
.groupBy("date_trunc('week', t.\"createdAt\")")
.orderBy('week', 'DESC')
.limit(weeks)
.getRawMany<SpendingTrendRow>();
}

/**
* Sums spending by category and computes each category's percentage of the
* grand total in SQL for accuracy.
*/
async getCategoryBreakdown(
userId: string,
asset: string,
since: Date,
): Promise<CategoryBreakdownRow[]> {
return this.transactionsRepository
.createQueryBuilder('t')
.select('t."category"', 'category')
.addSelect('SUM(t.amount)', 'total')
.addSelect(
`ROUND(SUM(t.amount) * 100 / NULLIF(SUM(SUM(t.amount)) OVER (), 0), 6)::text`,
'percentage',
)
.where('t."userId" = :userId', { userId })
.andWhere('t."asset" = :asset', { asset })
.andWhere('t."createdAt" >= :since', { since })
.groupBy('t."category"')
.orderBy('total', 'DESC')
.getRawMany<CategoryBreakdownRow>();
}

/**
* Joins budgets to transactions on category and asset, computing variance
* for each budget: variance = actual_spend - budgeted_amount.
* Negative variance means underspend; positive means overspend.
*/
async getBudgetVsActual(userId: string): Promise<BudgetVsActualRow[]> {
return this.budgetsRepository
.createQueryBuilder('b')
.select('b."name"', 'budgetName')
.addSelect('b.amount', 'budgeted')
.addSelect('COALESCE(SUM(t.amount), 0)::text', 'actual')
.addSelect(
'(COALESCE(SUM(t.amount), 0) - b.amount)::text',
'variance',
)
.leftJoin(
TransactionEntity,
't',
't."userId" = b."userId" AND t."category" = b."category" AND t."asset" = b."asset"',
)
.where('b."userId" = :userId', { userId })
.groupBy('b.id')
.addGroupBy('b."name"')
.addGroupBy('b.amount')
.orderBy('b."name"', 'ASC')
.getRawMany<BudgetVsActualRow>();
}
}
3 changes: 1 addition & 2 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import { CurrencyConversionModule } from './currency-conversion/currency-convers
import { NotificationModule } from './notification/notification.module';
import { MailModule } from './mail/mail.module';
import { AnalyticsModule } from './analytics/analytics.module';
import { AnalyticsSystemModule } from './analytics-system/analytics-system.module';
import { AdminModule } from './admin/admin.module';
import { SettingsModule } from './settings/settings.module';
import { AuditModule } from './audit/audit.module';
Expand All @@ -33,7 +32,7 @@ import { ProtectedModule } from './protected/protected.module';
imports: [
ConfigModule.forRoot({ isGlobal: true, load: [configuration], validationSchema: configurationValidationSchema }),
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 }) }),
AuthModule, UsersModule, WalletModule, BlockchainModule, TransactionsModule, BudgetsModule, BudgetAllocationModule, SavingsModule, CurrencyConversionModule, NotificationModule, MailModule, AnalyticsModule, AnalyticsSystemModule, AdminModule, SettingsModule, AuditModule, SecurityModule, CacheModule, LoggingModule, HealthModule, TranslationModule, AccessibilityModule, ProtectedModule,
AuthModule, UsersModule, WalletModule, BlockchainModule, TransactionsModule, BudgetsModule, BudgetAllocationModule, SavingsModule, CurrencyConversionModule, NotificationModule, MailModule, AnalyticsModule, AdminModule, SettingsModule, AuditModule, SecurityModule, CacheModule, LoggingModule, HealthModule, TranslationModule, AccessibilityModule, ProtectedModule,
],
providers: [TypedConfigService],
exports: [TypedConfigService],
Expand Down
Loading
Loading