From 174ee5f9f152d03c26841ae51fe1960aefa33f51 Mon Sep 17 00:00:00 2001
From: Moataz Farid
Date: Wed, 8 Jan 2025 08:32:44 +0200
Subject: [PATCH] Refactor Firestore Rules and Enhance Data Synchronization
Components
- Updated Firestore security rules to include new validation functions for feeding rounds, treasury categories, and transactions, ensuring data integrity and proper access control.
- Introduced a new DataSynchronizer component to synchronize data between React Query and Zustand store, improving state management across the application.
- Added TreasuryValidation component to validate completed payments against treasury categories, enhancing financial oversight and error detection.
- Enhanced FeedingRoundModal with improved category handling and UI updates, including better error messaging and category selection.
- Updated FeedingRoundList to support new status transitions and improved user feedback for round cancellations.
- Refactored treasury service methods to decouple feeding round creation from treasury adjustments, ensuring clearer transaction handling.
---
firebase.rules | 69 +--
src/components/DataSynchronizer.tsx | 34 ++
src/components/Layout.tsx | 4 +
src/components/TreasuryValidation.tsx | 80 ++++
src/components/modals/FeedingRoundModal.tsx | 470 ++++++++++++-------
src/pages/FeedingRoundList.tsx | 161 ++++++-
src/pages/TreasuryList.tsx | 20 +-
src/services/firebase/feedingRoundService.ts | 290 ++++++++----
src/services/firebase/treasuryService.ts | 1 +
src/utils/treasuryValidator.ts | 59 +++
10 files changed, 858 insertions(+), 330 deletions(-)
create mode 100644 src/components/DataSynchronizer.tsx
create mode 100644 src/components/TreasuryValidation.tsx
create mode 100644 src/utils/treasuryValidator.ts
diff --git a/firebase.rules b/firebase.rules
index b4fe0cf..d3194a9 100644
--- a/firebase.rules
+++ b/firebase.rules
@@ -6,60 +6,71 @@ service cloud.firestore {
return request.auth != null;
}
- function isAdmin() {
- return isSignedIn() && request.auth.token.admin == true;
- }
-
- function isValidAmount() {
- return request.resource.data.amount is number && request.resource.data.amount > 0;
- }
-
function hasValidTimestamps() {
let hasCreatedAt = request.resource.data.createdAt is timestamp;
let hasUpdatedAt = request.resource.data.updatedAt is timestamp;
return hasCreatedAt && hasUpdatedAt;
}
- // Common rules
- match /{document=**} {
- allow read: if isSignedIn();
- allow write: if isAdmin();
- }
-
- // Specific collection rules
- match /donors/{donorId} {
- allow read: if isSignedIn();
- allow write: if isAdmin();
+ function isValidFeedingRound() {
+ let data = request.resource.data;
+ return data.allocatedAmount is number
+ && data.allocatedAmount > 0
+ && data.unitPrice is number
+ && data.unitPrice > 0
+ && data.categoryId is string
+ && data.date is string
+ && (data.status == 'PENDING' || data.status == 'IN_PROGRESS' || data.status == 'COMPLETED' || data.status == 'CANCELLED')
+ && (!data.keys().hasAny(['photos']) || data.photos is list);
}
- match /donations/{donationId} {
- allow read: if isSignedIn();
- allow write: if isAdmin() && isValidAmount() && hasValidTimestamps();
+ function isValidTreasuryCategory() {
+ let data = request.resource.data;
+ return data.name is string
+ && data.name.size() > 0
+ && data.balance is number
+ && data.balance >= 0;
}
- match /beneficiaries/{beneficiaryId} {
- allow read: if isSignedIn();
- allow write: if isAdmin() && hasValidTimestamps();
+ function isValidTransaction() {
+ let data = request.resource.data;
+ return data.type in ['DEBIT', 'CREDIT', 'STATUS_UPDATE']
+ && data.amount is number
+ && data.amount > 0
+ && data.description is string
+ && data.category is string
+ && data.reference is string
+ && data.status in ['PENDING', 'COMPLETED', 'CANCELLED'];
}
- match /payments/{paymentId} {
+ // Common rules - all operations require authentication
+ match /{document=**} {
allow read: if isSignedIn();
- allow write: if isAdmin() && isValidAmount() && hasValidTimestamps();
+ allow write: if isSignedIn();
}
+ // Feeding rounds collection
match /feedingRounds/{roundId} {
allow read: if isSignedIn();
- allow write: if isAdmin() && isValidAmount() && hasValidTimestamps();
+ allow create: if isSignedIn() && isValidFeedingRound() && hasValidTimestamps();
+ allow update: if isSignedIn() && isValidFeedingRound() && hasValidTimestamps();
+ allow delete: if isSignedIn();
}
+ // Treasury collection
match /treasury/{categoryId} {
allow read: if isSignedIn();
- allow write: if isAdmin() && isValidAmount() && hasValidTimestamps();
+ allow create: if isSignedIn() && isValidTreasuryCategory() && hasValidTimestamps();
+ allow update: if isSignedIn() && isValidTreasuryCategory() && hasValidTimestamps();
+ allow delete: if isSignedIn();
}
+ // Transactions collection
match /transactions/{transactionId} {
allow read: if isSignedIn();
- allow write: if isAdmin() && isValidAmount() && hasValidTimestamps();
+ allow create: if isSignedIn() && isValidTransaction() && hasValidTimestamps();
+ allow update: if false; // Transactions should never be updated
+ allow delete: if false; // Transactions should never be deleted
}
}
}
\ No newline at end of file
diff --git a/src/components/DataSynchronizer.tsx b/src/components/DataSynchronizer.tsx
new file mode 100644
index 0000000..6dc06be
--- /dev/null
+++ b/src/components/DataSynchronizer.tsx
@@ -0,0 +1,34 @@
+import React from 'react';
+import { useAllData } from '../hooks/useFirebaseQuery';
+import { useStore } from '../store';
+
+/**
+ * Component that synchronizes data between React Query and Zustand store
+ */
+export const DataSynchronizer: React.FC = () => {
+ const { data } = useAllData();
+ const {
+ setTreasuryCategories,
+ setBeneficiaries,
+ setDonors,
+ setDonations,
+ setFeedingRounds,
+ setPayments,
+ setTransactions
+ } = useStore();
+
+ React.useEffect(() => {
+ if (data) {
+ // Update all store data when React Query data changes
+ setTreasuryCategories(data.treasury || []);
+ setBeneficiaries(data.beneficiaries || []);
+ setDonors(data.donors || []);
+ setDonations(data.donations || []);
+ setFeedingRounds(data.feedingRounds || { rounds: [], lastDoc: null });
+ setPayments(data.payments || []);
+ setTransactions(data.transactions || []);
+ }
+ }, [data, setTreasuryCategories, setBeneficiaries, setDonors, setDonations, setFeedingRounds, setPayments, setTransactions]);
+
+ return null;
+};
\ No newline at end of file
diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx
index 1447256..ad2646d 100644
--- a/src/components/Layout.tsx
+++ b/src/components/Layout.tsx
@@ -3,6 +3,7 @@ import { Link, Outlet, useLocation, NavLink } from 'react-router-dom';
import { Heart, Users, Wallet, Utensils, Menu, CircleDollarSign, UserRound, CreditCard, FileText, LogOut, LayoutDashboard, DollarSign, Gift, UserCheck } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
import { DebugModeToggle } from './DebugModeToggle';
+import { DataSynchronizer } from './DataSynchronizer';
const Layout: React.FC = () => {
const location = useLocation();
@@ -24,6 +25,9 @@ const Layout: React.FC = () => {
return (
+ {/* Data Synchronizer */}
+
+
{/* Mobile menu button */}
diff --git a/src/pages/FeedingRoundList.tsx b/src/pages/FeedingRoundList.tsx
index f7e08f9..f5c5ea9 100644
--- a/src/pages/FeedingRoundList.tsx
+++ b/src/pages/FeedingRoundList.tsx
@@ -1,5 +1,5 @@
import React, { useState, useMemo } from 'react';
-import { Utensils, Plus, Play, CheckCircle, Pencil, Trash2, Camera, ImageOff, ChevronDown, ChevronUp } from 'lucide-react';
+import { Utensils, Plus, Play, CheckCircle, Pencil, Trash2, Camera, ImageOff, ChevronDown, ChevronUp, X } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query';
import { useAllData } from '../hooks/useFirebaseQuery';
import { FeedingRoundModal } from '../components/modals/FeedingRoundModal';
@@ -9,6 +9,7 @@ import { format } from 'date-fns';
import { FeedingRound, Donor, Beneficiary, Donation, Payment, TreasuryCategory, Transaction } from '../types';
import { DocumentSnapshot, DocumentData } from 'firebase/firestore';
import { logger } from '../utils/logger';
+import { Timestamp } from 'firebase/firestore';
interface PaginatedFeedingRounds {
rounds: FeedingRound[];
@@ -86,7 +87,7 @@ const FeedingRoundList: React.FC = () => {
return false;
}
- if (!['PENDING', 'COMPLETED', 'CANCELLED'].includes(round.status)) {
+ if (!['PENDING', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED'].includes(round.status)) {
logger.warn('Invalid status', { round }, 'FeedingRoundList');
return false;
}
@@ -191,27 +192,126 @@ const FeedingRoundList: React.FC = () => {
try {
logger.debug('Updating round status', { id, newStatus }, 'FeedingRoundList');
setUpdatingStatus(id);
- // Optimistic update
- const updatedRounds = validFeedingRounds.map(round =>
- round.id === id ? { ...round, status: newStatus } : round
- );
- queryClient.setQueryData(['all-data'], (oldData) => ({
- ...oldData!,
- feedingRounds: { rounds: updatedRounds, lastDoc }
- }));
-
- await feedingRoundServices.updateStatus(id, newStatus);
- // Invalidate and refetch all relevant queries
- await Promise.all([
- queryClient.invalidateQueries({ queryKey: ['all-data'] }),
- queryClient.refetchQueries({ queryKey: ['all-data'] })
- ]);
+ // Find the current round
+ const currentRound = validFeedingRounds.find(round => round.id === id);
+ if (!currentRound) {
+ throw new Error('Round not found');
+ }
+
+ // For cancellation, ask for confirmation
+ if (newStatus === 'CANCELLED') {
+ const message = currentRound.status === 'IN_PROGRESS'
+ ? 'This will refund the allocated amount back to the treasury. Are you sure you want to cancel this round?'
+ : 'Are you sure you want to cancel this round?';
+
+ if (!window.confirm(message)) {
+ return;
+ }
+ }
+
+ // Validate status transitions
+ if (currentRound.status === 'COMPLETED') {
+ throw new Error('Cannot update a completed round');
+ }
+
+ if (newStatus === 'IN_PROGRESS' && currentRound.status !== 'PENDING') {
+ throw new Error('Can only start a pending round');
+ }
+
+ if (newStatus === 'COMPLETED' && currentRound.status !== 'IN_PROGRESS') {
+ throw new Error('Can only complete an in-progress round');
+ }
+
+ if (newStatus === 'CANCELLED' && !['PENDING', 'IN_PROGRESS'].includes(currentRound.status)) {
+ throw new Error('Can only cancel pending or in-progress rounds');
+ }
+
+ // Save the old data for rollback
+ const oldData = queryClient.getQueryData(['all-data']);
+
+ // Create optimistically updated round
+ const optimisticRound = {
+ ...currentRound,
+ status: newStatus,
+ updatedAt: Timestamp.now()
+ };
+
+ // Optimistic update
+ queryClient.setQueryData(['all-data'], (oldData) => {
+ if (!oldData) return oldData;
+
+ const updatedRounds = oldData.feedingRounds.rounds.map(round =>
+ round.id === id ? optimisticRound : round
+ );
+
+ logger.debug('Optimistic update', {
+ oldCount: oldData.feedingRounds.rounds.length,
+ newCount: updatedRounds.length,
+ updatedRound: optimisticRound
+ }, 'FeedingRoundList');
+
+ return {
+ ...oldData,
+ feedingRounds: {
+ rounds: updatedRounds,
+ lastDoc: oldData.feedingRounds.lastDoc
+ }
+ };
+ });
+
+ try {
+ // Perform the actual update
+ const updatedRound = await feedingRoundServices.updateStatus(
+ id,
+ newStatus as 'PENDING' | 'IN_PROGRESS' | 'COMPLETED' | 'CANCELLED'
+ );
+
+ // Update with actual server data
+ queryClient.setQueryData(['all-data'], (oldData) => {
+ if (!oldData) return oldData;
+
+ const updatedRounds = oldData.feedingRounds.rounds.map(round =>
+ round.id === id ? updatedRound : round
+ );
+
+ logger.debug('Server update', {
+ oldCount: oldData.feedingRounds.rounds.length,
+ newCount: updatedRounds.length,
+ updatedRound
+ }, 'FeedingRoundList');
+
+ return {
+ ...oldData,
+ feedingRounds: {
+ rounds: updatedRounds,
+ lastDoc: oldData.feedingRounds.lastDoc
+ }
+ };
+ });
+
+ // Only refetch treasury data if needed, without invalidating feeding rounds
+ if ((newStatus === 'IN_PROGRESS' && currentRound.status === 'PENDING') ||
+ (newStatus === 'CANCELLED' && currentRound.status === 'IN_PROGRESS')) {
+ // Fetch updated treasury data in the background
+ queryClient.refetchQueries({
+ queryKey: ['all-data'],
+ exact: false,
+ stale: true,
+ // Don't remove existing data while refetching
+ refetchPage: (_, index) => index === 0
+ });
+ }
+ } catch (error) {
+ // Revert to old data on error
+ if (oldData) {
+ queryClient.setQueryData(['all-data'], oldData);
+ }
+ throw error;
+ }
} catch (error) {
logger.error('Error updating status', error, 'FeedingRoundList');
- // Revert optimistic update
- queryClient.invalidateQueries({ queryKey: ['all-data'] });
- alert('Failed to update status');
+ alert(error instanceof Error ? error.message : 'Failed to update status');
} finally {
setUpdatingStatus(null);
}
@@ -272,6 +372,8 @@ const FeedingRoundList: React.FC = () => {
return 'bg-blue-100 text-blue-800';
case 'COMPLETED':
return 'bg-green-100 text-green-800';
+ case 'CANCELLED':
+ return 'bg-red-100 text-red-800';
default:
return 'bg-gray-100 text-gray-800';
}
@@ -429,7 +531,7 @@ const FeedingRoundList: React.FC = () => {
)}
)}
- {round.status !== 'COMPLETED' && (
+ {['PENDING', 'IN_PROGRESS'].includes(round.status) && (
<>
+
>
)}
{round.status === 'COMPLETED' && (
@@ -465,6 +579,11 @@ const FeedingRoundList: React.FC = () => {
)}
)}
+ {round.status === 'CANCELLED' && (
+
+ Cancelled
+
+ )}
diff --git a/src/pages/TreasuryList.tsx b/src/pages/TreasuryList.tsx
index 783521c..e3f9fef 100644
--- a/src/pages/TreasuryList.tsx
+++ b/src/pages/TreasuryList.tsx
@@ -1,16 +1,18 @@
import React from 'react';
-import { Wallet, Plus, TrendingUp, TrendingDown } from 'lucide-react';
+import { Wallet, Plus, TrendingUp, TrendingDown, CheckCircle } from 'lucide-react';
import { TreasuryCategoryModal } from '../components/modals/TreasuryCategoryModal';
import { treasuryServices } from '../services/firebase/treasuryService';
import { useQueryClient } from '@tanstack/react-query';
import { useAllData } from '../hooks/useFirebaseQuery';
import { TreasuryCategory } from '../types';
+import { TreasuryValidation, useTreasuryValidation } from '../components/TreasuryValidation';
const TreasuryList: React.FC = () => {
const { data, isLoading } = useAllData();
const categories = (data?.treasury || []) as TreasuryCategory[];
const [isAddModalOpen, setIsAddModalOpen] = React.useState(false);
const queryClient = useQueryClient();
+ const { runValidation, isValidating, validationResults } = useTreasuryValidation();
const handleAdjustBalance = async (id: string) => {
const adjustmentAmount = parseFloat(
@@ -62,7 +64,16 @@ const TreasuryList: React.FC = () => {
Monitor and manage fund categories and balances
-
+
+
+
+
{/* Summary Cards */}
diff --git a/src/services/firebase/feedingRoundService.ts b/src/services/firebase/feedingRoundService.ts
index e30a1b3..72024c2 100644
--- a/src/services/firebase/feedingRoundService.ts
+++ b/src/services/firebase/feedingRoundService.ts
@@ -102,7 +102,8 @@ export const feedingRoundServices = {
);
console.log('FeedingRoundService: Initial query created');
- if (status) {
+ // Only apply status filter if specifically requested
+ if (status && status !== 'ALL') {
q = query(q, where('status', '==', status));
console.log('FeedingRoundService: Added status filter:', status);
}
@@ -171,64 +172,23 @@ export const feedingRoundServices = {
},
/**
- * Creates a new feeding round and allocates funds from treasury
+ * Creates a new feeding round without affecting treasury
* @async
* @param {Omit} data - Feeding round data without ID
- * @throws {Error} If:
- * - Feeding category not found
- * - Insufficient funds in category
- * - Transaction fails
- *
- * @description
- * This operation performs the following steps atomically:
- * 1. Verifies the feeding category exists
- * 2. Checks sufficient funds are available
- * 3. Creates the feeding round record
- * 4. Deducts allocated amount from treasury category
*/
- create: async (data: Omit) => {
+ create: async (data: Omit): Promise => {
return retryOperation(async () => {
try {
const result = await runTransaction(db, async (transaction) => {
- const categoryRef = doc(db, COLLECTIONS.TREASURY, data.categoryId);
- const categoryDoc = await transaction.get(categoryRef);
-
- if (!categoryDoc.exists()) {
- throw new Error('Feeding category not found');
- }
-
- const currentBalance = categoryDoc.data().balance || 0;
- if (currentBalance < data.allocatedAmount) {
- throw new Error('Insufficient funds in feeding category');
- }
-
const roundRef = doc(collection(db, COLLECTIONS.FEEDING_ROUNDS));
const roundData = {
...data,
+ status: 'PENDING',
createdAt: Timestamp.now(),
updatedAt: Timestamp.now()
};
transaction.set(roundRef, roundData);
- transaction.update(categoryRef, {
- balance: currentBalance - data.allocatedAmount,
- updatedAt: Timestamp.now()
- });
-
- // Record the transaction inside the Firestore transaction
- const transactionRef = doc(collection(db, COLLECTIONS.TRANSACTIONS));
- const transactionData = {
- type: 'DEBIT',
- amount: data.allocatedAmount,
- description: `Feeding round allocation for ${format(new Date(data.date), 'MMM d, yyyy')}`,
- category: 'FEEDING_ROUND',
- reference: roundRef.id,
- status: 'COMPLETED',
- createdAt: Timestamp.now(),
- updatedAt: Timestamp.now()
- };
- transaction.set(transactionRef, transactionData);
-
return { id: roundRef.id, ...roundData };
});
@@ -275,17 +235,57 @@ export const feedingRoundServices = {
* @throws {Error} If updating the round fails
*
* @description
- * Updates the feeding round details while maintaining the allocated amount.
- * For changes to allocated amount, the round should be deleted and recreated.
+ * Only allows updating non-sensitive fields and adding drive link to completed rounds
*/
- update: async (id: string, data: Partial) => {
+ update: async (id: string, data: Partial): Promise => {
return retryOperation(async () => {
try {
- const docRef = doc(db, COLLECTIONS.FEEDING_ROUNDS, id);
- await updateDoc(docRef, {
- ...data,
- updatedAt: Timestamp.now()
+ let updatedRound: FeedingRound;
+
+ await runTransaction(db, async (transaction) => {
+ const roundRef = doc(db, COLLECTIONS.FEEDING_ROUNDS, id);
+ const roundDoc = await transaction.get(roundRef);
+
+ if (!roundDoc.exists()) {
+ throw new Error('Feeding round not found');
+ }
+
+ const currentRound = roundDoc.data() as FeedingRound;
+
+ // For completed rounds, only allow updating driveLink
+ if (currentRound.status === 'COMPLETED') {
+ if (Object.keys(data).some(key => key !== 'driveLink')) {
+ throw new Error('Can only update drive link for completed rounds');
+ }
+ }
+
+ // Don't allow updating sensitive fields
+ const safeData = { ...data };
+ delete safeData.allocatedAmount; // Can't change allocated amount
+ delete safeData.categoryId; // Can't change category
+ delete safeData.status; // Status must be changed through updateStatus
+
+ const updateData = {
+ ...safeData,
+ updatedAt: Timestamp.now()
+ };
+
+ transaction.update(roundRef, updateData);
+
+ // Set the complete updated round data
+ updatedRound = {
+ ...currentRound,
+ ...updateData,
+ id,
+ createdAt: currentRound.createdAt || Timestamp.now(),
+ status: currentRound.status || 'PENDING',
+ allocatedAmount: currentRound.allocatedAmount || 0,
+ categoryId: currentRound.categoryId || '',
+ photos: currentRound.photos || []
+ };
});
+
+ return updatedRound!;
} catch (error) {
console.error('Error updating feeding round:', error);
throw new Error(`Failed to update feeding round: ${error.message}`);
@@ -294,19 +294,24 @@ export const feedingRoundServices = {
},
/**
- * Updates the status of a feeding round
+ * Updates the status of a feeding round and handles treasury operations
* @async
* @param {string} id - Feeding round ID
- * @param {'PENDING' | 'IN_PROGRESS' | 'COMPLETED'} status - New status
+ * @param {'PENDING' | 'IN_PROGRESS' | 'COMPLETED' | 'CANCELLED'} status - New status
* @throws {Error} If updating the status fails
*
* @description
- * Updates the feeding round status to track its progress.
- * Status transitions: PENDING → IN_PROGRESS → COMPLETED
+ * Status transitions and treasury operations:
+ * - PENDING → IN_PROGRESS: Deduct amount from treasury
+ * - IN_PROGRESS → COMPLETED: No treasury operation
+ * - IN_PROGRESS → CANCELLED: Refund amount to treasury
+ * - COMPLETED: Only allow adding drive link
*/
- updateStatus: async (id: string, status: 'PENDING' | 'IN_PROGRESS' | 'COMPLETED') => {
+ updateStatus: async (id: string, status: 'PENDING' | 'IN_PROGRESS' | 'COMPLETED' | 'CANCELLED'): Promise => {
return retryOperation(async () => {
try {
+ let updatedRound: FeedingRound;
+
await runTransaction(db, async (transaction) => {
const roundRef = doc(db, COLLECTIONS.FEEDING_ROUNDS, id);
const roundDoc = await transaction.get(roundRef);
@@ -315,15 +320,97 @@ export const feedingRoundServices = {
throw new Error('Feeding round not found');
}
- const roundData = roundDoc.data();
- transaction.update(roundRef, {
+ const roundData = roundDoc.data() as FeedingRound;
+
+ // Validate status transitions
+ if (roundData.status === 'COMPLETED') {
+ throw new Error('Cannot update a completed round');
+ }
+
+ if (status === 'IN_PROGRESS' && roundData.status !== 'PENDING') {
+ throw new Error('Can only start a pending round');
+ }
+
+ if (status === 'COMPLETED' && roundData.status !== 'IN_PROGRESS') {
+ throw new Error('Can only complete an in-progress round');
+ }
+
+ if (status === 'CANCELLED' && !['PENDING', 'IN_PROGRESS'].includes(roundData.status)) {
+ throw new Error('Can only cancel pending or in-progress rounds');
+ }
+
+ // Handle treasury operations based on status transition
+ if (status === 'IN_PROGRESS' && roundData.status === 'PENDING') {
+ // Starting a round - deduct from treasury
+ const categoryRef = doc(db, COLLECTIONS.TREASURY, roundData.categoryId);
+ const categoryDoc = await transaction.get(categoryRef);
+
+ if (!categoryDoc.exists()) {
+ throw new Error('Treasury category not found');
+ }
+
+ const currentBalance = categoryDoc.data().balance || 0;
+ if (currentBalance < roundData.allocatedAmount) {
+ throw new Error('Insufficient funds in treasury category');
+ }
+
+ transaction.update(categoryRef, {
+ balance: currentBalance - roundData.allocatedAmount,
+ updatedAt: Timestamp.now()
+ });
+
+ // Record debit transaction
+ const debitTransactionRef = doc(collection(db, COLLECTIONS.TRANSACTIONS));
+ transaction.set(debitTransactionRef, {
+ type: 'DEBIT',
+ amount: roundData.allocatedAmount,
+ description: `Feeding round started for ${format(new Date(roundData.date), 'MMM d, yyyy')}`,
+ category: 'FEEDING_ROUND_START',
+ reference: id,
+ status: 'COMPLETED',
+ createdAt: Timestamp.now(),
+ updatedAt: Timestamp.now()
+ });
+ } else if (status === 'CANCELLED' && roundData.status === 'IN_PROGRESS') {
+ // Cancelling an in-progress round - refund to treasury
+ const categoryRef = doc(db, COLLECTIONS.TREASURY, roundData.categoryId);
+ const categoryDoc = await transaction.get(categoryRef);
+
+ if (!categoryDoc.exists()) {
+ throw new Error('Treasury category not found');
+ }
+
+ const currentBalance = categoryDoc.data().balance || 0;
+ transaction.update(categoryRef, {
+ balance: currentBalance + roundData.allocatedAmount,
+ updatedAt: Timestamp.now()
+ });
+
+ // Record credit transaction
+ const creditTransactionRef = doc(collection(db, COLLECTIONS.TRANSACTIONS));
+ transaction.set(creditTransactionRef, {
+ type: 'CREDIT',
+ amount: roundData.allocatedAmount,
+ description: `Feeding round cancelled for ${format(new Date(roundData.date), 'MMM d, yyyy')}`,
+ category: 'FEEDING_ROUND_CANCEL',
+ reference: id,
+ status: 'COMPLETED',
+ createdAt: Timestamp.now(),
+ updatedAt: Timestamp.now()
+ });
+ }
+
+ const updateData = {
status,
updatedAt: Timestamp.now()
- });
+ };
+
+ transaction.update(roundRef, updateData);
+ updatedRound = { ...roundData, ...updateData, id };
- // Record status change transaction inside the Firestore transaction
- const transactionRef = doc(collection(db, COLLECTIONS.TRANSACTIONS));
- const transactionData = {
+ // Record status change transaction
+ const statusTransactionRef = doc(collection(db, COLLECTIONS.TRANSACTIONS));
+ transaction.set(statusTransactionRef, {
type: 'STATUS_UPDATE',
amount: roundData.allocatedAmount,
description: `Feeding round status updated to ${status} for ${format(new Date(roundData.date), 'MMM d, yyyy')}`,
@@ -332,9 +419,10 @@ export const feedingRoundServices = {
status: 'COMPLETED',
createdAt: Timestamp.now(),
updatedAt: Timestamp.now()
- };
- transaction.set(transactionRef, transactionData);
+ });
});
+
+ return updatedRound!;
} catch (error) {
console.error('Error updating feeding round status:', error);
throw new Error(`Failed to update feeding round status: ${error.message}`);
@@ -343,20 +431,13 @@ export const feedingRoundServices = {
},
/**
- * Deletes a feeding round and refunds allocated amount to treasury
+ * Deletes a feeding round
* @async
* @param {string} id - Feeding round ID
* @throws {Error} If:
* - Feeding round not found
- * - Feeding category not found
+ * - Round is completed
* - Transaction fails
- *
- * @description
- * This operation performs the following steps atomically:
- * 1. Retrieves and validates the feeding round
- * 2. Verifies the treasury category exists
- * 3. Deletes the feeding round record
- * 4. Refunds the allocated amount to treasury category
*/
delete: async (id: string) => {
return retryOperation(async () => {
@@ -372,40 +453,47 @@ export const feedingRoundServices = {
}
deletedRoundData = roundDoc.data();
- const categoryRef = doc(db, COLLECTIONS.TREASURY, deletedRoundData.categoryId);
- const categoryDoc = await transaction.get(categoryRef);
-
- if (!categoryDoc.exists()) {
- throw new Error('Feeding category not found');
+
+ if (deletedRoundData.status === 'COMPLETED') {
+ throw new Error('Cannot delete a completed round');
}
- const currentBalance = categoryDoc.data().balance || 0;
+ // If the round was in progress, refund the treasury
+ if (deletedRoundData.status === 'IN_PROGRESS') {
+ const categoryRef = doc(db, COLLECTIONS.TREASURY, deletedRoundData.categoryId);
+ const categoryDoc = await transaction.get(categoryRef);
+
+ if (!categoryDoc.exists()) {
+ throw new Error('Treasury category not found');
+ }
- // Record the refund transaction first
- const transactionRef = doc(collection(db, COLLECTIONS.TRANSACTIONS));
- const now = Timestamp.now();
- const transactionData = {
- type: 'CREDIT',
- amount: deletedRoundData.allocatedAmount,
- description: `Refund from deleted feeding round for ${format(new Date(deletedRoundData.date), 'MMM d, yyyy')}`,
- category: 'FEEDING_ROUND_DELETE',
- reference: id,
- status: 'COMPLETED',
- createdAt: now,
- updatedAt: now
- };
- transaction.set(transactionRef, transactionData);
+ const currentBalance = categoryDoc.data().balance || 0;
+
+ // Record the refund transaction
+ const transactionRef = doc(collection(db, COLLECTIONS.TRANSACTIONS));
+ const now = Timestamp.now();
+ transaction.set(transactionRef, {
+ type: 'CREDIT',
+ amount: deletedRoundData.allocatedAmount,
+ description: `Refund from deleted feeding round for ${format(new Date(deletedRoundData.date), 'MMM d, yyyy')}`,
+ category: 'FEEDING_ROUND_DELETE',
+ reference: id,
+ status: 'COMPLETED',
+ createdAt: now,
+ updatedAt: now
+ });
+
+ // Update treasury balance
+ transaction.update(categoryRef, {
+ balance: currentBalance + deletedRoundData.allocatedAmount,
+ updatedAt: now
+ });
+ }
- // Then update treasury and delete the round
- transaction.update(categoryRef, {
- balance: currentBalance + deletedRoundData.allocatedAmount,
- updatedAt: now
- });
transaction.delete(roundRef);
});
- // Log successful deletion and transaction recording
- console.log(`Successfully deleted feeding round ${id} and recorded refund transaction`);
+ console.log(`Successfully deleted feeding round ${id}`);
return deletedRoundData;
} catch (error) {
console.error('Error deleting feeding round:', error);
diff --git a/src/services/firebase/treasuryService.ts b/src/services/firebase/treasuryService.ts
index bfa54e7..07e254d 100644
--- a/src/services/firebase/treasuryService.ts
+++ b/src/services/firebase/treasuryService.ts
@@ -32,6 +32,7 @@ export const treasuryServices = {
try {
await addDoc(collection(db, COLLECTIONS.TREASURY), {
...data,
+ treasuryId: 'default',
createdAt: Timestamp.now(),
updatedAt: Timestamp.now()
});
diff --git a/src/utils/treasuryValidator.ts b/src/utils/treasuryValidator.ts
new file mode 100644
index 0000000..1e4a94b
--- /dev/null
+++ b/src/utils/treasuryValidator.ts
@@ -0,0 +1,59 @@
+import { Payment, TreasuryCategory } from '../types';
+
+/**
+ * Validates if all completed payments have been properly deducted from treasury categories
+ * @param payments Array of payments to validate
+ * @param treasuryCategories Array of treasury categories to validate against
+ * @returns Object containing validation results
+ */
+export const validateTreasuryPayments = (
+ payments: Payment[],
+ treasuryCategories: TreasuryCategory[]
+) => {
+ const results = {
+ isValid: true,
+ discrepancies: [] as {
+ categoryId: string;
+ categoryName: string;
+ expectedBalance: number;
+ actualBalance: number;
+ difference: number;
+ completedPayments: Payment[];
+ }[]
+ };
+
+ // Group completed payments by category
+ const completedPaymentsByCategory = payments.reduce((acc, payment) => {
+ if (payment.status === 'COMPLETED') {
+ if (!acc[payment.categoryId]) {
+ acc[payment.categoryId] = [];
+ }
+ acc[payment.categoryId].push(payment);
+ }
+ return acc;
+ }, {} as Record);
+
+ // Calculate expected balances and check for discrepancies
+ treasuryCategories.forEach(category => {
+ const categoryPayments = completedPaymentsByCategory[category.id] || [];
+ const totalPaid = categoryPayments.reduce((sum, payment) => sum + payment.amount, 0);
+
+ // Calculate what the balance should be based on completed payments
+ // Note: This assumes the initial balance was correct and only checks completed payments
+ const expectedBalance = category.balance + totalPaid; // Add back the deducted payments to see if they match
+
+ if (Math.abs(expectedBalance - category.balance) > 0.01) { // Use small epsilon for floating point comparison
+ results.isValid = false;
+ results.discrepancies.push({
+ categoryId: category.id,
+ categoryName: category.name,
+ expectedBalance,
+ actualBalance: category.balance,
+ difference: expectedBalance - category.balance,
+ completedPayments: categoryPayments
+ });
+ }
+ });
+
+ return results;
+};
\ No newline at end of file