<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Lith SEO</title>
    <description>The latest articles on DEV Community by Lith SEO (@seolith).</description>
    <link>https://dev.to/seolith</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4123403%2F4fb1b1fe-0c51-4096-bfda-4ef783fdc7d7.png</url>
      <title>DEV Community: Lith SEO</title>
      <link>https://dev.to/seolith</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/seolith"/>
    <language>en</language>
    <item>
      <title>Point your AI assistant at your invoices — we shipped an MCP server</title>
      <dc:creator>Lith SEO</dc:creator>
      <pubDate>Fri, 09 Oct 2026 00:21:06 +0000</pubDate>
      <link>https://dev.to/seolith/point-your-ai-assistant-at-your-invoices-we-shipped-an-mcp-server-4h8p</link>
      <guid>https://dev.to/seolith/point-your-ai-assistant-at-your-invoices-we-shipped-an-mcp-server-4h8p</guid>
      <description>&lt;p&gt;FoxyInvoice now speaks &lt;strong&gt;MCP — the Model Context Protocol&lt;/strong&gt;. Connect Claude&lt;br&gt;
Desktop, Claude Code, VS Code, or Cursor to your workspace and ask it to&lt;br&gt;
invoice a client in plain English:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Invoice Globex for 10 hours of consulting at $150/hour, due in 30 days."&lt;/p&gt;

&lt;p&gt;→ &lt;em&gt;Created INV-2026-0007 for $1,608.75 ($1,500 + $108.75 CA tax), due&lt;br&gt;
November 7. It's a draft — want me to send it?&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Every number in that sentence came from the server, not the model. That's the&lt;br&gt;
design constraint this post hangs off. Here's the whole thing: how auth works&lt;br&gt;
when the client is a robot, why the tools are thin wrappers over the REST&lt;br&gt;
handlers, and the one trick that makes agent retries harmless.&lt;/p&gt;



&lt;p&gt;&lt;strong&gt;Try it on a real workspace:&lt;/strong&gt; FoxyInvoice is free to start — create one at &lt;a href="https://foxyinvoice.com/login" rel="noopener noreferrer"&gt;foxyinvoice.com/login&lt;/a&gt; and redeem founding code &lt;strong&gt;&lt;code&gt;U8B4Z8S87X&lt;/code&gt;&lt;/strong&gt; on the Upgrade page for &lt;strong&gt;6 months of Pro, free, no card&lt;/strong&gt;. Then Settings → AI assistants → Generate token, and ask Claude to invoice someone.&lt;/p&gt;
&lt;h2&gt;
  
  
  What shipped
&lt;/h2&gt;

&lt;p&gt;A JSON-RPC endpoint at &lt;code&gt;/api/v1/mcp&lt;/code&gt; speaking MCP's Streamable HTTP transport,&lt;br&gt;
exposing seven tools:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;list_clients&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Search clients by name or email&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;create_client&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Create a client&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;list_products&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;List the catalog&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;list_invoices&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;List invoices, filter by status/client&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;get_invoice&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;One invoice, all lines, computed totals&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;create_invoice&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Create a &lt;strong&gt;draft&lt;/strong&gt; invoice&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;send_invoice&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Email a draft to its client&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;h2&gt;
  
  
  Auth: a robot is not a browser session
&lt;/h2&gt;

&lt;p&gt;The SPA authenticates with short-lived JWTs behind httpOnly cookies. Right for&lt;br&gt;
a browser, wrong for an assistant you configure once — a token that dies every&lt;br&gt;
15 minutes breaks every MCP client config on earth. But a long-lived&lt;br&gt;
credential needs a story for "how do I make it stop".&lt;/p&gt;

&lt;p&gt;The answer is personal access tokens — the same shape GitHub chose:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Generated in &lt;strong&gt;Settings → AI assistants&lt;/strong&gt;, shown &lt;em&gt;once&lt;/em&gt;, stored only as a
SHA-256 hash. The database can't leak what it doesn't have.&lt;/li&gt;
&lt;li&gt;Presented as &lt;code&gt;Authorization: Bearer foxy_…&lt;/code&gt; on every call. No cookies, no
handshake state — the server is stateless, so any HTTP client that can POST
JSON can drive it.&lt;/li&gt;
&lt;li&gt;Revocable in one click, checked on every request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A token acts as its user — live.&lt;/strong&gt; There's no permissions snapshot in the
token. Every request re-resolves the user's current roles from the
database, so disabling a user or changing their roles takes effect on the
very next tool call. Nothing to propagate, no cache to invalidate, no
"I removed them and their integration still worked" bug class.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;JWTs are deliberately &lt;em&gt;not&lt;/em&gt; accepted at the MCP endpoint — a short-lived&lt;br&gt;
browser credential pasted into an assistant config would be a support ticket&lt;br&gt;
factory.&lt;/p&gt;
&lt;h2&gt;
  
  
  Tools are thin, the core is shared
&lt;/h2&gt;

&lt;p&gt;The tempting way to build this is a parallel implementation: tool handlers&lt;br&gt;
that re-do the queries the REST handlers already do. The correct way is to&lt;br&gt;
make the REST handlers thin and share what's underneath. We extracted the&lt;br&gt;
bodies of the client, product, invoice, and send handlers into &lt;code&gt;*_core&lt;/code&gt;&lt;br&gt;
functions that take the authenticated user and the request — both surfaces&lt;br&gt;
call them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;REST handler  ─┐
               ├─► clients::create_core(state, auth, body)
MCP tool call ─┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which means everything the REST API guarantees, the tools inherit for free,&lt;br&gt;
because it is literally the same code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Permission checks&lt;/strong&gt; — a token can never do more than its user can.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tenant scoping&lt;/strong&gt; — every query filters by the token's tenant; cross-tenant
probing returns not-found, not data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Server-owned money math&lt;/strong&gt; — the agent passes &lt;code&gt;qty&lt;/code&gt; and &lt;code&gt;unitPrice&lt;/code&gt; and
nothing else. The server assigns the &lt;code&gt;INV-YYYY-NNNN&lt;/code&gt; number, computes
per-line tax from the client's jurisdiction and the tenant's nexus rules,
rounds per the invoice rules, writes the audit trail. The model never
states an amount, because a confidently wrong total on an invoice is not a
bug, it's a liability.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quotas&lt;/strong&gt; — &lt;code&gt;send_invoice&lt;/code&gt; counts against the same monthly send limit as
the UI.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Sending is a separate tool, on purpose
&lt;/h2&gt;

&lt;p&gt;A draft costs nothing. An email is irreversible. So creating and sending are&lt;br&gt;
different tools with different names, and &lt;code&gt;create_invoice&lt;/code&gt; always produces a&lt;br&gt;
&lt;code&gt;Draft&lt;/code&gt; — there is no "send too" parameter. The send tool's description tells&lt;br&gt;
the agent to confirm with the user first.&lt;/p&gt;

&lt;p&gt;Is a tool description binding? No. But models follow it remarkably well, and&lt;br&gt;
the real backstop is structural: an agent has to make a second, deliberate,&lt;br&gt;
differently-named call to reach a human being's inbox. Accidents need two&lt;br&gt;
mistakes instead of one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The retry problem, solved with an idempotency key
&lt;/h2&gt;

&lt;p&gt;Agents retry. A dropped connection mid-&lt;code&gt;create_invoice&lt;/code&gt; means the model will&lt;br&gt;
try again — and without protection, "invoice the client" becomes two&lt;br&gt;
invoices.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;create_invoice&lt;/code&gt; accepts an optional &lt;code&gt;requestId&lt;/code&gt; (any UUID the agent picks&lt;br&gt;
and reuses across retries of the same logical create). The server stores it&lt;br&gt;
as the invoice's client-supplied id; a replay with the same &lt;code&gt;requestId&lt;/code&gt;&lt;br&gt;
returns the &lt;em&gt;existing&lt;/em&gt; invoice instead of inserting a duplicate. First call&lt;br&gt;
creates, retry returns what the first call created.&lt;/p&gt;

&lt;p&gt;This is the same mechanism the offline-first SPA already uses — when you&lt;br&gt;
create a client on a plane and it syncs later, a retried sync can't duplicate&lt;br&gt;
it either. One idea, two surfaces.&lt;/p&gt;

&lt;h2&gt;
  
  
  Errors an agent can act on
&lt;/h2&gt;

&lt;p&gt;MCP separates protocol errors from tool errors, and we lean on that: business&lt;br&gt;
failures — client not found, validation failed, quota exhausted, no&lt;br&gt;
permission — come back as &lt;em&gt;successful tool calls&lt;/em&gt; with &lt;code&gt;isError: true&lt;/code&gt; and a&lt;br&gt;
plain-language message. The model reads "Client has no email address — cannot&lt;br&gt;
send invoice", tells the user, and offers to fix it. Only genuine protocol&lt;br&gt;
garbage is a JSON-RPC error. The difference is an assistant that recovers by&lt;br&gt;
itself versus one that says "an error occurred".&lt;/p&gt;

&lt;h2&gt;
  
  
  The protocol, honestly assessed
&lt;/h2&gt;

&lt;p&gt;MCP is young and moving fast, and we took a position: implement the spec's&lt;br&gt;
Streamable HTTP transport statelessly and skip the rest. No session ids, no&lt;br&gt;
server-initiated SSE streams, no stdio. What that buys:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The endpoint is documented in one page — initialize negotiation,
tools/list, tools/call, and a 401 when the bearer is missing.&lt;/li&gt;
&lt;li&gt;Statelessness composes with idempotency: any request can die and be
retried, because there is no session to lose.&lt;/li&gt;
&lt;li&gt;Server-side, it's one axum handler in the Rust API — the same codebase
weight class as any other endpoint, not a subsystem.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We'll track the spec as it settles. The stateless subset is the part every&lt;br&gt;
client already agrees on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;If you have a workspace: &lt;strong&gt;Settings → AI assistants → Generate token&lt;/strong&gt;, paste&lt;br&gt;
the one-liner into Claude Code (or the JSON into Claude Desktop), and ask&lt;br&gt;
your assistant to invoice someone.&lt;/p&gt;

