TWiT-Ads v2 Rebuild

B1 — Order Math Core & Availability Calculator 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, §"Order math", §"Availability & the sea of red"), and Plans/phase-a-review.md first.

Goal: The pure-function heart of the system — order totals (gross → value-add deduct → revised → discount → final → commission → net-to-TWiT), guaranteed impressions, both presentations, and the availability classifier — as a DB-free, HTTP-free Go package with exhaustive table-driven tests. No UI, no schema.

Architecture: Two new pure packages. internal/money holds the whole-dollar house rule (extracted from internal/server/money.go so there is exactly ONE home for the rounding law); server and ordermath both import it. internal/ordermath holds the order-total contract and the availability classifier as pure functions over value structs — no pgx, no net/http, no time beyond civil dates. B2/B4 supply the inputs from stored orders; B3/B5 feed real placement counts into the classifier. This is the most-tested code in the repo (spec §9 risk register).

Tech Stack: Go 1.25, stdlib only. Integer cents throughout (whole-dollar multiples). Percentages as basis points (int, matching the established commission_bp / pctToBP / bpPercent convention).

Global Constraints


Task 1: Extract the whole-dollar house rule into internal/money

Move the money primitives out of package server (where ordermath cannot import them without dragging in pgx/http) into a pure internal/money package, as exported functions. Mechanical, green-first refactor — the existing money_test.go moves with it and must stay green.

Files:

Interfaces:

// Package money is the single home of TWiT's whole-dollar house rule
// (Leo, 2026-07-06): every dollar amount — input, computed, displayed — is
// rounded to the nearest whole dollar. Storage stays integer cents (multiples
// of 100) so intermediate arithmetic (CPM×downloads÷1000, basis-point
// percentages) is exact before the final rounding. Pure: no pgx, no net/http.
package money

import (
	"fmt"
	"strconv"
	"strings"
)

// Round rounds cents to the nearest whole dollar (half away from zero).
// EVERY computed money amount must pass through this before storage or display.
func Round(cents int64) int64 {
	if cents >= 0 {
		return (cents + 50) / 100 * 100
	}
	return -((-cents + 50) / 100 * 100)
}

// ParseDollars parses a human dollars string ("17,450", "$65", "2600.50")
// into whole-dollar cents. Decimals accepted but rounded to the nearest dollar.
// Rejects negatives and more than two decimal places.
func ParseDollars(s string) (int64, error) {
	s = strings.TrimSpace(s)
	s = strings.TrimPrefix(s, "$")
	s = strings.ReplaceAll(s, ",", "")
	if s == "" {
		return 0, fmt.Errorf("amount required")
	}
	whole, frac, hasFrac := strings.Cut(s, ".")
	if whole == "" {
		whole = "0"
	}
	w, err := strconv.ParseInt(whole, 10, 64)
	if err != nil {
		return 0, fmt.Errorf("bad amount %q", s)
	}
	if w < 0 || strings.HasPrefix(whole, "-") {
		return 0, fmt.Errorf("amount must not be negative")
	}
	var f int64
	if hasFrac {
		if len(frac) > 2 || len(frac) == 0 {
			return 0, fmt.Errorf("amount %q: use at most two decimal places", s)
		}
		if len(frac) == 1 {
			frac += "0"
		}
		f, err = strconv.ParseInt(frac, 10, 64)
		if err != nil || f < 0 {
			return 0, fmt.Errorf("bad amount %q", s)
		}
	}
	return Round(w*100 + f), nil
}

// InputString renders whole-dollar cents for form inputs: "2601", no commas.
func InputString(cents int64) string {
	cents = Round(cents)
	return strconv.FormatInt(cents/100, 10)
}

// Commas renders an int with thousands separators: 115000 → "115,000".
func Commas(n int) string {
	s := strconv.Itoa(n)
	neg := false
	if strings.HasPrefix(s, "-") {
		neg, s = true, s[1:]
	}
	for i := len(s) - 3; i > 0; i -= 3 {
		s = s[:i] + "," + s[i:]
	}
	if neg {
		s = "-" + s
	}
	return s
}

// Display renders cents for humans: whole dollars with commas, never cents.
func Display(cents int64) string {
	cents = Round(cents)
	sign := ""
	if cents < 0 {
		sign = "-"
		cents = -cents
	}
	return sign + Commas(int(cents/100))
}

