Files
compute/service/cost_do.go
zeekayandHanzo Dev 6132f712c2 fix: cost readers convert dollars to cents exactly, and stop hiding money
dollarStringToCents was one of three hand-written copies of this
conversion across vm, commerce and ai. They read the same DigitalOcean
fields and disagreed: "1,234.56" was 123456 in commerce and 0 here;
"-5.00" was -500 in ai and 0 here. Three answers means at least two are
wrong and nothing in the code says which, so all three now call
money.ParseCents — the module that already holds exact decimals and the
FromMinor/FromCents constructors this is the inverse of.

Three behaviours change, each of them a way the old converter lost money
without saying so:

  - a thousands separator no longer zeroes the month. DO writes one past
    $1000, ParseFloat refused it, and the fallback was 0 — so the
    largest bills read as no spend at all.
  - `f <= 0 → 0` is gone. A negative row is a credit or a refund;
    clamping it to zero overstates spend by exactly the amount returned
    to us, on every account that ever got one.
  - an unreadable value is an error, not a 0. Zero is a legal amount, so
    a caller could not tell "the vendor billed nothing" from "we could
    not read what the vendor billed", and reported the second as the
    first. Every caller here already returned (int64, error) and simply
    was not being given one.

The GCP cell reader keeps its null case as an honest zero — a SUM over
no rows really is no spend — but its float64 branch now goes through the
same converter via the value's shortest exact decimal form, instead of a
second *100 that rounded differently from the string path. Which answer
you got depended on how BigQuery happened to encode the cell.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-01 22:13:42 -07:00

66 lines
2.4 KiB
Go

// Copyright 2025 Hanzo Industries Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package service
import (
"context"
"fmt"
"strings"
"time"
"github.com/digitalocean/godo"
"github.com/hanzoai/money"
"golang.org/x/oauth2"
)
// doCostReader reads a BYOC DigitalOcean account's spend via the customer balance
// endpoint (GET /v2/customers/my/balance → month_to_date_usage). The balance API is
// account-wide from the API token, so no extra cost-read scope is needed beyond the
// token already stored to manage machines — the token needs read access to billing,
// which the standard DO API token has.
type doCostReader struct {
client *godo.Client
}
// newDOCostReader builds the reader from the BYOC token (clientSecret, or clientId
// as the DO convention allows the token in either field). An empty token yields
// ErrCostUnavailable so an unconfigured provider is skipped, never guessed.
func newDOCostReader(clientId, clientSecret string) (CloudCostReader, error) {
token := strings.TrimSpace(clientSecret)
if token == "" {
token = strings.TrimSpace(clientId)
}
if token == "" {
return nil, ErrCostUnavailable
}
ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token})
client := godo.NewClient(oauth2.NewClient(context.Background(), ts))
return &doCostReader{client: client}, nil
}
// MonthToDateCents reads the account's month-to-date usage. The balance endpoint is
// always current, so `now` is unused (the DO API has no historical-month selector).
func (r *doCostReader) MonthToDateCents(ctx context.Context, _ time.Time) (int64, error) {
bal, _, err := r.client.Balance.Get(ctx)
if err != nil {
return 0, fmt.Errorf("do cost: get balance: %w", err)
}
cents, err := money.ParseCents(bal.MonthToDateUsage)
if err != nil {
return 0, fmt.Errorf("do cost: month-to-date usage %q: %w", bal.MonthToDateUsage, err)
}
return cents, nil
}