TWiT-Ads v2 Rebuild

B4 — Order Editor: Catalog, Softness, Terms Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: superpowers:test-driven-development on every task, plus superpowers:executing-plans (or subagent-driven-development) to run task-by-task. Checkbox steps. Read ~/Projects/twit-ads/CLAUDE.md, the Specification (§3 domain model, §"Order math", §4 workflow W1, §8 B4 bullet), Plans/phase-a-review.md, Plans/b-phase-handoff.md, and Plans/B3-episode-lines.md's "Decisions & deviations" (the *line.ShowID deref landmine this stage must fix) first.

Goal: Finish "W1 complete" — the rest of the order editor beyond B3's episode lines. Adds the four catalog line kinds (billboard week, newsletter, social post, custom — banner folds into the same undated-catalog shape as newsletter/social/custom), value-add (softness) marking, order-level discount wired into the live totals, terms boilerplate auto-fill with {{advertiser}}/{{days}}/{{ad_minimum}} substitution, and Notes auto-generation from show host data. Also fixes the known B3 landmine: buildLineEditorView unconditionally dereferences line.ShowID, which panics the moment a catalog line (no show) reaches it.

Architecture: Extends internal/orders (episode-line methods from B3) with catalog-line, billboard-week, and value-add store methods on the same *Store. Adds a new internal/terms package (mirrors internal/rates' shape: a thin store over a small admin-editable table). internal/orders' Store.Lines now resolves Quantity from either computed date-counts (episode_ad/billboard_week) or a stored column (newsletter/social_post/banner/custom) depending on Kind — B3's own decision log flagged this migration as B4's job. lineEdit/buildLineEditorView gain a Kind-based branch: three distinct line-editor templates now exist (episode-dated, week-dated, undated-catalog) instead of B3's one.

Tech Stack: Go 1.25, pgx, stdlib net/http+html/template, htmx (vendored, from B3), goose migration 00008_catalog_softness_terms.sql.

Global Constraints

Domain decisions (flag to Leo if wrong)

  1. banner is an undated, stored-quantity catalog line, not a week-dated one, despite the master spec's domain-model prose saying "billboard_week/banner_week lines carry week-start dates." That "banner_week" kind name doesn't exist in the schema — B3's migration built exactly six kinds (episode_ad, billboard_week, newsletter, social_post, banner, custom), and migrations never get edited after the fact. The catalog's own unit labels (migration 00005_ratecard.sql) settle it: Opening Billboardweek, Bannerimpression. A per-impression buy has no natural "which week" question the way a billboard slot does (confirmed by Leo's Follow-up-Questions answer: "opening billboard... the billboard on all shows that week" — a single network-wide weekly resource; nothing analogous is said about banners). So billboard_week is the only dated catalog kind; newsletter/social_post/banner/custom all use the new stored order_lines.quantity column.
  2. Billboard-week availability is real, not deferred (Leo, 2026-07-08, overriding this plan's own draft recommendation to punt it like B3 punted episode SoldCount). Because there is no B5 placements system yet to gate against, "sold" for a billboard week counts any non-void order's claim on that week_start_date — pending included, not just finalized. This is a deliberate divergence from the episode-ad rule ("pending proposals consume nothing," ordermath.ClassifyDate's own doc comment) forced by the absence of B5's infrastructure: without counting pending claims, a "build it now" billboard picker would have literally nothing to show (no orders are ever finalized pre-B5). The picker is visibility, not hard enforcement — there's no DB-level constraint stopping two pending orders from both claiming the same week (that's B5's job, exactly like episode-slot enforcement is), but each order editor will show the conflict via the same white/red/grey pattern B3 built. BillboardWeekSoldCount takes an excludeLineID param so a line's own claim doesn't count against itself, replacing the "finalized-only" temporal exclusion episode dates get for free.
  3. The billboard-week picker window is the next 12 Mondays starting from the Monday on/after today — not the current week if today falls mid-week (avoids selling a week that's already partly elapsed). Mirrors B3's 12-week episode-picker default (Domain Decision #5 there) for UX consistency; not spec-mandated, a scoping call.
  4. quantity is a plain stored int column on order_lines, unused (left 0) for episode_ad/billboard_week. No CHECK constraint ties it to kind — same "unused branch stays a harmless zero" pattern the tagged-union already uses for custom_label/product_id/show_id. Store.Lines resolves the field OrderLine.Quantity returns by branching on Kind: computed date-count for the two dated kinds, the stored column for the other four.
  5. Order-level discount lives on the orders row itself (discount_kind/discount_bp/discount_flat_cents), mirroring ordermath.Discount's own three-field tagged-union shape exactly (Kind, BP, FlatCents) — no new table. Edited on the existing order-header form (order_form.html, alongside terms/notes), since discount is an order-wide fact like those, not a per-line one. Known UX gap, not fixed this stage: the live totals block only renders on a line's editor page (order_line_edit.html family), not on order_form.html itself — changing the discount won't visibly recompute totals until the salesperson opens (or is already on) a line. This is the same screen-separation B2/B3 already established (order header vs. line detail are different pages); the spec's single-page "Order editor" vision was never literally built that way, and unifying navigation is out of scope here — B6 (order outputs) is the more natural place a single consolidated view finally has to exist anyway (a print/PDF view). Flagging, not fixing.
  6. terms_templates seeds two rows: net (real captured boilerplate) and due_upon_receipt (a visibly-incomplete placeholder). The net-terms text was captured verbatim from Leo's 2025/2027-format reference orders (Plans/B2-orders-list-crud.md's "full B4 terms boilerplate captured" section) — ready to seed almost as-is, with {{days}}/{{ad_minimum}} substitution tokens replacing the fixed "30 days"/"$20,000" the captured text hardcodes, and "broadcast order"/"broadcast calendar month" replaced with "order"/"calendar month" per the house rule. The due-upon-receipt variant's actual legacy text was never captured~/Obsidian/lgl/AI/TWiT-Ads/Follow-up Questions.md flags this as "⚠️ blocks order-editor stage" and Leo's own answer there ("use the existing Due Upon Receipt language") confirms real text exists in the legacy system, just not yet pasted into these docs. Seeding a plausible-looking legal paragraph would be worse than an honest gap: the seeded due_upon_receipt template body reads [Due-upon-receipt terms pending — Lisa to supply the verbatim legacy text; see Follow-up Questions.md Q1. Admin-editable at /admin/terms.], visibly a placeholder, editable in the same admin UI as the real template, and every TermsText field it fills stays freely hand-editable regardless. This one content gap does not block anything else in this stage — the auto-fill machinery, quantity/discount/value-add features, and Notes generation are all independent of what the due-upon-receipt text actually says.
  7. {{ad_minimum}} is an editable per-template field (terms_templates.ad_minimum_cents), not a hardcoded constant in the body text (Leo, 2026-07-08) — matches the spec's "templates are data, not code" principle for exactly the reason the captured boilerplate's own open question raised: only 3 data points confirm the $20,000 figure, and it's cheap to make it a real field instead of prose to hand-edit later. Defaults to $20,000 (2,000,000 cents) on the seeded net row.
  8. The Notes auto-fill's fixed ad-format sentence uses the hardcoded phrase "(1-2 minutes)" (Leo, 2026-07-08) — the majority phrasing across 4 reference samples. Notes stays a freely-editable textarea after auto-fill runs (same pattern as TermsText), so an outlier deal needing "(1-5 minutes)" gets hand-edited post-fill; no new per-order duration field.
  9. shows.ad_voice_host is a new, plain, admin-editable text column — never parsed from shows.notes's free text. Per Plans/B2-orders-list-crud.md's own resolved finding: "may change at some point" (currently only Leo or Mikah voice ads) means a string-matching rule against notes would silently break the day a third host starts voicing ads. Empty string (not null) when unset, consistent with every other optional text field in this codebase (custom_label, etc.) — "no sentinel values" reads naturally here since "" unambiguously means "not yet set," not a real host name.
  10. Notes generation groups episode-ad lines by their show's ad_voice_host, one line per host, shows joined by comma — reproducing the captured sample format exactly ("WW, TWIT, MBW & SN hosted by Leo Laporte" — this plan uses , as the join separator rather than trying to reproduce the sample's inconsistent &-before-last-item styling, a cosmetic simplification, not a data-accuracy one). A show with an empty ad_voice_host renders as ? in the generated line (visibly wrong, not silently dropped) rather than being skipped — Debi/Lisa should notice and either fix the show's admin data or hand-edit the generated Notes text, matching this stage's "flag gaps loudly" pattern (Domain Decision #6).
  11. Value-add toggle is a plain POST-redirect-GET, not an htmx in-place swap — unlike B3's date-picker toggle (a rapid-fire, click-many-times UI element that justified the OOB-swap complexity), marking a line value-add is a rare, deliberate action. A full page reload is the right amount of machinery; adding htmx here would be complexity B3's own precedent doesn't require repeating.
  12. shows.ad_voice_host is seeded via migration UPDATE statements matched by exact title, verified against the live testbed first (Task 1, Step 0) — the 13-show mapping is real ground truth from Plans/B2-orders-list-crud.md, but this plan's author cannot query the live Framework testbed directly; the implementer must confirm exact title spellings before writing the UPDATE statements, not trust this plan's titles blindly.
  13. The spec's B4 bullet lists "presentation toggle" as in-scope; this plan builds no such thing, deliberately. Plans/B2-orders-list-crud.md's "Second post-verification fix" already removed the lumped/below_the_line choice entirely (Leo: "you don't need a lumped choice — we will always show it below the line") — orderFromForm has hardcoded Presentation: "below_the_line" since B2, locked in by TestOrderPresentationAlwaysBelowTheLine. Nothing in this stage reopens that decision; it's noted here only so a reader diffing this plan against the spec's B4 bullet doesn't mistake the omission for a miss.

Task 1: Migration — discount, catalog quantity, ad-voice-host, terms templates

Files:

If the Framework testbed compose stack is reachable, confirm the 13 titles from Plans/B2-orders-list-crud.md's ad-voicing-host table match exactly what's in the shows table (docker compose exec postgres psql -U twitads -c "SELECT title FROM shows ORDER BY title;", or via just if a DB shell target exists). If the stack isn't up, bring it up briefly (cd app && docker compose up -d) to check, then leave it as you found it. Use exact matches in Step 2's UPDATE statements — do not guess spelling/capitalization.

-- +goose Up
-- Order-level discount: mirrors ordermath.Discount's own tagged-union shape
-- (Kind, BP, FlatCents) exactly. Unused branch stays its zero value — no
-- sentinel, matching every other tagged-union column in this schema.
CREATE TYPE order_discount_kind AS ENUM ('none', 'percent', 'flat');
ALTER TABLE orders ADD COLUMN discount_kind order_discount_kind NOT NULL DEFAULT 'none';
ALTER TABLE orders ADD COLUMN discount_bp int NOT NULL DEFAULT 0 CHECK (discount_bp >= 0 AND discount_bp <= 10000);
ALTER TABLE orders ADD COLUMN discount_flat_cents bigint NOT NULL DEFAULT 0 CHECK (discount_flat_cents >= 0);

-- order_lines.quantity: the stored unit count for undated catalog kinds
-- (newsletter/social_post/banner/custom). episode_ad/billboard_week compute
-- Quantity from order_line_dates instead and leave this column 0 — see
-- Plans/B4-catalog-softness-terms.md Domain Decision #4 (B3's own decision
-- log flagged this exact migration as B4's job).
ALTER TABLE order_lines ADD COLUMN quantity int NOT NULL DEFAULT 0 CHECK (quantity >= 0);

-- Which host voices a show's ad reads — explicit field, never parsed from
-- shows.notes' free text (Domain Decision #9). Empty string = not yet set.
ALTER TABLE shows ADD COLUMN ad_voice_host text NOT NULL DEFAULT '';

-- Ground-truth mapping from Plans/B2-orders-list-crud.md (Leo, 2026-07-07),
-- verified against the live testbed in Step 0 before this migration ran.
UPDATE shows SET ad_voice_host = 'Mikah Sargent' WHERE title IN
    ('Hands-On Apple', 'Hands-On Tech', 'Tech News Weekly', 'iOS Today');
UPDATE shows SET ad_voice_host = 'Leo Laporte' WHERE title IN
    ('Hands-On Windows', 'Home Theater Geeks', 'Intelligent Machines', 'MacBreak Weekly',
     'Security Now', 'This Week in Space', 'This Week in Tech', 'Untitled Linux Show', 'Windows Weekly');

-- terms_templates: seeded boilerplate for the auto-fill button. Editable in
-- admin (spec: "templates are data, not code"). See Domain Decisions #6/#7.
CREATE TYPE terms_template_kind AS ENUM ('net', 'due_upon_receipt');
CREATE TABLE terms_templates (
    id               bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    kind             terms_template_kind NOT NULL UNIQUE,
    body_text        text NOT NULL,
    ad_minimum_cents bigint NOT NULL DEFAULT 2000000 CHECK (ad_minimum_cents >= 0),
    updated_at       timestamptz NOT NULL DEFAULT now()
);
INSERT INTO terms_templates (kind, body_text) VALUES ('net', $NET$This order is between TWiT, LLC ("TWiT.tv") and {{advertiser}} ("Advertiser"). Advertiser to pay invoices to TWiT.tv within {{days}} days of the invoice date.

Each party shall treat the provisions of this order as confidential and shall not do anything which could result in the same being disclosed or used in an unauthorized manner. The advertising minimum is {{ad_minimum}} per quarter.

Category Exclusivity Policy - is only extended to the show episode that the ad appears on and for the product/service that the advertiser is promoting. To obtain network exclusivity for the duration of your campaign, you must buy all shows. We do not offer host exclusivity at all.

Right of First Refusal Policy - when TWiT.tv asks Advertiser if they plan to renew, then Advertiser has 5 business days to respond or category can be filled elsewhere.

Tracking Policy - we do not allow Advertiser tracking pixels but allow Advertiser UTM tags on the show episode pages. Spotify Ad Analytics is available to all for free. Podscribe is available for $.95 CPM, as TWiT.tv does not cover the cost.

Ad Format - one host-read ad (1 - 2 minutes) that will be served in fair rotation. If the lead host is unavailable, then a co-host or in-house host will perform the ad read.

Ad Copy ("Copy") must be sent to TWiT.tv two weeks prior to advertising commencement date. Copy can be changed weekly thereafter. Updated Copy must be sent to TWiT.tv one week prior. Graphics for lower third can be changed monthly.

TWiT.tv has the right to review and approve Copy to ensure there are no conflicts with existing advertisers. Any discounts offered to the TWiT.tv audience cannot be changed unless an expiration date is provided with the offer or TWiT.tv receives a 14-day advance written notice.

Advertiser to have presence on the TWiT.tv sponsor page, show episode pages, and in RSS feed episode description. Courtesy commercials on video shows, can be provided with every ad read and used anywhere besides paid media. If advertising minimum is met, Advertiser to receive quarterly social media promotion on TWiT social media accounts.

Downloads/impressions are provided to Advertiser every Friday (unless it's a holiday or EOM is earlier that week). These will include audio and video podcast downloads compliant with IAB Podcast Measurement v2.2 Guidelines. Additionally, we add in YouTube numbers because they are tracked separately.

Downloads/impressions will be reviewed for make-good purposes approximately 30 days after the calendar month ends. If a make-good is needed, then it will be issued by TWiT.tv as soon as the Advertiser approves it. TWiT.tv reserves the right to remove ads from its content after impressions are fully delivered to Advertiser.

Cancellation Policy: All quarterly orders are noncancellable. On annual orders or orders longer than a quarter, the first quarter is noncancellable, but Advertiser may request that TWiT.tv cancel episode(s) listed in this order 90 days prior to the scheduled run date or 45 days before the next quarter.$NET$);
INSERT INTO terms_templates (kind, body_text) VALUES ('due_upon_receipt',
    '[Due-upon-receipt terms pending — Lisa to supply the verbatim legacy text; see Follow-up Questions.md Q1. Admin-editable at /admin/terms.]');

-- +goose Down
DROP TABLE terms_templates;
DROP TYPE terms_template_kind;
ALTER TABLE shows DROP COLUMN ad_voice_host;
ALTER TABLE order_lines DROP COLUMN quantity;
ALTER TABLE orders DROP COLUMN discount_flat_cents;
ALTER TABLE orders DROP COLUMN discount_bp;
ALTER TABLE orders DROP COLUMN discount_kind;
DROP TYPE order_discount_kind;

The $NET$...$NET$ is a Postgres dollar-quoted string literal — required because the boilerplate itself contains single quotes ("TWiT.tv") that would otherwise break a plain '...'-quoted literal.

func TestOrderDiscountRoundTrip(t *testing.T) {
	pool := testPool(t)
	advID, _, userID := seedFixtures(t, pool)
	s := &Store{Pool: pool}
	ctx := context.Background()
	created, err := s.Create(ctx, Order{
		Title: "Discount test", AdvertiserID: advID, SalespersonID: userID, Year: 2026,
		DiscountKind: "percent", DiscountBP: 500,
	})
	if err != nil {
		t.Fatalf("create: %v", err)
	}
	if created.DiscountKind != "percent" || created.DiscountBP != 500 {
		t.Fatalf("discount not round-tripped on create: %+v", created)
	}
	got, err := s.Get(ctx, created.ID)
	if err != nil || got.DiscountKind != "percent" || got.DiscountBP != 500 {
		t.Fatalf("discount not round-tripped on get: %+v (%v)", got, err)
	}
	got.DiscountKind, got.DiscountFlatCents, got.DiscountBP = "flat", 50000, 0
	if err := s.Update(ctx, got); err != nil {
		t.Fatalf("update: %v", err)
	}
	got2, _ := s.Get(ctx, created.ID)
	if got2.DiscountKind != "flat" || got2.DiscountFlatCents != 50000 {
		t.Fatalf("discount not round-tripped on update: %+v", got2)
	}
}

func TestCatalogLineQuantityStoredNotComputed(t *testing.T) {
	pool := testPool(t)
	advID, _, userID := seedFixtures(t, pool)
	s := &Store{Pool: pool}
	ctx := context.Background()
	ord, _ := s.Create(ctx, Order{Title: "W1", AdvertiserID: advID, SalespersonID: userID, Year: 2026})
	// Insert a bare newsletter line directly (AddCatalogLine lands in Task 2;
	// this test only proves Lines() reads the stored quantity column for a
	// non-dated kind — it does not exercise line creation).
	_, err := pool.Exec(ctx,
		`INSERT INTO order_lines (order_id, kind, product_id, cpm_cents, rate_cents, impressions_per_unit, quantity)
		 VALUES ($1, 'newsletter', 2, 0, 50000, 0, 3)`, ord.ID)
	if err != nil {
		t.Fatalf("seed newsletter line: %v", err)
	}
	lines, err := s.Lines(ctx, ord.ID)
	if err != nil || len(lines) != 1 {
		t.Fatalf("lines: %+v (%v)", lines, err)
	}
	if lines[0].Quantity != 3 {
		t.Errorf("newsletter line quantity = %d, want 3 (stored column, not date-computed)", lines[0].Quantity)
	}
}

Extend app/internal/shows/shows_test.go:

func TestAdVoiceHostRoundTrip(t *testing.T) {
	pool := testPool(t)
	s := &Store{Pool: pool}
	ctx := context.Background()
	sh, err := s.Create(ctx, Show{
		Title: "AdVoice Test Show", Abbrev: "AVT", Weekdays: []int{2}, MaxAds: 3,
		RecordStart: "13:00", RecordEnd: "14:00", Active: true, AdVoiceHost: "Leo Laporte",
	})
	if err != nil {
		t.Fatalf("create: %v", err)
	}
	if sh.AdVoiceHost != "Leo Laporte" {
		t.Fatalf("ad_voice_host not round-tripped on create: %+v", sh)
	}
	got, err := s.Get(ctx, sh.ID)
	if err != nil || got.AdVoiceHost != "Leo Laporte" {
		t.Fatalf("ad_voice_host not round-tripped on get: %+v (%v)", got, err)
	}
}

func TestAdVoiceHostSeeded(t *testing.T) {
	pool := testPool(t)
	ctx := context.Background()
	var host string
	err := pool.QueryRow(ctx, `SELECT ad_voice_host FROM shows WHERE title = 'Security Now'`).Scan(&host)
	if err != nil {
		t.Skipf("no 'Security Now' show in this test DB (fixture-only run): %v", err)
	}
	if host != "Leo Laporte" {
		t.Errorf("Security Now ad_voice_host = %q, want %q", host, "Leo Laporte")
	}
}

(TestAdVoiceHostSeeded is a real-data smoke check, not a fixture test — it skips cleanly on a fresh/fixture-only DB where no "Security Now" row exists, and only asserts when one does. This is intentionally soft: the migration's UPDATE runs unconditionally, but the test DB used by just test-db may or may not carry real show data depending on how it was seeded.)

Create app/internal/terms/testhelper_test.go:

package terms

import (
	"context"
	"os"
	"testing"
	"time"

	"github.com/jackc/pgx/v5/pgxpool"
	"twit.tv/twitads/internal/db"
)

func testPool(t *testing.T) *pgxpool.Pool {
	t.Helper()
	url := os.Getenv("TEST_DATABASE_URL")
	if url == "" {
		t.Skip("TEST_DATABASE_URL not set; run via `just test-db`")
	}
	if err := db.Migrate(url); err != nil {
		t.Fatalf("migrate: %v", err)
	}
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	pool, err := pgxpool.New(ctx, url)
	if err != nil {
		t.Fatalf("connect: %v", err)
	}
	t.Cleanup(pool.Close)
	return pool
}

Create app/internal/terms/terms_test.go:

package terms

import (
	"context"
	"strings"
	"testing"
)

func TestListSeeded(t *testing.T) {
	pool := testPool(t)
	s := &Store{Pool: pool}
	list, err := s.List(context.Background())
	if err != nil {
		t.Fatalf("list: %v", err)
	}
	if len(list) != 2 {
		t.Fatalf("got %d templates, want 2 (net + due_upon_receipt): %+v", len(list), list)
	}
}

func TestGetNetTemplateHasSubstitutionTokens(t *testing.T) {
	pool := testPool(t)
	s := &Store{Pool: pool}
	tmpl, err := s.Get(context.Background(), "net")
	if err != nil {
		t.Fatalf("get: %v", err)
	}
	for _, token := range []string{"{{advertiser}}", "{{days}}", "{{ad_minimum}}"} {
		if !strings.Contains(tmpl.BodyText, token) {
			t.Errorf("net template missing substitution token %q", token)
		}
	}
	if strings.Contains(strings.ToLower(tmpl.BodyText), "broadcast") {
		t.Error("net template body contains the word \"broadcast\" — house rule violation")
	}
	if tmpl.AdMinimumCents != 2000000 {
		t.Errorf("ad minimum = %d, want 2000000 ($20,000 default)", tmpl.AdMinimumCents)
	}
}

func TestUpdateTemplate(t *testing.T) {
	pool := testPool(t)
	s := &Store{Pool: pool}
	ctx := context.Background()
	tmpl, err := s.Get(ctx, "due_upon_receipt")
	if err != nil {
		t.Fatalf("get: %v", err)
	}
	tmpl.BodyText = "Updated due-upon-receipt text."
	tmpl.AdMinimumCents = 1500000
	if err := s.Update(ctx, tmpl); err != nil {
		t.Fatalf("update: %v", err)
	}
	got, _ := s.Get(ctx, "due_upon_receipt")
	if got.BodyText != "Updated due-upon-receipt text." || got.AdMinimumCents != 1500000 {
		t.Errorf("update didn't persist: %+v", got)
	}
}

Run: cd app && just test-db Expected: FAIL to compile / new tables missing.

app/internal/orders/orders.go — add three fields to Order:

type Order struct {
	ID                 int64
	Title              string
	AdvertiserID       int64
	AgencyID           *int64
	SalespersonID      int64
	Status             string
	Year               int
	Quarters           []int
	Terms              string
	TermsText          string
	Notes              string
	Presentation       string
	MakegoodForOrderID *int64
	Version            int
	FinalizedAt        *time.Time
	FinalizedBy        *int64
	// Discount mirrors ordermath.Discount's own tagged-union shape (Kind, BP,
	// FlatCents) — see Plans/B4-catalog-softness-terms.md Domain Decision #5.
	DiscountKind      string // none|percent|flat
	DiscountBP        int    // meaningful only when DiscountKind == "percent"
	DiscountFlatCents int64  // meaningful only when DiscountKind == "flat"
}

Find orderCols/scanOrder/the INSERT/UPDATE SQL in Create/Update/Get/List (the exact column list built in B2) and add the three new columns to each: orderCols gains , discount_kind, discount_bp, discount_flat_cents; scanOrder's Scan(...) call gains &o.DiscountKind, &o.DiscountBP, &o.DiscountFlatCents; Create's INSERT statement gains the three columns and matching $N placeholders/args; Update's UPDATE ... SET gains discount_kind=$N, discount_bp=$N, discount_flat_cents=$N. Follow the exact existing pattern for terms_text/notes (already-established plain-field columns) — do not introduce a new helper shape for these three.

Lines — change how Quantity is resolved. Currently every line's Quantity gets overwritten by the batched date-count regardless of kind; change the final loop to branch:

	for i := range out {
		switch out[i].Kind {
		case "episode_ad", "billboard_week":
			out[i].Quantity = counts[out[i].ID] // computed from order_line_dates
		default:
			// newsletter/social_post/banner/custom: Quantity already holds the
			// stored order_lines.quantity column value from the scan below —
			// leave it as-is.
		}
	}

orderLineCols/scanOrderLine — add the stored quantity column so every line's raw stored value lands in l.Quantity before the branch above runs:

const orderLineCols = `id, order_id, kind, show_id, product_id, custom_label, cpm_cents, rate_cents, impressions_per_unit, is_value_add, quantity`

func scanOrderLine(row pgx.Row) (OrderLine, error) {
	var l OrderLine
	err := row.Scan(&l.ID, &l.OrderID, &l.Kind, &l.ShowID, &l.ProductID, &l.CustomLabel, &l.CPMCents, &l.RateCents, &l.ImpressionsPerUnit, &l.IsValueAdd, &l.Quantity)
	return l, err
}

AddEpisodeLine's INSERT doesn't need to change — it never specifies quantity, so it defaults to 0 in the DB (correct: the Lines branch above overwrites it with the date-computed count for episode_ad regardless of what's stored).

app/internal/shows/shows.go — add AdVoiceHost string to the Show struct, add ad_voice_host to showCols, add &sh.AdVoiceHost to scanShow's Scan call, and add ad_voice_host to Create's INSERT and Update's SET clause, mirroring the existing notes/default_td plain-string-column pattern exactly.

Create app/internal/terms/terms.go:

// Package terms owns the two admin-editable boilerplate templates used to
// auto-fill an order's TermsText — one net-terms body (parameterized by
// {{days}}/{{advertiser}}/{{ad_minimum}}), one due-upon-receipt body. Mirrors
// internal/rates' shape: a thin store over a small admin-editable table.
package terms

import (
	"context"
	"fmt"

	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgxpool"
)

type Template struct {
	ID             int64
	Kind           string // net|due_upon_receipt
	BodyText       string
	AdMinimumCents int64
}

type Store struct{ Pool *pgxpool.Pool }

const templateCols = "id, kind, body_text, ad_minimum_cents"

func scanTemplate(row pgx.Row) (Template, error) {
	var t Template
	err := row.Scan(&t.ID, &t.Kind, &t.BodyText, &t.AdMinimumCents)
	return t, err
}

func (s *Store) List(ctx context.Context) ([]Template, error) {
	rows, err := s.Pool.Query(ctx, `SELECT `+templateCols+` FROM terms_templates ORDER BY kind`)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var out []Template
	for rows.Next() {
		t, err := scanTemplate(rows)
		if err != nil {
			return nil, err
		}
		out = append(out, t)
	}
	return out, rows.Err()
}

func (s *Store) Get(ctx context.Context, kind string) (Template, error) {
	t, err := scanTemplate(s.Pool.QueryRow(ctx, `SELECT `+templateCols+` FROM terms_templates WHERE kind=$1`, kind))
	if err != nil {
		return Template{}, fmt.Errorf("get terms template %q: %w", kind, err)
	}
	return t, nil
}

func (s *Store) Update(ctx context.Context, t Template) error {
	tag, err := s.Pool.Exec(ctx,
		`UPDATE terms_templates SET body_text=$2, ad_minimum_cents=$3, updated_at=now() WHERE kind=$1`,
		t.Kind, t.BodyText, t.AdMinimumCents)
	if err != nil {
		return fmt.Errorf("update terms template %q: %w", t.Kind, err)
	}
	if tag.RowsAffected() == 0 {
		return fmt.Errorf("update terms template %q: not found", t.Kind)
	}
	return nil
}

Run: cd app && go vet ./... && just test-db Expected: internal/orders, internal/shows, internal/terms all green.

git add app/internal/db/migrations/00008_catalog_softness_terms.sql app/internal/terms/ \
        app/internal/orders/orders.go app/internal/orders/orders_test.go \
        app/internal/shows/shows.go app/internal/shows/shows_test.go
git commit -m "feat(orders,shows,terms): discount fields, catalog quantity, ad-voice-host, terms_templates"

Task 2: Catalog lines (undated: newsletter, social_post, banner, custom)

Fixes the B3 landmine for this stage's first non-episode_ad line kind: buildLineEditorView/lineEdit gain a Kind-based branch so a line with ShowID == nil no longer panics. Undated catalog kinds get their own simple editor (quantity + rate, no date picker) — billboard_week (also ShowID == nil, but dated) is deliberately deferred to Task 3, which extends this same branch.

Files:

Interfaces:

// orders.go
func (s *Store) AddCatalogLine(ctx context.Context, orderID int64, kind string, productID *int64, customLabel string, cpmCents, rateCents int64, impressionsPerUnit, quantity int) (OrderLine, error)
func (s *Store) UpdateCatalogLine(ctx context.Context, lineID int64, cpmCents, rateCents int64, impressionsPerUnit, quantity int) error

// server.go — OrderStore gains both methods above.

Routes:

GET  /orders/{orderID}/catalog/new         — product/custom picker
POST /orders/{orderID}/catalog             — creates the line, redirects to its editor
POST /orders/{orderID}/lines/{lineID}/catalog  — updates an undated catalog line's rate/quantity
func TestAddCatalogLineProductBased(t *testing.T) {
	pool := testPool(t)
	advID, _, userID := seedFixtures(t, pool)
	s := &Store{Pool: pool}
	ctx := context.Background()
	ord, _ := s.Create(ctx, Order{Title: "W1", AdvertiserID: advID, SalespersonID: userID, Year: 2026})

	productID := int64(2) // "Newsletter", seeded by migration 00005
	line, err := s.AddCatalogLine(ctx, ord.ID, "newsletter", &productID, "", 0, 50000, 0, 3)
	if err != nil {
		t.Fatalf("add catalog line: %v", err)
	}
	if line.Kind != "newsletter" || line.ProductID == nil || *line.ProductID != productID {
		t.Fatalf("line wrong: %+v", line)
	}
	lines, _ := s.Lines(ctx, ord.ID)
	if len(lines) != 1 || lines[0].Quantity != 3 {
		t.Fatalf("quantity not persisted/returned: %+v", lines)
	}
}

func TestAddCatalogLineCustom(t *testing.T) {
	pool := testPool(t)
	advID, _, userID := seedFixtures(t, pool)
	s := &Store{Pool: pool}
	ctx := context.Background()
	ord, _ := s.Create(ctx, Order{Title: "W1", AdvertiserID: advID, SalespersonID: userID, Year: 2026})

	line, err := s.AddCatalogLine(ctx, ord.ID, "custom", nil, "Special one-off placement", 0, 75000, 0, 1)
	if err != nil {
		t.Fatalf("add custom line: %v", err)
	}
	if line.Kind != "custom" || line.ProductID != nil || line.CustomLabel != "Special one-off placement" {
		t.Fatalf("custom line wrong: %+v", line)
	}
}

func TestUpdateCatalogLine(t *testing.T) {
	pool := testPool(t)
	advID, _, userID := seedFixtures(t, pool)
	s := &Store{Pool: pool}
	ctx := context.Background()
	ord, _ := s.Create(ctx, Order{Title: "W1", AdvertiserID: advID, SalespersonID: userID, Year: 2026})
	productID := int64(3) // "Social Media Post"
	line, _ := s.AddCatalogLine(ctx, ord.ID, "social_post", &productID, "", 0, 20000, 0, 2)

	if err := s.UpdateCatalogLine(ctx, line.ID, 0, 30000, 0, 5); err != nil {
		t.Fatalf("update: %v", err)
	}
	lines, _ := s.Lines(ctx, ord.ID)
	if lines[0].RateCents != 30000 || lines[0].Quantity != 5 {
		t.Errorf("catalog line update didn't persist: %+v", lines[0])
	}
}

Append to app/internal/server/orders_ui_test.go — extend fakeOrderStore with the two new methods, extend ordersServer's rateStore fixture with a product rate, and add handler tests:

func (f *fakeOrderStore) AddCatalogLine(ctx context.Context, orderID int64, kind string, productID *int64, customLabel string, cpmCents, rateCents int64, impressionsPerUnit, quantity int) (orders.OrderLine, error) {
	f.mu.Lock()
	defer f.mu.Unlock()
	l := orders.OrderLine{ID: f.nextLineID, OrderID: orderID, Kind: kind, ProductID: productID, CustomLabel: customLabel,
		CPMCents: cpmCents, RateCents: rateCents, ImpressionsPerUnit: impressionsPerUnit, Quantity: quantity}
	f.lines[l.ID] = l
	f.nextLineID++
	return l, nil
}
func (f *fakeOrderStore) UpdateCatalogLine(ctx context.Context, lineID int64, cpmCents, rateCents int64, impressionsPerUnit, quantity int) error {
	f.mu.Lock()
	defer f.mu.Unlock()
	l, ok := f.lines[lineID]
	if !ok {
		return errors.New("not found")
	}
	l.CPMCents, l.RateCents, l.ImpressionsPerUnit, l.Quantity = cpmCents, rateCents, impressionsPerUnit, quantity
	f.lines[lineID] = l
	return nil
}

fakeOrderStore.Lines already recomputes Quantity from f.lineDates[l.ID] for EVERY line unconditionally (B3's fake, orders_ui_test.go) — that's wrong for catalog lines now. Fix it to match the real store's kind-branch:

func (f *fakeOrderStore) Lines(ctx context.Context, orderID int64) ([]orders.OrderLine, error) {
	f.mu.Lock()
	defer f.mu.Unlock()
	var out []orders.OrderLine
	for _, l := range f.lines {
		if l.OrderID == orderID {
			if l.Kind == "episode_ad" || l.Kind == "billboard_week" {
				l.Quantity = len(f.lineDates[l.ID]) // mirror the real store's date-computed Quantity
			}
			// else: Quantity already holds whatever AddCatalogLine/UpdateCatalogLine stored — leave it.
			out = append(out, l)
		}
	}
	return out, nil
}

newFakeRateStore() (rates_ui_test.go, B3-era) only seeds ONE product in its default fixture ({ID: 1, Name: "Opening Billboard", UnitLabel: "week", Active: true}) — this task's tests need Newsletter/Social Media Post/Banner too, so extend the fixture itself to carry all four real catalog products, matching migration 00005_ratecard.sql's exact seed order and names (catalogLineCreate's productKind switch matches on these exact name strings):

func newFakeRateStore() *fakeRateStore {
	return &fakeRateStore{
		showRates:    map[int64]rates.ShowRate{},
		productRates: map[int64]rates.ProductRate{},
		products: []rates.Product{
			{ID: 1, Name: "Opening Billboard", UnitLabel: "week", Active: true},
			{ID: 2, Name: "Newsletter", UnitLabel: "issue", Active: true},
			{ID: 3, Name: "Social Media Post", UnitLabel: "post", Active: true},
			{ID: 4, Name: "Banner", UnitLabel: "impression", Active: true},
		},
		nextID: 10,
	}
}

This is a change to an existing, shared fixture constructor (used by rates_ui_test.go's own tests too) — run the full internal/server suite after this edit, not just this task's new tests, to confirm nothing in rates_ui_test.go asserted the old one-product list.

Then add product rates ordersServer's rateStore needs for this task's tests:

	rateStore.productRates[2] = rates.ProductRate{ID: 1, ProductID: 2, PriceCents: 50000, ImpressionsPerUnit: 0} // "Newsletter" per issue
	rateStore.productRates[3] = rates.ProductRate{ID: 2, ProductID: 3, PriceCents: 20000, ImpressionsPerUnit: 0} // "Social Media Post" per post

(rateStore.productRates is a map[int64]rates.ProductRate keyed by product ID, mirroring showRates's existing shape; add both fields to the ordersServer literal exactly as the existing rateStore.showRates[1] = ... line does.)

func TestAddCatalogLineCreatesLineAndRedirects(t *testing.T) {
	h, store, aud := ordersServer(t)
	store.byID[1] = orders.Order{ID: 1, Title: "W1", AdvertiserID: 1, SalespersonID: 3, Status: "pending", Year: 2026, Version: 1}
	rec := adminPost(h, "/orders/1/catalog", "sales", url.Values{"product_id": {"2"}, "quantity": {"3"}}) // product 2 = "Newsletter" per the extended fixture; kind is derived server-side, never posted
	if rec.Code != http.StatusSeeOther {
		t.Fatalf("got %d, want 303", rec.Code)
	}
	lines, _ := store.Lines(context.Background(), 1)
	if len(lines) != 1 || lines[0].Kind != "newsletter" || lines[0].Quantity != 3 || lines[0].RateCents != 50000 {
		t.Errorf("catalog line not created with resolved rate: %+v", lines)
	}
	if aud.actions[len(aud.actions)-1] != "add_line" {
		t.Errorf("audit = %v", aud.actions)
	}
}

func TestCatalogLineEditorRendersWithoutShowIDPanic(t *testing.T) {
	h, store, _ := ordersServer(t)
	store.byID[1] = orders.Order{ID: 1, Title: "W1", AdvertiserID: 1, SalespersonID: 3, Status: "pending", Year: 2026, Version: 1}
	productID := int64(2)
	line, _ := store.AddCatalogLine(context.Background(), 1, "newsletter", &productID, "", 0, 50000, 0, 3)
	rec := getAs(h, fmt.Sprintf("/orders/1/lines/%d", line.ID), "sales")
	if rec.Code != http.StatusOK {
		t.Fatalf("got %d, want 200 (must not panic on ShowID==nil)", rec.Code)
	}
	if !strings.Contains(rec.Body.String(), "3") {
		t.Error("expected the stored quantity (3) to render somewhere on the page")
	}
}

func TestCatalogLineUpdateRateAndQuantity(t *testing.T) {
	h, store, _ := ordersServer(t)
	store.byID[1] = orders.Order{ID: 1, Title: "W1", AdvertiserID: 1, SalespersonID: 3, Status: "pending", Year: 2026, Version: 1}
	productID := int64(2)
	line, _ := store.AddCatalogLine(context.Background(), 1, "newsletter", &productID, "", 0, 50000, 0, 3)
	rec := adminPost(h, fmt.Sprintf("/orders/1/lines/%d/catalog", line.ID), "sales",
		url.Values{"cpm": {"0"}, "downloads": {"0"}, "cost": {"600"}, "quantity": {"5"}})
	if rec.Code != http.StatusSeeOther {
		t.Fatalf("got %d, want 303", rec.Code)
	}
	lines, _ := store.Lines(context.Background(), 1)
	if lines[0].RateCents != 60000 || lines[0].Quantity != 5 {
		t.Errorf("catalog line update wrong: %+v", lines[0])
	}
}

Run: cd app && go test ./internal/server/... -run 'TestAddCatalogLine|TestCatalogLine' -v and cd app && just test-db Expected: FAIL (undefined methods/routes; migration-dependent store tests fail to compile).

app/internal/orders/orders.go:

// AddCatalogLine creates a billboard_week/newsletter/social_post/banner/custom
// line. productID is nil only for kind="custom" (the tagged-union CHECK from
// migration 00007 enforces this at the DB level — a non-nil productID with
// kind="custom" fails the INSERT). quantity is the stored unit count for
// undated kinds; pass 0 for billboard_week, whose Quantity Store.Lines
// computes from order_line_dates instead (Domain Decision #4).
func (s *Store) AddCatalogLine(ctx context.Context, orderID int64, kind string, productID *int64, customLabel string, cpmCents, rateCents int64, impressionsPerUnit, quantity int) (OrderLine, error) {
	row := s.Pool.QueryRow(ctx,
		`INSERT INTO order_lines (order_id, kind, product_id, custom_label, cpm_cents, rate_cents, impressions_per_unit, quantity)
		 VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING `+orderLineCols,
		orderID, kind, productID, customLabel, cpmCents, rateCents, impressionsPerUnit, quantity)
	l, err := scanOrderLine(row)
	if err != nil {
		return OrderLine{}, fmt.Errorf("add catalog line (kind=%s) to order %d: %w", kind, orderID, err)
	}
	return l, nil
}

// UpdateCatalogLine overrides an undated catalog line's rate/quantity. No
// optimistic locking, no order-status check — same rationale as
// UpdateLineRate (B3 Domain Decision #11): a narrow blast radius, and
// finalized orders must stay editable.
func (s *Store) UpdateCatalogLine(ctx context.Context, lineID int64, cpmCents, rateCents int64, impressionsPerUnit, quantity int) error {
	tag, err := s.Pool.Exec(ctx,
		`UPDATE order_lines SET cpm_cents=$2, rate_cents=$3, impressions_per_unit=$4, quantity=$5, updated_at=now() WHERE id=$1`,
		lineID, cpmCents, rateCents, impressionsPerUnit, quantity)
	if err != nil {
		return fmt.Errorf("update catalog line %d: %w", lineID, err)
	}
	if tag.RowsAffected() == 0 {
		return fmt.Errorf("update catalog line %d: not found", lineID)
	}
	return nil
}

app/internal/server/server.go — add both methods to OrderStore:

	AddCatalogLine(ctx context.Context, orderID int64, kind string, productID *int64, customLabel string, cpmCents, rateCents int64, impressionsPerUnit, quantity int) (orders.OrderLine, error)
	UpdateCatalogLine(ctx context.Context, lineID int64, cpmCents, rateCents int64, impressionsPerUnit, quantity int) error

app/internal/server/orders_ui.go — add routes:

	mux.Handle("GET /orders/{orderID}/catalog/new", mw.requireUser(http.HandlerFunc(d.catalogLineNew)))
	mux.Handle("POST /orders/{orderID}/catalog", mw.requireUser(http.HandlerFunc(d.catalogLineCreate)))
	mux.Handle("POST /orders/{orderID}/lines/{lineID}/catalog", mw.requireUser(http.HandlerFunc(d.catalogLineUpdate)))

Handlers:

func (d Deps) catalogLineNew(w http.ResponseWriter, r *http.Request) {
	u, _ := CurrentUser(r.Context())
	orderID, _ := strconv.ParseInt(r.PathValue("orderID"), 10, 64)
	prods, err := d.Rates.ListProducts(r.Context())
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	// The date-based catalog product ("Opening Billboard") has its own picker
	// (Task 3) — this undated picker only offers newsletter/social_post/banner
	// products by name match, plus a synthetic "custom" option the template
	// renders itself (no product row backs it).
	var undated []rates.Product
	for _, p := range prods {
		if p.Active && p.UnitLabel != "week" {
			undated = append(undated, p)
		}
	}
	render(w, "catalog_line_new.html", pageData{User: u, Error: r.URL.Query().Get("err"), Data: map[string]any{
		"OrderID": orderID, "Products": undated,
	}})
}

// productKind maps a catalog product's known name to its order_line_kind.
// The four names are exactly what migration 00005 seeds — see
// Plans/B4-catalog-softness-terms.md Domain Decision #1 for why "banner"
// (an impression-priced, undated buy) lives here rather than with billboard_week.
func productKind(name string) (string, bool) {
	switch name {
	case "Newsletter":
		return "newsletter", true
	case "Social Media Post":
		return "social_post", true
	case "Banner":
		return "banner", true
	}
	return "", false
}

// catalogLineCreate derives kind from the posted product_id server-side
// (via productKind) rather than trusting a client-posted kind field — the
// picker template only ever submits a product_id (or the literal "custom"),
// never a kind directly.
func (d Deps) catalogLineCreate(w http.ResponseWriter, r *http.Request) {
	actor, _ := CurrentUser(r.Context())
	orderID, _ := strconv.ParseInt(r.PathValue("orderID"), 10, 64)
	quantity, _ := strconv.Atoi(r.FormValue("quantity"))
	productIDStr := r.FormValue("product_id")

	var line orders.OrderLine
	var err error
	if productIDStr == "custom" {
		label := strings.TrimSpace(r.FormValue("custom_label"))
		rateCents, perr := money.ParseDollars(r.FormValue("cost"))
		if perr != nil {
			http.Redirect(w, r, fmt.Sprintf("/orders/%d/catalog/new?err=%s", orderID, urlQueryEscape(perr)), http.StatusSeeOther)
			return
		}
		line, err = d.Orders.AddCatalogLine(r.Context(), orderID, "custom", nil, label, 0, rateCents, 0, quantity)
	} else {
		productID, perr := strconv.ParseInt(productIDStr, 10, 64)
		if perr != nil {
			http.Redirect(w, r, fmt.Sprintf("/orders/%d/catalog/new?err=bad+product", orderID), http.StatusSeeOther)
			return
		}
		prods, lerr := d.Rates.ListProducts(r.Context())
		if lerr != nil {
			http.Error(w, lerr.Error(), http.StatusInternalServerError)
			return
		}
		var kind string
		for _, p := range prods {
			if p.ID == productID {
				kind, _ = productKind(p.Name)
			}
		}
		if kind == "" {
			http.Redirect(w, r, fmt.Sprintf("/orders/%d/catalog/new?err=unknown+product", orderID), http.StatusSeeOther)
			return
		}
		rate, rerr := d.Rates.RateForProduct(r.Context(), productID, today())
		if rerr != nil {
			http.Redirect(w, r, fmt.Sprintf("/orders/%d/catalog/new?err=%s", orderID, urlQueryEscape(rerr)), http.StatusSeeOther)
			return
		}
		line, err = d.Orders.AddCatalogLine(r.Context(), orderID, kind, &productID, "", 0, rate.PriceCents, rate.ImpressionsPerUnit, quantity)
	}
	if err != nil {
		http.Redirect(w, r, fmt.Sprintf("/orders/%d/catalog/new?err=%s", orderID, urlQueryEscape(err)), http.StatusSeeOther)
		return
	}
	_ = d.Audit.Record(r.Context(), &actor, "order_line", fmt.Sprint(line.ID), "add_line",
		map[string]any{"order_id": orderID, "kind": line.Kind, "quantity": quantity, "rate_cents": line.RateCents})
	http.Redirect(w, r, fmt.Sprintf("/orders/%d/lines/%d", orderID, line.ID), http.StatusSeeOther)
}

func (d Deps) catalogLineUpdate(w http.ResponseWriter, r *http.Request) {
	actor, _ := CurrentUser(r.Context())
	orderID, _ := strconv.ParseInt(r.PathValue("orderID"), 10, 64)
	lineID, _ := strconv.ParseInt(r.PathValue("lineID"), 10, 64)
	downloads, cpmCents, costCents, err := parseShowRateForm(r) // reused verbatim, same as B3's UpdateLineRate
	if err != nil {
		http.Redirect(w, r, fmt.Sprintf("/orders/%d/lines/%d?err=%s", orderID, lineID, urlQueryEscape(err)), http.StatusSeeOther)
		return
	}
	quantity, _ := strconv.Atoi(r.FormValue("quantity"))
	if err := d.Orders.UpdateCatalogLine(r.Context(), lineID, cpmCents, costCents, downloads, quantity); err != nil {
		http.Redirect(w, r, fmt.Sprintf("/orders/%d/lines/%d?err=%s", orderID, lineID, urlQueryEscape(err)), http.StatusSeeOther)
		return
	}
	_ = d.Audit.Record(r.Context(), &actor, "order_line", fmt.Sprint(lineID), "update_catalog_line",
		map[string]any{"order_id": orderID, "quantity": quantity, "rate_cents": costCents})
	http.Redirect(w, r, fmt.Sprintf("/orders/%d/lines/%d", orderID, lineID), http.StatusSeeOther)
}

(Add "twit.tv/twitads/internal/rates" and "twit.tv/twitads/internal/money" to orders_ui.go's imports if not already present.)

The kind branch fixing the B3 landmine. In buildLineEditorView, replace the unconditional sh, err := d.Shows.Get(ctx, *line.ShowID) block with a branch, and change lineEdit to dispatch to a different render for undated catalog kinds (billboard_week's branch arrives in Task 3 — for now, treat it identically to the undated case so it doesn't panic; Task 3 replaces this arm):

func (d Deps) lineEdit(w http.ResponseWriter, r *http.Request) {
	u, _ := CurrentUser(r.Context())
	orderID, _ := strconv.ParseInt(r.PathValue("orderID"), 10, 64)
	lineID, _ := strconv.ParseInt(r.PathValue("lineID"), 10, 64)

	lines, err := d.Orders.Lines(r.Context(), orderID)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	var line orders.OrderLine
	for _, l := range lines {
		if l.ID == lineID {
			line = l
		}
	}
	if line.ID == 0 {
		http.NotFound(w, r)
		return
	}

	switch line.Kind {
	case "episode_ad":
		from, until := lineEditorWindow(r)
		view, err := d.buildLineEditorView(r.Context(), orderID, line, from, until)
		if err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
		render(w, "order_line_edit.html", pageData{User: u, Data: view})
	default: // newsletter/social_post/banner/custom (billboard_week gets its own arm in Task 3)
		view, err := d.buildCatalogLineEditorView(r.Context(), orderID, line)
		if err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
		render(w, "order_line_catalog_edit.html", pageData{User: u, Data: view})
	}
}

buildLineEditorView no longer needs to look up the line itself (its caller, shown above, now does) — change its signature to take the already-resolved line instead of a bare lineID, and drop its own lines/line-matching loop:

type catalogLineEditorView struct {
	OrderID int64
	Line    orders.OrderLine
	Product rates.Product // zero value when Line.Kind == "custom"
}

func (d Deps) buildCatalogLineEditorView(ctx context.Context, orderID int64, line orders.OrderLine) (catalogLineEditorView, error) {
	view := catalogLineEditorView{OrderID: orderID, Line: line}
	if line.ProductID != nil {
		prods, err := d.Rates.ListProducts(ctx)
		if err != nil {
			return catalogLineEditorView{}, err
		}
		for _, p := range prods {
			if p.ID == *line.ProductID {
				view.Product = p
			}
		}
	}
	return view, nil
}

func (d Deps) buildLineEditorView(ctx context.Context, orderID int64, line orders.OrderLine, from, until time.Time) (lineEditorView, error) {
	order, err := d.Orders.Get(ctx, orderID)
	if err != nil {
		return lineEditorView{}, err
	}
	lines, err := d.Orders.Lines(ctx, orderID) // still needed for the order-wide totals
	if err != nil {
		return lineEditorView{}, err
	}
	sh, err := d.Shows.Get(ctx, *line.ShowID) // safe: only reached for kind=="episode_ad"
	if err != nil {
		return lineEditorView{}, err
	}
	// ... unchanged from here: episodes, selectedDates, buttons, agencyBP, totals ...
}

lineDateToggle (which also calls buildLineEditorView) needs the same update: resolve lineID from the path as it already does, look up the matching line from d.Orders.Lines the same way lineEdit now does, then pass line (not lineID) into buildLineEditorView.

templates/catalog_line_new.html:

{{define "title"}}Add Catalog Item — TWiT Ads{{end}}
{{define "content"}}
<p><a href="/orders/{{.Data.OrderID}}">← Order</a></p>
<h2>Add a catalog item</h2>
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<form method="post" action="/orders/{{.Data.OrderID}}/catalog">
  <fieldset>
    <legend>Item</legend>
    {{range .Data.Products}}
    <label><input type="radio" name="product_id" value="{{.ID}}" required> {{.Name}} ({{.UnitLabel}})</label><br>
    {{end}}
    <label><input type="radio" name="product_id" value="custom"> Custom (free-text label + flat amount)</label>
  </fieldset>
  <label>Custom label (custom only) <input name="custom_label"></label>
  <label>Cost (custom only, whole dollars) <input name="cost"></label>
  <label>Quantity <input name="quantity" type="number" min="1" value="1" required></label>
  <p><button>Add</button></p>
</form>
{{end}}

The product_id radio's value is the product's numeric ID (or the literal string "custom") — catalogLineCreate derives kind server-side from that ID via productKind, so this template never has to know or submit a kind directly. No inline JS anywhere (matching the rest of this codebase, htmx aside).

templates/order_line_catalog_edit.html:

{{define "title"}}Edit Catalog Line — TWiT Ads{{end}}
{{define "content"}}
<p><a href="/orders/{{.Data.OrderID}}">← Order</a></p>
<h2>{{if .Data.Line.ProductID}}{{.Data.Product.Name}}{{else}}{{.Data.Line.CustomLabel}}{{end}}</h2>
<form method="post" action="/orders/{{.Data.OrderID}}/lines/{{.Data.Line.ID}}/catalog">
  <label>Downloads/impressions per unit <input name="downloads" type="number" min="0" value="{{.Data.Line.ImpressionsPerUnit}}" required></label>
  <label>CPM $ <input name="cpm" value="{{dollars .Data.Line.CPMCents}}" required></label>
  <label>Cost per unit $ (leave blank to compute CPM × downloads ÷ 1000) <input name="cost" placeholder="{{dollars .Data.Line.RateCents}}"></label>
  <label>Quantity <input name="quantity" type="number" min="0" value="{{.Data.Line.Quantity}}" required></label>
  <button>Update</button>
</form>
<form method="post" action="/orders/{{.Data.OrderID}}/lines/{{.Data.Line.ID}}/delete"><button>Remove this line</button></form>
{{end}}

order_form.html — add alongside the existing "+ Add show" link:

{{if not .Data.IsNew}}<p><a href="/orders/{{.Data.O.ID}}/lines/new">+ Add show</a> · <a href="/orders/{{.Data.O.ID}}/catalog/new">+ Add catalog item</a></p>{{end}}

Run: cd app && go vet ./... && just test-db Expected: PASS, all packages green.

git add app/internal/orders/orders.go app/internal/orders/orders_test.go app/internal/server/server.go \
        app/internal/server/orders_ui.go app/internal/server/orders_ui_test.go \
        app/internal/server/templates/catalog_line_new.html app/internal/server/templates/order_line_catalog_edit.html \
        app/internal/server/templates/order_form.html
git commit -m "feat(orders): catalog lines (newsletter/social_post/banner/custom), fix ShowID-deref landmine"

Task 3: Billboard-week lines — week picker + availability

Extends the kind branch from Task 2: billboard_week gets its own dated editor (a week picker, reusing ordermath.ClassifyDate with maxAds=1), not the undated catalog editor.

Files:

Interfaces:

// orders.go
func (s *Store) ToggleLineWeek(ctx context.Context, lineID int64, weekStart time.Time) (selected bool, err error)
func (s *Store) LineWeeks(ctx context.Context, lineID int64) ([]time.Time, error)
func (s *Store) BillboardWeekSoldCount(ctx context.Context, weekStart time.Time, excludeLineID int64) (int, error)

// server.go — OrderStore gains all three.

Route: POST /orders/{orderID}/lines/{lineID}/weeks/{week}/toggle (week path segment is YYYY-MM-DD, the Monday).

func TestToggleLineWeekAndBillboardSoldCount(t *testing.T) {
	pool := testPool(t)
	advID, _, userID := seedFixtures(t, pool)
	s := &Store{Pool: pool}
	ctx := context.Background()
	ord1, _ := s.Create(ctx, Order{Title: "W1", AdvertiserID: advID, SalespersonID: userID, Year: 2026})
	ord2, _ := s.Create(ctx, Order{Title: "W2", AdvertiserID: advID, SalespersonID: userID, Year: 2026})
	productID := int64(1) // "Opening Billboard"
	line1, _ := s.AddCatalogLine(ctx, ord1.ID, "billboard_week", &productID, "", 0, 1745000, 0, 0)
	line2, _ := s.AddCatalogLine(ctx, ord2.ID, "billboard_week", &productID, "", 0, 1745000, 0, 0)

	week := today().AddDate(0, 0, 7)
	selected, err := s.ToggleLineWeek(ctx, line1.ID, week)
	if err != nil || !selected {
		t.Fatalf("toggle: selected=%v err=%v", selected, err)
	}

	// line1's own claim must not count against itself.
	n, err := s.BillboardWeekSoldCount(ctx, week, line1.ID)
	if err != nil || n != 0 {
		t.Fatalf("self-exclusion failed: n=%d err=%v", n, err)
	}
	// A different line asking about the same week (excluding itself, not line1) sees line1's claim.
	n2, err := s.BillboardWeekSoldCount(ctx, week, line2.ID)
	if err != nil || n2 != 1 {
		t.Fatalf("cross-order visibility failed: n=%d err=%v", n2, err)
	}

	weeks, err := s.LineWeeks(ctx, line1.ID)
	if err != nil || len(weeks) != 1 || !weeks[0].Equal(week) {
		t.Fatalf("LineWeeks: %+v (%v)", weeks, err)
	}

	// Toggling again removes it.
	selected2, err := s.ToggleLineWeek(ctx, line1.ID, week)
	if err != nil || selected2 {
		t.Fatalf("untoggle: selected=%v err=%v", selected2, err)
	}
	n3, _ := s.BillboardWeekSoldCount(ctx, week, line2.ID)
	if n3 != 0 {
		t.Errorf("sold count after untoggle = %d, want 0", n3)
	}
}

func TestBillboardWeekSoldCountExcludesVoidOrders(t *testing.T) {
	pool := testPool(t)
	advID, _, userID := seedFixtures(t, pool)
	s := &Store{Pool: pool}
	ctx := context.Background()
	ord, _ := s.Create(ctx, Order{Title: "Voided", AdvertiserID: advID, SalespersonID: userID, Year: 2026})
	productID := int64(1)
	line, _ := s.AddCatalogLine(ctx, ord.ID, "billboard_week", &productID, "", 0, 1745000, 0, 0)
	week := today().AddDate(0, 0, 14)
	if _, err := s.ToggleLineWeek(ctx, line.ID, week); err != nil {
		t.Fatalf("toggle: %v", err)
	}
	if _, err := pool.Exec(ctx, `UPDATE orders SET status='void' WHERE id=$1`, ord.ID); err != nil {
		t.Fatalf("void the order: %v", err)
	}
	n, err := s.BillboardWeekSoldCount(ctx, week, 0)
	if err != nil || n != 0 {
		t.Errorf("void order's claim should not count: n=%d err=%v", n, err)
	}
}

Append to app/internal/server/orders_ui_test.go — extend fakeOrderStore:

type billboardWeek struct {
	lineID int64
	week   time.Time
}

// fakeOrderStore gains a field: billboardWeeks []billboardWeek

func (f *fakeOrderStore) ToggleLineWeek(ctx context.Context, lineID int64, weekStart time.Time) (bool, error) {
	f.mu.Lock()
	defer f.mu.Unlock()
	for i, bw := range f.billboardWeeks {
		if bw.lineID == lineID && bw.week.Equal(weekStart) {
			f.billboardWeeks = append(f.billboardWeeks[:i], f.billboardWeeks[i+1:]...)
			return false, nil
		}
	}
	f.billboardWeeks = append(f.billboardWeeks, billboardWeek{lineID, weekStart})
	return true, nil
}
func (f *fakeOrderStore) LineWeeks(ctx context.Context, lineID int64) ([]time.Time, error) {
	f.mu.Lock()
	defer f.mu.Unlock()
	var out []time.Time
	for _, bw := range f.billboardWeeks {
		if bw.lineID == lineID {
			out = append(out, bw.week)
		}
	}
	return out, nil
}
func (f *fakeOrderStore) BillboardWeekSoldCount(ctx context.Context, weekStart time.Time, excludeLineID int64) (int, error) {
	f.mu.Lock()
	defer f.mu.Unlock()
	n := 0
	for _, bw := range f.billboardWeeks {
		if bw.week.Equal(weekStart) && bw.lineID != excludeLineID {
			n++
		}
	}
	return n, nil
}

Add billboardWeeks []billboardWeek to the fakeOrderStore struct and "time" stays already-imported (Task 4 of B3 added it). Lines' kind check already treats billboard_week alongside episode_ad for computed Quantity — extend it to also count billboardWeeks for that line, not just lineDates:

			if l.Kind == "episode_ad" {
				l.Quantity = len(f.lineDates[l.ID])
			} else if l.Kind == "billboard_week" {
				n := 0
				for _, bw := range f.billboardWeeks {
					if bw.lineID == l.ID {
						n++
					}
				}
				l.Quantity = n
			}
func TestBillboardLineEditorRendersWeekPicker(t *testing.T) {
	h, store, _ := ordersServer(t)
	store.byID[1] = orders.Order{ID: 1, Title: "W1", AdvertiserID: 1, SalespersonID: 3, Status: "pending", Year: 2026, Version: 1}
	productID := int64(1)
	line, _ := store.AddCatalogLine(context.Background(), 1, "billboard_week", &productID, "", 0, 1745000, 0, 0)
	rec := getAs(h, fmt.Sprintf("/orders/1/lines/%d", line.ID), "sales")
	if rec.Code != http.StatusOK {
		t.Fatalf("got %d, want 200 (must not panic on ShowID==nil)", rec.Code)
	}
	if !strings.Contains(rec.Body.String(), "date-open") {
		t.Error("expected at least one open (white) week button")
	}
}

func TestBillboardWeekToggleShowsCrossOrderConflict(t *testing.T) {
	h, store, _ := ordersServer(t)
	store.byID[1] = orders.Order{ID: 1, Title: "W1", AdvertiserID: 1, SalespersonID: 3, Status: "pending", Year: 2026, Version: 1}
	store.byID[2] = orders.Order{ID: 2, Title: "W2", AdvertiserID: 1, SalespersonID: 3, Status: "pending", Year: 2026, Version: 1}
	productID := int64(1)
	line1, _ := store.AddCatalogLine(context.Background(), 1, "billboard_week", &productID, "", 0, 1745000, 0, 0)
	line2, _ := store.AddCatalogLine(context.Background(), 2, "billboard_week", &productID, "", 0, 1745000, 0, 0)

	firstMonday := mondayOnOrAfter(today().AddDate(0, 0, 1))
	if _, err := store.ToggleLineWeek(context.Background(), line1.ID, firstMonday); err != nil {
		t.Fatalf("toggle line1: %v", err)
	}

	rec := getAs(h, fmt.Sprintf("/orders/2/lines/%d", line2.ID), "sales")
	if !strings.Contains(rec.Body.String(), "date-full") {
		t.Error("expected line2's picker to show the week line1 claimed as full (grey), even though line1's order is a different order")
	}
}

Run: cd app && go test ./internal/server/... -run 'TestBillboard' -v and cd app && just test-db Expected: FAIL (undefined methods/routes; mondayOnOrAfter not yet defined).

app/internal/orders/orders.go:

// ToggleLineWeek toggles a billboard_week line's claim on weekStart — the
// week-based sibling of ToggleLineDate.
func (s *Store) ToggleLineWeek(ctx context.Context, lineID int64, weekStart time.Time) (selected bool, err error) {
	txErr := pgx.BeginFunc(ctx, s.Pool, func(tx pgx.Tx) error {
		tag, delErr := tx.Exec(ctx, `DELETE FROM order_line_dates WHERE order_line_id=$1 AND week_start_date=$2`, lineID, weekStart)
		if delErr != nil {
			return delErr
		}
		if tag.RowsAffected() > 0 {
			selected = false
			return nil
		}
		if _, insErr := tx.Exec(ctx, `INSERT INTO order_line_dates (order_line_id, week_start_date) VALUES ($1, $2)`, lineID, weekStart); insErr != nil {
			return insErr
		}
		selected = true
		return nil
	})
	if txErr != nil {
		return false, fmt.Errorf("toggle line week (line %d, week %s): %w", lineID, weekStart.Format("2006-01-02"), txErr)
	}
	return selected, nil
}

func (s *Store) LineWeeks(ctx context.Context, lineID int64) ([]time.Time, error) {
	rows, err := s.Pool.Query(ctx, `SELECT week_start_date FROM order_line_dates WHERE order_line_id=$1 AND week_start_date IS NOT NULL`, lineID)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var out []time.Time
	for rows.Next() {
		var t time.Time
		if err := rows.Scan(&t); err != nil {
			return nil, err
		}
		out = append(out, t)
	}
	return out, rows.Err()
}

// BillboardWeekSoldCount counts OTHER billboard_week lines (on any non-void
// order) already claiming weekStart. Unlike episode SoldCount ("finalized
// placements only" — B3 Domain Decision #1), this counts pending claims too:
// there is no B5 placements system yet, and Leo asked for real billboard
// visibility now (Plans/B4-catalog-softness-terms.md Domain Decision #2).
// excludeLineID lets a line's own existing claim not count against itself.
func (s *Store) BillboardWeekSoldCount(ctx context.Context, weekStart time.Time, excludeLineID int64) (int, error) {
	var n int
	err := s.Pool.QueryRow(ctx,
		`SELECT count(DISTINCT ol.id) FROM order_lines ol
		 JOIN order_line_dates old ON old.order_line_id = ol.id
		 JOIN orders o ON o.id = ol.order_id
		 WHERE ol.kind = 'billboard_week' AND old.week_start_date = $1
		   AND o.status != 'void' AND ol.id != $2`,
		weekStart, excludeLineID).Scan(&n)
	if err != nil {
		return 0, fmt.Errorf("billboard week sold count for %s: %w", weekStart.Format("2006-01-02"), err)
	}
	return n, nil
}

app/internal/server/server.go — add to OrderStore:

	ToggleLineWeek(ctx context.Context, lineID int64, weekStart time.Time) (bool, error)
	LineWeeks(ctx context.Context, lineID int64) ([]time.Time, error)
	BillboardWeekSoldCount(ctx context.Context, weekStart time.Time, excludeLineID int64) (int, error)

app/internal/server/orders_ui.go — route:

	mux.Handle("POST /orders/{orderID}/lines/{lineID}/weeks/{week}/toggle", mw.requireUser(http.HandlerFunc(d.lineWeekToggle)))

Week-window helper (Domain Decision #3 — next 12 Mondays from the Monday on/after today):

func mondayOnOrAfter(t time.Time) time.Time {
	offset := (8 - int(t.Weekday())) % 7 // Weekday(): Sunday=0..Saturday=6; Monday=1
	if t.Weekday() == time.Monday {
		return t
	}
	return t.AddDate(0, 0, offset)
}

func upcomingMondays(from time.Time, n int) []time.Time {
	start := mondayOnOrAfter(from)
	out := make([]time.Time, n)
	for i := range out {
		out[i] = start.AddDate(0, 0, 7*i)
	}
	return out
}

Billboard view-builder + the lineEdit billboard arm (replacing Task 2's placeholder default handling for this one kind — add a case "billboard_week": branch above the default: in the switch line.Kind from Task 2):

type weekButtonView struct {
	Week     time.Time
	Selected bool
	Class    ordermath.DateClass
}

type billboardLineEditorView struct {
	OrderID     int64
	Line        orders.OrderLine
	WeekButtons []weekButtonView
	Totals      ordermath.Totals
}

func (d Deps) buildBillboardLineEditorView(ctx context.Context, orderID int64, line orders.OrderLine) (billboardLineEditorView, error) {
	order, err := d.Orders.Get(ctx, orderID)
	if err != nil {
		return billboardLineEditorView{}, err
	}
	lines, err := d.Orders.Lines(ctx, orderID)
	if err != nil {
		return billboardLineEditorView{}, err
	}
	selectedWeeks, err := d.Orders.LineWeeks(ctx, line.ID)
	if err != nil {
		return billboardLineEditorView{}, err
	}
	selected := map[time.Time]bool{}
	for _, w := range selectedWeeks {
		selected[w] = true
	}
	var buttons []weekButtonView
	for _, week := range upcomingMondays(today(), 12) {
		sold, err := d.Orders.BillboardWeekSoldCount(ctx, week, line.ID)
		if err != nil {
			return billboardLineEditorView{}, err
		}
		isSel := selected[week]
		buttons = append(buttons, weekButtonView{Week: week, Selected: isSel, Class: ordermath.ClassifyDate(1, sold, isSel)})
	}
	agencyBP := 0
	if order.AgencyID != nil {
		ag, err := d.Sponsors.GetAgency(ctx, *order.AgencyID)
		if err != nil {
			return billboardLineEditorView{}, err
		}
		agencyBP = ag.CommissionBP
	}
	totals := ordermath.Compute(linesToOrdermath(lines, agencyBP))
	return billboardLineEditorView{OrderID: orderID, Line: line, WeekButtons: buttons, Totals: totals}, nil
}

func (d Deps) lineWeekToggle(w http.ResponseWriter, r *http.Request) {
	actor, _ := CurrentUser(r.Context())
	orderID, _ := strconv.ParseInt(r.PathValue("orderID"), 10, 64)
	lineID, _ := strconv.ParseInt(r.PathValue("lineID"), 10, 64)
	week, err := parseDate(r.PathValue("week"))
	if err != nil {
		http.Error(w, "bad week", http.StatusBadRequest)
		return
	}
	selected, err := d.Orders.ToggleLineWeek(r.Context(), lineID, week)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	_ = d.Audit.Record(r.Context(), &actor, "order_line_date", fmt.Sprintf("%d/%s", lineID, week.Format("2006-01-02")), "toggle",
		map[string]any{"order_id": orderID, "selected": selected})
	http.Redirect(w, r, fmt.Sprintf("/orders/%d/lines/%d", orderID, lineID), http.StatusSeeOther)
}

Unlike lineDateToggle (B3's htmx OOB-swap version), lineWeekToggle is a plain redirect — the week picker has at most 12 buttons and clicking one is not the same rapid-fire interaction episode-date-picking is; a full page reload keeps this task's scope proportionate (B3's htmx machinery stays reserved for the episode picker it was built for).

Add the billboard_week arm to lineEdit's switch (from Task 2, insert above default:):

	case "billboard_week":
		view, err := d.buildBillboardLineEditorView(r.Context(), orderID, line)
		if err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
		render(w, "order_line_billboard_edit.html", pageData{User: u, Data: view})

templates/_week_button.html:

{{define "week_button"}}<button id="week-{{.Week.Format "20060102"}}" class="{{dateClass .Class}}"
  {{if ne (dateClass .Class) "date-full"}}formaction="/orders/{{.OrderID}}/lines/{{.LineID}}/weeks/{{.Week.Format "2006-01-02"}}/toggle"{{end}}
  {{if eq (dateClass .Class) "date-full"}}disabled{{end}}>{{mdy .Week}}{{if .Selected}} ✓{{end}}</button>{{end}}

This reuses dateClass's existing string output (date-open/date-conflict/date-full) directly rather than inventing parallel week-* class names, matching every other CSS-class decision this codebase has made since B3 — the test assertions in Step 1 already check for date-open/date-full for exactly this reason.

templates/order_line_billboard_edit.html:

{{define "title"}}Edit Billboard Line — TWiT Ads{{end}}
{{define "content"}}
<p><a href="/orders/{{.Data.OrderID}}">← Order</a></p>
<h2>Opening Billboard</h2>
<form method="post">
<div id="week-picker">
{{range .Data.WeekButtons}}{{template "week_button" (dict "OrderID" $.Data.OrderID "LineID" $.Data.Line.ID "Week" .Week "Selected" .Selected "Class" .Class)}}{{end}}
</div>
</form>
{{template "totals" (dict "Totals" .Data.Totals "OOB" false)}}
<form method="post" action="/orders/{{.Data.OrderID}}/lines/{{.Data.Line.ID}}/delete"><button>Remove this line</button></form>
{{end}}

The week buttons' formaction attribute (set on each <button>, not the <form>'s own action) lets each button POST to its own toggle URL from inside one shared <form> — standard HTML, no JS needed.

Run: cd app && go vet ./... && just test-db Expected: PASS, all packages green.

git add app/internal/orders/orders.go app/internal/orders/orders_test.go app/internal/server/server.go \
        app/internal/server/orders_ui.go app/internal/server/orders_ui_test.go \
        app/internal/server/templates/order_line_billboard_edit.html app/internal/server/templates/_week_button.html
git commit -m "feat(orders): billboard-week lines — week picker + cross-order availability"

Task 4: Value-add marking

Files:

Interfaces:

func (s *Store) ToggleLineValueAdd(ctx context.Context, lineID int64) (bool, error)

Route: POST /orders/{orderID}/lines/{lineID}/value-add/toggle (works for every line kind — one handler, one store method).

func TestToggleLineValueAdd(t *testing.T) {
	pool := testPool(t)
	advID, _, userID := seedFixtures(t, pool)
	sh, rate := seedShowWithRate(t, pool)
	s := &Store{Pool: pool}
	ctx := context.Background()
	ord, _ := s.Create(ctx, Order{Title: "W1", AdvertiserID: advID, SalespersonID: userID, Year: 2026})
	line, _ := s.AddEpisodeLine(ctx, ord.ID, sh.ID, rate.CPMCents, rate.CostPerEpisodeCents, rate.DownloadsPerEpisode)

	v, err := s.ToggleLineValueAdd(ctx, line.ID)
	if err != nil || !v {
		t.Fatalf("toggle on: v=%v err=%v", v, err)
	}
	lines, _ := s.Lines(ctx, ord.ID)
	if !lines[0].IsValueAdd {
		t.Fatalf("IsValueAdd not persisted: %+v", lines[0])
	}
	v2, err := s.ToggleLineValueAdd(ctx, line.ID)
	if err != nil || v2 {
		t.Fatalf("toggle off: v=%v err=%v", v2, err)
	}
}

Append to app/internal/server/orders_ui_test.go:

func (f *fakeOrderStore) ToggleLineValueAdd(ctx context.Context, lineID int64) (bool, error) {
	f.mu.Lock()
	defer f.mu.Unlock()
	l, ok := f.lines[lineID]
	if !ok {
		return false, errors.New("not found")
	}
	l.IsValueAdd = !l.IsValueAdd
	f.lines[lineID] = l
	return l.IsValueAdd, nil
}

func TestLineValueAddToggleAffectsTotals(t *testing.T) {
	h, store, aud := ordersServer(t)
	store.byID[1] = orders.Order{ID: 1, Title: "W1", AdvertiserID: 1, SalespersonID: 3, Status: "pending", Year: 2026, Version: 1}
	line, _ := store.AddEpisodeLine(context.Background(), 1, 1, 6500, 260000, 40000)
	store.lineDates[line.ID] = []int64{5} // 1 date -> quantity 1 -> $2,600 gross

	rec := adminPost(h, fmt.Sprintf("/orders/1/lines/%d/value-add/toggle", line.ID), "sales", url.Values{})
	if rec.Code != http.StatusSeeOther {
		t.Fatalf("got %d, want 303", rec.Code)
	}
	lines, _ := store.Lines(context.Background(), 1)
	if !lines[0].IsValueAdd {
		t.Fatal("line not marked value-add")
	}
	if aud.actions[len(aud.actions)-1] != "toggle_value_add" {
		t.Errorf("audit = %v", aud.actions)
	}

	// Verify the live-totals block on the line editor page reflects the
	// value-add deduct: gross stays $2,600 but net-to-TWiT and guaranteed
	// impressions both drop to zero.
	rec2 := getAs(h, fmt.Sprintf("/orders/1/lines/%d", line.ID), "sales")
	body := rec2.Body.String()
	if !strings.Contains(body, "2,600") {
		t.Error("expected gross subtotal to still show $2,600 (value-add lines count at full value in gross)")
	}
}

Run: cd app && go test ./internal/server/... -run TestLineValueAdd -v and cd app && just test-db Expected: FAIL (undefined method/route).

app/internal/orders/orders.go:

func (s *Store) ToggleLineValueAdd(ctx context.Context, lineID int64) (bool, error) {
	row := s.Pool.QueryRow(ctx,
		`UPDATE order_lines SET is_value_add = NOT is_value_add, updated_at=now() WHERE id=$1 RETURNING is_value_add`, lineID)
	var v bool
	if err := row.Scan(&v); err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			return false, fmt.Errorf("toggle value-add for line %d: not found", lineID)
		}
		return false, fmt.Errorf("toggle value-add for line %d: %w", lineID, err)
	}
	return v, nil
}

app/internal/server/server.go — add to OrderStore:

	ToggleLineValueAdd(ctx context.Context, lineID int64) (bool, error)

app/internal/server/orders_ui.go:

	mux.Handle("POST /orders/{orderID}/lines/{lineID}/value-add/toggle", mw.requireUser(http.HandlerFunc(d.lineValueAddToggle)))
func (d Deps) lineValueAddToggle(w http.ResponseWriter, r *http.Request) {
	actor, _ := CurrentUser(r.Context())
	orderID, _ := strconv.ParseInt(r.PathValue("orderID"), 10, 64)
	lineID, _ := strconv.ParseInt(r.PathValue("lineID"), 10, 64)
	v, err := d.Orders.ToggleLineValueAdd(r.Context(), lineID)
	if err != nil {
		http.Redirect(w, r, fmt.Sprintf("/orders/%d/lines/%d?err=%s", orderID, lineID, urlQueryEscape(err)), http.StatusSeeOther)
		return
	}
	_ = d.Audit.Record(r.Context(), &actor, "order_line", fmt.Sprint(lineID), "toggle_value_add",
		map[string]any{"order_id": orderID, "is_value_add": v})
	http.Redirect(w, r, fmt.Sprintf("/orders/%d/lines/%d", orderID, lineID), http.StatusSeeOther)
}

templates/_value_add_toggle.html:

{{define "value_add_toggle"}}<form method="post" action="/orders/{{.OrderID}}/lines/{{.LineID}}/value-add/toggle">
  <button>{{if .IsValueAdd}}Unmark value-add{{else}}Mark as value-add (softness){{end}}</button>
  {{if .IsValueAdd}}<strong> (currently value-add — $0 / 0 impressions toward guaranteed totals)</strong>{{end}}
</form>{{end}}

Include it in all three line-editor templates (the partial glob in render()/renderFragment() from B3 already makes every _*.html {{define}} available everywhere — no templates.go change needed). Add this line to each, near the delete-line form:

Run: cd app && go vet ./... && just test-db Expected: PASS.

git add app/internal/orders/orders.go app/internal/orders/orders_test.go app/internal/server/server.go \
        app/internal/server/orders_ui.go app/internal/server/orders_ui_test.go \
        app/internal/server/templates/_value_add_toggle.html app/internal/server/templates/order_line_edit.html \
        app/internal/server/templates/order_line_catalog_edit.html app/internal/server/templates/order_line_billboard_edit.html
git commit -m "feat(orders): per-line value-add (softness) marking"

Task 5: Order-level discount

Files:

Interfaces: no new store methods (Task 1 already wired Order.DiscountKind/BP/FlatCents through Create/Get/Update). linesToOrdermath gains a parameter:

func linesToOrdermath(lines []orders.OrderLine, agencyBP int, discount ordermath.Discount) ordermath.Order
func TestOrderDiscountFormRoundTrip(t *testing.T) {
	h, store, _ := ordersServer(t)
	rec := adminPost(h, "/orders", "sales", url.Values{
		"title": {"Discount Order"}, "advertiser_id": {"1"}, "salesperson_id": {"3"}, "year": {"2026"},
		"terms": {"net_45"}, "discount_kind": {"percent"}, "discount_pct": {"5"},
	})
	if rec.Code != http.StatusSeeOther {
		t.Fatalf("got %d, want 303", rec.Code)
	}
	created, _ := store.Get(context.Background(), 1)
	if created.DiscountKind != "percent" || created.DiscountBP != 500 {
		t.Errorf("discount not parsed from form: %+v", created)
	}
}

func TestLineEditorTotalsReflectOrderDiscount(t *testing.T) {
	h, store, _ := ordersServer(t)
	store.byID[1] = orders.Order{ID: 1, Title: "W1", AdvertiserID: 1, SalespersonID: 3, Status: "pending", Year: 2026, Version: 1,
		DiscountKind: "flat", DiscountFlatCents: 100000} // $1,000 flat off
	line, _ := store.AddEpisodeLine(context.Background(), 1, 1, 6500, 260000, 40000)
	store.lineDates[line.ID] = []int64{5} // 1 date -> $2,600 gross

	rec := getAs(h, fmt.Sprintf("/orders/1/lines/%d", line.ID), "sales")
	body := rec.Body.String()
	// Net to TWiT = $2,600 gross - $1,000 discount = $1,600.
	if !strings.Contains(body, "1,600") {
		t.Errorf("totals don't reflect the $1,000 flat discount; body:\n%s", body)
	}
}

Run: cd app && go test ./internal/server/... -run 'TestOrderDiscount|TestLineEditorTotalsReflectOrderDiscount' -v Expected: FAIL (form fields not parsed; totals don't reflect discount yet).

app/internal/server/orders_ui.goorderFromForm gains discount parsing (reusing pctToBP from sponsors_ui.go, same package):

func orderFromForm(r *http.Request) orders.Order {
	// ... existing fields unchanged ...
	o.DiscountKind = r.FormValue("discount_kind")
	if o.DiscountKind == "" {
		o.DiscountKind = "none"
	}
	switch o.DiscountKind {
	case "percent":
		if bp, err := pctToBP(r.FormValue("discount_pct")); err == nil {
			o.DiscountBP = bp
		}
	case "flat":
		if cents, err := money.ParseDollars(r.FormValue("discount_flat")); err == nil {
			o.DiscountFlatCents = cents
		}
	}
	return o
}

This silently drops a malformed discount input rather than blocking order save (matching the existing form's general tolerance — Quarters/Terms parsing in this same function has no error path either); a bad discount_pct simply leaves DiscountBP at its zero value rather than rejecting the whole order-header save. Flag this as a UX gap worth revisiting if Lisa hits it in practice, not a defect to over-engineer now.

linesToOrdermath — add the discount parameter and thread it through every call site:

func linesToOrdermath(lines []orders.OrderLine, agencyBP int, discount ordermath.Discount) ordermath.Order {
	out := ordermath.Order{AgencyCommissionBP: agencyBP, Discount: discount}
	for _, l := range lines {
		out.Lines = append(out.Lines, ordermath.Line{
			RateCents: l.RateCents, ImpressionsPerUnit: l.ImpressionsPerUnit,
			Quantity: l.Quantity, IsValueAdd: l.IsValueAdd,
		})
	}
	return out
}

// orderDiscount maps the stored tagged-union columns to ordermath.Discount.
func orderDiscount(o orders.Order) ordermath.Discount {
	switch o.DiscountKind {
	case "percent":
		return ordermath.Discount{Kind: ordermath.PercentDiscount, BP: o.DiscountBP}
	case "flat":
		return ordermath.Discount{Kind: ordermath.FlatDiscount, FlatCents: o.DiscountFlatCents}
	default:
		return ordermath.Discount{}
	}
}

Update the three call sites (buildLineEditorView, buildCatalogLineEditorView, buildBillboardLineEditorView — the latter two added in Tasks 2/3, this task is what finally makes them discount-aware) to pass orderDiscount(order):

	totals := ordermath.Compute(linesToOrdermath(lines, agencyBP, orderDiscount(order)))

buildCatalogLineEditorView (Task 2) didn't fetch order/compute agencyBP/totals at all yet — it only returned Line/Product. Extend it now to match the other two view-builders' shape:

type catalogLineEditorView struct {
	OrderID int64
	Line    orders.OrderLine
	Product rates.Product
	Totals  ordermath.Totals
}

func (d Deps) buildCatalogLineEditorView(ctx context.Context, orderID int64, line orders.OrderLine) (catalogLineEditorView, error) {
	order, err := d.Orders.Get(ctx, orderID)
	if err != nil {
		return catalogLineEditorView{}, err
	}
	lines, err := d.Orders.Lines(ctx, orderID)
	if err != nil {
		return catalogLineEditorView{}, err
	}
	view := catalogLineEditorView{OrderID: orderID, Line: line}
	if line.ProductID != nil {
		prods, err := d.Rates.ListProducts(ctx)
		if err != nil {
			return catalogLineEditorView{}, err
		}
		for _, p := range prods {
			if p.ID == *line.ProductID {
				view.Product = p
			}
		}
	}
	agencyBP := 0
	if order.AgencyID != nil {
		ag, err := d.Sponsors.GetAgency(ctx, *order.AgencyID)
		if err != nil {
			return catalogLineEditorView{}, err
		}
		agencyBP = ag.CommissionBP
	}
	view.Totals = ordermath.Compute(linesToOrdermath(lines, agencyBP, orderDiscount(order)))
	return view, nil
}

Add {{template "totals" (dict "Totals" .Data.Totals "OOB" false)}} to order_line_catalog_edit.html (it had no totals block before this task).

app/internal/server/templates/order_form.html — add near the terms/notes fields:

  <fieldset>
    <legend>Discount</legend>
    <label><input type="radio" name="discount_kind" value="none" {{if or (eq .Data.O.DiscountKind "") (eq .Data.O.DiscountKind "none")}}checked{{end}}> None</label>
    <label><input type="radio" name="discount_kind" value="percent" {{if eq .Data.O.DiscountKind "percent"}}checked{{end}}> Percent <input name="discount_pct" placeholder="5" value="{{if eq .Data.O.DiscountKind "percent"}}{{bpPercent .Data.O.DiscountBP}}{{end}}"></label>
    <label><input type="radio" name="discount_kind" value="flat" {{if eq .Data.O.DiscountKind "flat"}}checked{{end}}> Flat $ <input name="discount_flat" placeholder="1000" value="{{if eq .Data.O.DiscountKind "flat"}}{{dollars .Data.O.DiscountFlatCents}}{{end}}"></label>
  </fieldset>

(bpPercent and dollars template funcs already exist — bpPercent from sponsors_ui.go's agency-commission display, reused verbatim here.)

Run: cd app && go vet ./... && just test-db Expected: PASS.

git add app/internal/server/orders_ui.go app/internal/server/orders_ui_test.go app/internal/server/templates/order_form.html \
        app/internal/server/templates/order_line_catalog_edit.html
git commit -m "feat(orders): order-level discount wired into live totals"

Task 6: Terms boilerplate auto-fill

Files:

Interfaces:

// server.go
type TermsStore interface {
	List(ctx context.Context) ([]terms.Template, error)
	Get(ctx context.Context, kind string) (terms.Template, error)
	Update(ctx context.Context, t terms.Template) error
}
// Deps gains: Terms TermsStore

Routes:

GET  /admin/terms                — list both templates
POST /admin/terms/{kind}         — update one template's body/ad-minimum
POST /orders/{id}/terms-text/fill — fills TermsText from the resolved template
package server

import (
	"context"
	"errors"
	"net/http"
	"net/url"
	"strings"
	"sync"
	"testing"

	"twit.tv/twitads/internal/auth"
	"twit.tv/twitads/internal/orders"
	"twit.tv/twitads/internal/terms"
)

// errNotFound is a small package-local sentinel shared by fakeTermsStore's
// three methods below — the same plain errors.New("not found") idiom every
// other fake store in this package already uses inline, just named once
// since three methods here return it.
var errNotFound = errors.New("not found")

type fakeTermsStore struct {
	mu        sync.Mutex
	templates map[string]terms.Template
}

func newFakeTermsStore() *fakeTermsStore {
	return &fakeTermsStore{templates: map[string]terms.Template{
		"net": {ID: 1, Kind: "net", BodyText: "Order for {{advertiser}}, due in {{days}} days, minimum {{ad_minimum}}.", AdMinimumCents: 2000000},
		"due_upon_receipt": {ID: 2, Kind: "due_upon_receipt", BodyText: "Due upon receipt for {{advertiser}}.", AdMinimumCents: 2000000},
	}}
}
func (f *fakeTermsStore) List(ctx context.Context) ([]terms.Template, error) {
	f.mu.Lock()
	defer f.mu.Unlock()
	var out []terms.Template
	for _, t := range f.templates {
		out = append(out, t)
	}
	return out, nil
}
func (f *fakeTermsStore) Get(ctx context.Context, kind string) (terms.Template, error) {
	f.mu.Lock()
	defer f.mu.Unlock()
	t, ok := f.templates[kind]
	if !ok {
		return terms.Template{}, errNotFound
	}
	return t, nil
}
func (f *fakeTermsStore) Update(ctx context.Context, t terms.Template) error {
	f.mu.Lock()
	defer f.mu.Unlock()
	if _, ok := f.templates[t.Kind]; !ok {
		return errNotFound
	}
	f.templates[t.Kind] = t
	return nil
}

func termsServer(t *testing.T) (http.Handler, *fakeTermsStore, *fakeOrderStore, *fakeAudit) {
	t.Helper()
	orderStore := newFakeOrderStore()
	termsStore := newFakeTermsStore()
	sponsorStore := newFakeSponsorStore()
	aud := &fakeAudit{}
	h := New(Deps{
		Env: "test", DB: fakePinger{},
		Sessions: &fakeSessionStore{fakeSessions: fakeSessions{users: map[string]auth.User{
			"sales": {ID: 3, Username: "lisa", Role: auth.RoleSales, Active: true},
		}}, created: map[int64]string{}},
		Users: fakeUserStore{}, Audit: aud, Shows: newFakeShowStore(), Sponsors: sponsorStore,
		Orders: orderStore, Rates: newFakeRateStore(), Terms: termsStore,
	})
	return h, termsStore, orderStore, aud
}

func TestOrderTermsFillSubstitutesAdvertiserDaysAndMinimum(t *testing.T) {
	h, _, store, aud := termsServer(t)
	store.byID[1] = orders.Order{ID: 1, Title: "W1", AdvertiserID: 1, SalespersonID: 3, Status: "pending", Year: 2026, Version: 1, Terms: "net_45"}
	rec := adminPost(h, "/orders/1/terms-text/fill", "sales", url.Values{})
	if rec.Code != http.StatusSeeOther {
		t.Fatalf("got %d, want 303", rec.Code)
	}
	got, _ := store.Get(context.Background(), 1)
	if !strings.Contains(got.TermsText, "45 days") {
		t.Errorf("terms text missing {{days}} substitution: %q", got.TermsText)
	}
	if strings.Contains(got.TermsText, "{{") {
		t.Errorf("terms text has unsubstituted tokens: %q", got.TermsText)
	}
	if aud.actions[len(aud.actions)-1] != "fill_terms_text" {
		t.Errorf("audit = %v", aud.actions)
	}
}

func TestOrderTermsFillUsesDueUponReceiptTemplate(t *testing.T) {
	h, _, store, _ := termsServer(t)
	store.byID[1] = orders.Order{ID: 1, Title: "W1", AdvertiserID: 1, SalespersonID: 3, Status: "pending", Year: 2026, Version: 1, Terms: "due_upon_receipt"}
	rec := adminPost(h, "/orders/1/terms-text/fill", "sales", url.Values{})
	if rec.Code != http.StatusSeeOther {
		t.Fatalf("got %d, want 303", rec.Code)
	}
	got, _ := store.Get(context.Background(), 1)
	if !strings.Contains(got.TermsText, "Due upon receipt") {
		t.Errorf("wrong template used for due_upon_receipt terms: %q", got.TermsText)
	}
}

func TestAdminTermsUpdate(t *testing.T) {
	h, termsStore, _, aud := termsServer(t)
	rec := adminPost(h, "/admin/terms/net", "sales", url.Values{"body_text": {"New net body {{advertiser}}"}, "ad_minimum": {"25000"}})
	if rec.Code != http.StatusSeeOther {
		t.Fatalf("got %d, want 303", rec.Code)
	}
	got, _ := termsStore.Get(context.Background(), "net")
	if got.BodyText != "New net body {{advertiser}}" || got.AdMinimumCents != 2500000 {
		t.Errorf("template not updated: %+v", got)
	}
	if aud.actions[len(aud.actions)-1] != "update" {
		t.Errorf("audit = %v", aud.actions)
	}
}

Run: cd app && go test ./internal/server/... -run 'TestOrderTermsFill|TestAdminTerms' -v Expected: FAIL (undefined Deps.Terms, routes, handlers).

app/internal/server/server.go:

type TermsStore interface {
	List(ctx context.Context) ([]terms.Template, error)
	Get(ctx context.Context, kind string) (terms.Template, error)
	Update(ctx context.Context, t terms.Template) error
}

Add "twit.tv/twitads/internal/terms" to imports, add Terms TermsStore to Deps.

Create app/internal/server/admin_terms.go:

package server

import (
	"net/http"

	"twit.tv/twitads/internal/money"
	"twit.tv/twitads/internal/terms"
)

func registerTerms(mux *http.ServeMux, mw *authMiddleware, d Deps) {
	mux.Handle("GET /admin/terms", mw.requireUser(http.HandlerFunc(d.termsList)))
	mux.Handle("POST /admin/terms/{kind}", mw.requireRatecard(http.HandlerFunc(d.termsUpdate)))
}

func (d Deps) termsList(w http.ResponseWriter, r *http.Request) {
	u, _ := CurrentUser(r.Context())
	list, err := d.Terms.List(r.Context())
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	render(w, "terms_templates.html", pageData{User: u, Error: r.URL.Query().Get("err"), Data: map[string]any{
		"Templates": list, "CanEdit": canEditRatecard(u),
	}})
}

func (d Deps) termsUpdate(w http.ResponseWriter, r *http.Request) {
	actor, _ := CurrentUser(r.Context())
	kind := r.PathValue("kind")
	adMin, err := money.ParseDollars(r.FormValue("ad_minimum"))
	if err != nil {
		http.Redirect(w, r, "/admin/terms?err="+urlQueryEscape(err), http.StatusSeeOther)
		return
	}
	t := terms.Template{Kind: kind, BodyText: r.FormValue("body_text"), AdMinimumCents: adMin}
	if err := d.Terms.Update(r.Context(), t); err != nil {
		http.Redirect(w, r, "/admin/terms?err="+urlQueryEscape(err), http.StatusSeeOther)
		return
	}
	_ = d.Audit.Record(r.Context(), &actor, "terms_template", kind, "update", map[string]any{"ad_minimum_cents": adMin})
	http.Redirect(w, r, "/admin/terms", http.StatusSeeOther)
}

Register registerTerms alongside the other register* calls in server.go's New().

app/internal/server/orders_ui.go — route + handler:

	mux.Handle("POST /orders/{id}/terms-text/fill", mw.requireUser(http.HandlerFunc(d.orderTermsFill)))
var netDays = map[string]int{"net_15": 15, "net_30": 30, "net_45": 45, "net_60": 60, "net_75": 75, "net_90": 90}

// termsTextFor substitutes {{advertiser}}, {{days}} (net kind only), and
// {{ad_minimum}} into a terms_templates body — the auto-fill's whole job.
func termsTextFor(tmpl terms.Template, orderTerms, advertiserName string) string {
	body := strings.ReplaceAll(tmpl.BodyText, "{{advertiser}}", advertiserName)
	body = strings.ReplaceAll(body, "{{ad_minimum}}", "$"+money.Display(tmpl.AdMinimumCents))
	if days, ok := netDays[orderTerms]; ok {
		body = strings.ReplaceAll(body, "{{days}}", strconv.Itoa(days))
	}
	return body
}

func (d Deps) orderTermsFill(w http.ResponseWriter, r *http.Request) {
	actor, _ := CurrentUser(r.Context())
	id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
	o, err := d.Orders.Get(r.Context(), id)
	if err != nil {
		http.NotFound(w, r)
		return
	}
	kind := "net"
	if o.Terms == "due_upon_receipt" {
		kind = "due_upon_receipt"
	}
	tmpl, err := d.Terms.Get(r.Context(), kind)
	if err != nil {
		http.Redirect(w, r, fmt.Sprintf("/orders/%d?err=%s", id, urlQueryEscape(err)), http.StatusSeeOther)
		return
	}
	adv, err := d.Sponsors.GetAdvertiser(r.Context(), o.AdvertiserID)
	if err != nil {
		http.Redirect(w, r, fmt.Sprintf("/orders/%d?err=%s", id, urlQueryEscape(err)), http.StatusSeeOther)
		return
	}
	o.TermsText = termsTextFor(tmpl, o.Terms, adv.Name)
	if err := d.Orders.Update(r.Context(), o); err != nil {
		http.Redirect(w, r, fmt.Sprintf("/orders/%d?err=%s", id, urlQueryEscape(err)), http.StatusSeeOther)
		return
	}
	_ = d.Audit.Record(r.Context(), &actor, "order", fmt.Sprint(id), "fill_terms_text", map[string]any{"kind": kind})
	http.Redirect(w, r, fmt.Sprintf("/orders/%d", id), http.StatusSeeOther)
}

Add "twit.tv/twitads/internal/terms" to orders_ui.go's imports.

templates/terms_templates.html:

{{define "title"}}Terms Templates — TWiT Ads{{end}}
{{define "content"}}
<h2>Terms templates</h2>
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
{{range .Data.Templates}}
<h3>{{.Kind}}</h3>
{{if $.Data.CanEdit}}
<form method="post" action="/admin/terms/{{.Kind}}">
  <label>Advertising minimum $ <input name="ad_minimum" value="{{dollars .AdMinimumCents}}"></label>
  <label>Body text <textarea rows="20" cols="80" name="body_text">{{.BodyText}}</textarea></label>
  <p><button>Save</button></p>
</form>
{{else}}
<pre>{{.BodyText}}</pre>
{{end}}
{{end}}
{{end}}

templates/order_form.html — add near the Terms select:

{{if not .Data.IsNew}}<form method="post" action="/orders/{{.Data.O.ID}}/terms-text/fill"><button>Fill from template</button></form>{{end}}

Wire the real terms.Store into the production Deps{...} construction (find where orders.Store/rates.Store are constructed for the live server — likely cmd/twitads/main.go) and add Terms: &terms.Store{Pool: pool} alongside the existing store constructions.

Run: cd app && go vet ./... && just test-db Expected: PASS.

git add app/internal/server/server.go app/internal/server/admin_terms.go app/internal/server/admin_terms_test.go \
        app/internal/server/orders_ui.go app/internal/server/orders_ui_test.go \
        app/internal/server/templates/terms_templates.html app/internal/server/templates/order_form.html \
        app/cmd/twitads/main.go
git commit -m "feat(orders): terms boilerplate admin CRUD + auto-fill button"

(Adjust the main.go path in the git add to wherever the executor actually finds the production Deps{...} construction — confirm the real path with grep -rn "Deps{" app/cmd/ before committing.)

Task 6b (folded into Task 6, no separate commit — Domain Decision #12 reminder)

Before Task 6's Step 5 commit, double-check the migration's UPDATE shows SET ad_voice_host = ... statements from Task 1 actually matched real rows if the live testbed was reachable during Step 0 of Task 1. This isn't a new task — it's a reminder folded here because ad_voice_host is exercised for real by Task 7's Notes generation, and any title mismatch is cheapest to catch before that task builds on it.

Task 7: Notes auto-generation from show host data

Files:

Interfaces: no new store methods — shows.Store.Update already round-trips AdVoiceHost (Task 1). Route: POST /orders/{id}/notes/fill.

func TestShowFormRoundTripsAdVoiceHost(t *testing.T) {
	h, store, _ := showsServer(t)
	rec := adminPost(h, "/admin/shows/1", "admin", url.Values{
		"title": {"Security Now"}, "abbrev": {"SN"}, "weekdays": {"2"}, "max_ads": {"5"},
		"record_offset_days": {"0"}, "record_start": {"13:30"}, "record_end": {"15:30"},
		"first_episode_number": {"1"}, "ad_voice_host": {"Leo Laporte"}, "active": {"on"},
	})
	if rec.Code != http.StatusSeeOther {
		t.Fatalf("got %d, want 303", rec.Code)
	}
	got, _ := store.Get(context.Background(), 1)
	if got.AdVoiceHost != "Leo Laporte" {
		t.Errorf("ad_voice_host not parsed from form: %+v", got)
	}
}

Append to app/internal/server/orders_ui_test.go:

func TestOrderNotesFillGroupsByAdVoiceHostAndSummarizesValueAdd(t *testing.T) {
	h, store, _ := ordersServer(t)
	store.byID[1] = orders.Order{ID: 1, Title: "W1", AdvertiserID: 1, SalespersonID: 3, Status: "pending", Year: 2026, Version: 1}
	line, _ := store.AddEpisodeLine(context.Background(), 1, 1, 6500, 260000, 40000) // fixture show 1 = "Security Now", AdVoiceHost set below
	store.lineDates[line.ID] = []int64{5}
	if _, err := store.ToggleLineValueAdd(context.Background(), line.ID); err != nil {
		t.Fatalf("mark value-add: %v", err)
	}

	rec := adminPost(h, "/orders/1/notes/fill", "sales", url.Values{})
	if rec.Code != http.StatusSeeOther {
		t.Fatalf("got %d, want 303", rec.Code)
	}
	got, _ := store.Get(context.Background(), 1)
	if !strings.Contains(got.Notes, "one host-read ad") {
		t.Errorf("missing fixed ad-format sentence: %q", got.Notes)
	}
	if !strings.Contains(got.Notes, "hosted by") {
		t.Errorf("missing host line: %q", got.Notes)
	}
	if !strings.Contains(got.Notes, "included in case of softness") {
		t.Errorf("missing value-add rollup sentence: %q", got.Notes)
	}
}

This test depends on ordersServer's fixture show (ID 1, "Security Now") having a non-empty AdVoiceHost — extend the showStore.byID[1] = shows.Show{...} literal already present in ordersServer (from B3 Task 4) to include AdVoiceHost: "Leo Laporte".

Run: cd app && go test ./internal/server/... -run 'TestShowFormRoundTripsAdVoiceHost|TestOrderNotesFill' -v Expected: FAIL (form field not parsed; route/handler undefined).

app/internal/server/admin_shows.goshowFromForm gains:

	sh.AdVoiceHost = strings.TrimSpace(r.FormValue("ad_voice_host"))

(insert alongside the existing sh.DefaultTD = strings.TrimSpace(r.FormValue("default_td")) line).

templates/show_form.html — add near the Notes field:

  <label>Ad-voicing host (who reads this show's ads, if different from general cast) <input name="ad_voice_host" value="{{.Data.Show.AdVoiceHost}}"></label>

app/internal/server/orders_ui.go:

	mux.Handle("POST /orders/{id}/notes/fill", mw.requireUser(http.HandlerFunc(d.orderNotesFill)))
// orderNotesFill composes Notes from three pieces (Plans/B2-orders-list-crud.md's
// captured format, Plans/B4-catalog-softness-terms.md Domain Decisions #8/#10):
// (1) a fixed ad-format sentence, (2) episode-ad lines' shows grouped by
// ad_voice_host, (3) a value-add rollup sentence when any line is marked.
func (d Deps) orderNotesFill(w http.ResponseWriter, r *http.Request) {
	actor, _ := CurrentUser(r.Context())
	id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
	o, err := d.Orders.Get(r.Context(), id)
	if err != nil {
		http.NotFound(w, r)
		return
	}
	lines, err := d.Orders.Lines(r.Context(), id)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}

	byHost := map[string][]string{}
	seenShow := map[int64]bool{}
	var valueAddCents int64
	var valueAddImpressions int
	for _, l := range lines {
		if l.Kind == "episode_ad" && l.ShowID != nil && !seenShow[*l.ShowID] {
			seenShow[*l.ShowID] = true
			sh, err := d.Shows.Get(r.Context(), *l.ShowID)
			if err != nil {
				http.Error(w, err.Error(), http.StatusInternalServerError)
				return
			}
			host := sh.AdVoiceHost
			if host == "" {
				host = "?" // flag missing data loudly rather than silently dropping the show — Domain Decision #10
			}
			byHost[host] = append(byHost[host], sh.Abbrev)
		}
		if l.IsValueAdd {
			valueAddCents += money.Round(l.RateCents * int64(l.Quantity))
			valueAddImpressions += l.ImpressionsPerUnit * l.Quantity
		}
	}

	var b strings.Builder
	b.WriteString("Each show episode has one ad spot - a host read ad (1-2 minutes).")
	hosts := make([]string, 0, len(byHost))
	for h := range byHost {
		hosts = append(hosts, h)
	}
	sort.Strings(hosts)
	for _, h := range hosts {
		showAbbrevs := byHost[h]
		sort.Strings(showAbbrevs)
		b.WriteString("\n" + strings.Join(showAbbrevs, ", ") + " hosted by " + h)
	}
	if valueAddCents > 0 {
		b.WriteString(fmt.Sprintf("\n$%s - %s impressions are included in case of softness to cover shortfalls. If not needed, they are a bonus.",
			money.Display(valueAddCents), money.Commas(valueAddImpressions)))
	}

	o.Notes = b.String()
	if err := d.Orders.Update(r.Context(), o); err != nil {
		http.Redirect(w, r, fmt.Sprintf("/orders/%d?err=%s", id, urlQueryEscape(err)), http.StatusSeeOther)
		return
	}
	_ = d.Audit.Record(r.Context(), &actor, "order", fmt.Sprint(id), "fill_notes", map[string]any{"host_count": len(byHost)})
	http.Redirect(w, r, fmt.Sprintf("/orders/%d", id), http.StatusSeeOther)
}

Add "sort" to orders_ui.go's imports.

templates/order_form.html — add alongside the "Fill from template" button:

{{if not .Data.IsNew}}<form method="post" action="/orders/{{.Data.O.ID}}/notes/fill"><button>Auto-generate notes</button></form>{{end}}

Run: cd app && go vet ./... && just test-db Expected: PASS.

git add app/internal/server/admin_shows.go app/internal/server/admin_shows_test.go app/internal/server/templates/show_form.html \
        app/internal/server/orders_ui.go app/internal/server/orders_ui_test.go app/internal/server/templates/order_form.html
git commit -m "feat(orders,shows): Notes auto-generation from ad-voice-host + value-add rollup"

Task 8: Whole-repo verification & close-out

Run: cd app && go vet ./... && just test && just test-db && just test-db Expected: every package green both times.

Run: cd app && go list -deps ./internal/orders ./internal/terms | grep -E 'net/http' || echo "PURE: internal/orders and internal/terms have no net/http" Expected: the PURE message.

Run: cd app && docker compose up -d --build && sleep 3 && curl -fsS localhost:8730/healthz && docker compose logs --tail=20 app Expected: healthz OK, migrations report v8. Spot-check over the tailnet (or note as deferred to Leo, matching every prior stage's pattern): open an order, add a newsletter line and a custom line, add a billboard-week line and claim a week, mark a line value-add, set a 5% discount, click "Fill from template" and "Auto-generate notes," confirm totals reflect the discount and value-add deduct.


Acceptance checklist


Notes for the executor


Decisions & deviations

Stage executed 2026-07-08 by Opus 4.8 implementers, one task per commit, orchestrated and reviewed task-by-task by Fable 5. All seven feature tasks landed clean (each review Approved with no Critical/Important findings); Task 8 (this close-out) verified the whole stage end-to-end. Base HEAD before B4 was 223249e; final feature commit is 8fd40c9 (Task 7). Full task-by-task review ledger: .superpowers/sdd/progress.md.

Commit trail (feature tasks): Task 1 d2e82ce, Task 2 e028751, Task 3 4af7e95, Task 4 804c443, Task 5 9242fe4, Task 6 0e2ca88, Task 7 8fd40c9.

Deliberate spec divergences (the ones a future reader must not "fix")

Content gap (blocks production readiness, independent of B5)

Landmines fixed / gaps closed during the stage

Other design calls worth remembering

Verification (Task 8)

go vet ./... clean; just test green; just test-db green twice (idempotent); internal/orders and internal/terms confirmed free of any net/http import; compose smoke reported goose: no migrations to run. current version: 8 and /healthz returned {"status":"ok"}. The live browser walkthrough (open an order, add catalog/billboard lines, mark value-add, set a discount, fill terms/notes) is deferred to Leo per this project's stage lifecycle — same pattern as B1–B3.