Skip to content
This repository was archived by the owner on Jun 19, 2026. It is now read-only.
Closed
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
6 changes: 5 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ GEMINI_MODEL="gemini-1.5-flash"
TWILIO_ACCOUNT_SID=""
TWILIO_AUTH_TOKEN=""
TWILIO_WHATSAPP_FROM=""
EMAIL_FROM=""
# Email delivery — Resend (recommended, free tier: 3,000/mo)
# Sign up: https://resend.com → API Keys → Create API Key
RESEND_API_KEY=""
EMAIL_FROM="FinMind <onboarding@resend.dev>"
# SMTP fallback (optional, only if not using Resend)
SMTP_URL=""

VITE_API_URL="http://localhost:8000"
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,15 @@ See `backend/app/db/schema.sql`. Key tables:
- users, categories, expenses, bills, reminders
- ad_impressions, subscription_plans, user_subscriptions
- refresh_tokens (optional if rotating), audit_logs
- weekly_digests (weekly financial summary persistence)

## Redis Caching Policy
- Keys
- `user:{id}:monthly_summary:{yyyy-mm}` — 30 min TTL
- `user:{id}:categories` — 24h TTL
- `user:{id}:upcoming_bills` — 15 min TTL
- `insights:{id}` — 24h TTL (invalidate on new expense/bill)
- `user:{id}:weekly_digest:{yyyy-mm-dd}` — 1h TTL
- Invalidation
- On expense/bill create/update/delete -> delete affected monthly_summary, upcoming_bills, insights
- Rate limiting (optional): `rl:{userId}:{endpoint}:{minute}` with short TTL
Expand All @@ -66,6 +68,7 @@ OpenAPI: `backend/app/openapi.yaml`
- Bills: CRUD `/bills`, pay/mark `/bills/{id}/pay`
- Reminders: CRUD `/reminders`, trigger `/reminders/run`
- Insights: `/insights/monthly`, `/insights/budget-suggestion`
- Digest: `/digest/weekly`, `/digest/weekly/history`, `/digest/weekly/send`

