🦊 FoxyInvoice

Chapter 01 — Prerequisites: every term you need, and where it will bite you

This chapter is a map, not a textbook. Every term below gets the same treatment: what it is in plain words, where it lives in FoxyInvoice, and — because this series made an honesty contract with you — the real incident that shows why you should care. You don't need to master any of this before starting. You need to recognize each drawer when you have to open it.

We'll cover six territories: Git, UI vs UX, databases, HTTP & APIs, Docker, and DNS & TLS. In roughly the order they'll hurt you.


1. Git: time travel for a team of one

What it is. Git stores your project's history as a chain of snapshots ("commits"). At any moment you can see what changed, who changed it, why (the commit message), and go back. GitHub is the cloud copy of that history — and, in our setup, the trigger for deployment.

Where it lives here. Everywhere. The rule that shapes all discipline in this repo: pushing to main IS a production deploy. There is no staging server, no release manager. git push → tests → security gates → eight minutes later it's live. That means the commit log is not bureaucracy; it's the changelog and the audit trail of the product.

The vocabulary you need:

The incident that teaches it. During one shipping sprint, two lines of work ended up on the same repo — one committing to a feature branch, one shipping through temporary worktrees to main. The branches diverged; the same file existed in two versions with different line endings, and a push of "everything" nearly shipped stale dependency files alongside new features. Recovery meant diffing trees (git diff --ignore-cr-at-eol — line-ending noise is real) and extracting only the true changes. The lesson: commit small, commit often, one logical change per commit. When every commit does one thing, you can cherry-pick, revert, and reason. When commits do five things, you can't.

One more git habit that pays rent: a strict .gitignore plus an automated secret scanner in CI. Sooner or later you will paste an API key into a config file. The gate that catches it before the public repo does is the cheapest insurance in this whole series.


2. UI vs UX: the screen is not the journey

What they are. UI (user interface) is what's on the screen: buttons, spacing, color, typography. UX (user experience) is whether the person succeeds: did they understand what to do, did it work, did they feel good afterward, did they come back? A beautiful UI with a broken journey fails. An ugly UI that gets someone paid wins.

Where it lives here. The UI system is Angular Material — a component library of pre-built, accessible buttons, tables, form fields (more on "why a component library" in Chapter 03). The UX is a thousand decisions, three of which are worth showing you because they're all real, and two of them were bugs reported by a real user:

  1. The duplicate-invoice bug (pure UX). After saving an invoice, the page stayed open with its button re-enabled. Users double-clicked — "invoice saved" but now there are three identical invoices. The UI was fine; the journey was broken. The fix was UX surgery: disable the button during the save flight, then navigate away to the list with a confirmation. The user's report said it perfectly: "the message is displayed as invoice saved, but the page does not get closed automatically." Listen to users like that; they're doing your UX audit for free.

  2. Progressive disclosure. The app has a "simple view" navigation — Clients, Invoices, Payments, Settings — with everything else folded behind an "Advanced tools" button. First-time freelancers see five options, not forty. That's a UX principle (don't show what the user doesn't need yet) expressed as a UI arrangement.

  3. The frozen preview (technical bug, UX symptom). The invoice editor shows live totals as you type. One day the preview froze at its first value: tax and totals didn't update as line items changed. The UI displayed fine; the experience of the feature was dead. Root cause was a re-system subtlety (a computed value tracking something it couldn't see). Users don't report "your reactivity graph is wrong" — they report "the numbers are wrong." Diagnose UX reports down to the technical layer.

The rule to keep: UI is what they see; UX is what they achieve. You will spend roughly ten times more effort on the second one, and most of that after launch, guided by feedback (Chapter 10 is that entire loop).


3. Databases: one table, one truth

What it is. A relational database (we use PostgreSQL) stores your application's facts as rows in tables with defined columns. A primary key uniquely identifies each row (here, a UUID); a foreign key says "this row belongs to that one" (every invoice points at a client; every client points at a tenant/workspace). Indexes make lookups fast and — just as important — can enforce business rules: the export_profiles table has a unique index on tenant_id, so "one tax profile per workspace" is guaranteed by the database, not by hopeful application code.

