ledgerone_backend/test/transactions.service.spec.ts

288 lines
11 KiB
TypeScript

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 exportsService = { syncGoogleSheets: jest.fn().mockResolvedValue({ status: "synced" }) };
const service = new TransactionsService(prisma as any, plaid as any, opaqueIds as any, viewRefs as any, exportsService as any);
return { service, prisma, plaid, opaqueIds, viewRefs, 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("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.");
});
});