350 lines
11 KiB
TypeScript
350 lines
11 KiB
TypeScript
/**
|
|
* Transaction Store - Platform-aware transaction management
|
|
* Uses Firebase abstraction layer for cross-platform support
|
|
*/
|
|
import { create } from 'zustand';
|
|
import { doc, collection } from '../firebase';
|
|
import type { Transaction } from '../services/transactionService';
|
|
|
|
export interface TransactionCacheEntry {
|
|
transactions: Transaction[];
|
|
loading: boolean;
|
|
error: string | null;
|
|
lastFetched?: number;
|
|
}
|
|
|
|
export interface TransactionDetailCacheEntry {
|
|
transaction: Transaction | null;
|
|
loading: boolean;
|
|
error: string | null;
|
|
lastFetched?: number;
|
|
}
|
|
|
|
interface TransactionStoreState {
|
|
transactionsByUid: Record<string, TransactionCacheEntry>;
|
|
transactionDetails: Record<string, TransactionDetailCacheEntry>;
|
|
ensureSubscription: (uid: string) => void;
|
|
refreshTransactions: (uid: string) => Promise<Transaction[]>;
|
|
invalidateTransactions: (uid: string) => Promise<Transaction[]>;
|
|
removeTransactions: (uid: string) => void;
|
|
ensureTransactionDetail: (transactionId: string) => void;
|
|
refreshTransactionDetail: (transactionId: string) => Promise<Transaction | null>;
|
|
invalidateTransactionDetail: (transactionId: string) => Promise<Transaction | null>;
|
|
removeTransactionDetail: (transactionId: string) => void;
|
|
clearAll: () => void;
|
|
}
|
|
|
|
const buildEntry = (
|
|
existing: TransactionCacheEntry | undefined,
|
|
overrides: Partial<TransactionCacheEntry>
|
|
): TransactionCacheEntry => ({
|
|
transactions: existing?.transactions ?? [],
|
|
loading: existing?.loading ?? false,
|
|
error: existing?.error ?? null,
|
|
lastFetched: existing?.lastFetched,
|
|
...overrides,
|
|
});
|
|
|
|
const buildDetailEntry = (
|
|
existing: TransactionDetailCacheEntry | undefined,
|
|
overrides: Partial<TransactionDetailCacheEntry>
|
|
): TransactionDetailCacheEntry => ({
|
|
transaction: existing?.transaction ?? null,
|
|
loading: existing?.loading ?? false,
|
|
error: existing?.error ?? null,
|
|
lastFetched: existing?.lastFetched,
|
|
...overrides,
|
|
});
|
|
|
|
const snapshotExists = (docSnap: any): boolean => {
|
|
const existsValue = docSnap?.exists;
|
|
if (typeof existsValue === 'function') {
|
|
try {
|
|
return !!existsValue.call(docSnap);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
return !!existsValue;
|
|
};
|
|
|
|
const mapSnapshotToTransactions = (snapshot: any): Transaction[] => {
|
|
if (!snapshot || !snapshot.docs) {
|
|
return [];
|
|
}
|
|
|
|
return snapshot.docs.map((docItem: any) => {
|
|
const data = docItem.data();
|
|
return {
|
|
...data,
|
|
id: data.id ?? docItem.id,
|
|
createdAt: data.createdAt?.toDate ? data.createdAt.toDate() : data.createdAt ?? new Date(),
|
|
updatedAt: data.updatedAt?.toDate ? data.updatedAt.toDate() : data.updatedAt ?? new Date(),
|
|
} as Transaction;
|
|
});
|
|
};
|
|
|
|
const mapDocumentSnapshotToTransaction = (
|
|
transactionId: string,
|
|
docSnap: any
|
|
): Transaction | null => {
|
|
if (!snapshotExists(docSnap)) {
|
|
return null;
|
|
}
|
|
|
|
const data = docSnap.data();
|
|
if (!data) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
...data,
|
|
id: data.id ?? transactionId,
|
|
createdAt: data.createdAt?.toDate ? data.createdAt.toDate() : data.createdAt ?? new Date(),
|
|
updatedAt: data.updatedAt?.toDate ? data.updatedAt.toDate() : data.updatedAt ?? new Date(),
|
|
} as Transaction;
|
|
};
|
|
|
|
export const useTransactionStore = create<TransactionStoreState>((set, get) => ({
|
|
transactionsByUid: {},
|
|
transactionDetails: {},
|
|
|
|
ensureSubscription: (uid: string) => {
|
|
if (!uid) {
|
|
return;
|
|
}
|
|
|
|
const existing = get().transactionsByUid[uid];
|
|
if (existing?.loading) {
|
|
return;
|
|
}
|
|
|
|
if (existing?.lastFetched) {
|
|
return;
|
|
}
|
|
|
|
void get().refreshTransactions(uid).catch((error) => {
|
|
set((state) => ({
|
|
transactionsByUid: {
|
|
...state.transactionsByUid,
|
|
[uid]: buildEntry(state.transactionsByUid[uid], {
|
|
loading: false,
|
|
error: error instanceof Error ? error.message : 'Failed to fetch transactions',
|
|
}),
|
|
},
|
|
}));
|
|
});
|
|
},
|
|
|
|
refreshTransactions: async (uid: string) => {
|
|
if (!uid) {
|
|
return [];
|
|
}
|
|
|
|
set((state) => ({
|
|
transactionsByUid: {
|
|
...state.transactionsByUid,
|
|
[uid]: buildEntry(state.transactionsByUid[uid], {
|
|
loading: true,
|
|
error: null,
|
|
}),
|
|
},
|
|
}));
|
|
|
|
try {
|
|
const transactionsCollection = collection('transactions');
|
|
const snapshot = await transactionsCollection
|
|
.where('uid', '==', uid)
|
|
.orderBy('createdAt', 'desc')
|
|
.get();
|
|
|
|
const transactions = mapSnapshotToTransactions(snapshot);
|
|
|
|
set((state) => {
|
|
const nextDetails = { ...state.transactionDetails };
|
|
transactions.forEach((transaction) => {
|
|
const detailEntry = nextDetails[transaction.id];
|
|
if (detailEntry) {
|
|
nextDetails[transaction.id] = buildDetailEntry(detailEntry, {
|
|
transaction,
|
|
loading: false,
|
|
error: null,
|
|
lastFetched: Date.now(),
|
|
});
|
|
}
|
|
});
|
|
|
|
return {
|
|
transactionsByUid: {
|
|
...state.transactionsByUid,
|
|
[uid]: buildEntry(state.transactionsByUid[uid], {
|
|
transactions,
|
|
loading: false,
|
|
error: null,
|
|
lastFetched: Date.now(),
|
|
}),
|
|
},
|
|
transactionDetails: nextDetails,
|
|
};
|
|
});
|
|
|
|
return transactions;
|
|
} catch (error) {
|
|
set((state) => ({
|
|
transactionsByUid: {
|
|
...state.transactionsByUid,
|
|
[uid]: buildEntry(state.transactionsByUid[uid], {
|
|
loading: false,
|
|
error: error instanceof Error ? error.message : 'Failed to fetch transactions',
|
|
}),
|
|
},
|
|
}));
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
invalidateTransactions: async (uid: string) => {
|
|
if (!uid) {
|
|
return [];
|
|
}
|
|
|
|
set((state) => ({
|
|
transactionsByUid: {
|
|
...state.transactionsByUid,
|
|
[uid]: buildEntry(state.transactionsByUid[uid], {
|
|
loading: true,
|
|
error: null,
|
|
lastFetched: undefined,
|
|
}),
|
|
},
|
|
}));
|
|
|
|
return get().refreshTransactions(uid);
|
|
},
|
|
|
|
removeTransactions: (uid: string) => {
|
|
set((state) => {
|
|
const { [uid]: _removed, ...rest } = state.transactionsByUid;
|
|
return { transactionsByUid: rest };
|
|
});
|
|
},
|
|
|
|
ensureTransactionDetail: (transactionId: string) => {
|
|
if (!transactionId) {
|
|
return;
|
|
}
|
|
|
|
const existing = get().transactionDetails[transactionId];
|
|
if (existing?.loading) {
|
|
return;
|
|
}
|
|
|
|
if (existing?.lastFetched) {
|
|
return;
|
|
}
|
|
|
|
void get().refreshTransactionDetail(transactionId).catch((error) => {
|
|
set((state) => ({
|
|
transactionDetails: {
|
|
...state.transactionDetails,
|
|
[transactionId]: buildDetailEntry(state.transactionDetails[transactionId], {
|
|
loading: false,
|
|
error: error instanceof Error ? error.message : 'Failed to fetch transaction',
|
|
}),
|
|
},
|
|
}));
|
|
});
|
|
},
|
|
|
|
refreshTransactionDetail: async (transactionId: string) => {
|
|
if (!transactionId) {
|
|
return null;
|
|
}
|
|
|
|
set((state) => ({
|
|
transactionDetails: {
|
|
...state.transactionDetails,
|
|
[transactionId]: buildDetailEntry(state.transactionDetails[transactionId], {
|
|
loading: true,
|
|
error: null,
|
|
}),
|
|
},
|
|
}));
|
|
|
|
try {
|
|
const docRef = doc('transactions', transactionId);
|
|
const docSnap = await docRef.get();
|
|
const transaction = mapDocumentSnapshotToTransaction(transactionId, docSnap);
|
|
|
|
set((state) => ({
|
|
transactionDetails: {
|
|
...state.transactionDetails,
|
|
[transactionId]: buildDetailEntry(state.transactionDetails[transactionId], {
|
|
transaction,
|
|
loading: false,
|
|
error: transaction ? null : 'Transaction not found',
|
|
lastFetched: Date.now(),
|
|
}),
|
|
},
|
|
}));
|
|
|
|
return transaction;
|
|
} catch (error) {
|
|
set((state) => ({
|
|
transactionDetails: {
|
|
...state.transactionDetails,
|
|
[transactionId]: buildDetailEntry(state.transactionDetails[transactionId], {
|
|
loading: false,
|
|
error: error instanceof Error ? error.message : 'Failed to fetch transaction',
|
|
}),
|
|
},
|
|
}));
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
invalidateTransactionDetail: async (transactionId: string) => {
|
|
if (!transactionId) {
|
|
return null;
|
|
}
|
|
|
|
set((state) => ({
|
|
transactionDetails: {
|
|
...state.transactionDetails,
|
|
[transactionId]: buildDetailEntry(state.transactionDetails[transactionId], {
|
|
loading: true,
|
|
error: null,
|
|
lastFetched: undefined,
|
|
}),
|
|
},
|
|
}));
|
|
|
|
return get().refreshTransactionDetail(transactionId);
|
|
},
|
|
|
|
removeTransactionDetail: (transactionId: string) => {
|
|
set((state) => {
|
|
const { [transactionId]: _removed, ...rest } = state.transactionDetails;
|
|
return { transactionDetails: rest };
|
|
});
|
|
},
|
|
|
|
clearAll: () => {
|
|
set({ transactionsByUid: {}, transactionDetails: {} });
|
|
},
|
|
}));
|
|
|
|
export const getTransactionsFromCache = (uid: string): Transaction[] => {
|
|
if (!uid) {
|
|
return [];
|
|
}
|
|
return useTransactionStore.getState().transactionsByUid[uid]?.transactions ?? [];
|
|
};
|
|
|
|
export const getTransactionDetailFromCache = (transactionId: string): Transaction | null => {
|
|
if (!transactionId) {
|
|
return null;
|
|
}
|
|
return useTransactionStore.getState().transactionDetails[transactionId]?.transaction ?? null;
|
|
};
|