diff --git a/app/controllers/api/v1/budgets_controller.rb b/app/controllers/api/v1/budgets_controller.rb
index f2816a3..fc0a157 100644
--- a/app/controllers/api/v1/budgets_controller.rb
+++ b/app/controllers/api/v1/budgets_controller.rb
@@ -41,7 +41,7 @@ def show
# PATCH/PUT /api/v1/budgets/:id
def update
if @budget.update(budget_params)
- render json: @budget, status: :ok
+ render json: BudgetSerializer.new(@budget).serializable_hash[:data], status: :ok
else
render json: { errors: @budget.errors.full_messages }, status: :unprocessable_entity
end
diff --git a/app/models/budget.rb b/app/models/budget.rb
index 5bca706..4731cea 100644
--- a/app/models/budget.rb
+++ b/app/models/budget.rb
@@ -4,4 +4,12 @@ class Budget < ApplicationRecord
validates :name, presence: true
validates :financial_goal, presence: true, numericality: { greater_than: 0 }
+
+ def total_spent
+ transactions.sum(:amount)
+ end
+
+ def remaining_amount
+ financial_goal - total_spent
+ end
end
diff --git a/app/serializers/budget_serializer.rb b/app/serializers/budget_serializer.rb
index f0d7e2f..c1ce8d9 100644
--- a/app/serializers/budget_serializer.rb
+++ b/app/serializers/budget_serializer.rb
@@ -3,6 +3,14 @@ class BudgetSerializer
attributes :id, :name, :financial_goal, :created_at, :updated_at
+ attribute :spent do |budget|
+ budget.total_spent
+ end
+
+ attribute :remaining do |budget|
+ budget.remaining_amount
+ end
+
has_many :transactions
belongs_to :user
end
diff --git a/client/src/App.js b/client/src/App.js
index 40a8a5b..5bdcf99 100644
--- a/client/src/App.js
+++ b/client/src/App.js
@@ -4,7 +4,7 @@ import './App.css';
import { AuthProvider } from './contexts/AuthContext';
import Layout from './components/Layout';
import Dashboard from './components/Dashboard';
-import Budgets from './components/Budgets';
+import Budgets from './components/Budgets/Budgets';
import Transactions from './components/Transactions/Transactions';
import Categories from './components/Categories';
import Analytics from './components/Analytics';
diff --git a/client/src/components/Budgets.js b/client/src/components/Budgets.js
deleted file mode 100644
index 8592775..0000000
--- a/client/src/components/Budgets.js
+++ /dev/null
@@ -1,253 +0,0 @@
-import React, { useState, useEffect } from 'react';
-import { useForm } from 'react-hook-form';
-import { PlusCircle, Edit, Trash2, Eye, DollarSign } from 'lucide-react';
-
-const Budgets = () => {
- const [budgets, setBudgets] = useState([]);
- const [showForm, setShowForm] = useState(false);
- const [editingBudget, setEditingBudget] = useState(null);
- const [loading, setLoading] = useState(true);
-
- const { register, handleSubmit, reset, formState: { errors } } = useForm();
-
- useEffect(() => {
- // Mock data - will be replaced with API calls
- setTimeout(() => {
- setBudgets([
- {
- id: 1,
- name: 'Monthly Budget',
- financial_goal: 3000,
- spent: 1850,
- remaining: 1150,
- created_at: '2024-01-01'
- },
- {
- id: 2,
- name: 'Vacation Fund',
- financial_goal: 5000,
- spent: 1200,
- remaining: 3800,
- created_at: '2024-01-15'
- },
- {
- id: 3,
- name: 'Emergency Fund',
- financial_goal: 10000,
- spent: 0,
- remaining: 10000,
- created_at: '2024-01-20'
- }
- ]);
- setLoading(false);
- }, 1000);
- }, []);
-
- const onSubmit = (data) => {
- const budgetData = {
- ...data,
- financial_goal: parseFloat(data.financial_goal),
- spent: 0,
- remaining: parseFloat(data.financial_goal)
- };
-
- if (editingBudget) {
- // Update existing budget
- setBudgets(budgets.map(budget =>
- budget.id === editingBudget.id
- ? { ...budget, ...budgetData }
- : budget
- ));
- } else {
- // Add new budget
- const newBudget = {
- ...budgetData,
- id: Date.now(),
- created_at: new Date().toISOString().split('T')[0]
- };
- setBudgets([...budgets, newBudget]);
- }
-
- reset();
- setShowForm(false);
- setEditingBudget(null);
- };
-
- const handleEdit = (budget) => {
- setEditingBudget(budget);
- reset({
- name: budget.name,
- financial_goal: budget.financial_goal
- });
- setShowForm(true);
- };
-
- const handleDelete = (budgetId) => {
- if (window.confirm('Are you sure you want to delete this budget?')) {
- setBudgets(budgets.filter(budget => budget.id !== budgetId));
- }
- };
-
- const handleCancel = () => {
- setShowForm(false);
- setEditingBudget(null);
- reset();
- };
-
- if (loading) {
- return (
-
- );
- }
-
- return (
-
-
-
-
Budgets
-
Manage your financial goals and track your spending
-
-
setShowForm(true)}
- >
-
- New Budget
-
-
-
- {/* Budget Form */}
- {showForm && (
-
-
-
{editingBudget ? 'Edit Budget' : 'Create New Budget'}
-
-
-
-
- )}
-
- {/* Budgets Grid */}
-
- {budgets.length === 0 ? (
-
-
-
No budgets yet
-
Create your first budget to start tracking your spending
-
setShowForm(true)}
- >
-
- Create Budget
-
-
- ) : (
- budgets.map((budget) => (
-
-
-
-
{budget.name}
- Created {budget.created_at}
-
-
- handleEdit(budget)}
- title="Edit Budget"
- >
-
-
- handleDelete(budget.id)}
- title="Delete Budget"
- >
-
-
-
-
-
-
-
- Goal
- ${budget.financial_goal.toLocaleString()}
-
-
- Spent
- ${budget.spent.toLocaleString()}
-
-
- Remaining
- ${budget.remaining.toLocaleString()}
-
-
-
-
-
-
- {((budget.spent / budget.financial_goal) * 100).toFixed(1)}% used
-
-
-
-
-
-
- View Details
-
-
-
- ))
- )}
-
-
- );
-};
-
-export default Budgets;
diff --git a/client/src/components/Budgets/BudgetCard.js b/client/src/components/Budgets/BudgetCard.js
new file mode 100644
index 0000000..da208ee
--- /dev/null
+++ b/client/src/components/Budgets/BudgetCard.js
@@ -0,0 +1,56 @@
+import React from 'react';
+import { Edit, Trash2, Eye } from 'lucide-react';
+
+const BudgetCard = ({ budget, onEdit, onDelete }) => {
+ const progressPercent = Math.min((budget.spent / budget.financial_goal) * 100, 100);
+
+ return (
+
+
+
+
{budget.name}
+ Created {budget.created_at}
+
+
+ onEdit(budget)} title="Edit Budget">
+
+
+ onDelete(budget.id)} title="Delete Budget">
+
+
+
+
+
+
+
+ Goal
+ ${budget.financial_goal.toLocaleString()}
+
+
+ Spent
+ ${budget.spent.toLocaleString()}
+
+
+ Remaining
+ ${budget.remaining.toLocaleString()}
+
+
+
+
+
+
{progressPercent.toFixed(1)}% used
+
+
+
+
+
+ View Details
+
+
+
+ );
+};
+
+export default BudgetCard;
diff --git a/client/src/components/Budgets/BudgetForm.js b/client/src/components/Budgets/BudgetForm.js
new file mode 100644
index 0000000..21d04ad
--- /dev/null
+++ b/client/src/components/Budgets/BudgetForm.js
@@ -0,0 +1,121 @@
+import React, { useEffect } from 'react';
+import { useForm } from 'react-hook-form';
+import { budgetsAPI } from '../../services/api';
+
+const BudgetForm = ({ editingBudget, onSubmit, onCancel, budgets }) => {
+ const {
+ register,
+ handleSubmit,
+ reset,
+ formState: { errors },
+ } = useForm({
+ defaultValues: { name: '', financial_goal: 0 },
+ });
+
+ useEffect(() => {
+ if (editingBudget) {
+ reset({
+ name: editingBudget.name,
+ financial_goal: editingBudget.financial_goal,
+ });
+ } else {
+ reset({ name: '', financial_goal: 0 });
+ }
+ }, [editingBudget, reset]);
+
+ const submitHandler = async (data) => {
+ const payload = {
+ name: data.name,
+ financial_goal: parseFloat(data.financial_goal),
+ };
+
+ try {
+ if (editingBudget) {
+ const res = await budgetsAPI.update(editingBudget.id, { budget: payload });
+ const returned = res.data?.data || res.data;
+ const attrs = returned?.attributes || payload;
+
+ const updatedBudgets = budgets.map((b) =>
+ String(b.id) === String(returned?.id || editingBudget.id)
+ ? {
+ ...b,
+ name: attrs.name,
+ financial_goal: parseFloat(attrs.financial_goal || b.financial_goal),
+ spent: parseFloat(attrs.spent || b.spent),
+ remaining: parseFloat(attrs.remaining || b.remaining),
+ created_at: attrs.created_at || b.created_at,
+ }
+ : b
+ );
+ onSubmit(updatedBudgets);
+ } else {
+ const res = await budgetsAPI.create({ budget: payload });
+ const returned = res.data?.data || res.data;
+ const attrs = returned?.attributes || payload;
+
+ const newBudget = {
+ id: returned?.id || Date.now(),
+ name: attrs.name,
+ financial_goal: parseFloat(attrs.financial_goal || 0),
+ spent: 0,
+ remaining: parseFloat(attrs.financial_goal || 0),
+ created_at: attrs.created_at || new Date().toISOString(),
+ };
+
+ onSubmit([newBudget, ...budgets]);
+ }
+ } catch (err) {
+ console.error('Budget save failed', err);
+ alert('Failed to save budget');
+ }
+ };
+
+ return (
+
+
+
{editingBudget ? 'Edit Budget' : 'Create New Budget'}
+
+
+
+
+ );
+};
+
+export default BudgetForm;
diff --git a/client/src/components/Budgets/Budgets.js b/client/src/components/Budgets/Budgets.js
new file mode 100644
index 0000000..230352f
--- /dev/null
+++ b/client/src/components/Budgets/Budgets.js
@@ -0,0 +1,102 @@
+import React, { useState, useEffect } from 'react';
+import { PlusCircle } from 'lucide-react';
+import { budgetsAPI } from '../../services/api';
+import LoadingSpinner from './LoadingSpinner';
+import BudgetForm from './BudgetForm';
+import BudgetsGrid from './BudgetsGrid';
+
+const Budgets = () => {
+ const [budgets, setBudgets] = useState([]);
+ const [showForm, setShowForm] = useState(false);
+ const [editingBudget, setEditingBudget] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ const fetchBudgets = async () => {
+ setLoading(true);
+ try {
+ const res = await budgetsAPI.getAll();
+ const items = res.data?.data || [];
+
+ const mapped = items.map((b) => {
+ const attrs = b.attributes || {};
+ return {
+ id: b.id || attrs.id,
+ name: attrs.name || b.name,
+ financial_goal: parseFloat(attrs.financial_goal || 0),
+ spent: parseFloat(attrs.spent || 0),
+ remaining: parseFloat(attrs.remaining || 0),
+ created_at: attrs.created_at ? new Date(attrs.created_at).toLocaleString() : '',
+ };
+ });
+ setBudgets(mapped);
+ } catch (err) {
+ console.error('Failed to load budgets', err);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchBudgets();
+ }, []);
+
+ const handleEdit = (budget) => {
+ setEditingBudget(budget);
+ setShowForm(true);
+ };
+
+ const handleDelete = async (budgetId) => {
+ if (!window.confirm('Are you sure you want to delete this budget?')) return;
+
+ setLoading(true);
+ try {
+ await budgetsAPI.delete(budgetId);
+ setBudgets((prev) => prev.filter((b) => String(b.id) !== String(budgetId)));
+ } catch (err) {
+ console.error('Delete budget failed', err);
+ alert('Failed to delete budget');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleFormSubmit = (updatedBudgets) => {
+ setBudgets(updatedBudgets);
+ setShowForm(false);
+ setEditingBudget(null);
+ };
+
+ const handleCancel = () => {
+ setShowForm(false);
+ setEditingBudget(null);
+ };
+
+ if (loading) return ;
+
+ return (
+
+
+
+ {showForm && (
+
+ )}
+
+
+
+ );
+};
+
+export default Budgets;
diff --git a/client/src/components/Budgets/BudgetsGrid.js b/client/src/components/Budgets/BudgetsGrid.js
new file mode 100644
index 0000000..f952105
--- /dev/null
+++ b/client/src/components/Budgets/BudgetsGrid.js
@@ -0,0 +1,28 @@
+import React from 'react';
+import { Edit, Trash2, Eye, DollarSign, PlusCircle } from 'lucide-react';
+import BudgetCard from './BudgetCard';
+
+const BudgetsGrid = ({ budgets, onEdit, onDelete }) => {
+ if (budgets.length === 0) {
+ return (
+
+
+
No budgets yet
+
Create your first budget to start tracking your spending
+
onEdit(null)}>
+ Create Budget
+
+
+ );
+ }
+
+ return (
+
+ {budgets.map((budget) => (
+
+ ))}
+
+ );
+};
+
+export default BudgetsGrid;
diff --git a/client/src/components/Budgets/LoadingSpinner.js b/client/src/components/Budgets/LoadingSpinner.js
new file mode 100644
index 0000000..5b05023
--- /dev/null
+++ b/client/src/components/Budgets/LoadingSpinner.js
@@ -0,0 +1,10 @@
+import React from 'react';
+
+const LoadingSpinner = ({ message }) => (
+
+);
+
+export default LoadingSpinner;
diff --git a/client/src/components/Transactions/TransactionForm.js b/client/src/components/Transactions/TransactionForm.js
index 707061c..bb1ece9 100644
--- a/client/src/components/Transactions/TransactionForm.js
+++ b/client/src/components/Transactions/TransactionForm.js
@@ -41,7 +41,11 @@ const TransactionForm = ({ budgets, categories, onSubmit, editingTransaction, on
type="number"
step="0.01"
min="0"
- {...register('amount', { required: 'Amount is required', min: { value: 0.01, message: 'Amount must be greater than 0' } })}
+ {...register('amount', {
+ required: 'Amount is required',
+ min: { value: 0.01, message: 'Amount must be greater than 0' },
+ max: { value: 1000000, message: 'Amount must not be greater than 1,000,000' },
+ })}
placeholder="0.00"
/>
{errors.amount && {errors.amount.message} }
@@ -55,18 +59,20 @@ const TransactionForm = ({ budgets, categories, onSubmit, editingTransaction, on
{errors.date && {errors.date.message} }
-
- Budget
-
- Select a budget
- {budgets.map((b) => (
-
- {b.attributes?.name || b.name}
-
- ))}
-
- {errors.budget_id && {errors.budget_id.message} }
-
+ {!editingTransaction && (
+
+ Budget
+
+ Select a budget
+ {budgets.map((b) => (
+
+ {b.attributes?.name || b.name}
+
+ ))}
+
+ {errors.budget_id && {errors.budget_id.message} }
+
+ )}
Category
diff --git a/spec/controllers/budget_controller_spec.rb b/spec/controllers/budget_controller_spec.rb
index 940347b..2d16154 100644
--- a/spec/controllers/budget_controller_spec.rb
+++ b/spec/controllers/budget_controller_spec.rb
@@ -9,7 +9,6 @@
end
describe 'GET #index' do
- # Create data inside a before block for this specific action
before do
create_list(:budget, 2, user: user)
end
@@ -62,13 +61,25 @@
describe 'PUT #update' do
let(:budget) { create(:budget, user: user) }
- it 'updates the budget' do
+ it 'updates the budget and returns serialized data' do
put :update, params: {
id: budget.id,
budget: { name: 'Updated Budget Name' }
}
- expect(response).to have_http_status(:success)
+
+ expect(response).to have_http_status(:ok)
+
expect(budget.reload.name).to eq('Updated Budget Name')
+
+ json = JSON.parse(response.body)
+
+ expect(json).to have_key('id').or have_key('data')
+ if json.key?('data')
+ expect(json['data']['id'].to_s).to eq(budget.id.to_s)
+ expect(json['data']['attributes']['name']).to eq('Updated Budget Name')
+ else
+ expect(json['id'].to_s).to eq(budget.id.to_s)
+ end
end
end
diff --git a/spec/models/budget_spec.rb b/spec/models/budget_spec.rb
index db21e6e..4ad17a0 100644
--- a/spec/models/budget_spec.rb
+++ b/spec/models/budget_spec.rb
@@ -11,4 +11,50 @@
it { should belong_to(:user) }
it { should have_many(:transactions) }
end
+
+ describe 'calculations' do
+ let(:user) { create(:user) }
+ let(:budget) { create(:budget, user: user, financial_goal: 1000.0) }
+
+ context 'total_spent' do
+ it 'returns 0 when there are no transactions' do
+ expect(budget.total_spent.to_f).to eq(0.0)
+ end
+
+ it 'sums transaction amounts for the budget' do
+ create(:transaction, budget: budget, amount: 150.0)
+ create(:transaction, budget: budget, amount: 75.5)
+ create(:transaction, budget: budget, amount: 24.5)
+
+ expect(budget.total_spent.to_f).to eq(250.0)
+ end
+
+ it 'does not include transactions from other budgets' do
+ other_budget = create(:budget, user: user)
+ create(:transaction, budget: other_budget, amount: 500.0)
+ create(:transaction, budget: budget, amount: 50.0)
+
+ expect(budget.total_spent.to_f).to eq(50.0)
+ end
+ end
+
+ context 'remaining_amount' do
+ it 'returns financial_goal when no transactions exist' do
+ expect(budget.remaining_amount.to_f).to eq(1000.0)
+ end
+
+ it 'returns financial_goal minus total_spent' do
+ create(:transaction, budget: budget, amount: 200.0)
+ create(:transaction, budget: budget, amount: 100.0)
+
+ expect(budget.remaining_amount.to_f).to eq(700.0)
+ end
+
+ it 'can be negative when spent exceeds goal' do
+ create(:transaction, budget: budget, amount: 1200.0)
+
+ expect(budget.remaining_amount.to_f).to eq(-200.0)
+ end
+ end
+ end
end