mirror of
https://github.com/marcusDenslow/terminalShop.git
synced 2026-10-25 02:52:20 +02:00
fix(tui): accept spend limit input in dollars (#56)
Spend-limit input now takes dollars (e.g. 25.50) instead of raw cents. Parsed to integer cents as dollars*100 + cents (no float multiply) so an amount like 50.01 never loses a cent to binary rounding; the persisted column and API still use cents. Current limit and prefill render via formatCentsDollars. Tests updated to the dollar grammar. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -22,9 +22,7 @@ func (m Model) accountBodyHeight() int {
|
||||
footerHeight := lipgloss.Height(m.BuildFooter())
|
||||
budget := m.heightContainer - headerHeight - footerHeight - 2
|
||||
cards := budget / orderCardHeight
|
||||
if cards < 1 {
|
||||
cards = 1
|
||||
}
|
||||
cards = max(cards, 1)
|
||||
return cards*orderCardHeight - 2
|
||||
}
|
||||
|
||||
@@ -468,12 +466,12 @@ func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
||||
case "spend limit":
|
||||
if !m.account.spendLimitFocused {
|
||||
ti := textinput.New()
|
||||
ti.Prompt = ""
|
||||
ti.Placeholder = "cents (blank = no limit)"
|
||||
ti.CharLimit = 9
|
||||
ti.Prompt = "$"
|
||||
ti.Placeholder = "dollars (blank = no limit)"
|
||||
ti.CharLimit = 12
|
||||
// Prefill with the current limit so the user edits rather than retypes.
|
||||
if m.User != nil && m.User.SelfLimitCents != nil {
|
||||
ti.SetValue(strconv.Itoa(*m.User.SelfLimitCents))
|
||||
ti.SetValue(formatCentsDollars(*m.User.SelfLimitCents))
|
||||
}
|
||||
cmd := ti.Focus()
|
||||
m.account.spendLimitInput = ti
|
||||
@@ -559,8 +557,7 @@ func (m Model) SpendLimitView(width int) string {
|
||||
|
||||
current := "no limit set"
|
||||
if m.User != nil && m.User.SelfLimitCents != nil {
|
||||
c := *m.User.SelfLimitCents
|
||||
current = fmt.Sprintf("$%.2f (%d cents)", float64(c)/100, c)
|
||||
current = "$" + formatCentsDollars(*m.User.SelfLimitCents)
|
||||
}
|
||||
|
||||
out := bodyStyle.Render(wordWrap(
|
||||
@@ -578,7 +575,7 @@ func (m Model) SpendLimitView(width int) string {
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(m.theme.Highlight()).
|
||||
Width(max(1, width-2))
|
||||
out += labelStyle.Render("New limit (cents)") + "\n"
|
||||
out += labelStyle.Render("New limit (dollars)") + "\n"
|
||||
out += box.Render(m.account.spendLimitInput.View())
|
||||
switch {
|
||||
case m.account.spendLimitSaving:
|
||||
@@ -616,23 +613,58 @@ func (m Model) setSpendLimitCmd(cents *int) tea.Cmd {
|
||||
}
|
||||
}
|
||||
|
||||
// parseSpendLimitInput maps the raw input to a *int of cents. Blank clears the
|
||||
// limit (nil -> revert to admin/global). "0" is a real "block everything"
|
||||
// ceiling, NOT a clear. Negative or non-numeric input is rejected. These rules
|
||||
// match the server boundary in api/handlers/account.go:SetSpendLimit.
|
||||
// parseSpendLimitInput maps the raw dollar input to a *int of cents. Blank
|
||||
// clears the limit (nil -> revert to admin/global). "0" is a real "block
|
||||
// everything" ceiling, NOT a clear. Negative, non-numeric, or more-than-2-
|
||||
// decimal input is rejected. Parsed as integer cents (dollars*100 + cents) with
|
||||
// no float multiply, so an amount like 50.01 never loses a cent to binary
|
||||
// rounding. The server still stores cents (api/handlers/account.go).
|
||||
func parseSpendLimitInput(s string) (*int, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
s = strings.TrimSpace(strings.TrimPrefix(s, "$"))
|
||||
if s == "" {
|
||||
return nil, nil
|
||||
}
|
||||
n, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("enter a whole number of cents, or blank to clear")
|
||||
}
|
||||
if n < 0 {
|
||||
if strings.HasPrefix(s, "-") {
|
||||
return nil, fmt.Errorf("limit cannot be negative")
|
||||
}
|
||||
return &n, nil
|
||||
|
||||
whole, frac, hasDot := strings.Cut(s, ".")
|
||||
if whole == "" {
|
||||
whole = "0" // allow ".50"
|
||||
}
|
||||
dollars, err := strconv.Atoi(whole)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("enter a dollar amount, e.g 25 or 25.50, or blank to clear")
|
||||
}
|
||||
|
||||
cents := 0
|
||||
if hasDot {
|
||||
switch len(frac) {
|
||||
case 0:
|
||||
// trailing dot, e.g "25." -> treat as .00
|
||||
case 1:
|
||||
frac += "0" // "5" -> 50 cents
|
||||
fallthrough
|
||||
case 2:
|
||||
cents, err = strconv.Atoi(frac)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("enter a dollar amount, e.g 25 or 25.00, or blank to clear")
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("use at most two decimal places, e.g 25.00")
|
||||
}
|
||||
}
|
||||
|
||||
total := dollars*100 + cents
|
||||
return &total, nil
|
||||
}
|
||||
|
||||
// formatCentsDollars renders an integer cents value as a plain dollar string
|
||||
// (no leading "$"), e.g. 2500 -> "25.00", 50 -> "0.50". Used for the input
|
||||
// prefill and the current-limit display.
|
||||
func formatCentsDollars(cents int) string {
|
||||
return fmt.Sprintf("%d.%02d", cents/100, cents%100)
|
||||
}
|
||||
|
||||
// defaultAccountFooter is the account-menu footer shown when no sub-view owns
|
||||
|
||||
@@ -15,13 +15,17 @@ func TestParseSpendLimitInput(t *testing.T) {
|
||||
wantNil bool
|
||||
wantErr bool
|
||||
}{
|
||||
{"", 0, true, false}, // blank clears
|
||||
{"0", 0, false, false}, // 0 is block-all, NOT a clear
|
||||
{"1500", 1500, false, false}, // plain value
|
||||
{" 250 ", 250, false, false}, // trims whitespace
|
||||
{"-5", 0, false, true}, // negative rejected
|
||||
{"abc", 0, false, true}, // non-numeric rejected
|
||||
{"12.5", 0, false, true}, // non-integer rejected
|
||||
{"", 0, true, false}, // blank clears
|
||||
{"0", 0, false, false}, // $0 is block-all, NOT a clear
|
||||
{"25", 2500, false, false}, // whole dollars
|
||||
{"25.50", 2550, false, false}, // dollars + cents
|
||||
{"25.5", 2550, false, false}, // one decimal => tenths
|
||||
{"0.05", 5, false, false}, // sub-dollar
|
||||
{" $25.00 ", 2500, false, false}, // strips $ and whitespace
|
||||
{".50", 50, false, false}, // leading dot
|
||||
{"-5", 0, false, true}, // negative rejected
|
||||
{"abc", 0, false, true}, // non-numeric rejected
|
||||
{"25.999", 0, false, true}, // too many decimals rejected
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got, err := parseSpendLimitInput(tc.in)
|
||||
@@ -80,8 +84,8 @@ func TestSpendLimitFocusFlow(t *testing.T) {
|
||||
if !m.account.spendLimitFocused {
|
||||
t.Fatal("enter did not focus the spend-limit input")
|
||||
}
|
||||
if got := m.account.spendLimitInput.Value(); got != "2500" {
|
||||
t.Errorf("prefill: want \"2500\", got %q", got)
|
||||
if got := m.account.spendLimitInput.Value(); got != "25.00" {
|
||||
t.Errorf("prefill: want \"25.00\", got %q", got)
|
||||
}
|
||||
|
||||
m, _ = m.AccountUpdate(tea.KeyPressMsg{Code: tea.KeyEsc})
|
||||
|
||||
Reference in New Issue
Block a user