Associado
A referral's commission depends on a handshake off-platform, and one side profits from denying it happened.
- fastapi
- postgresql
- pgvector
- claude
- sqlalchemy
The problem and constraints
Associado exists to formalize something small business associations already do informally and inconsistently: a member asks around for a plumber, a bookkeeper, a photographer, someone in the group vouches for a specific other member, the two connect, a deal happens, and, in theory, the association was owed a small commission for having made the introduction in the first place. In practice, that last part almost never happens, because nobody is tracking who referred whom, whether it led anywhere, or what it was worth.
The first constraint is that the one fact the entire business model depends on (did the deal actually close, and for how much) happens entirely outside the software, in a conversation and a handshake between two people. The platform can ask about it, but it cannot observe it directly, and it’s asking two people who don’t have symmetric incentives to answer honestly: the person who’d have to pay a commission has every reason to say less than the truth, while the person who wouldn’t pay anything has no particular reason to correct them.
The second constraint is the shape of the request itself. Members don’t type category names. They type “who fixes a leak,” and a system that only understands an exact catalog term like “plumber” will silently fail the moment someone’s vocabulary doesn’t match what got typed into the category list. But a system that answers purely on vague semantic similarity produces a result nobody can audit, and the single most predictable complaint this platform will ever receive is a provider asking why they, specifically, weren’t the one referred. Whatever resolves a request has to be both flexible enough to understand real language and precise enough to explain itself in one sentence.
The third constraint is money handling in a system whose core mechanism is an AI agent reading unstructured WhatsApp messages. Text from a member is exactly the kind of input a language model can be maneuvered by, deliberately or not, and the one part of this system that absolutely cannot be maneuvered is what gets charged to whom. Whatever architecture got built had to make it structurally impossible for anything the model touches to directly decide a commission, a guarantee no policy alone could give.
The fourth constraint was avoiding a specific, already-lived mistake. A sibling platform (a WhatsApp CRM already in production) solves a similarly-shaped problem with a visual workflow tool orchestrating dozens of integration steps, and that tool has a real, ongoing cost: credentials that don’t transfer between environments, execution order that depends on where a box sits on a canvas rather than anything committed to version control, and a saved workflow file that drifts from what’s actually running. This project’s expected volume is a few hundred requests a month for one association, well short of the multi-tenant SaaS scale that justified that other system’s complexity. Carrying the same architecture over here would mean paying operational costs this project’s actual size doesn’t call for.
The last constraint was time itself. A referral that never expires turns into a commission owed on a business relationship the association stopped actually being part of the moment introductions turned into an ongoing arrangement between two people. A plumber introduced once who ends up doing a customer’s monthly maintenance for years is a real outcome this platform needs to not misread as thirty-six separate commission-worthy events.
Architecture
A member sends a WhatsApp message describing what they need. An AI agent (Claude, using tool use rather than free-form generation) reads it and tries to resolve it to a category in the service catalog, first by exact and trigram matching against known terms and synonyms, then, only if that doesn’t land with confidence, by semantic vector search over category embeddings. If neither path resolves cleanly, the agent asks a clarifying question instead of guessing: an unresolved request is logged as a gap in the catalog instead of silently dropped, which is what eventually tells the association where a synonym or a whole category is missing.
Once a category resolves, the same agent queries for providers offering that service (filtered, structurally, to only those who’ve turned on referral consent) and returns a short list to the member, notifying each matched provider that they were referred. Everything up to this point is the agent’s job: understanding language, matching intent to a catalog entry, formatting a helpful answer. None of it touches money.
Money lives entirely in a separate, deterministic path the agent has no access to. Days after a referral goes out, a scheduling worker (running as part of the same process, on a plain database table rather than a task queue) sends a follow-up to both the requester and the provider, independently, asking whether the deal closed and for how much. Only when both answers describe the same deal, within a small tolerance for rounding, does the backend generate a commission automatically. A disagreement in value, one-sided silence, or any ambiguity doesn’t get resolved by more automation: it becomes a queued item in the admin panel for a person to look at, on the theory that a wrongfully generated charge costs the association more trust than a missed one costs it money.
That commission, once generated, is bounded in time on both ends. A request that goes 30 days without a confirmed close simply expires. There’s no such thing as a referral sitting open forever. And if the same requester and provider close another deal in the same category within 90 days of the last one, the system still logs the new referral and still notifies everyone, but records the resulting deal as exempt rather than commission-generating, visibly, as a zero-value, explicitly-exempt record rather than an entry that quietly doesn’t exist. Both windows are configuration values rather than constants baked into logic, and both are explicitly first-guess numbers the design expects to revisit once real conversion data exists to revisit them with.
Architecture
Decisions
DECISION 01/04 · Matching a free-text request
The first complaint this system will predictably get is a provider asking why they weren't referred; a category-first match keeps every referral explainable in one sentence, and gives commission rules a stable key to attach to. Letting the model choose providers directly would put the entire member list in its context and make the result unauditable
Cost acceptedTwo code paths exist for answering the same question, with confidence thresholds that start as an informed guess rather than a measured result, and an external embeddings provider enters the stack purely to cover the vocabulary gap taxonomy alone can't
DECISION 02/04 · Confirming a closed deal
The side that pays the commission is also the only side with an incentive to underreport or deny the deal happened: a single-sided report turns commission calculation into self-declaration by the party least motivated to be accurate about it
Cost acceptedDoubles the messages sent per referral, which raises both perceived annoyance and the account risk with the WhatsApp provider, and unresolved silence, the most common real-world outcome, becomes recurring human work instead of an automated close
DECISION 03/04 · How long a referral counts
Without a window, a referral from January would in theory owe commission on a deal closed in November, and a recurring relationship (an accountant hired every month) would owe commission every single month from one original introduction, both are commercial relationships the association stopped actually intermediating
Cost acceptedThe association loses commission on legitimately slow-closing deals (a large renovation, a long quote-and-decide cycle), and the anti-duplication rule can be gamed by simply waiting out 90 days between hires, both accepted because the alternative, charging forever, causes far more friction with members than the revenue it would protect
DECISION 04/04 · Single service
That sibling platform works, but its operational cost concentrates in specific, recurring places: a 99-node visual workflow whose credentials don't transfer between environments and whose real execution order depends on node position on a canvas rather than anything version-controlled. At this project's expected volume (hundreds of requests a month, business logic that's mostly deterministic rules), that cost has no matching benefit
Cost acceptedThere's no visual editor to tweak the flow without a deploy: any behavior change is a commit and a rebuild. The scheduling worker runs in the same process as the API, so an HTTP traffic spike can delay a follow-up message; separating it is the first change to make if volume grows enough to matter
Invariants
A member without active referral consent never appears in a match result, under any circumstance
Guaranteed by
the query that resolves a category to candidate providers filters on consentimento_indicacao = true before anything else runsThe AI agent can never write to the commissions table or change a deal's status
Guaranteed by
no tool exposed to the model has write access to those tables; every write to money or deal status happens exclusively in deterministic backend code the model cannot reachA monetary value is never represented as a float
Guaranteed by
every amount is a BIGINT in cents, BRL only, a schema-level constraint rather than a code convention someone has to rememberThe same inbound WhatsApp message is never processed twice, even if the provider delivers it more than once
Guaranteed by
every inbound message is stored keyed by the provider's own message ID under a UNIQUE index; a conflict on insert is treated as a duplicate and discardedA category's stable identifier never changes once created
Guaranteed by
categorias_servico.slug is immutable by convention and by the fact that commission history and reporting key off it; only the display label is editable
Risk identified
Risk identified · mitigated at design time
This project has no real incident yet because it never ran in production. Here is a risk the design already accounts for.
- Symptom
- If commission were generated from either side's word alone, the side that pays it has a direct incentive to underreport the deal's value or deny it closed at all, turning the commission into a self-declaration made by the party with the least reason to be honest about it
- Root cause
- The fact that a deal happened lives entirely outside the system, in a WhatsApp conversation and a handshake between two people; the platform's only signal is whatever either side chooses to type back, days later, when asked
- Fix
- Ask both sides independently, and generate a commission automatically only when the requester's and the provider's answers agree on the same deal, within a 20% value tolerance, so ordinary rounding doesn't turn into manual work. Anything short of that, including silence from either side, becomes a pending case queued for a human to resolve
- Prevention
- The design commits to running conservative by default: a missed commission costs the association less than a wrongful charge would, and every disagreement is treated as a data point to review rather than a threshold to quietly tune away until the complaints stop
Results
Full stack
Backend
- FastAPI (Python 3.12)
- SQLAlchemy 2 + Alembic
- Jinja2 + HTMX (admin panel)
Data
- PostgreSQL 16 + pgvector
Infra
- Evolution API (self-hosted WhatsApp)
- EasyPanel
AI
- Claude (Anthropic, tool use)
- external embeddings provider
Interested in a project like this? Get in touch.
Get in touch