Run: cd app && go test ./internal/money/ Expected: PASS. (If it fails to compile, the rename is incomplete — fix before touching server.)

	"dollars": money.InputString, // form inputs: plain parseable "2600"
	"money":   money.Display,     // display: whole dollars with commas
	"commas":  money.Commas,      // display: integer counts with commas

In rates_ui.go replace dollarsToCents(money.ParseDollars( (lines ~103, ~108, ~234, ~255) and roundToDollar(money.Round( (line ~114).

Run: cd app && go vet ./... && just test Expected: build clean; all packages pass. (just test runs the non-DB suite; just test-db unaffected — no schema touched.)

git add -A && git commit -m "refactor(money): extract whole-dollar house rule into internal/money"

Task 2: Order-total contract (ordermath.Compute)

The math contract from spec §"Order math", as a pure function. This is the golden-tested core.

Files:

Interfaces:

// Line is one order line as it feeds the math. Money in integer cents
// (whole-dollar multiples); impressions integers. Per-unit rate + quantity;
// the UI extends. RateCents is the rate copied from the card, already overridden
// by the salesperson if she changed it.
type Line struct {
	RateCents          int64 // per-unit price (cost/episode, week price, issue price, …)
	ImpressionsPerUnit int   // downloads/episode or product impressions/unit
	Quantity           int   // units: # selected episode dates, # weeks, # issues, …
	IsValueAdd         bool  // softness: contributes $0 and 0 guaranteed impressions
}

type DiscountKind int

const (
	NoDiscount DiscountKind = iota
	PercentDiscount              // Basis points in Discount.BP
	FlatDiscount                 // Dollars in Discount.FlatCents
)

type Discount struct {
	Kind      DiscountKind
	BP        int   // basis points when Kind == PercentDiscount (500 = 5%)
	FlatCents int64 // cents when Kind == FlatDiscount
}

type Order struct {
	Lines              []Line
	Discount           Discount
	AgencyCommissionBP int // 0 == direct (no agency); else agency.commission_bp
}

type Totals struct {
	GrossSubtotalCents        int64 // Σ all lines at full value (value-add included)
	GrossImpressions          int   // Σ all lines impressions (value-add included)
	ValueAddDeductCents       int64 // Σ value-add lines, dollars
	ValueAddDeductImpressions int   // Σ value-add lines, impressions
	RevisedTotalCents         int64 // gross − value-add deduct
	DiscountCents             int64 // resolved discount (money only)
	FinalTotalCents           int64 // revised − discount
	GuaranteedImpressions     int   // Σ paid-line impressions only
	CommissionCents           int64 // final × commission bp (0 if direct)
	NetToTWiTCents            int64 // final − commission
}

func Compute(o Order) Totals

Computation rules (round to whole dollar at EACH money step):

package ordermath

import "testing"

func TestCompute(t *testing.T) {
	cases := []struct {
		name string
		in   Order
		want Totals
	}{
		{
			// W1 "Recommended": IM midroll ×2 paid + ×2 value-add (2,600/ep, 40k dl),
			// billboard week 17,450 (0 impr), newsletter 600 (50k impr),
			// 5% order discount, 15% agency.
			name: "w1_recommended_billboard_softness",
			in: Order{
				Lines: []Line{
					{RateCents: 260000, ImpressionsPerUnit: 40000, Quantity: 2, IsValueAdd: false},
					{RateCents: 260000, ImpressionsPerUnit: 40000, Quantity: 2, IsValueAdd: true},
					{RateCents: 1745000, ImpressionsPerUnit: 0, Quantity: 1, IsValueAdd: false},
					{RateCents: 60000, ImpressionsPerUnit: 50000, Quantity: 1, IsValueAdd: false},
				},
				Discount:           Discount{Kind: PercentDiscount, BP: 500},
				AgencyCommissionBP: 1500,
			},
			want: Totals{
				GrossSubtotalCents:        2845000, // 5,200+5,200+17,450+600
				GrossImpressions:          210000,  // 80k+80k+0+50k
				ValueAddDeductCents:       520000,  // 5,200
				ValueAddDeductImpressions: 80000,
				RevisedTotalCents:         2325000, // 23,250
				DiscountCents:             116300,  // 5% of 23,250 = 1,162.50 → 1,163
				FinalTotalCents:           2208700, // 22,087
				GuaranteedImpressions:     130000,  // 80k paid + 0 + 50k
				CommissionCents:           331300,  // 15% of 22,087 = 3,313.05 → 3,313
				NetToTWiTCents:            1877400, // 18,774
			},
		},
		{
			name: "empty_order",
			in:   Order{},
			want: Totals{}, // all zero
		},
		{
			name: "single_paid_line_direct_no_discount",
			in: Order{Lines: []Line{
				{RateCents: 260000, ImpressionsPerUnit: 40000, Quantity: 3, IsValueAdd: false},
			}},
			want: Totals{
				GrossSubtotalCents: 780000, GrossImpressions: 120000,
				RevisedTotalCents: 780000, FinalTotalCents: 780000,
				GuaranteedImpressions: 120000, NetToTWiTCents: 780000,
			},
		},
		{
			name: "all_value_add_line",
			in: Order{Lines: []Line{
				{RateCents: 260000, ImpressionsPerUnit: 40000, Quantity: 1, IsValueAdd: true},
			}},
			want: Totals{
				GrossSubtotalCents: 260000, GrossImpressions: 40000,
				ValueAddDeductCents: 260000, ValueAddDeductImpressions: 40000,
				// revised 0, final 0, guaranteed 0, net 0
			},
		},
		{
			name: "flat_discount_direct",
			in: Order{
				Lines:    []Line{{RateCents: 1000000, ImpressionsPerUnit: 0, Quantity: 1}},
				Discount: Discount{Kind: FlatDiscount, FlatCents: 150000},
			},
			want: Totals{
				GrossSubtotalCents: 1000000, RevisedTotalCents: 1000000,
				DiscountCents: 150000, FinalTotalCents: 850000, NetToTWiTCents: 850000,
			},
		},
		{
			name: "flat_discount_clamped_to_revised",
			in: Order{
				Lines:    []Line{{RateCents: 100000, Quantity: 1}},
				Discount: Discount{Kind: FlatDiscount, FlatCents: 500000},
			},
			want: Totals{
				GrossSubtotalCents: 100000, RevisedTotalCents: 100000,
				DiscountCents: 100000, FinalTotalCents: 0, NetToTWiTCents: 0,
			},
		},
		{
			// Proves per-step rounding: 12.5% of 2,325,000c = 290,625c ($2,906.25) → $2,906.
			name: "percent_discount_rounds_half_away",
			in: Order{
				Lines:    []Line{{RateCents: 2325000, Quantity: 1}},
				Discount: Discount{Kind: PercentDiscount, BP: 1250},
			},
			want: Totals{
				GrossSubtotalCents: 2325000, RevisedTotalCents: 2325000,
				DiscountCents: 290600, FinalTotalCents: 2034400, NetToTWiTCents: 2034400,
			},
		},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			got := Compute(c.in)
			if got != c.want {
				t.Errorf("Compute()\n got  %+v\n want %+v", got, c.want)
			}
		})
	}
}

Run: cd app && go test ./internal/ordermath/ Expected: FAIL (does not compile — types/Compute undefined).

func Compute(o Order) Totals {
	var t Totals
	for _, l := range o.Lines {
		value := money.Round(l.RateCents * int64(l.Quantity))
		impr := l.ImpressionsPerUnit * l.Quantity
		t.GrossSubtotalCents += value
		t.GrossImpressions += impr
		if l.IsValueAdd {
			t.ValueAddDeductCents += value
			t.ValueAddDeductImpressions += impr
		} else {
			t.GuaranteedImpressions += impr
		}
	}
	t.RevisedTotalCents = money.Round(t.GrossSubtotalCents - t.ValueAddDeductCents)

	switch o.Discount.Kind {
	case PercentDiscount:
		t.DiscountCents = money.Round(t.RevisedTotalCents * int64(o.Discount.BP) / 10000)
	case FlatDiscount:
		t.DiscountCents = money.Round(o.Discount.FlatCents)
	}
	if t.DiscountCents > t.RevisedTotalCents {
		t.DiscountCents = t.RevisedTotalCents
	}
	t.FinalTotalCents = money.Round(t.RevisedTotalCents - t.DiscountCents)

	if o.AgencyCommissionBP > 0 {
		t.CommissionCents = money.Round(t.FinalTotalCents * int64(o.AgencyCommissionBP) / 10000)
	}
	t.NetToTWiTCents = money.Round(t.FinalTotalCents - t.CommissionCents)
	return t
}

(Import "twit.tv/twitads/internal/money".)

Run: cd app && go test ./internal/ordermath/ -run TestCompute -v Expected: PASS, all 7 sub-cases.

git add app/internal/ordermath/ && git commit -m "feat(ordermath): order-total contract with golden tests"

Task 3: Presentation projection (lumped vs below_the_line)

Spec §"Order math": same stored data, two renderings. lumped prints value-add lines inline zeroed ($0 / 0 impressions), no deduct block; below_the_line prints every line at full value with the deduct block beneath (built from Totals). B1 provides the pure per-line projection; B4 wires it to templates.

Files:

Interfaces:

type Presentation int

const (
	Lumped       Presentation = iota // value-add lines zeroed inline, no deduct block
	BelowTheLine                     // all lines at full value, deduct block beneath
)

// LineDisplay is one row as it should render under a chosen presentation.
type LineDisplay struct {
	ValueCents  int64
	Impressions int
	IsValueAdd  bool
}

// DisplayLines projects each order line to its rendered row for presentation p.
// Order is preserved 1:1 with o.Lines. Totals/deduct block is separate (Compute);
// ShowDeductBlock reports whether the caller draws it.
func DisplayLines(o Order, p Presentation) []LineDisplay
func ShowDeductBlock(o Order, p Presentation) bool

Rules:

package ordermath

import "testing"

func TestDisplayLines(t *testing.T) {
	o := Order{Lines: []Line{
		{RateCents: 260000, ImpressionsPerUnit: 40000, Quantity: 2, IsValueAdd: false},
		{RateCents: 260000, ImpressionsPerUnit: 40000, Quantity: 1, IsValueAdd: true},
	}}

	lumped := DisplayLines(o, Lumped)
	want := []LineDisplay{
		{ValueCents: 520000, Impressions: 80000, IsValueAdd: false},
		{ValueCents: 0, Impressions: 0, IsValueAdd: true}, // zeroed inline
	}
	if len(lumped) != 2 || lumped[0] != want[0] || lumped[1] != want[1] {
		t.Errorf("Lumped = %+v, want %+v", lumped, want)
	}
	if ShowDeductBlock(o, Lumped) {
		t.Error("Lumped must not show a deduct block")
	}

	below := DisplayLines(o, BelowTheLine)
	wantBelow := []LineDisplay{
		{ValueCents: 520000, Impressions: 80000, IsValueAdd: false},
		{ValueCents: 260000, Impressions: 40000, IsValueAdd: true}, // full value
	}
	if below[0] != wantBelow[0] || below[1] != wantBelow[1] {
		t.Errorf("BelowTheLine = %+v, want %+v", below, wantBelow)
	}
	if !ShowDeductBlock(o, BelowTheLine) {
		t.Error("BelowTheLine with a value-add line must show the deduct block")
	}

	// No value-add line → no deduct block even below the line.
	paidOnly := Order{Lines: []Line{{RateCents: 100000, Quantity: 1}}}
	if ShowDeductBlock(paidOnly, BelowTheLine) {
		t.Error("no value-add line ⇒ no deduct block")
	}
}

Run: cd app && go test ./internal/ordermath/ -run TestDisplayLines Expected: FAIL (undefined).

func DisplayLines(o Order, p Presentation) []LineDisplay {
	out := make([]LineDisplay, len(o.Lines))
	for i, l := range o.Lines {
		row := LineDisplay{
			ValueCents:  money.Round(l.RateCents * int64(l.Quantity)),
			Impressions: l.ImpressionsPerUnit * l.Quantity,
			IsValueAdd:  l.IsValueAdd,
		}
		if p == Lumped && l.IsValueAdd {
			row.ValueCents, row.Impressions = 0, 0
		}
		out[i] = row
	}
	return out
}

func ShowDeductBlock(o Order, p Presentation) bool {
	if p != BelowTheLine {
		return false
	}
	for _, l := range o.Lines {
		if l.IsValueAdd {
			return true
		}
	}
	return false
}

Run: cd app && go test ./internal/ordermath/ -v Expected: PASS (TestCompute + TestDisplayLines).

git add app/internal/ordermath/ && git commit -m "feat(ordermath): lumped vs below-the-line presentation projection"

Task 4: Availability classifier

Spec §"Availability & the sea of red": an episode's slots = show.max_ads; sold = count of placements. The date picker renders white (open) / red (conflict — this proposal holds a date that's since gone full) / grey (episode full — unclickable). B1 provides the pure classifier over (maxAds, sold, selectedByThisProposal); B3/B5 supply sold from live placement counts and the DB unique constraint enforces the impossibility.

Files:

Interfaces:

// DateClass is how one episode date renders in a proposal's date picker.
type DateClass int

const (
	DateOpen     DateClass = iota // white: a slot remains; selectable
	DateConflict                  // red: this proposal holds this date but it's now full
	DateFull                      // grey: full; not selectable
)

// RemainingSlots is max_ads − sold, floored at 0.
func RemainingSlots(maxAds, sold int) int

// ClassifyDate classifies one episode date for a proposal's picker. sold counts
// FINALIZED placements only (pending proposals consume nothing). selected is
// whether THIS proposal already picked this date.
func ClassifyDate(maxAds, sold int, selected bool) DateClass

Rules (remaining = RemainingSlots(maxAds, sold)):

package ordermath

import "testing"

func TestRemainingSlots(t *testing.T) {
	cases := []struct{ maxAds, sold, want int }{
		{4, 0, 4}, {4, 3, 1}, {4, 4, 0}, {4, 5, 0}, // oversold clamps to 0
	}
	for _, c := range cases {
		if got := RemainingSlots(c.maxAds, c.sold); got != c.want {
			t.Errorf("RemainingSlots(%d,%d) = %d, want %d", c.maxAds, c.sold, got, c.want)
		}
	}
}

func TestClassifyDate(t *testing.T) {
	cases := []struct {
		name                 string
		maxAds, sold         int
		selected             bool
		want                 DateClass
	}{
		{"open_not_selected", 4, 2, false, DateOpen},
		{"open_selected", 4, 2, true, DateOpen},
		{"last_slot_open", 4, 3, false, DateOpen},
		{"full_not_selected_grey", 4, 4, false, DateFull},
		{"full_selected_red_conflict", 4, 4, true, DateConflict},
		{"oversold_selected_red", 4, 5, true, DateConflict},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			if got := ClassifyDate(c.maxAds, c.sold, c.selected); got != c.want {
				t.Errorf("ClassifyDate(%d,%d,%v) = %v, want %v", c.maxAds, c.sold, c.selected, got, c.want)
			}
		})
	}
}

