whisper-money/resources/js/services/transaction-sync.ts

359 lines
11 KiB
TypeScript

import { encrypt, importKey } from '@/lib/crypto';
import { getStoredKey } from '@/lib/key-storage';
import { TransactionSyncManager } from '@/lib/sync-manager';
import type { Transaction } from '@/types/transaction';
import type { UUID } from '@/types/uuid';
import axios from 'axios';
interface TransactionUpdateData extends Partial<Transaction> {
label_ids?: string[];
}
interface TransactionFilters {
dateFrom?: Date | null;
dateTo?: Date | null;
amountMin?: number | null;
amountMax?: number | null;
categoryIds?: number[];
accountIds?: string[];
labelIds?: string[];
searchText?: string;
}
function getCsrfToken(): string {
return decodeURIComponent(
document.cookie
.split('; ')
.find((row) => row.startsWith('XSRF-TOKEN='))
?.split('=')[1] || '',
);
}
class TransactionSyncService {
private syncManager: TransactionSyncManager;
constructor() {
this.syncManager = new TransactionSyncManager({
endpoint: '/api/sync/transactions',
transformFromServer: (data) => {
const label_ids = data.labels?.map((l: { id: string }) => l.id);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { labels, ...rest } = data;
return {
...rest,
transaction_date: String(data.transaction_date).slice(
0,
10,
),
label_ids: label_ids || [],
};
},
});
}
async sync() {
return await this.syncManager.sync();
}
async getAll(): Promise<Transaction[]> {
return await this.syncManager.getAll();
}
async getById(id: UUID): Promise<Transaction | null> {
return await this.syncManager.getById(id);
}
async getByAccountId(accountId: UUID): Promise<Transaction[]> {
return await this.syncManager.getByAccountId(accountId);
}
async create(
data: Omit<Transaction, 'id' | 'created_at' | 'updated_at'>,
): Promise<Transaction> {
const response = await axios.post('/api/sync/transactions', data);
const serverData = response.data.data;
const label_ids = serverData.labels?.map((l: { id: string }) => l.id);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { labels, ...rest } = serverData;
return {
...rest,
transaction_date: String(serverData.transaction_date).slice(0, 10),
label_ids: label_ids || [],
} as Transaction;
}
async createMany(
transactions: Omit<Transaction, 'id' | 'created_at' | 'updated_at'>[],
): Promise<Transaction[]> {
const created: Transaction[] = [];
for (const data of transactions) {
const transaction = await this.create(data);
created.push(transaction);
}
return created;
}
async update(
id: string,
data: TransactionUpdateData,
): Promise<Transaction> {
const existing = await this.getById(id);
if (!existing) {
throw new Error('Transaction not found');
}
const { label_ids, ...transactionData } = data;
const response = await fetch(`/api/sync/transactions/${id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-XSRF-TOKEN': getCsrfToken(),
},
credentials: 'same-origin',
body: JSON.stringify({
...existing,
...transactionData,
label_ids,
}),
});
if (!response.ok) {
throw new Error('Failed to update transaction');
}
const result = await response.json();
const serverData = result.data;
const serverLabelIds = serverData.labels?.map(
(l: { id: string }) => l.id,
);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { labels: _labels, ...restServerData } = serverData;
return {
...restServerData,
transaction_date: String(serverData.transaction_date).slice(0, 10),
label_ids: serverLabelIds || [],
} as Transaction;
}
async updateMany(
ids: string[],
data: TransactionUpdateData,
): Promise<void> {
const { label_ids, ...transactionData } = data;
const response = await fetch('/transactions/bulk', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-XSRF-TOKEN': getCsrfToken(),
},
credentials: 'same-origin',
body: JSON.stringify({
transaction_ids: ids,
label_ids: label_ids,
...transactionData,
}),
});
if (!response.ok) {
throw new Error('Failed to bulk update transactions');
}
}
async updateByFilters(
filters: TransactionFilters,
data: TransactionUpdateData,
): Promise<number> {
const { label_ids, ...transactionData } = data;
const requestFilters: Record<string, unknown> = {};
if (filters.dateFrom) {
requestFilters.date_from = filters.dateFrom
.toISOString()
.split('T')[0];
}
if (filters.dateTo) {
requestFilters.date_to = filters.dateTo.toISOString().split('T')[0];
}
if (filters.amountMin !== null && filters.amountMin !== undefined) {
requestFilters.amount_min = filters.amountMin;
}
if (filters.amountMax !== null && filters.amountMax !== undefined) {
requestFilters.amount_max = filters.amountMax;
}
if (filters.categoryIds && filters.categoryIds.length > 0) {
requestFilters.category_ids = filters.categoryIds;
}
if (filters.accountIds && filters.accountIds.length > 0) {
requestFilters.account_ids = filters.accountIds;
}
if (filters.labelIds && filters.labelIds.length > 0) {
requestFilters.label_ids = filters.labelIds;
}
const response = await fetch('/transactions/bulk', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-XSRF-TOKEN': getCsrfToken(),
},
credentials: 'same-origin',
body: JSON.stringify({
filters: requestFilters,
label_ids: label_ids,
...transactionData,
}),
});
if (!response.ok) {
throw new Error('Failed to bulk update transactions by filters');
}
const result = await response.json();
return result.count || 0;
}
async delete(id: string): Promise<void> {
await axios.delete(`/api/sync/transactions/${id}`);
}
async updateManyIndividual(
updates: Array<{ id: string; data: TransactionUpdateData }>,
): Promise<void> {
for (const { id, data } of updates) {
await this.update(id, data);
}
}
async deleteMany(ids: string[]): Promise<void> {
for (const id of ids) {
await this.delete(id);
}
}
async checkDuplicates(
accountId: string,
transactions: Array<{
transaction_date: string;
amount: number;
description: string;
}>,
): Promise<boolean[]> {
try {
if (transactions.length === 0) {
return [];
}
const dates = transactions.map((t) => t.transaction_date);
const minDate = dates.reduce((a, b) => (a < b ? a : b));
const maxDate = dates.reduce((a, b) => (a > b ? a : b));
const normalizeDate = (dateStr: string): string =>
dateStr.slice(0, 10);
const allTransactions = await this.getByAccountId(accountId);
const transactionsInRange = allTransactions.filter((t) => {
const txDate = normalizeDate(t.transaction_date);
return txDate >= minDate && txDate <= maxDate;
});
const keyString = getStoredKey();
if (!keyString) {
console.warn('No encryption key found for duplicate check');
return transactions.map(() => false);
}
const key = await importKey(keyString);
const { decrypt } = await import('@/lib/crypto');
const decryptedTransactions = await Promise.all(
transactionsInRange.map(async (t) => {
try {
const decryptedDescription = await decrypt(
t.description,
key,
t.description_iv,
);
return {
transaction_date: normalizeDate(t.transaction_date),
amount: parseFloat(t.amount),
description: decryptedDescription
.toLowerCase()
.trim()
.replace(/\s+/g, ' '),
};
} catch {
return null;
}
}),
);
const validDecryptedTransactions = decryptedTransactions.filter(
(t) => t !== null,
);
return transactions.map((importingTx) => {
const normalizedDescription = importingTx.description
.toLowerCase()
.trim()
.replace(/\s+/g, ' ');
return validDecryptedTransactions.some(
(existing) =>
existing.transaction_date ===
importingTx.transaction_date &&
Math.abs(existing.amount - importingTx.amount) <
0.001 &&
existing.description === normalizedDescription,
);
});
} catch (error) {
console.warn(
'Duplicate check failed, assuming no duplicates:',
error,
);
return transactions.map(() => false);
}
}
async encryptDescription(
description: string,
): Promise<{ encrypted: string; iv: string }> {
const keyString = getStoredKey();
if (!keyString) {
throw new Error('Encryption key not set');
}
const key = await importKey(keyString);
return await encrypt(description, key);
}
async getLastSyncTime(): Promise<string | null> {
return await this.syncManager.getLastSyncTime();
}
isSyncing(): boolean {
return this.syncManager.isSyncing();
}
async clearAll(): Promise<void> {
await this.syncManager.clearAll();
}
}
export const transactionSyncService = new TransactionSyncService();