Project · Technical Deep Dive

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.

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.

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.

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:

  1. Try patterns first. Compare the incoming description against learned patterns by similarity. If similarity ≥ 0.85 and occurrence_count ≥ 3, auto-classify with the pattern. No LLM call.
  2. Fall back to the LLM only on cold descriptions. Groq Llama-3.3-70B (cheap, fast, "good enough" for short classification prompts).
  3. 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.

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.

teller-api Live

Bank sync via Teller. Handles per-user enrollment, account listing, transaction pulls, and the TLS-cert handshake required for Teller's mTLS authentication.

classify-transaction Live

The pattern + LLM classifier. Fetches patterns in parallel with feedback data, scores similarity in TypeScript, only calls Groq when patterns can't decide.

email-to-ticket Live

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.

property-data Live

External property-data enrichment (address normalization, estimated value, characteristics). Used when adding a new property to pre-fill the form.

admin-create-user Live

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.

admin-delete-user Live

Same shape as create. Cascades delete across owned data using FK constraints defined in the migrations — no orphan rows.

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.

Technology

LayerTechnologies
FrontendReact 18, Vite, TypeScript, Tailwind, shadcn/ui, TanStack Query, React Router
UI / UXRadix primitives, framer-motion, react-hook-form + zod, lucide icons, jsPDF
MapsMapbox GL — property pins, neighborhood overlays
ChartsRecharts — cash flow, occupancy, portfolio KPIs
BackendSupabase: PostgreSQL, Auth (JWT), Row-Level Security, Storage
Edge FunctionsDeno + TypeScript · dynamic CORS · safe error mapping
AIGroq Llama-3.3-70B (transaction classification fallback)
Bank syncTeller API (mTLS, per-account enrollment)
Email intakeResend inbound webhooks · Svix signature verification
HostingDigitalOcean $5 droplet · nginx · Let's Encrypt · static build

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.