carlos / platform Public

console: CE bill pull with manual override, scenario/bill write doors

- billPass (economicspass.go): pulls current + previous month via a
  ceCosts seam, wired into economicsPass after statementsPass. The
  real seam (cepull.go) builds a Cost Explorer client lazily, once
  per process; construction or query failures (AccessDenied being
  the expected shape) log once and latch the seam off — manual mode,
  matching the spec's "never pulled = manual entry, not an error"
  posture.
- upsertBill: one-attempt CAS read-etag-then-put (appconfig's applyAt
  shape), manual-wins for the automatic pull, always-overwrites for
  the manual write doors.
- POST /fleet/economics/scenarios, POST /fleet/economics/bill,
  POST /api/economics/bill: operator-only writes. Gate order is
  session/cliUser -> IsOperator -> a bare http.NotFound
  byte-identical with an unknown route, BEFORE CSRF (handleFleet's
  shape) — role existence must never be probeable.
- economics.Bill.PulledAt: omitempty -> omitzero, so a manual bill
  (which never sets it) no longer marshals a lying
  0001-01-01T00:00:00Z pulled_at key (Task 3 review carry-over).

go.mod: github.com/aws/aws-sdk-go-v2/service/costexplorer v1.67.5.

go build ./..., go vet ./..., go test ./... -count=1 (21 packages)
and GOOS=darwin go build ./... all clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell d27f0bb17847d9a06bdaa81ee80b734df5af9283 parent d543b1d
8 files changed, +746 −7
  • go.mod +1 −0
  • go.sum +2 −5
  • internal/console/cepull.go +108 −0
  • internal/console/economicsbill_test.go +219 −0
  • internal/console/economicspass.go +362 −1
  • internal/console/server.go +21 −0
  • internal/economics/bill.go +5 −1
  • internal/economics/bill_test.go +28 −0
