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
37 changes: 37 additions & 0 deletions packages/backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,43 @@ class RecurringExpense(db.Model):
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)



class SubscriptionCadence(str, Enum):
WEEKLY = "WEEKLY"
MONTHLY = "MONTHLY"
YEARLY = "YEARLY"


class SubscriptionStatus(str, Enum):
DETECTED = "DETECTED"
CONFIRMED = "CONFIRMED"
DISMISSED = "DISMISSED"


class Subscription(db.Model):
"""Auto-detected subscription from recurring expense patterns."""
__tablename__ = "subscriptions"

id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
merchant_name = db.Column(db.String(200), nullable=False)
category_id = db.Column(db.Integer, db.ForeignKey("categories.id"), nullable=True)
amount = db.Column(db.Numeric(12, 2), nullable=False)
currency = db.Column(db.String(10), default="INR", nullable=False)
detected_cadence = db.Column(SAEnum(SubscriptionCadence), nullable=False)
confidence_score = db.Column(db.Numeric(3, 2), nullable=False) # 0.00 to 1.00
occurrence_count = db.Column(db.Integer, default=1, nullable=False)
first_occurrence_date = db.Column(db.Date, nullable=False)
last_occurrence_date = db.Column(db.Date, nullable=False)
next_predicted_date = db.Column(db.Date, nullable=True)
average_amount = db.Column(db.Numeric(12, 2), nullable=True)
amount_variance = db.Column(db.Numeric(12, 2), nullable=True)
status = db.Column(SAEnum(SubscriptionStatus), default=SubscriptionStatus.DETECTED, nullable=False)
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)


class BillCadence(str, Enum):
MONTHLY = "MONTHLY"
WEEKLY = "WEEKLY"
Expand Down
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 .subscriptions import bp as subscriptions_bp


def register_routes(app: Flask):
Expand All @@ -17,4 +18,5 @@ def register_routes(app: Flask):
app.register_blueprint(insights_bp, url_prefix="/insights")
app.register_blueprint(categories_bp, url_prefix="/categories")
app.register_blueprint(docs_bp, url_prefix="/docs")
app.register_blueprint(subscriptions_bp, url_prefix="/subscriptions")
app.register_blueprint(dashboard_bp, url_prefix="/dashboard")
158 changes: 158 additions & 0 deletions packages/backend/app/routes/subscriptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
"""
Subscriptions API routes for auto-detected recurring charges.
"""

from datetime import date
from flask import Blueprint, current_app, jsonify, request
from flask_jwt_extended import jwt_required, get_jwt_identity
from ..extensions import db
from ..models import Subscription, SubscriptionStatus, SubscriptionCadence, User
from ..services.subscription_detector import detect_and_create_subscriptions, SubscriptionDetector
import logging

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


@bp.get("")
@jwt_required()
def list_subscriptions():
"""
List subscriptions for the current user.
Query params:
- status: filter by status (DETECTED, CONFIRMED, DISMISSED). Default: all.
"""
uid = int(get_jwt_identity())
q = db.session.query(Subscription).filter_by(user_id=uid)

status_filter = request.args.get("status", "").upper()
if status_filter in [s.value for s in SubscriptionStatus]:
q = q.filter_by(status=SubscriptionStatus(status_filter))

items = q.order_by(Subscription.created_at.desc()).all()
return jsonify([_subscription_to_dict(s) for s in items])


@bp.post("/detect")
@jwt_required()
def detect_subscriptions():
"""
Manually trigger subscription detection for the current user.
Returns list of newly detected subscriptions.
"""
uid = int(get_jwt_identity())

try:
new_subs = detect_and_create_subscriptions(uid)
return jsonify({
"detected": len(new_subs),
"subscriptions": [_subscription_to_dict(s) for s in new_subs]
}), 201
except Exception as e:
logger.exception("Subscription detection failed user=%s", uid)
return jsonify(error="detection failed", details=str(e)), 500


@bp.post("/<int:subscription_id>/confirm")
@jwt_required()
def confirm_subscription(subscription_id: int):
"""
Confirm a detected subscription (user accepts it as a real subscription).
"""
uid = int(get_jwt_identity())
sub = db.session.get(Subscription, subscription_id)

if not sub or sub.user_id != uid:
return jsonify(error="not found"), 404

if sub.status != SubscriptionStatus.DETECTED:
return jsonify(error="only DETECTED subscriptions can be confirmed"), 400

sub.status = SubscriptionStatus.CONFIRMED
db.session.commit()

logger.info("Confirmed subscription id=%s user=%s", subscription_id, uid)
return jsonify(_subscription_to_dict(sub)), 200


@bp.post("/<int:subscription_id>/dismiss")
@jwt_required()
def dismiss_subscription(subscription_id: int):
"""
Dismiss a detected subscription (user rejects the detection).
"""
uid = int(get_jwt_identity())
sub = db.session.get(Subscription, subscription_id)

if not sub or sub.user_id != uid:
return jsonify(error="not found"), 404

if sub.status == SubscriptionStatus.DISMISSED:
return jsonify(error="already dismissed"), 400

sub.status = SubscriptionStatus.DISMISSED
db.session.commit()

logger.info("Dismissed subscription id=%s user=%s", subscription_id, uid)
return jsonify(_subscription_to_dict(sub)), 200


@bp.delete("/<int:subscription_id>")
@jwt_required()
def delete_subscription(subscription_id: int):
"""
Delete a subscription (soft-delete by setting status to DISMISSED).
"""
uid = int(get_jwt_identity())
sub = db.session.get(Subscription, subscription_id)

if not sub or sub.user_id != uid:
return jsonify(error="not found"), 404

# Soft delete: mark as dismissed
sub.status = SubscriptionStatus.DISMISSED
db.session.commit()

logger.info("Deleted subscription id=%s user=%s", subscription_id, uid)
return jsonify(message="deleted"), 200


@bp.get("/predictions/refresh")
@jwt_required()
def refresh_predictions():
"""
Refresh next_predicted_date for all CONFIRMED subscriptions.
This can be run periodically via cron.
"""
uid = int(get_jwt_identity())

try:
detector = SubscriptionDetector()
detector.refresh_predictions(uid)
return jsonify(message="predictions refreshed"), 200
except Exception as e:
logger.exception("Prediction refresh failed user=%s", uid)
return jsonify(error="refresh failed", details=str(e)), 500


def _subscription_to_dict(s: Subscription) -> dict:
"""Convert Subscription model to dict for JSON response."""
return {
"id": s.id,
"merchant_name": s.merchant_name,
"category_id": s.category_id,
"amount": float(s.amount),
"currency": s.currency,
"detected_cadence": s.detected_cadence.value,
"confidence_score": float(s.confidence_score),
"occurrence_count": s.occurrence_count,
"first_occurrence_date": s.first_occurrence_date.isoformat(),
"last_occurrence_date": s.last_occurrence_date.isoformat(),
"next_predicted_date": s.next_predicted_date.isoformat() if s.next_predicted_date else None,
"average_amount": float(s.average_amount) if s.average_amount else None,
"amount_variance": float(s.amount_variance) if s.amount_variance else None,
"status": s.status.value,
"notes": s.notes,
"created_at": s.created_at.isoformat(),
"updated_at": s.updated_at.isoformat(),
}
Empty file.
Loading