Record activity history and support unpaginated transaction fetch

Adds an ActivityLog model that captures create/update/delete actions on
transactions, accounts, and valuations, with snapshots that survive a
cascading account deletion so a deleted transaction's amount and
description remain auditable. Each service writes its log entry inside
the same $transaction as the underlying mutation, so a failed mutation
rolls back the log too.

Also extends GET /transactions with all=true to skip pagination (capped
at 10,000 rows) so the upcoming CSV export can pull every matching row
in one request.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-25 13:53:22 -07:00
parent 7308d6d847
commit 4b3b1c71f9
16 changed files with 959 additions and 14 deletions
@@ -0,0 +1,36 @@
-- CreateEnum
CREATE TYPE "EntityType" AS ENUM ('TRANSACTION', 'ACCOUNT', 'ACCOUNT_VALUATION');
-- CreateEnum
CREATE TYPE "ActivityAction" AS ENUM ('CREATE', 'UPDATE', 'DELETE');
-- CreateTable
CREATE TABLE "activity_logs" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"entity_type" "EntityType" NOT NULL,
"entity_id" TEXT NOT NULL,
"action" "ActivityAction" NOT NULL,
"account_id" TEXT,
"destination_account_id" TEXT,
"summary" TEXT,
"snapshot" JSONB,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "activity_logs_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "activity_logs_user_id_created_at_idx" ON "activity_logs"("user_id", "created_at");
-- CreateIndex
CREATE INDEX "activity_logs_user_id_account_id_created_at_idx" ON "activity_logs"("user_id", "account_id", "created_at");
-- CreateIndex
CREATE INDEX "activity_logs_user_id_destination_account_id_created_at_idx" ON "activity_logs"("user_id", "destination_account_id", "created_at");
-- CreateIndex
CREATE INDEX "activity_logs_user_id_entity_type_created_at_idx" ON "activity_logs"("user_id", "entity_type", "created_at");
-- AddForeignKey
ALTER TABLE "activity_logs" ADD CONSTRAINT "activity_logs_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -18,6 +18,7 @@ model User {
accounts Account[]
transactions Transaction[]
categories Category[]
activityLogs ActivityLog[]
@@map("users")
}
@@ -109,3 +110,36 @@ model Category {
@@unique([userId, name])
@@map("categories")
}
enum EntityType {
TRANSACTION
ACCOUNT
ACCOUNT_VALUATION
}
enum ActivityAction {
CREATE
UPDATE
DELETE
}
model ActivityLog {
id String @id @default(uuid())
userId String @map("user_id")
entityType EntityType @map("entity_type")
entityId String @map("entity_id")
action ActivityAction
accountId String? @map("account_id")
destinationAccountId String? @map("destination_account_id")
summary String?
snapshot Json?
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, createdAt])
@@index([userId, accountId, createdAt])
@@index([userId, destinationAccountId, createdAt])
@@index([userId, entityType, createdAt])
@@map("activity_logs")
}
@@ -3,9 +3,10 @@ import { AccountsService } from './accounts.service';
import { AccountsController } from './accounts.controller';
import { AuthModule } from '../auth/auth.module';
import { ValuationsModule } from '../valuations/valuations.module';
import { ActivityLogModule } from '../activity-log/activity-log.module';
@Module({
imports: [AuthModule, ValuationsModule],
imports: [AuthModule, ValuationsModule, ActivityLogModule],
controllers: [AccountsController],
providers: [AccountsService],
exports: [AccountsService],
@@ -3,6 +3,7 @@ import { NotFoundException } from '@nestjs/common';
import { AccountsService } from './accounts.service';
import { PrismaService } from '../prisma/prisma.service';
import { EncryptionService } from '../encryption/encryption.service';
import { ActivityLogService } from '../activity-log/activity-log.service';
import { AccountType, TransactionType } from '@prisma/client';
jest.mock('@prisma/client', () => ({
@@ -18,6 +19,12 @@ jest.mock('@prisma/client', () => ({
RETIREMENT: 'RETIREMENT',
},
TransactionType: { INCOME: 'INCOME', EXPENSE: 'EXPENSE', TRANSFER: 'TRANSFER' },
EntityType: {
TRANSACTION: 'TRANSACTION',
ACCOUNT: 'ACCOUNT',
ACCOUNT_VALUATION: 'ACCOUNT_VALUATION',
},
ActivityAction: { CREATE: 'CREATE', UPDATE: 'UPDATE', DELETE: 'DELETE' },
}));
describe('AccountsService', () => {
@@ -67,6 +74,10 @@ describe('AccountsService', () => {
decryptField: jest.fn((v: string | null) => v),
};
const mockActivityLog = {
log: jest.fn().mockResolvedValue(undefined),
};
beforeEach(async () => {
jest.clearAllMocks();
txClient = makeTxClient();
@@ -80,6 +91,7 @@ describe('AccountsService', () => {
AccountsService,
{ provide: PrismaService, useValue: mockPrisma },
{ provide: EncryptionService, useValue: mockEncryption },
{ provide: ActivityLogService, useValue: mockActivityLog },
],
}).compile();
@@ -419,4 +431,136 @@ describe('AccountsService', () => {
expect(mockPrisma.$transaction).toHaveBeenCalled();
});
});
describe('activity logging', () => {
it('logs CREATE for a new account', async () => {
mockPrisma.account.create.mockResolvedValue({ ...mockAccount, id: 'acc-new' });
await service.create(userId, {
name: 'Main Checking',
type: AccountType.CHECKING,
balance: 5000,
});
expect(mockActivityLog.log).toHaveBeenCalledWith(
expect.objectContaining({
userId,
entityType: 'ACCOUNT',
entityId: 'acc-new',
action: 'CREATE',
accountId: 'acc-new',
}),
);
});
it('logs UPDATE for an account edit', async () => {
mockPrisma.account.findFirst.mockResolvedValue(mockAccount);
mockPrisma.account.update.mockResolvedValue({ ...mockAccount, name: 'Renamed' });
await service.update(userId, 'acc-1', { name: 'Renamed' });
expect(mockActivityLog.log).toHaveBeenCalledWith(
expect.objectContaining({
userId,
entityType: 'ACCOUNT',
entityId: 'acc-1',
action: 'UPDATE',
accountId: 'acc-1',
}),
);
});
it('logs DELETE for the account AND a DELETE per cascaded transaction', async () => {
mockPrisma.account.findFirst.mockResolvedValue(mockAccount);
txClient.transaction.findMany.mockResolvedValue([
{
id: 't1',
userId,
accountId: 'acc-1',
destinationAccountId: null,
categoryId: null,
type: TransactionType.EXPENSE,
amount: 50,
description: 'Coffee',
date: new Date('2026-04-01'),
},
{
id: 't2',
userId,
accountId: 'acc-1',
destinationAccountId: 'acc-2',
categoryId: null,
type: TransactionType.TRANSFER,
amount: 100,
description: 'Move money',
date: new Date('2026-04-02'),
},
]);
txClient.account.findMany.mockResolvedValue([
{ id: 'acc-2', type: AccountType.SAVINGS },
]);
txClient.account.delete.mockResolvedValue(mockAccount);
await service.remove(userId, 'acc-1');
// Account-level DELETE log
expect(mockActivityLog.log).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'ACCOUNT',
entityId: 'acc-1',
action: 'DELETE',
}),
);
// One DELETE log per cascaded transaction
expect(mockActivityLog.log).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'TRANSACTION',
entityId: 't1',
action: 'DELETE',
}),
);
expect(mockActivityLog.log).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'TRANSACTION',
entityId: 't2',
action: 'DELETE',
}),
);
});
it('logs DELETE entries BEFORE account.delete (so the audit trail survives the cascade)', async () => {
const callOrder: string[] = [];
mockPrisma.account.findFirst.mockResolvedValue(mockAccount);
txClient.transaction.findMany.mockResolvedValue([
{
id: 't1',
userId,
accountId: 'acc-1',
destinationAccountId: null,
categoryId: null,
type: TransactionType.EXPENSE,
amount: 50,
description: 'Coffee',
date: new Date('2026-04-01'),
},
]);
txClient.account.findMany.mockResolvedValue([]);
mockActivityLog.log.mockImplementation(async (input: any) => {
callOrder.push(`log:${input.entityType}:${input.action}`);
});
txClient.account.delete.mockImplementation(async () => {
callOrder.push('delete:account');
return mockAccount;
});
await service.remove(userId, 'acc-1');
// All log calls must precede the account.delete
const deleteIdx = callOrder.indexOf('delete:account');
expect(deleteIdx).toBeGreaterThan(0);
callOrder.slice(0, deleteIdx).forEach((entry) => {
expect(entry.startsWith('log:')).toBe(true);
});
});
});
});
@@ -3,9 +3,10 @@ import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import { TransactionType } from '@prisma/client';
import { TransactionType, EntityType, ActivityAction } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { EncryptionService } from '../encryption/encryption.service';
import { ActivityLogService } from '../activity-log/activity-log.service';
import { CreateAccountDto } from './dto/create-account.dto';
import { UpdateAccountDto } from './dto/update-account.dto';
import {
@@ -13,11 +14,58 @@ import {
signedDelta,
} from '../transactions/transactions.service';
function accountSnapshot(a: {
id: string;
name: string;
type: any;
balance: any;
institution: string | null;
}) {
return {
id: a.id,
name: a.name,
type: a.type,
balance: Number(a.balance),
institution: a.institution,
};
}
function txnSnapshot(t: {
id: string;
amount: any;
type: any;
accountId: string;
destinationAccountId: string | null;
categoryId: string | null;
description: string;
date: Date;
}) {
return {
id: t.id,
amount: Number(t.amount),
type: t.type,
accountId: t.accountId,
destinationAccountId: t.destinationAccountId,
categoryId: t.categoryId,
description: t.description,
date: t.date instanceof Date ? t.date.toISOString() : t.date,
};
}
function txnSummary(t: { amount: any; type: any; description: string }): string {
const amount = Number(t.amount).toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
return `$${amount} ${t.type}${t.description}`;
}
@Injectable()
export class AccountsService {
constructor(
private prisma: PrismaService,
private encryption: EncryptionService,
private activityLog: ActivityLogService,
) {}
async create(userId: string, dto: CreateAccountDto) {
@@ -32,6 +80,15 @@ export class AccountsService {
});
data.sortOrder = (max._max.sortOrder ?? -1) + 1;
const account = await this.prisma.account.create({ data });
await this.activityLog.log({
userId,
entityType: EntityType.ACCOUNT,
entityId: account.id,
action: ActivityAction.CREATE,
accountId: account.id,
summary: account.name,
snapshot: accountSnapshot(account),
});
return this.decryptAccount(account);
}
@@ -82,11 +139,20 @@ export class AccountsService {
data.accountNumber = this.encryption.encryptField(data.accountNumber);
}
const account = await this.prisma.account.update({ where: { id }, data });
await this.activityLog.log({
userId,
entityType: EntityType.ACCOUNT,
entityId: account.id,
action: ActivityAction.UPDATE,
accountId: account.id,
summary: account.name,
snapshot: accountSnapshot(account),
});
return this.decryptAccount(account);
}
async remove(userId: string, id: string) {
await this.findOne(userId, id);
const existing = await this.findOne(userId, id);
// Prisma cascades transaction rows away when the account is deleted, which
// bypasses TransactionsService.remove() entirely. For transfers, that
@@ -152,6 +218,33 @@ export class AccountsService {
}
}
// Snapshot every cascaded transaction BEFORE delete wipes them, so the
// audit trail survives the cascade.
for (const t of related) {
await this.activityLog.log({
userId,
entityType: EntityType.TRANSACTION,
entityId: t.id,
action: ActivityAction.DELETE,
accountId: t.accountId,
destinationAccountId: t.destinationAccountId,
summary: txnSummary(t),
snapshot: txnSnapshot(t),
tx,
});
}
await this.activityLog.log({
userId,
entityType: EntityType.ACCOUNT,
entityId: id,
action: ActivityAction.DELETE,
accountId: id,
summary: existing.name,
snapshot: accountSnapshot(existing),
tx,
});
return tx.account.delete({ where: { id } });
});
}
@@ -0,0 +1,19 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import {
ActivityLogService,
type ActivityLogFilters,
} from './activity-log.service';
import { AuthGuard } from '../auth/auth.guard';
import { CurrentUser } from '../auth/user.decorator';
import type { User } from '@prisma/client';
@Controller('activity')
@UseGuards(AuthGuard)
export class ActivityLogController {
constructor(private readonly activityLog: ActivityLogService) {}
@Get()
findAll(@CurrentUser() user: User, @Query() filters: ActivityLogFilters) {
return this.activityLog.findAll(user.id, filters);
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { ActivityLogService } from './activity-log.service';
import { ActivityLogController } from './activity-log.controller';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [AuthModule],
controllers: [ActivityLogController],
providers: [ActivityLogService],
exports: [ActivityLogService],
})
export class ActivityLogModule {}
@@ -0,0 +1,185 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ActivityLogService } from './activity-log.service';
import { PrismaService } from '../prisma/prisma.service';
jest.mock('@prisma/client', () => ({
PrismaClient: class {},
EntityType: {
TRANSACTION: 'TRANSACTION',
ACCOUNT: 'ACCOUNT',
ACCOUNT_VALUATION: 'ACCOUNT_VALUATION',
},
ActivityAction: { CREATE: 'CREATE', UPDATE: 'UPDATE', DELETE: 'DELETE' },
}));
import { EntityType, ActivityAction } from '@prisma/client';
describe('ActivityLogService', () => {
let service: ActivityLogService;
const userId = 'user-1';
const mockPrisma: any = {
activityLog: {
create: jest.fn(),
findMany: jest.fn(),
count: jest.fn(),
},
};
beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
ActivityLogService,
{ provide: PrismaService, useValue: mockPrisma },
],
}).compile();
service = module.get<ActivityLogService>(ActivityLogService);
});
describe('log', () => {
it('creates an activity row with all fields populated', async () => {
mockPrisma.activityLog.create.mockResolvedValue({});
await service.log({
userId,
entityType: EntityType.TRANSACTION,
entityId: 'txn-1',
action: ActivityAction.CREATE,
accountId: 'acc-1',
destinationAccountId: 'acc-2',
summary: '$50.00 EXPENSE — Coffee',
snapshot: { id: 'txn-1', amount: 50, type: 'EXPENSE' },
});
expect(mockPrisma.activityLog.create).toHaveBeenCalledWith({
data: {
userId,
entityType: EntityType.TRANSACTION,
entityId: 'txn-1',
action: ActivityAction.CREATE,
accountId: 'acc-1',
destinationAccountId: 'acc-2',
summary: '$50.00 EXPENSE — Coffee',
snapshot: { id: 'txn-1', amount: 50, type: 'EXPENSE' },
},
});
});
it('honors a passed tx client and skips the default prisma client', async () => {
const tx: any = { activityLog: { create: jest.fn().mockResolvedValue({}) } };
await service.log({
userId,
entityType: EntityType.ACCOUNT,
entityId: 'acc-1',
action: ActivityAction.DELETE,
tx,
});
expect(tx.activityLog.create).toHaveBeenCalledTimes(1);
expect(mockPrisma.activityLog.create).not.toHaveBeenCalled();
});
it('falls back to prisma when no tx is provided', async () => {
mockPrisma.activityLog.create.mockResolvedValue({});
await service.log({
userId,
entityType: EntityType.ACCOUNT,
entityId: 'acc-1',
action: ActivityAction.UPDATE,
});
expect(mockPrisma.activityLog.create).toHaveBeenCalledTimes(1);
});
});
describe('findAll', () => {
it('returns paginated rows newest first with default page/limit', async () => {
const rows = [{ id: 'a' }, { id: 'b' }];
mockPrisma.activityLog.findMany.mockResolvedValue(rows);
mockPrisma.activityLog.count.mockResolvedValue(2);
const result = await service.findAll(userId, {});
expect(mockPrisma.activityLog.findMany).toHaveBeenCalledWith({
where: { userId },
orderBy: { createdAt: 'desc' },
skip: 0,
take: 20,
});
expect(result).toEqual({ data: rows, total: 2, page: 1, limit: 20 });
});
it('filters by entityType', async () => {
mockPrisma.activityLog.findMany.mockResolvedValue([]);
mockPrisma.activityLog.count.mockResolvedValue(0);
await service.findAll(userId, { entityType: EntityType.TRANSACTION });
expect(mockPrisma.activityLog.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId, entityType: EntityType.TRANSACTION },
}),
);
});
it('filters by action', async () => {
mockPrisma.activityLog.findMany.mockResolvedValue([]);
mockPrisma.activityLog.count.mockResolvedValue(0);
await service.findAll(userId, { action: ActivityAction.DELETE });
expect(mockPrisma.activityLog.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId, action: ActivityAction.DELETE },
}),
);
});
it('filters by accountId across primary OR destination', async () => {
mockPrisma.activityLog.findMany.mockResolvedValue([]);
mockPrisma.activityLog.count.mockResolvedValue(0);
await service.findAll(userId, { accountId: 'acc-1' });
expect(mockPrisma.activityLog.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
userId,
OR: [
{ accountId: 'acc-1' },
{ destinationAccountId: 'acc-1' },
],
},
}),
);
});
it('filters by date range', async () => {
mockPrisma.activityLog.findMany.mockResolvedValue([]);
mockPrisma.activityLog.count.mockResolvedValue(0);
await service.findAll(userId, {
startDate: '2026-04-01',
endDate: '2026-04-30',
});
const call = mockPrisma.activityLog.findMany.mock.calls[0][0];
expect(call.where.createdAt.gte).toEqual(new Date('2026-04-01'));
expect(call.where.createdAt.lte).toEqual(new Date('2026-04-30'));
});
it('paginates with custom page and limit', async () => {
mockPrisma.activityLog.findMany.mockResolvedValue([]);
mockPrisma.activityLog.count.mockResolvedValue(50);
await service.findAll(userId, { page: 3, limit: 10 });
expect(mockPrisma.activityLog.findMany).toHaveBeenCalledWith(
expect.objectContaining({ skip: 20, take: 10 }),
);
});
});
});
@@ -0,0 +1,91 @@
import { Injectable } from '@nestjs/common';
import { Prisma, EntityType, ActivityAction } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
export interface LogInput {
userId: string;
entityType: EntityType;
entityId: string;
action: ActivityAction;
accountId?: string | null;
destinationAccountId?: string | null;
summary?: string;
snapshot?: Prisma.InputJsonValue;
tx?: Prisma.TransactionClient;
}
export interface ActivityLogFilters {
entityType?: EntityType;
action?: ActivityAction;
accountId?: string;
startDate?: string;
endDate?: string;
page?: number;
limit?: number;
}
@Injectable()
export class ActivityLogService {
constructor(private prisma: PrismaService) {}
async log(input: LogInput): Promise<void> {
const {
tx,
userId,
entityType,
entityId,
action,
accountId,
destinationAccountId,
summary,
snapshot,
} = input;
const data: any = { userId, entityType, entityId, action };
if (accountId !== undefined) data.accountId = accountId;
if (destinationAccountId !== undefined) {
data.destinationAccountId = destinationAccountId;
}
if (summary !== undefined) data.summary = summary;
if (snapshot !== undefined) data.snapshot = snapshot;
const client = tx ?? this.prisma;
await client.activityLog.create({ data });
}
async findAll(userId: string, filters: ActivityLogFilters) {
const page = Number(filters.page) || 1;
const limit = Number(filters.limit) || 20;
const where: Prisma.ActivityLogWhereInput = { userId };
if (filters.entityType) where.entityType = filters.entityType;
if (filters.action) where.action = filters.action;
if (filters.accountId) {
where.OR = [
{ accountId: filters.accountId },
{ destinationAccountId: filters.accountId },
];
}
if (filters.startDate || filters.endDate) {
where.createdAt = {};
if (filters.startDate) {
(where.createdAt as any).gte = new Date(filters.startDate);
}
if (filters.endDate) {
(where.createdAt as any).lte = new Date(filters.endDate);
}
}
const [data, total] = await Promise.all([
this.prisma.activityLog.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
}),
this.prisma.activityLog.count({ where }),
]);
return { data, total, page, limit };
}
}
+2
View File
@@ -12,6 +12,7 @@ import { FilesModule } from './files/files.module';
import { AggregationsModule } from './aggregations/aggregations.module';
import { AdvisorModule } from './advisor/advisor.module';
import { ValuationsModule } from './valuations/valuations.module';
import { ActivityLogModule } from './activity-log/activity-log.module';
@Module({
imports: [
@@ -26,6 +27,7 @@ import { ValuationsModule } from './valuations/valuations.module';
AggregationsModule,
AdvisorModule,
ValuationsModule,
ActivityLogModule,
],
controllers: [AppController],
providers: [AppService],
@@ -2,9 +2,10 @@ import { Module } from '@nestjs/common';
import { TransactionsService } from './transactions.service';
import { TransactionsController } from './transactions.controller';
import { AuthModule } from '../auth/auth.module';
import { ActivityLogModule } from '../activity-log/activity-log.module';
@Module({
imports: [AuthModule],
imports: [AuthModule, ActivityLogModule],
controllers: [TransactionsController],
providers: [TransactionsService],
exports: [TransactionsService],
@@ -3,6 +3,7 @@ import { BadRequestException, NotFoundException } from '@nestjs/common';
import { TransactionsService } from './transactions.service';
import { PrismaService } from '../prisma/prisma.service';
import { EncryptionService } from '../encryption/encryption.service';
import { ActivityLogService } from '../activity-log/activity-log.service';
import { TransactionType, AccountType } from '@prisma/client';
jest.mock('@prisma/client', () => ({
@@ -18,6 +19,12 @@ jest.mock('@prisma/client', () => ({
INVESTMENT: 'INVESTMENT',
RETIREMENT: 'RETIREMENT',
},
EntityType: {
TRANSACTION: 'TRANSACTION',
ACCOUNT: 'ACCOUNT',
ACCOUNT_VALUATION: 'ACCOUNT_VALUATION',
},
ActivityAction: { CREATE: 'CREATE', UPDATE: 'UPDATE', DELETE: 'DELETE' },
Prisma: {},
}));
@@ -72,6 +79,10 @@ describe('TransactionsService', () => {
decryptField: jest.fn((v: string | null) => v),
};
const mockActivityLog = {
log: jest.fn().mockResolvedValue(undefined),
};
beforeEach(async () => {
jest.clearAllMocks();
txClient = makeTxClient();
@@ -82,6 +93,7 @@ describe('TransactionsService', () => {
TransactionsService,
{ provide: PrismaService, useValue: mockPrisma },
{ provide: EncryptionService, useValue: mockEncryption },
{ provide: ActivityLogService, useValue: mockActivityLog },
],
}).compile();
@@ -390,4 +402,105 @@ describe('TransactionsService', () => {
await expect(service.remove(userId, 'nope')).rejects.toThrow(NotFoundException);
});
});
describe('activity logging', () => {
it('logs CREATE inside the same $transaction', async () => {
mockPrisma.account.findMany.mockResolvedValue([
{ id: 'acc-1', type: AccountType.CHECKING },
]);
txClient.transaction.create.mockResolvedValue({
...baseTxn,
id: 'txn-new',
type: TransactionType.EXPENSE,
amount: 42.5,
});
await service.create(userId, {
accountId: 'acc-1',
amount: 42.5,
type: TransactionType.EXPENSE,
description: 'Grocery run',
date: '2026-04-01',
});
expect(mockActivityLog.log).toHaveBeenCalledWith(
expect.objectContaining({
userId,
entityType: 'TRANSACTION',
entityId: 'txn-new',
action: 'CREATE',
accountId: 'acc-1',
tx: txClient,
}),
);
});
it('logs UPDATE on edit', async () => {
const existing = {
...baseTxn,
type: TransactionType.EXPENSE,
amount: 100,
};
mockPrisma.transaction.findFirst.mockResolvedValue(existing);
mockPrisma.account.findMany.mockResolvedValue([
{ id: 'acc-1', type: AccountType.CHECKING },
]);
txClient.transaction.update.mockResolvedValue({ ...existing, amount: 150 });
await service.update(userId, 'txn-1', { amount: 150 });
expect(mockActivityLog.log).toHaveBeenCalledWith(
expect.objectContaining({
userId,
entityType: 'TRANSACTION',
entityId: 'txn-1',
action: 'UPDATE',
tx: txClient,
}),
);
});
it('logs DELETE before tx.transaction.delete', async () => {
const callOrder: string[] = [];
const existing = { ...baseTxn, type: TransactionType.EXPENSE, amount: 100 };
mockPrisma.transaction.findFirst.mockResolvedValue(existing);
txClient.account.findMany.mockResolvedValue([
{ id: 'acc-1', type: AccountType.CHECKING },
]);
mockActivityLog.log.mockImplementation(async () => {
callOrder.push('log');
});
txClient.transaction.delete.mockImplementation(async () => {
callOrder.push('delete');
return existing;
});
await service.remove(userId, 'txn-1');
expect(mockActivityLog.log).toHaveBeenCalledWith(
expect.objectContaining({
userId,
entityType: 'TRANSACTION',
entityId: 'txn-1',
action: 'DELETE',
tx: txClient,
snapshot: expect.objectContaining({ id: 'txn-1', amount: 100 }),
}),
);
expect(callOrder).toEqual(['log', 'delete']);
});
});
describe('findAll with all=true', () => {
it('omits skip/take when all=true and caps the row count', async () => {
mockPrisma.transaction.findMany.mockResolvedValue([]);
mockPrisma.transaction.count.mockResolvedValue(0);
await service.findAll(userId, { all: true } as any);
const call = mockPrisma.transaction.findMany.mock.calls[0][0];
expect(call.skip).toBeUndefined();
expect(call.take).toBe(10000);
});
});
});
@@ -5,9 +5,18 @@ import {
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { EncryptionService } from '../encryption/encryption.service';
import { ActivityLogService } from '../activity-log/activity-log.service';
import { CreateTransactionDto } from './dto/create-transaction.dto';
import { UpdateTransactionDto } from './dto/update-transaction.dto';
import { TransactionType, AccountType, Prisma } from '@prisma/client';
import {
TransactionType,
AccountType,
EntityType,
ActivityAction,
Prisma,
} from '@prisma/client';
export const EXPORT_ROW_CAP = 10000;
export interface TransactionFilters {
accountId?: string;
@@ -17,6 +26,41 @@ export interface TransactionFilters {
endDate?: string;
page?: number;
limit?: number;
all?: boolean | string;
}
function transactionSnapshot(t: {
id: string;
amount: any;
type: TransactionType;
accountId: string;
destinationAccountId: string | null;
categoryId: string | null;
description: string;
date: Date;
}) {
return {
id: t.id,
amount: Number(t.amount),
type: t.type,
accountId: t.accountId,
destinationAccountId: t.destinationAccountId,
categoryId: t.categoryId,
description: t.description,
date: t.date instanceof Date ? t.date.toISOString() : t.date,
};
}
function transactionSummary(t: {
amount: any;
type: TransactionType;
description: string;
}): string {
const amount = Number(t.amount).toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
return `$${amount} ${t.type}${t.description}`;
}
const txnInclude = {
@@ -84,6 +128,7 @@ export class TransactionsService {
constructor(
private prisma: PrismaService,
private encryption: EncryptionService,
private activityLog: ActivityLogService,
) {}
async create(userId: string, dto: CreateTransactionDto) {
@@ -152,6 +197,18 @@ export class TransactionsService {
});
}
await this.activityLog.log({
userId,
entityType: EntityType.TRANSACTION,
entityId: created.id,
action: ActivityAction.CREATE,
accountId: created.accountId,
destinationAccountId: created.destinationAccountId,
summary: transactionSummary(created),
snapshot: transactionSnapshot(created),
tx,
});
return created;
});
return this.decryptTransaction(txn);
@@ -159,6 +216,7 @@ export class TransactionsService {
async findAll(userId: string, filters: TransactionFilters) {
const { accountId, categoryId, type, startDate, endDate } = filters;
const all = filters.all === true || filters.all === 'true';
const page = Number(filters.page) || 1;
const limit = Number(filters.limit) || 20;
@@ -178,18 +236,29 @@ export class TransactionsService {
if (endDate) (where.date as any).lte = new Date(endDate);
}
const findManyArgs: Prisma.TransactionFindManyArgs = {
where,
include: txnInclude,
orderBy: { date: 'desc' },
};
if (all) {
findManyArgs.take = EXPORT_ROW_CAP;
} else {
findManyArgs.skip = (page - 1) * limit;
findManyArgs.take = limit;
}
const [data, total] = await Promise.all([
this.prisma.transaction.findMany({
where,
include: txnInclude,
orderBy: { date: 'desc' },
skip: (page - 1) * limit,
take: limit,
}),
this.prisma.transaction.findMany(findManyArgs),
this.prisma.transaction.count({ where }),
]);
return { data: data.map((t) => this.decryptTransaction(t)), total, page, limit };
return {
data: data.map((t) => this.decryptTransaction(t)),
total,
page: all ? 1 : page,
limit: all ? data.length : limit,
};
}
async findOne(userId: string, id: string) {
@@ -312,6 +381,18 @@ export class TransactionsService {
include: txnInclude,
});
await this.activityLog.log({
userId,
entityType: EntityType.TRANSACTION,
entityId: updated.id,
action: ActivityAction.UPDATE,
accountId: updated.accountId,
destinationAccountId: updated.destinationAccountId,
summary: transactionSummary(updated),
snapshot: transactionSnapshot(updated),
tx,
});
// Apply the new transaction's effect
if (balanceAffected) {
const newPrimaryType = typeById.get(updated.accountId);
@@ -421,6 +502,18 @@ export class TransactionsService {
}
}
await this.activityLog.log({
userId,
entityType: EntityType.TRANSACTION,
entityId: existing.id,
action: ActivityAction.DELETE,
accountId: existing.accountId,
destinationAccountId: existing.destinationAccountId,
summary: transactionSummary(existing),
snapshot: transactionSnapshot(existing),
tx,
});
return tx.transaction.delete({ where: { id } });
});
}
@@ -1,7 +1,9 @@
import { Module } from '@nestjs/common';
import { ValuationsService } from './valuations.service';
import { ActivityLogModule } from '../activity-log/activity-log.module';
@Module({
imports: [ActivityLogModule],
providers: [ValuationsService],
exports: [ValuationsService],
})
@@ -2,6 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing';
import { NotFoundException } from '@nestjs/common';
import { ValuationsService } from './valuations.service';
import { PrismaService } from '../prisma/prisma.service';
import { ActivityLogService } from '../activity-log/activity-log.service';
jest.mock('@prisma/client', () => ({
PrismaClient: class {},
@@ -15,6 +16,12 @@ jest.mock('@prisma/client', () => ({
INVESTMENT: 'INVESTMENT',
RETIREMENT: 'RETIREMENT',
},
EntityType: {
TRANSACTION: 'TRANSACTION',
ACCOUNT: 'ACCOUNT',
ACCOUNT_VALUATION: 'ACCOUNT_VALUATION',
},
ActivityAction: { CREATE: 'CREATE', UPDATE: 'UPDATE', DELETE: 'DELETE' },
}));
describe('ValuationsService', () => {
@@ -36,12 +43,17 @@ describe('ValuationsService', () => {
},
};
const mockActivityLog = {
log: jest.fn().mockResolvedValue(undefined),
};
beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
ValuationsService,
{ provide: PrismaService, useValue: mockPrisma },
{ provide: ActivityLogService, useValue: mockActivityLog },
],
}).compile();
@@ -181,4 +193,61 @@ describe('ValuationsService', () => {
await expect(service.remove(userId, 'stranger-v')).rejects.toThrow(NotFoundException);
});
});
describe('activity logging', () => {
it('logs CREATE on a new valuation', async () => {
mockPrisma.account.findFirst.mockResolvedValue({ id: accountId });
mockPrisma.accountValuation.create.mockResolvedValue({
id: 'v-1',
accountId,
date: new Date('2026-04-17'),
value: 100,
});
mockPrisma.accountValuation.findFirst.mockResolvedValue(null);
await service.create(userId, accountId, { date: '2026-04-17', value: 100 });
expect(mockActivityLog.log).toHaveBeenCalledWith(
expect.objectContaining({
userId,
entityType: 'ACCOUNT_VALUATION',
entityId: 'v-1',
action: 'CREATE',
accountId,
}),
);
});
it('logs DELETE before removing the valuation', async () => {
const callOrder: string[] = [];
mockPrisma.accountValuation.findFirst.mockResolvedValueOnce({
id: 'v-1',
accountId,
value: 300,
date: new Date('2026-04-17'),
account: { userId },
});
mockPrisma.accountValuation.findFirst.mockResolvedValueOnce(null);
mockActivityLog.log.mockImplementation(async () => {
callOrder.push('log');
});
mockPrisma.accountValuation.delete.mockImplementation(async () => {
callOrder.push('delete');
return {};
});
await service.remove(userId, 'v-1');
expect(mockActivityLog.log).toHaveBeenCalledWith(
expect.objectContaining({
userId,
entityType: 'ACCOUNT_VALUATION',
entityId: 'v-1',
action: 'DELETE',
accountId,
}),
);
expect(callOrder).toEqual(['log', 'delete']);
});
});
});
@@ -1,5 +1,7 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { EntityType, ActivityAction } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { ActivityLogService } from '../activity-log/activity-log.service';
import { CreateValuationDto } from './dto/create-valuation.dto';
/**
@@ -13,9 +15,36 @@ function parseDateInput(dateStr: string): Date {
return new Date(Date.UTC(y, m - 1, d, 12, 0, 0));
}
function valuationSnapshot(v: {
id: string;
accountId: string;
date: Date;
value: any;
}) {
return {
id: v.id,
accountId: v.accountId,
date: v.date instanceof Date ? v.date.toISOString() : v.date,
value: Number(v.value),
};
}
function valuationSummary(v: { value: any; date: Date }): string {
const value = Number(v.value).toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
const date =
v.date instanceof Date ? v.date.toISOString().slice(0, 10) : String(v.date).slice(0, 10);
return `$${value} on ${date}`;
}
@Injectable()
export class ValuationsService {
constructor(private prisma: PrismaService) {}
constructor(
private prisma: PrismaService,
private activityLog: ActivityLogService,
) {}
async create(userId: string, accountId: string, dto: CreateValuationDto) {
const account = await this.prisma.account.findFirst({
@@ -34,6 +63,17 @@ export class ValuationsService {
});
await this.recomputeBalance(accountId);
await this.activityLog.log({
userId,
entityType: EntityType.ACCOUNT_VALUATION,
entityId: created.id,
action: ActivityAction.CREATE,
accountId,
summary: valuationSummary(created),
snapshot: valuationSnapshot(created),
});
return created;
}
@@ -63,6 +103,16 @@ export class ValuationsService {
throw new NotFoundException('Valuation not found');
}
await this.activityLog.log({
userId,
entityType: EntityType.ACCOUNT_VALUATION,
entityId: valuation.id,
action: ActivityAction.DELETE,
accountId: valuation.accountId,
summary: valuationSummary(valuation),
snapshot: valuationSnapshot(valuation),
});
await this.prisma.accountValuation.delete({ where: { id: valuationId } });
await this.recomputeBalance(valuation.accountId);
return { success: true };