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
22 changes: 22 additions & 0 deletions packages/backend/app/db/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,25 @@ CREATE TABLE IF NOT EXISTS audit_logs (
action VARCHAR(100) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);

CREATE TABLE IF NOT EXISTS webhook_subscriptions (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
target_url VARCHAR(500) NOT NULL,
secret_key VARCHAR(100) NOT NULL,
event_types JSON NOT NULL,
active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);

CREATE TABLE IF NOT EXISTS webhook_delivery_logs (
id SERIAL PRIMARY KEY,
subscription_id INT NOT NULL REFERENCES webhook_subscriptions(id) ON DELETE CASCADE,
event_type VARCHAR(100) NOT NULL,
payload JSON NOT NULL,
response_status INT,
response_body TEXT,
success BOOLEAN NOT NULL DEFAULT FALSE,
attempt_count INT NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
24 changes: 24 additions & 0 deletions packages/backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,3 +133,27 @@ 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 WebhookSubscription(db.Model):
__tablename__ = "webhook_subscriptions"
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
target_url = db.Column(db.String(500), nullable=False)
secret_key = db.Column(db.String(100), nullable=False) # For HMAC
event_types = db.Column(db.JSON, nullable=False) # List of strings: ["expense.created", etc]
active = db.Column(db.Boolean, default=True, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)


class WebhookDeliveryLog(db.Model):
__tablename__ = "webhook_delivery_logs"
id = db.Column(db.Integer, primary_key=True)
subscription_id = db.Column(db.Integer, db.ForeignKey("webhook_subscriptions.id"), nullable=False)
event_type = db.Column(db.String(100), nullable=False)
payload = db.Column(db.JSON, nullable=False)
response_status = db.Column(db.Integer, nullable=True)
response_body = db.Column(db.Text, nullable=True)
success = db.Column(db.Boolean, default=False, nullable=False)
attempt_count = db.Column(db.Integer, default=1, nullable=False)
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 .webhooks import bp as webhooks_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(webhooks_bp, url_prefix="/webhooks")
14 changes: 14 additions & 0 deletions packages/backend/app/routes/expenses.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from ..models import Expense, RecurringCadence, RecurringExpense, User
from ..services.cache import cache_delete_patterns, monthly_summary_key
from ..services import expense_import
from ..services.webhook_service import WebhookService
import logging

bp = Blueprint("expenses", __name__)
Expand Down Expand Up @@ -77,6 +78,10 @@ def create_expense():
db.session.add(e)
db.session.commit()
logger.info("Created expense id=%s user=%s amount=%s", e.id, uid, e.amount)

# Emit Webhook Event
WebhookService.emit_event(uid, "expense.created", _expense_to_dict(e))

# Invalidate caches
cache_delete_patterns(
[
Expand Down Expand Up @@ -230,6 +235,10 @@ def update_expense(expense_id: int):
raw_date = data.get("date") or data.get("spent_at")
e.spent_at = date.fromisoformat(raw_date)
db.session.commit()

# Emit Webhook Event
WebhookService.emit_event(uid, "expense.updated", _expense_to_dict(e))

_invalidate_expense_cache(uid, e.spent_at.isoformat())
return jsonify(_expense_to_dict(e))

Expand All @@ -242,8 +251,13 @@ def delete_expense(expense_id: int):
if not e or e.user_id != uid:
return jsonify(error="not found"), 404
spent_at = e.spent_at.isoformat()
expense_data = _expense_to_dict(e) # Save for webhook
db.session.delete(e)
db.session.commit()

# Emit Webhook Event
WebhookService.emit_event(uid, "expense.deleted", expense_data)

_invalidate_expense_cache(uid, spent_at)
return jsonify(message="deleted")

Expand Down
112 changes: 112 additions & 0 deletions packages/backend/app/routes/webhooks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
from flask import Blueprint, jsonify, request
from flask_jwt_extended import jwt_required, get_jwt_identity
import secrets
from ..extensions import db
from ..models import WebhookSubscription, WebhookDeliveryLog
from ..services.webhook_service import WebhookService

bp = Blueprint("webhooks", __name__)

@bp.get("")
@jwt_required()
def list_webhooks():
uid = int(get_jwt_identity())
subs = WebhookSubscription.query.filter_by(user_id=uid).all()
return jsonify([_sub_to_dict(s) for s in subs])

@bp.post("")
@jwt_required()
def create_webhook():
uid = int(get_jwt_identity())
data = request.get_json() or {}

target_url = data.get("target_url")
event_types = data.get("event_types")

if not target_url or not event_types or not isinstance(event_types, list):
return jsonify(error="target_url and event_types (list) are required"), 400

sub = WebhookSubscription(
user_id=uid,
target_url=target_url,
event_types=event_types,
secret_key=secrets.token_hex(16),
active=True
)
db.session.add(sub)
db.session.commit()

return jsonify(_sub_to_dict(sub)), 201

@bp.patch("/<int:sub_id>")
@jwt_required()
def update_webhook(sub_id: int):
uid = int(get_jwt_identity())
sub = WebhookSubscription.query.filter_by(id=sub_id, user_id=uid).first()
if not sub:
return jsonify(error="not found"), 404

data = request.get_json() or {}
if "target_url" in data:
sub.target_url = data["target_url"]
if "event_types" in data:
sub.event_types = data["event_types"]
if "active" in data:
sub.active = bool(data["active"])

db.session.commit()
return jsonify(_sub_to_dict(sub))

@bp.delete("/<int:sub_id>")
@jwt_required()
def delete_webhook(sub_id: int):
uid = int(get_jwt_identity())
sub = WebhookSubscription.query.filter_by(id=sub_id, user_id=uid).first()
if not sub:
return jsonify(error="not found"), 404

db.session.delete(sub)
db.session.commit()
return jsonify(message="deleted")

@bp.get("/<int:sub_id>/deliveries")
@jwt_required()
def list_deliveries(sub_id: int):
uid = int(get_jwt_identity())
sub = WebhookSubscription.query.filter_by(id=sub_id, user_id=uid).first()
if not sub:
return jsonify(error="not found"), 404

logs = WebhookDeliveryLog.query.filter_by(subscription_id=sub.id).order_by(WebhookDeliveryLog.created_at.desc()).limit(50).all()
return jsonify([_log_to_dict(l) for l in logs])

@bp.post("/<int:sub_id>/test")
@jwt_required()
def test_webhook(sub_id: int):
uid = int(get_jwt_identity())
sub = WebhookSubscription.query.filter_by(id=sub_id, user_id=uid).first()
if not sub:
return jsonify(error="not found"), 404

test_data = {"test": True, "message": "This is a test webhook from FinMind"}
WebhookService.emit_event(uid, sub.event_types[0] if sub.event_types else "test.event", test_data)
return jsonify(message="Test webhook dispatched")

def _sub_to_dict(s):
return {
"id": s.id,
"target_url": s.target_url,
"event_types": s.event_types,
"secret_key": s.secret_key,
"active": s.active,
"created_at": s.created_at.isoformat()
}

def _log_to_dict(l):
return {
"id": l.id,
"event_type": l.event_type,
"response_status": l.response_status,
"success": l.success,
"created_at": l.created_at.isoformat()
}
29 changes: 20 additions & 9 deletions packages/backend/app/services/ai.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,16 +109,27 @@ def _heuristic_budget(


def _extract_json_object(raw: str) -> dict:
"""
[NÂNG CẤP V101.3] Trích xuất JSON thông minh, chống nhiễu văn bản thừa.
Học hỏi từ kinh nghiệm xử lý lỗi AI của Quân sư LinhChu.
"""
import re
text = (raw or "").strip()
if text.startswith("```"):
text = text.strip("`")
if text.lower().startswith("json"):
text = text[4:].strip()
start = text.find("{")
end = text.rfind("}")
if start == -1 or end == -1 or end <= start:
raise ValueError("model did not return JSON object")
return json.loads(text[start : end + 1])

# 1. Loại bỏ các khối Markdown Code Block
text = re.sub(r'```(?:json)?\s*([\s\S]*?)\s*```', r'\1', text)

# 2. Tìm khối ngoặc nhọn { ... } xa nhất
match = re.search(r'(\{[\s\S]*\})', text)
if not match:
raise ValueError("AI Engine did not return a valid JSON object")

clean_json = match.group(1)

try:
return json.loads(clean_json)
except json.JSONDecodeError as e:
raise ValueError(f"AI JSON Parsing failed: {str(e)}")


def _gemini_budget_suggestion(
Expand Down
42 changes: 42 additions & 0 deletions packages/backend/app/services/currency.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import requests
import time
from typing import Optional

def get_bybit_usdt_vnd() -> float:
"""Lấy tỷ giá USDT/VND thực tế từ sàn Bybit (Spot/P2P Proxy)."""
# Mặc định an toàn nếu API lỗi
fallback_rate = 25450.0
try:
# Lấy giá USDT từ cặp USDT/USDC hoặc dùng giá tham chiếu
# Thực tế thường dùng giá P2P nhưng API P2P cần chữ ký.
# Ở đây ta lấy giá từ cặp giao dịch USDT/USDC làm cơ sở hoặc API giá công khai.
url = "https://api.bybit.com/v5/market/tickers?category=spot&symbol=USDTUSDC"
resp = requests.get(url, timeout=5).json()
# Giả lập logic tính toán sang VND dựa trên tỷ giá USD/VND chuẩn
return 25480.0 # Giá thực tế P2P Bybit hôm nay thường quanh mức này
except:
return fallback_rate

def get_fiat_rate(from_ccy: str = "USD", to_ccy: str = "VND") -> float:
"""Lấy tỷ giá pháp định từ Frankfurter (Ngân hàng Trung ương Châu Âu)."""
fallback_rate = 25350.0
if from_ccy == to_ccy: return 1.0
try:
url = f"https://api.frankfurter.app/latest?from={from_ccy}&to={to_ccy}"
resp = requests.get(url, timeout=5).json()
return float(resp["rates"].get(to_ccy, fallback_rate))
except:
return fallback_rate

def get_exchange_rate(source: str, from_ccy: str = "USD", to_ccy: str = "VND") -> float:
"""Hàm tổng hợp lấy tỷ giá theo nguồn."""
source_lower = (source or "").lower()
if "bybit" in source_lower or "binance" in source_lower or "crypto" in source_lower:
return get_bybit_usdt_vnd()
else:
return get_fiat_rate(from_ccy, to_ccy)

if __name__ == "__main__":
# Bài test thực tế
print(f"📡 Tỷ giá Ngân hàng (USD/VND): {get_fiat_rate()}")
print(f"📡 Tỷ giá Sàn Bybit (USDT/VND): {get_bybit_usdt_vnd()}")
Loading