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: 2 additions & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,10 @@
"@types/node": "^22.10.7",
"@types/nodemailer": "^7.0.11",
"@types/passport-jwt": "^4.0.1",
"@types/superagent": "^8.1.9",
"@types/supertest": "^6.0.2",
"@types/uuid": "^11.0.0",
"@types/validator": "^13.15.10",
"eslint": "^9.39.3",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
Expand Down
6 changes: 6 additions & 0 deletions backend/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class AddTransactionTagsAndCategory1780000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "transactions"
ADD COLUMN IF NOT EXISTS "category" varchar;
`);

await queryRunner.query(`
ALTER TABLE "transactions"
ADD COLUMN IF NOT EXISTS "tags" text[] DEFAULT ARRAY[]::text[];
`);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "transactions"
DROP COLUMN IF EXISTS "tags";
`);

await queryRunner.query(`
ALTER TABLE "transactions"
DROP COLUMN IF EXISTS "category";
`);
}
}
51 changes: 51 additions & 0 deletions backend/src/modules/transactions/auto-categorization.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { Injectable } from '@nestjs/common';

/**
* Simple rule-based auto-categorization service.
* This is a lightweight starting point that can be replaced by an ML model later.
*/
@Injectable()
export class AutoCategorizationService {
private keywordMap: Record<string, string> = {
grocery: 'Groceries',
supermarket: 'Groceries',
starbucks: 'Dining',
restaurant: 'Dining',
uber: 'Transport',
lyft: 'Transport',
rent: 'Rent',
salary: 'Income',
paycheck: 'Income',
amazon: 'Shopping',
};

predictCategory(metadata: Record<string, any> | undefined): string | null {
if (!metadata) return null;

// Look into common fields
const searchable: string[] = [];

if (typeof metadata.description === 'string')
searchable.push(metadata.description);

if (typeof metadata.memo === 'string') searchable.push(metadata.memo);

if (typeof metadata.counterparty === 'string')
searchable.push(metadata.counterparty);

// Include merchant/name fields in metadata
if (metadata?.merchant && typeof metadata.merchant === 'string') {
searchable.push(metadata.merchant);
}

const haystack = searchable.join(' ').toLowerCase();

for (const key of Object.keys(this.keywordMap)) {
if (haystack.includes(key)) {
return this.keywordMap[key];
}
}

return null;
}
}
32 changes: 32 additions & 0 deletions backend/src/modules/transactions/dto/bulk-tag.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsArray, IsString } from 'class-validator';

export class BulkTagDto {
@ApiPropertyOptional({
description: 'List of transaction IDs to operate on',
isArray: true,
example: ['uuid-1', 'uuid-2'],
})
@IsOptional()
@IsArray()
@IsString({ each: true })
ids?: string[];

@ApiPropertyOptional({
description: 'Tags to apply',
isArray: true,
example: ['groceries'],
})
@IsOptional()
@IsArray()
@IsString({ each: true })
tags?: string[];

@ApiPropertyOptional({
description: 'Category to set',
example: 'Groceries',
})
@IsOptional()
@IsString()
category?: string;
}
31 changes: 31 additions & 0 deletions backend/src/modules/transactions/dto/tag-transaction.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsArray, IsString, IsIn } from 'class-validator';

export class TagTransactionDto {
@ApiPropertyOptional({
description: 'Tags to apply to the transaction',
isArray: true,
example: ['groceries', 'food'],
})
@IsOptional()
@IsArray()
@IsString({ each: true })
tags?: string[];

@ApiPropertyOptional({
description: 'Category to assign',
example: 'Groceries',
})
@IsOptional()
@IsString()
category?: string;

@ApiPropertyOptional({
description: 'Action to perform on tags',
example: 'add',
})
@IsOptional()
@IsString()
@IsIn(['add', 'remove', 'set'])
action?: 'add' | 'remove' | 'set';
}
36 changes: 35 additions & 1 deletion backend/src/modules/transactions/dto/transaction-query.dto.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type, Transform } from 'class-transformer';
import { IsOptional, IsEnum, IsDateString, IsArray } from 'class-validator';
import {
IsOptional,
IsEnum,
IsDateString,
IsArray,
IsString,
} from 'class-validator';
import { PageOptionsDto } from '../../../common/dto/page-options.dto';
import { LedgerTransactionType } from '../../blockchain/entities/transaction.entity';

Expand Down Expand Up @@ -44,4 +50,32 @@ export class TransactionQueryDto extends PageOptionsDto {
})
@IsOptional()
readonly poolId?: string;

@ApiPropertyOptional({
description: 'Filter by category',
example: 'Groceries',
})
@IsOptional()
@IsString()
readonly category?: string;