Run: cd app && go test ./internal/ordermath/ -run 'TestRemainingSlots|TestClassifyDate' Expected: FAIL (undefined).

package ordermath

func RemainingSlots(maxAds, sold int) int {
	if r := maxAds - sold; r > 0 {
		return r
	}
	return 0
}

func ClassifyDate(maxAds, sold int, selected bool) DateClass {
	if RemainingSlots(maxAds, sold) == 0 {
		if selected {
			return DateConflict
		}
		return DateFull
	}
	return DateOpen
}

Run: cd app && go test ./internal/ordermath/ -v Expected: PASS (all four test funcs).

git add app/internal/ordermath/ && git commit -m "feat(ordermath): pure episode availability classifier"

Task 5: Whole-repo verification & close-out

Run: cd app && go vet ./... && just test && just test-db Expected: every package green, run twice for idempotency (just test-db uses -p 1; nothing in B1 touches the DB, so it should be untouched by the refactor — confirm it still passes).

Run: cd app && go list -deps ./internal/ordermath ./internal/money | grep -E 'pgx|net/http' || echo "PURE: no pgx / net/http" Expected: PURE: no pgx / net/http.

Run: cd app && docker compose up -d --build && sleep 3 && curl -fsS localhost:8080/healthz && docker compose logs --tail=20 app Expected: healthz OK; rate-card page still renders whole-dollar amounts (spot-check /rates in a browser over the tailnet — the money/dollars/commas funcmap now routes through internal/money).

git add -A && git commit -m "docs(B1): stage close-out — order math core complete"
bun run scripts/publish-site.ts --deploy   # requires bw-unlock

Acceptance checklist


Decisions & deviations (executed 2026-07-07 by Opus 4.8, subagent-driven)

Final whole-branch review (Opus 4.8, 2026-07-07) — Verdict: Ready to proceed to B2

No Critical or Important issues. Reviewer hand-traced both goldens end-to-end and confirmed the refactor is complete/non-regressing.

Notes for the executor