# BLUEPRINT: Easy Home Flow # Version: 2.1.0 # URL: https://easyhomeflow.com # Updated: 2026-08-30 ## IDENTITY name: Easy Home Flow description: Home and rental operations for tenants, landlords, and property managers — tenancies, applications, invites, and shared utility expenses. category: finance contact: https://easyhomeflow.com ## SUMMARY tagline: One app for tenants, landlords, and property managers to run a home. audience: Small landlords, tenants, and property managers. Beta preview — data may be reset. capabilities: - set-role: Switch the signed-in user between tenant, landlord, and manager - create-expense: Record a utility bill and split it across occupants by days occupied - mark-expense-split-paid: Mark one tenant's share of an expense paid or unpaid - create-invite: Invite a tenant to a tenancy, or claim a property as landlord - accept-invite: Accept an invite by token and receive the matching role - send-application: Email a rental application link for a specific unit - vote-feature: Vote on a roadmap feature without an account Properties, units, dashboards, and inspections are Firestore documents read and written directly by the web app. They are NOT exposed over this API. Do not invent REST routes for them — /routes/properties, /routes/units and /routes/inspections do not exist. There is no MCP server. Do not look for one. Four capabilities declare a ui method backed by stable data-agent-id attributes wired into the live interface: set-role, accept-invite, send-application, create-expense. Those selectors are a contract and will not be renamed in a refactor. Every other capability is api-only — it has no hooks, so do not guess Radix internals or CSS selectors for it. Every endpoint below is served at https://easyhomeflow.com — Firebase Hosting proxies /routes/** and /healthz to Cloud Run. Do not address the Cloud Run host directly. ## AUTH provider: firebase methods: oauth-google, anonymous Send the Firebase ID token as `Authorization: Bearer ` on every capability marked `auth-required: true`. The web app offers Sign in with Google or Continue as Guest (Firebase anonymous auth); both produce a usable ID token, but a guest token carries no role claim until set-role is called. Role lives in a Firebase custom claim, not in a request field. After set-role you MUST force-refresh the ID token or the new role is absent from the token you hold. Endpoints under /routes/auth/dev/ exist but return 403 outside development. Ignore them. ## ACCESS preferred: api last-resort: ui There is no mcp server. Prefer api whenever you can obtain a Firebase ID token. Four capabilities also declare a ui method: set-role, accept-invite, send-application, and create-expense. Those flows are wired with stable data-agent-id attributes. Every other capability is api-only — do not attempt to drive its UI, because no selectors are guaranteed for it. Note that the api path requires a Firebase ID token and this app issues no API keys, so an agent running inside the signed-in user's browser session will usually find ui the only method actually available to it. ## CAPABILITY: check-health description: Liveness check for the FastAPI service behind Firebase Hosting. input: [] output: - type: json retrieval: inline description: {"ok": true} when the API is up. auth-required: false scope: read-only ### API method: GET endpoint: /healthz response: ok: boolean ## CAPABILITY: set-role description: Set the signed-in user's role claim to tenant, landlord, or manager. input: - name: role type: string required: true description: One of tenant, landlord, manager. output: - type: json retrieval: inline description: Confirmation message and the role that was set. next-step: Force-refresh the Firebase ID token so the custom claim is present, then open /tenant, /landlord, or /property-manager. auth-required: true scope: account-modify permissions: - user: write ### API method: POST endpoint: /routes/auth/set-role body: role: <> response: message: string role: string ### UI steps: 1. NAVIGATE / 2. ASSERT-AUTH 3. CLICK [data-agent-id="role-select-<>"] 4. VERIFY the app navigated to /tenant, /landlord, or /property-manager The three role cards are role-select-tenant, role-select-landlord, and role-select-manager. Note the manager card sets role "manager" but navigates to /property-manager. The UI refreshes the token claim for you; the API does not. ## CAPABILITY: create-expense description: Record a utility bill for a property and split it across occupants for the billing period. input: - name: property_id type: string required: true - name: unit_id type: string required: false description: Only for multi-unit properties. - name: utility_type type: string required: true description: One of hydro, gas, water, internet, other. - name: billing_start type: string required: true description: ISO date string. - name: billing_end type: string required: true description: ISO date string. - name: total_amount type: number required: true - name: fixed_costs type: number required: false description: Hook-up fees and account charges. Defaults to 0. - name: fixed_cost_split type: string required: false description: landlord_only (default) or split_among_tenants. - name: split_method type: string required: false description: by_days (default), equal, or custom. - name: receipt_url type: string required: false - name: notes type: string required: false output: - type: json retrieval: inline description: ExpenseResponse including the computed splits array. next-step: Splits are computed server-side from tenancy occupancy. Read them back before telling a user what anyone owes. auth-required: true scope: financial-transaction permissions: - expenses: write ### API method: POST endpoint: /routes/expenses/create body: property_id: <> unit_id: <> utility_type: <> billing_start: <> billing_end: <> total_amount: <> fixed_costs: <> fixed_cost_split: <> split_method: <> receipt_url: <> notes: <> ### UI steps: 1. NAVIGATE /property-profile?id=<> 2. ASSERT-AUTH 3. CLICK [data-agent-id="expense-create-open"] 4. CLICK [data-agent-id="expense-utility-type-trigger"] 5. CLICK [data-agent-id="expense-utility-type-option"][data-agent-key="<>"] 6. INPUT [data-agent-id="expense-billing-start"] <> 7. INPUT [data-agent-id="expense-billing-end"] <> 8. INPUT [data-agent-id="expense-total-amount"] <> 9. INPUT [data-agent-id="expense-fixed-costs"] <> 10. CLICK [data-agent-id="expense-split-method-trigger"] 11. CLICK [data-agent-id="expense-split-method-option"][data-agent-key="<>"] 12. CLICK [data-agent-id="expense-create-submit"] 13. WAIT [data-agent-id="expense-create-submit"] (max: 30s) 14. COMPLETE the dialog closes and the new bill appears in the expenses list Both dropdowns are Radix listboxes, not native selects — open the trigger, then click the option. data-agent-key carries the exact wire value, so match it verbatim and do NOT normalize it: the split-method key is by_days with an underscore, not by-days. Date inputs are type="date" and expect YYYY-MM-DD. Steps 9 and 10-11 are optional; fixed costs defaults to 0 and split method defaults to by_days. The fixed-cost-split control only renders once fixed costs is greater than 0 and has no hook — leave it at its default (landlord_only) or use the API if you need to change it. The dialog validates before submitting: total amount must be positive, fixed costs must not exceed it, and billing end must be after billing start. A failure shows a toast and the dialog stays open. ## CAPABILITY: list-property-expenses description: List every recorded expense for one property. input: - name: property_id type: string required: true description: Path parameter. output: - type: json retrieval: inline description: Array of ExpenseResponse objects. auth-required: true scope: read-only permissions: - expenses: read ### API method: GET endpoint: /routes/expenses/property/{property_id} ## CAPABILITY: get-expense description: Fetch one expense and its per-tenant splits. input: - name: expense_id type: string required: true description: Path parameter. output: - type: json retrieval: inline description: ExpenseResponse with splits, each carrying tenant_name, days_occupied, percentage, amount_owed, paid. auth-required: true scope: read-only permissions: - expenses: read ### API method: GET endpoint: /routes/expenses/{expense_id} ## CAPABILITY: mark-expense-split-paid description: Mark one tenant's share of an expense as paid or unpaid. input: - name: expense_id type: string required: true description: Path parameter. - name: tenant_id type: string required: true description: Path parameter. - name: paid type: boolean required: true - name: paid_date type: string required: false description: ISO date string. output: - type: json retrieval: inline description: Updated ExpenseResponse. auth-required: true scope: financial-transaction constrained-by: - get-expense permissions: - expenses: write ### API method: PATCH endpoint: /routes/expenses/{expense_id}/payment/{tenant_id} body: paid: <> paid_date: <> ## CAPABILITY: delete-expense description: Permanently delete an expense and its splits. input: - name: expense_id type: string required: true description: Path parameter. output: - type: confirmation retrieval: inline description: Deletion confirmation. auth-required: true scope: destructive constrained-by: - get-expense permissions: - expenses: delete ### API method: DELETE endpoint: /routes/expenses/{expense_id} ## CAPABILITY: get-tenancy description: Read one tenancy including rent, deposit, lease dates, and activation gates. input: - name: tenancy_id type: string required: true description: Path parameter. output: - type: json retrieval: inline description: TenancyResponse with status (pending, provisional, active, ended), deposit_received, lease_signed, utility_split_percentage. auth-required: true scope: read-only permissions: - tenancies: read ### API method: GET endpoint: /routes/tenancies/{tenancy_id} ## CAPABILITY: update-tenancy description: Update a tenancy's activation gates or utility split. Landlord or manager only. input: - name: tenancy_id type: string required: true description: Path parameter. - name: deposit_received type: boolean required: false - name: lease_signed type: boolean required: false - name: status type: string required: false description: pending, provisional, active, or ended. - name: utility_split_percentage type: number required: false description: Percentage of utility costs, 0-100. output: - type: json retrieval: inline description: Updated TenancyResponse. next-step: Changes are written to the tenancy audit log. Read get-tenancy-audit to confirm what changed. auth-required: true scope: edit constrained-by: - get-tenancy permissions: - tenancies: write ### API method: PATCH endpoint: /routes/tenancies/{tenancy_id} body: deposit_received: <> lease_signed: <> status: <> utility_split_percentage: <> ## CAPABILITY: get-tenancy-audit description: Read the audit trail of changes made to a tenancy. input: - name: tenancy_id type: string required: true description: Path parameter. output: - type: json retrieval: inline description: Array of audit entries. auth-required: true scope: read-only permissions: - tenancies: read ### API method: GET endpoint: /routes/tenancies/{tenancy_id}/audit ## CAPABILITY: get-invite description: Look up an invitation by token before signing in. Public so a recipient without an account can see what they were sent. input: - name: token type: string required: true description: Path parameter. The invite token from the emailed link. output: - type: json retrieval: inline description: type, status, expires_at, email, metadata. Safe public fields only. next-step: Sign in (Google or guest), then call accept-invite with the same token. auth-required: false scope: read-only permissions: - invites: read ### API method: GET endpoint: /routes/tenancy-invites/{token} response: type: string status: string expires_at: string email: string metadata: object ## CAPABILITY: create-invite description: Create an invitation link for a tenancy or property. Caller must own the tenancy being invited to. input: - name: type type: string required: true description: tenant_join, landlord_claim, or subtenant_accept. - name: tenancy_id type: string required: false description: Required when type is tenant_join. - name: property_id type: string required: false - name: email type: string required: false output: - type: json retrieval: inline description: Invite record including the token to send. auth-required: true scope: account-modify permissions: - invites: write ### API method: POST endpoint: /routes/tenancy-invites/create body: type: <> tenancy_id: <> property_id: <> email: <> ## CAPABILITY: accept-invite description: Accept an invitation by token. Assigns the matching role to the signed-in user. input: - name: token type: string required: true output: - type: json retrieval: inline description: Confirmation message and redirect_to path. next-step: Force-refresh the Firebase ID token, then navigate to redirect_to. An invite can only be accepted once; a second attempt returns 400. auth-required: true scope: account-modify constrained-by: - get-invite permissions: - invites: write - user: write ### API method: POST endpoint: /routes/tenancy-invites/accept body: token: <> response: message: string redirect_to: string ### UI steps: 1. NAVIGATE /join-tenancy?token=<> 2. ASSERT-AUTH 3. VERIFY the invite details panel shows an expiry in the future 4. CLICK [data-agent-id="invite-accept-submit"] 5. WAIT [data-agent-id="invite-accept-submit"] (max: 20s) 6. COMPLETE the app redirects to the tenancy or property page for the accepted role If the token is expired or already accepted, the page renders an expired/error card and no accept button is present. Do not retry — the token is single-use. ## CAPABILITY: send-application description: Email a rental application link for a specific unit. Landlords and property managers only. input: - name: property_id type: string required: true - name: unit_id type: string required: true - name: tenant_email type: string required: true description: Must be a valid email address. - name: property_name type: string required: true - name: unit_name type: string required: true output: - type: json retrieval: inline description: success, token, and a message. next-step: The emailed link expires 7 days after sending and is single-use. auth-required: true scope: form-submit permissions: - applications: write ### API method: POST endpoint: /routes/send-application body: property_id: <> unit_id: <> tenant_email: <> property_name: <> unit_name: <> response: success: boolean token: string message: string ### UI steps: 1. NAVIGATE /property-profile?id=<> 2. ASSERT-AUTH 3. CLICK [data-agent-id="send-application-open"] 4. INPUT [data-agent-id="send-application-tenant-email"] <> 5. CLICK [data-agent-id="send-application-unit-trigger"] 6. CLICK [data-agent-id="send-application-unit-option"][data-agent-key="<>"] 7. CLICK [data-agent-id="send-application-submit"] 8. VERIFY a success toast naming the tenant email appears The unit list is a Radix listbox, not a native select — open the trigger first, then click the option. Options are keyed by unit id via data-agent-key. property_name and unit_name are filled in by the UI from the selected property and unit, so unlike the API you do not supply them. ## CAPABILITY: create-applicant-user description: Create or retrieve a passwordless Firebase account for an applicant so their application has a stable UID. input: - name: email type: string required: true - name: full_name type: string required: false output: - type: json retrieval: inline description: firebase_uid, email, is_new_user. next-step: The applicant activates the account later via a password reset link. Returns the existing UID if the email is already registered. auth-required: true scope: account-modify permissions: - user: write ### API method: POST endpoint: /routes/applications/create-applicant-user body: email: <> full_name: <> response: firebase_uid: string email: string is_new_user: boolean ## CAPABILITY: vote-feature description: Vote for a roadmap feature. No account required. input: - name: feature type: string required: true description: Feature identifier. output: - type: json retrieval: inline description: status and message. auth-required: false scope: form-submit permissions: - feedback: write ### API method: POST endpoint: /routes/feedback/vote body: feature: <> ## CAPABILITY: submit-suggestion description: Send a free-text product suggestion. No account required. input: - name: suggestion type: string required: true - name: email type: string required: false description: Optional reply address. output: - type: json retrieval: inline description: status and message. auth-required: false scope: form-submit permissions: - feedback: write ### API method: POST endpoint: /routes/feedback/suggestion body: suggestion: <> email: <> ## CAPABILITY: feedback-summary description: Read aggregated roadmap vote counts and submitted suggestions. input: [] output: - type: json retrieval: inline description: votes as a feature-to-count map, plus a suggestions array sorted newest first. auth-required: true scope: read-only permissions: - feedback: read ### API method: GET endpoint: /routes/feedback/summary response: votes: object suggestions: array