From 890459374840b18ded9d211bf680331c19e6a376 Mon Sep 17 00:00:00 2001 From: yuliuyi717-ux <264093635+yuliuyi717-ux@users.noreply.github.com> Date: Tue, 10 Mar 2026 07:25:01 +0800 Subject: [PATCH] Add shared household budgeting support --- AUTODEV_REPORT.md | 32 ++++ README.md | 9 +- packages/backend/app/__init__.py | 61 ++++++- packages/backend/app/db/schema.sql | 40 +++++ packages/backend/app/models.py | 41 +++++ packages/backend/app/openapi.yaml | 184 +++++++++++++++++++- packages/backend/app/routes/__init__.py | 2 + packages/backend/app/routes/bills.py | 56 ++++-- packages/backend/app/routes/categories.py | 67 +++++-- packages/backend/app/routes/dashboard.py | 44 +++-- packages/backend/app/routes/expenses.py | 108 +++++++++--- packages/backend/app/routes/households.py | 171 ++++++++++++++++++ packages/backend/app/services/ai.py | 21 ++- packages/backend/app/services/households.py | 54 ++++++ packages/backend/tests/test_households.py | 146 ++++++++++++++++ 15 files changed, 970 insertions(+), 66 deletions(-) create mode 100644 AUTODEV_REPORT.md create mode 100644 packages/backend/app/routes/households.py create mode 100644 packages/backend/app/services/households.py create mode 100644 packages/backend/tests/test_households.py diff --git a/AUTODEV_REPORT.md b/AUTODEV_REPORT.md new file mode 100644 index 0000000..3546598 --- /dev/null +++ b/AUTODEV_REPORT.md @@ -0,0 +1,32 @@ +# AUTODEV Report - Issue #134 Shared household budgeting support + +## Changed Files +- `README.md` +- `packages/backend/app/__init__.py` +- `packages/backend/app/db/schema.sql` +- `packages/backend/app/models.py` +- `packages/backend/app/openapi.yaml` +- `packages/backend/app/routes/__init__.py` +- `packages/backend/app/routes/bills.py` +- `packages/backend/app/routes/categories.py` +- `packages/backend/app/routes/dashboard.py` +- `packages/backend/app/routes/expenses.py` +- `packages/backend/app/routes/households.py` +- `packages/backend/app/services/ai.py` +- `packages/backend/app/services/households.py` +- `packages/backend/tests/test_households.py` + +## Test Commands +1. `sh ./scripts/test-backend.sh tests/test_households.py` +2. `sh ./scripts/test-backend.sh tests/test_households.py tests/test_categories.py tests/test_expenses.py tests/test_bills.py tests/test_dashboard.py tests/test_insights.py tests/test_auth.py` +3. `docker compose run --rm backend sh -lc "flake8 app tests"` + +## Results +- Command 1: passed (`2 passed`) +- Command 2: passed (`20 passed`) +- Command 3: passed (no lint errors) + +## Risks / Follow-ups +- This implementation enforces one household membership per user (`household_members.user_id` is unique). Multi-household membership is not supported. +- Frontend household management UI is not added in this change; collaboration is available through backend APIs and optional `household_id` payload fields. +- Some non-core flows (for example statement import and recurring-expense generation) still create personal expenses only unless explicitly extended in future work. diff --git a/README.md b/README.md index 49592bf..99e24e2 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,8 @@ flowchart LR ## PostgreSQL Schema (DDL) See `backend/app/db/schema.sql`. Key tables: -- users, categories, expenses, bills, reminders +- users, households, household_members +- categories, expenses, bills, reminders (each supports optional `household_id` for shared records) - ad_impressions, subscription_plans, user_subscriptions - refresh_tokens (optional if rotating), audit_logs @@ -62,8 +63,10 @@ See `backend/app/db/schema.sql`. Key tables: ## API Endpoints OpenAPI: `backend/app/openapi.yaml` - Auth: `/auth/register`, `/auth/login`, `/auth/refresh` -- Expenses: CRUD `/expenses` -- Bills: CRUD `/bills`, pay/mark `/bills/{id}/pay` +- Households: `/households`, `/households/current`, `/households/join`, `/households/leave`, `/households/members/{memberUserId}` +- Categories: CRUD `/categories` (supports optional `household_id`) +- Expenses: CRUD `/expenses` (supports optional `household_id`) +- Bills: CRUD `/bills`, pay/mark `/bills/{id}/pay` (supports optional `household_id`) - Reminders: CRUD `/reminders`, trigger `/reminders/run` - Insights: `/insights/monthly`, `/insights/budget-suggestion` diff --git a/packages/backend/app/__init__.py b/packages/backend/app/__init__.py index cdf76b4..44766d3 100644 --- a/packages/backend/app/__init__.py +++ b/packages/backend/app/__init__.py @@ -110,10 +110,69 @@ def _ensure_schema_compatibility(app: Flask) -> None: NOT NULL DEFAULT 'INR' """ ) + cur.execute( + """ + DO $$ BEGIN + CREATE TYPE household_role AS ENUM ('ADMIN','MEMBER'); + EXCEPTION + WHEN duplicate_object THEN NULL; + END $$; + """ + ) + cur.execute( + """ + CREATE TABLE IF NOT EXISTS households ( + id SERIAL PRIMARY KEY, + name VARCHAR(150) NOT NULL, + invite_code VARCHAR(32) UNIQUE NOT NULL, + created_by INT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMP NOT NULL DEFAULT NOW() + ) + """ + ) + cur.execute( + """ + CREATE TABLE IF NOT EXISTS household_members ( + id SERIAL PRIMARY KEY, + household_id INT NOT NULL REFERENCES households(id) ON DELETE CASCADE, + user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role household_role NOT NULL DEFAULT 'MEMBER', + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + CONSTRAINT uq_household_member UNIQUE (household_id, user_id) + ) + """ + ) + cur.execute( + """ + CREATE UNIQUE INDEX IF NOT EXISTS idx_household_members_user_unique + ON household_members(user_id) + """ + ) + cur.execute( + """ + ALTER TABLE categories + ADD COLUMN IF NOT EXISTS household_id INT + REFERENCES households(id) ON DELETE SET NULL + """ + ) + cur.execute( + """ + ALTER TABLE expenses + ADD COLUMN IF NOT EXISTS household_id INT + REFERENCES households(id) ON DELETE SET NULL + """ + ) + cur.execute( + """ + ALTER TABLE bills + ADD COLUMN IF NOT EXISTS household_id INT + REFERENCES households(id) ON DELETE SET NULL + """ + ) conn.commit() except Exception: app.logger.exception( - "Schema compatibility patch failed for users.preferred_currency" + "Schema compatibility patch failed for household compatibility changes" ) conn.rollback() finally: diff --git a/packages/backend/app/db/schema.sql b/packages/backend/app/db/schema.sql index 410189d..3616781 100644 --- a/packages/backend/app/db/schema.sql +++ b/packages/backend/app/db/schema.sql @@ -11,16 +11,47 @@ CREATE TABLE IF NOT EXISTS users ( ALTER TABLE users ADD COLUMN IF NOT EXISTS preferred_currency VARCHAR(10) NOT NULL DEFAULT 'INR'; +DO $$ BEGIN + CREATE TYPE household_role AS ENUM ('ADMIN','MEMBER'); +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; + +CREATE TABLE IF NOT EXISTS households ( + id SERIAL PRIMARY KEY, + name VARCHAR(150) NOT NULL, + invite_code VARCHAR(32) UNIQUE NOT NULL, + created_by INT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS household_members ( + id SERIAL PRIMARY KEY, + household_id INT NOT NULL REFERENCES households(id) ON DELETE CASCADE, + user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role household_role NOT NULL DEFAULT 'MEMBER', + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + CONSTRAINT uq_household_member UNIQUE (household_id, user_id) +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_household_members_user_unique ON household_members(user_id); +CREATE INDEX IF NOT EXISTS idx_household_members_household ON household_members(household_id); + CREATE TABLE IF NOT EXISTS categories ( id SERIAL PRIMARY KEY, user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + household_id INT REFERENCES households(id) ON DELETE SET NULL, name VARCHAR(100) NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT NOW() ); +CREATE INDEX IF NOT EXISTS idx_categories_household ON categories(household_id); + +ALTER TABLE categories + ADD COLUMN IF NOT EXISTS household_id INT REFERENCES households(id) ON DELETE SET NULL; CREATE TABLE IF NOT EXISTS expenses ( id SERIAL PRIMARY KEY, user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + household_id INT REFERENCES households(id) ON DELETE SET NULL, category_id INT REFERENCES categories(id) ON DELETE SET NULL, amount NUMERIC(12,2) NOT NULL, currency VARCHAR(10) NOT NULL DEFAULT 'INR', @@ -30,10 +61,14 @@ CREATE TABLE IF NOT EXISTS expenses ( created_at TIMESTAMP NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_expenses_user_spent_at ON expenses(user_id, spent_at DESC); +CREATE INDEX IF NOT EXISTS idx_expenses_household_spent_at ON expenses(household_id, spent_at DESC); ALTER TABLE expenses ADD COLUMN IF NOT EXISTS expense_type VARCHAR(20) NOT NULL DEFAULT 'EXPENSE'; +ALTER TABLE expenses + ADD COLUMN IF NOT EXISTS household_id INT REFERENCES households(id) ON DELETE SET NULL; + DO $$ BEGIN CREATE TYPE recurring_cadence AS ENUM ('DAILY','WEEKLY','MONTHLY','YEARLY'); EXCEPTION @@ -68,6 +103,7 @@ END $$; CREATE TABLE IF NOT EXISTS bills ( id SERIAL PRIMARY KEY, user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + household_id INT REFERENCES households(id) ON DELETE SET NULL, name VARCHAR(200) NOT NULL, amount NUMERIC(12,2) NOT NULL, currency VARCHAR(10) NOT NULL DEFAULT 'INR', @@ -80,10 +116,14 @@ CREATE TABLE IF NOT EXISTS bills ( created_at TIMESTAMP NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_bills_user_due ON bills(user_id, next_due_date); +CREATE INDEX IF NOT EXISTS idx_bills_household_due ON bills(household_id, next_due_date); ALTER TABLE bills ADD COLUMN IF NOT EXISTS autopay_enabled BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE bills + ADD COLUMN IF NOT EXISTS household_id INT REFERENCES households(id) ON DELETE SET NULL; + CREATE TABLE IF NOT EXISTS reminders ( id SERIAL PRIMARY KEY, user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE, diff --git a/packages/backend/app/models.py b/packages/backend/app/models.py index 64d4481..9282ce3 100644 --- a/packages/backend/app/models.py +++ b/packages/backend/app/models.py @@ -19,10 +19,45 @@ class User(db.Model): created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) +class HouseholdRole(str, Enum): + ADMIN = "ADMIN" + MEMBER = "MEMBER" + + +class Household(db.Model): + __tablename__ = "households" + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(150), nullable=False) + invite_code = db.Column(db.String(32), unique=True, nullable=False) + created_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + +class HouseholdMember(db.Model): + __tablename__ = "household_members" + id = db.Column(db.Integer, primary_key=True) + household_id = db.Column( + db.Integer, db.ForeignKey("households.id"), nullable=False, index=True + ) + user_id = db.Column( + db.Integer, db.ForeignKey("users.id"), nullable=False, unique=True + ) + role = db.Column( + SAEnum(HouseholdRole), default=HouseholdRole.MEMBER, nullable=False + ) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + __table_args__ = ( + db.UniqueConstraint("household_id", "user_id", name="uq_household_member"), + ) + + class Category(db.Model): __tablename__ = "categories" id = db.Column(db.Integer, primary_key=True) user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + household_id = db.Column( + db.Integer, db.ForeignKey("households.id"), nullable=True, index=True + ) name = db.Column(db.String(100), nullable=False) created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) @@ -31,6 +66,9 @@ class Expense(db.Model): __tablename__ = "expenses" id = db.Column(db.Integer, primary_key=True) user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + household_id = db.Column( + db.Integer, db.ForeignKey("households.id"), nullable=True, index=True + ) 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) @@ -77,6 +115,9 @@ class Bill(db.Model): __tablename__ = "bills" id = db.Column(db.Integer, primary_key=True) user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + household_id = db.Column( + db.Integer, db.ForeignKey("households.id"), nullable=True, index=True + ) name = db.Column(db.String(200), nullable=False) amount = db.Column(db.Numeric(12, 2), nullable=False) currency = db.Column(db.String(10), default="INR", nullable=False) diff --git a/packages/backend/app/openapi.yaml b/packages/backend/app/openapi.yaml index 3f8ec3f..4da328b 100644 --- a/packages/backend/app/openapi.yaml +++ b/packages/backend/app/openapi.yaml @@ -12,6 +12,7 @@ tags: - name: Bills - name: Reminders - name: Insights + - name: Households paths: /auth/register: post: @@ -129,7 +130,8 @@ paths: required: [name] properties: name: { type: string } - example: { name: Subscriptions } + household_id: { type: integer, nullable: true } + example: { name: Subscriptions, household_id: 1 } responses: '201': { description: Created } '400': @@ -180,6 +182,7 @@ paths: amount: 12.5 currency: USD category_id: 1 + household_id: 1 description: Lunch date: 2025-08-10 responses: @@ -286,6 +289,7 @@ paths: name: Internet amount: 49.99 currency: USD + household_id: 1 next_due_date: 2025-08-15 cadence: MONTHLY channel_email: true @@ -481,6 +485,163 @@ paths: application/json: schema: { $ref: '#/components/schemas/Error' } + /households: + post: + summary: Create a household and join as admin + tags: [Households] + security: [{ bearerAuth: [] }] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name] + properties: + name: { type: string } + example: + name: Home Budget + responses: + '201': + description: Household created + content: + application/json: + schema: + type: object + properties: + id: { type: integer } + name: { type: string } + invite_code: { type: string } + '400': + description: Validation error + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '409': + description: User already belongs to a household + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + + /households/current: + get: + summary: Get the household for the current user + tags: [Households] + security: [{ bearerAuth: [] }] + responses: + '200': + description: Household details + content: + application/json: + schema: + $ref: '#/components/schemas/Household' + '404': + description: User is not in a household + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + + /households/join: + post: + summary: Join household with an invite code + tags: [Households] + security: [{ bearerAuth: [] }] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [invite_code] + properties: + invite_code: { type: string } + example: + invite_code: 7B88A23D + responses: + '200': + description: Joined household + content: + application/json: + schema: + type: object + properties: + household_id: { type: integer } + '400': + description: Validation error + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '404': + description: Household not found + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '409': + description: User already belongs to another household + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + + /households/leave: + post: + summary: Leave the current household + tags: [Households] + security: [{ bearerAuth: [] }] + responses: + '200': + description: Left household + content: + application/json: + schema: + type: object + properties: + message: { type: string } + '400': + description: Admin cannot leave while other members exist + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '404': + description: Household not found + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + + /households/members/{memberUserId}: + delete: + summary: Remove a household member (admin only) + tags: [Households] + security: [{ bearerAuth: [] }] + parameters: + - in: path + name: memberUserId + required: true + schema: { type: integer } + responses: + '200': + description: Member removed + content: + application/json: + schema: + type: object + properties: + message: { type: string } + '400': + description: Invalid operation + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '403': + description: Admin required + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '404': + description: Household or member not found + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + components: securitySchemes: bearerAuth: @@ -503,10 +664,12 @@ components: properties: id: { type: integer } name: { type: string } + household_id: { type: integer, nullable: true } Expense: type: object properties: id: { type: integer } + household_id: { type: integer, nullable: true } amount: { type: number, format: float } currency: { type: string } category_id: { type: integer } @@ -519,6 +682,7 @@ components: properties: amount: { type: number, format: float } currency: { type: string, default: USD } + household_id: { type: integer, nullable: true } category_id: { type: integer, nullable: true } expense_type: { type: string, default: EXPENSE } description: { type: string, nullable: true } @@ -552,6 +716,7 @@ components: type: object properties: id: { type: integer } + household_id: { type: integer, nullable: true } name: { type: string } amount: { type: number, format: float } currency: { type: string } @@ -567,11 +732,28 @@ components: name: { type: string } amount: { type: number, format: float } currency: { type: string, default: USD } + household_id: { type: integer, nullable: true } next_due_date: { type: string, format: date } cadence: { type: string, enum: [WEEKLY, MONTHLY, YEARLY, ONCE], default: MONTHLY } autopay_enabled: { type: boolean, default: false } channel_email: { type: boolean, default: true } channel_whatsapp: { type: boolean, default: false } + HouseholdMember: + type: object + properties: + user_id: { type: integer } + email: { type: string, format: email } + role: { type: string, enum: [ADMIN, MEMBER] } + Household: + type: object + properties: + id: { type: integer } + name: { type: string } + invite_code: { type: string } + members: + type: array + items: + $ref: '#/components/schemas/HouseholdMember' Reminder: type: object properties: diff --git a/packages/backend/app/routes/__init__.py b/packages/backend/app/routes/__init__.py index f13b0f8..aa520c5 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 .households import bp as households_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(households_bp, url_prefix="/households") diff --git a/packages/backend/app/routes/bills.py b/packages/backend/app/routes/bills.py index f557e90..7053231 100644 --- a/packages/backend/app/routes/bills.py +++ b/packages/backend/app/routes/bills.py @@ -1,9 +1,16 @@ from datetime import date, timedelta from flask import Blueprint, jsonify, request from flask_jwt_extended import jwt_required, get_jwt_identity +from sqlalchemy import and_, or_ from ..extensions import db from ..models import Bill, BillCadence, User from ..services.cache import cache_delete_patterns +from ..services.households import ( + can_access_scope, + get_household_ids, + get_household_member_user_ids, + is_household_member, +) import logging bp = Blueprint("bills", __name__) @@ -14,17 +21,24 @@ @jwt_required() def list_bills(): uid = int(get_jwt_identity()) - items = ( - db.session.query(Bill) - .filter_by(user_id=uid, active=True) - .order_by(Bill.next_due_date) - .all() - ) + household_ids = list(get_household_ids(uid)) + q = db.session.query(Bill).filter(Bill.active.is_(True)) + if household_ids: + q = q.filter( + or_( + and_(Bill.user_id == uid, Bill.household_id.is_(None)), + Bill.household_id.in_(household_ids), + ) + ) + else: + q = q.filter(and_(Bill.user_id == uid, Bill.household_id.is_(None))) + items = q.order_by(Bill.next_due_date).all() logger.info("List bills user=%s count=%s", uid, len(items)) return jsonify( [ { "id": b.id, + "household_id": b.household_id, "name": b.name, "amount": float(b.amount), "currency": b.currency, @@ -45,8 +59,17 @@ def create_bill(): uid = int(get_jwt_identity()) user = db.session.get(User, uid) data = request.get_json() or {} + household_id = data.get("household_id") + if household_id is not None: + try: + household_id = int(household_id) + except (TypeError, ValueError): + return jsonify(error="invalid household_id"), 400 + if not is_household_member(uid, household_id): + return jsonify(error="forbidden household"), 403 b = Bill( user_id=uid, + household_id=household_id, name=data["name"], amount=data["amount"], currency=data.get("currency") or (user.preferred_currency if user else "INR"), @@ -59,9 +82,7 @@ def create_bill(): db.session.add(b) db.session.commit() logger.info("Created bill id=%s user=%s name=%s", b.id, uid, b.name) - cache_delete_patterns( - [f"user:{uid}:upcoming_bills*", f"user:{uid}:dashboard_summary:*"] - ) + _invalidate_bill_cache(uid, b.household_id) return jsonify(id=b.id), 201 @@ -70,7 +91,7 @@ def create_bill(): def mark_paid(bill_id: int): uid = int(get_jwt_identity()) b = db.session.get(Bill, bill_id) - if not b or b.user_id != uid: + if not b or not can_access_scope(uid, b.user_id, b.household_id): return jsonify(error="not found"), 404 # Move next due date based on cadence if b.cadence == BillCadence.MONTHLY: @@ -82,10 +103,19 @@ def mark_paid(bill_id: int): else: b.active = False db.session.commit() - cache_delete_patterns( - [f"user:{uid}:upcoming_bills*", f"user:{uid}:dashboard_summary:*"] - ) + _invalidate_bill_cache(uid, b.household_id) logger.info( "Marked bill paid id=%s user=%s next_due_date=%s", b.id, uid, b.next_due_date ) return jsonify(message="updated") + + +def _invalidate_bill_cache(uid: int, household_id: int | None): + affected_users = {uid, *get_household_member_user_ids(household_id)} + for affected_uid in affected_users: + cache_delete_patterns( + [ + f"user:{affected_uid}:upcoming_bills*", + f"user:{affected_uid}:dashboard_summary:*", + ] + ) diff --git a/packages/backend/app/routes/categories.py b/packages/backend/app/routes/categories.py index 71269a1..f9fc6c8 100644 --- a/packages/backend/app/routes/categories.py +++ b/packages/backend/app/routes/categories.py @@ -1,8 +1,14 @@ import logging from flask import Blueprint, jsonify, request from flask_jwt_extended import jwt_required, get_jwt_identity +from sqlalchemy import and_, or_ from ..extensions import db from ..models import Category +from ..services.households import ( + can_access_scope, + get_household_ids, + is_household_member, +) bp = Blueprint("categories", __name__) logger = logging.getLogger("finmind.categories") @@ -12,11 +18,22 @@ @jwt_required() def list_categories(): uid = int(get_jwt_identity()) - items = ( - db.session.query(Category).filter_by(user_id=uid).order_by(Category.name).all() - ) + household_ids = list(get_household_ids(uid)) + q = db.session.query(Category) + if household_ids: + q = q.filter( + or_( + and_(Category.user_id == uid, Category.household_id.is_(None)), + Category.household_id.in_(household_ids), + ) + ) + else: + q = q.filter(and_(Category.user_id == uid, Category.household_id.is_(None))) + items = q.order_by(Category.name).all() logger.info("List categories for user=%s count=%s", uid, len(items)) - return jsonify([{"id": c.id, "name": c.name} for c in items]) + return jsonify( + [{"id": c.id, "name": c.name, "household_id": c.household_id} for c in items] + ) @bp.post("") @@ -25,18 +42,37 @@ def create_category(): uid = int(get_jwt_identity()) data = request.get_json() or {} name = (data.get("name") or "").strip() + household_id = data.get("household_id") if not name: logger.warning("Create category missing name user=%s", uid) return jsonify(error="name required"), 400 + if household_id is not None: + try: + household_id = int(household_id) + except (TypeError, ValueError): + return jsonify(error="invalid household_id"), 400 + if not is_household_member(uid, household_id): + return jsonify(error="forbidden household"), 403 # Optional: enforce unique name per user - exists = db.session.query(Category).filter_by(user_id=uid, name=name).first() + if household_id is None: + exists = ( + db.session.query(Category) + .filter_by(user_id=uid, household_id=None, name=name) + .first() + ) + else: + exists = ( + db.session.query(Category) + .filter_by(household_id=household_id, name=name) + .first() + ) if exists: return jsonify(error="category already exists"), 409 - c = Category(user_id=uid, name=name) + c = Category(user_id=uid, household_id=household_id, name=name) 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 + return jsonify(id=c.id, name=c.name, household_id=c.household_id), 201 @bp.patch("/") @@ -44,16 +80,27 @@ def create_category(): def update_category(category_id: int): uid = int(get_jwt_identity()) c = db.session.get(Category, category_id) - if not c or c.user_id != uid: + if not c or not can_access_scope(uid, c.user_id, c.household_id): return jsonify(error="not found"), 404 data = request.get_json() or {} name = (data.get("name") or "").strip() if not name: return jsonify(error="name required"), 400 + duplicate_q = db.session.query(Category).filter( + Category.id != c.id, Category.name == name + ) + if c.household_id is None: + duplicate_q = duplicate_q.filter( + Category.user_id == uid, Category.household_id.is_(None) + ) + else: + duplicate_q = duplicate_q.filter(Category.household_id == c.household_id) + if duplicate_q.first(): + return jsonify(error="category already exists"), 409 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) + return jsonify(id=c.id, name=c.name, household_id=c.household_id) @bp.delete("/") @@ -61,7 +108,7 @@ def update_category(category_id: int): def delete_category(category_id: int): uid = int(get_jwt_identity()) c = db.session.get(Category, category_id) - if not c or c.user_id != uid: + if not c or not can_access_scope(uid, c.user_id, c.household_id): return jsonify(error="not found"), 404 db.session.delete(c) db.session.commit() diff --git a/packages/backend/app/routes/dashboard.py b/packages/backend/app/routes/dashboard.py index c310611..c874a25 100644 --- a/packages/backend/app/routes/dashboard.py +++ b/packages/backend/app/routes/dashboard.py @@ -1,11 +1,12 @@ from datetime import date -from sqlalchemy import extract, func +from sqlalchemy import and_, extract, func, or_ from flask import Blueprint, jsonify, request from flask_jwt_extended import jwt_required, get_jwt_identity from ..extensions import db from ..models import Bill, Expense, Category from ..services.cache import cache_get, cache_set, dashboard_summary_key +from ..services.households import get_household_ids bp = Blueprint("dashboard", __name__) @@ -17,10 +18,30 @@ def dashboard_summary(): ym = (request.args.get("month") or date.today().strftime("%Y-%m")).strip() if not _is_valid_month(ym): return jsonify(error="invalid month, expected YYYY-MM"), 400 + household_ids = list(get_household_ids(uid)) + expense_scope = ( + or_( + and_(Expense.user_id == uid, Expense.household_id.is_(None)), + Expense.household_id.in_(household_ids), + ) + if household_ids + else and_(Expense.user_id == uid, Expense.household_id.is_(None)) + ) + bill_scope = ( + or_( + and_(Bill.user_id == uid, Bill.household_id.is_(None)), + Bill.household_id.in_(household_ids), + ) + if household_ids + else and_(Bill.user_id == uid, Bill.household_id.is_(None)) + ) + key = dashboard_summary_key(uid, ym) - cached = cache_get(key) - if cached: - return jsonify(cached) + use_cache = not household_ids + if use_cache: + cached = cache_get(key) + if cached: + return jsonify(cached) payload = { "period": {"month": ym}, @@ -44,7 +65,7 @@ def dashboard_summary(): income = ( db.session.query(func.coalesce(func.sum(Expense.amount), 0)) .filter( - Expense.user_id == uid, + expense_scope, extract("year", Expense.spent_at) == year, extract("month", Expense.spent_at) == month, Expense.expense_type == "INCOME", @@ -54,7 +75,7 @@ def dashboard_summary(): expenses = ( db.session.query(func.coalesce(func.sum(Expense.amount), 0)) .filter( - Expense.user_id == uid, + expense_scope, extract("year", Expense.spent_at) == year, extract("month", Expense.spent_at) == month, Expense.expense_type != "INCOME", @@ -74,7 +95,7 @@ def dashboard_summary(): try: rows = ( db.session.query(Expense) - .filter(Expense.user_id == uid) + .filter(expense_scope) .order_by(Expense.spent_at.desc(), Expense.id.desc()) .limit(10) .all() @@ -98,7 +119,7 @@ def dashboard_summary(): bills = ( db.session.query(Bill) .filter( - Bill.user_id == uid, + bill_scope, Bill.active.is_(True), Bill.next_due_date >= today, ) @@ -135,10 +156,10 @@ def dashboard_summary(): ) .outerjoin( Category, - (Category.id == Expense.category_id) & (Category.user_id == uid), + Category.id == Expense.category_id, ) .filter( - Expense.user_id == uid, + expense_scope, extract("year", Expense.spent_at) == year, extract("month", Expense.spent_at) == month, Expense.expense_type != "INCOME", @@ -164,7 +185,8 @@ def dashboard_summary(): except Exception: payload["errors"].append("category_breakdown_unavailable") - cache_set(key, payload, ttl_seconds=300) + if use_cache: + cache_set(key, payload, ttl_seconds=300) return jsonify(payload) diff --git a/packages/backend/app/routes/expenses.py b/packages/backend/app/routes/expenses.py index 1376d46..ff77fa2 100644 --- a/packages/backend/app/routes/expenses.py +++ b/packages/backend/app/routes/expenses.py @@ -1,13 +1,20 @@ import calendar from datetime import date, timedelta from decimal import Decimal, InvalidOperation +from sqlalchemy import and_, or_ 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 Expense, RecurringCadence, RecurringExpense, User +from ..models import Category, Expense, RecurringCadence, RecurringExpense, User from ..services.cache import cache_delete_patterns, monthly_summary_key from ..services import expense_import +from ..services.households import ( + can_access_scope, + get_household_ids, + get_household_member_user_ids, + is_household_member, +) import logging bp = Blueprint("expenses", __name__) @@ -18,7 +25,17 @@ @jwt_required() def list_expenses(): uid = int(get_jwt_identity()) - q = db.session.query(Expense).filter_by(user_id=uid) + household_ids = list(get_household_ids(uid)) + q = db.session.query(Expense) + if household_ids: + q = q.filter( + or_( + and_(Expense.user_id == uid, Expense.household_id.is_(None)), + Expense.household_id.in_(household_ids), + ) + ) + else: + q = q.filter(and_(Expense.user_id == uid, Expense.household_id.is_(None))) from_date = request.args.get("from") to_date = request.args.get("to") search = (request.args.get("search") or "").strip() @@ -63,27 +80,49 @@ def create_expense(): return jsonify(error="invalid amount"), 400 raw_date = data.get("date") or data.get("spent_at") description = (data.get("description") or data.get("notes") or "").strip() + household_id = data.get("household_id") + category_id = data.get("category_id") if not description: return jsonify(error="description required"), 400 + if household_id is not None: + try: + household_id = int(household_id) + except (TypeError, ValueError): + return jsonify(error="invalid household_id"), 400 + if not is_household_member(uid, household_id): + return jsonify(error="forbidden household"), 403 + if category_id is not None: + try: + category_id = int(category_id) + except (TypeError, ValueError): + return jsonify(error="invalid category_id"), 400 + category = db.session.get(Category, category_id) + if not category: + return jsonify(error="invalid category_id"), 400 + if not can_access_scope(uid, category.user_id, category.household_id): + return jsonify(error="invalid category_id"), 400 + if household_id is None and category.household_id is not None: + household_id = category.household_id + if ( + household_id is not None + and category.household_id is not None + and category.household_id != household_id + ): + return jsonify(error="category household mismatch"), 400 e = Expense( user_id=uid, + household_id=household_id, amount=amount, currency=(data.get("currency") or (user.preferred_currency if user else "INR")), expense_type=str(data.get("expense_type") or "EXPENSE").upper(), - category_id=data.get("category_id"), + category_id=category_id, notes=description, spent_at=date.fromisoformat(raw_date) if raw_date else date.today(), ) db.session.add(e) db.session.commit() logger.info("Created expense id=%s user=%s amount=%s", e.id, uid, e.amount) - # Invalidate caches - cache_delete_patterns( - [ - monthly_summary_key(uid, e.spent_at.strftime("%Y-%m")), - f"insights:{uid}:*", - ] - ) + _invalidate_expense_cache(uid, e.spent_at.isoformat(), e.household_id) return jsonify(_expense_to_dict(e)), 201 @@ -207,7 +246,7 @@ def generate_recurring_expenses(recurring_id: int): def update_expense(expense_id: int): uid = int(get_jwt_identity()) e = db.session.get(Expense, expense_id) - if not e or e.user_id != uid: + if not e or not can_access_scope(uid, e.user_id, e.household_id): return jsonify(error="not found"), 404 data = request.get_json() or {} if "amount" in data: @@ -220,7 +259,26 @@ def update_expense(expense_id: int): if "expense_type" in data: e.expense_type = str(data.get("expense_type") or "EXPENSE").upper() if "category_id" in data: - e.category_id = data.get("category_id") + category_id = data.get("category_id") + if category_id is None: + e.category_id = None + else: + try: + category_id = int(category_id) + except (TypeError, ValueError): + return jsonify(error="invalid category_id"), 400 + category = db.session.get(Category, category_id) + if not category: + return jsonify(error="invalid category_id"), 400 + if not can_access_scope(uid, category.user_id, category.household_id): + return jsonify(error="invalid category_id"), 400 + if ( + e.household_id is not None + and category.household_id is not None + and category.household_id != e.household_id + ): + return jsonify(error="category household mismatch"), 400 + e.category_id = category_id if "description" in data or "notes" in data: description = (data.get("description") or data.get("notes") or "").strip() if not description: @@ -230,7 +288,7 @@ 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() - _invalidate_expense_cache(uid, e.spent_at.isoformat()) + _invalidate_expense_cache(uid, e.spent_at.isoformat(), e.household_id) return jsonify(_expense_to_dict(e)) @@ -239,12 +297,13 @@ def update_expense(expense_id: int): def delete_expense(expense_id: int): uid = int(get_jwt_identity()) e = db.session.get(Expense, expense_id) - if not e or e.user_id != uid: + if not e or not can_access_scope(uid, e.user_id, e.household_id): return jsonify(error="not found"), 404 spent_at = e.spent_at.isoformat() + household_id = e.household_id db.session.delete(e) db.session.commit() - _invalidate_expense_cache(uid, spent_at) + _invalidate_expense_cache(uid, spent_at, household_id) return jsonify(message="deleted") @@ -314,6 +373,7 @@ def import_commit(): def _expense_to_dict(e: Expense) -> dict: return { "id": e.id, + "household_id": e.household_id, "amount": float(e.amount), "currency": e.currency, "category_id": e.category_id, @@ -384,12 +444,14 @@ def _is_duplicate(uid: int, row: dict) -> bool: ) -def _invalidate_expense_cache(uid: int, at: str): +def _invalidate_expense_cache(uid: int, at: str, household_id: int | None = None): ym = at[:7] - cache_delete_patterns( - [ - monthly_summary_key(uid, ym), - f"insights:{uid}:*", - f"user:{uid}:dashboard_summary:*", - ] - ) + affected_users = {uid, *get_household_member_user_ids(household_id)} + for affected_uid in affected_users: + cache_delete_patterns( + [ + monthly_summary_key(affected_uid, ym), + f"insights:{affected_uid}:*", + f"user:{affected_uid}:dashboard_summary:*", + ] + ) diff --git a/packages/backend/app/routes/households.py b/packages/backend/app/routes/households.py new file mode 100644 index 0000000..e65f507 --- /dev/null +++ b/packages/backend/app/routes/households.py @@ -0,0 +1,171 @@ +import secrets + +from flask import Blueprint, jsonify, request +from flask_jwt_extended import get_jwt_identity, jwt_required + +from ..extensions import db +from ..models import Household, HouseholdMember, HouseholdRole, User +from ..services.households import get_membership, is_household_admin + +bp = Blueprint("households", __name__) + + +def _generate_invite_code() -> str: + return secrets.token_hex(4).upper() + + +def _member_role_value(member: HouseholdMember) -> str: + return member.role.value if hasattr(member.role, "value") else str(member.role) + + +def _household_payload(household: Household) -> dict: + members = ( + db.session.query(HouseholdMember, User) + .join(User, User.id == HouseholdMember.user_id) + .filter(HouseholdMember.household_id == household.id) + .order_by(HouseholdMember.created_at.asc()) + .all() + ) + return { + "id": household.id, + "name": household.name, + "invite_code": household.invite_code, + "members": [ + { + "user_id": member.user_id, + "email": user.email, + "role": _member_role_value(member), + } + for member, user in members + ], + } + + +@bp.post("") +@jwt_required() +def create_household(): + uid = int(get_jwt_identity()) + if get_membership(uid): + return jsonify(error="already in a household"), 409 + + data = request.get_json() or {} + name = (data.get("name") or "").strip() + if not name: + return jsonify(error="name required"), 400 + + code = _generate_invite_code() + while db.session.query(Household.id).filter_by(invite_code=code).first(): + code = _generate_invite_code() + + household = Household(name=name, invite_code=code, created_by=uid) + db.session.add(household) + db.session.flush() + db.session.add( + HouseholdMember( + household_id=household.id, + user_id=uid, + role=HouseholdRole.ADMIN, + ) + ) + db.session.commit() + return ( + jsonify( + id=household.id, + name=household.name, + invite_code=household.invite_code, + ), + 201, + ) + + +@bp.get("/current") +@jwt_required() +def get_current_household(): + uid = int(get_jwt_identity()) + membership = get_membership(uid) + if not membership: + return jsonify(error="household not found"), 404 + household = db.session.get(Household, membership.household_id) + if not household: + return jsonify(error="household not found"), 404 + return jsonify(_household_payload(household)) + + +@bp.post("/join") +@jwt_required() +def join_household(): + uid = int(get_jwt_identity()) + data = request.get_json() or {} + invite_code = (data.get("invite_code") or "").strip().upper() + if not invite_code: + return jsonify(error="invite_code required"), 400 + household = db.session.query(Household).filter_by(invite_code=invite_code).first() + if not household: + return jsonify(error="household not found"), 404 + + membership = get_membership(uid) + if membership and membership.household_id != household.id: + return jsonify(error="already in a household"), 409 + if membership and membership.household_id == household.id: + return jsonify(household_id=household.id), 200 + + db.session.add( + HouseholdMember( + household_id=household.id, + user_id=uid, + role=HouseholdRole.MEMBER, + ) + ) + db.session.commit() + return jsonify(household_id=household.id), 200 + + +@bp.post("/leave") +@jwt_required() +def leave_household(): + uid = int(get_jwt_identity()) + membership = get_membership(uid) + if not membership: + return jsonify(error="household not found"), 404 + + member_count = ( + db.session.query(HouseholdMember) + .filter_by(household_id=membership.household_id) + .count() + ) + if membership.role == HouseholdRole.ADMIN and member_count > 1: + return jsonify(error="admin cannot leave while other members exist"), 400 + + if member_count <= 1: + household = db.session.get(Household, membership.household_id) + if household: + db.session.delete(household) + else: + db.session.delete(membership) + db.session.commit() + return jsonify(message="left household"), 200 + + +@bp.delete("/members/") +@jwt_required() +def remove_member(member_user_id: int): + uid = int(get_jwt_identity()) + membership = get_membership(uid) + if not membership: + return jsonify(error="household not found"), 404 + if not is_household_admin(uid, membership.household_id): + return jsonify(error="admin required"), 403 + if member_user_id == uid: + return jsonify(error="use leave endpoint"), 400 + + target = ( + db.session.query(HouseholdMember) + .filter_by(household_id=membership.household_id, user_id=member_user_id) + .first() + ) + if not target: + return jsonify(error="member not found"), 404 + + db.session.delete(target) + db.session.commit() + return jsonify(message="removed"), 200 diff --git a/packages/backend/app/services/ai.py b/packages/backend/app/services/ai.py index 951fbd0..be5879e 100644 --- a/packages/backend/app/services/ai.py +++ b/packages/backend/app/services/ai.py @@ -1,11 +1,12 @@ import json from urllib import request -from sqlalchemy import extract, func +from sqlalchemy import and_, extract, func, or_ from ..config import Settings from ..extensions import db from ..models import Expense +from .households import get_household_ids _settings = Settings() DEFAULT_PERSONA = ( @@ -16,10 +17,11 @@ def _monthly_totals(uid: int, ym: str) -> tuple[float, float]: year, month = map(int, ym.split("-")) + expense_scope = _expense_scope(uid) income = ( db.session.query(func.coalesce(func.sum(Expense.amount), 0)) .filter( - Expense.user_id == uid, + expense_scope, extract("year", Expense.spent_at) == year, extract("month", Expense.spent_at) == month, Expense.expense_type == "INCOME", @@ -29,7 +31,7 @@ def _monthly_totals(uid: int, ym: str) -> tuple[float, float]: expenses = ( db.session.query(func.coalesce(func.sum(Expense.amount), 0)) .filter( - Expense.user_id == uid, + expense_scope, extract("year", Expense.spent_at) == year, extract("month", Expense.spent_at) == month, Expense.expense_type != "INCOME", @@ -41,12 +43,13 @@ def _monthly_totals(uid: int, ym: str) -> tuple[float, float]: def _category_spend(uid: int, ym: str) -> dict[str, float]: year, month = map(int, ym.split("-")) + expense_scope = _expense_scope(uid) rows = ( db.session.query( Expense.category_id, func.coalesce(func.sum(Expense.amount), 0) ) .filter( - Expense.user_id == uid, + expense_scope, extract("year", Expense.spent_at) == year, extract("month", Expense.spent_at) == month, Expense.expense_type != "INCOME", @@ -64,6 +67,16 @@ def _previous_month(ym: str) -> str: return f"{year:04d}-{month - 1:02d}" +def _expense_scope(uid: int): + household_ids = list(get_household_ids(uid)) + if household_ids: + return or_( + and_(Expense.user_id == uid, Expense.household_id.is_(None)), + Expense.household_id.in_(household_ids), + ) + return and_(Expense.user_id == uid, Expense.household_id.is_(None)) + + def _build_analytics(uid: int, ym: str) -> dict: _, current_expenses = _monthly_totals(uid, ym) _, prev_expenses = _monthly_totals(uid, _previous_month(ym)) diff --git a/packages/backend/app/services/households.py b/packages/backend/app/services/households.py new file mode 100644 index 0000000..1a7c287 --- /dev/null +++ b/packages/backend/app/services/households.py @@ -0,0 +1,54 @@ +from ..extensions import db +from ..models import HouseholdMember, HouseholdRole + + +def get_membership(user_id: int) -> HouseholdMember | None: + return db.session.query(HouseholdMember).filter_by(user_id=user_id).first() + + +def get_household_ids(user_id: int) -> set[int]: + rows = ( + db.session.query(HouseholdMember.household_id) + .filter_by(user_id=user_id) + .all() + ) + return {household_id for (household_id,) in rows} + + +def is_household_member(user_id: int, household_id: int | None) -> bool: + if household_id is None: + return False + return ( + db.session.query(HouseholdMember.id) + .filter_by(user_id=user_id, household_id=household_id) + .first() + is not None + ) + + +def is_household_admin(user_id: int, household_id: int) -> bool: + member = ( + db.session.query(HouseholdMember) + .filter_by(user_id=user_id, household_id=household_id) + .first() + ) + return member is not None and member.role == HouseholdRole.ADMIN + + +def can_access_scope( + user_id: int, owner_user_id: int, household_id: int | None +) -> bool: + if household_id is None: + return owner_user_id == user_id + return is_household_member(user_id, household_id) + + +def get_household_member_user_ids(household_id: int | None) -> list[int]: + if household_id is None: + return [] + rows = ( + db.session.query(HouseholdMember.user_id) + .filter_by(household_id=household_id) + .all() + ) + return [user_id for (user_id,) in rows] diff --git a/packages/backend/tests/test_households.py b/packages/backend/tests/test_households.py new file mode 100644 index 0000000..ea5a542 --- /dev/null +++ b/packages/backend/tests/test_households.py @@ -0,0 +1,146 @@ +from datetime import date + + +def _register_and_auth(client, email: str) -> dict[str, str]: + password = "password123" + r = client.post("/auth/register", json={"email": email, "password": password}) + assert r.status_code in (201, 409) + r = client.post("/auth/login", json={"email": email, "password": password}) + assert r.status_code == 200 + token = r.get_json()["access_token"] + return {"Authorization": f"Bearer {token}"} + + +def _get_me(client, auth_header: dict[str, str]) -> dict: + r = client.get("/auth/me", headers=auth_header) + assert r.status_code == 200 + return r.get_json() + + +def test_household_create_join_leave_and_remove_member(client): + owner_auth = _register_and_auth(client, "owner@example.com") + member_auth = _register_and_auth(client, "member@example.com") + member_me = _get_me(client, member_auth) + + r = client.post("/households", json={"name": "Home Budget"}, headers=owner_auth) + assert r.status_code == 201 + created = r.get_json() + assert created["name"] == "Home Budget" + assert isinstance(created["invite_code"], str) and len(created["invite_code"]) >= 6 + household_id = created["id"] + + r = client.post( + "/households/join", + json={"invite_code": created["invite_code"]}, + headers=member_auth, + ) + assert r.status_code == 200 + assert r.get_json()["household_id"] == household_id + + r = client.get("/households/current", headers=member_auth) + assert r.status_code == 200 + current_household = r.get_json() + assert current_household["id"] == household_id + assert len(current_household["members"]) == 2 + + r = client.post("/households/leave", headers=member_auth) + assert r.status_code == 200 + + r = client.get("/households/current", headers=member_auth) + assert r.status_code == 404 + + r = client.post( + "/households/join", + json={"invite_code": created["invite_code"]}, + headers=member_auth, + ) + assert r.status_code == 200 + + r = client.delete( + f"/households/members/{member_me['id']}", + headers=owner_auth, + ) + assert r.status_code == 200 + + r = client.get("/households/current", headers=member_auth) + assert r.status_code == 404 + + +def test_household_shared_categories_expenses_and_bills(client): + owner_auth = _register_and_auth(client, "budget-owner@example.com") + member_auth = _register_and_auth(client, "budget-member@example.com") + outsider_auth = _register_and_auth(client, "budget-outsider@example.com") + + r = client.post( + "/households", json={"name": "Shared Household"}, headers=owner_auth + ) + assert r.status_code == 201 + household = r.get_json() + household_id = household["id"] + + r = client.post( + "/households/join", + json={"invite_code": household["invite_code"]}, + headers=member_auth, + ) + assert r.status_code == 200 + + r = client.post( + "/categories", + json={"name": "Shared Groceries", "household_id": household_id}, + headers=owner_auth, + ) + assert r.status_code == 201 + category_id = r.get_json()["id"] + + r = client.get("/categories", headers=member_auth) + assert r.status_code == 200 + names = {item["name"] for item in r.get_json()} + assert "Shared Groceries" in names + + r = client.post( + "/expenses", + json={ + "amount": 120.0, + "description": "Weekly groceries", + "date": "2026-03-01", + "category_id": category_id, + "household_id": household_id, + }, + headers=member_auth, + ) + assert r.status_code == 201 + + r = client.get("/expenses?search=Weekly%20groceries", headers=owner_auth) + assert r.status_code == 200 + assert len(r.get_json()) == 1 + + r = client.post( + "/bills", + json={ + "name": "Internet", + "amount": 59.99, + "next_due_date": date.today().isoformat(), + "cadence": "MONTHLY", + "household_id": household_id, + }, + headers=member_auth, + ) + assert r.status_code == 201 + + r = client.get("/bills", headers=owner_auth) + assert r.status_code == 200 + bill_names = {item["name"] for item in r.get_json()} + assert "Internet" in bill_names + + r = client.post( + "/expenses", + json={ + "amount": 50.0, + "description": "Unauthorized shared expense", + "date": "2026-03-02", + "household_id": household_id, + }, + headers=outsider_auth, + ) + assert r.status_code == 403