summaryrefslogtreecommitdiff
path: root/internal/adminapi
diff options
context:
space:
mode:
authorChia <Chia@93.nz>2026-08-05 22:01:29 +1200
committerChia <Chia@93.nz>2026-08-05 22:07:50 +1200
commiteadb2ffe85c43cf6fc741c9823cd28eedb4a844c (patch)
tree1aba2536d57360da403aa35c9ced58b615c7064e /internal/adminapi
parentcd0dd91ab93653631904f2ea0e574ccde6d60339 (diff)
feat: harden prepaid billing and commercial operations
Diffstat (limited to '')
-rw-r--r--internal/adminapi/api.go208
1 files changed, 200 insertions, 8 deletions
diff --git a/internal/adminapi/api.go b/internal/adminapi/api.go
index 4e66d7f..9f460e3 100644
--- a/internal/adminapi/api.go
+++ b/internal/adminapi/api.go
@@ -144,6 +144,7 @@ func (a *API) Handler() http.Handler {
mux.HandleFunc("GET "+apiPrefix+"/models", a.withAuth("platform.read", a.listModels))
mux.HandleFunc("POST "+apiPrefix+"/models", a.withAuth("platform.write", a.createModel))
mux.HandleFunc("POST "+apiPrefix+"/models/{id}/toggle", a.withAuth("platform.write", a.toggleModel))
+ mux.HandleFunc("POST "+apiPrefix+"/models/{id}/prices", a.withAuth("platform.write", a.createModelPriceVersion))
mux.HandleFunc("POST "+apiPrefix+"/reload", a.withAuth("platform.write", a.reload))
if a.billing != nil {
mux.HandleFunc("GET "+apiPrefix+"/billing/accounts", a.withAuth("billing.read", a.listBillingAccounts))
@@ -152,6 +153,16 @@ func (a *API) Handler() http.Handler {
mux.HandleFunc("GET "+apiPrefix+"/billing/orders/{id}", a.withAuth("billing.read", a.getTopUpOrder))
mux.HandleFunc("POST "+apiPrefix+"/billing/adjustments", a.withAuth("billing.adjust", a.adjustBalance))
mux.HandleFunc("POST "+apiPrefix+"/billing/checkout-sessions", a.withAuth("billing.topup", a.createCheckoutSession))
+ mux.HandleFunc("POST "+apiPrefix+"/billing/portal-sessions", a.withAuth("billing.topup", a.createPortalSession))
+ mux.HandleFunc("POST "+apiPrefix+"/billing/orders/{id}/retry", a.withAuth("billing.topup", a.retryCheckoutSession))
+ mux.HandleFunc("POST "+apiPrefix+"/billing/orders/{id}/resolve-missing", a.withAuth("billing.adjust", a.resolveMissingTopUp))
+ mux.HandleFunc("POST "+apiPrefix+"/billing/orders/{id}/reverse-missing-credit", a.withAuth("billing.adjust", a.reverseMissingTopUpCredit))
+ mux.HandleFunc("POST "+apiPrefix+"/billing/orders/{id}/refund", a.withAuth("billing.adjust", a.createRefund))
+ mux.HandleFunc("GET "+apiPrefix+"/billing/refunds", a.withAuth("billing.read", a.listRefunds))
+ mux.HandleFunc("GET "+apiPrefix+"/billing/disputes", a.withAuth("billing.read", a.listDisputes))
+ mux.HandleFunc("GET "+apiPrefix+"/billing/invoices", a.withAuth("billing.read", a.listInvoices))
+ mux.HandleFunc("POST "+apiPrefix+"/billing/reconcile", a.withAuth("billing.adjust", a.reconcileBilling))
+ mux.HandleFunc("GET "+apiPrefix+"/billing/export.csv", a.withAuth("billing.read", a.exportBillingCSV))
}
mux.HandleFunc("GET "+apiPrefix+"/usage", a.withAuth("usage.read", a.listUsage))
mux.HandleFunc("GET "+apiPrefix+"/usage/summary", a.withAuth("usage.read", a.usageSummary))
@@ -945,11 +956,11 @@ func (a *API) listBillingLedger(w http.ResponseWriter, r *http.Request) {
func (a *API) listTopUpOrders(w http.ResponseWriter, r *http.Request) {
actor := a.actor(r)
- if actor.TenantID == "" {
- apierror.Write(w, apierror.Error{Status: http.StatusBadRequest, Type: "tenant_required", Message: "A tenant is required"}, requestID(r))
- return
+ tenantID := actor.TenantID
+ if tenantID == "" {
+ tenantID = r.URL.Query().Get("tenant_id")
}
- result, err := a.billing.ListTopUpOrders(r.Context(), actor.TenantID, 50)
+ result, err := a.billing.ListTopUpOrders(r.Context(), tenantID, 100)
if err != nil {
a.billingError(w, r, err)
return
@@ -959,10 +970,6 @@ func (a *API) listTopUpOrders(w http.ResponseWriter, r *http.Request) {
func (a *API) getTopUpOrder(w http.ResponseWriter, r *http.Request) {
actor := a.actor(r)
- if actor.TenantID == "" {
- apierror.Write(w, apierror.Error{Status: http.StatusBadRequest, Type: "tenant_required", Message: "A tenant is required"}, requestID(r))
- return
- }
result, err := a.billing.GetTopUpOrder(r.Context(), actor.TenantID, r.PathValue("id"))
if err != nil {
a.billingError(w, r, err)
@@ -992,6 +999,7 @@ func (a *API) createCheckoutSession(w http.ResponseWriter, r *http.Request) {
if tenantID := a.actor(r).TenantID; tenantID != "" {
input.TenantID = tenantID
}
+ input.CustomerEmail = a.actor(r).Email
result, err := a.billing.CreateCheckout(r.Context(), input)
if err != nil {
a.billingError(w, r, err)
@@ -1000,6 +1008,169 @@ func (a *API) createCheckoutSession(w http.ResponseWriter, r *http.Request) {
writeStatusJSON(w, http.StatusCreated, result)
}
+func (a *API) createPortalSession(w http.ResponseWriter, r *http.Request) {
+ tenantID := a.actor(r).TenantID
+ if tenantID == "" {
+ apierror.Write(w, apierror.Error{Status: http.StatusBadRequest, Type: "tenant_required", Message: "A tenant is required"}, requestID(r))
+ return
+ }
+ result, err := a.billing.CreatePortalSession(r.Context(), tenantID)
+ if err != nil {
+ a.billingError(w, r, err)
+ return
+ }
+ writeStatusJSON(w, http.StatusCreated, result)
+}
+
+func (a *API) retryCheckoutSession(w http.ResponseWriter, r *http.Request) {
+ actor := a.actor(r)
+ if actor.TenantID == "" {
+ a.scopeError(w, r)
+ return
+ }
+ result, err := a.billing.RetryCheckout(r.Context(), actor.TenantID, r.PathValue("id"), actor.Email)
+ if err != nil {
+ a.billingError(w, r, err)
+ return
+ }
+ writeStatusJSON(w, http.StatusCreated, result)
+}
+
+func (a *API) resolveMissingTopUp(w http.ResponseWriter, r *http.Request) {
+ var input billing.ResolveMissingTopUpInput
+ if !decodeBody(w, r, &input) {
+ return
+ }
+ tenantID := r.URL.Query().Get("tenant_id")
+ if actorTenant := a.actor(r).TenantID; actorTenant != "" {
+ tenantID = actorTenant
+ }
+ if tenantID == "" {
+ a.scopeError(w, r)
+ return
+ }
+ actor := a.actor(r)
+ actorType := "console_user"
+ if actor.Bootstrap {
+ actorType = "bootstrap"
+ }
+ result, err := a.billing.ResolveMissingTopUp(r.Context(), tenantID, r.PathValue("id"), input,
+ billing.ResolutionActor{ID: actor.ID, Type: actorType})
+ if err != nil {
+ a.billingError(w, r, err)
+ return
+ }
+ writeJSON(w, result)
+}
+
+func (a *API) reverseMissingTopUpCredit(w http.ResponseWriter, r *http.Request) {
+ var input billing.ResolveMissingTopUpInput
+ if !decodeBody(w, r, &input) {
+ return
+ }
+ tenantID := r.URL.Query().Get("tenant_id")
+ actor := a.actor(r)
+ if actor.TenantID != "" {
+ tenantID = actor.TenantID
+ }
+ if tenantID == "" {
+ a.scopeError(w, r)
+ return
+ }
+ actorType := "console_user"
+ if actor.Bootstrap {
+ actorType = "bootstrap"
+ }
+ result, err := a.billing.ReverseMissingTopUpCredit(r.Context(), tenantID, r.PathValue("id"), input,
+ billing.ResolutionActor{ID: actor.ID, Type: actorType})
+ if err != nil {
+ a.billingError(w, r, err)
+ return
+ }
+ writeJSON(w, result)
+}
+
+func (a *API) createRefund(w http.ResponseWriter, r *http.Request) {
+ var input billing.RefundInput
+ if !decodeBody(w, r, &input) {
+ return
+ }
+ tenantID := r.URL.Query().Get("tenant_id")
+ if actorTenant := a.actor(r).TenantID; actorTenant != "" {
+ tenantID = actorTenant
+ }
+ if tenantID == "" {
+ apierror.Write(w, apierror.Error{Status: http.StatusBadRequest, Type: "tenant_required", Message: "tenant_id is required"}, requestID(r))
+ return
+ }
+ result, err := a.billing.CreateRefund(r.Context(), tenantID, r.PathValue("id"), input)
+ if err != nil {
+ a.billingError(w, r, err)
+ return
+ }
+ writeStatusJSON(w, http.StatusAccepted, result)
+}
+
+func (a *API) listRefunds(w http.ResponseWriter, r *http.Request) {
+ tenantID := a.actor(r).TenantID
+ if tenantID == "" {
+ tenantID = r.URL.Query().Get("tenant_id")
+ }
+ result, err := a.billing.ListRefunds(r.Context(), tenantID, 200)
+ if err != nil {
+ a.billingError(w, r, err)
+ return
+ }
+ writeJSON(w, result)
+}
+
+func (a *API) listDisputes(w http.ResponseWriter, r *http.Request) {
+ tenantID := a.actor(r).TenantID
+ if tenantID == "" {
+ tenantID = r.URL.Query().Get("tenant_id")
+ }
+ result, err := a.billing.ListDisputes(r.Context(), tenantID, 200)
+ if err != nil {
+ a.billingError(w, r, err)
+ return
+ }
+ writeJSON(w, result)
+}
+
+func (a *API) listInvoices(w http.ResponseWriter, r *http.Request) {
+ tenantID := a.actor(r).TenantID
+ if tenantID == "" {
+ tenantID = r.URL.Query().Get("tenant_id")
+ }
+ result, err := a.billing.ListInvoices(r.Context(), tenantID, 200)
+ if err != nil {
+ a.billingError(w, r, err)
+ return
+ }
+ writeJSON(w, result)
+}
+
+func (a *API) reconcileBilling(w http.ResponseWriter, r *http.Request) {
+ result, err := a.billing.Reconcile(r.Context(), 200)
+ if err != nil {
+ a.billingError(w, r, err)
+ return
+ }
+ writeJSON(w, result)
+}
+
+func (a *API) exportBillingCSV(w http.ResponseWriter, r *http.Request) {
+ tenantID := a.actor(r).TenantID
+ if tenantID == "" {
+ tenantID = r.URL.Query().Get("tenant_id")
+ }
+ w.Header().Set("Content-Type", "text/csv; charset=utf-8")
+ w.Header().Set("Content-Disposition", `attachment; filename="aigw-financial-ledger.csv"`)
+ if err := a.billing.WriteFinancialCSV(r.Context(), tenantID, w); err != nil {
+ a.logger.Error("billing_export_failed", "error", err)
+ }
+}
+
func (a *API) listTenants(w http.ResponseWriter, r *http.Request) {
result, err := a.store.ListTenantsFor(r.Context(), a.actor(r).TenantID)
if err != nil {
@@ -1185,6 +1356,23 @@ func (a *API) toggleModel(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]any{"id": id, "enabled": input.Enabled})
}
+func (a *API) createModelPriceVersion(w http.ResponseWriter, r *http.Request) {
+ var input controlplane.CreatePriceVersionInput
+ if !decodeBody(w, r, &input) {
+ return
+ }
+ id := r.PathValue("id")
+ generation, err := a.store.CreateModelPriceVersion(r.Context(), id, input)
+ if err != nil {
+ a.mutationError(w, r, err)
+ return
+ }
+ if !a.changed(w, r, generation, "model_price", id) {
+ return
+ }
+ writeStatusJSON(w, http.StatusCreated, map[string]any{"model_id": id, "generation": generation})
+}
+
func (a *API) reload(w http.ResponseWriter, r *http.Request) {
generation, err := a.manager.Reload(r.Context())
if err != nil {
@@ -1375,6 +1563,10 @@ func (a *API) billingError(w http.ResponseWriter, r *http.Request, err error) {
status = http.StatusNotFound
typeName = "topup_order_not_found"
message = "Top-up order was not found"
+ case errors.Is(err, billing.ErrCannotResolveTopUp):
+ status = http.StatusConflict
+ typeName = "topup_order_not_resolvable"
+ message = err.Error()
default:
a.logger.Error("admin_billing_error", "error", err)
}