## MVP UI/UX Plan
- Auth screens: register/login.
Expand Down
28 changes: 25 additions & 3 deletions packages/backend/app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,11 +110,33 @@ def _ensure_schema_compatibility(app: Flask) -> None:
NOT NULL DEFAULT 'INR'
"""
)
cur.execute(
"""
ALTER TABLE users
ADD COLUMN IF NOT EXISTS digest_email_enabled BOOLEAN
NOT NULL DEFAULT TRUE
"""
)
cur.execute(
"""
CREATE TABLE IF NOT EXISTS weekly_digests (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
week_start DATE NOT NULL,
week_end DATE NOT NULL,
payload JSONB NOT NULL DEFAULT '{}',
ai_insight TEXT,
method VARCHAR(20) NOT NULL DEFAULT 'heuristic',
delivered_at TIMESTAMP,
channel VARCHAR(20) NOT NULL DEFAULT 'email',
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE(user_id, week_start)
)
"""
)
conn.commit()
except Exception:
app.logger.exception(
"Schema compatibility patch failed for users.preferred_currency"
)
app.logger.exception("Schema compatibility patch failed")
conn.rollback()
finally:
conn.close()
1 change: 1 addition & 0 deletions packages/backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ class Settings(BaseSettings):

email_from: str | None = None
smtp_url: str | None = None # e.g. smtp+ssl://user:pass@mail:465
resend_api_key: str | None = None

# pydantic-settings v2 configuration
model_config = SettingsConfigDict(
Expand Down
19 changes: 19 additions & 0 deletions packages/backend/app/db/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,22 @@ CREATE TABLE IF NOT EXISTS audit_logs (
action VARCHAR(100) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);

ALTER TABLE users
ADD COLUMN IF NOT EXISTS digest_email_enabled BOOLEAN NOT NULL DEFAULT TRUE;

CREATE TABLE IF NOT EXISTS weekly_digests (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
week_start DATE NOT NULL,
week_end DATE NOT NULL,
payload JSONB NOT NULL DEFAULT '{}',
ai_insight TEXT,
method VARCHAR(20) NOT NULL DEFAULT 'heuristic',
delivered_at TIMESTAMP,
channel VARCHAR(20) NOT NULL DEFAULT 'email',
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE(user_id, week_start)
);
CREATE INDEX IF NOT EXISTS idx_weekly_digests_user_week
ON weekly_digests(user_id, week_start DESC);
18 changes: 18 additions & 0 deletions packages/backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ class User(db.Model):
password_hash = db.Column(db.String(255), nullable=False)
preferred_currency = db.Column(db.String(10), default="INR", nullable=False)
role = db.Column(db.String(20), default=Role.USER.value, nullable=False)
digest_email_enabled = db.Column(db.Boolean, default=True, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)


Expand Down Expand Up @@ -133,3 +134,20 @@ 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 WeeklyDigest(db.Model):
__tablename__ = "weekly_digests"
__table_args__ = (
db.UniqueConstraint("user_id", "week_start", name="uq_digest_user_week"),
)
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
week_start = db.Column(db.Date, nullable=False)
week_end = db.Column(db.Date, nullable=False)
payload = db.Column(db.JSON, nullable=False, default=dict)
ai_insight = db.Column(db.Text, nullable=True)
method = db.Column(db.String(20), default="heuristic", nullable=False)
delivered_at = db.Column(db.DateTime, nullable=True)
channel = db.Column(db.String(20), default="email", nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
124 changes: 124 additions & 0 deletions packages/backend/app/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ tags:
- name: Bills
- name: Reminders
- name: Insights
- name: Digest
paths:
/auth/register:
post:
Expand Down Expand Up @@ -481,6 +482,116 @@ paths:
application/json:
schema: { $ref: '#/components/schemas/Error' }

/digest/weekly:
get:
summary: Get weekly financial digest
description: Returns a comprehensive weekly spending summary with AI insight. Generates a new digest if one does not exist for the requested week.
tags: [Digest]
security: [{ bearerAuth: [] }]
parameters:
- in: query
name: week_start
required: false
schema: { type: string, format: date }
description: Monday of the desired week (YYYY-MM-DD). Defaults to last completed week.
responses:
'200':
description: Weekly digest
content:
application/json:
schema:
$ref: '#/components/schemas/WeeklyDigest'
example:
id: 1
user_id: 1
week_start: "2026-03-02"
week_end: "2026-03-08"
payload:
summary:
total_income: 25000
total_expenses: 12450
net_flow: 12550
week_over_week_change_pct: -8.5
transaction_count: 23
category_breakdown:
- { name: "Food & Dining", amount: 4200, share_pct: 33.7 }
highlights:
top_category: "Food & Dining"
daily_average: 1778.57
upcoming_bills:
- { name: Internet, amount: 999, due_date: "2026-03-12" }
ai_insight: "Your spending decreased by 8.5% — great job!"
method: gemini
'400':
description: Invalid week_start
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'401':
description: Unauthorized
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }

/digest/weekly/history:
get:
summary: List past weekly digests
tags: [Digest]
security: [{ bearerAuth: [] }]
parameters:
- in: query
name: limit
required: false
schema: { type: integer, default: 10, maximum: 52 }
responses:
'200':
description: List of digest summaries
content:
application/json:
schema:
type: array
items:
type: object
properties:
id: { type: integer }
week_start: { type: string, format: date }
week_end: { type: string, format: date }
total_expenses: { type: number }
net_flow: { type: number }
method: { type: string }
delivered_at: { type: string, format: date-time, nullable: true }
created_at: { type: string, format: date-time }
'401':
description: Unauthorized
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }

/digest/weekly/send:
post:
summary: Send weekly digest email
description: Generates the digest if not already created, then sends it via email to the authenticated user.
tags: [Digest]
security: [{ bearerAuth: [] }]
responses:
'200':
description: Send result
content:
application/json:
schema:
type: object
properties:
sent: { type: boolean }
digest_id: { type: integer }
example:
sent: true
digest_id: 1
'401':
description: Unauthorized
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }

components:
securitySchemes:
bearerAuth:
Expand Down Expand Up @@ -587,3 +698,16 @@ components:
message: { type: string }
send_at: { type: string, format: date-time }
channel: { type: string, enum: [email, whatsapp], default: email }
WeeklyDigest:
type: object
properties:
id: { type: integer }
user_id: { type: integer }
week_start: { type: string, format: date }
week_end: { type: string, format: date }
payload: { type: object, additionalProperties: true }
ai_insight: { type: string, nullable: true }
method: { type: string, enum: [gemini, heuristic] }
delivered_at: { type: string, format: date-time, nullable: true }
channel: { type: string }
created_at: { type: string, format: date-time }
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 .digest import bp as digest_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(digest_bp, url_prefix="/digest")
Loading
Loading