Skip to content
This repository was archived by the owner on Jun 19, 2026. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 134 additions & 0 deletions app/src/api/savings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { api } from './client';

export type GoalCategory =
| 'EMERGENCY'
| 'VACATION'
| 'HOME'
| 'CAR'
| 'EDUCATION'
| 'RETIREMENT'
| 'WEDDING'
| 'GADGET'
| 'INVESTMENT'
| 'OTHER';

export type GoalStatus = 'ACTIVE' | 'COMPLETED' | 'CANCELLED';

export type Milestone = {
id: number;
title: string;
target_percentage: number;
reached: boolean;
reached_at: string | null;
};

export type Contribution = {
id: number;
amount: number;
contribution_type: 'DEPOSIT' | 'WITHDRAWAL';
notes: string | null;
created_at: string;
};

export type SavingsGoal = {
id: number;
name: string;
target_amount: number;
current_amount: number;
currency: string;
category: GoalCategory;
status: GoalStatus;
deadline: string | null;
notes: string | null;
progress_percentage: number;
created_at: string;
updated_at: string;
milestones?: Milestone[];
contributions?: Contribution[];
newly_reached_milestones?: string[];
};

export type GoalCreate = {
name: string;
target_amount: number;
current_amount?: number;
currency?: string;
category?: GoalCategory;
deadline?: string;
notes?: string;
milestones?: { title: string; target_percentage: number }[];
};

export type GoalUpdate = Partial<GoalCreate> & { status?: GoalStatus };

export type GoalsSummary = {
active_goals: number;
total_target: number;
total_saved: number;
overall_progress: number;
};

export type ContributionResponse = {
message: string;
current_amount: number;
progress_percentage: number;
newly_reached_milestones: {
id: number;
title: string;
target_percentage: number;
}[];
status: GoalStatus;
};

export async function listGoals(
status?: string,
): Promise<SavingsGoal[]> {
const qs = new URLSearchParams();
if (status) qs.set('status', status);
const path = '/savings-goals' + (qs.toString() ? `?${qs.toString()}` : '');
return api<SavingsGoal[]>(path);
}

export async function getGoal(id: number): Promise<SavingsGoal> {
return api<SavingsGoal>(`/savings-goals/${id}`);
}

export async function createGoal(payload: GoalCreate): Promise<{ id: number }> {
return api<{ id: number }>('/savings-goals', { method: 'POST', body: payload });
}

export async function updateGoal(
id: number,
payload: GoalUpdate,
): Promise<SavingsGoal> {
return api<SavingsGoal>(`/savings-goals/${id}`, {
method: 'PATCH',
body: payload,
});
}

export async function deleteGoal(
id: number,
): Promise<{ message: string }> {
return api<{ message: string }>(`/savings-goals/${id}`, { method: 'DELETE' });
}

export async function addContribution(
goalId: number,
payload: { amount: number; type?: 'DEPOSIT' | 'WITHDRAWAL'; notes?: string },
): Promise<ContributionResponse> {
return api<ContributionResponse>(`/savings-goals/${goalId}/contribute`, {
method: 'POST',
body: payload,
});
}

export async function listContributions(
goalId: number,
): Promise<Contribution[]> {
return api<Contribution[]>(`/savings-goals/${goalId}/contributions`);
}

export async function getGoalsSummary(): Promise<GoalsSummary> {
return api<GoalsSummary>('/savings-goals/summary');
}
38 changes: 38 additions & 0 deletions packages/backend/app/db/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,41 @@ CREATE TABLE IF NOT EXISTS audit_logs (
action VARCHAR(100) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);

