diff --git a/packages/backend/app/__init__.py b/packages/backend/app/__init__.py index cdf76b45f..8cda067f2 100644 --- a/packages/backend/app/__init__.py +++ b/packages/backend/app/__init__.py @@ -110,6 +110,45 @@ def _ensure_schema_compatibility(app: Flask) -> None: NOT NULL DEFAULT 'INR' """ ) + cur.execute( + """ + CREATE TABLE IF NOT EXISTS webhook_endpoints ( + id SERIAL PRIMARY KEY, + user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + url VARCHAR(500) NOT NULL, + secret VARCHAR(255) NOT NULL, + active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP NOT NULL DEFAULT NOW() + ) + """ + ) + cur.execute( + """ + CREATE INDEX IF NOT EXISTS idx_webhook_endpoints_user_active + ON webhook_endpoints(user_id, active) + """ + ) + cur.execute( + """ + CREATE TABLE IF NOT EXISTS webhook_deliveries ( + id SERIAL PRIMARY KEY, + endpoint_id INT NOT NULL REFERENCES webhook_endpoints(id) ON DELETE CASCADE, + event_type VARCHAR(100) NOT NULL, + delivery_id VARCHAR(64) NOT NULL, + attempts INT NOT NULL DEFAULT 0, + success BOOLEAN NOT NULL DEFAULT FALSE, + status_code INT, + error VARCHAR(500), + created_at TIMESTAMP NOT NULL DEFAULT NOW() + ) + """ + ) + cur.execute( + """ + CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_endpoint_created + ON webhook_deliveries(endpoint_id, created_at DESC) + """ + ) conn.commit() except Exception: app.logger.exception( diff --git a/packages/backend/app/db/schema.sql b/packages/backend/app/db/schema.sql index 410189def..72fed2c3c 100644 --- a/packages/backend/app/db/schema.sql +++ b/packages/backend/app/db/schema.sql @@ -95,6 +95,29 @@ CREATE TABLE IF NOT EXISTS reminders ( ); CREATE INDEX IF NOT EXISTS idx_reminders_due ON reminders(user_id, sent, send_at); +CREATE TABLE IF NOT EXISTS webhook_endpoints ( + id SERIAL PRIMARY KEY, + user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + url VARCHAR(500) NOT NULL, + secret VARCHAR(255) NOT NULL, + active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_webhook_endpoints_user_active ON webhook_endpoints(user_id, active); + +CREATE TABLE IF NOT EXISTS webhook_deliveries ( + id SERIAL PRIMARY KEY, + endpoint_id INT NOT NULL REFERENCES webhook_endpoints(id) ON DELETE CASCADE, + event_type VARCHAR(100) NOT NULL, + delivery_id VARCHAR(64) NOT NULL, + attempts INT NOT NULL DEFAULT 0, + success BOOLEAN NOT NULL DEFAULT FALSE, + status_code INT, + error VARCHAR(500), + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_endpoint_created ON webhook_deliveries(endpoint_id, created_at DESC); + CREATE TABLE IF NOT EXISTS ad_impressions ( id SERIAL PRIMARY KEY, user_id INT REFERENCES users(id) ON DELETE SET NULL, diff --git a/packages/backend/app/models.py b/packages/backend/app/models.py index 64d448104..3488a760d 100644 --- a/packages/backend/app/models.py +++ b/packages/backend/app/models.py @@ -100,6 +100,31 @@ class Reminder(db.Model): channel = db.Column(db.String(20), default="email", nullable=False) +class WebhookEndpoint(db.Model): + __tablename__ = "webhook_endpoints" + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + url = db.Column(db.String(500), nullable=False) + secret = db.Column(db.String(255), nullable=False) + active = db.Column(db.Boolean, default=True, nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + +class WebhookDelivery(db.Model): + __tablename__ = "webhook_deliveries" + id = db.Column(db.Integer, primary_key=True) + endpoint_id = db.Column( + db.Integer, db.ForeignKey("webhook_endpoints.id"), nullable=False + ) + event_type = db.Column(db.String(100), nullable=False) + delivery_id = db.Column(db.String(64), nullable=False) + attempts = db.Column(db.Integer, default=0, nullable=False) + success = db.Column(db.Boolean, default=False, nullable=False) + status_code = db.Column(db.Integer, nullable=True) + error = db.Column(db.String(500), nullable=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + class AdImpression(db.Model): __tablename__ = "ad_impressions" id = db.Column(db.Integer, primary_key=True) diff --git a/packages/backend/app/openapi.yaml b/packages/backend/app/openapi.yaml index 3f8ec3f0f..398889a4d 100644 --- a/packages/backend/app/openapi.yaml +++ b/packages/backend/app/openapi.yaml @@ -11,6 +11,7 @@ tags: - name: Expenses - name: Bills - name: Reminders + - name: Webhooks - name: Insights paths: /auth/register: @@ -481,6 +482,133 @@ paths: application/json: schema: { $ref: '#/components/schemas/Error' } + /webhooks: + get: + summary: List webhook endpoints + tags: [Webhooks] + security: [{ bearerAuth: [] }] + responses: + '200': + description: User webhook endpoints without secrets + content: + application/json: + schema: + type: array + items: { $ref: '#/components/schemas/WebhookEndpoint' } + post: + summary: Create webhook endpoint + description: Creates an endpoint and returns its signing secret once. + tags: [Webhooks] + security: [{ bearerAuth: [] }] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [url] + properties: + url: { type: string, format: uri } + active: { type: boolean, default: true } + example: { url: "https://example.com/finmind/webhook" } + responses: + '201': + description: Created endpoint with secret + content: + application/json: + schema: { $ref: '#/components/schemas/WebhookEndpointWithSecret' } + '400': + description: Invalid URL + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + /webhooks/event-types: + get: + summary: List supported webhook event types + description: | + Supported event types: `expense.created`, `expense.updated`, + `expense.deleted`, `category.created`, `category.updated`, + `category.deleted`, `bill.created`, and `bill.paid`. + tags: [Webhooks] + responses: + '200': + description: Supported webhook event type strings + content: + application/json: + schema: + type: array + items: { type: string } + example: [expense.created, expense.updated, expense.deleted] + /webhooks/{endpoint_id}: + patch: + summary: Update webhook endpoint + tags: [Webhooks] + security: [{ bearerAuth: [] }] + parameters: + - in: path + name: endpoint_id + required: true + schema: { type: integer } + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + url: { type: string, format: uri } + active: { type: boolean } + responses: + '200': + description: Updated endpoint + content: + application/json: + schema: { $ref: '#/components/schemas/WebhookEndpoint' } + '404': + description: Not found + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + delete: + summary: Delete webhook endpoint + tags: [Webhooks] + security: [{ bearerAuth: [] }] + parameters: + - in: path + name: endpoint_id + required: true + schema: { type: integer } + responses: + '204': { description: Deleted } + '404': + description: Not found + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + /webhooks/{endpoint_id}/deliveries: + get: + summary: List recent webhook deliveries + tags: [Webhooks] + security: [{ bearerAuth: [] }] + parameters: + - in: path + name: endpoint_id + required: true + schema: { type: integer } + responses: + '200': + description: Recent delivery attempts + content: + application/json: + schema: + type: array + items: { $ref: '#/components/schemas/WebhookDelivery' } + '404': + description: Not found + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + components: securitySchemes: bearerAuth: @@ -587,3 +715,27 @@ components: message: { type: string } send_at: { type: string, format: date-time } channel: { type: string, enum: [email, whatsapp], default: email } + WebhookEndpoint: + type: object + properties: + id: { type: integer } + url: { type: string, format: uri } + active: { type: boolean } + created_at: { type: string, format: date-time, nullable: true } + WebhookEndpointWithSecret: + allOf: + - $ref: '#/components/schemas/WebhookEndpoint' + - type: object + properties: + secret: { type: string, description: Returned only when the endpoint is created } + WebhookDelivery: + type: object + properties: + id: { type: integer } + event_type: { type: string } + delivery_id: { type: string } + attempts: { type: integer } + success: { type: boolean } + status_code: { type: integer, nullable: true } + error: { type: string, nullable: true } + created_at: { type: string, format: date-time, nullable: true } diff --git a/packages/backend/app/routes/__init__.py b/packages/backend/app/routes/__init__.py index f13b0f897..75b957756 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 .webhooks import bp as webhooks_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(webhooks_bp, url_prefix="/webhooks") diff --git a/packages/backend/app/routes/bills.py b/packages/backend/app/routes/bills.py index f557e90d4..5c979957f 100644 --- a/packages/backend/app/routes/bills.py +++ b/packages/backend/app/routes/bills.py @@ -4,6 +4,7 @@ from ..extensions import db from ..models import Bill, BillCadence, User from ..services.cache import cache_delete_patterns +from ..services.webhooks import emit_webhook_event import logging bp = Blueprint("bills", __name__) @@ -62,6 +63,8 @@ def create_bill(): cache_delete_patterns( [f"user:{uid}:upcoming_bills*", f"user:{uid}:dashboard_summary:*"] ) + event_data = _bill_to_dict(b) + emit_webhook_event(uid, "bill.created", event_data) return jsonify(id=b.id), 201 @@ -88,4 +91,19 @@ def mark_paid(bill_id: int): logger.info( "Marked bill paid id=%s user=%s next_due_date=%s", b.id, uid, b.next_due_date ) + emit_webhook_event(uid, "bill.paid", _bill_to_dict(b)) return jsonify(message="updated") + + +def _bill_to_dict(b: Bill) -> dict: + return { + "id": b.id, + "name": b.name, + "amount": float(b.amount), + "currency": b.currency, + "next_due_date": b.next_due_date.isoformat(), + "cadence": b.cadence.value, + "autopay_enabled": b.autopay_enabled, + "channel_whatsapp": b.channel_whatsapp, + "channel_email": b.channel_email, + } diff --git a/packages/backend/app/routes/categories.py b/packages/backend/app/routes/categories.py index 71269a13f..42e1b1b8c 100644 --- a/packages/backend/app/routes/categories.py +++ b/packages/backend/app/routes/categories.py @@ -3,6 +3,7 @@ from flask_jwt_extended import jwt_required, get_jwt_identity from ..extensions import db from ..models import Category +from ..services.webhooks import emit_webhook_event bp = Blueprint("categories", __name__) logger = logging.getLogger("finmind.categories") @@ -36,7 +37,9 @@ def create_category(): db.session.add(c) db.session.commit() logger.info("Created category id=%s user=%s", c.id, uid) - return jsonify(id=c.id, name=c.name), 201 + event_data = {"id": c.id, "name": c.name} + emit_webhook_event(uid, "category.created", event_data) + return jsonify(event_data), 201 @bp.patch("/") @@ -53,7 +56,9 @@ def update_category(category_id: int): c.name = name db.session.commit() logger.info("Updated category id=%s user=%s", c.id, uid) - return jsonify(id=c.id, name=c.name) + event_data = {"id": c.id, "name": c.name} + emit_webhook_event(uid, "category.updated", event_data) + return jsonify(event_data) @bp.delete("/") @@ -63,7 +68,9 @@ def delete_category(category_id: int): c = db.session.get(Category, category_id) if not c or c.user_id != uid: return jsonify(error="not found"), 404 + event_data = {"id": c.id, "name": c.name} db.session.delete(c) db.session.commit() - logger.info("Deleted category id=%s user=%s", c.id, uid) + logger.info("Deleted category id=%s user=%s", event_data["id"], uid) + emit_webhook_event(uid, "category.deleted", event_data) return jsonify(message="deleted") diff --git a/packages/backend/app/routes/expenses.py b/packages/backend/app/routes/expenses.py index 1376d46f5..47a9394ba 100644 --- a/packages/backend/app/routes/expenses.py +++ b/packages/backend/app/routes/expenses.py @@ -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.webhooks import emit_webhook_event import logging bp = Blueprint("expenses", __name__) @@ -84,7 +85,9 @@ def create_expense(): f"insights:{uid}:*", ] ) - return jsonify(_expense_to_dict(e)), 201 + event_data = _expense_to_dict(e) + emit_webhook_event(uid, "expense.created", event_data) + return jsonify(event_data), 201 @bp.get("/recurring") @@ -231,7 +234,9 @@ def update_expense(expense_id: int): e.spent_at = date.fromisoformat(raw_date) db.session.commit() _invalidate_expense_cache(uid, e.spent_at.isoformat()) - return jsonify(_expense_to_dict(e)) + event_data = _expense_to_dict(e) + emit_webhook_event(uid, "expense.updated", event_data) + return jsonify(event_data) @bp.delete("/") @@ -242,9 +247,11 @@ 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() + event_data = _expense_to_dict(e) db.session.delete(e) db.session.commit() _invalidate_expense_cache(uid, spent_at) + emit_webhook_event(uid, "expense.deleted", event_data) return jsonify(message="deleted") diff --git a/packages/backend/app/routes/webhooks.py b/packages/backend/app/routes/webhooks.py new file mode 100644 index 000000000..580532f6a --- /dev/null +++ b/packages/backend/app/routes/webhooks.py @@ -0,0 +1,125 @@ +import secrets +from urllib.parse import urlparse + +from flask import Blueprint, jsonify, request +from flask_jwt_extended import get_jwt_identity, jwt_required + +from ..extensions import db +from ..models import WebhookDelivery, WebhookEndpoint +from ..services.webhooks import WEBHOOK_EVENT_TYPES + +bp = Blueprint("webhooks", __name__) + + +@bp.get("") +@jwt_required() +def list_webhooks(): + uid = int(get_jwt_identity()) + endpoints = ( + db.session.query(WebhookEndpoint) + .filter_by(user_id=uid) + .order_by(WebhookEndpoint.created_at.desc()) + .all() + ) + return jsonify([_endpoint_to_dict(endpoint) for endpoint in endpoints]) + + +@bp.post("") +@jwt_required() +def create_webhook(): + uid = int(get_jwt_identity()) + data = request.get_json() or {} + url = str(data.get("url") or "").strip() + if not _valid_url(url): + return jsonify(error="valid http(s) url required"), 400 + endpoint = WebhookEndpoint( + user_id=uid, + url=url, + secret=secrets.token_urlsafe(32), + active=bool(data.get("active", True)), + ) + db.session.add(endpoint) + db.session.commit() + response = _endpoint_to_dict(endpoint) + response["secret"] = endpoint.secret + return jsonify(response), 201 + + +@bp.patch("/") +@jwt_required() +def update_webhook(endpoint_id: int): + uid = int(get_jwt_identity()) + endpoint = db.session.get(WebhookEndpoint, endpoint_id) + if not endpoint or endpoint.user_id != uid: + return jsonify(error="not found"), 404 + data = request.get_json() or {} + if "url" in data: + url = str(data.get("url") or "").strip() + if not _valid_url(url): + return jsonify(error="valid http(s) url required"), 400 + endpoint.url = url + if "active" in data: + endpoint.active = bool(data.get("active")) + db.session.commit() + return jsonify(_endpoint_to_dict(endpoint)) + + +@bp.delete("/") +@jwt_required() +def delete_webhook(endpoint_id: int): + uid = int(get_jwt_identity()) + endpoint = db.session.get(WebhookEndpoint, endpoint_id) + if not endpoint or endpoint.user_id != uid: + return jsonify(error="not found"), 404 + db.session.delete(endpoint) + db.session.commit() + return "", 204 + + +@bp.get("//deliveries") +@jwt_required() +def list_webhook_deliveries(endpoint_id: int): + uid = int(get_jwt_identity()) + endpoint = db.session.get(WebhookEndpoint, endpoint_id) + if not endpoint or endpoint.user_id != uid: + return jsonify(error="not found"), 404 + deliveries = ( + db.session.query(WebhookDelivery) + .filter_by(endpoint_id=endpoint.id) + .order_by(WebhookDelivery.created_at.desc()) + .limit(100) + .all() + ) + return jsonify([_delivery_to_dict(delivery) for delivery in deliveries]) + + +@bp.get("/event-types") +def list_event_types(): + return jsonify(list(WEBHOOK_EVENT_TYPES)) + + +def _valid_url(url: str) -> bool: + parsed = urlparse(url) + return parsed.scheme in {"http", "https"} and bool(parsed.netloc) + + +def _endpoint_to_dict(endpoint: WebhookEndpoint) -> dict: + return { + "id": endpoint.id, + "url": endpoint.url, + "active": endpoint.active, + "created_at": endpoint.created_at.isoformat() if endpoint.created_at else None, + } + + +def _delivery_to_dict(delivery: WebhookDelivery) -> dict: + return { + "id": delivery.id, + "event_type": delivery.event_type, + "delivery_id": delivery.delivery_id, + "attempts": delivery.attempts, + "success": delivery.success, + "status_code": delivery.status_code, + "error": delivery.error, + "created_at": delivery.created_at.isoformat() if delivery.created_at else None, + } diff --git a/packages/backend/app/services/webhooks.py b/packages/backend/app/services/webhooks.py new file mode 100644 index 000000000..825f020fb --- /dev/null +++ b/packages/backend/app/services/webhooks.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import hashlib +import hmac +import json +import logging +import time +import uuid +from datetime import datetime, timezone +from typing import Any + +import requests +from flask import current_app + +from ..extensions import db +from ..models import WebhookDelivery, WebhookEndpoint + +logger = logging.getLogger("finmind.webhooks") +WEBHOOK_EVENT_TYPES = ( + "expense.created", + "expense.updated", + "expense.deleted", + "category.created", + "category.updated", + "category.deleted", + "bill.created", + "bill.paid", +) + + +def emit_webhook_event(user_id: int, event_type: str, data: dict[str, Any]) -> None: + """Best-effort signed webhook delivery with retry/failure recording.""" + if event_type not in WEBHOOK_EVENT_TYPES: + raise ValueError(f"unsupported webhook event type: {event_type}") + + endpoints = ( + db.session.query(WebhookEndpoint) + .filter_by(user_id=user_id, active=True) + .order_by(WebhookEndpoint.id.asc()) + .all() + ) + if not endpoints: + return + + for endpoint in endpoints: + _deliver_to_endpoint(endpoint, event_type, user_id, data) + + +def _deliver_to_endpoint( + endpoint: WebhookEndpoint, event_type: str, user_id: int, data: dict[str, Any] +) -> None: + delivery_id = str(uuid.uuid4()) + created_at = datetime.now(timezone.utc).isoformat() + payload = { + "id": delivery_id, + "event": event_type, + "user_id": user_id, + "data": data, + "created_at": created_at, + } + body = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8") + timestamp = str(int(time.time())) + signature = _signature(endpoint.secret, timestamp, body) + headers = { + "Content-Type": "application/json", + "X-FinMind-Event": event_type, + "X-FinMind-Delivery": delivery_id, + "X-FinMind-Timestamp": timestamp, + "X-FinMind-Signature": signature, + } + + attempts = 0 + success = False + status_code = None + error = None + max_attempts = int(current_app.config.get("WEBHOOK_MAX_ATTEMPTS", 3)) + timeout = float(current_app.config.get("WEBHOOK_TIMEOUT_SECONDS", 3)) + + for attempt in range(1, max_attempts + 1): + attempts = attempt + try: + response = requests.post( + endpoint.url, + data=body, + headers=headers, + timeout=timeout, + ) + status_code = response.status_code + if 200 <= response.status_code < 300: + success = True + error = None + break + error = f"HTTP {response.status_code}" + except requests.RequestException as exc: + error = str(exc) + if attempt < max_attempts: + time.sleep(0.1 * attempt) + + db.session.add( + WebhookDelivery( + endpoint_id=endpoint.id, + event_type=event_type, + delivery_id=delivery_id, + attempts=attempts, + success=success, + status_code=status_code, + error=error, + ) + ) + try: + db.session.commit() + except Exception: + logger.exception("Failed to persist webhook delivery result") + db.session.rollback() + + +def _signature(secret: str, timestamp: str, body: bytes) -> str: + digest = hmac.new( + secret.encode("utf-8"), + timestamp.encode("utf-8") + b"." + body, + hashlib.sha256, + ).hexdigest() + return f"sha256={digest}" diff --git a/packages/backend/tests/test_webhooks.py b/packages/backend/tests/test_webhooks.py new file mode 100644 index 000000000..b493c0356 --- /dev/null +++ b/packages/backend/tests/test_webhooks.py @@ -0,0 +1,152 @@ +import hashlib +import hmac + +import pytest +from flask_jwt_extended import create_access_token +from werkzeug.security import generate_password_hash + +from app.models import User, WebhookDelivery +from app.extensions import db + + +class _Response: + def __init__(self, status_code): + self.status_code = status_code + + +@pytest.fixture(autouse=True) +def disable_cache_invalidation(monkeypatch): + monkeypatch.setattr("app.routes.expenses.cache_delete_patterns", lambda _patterns: None) + + +@pytest.fixture() +def auth_header(app_fixture): + with app_fixture.app_context(): + user = User( + email="webhook-test@example.com", + password_hash=generate_password_hash("password123"), + preferred_currency="INR", + ) + db.session.add(user) + db.session.commit() + token = create_access_token(identity=str(user.id)) + return {"Authorization": f"Bearer {token}"} + + +def test_webhook_endpoint_crud_and_event_types(client, auth_header): + r = client.get("/webhooks/event-types") + assert r.status_code == 200 + assert "expense.created" in r.get_json() + + r = client.post("/webhooks", json={"url": "not-a-url"}, headers=auth_header) + assert r.status_code == 400 + + r = client.post( + "/webhooks", json={"url": "https://example.com/webhook"}, headers=auth_header + ) + assert r.status_code == 201 + created = r.get_json() + endpoint_id = created["id"] + assert created["url"] == "https://example.com/webhook" + assert created["active"] is True + assert created["secret"] + + r = client.get("/webhooks", headers=auth_header) + assert r.status_code == 200 + listed = r.get_json() + assert listed[0]["id"] == endpoint_id + assert "secret" not in listed[0] + + r = client.patch( + f"/webhooks/{endpoint_id}", + json={"url": "https://example.com/updated", "active": False}, + headers=auth_header, + ) + assert r.status_code == 200 + assert r.get_json()["active"] is False + + r = client.delete(f"/webhooks/{endpoint_id}", headers=auth_header) + assert r.status_code == 204 + + +def test_expense_create_delivers_signed_webhook_and_records_success( + client, auth_header, monkeypatch, app_fixture +): + r = client.post( + "/webhooks", json={"url": "https://example.com/webhook"}, headers=auth_header + ) + assert r.status_code == 201 + endpoint = r.get_json() + secret = endpoint["secret"] + calls = [] + + def fake_post(url, data, headers, timeout): + calls.append({"url": url, "data": data, "headers": headers, "timeout": timeout}) + return _Response(204) + + monkeypatch.setattr("app.services.webhooks.requests.post", fake_post) + + r = client.post( + "/expenses", + json={"amount": 12.5, "description": "Coffee", "date": "2026-02-12"}, + headers=auth_header, + ) + assert r.status_code == 201 + assert len(calls) == 1 + call = calls[0] + assert call["url"] == "https://example.com/webhook" + assert call["headers"]["X-FinMind-Event"] == "expense.created" + assert call["headers"]["X-FinMind-Delivery"] + timestamp = call["headers"]["X-FinMind-Timestamp"] + expected = hmac.new( + secret.encode("utf-8"), + timestamp.encode("utf-8") + b"." + call["data"], + hashlib.sha256, + ).hexdigest() + assert call["headers"]["X-FinMind-Signature"] == f"sha256={expected}" + + with app_fixture.app_context(): + delivery = db.session.query(WebhookDelivery).one() + assert delivery.event_type == "expense.created" + assert delivery.success is True + assert delivery.status_code == 204 + assert delivery.attempts == 1 + + r = client.get(f"/webhooks/{endpoint['id']}/deliveries", headers=auth_header) + assert r.status_code == 200 + deliveries = r.get_json() + assert deliveries[0]["event_type"] == "expense.created" + assert deliveries[0]["success"] is True + + +def test_webhook_delivery_retries_and_records_failure( + client, auth_header, monkeypatch, app_fixture +): + r = client.post( + "/webhooks", json={"url": "https://example.com/webhook"}, headers=auth_header + ) + assert r.status_code == 201 + attempts = [] + + def fake_post(url, data, headers, timeout): + attempts.append(url) + return _Response(500) + + monkeypatch.setattr("app.services.webhooks.requests.post", fake_post) + monkeypatch.setattr("app.services.webhooks.time.sleep", lambda _seconds: None) + + r = client.post( + "/expenses", + json={"amount": 8.0, "description": "Lunch", "date": "2026-02-13"}, + headers=auth_header, + ) + assert r.status_code == 201 + assert len(attempts) == 3 + + with app_fixture.app_context(): + delivery = db.session.query(WebhookDelivery).one() + assert delivery.event_type == "expense.created" + assert delivery.success is False + assert delivery.status_code == 500 + assert delivery.attempts == 3 + assert delivery.error == "HTTP 500"