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"), andPlans/phase-a-review.mdfirst.
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
- Money = integer cents, whole-dollar multiples. EVERY computed amount passes through
money.Round(round half away from zero to nearest dollar) at each step — line values, deduct, discount, commission, net. Never floats for stored/returned money. (Spec §"Money rule", §"Order math".) - Percentages are basis points (
int): 500 = 5%, 1500 = 15%. Discount and commission round to the whole dollar after applying. (Established:commission_bp,internal/server/sponsors_ui.go:pctToBP.) - Impressions are integers. Value-add lines contribute 0 guaranteed impressions and $0. Guaranteed impressions = Σ paid-line impressions only.
- Pure packages.
internal/moneyandinternal/ordermathimport nopgx, nonet/http.ordermathimportsinternal/moneyonly. - TDD red/green, one change at a time. Table-driven tests written and failing before implementation.
go vet ./...+just testgreen at every commit. - No migration, no store, no audit in B1 (nothing is persisted here). Order/line/placement tables arrive in B2/B5.
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:
- Create:
app/internal/money/money.go - Create:
app/internal/money/money_test.go(moved fromapp/internal/server/money_test.go, package + call-sites renamed) - Delete:
app/internal/server/money.go,app/internal/server/money_test.go - Modify:
app/internal/server/templates.go:38-40(funcmap),app/internal/server/rates_ui.go:103,108,114,234,255(call sites)
Interfaces:
Produces (consumed by
servernow andordermathin Task 2):money.Round(cents int64) int64— nearest whole dollar, half away from zero (wasroundToDollar)money.ParseDollars(s string) (int64, error)— wasdollarsToCentsmoney.InputString(cents int64) string— plain, comma-free, for<input>values (wascentsToDollars)money.Commas(n int) string— thousands separators (wascommas)money.Display(cents int64) string— whole dollars + commas for humans (wasdisplayMoney)
Step 1: Create
internal/money/money.go— same bodies as currentserver/money.go,package money, functions exported and renamed per the interface list above.DisplaycallsCommas;ParseDollars/InputStringcallRound.
// 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))
}
Step 2: Move the test file. Move
server/money_test.go→internal/money/money_test.go; changepackage server→package money; rename every call (dollarsToCents→money.ParseDollarsbecomes bareParseDollarssince same package,roundToDollar→Round,centsToDollars→InputString,displayMoney→Display). Deleteserver/money.goand the oldserver/money_test.go.Step 3: Run the moved tests — expect PASS (behavior unchanged).
Run: cd app && go test ./internal/money/
Expected: PASS. (If it fails to compile, the rename is incomplete — fix before touching server.)
- Step 4: Re-point
servercall sites. Add"twit.tv/twitads/internal/money"import totemplates.goandrates_ui.go. Funcmap (templates.go:38-40):
"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).
- Step 5: Whole build + tests green.
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.)
- Step 6: Commit.
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:
- Create:
app/internal/ordermath/ordermath.go - Test:
app/internal/ordermath/ordermath_test.go
Interfaces:
- Consumes:
money.Round(Task 1). - Produces (consumed by B2/B4 UI and B6 outputs):
// 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):
line value =
money.Round(RateCents * Quantity); line impressions =ImpressionsPerUnit * QuantityGrossSubtotalCents= Σ line value (all lines);GrossImpressions= Σ line impressions (all lines)ValueAddDeductCents= Σ line value whereIsValueAdd;ValueAddDeductImpressions= Σ line impressions whereIsValueAddRevisedTotalCents=money.Round(Gross − ValueAddDeduct)DiscountCents:NoDiscount→0;PercentDiscount→money.Round(RevisedTotalCents * BP / 10000);FlatDiscount→money.Round(FlatCents), clamped to ≤RevisedTotalCentsFinalTotalCents=money.Round(Revised − Discount)GuaranteedImpressions= Σ line impressions where NOTIsValueAdd(== Gross − ValueAddDeduct impressions)CommissionCents=money.Round(FinalTotalCents * AgencyCommissionBP / 10000)(0 when bp==0)NetToTWiTCents=money.Round(Final − Commission)Step 1: Write the failing golden test (
ordermath_test.go). A W1-shaped order plus focused edge cases, table-driven. The load-bearing golden (billboard week + layered softness + 5% discount + 15% agency):
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)
}
})
}
}
- Step 2: Run — expect FAIL (
undefined: Compute).
Run: cd app && go test ./internal/ordermath/
Expected: FAIL (does not compile — types/Compute undefined).
- Step 3: Implement
ordermath.go— the types above plus:
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".)
- Step 4: Run — expect PASS.
Run: cd app && go test ./internal/ordermath/ -run TestCompute -v
Expected: PASS, all 7 sub-cases.
- Step 5: Commit.
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:
- Modify:
app/internal/ordermath/ordermath.go(add below) - Test:
app/internal/ordermath/presentation_test.go
Interfaces:
- Produces:
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:
DisplayLines: each row value =money.Round(RateCents*Quantity), impressions =ImpressionsPerUnit*Quantity, except underLumpeda value-add line rendersValueCents: 0, Impressions: 0(stillIsValueAdd: trueso the template can badge it). UnderBelowTheLineevery line renders at full value.ShowDeductBlock=p == BelowTheLine && (order has ≥1 value-add line).Step 1: Write the failing test (
presentation_test.go) — one order with a paid line and a value-add line, asserted under both presentations:
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")
}
}
- Step 2: Run — expect FAIL (
undefined: DisplayLines).
Run: cd app && go test ./internal/ordermath/ -run TestDisplayLines
Expected: FAIL (undefined).
- Step 3: Implement in
ordermath.go:
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
}
- Step 4: Run — expect PASS.
Run: cd app && go test ./internal/ordermath/ -v
Expected: PASS (TestCompute + TestDisplayLines).
- Step 5: Commit.
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:
- Create:
app/internal/ordermath/availability.go - Test:
app/internal/ordermath/availability_test.go
Interfaces:
- Produces (consumed by B3 date picker, B5 finalize pre-check):
// 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)):
selected && remaining == 0→DateConflict(red — the sea of red)!selected && remaining == 0→DateFull(grey)otherwise (
remaining >= 1) →DateOpen(white); a selected+open date is still white, the editor draws the checkmark.Step 1: Write the failing test (
availability_test.go):
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)
}
})
}
}
- Step 2: Run — expect FAIL (undefined).
Run: cd app && go test ./internal/ordermath/ -run 'TestRemainingSlots|TestClassifyDate'
Expected: FAIL (undefined).
- Step 3: Implement
availability.go:
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
}
- Step 4: Run — expect PASS.
Run: cd app && go test ./internal/ordermath/ -v
Expected: PASS (all four test funcs).
- Step 5: Commit.
git add app/internal/ordermath/ && git commit -m "feat(ordermath): pure episode availability classifier"
Task 5: Whole-repo verification & close-out
- Step 1: Vet + full test suite (non-DB + DB).
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).
- Step 2: Confirm purity —
ordermathandmoneypull in no infrastructure:
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.
- Step 3: Compose smoke (the money refactor changed template funcs — prove the app still renders).
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).
- Step 4: Tick the checklist below, append a "Decisions & deviations" section, update
Plans/README.mdB1 row → executed, commit, republish.
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
- Step 5: STOP for Leo's gate. Do not start B2. Recommend (per
phase-a-review.md) Fable reviews the golden table before Leo signs off — the interview numbers behind the golden should be confirmed against Lisa's real examples.
Acceptance checklist
-
internal/moneyis the single home ofRound; no other package defines whole-dollar rounding. -
internal/ordermathimports neitherpgxnornet/http(Task 5 Step 2 proves it). -
Computegolden (w1_recommended_billboard_softness) passes exactly: gross 28,450 → deduct 5,200 → revised 23,250 → discount 1,163 → final 22,087 → commission 3,313 → net 18,774; guaranteed impressions 130,000. - Value-add lines contribute $0 and 0 guaranteed impressions; deduct block carries both dollars and impressions.
- Discount handles percent (bp, rounded) and flat (clamped to revised); direct orders yield commission 0, net = final.
- Both presentations project correctly; deduct block shows only for
BelowTheLinewith ≥1 value-add line. - Availability classifier yields white/red/grey per the three rules;
RemainingSlotsclamps oversold to 0. -
go vet ./...,just test,just test-dball green (twice); compose smoke OK (healthz + login render)./ratesvisual render deferred to Leo/Lisa's gate (needs auth).
Decisions & deviations (executed 2026-07-07 by Opus 4.8, subagent-driven)
- Package split. Landed exactly as planned: pure
internal/money(the single home of the whole-dollarRound, plusParseDollars/InputString/Commas/Display) and pureinternal/ordermath(Compute/Totals,DisplayLines/ShowDeductBlock,RemainingSlots/ClassifyDate).go list -depsconfirms neither pulls in pgx or net/http. No schema, no store — B1 stays DB-free as intended. - Golden verified twice. The
w1_recommended_billboard_softnessandpercent_discount_rounds_half_awaycases were independently hand-recomputed by the task reviewer (not just run): every field matches, per-step rounding confirmed, integer arithmetic multiply-before-divide confirmed. All 7Computesub-cases + presentation + availability tables green. - Refactor was behavior-preserving. The
moneyextraction moved five function bodies byte-for-byte; the movedmoney_test.gopassed unchanged; everyservercall site (funcmap + 5rates_ui.gosites) re-pointed tomoney.*; zero old names remain. Fulljust test-dbgreen twice (-count=1 -p 1), so the money-critical A-phase code is unregressed. - Open item for the gate (Minor, spec-mandated, not fixed): the discount/commission formula
amount * bp / 10000integer-divides beforemoney.Round, truncating any sub-cent remainder before rounding. This is the formula the spec mandates and it is exact in every tested case (revised totals are whole-dollar multiples), but a non-exact bp/amount combination could in principle truncate across a 50¢ rounding boundary. Flagged for Leo/Fable: accept as-is, or add a rounding-before-truncation guard + a non-exact-bp golden in B4 when discounts get UI. Recorded in.superpowers/sdd/progress.md. - gofmt. Two B1 files needed a whitespace realign after transcription from the plan's hand-aligned code (
style(ordermath): gofmt B1 files, f21bac4); B1 files are now gofmt-clean. (Two pre-existing non-compliant test files ininternal/serverare out of B1 scope, left untouched.) - Compose smoke. App builds and boots on the testbed (migrations at v5, listening on :8730), healthz
{"status":"ok"}, login renders 200. Themoney/dollars/commasfuncmap now routes throughinternal/money; func names are unchanged so templates resolve. Authenticated/ratesvisual spot-check left for Leo/Lisa at verification. - Not done here (needs Leo):
bun run scripts/publish-site.ts --deployrequires an unlocked Bitwarden vault (bw-unlock) — run it when you verify. The compose stack (app + postgres) was left up so you can eyeball/ratesover the tailnet.
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.
- Truncation item RESOLVED (was the one open flag). The reviewer proved
amount*bp/10000beforeRoundis benign given the invariant that the amount is a whole-dollar multiple (per-step rounding guarantees it), and brute-forced 200,000 dollar values × 13 basis-point values (incl. boundary-hostile 4950/4999/5050) → 0 mismatches vs exact nearest-dollar. Action: none in code; B4 must validatebp ∈ [0,10000]and reject non-whole-dollar amounts at the input boundary so a future caller can't break the invariant. - CLOSED NOW: added the
flat_discount_with_agency_commissiongolden (the two money paths exercised together — the one coverage gap the review found). $20k gross − $5k flat → $15k final → 15% commission $2,250 → net $12,750. - Deferred to B4 (Minor, non-gating): (a)
Line{RateCents, Quantity}models a uniform rate across N units — if the editor allows a per-date rate override within one show's line, B3/B4 must decompose into oneLineper distinct rate (or store per-placement rows, whichComputesums naturally); make this an explicit editor-design decision, not a late discovery. (b) The pure core trusts inputs (no negative-bp / negative-flat guard) — B4 owns input validation. (c) Ensure B5'ssold(count of finalized placements per episode) is computed consistently with the DB(episode, position)unique constraint, so the classifier's grey/red agrees with what finalize actually rejects.
Notes for the executor
- No schema, no store, no audit in B1 — nothing persists. Resist adding an orders table "to be ready"; that's B2. Keeping B1 DB-free is what makes it the trustworthy core.
- Per-step rounding is the contract, not an implementation detail — round at each money step (line, revised, discount, final, commission, net), never once at the end. The
percent_discount_rounds_half_awayand W1 goldens lock this in. - Types here are the interface B2/B4 build against:
Lineis per-unit rate + quantity + value-add flag;Discountis a tagged union; commission is basis points. If B4 needs richer inputs (e.g. per-date rate overrides within one line), extendLinethen, with a new golden. - Copy the A-phase idioms for anything that grows a DB later; B1 itself is pure functions only.