Skip to main content

Billing Dashboard Implementation Guide

Overview

This guide explains how to implement a tenant-based real-time billing dashboard by combining the SaaSus Metering API and Pricing API, based on the sample app Billing Dashboard.

Screenshot of the billing dashboard:

Backend Endpoint Summary

CategoryMethod & PathDescription
Plan Period CandidatesGET /billing/plan_periods?tenant_id=Returns monthly or yearly periods based on the tenant’s plan history. Used for generating a select box.
Dashboard AggregationGET /billing/dashboardRetrieves metering summaries, billing amounts, and plan information in one response.
Meter Update (Current Time)POST /billing/metering/{tenant_id}/{unit}Applies immediate metering updates using add / sub / direct method.
Meter Update (Custom Timestamp)POST /billing/metering/{tenant_id}/{unit}/{ts}Updates metering values using a specified timestamp. Useful for corrections.
info

Plan history can be obtained from the plan_histories array in the response of Get Tenant Details in the Auth API.

Plan Period Candidates Endpoint

Implementing a Period Select Box

The backend splits plan periods into monthly or yearly segments and returns labels and Unix timestamps (seconds) for the frontend.

info

Use the plan_histories array (which contains plan_id and plan_applied_at) from the response of Get Tenant Details in the Auth API to determine the plan application timing. Treat current_plan_period_end as the final boundary. Exclude entries with an empty plan_id.

info

The following implementation flow and code samples assume a Go backend and a React frontend.

Implementation Flow

  1. Get Tenant Info — Retrieve planHistories and currentPlanPeriodEnd from the Auth API.
  2. Create Boundary Edges — Sort planAppliedAt chronologically to form edge array.
  3. Define Final Boundary — Use currentPlanPeriodEnd (or now if unavailable) minus 1 second as the final edge.
  4. Split Periods — Determine if the interval is monthly or yearly, then generate PlanPeriodOption using step() loop.
  5. Sort by Latest First — Sort by Start in descending order and return to frontend.
// Step 2 & 4: Create edges and split by month/year
type edge struct { PlanID string; Time time.Time }
// ...populate edge array sorted by Time ascending...
for cur := periodStart; !cur.After(periodEnd); {
next := step(cur) // +1M for month, +1Y for year
end := next.Add(-1 * time.Second)
results = append(results, PlanPeriodOption{ Label: label(cur,end), PlanID: e.PlanID, Start: cur.Unix(), End: end.Unix() })
if end.Equal(periodEnd) { break }
cur = end.Add(time.Second)
}

On the frontend, bind these to a <select> element and refetch the dashboard on change:

// BillingDashboard.tsx (excerpt)
const fetchPeriodOptions = async () => {
const res = await axios.get<PlanPeriodOption[]>("/billing/plan_periods", { params:{ tenant_id } });
setPeriodOptions(res.data);
setSelectedPeriod(toState(res.data[0]));
};

Example Implementations

The following links point to repositories that include implementations of this endpoint.
Search by function name to locate the relevant code.

Dashboard Aggregation Endpoint

Difference Between max and sum in aggregate_usage

ModeTypical Use CaseAggregation Method
sum (default)API call counts / trafficUse total value in period
maxConcurrent users / sessionsUse maximum value in period

In calculateMeteringUnitBillings, the function checks aggregate_usage and uses the max or sum accordingly:

if aggUsage == "max" {
for _, c := range resp.JSON200.Counts {
if float64(c.Count) > count { count = float64(c.Count) }
}
} else { // sum
for _, c := range resp.JSON200.Counts { count += float64(c.Count) }
}
info

The aggregate_usage setting is only visible in the UI when Stripe integration is enabled.
It is hidden when using the platform standalone.

Billing Logic by unit_type

Metering units have a type that determines the billing logic as follows:

typeMeasurement UnitBilling Logic
fixedFixed UnitUse fixed price as is
usageUsage Unitcount × price
tieredTiered UnitAdd prices based on matching tier
tiered_usageTiered Usage UnitAccumulate from lower tiers upward
func calculateAmountByUnitType(count float64, u map[string]interface{}) float64 {
unitType, _ := u["type"].(string)
price, _ := u["unit_amount"].(float64)

switch unitType {
case "fixed":
return price
case "usage":
return count * price
case "tiered":
return calcTiered(count, u)
case "tiered_usage":
return calcTieredUsage(count, u)
default:
// Fallback: treat unknown type as usage
return count * price
}
}

