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, andPlans/B3-episode-lines.md's "Decisions & deviations" (the*line.ShowIDderef 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
- Money: TWiT never uses cents. Integer-cents storage (always multiples of 100); parse inputs with
money.ParseDollars, pass EVERY computed amount throughmoney.Round, display via themoneytemplate func in aclass="num"right-justified cell. Impressions = integers,commasfunc. Civil dates in America/Los_Angeles; all displayed dates MM/DD/YYYY viamdy. No sentinel values. - TDD, red/green, always.
go vet ./...+just test(+just test-dbwhere DB touched) green at every commit. - One change at a time. Build → test → confirm → next. Never batch.
- Audit log entry for every mutation of orders/order_lines (add/update/delete line, toggle value-add, toggle billboard week, terms/notes fill) via
d.Audit.Record. - Migrations forward-only; next number is
00008(last is00007_order_lines.sql). - Line edits are NOT gated on order status (B3 Domain Decision #11, still binding): every new mutation this stage adds — catalog-line rate/quantity, value-add toggle, billboard-week toggle — has no
order.Statuscheck, matchingUpdateLineRate's precedent. - The word "broadcast" does not appear in UI text — including seeded template text. The captured legacy boilerplate says "broadcast order"/"broadcast calendar month" throughout; the seeded copy reads "order"/"calendar month" instead.
html/template'seq/nedo NOT auto-dereference pointer arguments (B2/B3's lesson) — any comparison against a nullable*int64must dereference in Go first or useeqPtrI64.
Domain decisions (flag to Leo if wrong)
banneris an undated, stored-quantity catalog line, not aweek-dated one, despite the master spec's domain-model prose saying "billboard_week/banner_weeklines 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 (migration00005_ratecard.sql) settle it:Opening Billboard→week,Banner→impression. 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). Sobillboard_weekis the only dated catalog kind;newsletter/social_post/banner/customall use the new storedorder_lines.quantitycolumn.- 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 thatweek_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.BillboardWeekSoldCounttakes anexcludeLineIDparam so a line's own claim doesn't count against itself, replacing the "finalized-only" temporal exclusion episode dates get for free. - 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.
quantityis a plain storedintcolumn onorder_lines, unused (left0) forepisode_ad/billboard_week. No CHECK constraint ties it tokind— same "unused branch stays a harmless zero" pattern the tagged-union already uses forcustom_label/product_id/show_id.Store.Linesresolves the fieldOrderLine.Quantityreturns by branching onKind: computed date-count for the two dated kinds, the stored column for the other four.- Order-level discount lives on the
ordersrow itself (discount_kind/discount_bp/discount_flat_cents), mirroringordermath.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.htmlfamily), not onorder_form.htmlitself — 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. terms_templatesseeds two rows:net(real captured boilerplate) anddue_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.mdflags 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 seededdue_upon_receipttemplate 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 everyTermsTextfield 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.{{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 seedednetrow.- 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.
Notesstays a freely-editable textarea after auto-fill runs (same pattern asTermsText), so an outlier deal needing "(1-5 minutes)" gets hand-edited post-fill; no new per-order duration field. shows.ad_voice_hostis a new, plain, admin-editable text column — never parsed fromshows.notes's free text. PerPlans/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 againstnoteswould 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.- 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 emptyad_voice_hostrenders 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). - 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.
shows.ad_voice_hostis seeded via migration UPDATE statements matched by exacttitle, verified against the live testbed first (Task 1, Step 0) — the 13-show mapping is real ground truth fromPlans/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 theUPDATEstatements, not trust this plan's titles blindly.- 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 thelumped/below_the_linechoice entirely (Leo: "you don't need a lumped choice — we will always show it below the line") —orderFromFormhas hardcodedPresentation: "below_the_line"since B2, locked in byTestOrderPresentationAlwaysBelowTheLine. 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:
Create:
app/internal/db/migrations/00008_catalog_softness_terms.sqlCreate:
app/internal/terms/terms.goCreate:
app/internal/terms/terms_test.goCreate:
app/internal/terms/testhelper_test.goModify:
app/internal/orders/orders.go(Order struct + Create/Get/List/Update SQL; OrderLine.Quantity dual-source resolution inLines)Modify:
app/internal/orders/orders_test.goModify:
app/internal/shows/shows.go(Show struct + Create/Update/Get/List SQL)Modify:
app/internal/shows/shows_test.goStep 0: Verify the live show titles before writing seed data.
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.
- Step 1: Write the migration.
-- +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.
- Step 2: Write the failing tests. Extend
app/internal/orders/orders_test.go:
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)
}
}
- Step 3: Run — expect FAIL (undefined fields/methods; migration not applied).
Run: cd app && just test-db
Expected: FAIL to compile / new tables missing.
- Step 4: Implement.
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
}
- Step 5: Run — expect PASS.
Run: cd app && go vet ./... && just test-db
Expected: internal/orders, internal/shows, internal/terms all green.
- Step 6: Commit.
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:
- Modify:
app/internal/orders/orders.go(AddCatalogLine,UpdateCatalogLine) - Modify:
app/internal/orders/orders_test.go - Modify:
app/internal/server/server.go(OrderStoreinterface) - Modify:
app/internal/server/orders_ui.go(kind branch inlineEdit/buildLineEditorView; catalog-add + catalog-update handlers) - Modify:
app/internal/server/orders_ui_test.go - Create:
app/internal/server/templates/catalog_line_new.html - Create:
app/internal/server/templates/order_line_catalog_edit.html - Modify:
app/internal/server/templates/order_form.html("+ Add catalog item" link)
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
- Step 1: Write the failing tests. Append to
app/internal/orders/orders_test.go:
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])
}
}
- Step 2: Run — expect FAIL.
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).
- Step 3: Implement.
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}}
- Step 4: Run — expect PASS.
Run: cd app && go vet ./... && just test-db
Expected: PASS, all packages green.
- Step 5: Commit.
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:
- Modify:
app/internal/orders/orders.go(ToggleLineWeek,LineWeeks,BillboardWeekSoldCount) - Modify:
app/internal/orders/orders_test.go - Modify:
app/internal/server/server.go(OrderStoreinterface) - Modify:
app/internal/server/orders_ui.go(billboard arm of the kind branch; week-toggle handler) - Modify:
app/internal/server/orders_ui_test.go - Create:
app/internal/server/templates/order_line_billboard_edit.html - Create:
app/internal/server/templates/_week_button.html
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).
- Step 1: Write the failing tests. Append to
app/internal/orders/orders_test.go:
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")
}
}
- Step 2: Run — expect FAIL.
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).
- Step 3: Implement.
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.
- Step 4: Run — expect PASS.
Run: cd app && go vet ./... && just test-db
Expected: PASS, all packages green.
- Step 5: Commit.
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:
- Modify:
app/internal/orders/orders.go(ToggleLineValueAdd) - Modify:
app/internal/orders/orders_test.go - Modify:
app/internal/server/server.go(OrderStoreinterface) - Modify:
app/internal/server/orders_ui.go(lineValueAddTogglehandler) - Modify:
app/internal/server/orders_ui_test.go - Create:
app/internal/server/templates/_value_add_toggle.html - Modify:
app/internal/server/templates/order_line_edit.html,order_line_catalog_edit.html,order_line_billboard_edit.html(include the partial)
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).
- Step 1: Write the failing tests. Append to
app/internal/orders/orders_test.go:
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)")
}
}
- Step 2: Run — expect FAIL.
Run: cd app && go test ./internal/server/... -run TestLineValueAdd -v and cd app && just test-db
Expected: FAIL (undefined method/route).
- Step 3: Implement.
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:
order_line_edit.html:{{template "value_add_toggle" (dict "OrderID" .Data.OrderID "LineID" .Data.Line.ID "IsValueAdd" .Data.Line.IsValueAdd)}}order_line_catalog_edit.html:{{template "value_add_toggle" (dict "OrderID" .Data.OrderID "LineID" .Data.Line.ID "IsValueAdd" .Data.Line.IsValueAdd)}}order_line_billboard_edit.html:{{template "value_add_toggle" (dict "OrderID" .Data.OrderID "LineID" .Data.Line.ID "IsValueAdd" .Data.Line.IsValueAdd)}}Step 4: Run — expect PASS.
Run: cd app && go vet ./... && just test-db
Expected: PASS.
- Step 5: Commit.
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:
- Modify:
app/internal/server/orders_ui.go(orderFromForm,linesToOrdermath/buildLineEditorView/buildCatalogLineEditorView/buildBillboardLineEditorView— all three view-builders must populateordermath.Order.Discount) - Modify:
app/internal/server/orders_ui_test.go - Modify:
app/internal/server/templates/order_form.html
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
- Step 1: Write the failing tests. Append to
app/internal/server/orders_ui_test.go:
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)
}
}
- Step 2: Run — expect FAIL.
Run: cd app && go test ./internal/server/... -run 'TestOrderDiscount|TestLineEditorTotalsReflectOrderDiscount' -v
Expected: FAIL (form fields not parsed; totals don't reflect discount yet).
- Step 3: Implement.
app/internal/server/orders_ui.go — orderFromForm 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.)
- Step 4: Run — expect PASS.
Run: cd app && go vet ./... && just test-db
Expected: PASS.
- Step 5: Commit.
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:
- Modify:
app/internal/server/server.go(TermsStoreinterface,Deps.Terms) - Modify:
app/internal/server/orders_ui.go(orderTermsFillhandler,termsTextFor) - Modify:
app/internal/server/orders_ui_test.go - Create:
app/internal/server/admin_terms.go - Create:
app/internal/server/admin_terms_test.go - Create:
app/internal/server/templates/terms_templates.html - Modify:
app/internal/server/templates/order_form.html("Fill from template" button) - Modify:
app/internal/server/mainwiring — whereverDeps{...}is constructed for the real server (cmd/twitads/main.goor equivalent) to pass a realterms.Store
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
- Step 1: Write the failing tests. Create
app/internal/server/admin_terms_test.go:
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)
}
}
- Step 2: Run — expect FAIL.
Run: cd app && go test ./internal/server/... -run 'TestOrderTermsFill|TestAdminTerms' -v
Expected: FAIL (undefined Deps.Terms, routes, handlers).
- Step 3: Implement.
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.
- Step 4: Run — expect PASS.
Run: cd app && go vet ./... && just test-db
Expected: PASS.
- Step 5: Commit.
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:
- Modify:
app/internal/server/admin_shows.go(showFromFormgainsAdVoiceHost) - Modify:
app/internal/server/admin_shows_test.go - Modify:
app/internal/server/templates/show_form.html - Modify:
app/internal/server/orders_ui.go(orderNotesFillhandler) - Modify:
app/internal/server/orders_ui_test.go - Modify:
app/internal/server/templates/order_form.html("Auto-generate notes" button)
Interfaces: no new store methods — shows.Store.Update already round-trips AdVoiceHost (Task 1). Route: POST /orders/{id}/notes/fill.
- Step 1: Write the failing tests. Append to
app/internal/server/admin_shows_test.go:
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".
- Step 2: Run — expect FAIL.
Run: cd app && go test ./internal/server/... -run 'TestShowFormRoundTripsAdVoiceHost|TestOrderNotesFill' -v
Expected: FAIL (form field not parsed; route/handler undefined).
- Step 3: Implement.
app/internal/server/admin_shows.go — showFromForm 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}}
- Step 4: Run — expect PASS.
Run: cd app && go vet ./... && just test-db
Expected: PASS.
- Step 5: Commit.
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
- Step 1: Full suite, twice.
Run: cd app && go vet ./... && just test && just test-db && just test-db
Expected: every package green both times.
- Step 2: Purity spot-check.
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.
- Step 3: Compose smoke.
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.
Step 4: Tick the checklist below, append "Decisions & deviations," update
Plans/README.mdandCLAUDE.md's current-state line, commit, republish (bun run scripts/publish-site.ts --deploy, or note if the vault needsbw-unlock).Step 5: STOP for Leo's gate. Do not start B5. B5 is the highest-risk stage in this project (transactional finalize, oversell-impossible-by-construction, un-finalize, amend-in-place) —
phase-a-review.md's standing recommendation is a Fable design review before B5 implementation begins, not just a post-hoc code review. Also flag explicitly at the gate: this stage'sdue_upon_receiptterms template is a placeholder (Domain Decision #6) — Lisa needs to supply the real legacy text before this is production-ready, independent of B5.
Acceptance checklist
- Migration
00008_catalog_softness_terms.sqlapplied:orders.discount_kind/bp/flat_cents,order_lines.quantity,shows.ad_voice_host(seeded for the 13 known shows, verified against live titles),terms_templates(seedednet+due_upon_receipt). - Catalog lines:
newsletter/social_post/banner/customaddable via/orders/{id}/catalog/new, editable (rate + quantity) without a date picker;billboard_weekaddable via the same picker, editable via its own week picker. -
buildLineEditorView's B3*line.ShowIDderef landmine is fixed —lineEditbranches byKind; no line kind panics its editor page. - Billboard-week availability: white/red/grey via
ordermath.ClassifyDate(1, ...), sold count spans orders (not just the current order), self-exclusion viaexcludeLineID, void orders excluded. - Value-add toggle works on every line kind, persists, and the next totals recompute reflects the $0/0-impressions deduct.
- Order-level discount (percent or flat) parses from the order form, persists, and the next line-editor totals recompute reflects it (
FinalTotalCents/NetToTWiTCentsdown,GrossSubtotalCentsunchanged). - Terms boilerplate:
nettemplate has real captured text with{{advertiser}}/{{days}}/{{ad_minimum}}substituted correctly per the order'sTermsenum value;due_upon_receiptuses its own template; "Fill from template" persistsTermsText, stays hand-editable after. - Notes auto-generation: fixed ad-format sentence + per-ad-voice-host grouped show lines + value-add rollup sentence (only when value-add lines exist); "Auto-generate notes" persists
Notes, stays hand-editable after. -
go vet ./...,just test,just test-db(×2) all green;internal/orders/internal/termshave nonet/httpimport; compose smoke reports migration v8. Live/orderswalkthrough deferred to Leo, same pattern as B1–B3.
Notes for the executor
banner's undated-quantity classification (Domain Decision #1) is the single most spec-divergent call in this plan — the master spec's prose says "billboard_week/banner_week... carry week-start dates," but nobanner_weekkind exists in the already-shipped B3 schema. If this turns out wrong (Lisa expects banners to be week-scoped too), the fix is additive: givebannerits own week-picker arm inlineEdit's switch, matchingbillboard_week's shape exactly — nothing this plan builds needs to be torn out, just extended.- Billboard-week's "pending counts as sold" rule (Domain Decision #2) is a genuine, deliberate divergence from
ordermath.ClassifyDate's own doc comment ("sold counts FINALIZED placements only"). Don't "fix" this by making billboard sold-counting finalized-only to match episode dates — that was this plan's own first instinct, overridden by Leo directly. If a future stage adds real billboard placements (a B5 extension, maybe), this counting rule should be revisited then, not now. - The
due_upon_receiptterms template is an intentional, visible placeholder (Domain Decision #6) — don't fill it with invented legal text to make the feature "look done." The gap is Lisa's content to supply, not a task requirement to complete by guessing. orderFromForm's discount parsing silently drops malformed input rather than rejecting the whole order-header save (matching the existing function's tolerance for other fields). This is a known, small UX gap — surface it if Lisa hits it, don't preemptively harden it into a blocking validation this stage didn't ask for.- Every new mutation this stage adds has no order-status gate, matching B3 Domain Decision #11 exactly: catalog-line rate/quantity updates, value-add toggling, and billboard-week claiming must all keep working on finalized orders. If any test resembling B3's
TestLineRateOverrideWorksOnFinalizedOrderdoesn't have an equivalent for this stage's new mutations, that's a gap to fill, not a signal the rule doesn't apply here. buildLineEditorView(episode_ad),buildCatalogLineEditorView(undated catalog), andbuildBillboardLineEditorView(billboard_week) are three separate, kind-specific view-builders on purpose — resist the urge to unify them into one function with more branches. Each has a genuinely different shape (show+episodes vs. product+quantity vs. week-picker), and B3's ownlineEditorView/dateButtonViewpair already established "small, kind-specific structs" as this codebase's idiom over one do-everything struct.- Copy
sponsors_ui.go's,rates_ui.go's, and B3'sorders_ui.goidioms for anything not spelled out explicitly here (form re-render on error,?err=redirect pattern,pctToBP/money.ParseDollarsfor input parsing, thedict/renderFragmentmachinery from B3 for any partial reuse).
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")
- Banner/billboard split (Domain Decision #1) — the single most spec-divergent call in this plan. The master spec's domain-model prose says
billboard_week/banner_weeklines "carry week-start dates," but nobanner_weekkind exists in B3's already-shipped schema (which built exactly six kinds), and migrations are never edited after the fact. The catalog's own unit labels settle it —Opening Billboard→week,Banner→impression— sobillboard_weekis the only dated catalog kind, andbannerfolds into the undated, stored-quantityshape alongsidenewsletter/social_post/custom. If Lisa turns out to expect banners to be week-scoped, the fix is purely additive (givebannerits own week-picker arm inlineEdit's switch, matchingbillboard_week); nothing built here gets torn out. - Billboard "pending counts as sold" (Domain Decision #2) — a deliberate override of
ordermath.ClassifyDate's own doc comment ("sold counts FINALIZED placements only"), made by Leo directly during planning, overriding this plan's own first-draft instinct to punt billboard availability like B3 punted episodeSoldCount. Because there is no B5 placements system yet to gate against, a billboard week counts any non-void order's claim as sold (pending included) — otherwise a pre-B5 picker would have nothing to show, since no order is ever finalized yet.BillboardWeekSoldCounttakes anexcludeLineIDso a line's own claim doesn't count against itself. This is visibility, not hard enforcement (no DB constraint stops two pending orders claiming the same week — that's B5's job). When B5 adds real billboard placements, revisit this counting rule — do not silently "correct" it to finalized-only now.
Content gap (blocks production readiness, independent of B5)
- The
due_upon_receiptterms template is an intentional, visible placeholder (Domain Decision #6). Its seeded 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.]. Thenettemplate carries real captured legal boilerplate (verbatim from Leo's reference orders, with{{advertiser}}/{{days}}/{{ad_minimum}}substitution tokens and "broadcast" scrubbed per the house rule); the due-upon-receipt legacy text was never captured. Lisa must supply the real verbatim text before an order using due-upon-receipt terms is production-ready. Seeding invented legal prose would have been worse than an honest, editable placeholder.
Landmines fixed / gaps closed during the stage
- B3
*line.ShowIDderef landmine fixed in Task 2.buildLineEditorViewused to unconditionally dereference the nullableShowID, which would panic the moment a catalog line (no show) reached it. Task 2 introduced aresolveLinehelper plus aKind-branch inlineEdit(episode_ad→buildLineEditorView; else →buildCatalogLineEditorView), andTestCatalogLineEditorRendersWithoutShowIDPanicproves aShowID==nilline now renders 200 instead of panicking. Store.Duplicatediscount-copying gap closed in Task 5, not left open. Flagged during Task 1's review (Duplicate didn't copy the new discount fields), it was explicitly deferred to Task 5 and fixed there with a dedicated test the implementer confirmed RED by reverting the fix first — not silently dropped.
Other design calls worth remembering
- Three separate kind-specific view-builders on purpose (
buildLineEditorViewfor episode_ad,buildCatalogLineEditorViewfor undated catalog,buildBillboardLineEditorViewfor billboard_week) — not unified into one branchy function, matching B3's "small, kind-specific structs" idiom. - Value-add toggle is a plain POST-redirect-GET, not an htmx in-place swap (Domain Decision #11) — a rare deliberate action, unlike B3's rapid-fire date picker.
- No order-status gate on any new mutation (catalog rate/quantity, value-add toggle, billboard-week claim), matching B3 Domain Decision #11 — finalized orders stay editable.
shows.ad_voice_hostseeded and verified against the REAL 13-show production DB (not testbed fixtures, not guessed) — 9 shows Leo, 4 Mikah, all titles matched exactly (Task 1).- Known, un-hardened UX gaps (flagged, not fixed):
orderFromFormsilently drops malformed discount input rather than rejecting the whole header save; and the live totals block only renders on a line's editor page, so changing the order-level discount doesn't visibly recompute totals until a line is opened (the order header and line detail are separate pages — B6's consolidated print/PDF view is the natural place a single unified view finally lands).
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.