Maschitto
Full-stack property management platform for landlords running a small rental portfolio: properties, tenants, leases with PDF generation, bank sync, an AI transaction classifier that learns patterns instead of re-calling the LLM, maintenance tickets created from inbound email, and portfolio analytics. Live at maschitto.ar.
The Use Case
Why I built it
Managing a small rental portfolio sits in an awkward middle: spreadsheets are fine for two properties, the enterprise PMS products (Buildium, AppFolio) are built for 100+ units and price accordingly, and the cheap consumer apps are half-baked. I wanted something that handled the boring parts well — tenants, leases, bank-synced finances, maintenance tickets — without monthly per-unit fees, and that could plug into the parts of my workflow that the off-the-shelf options can't (LLC accounting, custom tax summaries, email-driven ticket flow).
Built end-to-end as a side project. Production on a single $5 DigitalOcean VPS serving the static frontend, with Supabase doing the heavy lifting on the backend.
Architecture
How it's wired
┌──────────────────────────────────────────────────────────────────┐
│ Browser (React 18 + Vite + TS) │
│ shadcn/ui · Tailwind · TanStack Query · Mapbox │
└──────────────────────────┬───────────────────────────────────────┘
│ HTTPS / Supabase JS SDK
▼
┌──────────────────────────────────────────────────────────────────┐
│ Supabase │
│ ┌────────────┐ ┌────────────────┐ ┌─────────────────────┐ │
│ │ Postgres │ │ Auth (RLS) │ │ Edge Functions │ │
│ │ + storage │ │ per-user JWT │ │ (Deno · TypeScript) │ │
│ └────────────┘ └────────────────┘ └──────────┬──────────┘ │
└──────────────────────────────────────────────────────│───────────┘
│
┌─────────────────────┬─────────────────────┬─┴──────────────┐
▼ ▼ ▼ ▼
┌─────────┐ ┌───────────┐ ┌──────────┐ ┌─────────────┐
│ Teller │ │ Groq │ │ Resend │ │ Property │
│ banks │ │ Llama-3.3 │ │ (email │ │ data API │
│ (TLS) │ │ 70B │ │ inbound)│ │ │
└─────────┘ └───────────┘ └──────────┘ └─────────────┘
bank sync & AI transaction maintenance property
transactions classification ticket intake enrichment
The frontend is a static React build served by nginx on a single VPS. All state and business logic live in Supabase: Postgres + Row-Level Security (per-user isolation enforced in the database, not the app), and six Deno-based edge functions handling everything that needs server-side secrets or third-party calls.
The Interesting Bit
AI classification that doesn't re-call the LLM
The naive way to AI-classify bank transactions is to call the LLM once per transaction: read the description and amount, return a category and a property assignment. That's fine for a demo and miserable in production — every import burns API tokens on transactions the model has classified a hundred times before ("Home Depot #2384 · -$47.12").
Maschitto's classifier keeps a transaction_classification_patterns
table. Every time a user accepts (or corrects) a classification, the pattern
and target get persisted with an occurrence_count. The classifier
flow is:
- Try patterns first. Compare the incoming description against learned patterns by similarity. If
similarity ≥ 0.85andoccurrence_count ≥ 3, auto-classify with the pattern. No LLM call. - Fall back to the LLM only on cold descriptions. Groq Llama-3.3-70B (cheap, fast, "good enough" for short classification prompts).
- Persist the outcome back as a pattern with
occurrence_count = 1, so the next sighting will graduate toward auto-classification.
Net effect: a steady-state import of 50-100 transactions hits the LLM on maybe
5-10 of them. The rest are pattern-matched in Postgres. The two thresholds
(0.85 similarity, 3 occurrences) keep early mistakes
from being committed as ground truth — a one-off mis-classification doesn't
start auto-applying until three users (or the same user three times) have
confirmed it.
Edge Functions
Backend on Deno
Six edge functions, all Deno + TypeScript, deployed via Supabase. Every one follows the same shape: a dynamic CORS allowlist (including Lovable preview subdomains for the dev environment), a safe-error mapper that never leaks internal messages, and strict input validation at the boundary.
Bank sync via Teller. Handles per-user enrollment, account listing, transaction pulls, and the TLS-cert handshake required for Teller's mTLS authentication.
The pattern + LLM classifier. Fetches patterns in parallel with feedback data, scores similarity in TypeScript, only calls Groq when patterns can't decide.
Resend inbound-email webhook. Verifies Svix signature (with a 5-minute timestamp tolerance), parses the email, and creates a maintenance ticket against the right property.
External property-data enrichment (address normalization, estimated value, characteristics). Used when adding a new property to pre-fill the form.
Admin-only path to invite new users. Server-side role check, then a privileged Supabase admin client creates the auth user + seeds the profile row.
Same shape as create. Cascades delete across owned data using FK constraints defined in the migrations — no orphan rows.
Security Posture
Defaults that aren't defaults
Row-Level Security on every table
Per-user isolation is enforced inside Postgres, not by the app layer. A compromised access token can only see its own rows. Tested with a smoke suite that tries to read another user's data and expects a clean empty result, not a 403.
Svix-verified webhook intake
The email-to-ticket webhook verifies Svix signatures with a strict 5-minute timestamp tolerance — replays older than that are rejected before parsing the payload, eliminating a whole class of replay attacks against a public endpoint.
Dynamic, allowlist-only CORS
No *. Production domains + localhost dev ports + Lovable
preview subdomains, matched per request. Anything else gets the first
allowed origin (a default that won't accidentally enable a third-party
site).
Safe error mapping
Every edge function classifies internal errors into user-safe messages ("AI service temporarily unavailable", "Request timed out") before returning them. Postgres errors, stack traces, and provider error bodies never reach the client.
Stack
Technology
| Layer | Technologies |
|---|---|
| Frontend | React 18, Vite, TypeScript, Tailwind, shadcn/ui, TanStack Query, React Router |
| UI / UX | Radix primitives, framer-motion, react-hook-form + zod, lucide icons, jsPDF |
| Maps | Mapbox GL — property pins, neighborhood overlays |
| Charts | Recharts — cash flow, occupancy, portfolio KPIs |
| Backend | Supabase: PostgreSQL, Auth (JWT), Row-Level Security, Storage |
| Edge Functions | Deno + TypeScript · dynamic CORS · safe error mapping |
| AI | Groq Llama-3.3-70B (transaction classification fallback) |
| Bank sync | Teller API (mTLS, per-account enrollment) |
| Email intake | Resend inbound webhooks · Svix signature verification |
| Hosting | DigitalOcean $5 droplet · nginx · Let's Encrypt · static build |
Engineering Notes
Decisions worth calling out
- Patterns over re-calling the LLM. The classifier is the single biggest cost lever in the app. A pattern table + similarity threshold + occurrence floor turns transaction classification from a per-token cost into a per-novel-merchant cost. Steady-state import keeps the LLM idle.
- Two-knob graduation. Auto-apply gates on both high similarity (
≥0.85) and repeated confirmation (≥3). High-similarity alone would let one wrong click become canon; repeat-without-similarity would over-trigger on noisy descriptions. Both knobs together keep the false-positive rate low. - RLS instead of app-level scoping. Every multi-tenant query in the app is implicitly scoped because Postgres enforces it. The frontend can't accidentally forget a
where user_id = ?clause — there isn't one to forget. - Replay window on the inbound webhook. A 5-minute Svix timestamp tolerance is much tighter than the default "verify signature, accept whenever". For a public endpoint that creates database rows, the cost of being strict is much lower than the cost of being permissive.
- Same shape across all edge functions. CORS, validation, error mapping, and admin checks are copy-paste consistent. New functions inherit the security posture by following the template — no per-function decisions to forget.
- $5/month all-in (frontend). Static build on a single droplet. Supabase free tier covers the backend at this scale. Cost matters when the project pays for itself in time saved, not in revenue.