-- Savings Goals
CREATE TABLE IF NOT EXISTS savings_goals (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(200) NOT NULL,
target_amount NUMERIC(12,2) NOT NULL,
current_amount NUMERIC(12,2) NOT NULL DEFAULT 0,
currency VARCHAR(10) NOT NULL DEFAULT 'INR',
category VARCHAR(20) NOT NULL DEFAULT 'OTHER',
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
deadline DATE,
notes VARCHAR(500),
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_savings_goals_user ON savings_goals(user_id, status);

CREATE TABLE IF NOT EXISTS goal_milestones (
id SERIAL PRIMARY KEY,
goal_id INT NOT NULL REFERENCES savings_goals(id) ON DELETE CASCADE,
title VARCHAR(200) NOT NULL,
target_percentage INT NOT NULL,
reached BOOLEAN NOT NULL DEFAULT FALSE,
reached_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_goal_milestones_goal ON goal_milestones(goal_id);

CREATE TABLE IF NOT EXISTS goal_contributions (
id SERIAL PRIMARY KEY,
goal_id INT NOT NULL REFERENCES savings_goals(id) ON DELETE CASCADE,
amount NUMERIC(12,2) NOT NULL,
contribution_type VARCHAR(20) NOT NULL DEFAULT 'DEPOSIT',
notes VARCHAR(500),
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_goal_contributions_goal ON goal_contributions(goal_id, created_at DESC);
71 changes: 71 additions & 0 deletions packages/backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,3 +133,74 @@ class AuditLog(db.Model):
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
action = db.Column(db.String(100), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)


class GoalCategory(str, Enum):
EMERGENCY = "EMERGENCY"
VACATION = "VACATION"
HOME = "HOME"
CAR = "CAR"
EDUCATION = "EDUCATION"
RETIREMENT = "RETIREMENT"
WEDDING = "WEDDING"
GADGET = "GADGET"
INVESTMENT = "INVESTMENT"
OTHER = "OTHER"


class GoalStatus(str, Enum):
ACTIVE = "ACTIVE"
COMPLETED = "COMPLETED"
CANCELLED = "CANCELLED"


class SavingsGoal(db.Model):
__tablename__ = "savings_goals"
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
name = db.Column(db.String(200), nullable=False)
target_amount = db.Column(db.Numeric(12, 2), nullable=False)
current_amount = db.Column(db.Numeric(12, 2), default=0, nullable=False)
currency = db.Column(db.String(10), default="INR", nullable=False)
category = db.Column(db.String(20), default=GoalCategory.OTHER.value, nullable=False)
status = db.Column(db.String(20), default=GoalStatus.ACTIVE.value, nullable=False)
deadline = db.Column(db.Date, nullable=True)
notes = db.Column(db.String(500), nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(
db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
)

milestones = db.relationship(
"GoalMilestone", backref="goal", lazy="dynamic", cascade="all, delete-orphan"
)
contributions = db.relationship(
"GoalContribution", backref="goal", lazy="dynamic", cascade="all, delete-orphan"
)


class GoalMilestone(db.Model):
__tablename__ = "goal_milestones"
id = db.Column(db.Integer, primary_key=True)
goal_id = db.Column(
db.Integer, db.ForeignKey("savings_goals.id", ondelete="CASCADE"), nullable=False
)
title = db.Column(db.String(200), nullable=False)
target_percentage = db.Column(db.Integer, nullable=False) # 25, 50, 75, 100
reached = db.Column(db.Boolean, default=False, nullable=False)
reached_at = db.Column(db.DateTime, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)


class GoalContribution(db.Model):
__tablename__ = "goal_contributions"
id = db.Column(db.Integer, primary_key=True)
goal_id = db.Column(
db.Integer, db.ForeignKey("savings_goals.id", ondelete="CASCADE"), nullable=False
)
amount = db.Column(db.Numeric(12, 2), nullable=False)
contribution_type = db.Column(
db.String(20), default="DEPOSIT", nullable=False
) # DEPOSIT or WITHDRAWAL
notes = db.Column(db.String(500), nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
2 changes: 2 additions & 0 deletions packages/backend/app/routes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from .categories import bp as categories_bp
from .docs import bp as docs_bp
from .dashboard import bp as dashboard_bp
from .savings_goals import bp as savings_goals_bp


def register_routes(app: Flask):
Expand All @@ -18,3 +19,4 @@ def register_routes(app: Flask):
app.register_blueprint(categories_bp, url_prefix="/categories")
app.register_blueprint(docs_bp, url_prefix="/docs")
app.register_blueprint(dashboard_bp, url_prefix="/dashboard")
app.register_blueprint(savings_goals_bp, url_prefix="/savings-goals")
Loading
Loading