Files
commerce/api/billing/invoice_preview.go
zooqueenandHanzo Dev 15f038f6e0 An invoice preview reads the credit grants and never spends them
POST /v1/billing/invoice-preview called BurnCredits, so every preview
took the previewed amount off the subject's grants. It now calls
BurnCreditsPreview, as GET /v1/billing/invoices/upcoming already did.
Both are tested: three views leave the grant whole and still report what
it would cover.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-24 01:09:52 -07:00

189 lines
4.6 KiB
Go

package billing
import (
"time"
"github.com/zap-proto/zip"
"github.com/hanzoai/commerce/datastore"
"github.com/hanzoai/commerce/log"
"github.com/hanzoai/commerce/middleware"
"github.com/hanzoai/commerce/models/meter"
"github.com/hanzoai/commerce/models/pricingrule"
"github.com/hanzoai/commerce/util/json/http"
)
type invoicePreviewRequest struct {
UserId string `json:"userId"`
PeriodStart string `json:"periodStart"`
PeriodEnd string `json:"periodEnd"`
}
type lineItem struct {
MeterId string `json:"meterId"`
MeterName string `json:"meterName"`
Quantity int64 `json:"quantity"`
UnitCost int64 `json:"unitCost"`
TotalCost int64 `json:"totalCost"`
Model string `json:"model"`
}
// InvoicePreview calculates an invoice preview: usage x pricing - credits.
//
// POST /v1/billing/invoice-preview
func InvoicePreview(c *zip.Ctx) error {
org := middleware.GetOrganization(c)
db := datastore.New(org.Namespaced(c.Context()))
var req invoicePreviewRequest
if err := c.Bind(&req); err != nil {
return http.Fail(c, 400, "invalid request body", err)
}
if req.UserId == "" {
return http.Fail(c, 400, "userId is required", nil)
}
var periodStart, periodEnd time.Time
var err error
if req.PeriodStart != "" {
periodStart, err = parseDateOrTime(req.PeriodStart)
if err != nil {
return http.Fail(c, 400, "invalid periodStart format", err)
}
}
if req.PeriodEnd != "" {
periodEnd, err = parseDateOrTime(req.PeriodEnd)
if err != nil {
return http.Fail(c, 400, "invalid periodEnd format", err)
}
}
// 1. Get all meters
rootKey := db.NewKey("synckey", "", 1, nil)
meters := make([]*meter.Meter, 0)
mq := meter.Query(db).Ancestor(rootKey)
if _, err := mq.GetAll(&meters); err != nil {
log.Error("Failed to query meters: %v", err, c)
return http.Fail(c, 500, "failed to query meters", err)
}
// 2. Get all pricing rules
rules := make([]*pricingrule.PricingRule, 0)
rq := pricingrule.Query(db).Ancestor(rootKey)
if _, err := rq.GetAll(&rules); err != nil {
log.Error("Failed to query pricing rules: %v", err, c)
return http.Fail(c, 500, "failed to query pricing rules", err)
}
// Index pricing rules by meterId
rulesByMeter := make(map[string]*pricingrule.PricingRule)
for _, r := range rules {
rulesByMeter[r.MeterId] = r
}
// 3. For each meter, aggregate events and calculate cost
var totalCost int64
lineItems := make([]lineItem, 0)
for _, m := range meters {
// Query events for this meter+user+period
events := make([]*meter.MeterEvent, 0)
eq := meter.QueryEvents(db).Ancestor(rootKey).
Filter("MeterId=", m.Id()).
Filter("UserId=", req.UserId)
if !periodStart.IsZero() {
eq = eq.Filter("Timestamp>=", periodStart)
}
if !periodEnd.IsZero() {
eq = eq.Filter("Timestamp<=", periodEnd)
}
if _, err := eq.GetAll(&events); err != nil {
log.Error("Failed to query meter events for %s: %v", m.Id(), err, c)
continue
}
if len(events) == 0 {
continue
}
// Aggregate
var quantity int64
for _, evt := range events {
switch m.AggregationType {
case meter.AggSum:
quantity += evt.Value
case meter.AggCount:
quantity++
case meter.AggLast:
quantity = evt.Value
default:
quantity += evt.Value
}
}
// Calculate cost using pricing rule
rule, hasRule := rulesByMeter[m.Id()]
var cost int64
var pricingModel string
if hasRule {
cost = rule.CalculateCost(quantity)
pricingModel = string(rule.PricingType)
}
if cost > 0 || quantity > 0 {
var unitCost int64
if hasRule {
unitCost = rule.UnitPrice
}
lineItems = append(lineItems, lineItem{
MeterId: m.Id(),
MeterName: m.Name,
Quantity: quantity,
UnitCost: unitCost,
TotalCost: cost,
Model: pricingModel,
})
totalCost += cost
}
}
// 4. What the credit grants would cover. A preview reads them; it never spends.
creditApplied := int64(0)
overage := totalCost
if totalCost > 0 {
remaining, err := BurnCreditsPreview(db, req.UserId, totalCost)
if err != nil {
log.Error("Failed to calculate credit burn-down: %v", err, c)
// Non-fatal: show preview without credits
} else {
creditApplied = totalCost - remaining
overage = remaining
}
}
return c.JSON(200, map[string]any{
"userId": req.UserId,
"periodStart": req.PeriodStart,
"periodEnd": req.PeriodEnd,
"lineItems": lineItems,
"subtotal": totalCost,
"creditApplied": creditApplied,
"amountDue": overage,
"currency": "usd",
})
}
func parseDateOrTime(s string) (time.Time, error) {
if t, err := time.Parse(time.RFC3339, s); err == nil {
return t, nil
}
return time.Parse("2006-01-02", s)
}