From f7653ac38e13b4a34ee861412052130a8ecad238 Mon Sep 17 00:00:00 2001 From: MerlinTheWhiz Date: Sun, 29 Mar 2026 13:10:16 +0100 Subject: [PATCH] Implement Transaction Tagging and Categorization --- backend/package.json | 2 + backend/pnpm-lock.yaml | 6 + ...000000000-AddTransactionTagsAndCategory.ts | 27 ++++ .../auto-categorization.service.ts | 51 ++++++++ .../modules/transactions/dto/bulk-tag.dto.ts | 32 +++++ .../transactions/dto/tag-transaction.dto.ts | 31 +++++ .../transactions/dto/transaction-query.dto.ts | 36 +++++- .../dto/transaction-response.dto.ts | 10 ++ .../entities/transaction.entity.ts | 7 ++ .../transactions.controller.spec.ts | 47 +++++++ .../transactions/transactions.controller.ts | 32 ++++- .../transactions/transactions.module.ts | 2 + .../transactions.service.tagging.spec.ts | 116 ++++++++++++++++++ .../transactions/transactions.service.ts | 101 +++++++++++++++ 14 files changed, 498 insertions(+), 2 deletions(-) create mode 100644 backend/src/migrations/1780000000000-AddTransactionTagsAndCategory.ts create mode 100644 backend/src/modules/transactions/auto-categorization.service.ts create mode 100644 backend/src/modules/transactions/dto/bulk-tag.dto.ts create mode 100644 backend/src/modules/transactions/dto/tag-transaction.dto.ts create mode 100644 backend/src/modules/transactions/transactions.service.tagging.spec.ts diff --git a/backend/package.json b/backend/package.json index b744bb725..8fbb3d654 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/pnpm-lock.yaml b/backend/pnpm-lock.yaml index 2eb5519a5..51a4e4fb3 100644 --- a/backend/pnpm-lock.yaml +++ b/backend/pnpm-lock.yaml @@ -162,12 +162,18 @@ importers: '@types/passport-jwt': specifier: ^4.0.1 version: 4.0.1 + '@types/superagent': + specifier: ^8.1.9 + version: 8.1.9 '@types/supertest': specifier: ^6.0.2 version: 6.0.3 '@types/uuid': specifier: ^11.0.0 version: 11.0.0 + '@types/validator': + specifier: ^13.15.10 + version: 13.15.10 eslint: specifier: ^9.39.3 version: 9.39.3(jiti@2.6.1) diff --git a/backend/src/migrations/1780000000000-AddTransactionTagsAndCategory.ts b/backend/src/migrations/1780000000000-AddTransactionTagsAndCategory.ts new file mode 100644 index 000000000..e28e7fb07 --- /dev/null +++ b/backend/src/migrations/1780000000000-AddTransactionTagsAndCategory.ts @@ -0,0 +1,27 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddTransactionTagsAndCategory1780000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + 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 { + await queryRunner.query(` + ALTER TABLE "transactions" + DROP COLUMN IF EXISTS "tags"; + `); + + await queryRunner.query(` + ALTER TABLE "transactions" + DROP COLUMN IF EXISTS "category"; + `); + } +} diff --git a/backend/src/modules/transactions/auto-categorization.service.ts b/backend/src/modules/transactions/auto-categorization.service.ts new file mode 100644 index 000000000..7bc2c5f8c --- /dev/null +++ b/backend/src/modules/transactions/auto-categorization.service.ts @@ -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 = { + grocery: 'Groceries', + supermarket: 'Groceries', + starbucks: 'Dining', + restaurant: 'Dining', + uber: 'Transport', + lyft: 'Transport', + rent: 'Rent', + salary: 'Income', + paycheck: 'Income', + amazon: 'Shopping', + }; + + predictCategory(metadata: Record | 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; + } +} diff --git a/backend/src/modules/transactions/dto/bulk-tag.dto.ts b/backend/src/modules/transactions/dto/bulk-tag.dto.ts new file mode 100644 index 000000000..c703e1d28 --- /dev/null +++ b/backend/src/modules/transactions/dto/bulk-tag.dto.ts @@ -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; +} diff --git a/backend/src/modules/transactions/dto/tag-transaction.dto.ts b/backend/src/modules/transactions/dto/tag-transaction.dto.ts new file mode 100644 index 000000000..a38328830 --- /dev/null +++ b/backend/src/modules/transactions/dto/tag-transaction.dto.ts @@ -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'; +} diff --git a/backend/src/modules/transactions/dto/transaction-query.dto.ts b/backend/src/modules/transactions/dto/transaction-query.dto.ts index d5367a623..467a44982 100644 --- a/backend/src/modules/transactions/dto/transaction-query.dto.ts +++ b/backend/src/modules/transactions/dto/transaction-query.dto.ts @@ -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'; @@ -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[]; } diff --git a/backend/src/modules/transactions/dto/transaction-response.dto.ts b/backend/src/modules/transactions/dto/transaction-response.dto.ts index 5c746e2a1..deff220bc 100644 --- a/backend/src/modules/transactions/dto/transaction-response.dto.ts +++ b/backend/src/modules/transactions/dto/transaction-response.dto.ts @@ -76,6 +76,16 @@ export class TransactionResponseDto { @ApiProperty({ description: 'Additional metadata', nullable: true }) metadata: Record | 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; diff --git a/backend/src/modules/transactions/entities/transaction.entity.ts b/backend/src/modules/transactions/entities/transaction.entity.ts index 901def7e4..757849f38 100644 --- a/backend/src/modules/transactions/entities/transaction.entity.ts +++ b/backend/src/modules/transactions/entities/transaction.entity.ts @@ -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; } diff --git a/backend/src/modules/transactions/transactions.controller.spec.ts b/backend/src/modules/transactions/transactions.controller.spec.ts index 84dce5362..9a464c955 100644 --- a/backend/src/modules/transactions/transactions.controller.spec.ts +++ b/backend/src/modules/transactions/transactions.controller.spec.ts @@ -14,6 +14,9 @@ describe('TransactionsController', () => { const mockTransactionsService = { findAllForUser: jest.fn(), + tagTransaction: jest.fn(), + listCategories: jest.fn(), + bulkTag: jest.fn(), }; beforeEach(async () => { @@ -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 }); + }); }); }); diff --git a/backend/src/modules/transactions/transactions.controller.ts b/backend/src/modules/transactions/transactions.controller.ts index 8fc71cb2b..7e58e3ce2 100644 --- a/backend/src/modules/transactions/transactions.controller.ts +++ b/backend/src/modules/transactions/transactions.controller.ts @@ -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, @@ -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'; @@ -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); + } } diff --git a/backend/src/modules/transactions/transactions.module.ts b/backend/src/modules/transactions/transactions.module.ts index d94195849..5e0ccceb1 100644 --- a/backend/src/modules/transactions/transactions.module.ts +++ b/backend/src/modules/transactions/transactions.module.ts @@ -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'; @@ -11,6 +12,7 @@ import { TransactionFormattingInterceptor } from '../../common/interceptors/tran controllers: [TransactionsController], providers: [ TransactionsService, + AutoCategorizationService, { provide: APP_INTERCEPTOR, useClass: TransactionFormattingInterceptor, diff --git a/backend/src/modules/transactions/transactions.service.tagging.spec.ts b/backend/src/modules/transactions/transactions.service.tagging.spec.ts new file mode 100644 index 000000000..e470ee379 --- /dev/null +++ b/backend/src/modules/transactions/transactions.service.tagging.spec.ts @@ -0,0 +1,116 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { TransactionsService } from './transactions.service'; +import { LedgerTransaction } from '../blockchain/entities/transaction.entity'; + +describe('TransactionsService tagging', () => { + let service: TransactionsService; + + const mockRepository: any = { + findOne: jest.fn(), + save: jest.fn(), + createQueryBuilder: jest.fn(), + findBy: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TransactionsService, + { + provide: getRepositoryToken(LedgerTransaction), + useValue: mockRepository, + }, + ], + }).compile(); + + service = module.get(TransactionsService); + + jest.clearAllMocks(); + }); + + it('returns not found when tagging a missing transaction', async () => { + mockRepository.findOne.mockResolvedValue(undefined); + + const res = await service.tagTransaction('user-1', 'tx-1', { tags: ['a'] }); + + expect(res).toEqual({ ok: false, message: 'Transaction not found' }); + expect(mockRepository.findOne).toHaveBeenCalledWith({ + where: { id: 'tx-1', userId: 'user-1' }, + }); + }); + + it('adds tags and sets category for a transaction', async () => { + const tx = { + id: 'tx-2', + userId: 'user-1', + tags: ['food'], + category: null, + createdAt: new Date(), + } as any; + + mockRepository.findOne.mockResolvedValue(tx); + mockRepository.save.mockImplementation(async (t) => t); + + const payload = { tags: ['groceries'], category: 'Groceries' }; + + const res = await service.tagTransaction('user-1', 'tx-2', payload); + + expect(res.ok).toBe(true); + expect(res.transaction).toBeDefined(); + // narrow the type for TS strict checks + const t = res.transaction!; + expect(t.tags).toEqual(expect.arrayContaining(['food', 'groceries'])); + expect(t.category).toBe('Groceries'); + expect(mockRepository.save).toHaveBeenCalled(); + }); + + it('bulkTag returns error when no ids provided', async () => { + const res = await service.bulkTag('user-1', { ids: [] }); + expect(res).toEqual({ ok: false, message: 'No ids provided' }); + }); + + it('bulkTag updates multiple transactions and returns count', async () => { + const txs = [ + { id: 'a', userId: 'user-1', tags: ['x'], category: null }, + { id: 'b', userId: 'user-1', tags: [], category: null }, + ]; + + mockRepository.findBy.mockResolvedValue(txs); + mockRepository.save.mockImplementation(async (items) => items); + + const res = await service.bulkTag('user-1', { + ids: ['a', 'b'], + tags: ['new'], + action: 'add', + }); + + expect(res.ok).toBe(true); + expect(res.count).toBe(2); + expect(mockRepository.save).toHaveBeenCalledWith(txs); + // ensure tags updated + expect(txs[0].tags).toEqual(expect.arrayContaining(['x', 'new'])); + expect(txs[1].tags).toEqual(expect.arrayContaining(['new'])); + }); + + it('listCategories returns distinct categories', async () => { + const qb: any = { + select: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + getRawMany: jest + .fn() + .mockResolvedValue([{ category: 'A' }, { category: 'B' }]), + }; + + mockRepository.createQueryBuilder.mockReturnValue(qb); + + const res = await service.listCategories('user-1'); + + expect(res).toEqual(['A', 'B']); + expect(mockRepository.createQueryBuilder).toHaveBeenCalledWith( + 'transaction', + ); + }); +}); diff --git a/backend/src/modules/transactions/transactions.service.ts b/backend/src/modules/transactions/transactions.service.ts index aac33a007..e9232df0b 100644 --- a/backend/src/modules/transactions/transactions.service.ts +++ b/backend/src/modules/transactions/transactions.service.ts @@ -72,6 +72,8 @@ export class TransactionsService { publicKey: dto.publicKey ?? '', eventId: dto.eventId, transactionHash: dto.transactionHash ?? '', + category: dto.category ?? '', + tags: dto.tags ? dto.tags.join(';') : '', ledgerSequence: dto.ledgerSequence ?? '', poolId: dto.poolId ?? '', assetId: dto.assetId ?? '', @@ -127,6 +129,21 @@ export class TransactionsService { }); } + // Filter by category + if (queryDto.category) { + queryBuilder.andWhere('transaction.category = :category', { + category: queryDto.category, + }); + } + + // Filter by tags (any overlap) + if (queryDto.tags && queryDto.tags.length > 0) { + // Use Postgres array overlap operator (&&) + queryBuilder.andWhere('transaction.tags && :tags', { + tags: queryDto.tags, + }); + } + // Apply ordering queryBuilder.orderBy('transaction.createdAt', queryDto.order ?? 'DESC'); @@ -149,6 +166,8 @@ export class TransactionsService { publicKey: transaction.publicKey, eventId: transaction.eventId, transactionHash: transaction.transactionHash, + category: transaction.category ?? null, + tags: transaction.tags ?? [], ledgerSequence: transaction.ledgerSequence, poolId: transaction.poolId, metadata: transaction.metadata, @@ -168,6 +187,88 @@ export class TransactionsService { } as TransactionResponseDto; } + async tagTransaction(userId: string, transactionId: string, payload: any) { + const tx = await this.transactionRepository.findOne({ + where: { id: transactionId, userId }, + }); + + if (!tx) { + return { ok: false, message: 'Transaction not found' }; + } + + // Handle tags + if (payload?.tags) { + const current = tx.tags ?? []; + const incoming = Array.isArray(payload.tags) ? payload.tags : []; + + if (payload.action === 'remove') { + tx.tags = current.filter((t) => !incoming.includes(t)); + } else if (payload.action === 'set') { + tx.tags = incoming; + } else { + // add + const set = new Set(current.concat(incoming)); + tx.tags = Array.from(set); + } + } + + if (typeof payload?.category === 'string') { + tx.category = payload.category; + } + + await this.transactionRepository.save(tx); + + return { ok: true, transaction: this.transformToResponseDto(tx) }; + } + + async listCategories(userId: string) { + const rows = await this.transactionRepository + .createQueryBuilder('transaction') + .select('DISTINCT transaction.category', 'category') + .where('transaction.userId = :userId', { userId }) + .andWhere('transaction.category IS NOT NULL') + .orderBy('transaction.category', 'ASC') + .getRawMany(); + + return rows.map((r) => r.category); + } + + async bulkTag(userId: string, body: any) { + // Support ids-based operations for now + if (!body?.ids || !Array.isArray(body.ids) || !body.ids.length) { + return { ok: false, message: 'No ids provided' }; + } + + const txs = await this.transactionRepository.findBy({ + id: body.ids, + userId, + } as any); + + for (const tx of txs) { + if (body.tags) { + const current = tx.tags ?? []; + const incoming = Array.isArray(body.tags) ? body.tags : []; + + if (body.action === 'remove') { + tx.tags = current.filter((t) => !incoming.includes(t)); + } else if (body.action === 'set') { + tx.tags = incoming; + } else { + const set = new Set(current.concat(incoming)); + tx.tags = Array.from(set); + } + } + + if (typeof body.category === 'string') { + tx.category = body.category; + } + } + + await this.transactionRepository.save(txs); + + return { ok: true, count: txs.length }; + } + /** * Extract asset ID from transaction metadata or return default */