Skip to content
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
35 changes: 35 additions & 0 deletions app/src/api/onboarding.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { api } from './client';

export type OnboardingStatus = {
has_expense: boolean;
has_category: boolean;
has_bill: boolean;
has_budget_goal: boolean;
profile_complete: boolean;
};

export type StepResult = {
step: string;
completed?: boolean;
already_completed?: boolean;
};

export type Suggestion = {
step: string;
action: string;
};

export async function getStatus(): Promise<OnboardingStatus> {
return api<OnboardingStatus>('/onboarding/status');
}

export async function completeStep(step: string): Promise<StepResult> {
return api<StepResult>('/onboarding/complete-step', {
method: 'POST',
body: { step },
});
}

export async function getSuggestions(): Promise<Suggestion[]> {
return api<Suggestion[]>('/onboarding/suggestions');
}
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 .onboarding import bp as onboarding_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(onboarding_bp, url_prefix="/onboarding")
105 changes: 105 additions & 0 deletions packages/backend/app/routes/onboarding.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import json
import logging
from flask import Blueprint, jsonify, request
from flask_jwt_extended import jwt_required, get_jwt_identity
from ..extensions import db
from ..models import AuditLog, Expense, Category, Bill

bp = Blueprint("onboarding", __name__)
logger = logging.getLogger("finmind.onboarding")

ONBOARDING_STEPS = [
"has_expense",
"has_category",
"has_bill",
"has_budget_goal",
"profile_complete",
]


def _get_completed_steps(uid):
rows = (
db.session.query(AuditLog)
.filter(AuditLog.user_id == uid, AuditLog.action.like("onboarding_step:%"))
.all()
)
completed = set()
for r in rows:
try:
step = r.action.split(":", 1)[1]
completed.add(step)
except IndexError:
continue
return completed


def _check_status(uid):
manual = _get_completed_steps(uid)
status = {}
status["has_expense"] = (
"has_expense" in manual
or db.session.query(Expense).filter(Expense.user_id == uid).first() is not None
)
status["has_category"] = (
"has_category" in manual
or db.session.query(Category).filter(Category.user_id == uid).first() is not None
)
status["has_bill"] = (
"has_bill" in manual
or db.session.query(Bill).filter(Bill.user_id == uid).first() is not None
)
status["has_budget_goal"] = "has_budget_goal" in manual
status["profile_complete"] = "profile_complete" in manual
return status


@bp.get("/status")
@jwt_required()
def get_status():
uid = int(get_jwt_identity())
status = _check_status(uid)
logger.info("Onboarding status user=%s", uid)
return jsonify(status)


@bp.post("/complete-step")
@jwt_required()
def complete_step():
uid = int(get_jwt_identity())
data = request.get_json() or {}
step = (data.get("step") or "").strip()
if step not in ONBOARDING_STEPS:
return jsonify(error=f"invalid step, must be one of {ONBOARDING_STEPS}"), 400

existing = (
db.session.query(AuditLog)
.filter(AuditLog.user_id == uid, AuditLog.action == f"onboarding_step:{step}")
.first()
)
if existing:
return jsonify({"step": step, "already_completed": True})

log = AuditLog(user_id=uid, action=f"onboarding_step:{step}")
db.session.add(log)
db.session.commit()
logger.info("Onboarding step completed user=%s step=%s", uid, step)
return jsonify({"step": step, "completed": True}), 201


@bp.get("/suggestions")
@jwt_required()
def get_suggestions():
uid = int(get_jwt_identity())
status = _check_status(uid)
suggestions = []
if not status["has_expense"]:
suggestions.append({"step": "has_expense", "action": "Add your first expense"})
if not status["has_category"]:
suggestions.append({"step": "has_category", "action": "Create a spending category"})
if not status["has_bill"]:
suggestions.append({"step": "has_bill", "action": "Set up a recurring bill"})
if not status["has_budget_goal"]:
suggestions.append({"step": "has_budget_goal", "action": "Set a budget goal"})
if not status["profile_complete"]:
suggestions.append({"step": "profile_complete", "action": "Complete your profile"})
return jsonify(suggestions)
65 changes: 65 additions & 0 deletions packages/backend/tests/test_onboarding.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
def test_onboarding_requires_auth(client):
r = client.get("/onboarding/status")
assert r.status_code in (401, 422)

r = client.post("/onboarding/complete-step", json={"step": "has_expense"})
assert r.status_code in (401, 422)

r = client.get("/onboarding/suggestions")
assert r.status_code in (401, 422)


def test_status_empty(client, auth_header):
r = client.get("/onboarding/status", headers=auth_header)
assert r.status_code == 200
status = r.get_json()
assert status["has_expense"] is False
assert status["has_category"] is False
assert status["has_bill"] is False
assert status["has_budget_goal"] is False
assert status["profile_complete"] is False


def test_complete_step(client, auth_header):
r = client.post(
"/onboarding/complete-step",
json={"step": "has_budget_goal"},
headers=auth_header,
)
assert r.status_code == 201
assert r.get_json()["completed"] is True

r = client.get("/onboarding/status", headers=auth_header)
assert r.status_code == 200
assert r.get_json()["has_budget_goal"] is True

# Repeating should not error
r = client.post(
"/onboarding/complete-step",
json={"step": "has_budget_goal"},
headers=auth_header,
)
assert r.status_code == 200
assert r.get_json()["already_completed"] is True


def test_suggestions(client, auth_header):
r = client.get("/onboarding/suggestions", headers=auth_header)
assert r.status_code == 200
suggestions = r.get_json()
assert len(suggestions) == 5
steps = [s["step"] for s in suggestions]
assert "has_expense" in steps
assert "has_budget_goal" in steps

# Complete a step and check suggestions shrink
client.post(
"/onboarding/complete-step",
json={"step": "profile_complete"},
headers=auth_header,
)
r = client.get("/onboarding/suggestions", headers=auth_header)
assert r.status_code == 200
suggestions = r.get_json()
assert len(suggestions) == 4
assert "profile_complete" not in [s["step"] for s in suggestions]