Compre Barato Alagoas
Price comparator for supermarkets, pharmacies, and stores in Alagoas, built on public electronic invoice (NFC-e) data from SEFAZ-AL.
Overview
The user types (or speaks) a shopping list in natural language — e.g. "5kg de arroz, 1L de leite, sabão em pó". The app looks up prices near them, computes a fair unit price (per kg, per liter, per unit), and returns stores ordered from cheapest to most expensive, showing how much you save and the date of each sale.
- Platforms: Flutter — one codebase for Android and web.
- Backend: FastAPI (Python) — secure intermediary, normalization, and ranking.
- Data: public Economiza Alagoas API (SEFAZ-AL).
- State/cache: Redis (required).
- AI: Claude Haiku interprets the list in natural language.
The problem
SEFAZ-AL is currently the only state revenue department in Brazil to publish NFC-e prices publicly and for free. The value is in the data; the obstacle is access. The project's goal is to deliver the same information in a simple, fast, and accessible way for people with low income and limited technical familiarity, in an interface designed for that (large taps, little text).
Architecture
User (Flutter — Android / web)
│ HTTPS, only to our API
▼
Backend (FastAPI) ───► Public Economiza Alagoas API (SEFAZ-AL)
│ (access token lives ONLY on the server)
├── interpret the list ............. LLM (Claude Haiku)
├── normalize size/unit ............ fair price per kg / L / unit
├── rank stores .................... by basket coverage + total
└── cache + state .................. Redis (required)
The backend is a secure intermediary: it holds the SEFAZ
token. The user app never talks to SEFAZ directly and
never receives the token. Every app call goes to
/api/v1/* on our backend.
| Folder | Contents |
|---|---|
backend/ | FastAPI API: intermediary, normalization, ranking, cache, device identity, metrics. |
frontend/ | Flutter app (Android + web): Riverpod, OpenStreetMap, voice input. |
admin-frontend/ | Static admin panel (HTML + JS), served at admin.<domain>. |
docs/ | This documentation (static), served at docs.<domain>. |
deploy/ | docker-compose + nginx vhosts. |
Search flow
Endpoint: POST /api/v1/search · Orchestration:
backend/app/services/search_service.py.
- List interpretation (LLM): free text becomes structured items (label + search term + quantity). Compound lines ("arroz e feijão") are split; quantities and sizes are stripped from the search term.
- Per-item search: one SEFAZ call per item (the API
accepts only one criterion per request), using the term as
descricaoplus geolocation + radius. - Normalization: each returned row becomes an "offer" with price per base unit (kg / L / unit) — see below.
- Ranking: offers are grouped by store and stores are ordered by basket coverage and total price.
- Cache + list: each item result is cached in Redis and the list gets a UUID for sharing.
(term, origin, radius, days). Repeated searches are served
from cache without a new SEFAZ query.Fair price (normalization)
Code: backend/app/services/normalization/
(quantity.py, units.py, matcher.py).
This is the central component of the project.
Unit-price decision tree
- If SEFAZ
unidadeMedidais already weight/volume (KG, G, L, ML…), the product is sold bulk andvalorVendais already the price for that unit — we only convert to the canonical base. - Otherwise the price is for a package: we extract size from the
description text (
extract_quantity) and divide. - If size cannot be determined, we fall back to package price and
set
quantity_parsed = false— the signal that comparison quality dropped on that line.
Extraction examples
| Description (free text) | Extracted size |
|---|---|
| ARROZ BRANCO TIPO 1 PCT 5KG | 5 kg |
| LEITE NA CAIXA 1L | 1 L |
| CAFE A VACUO 250G | 0.25 kg |
| CERVEJA LATA 350ML C/12 | 12 × 350 ml (multipack) |
| OVOS BRANCOS C/12 | 12 units |
Each offer also carries an extraction method (unidade_medida /
description / fallback) and a 0–1 confidence used in
panel quality metrics.
Basket ranking
Code: backend/app/services/ranking.py.
For each (store, item) we keep the best-value offer (lowest price per base unit). Stores are ordered by:
- Coverage — more of your list items available first (a cheap store missing half the list does not really help);
- Cheapest basket — sum of package prices;
- Closest — Haversine distance from your origin.
items_found, items_total, and a
missing list of items it does not have. The UI shows the cheapest
store expanded ("CHEAPEST") and the others collapsed with "+R$ delta".Data honesty
NFC-e prices reflect recent sales, not necessarily today's. The app is explicit about that:
- Date per item: each price shows when that sale was recorded
(
sale_date, fromdataVenda) — never a vague global "last N days" (prices change every day). - Results-screen notice: "Each price shows the date it was recorded."
- Comparison quality: when size could not be extracted, the offer is marked as package comparison (not per kg/L).
Cache and usage limits
Redis is required (no in-memory fallback). On startup the app
pings Redis and fails fast if it is unavailable
(backend/app/cache.py, main.lifespan). Redis holds:
- per-item search cache (default TTL 6 h);
- shareable list UUIDs (30-day TTL, renewed on each access);
- device records (LGPD consent, saved lists);
- usage-limit counters and panel metrics.
Daily limit: enforce_rate_limit (backend/app/api/deps.py)
counts searches per client per day (key ratelimit:{day}:{client},
24 h TTL) and returns 429 when exceeded. Configurable via
DAILY_SEARCH_LIMIT (0 disables).
Device identity & LGPD (no login)
There are no accounts. The app once generates a 256-bit token
(frontend/lib/data/device_identity.dart, stored in secure storage
/ Keystore) and sends it in the X-Device-Token header. It is treated
as a credential — never logged and never stored in cleartext.
| Endpoint | Purpose |
|---|---|
POST /api/v1/device/consent | Records LGPD consent (legal basis for saving data in the cloud). |
GET /api/v1/device/me | Shows what the server stores for that device. |
DELETE /api/v1/device/me | Erasure (LGPD): removes everything the server has for the device. |
A search from a device with consent has its list saved automatically
on the server (history without login). The consent trigger is the
"Save lists to the cloud" button (CloudSyncSheet). The policy lives in
the app's PolicyScreen and in frontend/web/privacy.html.
Anonymous usage measurement (unique-device counts for growth metrics) uses an identifier separate from this token, with a legitimate-interest legal basis and opt-out — detailed in the legitimate interest assessment (LIA).
Shared lists
Links use the format …/abrir/<uuid>: the basket is stored on the server,
not in the URL (links stay short for any list size). The /abrir path
prefix is kept for existing Android App Links.
POST /searchreturnslist_id; identical baskets reuse the same id (hash deduplication).GET /api/v1/lists/{id}resolves the uuid back to items;404(expired/unknown) sends the user to the home screen.- Whoever opens the link searches from their own location.
- On Android, App Links open the installed app directly (intent-filter with
pathPrefix /abrir+assetlinks.json).
User feedback
POST /api/v1/feedback (anonymous; optional X-Device-Token).
The results screen has a 👍/👎 card and "report wrong item". It is best-effort and
never breaks the main flow. Events feed the panel's feedback section.
Admin panel
Static SPA in admin-frontend/, served at
admin.<domain>, protected by ADMIN_TOKEN (bearer,
constant-time comparison, fail-closed when unset → 401).
Data comes from backend/app/analytics.py, Redis-native (no
Postgres).
"AI & Product" section
- Overview: total searches, estimated unique users (HyperLogLog), match rate, LLM cost.
- Quality: size-extraction rate, parse-method distribution.
- Costs: LLM cost per search (
services/llm/pricing.py; Haiku 4.5 at US$1/US$5 per 1M tokens). Marked "mock mode" until the real key is set. - Feedback, searches per hour, top searched and not-found items.
"Technical" section
- Performance: latency histograms per stage (total, llm, sefaz, cache, normalize, rank) with p50/p95.
- Providers: calls, errors, and latency for third parties (
sefaz,llm). - Settings: secrets vault — set/rotate the SEFAZ token, encrypted at rest (Fernet) and never shown in files, logs, or on screen. See Security & data.
All metric writes are best-effort and never block a search.
API (endpoints)
| Method | Route | Description |
|---|---|---|
| GET | /health | Status + active data sources. |
| POST | /api/v1/search | Basket search (body: items, latitude, longitude, radius_km, days). |
| GET | /api/v1/suggestions | Common item suggestions. |
| GET | /api/v1/lists/{id} | Resolve a shared list. |
| POST | /api/v1/device/consent | LGPD consent. |
| GET/DELETE | /api/v1/device/me | View / delete device data. |
| POST | /api/v1/feedback | Anonymous feedback. |
| — | /admin/api/* | Panel metrics (requires ADMIN_TOKEN). |
Limits imposed by SEFAZ and enforced by the schema: radius 1–15 km, days 1–10,
up to 30 items per search. Interactive docs at /docs (Swagger) in non-production.
Mock mode vs. production
Both external services (SEFAZ and LLM) sit behind a Protocol + factory.
Switching between simulated and real is only flipping flags in the root .env —
no code changes.
| Flag | true (default today) | false (production) |
|---|---|---|
USE_MOCK_SEFAZ | synthetic Maceió catalog (data/mock_sefaz.json) | real SEFAZ-AL API (requires SEFAZ_APP_TOKEN) |
USE_MOCK_LLM | deterministic rule-based parser | Claude Haiku (requires ANTHROPIC_API_KEY) |
services/sefaz/http_client.py)
is the only place the token is attached, and it builds the
produto/pesquisa body exactly as the developer manual specifies.Stack & dependencies
- Backend: FastAPI, Pydantic, httpx, redis. Tests with fakeredis (no
server).
73 testsin the backend,21in the frontend. - Frontend: Flutter + Riverpod, flutter_map (OpenStreetMap), speech_to_text (voice), geolocator, flutter_secure_storage.
- AI: Claude Haiku 4.5 to interpret the list.
- Observability: Sentry (enabled via DSN; no-op without it).
- Deploy: docker-compose behind nginx + certbot. App, panel, and docs on their own subdomains.
Known limitations
Transparency is a project value. What is still not ready or is simplified today:
| Item | Status |
|---|---|
| Cross-store product matching (GTIN / LLM adjudication) | not done Today we rely on SEFAZ keyword search and group by store; there is no GTIN canonicalization of the "same product" nor LLM disambiguation. Ambiguous terms (e.g. "leite" bringing liquid and powdered milk) can mix variants. |
| Real SEFAZ data | awaiting token Runs in mock mode until the free SEFAZ token is configured. |
| SEFAZ calls per item | sequential A long uncached list makes N serial calls (candidate for parallelization). |
| Promotion notifications (push) | not done Require FCM/Web-Push + a price-monitoring pipeline + live SEFAZ. Device identity is only the foundation. |
| iOS | not configured Flutter base supports it, but only Android and web are configured. |
| Postgres / pgvector and Langfuse | unused Declared for the future (embeddings, LLM tracing), but the app today is Redis-native and does not use them. |
| Match accuracy evaluation | partial There is a quality metric for size extraction; there is not yet a labeled eval of term→product match precision. |
Project status
- Live: web app + API and admin panel (in mock mode).
- To go to production with real data: set
SEFAZ_APP_TOKENandANTHROPIC_API_KEYin.envand flipUSE_MOCK_*=false. - License: MIT.