See calcTiered and calcTieredUsage for details.

Example Implementations

The following links point to repositories that include implementations of this endpoint.
Search by function name to locate the relevant code.

Meter Updates

The dashboard allows metering updates in two scenarios: inline edits (current time) and modal edits (specific timestamp).

Inline Edit Example (Current Time)

func updateCountOfNow(c echo.Context) error {
tenantId := c.Param("tenantId")
unitName := c.Param("unit")

userInfo, _ := c.Get(string(ctxlib.UserInfoKey)).(*authapi.UserInfo)
if !hasBillingAccess(userInfo, tenantId) {
return c.String(http.StatusForbidden, "Insufficient permissions")
}

var body struct {
Method string `json:"method"` // add | sub | direct
Count int `json:"count"`
}
if err := c.Bind(&body); err != nil {
return c.String(http.StatusBadRequest, "invalid JSON body")
}
if body.Count < 0 {
return c.String(http.StatusBadRequest, "count must be >= 0")
}

method := pricingapi.UpdateMeteringUnitTimestampCountMethod(body.Method)
switch method {
case pricingapi.Add, pricingapi.Sub, pricingapi.Direct:
// OK
default:
return c.String(http.StatusBadRequest, "method must be add, sub, or direct")
}

param := pricingapi.UpdateMeteringUnitTimestampCountNowParam{
Method: method,
Count: body.Count,
}

resp, err := pricingClient.UpdateMeteringUnitTimestampCountNowWithResponse(
c.Request().Context(), tenantId, unitName, param,
)
if err != nil {
log.Printf("pricing API error: %v", err)
return c.String(http.StatusInternalServerError, "pricing API error")
}
if resp.JSON200 == nil {
return c.String(resp.StatusCode(), string(resp.Body))
}

return c.JSON(http.StatusOK, resp.JSON200)
}
info

The inline plus/minus buttons are only displayed when the selected period is currently ongoing.

Example Implementations

The following links point to repositories that include implementations of this endpoint.
Search by function name to locate the relevant code.

func updateCountOfSpecifiedTS(c echo.Context) error {
tenantId := c.Param("tenantId")
unitName := c.Param("unit")
tsStr := c.Param("ts")

userInfo, _ := c.Get(string(ctxlib.UserInfoKey)).(*authapi.UserInfo)
if !hasBillingAccess(userInfo, tenantId) {
return c.String(http.StatusForbidden, "Insufficient permissions")
}

ts, err := strconv.ParseInt(tsStr, 10, 64)
if err != nil {
return c.String(http.StatusBadRequest, "ts must be 10-digit unix seconds")
}

var body struct {
Method string `json:"method"` // add | sub | direct
Count int `json:"count"`
}
if err := c.Bind(&body); err != nil {
return c.String(http.StatusBadRequest, "invalid JSON body")
}
if body.Count < 0 {
return c.String(http.StatusBadRequest, "count must be >= 0")
}

method := pricingapi.UpdateMeteringUnitTimestampCountMethod(body.Method)
switch method {
case pricingapi.Add, pricingapi.Sub, pricingapi.Direct:
// OK
default:
return c.String(http.StatusBadRequest, "method must be add, sub, or direct")
}

param := pricingapi.UpdateMeteringUnitTimestampCountParam{
Method: method,
Count: body.Count,
}

resp, err := pricingClient.UpdateMeteringUnitTimestampCountWithResponse(
c.Request().Context(), tenantId, unitName, int(ts), param,
)
if err != nil {
log.Printf("pricing API error: %v", err)
return c.String(http.StatusInternalServerError, "pricing API error")
}
if resp.JSON200 == nil {
return c.String(resp.StatusCode(), string(resp.Body))
}

return c.JSON(http.StatusOK, resp.JSON200)
}

Example Implementations

The following links point to repositories that include implementations of this endpoint.
Search by function name to locate the relevant code.