From 5ae0dc97c217f6e699e301f3054310dd57362141 Mon Sep 17 00:00:00 2001 From: ekenesamuel Date: Wed, 29 Apr 2026 00:51:39 +0100 Subject: [PATCH 1/2] Add transaction status lifecycle enforcement --- .../migration.sql | 15 +++ prisma/schema.prisma | 1 - src/admin/admin.controller.ts | 9 ++ src/admin/admin.module.ts | 3 +- src/admin/admin.service.ts | 31 ++--- src/admin/dto/admin.dto.ts | 5 + .../transaction-status.constants.ts | 20 +++ src/transactions/transactions.module.ts | 10 ++ src/transactions/transactions.service.ts | 59 +++++++++ src/types/prisma.types.ts | 1 - test/admin/transaction-status.dto.spec.ts | 23 ++++ .../transactions/transactions.service.spec.ts | 116 ++++++++++++++++++ 12 files changed, 276 insertions(+), 17 deletions(-) create mode 100644 prisma/migrations/20260429000000_enforce_transaction_status_lifecycle/migration.sql create mode 100644 src/transactions/transaction-status.constants.ts create mode 100644 src/transactions/transactions.module.ts create mode 100644 src/transactions/transactions.service.ts create mode 100644 test/admin/transaction-status.dto.spec.ts create mode 100644 test/transactions/transactions.service.spec.ts diff --git a/prisma/migrations/20260429000000_enforce_transaction_status_lifecycle/migration.sql b/prisma/migrations/20260429000000_enforce_transaction_status_lifecycle/migration.sql new file mode 100644 index 00000000..592ed6bf --- /dev/null +++ b/prisma/migrations/20260429000000_enforce_transaction_status_lifecycle/migration.sql @@ -0,0 +1,15 @@ +UPDATE "transactions" +SET "status" = 'CANCELLED' +WHERE "status" = 'FAILED'; + +ALTER TYPE "TransactionStatus" RENAME TO "TransactionStatus_old"; + +CREATE TYPE "TransactionStatus" AS ENUM ('PENDING', 'COMPLETED', 'CANCELLED'); + +ALTER TABLE "transactions" +ALTER COLUMN "status" DROP DEFAULT, +ALTER COLUMN "status" TYPE "TransactionStatus" +USING ("status"::text::"TransactionStatus"), +ALTER COLUMN "status" SET DEFAULT 'PENDING'; + +DROP TYPE "TransactionStatus_old"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 11e5b6e9..122eabe9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -45,7 +45,6 @@ enum TransactionStatus { PENDING COMPLETED CANCELLED - FAILED } // Document types diff --git a/src/admin/admin.controller.ts b/src/admin/admin.controller.ts index ae036cec..1d6af08a 100644 --- a/src/admin/admin.controller.ts +++ b/src/admin/admin.controller.ts @@ -18,6 +18,7 @@ import { ModerationQueueQueryDto, ReviewFraudAlertDto, TransactionMonitoringQueryDto, + UpdateTransactionStatusDto, } from './dto/admin.dto'; import { RestoreBackupDto, UpdateBackupScheduleDto } from '../backup/dto/backup.dto'; @@ -127,6 +128,14 @@ export class AdminController { return this.adminService.transactionMonitoringSummary(); } + @Patch('transactions/:id/status') + updateTransactionStatus( + @Param('id') transactionId: string, + @Body() payload: UpdateTransactionStatusDto, + ) { + return this.adminService.updateTransactionStatus(transactionId, payload); + } + @Get('fraud/alerts') listFraudAlerts(@Query() query: FraudAlertsQueryDto) { return this.adminService.listFraudAlerts(query); diff --git a/src/admin/admin.module.ts b/src/admin/admin.module.ts index 0d21a180..98a91c2a 100644 --- a/src/admin/admin.module.ts +++ b/src/admin/admin.module.ts @@ -4,9 +4,10 @@ import { AdminService } from './admin.service'; import { PrismaModule } from '../database/prisma.module'; import { FraudModule } from '../fraud/fraud.module'; import { BackupModule } from '../backup/backup.module'; +import { TransactionsModule } from '../transactions/transactions.module'; @Module({ - imports: [PrismaModule, FraudModule, BackupModule], + imports: [PrismaModule, FraudModule, BackupModule, TransactionsModule], controllers: [AdminController], providers: [AdminService], exports: [AdminService], diff --git a/src/admin/admin.service.ts b/src/admin/admin.service.ts index dafdd7ac..e13ad41e 100644 --- a/src/admin/admin.service.ts +++ b/src/admin/admin.service.ts @@ -13,9 +13,11 @@ import { ModerationQueueQueryDto, ReviewFraudAlertDto, TransactionMonitoringQueryDto, + UpdateTransactionStatusDto, } from './dto/admin.dto'; -import { PropertyStatus } from '../types/prisma.types'; +import { PropertyStatus, TransactionStatus, TransactionType } from '../types/prisma.types'; import { FraudService } from '../fraud/fraud.service'; +import { TransactionsService } from '../transactions/transactions.service'; @Injectable() export class AdminService { @@ -23,6 +25,7 @@ export class AdminService { private readonly prisma: PrismaService, private readonly fraudService: FraudService, private readonly backupService: BackupService, + private readonly transactionsService: TransactionsService, ) {} async listBackups() { @@ -70,15 +73,14 @@ export class AdminService { salesAggregate, rentAggregate, ] = await Promise.all([ - this.prisma.transaction.count({ where: { status: 'COMPLETED' } }), - this.prisma.transaction.count({ where: { status: 'PENDING' } }), - this.prisma.transaction.count({ where: { status: 'FAILED' } }), + this.prisma.transaction.count({ where: { status: TransactionStatus.COMPLETED } }), + this.prisma.transaction.count({ where: { status: TransactionStatus.PENDING } }), this.prisma.transaction.aggregate({ - where: { status: 'COMPLETED', type: 'SALE' }, + where: { status: TransactionStatus.COMPLETED, type: TransactionType.SALE }, _sum: { amount: true }, }), this.prisma.transaction.aggregate({ - where: { status: 'COMPLETED', type: 'TRANSFER' }, + where: { status: TransactionStatus.COMPLETED, type: TransactionType.TRANSFER }, _sum: { amount: true }, }), ]); @@ -101,7 +103,6 @@ export class AdminService { systemHealth: { completedTransactions, pendingTransactions, - failedTransactions, }, }; } @@ -310,13 +311,12 @@ export class AdminService { } async transactionMonitoringSummary() { - const [pending, completed, cancelled, failed, aggregateValue] = await Promise.all([ - this.prisma.transaction.count({ where: { status: 'PENDING' } }), - this.prisma.transaction.count({ where: { status: 'COMPLETED' } }), - this.prisma.transaction.count({ where: { status: 'CANCELLED' } }), - this.prisma.transaction.count({ where: { status: 'FAILED' } }), + const [pending, completed, cancelled, aggregateValue] = await Promise.all([ + this.prisma.transaction.count({ where: { status: TransactionStatus.PENDING } }), + this.prisma.transaction.count({ where: { status: TransactionStatus.COMPLETED } }), + this.prisma.transaction.count({ where: { status: TransactionStatus.CANCELLED } }), this.prisma.transaction.aggregate({ - where: { status: 'COMPLETED' }, + where: { status: TransactionStatus.COMPLETED }, _sum: { amount: true }, }), ]); @@ -325,11 +325,14 @@ export class AdminService { pending, completed, cancelled, - failed, totalCompletedValue: aggregateValue._sum.amount ?? 0, }; } + async updateTransactionStatus(transactionId: string, payload: UpdateTransactionStatusDto) { + return this.transactionsService.updateTransactionStatus(transactionId, payload.status); + } + async listFraudAlerts(query: FraudAlertsQueryDto) { return this.fraudService.listAlerts(query); } diff --git a/src/admin/dto/admin.dto.ts b/src/admin/dto/admin.dto.ts index e664db89..c4200ccc 100644 --- a/src/admin/dto/admin.dto.ts +++ b/src/admin/dto/admin.dto.ts @@ -139,6 +139,11 @@ export class TransactionMonitoringQueryDto { limit: number = 20; } +export class UpdateTransactionStatusDto { + @IsEnum(TransactionStatus) + status!: TransactionStatus; +} + export { AddFraudInvestigationNoteDto, BlockFraudUserDto, diff --git a/src/transactions/transaction-status.constants.ts b/src/transactions/transaction-status.constants.ts new file mode 100644 index 00000000..103e15d8 --- /dev/null +++ b/src/transactions/transaction-status.constants.ts @@ -0,0 +1,20 @@ +import { TransactionStatus } from '../types/prisma.types'; + +export const DEFAULT_TRANSACTION_STATUS = TransactionStatus.PENDING; + +const ALLOWED_TRANSACTION_STATUS_TRANSITIONS: Record = { + [TransactionStatus.PENDING]: [TransactionStatus.COMPLETED, TransactionStatus.CANCELLED], + [TransactionStatus.COMPLETED]: [], + [TransactionStatus.CANCELLED]: [], +}; + +export function canTransitionTransactionStatus( + currentStatus: TransactionStatus, + nextStatus: TransactionStatus, +): boolean { + if (currentStatus === nextStatus) { + return true; + } + + return ALLOWED_TRANSACTION_STATUS_TRANSITIONS[currentStatus].includes(nextStatus); +} diff --git a/src/transactions/transactions.module.ts b/src/transactions/transactions.module.ts new file mode 100644 index 00000000..d4bd349f --- /dev/null +++ b/src/transactions/transactions.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from '../database/prisma.module'; +import { TransactionsService } from './transactions.service'; + +@Module({ + imports: [PrismaModule], + providers: [TransactionsService], + exports: [TransactionsService], +}) +export class TransactionsModule {} diff --git a/src/transactions/transactions.service.ts b/src/transactions/transactions.service.ts new file mode 100644 index 00000000..a2910822 --- /dev/null +++ b/src/transactions/transactions.service.ts @@ -0,0 +1,59 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { Decimal } from '@prisma/client/runtime/library'; +import { PrismaService } from '../database/prisma.service'; +import { TransactionStatus, TransactionType } from '../types/prisma.types'; +import { + canTransitionTransactionStatus, + DEFAULT_TRANSACTION_STATUS, +} from './transaction-status.constants'; + +export interface CreateTransactionInput { + propertyId: string; + buyerId: string; + sellerId: string; + amount: Decimal | number | string; + type: TransactionType; + status?: TransactionStatus; + blockchainHash?: string | null; + contractAddress?: string | null; + notes?: string | null; +} + +@Injectable() +export class TransactionsService { + constructor(private readonly prisma: PrismaService) {} + + async createTransaction(input: CreateTransactionInput) { + return this.prisma.transaction.create({ + data: { + ...input, + status: input.status ?? DEFAULT_TRANSACTION_STATUS, + }, + }); + } + + async updateTransactionStatus(transactionId: string, status: TransactionStatus) { + const transaction = await this.prisma.transaction.findUnique({ + where: { id: transactionId }, + }); + + if (!transaction) { + throw new NotFoundException(`Transaction ${transactionId} not found`); + } + + if (!canTransitionTransactionStatus(transaction.status as TransactionStatus, status)) { + throw new BadRequestException( + `Transaction status cannot transition from ${transaction.status} to ${status}`, + ); + } + + if (transaction.status === status) { + return transaction; + } + + return this.prisma.transaction.update({ + where: { id: transactionId }, + data: { status }, + }); + } +} diff --git a/src/types/prisma.types.ts b/src/types/prisma.types.ts index 5a13931c..d6513dc5 100644 --- a/src/types/prisma.types.ts +++ b/src/types/prisma.types.ts @@ -78,7 +78,6 @@ export enum TransactionStatus { PENDING = 'PENDING', COMPLETED = 'COMPLETED', CANCELLED = 'CANCELLED', - FAILED = 'FAILED', } export enum FraudSeverity { diff --git a/test/admin/transaction-status.dto.spec.ts b/test/admin/transaction-status.dto.spec.ts new file mode 100644 index 00000000..83aa763d --- /dev/null +++ b/test/admin/transaction-status.dto.spec.ts @@ -0,0 +1,23 @@ +import { BadRequestException, ValidationPipe } from '@nestjs/common'; +import { ArgumentMetadata } from '@nestjs/common/interfaces'; +import { UpdateTransactionStatusDto } from '../../src/admin/dto/admin.dto'; + +describe('UpdateTransactionStatusDto', () => { + const pipe = new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }); + + const metadata: ArgumentMetadata = { + type: 'body', + metatype: UpdateTransactionStatusDto, + data: '', + }; + + it('rejects invalid transaction status values', async () => { + await expect(pipe.transform({ status: 'FAILED' }, metadata)).rejects.toBeInstanceOf( + BadRequestException, + ); + }); +}); diff --git a/test/transactions/transactions.service.spec.ts b/test/transactions/transactions.service.spec.ts new file mode 100644 index 00000000..b35c77ba --- /dev/null +++ b/test/transactions/transactions.service.spec.ts @@ -0,0 +1,116 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { PrismaService } from '../../src/database/prisma.service'; +import { TransactionStatus, TransactionType } from '../../src/types/prisma.types'; +import { TransactionsService } from '../../src/transactions/transactions.service'; + +describe('TransactionsService', () => { + let service: TransactionsService; + + const mockPrismaService = { + transaction: { + create: jest.fn(), + findUnique: jest.fn(), + update: jest.fn(), + }, + } as any; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TransactionsService, + { provide: PrismaService, useValue: mockPrismaService }, + ], + }).compile(); + + service = module.get(TransactionsService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('creates transactions with PENDING status by default', async () => { + mockPrismaService.transaction.create.mockResolvedValue({ + id: 'txn-1', + status: TransactionStatus.PENDING, + }); + + await service.createTransaction({ + propertyId: 'property-1', + buyerId: 'buyer-1', + sellerId: 'seller-1', + amount: 1000, + type: TransactionType.SALE, + }); + + expect(mockPrismaService.transaction.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + propertyId: 'property-1', + buyerId: 'buyer-1', + sellerId: 'seller-1', + amount: 1000, + type: TransactionType.SALE, + status: TransactionStatus.PENDING, + }), + }); + }); + + it('allows a valid transition from PENDING to COMPLETED', async () => { + mockPrismaService.transaction.findUnique.mockResolvedValue({ + id: 'txn-1', + status: TransactionStatus.PENDING, + }); + mockPrismaService.transaction.update.mockResolvedValue({ + id: 'txn-1', + status: TransactionStatus.COMPLETED, + }); + + const result = await service.updateTransactionStatus('txn-1', TransactionStatus.COMPLETED); + + expect(mockPrismaService.transaction.update).toHaveBeenCalledWith({ + where: { id: 'txn-1' }, + data: { status: TransactionStatus.COMPLETED }, + }); + expect(result.status).toBe(TransactionStatus.COMPLETED); + }); + + it('allows a valid transition from PENDING to CANCELLED', async () => { + mockPrismaService.transaction.findUnique.mockResolvedValue({ + id: 'txn-2', + status: TransactionStatus.PENDING, + }); + mockPrismaService.transaction.update.mockResolvedValue({ + id: 'txn-2', + status: TransactionStatus.CANCELLED, + }); + + const result = await service.updateTransactionStatus('txn-2', TransactionStatus.CANCELLED); + + expect(mockPrismaService.transaction.update).toHaveBeenCalledWith({ + where: { id: 'txn-2' }, + data: { status: TransactionStatus.CANCELLED }, + }); + expect(result.status).toBe(TransactionStatus.CANCELLED); + }); + + it('rejects invalid status transitions', async () => { + mockPrismaService.transaction.findUnique.mockResolvedValue({ + id: 'txn-3', + status: TransactionStatus.COMPLETED, + }); + + await expect( + service.updateTransactionStatus('txn-3', TransactionStatus.CANCELLED), + ).rejects.toBeInstanceOf(BadRequestException); + expect(mockPrismaService.transaction.update).not.toHaveBeenCalled(); + }); + + it('rejects updates for missing transactions', async () => { + mockPrismaService.transaction.findUnique.mockResolvedValue(null); + + await expect( + service.updateTransactionStatus('missing-txn', TransactionStatus.COMPLETED), + ).rejects.toBeInstanceOf(NotFoundException); + }); +}); From aba8e1e0eff8874adefb92f1bcbe0dbc9f033654 Mon Sep 17 00:00:00 2001 From: ekenesamuel Date: Wed, 29 Apr 2026 01:01:07 +0100 Subject: [PATCH 2/2] Fix admin dashboard transaction aggregate tuple --- src/admin/admin.service.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/admin/admin.service.ts b/src/admin/admin.service.ts index e13ad41e..5af55ffd 100644 --- a/src/admin/admin.service.ts +++ b/src/admin/admin.service.ts @@ -66,13 +66,8 @@ export class AdminService { this.prisma.property.count({ where: { status: PropertyStatus.ACTIVE } }), ]); - const [ - completedTransactions, - pendingTransactions, - failedTransactions, - salesAggregate, - rentAggregate, - ] = await Promise.all([ + const [completedTransactions, pendingTransactions, salesAggregate, rentAggregate] = + await Promise.all([ this.prisma.transaction.count({ where: { status: TransactionStatus.COMPLETED } }), this.prisma.transaction.count({ where: { status: TransactionStatus.PENDING } }), this.prisma.transaction.aggregate({