メインコンテンツまでスキップ

課金情報ダッシュボード実装ガイド

概要

本ページでは、サンプルアプリ 課金情報ダッシュボード を題材に、SaaSus Metering API/Pricing API を組み合わせて テナントごとの請求額と使用量をリアルタイムで把握できるダッシュボード の実装方法を解説します。

以下は課金情報ダッシュボードのスクリーンショットです。

バックエンド実装エンドポイント一覧

種別メソッド & パス説明
請求期間候補GET /billing/plan_periods?tenant_id=テナントに対して適用されたプラン履歴をもとに 月次/年次 区間を返却します。セレクトボックス生成に使用。
ダッシュボード集計GET /billing/dashboardメータリング集計結果と課金額、プラン情報をまとめて取得。
メータ更新 (現時刻)POST /billing/metering/{tenant_id}/{unit}add / sub / direct を指定して即時メータを加減。
メータ更新 (任意TS)POST /billing/metering/{tenant_id}/{unit}/{ts}履歴補正など、任意タイムスタンプで更新。
備考

プラン履歴は Auth API の テナント情報を取得 のレスポンスに含まれる plan_histories 配列から取得できます。

請求期間候補エンドポイント

請求期間セレクトボックスの実装

プラン履歴を 単位に切り分ける処理は バックエンド で行い、フロントには区間ラベルと Unix タイムスタンプ(秒)を渡します。

備考

Auth API の テナント情報を取得 レスポンスの plan_histories 配列(plan_id と plan_applied_at)を用いてプラン適用タイミングを取得し、current_plan_period_end を最終境界として扱います。plan_id が空のエントリは除外してください。

備考

以下の実装フロー概要およびコードサンプルはバックエンドが Go、フロントエンドが React を前提としています。

実装フロー概要

  1. テナント情報取得 — Auth API でテナントを取得し、planHistoriescurrentPlanPeriodEnd を取得。
  2. 境界エッジ作成planAppliedAt を昇順にソートして境界点 (edge) の配列を作成。
  3. 最終境界決定currentPlanPeriodEnd があればその 1 秒前、無ければ「現在」を最終境界に設定。
  4. 区間分割 — 区間ごとに月次 / 年次かを判定し、step() でループしながら PlanPeriodOption を生成。
  5. 最新優先ソートStart をキーに降順ソートしてフロントへ返却。
// ② 境界エッジ作成 & ④ 区間を month/year で分割
type edge struct { PlanID string; Time time.Time }
// ...プラン履歴を Time 昇順で edge 配列に格納...
for cur := periodStart; !cur.After(periodEnd); {
next := step(cur) // month なら +1M, year なら +1Y
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)
}

フロントではこれを <select> にバインドし、選択が変わるたびに GET /billing/dashboard を再フェッチします。

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

実装例リンク

以下のリンク先に、本エンドポイントの実装が含まれています。
関数名で検索して該当箇所をご確認ください。

ダッシュボード集計エンドポイント

aggregate_usage: maxsum の違い

モード典型ユースケース集計方法
sum (デフォルト)API 呼び出し回数/データ転送量期間内 合計値 を使用。
max同時接続数/アクティブユーザー数期間内 最大値 を使用。

calculateMeteringUnitBillings ではメータの aggregate_usage をチェックし、max の場合は対象期間のレコードの中で最大値を採用します。

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) }
}
備考

aggregate_usage の設定項目は、Stripe 連携を有効にしている場合のみ UI に表示されます。
プラットフォーム単体利用時には非表示になります。

unit_type による課金額の算出ロジック

メータリングユニットには type が設定されており、以下のように課金方式が異なります。

type計測単位課金ロジック
fixed固定ユニット固定単価をそのまま金額に反映
usage使用量ユニット使用量 × 単価
tiered段階ユニット該当段を判定し、その条件に基づいて加算
tiered_usage段階使用量ユニット下位段から順に積み上げ式で加算
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:
// フォールバック: 不明な unit_type は usage とみなして従量課金で計算
return count * price
}
}

※ tiered / tiered_usage の詳細ロジックは calcTiered, calcTieredUsage を参照

実装例リンク

以下のリンク先に、本エンドポイントの実装が含まれています。
関数名で検索して該当箇所をご確認ください。

メータ更新

課金ダッシュボード上でのメータ更新は、現在時刻に対して更新するインライン編集ケースと、任意のタイムスタンプで履歴補正するモーダル編集ケースに分かれます。

インライン編集例

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)
}

備考

インラインのプラス/マイナスボタンは、選択中の期間が「現在進行中」の場合のみ表示されます。

実装例リンク

以下のリンク先に、本エンドポイントの実装が含まれています。
関数名で検索して該当箇所をご確認ください。

モーダル編集例(任意タイムスタンプで補正)

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)
}

実装例リンク

以下のリンク先に、本エンドポイントの実装が含まれています。
関数名で検索して該当箇所をご確認ください。