288 lines
11 KiB
TypeScript
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.");
|
|
});
|
|
});
|