A5 — Rate Card & Sellable Catalog Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: superpowers:executing-plans (or subagent-driven-development). Checkbox steps.
Goal: Versioned rate cards: per-show midroll rates (downloads/episode, CPM, cost/episode) and catalog-product rates (billboard week, newsletter issue, social post, banner impression), effective-dated with append-only history; rate-card-flag gating; the resolution function B-phase copies onto order lines.
Architecture: products (data-driven catalog — Lisa's new production rate card must be enterable without code changes) plus two versioned rate tables, show_rates and product_rates, each unique on (target, effective_date), resolved by "latest effective_date ≤ as-of wins." Rates are COPIED onto order lines at booking (B-phase), so editing history never mutates existing orders — the legacy latest-always-wins trap is dead by construction. Idioms per A2–A4.
Global Constraints (as A1–A4; read ~/Projects/twit-ads/CLAUDE.md first)
- Migration
00005_ratecard.sql. TDD. Audit every mutation. Bash. Money = integer cents everywhere; UI accepts dollars ("0.65", "17450"), converted in handlers via a tested pure helper. CPM is money (cents per thousand impressions). - Gating: viewing = any logged-in user; mutations =
requireRatecard(admin ORcan_edit_ratecardflag — Lisa + Debi).
Domain decisions (flag to Leo if wrong):
- Show rates carry downloads/episode + CPM + cost/episode as independent fields (UI offers CPM×downloads/1000 auto-fill for cost, but override allowed — matches legacy rate card).
- Products seeded by migration as data: Opening Billboard (per week), Newsletter (per issue), Social Media Post (per post), Banner (per impression). Product rates carry price + guaranteed impressions per unit. New/renamed products are UI operations, not migrations.
- Rate entries are editable/deletable with audit (typo repair) — safe because orders copy rates at booking; unique (target, effective_date) prevents ambiguity.
- The custom line type (live events etc.) has no catalog entry — it's free text + flat amount at order time (B4).
Task 1: Migration + rates store
Files: app/internal/db/migrations/00005_ratecard.sql, app/internal/rates/rates.go (+ testhelper + tests; truncate list grows: show_rates, product_rates, products, ...).
-- +goose Up
CREATE TABLE products (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL UNIQUE,
unit_label text NOT NULL, -- week | issue | post | impression
active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO products (name, unit_label) VALUES
('Opening Billboard', 'week'),
('Newsletter', 'issue'),
('Social Media Post', 'post'),
('Banner', 'impression');
CREATE TABLE show_rates (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
show_id bigint NOT NULL REFERENCES shows(id),
effective_date date NOT NULL,
downloads_per_episode int NOT NULL CHECK (downloads_per_episode >= 0),
cpm_cents bigint NOT NULL CHECK (cpm_cents >= 0),
cost_per_episode_cents bigint NOT NULL CHECK (cost_per_episode_cents >= 0),
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (show_id, effective_date)
);
CREATE TABLE product_rates (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
product_id bigint NOT NULL REFERENCES products(id),
effective_date date NOT NULL,
price_cents bigint NOT NULL CHECK (price_cents >= 0),
impressions_per_unit int NOT NULL DEFAULT 0 CHECK (impressions_per_unit >= 0),
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (product_id, effective_date)
);
-- +goose Down
DROP TABLE product_rates; DROP TABLE show_rates; DROP TABLE products;
Interfaces (types ShowRate{ID, ShowID, EffectiveDate, DownloadsPerEpisode int, CPMCents, CostPerEpisodeCents int64}, Product{ID, Name, UnitLabel string, Active}, ProductRate{ID, ProductID, EffectiveDate, PriceCents int64, ImpressionsPerUnit int}):
type Store struct{ Pool *pgxpool.Pool }
// SetShowRate (insert; unique-violation → error), UpdateShowRate, DeleteShowRate,
// ShowRateHistory(showID) (newest first), RateForShow(showID, asOf) (ShowRate, error) — latest effective<=asOf; ErrNoRate when none.
// Products: ListProducts, CreateProduct, UpdateProduct.
// SetProductRate/UpdateProductRate/DeleteProductRate/ProductRateHistory/RateForProduct — mirror the show functions.
Tests (the load-bearing one is version resolution): two entries (Jan 1 / Jul 1); asOf Jun 30 → Jan rate; asOf Jul 1 → Jul rate; asOf before Jan 1 → ErrNoRate. Plus history ordering, duplicate effective_date rejected, product CRUD. Red → implement → just test-db green → commit.
Task 2: Money helper + ratecard middleware
Files: app/internal/server/money.go (+test), middleware addition (+test).
dollarsToCents(s string) (int64, error)— accepts "17450", "17,450", "$17,450.00", "0.65"; rejects negatives, >2 decimals, garbage. Pure, table-driven TDD. CompanioncentsToDollars(int64) stringfor display ("17,450.00" → keep simple: no thousands separators, "17450.00").requireRatecard: admin ORCanEditRatecard; 403 otherwise (test: lisa sales+flag passes, debi continuity+flag passes, ty sales no-flag 403).
Task 3: UI
Files: app/internal/server/rates_ui.go, templates ratecard.html (current card: per active show downloads/CPM/cost + per product price/impressions, each with effective date + History link), show_rates.html (history + add/edit/delete forms), product_rates.html (same per product + product create/edit at top). Deps gains Rates RateStore interface; home nav gains "Rate Card"; main.go wires. Audit entities show_rate/product_rate/product.
Routes: GET /rates (view, requireUser); GET/POST /rates/shows/{id} + POST /rates/shows/{id}/{rateID}/delete (requireRatecard); same shape /rates/products/{id}; POST /rates/products (create product), POST /rates/products/{id}/edit (rename/active).
Tests: view open to sales; mutations 403 without flag; add show rate with dollars input lands as cents; delete audits.
Task 4: Wire, E2E, close out
- vet + just test + test-db; compose rebuild.
- E2E: grant e2e account ratecard flag; add SN rate (eff. 2026-01-01: 40,000 downloads, CPM $65 → auto cost $2,600); add revision eff. 2026-09-01 CPM $70; confirm /rates shows the January rate today and history shows both; price Opening Billboard $17,450/week with 200,000 impressions; audit trail correct. Deactivate e2e account.
- README row → executed; decisions appended; commit; republish site. STOP for Leo/Lisa: enter the real current rate card (Lisa's numbers), confirm mid-year-revision semantics read right.
Decisions & deviations (executed 2026-07-06 by Kenobi/Fable 5)
- Test-helper fix: the rates suite creates products, and products are migration-seeded rather than truncated — helper now resets AND re-seeds so the suite is idempotent across runs (proven by running twice).
- Rate-card page view models use map-based template data (matches complexity better than one-off structs); everything else per plan.
- E2E left in dev DB: SN rate eff. 2026-01-01 (40k downloads, $65 CPM, $2,600 auto-computed cost) + eff. 2026-09-01 revision ($70 CPM, correctly NOT applied today), Opening Billboard $17,450/week @ 200k impressions. Leo/Lisa replace with the real card at verification.
- All green: 8 packages × 2 consecutive runs; live E2E incl. as-of resolution and blank-cost auto-computation.