Squode
Quotation software for interior design and renovation firms in Singapore and Malaysia. Turn a messy site-visit scope into a priced, reviewable quotation draft, against the firm's own materials and pricing.
Role: Product · Backend · Frontend · AI integration · Database design · Status: In development — V2 rebuild, not yet relaunched · Stack: React (Vite) SPA, Supabase (Postgres + Auth + Edge Functions), OpenAI API
The problem
I started building Squode because of a conversation, not a hunch.
A friend's boss owns an interior design firm. He complained to her about the same thing every renovation quotation: it takes days, sometimes weeks, to go from a client walkthrough to a priced quote. Someone visits the site, takes notes, then manually works through those notes against the firm's material list — checking prices, checking what's in stock, checking margins — before anything can be sent back to the client. He told her he'd pay for something that made that faster.
That's the whole thesis. Not "digitize the ID firm." Not "run their whole business." Just: take the scope of work a designer already has, and turn it into a quotation draft against the firm's own materials, fast enough that it stops being the bottleneck.
Why V1 grew into the wrong thing
The first version of Squode didn't stay that narrow.
By the time I decided to rebuild it, V1 had grown to cover projects, payments, variation orders, site notes, commercial documents (quote → invoice → receipt), team management, and reports — on top of the original AI quotation extraction. Some of that is legitimately useful to an ID firm. None of it was the reason the product existed.
The technical shape of V1 made this worse, not just the feature list. Tenancy was modeled as one user belonging to one organization, resolved by subdomain — a user_profiles row pointed at a single org_id, and the app routed by hostname. That's a reasonable model if you're selling to single-company customers who each get their own instance. It's the wrong model for self-serve signup, and it makes "join a second workspace" or "an account with multiple companies" awkward to bolt on afterward.
By the time this became clear, the backend had accumulated 70+ migrations, several rewrites of the auth/invite triggers, and enough drift that I wasn't fully confident what the actually-deployed state was versus what the migration history implied. There was no paying customer data to protect. Rewriting the backend cleanly was the lower-risk option compared to untangling every assumption the old schema had baked in.
So the V2 rebuild is doing two things at the same time, deliberately:
Product reset. Strip Squode back to the one loop worth proving: messy scope in, priced quotation draft out, matched against the firm's own materials.
Architecture reset. Replace subdomain-per-company tenancy with a normal SaaS shape — one user account, one or more workspaces, membership enforced by row-level security, workspace selected by URL (/w/:workspaceSlug) instead of hostname.
The old tenant-resolution code — TenantContext, useTenant, TenantGuard — is still sitting in the repo. It isn't a second supported auth path; it's leftover from the model I moved away from, and it should be deleted once nothing depends on it.
The hardest decision: narrowing on purpose
V1_QUOTATION_ONLY is one boolean. It's threaded through ten files, and while it's true, it hides a fully-built commercial layer: reports, payment and milestone tracking, commercial document send/export, invoice and bank-detail settings. None of that code was deleted — it's flagged off.
That's the point. Squode had already built its way into project management, payments, and document workflows before the core quotation loop had actually been validated. Those features are useful to an ID firm in the abstract, but none of them answer the question that matters right now: does turning a messy scope into a usable quotation draft, against a firm's own materials, actually save the time it's supposed to save?
The commercial layer isn't gone. It comes back after the quotation workflow proves itself — not before. Until then, keeping it visible would mean re-litigating a full ERP-shaped surface area before the one thing worth testing has been tested.
What's unfinished, and I'm not pretending otherwise
Two parts of the current build are real gaps, not disguised decisions.
The matcher is a placeholder, not a finished product. The V1 AI pipeline matched extracted scope items against the material catalog using hybrid lexical + semantic search (embeddings) and a reranking pass — roughly fifteen hand-tuned signals (category compatibility, unit compatibility, spec overlap, favorite/usage boosts, a vague-item detector) that produced an explicit, human-readable reason for every match: "Matched by item name and material details," with warnings like "Similar alternatives available." That whole layer — over a thousand lines — was deleted in the V2 rewrite and replaced with a single OpenAI extraction call and a bare word-overlap match (Jaccard similarity, threshold 0.35, best match wins, no runner-up, no explanation surfaced).
The underlying product principle was correct: AI should extract scope, match it against the firm's own materials, show its confidence and reasoning, and require a human to review before anything becomes commercially real. The V1 implementation of that principle was overbuilt. The V2 implementation is underbuilt — a bridge, not the destination. The plan is to bring back a matcher that's simpler than the original 1,339 lines but still explainable, not to leave word-overlap matching as the permanent answer.
The review boundary isn't actually enforced yet. In the current build, confirming an AI generation and verifying its line items are two unconnected actions. "Confirm generation" finalizes the billing usage event and marks the draft confirmed — a state I want decoupled from review, because a successful AI generation consumes real cost the moment it runs, whether or not the user likes the output afterward. But nothing currently stops a user from clicking "Confirm generation" without verifying a single line, and nothing stops a pending line — never reviewed, never touched by a human — from appearing in the quotation preview as if it were finalized. That's not an intentional design; it's a gap. The rule Squode is supposed to enforce is that AI produces pending items, and a human has to verify, edit, or skip each one before it counts as commercial truth. Right now the code doesn't hold that line.
There's a related, smaller bug worth naming: the old matcher marked an item verified automatically when the AI found no catalog match at all, and left it pending when it did find one — meaning less certainty produced more trust, not less. That's backwards, and I wouldn't carry it forward. An unmatched item should stay pending (or move to a distinct unmatched state) with pricing left blank until a person confirms it — AI shouldn't get to manufacture commercial certainty just because it couldn't find a match to second-guess.
The open tension: Squode V2 hasn't launched
Squode V2 is not live yet. It's mid-rebuild.
I'm not going to dress that up with numbers — there's no honest install count or subscription count to publish, because the thing hasn't shipped. What I can be honest about is what's built and what isn't, because that's real:
The workspace/account architecture is built and wired end to end — create a workspace, join by invitation, switch between workspaces, get isolated by row-level security instead of by subdomain. The AI-generate-and-review quotation loop is built and wired — generate a draft from scope text, add materials or custom lines, mark lines verified or skipped, see a running total. What's not built: the paid tier. workspace_entitlements only seeds a free plan right now — 1 seat, 1 material import, 3 AI quotation generations — and there's no second plan row in the schema. The old billing page is still routed in the app and still points at a Stripe checkout/portal integration that no longer exists after the rewrite; it needs to be rebuilt against the new entitlement model, not resurrected as-is. The route guard that would actually enforce the paywall (SubscriptionGuard) is written but was never wired into the route tree.
So if the question is "how many people are paying for Squode V2 right now," the honest answer is that the question doesn't apply yet — the paid path itself doesn't exist. That's not a disappointing metric to report; it's just the accurate state of an unfinished rebuild. It becomes a real number worth publishing after relaunch, not before.
Architecture
CLIENT (React SPA) SUPABASE
│
User enters scope text ──────────────────┐ │
▼ │
generate-quotation-draft (edge fn)
│ │
▼ │
OpenAI chat completion (extraction)
│ │
▼ │
word-overlap match vs. materials table
│ │
▼ │
insert quotation_line_items (status: pending)
│ │
◄─────────────────────────────────┘ │
User reviews: verify / skip / edit │
│ │
▼ │
"Confirm generation" ──────► confirm_workspace_usage RPC (billing only)
│
Workspace isolation: RLS by organization_id, membership via
organization_members, workspace selected by /w/:workspaceSlug
(not by subdomain/hostname — the V2 change from V1)
What I'd do differently, and what's next
- Enforce the review boundary in the schema/RPCs, not just the UI copy. A quotation shouldn't be exportable or sendable while it still has
pendinglines from an AI generation. Right now that's a sentence in a banner, not a constraint. - Rebuild the matcher as a bridge, not a rewrite-and-forget. Bring back explainability (why this match, what else was close) without bringing back all fifteen tuning constants from V1. Confidence shown to a human is the point; a fully automated reranker isn't.
- Delete the dead paths instead of leaving them as ambiguity.
TenantContext/useTenant/TenantGuard, the unusedSubscriptionGuard, the AI response types inai.tsthat nothing produces anymore — none of these are "maybe useful later." They're a previous architecture's residue, and leaving them in makes the current one harder to read. - Rebuild billing against the new entitlement model before touching the old Stripe screen.
workspace_entitlementsalready models the free tier correctly; the paid path should extend that, not resurrect a checkout flow built for a schema that no longer exists. - Sequence relaunch around the one question that matters: does turning messy scope into a quotation draft, against a firm's own materials, actually save the time an ID firm is currently losing to manual quoting? Everything else — payments, reports, documents, monetization — comes after that's answered, not before.
What this rebuild is teaching me
The instinct after building V1 was to defend what was already there — it had more features, it did more things. The harder call was admitting that "more things" wasn't the same as "the thing that mattered." The friend's-boss conversation that started this was never about payments or reports. It was about one specific, expensive delay: quotes taking days to weeks. V1 solved that and then kept going past it. V2 is an attempt to stop at the actual problem before building past it again.
The other lesson is about honesty during a rebuild specifically. It's tempting to describe an unfinished product as if the missing pieces were choices. They mostly aren't — the matcher is unfinished, billing is unfinished, the review boundary isn't enforced yet. Naming those as gaps rather than decisions is the only way to actually track which parts of V2 are done and which parts still need work before this is ready to put in front of the person who asked for it in the first place.