@ApiPropertyOptional({
description: 'Filter by tags (comma-separated or array)',
example: 'food,groceries',
isArray: true,
})
@IsOptional()
@Transform(({ value }) => {
if (!value) return undefined;
if (typeof value === 'string') {
return value
.split(',')
.map((v) => v.trim())
.filter(Boolean);
}
return Array.isArray(value) ? value : undefined;
})
@IsArray()
@IsString({ each: true })
readonly tags?: string[];
}
10 changes: 10 additions & 0 deletions backend/src/modules/transactions/dto/transaction-response.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,16 @@ export class TransactionResponseDto {
@ApiProperty({ description: 'Additional metadata', nullable: true })
metadata: Record<string, unknown> | null;

@ApiProperty({ description: 'Transaction category', nullable: true })
category?: string | null;

@ApiProperty({
description: 'Tags attached to the transaction',
nullable: true,
isArray: true,
})
tags?: string[];

@ApiProperty({ description: 'Transaction creation date (ISO 8601)' })
createdAt: string;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,13 @@ export class Transaction extends BaseEntity {
@CreateDateColumn()
createdAt: Date;

@Column({ type: 'varchar', nullable: true })
category: string | null;

// Tags stored as postgres text[] for efficient filtering/overlap checks
@Column('text', { array: true, default: () => 'ARRAY[]::text[]' })
tags: string[];

get transactionHash(): string | null | undefined {
return this.txHash;
}
Expand Down
47 changes: 47 additions & 0 deletions backend/src/modules/transactions/transactions.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ describe('TransactionsController', () => {

const mockTransactionsService = {
findAllForUser: jest.fn(),
tagTransaction: jest.fn(),
listCategories: jest.fn(),
bulkTag: jest.fn(),
};

beforeEach(async () => {
Expand Down Expand Up @@ -119,5 +122,49 @@ describe('TransactionsController', () => {
queryDto,
);
});

it('should call tagTransaction on POST /:id/tag', async () => {
const payload = { tags: ['food'], category: 'Groceries', action: 'add' };
mockTransactionsService.tagTransaction.mockResolvedValue({ ok: true });

const res = await controller.tagTransaction(
mockUser,
'tx-1',
payload as any,
);

expect(service.tagTransaction).toHaveBeenCalledWith(
mockUser.id,
'tx-1',
payload,
);
expect(res).toEqual({ ok: true });
});

it('should return categories from GET /categories', async () => {
mockTransactionsService.listCategories.mockResolvedValue([
'Groceries',
'Transport',
]);

const res = await controller.getCategories(mockUser);

expect(service.listCategories).toHaveBeenCalledWith(mockUser.id);
expect(res).toEqual(['Groceries', 'Transport']);
});

it('should call bulkTag on POST /tags/bulk', async () => {
const body = {
ids: ['tx-1', 'tx-2'],
tags: ['food'],
category: 'Groceries',
};
mockTransactionsService.bulkTag.mockResolvedValue({ ok: true, count: 2 });

const res = await controller.bulkTag(mockUser, body as any);

expect(service.bulkTag).toHaveBeenCalledWith(mockUser.id, body);
expect(res).toEqual({ ok: true, count: 2 });
});
});
});
32 changes: 31 additions & 1 deletion backend/src/modules/transactions/transactions.controller.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
import { Controller, Get, Query, UseGuards, Res } from '@nestjs/common';
import {
Controller,
Get,
Query,
UseGuards,
Res,
Param,
Post,
Body,
} from '@nestjs/common';
import { Response } from 'express';
import {
ApiBearerAuth,
Expand All @@ -9,6 +18,8 @@ import {
import { TransactionsService } from './transactions.service';
import { TransactionQueryDto } from './dto/transaction-query.dto';
import { TransactionResponseDto } from './dto/transaction-response.dto';
import { TagTransactionDto } from './dto/tag-transaction.dto';
import { BulkTagDto } from './dto/bulk-tag.dto';
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { PageDto } from '../../common/dto/page.dto';
Expand Down Expand Up @@ -72,4 +83,23 @@ export class TransactionsController {

csvStream.pipe(res);
}

@Post(':id/tag')
async tagTransaction(
@CurrentUser() user: { id: string },
@Param('id') id: string,
@Body() payload: TagTransactionDto,
) {
return this.transactionsService.tagTransaction(user.id, id, payload);
}

@Get('categories')
async getCategories(@CurrentUser() user: { id: string }) {
return this.transactionsService.listCategories(user.id);
}

@Post('tags/bulk')
async bulkTag(@CurrentUser() user: { id: string }, @Body() body: BulkTagDto) {
return this.transactionsService.bulkTag(user.id, body);
}
}
2 changes: 2 additions & 0 deletions backend/src/modules/transactions/transactions.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { TransactionsController } from './transactions.controller';
import { TransactionsService } from './transactions.service';
import { AutoCategorizationService } from './auto-categorization.service';
import { LedgerTransaction } from '../blockchain/entities/transaction.entity';
import { TransactionFormattingInterceptor } from '../../common/interceptors/transaction-formatting.interceptor';

Expand All @@ -11,6 +12,7 @@ import { TransactionFormattingInterceptor } from '../../common/interceptors/tran
controllers: [TransactionsController],
providers: [
TransactionsService,
AutoCategorizationService,
{
provide: APP_INTERCEPTOR,
useClass: TransactionFormattingInterceptor,
Expand Down
Loading
Loading