The five-paragraph schema tour. tenants (workspaces) → users (people, attached to tenants) → clients (your customers) → invoices (header: number, dates, status) → invoice_line_items (qty × price rows). Everything else — payments, reminders, feedback, audit — hangs off that spine.

Lessons this repo paid full price for:

  1. Money is DECIMAL(18,2), never a float. Binary floats cannot represent 0.10 exactly — the error is tiny, but accountants add thousands of numbers and notice. This app stores every amount as a decimal pair (amount, currency) all the way down; the type system refuses to add dollars to euros (a Money.Add across currencies throws — which once surfaced as a cryptic 500 we'll dissect in Chapter 13).

  2. Migrations are code, and code that doesn't run doesn't exist. A database migration is a versioned script that evolves the schema (create table, add column). One was committed to the repo without its registration file — so the migration engine never saw it, the table never got created in production, and the feature returned 500s for every user. The fix taught rule 3.

  3. Never change production schema by hand. Twice, a column was added directly in psql on the live database. Both times, the next deploy's proper migration collided with the hand-made change and crash-looped the API at startup — a billing app, down, loudly. The surviving rule: migrations must be idempotent where reality is messy (ADD COLUMN IF NOT EXISTS), and the hand-edit is a last resort you document immediately.

  4. An ORM is a lever with traps. An ORM (object-relational mapper — here, Entity Framework Core) lets application code speak in objects while it writes the SQL. Its best trick in this app: global query filters that automatically scope every query to the current tenant (Chapter 04's isolation story). Its worst trap, also real: a repository method loaded an invoice without its line items, so "edit line" returned 404 and "add line" silently recomputed totals against an empty set — data corruption wearing a boring bug's clothes.

  5. Connection pools run out. Postgres defaults to 100 connections; two application pools (API + worker, twice over for two stacks) can exhaust that under burst. Ours is set to 200 with a comment in the docs explaining why. Infrastructure defaults are decisions someone else made for a generic workload; your job is to notice which ones were wrong for yours.


4. HTTP & APIs: the conversation format

What it is. Every interaction between the browser app and the server is an HTTP request/response: the browser asks (a verb plus a URL plus usually a JSON body), the server answers with a status code plus JSON. That's the whole ceremony. "REST" is the set of habits around making those URLs sensible: GET /api/v1/invoices (list), POST /api/v1/invoices (create), PUT /api/v1/invoices/{id} (update), DELETE for the obvious.

The status codes you'll actually meet — each with a real endpoint from this app:

Code Meaning Real example
200 OK GET /healthz — the smoke test
201 Created POST /api/v1/invoices — a new invoice exists
400 Your request was malformed posting an invalid email to /public/template-drafts
401 Not authenticated calling /template-drafts/convert with no token
404 Not found opening a shared invoice link with a bad token
409 Conflict accepting a quote that was already handled — one-shot operations say "no" this way
500 Our fault the crash-loop era; the server logs carry the stack trace

Learning to want the right code is a skill: 404-vs-409 is the difference between "gone" and "already did that," and clients behave better when you're precise.

Authentication, in one paragraph. After login, the server hands the browser a JWT (JSON Web Token): a signed ticket containing the user's id, tenant, and permissions. The browser shows it with every request (Authorization: Bearer …); the server verifies the signature — no session table lookup needed. Long sessions use a second, rotating refresh token. The vocabulary: claims (facts inside the ticket), expiry (tickets are short-lived on purpose), scopes (what the ticket allows — ours carry permission names like invoice:create).

Two patterns worth stealing:


5. Docker: "works on my machine" dies here

What it is. A Docker image is a blueprint (built from a Dockerfile: "start from this OS, install these dependencies, copy this code, run this command"). A container is a running instance of that image — isolated processes with their own filesystem. Docker Compose describes a whole stack (database, API, worker, proxy) in one YAML file so docker compose up reproduces it identically anywhere.

Where it lives here. One modest VPS runs, in containers: two Postgres instances, two .NET APIs, two workers, and a Caddy reverse proxy — the freemium stack and the enterprise stack side by side, sharing nothing but the machine. They find each other by container name on a shared Docker network (caddy proxies to fox-api:8080 — that name is load-bearing). Database files live on a volume (pgdata), which is why you can rebuild containers all day and the data doesn't move.

The three lessons this stack paid for:

  1. Environment files are credentials with a hat on. The freemium stack's compose command must include -p fox --env-file .env.freemium. Run it without the env file once, and compose interpolates the other stack's database credentials into the container, which then crash-loops on auth failure. Config-by- environment ("12-factor") is great; ambiguous defaults are not.

  2. Secrets never go in layers. The API image restores private NuGet packages, which needs a token. The Dockerfile declares a BuildKit secret: the token is mounted during the build step and evaporates — never baked into the image or its history, where anyone with the image could read it.

  3. A masked failure is worse than a failure. The deploy script once ran the image build as build … || echo "WARNING: build failed (using cached image)" and then started containers with up -d --no-build. The build failed (missing token), the script shrugged, and the deploy "succeeded" — shipping eight-hour-old containers while the pipeline showed green. Failures must be allowed to fail. That one line of shell is the most expensive fourteen words in this chapter.


6. DNS & TLS: how a name becomes a page

The chain, in order. You type foxyinvoice.comDNS (the internet's phone book) resolves the name to the server's IP via an A record → your browser connects on port 443 → TLS handshake: the server presents a certificate (signed proof that this server is entitled to this name) → encrypted HTTP flows inside. That certificate is issued by Let's Encrypt, renewed automatically by Caddy — no cron jobs, no expiry surprises. This is "auto-TLS," and it's why we run Caddy at the edge.

Record types you'll touch: A (name → IP), MX (where mail for the domain goes — ours route through Cloudflare Email Routing, which is its own Chapter 06 saga), TXT (free-form text, used for ownership proofs like Search Console verification).

A real subtlety: for the TLS certificate to be issued, the certificate authority must reach our server directly — so the DNS records are "grey-clouded" (DNS-only) rather than proxied through Cloudflare's orange cloud. One toggle in a dashboard, decided by understanding what the handshake needs.

And one forward pointer: the edge is also where crawler hygiene lives. Before our app executes a single line, Caddy has already decided — per URL — whether the response carries X-Robots-Tag: noindex or is allowed into search indexes. The public marketing pages are on an explicit allowlist; everything else (your invoices, the admin console, /upgrade) tells crawlers to stay away. Security and discoverability both start at the front door.


The minimum setup

You can follow this entire series with:

  1. Git and a GitHub account (free).
  2. VS Code (or any editor) and a terminal you don't fear.
  3. Docker Desktop — even if you never write a Dockerfile, running the stack is one command.
  4. Node.js LTS and the .NET SDK — only if you'll modify code, not if you're reading.
  5. The willingness to read an error message top to bottom before panicking. Stack traces are biographies, not insults.
flowchart LR
    subgraph Terms["What you now know"]
        GIT["Git
history & deploys"] UIUX["UI / UX
screen vs journey"] DB["Databases
tables, money, migrations"] HTTP["HTTP & APIs
verbs, codes, tokens"] DOCKER["Docker
images, compose, secrets"] DNS["DNS & TLS
names, certs, the edge"] end subgraph Bites["Where each one bites"] DEPLOY["the deploy pipeline"] USERS["real users & feedback"] DATA["data integrity"] EDGE["the front door"] end GIT --> DEPLOY DOCKER --> DEPLOY DNS --> EDGE UIUX --> USERS HTTP --> USERS DB --> DATA

Every one of those arrows has a scar somewhere in this repo's history — and each scar gets its full story in the chapters ahead.


Next: Chapter 02 — Product thinking: pick a real problem, price it, position it