All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m24s
277 lines
7.8 KiB
TypeScript
277 lines
7.8 KiB
TypeScript
import { prisma } from "@/lib/prisma";
|
|
import type { Transaction } from "@/lib/types";
|
|
|
|
export interface CreateManyResult {
|
|
count: number;
|
|
transactions: Transaction[];
|
|
}
|
|
|
|
export const transactionService = {
|
|
async createMany(transactions: Transaction[]): Promise<CreateManyResult> {
|
|
// Get unique account IDs
|
|
const accountIds = [...new Set(transactions.map((t) => t.accountId))];
|
|
|
|
// Check for existing transactions by fitId
|
|
const existingByFitId = await prisma.transaction.findMany({
|
|
where: {
|
|
accountId: { in: accountIds },
|
|
fitId: { in: transactions.map((t) => t.fitId) },
|
|
},
|
|
select: {
|
|
accountId: true,
|
|
fitId: true,
|
|
date: true,
|
|
amount: true,
|
|
description: true,
|
|
},
|
|
});
|
|
|
|
// Get all existing transactions for these accounts to check duplicates by criteria
|
|
const allExistingTransactions = await prisma.transaction.findMany({
|
|
where: {
|
|
accountId: { in: accountIds },
|
|
},
|
|
select: {
|
|
accountId: true,
|
|
date: true,
|
|
amount: true,
|
|
description: true,
|
|
},
|
|
});
|
|
|
|
// Create sets for fast lookup
|
|
const existingFitIdSet = new Set(
|
|
existingByFitId.map((t) => `${t.accountId}-${t.fitId}`),
|
|
);
|
|
|
|
// Create set for duplicates by amount + date + description
|
|
const existingCriteriaSet = new Set(
|
|
allExistingTransactions.map(
|
|
(t) => `${t.accountId}-${t.date}-${t.amount}-${t.description}`,
|
|
),
|
|
);
|
|
|
|
// Filter out duplicates based on fitId OR (amount + date + description)
|
|
const newTransactions = transactions.filter((t) => {
|
|
const fitIdKey = `${t.accountId}-${t.fitId}`;
|
|
const criteriaKey = `${t.accountId}-${t.date}-${t.amount}-${t.description}`;
|
|
|
|
return (
|
|
!existingFitIdSet.has(fitIdKey) && !existingCriteriaSet.has(criteriaKey)
|
|
);
|
|
});
|
|
|
|
if (newTransactions.length === 0) {
|
|
return { count: 0, transactions: [] };
|
|
}
|
|
|
|
const created = await prisma.transaction.createMany({
|
|
data: newTransactions.map((t) => ({
|
|
accountId: t.accountId,
|
|
date: t.date,
|
|
amount: t.amount,
|
|
description: t.description,
|
|
type: t.type,
|
|
categoryId: t.categoryId,
|
|
isReconciled: t.isReconciled,
|
|
fitId: t.fitId,
|
|
memo: t.memo,
|
|
checkNum: t.checkNum,
|
|
})),
|
|
});
|
|
|
|
return { count: created.count, transactions: newTransactions };
|
|
},
|
|
|
|
async update(
|
|
id: string,
|
|
data: Partial<Omit<Transaction, "id">>,
|
|
): Promise<Transaction> {
|
|
const updated = await prisma.transaction.update({
|
|
where: { id },
|
|
data: {
|
|
accountId: data.accountId,
|
|
date: data.date,
|
|
amount: data.amount,
|
|
description: data.description,
|
|
type: data.type,
|
|
categoryId: data.categoryId,
|
|
isReconciled: data.isReconciled,
|
|
fitId: data.fitId,
|
|
memo: data.memo,
|
|
checkNum: data.checkNum,
|
|
},
|
|
});
|
|
|
|
return {
|
|
id: updated.id,
|
|
accountId: updated.accountId,
|
|
date: updated.date,
|
|
amount: updated.amount,
|
|
description: updated.description,
|
|
type: updated.type as Transaction["type"],
|
|
categoryId: updated.categoryId,
|
|
isReconciled: updated.isReconciled,
|
|
fitId: updated.fitId,
|
|
memo: updated.memo ?? undefined,
|
|
checkNum: updated.checkNum ?? undefined,
|
|
};
|
|
},
|
|
|
|
async delete(id: string): Promise<void> {
|
|
try {
|
|
await prisma.transaction.delete({
|
|
where: { id },
|
|
});
|
|
} catch (error: unknown) {
|
|
if (
|
|
error &&
|
|
typeof error === "object" &&
|
|
"code" in error &&
|
|
error.code === "P2025"
|
|
) {
|
|
throw new Error(`Transaction with id ${id} not found`);
|
|
}
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
async getDuplicateIds(): Promise<Set<string>> {
|
|
// Get all transactions grouped by account
|
|
const allTransactions = await prisma.transaction.findMany({
|
|
orderBy: [
|
|
{ accountId: "asc" },
|
|
{ date: "asc" },
|
|
{ createdAt: "asc" }, // Oldest first
|
|
],
|
|
select: {
|
|
id: true,
|
|
accountId: true,
|
|
date: true,
|
|
amount: true,
|
|
},
|
|
});
|
|
|
|
// Group by account for efficient processing
|
|
const transactionsByAccount = new Map<string, typeof allTransactions>();
|
|
for (const transaction of allTransactions) {
|
|
if (!transactionsByAccount.has(transaction.accountId)) {
|
|
transactionsByAccount.set(transaction.accountId, []);
|
|
}
|
|
transactionsByAccount.get(transaction.accountId)!.push(transaction);
|
|
}
|
|
|
|
const duplicateIds = new Set<string>();
|
|
const seenKeys = new Map<string, string>(); // key -> first transaction ID
|
|
|
|
// For each account, find duplicates by amount + date only
|
|
for (const [accountId, transactions] of transactionsByAccount.entries()) {
|
|
for (const transaction of transactions) {
|
|
const key = `${accountId}-${transaction.date}-${transaction.amount}`;
|
|
|
|
if (seenKeys.has(key)) {
|
|
// This is a duplicate - mark both the first and this one
|
|
const firstId = seenKeys.get(key)!;
|
|
duplicateIds.add(firstId);
|
|
duplicateIds.add(transaction.id);
|
|
} else {
|
|
// First occurrence
|
|
seenKeys.set(key, transaction.id);
|
|
}
|
|
}
|
|
}
|
|
|
|
return duplicateIds;
|
|
},
|
|
|
|
async deduplicate(): Promise<{
|
|
deletedCount: number;
|
|
duplicatesFound: number;
|
|
}> {
|
|
// Get all transactions grouped by account
|
|
const allTransactions = await prisma.transaction.findMany({
|
|
orderBy: [
|
|
{ accountId: "asc" },
|
|
{ date: "asc" },
|
|
{ id: "asc" }, // Keep the oldest transaction (first created)
|
|
],
|
|
select: {
|
|
id: true,
|
|
accountId: true,
|
|
date: true,
|
|
amount: true,
|
|
description: true,
|
|
},
|
|
});
|
|
|
|
// Group by account for efficient processing
|
|
const transactionsByAccount = new Map<string, typeof allTransactions>();
|
|
for (const transaction of allTransactions) {
|
|
if (!transactionsByAccount.has(transaction.accountId)) {
|
|
transactionsByAccount.set(transaction.accountId, []);
|
|
}
|
|
transactionsByAccount.get(transaction.accountId)!.push(transaction);
|
|
}
|
|
|
|
const duplicatesToDelete: string[] = [];
|
|
const seenKeys = new Set<string>();
|
|
|
|
// For each account, find duplicates
|
|
for (const [accountId, transactions] of transactionsByAccount.entries()) {
|
|
for (const transaction of transactions) {
|
|
const key = `${accountId}-${transaction.date}-${transaction.amount}-${transaction.description}`;
|
|
|
|
if (seenKeys.has(key)) {
|
|
// This is a duplicate, mark for deletion
|
|
duplicatesToDelete.push(transaction.id);
|
|
} else {
|
|
// First occurrence, keep it
|
|
seenKeys.add(key);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Delete duplicates
|
|
if (duplicatesToDelete.length > 0) {
|
|
await prisma.transaction.deleteMany({
|
|
where: {
|
|
id: { in: duplicatesToDelete },
|
|
},
|
|
});
|
|
}
|
|
|
|
return {
|
|
deletedCount: duplicatesToDelete.length,
|
|
duplicatesFound: duplicatesToDelete.length,
|
|
};
|
|
},
|
|
|
|
async reconcileByDateRange(
|
|
startDate: string | undefined,
|
|
endDate: string,
|
|
reconciled: boolean = true,
|
|
): Promise<{ updatedCount: number }> {
|
|
// Update all transactions in the date range
|
|
// If startDate is not provided, use a very old date to include all transactions up to endDate
|
|
const whereClause: {
|
|
date: { lte: string; gte?: string };
|
|
isReconciled: boolean;
|
|
} = {
|
|
date: {
|
|
lte: endDate,
|
|
...(startDate && { gte: startDate }),
|
|
},
|
|
isReconciled: !reconciled, // Only update transactions that don't already have the target state
|
|
};
|
|
|
|
const result = await prisma.transaction.updateMany({
|
|
where: whereClause,
|
|
data: {
|
|
isReconciled: reconciled,
|
|
},
|
|
});
|
|
|
|
return { updatedCount: result.count };
|
|
},
|
|
};
|