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
39 changes: 39 additions & 0 deletions packages/backend/app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
23 changes: 23 additions & 0 deletions packages/backend/app/db/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
25 changes: 25 additions & 0 deletions packages/backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
152 changes: 152 additions & 0 deletions packages/backend/app/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ tags:
- name: Expenses
- name: Bills
- name: Reminders
- name: Webhooks
- name: Insights
paths:
/auth/register:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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 }
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")
18 changes: 18 additions & 0 deletions packages/backend/app/routes/bills.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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


Expand All @@ -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,
}
13 changes: 10 additions & 3 deletions packages/backend/app/routes/categories.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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("/<int:category_id>")
Expand All @@ -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("/<int:category_id>")
Expand All @@ -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")
Loading