&lt;p&gt;Full reference (every tool, token lifecycle, wire format):&lt;br&gt;
&lt;a href="https://foxyinvoice.com/docs/mcp/" rel="noopener noreferrer"&gt;foxyinvoice.com/docs/mcp&lt;/a&gt;&lt;br&gt;
· this post lives at&lt;br&gt;
&lt;a href="https://foxyinvoice.com/blog/mcp-ai-invoices/" rel="noopener noreferrer"&gt;foxyinvoice.com/blog/mcp-ai-invoices&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;FoxyInvoice is a live product — free invoicing for freelancers and small&lt;br&gt;
businesses, built by &lt;a href="https://foxyinvoice.com" rel="noopener noreferrer"&gt;SEOlith&lt;/a&gt;. This post is part of&lt;br&gt;
&lt;a href="https://foxyinvoice.com/blog/" rel="noopener noreferrer"&gt;a series documenting the whole build&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>mcp</category>
      <category>saas</category>
      <category>rust</category>
    </item>
    <item>
      <title>Building FoxyInvoice — Appendix: Glossary &amp; Checklists</title>
      <dc:creator>Lith SEO</dc:creator>
      <pubDate>Sat, 26 Sep 2026 13:53:23 +0000</pubDate>
      <link>https://dev.to/seolith/building-foxyinvoice-appendix-glossary-checklists-72f</link>
      <guid>https://dev.to/seolith/building-foxyinvoice-appendix-glossary-checklists-72f</guid>
      <description>&lt;p&gt;&lt;em&gt;This series is written in the open, from a real production system. This closing reference page collects every term the series used and the two checklists that turn it into a launch. [All chapters and diagrams live in the public repo.]&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Glossary — every term this series used
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Git &amp;amp; process&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Repository (repo)&lt;/strong&gt; — the project plus its full history.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Commit&lt;/strong&gt; — one versioned snapshot with a message.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Branch / merge / rebase&lt;/strong&gt; — parallel lines of work / combining them / replaying yours on top.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Worktree&lt;/strong&gt; — a second checkout of the same repo, for working two branches at once (source of a war story).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tag&lt;/strong&gt; — a named point in history (&lt;code&gt;blog-v1.0&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CI/CD&lt;/strong&gt; — automation that tests (CI) and deploys (CD) on push.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Concurrency group&lt;/strong&gt; — pipeline lock ensuring one deploy at a time.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Frontend&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SPA&lt;/strong&gt; — single-page app; the server ships a shell, JavaScript renders pages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PWA&lt;/strong&gt; — progressive web app; installable, offline shell via service worker.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;UI vs UX&lt;/strong&gt; — what's on the screen vs whether the person succeeds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Progressive disclosure&lt;/strong&gt; — show only what's needed now; hide the rest behind "Advanced."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Route input binding&lt;/strong&gt; — URL parameters delivered as component inputs (timing matters — Story 1).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Signals&lt;/strong&gt; — reactive values the UI tracks automatically.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SSR / prerendering&lt;/strong&gt; — server renders pages per-request / static HTML generated at build time.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Backend &amp;amp; data&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;REST / HTTP verbs / status codes&lt;/strong&gt; — the request vocabulary (GET/POST/PUT/DELETE; 200/201/400/401/404/409/500).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JWT&lt;/strong&gt; — signed token carrying claims (user, tenant, permissions).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Refresh token&lt;/strong&gt; — long-lived credential rotated to extend sessions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ORM (EF Core)&lt;/strong&gt; — write objects; it writes the SQL.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Query filter&lt;/strong&gt; — automatic &lt;code&gt;WHERE&lt;/code&gt; clause (ours: tenant scoping).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Migration&lt;/strong&gt; — versioned schema-change script; the only legal writer of schema.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Idempotent&lt;/strong&gt; — safe to run twice (&lt;code&gt;ADD COLUMN IF NOT EXISTS&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transaction / outbox pattern&lt;/strong&gt; — commit business row + email row atomically; a worker delivers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Value object&lt;/strong&gt; — a type defined by its values (&lt;code&gt;Money&lt;/code&gt; = decimal + currency), equality by content, refuses illegal math.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nexus&lt;/strong&gt; — a presence in a US state that triggers sales-tax duties.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Infrastructure&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;DNS records: A / MX / TXT&lt;/strong&gt; — name→IP / mail routing / free-form proofs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TLS / certificate / ACME&lt;/strong&gt; — encryption; the proof you own the domain; the protocol that issues it (Caddy automates).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grey-cloud&lt;/strong&gt; — DNS-only routing (no proxy) — required for direct ACME challenges.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;VPS&lt;/strong&gt; — your rented Linux box.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docker: image / container / compose / volume / network&lt;/strong&gt; — blueprint / running instance / stack file / persistent disk / private LAN.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;BuildKit secret&lt;/strong&gt; — credential mounted during build only; never in image layers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rate limiting&lt;/strong&gt; — per-IP request budgets on public endpoints.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;X-Robots-Tag / robots.txt allowlist&lt;/strong&gt; — crawler directives; default-closed indexing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IndexNow&lt;/strong&gt; — push protocol telling engines your URLs changed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;llms.txt&lt;/code&gt;&lt;/strong&gt; — markdown menu for AI answer engines.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Product &amp;amp; ops&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Multi-tenancy&lt;/strong&gt; — many businesses, one system, isolated data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RBAC&lt;/strong&gt; — roles → permissions → guards.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Share token&lt;/strong&gt; — unguessable URL as scoped authorization.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Funnel&lt;/strong&gt; — stranger → visitor → signup → activated → paid.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Marginal cost&lt;/strong&gt; — cost of one more user (ours: ≈0).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Break-even&lt;/strong&gt; — subscribers covering fixed costs (ours: 2 Pro).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GEO (generative-engine optimization)&lt;/strong&gt; — being the machine-readable answer.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The checklists
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Server setup&lt;/strong&gt; (full walkthrough in Chapter 6):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Domain bought; nameservers → Cloudflare; A record grey-cloud&lt;/li&gt;
&lt;li&gt;[ ] VPS up; compose stack: postgres healthy → api healthy → worker running&lt;/li&gt;
&lt;li&gt;[ ] Caddy certificates issued; site loads over HTTPS&lt;/li&gt;
&lt;li&gt;[ ] SES identity verified; &lt;strong&gt;probe email sent &amp;amp; received&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Email routing rules + &lt;strong&gt;verified destinations&lt;/strong&gt;; probe again&lt;/li&gt;
&lt;li&gt;[ ] Nightly backups, size-checked, landing off-box&lt;/li&gt;
&lt;li&gt;[ ] Secrets generated on-host; repo secret-scan green&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Launch&lt;/strong&gt; (product-level):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Signup → onboarding → first invoice sent, tested in a clean browser&lt;/li&gt;
&lt;li&gt;[ ] Feedback widget round-trip (submit → email → console → resolve)&lt;/li&gt;
&lt;li&gt;[ ] Payments sandbox end-to-end (link → webhook → Paid status)&lt;/li&gt;
&lt;li&gt;[ ] robots.txt allowlist reviewed; prerendered pages spot-checked with &lt;code&gt;curl&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Search Console + Bing Webmaster verified; sitemap submitted&lt;/li&gt;
&lt;li&gt;[ ] Access logs on; one organic crawl observed&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;— That's the series. Every chapter, diagram, and scar lives in the&lt;br&gt;
repo, versioned in git, ready for whoever builds the next one.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The machine this series documents is live, and reading these words is&lt;br&gt;
the proof it ships. Create a free workspace at&lt;br&gt;
&lt;a href="https://foxyinvoice.com/login" rel="noopener noreferrer"&gt;foxyinvoice.com&lt;/a&gt;, then redeem founding code&lt;br&gt;
&lt;code&gt;U8B4Z8S87X&lt;/code&gt; on the Upgrade page — 6 months of Pro, free, no card. If anything&lt;br&gt;
breaks, there's a feedback button in the app. I read every one.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>saas</category>
      <category>webdev</category>
      <category>tutorial</category>
      <category>beginners</category>
    </item>
    <item>
      <title>Building FoxyInvoice — Chapter 13: War stories — six postmortems and what each one cost</title>
      <dc:creator>Lith SEO</dc:creator>
      <pubDate>Sat, 26 Sep 2026 13:42:45 +0000</pubDate>
      <link>https://dev.to/seolith/building-foxyinvoice-chapter-13-war-stories-six-postmortems-and-what-each-one-cost-328e</link>
      <guid>https://dev.to/seolith/building-foxyinvoice-chapter-13-war-stories-six-postmortems-and-what-each-one-cost-328e</guid>
      <description>&lt;p&gt;&lt;em&gt;This series is written in the open, from a real production system. This chapter is six real postmortems, each in the same format: symptom, diagnosis, fix, lesson. [All chapters and diagrams live in the public repo.]&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Every incident below is real, timestamped in git, and shipped a&lt;br&gt;
permanent change to how we work. The format is the same each time —&lt;br&gt;
&lt;strong&gt;symptom, diagnosis, fix, lesson&lt;/strong&gt; — because the lesson is the only&lt;br&gt;
part worth keeping. Read them as a set and a meta-lesson emerges:&lt;br&gt;
almost every failure was &lt;em&gt;a system telling us a comforting lie&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Story 1: The edit button that created invoices
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom.&lt;/strong&gt; User report: &lt;em&gt;"On clicking Edit invoice button, new&lt;br&gt;
invoice page is opened. Not able to edit invoices."&lt;/em&gt; Plus, from the&lt;br&gt;
same user: double-clicking Create produced duplicates.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Diagnosis.&lt;/strong&gt; Two bugs stacked. (a) The editor read its route&lt;br&gt;
parameter in the constructor — but Angular binds route inputs &lt;em&gt;after&lt;/em&gt;&lt;br&gt;
construction, so the id was always null and "Edit" rendered a blank&lt;br&gt;
"New" form; saving minted a fresh invoice. (b) Deeper: the invoice&lt;br&gt;
repository loaded entities &lt;strong&gt;without line items&lt;/strong&gt; — line updates&lt;br&gt;
404'd, and adding a line recomputed totals against an &lt;em&gt;empty set&lt;/em&gt;,&lt;br&gt;
silently zeroing an invoice's math. One confused click had uncovered a&lt;br&gt;
data-corruption class.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix.&lt;/strong&gt; An &lt;code&gt;effect()&lt;/code&gt; that reacts when the route input binds;&lt;br&gt;
&lt;code&gt;Include(LineItems)&lt;/code&gt; at the repository level (also fixed quote&lt;br&gt;
conversion losing its lines); create-success now navigates away so a&lt;br&gt;
second click can't duplicate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson.&lt;/strong&gt; Reproduce &lt;em&gt;in a real browser&lt;/em&gt; before theorizing — the&lt;br&gt;
visible bug was framing timing, but only reproduction exposed the&lt;br&gt;
corruption underneath. And domain integrity shouldn't depend on every&lt;br&gt;
call site remembering an &lt;code&gt;Include&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Story 2: The hand-edited database (twice)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom.&lt;/strong&gt; API containers crash-looping at startup on deploy;&lt;br&gt;
logs show &lt;code&gt;column already exists&lt;/code&gt; / &lt;code&gt;relation already exists&lt;/code&gt; from the&lt;br&gt;
migration engine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Diagnosis.&lt;/strong&gt; Schema changes had been applied &lt;strong&gt;by hand&lt;/strong&gt; (&lt;code&gt;psql&lt;/code&gt;) on&lt;br&gt;
the production database — once a table, once a column — while the&lt;br&gt;
&lt;em&gt;proper migration&lt;/em&gt; for the same change sat in the repo. The migration&lt;br&gt;
collided with the hand-made reality, threw, rolled back, and the&lt;br&gt;
container died at boot. Both times.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix.&lt;/strong&gt; Emergency reconciliation (apply the missing column, record the&lt;br&gt;
migration in the history table), then rewrite the migrations&lt;br&gt;
&lt;strong&gt;idempotently&lt;/strong&gt; (&lt;code&gt;CREATE TABLE IF NOT EXISTS&lt;/code&gt;, &lt;code&gt;ADD COLUMN IF NOT&lt;br&gt;
EXISTS&lt;/code&gt;) so they're safe on every database state. New repo-wide rule,&lt;br&gt;
now in the developer docs: never apply schema changes out-of-band; if&lt;br&gt;
forced, the follow-up migration must tolerate reality.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson.&lt;/strong&gt; A migration isn't a formality — it's the &lt;em&gt;only&lt;/em&gt; writer of&lt;br&gt;
schema. Two writers means one of them is lying to the other.&lt;/p&gt;

&lt;h2&gt;
  
  
  Story 3: The deploy queue zombie
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom.&lt;/strong&gt; Every deploy sat "pending" forever — while runners were&lt;br&gt;
online and idle. Cancelling and re-running changed nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Diagnosis.&lt;/strong&gt; The concurrency group that serializes deploys had a&lt;br&gt;
&lt;strong&gt;zombie holder&lt;/strong&gt;: a cancelled run that never released the lock. New&lt;br&gt;
runs were created pending &lt;em&gt;with zero jobs&lt;/em&gt; — the job row only appears&lt;br&gt;
once the lock is acquired — so the UI showed an eternal, healthy-&lt;br&gt;
looking queue.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix.&lt;/strong&gt; Renaming the concurrency group sidesteps the zombie entirely&lt;br&gt;
(&lt;code&gt;production-deploy-v2&lt;/code&gt;). Diagnosis technique worth stealing: &lt;strong&gt;zero&lt;br&gt;
jobs + idle runners = deadlock, not queue.&lt;/strong&gt; The jobs API tells you&lt;br&gt;
instantly which one you have.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson.&lt;/strong&gt; "Pending forever" is a distinct failure mode. Know its&lt;br&gt;
signature, because the queue UI is constitutionally incapable of&lt;br&gt;
admitting it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Story 4: The email that bounced to everyone
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom.&lt;/strong&gt; Feedback notifications bouncing as&lt;br&gt;
&lt;code&gt;MAILER-DAEMON&lt;/code&gt; delivery failures — user reports reaching nobody.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Diagnosis.&lt;/strong&gt; The notification address lived on a domain whose mail&lt;br&gt;
routes pointed at Cloudflare Email Routing — and while the &lt;em&gt;rules&lt;/em&gt;&lt;br&gt;
existed, the &lt;strong&gt;destination address was unverified&lt;/strong&gt;. Every bounce&lt;br&gt;
also masked a second gap: notifications had no &lt;code&gt;Reply-To&lt;/code&gt;, so any&lt;br&gt;
manual reply would have gone to a dead sender.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix.&lt;/strong&gt; Notifications moved to a verified inbox; &lt;code&gt;Reply-To&lt;/code&gt; now&lt;br&gt;
points at the reporter; and any mail-routing change gets a &lt;strong&gt;probe&lt;br&gt;
email&lt;/strong&gt; — config-saved is not delivered. (Also learned to read MX&lt;br&gt;
records before assuming Google hosted everything.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson.&lt;/strong&gt; Email infrastructure needs end-to-end tests like every&lt;br&gt;
other infrastructure. A saved rule is a hypothesis; a received probe&lt;br&gt;
is a result.&lt;/p&gt;

&lt;h2&gt;
  
  
  Story 5: The partial upgrade that broke the build
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom.&lt;/strong&gt; Deploy "succeeded" — backends healthy — but the SPA build&lt;br&gt;
had failed inside it with &lt;code&gt;npm ERESOLVE&lt;/code&gt;. New code was live on the&lt;br&gt;
API; the site itself was yesterday's.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Diagnosis.&lt;/strong&gt; A dependency bot bumped &lt;em&gt;three&lt;/em&gt; Angular packages to&lt;br&gt;
22.1.4 while the rest of the family stayed on 22.1.3. Angular's peer&lt;br&gt;
dependencies demand exact-version alignment, so &lt;code&gt;npm install&lt;/code&gt; became&lt;br&gt;
unresolvable. Why didn't the deploy fail loudly? It overlapped with&lt;br&gt;
story-3-era pipeline bugs — the failure was surfaced only after the&lt;br&gt;
queue was fixed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix.&lt;/strong&gt; Align the entire &lt;code&gt;@angular/*&lt;/code&gt; family + Material/CDK in one&lt;br&gt;
commit, regenerate the lockfile, and add the rule to the docs: &lt;strong&gt;on&lt;br&gt;
any Angular bump, move the whole family together.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson.&lt;/strong&gt; Ecosystems with strict peer-graphs turn "small" upgrades&lt;br&gt;
into set-theory problems. When a bot proposes a partial set, the set&lt;br&gt;
&lt;em&gt;is&lt;/em&gt; the unit of review.&lt;/p&gt;

&lt;h2&gt;
  
  
  Story 6: The version diamond
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom.&lt;/strong&gt; A telemetry/health-check upgrade crashed the API&lt;br&gt;
containers into a restart loop the moment it deployed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Diagnosis.&lt;/strong&gt; Our internal platform packages required MediatR 14; the&lt;br&gt;
app pinned MediatR 12. Both versions in one process resolved to a&lt;br&gt;
runtime &lt;code&gt;MissingMethodException&lt;/code&gt;-style explosion — the classic&lt;br&gt;
&lt;strong&gt;dependency diamond&lt;/strong&gt;, invisible at compile time in the right&lt;br&gt;
(disastrous) packaging layout.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix.&lt;/strong&gt; The interim revert is documented (that feature was withdrawn&lt;br&gt;
same-day); the durable fix came later — replacing MediatR entirely&lt;br&gt;
with a tiny MIT-licensed in-process mediator with no version gravity,&lt;br&gt;
and bumping the platform packages as a set. Chapter 03's architecture&lt;br&gt;
now documents the post-migration world.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson.&lt;/strong&gt; Your dependency graph is architecture. Any package that&lt;br&gt;
&lt;em&gt;everyone&lt;/em&gt; transitively requires becomes a load-bearing wall — pick&lt;br&gt;
ones with a migration story, or none at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The template (steal this)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The postmortem template, rendered live:&lt;/strong&gt; &lt;a href="https://foxyinvoice.com/blog/13-war-stories/" rel="noopener noreferrer"&gt;SYMPTOM → DIAGNOSIS → FIX → LESSON — the loop that turns each incident into a permanent rule&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Postmortems earn their keep only if the &lt;strong&gt;lesson&lt;/strong&gt; becomes a check, a&lt;br&gt;
doc line, a gate, or an idempotent default — otherwise you're just&lt;br&gt;
collecting scars. Every lesson above now lives in the repo's&lt;br&gt;
developer landmines file, where the next 2 a.m. version of us will&lt;br&gt;
actually read it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Reading this and want to write your own war stories instead of reading ours? Create a free workspace at&lt;br&gt;
&lt;a href="https://foxyinvoice.com/login" rel="noopener noreferrer"&gt;foxyinvoice.com&lt;/a&gt;, then redeem founding code&lt;br&gt;
&lt;code&gt;U8B4Z8S87X&lt;/code&gt; on the Upgrade page — 6 months of Pro, free, no card. If anything&lt;br&gt;
breaks, there's a feedback button in the app. I read every one.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Next: Appendix A — Glossary &amp;amp; Checklists. The series is complete; the machine keeps running.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>saas</category>
      <category>webdev</category>
      <category>tutorial</category>
      <category>beginners</category>
    </item>
    <item>
      <title>Building FoxyInvoice — Chapter 12: Engagement &amp; automation — UI first, then automate</title>
      <dc:creator>Lith SEO</dc:creator>
      <pubDate>Sat, 26 Sep 2026 02:48:47 +0000</pubDate>
      <link>https://dev.to/seolith/building-foxyinvoice-chapter-12-engagement-automation-ui-first-then-automate-14d5</link>
      <guid>https://dev.to/seolith/building-foxyinvoice-chapter-12-engagement-automation-ui-first-then-automate-14d5</guid>
      <description>&lt;p&gt;&lt;em&gt;This series is written in the open, from a real production system. This chapter is the engagement surface: onboarding, progress you don't lose, and gentle machines that act for the user. [All chapters and diagrams live in the public repo.]&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;"Engagement" conjures dark-pattern streaks and notification spam. It&lt;br&gt;
shouldn't. Engagement is &lt;em&gt;the product working so well that leaving it&lt;br&gt;
half-finished feels like loss&lt;/em&gt; — progress the user doesn't want to&lt;br&gt;
abandon, moments of completion worth screenshotting, and gentle machines&lt;br&gt;
that act on the user's behalf. This chapter is the engagement surface&lt;br&gt;
we shipped, in the order it was built, under one governing rule:&lt;br&gt;
&lt;strong&gt;build the manual UI first; automate only flows that already proved&lt;br&gt;
themselves by hand.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The onboarding staircase (first five minutes)
&lt;/h2&gt;

&lt;p&gt;A new signup lands on a three-step wizard: name your business + pick&lt;br&gt;
currency → add your first client → "you're ready" (with the founding-&lt;br&gt;
code hint for testers). Each step is one decision, skippable, and&lt;br&gt;
writes real data — so finishing onboarding &lt;em&gt;is&lt;/em&gt; having a workspace.&lt;br&gt;
Then the dashboard's get-started checklist walks the rest: first&lt;br&gt;
invoice, first send, first payment link. The metric this staircase&lt;br&gt;
serves is the only one that matters early: &lt;strong&gt;time to first sent&lt;br&gt;
invoice.&lt;/strong&gt; Everything that shortens it is engagement; everything that&lt;br&gt;
lengthens it is churn you built yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Progress you can see (and don't want to lose)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Draft save &amp;amp; resume, twice over.&lt;/strong&gt; The invoice editor has a "Save
draft" that stays on the page (URL swapping to the edit route so a
refresh can't duplicate it), plus local autosave with a resume banner
— mid-form work survives navigation. The template generator offers
the same to anonymous visitors (Chapter 11's slice 1). The
psychology is honest: no dark patterns, just &lt;em&gt;don't lose the user's
work&lt;/em&gt;, and they'll come back to it themselves.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The usage meter as an honest nudge.&lt;/strong&gt; Free accounts see
"4 / 10 invoice actions this month." Not a countdown timer, not a
lock — a number, and when it approaches the limit, an upgrade page
explains what Pro adds. Conversion pressure that respects the user
converts the &lt;em&gt;right&lt;/em&gt; users: the ones who grew.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Gentle machines: automation that acts for the user
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reminders.&lt;/strong&gt; The worker scans daily: a pre-due nudge three days
before, a friendly reminder at +3 days overdue, a firmer notice at
+14 — each deduplicated so a client never gets spammed by a cron
rerun. Invoice-sending anxiety is the freelancer's biggest tax; the
product volunteering to chase payment is engagement of the deepest
kind.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recurring invoices.&lt;/strong&gt; A template + schedule becomes Draft invoices
on cadence, taxed and totaled through the same engine, optionally
auto-sent. The user's "engagement" with their monthly retainers
becomes &lt;em&gt;checking that the machine did it&lt;/em&gt; — which is the goal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quote acceptance on the public link.&lt;/strong&gt; The client clicks Accept on
their phone; the system converts the quote to a real Draft invoice
server-side and emails the owner. Closing a deal while the owner
sleeps is the product's best trick.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Founding codes.&lt;/strong&gt; Six months of Pro per code; the founder console
generates batches and shares each with one tap (native share sheet
or a ready-to-send clipboard message with redemption instructions).
Generosity with a ledger — every code tracks redeemed status.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each of these automated &lt;em&gt;after&lt;/em&gt; a manual version proved the flow —&lt;br&gt;
reminders were conceptually "send that email yourself," recurring was&lt;br&gt;
"clone that invoice monthly," quote-accept was "click Convert when they&lt;br&gt;
say yes." Automation amplified verified behavior instead of guessing at&lt;br&gt;
it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we deliberately did not build
&lt;/h2&gt;

&lt;p&gt;The refuse-list from Chapter 02, enforced in practice: no time&lt;br&gt;
tracking, no expense ledger, no inventory, no notification inbox vying&lt;br&gt;
for attention. Also — no streak mechanics, no badges, no emails&lt;br&gt;
manufacturing re-engagement. The product's hook is the user's own&lt;br&gt;
unpaid invoice; we just make sure it's easy to finish and easy to&lt;br&gt;
chase.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Recap.&lt;/strong&gt; Onboard to a finished-feeling workspace in three steps,&lt;br&gt;
never lose work, show honest usage, and let gentle machines —&lt;br&gt;
reminders, recurrence, quote acceptance — act on the user's behalf.&lt;br&gt;
Automate only what proved itself manually, and measure everything&lt;br&gt;
against time-to-first-sent-invoice.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Reading this and want your own dashboard of truth? Create a free workspace at&lt;br&gt;
&lt;a href="https://foxyinvoice.com/login" rel="noopener noreferrer"&gt;foxyinvoice.com&lt;/a&gt;, then redeem founding code&lt;br&gt;
&lt;code&gt;U8B4Z8S87X&lt;/code&gt; on the Upgrade page — 6 months of Pro, free, no card. If anything&lt;br&gt;
breaks, there's a feedback button in the app. I read every one.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Next: Chapter 13 — War stories: six postmortems and what each one cost.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>saas</category>
      <category>webdev</category>
      <category>tutorial</category>
      <category>beginners</category>
    </item>
    <item>
      <title>Building FoxyInvoice — Chapter 11: Reach — SEO, AI crawlers, and being the machine-readable answer</title>
      <dc:creator>Lith SEO</dc:creator>
      <pubDate>Sat, 26 Sep 2026 00:49:08 +0000</pubDate>
      <link>https://dev.to/seolith/building-foxyinvoice-chapter-11-reach-seo-ai-crawlers-and-being-the-machine-readable-answer-24on</link>
      <guid>https://dev.to/seolith/building-foxyinvoice-chapter-11-reach-seo-ai-crawlers-and-being-the-machine-readable-answer-24on</guid>
      <description>&lt;p&gt;&lt;em&gt;This series is written in the open, from a real production system. This chapter is the reach stack: prerendering, robots.txt, llms.txt, IndexNow, and measuring it all with access logs. [All chapters and diagrams live in the public repo.]&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Somewhere between "launched" and "learned people exist," every product&lt;br&gt;
discovers distribution. Ours arrived with a twist that defines this&lt;br&gt;
chapter: &lt;strong&gt;half your future traffic now arrives via machines that never&lt;br&gt;
render your app&lt;/strong&gt; — search crawlers that mostly cope, and AI answer&lt;br&gt;
engines (GPTBot, ClaudeBot, PerplexityBot) that flat-out don't run&lt;br&gt;
JavaScript. If your site is a client-side SPA, those engines see a&lt;br&gt;
blank page with a title. This chapter is everything we built so the&lt;br&gt;
crawlable half of the internet can actually read the product — and how&lt;br&gt;
we measure it.&lt;/p&gt;
&lt;h2&gt;
  
  
  The problem, precisely
&lt;/h2&gt;

&lt;p&gt;The FoxyInvoice app is an Angular SPA: the server ships an empty shell&lt;br&gt;
and JavaScript builds the page. Human browsers: fine. Googlebot:&lt;br&gt;
tolerant, eventually. &lt;strong&gt;AI crawlers: blind.&lt;/strong&gt; Fetch the marketing page&lt;br&gt;
with &lt;code&gt;curl&lt;/code&gt; — which is exactly what a crawler does — and you got:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;title&amp;gt;&lt;/span&gt;Invoicing&lt;span class="nt"&gt;&amp;lt;/title&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;app-root&amp;gt;&amp;lt;/app-root&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One word and an empty div. To every machine reader, our entire free&lt;br&gt;
invoice-template business was the word "Invoicing."&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix stack, bottom to top
&lt;/h2&gt;
&lt;h3&gt;
  
  
  1. The allowlist robots philosophy
&lt;/h3&gt;

&lt;p&gt;Our &lt;code&gt;robots.txt&lt;/code&gt; doesn't list what's forbidden — it lists what's&lt;br&gt;
&lt;em&gt;allowed&lt;/em&gt;, then disallows everything else:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight apache"&gt;&lt;code&gt;&lt;span class="nc"&gt;User&lt;/span&gt;-agent: *
&lt;span class="nc"&gt;Allow&lt;/span&gt;: /$ /pricing /privacy /terms /templates /templates/*
Disallow: /
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every future route — admin consoles, &lt;code&gt;/upgrade&lt;/code&gt;, QA harnesses — is&lt;br&gt;
&lt;strong&gt;non-indexable by default&lt;/strong&gt; until deliberately made public. The same&lt;br&gt;
policy is enforced &lt;em&gt;server-side&lt;/em&gt; with &lt;code&gt;X-Robots-Tag: noindex, nofollow&lt;/code&gt;&lt;br&gt;
headers at the edge proxy, per URL: private pages carry it; public&lt;br&gt;
pages don't. Belt and suspenders, because robots.txt disallow alone&lt;br&gt;
never guarantees de-indexing.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Prerendering at build time (the pragmatic middle)
&lt;/h3&gt;

&lt;p&gt;Full server-side rendering means an always-on SSR server — violating&lt;br&gt;
our boringness constraint — for pages that are 95% app shell. Instead,&lt;br&gt;
a &lt;strong&gt;post-build script&lt;/strong&gt; generates static, crawler-ready copies of every&lt;br&gt;
public route when the SPA compiles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Real &lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt; and meta description per page ("Free Plumbing Invoice
Template — FoxyInvoice")&lt;/li&gt;
&lt;li&gt;Canonical URL, Open Graph tags, JSON-LD structured data&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Full article body copy&lt;/strong&gt; inside the shell — line items, how-to
steps, FAQ — because meta tags describe content; answer engines
&lt;em&gt;quote&lt;/em&gt; content&lt;/li&gt;
&lt;li&gt;The build &lt;strong&gt;fails if the sitemap and the template data drift apart&lt;/strong&gt;
(a one-line count check that caught a real bug on its first run)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The edge proxy's &lt;code&gt;try_files&lt;/code&gt; serves these static files to anything that&lt;br&gt;
fetches the URL; real browsers still get the app (Angular replaces the&lt;br&gt;
static body on boot). No new server, no SSR framework, crawler-complete&lt;br&gt;
pages.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. &lt;code&gt;llms.txt&lt;/code&gt; — a menu for answer engines
&lt;/h3&gt;

&lt;p&gt;The emerging convention: a markdown file at the root telling AI systems&lt;br&gt;
what the product is and linking its key pages, in their vocabulary.&lt;br&gt;
Ours lists the product, every template page with a one-line&lt;br&gt;
description, and contact points. Cheap, human-readable, and exactly&lt;br&gt;
the artifact a "recommend an invoicing tool" query wants.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. IndexNow: push instead of wait
&lt;/h3&gt;

&lt;p&gt;Crawlers traditionally rediscover content on their own schedule.&lt;br&gt;
&lt;strong&gt;IndexNow&lt;/strong&gt; flips it: on every deploy, the pipeline POSTs all sitemap&lt;br&gt;
URLs to the alliance endpoint (Bing-powered — which feeds several AI&lt;br&gt;
answer engines) with a key file proving domain ownership. Our first&lt;br&gt;
ping returned HTTP 202 — accepted — and the pages were in Bing's queue&lt;br&gt;
the same evening.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Measurement: access logs are the truth
&lt;/h3&gt;

&lt;p&gt;"You can't improve what you can't see," so the edge now logs every&lt;br&gt;
request as JSON (rotated, bounded). The analysis is one script:&lt;br&gt;
user-agents, paths, crawler classes. Within days we could &lt;em&gt;see&lt;/em&gt; search&lt;br&gt;
crawlers reading &lt;code&gt;robots.txt&lt;/code&gt; and template pages — and, just as&lt;br&gt;
valuable, probe-noise (stray &lt;code&gt;/wp-admin&lt;/code&gt; scans) being correctly&lt;br&gt;
absorbed. Pair this with &lt;strong&gt;Google Search Console + Bing Webmaster&lt;/strong&gt;&lt;br&gt;
registration (a five-minute runbook lives in the repo) for the&lt;br&gt;
indexing-and-impressions view logs can't give you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The funnel the surface feeds
&lt;/h2&gt;

&lt;p&gt;Reach is only worth building if it lands somewhere. The template&lt;br&gt;
gallery — now crawlable — is the top of a three-slice funnel we shipped&lt;br&gt;
in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Slice 1 — no-signup value + persistence&lt;/strong&gt;: the generator works
anonymously, autosaves locally (30 days), and offers resume on
return.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Slice 2 — the signup handoff&lt;/strong&gt;: "Save &amp;amp; finish online" carries the
&lt;em&gt;exact filled invoice&lt;/em&gt; through signup into the new account as a
real draft.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Slice 3 — the soft capture&lt;/strong&gt;: "Email me this invoice" stores the
draft server-side against their address (with a nurture email);
if they later sign up with that address, the dashboard converts it
into a real invoice automatically.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each slice was verified in a real browser before shipping — the funnel&lt;br&gt;
is the one place where "should work" is banned.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Recap.&lt;/strong&gt; Serve machines real HTML (prerendered at build), allowlist&lt;br&gt;
what's public (default-closed), publish &lt;code&gt;llms.txt&lt;/code&gt;, push updates via&lt;br&gt;
IndexNow, and measure with access logs + Search Console. The era of&lt;br&gt;
"GEO" — being the machine-readable answer — is just SEO where the&lt;br&gt;
reader never renders your JavaScript.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Reading this and want to be found the way this series was? Create a free workspace at&lt;br&gt;
&lt;a href="https://foxyinvoice.com/login" rel="noopener noreferrer"&gt;foxyinvoice.com&lt;/a&gt;, then redeem founding code&lt;br&gt;
&lt;code&gt;U8B4Z8S87X&lt;/code&gt; on the Upgrade page — 6 months of Pro, free, no card. If anything&lt;br&gt;
breaks, there's a feedback button in the app. I read every one.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Next: Chapter 12 — Engagement &amp;amp; automation: UI first, then automate.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>saas</category>
      <category>webdev</category>
      <category>tutorial</category>
      <category>beginners</category>
    </item>
    <item>
      <title>Building FoxyInvoice — Chapter 10: Feedback tickets shipped — closing the loop while they remember</title>
      <dc:creator>Lith SEO</dc:creator>
      <pubDate>Fri, 25 Sep 2026 15:16:45 +0000</pubDate>
      <link>https://dev.to/seolith/building-foxyinvoice-chapter-10-feedback-tickets-shipped-closing-the-loop-while-they-1c00</link>
      <guid>https://dev.to/seolith/building-foxyinvoice-chapter-10-feedback-tickets-shipped-closing-the-loop-while-they-1c00</guid>
      <description>&lt;p&gt;&lt;em&gt;This series is written in the open, from a real production system. This chapter is the feedback loop: widget, notification, console, and a real bug from report to resolved. [All chapters and diagrams live in the public repo.]&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The single most valuable object in this company is a bug report from a&lt;br&gt;
real user. It is worth more than a roadmap session (it's evidence, not&lt;br&gt;
opinion) and more than an analytics dashboard (it comes with intent and&lt;br&gt;
context). Everything in this chapter exists to shorten the distance&lt;br&gt;
between &lt;em&gt;user notices problem&lt;/em&gt; and &lt;em&gt;user hears it's fixed&lt;/em&gt; — because a&lt;br&gt;
loop that closes while they still remember filing it converts a critic&lt;br&gt;
into a collaborator.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The feedback loop, rendered live:&lt;/strong&gt; &lt;a href="https://foxyinvoice.com/blog/10-feedback/" rel="noopener noreferrer"&gt;widget → attachment upload → reply-to-reporter email → triage → fix → deploy → resolve&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The widget: lower the friction to zero
&lt;/h2&gt;

&lt;p&gt;A floating button on every page. No login required — anonymous users&lt;br&gt;
leave an optional reply-to email (and many do). The message field asks&lt;br&gt;
for anything: bugs, confusion, ideas. The attachment field accepts&lt;br&gt;
&lt;strong&gt;screenshots and screen recordings&lt;/strong&gt; (MP4/WebM/MOV, ≤ 15 MB) —&lt;br&gt;
recordings after we learned that users describing a broken flow in&lt;br&gt;
words produce ambiguous prose, while thirty seconds of screen video is&lt;br&gt;
a repro script. Two small decisions that matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The comment is the payload; the attachment is enrichment.&lt;/strong&gt; If
object storage hiccups mid-submit, the feedback still saves and the
loss is merely &lt;em&gt;noted in the logs&lt;/em&gt; — never lose the words because the
picture failed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The page URL and user-agent ride along automatically.&lt;/strong&gt; "It's
broken on /payments" arrives saying exactly which /payments.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The notification: reply-able by construction
&lt;/h2&gt;

&lt;p&gt;Submission queues an outbox email (Chapter 03's transactional pattern)&lt;br&gt;
to the founder with everything inline and — the detail that took a&lt;br&gt;
bounce-storm to learn — &lt;strong&gt;&lt;code&gt;Reply-To&lt;/code&gt; set to the reporter's address.&lt;/strong&gt;&lt;br&gt;
Staff hitting "reply" answers the user, not a dead mailbox. The email&lt;br&gt;
also carries a deep link straight to the console entry.&lt;/p&gt;

&lt;h2&gt;
  
  
  The console: a tiny ticket system, honestly scoped
&lt;/h2&gt;

&lt;p&gt;A single page with four tabs — &lt;strong&gt;New → Triaged → Resolved / Archived&lt;/strong&gt;&lt;br&gt;
— attachments rendered inline (with an auth-header blob fetch, because&lt;br&gt;
&lt;code&gt;&amp;lt;img src&amp;gt;&lt;/code&gt; can't carry tokens), recordings playable in a &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt;&lt;br&gt;
player, and a status workflow with timestamps. That's the entire ticket&lt;br&gt;
system. No SLAs, no assignee matrices, no Jira — a workflow is only as&lt;br&gt;
wide as the team that maintains it, and this one is maintained in the&lt;br&gt;
same codebase as the product (fix-adjacent, therefore fix-adjacent &lt;em&gt;in&lt;br&gt;
the diff&lt;/em&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Case study: one bug, report to resolved
&lt;/h2&gt;

&lt;p&gt;The best way to show the loop is a real one. A user reported: &lt;em&gt;"On&lt;br&gt;
clicking Edit invoice button, new invoice page is opened. Not able to&lt;br&gt;
edit invoices."&lt;/em&gt; Same morning, a second report from the same user:&lt;br&gt;
double-clicking Create made duplicate invoices.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Repro&lt;/strong&gt; (docker + seeded DB): both reproduced instantly. &lt;strong&gt;Diagnosis&lt;/strong&gt;&lt;br&gt;
went two layers deep — the visible bug was Angular route-input timing&lt;br&gt;
(the editor read its &lt;code&gt;id&lt;/code&gt; before the router bound it), but reproduction&lt;br&gt;
exposed something worse underneath: the invoice repository loaded&lt;br&gt;
entities &lt;em&gt;without line items&lt;/em&gt;, so line updates 404'd and — the&lt;br&gt;
corruption class — adding a line recomputed totals against an empty&lt;br&gt;
set, silently wiping the invoice's math. One user's confused click had&lt;br&gt;
uncovered a data-integrity hole. &lt;strong&gt;Fix&lt;/strong&gt;: an &lt;code&gt;effect&lt;/code&gt; for the route&lt;br&gt;
input, and &lt;code&gt;Include(LineItems)&lt;/code&gt; at the repository level (which also&lt;br&gt;
fixed quote conversion losing its lines). &lt;strong&gt;Ship&lt;/strong&gt;: tests, commit&lt;br&gt;
referencing the feedback, push — the Chapter 07 pipeline had it live&lt;br&gt;
same-day. &lt;strong&gt;Close&lt;/strong&gt;: status → Resolved; the reply (one click, thanks to&lt;br&gt;
&lt;code&gt;Reply-To&lt;/code&gt;) told the user exactly what had been wrong and that their&lt;br&gt;
report caught a second bug they never saw. That user has filed more&lt;br&gt;
feedback than anyone — because the loop visibly worked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this beats analytics
&lt;/h2&gt;

&lt;p&gt;Analytics tells you &lt;em&gt;that&lt;/em&gt; something is wrong ("drop-off on step 3").&lt;br&gt;
Feedback tells you &lt;em&gt;why&lt;/em&gt; ("there's no currency picker on this form" —&lt;br&gt;
a real report that same week). Instrument funnels, yes; but read your&lt;br&gt;
inbox like a scientist. At small scale, ten verbose humans outperform&lt;br&gt;
any dashboard you can build.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Recap.&lt;/strong&gt; Zero-friction capture with recordings, reply-able&lt;br&gt;
notifications, a ticket workflow exactly as wide as the team, and a&lt;br&gt;
loop fast enough that the reporter experiences the fix. The feedback&lt;br&gt;
loop isn't support — it's your highest-signal product process.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Reading this and want to file bug #37 yourself? Create a free workspace at&lt;br&gt;
&lt;a href="https://foxyinvoice.com/login" rel="noopener noreferrer"&gt;foxyinvoice.com&lt;/a&gt;, then redeem founding code&lt;br&gt;
&lt;code&gt;U8B4Z8S87X&lt;/code&gt; on the Upgrade page — 6 months of Pro, free, no card. If anything&lt;br&gt;
breaks, there's a feedback button in the app. I read every one.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Next: Chapter 11 — Reach: SEO, AI crawlers, and being the machine-readable answer.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>saas</category>
      <category>webdev</category>
      <category>tutorial</category>
      <category>beginners</category>
    </item>
    <item>
      <title>Building FoxyInvoice — Chapter 9: The daily cadence — twenty minutes that run the business</title>
      <dc:creator>Lith SEO</dc:creator>
      <pubDate>Wed, 23 Sep 2026 01:44:00 +0000</pubDate>
      <link>https://dev.to/seolith/building-foxyinvoice-chapter-9-the-daily-cadence-twenty-minutes-that-run-the-business-1ace</link>
      <guid>https://dev.to/seolith/building-foxyinvoice-chapter-9-the-daily-cadence-twenty-minutes-that-run-the-business-1ace</guid>
      <description>&lt;p&gt;&lt;em&gt;This series is written in the open, from a real production system. This chapter is the daily operating loop — the habit, not the feature, that actually runs the business. [All chapters and diagrams live in the public repo.]&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Products don't fail from missing features; they fail from absent&lt;br&gt;
operators. The antidote isn't more hours — it's a &lt;em&gt;cadence&lt;/em&gt;: a small,&lt;br&gt;
fixed routine you can keep on your worst day. Ours takes about twenty&lt;br&gt;
minutes and runs the entire go-to-market. This chapter is the routine,&lt;br&gt;
the tools that automate each step, and the philosophy underneath:&lt;br&gt;
&lt;strong&gt;machines do the scanning; you do only the judgment.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The loop
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The daily cadence, rendered live:&lt;/strong&gt; &lt;a href="https://foxyinvoice.com/blog/09-cadence/" rel="noopener noreferrer"&gt;triage queues (5 min) → hunt HN/Reddit (10 min) → human judgment on replies (5 min) → feed recurring questions back into the product&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1 — triage (5 min).&lt;/strong&gt; Three inboxes, in order of human urgency:&lt;br&gt;
the &lt;em&gt;feedback console&lt;/em&gt; (users took time to tell us something — Chapter&lt;br&gt;
10), the &lt;em&gt;lead queue&lt;/em&gt; (today's radar sweep, pre-scored), and the &lt;em&gt;error&lt;br&gt;
console&lt;/em&gt; (crashes the app self-reported). Anything actionable gets&lt;br&gt;
assigned; everything else waits unapologetically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2 — hunt (10 min).&lt;/strong&gt; Your future users are publicly complaining&lt;br&gt;
about the problem your product solves — today, in threads you can read.&lt;br&gt;
Two channels, both automated to the edge of judgment:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hacker News radar&lt;/strong&gt;: an hourly sweep (via Algolia's API) for
exact-phrase matches on problem language. One hard-won detail:
&lt;strong&gt;strict phrase matching&lt;/strong&gt; — quoted queries plus a post-filter.
Without it, a search for "wave alternative" matches any comment
containing "alternative," and the queue drowns in noise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reddit search kit&lt;/strong&gt;: bookmarked searches (sorted: new, past month)
across the subs where our segments actually live. We originally
wanted the Reddit API; its policy approval sat in a ticket for weeks,
so we &lt;strong&gt;switched to public search RSS&lt;/strong&gt; and lost nothing. When an
API gate blocks you, ask whether the public page already shows what
you needed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Step 3 — judgment (5 min).&lt;/strong&gt; The radars score; only you decide.&lt;br&gt;
Genuine demand → draft a reply &lt;em&gt;in the lead tool&lt;/em&gt; (so it's tracked),&lt;br&gt;
then post it where the person is, human and specific. Everything else →&lt;br&gt;
skip. One honest reply a day beats fifty spray-and-pray comments; the&lt;br&gt;
latter also gets you banned, which is a growth strategy with an&lt;br&gt;
expiration date.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 4 — feed the machine.&lt;/strong&gt; Every recurring question becomes&lt;br&gt;
product: a FAQ entry, a template, a UX fix, a chapter of this series.&lt;br&gt;
The cadence's real output isn't replies — it's &lt;em&gt;compounding&lt;br&gt;
sculpting of the product toward real demand&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "daily games" is really "operator habits"
&lt;/h2&gt;

&lt;p&gt;The brief for this series asked about daily games and player&lt;br&gt;
engagement — gamified streaks, invites, nudges. Here's the honest&lt;br&gt;
sequence we landed on: &lt;strong&gt;engagement mechanics for users only work after&lt;br&gt;
engagement habits for the operator.&lt;/strong&gt; You can't design a retention loop&lt;br&gt;
for customers you don't have yet; you &lt;em&gt;can&lt;/em&gt; keep a daily loop that&lt;br&gt;
guarantees you'll find, hear, and serve the next ten. The user-facing&lt;br&gt;
loops (streak-like usage meters, founding codes, reminders) arrive in&lt;br&gt;
Chapter 12 — built on top of a cadence that made their data&lt;br&gt;
meaningful.&lt;/p&gt;

&lt;h2&gt;
  
  
  The metrics glance (the extra two minutes)
&lt;/h2&gt;

&lt;p&gt;Dashboard: signups this week, invoices created, feedback count, crawler&lt;br&gt;
hits from the access logs (Chapter 11 made reach &lt;em&gt;measurable&lt;/em&gt; —&lt;br&gt;
GPTBot visiting your sitemap is a real thing you can see now). No&lt;br&gt;
charts-for-charts; four numbers, honestly counted, enough to know if&lt;br&gt;
the week moved.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Recap.&lt;/strong&gt; Triage three queues, hunt two channels with machines doing&lt;br&gt;
the scanning, spend your five judgment minutes on one great reply, and&lt;br&gt;
let every recurring question sculpt the product. Twenty minutes; the&lt;br&gt;
compounding is the point.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Reading this and want a machine worth running a cadence on? Create a free workspace at&lt;br&gt;
&lt;a href="https://foxyinvoice.com/login" rel="noopener noreferrer"&gt;foxyinvoice.com&lt;/a&gt;, then redeem founding code&lt;br&gt;
&lt;code&gt;U8B4Z8S87X&lt;/code&gt; on the Upgrade page — 6 months of Pro, free, no card. If anything&lt;br&gt;
breaks, there's a feedback button in the app. I read every one.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Next: Chapter 10 — Feedback → tickets → shipped: closing the loop while they remember.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>saas</category>
      <category>webdev</category>
      <category>tutorial</category>
      <category>beginners</category>
    </item>
    <item>
      <title>Building FoxyInvoice — Chapter 8: Unit economics — what it actually costs</title>
      <dc:creator>Lith SEO</dc:creator>
      <pubDate>Wed, 23 Sep 2026 00:27:12 +0000</pubDate>
      <link>https://dev.to/seolith/building-foxyinvoice-chapter-8-unit-economics-what-it-actually-costs-3epg</link>
      <guid>https://dev.to/seolith/building-foxyinvoice-chapter-8-unit-economics-what-it-actually-costs-3epg</guid>
      <description>&lt;p&gt;&lt;em&gt;This series is written in the open, from a real production system. This chapter is the cost sheet most build-in-public series skip — real numbers, including the honest ones. [All chapters and diagrams live in the public repo.]&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This is the chapter most build-in-public series skip, which is exactly&lt;br&gt;
why it's here. The numbers below are the real cost structure of running&lt;br&gt;
FoxyInvoice at its current scale, with marginal items marked as&lt;br&gt;
estimates where our invoices round them away. The punchline arrives&lt;br&gt;
early: &lt;strong&gt;the machine costs about a takeaway dinner a month; the real&lt;br&gt;
investment is your evenings.&lt;/strong&gt; Everything after that is arithmetic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixed costs: the whole business on one table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;Cost (monthly)&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;VPS (entire stack, both products)&lt;/td&gt;
&lt;td&gt;~$8–15&lt;/td&gt;
&lt;td&gt;One small instance; the bottleneck is RAM, not CPU&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Domain&lt;/td&gt;
&lt;td&gt;~$1&lt;/td&gt;
&lt;td&gt;Annual renewal amortized&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DNS + Email routing (Cloudflare)&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;td&gt;Free tier, honestly enough&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Email sending (SES)&lt;/td&gt;
&lt;td&gt;~$0&lt;/td&gt;
&lt;td&gt;First 3k+ emails/mo effectively free at our volume; ~$0.10/1k after&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Object storage (PDFs, attachments)&lt;/td&gt;
&lt;td&gt;~$0–2&lt;/td&gt;
&lt;td&gt;Kilobytes-to-megabytes per user&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backups off-box (S3 + email)&lt;/td&gt;
&lt;td&gt;~$0–1&lt;/td&gt;
&lt;td&gt;Compressed dumps are tiny at this scale&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CI/CD&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;td&gt;GitHub free tier + self-hosted runners&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Monitoring / logs&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;td&gt;Access logs + error console in-app; no paid APM&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;≈ $10–19/mo&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~$120–230/year, both products&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two deliberate zeros deserve a sentence. &lt;strong&gt;No managed database&lt;/strong&gt; — a&lt;br&gt;
$15–80/month line item at every "you should use managed Postgres"&lt;br&gt;
checkpoint — because our nightly, size-checked, off-box backups plus&lt;br&gt;
volume-backed containers cover the actual risk at this scale (Chapter&lt;br&gt;
06). And &lt;strong&gt;no paid observability stack&lt;/strong&gt;, because access logs plus an&lt;br&gt;
in-app error console answer the questions a one-person team actually&lt;br&gt;
asks. Managed services are answers to problems of teams you don't&lt;br&gt;
have; buy them when the problem arrives, not the fear.&lt;/p&gt;

&lt;h2&gt;
  
  
  Marginal cost: what one more free user costs
&lt;/h2&gt;

&lt;p&gt;A signup costs: a few database rows (bytes), a welcome email&lt;br&gt;
(~$0.0001), and their invoices' PDFs in storage (cents per year).&lt;br&gt;
&lt;strong&gt;Marginal cost per free user is effectively zero.&lt;/strong&gt; The free tier's&lt;br&gt;
10-actions-per-month limit protects database write amplification and&lt;br&gt;
product focus — not margins. That's why "free for 10 years" is a&lt;br&gt;
promise we can afford to put in the terms: the cost of keeping it is&lt;br&gt;
measured in rounding errors.&lt;/p&gt;

&lt;h2&gt;
  
  
  The paid side: Stripe's take
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Payment links (invoice payments):&lt;/strong&gt; ~2.9% + 30¢ per transaction,
paid by the flow of money, not by sitting there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Subscriptions (Pro $9 / Business $29):&lt;/strong&gt; the same processing rate
on each charge. Net on a Pro subscription after processing: roughly
$8.40/month.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Break-even arithmetic
&lt;/h2&gt;

&lt;p&gt;With ~$15/month fixed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;2 Pro subscribers&lt;/strong&gt; cover the entire infrastructure. Two.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;10 Pro&lt;/strong&gt; ≈ $84 net — the VPS, storage, a domain, and a very
modest monthly "salary" for the robot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;100 Pro&lt;/strong&gt; ≈ $840/mo net — real side-income territory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;1,000 Pro&lt;/strong&gt; ≈ $8.4k/mo — "quit reconsidering" territory, and the
scale where managed databases and paid observability &lt;em&gt;do&lt;/em&gt; enter the
budget (the cost table grows with trust, not before it).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Founding codes barely register: 6 months of Pro granted free costs&lt;br&gt;
nothing cash-out — it defers revenue that mostly wouldn't have existed&lt;br&gt;
yet, and buys testimonials, bug reports, and the honest word-of-mouth a&lt;br&gt;
new product can't buy any other way. Marketing spend to date: $0. The&lt;br&gt;
template gallery, this series, and replies where the users actually&lt;br&gt;
are (Chapter 09) &lt;em&gt;are&lt;/em&gt; the marketing budget.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cost the table can't show
&lt;/h2&gt;

&lt;p&gt;Your time. The honest ledger of a solo SaaS: hundreds of evenings of&lt;br&gt;
build, a standing 20-minute daily cadence, and the occasional 2 a.m.&lt;br&gt;
incident (Chapter 13 prices those in cortisol). We don't monetize that&lt;br&gt;
column, but we don't hide it either — it's the actual investment&lt;br&gt;
everything else compounds on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the revenue honesty stands today
&lt;/h2&gt;

&lt;p&gt;At the time of this writing: paid subscriptions ≈ zero, users in the&lt;br&gt;
dozens, one power user whose feedback shaped the product more than any&lt;br&gt;
roadmap session. The machine is the story — a business whose &lt;em&gt;cost&lt;br&gt;
floor&lt;/em&gt; is dinner-for-one monthly can afford to grow slowly and in&lt;br&gt;
public, which is precisely the experiment this series documents.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Recap.&lt;/strong&gt; ≈$15/month runs two products. Free users cost nothing,&lt;br&gt;
which is why free-forever is a credible promise. Two Pro subscribers&lt;br&gt;
break even; one thousand change your life. Managed services are&lt;br&gt;
bought when the team-shaped problems arrive. And the real currency&lt;br&gt;
was evenings all along.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Reading this and wondering what your own machine would cost? Create a free workspace at&lt;br&gt;
&lt;a href="https://foxyinvoice.com/login" rel="noopener noreferrer"&gt;foxyinvoice.com&lt;/a&gt;, then redeem founding code&lt;br&gt;
&lt;code&gt;U8B4Z8S87X&lt;/code&gt; on the Upgrade page — 6 months of Pro, free, no card. If anything&lt;br&gt;
breaks, there's a feedback button in the app. I read every one.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Next: Chapter 9 — The daily cadence: twenty minutes that run the business.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>saas</category>
      <category>webdev</category>
      <category>tutorial</category>
      <category>beginners</category>
    </item>
    <item>
      <title>Building FoxyInvoice — Chapter 7: CI/CD — push to main and it's live</title>
      <dc:creator>Lith SEO</dc:creator>
      <pubDate>Wed, 23 Sep 2026 00:08:25 +0000</pubDate>
      <link>https://dev.to/seolith/building-foxyinvoice-chapter-7-cicd-push-to-main-and-its-live-3pnj</link>
      <guid>https://dev.to/seolith/building-foxyinvoice-chapter-7-cicd-push-to-main-and-its-live-3pnj</guid>
      <description>&lt;p&gt;&lt;em&gt;This series is written in the open, from a real production system. This chapter is the deploy pipeline end to end, plus three real incidents where it quietly lied to us. [All chapters and diagrams live in the public repo.]&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;There is no staging environment in this system. There is &lt;code&gt;main&lt;/code&gt;, and&lt;br&gt;
&lt;code&gt;main&lt;/code&gt; is production. That sounds reckless until you see the gates —&lt;br&gt;
and the alternative for a solo operator (a staging box you maintain,&lt;br&gt;
promote to "when there's time," and drift from reality) is &lt;em&gt;worse&lt;/em&gt;.&lt;br&gt;
This chapter is the pipeline that makes push-to-deploy safe enough to&lt;br&gt;
sleep through, and the three times it lied to us anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pipeline, end to end
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The deploy pipeline, rendered live:&lt;/strong&gt; &lt;a href="https://foxyinvoice.com/blog/07-cicd/" rel="noopener noreferrer"&gt;push → parallel gates (tests, security, secrets) → SSH deploy → health gates → SPA rebuild → smoke test → IndexNow&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Walk the interesting parts:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The gates run first, in parallel.&lt;/strong&gt; Unit tests plus&lt;br&gt;
Testcontainers-backed integration tests (real Postgres in ephemeral&lt;br&gt;
Docker — tenant-isolation tests from Chapter 04 run here). A&lt;br&gt;
&lt;strong&gt;conformance gate&lt;/strong&gt; checks repo standards against a committed&lt;br&gt;
&lt;em&gt;baseline&lt;/em&gt; — new violations block, frozen historical debt doesn't, so&lt;br&gt;
the fleet can adopt gates without a Big Bang cleanup. And &lt;strong&gt;gitleaks&lt;/strong&gt;&lt;br&gt;
scans for credentials, because everyone eventually pastes one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A concurrency group serializes deploys.&lt;/strong&gt; Two pushes minutes apart&lt;br&gt;
deploy in order, never interleaved. (Keep this term in mind — it has a&lt;br&gt;
war story below.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The deploy is SSH + shell, nothing exotic.&lt;/strong&gt; &lt;code&gt;git reset --hard&lt;/code&gt; on&lt;br&gt;
the host (why manual &lt;code&gt;docker compose up&lt;/code&gt; there deploys stale code — the&lt;br&gt;
host repo is always mid-flight), then a build that receives the private&lt;br&gt;
feed token as a &lt;strong&gt;BuildKit secret&lt;/strong&gt; — mounted during the build,&lt;br&gt;
evaporating after; never in a layer or image history.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Health gates, then SPA, then smoke.&lt;/strong&gt; Each API container must report&lt;br&gt;
healthy before the pipeline proceeds (unhealthy = dump container logs&lt;br&gt;
and fail loudly). Database migrations apply automatically on container&lt;br&gt;
startup — EF Core tracks applied migrations in a history table, so&lt;br&gt;
restarts are idempotent. Then the SPA rebuilds &lt;em&gt;on the host&lt;/em&gt; (including&lt;br&gt;
the SEO prerender step from Chapter 03), swaps into Caddy's directory,&lt;br&gt;
and both &lt;code&gt;/healthz&lt;/code&gt; endpoints must return 200. The final step even&lt;br&gt;
pings IndexNow so discovery engines learn the content changed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Runners: self-hosted, in a pool.&lt;/strong&gt; GitHub's included minutes are&lt;br&gt;
exhausted at a $0 spending limit, so jobs run on the org's own Linux&lt;br&gt;
runners — shared with sibling repos, which means queueing is normal and&lt;br&gt;
monitored. (This will matter in war story #3.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The three times the pipeline lied
&lt;/h2&gt;

&lt;p&gt;Every CI/CD system is a distributed system, and distributed systems&lt;br&gt;
lie. Ours did, three ways, each now a permanent lesson:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The masked failure.&lt;/strong&gt; The build step ended with&lt;br&gt;
&lt;code&gt;|| echo "WARNING: build failed (using cached image)"&lt;/code&gt; and then ran&lt;br&gt;
&lt;code&gt;up -d --no-build&lt;/code&gt;. The build failed (missing token), the script&lt;br&gt;
shrugged, containers kept running yesterday's image — and the&lt;br&gt;
pipeline reported &lt;strong&gt;success&lt;/strong&gt; for eight hours. The lesson, now a&lt;br&gt;
repo-wide rule: &lt;em&gt;a failed build must abort the deploy.&lt;/em&gt; A green&lt;br&gt;
check that shipped nothing is worse than red.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The zombie concurrency holder.&lt;/strong&gt; A cancelled deploy run never&lt;br&gt;
released the concurrency group. Every later run was created&lt;br&gt;
&lt;em&gt;pending with zero jobs&lt;/em&gt; — the job row only appears when the lock is&lt;br&gt;
acquired — and sat there forever while the UI implied queuing was&lt;br&gt;
normal. Diagnosis came from the jobs API: zero jobs + idle runners =&lt;br&gt;
deadlock, not queue. The fix was renaming the group; the lesson is&lt;br&gt;
that "pending forever" is a distinct failure mode you must know how&lt;br&gt;
to recognize.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The label that matched nothing.&lt;/strong&gt; Moving the deploy job to the&lt;br&gt;
self-hosted pool, the runner labels were written as a &lt;em&gt;quoted&lt;br&gt;
string&lt;/em&gt; — which YAML/Actions parsed as &lt;strong&gt;one giant literal label&lt;/strong&gt;&lt;br&gt;
no runner on Earth advertises. Jobs queued eternally again, this&lt;br&gt;
time invisible in a different way. The fix: a real YAML list. The&lt;br&gt;
general lesson: when nothing ever picks up your job, print exactly&lt;br&gt;
what labels it's demanding — don't trust your eyes reading YAML.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Rollback, and why we rarely say the word
&lt;/h2&gt;

&lt;p&gt;Rollback here is &lt;code&gt;git reset&lt;/code&gt; to the previous SHA and redeploy — minutes&lt;br&gt;
of work, no image registry needed (everything builds on the host).&lt;br&gt;
But &lt;strong&gt;database migrations&lt;/strong&gt; complicate true rollback: a migration that&lt;br&gt;
ran won't un-run safely. So the doctrine is &lt;em&gt;forward-fix&lt;/em&gt;: the pipeline&lt;br&gt;
is fast enough (and gates strong enough) that fixing forward beats&lt;br&gt;
reverting schemas. The one hard rule that came from painful&lt;br&gt;
experience: &lt;strong&gt;never apply schema changes out-of-band&lt;/strong&gt; — hand-editing&lt;br&gt;
the production DB twice caused startup crash-loops when the real&lt;br&gt;
migration later collided with the hand-made change. If reality forces&lt;br&gt;
your hand, the follow-up migration must be idempotent&lt;br&gt;
(&lt;code&gt;ADD COLUMN IF NOT EXISTS&lt;/code&gt;) and the history table reconciled.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why push-to-deploy is a psychological feature
&lt;/h2&gt;

&lt;p&gt;For a solo developer, the deepest value isn't the minutes saved — it's&lt;br&gt;
that &lt;strong&gt;shipping stays a habit.&lt;/strong&gt; When deploys are ceremony, you batch&lt;br&gt;
changes, batches breed fear, fear breeds bigger batches. When &lt;code&gt;git&lt;br&gt;
push&lt;/code&gt; &lt;em&gt;is&lt;/em&gt; the release process, every fix ships the day it's written&lt;br&gt;
(and Chapter 10's feedback loop closes while the user still remembers&lt;br&gt;
filing the report).&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Recap.&lt;/strong&gt; Gates before deploy, one deploy at a time, health-gated&lt;br&gt;
containers, secrets that evaporate, smoke tests, and a philosophy of&lt;br&gt;
forward-fix. The pipeline's job is to make shipping boring — and its&lt;br&gt;
own failure modes taught us more than its successes.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Reading this and want to see a push go all the way through? Create a free workspace at&lt;br&gt;
&lt;a href="https://foxyinvoice.com/login" rel="noopener noreferrer"&gt;foxyinvoice.com&lt;/a&gt;, then redeem founding code&lt;br&gt;
&lt;code&gt;U8B4Z8S87X&lt;/code&gt; on the Upgrade page — 6 months of Pro, free, no card. If anything&lt;br&gt;
breaks, there's a feedback button in the app. I read every one.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Next: Chapter 8 — Unit economics: what it actually costs.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>saas</category>
      <category>webdev</category>
      <category>tutorial</category>
      <category>beginners</category>
    </item>
    <item>
      <title>Building FoxyInvoice — Chapter 6: Accounts, hosting, DNS, email — from zero to a domain</title>
      <dc:creator>Lith SEO</dc:creator>
      <pubDate>Mon, 21 Sep 2026 21:48:31 +0000</pubDate>
      <link>https://dev.to/seolith/building-foxyinvoice-chapter-6-accounts-hosting-dns-email-from-zero-to-a-domain-2bh5</link>
      <guid>https://dev.to/seolith/building-foxyinvoice-chapter-6-accounts-hosting-dns-email-from-zero-to-a-domain-2bh5</guid>
      <description>&lt;p&gt;&lt;em&gt;This series is written in the open, from a real production system. This chapter is the step-by-step shopping list for turning code on a laptop into a URL someone else can type. [All chapters and diagrams live in the public repo.]&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This is the chapter people skip and then suffer: the accounts, the&lt;br&gt;
names, the records that turn code on your laptop into a URL someone&lt;br&gt;
else can type. It's step-by-step because order matters — several steps&lt;br&gt;
depend on earlier ones existing. Budget an afternoon and about the cost&lt;br&gt;
of two coffees a month.&lt;/p&gt;
&lt;h2&gt;
  
  
  The shopping list (in dependency order)
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Domain&lt;/strong&gt; — buy first; everything else hangs off it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DNS host&lt;/strong&gt; (Cloudflare, free tier) — points the name at machines
and routes mail.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A VPS&lt;/strong&gt; — one modest Linux box runs the entire system.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Email sending&lt;/strong&gt; (SES SMTP) — accounts need a verified identity and
API keys.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Object storage&lt;/strong&gt; (S3-compatible) — PDFs and feedback attachments.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Payments&lt;/strong&gt; (Stripe) — for payment links and subscriptions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub&lt;/strong&gt; — code + CI/CD (you have this if you're reading on it).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each subsection: what to click, what it costs, and the trap we hit.&lt;/p&gt;
&lt;h2&gt;
  
  
  Domain + Cloudflare
&lt;/h2&gt;

&lt;p&gt;Buy the domain anywhere reputable; point its &lt;strong&gt;nameservers&lt;/strong&gt; at&lt;br&gt;
Cloudflare. Two record concepts do all the work in this project:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A record&lt;/strong&gt; — &lt;code&gt;foxyinvoice.com → &amp;lt;VPS IP&amp;gt;&lt;/code&gt;. The trap: for Caddy's
automatic TLS (next section) to prove domain ownership, the
certificate authority must reach &lt;em&gt;your server directly&lt;/em&gt; — so records
stay &lt;strong&gt;grey-cloud (DNS-only)&lt;/strong&gt;, not proxied. One toggle; TLS silently
fails without it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MX records&lt;/strong&gt; — inbound mail routing (below).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Cloudflare also gives you &lt;strong&gt;Email Routing&lt;/strong&gt; free: rules like&lt;br&gt;
&lt;code&gt;support@yourdomain → your-real-inbox&lt;/code&gt;. The war story from this repo:&lt;br&gt;
rules existed, but the &lt;em&gt;destination address&lt;/em&gt; had an unverified state —&lt;br&gt;
and every feedback notification bounced as a &lt;code&gt;MAILER-DAEMON&lt;/code&gt; storm for&lt;br&gt;
a day before anyone noticed the pattern. Lesson: &lt;strong&gt;email infrastructure&lt;br&gt;
needs a delivery test, not just a config save.&lt;/strong&gt; We now send probe&lt;br&gt;
mails on any routing change.&lt;/p&gt;
&lt;h2&gt;
  
  
  The VPS and the compose file
&lt;/h2&gt;

&lt;p&gt;One Linux box (any provider; ours is a small OVH instance) runs&lt;br&gt;
everything as Docker Compose services:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;postgres (per product) · api (per product) · worker (per product) · caddy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details that earned their place in the repo docs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Compose projects are namespaces.&lt;/strong&gt; The freemium stack runs with
&lt;code&gt;-p fox --env-file .env.freemium&lt;/code&gt;; forget the env-file once and
compose interpolates the &lt;em&gt;other&lt;/em&gt; stack's database password into the
container, which crash-loops on auth failure. Environment files are
credentials wearing a hat.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data lives in volumes&lt;/strong&gt; (&lt;code&gt;pgdata&lt;/code&gt;), so &lt;code&gt;docker compose down&lt;/code&gt;,
rebuild, &lt;code&gt;up&lt;/code&gt; — the data never moves.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Caddy: TLS you never think about
&lt;/h2&gt;

&lt;p&gt;Caddy sits in front, terminates TLS, proxies &lt;code&gt;/api/*&lt;/code&gt; to the api&lt;br&gt;
container by &lt;strong&gt;name&lt;/strong&gt;, and serves the built SPA from disk. Its&lt;br&gt;
superpower is &lt;strong&gt;automatic certificates&lt;/strong&gt; — Let's Encrypt/ZeroSSL issue&lt;br&gt;
and renew with zero cron jobs. The trap that cost us an evening: the&lt;br&gt;
Caddyfile is bind-mounted &lt;strong&gt;read-only as a single file&lt;/strong&gt; into the&lt;br&gt;
container; editing the host file with any inode-replacing tool&lt;br&gt;
(&lt;code&gt;sed -i&lt;/code&gt;) is &lt;em&gt;invisible&lt;/em&gt; to the running container, and reloads happily&lt;br&gt;
keep the old config. Fix: edit in place + &lt;code&gt;docker restart caddy&lt;/code&gt;.&lt;br&gt;
Config-drift bugs look like caching bugs and aren't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Email: SES and the sandbox saga
&lt;/h2&gt;

&lt;p&gt;Transaction email needs a real SMTP relay (your VPS's port-25 mail will&lt;br&gt;
land in spam purgatory). We use AWS SES via SMTP credentials — with the&lt;br&gt;
honest story that our &lt;em&gt;production-grade&lt;/em&gt; SES lives in an older AWS&lt;br&gt;
account while the newer one sits in &lt;strong&gt;sandbox&lt;/strong&gt; (can only email&lt;br&gt;
verified addresses). The pragmatic architecture that fell out: the&lt;br&gt;
mailer takes host/credentials from env vars — swap relays without code&lt;br&gt;
changes. Also: &lt;strong&gt;feedback emails set &lt;code&gt;Reply-To&lt;/code&gt; to the reporter&lt;/strong&gt;, so&lt;br&gt;
staff replies reach users instead of a dead inbox.&lt;/p&gt;

&lt;h2&gt;
  
  
  Object storage + payments
&lt;/h2&gt;

&lt;p&gt;S3-compatible storage holds rendered invoice PDFs and feedback&lt;br&gt;
attachments (screenshots &lt;em&gt;and&lt;/em&gt; screen recordings — ≤ 15 MB, with a&lt;br&gt;
server-side size guard so a giant recording can't choke memory).&lt;br&gt;
Stripe needs only a developer account to start: payment links are&lt;br&gt;
created per invoice server-side; the webhook is the source of truth for&lt;br&gt;
"paid." Subscriptions (Pro/Business) run through Stripe Checkout so,&lt;br&gt;
again, we never see a card number.&lt;/p&gt;

&lt;h2&gt;
  
  
  Secrets: generated, never committed
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;generate-secrets.sh&lt;/code&gt; produces random DB passwords, JWT signing keys,&lt;br&gt;
and admin passwords at first setup; they live in &lt;code&gt;.env&lt;/code&gt; files on the&lt;br&gt;
host, referenced by compose. The repo carries a &lt;strong&gt;secret-scanning gate&lt;/strong&gt;&lt;br&gt;
(gitleaks) in CI because everyone eventually pastes a key somewhere —&lt;br&gt;
the gate turns a bad Tuesday into a red check.&lt;/p&gt;

&lt;h2&gt;
  
  
  The launch checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Domain bought, nameservers at Cloudflare&lt;/li&gt;
&lt;li&gt;[ ] A record → VPS IP, &lt;strong&gt;grey-cloud&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Compose up: postgres healthy → api healthy → worker running&lt;/li&gt;
&lt;li&gt;[ ] Caddy obtained certificates (site loads with the padlock)&lt;/li&gt;
&lt;li&gt;[ ] SES identity verified; &lt;strong&gt;probe email sent and received&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Email routing rules + destination &lt;strong&gt;verified&lt;/strong&gt; (probe again)&lt;/li&gt;
&lt;li&gt;[ ] Backups cron'd, size-checked, landing off-box&lt;/li&gt;
&lt;li&gt;[ ] Secrets generated on-host; repo scan green&lt;/li&gt;
&lt;li&gt;[ ] Stripe webhook URL configured and tested&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;strong&gt;Recap.&lt;/strong&gt; Domain → DNS (grey-cloud!) → one VPS running compose →&lt;br&gt;
Caddy's auto-TLS → SES with probes → storage → Stripe → secrets by&lt;br&gt;
script. The traps are all &lt;em&gt;configuration drift that looks like caching&lt;/em&gt;&lt;br&gt;
— until you write probes for each hop.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Reading this and want a domain of your own to point at? Create a free workspace at&lt;br&gt;
&lt;a href="https://foxyinvoice.com/login" rel="noopener noreferrer"&gt;foxyinvoice.com&lt;/a&gt;, then redeem founding code&lt;br&gt;
&lt;code&gt;U8B4Z8S87X&lt;/code&gt; on the Upgrade page — 6 months of Pro, free, no card. If anything&lt;br&gt;
breaks, there's a feedback button in the app. I read every one.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Next: Chapter 7 — CI/CD: push to main and it's live.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>saas</category>
      <category>webdev</category>
      <category>tutorial</category>
      <category>beginners</category>
    </item>
    <item>
      <title>Building FoxyInvoice — Chapter 5: The domain — invoices, tax, and money math</title>
      <dc:creator>Lith SEO</dc:creator>
      <pubDate>Mon, 21 Sep 2026 02:43:09 +0000</pubDate>
      <link>https://dev.to/seolith/building-foxyinvoice-chapter-5-the-domain-invoices-tax-and-money-math-56h7</link>
      <guid>https://dev.to/seolith/building-foxyinvoice-chapter-5-the-domain-invoices-tax-and-money-math-56h7</guid>
      <description>&lt;p&gt;&lt;em&gt;This series is written in the open, from a real production system. This chapter tours the domain logic: the invoice lifecycle, the tax waterfall, and the exports accountants actually want. [All chapters and diagrams live in the public repo.]&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The "domain" is the part of the software that would be true even if you&lt;br&gt;
ran the business on paper: what an invoice &lt;em&gt;is&lt;/em&gt;, when tax applies, how a&lt;br&gt;
quote becomes a bill. Get this layer right and every interface on top —&lt;br&gt;
web, public link, export file — is just a window onto the same truth.&lt;br&gt;
This chapter tours FoxyInvoice's domain logic, which is where most of&lt;br&gt;
the genuine engineering lives.&lt;/p&gt;

&lt;h2&gt;
  
  
  The invoice state machine
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The invoice lifecycle, rendered live:&lt;/strong&gt; &lt;a href="https://foxyinvoice.com/blog/05-domain/" rel="noopener noreferrer"&gt;Draft → Sent → Paid/Partial/Overdue → Void, and how a quote becomes a real invoice on accept&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Small on purpose. Every extra state is a branch in fifty UIs and a&lt;br&gt;
question for every customer. &lt;code&gt;Overdue&lt;/code&gt; isn't stored so much as&lt;br&gt;
&lt;em&gt;derived&lt;/em&gt;: a daily worker flags Sent invoices past due (which also&lt;br&gt;
triggers reminders — +3 and +14 days, plus a pre-due nudge three days&lt;br&gt;
before). &lt;strong&gt;Credit notes&lt;/strong&gt; exist as a third type for refunds/adjustments.&lt;/p&gt;

&lt;p&gt;Two invariants protect this machine:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Totals are server property.&lt;/strong&gt; The SPA shows a live preview
explicitly labeled &lt;em&gt;"server-confirmed on save"&lt;/em&gt; — but no number the
browser sends is ever trusted. Every mutation (edit a line, change a
discount) triggers full recomputation from the line items up through
the tax engine. The client lying about a total simply has no effect.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Edit windows.&lt;/strong&gt; Invoices lock &lt;code&gt;InvoiceEditWindowHours&lt;/code&gt; (default
24h) after certain transitions — accounting systems don't like
history being quietly rewritten.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The tax engine: nexus, jurisdictions, and a waterfall
&lt;/h2&gt;

&lt;p&gt;US sales tax is the reason this product has a "tax engine" and not a&lt;br&gt;
&lt;code&gt;tax_rate&lt;/code&gt; column. The rules in one paragraph: &lt;em&gt;whether you collect&lt;br&gt;
sales tax for a sale depends on where your business has **nexus&lt;/em&gt;* (a&lt;br&gt;
presence triggering tax duties), what &lt;strong&gt;jurisdiction&lt;/strong&gt; the client is in,&lt;br&gt;
and what &lt;strong&gt;kind of thing&lt;/strong&gt; you sold — service vs good, taxable or&lt;br&gt;
exempt, sometimes with a rate override.*&lt;/p&gt;

&lt;p&gt;The engine models exactly that, evaluated &lt;strong&gt;per line item&lt;/strong&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Is the client tax-exempt? → zero tax, keep the line total.&lt;/li&gt;
&lt;li&gt;Do you have nexus in the client's jurisdiction? → no: don't collect.&lt;/li&gt;
&lt;li&gt;Is a rate known for that jurisdiction? → no: can't collect.&lt;/li&gt;
&lt;li&gt;Is there a taxability rule for this product &lt;em&gt;type&lt;/em&gt; in this
jurisdiction? → use it (including rate overrides); otherwise fall
back to the product's default taxability.&lt;/li&gt;
&lt;li&gt;Apply: &lt;code&gt;lineTotal × rate&lt;/code&gt;, rounded to cents. Never floats.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Jurisdictions carry component breakdowns (state/county/city) for&lt;br&gt;
reporting; a &lt;strong&gt;tax liability report&lt;/strong&gt; sums what you owe where. The&lt;br&gt;
whole calculator is a pure function — no I/O — which makes it the most&lt;br&gt;
tested code in the repo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quotes that convert
&lt;/h2&gt;

&lt;p&gt;A quote is an invoice with &lt;code&gt;type=Quote&lt;/code&gt; and the same math. Conversion&lt;br&gt;
clones header + lines into a real Draft invoice (new number from the&lt;br&gt;
tenant's sequence, atomically reserved with &lt;code&gt;SELECT … FOR UPDATE&lt;/code&gt; so&lt;br&gt;
two concurrent creates can't collide), then voids the quote so it can't&lt;br&gt;
convert twice. The client-facing version (Chapter 12): a share link&lt;br&gt;
with &lt;strong&gt;Accept / Decline&lt;/strong&gt; buttons — accept runs this exact conversion&lt;br&gt;
server-side and emails the owner. One code path for button and menu.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting paid: two directions
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Online:&lt;/strong&gt; per-invoice &lt;strong&gt;Stripe payment links&lt;/strong&gt;; the checkout
webhook auto-records the payment and moves the state machine. No card
data ever touches our servers (Chapter 04).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Offline:&lt;/strong&gt; record payments manually, or email a payment notice to a
dedicated inbound address — SES receives it, S3 stores it, a worker
parses it, and a human confirms before it books. Note the trust
ladder: &lt;em&gt;machines suggest, humans confirm&lt;/em&gt; for anything money-shaped.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Recurring invoices&lt;/strong&gt;: templates with a schedule; a worker generates&lt;br&gt;
Draft invoices on cadence, runs them through the same tax engine, and&lt;br&gt;
can auto-send. Because it reuses the create path, every invariant&lt;br&gt;
applies for free.&lt;/p&gt;

&lt;h2&gt;
  
  
  Exports: the accountant is the audience
&lt;/h2&gt;

&lt;p&gt;An invoicing product that can't hand off to the accountant is a toy.&lt;br&gt;
Three formats ship today — &lt;strong&gt;CSV&lt;/strong&gt; (spreadsheets), &lt;strong&gt;QuickBooks IIF&lt;/strong&gt;&lt;br&gt;
(balanced TRNS/SPL entries — debits and credits must sum to zero or&lt;br&gt;
QuickBooks rejects the file), and &lt;strong&gt;Tally XML&lt;/strong&gt; for India (voucher&lt;br&gt;
envelopes where every voucher must balance). An &lt;strong&gt;export profile&lt;/strong&gt;&lt;br&gt;
per workspace holds country, tax registration number, its&lt;br&gt;
locale-aware label (EIN, GSTIN, VAT No…), and fiscal-year start — so a&lt;br&gt;
PDF invoice in India shows &lt;code&gt;GSTIN: 27ABCDE…&lt;/code&gt; without anyone configuring&lt;br&gt;
"labels." Export code is where you learn accountants are a &lt;em&gt;format&lt;/em&gt;&lt;br&gt;
problem more than a math problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Money, one more time
&lt;/h2&gt;

&lt;p&gt;Every amount is &lt;code&gt;(decimal, currency)&lt;/code&gt;; cross-currency arithmetic&lt;br&gt;
throws; totals are aggregates of per-line decimals rounded once at the&lt;br&gt;
edges (subtot→tax→total each rounded to cents, in a fixed order —&lt;br&gt;
rounding order &lt;em&gt;is&lt;/em&gt; an API contract with your accountant).&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Recap.&lt;/strong&gt; A small state machine guarded by server-owned totals, a pure&lt;br&gt;
per-line tax waterfall driven by nexus and jurisdiction, conversion&lt;br&gt;
instead of duplication, machines-suggest-humans-confirm for inbound&lt;br&gt;
money, and exports designed for the person who'll actually read them.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Reading this and want to send a real invoice through this machine? Create a free workspace at&lt;br&gt;
&lt;a href="https://foxyinvoice.com/login" rel="noopener noreferrer"&gt;foxyinvoice.com&lt;/a&gt;, then redeem founding code&lt;br&gt;
&lt;code&gt;U8B4Z8S87X&lt;/code&gt; on the Upgrade page — 6 months of Pro, free, no card. If anything&lt;br&gt;
breaks, there's a feedback button in the app. I read every one.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Next: Chapter 6 — Accounts, hosting, DNS, email: from zero to a domain.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>saas</category>
      <category>webdev</category>
      <category>tutorial</category>
      <category>beginners</category>
    </item>
    <item>
      <title>Building FoxyInvoice — Chapter 4: Multi-tenancy &amp; data security — the vault</title>
      <dc:creator>Lith SEO</dc:creator>
      <pubDate>Sun, 20 Sep 2026 15:30:48 +0000</pubDate>
      <link>https://dev.to/seolith/building-foxyinvoice-chapter-4-multi-tenancy-data-security-the-vault-l79</link>
      <guid>https://dev.to/seolith/building-foxyinvoice-chapter-4-multi-tenancy-data-security-the-vault-l79</guid>
      <description>&lt;p&gt;&lt;em&gt;This series is written in the open, from a real production system. This chapter is the vault: identity, isolation, authorization, and the tests that prove it holds. [All chapters and diagrams live in the public repo.]&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Multi-tenant means many unrelated companies share one running system and&lt;br&gt;
one database, each seeing only its own data. Get it right and hosting&lt;br&gt;
stays cheap forever. Get it wrong and Business A reads Business B's&lt;br&gt;
invoices — for a billing product, that's the ballgame. This chapter is&lt;br&gt;
how FoxyInvoice's isolation works, and — more important — how it's&lt;br&gt;
&lt;em&gt;proven&lt;/em&gt; every time the tests run.&lt;/p&gt;

&lt;h2&gt;
  
  
  The threat model, honestly
&lt;/h2&gt;

&lt;p&gt;Solo developers imagine hackers. The realistic threat is &lt;strong&gt;your own&lt;br&gt;
future self at 2 a.m. writing a query that forgets the tenant filter.&lt;/strong&gt;&lt;br&gt;
Every mechanism below exists to make that mistake &lt;em&gt;impossible to write&lt;/em&gt;,&lt;br&gt;
or &lt;em&gt;impossible to ship&lt;/em&gt;. Security here is less about firewalls and more&lt;br&gt;
about making the wrong code hard and the breach loud.&lt;/p&gt;

&lt;h2&gt;
  
  
  Identity: who are you
&lt;/h2&gt;

&lt;p&gt;Three ways in, one result — a signed ticket:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Email + password&lt;/strong&gt;, hashed with &lt;strong&gt;Argon2id&lt;/strong&gt; (memory-hard; a leaked
hash is expensive to crack). Passwords are never stored, logged, or
emailed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Google SSO&lt;/strong&gt; — OAuth handshake, automatic workspace provisioning.
Honesty footnote: the OAuth client still lives in an old, legacy-named
cloud project that predates the product — it's the live sign-in
identity for both sites; a rename is eventual.&lt;/li&gt;
&lt;li&gt;Either way, login mints a short-lived &lt;strong&gt;JWT access token&lt;/strong&gt; (claims:
user id, tenant id, permission list) plus a rotating &lt;strong&gt;refresh
token&lt;/strong&gt;. The browser shows the JWT on every API call; the server
verifies the signature — no session table lookup on the hot path.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Isolation: the three locks
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The security model, rendered live:&lt;/strong&gt; &lt;a href="https://foxyinvoice.com/blog/04-security/" rel="noopener noreferrer"&gt;how a JWT’s tenantId flows into global EF Core query filters, a save interceptor that stamps every insert, and CI tests that prove zero leaks&lt;/a&gt;.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Global query filters.&lt;/strong&gt; The ORM (EF Core) attaches
&lt;code&gt;WHERE TenantId = current&lt;/code&gt; to &lt;em&gt;every&lt;/em&gt; query on tenant-scoped
entities — automatically. Application code writes plain
&lt;code&gt;db.Invoices.ToList()&lt;/code&gt; and cannot forget the filter, because the
filter isn't in the application code. (A per-tenant model-cache key
keeps the compiled filters correct when tenants interleave.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The stamp.&lt;/strong&gt; On save, an interceptor writes the caller's tenant
onto every new row. You can't insert into someone else's workspace
even by trying — the framework overwrites you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The proof.&lt;/strong&gt; Integration tests log in as two tenants, create
overlapping data, and assert zero cross-visibility. Isolation that
isn't tested is a vibe. These tests run in CI on every push.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The escape hatches are deliberate and rare: background jobs (reminders,&lt;br&gt;
radars) use &lt;code&gt;IgnoreQueryFilters()&lt;/code&gt; with explicit tenant handling — each&lt;br&gt;
one a conscious, commented decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  Authorization: what may you do
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;RBAC&lt;/strong&gt; — roles map to permission strings (&lt;code&gt;invoice:create&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;tenant:manage_settings&lt;/code&gt;, …). The same list drives three layers: route&lt;br&gt;
guards in the SPA, element-level hiding in templates, and — the one&lt;br&gt;
that actually matters — &lt;code&gt;HasPermission&lt;/code&gt; checks on the API. The UI is&lt;br&gt;
courtesy; the server is law. Custom role builder for workspaces that&lt;br&gt;
want finer grain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scoped access: the share token
&lt;/h2&gt;

&lt;p&gt;For "show this invoice to my client," there's a fourth lock: a&lt;br&gt;
&lt;strong&gt;32-byte unguessable token&lt;/strong&gt; in the URL &lt;em&gt;is&lt;/em&gt; the authorization — no&lt;br&gt;
account for the client, scoped to one document, expiring, revocable.&lt;br&gt;
Quote acceptance (Chapter 12) rides the same mechanism.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rest of the vault checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Audit trail&lt;/strong&gt; — an interceptor snapshots before/after JSON for
changed entities: who, what, when, from which IP. Subtle bug worth
stealing: if a save &lt;em&gt;fails&lt;/em&gt;, the interceptor must drain its pending
audit buffer, or the next successful save writes audit rows for
changes that never committed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backups&lt;/strong&gt; — nightly &lt;code&gt;pg_dump&lt;/code&gt;, gzip, size-checked (a 200-byte
backup is a failed backup), copied &lt;strong&gt;off the box&lt;/strong&gt; (S3 + email). A
backup you've never restored is a hope, not a backup.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No card data, ever&lt;/strong&gt; — Stripe holds payment methods; we store ids.
PCI compliance by not playing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GDPR posture&lt;/strong&gt; — "export everything I own" produces a full JSON
dump (we built it as a feature and test it with it); deletion
disables users immediately and hard-purges after a 30-day grace.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secrets&lt;/strong&gt; — generated by script, living only in &lt;code&gt;.env&lt;/code&gt; files on the
host, caught by a gitleaks gate if they ever try to enter the repo.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Security posture: honest about the stage we're at
&lt;/h2&gt;

&lt;p&gt;Isolation, tests, backups, and no-card-data are the controls we treat&lt;br&gt;
as non-negotiable at any size — the 80/20 that protects what actually&lt;br&gt;
matters for a billing product. Beyond that spine, the security program&lt;br&gt;
matures the same way everything else in this series does: gated by&lt;br&gt;
real usage rather than built speculatively ahead of it. The next layer&lt;br&gt;
— deeper account-takeover hardening, broader defense-in-depth — scales&lt;br&gt;
with the stakes, and grows in the open, in this repo.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Recap.&lt;/strong&gt; Signed identity, filters you can't forget, stamps you can't&lt;br&gt;
spoof, tests that prove it, permissions enforced where it counts, and&lt;br&gt;
scoped tokens for sharing. The vault is mostly &lt;em&gt;removing ways to be&lt;br&gt;
wrong&lt;/em&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Reading this and want to poke at the vault yourself? Create a free workspace at&lt;br&gt;
&lt;a href="https://foxyinvoice.com/login" rel="noopener noreferrer"&gt;foxyinvoice.com&lt;/a&gt;, then redeem founding code&lt;br&gt;
&lt;code&gt;U8B4Z8S87X&lt;/code&gt; on the Upgrade page — 6 months of Pro, free, no card. If anything&lt;br&gt;
breaks, there's a feedback button in the app. I read every one.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Next: Chapter 5 — The domain: invoices, tax, and money math.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>saas</category>
      <category>webdev</category>
      <category>tutorial</category>
      <category>beginners</category>
    </item>
  </channel>
</rss>
