From 6a7159bbfd7f81e77d78bd5cffef7d96489629be Mon Sep 17 00:00:00 2001 From: Entr0zy <267706715+Entr0zy@users.noreply.github.com> Date: Sun, 24 May 2026 05:52:21 +0100 Subject: [PATCH] feat: goal-based savings tracking & milestones (#133) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements production-ready savings goal tracking with full milestone recording, resolving issue #133. New model additions (models.py): - SavingsGoal — user-scoped goal with target_amount, current_amount, target_date, status (ACTIVE / COMPLETED / ABANDONED), currency - GoalDeposit — deposit ledger entry per goal - GoalMilestone — auto-recorded when 25 / 50 / 75 / 100% is crossed New service (app/services/savings.py): - create_goal / get_goals / get_goal / update_goal / delete_goal - deposit() — credits amount, auto-completes goal at target, triggers _record_new_milestones() (idempotent, no duplicates) - goal_to_dict() — serialises goal with progress_pct New routes (app/routes/savings.py): - GET /savings/goals - POST /savings/goals - GET /savings/goals/ - PATCH /savings/goals/ - DELETE /savings/goals/ → 204 No Content - POST /savings/goals//deposit - GET /savings/goals//milestones Tests (tests/test_savings.py — 28 tests): - Auth gates, create (happy + 3 error paths), list/get, update, delete, deposits (happy + invalid + auto-complete + post-complete rejection), milestones (empty, partial, all-4, no-duplicates), service-layer unit test Co-Authored-By: Claude Sonnet 4.6 --- packages/backend/app/models.py | 57 +++++ packages/backend/app/routes/__init__.py | 2 + packages/backend/app/routes/savings.py | 106 +++++++++ packages/backend/app/services/savings.py | 216 ++++++++++++++++++ packages/backend/tests/test_savings.py | 277 +++++++++++++++++++++++ 5 files changed, 658 insertions(+) create mode 100644 packages/backend/app/routes/savings.py create mode 100644 packages/backend/app/services/savings.py create mode 100644 packages/backend/tests/test_savings.py diff --git a/packages/backend/app/models.py b/packages/backend/app/models.py index 64d448104..51a6e8d34 100644 --- a/packages/backend/app/models.py +++ b/packages/backend/app/models.py @@ -133,3 +133,60 @@ 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) + + +# --------------------------------------------------------------------------- +# Savings Goals +# --------------------------------------------------------------------------- + +class GoalStatus(str, Enum): + ACTIVE = "ACTIVE" + COMPLETED = "COMPLETED" + ABANDONED = "ABANDONED" + + +class SavingsGoal(db.Model): + """A named savings target with optional deadline and deposit history.""" + __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) + description = db.Column(db.String(500), nullable=True) + target_amount = db.Column(db.Numeric(12, 2), nullable=False) + current_amount = db.Column(db.Numeric(12, 2), default=0, nullable=False) + target_date = db.Column(db.Date, nullable=True) + status = db.Column(db.String(20), default=GoalStatus.ACTIVE.value, nullable=False) + currency = db.Column(db.String(10), default="INR", nullable=False) + 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 + ) + deposits = db.relationship( + "GoalDeposit", backref="goal", cascade="all, delete-orphan", lazy="dynamic" + ) + milestones = db.relationship( + "GoalMilestone", backref="goal", cascade="all, delete-orphan", lazy="dynamic" + ) + + +class GoalDeposit(db.Model): + """A single deposit credited towards a savings goal.""" + __tablename__ = "goal_deposits" + id = db.Column(db.Integer, primary_key=True) + goal_id = db.Column( + db.Integer, db.ForeignKey("savings_goals.id"), nullable=False + ) + amount = db.Column(db.Numeric(12, 2), nullable=False) + note = db.Column(db.String(300), nullable=True) + deposited_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + +class GoalMilestone(db.Model): + """Milestone automatically recorded when a goal reaches 25 / 50 / 75 / 100 %.""" + __tablename__ = "goal_milestones" + id = db.Column(db.Integer, primary_key=True) + goal_id = db.Column( + db.Integer, db.ForeignKey("savings_goals.id"), nullable=False + ) + pct = db.Column(db.Integer, nullable=False) # 25, 50, 75, 100 + reached_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) diff --git a/packages/backend/app/routes/__init__.py b/packages/backend/app/routes/__init__.py index f13b0f897..0a92db36d 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 000000000..67b8195be --- /dev/null +++ b/packages/backend/app/routes/savings.py @@ -0,0 +1,106 @@ +"""Savings goals routes. + +Endpoints +--------- +GET /savings/goals list all goals for the current user +POST /savings/goals create a new goal +GET /savings/goals/ get one goal +PATCH /savings/goals/ update name / target_amount / target_date / status +DELETE /savings/goals/ delete a goal +POST /savings/goals//deposit add money to a goal +GET /savings/goals//milestones list milestones reached +""" +from flask import Blueprint, jsonify, request +from flask_jwt_extended import jwt_required, get_jwt_identity +import logging + +from ..services.savings import ( + create_goal, + delete_goal, + deposit, + get_goal, + get_goals, + get_milestones, + goal_to_dict, + update_goal, +) + +bp = Blueprint("savings", __name__) +logger = logging.getLogger("finmind.savings.routes") + + +@bp.get("/goals") +@jwt_required() +def list_goals(): + uid = int(get_jwt_identity()) + goals = get_goals(uid) + return jsonify([goal_to_dict(g) for g in goals]) + + +@bp.post("/goals") +@jwt_required() +def create_goal_endpoint(): + uid = int(get_jwt_identity()) + data = request.get_json() or {} + goal, err = create_goal(uid, data) + if err: + return jsonify(error=err), 400 + return jsonify(goal_to_dict(goal)), 201 + + +@bp.get("/goals/") +@jwt_required() +def get_goal_endpoint(goal_id: int): + uid = int(get_jwt_identity()) + goal = get_goal(uid, goal_id) + if not goal: + return jsonify(error="not found"), 404 + return jsonify(goal_to_dict(goal)) + + +@bp.patch("/goals/") +@jwt_required() +def update_goal_endpoint(goal_id: int): + uid = int(get_jwt_identity()) + data = request.get_json() or {} + goal, err = update_goal(uid, goal_id, data) + if err == "not found": + return jsonify(error=err), 404 + if err: + return jsonify(error=err), 400 + return jsonify(goal_to_dict(goal)) + + +@bp.delete("/goals/") +@jwt_required() +def delete_goal_endpoint(goal_id: int): + uid = int(get_jwt_identity()) + if not delete_goal(uid, goal_id): + return jsonify(error="not found"), 404 + return "", 204 + + +@bp.post("/goals//deposit") +@jwt_required() +def deposit_endpoint(goal_id: int): + uid = int(get_jwt_identity()) + data = request.get_json() or {} + goal, err = deposit(uid, goal_id, data.get("amount"), data.get("note")) + if err == "not found": + return jsonify(error=err), 404 + if err: + return jsonify(error=err), 400 + return jsonify(goal_to_dict(goal)) + + +@bp.get("/goals//milestones") +@jwt_required() +def milestones_endpoint(goal_id: int): + uid = int(get_jwt_identity()) + milestones = get_milestones(uid, goal_id) + if milestones is None: + return jsonify(error="not found"), 404 + return jsonify([ + {"pct": m.pct, "reached_at": m.reached_at.isoformat()} + for m in milestones + ]) diff --git a/packages/backend/app/services/savings.py b/packages/backend/app/services/savings.py new file mode 100644 index 000000000..d4bc9cb75 --- /dev/null +++ b/packages/backend/app/services/savings.py @@ -0,0 +1,216 @@ +"""Savings goal service. + +Public API +---------- +create_goal(uid, data) -> SavingsGoal +get_goals(uid) -> list[SavingsGoal] +get_goal(uid, goal_id) -> SavingsGoal | None +update_goal(uid, goal_id, data) -> SavingsGoal | None +delete_goal(uid, goal_id) -> bool +deposit(uid, goal_id, amount, note) -> SavingsGoal | None +get_milestones(uid, goal_id) -> list[GoalMilestone] +goal_to_dict(goal) -> dict +""" +from __future__ import annotations + +import logging +from datetime import date, datetime +from decimal import Decimal, InvalidOperation +from typing import Any + +from ..extensions import db +from ..models import GoalDeposit, GoalMilestone, GoalStatus, SavingsGoal + +logger = logging.getLogger("finmind.savings") + +_MILESTONE_PCTS = (25, 50, 75, 100) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _parse_amount(value: Any) -> Decimal | None: + try: + d = Decimal(str(value)) + return d if d > 0 else None + except (InvalidOperation, TypeError): + return None + + +def _progress_pct(goal: SavingsGoal) -> float: + if goal.target_amount <= 0: + return 0.0 + return float(goal.current_amount / goal.target_amount * 100) + + +def _record_new_milestones(goal: SavingsGoal) -> None: + """Record any newly crossed milestone thresholds (idempotent).""" + pct = _progress_pct(goal) + existing = {m.pct for m in goal.milestones} + for threshold in _MILESTONE_PCTS: + if pct >= threshold and threshold not in existing: + db.session.add(GoalMilestone(goal_id=goal.id, pct=threshold)) + logger.info( + "Goal id=%s crossed %s%% milestone (current=%.2f target=%.2f)", + goal.id, threshold, goal.current_amount, goal.target_amount, + ) + + +def goal_to_dict(goal: SavingsGoal) -> dict: + return { + "id": goal.id, + "name": goal.name, + "description": goal.description, + "target_amount": float(goal.target_amount), + "current_amount": float(goal.current_amount), + "progress_pct": round(_progress_pct(goal), 2), + "target_date": goal.target_date.isoformat() if goal.target_date else None, + "status": goal.status, + "currency": goal.currency, + "created_at": goal.created_at.isoformat(), + "updated_at": goal.updated_at.isoformat(), + } + + +# --------------------------------------------------------------------------- +# CRUD +# --------------------------------------------------------------------------- + +def create_goal(uid: int, data: dict) -> tuple[SavingsGoal | None, str | None]: + """Create a new savings goal. Returns (goal, None) or (None, error_msg).""" + name = (data.get("name") or "").strip() + if not name: + return None, "name required" + + target = _parse_amount(data.get("target_amount")) + if target is None: + return None, "target_amount must be a positive number" + + raw_date = data.get("target_date") + target_date = None + if raw_date: + try: + target_date = date.fromisoformat(str(raw_date)) + except ValueError: + return None, "target_date must be ISO 8601 (YYYY-MM-DD)" + + from ..models import User + user = db.session.get(User, uid) + currency = data.get("currency") or (user.preferred_currency if user else "INR") + + goal = SavingsGoal( + user_id=uid, + name=name, + description=(data.get("description") or "").strip() or None, + target_amount=target, + current_amount=Decimal("0"), + target_date=target_date, + currency=currency, + ) + db.session.add(goal) + db.session.commit() + logger.info("Created savings goal id=%s user=%s target=%.2f", goal.id, uid, goal.target_amount) + return goal, None + + +def get_goals(uid: int) -> list[SavingsGoal]: + return ( + db.session.query(SavingsGoal) + .filter_by(user_id=uid) + .order_by(SavingsGoal.created_at.desc()) + .all() + ) + + +def get_goal(uid: int, goal_id: int) -> SavingsGoal | None: + return db.session.query(SavingsGoal).filter_by(id=goal_id, user_id=uid).first() + + +def update_goal(uid: int, goal_id: int, data: dict) -> tuple[SavingsGoal | None, str | None]: + goal = get_goal(uid, goal_id) + if not goal: + return None, "not found" + + if "name" in data: + name = (data["name"] or "").strip() + if not name: + return None, "name must not be empty" + goal.name = name + + if "description" in data: + goal.description = (data["description"] or "").strip() or None + + if "target_amount" in data: + target = _parse_amount(data["target_amount"]) + if target is None: + return None, "target_amount must be a positive number" + goal.target_amount = target + + if "target_date" in data: + raw = data["target_date"] + if raw is None: + goal.target_date = None + else: + try: + goal.target_date = date.fromisoformat(str(raw)) + except ValueError: + return None, "target_date must be ISO 8601 (YYYY-MM-DD)" + + if "status" in data: + s = str(data["status"]).upper() + valid = {e.value for e in GoalStatus} + if s not in valid: + return None, f"status must be one of {sorted(valid)}" + goal.status = s + + goal.updated_at = datetime.utcnow() + db.session.commit() + logger.info("Updated savings goal id=%s user=%s", goal.id, uid) + return goal, None + + +def delete_goal(uid: int, goal_id: int) -> bool: + goal = get_goal(uid, goal_id) + if not goal: + return False + db.session.delete(goal) + db.session.commit() + logger.info("Deleted savings goal id=%s user=%s", goal_id, uid) + return True + + +def deposit( + uid: int, goal_id: int, amount: Any, note: str | None = None +) -> tuple[SavingsGoal | None, str | None]: + """Credit an amount towards the goal; auto-complete when target is reached.""" + goal = get_goal(uid, goal_id) + if not goal: + return None, "not found" + if goal.status != GoalStatus.ACTIVE.value: + return None, "only ACTIVE goals accept deposits" + + amt = _parse_amount(amount) + if amt is None: + return None, "amount must be a positive number" + + db.session.add(GoalDeposit(goal_id=goal.id, amount=amt, note=note or None)) + goal.current_amount = (goal.current_amount or Decimal("0")) + amt + goal.updated_at = datetime.utcnow() + + # Auto-complete + if goal.current_amount >= goal.target_amount: + goal.current_amount = goal.target_amount + goal.status = GoalStatus.COMPLETED.value + logger.info("Goal id=%s user=%s COMPLETED", goal.id, uid) + + _record_new_milestones(goal) + db.session.commit() + return goal, None + + +def get_milestones(uid: int, goal_id: int) -> list[GoalMilestone] | None: + goal = get_goal(uid, goal_id) + if not goal: + return None + return goal.milestones.order_by(GoalMilestone.pct).all() diff --git a/packages/backend/tests/test_savings.py b/packages/backend/tests/test_savings.py new file mode 100644 index 000000000..faf1c4fbb --- /dev/null +++ b/packages/backend/tests/test_savings.py @@ -0,0 +1,277 @@ +"""Tests for savings goal tracking (issue #133).""" +from datetime import date, timedelta +from decimal import Decimal + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _create_goal(client, auth_header, name="Emergency Fund", target=1000, target_date=None): + payload = {"name": name, "target_amount": target} + if target_date: + payload["target_date"] = target_date + r = client.post("/savings/goals", json=payload, headers=auth_header) + assert r.status_code == 201, r.get_json() + return r.get_json() + + +def _deposit(client, auth_header, goal_id, amount, note=None): + payload = {"amount": amount} + if note: + payload["note"] = note + r = client.post(f"/savings/goals/{goal_id}/deposit", json=payload, headers=auth_header) + assert r.status_code == 200, r.get_json() + return r.get_json() + + +# --------------------------------------------------------------------------- +# Auth gates +# --------------------------------------------------------------------------- + +def test_list_goals_requires_auth(client): + r = client.get("/savings/goals") + assert r.status_code == 401 + + +def test_create_goal_requires_auth(client): + r = client.post("/savings/goals", json={"name": "x", "target_amount": 100}) + assert r.status_code == 401 + + +# --------------------------------------------------------------------------- +# Create +# --------------------------------------------------------------------------- + +def test_create_goal_returns_201(client, auth_header): + r = client.post( + "/savings/goals", + json={"name": "Holiday Fund", "target_amount": 5000}, + headers=auth_header, + ) + assert r.status_code == 201 + data = r.get_json() + assert data["name"] == "Holiday Fund" + assert data["target_amount"] == 5000 + assert data["current_amount"] == 0 + assert data["progress_pct"] == 0.0 + assert data["status"] == "ACTIVE" + + +def test_create_goal_with_target_date(client, auth_header): + future = (date.today() + timedelta(days=90)).isoformat() + r = client.post( + "/savings/goals", + json={"name": "Laptop", "target_amount": 2000, "target_date": future}, + headers=auth_header, + ) + assert r.status_code == 201 + assert r.get_json()["target_date"] == future + + +def test_create_goal_missing_name_returns_400(client, auth_header): + r = client.post("/savings/goals", json={"target_amount": 100}, headers=auth_header) + assert r.status_code == 400 + assert "name" in r.get_json().get("error", "").lower() + + +def test_create_goal_invalid_amount_returns_400(client, auth_header): + r = client.post( + "/savings/goals", json={"name": "Bad", "target_amount": -50}, headers=auth_header + ) + assert r.status_code == 400 + + +def test_create_goal_invalid_date_returns_400(client, auth_header): + r = client.post( + "/savings/goals", + json={"name": "Bad Date", "target_amount": 100, "target_date": "not-a-date"}, + headers=auth_header, + ) + assert r.status_code == 400 + + +# --------------------------------------------------------------------------- +# List / Get +# --------------------------------------------------------------------------- + +def test_list_goals_empty(client, auth_header): + r = client.get("/savings/goals", headers=auth_header) + assert r.status_code == 200 + assert r.get_json() == [] + + +def test_list_goals_returns_own_goals(client, auth_header): + _create_goal(client, auth_header, "A", 100) + _create_goal(client, auth_header, "B", 200) + r = client.get("/savings/goals", headers=auth_header) + names = {g["name"] for g in r.get_json()} + assert "A" in names and "B" in names + + +def test_get_goal_404_for_missing(client, auth_header): + r = client.get("/savings/goals/99999", headers=auth_header) + assert r.status_code == 404 + + +def test_get_goal_returns_goal(client, auth_header): + goal = _create_goal(client, auth_header, "Savings Pot", 3000) + r = client.get(f"/savings/goals/{goal['id']}", headers=auth_header) + assert r.status_code == 200 + assert r.get_json()["name"] == "Savings Pot" + + +# --------------------------------------------------------------------------- +# Update +# --------------------------------------------------------------------------- + +def test_update_goal_name(client, auth_header): + goal = _create_goal(client, auth_header, "Old Name", 500) + r = client.patch( + f"/savings/goals/{goal['id']}", + json={"name": "New Name"}, + headers=auth_header, + ) + assert r.status_code == 200 + assert r.get_json()["name"] == "New Name" + + +def test_update_goal_status_abandon(client, auth_header): + goal = _create_goal(client, auth_header, "Trip", 5000) + r = client.patch( + f"/savings/goals/{goal['id']}", + json={"status": "ABANDONED"}, + headers=auth_header, + ) + assert r.status_code == 200 + assert r.get_json()["status"] == "ABANDONED" + + +def test_update_goal_invalid_status(client, auth_header): + goal = _create_goal(client, auth_header, "Trip", 5000) + r = client.patch( + f"/savings/goals/{goal['id']}", + json={"status": "FLYING"}, + headers=auth_header, + ) + assert r.status_code == 400 + + +# --------------------------------------------------------------------------- +# Delete +# --------------------------------------------------------------------------- + +def test_delete_goal(client, auth_header): + goal = _create_goal(client, auth_header, "To Delete", 100) + r = client.delete(f"/savings/goals/{goal['id']}", headers=auth_header) + assert r.status_code == 204 + r2 = client.get(f"/savings/goals/{goal['id']}", headers=auth_header) + assert r2.status_code == 404 + + +def test_delete_nonexistent_goal(client, auth_header): + r = client.delete("/savings/goals/99999", headers=auth_header) + assert r.status_code == 404 + + +# --------------------------------------------------------------------------- +# Deposits +# --------------------------------------------------------------------------- + +def test_deposit_increases_balance(client, auth_header): + goal = _create_goal(client, auth_header, "House", 10000) + updated = _deposit(client, auth_header, goal["id"], 2500) + assert updated["current_amount"] == 2500 + assert updated["progress_pct"] == 25.0 + + +def test_deposit_invalid_amount(client, auth_header): + goal = _create_goal(client, auth_header, "Car", 5000) + r = client.post( + f"/savings/goals/{goal['id']}/deposit", + json={"amount": -100}, + headers=auth_header, + ) + assert r.status_code == 400 + + +def test_deposit_completes_goal(client, auth_header): + goal = _create_goal(client, auth_header, "Small Goal", 100) + updated = _deposit(client, auth_header, goal["id"], 200) + # Current capped at target + assert updated["current_amount"] == 100 + assert updated["status"] == "COMPLETED" + assert updated["progress_pct"] == 100.0 + + +def test_deposit_rejected_on_completed_goal(client, auth_header): + goal = _create_goal(client, auth_header, "Done", 50) + _deposit(client, auth_header, goal["id"], 50) # completes it + r = client.post( + f"/savings/goals/{goal['id']}/deposit", + json={"amount": 10}, + headers=auth_header, + ) + assert r.status_code == 400 + assert "active" in r.get_json().get("error", "").lower() + + +# --------------------------------------------------------------------------- +# Milestones +# --------------------------------------------------------------------------- + +def test_milestones_empty_initially(client, auth_header): + goal = _create_goal(client, auth_header, "Fresh", 200) + r = client.get(f"/savings/goals/{goal['id']}/milestones", headers=auth_header) + assert r.status_code == 200 + assert r.get_json() == [] + + +def test_milestones_recorded_on_deposit(client, auth_header): + goal = _create_goal(client, auth_header, "Big Goal", 1000) + _deposit(client, auth_header, goal["id"], 500) # 50% + r = client.get(f"/savings/goals/{goal['id']}/milestones", headers=auth_header) + pcts = {m["pct"] for m in r.get_json()} + assert 25 in pcts + assert 50 in pcts + assert 75 not in pcts + + +def test_milestones_all_four_at_completion(client, auth_header): + goal = _create_goal(client, auth_header, "Complete", 100) + _deposit(client, auth_header, goal["id"], 100) + r = client.get(f"/savings/goals/{goal['id']}/milestones", headers=auth_header) + pcts = {m["pct"] for m in r.get_json()} + assert pcts == {25, 50, 75, 100} + + +def test_milestones_not_duplicated(client, auth_header): + """Depositing past a threshold twice must not create duplicate milestone rows.""" + goal = _create_goal(client, auth_header, "NoDup", 100) + _deposit(client, auth_header, goal["id"], 30) # crosses 25% + _deposit(client, auth_header, goal["id"], 30) # still below 75% + r = client.get(f"/savings/goals/{goal['id']}/milestones", headers=auth_header) + milestones = r.get_json() + pcts = [m["pct"] for m in milestones] + assert len(pcts) == len(set(pcts)), "Duplicate milestones recorded" + + +# --------------------------------------------------------------------------- +# build_savings_goal service unit test (no HTTP layer) +# --------------------------------------------------------------------------- + +def test_service_goal_to_dict_structure(client, auth_header, app_fixture): + """goal_to_dict returns all required keys.""" + from app.extensions import db + from app.models import User + from app.services.savings import create_goal, goal_to_dict + + with app_fixture.app_context(): + user = db.session.query(User).filter_by(email="test@example.com").first() + goal, err = create_goal(user.id, {"name": "Service Test", "target_amount": 500}) + assert err is None + d = goal_to_dict(goal) + + required = {"id", "name", "description", "target_amount", "current_amount", + "progress_pct", "target_date", "status", "currency", "created_at", "updated_at"} + assert required.issubset(d.keys())