import { TransactionsService } from "../src/transactions/transactions.service"; import { createPrismaMock } from "./utils/mock-prisma"; import { BadRequestException } from "@nestjs/common"; const createService = () => { const prisma = createPrismaMock(); const plaid = { syncTransactionsForUser: jest.fn() }; const opaqueIds = { encode: jest.fn((kind: string, _userId: string, id: string) => `opaque_${kind}_${id}`), decode: jest.fn((_kind: string, _userId: string, token: string) => token.replace(/^opaque_[^_]+_/, "")), }; const viewRefs = { matches: jest.fn() }; const rawPayloads = { encrypt: jest.fn((payload: unknown) => ({ encrypted: true, version: 1, ciphertext: JSON.stringify(payload) })) }; const exportsService = { syncGoogleSheets: jest.fn().mockResolvedValue({ status: "synced" }) }; const service = new TransactionsService(prisma as any, plaid as any, opaqueIds as any, viewRefs as any, rawPayloads as any, exportsService as any); return { service, prisma, plaid, opaqueIds, viewRefs, rawPayloads, exportsService }; }; describe("TransactionsService", () => { it("calculates summary income/expense/net", async () => { const { service, prisma } = createService(); prisma.transactionRaw.findMany.mockResolvedValue([ { amount: -120.5, date: new Date("2025-01-01") }, { amount: 40, date: new Date("2025-01-02") }, { amount: -10, date: new Date("2025-01-03") } ]); const result = await service.summary("user_1", "2025-01-01", "2025-01-31"); expect(result.total).toBe("-90.50"); expect(result.income).toBe("130.50"); expect(result.expense).toBe("40.00"); expect(result.net).toBe("90.50"); expect(result.count).toBe(3); }); it("builds cashflow buckets for requested months", async () => { const { service, prisma } = createService(); prisma.transactionRaw.findMany.mockResolvedValue([ { amount: -200, date: new Date("2025-01-12") }, { amount: 50, date: new Date("2025-02-03") } ]); const result = await service.cashflow("user_1", 3); expect(result).toHaveLength(3); expect(result.every((row) => /^\d{4}-\d{2}$/.test(row.month))).toBe(true); }); it("returns merchant insights sorted by spend", async () => { const { service, prisma } = createService(); prisma.transactionRaw.findMany.mockResolvedValue([ { description: "Coffee Bar", amount: 12.5 }, { description: "Coffee Bar", amount: 7.5 }, { description: "Grocer", amount: 40 }, { description: "Refund", amount: -10 } ]); const result = await service.merchantInsights("user_1", 2); expect(result[0].merchant).toBe("Grocer"); expect(result[0].total).toBe("40.00"); expect(result[1].merchant).toBe("Coffee Bar"); expect(result[1].count).toBe(2); }); it("creates manual transaction and derived fields", async () => { const { service, prisma } = createService(); prisma.account.findFirst.mockResolvedValue({ id: "acct_1", userId: "user_1" }); prisma.transactionRaw.create.mockResolvedValue({ id: "tx_1" }); prisma.transactionDerived.create.mockResolvedValue({ id: "derived_1" }); const result = await service.createManualTransaction("user_1", { accountId: "acct_1", date: "2025-01-15", description: "Manual payment", amount: 123.45, category: "Operations", note: "Test note", attribution: "ours", splitMode: "equal", hidden: false }); expect(result).toEqual({ id: "opaque_transaction_tx_1" }); expect(prisma.transactionRaw.create).toHaveBeenCalled(); expect(prisma.transactionDerived.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ attribution: "ours", splitMode: "equal", splitMinePercent: 50, splitYoursPercent: 50, }), })); }); it("returns opaque transaction and account IDs in list responses", async () => { const { service, prisma } = createService(); prisma.transactionRaw.findMany.mockResolvedValue([ { id: "tx_raw_1", accountId: "acct_raw_1", description: "Coffee", amount: 4.25, derived: null, account: { ownershipType: "joint", ownerUserId: null }, date: new Date("2026-07-01"), source: "csv", }, ]); prisma.transactionRaw.count.mockResolvedValue(1); const result = await service.list("user_1", {}); expect(result.transactions[0].id).toBe("opaque_transaction_tx_raw_1"); expect(result.transactions[0].accountId).toBe("opaque_account_acct_raw_1"); expect(result.transactions[0].attribution).toBe("ours"); expect(result.transactions[0].split).toEqual({ mode: "none", minePercent: 100, yoursPercent: 0, mineAmount: 4.25, yoursAmount: 0, }); expect(result.transactions[0].id).not.toBe("tx_raw_1"); expect(result.transactions[0].accountId).not.toBe("acct_raw_1"); }); it("updates derived transaction attribution", async () => { const { service, prisma } = createService(); prisma.transactionRaw.findFirst.mockResolvedValue({ id: "tx_1", account: { ownershipType: "mine", ownerUserId: "user_1" }, }); prisma.transactionDerived.upsert.mockResolvedValue({ rawTransactionId: "tx_1", attribution: "yours", }); const result = await service.updateDerived("user_1", "opaque_transaction_tx_1", { userCategory: "Dining", userNotes: "Partner paid", attribution: "yours", splitMode: "custom", splitMinePercent: 40, splitYoursPercent: 60, isHidden: false, }); expect(result.attribution).toBe("yours"); expect(prisma.transactionDerived.upsert).toHaveBeenCalledWith(expect.objectContaining({ update: expect.objectContaining({ attribution: "yours", splitMode: "custom", splitMinePercent: 40, splitYoursPercent: 60 }), create: expect.objectContaining({ attribution: "yours", splitMode: "custom", splitMinePercent: 40, splitYoursPercent: 60 }), })); }); it("rejects custom splits that do not total 100", async () => { const { service, prisma } = createService(); prisma.transactionRaw.findFirst.mockResolvedValue({ id: "tx_1", account: { ownershipType: "joint", ownerUserId: null }, }); await expect(service.updateDerived("user_1", "opaque_transaction_tx_1", { splitMode: "custom", splitMinePercent: 70, splitYoursPercent: 20, })).rejects.toBeInstanceOf(BadRequestException); expect(prisma.transactionDerived.upsert).not.toHaveBeenCalled(); }); it("creates and lists transaction comments for owned transactions", async () => { const { service, prisma, opaqueIds } = createService(); prisma.transactionRaw.findFirst.mockResolvedValue({ id: "tx_1" }); prisma.transactionComment.create.mockResolvedValue({ id: "comment_1", rawTransactionId: "tx_1", userId: "user_1", body: "Discuss with partner", createdAt: new Date("2026-07-16T00:00:00.000Z"), updatedAt: new Date("2026-07-16T00:00:00.000Z"), user: { id: "user_1", email: "owner@example.com", fullName: "Owner User" }, }); prisma.transactionComment.findMany.mockResolvedValue([ { id: "comment_1", rawTransactionId: "tx_1", userId: "user_1", body: "Discuss with partner", createdAt: new Date("2026-07-16T00:00:00.000Z"), updatedAt: new Date("2026-07-16T00:00:00.000Z"), user: { id: "user_1", email: "owner@example.com", fullName: "Owner User" }, }, ]); prisma.auditLog.create.mockResolvedValue({}); const created = await service.createComment("user_1", "opaque_transaction_tx_1", { body: " Discuss with partner " }); const listed = await service.listComments("user_1", "opaque_transaction_tx_1"); expect(created).toEqual(expect.objectContaining({ id: "opaque_transaction_comment_comment_1", body: "Discuss with partner", author: { displayName: "Owner User", email: "owner@example.com" }, })); expect(listed).toHaveLength(1); expect(prisma.transactionComment.create).toHaveBeenCalledWith(expect.objectContaining({ data: { rawTransactionId: "tx_1", userId: "user_1", body: "Discuss with partner", }, })); expect(prisma.transactionComment.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: { rawTransactionId: "tx_1" }, take: 100, })); expect(prisma.auditLog.create).toHaveBeenCalledWith({ data: expect.objectContaining({ userId: "user_1", action: "transaction.comment.create", metadata: expect.objectContaining({ rawTransactionId: "tx_1", commentId: "comment_1" }), }), }); expect(opaqueIds.decode).toHaveBeenCalledWith("transaction", "user_1", "opaque_transaction_tx_1"); }); it("rejects blank transaction comments", async () => { const { service, prisma } = createService(); await expect(service.createComment("user_1", "opaque_transaction_tx_1", { body: " " })).rejects.toBeInstanceOf(BadRequestException); expect(prisma.transactionComment.create).not.toHaveBeenCalled(); }); it("caps UI transaction list responses at 25 rows", async () => { const { service, prisma } = createService(); prisma.transactionRaw.findMany.mockResolvedValue([]); prisma.transactionRaw.count.mockResolvedValue(0); const result = await service.list("user_1", { limit: 100 }); expect(result.limit).toBe(25); expect(prisma.transactionRaw.findMany).toHaveBeenCalledWith(expect.objectContaining({ take: 25, })); }); it("throws when no account is available for manual transaction", async () => { const { service, prisma } = createService(); prisma.account.findFirst.mockResolvedValue(null); await expect(service.createManualTransaction("user_1", { date: "2025-01-15", description: "Manual payment", amount: 10 })).rejects.toBeInstanceOf(BadRequestException); expect(prisma.transactionRaw.create).not.toHaveBeenCalled(); }); it("imports multiple CSV files with per-file results", async () => { const { service, prisma, exportsService } = createService(); prisma.account.findFirst.mockResolvedValue({ id: "acct_csv", userId: "user_1" }); prisma.transactionRaw.upsert.mockResolvedValue({}); const makeFile = (name: string, body: string) => ({ originalname: name, buffer: Buffer.from(body), }) as Express.Multer.File; const result = await service.importCsvBatch("user_1", [ makeFile("first.csv", "Date,Description,Amount\n2026-07-01,Coffee,4.25\n"), makeFile("second.csv", "Date,Description,Amount\n2026-07-02,Lunch,12.50\n"), ]); expect(result.totalFiles).toBe(2); expect(result.processedFiles).toBe(2); expect(result.failedFiles).toBe(0); expect(result.imported).toBe(2); expect(result.results).toHaveLength(2); expect(prisma.transactionRaw.upsert).toHaveBeenCalledTimes(2); expect(exportsService.syncGoogleSheets).toHaveBeenCalledTimes(1); expect(exportsService.syncGoogleSheets).toHaveBeenCalledWith("user_1", "csv_batch_import"); }); it("previews CSV columns with inferred mapping", async () => { const { service, prisma } = createService(); prisma.csvImportMapping.findUnique.mockResolvedValue(null); const file = { originalname: "preview.csv", buffer: Buffer.from("Posted Date,Merchant,Amount\n2026-07-01,Coffee,4.25\n"), } as Express.Multer.File; const result = await service.previewCsv("user_1", file); expect(result.headers).toEqual(["Posted Date", "Merchant", "Amount"]); expect(result.mapping).toEqual(expect.objectContaining({ date: "Posted Date", description: "Merchant", amount: "Amount", })); expect(result.remembered).toBe(false); expect(result.rowCount).toBe(1); expect(result).not.toHaveProperty("sampleRows"); }); it("uses mapped CSV import and remembers the mapping", async () => { const { service, prisma } = createService(); prisma.account.findFirst.mockResolvedValue({ id: "acct_csv", userId: "user_1" }); prisma.transactionRaw.upsert.mockResolvedValue({}); prisma.csvImportMapping.upsert.mockResolvedValue({}); const file = { originalname: "mapped.csv", buffer: Buffer.from("When,Who,Cost\n2026-07-01,Coffee,4.25\n"), } as Express.Multer.File; const result = await service.importCsv("user_1", file, { date: "When", description: "Who", amount: "Cost", amountMultiplier: 1, }); expect(result.imported).toBe(1); expect(prisma.csvImportMapping.upsert).toHaveBeenCalled(); expect(prisma.transactionRaw.upsert).toHaveBeenCalledWith(expect.objectContaining({ create: expect.objectContaining({ description: "Coffee", amount: 4.25, }), })); }); it("keeps batch import going when one CSV file fails", async () => { const { service, prisma } = createService(); prisma.account.findFirst.mockResolvedValue({ id: "acct_csv", userId: "user_1" }); prisma.transactionRaw.upsert.mockResolvedValue({}); const makeFile = (name: string, body: string) => ({ originalname: name, buffer: Buffer.from(body), }) as Express.Multer.File; const result = await service.importCsvBatch("user_1", [ makeFile("good.csv", "Date,Description,Amount\n2026-07-01,Coffee,4.25\n"), makeFile("bad.txt", "not,csv\n1,2\n"), ]); expect(result.totalFiles).toBe(2); expect(result.processedFiles).toBe(1); expect(result.failedFiles).toBe(1); expect(result.imported).toBe(1); expect(result.results[1].error).toBe("File must be a CSV."); }); });