console: closed-month CE settle-and-stop must not fire from the month's own last pull
shouldSkipCEPull treated existing.PulledAt.Day() >= billCEDayGrace as proof a closed month had settled, but a month's own last daily pull (e.g. July 31, Day()==31) satisfies that by calendar coincidence, the moment the month closes — permanently skipping CE's ~24h-lagged final line items for every month, forever. Require the settling pull to have happened in a LATER month AND on day >= billCEDayGrace of it: usage.MonthOf(existing.PulledAt) > existing.Month (string compare, safe for "2006-01"). Adds the missing regression: a bill for July with PulledAt July 31, billPass ticking on Aug 1, must still re-pull July. The existing settled-case test (PulledAt Aug 5) still passes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2 files changed,
+57
−6
internal/console/economicsbill_test.go+46 −0internal/console/economicspass.go+11 −6
diff --git a/internal/console/economicsbill_test.go b/internal/console/economicsbill_test.go| index 13e3e84be..901639a1c 100644 |
| --- a/internal/console/economicsbill_test.go |
| +++ b/internal/console/economicsbill_test.go |
| @@ -209,6 +209,52 @@ func TestBillPassStopsPullingSettledClosedMonth(t *testing.T) { |
| } |
| } |
| +// TestBillPassRepullsJustClosedMonth is the regression for the "own last |
| +// daily pull looks settled" bug: a month's last pull WHILE STILL OPEN (e.g. |
| +// July 31, Day()==31) must not be mistaken for the day-3-of-the-FOLLOWING- |
| +// month settle-and-stop rule just because Day() happens to be >= |
| +// billCEDayGrace. The moment the month closes (billPass ticks on Aug 1) it |
| +// must still be re-pulled — CE's ~24h-lagged final line items for July |
| +// haven't landed yet at the instant July turns into August. |
| +func TestBillPassRepullsJustClosedMonth(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + ctx := context.Background() |
| + |
| + notYetSettled := economics.Bill{ |
| + Schema: 1, Month: "2026-07", TotalUSD: 10, |
| + Services: map[string]float64{"AmazonEC2": 10}, Source: economics.SourceCostExplorer, |
| + PulledAt: time.Date(2026, 7, 31, 3, 0, 0, 0, time.UTC), // the month's own last daily pull |
| + } |
| + putStoreJSON(t, fx.rel, economics.BillKey("2026-07"), notYetSettled) |
| + |
| + var julyPulled bool |
| + fx.srv.ceCosts = func(ctx context.Context, start, end time.Time) (map[string]float64, error) { |
| + if start.Format("2006-01") == "2026-07" { |
| + julyPulled = true |
| + } |
| + return map[string]float64{"AmazonEC2": 99}, nil |
| + } |
| + |
| + if err := fx.srv.billPass(ctx, time.Date(2026, 8, 1, 3, 0, 0, 0, time.UTC)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if !julyPulled { |
| + t.Fatal("billPass did not re-pull July on Aug 1, though July's own PulledAt.Day()==31 satisfies the day-3 grace by coincidence — the just-closed month must still be re-pulled") |
| + } |
| + |
| + raw, _, err := fx.rel.Get(ctx, economics.BillKey("2026-07")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var got economics.Bill |
| + if err := json.Unmarshal(raw, &got); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got.TotalUSD != 99 { |
| + t.Fatalf("July bill total = %v, want 99 (re-pulled with the fresh ceCosts value)", got.TotalUSD) |
| + } |
| +} |
| + |
| // TestBillPassRetriesAfterTransientCEFailure: a non-auth-shaped ceCosts |
| // failure (a throttle, a network blip) must not disable further attempts — |
| // realCECosts' auth-shaped latch (cepull.go, isAuthShapedError) is the only |
diff --git a/internal/console/economicspass.go b/internal/console/economicspass.go| index c3b598306..ed8e45cb2 100644 |
| --- a/internal/console/economicspass.go |
| +++ b/internal/console/economicspass.go |
| @@ -686,12 +686,17 @@ func shouldSkipCEPull(existing *economics.Bill, closedMonth bool, now time.Time) |
| if existing == nil || existing.Source != economics.SourceCostExplorer { |
| return false |
| } |
| - if closedMonth && existing.PulledAt.Day() >= billCEDayGrace { |
| - // Settled: a closed month pulled on or after day 3 of the |
| - // following month has already seen CE's late-arriving line items |
| - // land. Its numbers don't move again — re-pulling it forever |
| - // would be pure waste, unlike the open month, which keeps |
| - // changing all month. |
| + if closedMonth && usage.MonthOf(existing.PulledAt) > existing.Month && existing.PulledAt.Day() >= billCEDayGrace { |
| + // Settled: a closed month pulled on or after day 3 of a LATER |
| + // month has already seen CE's late-arriving line items land. Its |
| + // numbers don't move again — re-pulling it forever would be pure |
| + // waste, unlike the open month, which keeps changing all month. |
| + // |
| + // The month check matters on its own: a pull made from WITHIN the |
| + // closing month (e.g. the month's own last daily pull, July 31) |
| + // can land on day >= billCEDayGrace purely by calendar coincidence |
| + // (Day()==31) without the month having closed at all yet — that |
| + // is not settlement, and must not be treated as such. |
| return true |
| } |
| return usage.DayOf(existing.PulledAt) == usage.DayOf(now) |