diff --git a/AUTODEV_REPORT.md b/AUTODEV_REPORT.md new file mode 100644 index 00000000..904910be --- /dev/null +++ b/AUTODEV_REPORT.md @@ -0,0 +1,36 @@ +# AUTODEV Report - Issue #133 Goal-based savings tracking & milestones + +## Summary +Implemented backend savings-goal tracking with milestone progress: +- Added a new `savings_goals` data model/table. +- Added authenticated endpoints to list/create goals and add contributions. +- Added milestone and progress calculations (25/50/75/100%), next milestone, remaining amount, monthly target, and status. +- Added backend tests and updated API/docs references. + +## Changed Files +- `packages/backend/tests/test_savings_goals.py` +- `packages/backend/app/models.py` +- `packages/backend/app/routes/savings.py` +- `packages/backend/app/routes/__init__.py` +- `packages/backend/app/db/schema.sql` +- `packages/backend/app/openapi.yaml` +- `README.md` + +## Test Commands +1. `sh ./scripts/test-backend.sh tests/test_savings_goals.py` (RED phase before implementation) +2. `sh ./scripts/test-backend.sh tests/test_savings_goals.py` (GREEN after implementation) +3. `sh ./scripts/test-backend.sh` (full backend regression suite) +4. `docker compose run --rm backend sh -lc "flake8 app/routes/savings.py app/models.py tests/test_savings_goals.py"` +5. `cd packages/backend && ../../.venv/bin/python - <<'PY' ... yaml.safe_load('app/openapi.yaml') ... PY` (OpenAPI YAML parse check) + +## Results +- RED phase: **FAIL as expected** (`404` on missing `/savings/goals` endpoints). +- New savings tests after implementation: **PASS** (`2 passed`). +- Full backend suite: **PASS** (`24 passed`). +- Flake8 on touched backend files: **PASS**. +- OpenAPI YAML parse check: **PASS**. + +## Risks / Follow-ups +- Existing deployed Postgres databases may require applying updated `schema.sql` (or equivalent migration) before using new savings endpoints. +- Milestones are currently fixed at 25/50/75/100; custom milestone definitions are not yet supported. +- No frontend wiring was added in this issue; current budgets UI remains static and does not yet consume the new savings API. diff --git a/README.md b/README.md index 49592bff..7f277600 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ flowchart LR ## PostgreSQL Schema (DDL) See `backend/app/db/schema.sql`. Key tables: -- users, categories, expenses, bills, reminders +- users, categories, expenses, bills, reminders, savings_goals - ad_impressions, subscription_plans, user_subscriptions - refresh_tokens (optional if rotating), audit_logs @@ -66,6 +66,7 @@ OpenAPI: `backend/app/openapi.yaml` - Bills: CRUD `/bills`, pay/mark `/bills/{id}/pay` - Reminders: CRUD `/reminders`, trigger `/reminders/run` - Insights: `/insights/monthly`, `/insights/budget-suggestion` +- Savings: goal tracking `/savings/goals`, contribute `/savings/goals/{id}/contributions` ## MVP UI/UX Plan - Auth screens: register/login. diff --git a/packages/backend/app/db/schema.sql b/packages/backend/app/db/schema.sql index 410189de..091313a1 100644 --- a/packages/backend/app/db/schema.sql +++ b/packages/backend/app/db/schema.sql @@ -84,6 +84,19 @@ CREATE INDEX IF NOT EXISTS idx_bills_user_due ON bills(user_id, next_due_date); ALTER TABLE bills ADD COLUMN IF NOT EXISTS autopay_enabled BOOLEAN NOT NULL DEFAULT FALSE; +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', + target_date DATE, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_savings_goals_user_target ON savings_goals(user_id, target_date); + CREATE TABLE IF NOT EXISTS reminders ( id SERIAL PRIMARY KEY, user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE, diff --git a/packages/backend/app/models.py b/packages/backend/app/models.py index 64d44810..453d92ad 100644 --- a/packages/backend/app/models.py +++ b/packages/backend/app/models.py @@ -89,6 +89,24 @@ class Bill(db.Model): created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) +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) + target_date = db.Column(db.Date, 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, + ) + + class Reminder(db.Model): __tablename__ = "reminders" id = db.Column(db.Integer, primary_key=True) diff --git a/packages/backend/app/openapi.yaml b/packages/backend/app/openapi.yaml index 3f8ec3f0..4dc2bd11 100644 --- a/packages/backend/app/openapi.yaml +++ b/packages/backend/app/openapi.yaml @@ -12,6 +12,7 @@ tags: - name: Bills - name: Reminders - name: Insights + - name: Savings paths: /auth/register: post: @@ -481,6 +482,86 @@ paths: application/json: schema: { $ref: '#/components/schemas/Error' } + /savings/goals: + get: + summary: List savings goals + tags: [Savings] + security: [{ bearerAuth: [] }] + responses: + '200': + description: List of savings goals + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/SavingsGoal' + post: + summary: Create savings goal + tags: [Savings] + security: [{ bearerAuth: [] }] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/NewSavingsGoal' + example: + name: Emergency Fund + target_amount: 10000 + current_amount: 2500 + currency: USD + target_date: 2026-12-31 + responses: + '201': { description: Created } + '400': + description: Validation error + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + + /savings/goals/{goalId}/contributions: + post: + summary: Add contribution to savings goal + tags: [Savings] + security: [{ bearerAuth: [] }] + parameters: + - in: path + name: goalId + required: true + schema: { type: integer } + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SavingsContribution' + example: + amount: 500 + responses: + '200': + description: Updated goal with milestone progress + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/SavingsGoal' + - type: object + properties: + newly_reached_milestones: + type: array + items: { type: integer } + '400': + description: Validation error + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '404': + description: Goal not found + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + components: securitySchemes: bearerAuth: @@ -587,3 +668,44 @@ components: message: { type: string } send_at: { type: string, format: date-time } channel: { type: string, enum: [email, whatsapp], default: email } + SavingsGoal: + type: object + properties: + id: { type: integer } + name: { type: string } + target_amount: { type: number, format: float } + current_amount: { type: number, format: float } + remaining_amount: { type: number, format: float } + progress_pct: { type: number, format: float } + status: { type: string, enum: [ahead, on-track, behind, completed] } + monthly_target: { type: number, format: float } + currency: { type: string } + target_date: { type: string, format: date, nullable: true } + milestones: + type: array + items: + type: object + properties: + percentage: { type: integer } + amount: { type: number, format: float } + reached: { type: boolean } + next_milestone: + type: object + nullable: true + properties: + percentage: { type: integer } + amount: { type: number, format: float } + NewSavingsGoal: + type: object + required: [name, target_amount] + properties: + name: { type: string } + target_amount: { type: number, format: float } + current_amount: { type: number, format: float, default: 0 } + currency: { type: string, default: INR } + target_date: { type: string, format: date, nullable: true } + SavingsContribution: + type: object + required: [amount] + properties: + amount: { type: number, format: float } diff --git a/packages/backend/app/routes/__init__.py b/packages/backend/app/routes/__init__.py index f13b0f89..0a92db36 100644 --- a/packages/backend/app/routes/__init__.py +++ b/packages/backend/app/routes/__init__.py @@ -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 import bp as savings_bp def register_routes(app: Flask): @@ -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_bp, url_prefix="/savings") diff --git a/packages/backend/app/routes/savings.py b/packages/backend/app/routes/savings.py new file mode 100644 index 00000000..251d1d69 --- /dev/null +++ b/packages/backend/app/routes/savings.py @@ -0,0 +1,202 @@ +from datetime import date, datetime +from decimal import Decimal, InvalidOperation +import math + +from flask import Blueprint, jsonify, request +from flask_jwt_extended import get_jwt_identity, jwt_required + +from ..extensions import db +from ..models import SavingsGoal, User + +bp = Blueprint("savings", __name__) +MILESTONE_PERCENTAGES = (25, 50, 75, 100) + + +@bp.get("/goals") +@jwt_required() +def list_goals(): + uid = int(get_jwt_identity()) + items = ( + db.session.query(SavingsGoal) + .filter(SavingsGoal.user_id == uid) + .order_by(SavingsGoal.created_at.desc(), SavingsGoal.id.desc()) + .all() + ) + return jsonify([_serialize_goal(goal) for goal in items]) + + +@bp.post("/goals") +@jwt_required() +def create_goal(): + uid = int(get_jwt_identity()) + data = request.get_json() or {} + + name = str(data.get("name") or "").strip() + if not name: + return jsonify(error="name is required"), 400 + + target_amount = _parse_decimal(data.get("target_amount")) + if target_amount is None or target_amount <= 0: + return jsonify(error="target_amount must be greater than 0"), 400 + + current_amount = _parse_decimal(data.get("current_amount"), default=Decimal("0")) + if current_amount is None or current_amount < 0: + return jsonify(error="current_amount must be greater than or equal to 0"), 400 + + target_date = _parse_target_date(data.get("target_date")) + if data.get("target_date") and target_date is None: + return jsonify(error="target_date must be a valid ISO date"), 400 + + user = db.session.get(User, uid) + goal = SavingsGoal( + user_id=uid, + name=name, + target_amount=target_amount, + current_amount=current_amount, + currency=(data.get("currency") or (user.preferred_currency if user else "INR")), + target_date=target_date, + ) + db.session.add(goal) + db.session.commit() + return jsonify(id=goal.id), 201 + + +@bp.post("/goals//contributions") +@jwt_required() +def add_contribution(goal_id: int): + uid = int(get_jwt_identity()) + goal = db.session.get(SavingsGoal, goal_id) + if not goal or goal.user_id != uid: + return jsonify(error="not found"), 404 + + data = request.get_json() or {} + amount = _parse_decimal(data.get("amount")) + if amount is None or amount <= 0: + return jsonify(error="amount must be greater than 0"), 400 + + previous_progress = _progress_pct(goal.current_amount, goal.target_amount) + goal.current_amount = (_to_decimal(goal.current_amount) + amount).quantize( + Decimal("0.01") + ) + goal.updated_at = datetime.utcnow() + db.session.commit() + + payload = _serialize_goal(goal) + payload["newly_reached_milestones"] = _newly_reached_milestones( + previous_progress, payload["progress_pct"] + ) + return jsonify(payload) + + +def _parse_decimal(value, default=None): + if value is None: + return default + parsed = _to_decimal(value) + if parsed is None: + return None + return parsed.quantize(Decimal("0.01")) + + +def _to_decimal(value) -> Decimal | None: + try: + return Decimal(str(value)) + except (InvalidOperation, TypeError, ValueError): + return None + + +def _parse_target_date(raw_value): + if raw_value in (None, ""): + return None + if isinstance(raw_value, date): + return raw_value + try: + return date.fromisoformat(str(raw_value)) + except ValueError: + return None + + +def _serialize_goal(goal: SavingsGoal) -> dict: + target_amount = float(goal.target_amount or 0) + current_amount = float(goal.current_amount or 0) + progress_pct = _progress_pct(goal.current_amount, goal.target_amount) + remaining_amount = round(max(target_amount - current_amount, 0), 2) + milestones, next_milestone = _build_milestones(target_amount, current_amount) + + return { + "id": goal.id, + "name": goal.name, + "target_amount": target_amount, + "current_amount": current_amount, + "currency": goal.currency, + "target_date": goal.target_date.isoformat() if goal.target_date else None, + "progress_pct": progress_pct, + "remaining_amount": remaining_amount, + "status": _goal_status(goal, progress_pct), + "monthly_target": _monthly_target(remaining_amount, goal.target_date), + "milestones": milestones, + "next_milestone": next_milestone, + } + + +def _progress_pct(current_amount, target_amount) -> float: + target = _to_decimal(target_amount) or Decimal("0") + if target <= 0: + return 0.0 + current = _to_decimal(current_amount) or Decimal("0") + pct = (current / target) * Decimal("100") + return round(float(min(pct, Decimal("100"))), 2) + + +def _build_milestones(target_amount: float, current_amount: float): + milestones = [] + next_milestone = None + for percentage in MILESTONE_PERCENTAGES: + amount = round((target_amount * percentage) / 100, 2) + reached = current_amount >= amount + milestone = {"percentage": percentage, "amount": amount, "reached": reached} + milestones.append(milestone) + if not reached and next_milestone is None: + next_milestone = {"percentage": percentage, "amount": amount} + return milestones, next_milestone + + +def _goal_status(goal: SavingsGoal, progress_pct: float) -> str: + if progress_pct >= 100: + return "completed" + + if not goal.target_date: + return "on-track" + + today = date.today() + if goal.target_date <= today: + return "behind" + + created_day = goal.created_at.date() if goal.created_at else today + total_days = max((goal.target_date - created_day).days, 1) + elapsed_days = min(max((today - created_day).days, 0), total_days) + expected_pct = (elapsed_days / total_days) * 100 + if progress_pct >= expected_pct + 5: + return "ahead" + if progress_pct + 5 >= expected_pct: + return "on-track" + return "behind" + + +def _monthly_target(remaining_amount: float, target_date_value: date | None) -> float: + if remaining_amount <= 0: + return 0.0 + if not target_date_value: + return 0.0 + days_remaining = (target_date_value - date.today()).days + if days_remaining <= 0: + return round(remaining_amount, 2) + months_remaining = max(math.ceil(days_remaining / 30), 1) + return round(remaining_amount / months_remaining, 2) + + +def _newly_reached_milestones(previous_progress: float, current_progress: float): + reached = [] + for percentage in MILESTONE_PERCENTAGES: + if previous_progress < percentage <= current_progress: + reached.append(percentage) + return reached diff --git a/packages/backend/tests/test_savings_goals.py b/packages/backend/tests/test_savings_goals.py new file mode 100644 index 00000000..a956e358 --- /dev/null +++ b/packages/backend/tests/test_savings_goals.py @@ -0,0 +1,78 @@ +from datetime import date, timedelta + + +def test_savings_goal_create_list_and_milestone_progress(client, auth_header): + r = client.get("/savings/goals", headers=auth_header) + assert r.status_code == 200 + assert r.get_json() == [] + + payload = { + "name": "Emergency Fund", + "target_amount": 1000, + "current_amount": 200, + "target_date": (date.today() + timedelta(days=180)).isoformat(), + } + r = client.post("/savings/goals", json=payload, headers=auth_header) + assert r.status_code == 201 + goal_id = r.get_json()["id"] + + r = client.get("/savings/goals", headers=auth_header) + assert r.status_code == 200 + items = r.get_json() + assert len(items) == 1 + goal = items[0] + assert goal["id"] == goal_id + assert goal["name"] == "Emergency Fund" + assert goal["progress_pct"] == 20.0 + assert goal["remaining_amount"] == 800.0 + assert goal["status"] in ("on-track", "ahead", "behind") + assert goal["next_milestone"]["percentage"] == 25 + + r = client.post( + f"/savings/goals/{goal_id}/contributions", + json={"amount": 60}, + headers=auth_header, + ) + assert r.status_code == 200 + updated = r.get_json() + assert updated["current_amount"] == 260.0 + assert updated["progress_pct"] == 26.0 + assert updated["newly_reached_milestones"] == [25] + assert updated["next_milestone"]["percentage"] == 50 + + +def test_savings_goal_validations_and_currency_default(client, auth_header): + r = client.patch( + "/auth/me", json={"preferred_currency": "INR"}, headers=auth_header + ) + assert r.status_code == 200 + + r = client.post( + "/savings/goals", + json={"name": "Trip", "target_amount": 5000}, + headers=auth_header, + ) + assert r.status_code == 201 + goal_id = r.get_json()["id"] + + r = client.get("/savings/goals", headers=auth_header) + assert r.status_code == 200 + created = next((goal for goal in r.get_json() if goal["id"] == goal_id), None) + assert created is not None + assert created["currency"] == "INR" + + r = client.post( + f"/savings/goals/{goal_id}/contributions", + json={"amount": 0}, + headers=auth_header, + ) + assert r.status_code == 400 + assert "amount" in r.get_json()["error"] + + r = client.post( + "/savings/goals", + json={"name": "Invalid Goal", "target_amount": -10}, + headers=auth_header, + ) + assert r.status_code == 400 + assert "target_amount" in r.get_json()["error"]