diff --git a/go.mod b/go.mod
index 7ed1528cc..6773eada6 100644
--- a/go.mod
+++ b/go.mod
@@ -6,6 +6,7 @@ require (
github.com/aws/aws-sdk-go-v2 v1.43.5
github.com/aws/aws-sdk-go-v2/config v1.32.6
github.com/aws/aws-sdk-go-v2/credentials v1.19.6
+ github.com/aws/aws-sdk-go-v2/service/costexplorer v1.67.5
github.com/aws/aws-sdk-go-v2/service/iam v1.58.2
github.com/aws/aws-sdk-go-v2/service/route53 v1.64.1
github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0
diff --git a/go.sum b/go.sum
index 0a0183997..256893dee 100644
--- a/go.sum
+++ b/go.sum
@@ -38,6 +38,8 @@ github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEG
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31 h1:3GUprIsfmGcC5SACIyB0e7E0BM1O1b3Erl5CePYIAeQ=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31/go.mod h1:7PuV1yl5e2xnUbm+RqvVg5i2iBM8EyijZNoI9wsOoOc=
+github.com/aws/aws-sdk-go-v2/service/costexplorer v1.67.5 h1:hiJJTfio1+i/xnfLwu6RsDjDzkGSiLux54LTzDK4MyA=
+github.com/aws/aws-sdk-go-v2/service/costexplorer v1.67.5/go.mod h1:gy1JJMsby9XLbrwMhJcMu0IuMJF4DvDe4pD1wqMmwkk=
github.com/aws/aws-sdk-go-v2/service/iam v1.58.2 h1:/6iRcqrC6k1rMA6uCZMzFE9inOrBpNmhbrZ90X8qH50=
github.com/aws/aws-sdk-go-v2/service/iam v1.58.2/go.mod h1:Wm74PIQWDrV2tGPFYtwLqh8jZ7JU/rnq/leiC6UI4dg=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 h1:mbRIur/BiHK6SKPjoBIXSE/hJ6g6JGRLuxQy1jGjlN4=
@@ -103,7 +105,6 @@ github.com/hablullah/go-juliandays v1.0.0 h1:A8YM7wIj16SzlKT0SRJc9CD29iiaUzpBLzh
github.com/hablullah/go-juliandays v1.0.0/go.mod h1:0JOYq4oFOuDja+oospuc61YoX+uNEn7Z6uHYTbBzdGc=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
-github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jalaali/go-jalaali v0.0.0-20210801064154-80525e88d958 h1:qxLoi6CAcXVzjfvu+KXIXJOAsQB62LXjsfbOaErsVzE=
github.com/jalaali/go-jalaali v0.0.0-20210801064154-80525e88d958/go.mod h1:Wqfu7mjUHj9WDzSSPI5KfBclTTEnLveRUFr/ujWnTgE=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
@@ -120,7 +121,6 @@ github.com/markusmobius/go-dateparser v1.2.4 h1:2e8XJozaERVxGwsRg72coi51L2aiYqE2
github.com/markusmobius/go-dateparser v1.2.4/go.mod h1:CBAUADJuMNhJpyM6IYaWAoFhtKaqnUcznY2cL7gNugY=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
-github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y=
github.com/mattn/go-sqlite3 v1.14.8/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mattn/go-sqlite3 v1.14.19 h1:fhGleo2h1p8tVChob4I9HpmVFIAkKGpiukdrgQbWfGI=
github.com/mattn/go-sqlite3 v1.14.19/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
@@ -182,7 +182,6 @@ go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
-golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
@@ -213,8 +212,6 @@ google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
-gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
-gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
diff --git a/internal/console/cepull.go b/internal/console/cepull.go
new file mode 100644
index 000000000..80caf522b
--- /dev/null
+++ b/internal/console/cepull.go
@@ -0,0 +1,108 @@
+// Cost Explorer: the real-AWS half of the billPass seam (economicspass.go).
+// A deployment with no billing IAM grant, or one whose AWS config simply
+// doesn't resolve (no box role, no ~/.aws/credentials — plenty of dev/test
+// environments), must not have that surface as a recurring error: the spec's
+// posture is "a deployment that never pulled shows manual entry, not an
+// error" (economicsview.go's loadRecentBills carries the same posture for a
+// missing bill object). realCECosts is where that posture is enforced: a
+// construction or query failure is logged exactly ONCE per process
+// (s.ceWarnOnce) and then s.ceDisabled latches true, so every later billPass
+// tick returns the same cached failure immediately — no repeated AWS calls,
+// no repeated log spam, hourly, forever.
+package console
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "strconv"
+ "time"
+
+ "github.com/aws/aws-sdk-go-v2/aws"
+ awsconfig "github.com/aws/aws-sdk-go-v2/config"
+ "github.com/aws/aws-sdk-go-v2/service/costexplorer"
+ cetypes "github.com/aws/aws-sdk-go-v2/service/costexplorer/types"
+)
+
+// resolveCECosts is billPass's entry point into the seam: the test/fake
+// s.ceCosts function when a test set one, otherwise the real, lazily-built,
+// process-cached Cost Explorer client. Guarded by sync.Once/a plain bool
+// rather than a mutex because billPass only ever runs from
+// StartEconomicsPass's single-goroutine hourly ticker (economicspass.go) —
+// there is no concurrent caller for these fields to race against.
+func (s *Server) resolveCECosts(ctx context.Context, start, end time.Time) (map[string]float64, error) {
+ if s.ceCosts != nil {
+ return s.ceCosts(ctx, start, end)
+ }
+ return s.realCECosts(ctx, start, end)
+}
+
+// realCECosts builds the Cost Explorer client once (s.ceClientOnce) and
+// reuses it for the life of the process. Any failure — building the AWS
+// config, or the GetCostAndUsage call itself (AccessDenied is the expected
+// shape for a deployment that never granted billing IAM) — latches
+// s.ceDisabled so every subsequent call fails fast without touching AWS
+// again, and logs exactly once via s.ceWarnOnce.
+func (s *Server) realCECosts(ctx context.Context, start, end time.Time) (map[string]float64, error) {
+ if s.ceDisabled {
+ return nil, fmt.Errorf("console: cost explorer disabled for this process")
+ }
+ s.ceClientOnce.Do(func() {
+ cfg, err := awsconfig.LoadDefaultConfig(ctx)
+ if err != nil {
+ s.disableCE(fmt.Errorf("console: cost explorer: load aws config: %w", err))
+ return
+ }
+ s.ceClient = costexplorer.NewFromConfig(cfg)
+ })
+ if s.ceDisabled {
+ return nil, fmt.Errorf("console: cost explorer disabled for this process")
+ }
+
+ out, err := s.ceClient.GetCostAndUsage(ctx, &costexplorer.GetCostAndUsageInput{
+ Granularity: cetypes.GranularityMonthly,
+ Metrics: []string{"UnblendedCost"},
+ GroupBy: []cetypes.GroupDefinition{
+ {Type: cetypes.GroupDefinitionTypeDimension, Key: aws.String("SERVICE")},
+ },
+ TimePeriod: &cetypes.DateInterval{
+ Start: aws.String(start.Format("2006-01-02")),
+ End: aws.String(end.Format("2006-01-02")),
+ },
+ })
+ if err != nil {
+ s.disableCE(fmt.Errorf("console: cost explorer: get cost and usage: %w", err))
+ return nil, err
+ }
+
+ costs := map[string]float64{}
+ for _, res := range out.ResultsByTime {
+ for _, g := range res.Groups {
+ if len(g.Keys) == 0 {
+ continue
+ }
+ mv, ok := g.Metrics["UnblendedCost"]
+ if !ok || mv.Amount == nil {
+ continue
+ }
+ amt, perr := strconv.ParseFloat(*mv.Amount, 64)
+ if perr != nil {
+ log.Printf("console: cost explorer: %s: unparseable amount %q: %v", g.Keys[0], *mv.Amount, perr)
+ continue
+ }
+ costs[g.Keys[0]] += amt
+ }
+ }
+ return costs, nil
+}
+
+// disableCE logs err exactly once for the process (s.ceWarnOnce) and
+// latches s.ceDisabled — called on both the construction-failure and the
+// query-failure path, so either shape of "billing IAM isn't set up here"
+// ends the same way: one log line, then silent manual mode.
+func (s *Server) disableCE(err error) {
+ s.ceDisabled = true
+ s.ceWarnOnce.Do(func() {
+ log.Printf("%v — bill pulls disabled for this process; manual bill entry only", err)
+ })
+}
diff --git a/internal/console/economicsbill_test.go b/internal/console/economicsbill_test.go
new file mode 100644
index 000000000..342d1026e
--- /dev/null
+++ b/internal/console/economicsbill_test.go
@@ -0,0 +1,219 @@
+package console
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/url"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/economics"
+)
+
+// TestBillPassPullsAndManualWins: billPass pulls current + previous month
+// through the ceCosts seam and writes each SourceCostExplorer, then a
+// manually-entered bill for the current month (written directly, the way
+// the write doors this task adds do it) survives a second billPass run
+// byte-identical — manual always wins over an automatic pull.
+func TestBillPassPullsAndManualWins(t *testing.T) {
+ fx := newConsoleFixture(t)
+ ctx := context.Background()
+ now := time.Date(2026, 8, 11, 3, 0, 0, 0, time.UTC)
+
+ fx.srv.ceCosts = func(ctx context.Context, start, end time.Time) (map[string]float64, error) {
+ return map[string]float64{"AmazonEC2": 55.5}, nil
+ }
+
+ if err := fx.srv.billPass(ctx, now); err != nil {
+ t.Fatal(err)
+ }
+
+ getBill := func(month string) economics.Bill {
+ t.Helper()
+ raw, _, err := fx.rel.Get(ctx, economics.BillKey(month))
+ if err != nil {
+ t.Fatalf("bill %s: %v", month, err)
+ }
+ var b economics.Bill
+ if err := json.Unmarshal(raw, &b); err != nil {
+ t.Fatal(err)
+ }
+ return b
+ }
+
+ for _, month := range []string{"2026-08", "2026-07"} {
+ b := getBill(month)
+ if b.Source != economics.SourceCostExplorer {
+ t.Fatalf("%s source = %q, want %q", month, b.Source, economics.SourceCostExplorer)
+ }
+ if b.TotalUSD != 55.5 {
+ t.Fatalf("%s total = %v, want 55.5", month, b.TotalUSD)
+ }
+ if b.Services["AmazonEC2"] != 55.5 {
+ t.Fatalf("%s services = %v, want AmazonEC2=55.5", month, b.Services)
+ }
+ }
+
+ // Write a manual bill for the current month straight to the bucket
+ // (read-etag-then-put, since the object already exists from the pull
+ // above), then confirm a second billPass run leaves it byte-identical.
+ _, etag, err := fx.rel.Get(ctx, economics.BillKey("2026-08"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ manual := economics.Bill{Schema: 1, Month: "2026-08", TotalUSD: 999, Source: economics.SourceManual}
+ manualRaw, err := json.Marshal(manual)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := fx.rel.Put(ctx, economics.BillKey("2026-08"), manualRaw, etag); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := fx.srv.billPass(ctx, now); err != nil {
+ t.Fatal(err)
+ }
+ after, _, err := fx.rel.Get(ctx, economics.BillKey("2026-08"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(after) != string(manualRaw) {
+ t.Fatalf("manual bill overwritten by billPass:\n got %s\n want %s", after, manualRaw)
+ }
+}
+
+// TestBillPassNoCEIsQuiet: a ceCosts failure (no IAM grant, network down,
+// whatever) degrades billPass to a logged no-op — never a fatal error, and
+// never a written bill for either month.
+func TestBillPassNoCEIsQuiet(t *testing.T) {
+ fx := newConsoleFixture(t)
+ ctx := context.Background()
+ now := time.Date(2026, 8, 11, 3, 0, 0, 0, time.UTC)
+
+ fx.srv.ceCosts = func(ctx context.Context, start, end time.Time) (map[string]float64, error) {
+ return nil, errors.New("AccessDeniedException: user is not authorized")
+ }
+
+ if err := fx.srv.billPass(ctx, now); err != nil {
+ t.Fatal(err)
+ }
+ for _, month := range []string{"2026-08", "2026-07"} {
+ if _, _, err := fx.rel.Get(ctx, economics.BillKey(month)); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("bill %s written despite a ceCosts failure: err=%v", month, err)
+ }
+ }
+}
+
+// TestScenarioSaveGateAndValidation pins the gate order (session ->
+// IsOperator -> byte-identical 404, matching TestFleetOperatorOnly's
+// comparison shape) BEFORE csrf, then bad-JSON and happy-path behaviour.
+func TestScenarioSaveGateAndValidation(t *testing.T) {
+ fx := newConsoleFixture(t)
+
+ out := newBrowser(t)
+ _, _, bodyGhost := get(t, out, fx.ts.URL+"/this-route-does-not-exist")
+
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+ sc := economics.DefaultScenario()
+ scRaw, err := json.Marshal(sc)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // Signed in, owns kass, but is not an operator: the same 404 a missing
+ // route gets.
+ form := url.Values{"csrf": {csrf}, "name": {"launch"}, "scenario": {string(scRaw)}}
+ if status, _, body := postForm(t, c, fx.ts.URL+"/fleet/economics/scenarios", form); status != http.StatusNotFound || body != bodyGhost {
+ t.Fatalf("non-operator scenario save = %d %q, want the same 404 as a missing route (%q)", status, body, bodyGhost)
+ }
+
+ if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil {
+ t.Fatal(err)
+ }
+
+ // Bad JSON: a 4xx, and nothing gets written.
+ badForm := url.Values{"csrf": {csrf}, "name": {"launch"}, "scenario": {"{not valid json"}}
+ if status, _, _ := postForm(t, c, fx.ts.URL+"/fleet/economics/scenarios", badForm); status < 400 || status >= 500 {
+ t.Fatalf("bad scenario JSON = %d, want 4xx", status)
+ }
+ if _, _, err := fx.rel.Get(context.Background(), economics.ScenarioKey("launch")); !errors.Is(err, os.ErrNotExist) {
+ t.Fatal("a bad-JSON scenario save still wrote a scenario object")
+ }
+
+ // Valid scenario + activate=1: both objects land, Active.Name is right.
+ okForm := url.Values{"csrf": {csrf}, "name": {"launch"}, "scenario": {string(scRaw)}, "activate": {"1"}}
+ status, loc, _ := postForm(t, c, fx.ts.URL+"/fleet/economics/scenarios", okForm)
+ if status != http.StatusSeeOther || loc != "/fleet/economics" {
+ t.Fatalf("valid scenario save = %d -> %q, want 303 -> /fleet/economics", status, loc)
+ }
+ raw, _, err := fx.rel.Get(context.Background(), economics.ScenarioKey("launch"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var saved economics.Scenario
+ if err := json.Unmarshal(raw, &saved); err != nil {
+ t.Fatal(err)
+ }
+ if saved.Name != "launch" {
+ t.Fatalf("saved scenario name = %q, want %q (form name must win over the JSON body's)", saved.Name, "launch")
+ }
+ activeRaw, _, err := fx.rel.Get(context.Background(), economics.ActiveKey)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var active economics.Active
+ if err := json.Unmarshal(activeRaw, &active); err != nil {
+ t.Fatal(err)
+ }
+ if active.Name != "launch" {
+ t.Fatalf("active.Name = %q, want %q", active.Name, "launch")
+ }
+}
+
+// TestAPIBillDoor: the CLI's bearer door mirrors the console form's gate
+// order (cliUser -> IsOperator -> byte-identical 404) and manual-write
+// semantics.
+func TestAPIBillDoor(t *testing.T) {
+ fx := newConsoleFixture(t)
+ token := cliToken(t, fx, "paul@keymail.dev", "code-apibill")
+
+ body := `{"month":"2026-08","total_usd":123.45,"services":{"AmazonEC2":55.0}}`
+
+ ghostStatus, ghostBody := postBody(t, fx.ts.URL+"/this-api-route-does-not-exist", token, body)
+ status, out := postBody(t, fx.ts.URL+"/api/economics/bill", token, body)
+ if status != http.StatusNotFound || status != ghostStatus || string(out) != string(ghostBody) {
+ t.Fatalf("non-operator api bill = %d %q, want the same 404 as a missing route (%d %q)", status, out, ghostStatus, ghostBody)
+ }
+
+ if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil {
+ t.Fatal(err)
+ }
+
+ status, out = postBody(t, fx.ts.URL+"/api/economics/bill", token, body)
+ if status != http.StatusOK {
+ t.Fatalf("operator api bill = %d %s, want 200", status, out)
+ }
+ var b economics.Bill
+ if err := json.Unmarshal(out, &b); err != nil {
+ t.Fatalf("bill response: %v (%s)", err, out)
+ }
+ if b.Month != "2026-08" || b.TotalUSD != 123.45 || b.Source != economics.SourceManual {
+ t.Fatalf("bill response = %+v", b)
+ }
+
+ raw, _, err := fx.rel.Get(context.Background(), economics.BillKey("2026-08"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var stored economics.Bill
+ if err := json.Unmarshal(raw, &stored); err != nil {
+ t.Fatal(err)
+ }
+ if stored.Source != economics.SourceManual || stored.TotalUSD != 123.45 || stored.Services["AmazonEC2"] != 55.0 {
+ t.Fatalf("stored bill = %+v", stored)
+ }
+}
diff --git a/internal/console/economicspass.go b/internal/console/economicspass.go
index 5d599d346..ef2b14211 100644
--- a/internal/console/economicspass.go
+++ b/internal/console/economicspass.go
@@ -2,10 +2,14 @@ package console
import (
"context"
+ "crypto/subtle"
"encoding/json"
+ "errors"
"fmt"
"log"
+ "net/http"
"os"
+ "strconv"
"strings"
"time"
@@ -60,7 +64,9 @@ func (s *Server) economicsPass(ctx context.Context, now time.Time) error {
if err := s.statementsPass(ctx, now); err != nil {
log.Printf("console: statements pass: %v", err)
}
- // Task 12 adds: s.billPass(ctx, now)
+ if err := s.billPass(ctx, now); err != nil {
+ log.Printf("console: bill pass: %v", err)
+ }
return nil
}
@@ -582,6 +588,93 @@ func (s *Server) statementsPass(ctx context.Context, now time.Time) error {
return nil
}
+// billPass pulls this month and last month's whole-account bill through the
+// ceCosts seam (cepull.go) and writes each, manual-wins. It ALWAYS returns
+// nil: a Cost Explorer failure (no billing IAM grant, a disabled seam, AWS
+// down) is degraded state, not a pass failure — the spec's posture is "a
+// deployment that never pulled shows manual entry, not an error"
+// (economicsview.go's loadRecentBills carries the same posture for a bill
+// object that simply isn't there yet). Each month's pull/write is
+// independent — a failure on one must not skip the other.
+func (s *Server) billPass(ctx context.Context, now time.Time) error {
+ nowUTC := now.UTC()
+ firstOfThisMonth := time.Date(nowUTC.Year(), nowUTC.Month(), 1, 0, 0, 0, 0, time.UTC)
+ firstOfLastMonth := firstOfThisMonth.AddDate(0, -1, 0)
+
+ periods := []struct {
+ month string
+ start, end time.Time
+ }{
+ {usage.MonthOf(nowUTC), firstOfThisMonth, nowUTC},
+ {usage.MonthOf(firstOfLastMonth), firstOfLastMonth, firstOfThisMonth},
+ }
+ for _, p := range periods {
+ if err := s.pullBillMonth(ctx, p.month, p.start, p.end, nowUTC); err != nil {
+ log.Printf("console: bill pass %s: %v", p.month, err)
+ }
+ }
+ return nil
+}
+
+// pullBillMonth pulls one month's whole-account by-service costs through
+// the ceCosts seam and writes it, manual-wins (upsertBill's manualWins=true).
+func (s *Server) pullBillMonth(ctx context.Context, month string, start, end, pulledAt time.Time) error {
+ costs, err := s.resolveCECosts(ctx, start, end)
+ if err != nil {
+ return fmt.Errorf("cost explorer: %w", err)
+ }
+ var total float64
+ for _, v := range costs {
+ total += v
+ }
+ bill := economics.Bill{
+ Schema: 1,
+ Month: month,
+ TotalUSD: total,
+ Services: costs,
+ Source: economics.SourceCostExplorer,
+ PulledAt: pulledAt,
+ }
+ return s.upsertBill(ctx, bill, true)
+}
+
+// upsertBill is one CAS read-etag-then-put attempt at economics.BillKey(bill
+// .Month) — appconfig's applyAt pattern (internal/appconfig/store.go:162-190),
+// one attempt, no retry loop: a lost race surfaces as release.ErrCASConflict
+// to the caller, who decides what "try again" means for their context (a
+// background pass logs and waits for its next tick; a write door turns it
+// into a flash message). When manualWins is true (billPass's own calls,
+// via pullBillMonth) an existing SourceManual bill is left untouched —
+// manual entry always beats an automatic pull. The manual write doors
+// (handleEconomicsBillSave, handleAPIEconomicsBill) call this with
+// manualWins=false: manual entry always overwrites whatever is there,
+// a previous CE pull or an earlier manual entry alike.
+func (s *Server) upsertBill(ctx context.Context, bill economics.Bill, manualWins bool) error {
+ key := economics.BillKey(bill.Month)
+ raw, etag, err := s.cfg.Releases.Get(ctx, key)
+ switch {
+ case err == nil:
+ if manualWins {
+ var existing economics.Bill
+ if jerr := json.Unmarshal(raw, &existing); jerr == nil && existing.Source == economics.SourceManual {
+ return nil
+ }
+ }
+ case errors.Is(err, os.ErrNotExist):
+ etag = ""
+ default:
+ return fmt.Errorf("console: bill %s: read: %w", key, err)
+ }
+ body, err := json.Marshal(bill)
+ if err != nil {
+ return fmt.Errorf("console: bill %s: marshal: %w", key, err)
+ }
+ if _, err := s.cfg.Releases.Put(ctx, key, body, etag); err != nil {
+ return fmt.Errorf("console: bill %s: write: %w", key, err)
+ }
+ return nil
+}
+
// sizeStore routes through the test seam or the real S3 sizer.
func (s *Server) sizeStore(ctx context.Context, p *objstore.Provisioned) (int64, error) {
if s.storeSizer != nil {
@@ -627,3 +720,271 @@ func sizeProvisionedStore(ctx context.Context, p *objstore.Provisioned) (int64,
}
return total, nil
}
+
+// ---- unit economics: scenario/bill write doors -----------------------------
+
+// putScenario CAS-writes economics.ScenarioKey(sc.Name) — appconfig's
+// applyAt read-etag-then-put shape (internal/appconfig/store.go:162-190),
+// one attempt: a lost race surfaces release.ErrCASConflict to the caller
+// (handleEconomicsScenarioSave), which turns it into a retryable flash
+// error rather than silently overwriting or retrying blind.
+func (s *Server) putScenario(ctx context.Context, sc economics.Scenario) error {
+ key := economics.ScenarioKey(sc.Name)
+ _, etag, err := s.cfg.Releases.Get(ctx, key)
+ switch {
+ case err == nil:
+ case errors.Is(err, os.ErrNotExist):
+ etag = ""
+ default:
+ return fmt.Errorf("console: scenario %s: read: %w", key, err)
+ }
+ body, err := json.Marshal(sc)
+ if err != nil {
+ return fmt.Errorf("console: scenario %s: marshal: %w", key, err)
+ }
+ if _, err := s.cfg.Releases.Put(ctx, key, body, etag); err != nil {
+ return fmt.Errorf("console: scenario %s: write: %w", key, err)
+ }
+ return nil
+}
+
+// putActiveScenario CAS-writes economics.ActiveKey to name it — same
+// one-attempt shape as putScenario, same ErrCASConflict contract.
+func (s *Server) putActiveScenario(ctx context.Context, name string) error {
+ _, etag, err := s.cfg.Releases.Get(ctx, economics.ActiveKey)
+ switch {
+ case err == nil:
+ case errors.Is(err, os.ErrNotExist):
+ etag = ""
+ default:
+ return fmt.Errorf("console: active scenario: read: %w", err)
+ }
+ body, err := json.Marshal(economics.Active{Schema: 1, Name: name})
+ if err != nil {
+ return fmt.Errorf("console: active scenario: marshal: %w", err)
+ }
+ if _, err := s.cfg.Releases.Put(ctx, economics.ActiveKey, body, etag); err != nil {
+ return fmt.Errorf("console: active scenario: write: %w", err)
+ }
+ return nil
+}
+
+// handleEconomicsScenarioSave writes a named pricing scenario (and,
+// optionally, activates it). Gate is handleFleet's exact shape — session ->
+// IsOperator -> a bare http.NotFound byte-identical with an unknown route —
+// BEFORE the CSRF check, per the console security rule: role existence must
+// never be probeable, and a CSRF rejection would leak that this route
+// exists to a non-operator who merely guessed the URL.
+func (s *Server) handleEconomicsScenarioSave(w http.ResponseWriter, r *http.Request) {
+ addr, raw, ok := s.session(r)
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ isOp, err := s.store.IsOperator(addr)
+ if err != nil {
+ log.Printf("console: economics scenario save: is-operator: %v", err)
+ http.NotFound(w, r)
+ return
+ }
+ if !isOp {
+ http.NotFound(w, r)
+ return
+ }
+ if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
+ s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
+ return
+ }
+
+ name := strings.TrimSpace(r.FormValue("name"))
+ var sc economics.Scenario
+ if err := json.Unmarshal([]byte(r.FormValue("scenario")), &sc); err != nil {
+ s.renderMessage(w, r, http.StatusBadRequest, "Invalid scenario", "Could not parse the scenario JSON: "+err.Error())
+ return
+ }
+ // The form's name field wins over whatever the JSON body carries — the
+ // name is the bucket key (ScenarioKey), so it must come from the one
+ // place a CSRF-protected form field lives, not from a value a client
+ // could otherwise mismatch against the URL a link points at.
+ sc.Name = name
+ if err := sc.Validate(); err != nil {
+ s.renderMessage(w, r, http.StatusBadRequest, "Invalid scenario", err.Error())
+ return
+ }
+
+ back := func(kind, msg string) {
+ s.setFlash(w, kind, msg)
+ http.Redirect(w, r, "/fleet/economics", http.StatusSeeOther)
+ }
+
+ if err := s.putScenario(r.Context(), sc); err != nil {
+ if errors.Is(err, release.ErrCASConflict) {
+ back("err", "Someone else saved a scenario — try again.")
+ return
+ }
+ log.Printf("console: economics scenario save: %v", err)
+ s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not save the scenario. Please try again.")
+ return
+ }
+
+ if r.FormValue("activate") == "1" {
+ if err := s.putActiveScenario(r.Context(), name); err != nil {
+ if errors.Is(err, release.ErrCASConflict) {
+ back("err", "Scenario saved, but someone else changed the active scenario — try again.")
+ return
+ }
+ log.Printf("console: economics scenario save: activate %s: %v", name, err)
+ back("err", "Scenario saved, but could not activate it. Please try again.")
+ return
+ }
+ }
+
+ back("ok", "Saved scenario "+name+".")
+}
+
+// parseBillLines parses the manual-bill form's "lines" textarea: one
+// "Service=123.45" per line, blank lines skipped. A malformed line is a
+// hard error naming the offending line — silently dropping it would
+// understate the bill with no visible sign, the same reasoning
+// applyAt's ValidVarValue check documents for config values.
+func parseBillLines(text string) (map[string]float64, error) {
+ services := map[string]float64{}
+ for _, line := range strings.Split(text, "\n") {
+ line = strings.TrimSpace(line)
+ if line == "" {
+ continue
+ }
+ name, amtStr, ok := strings.Cut(line, "=")
+ if !ok {
+ return nil, fmt.Errorf("line %q: want Service=123.45", line)
+ }
+ name = strings.TrimSpace(name)
+ if name == "" {
+ return nil, fmt.Errorf("line %q: empty service name", line)
+ }
+ amt, err := strconv.ParseFloat(strings.TrimSpace(amtStr), 64)
+ if err != nil {
+ return nil, fmt.Errorf("line %q: %w", line, err)
+ }
+ services[name] = amt
+ }
+ return services, nil
+}
+
+// handleEconomicsBillSave writes a manually-entered bill for one month —
+// SourceManual, always overwriting whatever is there (upsertBill's
+// manualWins=false). Same gate shape as handleEconomicsScenarioSave.
+func (s *Server) handleEconomicsBillSave(w http.ResponseWriter, r *http.Request) {
+ addr, raw, ok := s.session(r)
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ isOp, err := s.store.IsOperator(addr)
+ if err != nil {
+ log.Printf("console: economics bill save: is-operator: %v", err)
+ http.NotFound(w, r)
+ return
+ }
+ if !isOp {
+ http.NotFound(w, r)
+ return
+ }
+ if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
+ s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
+ return
+ }
+
+ back := func(kind, msg string) {
+ s.setFlash(w, kind, msg)
+ http.Redirect(w, r, "/fleet/economics", http.StatusSeeOther)
+ }
+
+ month := strings.TrimSpace(r.FormValue("month"))
+ if _, err := time.Parse("2006-01", month); err != nil {
+ s.renderMessage(w, r, http.StatusBadRequest, "Invalid bill", "Month must be formatted YYYY-MM.")
+ return
+ }
+ total, err := strconv.ParseFloat(strings.TrimSpace(r.FormValue("total")), 64)
+ if err != nil {
+ s.renderMessage(w, r, http.StatusBadRequest, "Invalid bill", "Total must be a number.")
+ return
+ }
+ services, err := parseBillLines(r.FormValue("lines"))
+ if err != nil {
+ s.renderMessage(w, r, http.StatusBadRequest, "Invalid bill", err.Error())
+ return
+ }
+
+ bill := economics.Bill{
+ Schema: 1,
+ Month: month,
+ TotalUSD: total,
+ Services: services,
+ Source: economics.SourceManual,
+ }
+ if err := s.upsertBill(r.Context(), bill, false); err != nil {
+ if errors.Is(err, release.ErrCASConflict) {
+ back("err", "Someone else saved a bill for this month — try again.")
+ return
+ }
+ log.Printf("console: economics bill save: %v", err)
+ s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not save the bill. Please try again.")
+ return
+ }
+ back("ok", "Saved the "+month+" bill.")
+}
+
+// handleAPIEconomicsBill is the CLI's manual-bill door (issue: `carlos
+// economics bill`, Task 15). Gate mirrors the console form's exactly, just
+// through the bearer/cliUser identity instead of a session: cliUser ->
+// IsOperator -> a bare http.NotFound byte-identical with an unknown API
+// route, BEFORE the body is even read — same role-not-probeable reasoning
+// as handleEconomicsScenarioSave.
+func (s *Server) handleAPIEconomicsBill(w http.ResponseWriter, r *http.Request) {
+ addr, ok := s.cliUser(w, r)
+ if !ok {
+ return
+ }
+ isOp, err := s.store.IsOperator(addr)
+ if err != nil {
+ log.Printf("console: api economics bill: is-operator: %v", err)
+ http.NotFound(w, r)
+ return
+ }
+ if !isOp {
+ http.NotFound(w, r)
+ return
+ }
+
+ var req struct {
+ Month string `json:"month"`
+ TotalUSD float64 `json:"total_usd"`
+ Services map[string]float64 `json:"services"`
+ }
+ if !readJSON(w, r, &req) {
+ return
+ }
+ if _, err := time.Parse("2006-01", req.Month); err != nil {
+ jsonError(w, http.StatusBadRequest, "month must be formatted YYYY-MM")
+ return
+ }
+
+ bill := economics.Bill{
+ Schema: 1,
+ Month: req.Month,
+ TotalUSD: req.TotalUSD,
+ Services: req.Services,
+ Source: economics.SourceManual,
+ }
+ if err := s.upsertBill(r.Context(), bill, false); err != nil {
+ if errors.Is(err, release.ErrCASConflict) {
+ jsonError(w, http.StatusConflict, "someone else saved a bill for this month — try again")
+ return
+ }
+ log.Printf("console: api economics bill: %v", err)
+ jsonError(w, http.StatusInternalServerError, "could not save the bill")
+ return
+ }
+ writeJSON(w, http.StatusOK, bill)
+}
diff --git a/internal/console/server.go b/internal/console/server.go
index 3ad810ba6..abf5fa5e2 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -20,8 +20,11 @@ import (
"os"
"regexp"
"strings"
+ "sync"
"time"
+ "github.com/aws/aws-sdk-go-v2/service/costexplorer"
+
"github.com/carlosframework/platform/internal/applog"
"github.com/carlosframework/platform/internal/domains"
"github.com/carlosframework/platform/internal/objstore"
@@ -278,6 +281,18 @@ type Server struct {
// (sizeProvisionedStore, economicspass.go); tests inject a fake so a
// sweep can be proven to degrade ("unswept") without live AWS.
storeSizer func(ctx context.Context, p *objstore.Provisioned) (int64, error)
+ // ceCosts is billPass's test/fake seam for Cost Explorer (cepull.go,
+ // economicspass.go): nil means "use the real, lazily-built CE client"
+ // (s.realCECosts); tests inject a fake so a pull can be proven to
+ // succeed, or to degrade quietly, without live AWS billing IAM.
+ ceCosts func(ctx context.Context, start, end time.Time) (map[string]float64, error)
+ // ceClientOnce/ceClient/ceWarnOnce/ceDisabled cache s.realCECosts'
+ // Cost Explorer client and its process-lifetime disabled state — see
+ // cepull.go's doc comment for why a mutex isn't needed here.
+ ceClientOnce sync.Once
+ ceClient *costexplorer.Client
+ ceWarnOnce sync.Once
+ ceDisabled bool
// shipSem bounds how many `carlos ship` uploads (apicli.go,
// handleAPIShip) may run AT ONCE, as opposed to s.cli.ship, which
// bounds them over TIME. A rate limiter is a counter; it says nothing
@@ -364,6 +379,12 @@ func NewServer(cfg Config, st *Store) (*Server, error) {
s.mux.HandleFunc("POST /auth/signout", s.handleSignout)
s.mux.HandleFunc("GET /fleet", s.handleFleet)
s.mux.HandleFunc("GET /fleet/metrics", s.handleFleetMetrics)
+ // Unit economics (Task 12): scenario/bill writes, operator-only, same
+ // gate shape as handleFleet above. GET /fleet/economics itself is
+ // Task 13.
+ s.mux.HandleFunc("POST /fleet/economics/scenarios", s.handleEconomicsScenarioSave)
+ s.mux.HandleFunc("POST /fleet/economics/bill", s.handleEconomicsBillSave)
+ s.mux.HandleFunc("POST /api/economics/bill", s.handleAPIEconomicsBill)
// CLI identity: the device-code login `carlos auth login` drives, the
// approval page, and the tokens an owner can see and kill (authcli.go).
s.registerCLIRoutes()
diff --git a/internal/economics/bill.go b/internal/economics/bill.go
index 5c37914f4..506dd7aaa 100644
--- a/internal/economics/bill.go
+++ b/internal/economics/bill.go
@@ -17,7 +17,11 @@ type Bill struct {
TotalUSD float64 `json:"total_usd"`
Services map[string]float64 `json:"services"`
Source string `json:"source"` // SourceManual | SourceCostExplorer
- PulledAt time.Time `json:"pulled_at,omitempty"`
+ // PulledAt uses omitzero, not omitempty: encoding/json's omitempty never
+ // treats a zero time.Time as empty (it isn't a nil/0/"" by the encoder's
+ // own rule), so a manually-entered bill — which never sets this field —
+ // would otherwise marshal a lying "0001-01-01T00:00:00Z" pulled_at key.
+ PulledAt time.Time `json:"pulled_at,omitzero"`
}
// BillsPrefix lists stored bills.
diff --git a/internal/economics/bill_test.go b/internal/economics/bill_test.go
new file mode 100644
index 000000000..bf505ab6e
--- /dev/null
+++ b/internal/economics/bill_test.go
@@ -0,0 +1,28 @@
+package economics
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+)
+
+// TestManualBillMarshalsWithoutPulledAt: a SourceManual bill never sets
+// PulledAt, and encoding/json's omitempty does NOT treat a zero time.Time as
+// empty — omitzero does. A manual bill that marshalled a pulled_at key would
+// lie: "0001-01-01T00:00:00Z" reads as a real pull that never happened.
+func TestManualBillMarshalsWithoutPulledAt(t *testing.T) {
+ b := Bill{
+ Schema: 1,
+ Month: "2026-08",
+ TotalUSD: 42.5,
+ Services: map[string]float64{"AmazonEC2": 42.5},
+ Source: SourceManual,
+ }
+ raw, err := json.Marshal(b)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.Contains(string(raw), "pulled_at") {
+ t.Fatalf("manual bill marshalled a pulled_at key: %s", raw)
+ }
+}