diff --git a/api/v3/handlers/customers/credits/list_transactions.go b/api/v3/handlers/customers/credits/list_transactions.go new file mode 100644 index 0000000000000000000000000000000000000000..17ad83182d7a0578fe882cacfbf21700dcac9802 --- /dev/null +++ b/api/v3/handlers/customers/credits/list_transactions.go @@ -0,0 +1,157 @@ +package customerscredits + +import ( + "context" + "fmt" + "net/http" + + "github.com/oapi-codegen/nullable" + "github.com/samber/lo" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/apierrors" + "github.com/openmeterio/openmeter/openmeter/customer" + "github.com/openmeterio/openmeter/openmeter/ledger/customerbalance" + "github.com/openmeterio/openmeter/pkg/currencyx" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" +) + +type ( + ListCreditTransactionsRequest = customerbalance.ListCreditTransactionsInput + ListCreditTransactionsResponse = api.CreditTransactionPaginatedResponse + ListCreditTransactionsParams struct { + CustomerID api.ULID + Params api.ListCreditTransactionsParams + } + ListCreditTransactionsHandler httptransport.HandlerWithArgs[ListCreditTransactionsRequest, ListCreditTransactionsResponse, ListCreditTransactionsParams] +) + +func (h *handler) ListCreditTransactions() ListCreditTransactionsHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, args ListCreditTransactionsParams) (ListCreditTransactionsRequest, error) { + ns, err := h.resolveNamespace(ctx) + if err != nil { + return ListCreditTransactionsRequest{}, err + } + + size := 20 + if args.Params.Page != nil { + size = lo.FromPtrOr(args.Params.Page.Size, 20) + } + + if size < 1 { + err := fmt.Errorf("must be greater than 0") + return ListCreditTransactionsRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{ + { + Field: "page.size", + Reason: "must be greater than 0", + Source: apierrors.InvalidParamSourceQuery, + }, + }) + } + + req := customerbalance.ListCreditTransactionsInput{ + CustomerID: customer.CustomerID{ + Namespace: ns, + ID: args.CustomerID, + }, + Limit: size, + } + + if args.Params.Page != nil { + if args.Params.Page.After != nil { + after, err := decodeBillingCreditTransactionCursor(*args.Params.Page.After, ns) + if err != nil { + return ListCreditTransactionsRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{ + { + Field: "page.after", + Reason: err.Error(), + Source: apierrors.InvalidParamSourceQuery, + }, + }) + } + + req.After = after + } + + if args.Params.Page.Before != nil { + before, err := decodeBillingCreditTransactionCursor(*args.Params.Page.Before, ns) + if err != nil { + return ListCreditTransactionsRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{ + { + Field: "page.before", + Reason: err.Error(), + Source: apierrors.InvalidParamSourceQuery, + }, + }) + } + + req.Before = before + } + } + + if args.Params.Filter != nil { + req.Type = fromAPIBillingCreditTransactionType(args.Params.Filter.Type) + + if args.Params.Filter.Currency != nil { + currency := currencyx.Code(*args.Params.Filter.Currency) + req.Currency = ¤cy + } + + featureFilter, err := fromAPICustomerCreditFeatureFilter(args.Params.Filter.FeatureKey) + if err != nil { + return ListCreditTransactionsRequest{}, newFeatureKeyFilterBadRequest(ctx, err) + } + + req.FeatureFilter = featureFilter + } + + return req, nil + }, + func(ctx context.Context, request ListCreditTransactionsRequest) (ListCreditTransactionsResponse, error) { + result, err := h.balanceFacade.ListCreditTransactions(ctx, request) + if err != nil { + return ListCreditTransactionsResponse{}, fmt.Errorf("list credit transactions: %w", err) + } + + // We intentionally expose opaque cursor tokens instead of URI links. + // This endpoint reuses the shared cursor metadata schema, but still emits + // opaque token values rather than fully qualified URLs. + meta := api.CursorMeta{ + Page: api.CursorMetaPage{ + Next: nullable.NewNullNullable[string](), + Previous: nullable.NewNullNullable[string](), + Size: float32(request.Limit), + }, + } + + if result.NextCursor != nil { + next, err := encodeBillingCreditTransactionCursor(*result.NextCursor) + if err != nil { + return ListCreditTransactionsResponse{}, fmt.Errorf("encode next cursor: %w", err) + } + meta.Page.Next = nullable.NewNullableWithValue(next) + } + + if result.PreviousCursor != nil { + previous, err := encodeBillingCreditTransactionCursor(*result.PreviousCursor) + if err != nil { + return ListCreditTransactionsResponse{}, fmt.Errorf("encode previous cursor: %w", err) + } + meta.Page.Previous = nullable.NewNullableWithValue(previous) + } + + return api.CreditTransactionPaginatedResponse{ + Data: toAPIBillingCreditTransactions(result.Items), + Meta: meta, + }, nil + }, + commonhttp.JSONResponseEncoderWithStatus[ListCreditTransactionsResponse](http.StatusOK), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("list-credit-transactions"), + httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()), + )..., + ) +} diff --git a/api/v3/handlers/customers/credits/list_transactions_test.go b/api/v3/handlers/customers/credits/list_transactions_test.go new file mode 100644 index 0000000000000000000000000000000000000000..e1f3777232c1dc34d37f55401e7c5f5d60a1cb74 --- /dev/null +++ b/api/v3/handlers/customers/credits/list_transactions_test.go @@ -0,0 +1,117 @@ +package customerscredits + +import ( + "testing" + "time" + + "github.com/alpacahq/alpacadecimal" + "github.com/stretchr/testify/require" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/openmeter/ledger" + "github.com/openmeterio/openmeter/openmeter/ledger/customerbalance" + "github.com/openmeterio/openmeter/pkg/currencyx" + "github.com/openmeterio/openmeter/pkg/models" +) + +func TestFromAPIBillingCreditTransactionType_Funded(t *testing.T) { + filter := api.BillingCreditTransactionTypeFunded + + txType := fromAPIBillingCreditTransactionType(&filter) + + require.NotNil(t, txType) + require.Equal(t, customerbalance.CreditTransactionTypeFunded, *txType) +} + +func TestFromAPIBillingCreditTransactionType_Expired(t *testing.T) { + filter := api.BillingCreditTransactionTypeExpired + + txType := fromAPIBillingCreditTransactionType(&filter) + + require.NotNil(t, txType) + require.Equal(t, customerbalance.CreditTransactionTypeExpired, *txType) +} + +func TestToAPIBillingCreditTransaction(t *testing.T) { + createdAt := time.Date(2026, 4, 10, 9, 0, 0, 0, time.UTC) + bookedAt := createdAt.Add(time.Second) + description := "Welcome credits" + + tx := toAPIBillingCreditTransaction(customerbalance.CreditTransaction{ + ID: models.NamespacedID{ + Namespace: "ns", + ID: "tx-1", + }, + CreatedAt: createdAt, + BookedAt: bookedAt, + Type: customerbalance.CreditTransactionTypeConsumed, + Currency: currencyx.Code("USD"), + Amount: alpacadecimal.NewFromInt(-10), + Balance: customerbalance.CreditTransactionBalance{ + Before: alpacadecimal.NewFromInt(52), + After: alpacadecimal.NewFromInt(42), + }, + Name: "credit_transaction", + Description: &description, + Annotations: models.Annotations{ + ledger.AnnotationChargeID: "charge-1", + }, + }) + + require.Equal(t, api.ULID("tx-1"), tx.Id) + require.Equal(t, api.BillingCreditTransactionTypeConsumed, tx.Type) + require.Equal(t, api.BillingCurrencyCode("USD"), tx.Currency) + require.Equal(t, api.Numeric("-10"), tx.Amount) + require.Equal(t, api.Numeric("52"), tx.AvailableBalance.Before) + require.Equal(t, api.Numeric("42"), tx.AvailableBalance.After) + require.NotNil(t, tx.Description) + require.Equal(t, description, *tx.Description) + require.NotNil(t, tx.Labels) + require.Equal(t, "charge-1", (*tx.Labels)["charge_id"]) +} + +func TestToAPIBillingCreditTransaction_Expired(t *testing.T) { + tx := toAPIBillingCreditTransaction(customerbalance.CreditTransaction{ + ID: models.NamespacedID{ + Namespace: "ns", + ID: "tx-1", + }, + CreatedAt: time.Date(2026, 4, 10, 9, 0, 0, 0, time.UTC), + BookedAt: time.Date(2026, 4, 11, 9, 0, 0, 0, time.UTC), + Type: customerbalance.CreditTransactionTypeExpired, + Currency: currencyx.Code("USD"), + Amount: alpacadecimal.NewFromInt(-4), + }) + + require.Equal(t, api.BillingCreditTransactionTypeExpired, tx.Type) +} + +func TestCreditTransactionCursorConversion(t *testing.T) { + bookedAt := time.Date(2026, 4, 10, 10, 0, 0, 0, time.UTC) + createdAt := bookedAt.Add(-time.Second) + namespace := "ns" + + ledgerCursor := ledger.TransactionCursor{ + BookedAt: bookedAt, + CreatedAt: createdAt, + ID: models.NamespacedID{ + Namespace: namespace, + ID: "01J7JABCDXYZ1234567890ABCD", + }, + } + + encoded, err := encodeBillingCreditTransactionCursor(ledgerCursor) + require.NoError(t, err) + + decoded, err := decodeBillingCreditTransactionCursor(encoded, namespace) + require.NoError(t, err) + require.Equal(t, bookedAt, decoded.BookedAt) + require.Equal(t, createdAt, decoded.CreatedAt) + require.Equal(t, namespace, decoded.ID.Namespace) + require.Equal(t, ledgerCursor.ID.ID, decoded.ID.ID) +} + +func TestCreditTransactionCursorConversion_InvalidCursor(t *testing.T) { + _, err := decodeBillingCreditTransactionCursor("not-base64", "ns") + require.Error(t, err) +} diff --git a/api/v3/handlers/customers/credits/void_grant.go b/api/v3/handlers/customers/credits/void_grant.go new file mode 100644 index 0000000000000000000000000000000000000000..a5a62b2dfb79f67b26c9518f5358a5ac03be00fd --- /dev/null +++ b/api/v3/handlers/customers/credits/void_grant.go @@ -0,0 +1,55 @@ +package customerscredits + +import ( + "context" + "net/http" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/apierrors" + "github.com/openmeterio/openmeter/api/v3/request" + "github.com/openmeterio/openmeter/openmeter/billing/creditgrant" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" +) + +type ( + VoidCreditGrantRequest = creditgrant.VoidInput + VoidCreditGrantResponse = api.BillingCreditGrant + VoidCreditGrantParams struct { + CustomerID api.ULID + CreditGrantID api.ULID + } + VoidCreditGrantHandler = httptransport.HandlerWithArgs[VoidCreditGrantRequest, VoidCreditGrantResponse, VoidCreditGrantParams] +) + +func (h *handler) VoidCreditGrant() VoidCreditGrantHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, args VoidCreditGrantParams) (VoidCreditGrantRequest, error) { + ns, err := h.resolveNamespace(ctx) + if err != nil { + return VoidCreditGrantRequest{}, err + } + + var body api.VoidCreditGrantRequest + if err := request.ParseOptionalBody(r, &body); err != nil { + return VoidCreditGrantRequest{}, err + } + + return fromAPIVoidCreditGrantRequest(ns, args.CustomerID, args.CreditGrantID, body) + }, + func(ctx context.Context, request VoidCreditGrantRequest) (VoidCreditGrantResponse, error) { + grant, err := h.creditGrantService.Void(ctx, request) + if err != nil { + return VoidCreditGrantResponse{}, err + } + + return toAPIBillingCreditGrant(grant) + }, + commonhttp.JSONResponseEncoderWithStatus[VoidCreditGrantResponse](http.StatusOK), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("void-credit-grant"), + httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()), + )..., + ) +} diff --git a/api/v3/handlers/customers/credits/void_grant_test.go b/api/v3/handlers/customers/credits/void_grant_test.go new file mode 100644 index 0000000000000000000000000000000000000000..e0eeb54074336cfd4e2084c18750e16485c34f74 --- /dev/null +++ b/api/v3/handlers/customers/credits/void_grant_test.go @@ -0,0 +1,71 @@ +package customerscredits + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/apierrors" + "github.com/openmeterio/openmeter/openmeter/billing/creditgrant" + "github.com/openmeterio/openmeter/pkg/models" +) + +func TestConvertAPIVoidCreditGrantRequestDefaultsPaymentAdjustment(t *testing.T) { + req, err := fromAPIVoidCreditGrantRequest( + "ns", + "cust-1", + "grant-1", + api.VoidCreditGrantRequest{}, + ) + + require.NoError(t, err) + require.Equal(t, "ns", req.Namespace) + require.Equal(t, "cust-1", req.CustomerID) + require.Equal(t, "grant-1", req.ChargeID) + require.Equal(t, creditgrant.VoidPaymentAdjustmentNone, req.PaymentAdjustment) +} + +func TestConvertAPIVoidCreditGrantRequestAcceptsNonePaymentAdjustment(t *testing.T) { + adjustment := api.BillingCreditGrantVoidPaymentAdjustmentNone + + req, err := fromAPIVoidCreditGrantRequest( + "ns", + "cust-1", + "grant-1", + api.VoidCreditGrantRequest{ + PaymentAdjustment: &adjustment, + }, + ) + + require.NoError(t, err) + require.Equal(t, creditgrant.VoidPaymentAdjustmentNone, req.PaymentAdjustment) +} + +func TestConvertAPIVoidCreditGrantRequestRejectsUnsupportedPaymentAdjustment(t *testing.T) { + adjustment := api.BillingCreditGrantVoidPaymentAdjustment("external") + + _, err := fromAPIVoidCreditGrantRequest( + "ns", + "cust-1", + "grant-1", + api.VoidCreditGrantRequest{ + PaymentAdjustment: &adjustment, + }, + ) + + require.Error(t, err) + issues, convErr := models.AsValidationIssues(err) + require.NoError(t, convErr) + require.Len(t, issues, 1) + require.ErrorContains(t, err, "unsupported credit grant void payment adjustment") + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v3/customers/cust-1/credits/grants/grant-1/void", nil) + handled := apierrors.GenericErrorEncoder()(t.Context(), err, rec, req) + + require.True(t, handled) + require.Equal(t, http.StatusBadRequest, rec.Code) +} diff --git a/api/v3/handlers/customers/delete.go b/api/v3/handlers/customers/delete.go new file mode 100644 index 0000000000000000000000000000000000000000..ff888535e7c52d786f03df5124000d8a49c1a623 --- /dev/null +++ b/api/v3/handlers/customers/delete.go @@ -0,0 +1,55 @@ +package customers + +import ( + "context" + "net/http" + + "github.com/openmeterio/openmeter/api/v3/apierrors" + customer "github.com/openmeterio/openmeter/openmeter/customer" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" +) + +type ( + DeleteCustomerRequest struct { + Namespace string + CustomerID string + } + DeleteCustomerResponse = interface{} + DeleteCustomerParams = string + DeleteCustomerHandler httptransport.HandlerWithArgs[DeleteCustomerRequest, DeleteCustomerResponse, DeleteCustomerParams] +) + +// DeleteCustomer returns a handler for deleting a customer. +func (h *handler) DeleteCustomer() DeleteCustomerHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, customerID DeleteCustomerParams) (DeleteCustomerRequest, error) { + ns, err := h.resolveNamespace(ctx) + if err != nil { + return DeleteCustomerRequest{}, err + } + + return DeleteCustomerRequest{ + Namespace: ns, + CustomerID: customerID, + }, nil + }, + func(ctx context.Context, request DeleteCustomerRequest) (DeleteCustomerResponse, error) { + err := h.service.DeleteCustomer(ctx, customer.DeleteCustomerInput{ + Namespace: request.Namespace, + ID: request.CustomerID, + }) + if err != nil { + return nil, err + } + + return nil, nil + }, + commonhttp.EmptyResponseEncoder[DeleteCustomerResponse](http.StatusNoContent), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("delete-customer"), + httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()), + )..., + ) +} diff --git a/api/v3/handlers/customers/entitlementaccess/handler.go b/api/v3/handlers/customers/entitlementaccess/handler.go new file mode 100644 index 0000000000000000000000000000000000000000..c9c9f2e42ec7bd94f9f7921edde3ea40ac366381 --- /dev/null +++ b/api/v3/handlers/customers/entitlementaccess/handler.go @@ -0,0 +1,34 @@ +package customersentitlement + +import ( + "context" + + "github.com/openmeterio/openmeter/openmeter/customer" + "github.com/openmeterio/openmeter/openmeter/entitlement" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" +) + +type Handler interface { + ListCustomerEntitlementAccess() ListCustomerEntitlementAccessHandler +} + +type handler struct { + resolveNamespace func(ctx context.Context) (string, error) + customerService customer.Service + entitlementService entitlement.Service + options []httptransport.HandlerOption +} + +func New( + resolveNamespace func(ctx context.Context) (string, error), + customerService customer.Service, + entitlementService entitlement.Service, + options ...httptransport.HandlerOption, +) Handler { + return &handler{ + resolveNamespace: resolveNamespace, + customerService: customerService, + entitlementService: entitlementService, + options: options, + } +} diff --git a/api/v3/handlers/customers/entitlementaccess/list.go b/api/v3/handlers/customers/entitlementaccess/list.go new file mode 100644 index 0000000000000000000000000000000000000000..4a1adc5c196e92e4581ac8f57966be5d71b775fe --- /dev/null +++ b/api/v3/handlers/customers/entitlementaccess/list.go @@ -0,0 +1,97 @@ +package customersentitlement + +import ( + "context" + "fmt" + "net/http" + "sort" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/apierrors" + "github.com/openmeterio/openmeter/openmeter/customer" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" +) + +type ( + CustomerID = string + ListCustomerEntitlementAccessResponse = api.ListCustomerEntitlementAccessResponseData + ListCustomerEntitlementAccessHandler httptransport.HandlerWithArgs[ListCustomerEntitlementAccessRequest, ListCustomerEntitlementAccessResponse, CustomerID] +) + +type ListCustomerEntitlementAccessRequest struct { + CustomerID customer.CustomerID +} + +func (h *handler) ListCustomerEntitlementAccess() ListCustomerEntitlementAccessHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, customerID CustomerID) (ListCustomerEntitlementAccessRequest, error) { + ns, err := h.resolveNamespace(ctx) + if err != nil { + return ListCustomerEntitlementAccessRequest{}, err + } + + req := ListCustomerEntitlementAccessRequest{ + CustomerID: customer.CustomerID{ + Namespace: ns, + ID: customerID, + }, + } + + return req, nil + }, + func(ctx context.Context, request ListCustomerEntitlementAccessRequest) (ListCustomerEntitlementAccessResponse, error) { + // Get the customer + cus, err := h.customerService.GetCustomer(ctx, customer.GetCustomerInput{ + CustomerID: &request.CustomerID, + }) + if err != nil { + return ListCustomerEntitlementAccessResponse{}, err + } + + if cus != nil && cus.IsDeleted() { + return ListCustomerEntitlementAccessResponse{}, + apierrors.NewPreconditionFailedError(ctx, + fmt.Sprintf("customer is deleted [namespace=%s customer.id=%s]", cus.Namespace, cus.ID), + ) + } + + // Get the access + access, err := h.entitlementService.GetAccess(ctx, cus.Namespace, cus.ID) + if err != nil { + return ListCustomerEntitlementAccessResponse{}, err + } + + // Convert the access to the API response + items := make([]api.BillingEntitlementAccessResult, 0, len(access.Entitlements)) + for featureKey, entitlement := range access.Entitlements { + found, item, err := mapEntitlementValueToAPI(featureKey, entitlement.Value) + if err != nil { + return ListCustomerEntitlementAccessResponse{}, err + } + + if !found { + continue + } + + items = append(items, item) + } + + // Sort the items by feature key + sort.Slice(items, func(i, j int) bool { + return items[i].FeatureKey < items[j].FeatureKey + }) + + // Return the response + return ListCustomerEntitlementAccessResponse{ + Data: items, + }, nil + }, + commonhttp.JSONResponseEncoderWithStatus[ListCustomerEntitlementAccessResponse](http.StatusOK), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("list-customer-entitlement-access"), + httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()), + )..., + ) +} diff --git a/api/v3/handlers/customers/entitlementaccess/mapping.go b/api/v3/handlers/customers/entitlementaccess/mapping.go new file mode 100644 index 0000000000000000000000000000000000000000..e452a8bc87bee3af5adb9f78e0875e33a00597af --- /dev/null +++ b/api/v3/handlers/customers/entitlementaccess/mapping.go @@ -0,0 +1,46 @@ +package customersentitlement + +import ( + "errors" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/openmeter/entitlement" + booleanentitlement "github.com/openmeterio/openmeter/openmeter/entitlement/boolean" + meteredentitlement "github.com/openmeterio/openmeter/openmeter/entitlement/metered" + staticentitlement "github.com/openmeterio/openmeter/openmeter/entitlement/static" +) + +// mapEntitlementValueToAPI maps an entitlement value to an API entitlement access result. +func mapEntitlementValueToAPI(featureKey string, entitlementValue entitlement.EntitlementValue) (bool, api.BillingEntitlementAccessResult, error) { + switch ent := entitlementValue.(type) { + case *meteredentitlement.MeteredEntitlementValue: + return true, api.BillingEntitlementAccessResult{ + FeatureKey: featureKey, + Type: api.BillingEntitlementTypeMetered, + HasAccess: ent.HasAccess(), + }, nil + case *staticentitlement.StaticEntitlementValue: + accessResult := api.BillingEntitlementAccessResult{ + FeatureKey: featureKey, + Type: api.BillingEntitlementTypeStatic, + HasAccess: ent.HasAccess(), + Config: &ent.Config, + } + + return true, accessResult, nil + case *booleanentitlement.BooleanEntitlementValue: + return true, api.BillingEntitlementAccessResult{ + FeatureKey: featureKey, + Type: api.BillingEntitlementTypeBoolean, + HasAccess: ent.HasAccess(), + }, nil + + case *entitlement.NoAccessValue: + // FIXME(pmarton): do we need to handle no access value? + // According to comments this only happens when the entitlement is not active + // This is the reason why we return a bool and not an error + return false, api.BillingEntitlementAccessResult{}, nil + default: + return true, api.BillingEntitlementAccessResult{}, errors.New("unknown entitlement type") + } +} diff --git a/api/v3/handlers/customers/get.go b/api/v3/handlers/customers/get.go new file mode 100644 index 0000000000000000000000000000000000000000..d1420348d5bc3bd155bace83d02ac5c8c22ad9ca --- /dev/null +++ b/api/v3/handlers/customers/get.go @@ -0,0 +1,53 @@ +package customers + +import ( + "context" + "net/http" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/apierrors" + "github.com/openmeterio/openmeter/openmeter/customer" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" +) + +type ( + GetCustomerRequest = customer.GetCustomerInput + GetCustomerResponse = api.BillingCustomer + GetCustomerParams = string + GetCustomerHandler httptransport.HandlerWithArgs[GetCustomerRequest, GetCustomerResponse, GetCustomerParams] +) + +// GetCustomer returns a handler for getting a customer. +func (h *handler) GetCustomer() GetCustomerHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, customerID GetCustomerParams) (GetCustomerRequest, error) { + ns, err := h.resolveNamespace(ctx) + if err != nil { + return GetCustomerRequest{}, err + } + + return GetCustomerRequest{ + CustomerID: &customer.CustomerID{ + Namespace: ns, + ID: customerID, + }, + }, nil + }, + func(ctx context.Context, request GetCustomerRequest) (GetCustomerResponse, error) { + // Get the customer + cus, err := h.service.GetCustomer(ctx, request) + if err != nil { + return GetCustomerResponse{}, err + } + + return ToAPIBillingCustomer(*cus), nil + }, + commonhttp.JSONResponseEncoderWithStatus[GetCustomerResponse](http.StatusOK), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("get-customer"), + httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()), + )..., + ) +} diff --git a/api/v3/handlers/customers/handler.go b/api/v3/handlers/customers/handler.go new file mode 100644 index 0000000000000000000000000000000000000000..04db73a6af7726f70f389d88602adfaada9e476b --- /dev/null +++ b/api/v3/handlers/customers/handler.go @@ -0,0 +1,34 @@ +package customers + +import ( + "context" + + "github.com/openmeterio/openmeter/openmeter/customer" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" +) + +type Handler interface { + ListCustomers() ListCustomersHandler + CreateCustomer() CreateCustomerHandler + DeleteCustomer() DeleteCustomerHandler + GetCustomer() GetCustomerHandler + UpsertCustomer() UpsertCustomerHandler +} + +type handler struct { + resolveNamespace func(ctx context.Context) (string, error) + service customer.Service + options []httptransport.HandlerOption +} + +func New( + resolveNamespace func(ctx context.Context) (string, error), + service customer.Service, + options ...httptransport.HandlerOption, +) Handler { + return &handler{ + resolveNamespace: resolveNamespace, + service: service, + options: options, + } +} diff --git a/api/v3/handlers/customers/list.go b/api/v3/handlers/customers/list.go new file mode 100644 index 0000000000000000000000000000000000000000..21ed9a624766658ac30264c7c67c916383174668 --- /dev/null +++ b/api/v3/handlers/customers/list.go @@ -0,0 +1,154 @@ +package customers + +import ( + "context" + "fmt" + "net/http" + + "github.com/samber/lo" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/apierrors" + "github.com/openmeterio/openmeter/api/v3/filters" + "github.com/openmeterio/openmeter/api/v3/request" + "github.com/openmeterio/openmeter/api/v3/response" + "github.com/openmeterio/openmeter/openmeter/customer" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" + "github.com/openmeterio/openmeter/pkg/pagination" + "github.com/openmeterio/openmeter/pkg/sortx" +) + +type ( + ListCustomersRequest = customer.ListCustomersInput + ListCustomersResponse = response.PagePaginationResponse[api.BillingCustomer] + ListCustomersParams = api.ListCustomersParams + ListCustomersHandler httptransport.HandlerWithArgs[ListCustomersRequest, ListCustomersResponse, ListCustomersParams] +) + +func (h *handler) ListCustomers() ListCustomersHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, params ListCustomersParams) (ListCustomersRequest, error) { + ns, err := h.resolveNamespace(ctx) + if err != nil { + return ListCustomersRequest{}, err + } + + page := pagination.NewPage(1, 20) + if params.Page != nil { + page = pagination.NewPage( + lo.FromPtrOr(params.Page.Number, 1), + lo.FromPtrOr(params.Page.Size, 20), + ) + } + + if err := page.Validate(); err != nil { + return ListCustomersRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{ + apierrors.InvalidParameter{ + Field: "page", + Reason: err.Error(), + Source: apierrors.InvalidParamSourceQuery, + }, + }) + } + + var orderBy string + var order sortx.Order + if params.Sort != nil { + sort, err := request.ParseSortBy(*params.Sort) + if err != nil { + return ListCustomersRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{ + apierrors.InvalidParameter{ + Field: "sort", + Reason: err.Error(), + Source: apierrors.InvalidParamSourceQuery, + }, + }) + } + orderBy, err = FromAPICustomerSortField(ctx, sort.Field) + if err != nil { + return ListCustomersRequest{}, err + } + order = sort.Order.ToSortxOrder() + } + + req := ListCustomersRequest{ + Namespace: ns, + Page: page, + OrderBy: orderBy, + Order: order, + } + + if params.Filter != nil { + key, err := filters.FromAPIFilterString(params.Filter.Key) + if err != nil { + return ListCustomersRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{ + {Field: "filter[key]", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery}, + }) + } + req.Key = key + name, err := filters.FromAPIFilterString(params.Filter.Name) + if err != nil { + return ListCustomersRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{ + {Field: "filter[name]", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery}, + }) + } + req.Name = name + primaryEmail, err := filters.FromAPIFilterString(params.Filter.PrimaryEmail) + if err != nil { + return ListCustomersRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{ + {Field: "filter[primary_email]", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery}, + }) + } + req.PrimaryEmail = primaryEmail + usageAttributionSubjectKey, err := filters.FromAPIFilterString(params.Filter.UsageAttributionSubjectKey) + if err != nil { + return ListCustomersRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{ + {Field: "filter[usage_attribution_subject_key]", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery}, + }) + } + req.UsageAttributionSubjectKey = usageAttributionSubjectKey + planKey, err := filters.FromAPIFilterString(params.Filter.PlanKey) + if err != nil { + return ListCustomersRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{ + {Field: "filter[plan_key]", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery}, + }) + } + req.PlanKey = planKey + billingProfileID, err := filters.FromAPIFilterULID(params.Filter.BillingProfileId) + if err != nil { + return ListCustomersRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{ + {Field: "filter[billing_profile_id]", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery}, + }) + } + req.BillingProfileID = billingProfileID + } + + return req, nil + }, + func(ctx context.Context, request ListCustomersRequest) (ListCustomersResponse, error) { + resp, err := h.service.ListCustomers(ctx, request) + if err != nil { + return ListCustomersResponse{}, fmt.Errorf("failed to list customers: %w", err) + } + + customers := lo.Map(resp.Items, func(item customer.Customer, _ int) api.BillingCustomer { + return ToAPIBillingCustomer(item) + }) + + r := response.NewPagePaginationResponse(customers, response.PageMetaPage{ + Size: request.Page.PageSize, + Number: request.Page.PageNumber, + Total: lo.ToPtr(resp.TotalCount), + }) + + return r, nil + }, + commonhttp.JSONResponseEncoderWithStatus[ListCustomersResponse](http.StatusOK), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("list-customers"), + httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()), + )..., + ) +} diff --git a/api/v3/handlers/customers/upsert.go b/api/v3/handlers/customers/upsert.go new file mode 100644 index 0000000000000000000000000000000000000000..be7d8ace4634c226007acafa83075576093d1095 --- /dev/null +++ b/api/v3/handlers/customers/upsert.go @@ -0,0 +1,106 @@ +package customers + +import ( + "context" + "errors" + "net/http" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/apierrors" + "github.com/openmeterio/openmeter/api/v3/request" + "github.com/openmeterio/openmeter/openmeter/customer" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" +) + +type ( + UpsertCustomerRequest struct { + Namespace string + CustomerID string + CustomerMutate customer.CustomerMutate + } + UpsertCustomerParams = string + UpsertCustomerResponse = api.BillingCustomer + UpsertCustomerHandler httptransport.HandlerWithArgs[UpsertCustomerRequest, UpsertCustomerResponse, UpsertCustomerParams] +) + +// UpsertCustomer returns a handler for updating a customer. +func (h *handler) UpsertCustomer() UpsertCustomerHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, customerID UpsertCustomerParams) (UpsertCustomerRequest, error) { + body := api.UpsertCustomerRequest{} + if err := request.ParseBody(r, &body); err != nil { + return UpsertCustomerRequest{}, err + } + + ns, err := h.resolveNamespace(ctx) + if err != nil { + return UpsertCustomerRequest{}, err + } + + customerMutate, err := FromAPIUpsertCustomerRequest(body) + if err != nil { + return UpsertCustomerRequest{}, err + } + + req := UpsertCustomerRequest{ + Namespace: ns, + CustomerID: customerID, + // Key cannot be updated according to api.UpsertCustomerRequest. + // Therefore, at this point we don't have a key yet. It is ignored in this conversion. + CustomerMutate: customerMutate, + } + + return req, nil + }, + func(ctx context.Context, request UpsertCustomerRequest) (UpsertCustomerResponse, error) { + // FIXME: begin + // Normally, we should avoid putting business logic in handlers, and we should avoid such extra + // round-trips, since service.UpdateCustomer performs similar checks under the hood. + // However, in v3 customer API, key cannot be updated, and service.UpdateCustomer will wipe the key in the + // DB if it was nil in the input. Therefore, in order to avoid wiping the key, we need to get + // and "backfill" it from the existing customer entity. + cus, err := h.service.GetCustomer(ctx, customer.GetCustomerInput{ + CustomerID: &customer.CustomerID{ + ID: request.CustomerID, + Namespace: request.Namespace, + }, + }) + if err != nil { + return UpsertCustomerResponse{}, err + } + + if cus.IsDeleted() { + return UpsertCustomerResponse{}, + apierrors.NewGoneError( + ctx, + errors.New("customer is deleted"), + ) + } + + // Use the key from the just retrieved customer, as it is required by the UpdateCustomer service method. + request.CustomerMutate.Key = cus.Key + // FIXME: end + + updatedCustomer, err := h.service.UpdateCustomer(ctx, customer.UpdateCustomerInput{ + CustomerID: cus.GetID(), + CustomerMutate: request.CustomerMutate, + }) + if err != nil { + return UpsertCustomerResponse{}, err + } + + if updatedCustomer == nil { + return UpsertCustomerResponse{}, errors.New("failed to update customer") + } + + return ToAPIBillingCustomer(*updatedCustomer), nil + }, + commonhttp.JSONResponseEncoderWithStatus[UpsertCustomerResponse](http.StatusOK), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("upsert-customer"), + httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()), + )..., + ) +} diff --git a/api/v3/handlers/events/convert.go b/api/v3/handlers/events/convert.go new file mode 100644 index 0000000000000000000000000000000000000000..f52530bc37184ba8e36ae3559e733ce1268349b2 --- /dev/null +++ b/api/v3/handlers/events/convert.go @@ -0,0 +1,164 @@ +package events + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/oapi-codegen/nullable" + "github.com/samber/lo" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/apierrors" + "github.com/openmeterio/openmeter/api/v3/request" + "github.com/openmeterio/openmeter/openmeter/meterevent" + "github.com/openmeterio/openmeter/openmeter/streaming" + "github.com/openmeterio/openmeter/pkg/filter" + "github.com/openmeterio/openmeter/pkg/sortx" +) + +const cloudEventsSpecVersion = "1.0" + +// toAPIMeteringIngestedEvent converts a meterevent.Event to its API wire form. +func toAPIMeteringIngestedEvent(e meterevent.Event) (api.MeteringIngestedEvent, error) { + event := api.MeteringEvent{ + Id: e.ID, + Source: e.Source, + Specversion: cloudEventsSpecVersion, + Subject: e.Subject, + Type: e.Type, + Time: nullable.NewNullableWithValue[api.DateTime](e.Time), + } + + if e.Data != "" { + var data map[string]any + if err := json.Unmarshal([]byte(e.Data), &data); err != nil { + return api.MeteringIngestedEvent{}, fmt.Errorf("parse event data as json: %w", err) + } + event.Data = nullable.NewNullableWithValue(data) + event.Datacontenttype = nullable.NewNullableWithValue[api.MeteringEventDatacontenttype](api.MeteringEventDatacontenttype("application/json")) + } + + return api.MeteringIngestedEvent{ + Event: event, + Customer: toAPICustomerReference(e.CustomerID), + IngestedAt: e.IngestedAt, + StoredAt: e.StoredAt, + ValidationErrors: toAPIMeteringIngestedEventValidationErrors(e.ValidationErrors), + }, nil +} + +func toAPICustomerReference(id *string) *api.CustomerReference { + if id == nil || *id == "" { + return nil + } + return &api.CustomerReference{Id: *id} +} + +func toAPIMeteringIngestedEventValidationErrors(errs []error) *[]api.MeteringIngestedEventValidationError { + if len(errs) == 0 { + return nil + } + + result := lo.FilterMap(errs, func(err error, _ int) (api.MeteringIngestedEventValidationError, bool) { + if err == nil { + return api.MeteringIngestedEventValidationError{}, false + } + + return api.MeteringIngestedEventValidationError{ + Code: "validation_error", + Message: err.Error(), + }, true + }) + + if len(result) == 0 { + return nil + } + + return lo.ToPtr(result) +} + +// fromAPICustomerIDFilter maps the v3 customer_id filter to the backend filter, +// rejecting every operator that the underlying service cannot evaluate. Only +// `eq` and `oeq` are supported because ListEventsV2Params requires a concrete +// IN set. +func fromAPICustomerIDFilter(ctx context.Context, f *api.ULIDFieldFilter) (*filter.FilterString, error) { + if f == nil { + return nil, nil + } + + if f.Neq != nil { + err := errors.New("only eq and oeq operators are supported") + return nil, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{ + { + Field: "filter[customer_id]", + Reason: err.Error(), + Source: apierrors.InvalidParamSourceQuery, + }, + }) + } + + var values []string + if f.Eq != nil { + values = append(values, *f.Eq) + } + if len(f.Oeq) > 0 { + values = append(values, f.Oeq...) + } + + if len(values) == 0 { + return nil, nil + } + + return &filter.FilterString{In: &values}, nil +} + +func FromAPIEventSortField(ctx context.Context, field string) (streaming.EventSortField, error) { + switch field { + case "time": + return streaming.EventSortFieldTime, nil + case "ingested_at": + return streaming.EventSortFieldIngestedAt, nil + case "stored_at": + return streaming.EventSortFieldStoredAt, nil + default: + return "", apierrors.NewUnsupportedSortFieldError( + ctx, field, "time", "ingested_at", "stored_at", + ) + } +} + +// fromAPIEventSort resolves the public sort query into a backend sort field and direction. +func fromAPIEventSort(ctx context.Context, sort *api.SortQuery) (streaming.EventSortField, sortx.Order, error) { + if lo.FromPtr(sort) == "" { + return "", "", nil + } + + parsed, err := request.ParseSortBy(*sort) + if err != nil { + return "", "", apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{ + { + Field: "sort", + Reason: err.Error(), + Source: apierrors.InvalidParamSourceQuery, + }, + }) + } + + field, err := FromAPIEventSortField(ctx, parsed.Field) + if err != nil { + return "", "", err + } + + // If the caller did not supply an explicit asc/desc suffix, default to + // descending so `sort=time` behaves the same as omitting the parameter + // (most recent first). + order := parsed.Order.ToSortxOrder() + if len(strings.Fields(*sort)) == 1 { + order = sortx.OrderDesc + } + + return field, order, nil +} diff --git a/api/v3/handlers/events/handler.go b/api/v3/handlers/events/handler.go new file mode 100644 index 0000000000000000000000000000000000000000..f8f440f546204279831d5c506def5a02b9db58be --- /dev/null +++ b/api/v3/handlers/events/handler.go @@ -0,0 +1,35 @@ +package events + +import ( + "context" + + "github.com/openmeterio/openmeter/openmeter/ingest" + "github.com/openmeterio/openmeter/openmeter/meterevent" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" +) + +type Handler interface { + IngestEvents() IngestEventsHandler + ListMeteringEvents() ListMeteringEventsHandler +} + +type handler struct { + resolveNamespace func(ctx context.Context) (string, error) + service ingest.Service + metereventService meterevent.Service + options []httptransport.HandlerOption +} + +func New( + resolveNamespace func(ctx context.Context) (string, error), + service ingest.Service, + metereventService meterevent.Service, + options ...httptransport.HandlerOption, +) Handler { + return &handler{ + resolveNamespace: resolveNamespace, + service: service, + metereventService: metereventService, + options: options, + } +} diff --git a/api/v3/handlers/events/ingest.go b/api/v3/handlers/events/ingest.go new file mode 100644 index 0000000000000000000000000000000000000000..4c463cacd87612059afbe7ef82ead682ea9ab4a7 --- /dev/null +++ b/api/v3/handlers/events/ingest.go @@ -0,0 +1,106 @@ +package events + +import ( + "context" + "encoding/json" + "errors" + "mime" + "net/http" + + "github.com/cloudevents/sdk-go/v2/event" + + "github.com/openmeterio/openmeter/api" + "github.com/openmeterio/openmeter/api/v3/apierrors" + "github.com/openmeterio/openmeter/openmeter/ingest" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" +) + +type ( + IngestEventsRequest = ingest.IngestEventsRequest + IngestEventsResponse = *struct{} + IngestEventsHandler httptransport.Handler[ingest.IngestEventsRequest, IngestEventsResponse] +) + +func (h *handler) IngestEvents() IngestEventsHandler { + return httptransport.NewHandler( + func(ctx context.Context, r *http.Request) (ingest.IngestEventsRequest, error) { + req := ingest.IngestEventsRequest{} + + namespace, err := h.resolveNamespace(ctx) + if err != nil { + return req, err + } + + req.Namespace = namespace + + contentType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + if err != nil { + return req, apierrors.NewBadRequestError(ctx, err, nil) + } + + switch contentType { + case "application/json": + var apiRequest api.IngestEventsBody + + err := json.NewDecoder(r.Body).Decode(&apiRequest) + if err != nil { + return req, apierrors.NewBadRequestError(ctx, err, nil) + } + + // Try to parse as a single event + e, err := apiRequest.AsEvent() + if err == nil { + req.Events = []event.Event{e} + } else { + // Try to parse as a batch of events + e, err := apiRequest.AsIngestEventsBody1() + if err == nil { + req.Events = e + } + } + + // If we still don't have any events, return an error + if len(req.Events) == 0 { + return req, apierrors.NewBadRequestError(ctx, errors.New("no events found"), nil) + } + case "application/cloudevents+json": + var apiRequest api.IngestEventsApplicationCloudeventsPlusJSONRequestBody + + err := json.NewDecoder(r.Body).Decode(&apiRequest) + if err != nil { + return req, apierrors.NewBadRequestError(ctx, err, nil) + } + + req.Events = []event.Event{apiRequest} + case "application/cloudevents-batch+json": + var apiRequest api.IngestEventsApplicationCloudeventsBatchPlusJSONBody + + err := json.NewDecoder(r.Body).Decode(&apiRequest) + if err != nil { + return req, apierrors.NewBadRequestError(ctx, err, nil) + } + + req.Events = apiRequest + default: + return req, apierrors.NewBadRequestError(ctx, errors.New("invalid content type"), nil) + } + + return req, nil + }, + func(ctx context.Context, request ingest.IngestEventsRequest) (IngestEventsResponse, error) { + _, err := h.service.IngestEvents(ctx, request) + if err != nil { + return nil, err + } + + return nil, nil + }, + commonhttp.EmptyResponseEncoder[IngestEventsResponse](http.StatusAccepted), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("ingest-metering-events"), + httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()), + )..., + ) +} diff --git a/api/v3/handlers/events/list.go b/api/v3/handlers/events/list.go new file mode 100644 index 0000000000000000000000000000000000000000..4ac143d0b7851d14cc8a32c0b9872e7fb07690ba --- /dev/null +++ b/api/v3/handlers/events/list.go @@ -0,0 +1,220 @@ +package events + +import ( + "context" + "errors" + "fmt" + "net/http" + + "github.com/samber/lo" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/apierrors" + "github.com/openmeterio/openmeter/api/v3/filters" + "github.com/openmeterio/openmeter/api/v3/response" + "github.com/openmeterio/openmeter/openmeter/meterevent" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" + pagination "github.com/openmeterio/openmeter/pkg/pagination/v2" + "github.com/openmeterio/openmeter/pkg/slicesx" +) + +const defaultListMeteringEventsPageSize = 20 + +type ( + ListMeteringEventsRequest = meterevent.ListEventsV2Params + ListMeteringEventsResponse = response.CursorPaginationResponse[api.MeteringIngestedEvent] + ListMeteringEventsParams = api.ListMeteringEventsParams + ListMeteringEventsHandler httptransport.HandlerWithArgs[ListMeteringEventsRequest, ListMeteringEventsResponse, ListMeteringEventsParams] +) + +func (h *handler) ListMeteringEvents() ListMeteringEventsHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, params ListMeteringEventsParams) (ListMeteringEventsRequest, error) { + ns, err := h.resolveNamespace(ctx) + if err != nil { + return ListMeteringEventsRequest{}, err + } + + req := ListMeteringEventsRequest{ + Namespace: ns, + } + + pageSize := defaultListMeteringEventsPageSize + if params.Page != nil { + if params.Page.Before != nil { + return ListMeteringEventsRequest{}, apierrors.NewBadRequestError(ctx, errors.New("page[before] is not supported"), apierrors.InvalidParameters{ + { + Field: "page[before]", + Reason: "backward pagination is not supported", + Source: apierrors.InvalidParamSourceQuery, + }, + }) + } + + if params.Page.After != nil { + cursor, err := pagination.DecodeCursor(*params.Page.After) + if err != nil { + return ListMeteringEventsRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{ + { + Field: "page[after]", + Reason: err.Error(), + Source: apierrors.InvalidParamSourceQuery, + }, + }) + } + req.Cursor = cursor + } + + if params.Page.Size != nil { + pageSize = *params.Page.Size + } + } + + if pageSize < 1 || pageSize > meterevent.MaximumLimit { + return ListMeteringEventsRequest{}, apierrors.NewBadRequestError(ctx, fmt.Errorf("page[size] must be between 1 and %d", meterevent.MaximumLimit), apierrors.InvalidParameters{ + { + Field: "page[size]", + Reason: fmt.Sprintf("must be between 1 and %d", meterevent.MaximumLimit), + Source: apierrors.InvalidParamSourceQuery, + }, + }) + } + req.Limit = lo.ToPtr(pageSize) + + if params.Filter != nil { + if err := applyFilters(ctx, &req, params.Filter); err != nil { + return ListMeteringEventsRequest{}, err + } + } + + sortBy, sortOrder, err := fromAPIEventSort(ctx, params.Sort) + if err != nil { + return ListMeteringEventsRequest{}, err + } + req.SortBy = sortBy + req.SortOrder = sortOrder + + return req, nil + }, + func(ctx context.Context, req ListMeteringEventsRequest) (ListMeteringEventsResponse, error) { + result, err := h.metereventService.ListEventsV2(ctx, req) + if err != nil { + return ListMeteringEventsResponse{}, err + } + + items, err := slicesx.MapWithErr(result.Items, toAPIMeteringIngestedEvent) + if err != nil { + return ListMeteringEventsResponse{}, fmt.Errorf("convert events: %w", err) + } + + pageSize := lo.FromPtrOr(req.Limit, defaultListMeteringEventsPageSize) + resp := response.NewCursorPaginationResponse(result.Items, pageSize) + + return ListMeteringEventsResponse{ + Data: items, + Meta: resp.Meta, + }, nil + }, + commonhttp.JSONResponseEncoderWithStatus[ListMeteringEventsResponse](http.StatusOK), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("list-metering-events"), + httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()), + )..., + ) +} + +func applyFilters(ctx context.Context, req *ListMeteringEventsRequest, f *api.ListEventsParamsFilter) error { + id, err := filters.FromAPIFilterString(f.Id) + if err != nil { + return apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{ + { + Field: "filter[id]", + Reason: err.Error(), + Source: apierrors.InvalidParamSourceQuery, + }, + }) + } + req.ID = id + + source, err := filters.FromAPIFilterString(f.Source) + if err != nil { + return apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{ + { + Field: "filter[source]", + Reason: err.Error(), + Source: apierrors.InvalidParamSourceQuery, + }, + }) + } + req.Source = source + + subject, err := filters.FromAPIFilterString(f.Subject) + if err != nil { + return apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{ + { + Field: "filter[subject]", + Reason: err.Error(), + Source: apierrors.InvalidParamSourceQuery, + }, + }) + } + req.Subject = subject + + typeFilter, err := filters.FromAPIFilterString(f.Type) + if err != nil { + return apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{ + { + Field: "filter[type]", + Reason: err.Error(), + Source: apierrors.InvalidParamSourceQuery, + }, + }) + } + req.Type = typeFilter + + timeFilter, err := filters.FromAPIFilterDateTime(f.Time) + if err != nil { + return apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{ + { + Field: "filter[time]", + Reason: err.Error(), + Source: apierrors.InvalidParamSourceQuery, + }, + }) + } + req.Time = timeFilter + + ingestedAt, err := filters.FromAPIFilterDateTime(f.IngestedAt) + if err != nil { + return apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{ + { + Field: "filter[ingested_at]", + Reason: err.Error(), + Source: apierrors.InvalidParamSourceQuery, + }, + }) + } + req.IngestedAt = ingestedAt + + storedAt, err := filters.FromAPIFilterDateTime(f.StoredAt) + if err != nil { + return apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{ + { + Field: "filter[stored_at]", + Reason: err.Error(), + Source: apierrors.InvalidParamSourceQuery, + }, + }) + } + req.StoredAt = storedAt + + customerID, err := fromAPICustomerIDFilter(ctx, f.CustomerId) + if err != nil { + return err + } + req.CustomerID = customerID + + return nil +} diff --git a/api/v3/handlers/events/list_test.go b/api/v3/handlers/events/list_test.go new file mode 100644 index 0000000000000000000000000000000000000000..bfb226eea59f070f0a1fefde5166f9a00b10d7ab --- /dev/null +++ b/api/v3/handlers/events/list_test.go @@ -0,0 +1,110 @@ +package events + +import ( + "errors" + "testing" + + "github.com/samber/lo" + "github.com/stretchr/testify/require" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/apierrors" + "github.com/openmeterio/openmeter/openmeter/streaming" + "github.com/openmeterio/openmeter/pkg/sortx" +) + +func TestFromAPICustomerIDFilter(t *testing.T) { + ctx := t.Context() + + t.Run("nil filter returns nil", func(t *testing.T) { + out, err := fromAPICustomerIDFilter(ctx, nil) + require.NoError(t, err) + require.Nil(t, out) + }) + + t.Run("eq maps to In with one element", func(t *testing.T) { + out, err := fromAPICustomerIDFilter(ctx, &api.ULIDFieldFilter{Eq: lo.ToPtr("01G65Z755AFWAKHE12NY0CQ9FH")}) + require.NoError(t, err) + require.NotNil(t, out) + require.NotNil(t, out.In) + require.Equal(t, []string{"01G65Z755AFWAKHE12NY0CQ9FH"}, *out.In) + }) + + t.Run("oeq maps to In", func(t *testing.T) { + out, err := fromAPICustomerIDFilter(ctx, &api.ULIDFieldFilter{Oeq: []string{"a", "b"}}) + require.NoError(t, err) + require.NotNil(t, out) + require.Equal(t, []string{"a", "b"}, *out.In) + }) + + t.Run("neq is rejected", func(t *testing.T) { + _, err := fromAPICustomerIDFilter(ctx, &api.ULIDFieldFilter{Neq: lo.ToPtr("x")}) + require.Error(t, err) + assertBadRequestField(t, err, "filter[customer_id]") + }) +} + +func TestFromAPIEventSort(t *testing.T) { + ctx := t.Context() + + t.Run("nil returns empty values", func(t *testing.T) { + field, order, err := fromAPIEventSort(ctx, nil) + require.NoError(t, err) + require.Equal(t, streaming.EventSortField(""), field) + require.Equal(t, sortx.Order(""), order) + }) + + t.Run("time defaults to desc when no suffix", func(t *testing.T) { + sort := api.SortQuery("time") + field, order, err := fromAPIEventSort(ctx, &sort) + require.NoError(t, err) + require.Equal(t, streaming.EventSortFieldTime, field) + require.Equal(t, sortx.OrderDesc, order) + }) + + t.Run("ingested_at desc", func(t *testing.T) { + sort := api.SortQuery("ingested_at desc") + field, order, err := fromAPIEventSort(ctx, &sort) + require.NoError(t, err) + require.Equal(t, streaming.EventSortFieldIngestedAt, field) + require.Equal(t, sortx.OrderDesc, order) + }) + + t.Run("stored_at defaults to desc when no suffix", func(t *testing.T) { + sort := api.SortQuery("stored_at") + field, order, err := fromAPIEventSort(ctx, &sort) + require.NoError(t, err) + require.Equal(t, streaming.EventSortFieldStoredAt, field) + require.Equal(t, sortx.OrderDesc, order) + }) + + t.Run("time asc suffix is honored", func(t *testing.T) { + sort := api.SortQuery("time asc") + field, order, err := fromAPIEventSort(ctx, &sort) + require.NoError(t, err) + require.Equal(t, streaming.EventSortFieldTime, field) + require.Equal(t, sortx.OrderAsc, order) + }) + + t.Run("unknown field is rejected", func(t *testing.T) { + sort := api.SortQuery("created_at") + _, _, err := fromAPIEventSort(ctx, &sort) + require.Error(t, err) + assertBadRequestField(t, err, "sort") + }) + + t.Run("malformed input is rejected", func(t *testing.T) { + sort := api.SortQuery("time bogus extra") + _, _, err := fromAPIEventSort(ctx, &sort) + require.Error(t, err) + assertBadRequestField(t, err, "sort") + }) +} + +func assertBadRequestField(t *testing.T, err error, field string) { + t.Helper() + var apiErr *apierrors.BaseAPIError + require.True(t, errors.As(err, &apiErr), "expected *apierrors.BaseAPIError, got %T", err) + require.Len(t, apiErr.InvalidParameters, 1) + require.Equal(t, field, apiErr.InvalidParameters[0].Field) +} diff --git a/api/v3/handlers/featurecost/convert.go b/api/v3/handlers/featurecost/convert.go new file mode 100644 index 0000000000000000000000000000000000000000..40838e25dfaa24f7912814146aab56e96274db90 --- /dev/null +++ b/api/v3/handlers/featurecost/convert.go @@ -0,0 +1,69 @@ +package featurecost + +import ( + "github.com/oapi-codegen/nullable" + "github.com/samber/lo" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/handlers/meters/query" + "github.com/openmeterio/openmeter/openmeter/cost" +) + +// ToAPIFeatureCostQueryResult converts a domain CostQueryResult to the v3 API response. +func ToAPIFeatureCostQueryResult(result *cost.CostQueryResult, body api.MeterQueryRequest) api.FeatureCostQueryResult { + if result == nil { + return api.FeatureCostQueryResult{ + From: body.From, + To: body.To, + Data: []api.FeatureCostQueryRow{}, + } + } + return api.FeatureCostQueryResult{ + From: body.From, + To: body.To, + Data: lo.Map(result.Rows, func(row cost.CostQueryRow, _ int) api.FeatureCostQueryRow { + return toAPIFeatureCostQueryRow(row) + }), + } +} + +func toAPIFeatureCostQueryRow(row cost.CostQueryRow) api.FeatureCostQueryRow { + dimensions := make(map[string]string) + + if row.Subject != nil { + dimensions[query.DimensionSubject] = *row.Subject + } + + if row.CustomerID != nil { + dimensions[query.DimensionCustomerID] = *row.CustomerID + } + + for key, value := range row.GroupBy { + if key == query.DimensionSubject || key == query.DimensionCustomerID { + continue + } + if value != nil { + dimensions[key] = *value + } + } + + apiRow := api.FeatureCostQueryRow{ + Usage: row.Usage.String(), + Currency: api.CurrencyCode(row.Currency), + From: row.WindowStart, + To: row.WindowEnd, + Dimensions: dimensions, + } + + if row.Cost != nil { + apiRow.Cost = nullable.NewNullableWithValue(row.Cost.String()) + } else { + apiRow.Cost = nullable.NewNullNullable[api.Numeric]() + } + + if row.Detail != "" { + apiRow.Detail = lo.ToPtr(row.Detail) + } + + return apiRow +} diff --git a/api/v3/handlers/featurecost/handler.go b/api/v3/handlers/featurecost/handler.go new file mode 100644 index 0000000000000000000000000000000000000000..cfe2249e10151efa8f25901f305769c32e112cb4 --- /dev/null +++ b/api/v3/handlers/featurecost/handler.go @@ -0,0 +1,42 @@ +package featurecost + +import ( + "context" + + "github.com/openmeterio/openmeter/openmeter/cost" + "github.com/openmeterio/openmeter/openmeter/customer" + "github.com/openmeterio/openmeter/openmeter/meter" + "github.com/openmeterio/openmeter/openmeter/productcatalog/feature" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" +) + +type Handler interface { + QueryFeatureCost() QueryFeatureCostHandler +} + +type handler struct { + resolveNamespace func(ctx context.Context) (string, error) + costService cost.Service + featureConnector feature.FeatureConnector + meterService meter.Service + customerService customer.Service + options []httptransport.HandlerOption +} + +func New( + resolveNamespace func(ctx context.Context) (string, error), + costService cost.Service, + featureConnector feature.FeatureConnector, + meterService meter.Service, + customerService customer.Service, + options ...httptransport.HandlerOption, +) Handler { + return &handler{ + resolveNamespace: resolveNamespace, + costService: costService, + featureConnector: featureConnector, + meterService: meterService, + customerService: customerService, + options: options, + } +} diff --git a/api/v3/handlers/featurecost/query.go b/api/v3/handlers/featurecost/query.go new file mode 100644 index 0000000000000000000000000000000000000000..43b66776e66d61f71f8718c508ca8aa0d687d0e5 --- /dev/null +++ b/api/v3/handlers/featurecost/query.go @@ -0,0 +1,97 @@ +package featurecost + +import ( + "context" + "fmt" + "net/http" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/apierrors" + "github.com/openmeterio/openmeter/api/v3/handlers/meters/query" + "github.com/openmeterio/openmeter/api/v3/request" + "github.com/openmeterio/openmeter/openmeter/cost" + "github.com/openmeterio/openmeter/openmeter/meter" + "github.com/openmeterio/openmeter/openmeter/productcatalog/feature" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" + "github.com/openmeterio/openmeter/pkg/models" +) + +type ( + QueryFeatureCostRequest struct { + Namespace string + FeatureID string + Body api.MeterQueryRequest + } + QueryFeatureCostResponse = api.FeatureCostQueryResult + QueryFeatureCostParams = string + QueryFeatureCostHandler httptransport.HandlerWithArgs[QueryFeatureCostRequest, QueryFeatureCostResponse, QueryFeatureCostParams] +) + +func (h *handler) QueryFeatureCost() QueryFeatureCostHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, featureID QueryFeatureCostParams) (QueryFeatureCostRequest, error) { + ns, err := h.resolveNamespace(ctx) + if err != nil { + return QueryFeatureCostRequest{}, err + } + + var body api.MeterQueryRequest + if err := request.ParseOptionalBody(r, &body); err != nil { + return QueryFeatureCostRequest{}, err + } + + return QueryFeatureCostRequest{ + Namespace: ns, + FeatureID: featureID, + Body: body, + }, nil + }, + func(ctx context.Context, req QueryFeatureCostRequest) (QueryFeatureCostResponse, error) { + // Get the feature to find its meter + feat, err := h.featureConnector.GetFeature(ctx, req.Namespace, req.FeatureID, feature.IncludeArchivedFeatureFalse) + if err != nil { + return QueryFeatureCostResponse{}, err + } + + if feat.MeterID == nil { + return QueryFeatureCostResponse{}, models.NewGenericValidationError( + fmt.Errorf("feature %s has no meter associated", feat.Key), + ) + } + + // Get the meter for query param validation + m, err := h.meterService.GetMeterByIDOrSlug(ctx, meter.GetMeterInput{ + Namespace: req.Namespace, + IDOrSlug: *feat.MeterID, + }) + if err != nil { + return QueryFeatureCostResponse{}, fmt.Errorf("failed to get meter: %w", err) + } + + // Build streaming query params using shared logic + params, err := query.BuildQueryParams(ctx, m, req.Body, query.NewCustomerResolver(h.customerService)) + if err != nil { + return QueryFeatureCostResponse{}, err + } + + // Query feature cost + result, err := h.costService.QueryFeatureCost(ctx, cost.QueryFeatureCostInput{ + Namespace: req.Namespace, + FeatureID: feat.ID, + QueryParams: params, + }) + if err != nil { + return QueryFeatureCostResponse{}, err + } + + return ToAPIFeatureCostQueryResult(result, req.Body), nil + }, + commonhttp.JSONResponseEncoderWithStatus[QueryFeatureCostResponse](http.StatusOK), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("query-feature-cost"), + httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()), + )..., + ) +} diff --git a/api/v3/handlers/features/convert.go b/api/v3/handlers/features/convert.go new file mode 100644 index 0000000000000000000000000000000000000000..ad3f748b034a8e17e7dc9c36771d805dbaaf7868 --- /dev/null +++ b/api/v3/handlers/features/convert.go @@ -0,0 +1,352 @@ +package features + +import ( + "context" + "fmt" + + "github.com/alpacahq/alpacadecimal" + "github.com/oapi-codegen/nullable" + "github.com/samber/lo" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/apierrors" + "github.com/openmeterio/openmeter/api/v3/labels" + "github.com/openmeterio/openmeter/api/v3/request" + "github.com/openmeterio/openmeter/openmeter/llmcost" + "github.com/openmeterio/openmeter/openmeter/productcatalog/feature" + "github.com/openmeterio/openmeter/pkg/filter" +) + +func FromAPIFeatureSortField(ctx context.Context, field string) (feature.FeatureOrderBy, error) { + switch field { + case "key": + return feature.FeatureOrderByKey, nil + case "name": + return feature.FeatureOrderByName, nil + case "created_at": + return feature.FeatureOrderByCreatedAt, nil + case "updated_at": + return feature.FeatureOrderByUpdatedAt, nil + default: + return "", apierrors.NewUnsupportedSortFieldError( + ctx, field, "key", "name", "created_at", "updated_at", + ) + } +} + +func convertFeatureToAPI(f feature.Feature) (api.Feature, error) { + resp := api.Feature{ + Id: f.ID, + Key: f.Key, + Name: f.Name, + Description: f.Description, + Labels: labels.FromMetadata(f.Metadata), + CreatedAt: f.CreatedAt, + UpdatedAt: f.UpdatedAt, + DeletedAt: f.ArchivedAt, + } + + if f.MeterID != nil { + resp.Meter = &api.FeatureMeterReference{ + Id: *f.MeterID, + } + + if len(f.MeterGroupByFilters) > 0 { + filters := convertFiltersToAPI(f.MeterGroupByFilters) + resp.Meter.Filters = &filters + } + } + + if f.UnitCost != nil { + apiUnitCost, err := convertUnitCostToAPI(f.UnitCost) + if err != nil { + return api.Feature{}, fmt.Errorf("failed to convert unit cost: %w", err) + } + resp.UnitCost = &apiUnitCost + } + + return resp, nil +} + +func convertCreateRequestToDomain(ns string, body api.CreateFeatureRequest, meterID *string) (feature.CreateFeatureInputs, error) { + metadata, err := labels.ToMetadata(body.Labels) + if err != nil { + return feature.CreateFeatureInputs{}, fmt.Errorf("failed to convert labels: %w", err) + } + + inputs := feature.CreateFeatureInputs{ + Namespace: ns, + Name: body.Name, + Description: body.Description, + Key: body.Key, + MeterID: meterID, + Metadata: metadata, + } + + if body.Meter != nil { + if body.Meter.Filters != nil { + inputs.MeterGroupByFilters = convertFiltersFromAPI(*body.Meter.Filters) + } + } + + if body.UnitCost != nil { + unitCost, err := convertUnitCostFromAPI(body.UnitCost) + if err != nil { + return feature.CreateFeatureInputs{}, fmt.Errorf("invalid unit cost: %w", err) + } + inputs.UnitCost = unitCost + } + + return inputs, nil +} + +func convertUpdateRequestToDomain(ns string, featureID string, body api.UpdateFeatureRequest) (feature.UpdateFeatureInputs, error) { + input := feature.UpdateFeatureInputs{ + Namespace: ns, + ID: featureID, + } + + if body.UnitCost.IsNull() { + input.UnitCost = nullable.NewNullNullable[feature.UnitCost]() + } else if body.UnitCost.IsSpecified() { + v, err := body.UnitCost.Get() + if err != nil { + return feature.UpdateFeatureInputs{}, fmt.Errorf("invalid unit cost: %w", err) + } + unitCost, err := convertUnitCostFromAPI(&v) + if err != nil { + return feature.UpdateFeatureInputs{}, fmt.Errorf("invalid unit cost: %w", err) + } + input.UnitCost = nullable.NewNullableWithValue(*unitCost) + } + + return input, nil +} + +func convertUnitCostToAPI(u *feature.UnitCost) (api.BillingFeatureUnitCost, error) { + var out api.BillingFeatureUnitCost + + switch u.Type { + case feature.UnitCostTypeManual: + if err := out.FromBillingFeatureManualUnitCost(api.BillingFeatureManualUnitCost{ + Amount: u.Manual.Amount.String(), + }); err != nil { + return out, fmt.Errorf("failed to convert manual unit cost: %w", err) + } + case feature.UnitCostTypeLLM: + llmCost := api.BillingFeatureLLMUnitCost{} + if u.LLM.ProviderProperty != "" { + llmCost.ProviderProperty = lo.ToPtr(u.LLM.ProviderProperty) + } + if u.LLM.Provider != "" { + llmCost.Provider = lo.ToPtr(u.LLM.Provider) + } + if u.LLM.ModelProperty != "" { + llmCost.ModelProperty = lo.ToPtr(u.LLM.ModelProperty) + } + if u.LLM.Model != "" { + llmCost.Model = lo.ToPtr(u.LLM.Model) + } + if u.LLM.TokenTypeProperty != "" { + llmCost.TokenTypeProperty = lo.ToPtr(u.LLM.TokenTypeProperty) + } + if u.LLM.TokenType != "" { + llmCost.TokenType = lo.ToPtr(api.BillingFeatureLLMTokenType(u.LLM.TokenType)) + } + if err := out.FromBillingFeatureLLMUnitCost(llmCost); err != nil { + return out, fmt.Errorf("failed to convert LLM unit cost: %w", err) + } + default: + return out, fmt.Errorf("unknown unit cost type: %s", u.Type) + } + + return out, nil +} + +func convertUnitCostFromAPI(u *api.BillingFeatureUnitCost) (*feature.UnitCost, error) { + discriminator, err := u.Discriminator() + if err != nil { + return nil, fmt.Errorf("failed to determine unit cost type: %w", err) + } + + switch discriminator { + case "manual": + manual, err := u.AsBillingFeatureManualUnitCost() + if err != nil { + return nil, fmt.Errorf("failed to parse manual unit cost: %w", err) + } + + amount, err := alpacadecimal.NewFromString(manual.Amount) + if err != nil { + return nil, fmt.Errorf("invalid manual unit cost amount %q: %w", manual.Amount, err) + } + + return &feature.UnitCost{ + Type: feature.UnitCostTypeManual, + Manual: &feature.ManualUnitCost{ + Amount: amount, + }, + }, nil + case "llm": + llm, err := u.AsBillingFeatureLLMUnitCost() + if err != nil { + return nil, fmt.Errorf("failed to parse LLM unit cost: %w", err) + } + + return &feature.UnitCost{ + Type: feature.UnitCostTypeLLM, + LLM: &feature.LLMUnitCost{ + ProviderProperty: lo.FromPtrOr(llm.ProviderProperty, ""), + Provider: lo.FromPtrOr(llm.Provider, ""), + ModelProperty: lo.FromPtrOr(llm.ModelProperty, ""), + Model: lo.FromPtrOr(llm.Model, ""), + TokenTypeProperty: lo.FromPtrOr(llm.TokenTypeProperty, ""), + TokenType: string(lo.FromPtrOr(llm.TokenType, "")), + }, + }, nil + default: + return nil, fmt.Errorf("unknown unit cost type: %s", discriminator) + } +} + +func enrichFeatureResponseWithPricing(resp *api.Feature, pricing *llmcost.ModelPricing) { + if resp.UnitCost == nil || pricing == nil { + return + } + + disc, err := resp.UnitCost.Discriminator() + if err != nil || disc != "llm" { + return + } + + llmCost, err := resp.UnitCost.AsBillingFeatureLLMUnitCost() + if err != nil { + return + } + + apiPricing := api.BillingFeatureLLMUnitCostPricing{ + InputPerToken: pricing.InputPerToken.String(), + OutputPerToken: pricing.OutputPerToken.String(), + } + + if pricing.CacheReadPerToken != nil { + v := pricing.CacheReadPerToken.String() + apiPricing.CacheReadPerToken = &v + } + + if pricing.CacheWritePerToken != nil { + v := pricing.CacheWritePerToken.String() + apiPricing.CacheWritePerToken = &v + } + + if pricing.ReasoningPerToken != nil { + v := pricing.ReasoningPerToken.String() + apiPricing.ReasoningPerToken = &v + } + + llmCost.Pricing = &apiPricing + _ = resp.UnitCost.FromBillingFeatureLLMUnitCost(llmCost) +} + +// resolveLLMPricing resolves LLM pricing for a feature from the LLM cost database. +func resolveLLMPricing(ctx context.Context, svc llmcost.Service, feat *feature.Feature) *llmcost.ModelPricing { + if feat.UnitCost == nil || feat.UnitCost.LLM == nil { + return nil + } + + llmConf := feat.UnitCost.LLM + + provider := llmConf.Provider + if provider == "" { + provider = extractEqFilterValue(feat.MeterGroupByFilters, llmConf.ProviderProperty) + } + if provider == "" { + return nil + } + + model := llmConf.Model + if model == "" { + model = extractEqFilterValue(feat.MeterGroupByFilters, llmConf.ModelProperty) + } + if model == "" { + return nil + } + + price, err := svc.ResolvePrice(ctx, llmcost.ResolvePriceInput{ + Namespace: feat.Namespace, + Provider: llmcost.Provider(provider), + ModelID: model, + }) + if err != nil { + return nil + } + + return &price.Pricing +} + +func extractEqFilterValue(filters feature.MeterGroupByFilters, key string) string { + if filters == nil { + return "" + } + + f, ok := filters[key] + if !ok || f.Eq == nil { + return "" + } + + return *f.Eq +} + +func convertFiltersFromAPI(apiFilters map[string]api.QueryFilterStringMapItem) feature.MeterGroupByFilters { + result := make(feature.MeterGroupByFilters, len(apiFilters)) + for k, v := range apiFilters { + result[k] = request.ConvertQueryFilterStringMapItem(v) + } + return result +} + +func convertFiltersToAPI(filters feature.MeterGroupByFilters) map[string]api.QueryFilterStringMapItem { + result := make(map[string]api.QueryFilterStringMapItem, len(filters)) + for k, v := range filters { + result[k] = convertFilterStringToAPIMapItem(v) + } + return result +} + +func convertFilterStringToAPIMapItem(f filter.FilterString) api.QueryFilterStringMapItem { + return api.QueryFilterStringMapItem{ + Exists: f.Exists, + Eq: f.Eq, + Neq: f.Ne, + In: f.In, + Nin: f.Nin, + Contains: filter.ReverseContainsPattern(f.Like), + Ncontains: filter.ReverseContainsPattern(f.Nlike), + And: convertFilterStringListToAPI(f.And), + Or: convertFilterStringListToAPI(f.Or), + } +} + +func convertFilterStringListToAPI(filters *[]filter.FilterString) *[]api.QueryFilterString { + if filters == nil { + return nil + } + result := make([]api.QueryFilterString, len(*filters)) + for i, f := range *filters { + result[i] = convertFilterStringToAPIQueryFilter(f) + } + return &result +} + +func convertFilterStringToAPIQueryFilter(f filter.FilterString) api.QueryFilterString { + return api.QueryFilterString{ + Eq: f.Eq, + Neq: f.Ne, + In: f.In, + Nin: f.Nin, + Contains: filter.ReverseContainsPattern(f.Like), + Ncontains: filter.ReverseContainsPattern(f.Nlike), + And: convertFilterStringListToAPI(f.And), + Or: convertFilterStringListToAPI(f.Or), + } +} diff --git a/api/v3/handlers/features/convert_test.go b/api/v3/handlers/features/convert_test.go new file mode 100644 index 0000000000000000000000000000000000000000..19d9b2d5bc19ad3ba4ac2f04a08cd96369693620 --- /dev/null +++ b/api/v3/handlers/features/convert_test.go @@ -0,0 +1,574 @@ +package features + +import ( + "testing" + "time" + + "github.com/alpacahq/alpacadecimal" + "github.com/oapi-codegen/nullable" + "github.com/samber/lo" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/labels" + "github.com/openmeterio/openmeter/openmeter/llmcost" + "github.com/openmeterio/openmeter/openmeter/productcatalog/feature" + "github.com/openmeterio/openmeter/pkg/filter" +) + +func TestConvertUnitCostToAPI(t *testing.T) { + t.Run("manual unit cost", func(t *testing.T) { + uc := &feature.UnitCost{ + Type: feature.UnitCostTypeManual, + Manual: &feature.ManualUnitCost{ + Amount: alpacadecimal.NewFromFloat(0.005), + }, + } + + result, err := convertUnitCostToAPI(uc) + require.NoError(t, err) + + disc, err := result.Discriminator() + require.NoError(t, err) + assert.Equal(t, "manual", disc) + + manual, err := result.AsBillingFeatureManualUnitCost() + require.NoError(t, err) + assert.Equal(t, api.Numeric("0.005"), manual.Amount) + }) + + t.Run("llm unit cost with properties", func(t *testing.T) { + uc := &feature.UnitCost{ + Type: feature.UnitCostTypeLLM, + LLM: &feature.LLMUnitCost{ + ProviderProperty: "provider", + ModelProperty: "model", + TokenTypeProperty: "type", + }, + } + + result, err := convertUnitCostToAPI(uc) + require.NoError(t, err) + + disc, err := result.Discriminator() + require.NoError(t, err) + assert.Equal(t, "llm", disc) + + llm, err := result.AsBillingFeatureLLMUnitCost() + require.NoError(t, err) + assert.Equal(t, lo.ToPtr("provider"), llm.ProviderProperty) + assert.Equal(t, lo.ToPtr("model"), llm.ModelProperty) + assert.Equal(t, lo.ToPtr("type"), llm.TokenTypeProperty) + assert.Nil(t, llm.Provider) + assert.Nil(t, llm.Model) + assert.Nil(t, llm.TokenType) + }) + + t.Run("llm unit cost with static values", func(t *testing.T) { + uc := &feature.UnitCost{ + Type: feature.UnitCostTypeLLM, + LLM: &feature.LLMUnitCost{ + Provider: "openai", + Model: "gpt-4", + TokenType: "input", + }, + } + + result, err := convertUnitCostToAPI(uc) + require.NoError(t, err) + + llm, err := result.AsBillingFeatureLLMUnitCost() + require.NoError(t, err) + assert.Equal(t, lo.ToPtr("openai"), llm.Provider) + assert.Equal(t, lo.ToPtr("gpt-4"), llm.Model) + assert.Equal(t, lo.ToPtr(api.BillingFeatureLLMTokenTypeInput), llm.TokenType) + assert.Nil(t, llm.ProviderProperty) + assert.Nil(t, llm.ModelProperty) + assert.Nil(t, llm.TokenTypeProperty) + }) + + t.Run("unknown type returns error", func(t *testing.T) { + uc := &feature.UnitCost{Type: "unknown"} + _, err := convertUnitCostToAPI(uc) + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown unit cost type") + }) +} + +func TestConvertUnitCostFromAPI(t *testing.T) { + t.Run("manual unit cost", func(t *testing.T) { + var apiUC api.BillingFeatureUnitCost + err := apiUC.FromBillingFeatureManualUnitCost(api.BillingFeatureManualUnitCost{ + Amount: "0.123", + }) + require.NoError(t, err) + + result, err := convertUnitCostFromAPI(&apiUC) + require.NoError(t, err) + assert.Equal(t, feature.UnitCostTypeManual, result.Type) + assert.NotNil(t, result.Manual) + assert.Equal(t, "0.123", result.Manual.Amount.String()) + assert.Nil(t, result.LLM) + }) + + t.Run("llm unit cost with properties", func(t *testing.T) { + var apiUC api.BillingFeatureUnitCost + err := apiUC.FromBillingFeatureLLMUnitCost(api.BillingFeatureLLMUnitCost{ + ProviderProperty: lo.ToPtr("provider"), + ModelProperty: lo.ToPtr("model"), + TokenTypeProperty: lo.ToPtr("type"), + }) + require.NoError(t, err) + + result, err := convertUnitCostFromAPI(&apiUC) + require.NoError(t, err) + assert.Equal(t, feature.UnitCostTypeLLM, result.Type) + assert.NotNil(t, result.LLM) + assert.Equal(t, "provider", result.LLM.ProviderProperty) + assert.Equal(t, "model", result.LLM.ModelProperty) + assert.Equal(t, "type", result.LLM.TokenTypeProperty) + assert.Empty(t, result.LLM.Provider) + assert.Empty(t, result.LLM.Model) + assert.Empty(t, result.LLM.TokenType) + }) + + t.Run("llm unit cost with static values", func(t *testing.T) { + var apiUC api.BillingFeatureUnitCost + err := apiUC.FromBillingFeatureLLMUnitCost(api.BillingFeatureLLMUnitCost{ + Provider: lo.ToPtr("anthropic"), + Model: lo.ToPtr("claude-3-5-sonnet"), + TokenType: lo.ToPtr(api.BillingFeatureLLMTokenTypeOutput), + }) + require.NoError(t, err) + + result, err := convertUnitCostFromAPI(&apiUC) + require.NoError(t, err) + assert.Equal(t, feature.UnitCostTypeLLM, result.Type) + assert.Equal(t, "anthropic", result.LLM.Provider) + assert.Equal(t, "claude-3-5-sonnet", result.LLM.Model) + assert.Equal(t, "output", result.LLM.TokenType) + }) +} + +func TestConvertUnitCostRoundTrip(t *testing.T) { + t.Run("manual round trip", func(t *testing.T) { + original := &feature.UnitCost{ + Type: feature.UnitCostTypeManual, + Manual: &feature.ManualUnitCost{ + Amount: alpacadecimal.NewFromFloat(1.50), + }, + } + + apiUC, err := convertUnitCostToAPI(original) + require.NoError(t, err) + + result, err := convertUnitCostFromAPI(&apiUC) + require.NoError(t, err) + + assert.Equal(t, original.Type, result.Type) + assert.True(t, original.Manual.Amount.Equal(result.Manual.Amount)) + }) + + t.Run("llm round trip", func(t *testing.T) { + original := &feature.UnitCost{ + Type: feature.UnitCostTypeLLM, + LLM: &feature.LLMUnitCost{ + ProviderProperty: "provider", + ModelProperty: "model", + TokenTypeProperty: "type", + }, + } + + apiUC, err := convertUnitCostToAPI(original) + require.NoError(t, err) + + result, err := convertUnitCostFromAPI(&apiUC) + require.NoError(t, err) + + assert.Equal(t, original.Type, result.Type) + assert.Equal(t, original.LLM, result.LLM) + }) +} + +func TestConvertFeatureToAPI(t *testing.T) { + now := time.Now().UTC().Truncate(time.Millisecond) + + t.Run("feature without meter or unit cost", func(t *testing.T) { + f := feature.Feature{ + Namespace: "default", + ID: "feat-1", + Name: "My Feature", + Key: "my_feature", + Metadata: map[string]string{"env": "test"}, + CreatedAt: now, + UpdatedAt: now, + } + + result, err := convertFeatureToAPI(f) + require.NoError(t, err) + assert.Equal(t, "feat-1", result.Id) + assert.Equal(t, api.ResourceKey("my_feature"), result.Key) + assert.Equal(t, "My Feature", result.Name) + assert.Nil(t, result.Meter) + assert.Nil(t, result.UnitCost) + assert.Nil(t, result.DeletedAt) + require.NotNil(t, result.Labels) + assert.Equal(t, "test", (*result.Labels)["env"]) + }) + + t.Run("feature with meter and filters", func(t *testing.T) { + f := feature.Feature{ + Namespace: "default", + ID: "feat-2", + Name: "Token Feature", + Key: "tokens", + MeterID: lo.ToPtr("01ARZ3NDEKTSV4RRFFQ69G5FAV"), + MeterGroupByFilters: feature.MeterGroupByFilters{ + "provider": {Eq: lo.ToPtr("openai")}, + }, + CreatedAt: now, + UpdatedAt: now, + } + + result, err := convertFeatureToAPI(f) + require.NoError(t, err) + require.NotNil(t, result.Meter) + assert.Equal(t, api.ULID("01ARZ3NDEKTSV4RRFFQ69G5FAV"), result.Meter.Id) + require.NotNil(t, result.Meter.Filters) + filterMap := *result.Meter.Filters + assert.Equal(t, lo.ToPtr("openai"), filterMap["provider"].Eq) + }) + + t.Run("feature with manual unit cost", func(t *testing.T) { + f := feature.Feature{ + Namespace: "default", + ID: "feat-3", + Name: "API Calls", + Key: "api_calls", + MeterID: lo.ToPtr("api_requests"), + UnitCost: &feature.UnitCost{ + Type: feature.UnitCostTypeManual, + Manual: &feature.ManualUnitCost{ + Amount: alpacadecimal.NewFromFloat(0.01), + }, + }, + CreatedAt: now, + UpdatedAt: now, + } + + result, err := convertFeatureToAPI(f) + require.NoError(t, err) + require.NotNil(t, result.UnitCost) + + disc, err := result.UnitCost.Discriminator() + require.NoError(t, err) + assert.Equal(t, "manual", disc) + }) + + t.Run("feature with archived at", func(t *testing.T) { + archived := now.Add(-time.Hour) + f := feature.Feature{ + Namespace: "default", + ID: "feat-4", + Name: "Archived", + Key: "archived", + ArchivedAt: &archived, + CreatedAt: now, + UpdatedAt: now, + } + + result, err := convertFeatureToAPI(f) + require.NoError(t, err) + require.NotNil(t, result.DeletedAt) + assert.Equal(t, archived, *result.DeletedAt) + }) +} + +func TestConvertCreateRequestToDomain(t *testing.T) { + t.Run("minimal request without meter", func(t *testing.T) { + body := api.CreateFeatureRequest{ + Key: "my_key", + Name: "My Feature", + } + + result, err := convertCreateRequestToDomain("test-ns", body, nil) + require.NoError(t, err) + assert.Equal(t, "test-ns", result.Namespace) + assert.Equal(t, "my_key", result.Key) + assert.Equal(t, "My Feature", result.Name) + assert.Nil(t, result.MeterID) + assert.Nil(t, result.UnitCost) + }) + + t.Run("with meter ID and filters", func(t *testing.T) { + meterID := "01ARZ3NDEKTSV4RRFFQ69G5FAV" + body := api.CreateFeatureRequest{ + Key: "tokens", + Name: "Tokens", + Meter: &api.FeatureMeterReference{ + Id: api.ULID("01ARZ3NDEKTSV4RRFFQ69G5FAV"), + Filters: &map[string]api.QueryFilterStringMapItem{ + "model": {Eq: lo.ToPtr("gpt-4")}, + }, + }, + } + + result, err := convertCreateRequestToDomain("ns", body, &meterID) + require.NoError(t, err) + require.NotNil(t, result.MeterID) + assert.Equal(t, "01ARZ3NDEKTSV4RRFFQ69G5FAV", *result.MeterID) + require.NotNil(t, result.MeterGroupByFilters) + assert.Equal(t, lo.ToPtr("gpt-4"), result.MeterGroupByFilters["model"].Eq) + }) + + t.Run("with manual unit cost", func(t *testing.T) { + var uc api.BillingFeatureUnitCost + err := uc.FromBillingFeatureManualUnitCost(api.BillingFeatureManualUnitCost{Amount: "0.05"}) + require.NoError(t, err) + + body := api.CreateFeatureRequest{ + Key: "feat", + Name: "Feature", + UnitCost: &uc, + } + + result, err := convertCreateRequestToDomain("ns", body, nil) + require.NoError(t, err) + require.NotNil(t, result.UnitCost) + assert.Equal(t, feature.UnitCostTypeManual, result.UnitCost.Type) + assert.Equal(t, "0.05", result.UnitCost.Manual.Amount.String()) + }) + + t.Run("with labels", func(t *testing.T) { + labels := api.Labels{"env": "prod", "team": "billing"} + body := api.CreateFeatureRequest{ + Key: "feat", + Name: "Feature", + Labels: &labels, + } + + result, err := convertCreateRequestToDomain("ns", body, nil) + require.NoError(t, err) + assert.Equal(t, "prod", result.Metadata["env"]) + assert.Equal(t, "billing", result.Metadata["team"]) + }) +} + +func TestConvertUpdateRequestToDomain(t *testing.T) { + t.Run("with unit cost value", func(t *testing.T) { + var uc api.BillingFeatureUnitCost + err := uc.FromBillingFeatureManualUnitCost(api.BillingFeatureManualUnitCost{Amount: "0.05"}) + require.NoError(t, err) + + body := api.UpdateFeatureRequest{UnitCost: nullable.NewNullableWithValue(uc)} + result, err := convertUpdateRequestToDomain("ns", "feat-1", body) + require.NoError(t, err) + assert.Equal(t, "ns", result.Namespace) + assert.Equal(t, "feat-1", result.ID) + assert.True(t, result.UnitCost.IsSpecified()) + assert.False(t, result.UnitCost.IsNull()) + unitCost, err := result.UnitCost.Get() + require.NoError(t, err) + assert.Equal(t, feature.UnitCostTypeManual, unitCost.Type) + assert.Equal(t, "0.05", unitCost.Manual.Amount.String()) + }) + + t.Run("with explicit null clears unit cost", func(t *testing.T) { + body := api.UpdateFeatureRequest{UnitCost: nullable.NewNullNullable[api.BillingFeatureUnitCost]()} + result, err := convertUpdateRequestToDomain("ns", "feat-1", body) + require.NoError(t, err) + assert.True(t, result.UnitCost.IsNull()) + }) + + t.Run("with omitted unit cost", func(t *testing.T) { + body := api.UpdateFeatureRequest{} + result, err := convertUpdateRequestToDomain("ns", "feat-1", body) + require.NoError(t, err) + assert.False(t, result.UnitCost.IsSpecified()) + }) +} + +func TestEnrichFeatureResponseWithPricing(t *testing.T) { + t.Run("adds pricing to llm unit cost", func(t *testing.T) { + var uc api.BillingFeatureUnitCost + err := uc.FromBillingFeatureLLMUnitCost(api.BillingFeatureLLMUnitCost{ + ProviderProperty: lo.ToPtr("provider"), + ModelProperty: lo.ToPtr("model"), + }) + require.NoError(t, err) + + resp := &api.Feature{UnitCost: &uc} + pricing := &llmcost.ModelPricing{ + InputPerToken: alpacadecimal.NewFromFloat(0.00001), + OutputPerToken: alpacadecimal.NewFromFloat(0.00003), + } + + enrichFeatureResponseWithPricing(resp, pricing) + + llm, err := resp.UnitCost.AsBillingFeatureLLMUnitCost() + require.NoError(t, err) + require.NotNil(t, llm.Pricing) + assert.Equal(t, api.Numeric("0.00001"), llm.Pricing.InputPerToken) + assert.Equal(t, api.Numeric("0.00003"), llm.Pricing.OutputPerToken) + assert.Nil(t, llm.Pricing.CacheReadPerToken) + }) + + t.Run("adds optional pricing fields", func(t *testing.T) { + var uc api.BillingFeatureUnitCost + err := uc.FromBillingFeatureLLMUnitCost(api.BillingFeatureLLMUnitCost{ + Provider: lo.ToPtr("openai"), + Model: lo.ToPtr("gpt-4"), + }) + require.NoError(t, err) + + resp := &api.Feature{UnitCost: &uc} + pricing := &llmcost.ModelPricing{ + InputPerToken: alpacadecimal.NewFromFloat(0.00001), + OutputPerToken: alpacadecimal.NewFromFloat(0.00003), + CacheReadPerToken: lo.ToPtr(alpacadecimal.NewFromFloat(0.000005)), + ReasoningPerToken: lo.ToPtr(alpacadecimal.NewFromFloat(0.00006)), + } + + enrichFeatureResponseWithPricing(resp, pricing) + + llm, err := resp.UnitCost.AsBillingFeatureLLMUnitCost() + require.NoError(t, err) + require.NotNil(t, llm.Pricing.CacheReadPerToken) + assert.Equal(t, api.Numeric("0.000005"), *llm.Pricing.CacheReadPerToken) + require.NotNil(t, llm.Pricing.ReasoningPerToken) + assert.Equal(t, api.Numeric("0.00006"), *llm.Pricing.ReasoningPerToken) + assert.Nil(t, llm.Pricing.CacheWritePerToken) + }) + + t.Run("no-op when unit cost is manual", func(t *testing.T) { + var uc api.BillingFeatureUnitCost + err := uc.FromBillingFeatureManualUnitCost(api.BillingFeatureManualUnitCost{Amount: "0.005"}) + require.NoError(t, err) + resp := &api.Feature{UnitCost: &uc} + + enrichFeatureResponseWithPricing(resp, &llmcost.ModelPricing{ + InputPerToken: alpacadecimal.NewFromFloat(0.00001), + OutputPerToken: alpacadecimal.NewFromFloat(0.00003), + }) + + // Manual cost should be unchanged + manual, err := resp.UnitCost.AsBillingFeatureManualUnitCost() + require.NoError(t, err) + assert.Equal(t, api.Numeric("0.005"), manual.Amount) + }) + + t.Run("no-op when unit cost is nil", func(t *testing.T) { + resp := &api.Feature{} + enrichFeatureResponseWithPricing(resp, &llmcost.ModelPricing{}) + assert.Nil(t, resp.UnitCost) + }) + + t.Run("no-op when pricing is nil", func(t *testing.T) { + var uc api.BillingFeatureUnitCost + err := uc.FromBillingFeatureLLMUnitCost(api.BillingFeatureLLMUnitCost{}) + require.NoError(t, err) + resp := &api.Feature{UnitCost: &uc} + enrichFeatureResponseWithPricing(resp, nil) + + llm, err := resp.UnitCost.AsBillingFeatureLLMUnitCost() + require.NoError(t, err) + assert.Nil(t, llm.Pricing) + }) +} + +func TestExtractEqFilterValue(t *testing.T) { + t.Run("returns value for eq filter", func(t *testing.T) { + filters := feature.MeterGroupByFilters{ + "provider": {Eq: lo.ToPtr("openai")}, + } + assert.Equal(t, "openai", extractEqFilterValue(filters, "provider")) + }) + + t.Run("returns empty for missing key", func(t *testing.T) { + filters := feature.MeterGroupByFilters{ + "provider": {Eq: lo.ToPtr("openai")}, + } + assert.Equal(t, "", extractEqFilterValue(filters, "model")) + }) + + t.Run("returns empty for non-eq filter", func(t *testing.T) { + filters := feature.MeterGroupByFilters{ + "provider": {In: &[]string{"openai", "anthropic"}}, + } + assert.Equal(t, "", extractEqFilterValue(filters, "provider")) + }) + + t.Run("returns empty for nil filters", func(t *testing.T) { + assert.Equal(t, "", extractEqFilterValue(nil, "key")) + }) +} + +func TestConvertFiltersRoundTrip(t *testing.T) { + original := feature.MeterGroupByFilters{ + "provider": {Eq: lo.ToPtr("openai")}, + "model": {In: &[]string{"gpt-4", "gpt-4o"}}, + } + + apiFilters := convertFiltersToAPI(original) + assert.Len(t, apiFilters, 2) + assert.Equal(t, lo.ToPtr("openai"), apiFilters["provider"].Eq) + assert.Equal(t, &[]string{"gpt-4", "gpt-4o"}, apiFilters["model"].In) + + roundTripped := convertFiltersFromAPI(apiFilters) + assert.Equal(t, lo.ToPtr("openai"), roundTripped["provider"].Eq) + assert.Equal(t, &[]string{"gpt-4", "gpt-4o"}, roundTripped["model"].In) +} + +func TestConvertMetadataLabels(t *testing.T) { + t.Run("metadata to labels", func(t *testing.T) { + l := labels.FromMetadata(map[string]string{"a": "1", "b": "2"}) + require.NotNil(t, l) + assert.Equal(t, "1", (*l)["a"]) + assert.Equal(t, "2", (*l)["b"]) + }) + + t.Run("nil metadata returns nil labels", func(t *testing.T) { + l := labels.FromMetadata((map[string]string)(nil)) + assert.Empty(t, l) + }) + + t.Run("labels to metadata", func(t *testing.T) { + l := api.Labels{"x": "y"} + meta, err := labels.ToMetadata(&l) + assert.NoError(t, err) + assert.Equal(t, "y", meta["x"]) + }) + + t.Run("nil labels returns nil metadata", func(t *testing.T) { + meta, err := labels.ToMetadata(nil) + assert.NoError(t, err) + assert.Nil(t, meta) + }) +} + +func TestConvertFilterStringToAPIMapItem(t *testing.T) { + t.Run("eq filter", func(t *testing.T) { + f := filter.FilterString{Eq: lo.ToPtr("val")} + result := convertFilterStringToAPIMapItem(f) + assert.Equal(t, lo.ToPtr("val"), result.Eq) + }) + + t.Run("in filter", func(t *testing.T) { + f := filter.FilterString{In: &[]string{"a", "b"}} + result := convertFilterStringToAPIMapItem(f) + assert.Equal(t, &[]string{"a", "b"}, result.In) + }) + + t.Run("ne filter", func(t *testing.T) { + f := filter.FilterString{Ne: lo.ToPtr("excluded")} + result := convertFilterStringToAPIMapItem(f) + assert.Equal(t, lo.ToPtr("excluded"), result.Neq) + }) + + t.Run("exists filter", func(t *testing.T) { + f := filter.FilterString{Exists: lo.ToPtr(true)} + result := convertFilterStringToAPIMapItem(f) + assert.Equal(t, lo.ToPtr(true), result.Exists) + }) +} diff --git a/api/v3/handlers/features/create.go b/api/v3/handlers/features/create.go new file mode 100644 index 0000000000000000000000000000000000000000..9df48f4e5a69f1ab0d8d1abb616e9cb9bf235712 --- /dev/null +++ b/api/v3/handlers/features/create.go @@ -0,0 +1,95 @@ +package features + +import ( + "context" + "fmt" + "net/http" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/apierrors" + "github.com/openmeterio/openmeter/api/v3/request" + "github.com/openmeterio/openmeter/openmeter/meter" + "github.com/openmeterio/openmeter/openmeter/productcatalog/feature" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" + "github.com/openmeterio/openmeter/pkg/models" +) + +type ( + CreateFeatureRequest = feature.CreateFeatureInputs + CreateFeatureResponse = api.Feature + CreateFeatureHandler httptransport.Handler[CreateFeatureRequest, CreateFeatureResponse] +) + +func (h *handler) CreateFeature() CreateFeatureHandler { + return httptransport.NewHandler( + func(ctx context.Context, r *http.Request) (CreateFeatureRequest, error) { + body := api.CreateFeatureRequest{} + if err := request.ParseBody(r, &body); err != nil { + return CreateFeatureRequest{}, err + } + + ns, err := h.resolveNamespace(ctx) + if err != nil { + return CreateFeatureRequest{}, err + } + + // Resolve and validate the meter reference. + var meterID *string + if body.Meter != nil { + if body.Meter.Id == "" { + return CreateFeatureRequest{}, models.NewGenericValidationError( + fmt.Errorf("meter id is required"), + ) + } + + m, err := h.meterService.GetMeterByIDOrSlug(ctx, meter.GetMeterInput{ + Namespace: ns, + IDOrSlug: body.Meter.Id, + }) + if err != nil { + return CreateFeatureRequest{}, err + } + meterID = &m.ID + + // Validate meter filters. + if body.Meter.Filters != nil { + if err := validateMeterFilters(*body.Meter.Filters, m); err != nil { + return CreateFeatureRequest{}, err + } + } + } + + return convertCreateRequestToDomain(ns, body, meterID) + }, + func(ctx context.Context, req CreateFeatureRequest) (CreateFeatureResponse, error) { + created, err := h.connector.CreateFeature(ctx, req) + if err != nil { + return CreateFeatureResponse{}, err + } + + return convertFeatureToAPI(created) + }, + commonhttp.JSONResponseEncoderWithStatus[CreateFeatureResponse](http.StatusCreated), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("create-feature"), + httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()), + httptransport.WithErrorEncoder(errorEncoder()), + )..., + ) +} + +// validateMeterFilters validates that filter keys exist in the meter's dimensions. +// The single-operator invariant on each filter value is enforced downstream by +// feature.MeterGroupByFilters.Validate. +func validateMeterFilters(filters map[string]api.QueryFilterStringMapItem, m meter.Meter) error { + for k := range filters { + if _, ok := m.GroupBy[k]; !ok { + return models.NewGenericValidationError( + fmt.Errorf("filter key %q is not a valid dimension of meter %q", k, m.Key), + ) + } + } + return nil +} diff --git a/api/v3/handlers/features/create_test.go b/api/v3/handlers/features/create_test.go new file mode 100644 index 0000000000000000000000000000000000000000..bffd90080664ba45095796041550ba5592996821 --- /dev/null +++ b/api/v3/handlers/features/create_test.go @@ -0,0 +1,60 @@ +package features + +import ( + "testing" + + "github.com/samber/lo" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/openmeter/meter" +) + +func TestValidateMeterFilters(t *testing.T) { + testMeter := meter.Meter{ + Key: "tokens_total", + GroupBy: map[string]string{ + "provider": "$.provider", + "model": "$.model", + "type": "$.type", + }, + } + + t.Run("valid filters", func(t *testing.T) { + filters := map[string]api.QueryFilterStringMapItem{ + "provider": {Eq: lo.ToPtr("openai")}, + "model": {In: &[]string{"gpt-4", "gpt-4o"}}, + } + + err := validateMeterFilters(filters, testMeter) + require.NoError(t, err) + }) + + t.Run("invalid dimension key", func(t *testing.T) { + filters := map[string]api.QueryFilterStringMapItem{ + "nonexistent": {Eq: lo.ToPtr("value")}, + } + + err := validateMeterFilters(filters, testMeter) + require.Error(t, err) + assert.Contains(t, err.Error(), "nonexistent") + assert.Contains(t, err.Error(), "not a valid dimension") + }) + + t.Run("empty filters", func(t *testing.T) { + err := validateMeterFilters(map[string]api.QueryFilterStringMapItem{}, testMeter) + require.NoError(t, err) + }) + + t.Run("mix of valid and invalid keys", func(t *testing.T) { + filters := map[string]api.QueryFilterStringMapItem{ + "provider": {Eq: lo.ToPtr("openai")}, + "bad_key": {Eq: lo.ToPtr("value")}, + } + + err := validateMeterFilters(filters, testMeter) + require.Error(t, err) + assert.Contains(t, err.Error(), "bad_key") + }) +} diff --git a/api/v3/handlers/features/delete.go b/api/v3/handlers/features/delete.go new file mode 100644 index 0000000000000000000000000000000000000000..6bf7e195742b06c48bfc2b9a0d1d6feddd5d37e3 --- /dev/null +++ b/api/v3/handlers/features/delete.go @@ -0,0 +1,49 @@ +package features + +import ( + "context" + "net/http" + + "github.com/openmeterio/openmeter/api/v3/apierrors" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" + "github.com/openmeterio/openmeter/pkg/models" +) + +type ( + DeleteFeatureRequest = models.NamespacedID + DeleteFeatureResponse = interface{} + DeleteFeatureParams = string + DeleteFeatureHandler httptransport.HandlerWithArgs[DeleteFeatureRequest, DeleteFeatureResponse, DeleteFeatureParams] +) + +func (h *handler) DeleteFeature() DeleteFeatureHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, featureID DeleteFeatureParams) (DeleteFeatureRequest, error) { + ns, err := h.resolveNamespace(ctx) + if err != nil { + return DeleteFeatureRequest{}, err + } + + return DeleteFeatureRequest{ + Namespace: ns, + ID: featureID, + }, nil + }, + func(ctx context.Context, req DeleteFeatureRequest) (DeleteFeatureResponse, error) { + err := h.connector.ArchiveFeature(ctx, req) + if err != nil { + return nil, err + } + + return nil, nil + }, + commonhttp.EmptyResponseEncoder[DeleteFeatureResponse](http.StatusNoContent), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("delete-feature"), + httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()), + httptransport.WithErrorEncoder(errorEncoder()), + )..., + ) +} diff --git a/api/v3/handlers/features/error_encoder.go b/api/v3/handlers/features/error_encoder.go new file mode 100644 index 0000000000000000000000000000000000000000..36196e6b318aa8988575d40536581aa91aace5e3 --- /dev/null +++ b/api/v3/handlers/features/error_encoder.go @@ -0,0 +1,19 @@ +package features + +import ( + "context" + "net/http" + + "github.com/openmeterio/openmeter/openmeter/productcatalog/feature" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport/encoder" +) + +func errorEncoder() encoder.ErrorEncoder { + return func(ctx context.Context, err error, w http.ResponseWriter, r *http.Request) bool { + return commonhttp.HandleErrorIfTypeMatches[*feature.FeatureInvalidFiltersError](ctx, http.StatusBadRequest, err, w) || + commonhttp.HandleErrorIfTypeMatches[*feature.FeatureInvalidMeterAggregationError](ctx, http.StatusBadRequest, err, w) || + commonhttp.HandleErrorIfTypeMatches[*feature.ForbiddenError](ctx, http.StatusForbidden, err, w) || + commonhttp.HandleErrorIfTypeMatches[*feature.FeatureWithNameAlreadyExistsError](ctx, http.StatusConflict, err, w) + } +} diff --git a/api/v3/handlers/features/get.go b/api/v3/handlers/features/get.go new file mode 100644 index 0000000000000000000000000000000000000000..80a5650caf0d2bd960cce4c123cdeac37bbc933b --- /dev/null +++ b/api/v3/handlers/features/get.go @@ -0,0 +1,66 @@ +package features + +import ( + "context" + "net/http" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/apierrors" + "github.com/openmeterio/openmeter/openmeter/productcatalog/feature" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" +) + +type ( + GetFeatureRequest struct { + Namespace string + IDOrKey string + } + GetFeatureResponse = api.Feature + GetFeatureParams = string + GetFeatureHandler httptransport.HandlerWithArgs[GetFeatureRequest, GetFeatureResponse, GetFeatureParams] +) + +func (h *handler) GetFeature() GetFeatureHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, featureID GetFeatureParams) (GetFeatureRequest, error) { + ns, err := h.resolveNamespace(ctx) + if err != nil { + return GetFeatureRequest{}, err + } + + return GetFeatureRequest{ + Namespace: ns, + IDOrKey: featureID, + }, nil + }, + func(ctx context.Context, req GetFeatureRequest) (GetFeatureResponse, error) { + feat, err := h.connector.GetFeature(ctx, req.Namespace, req.IDOrKey, feature.IncludeArchivedFeatureFalse) + if err != nil { + return GetFeatureResponse{}, err + } + + resp, err := convertFeatureToAPI(*feat) + if err != nil { + return GetFeatureResponse{}, err + } + + // Resolve LLM pricing if applicable + if feat.UnitCost != nil && feat.UnitCost.Type == feature.UnitCostTypeLLM && h.llmcostService != nil { + pricing := resolveLLMPricing(ctx, h.llmcostService, feat) + if pricing != nil { + enrichFeatureResponseWithPricing(&resp, pricing) + } + } + + return resp, nil + }, + commonhttp.JSONResponseEncoderWithStatus[GetFeatureResponse](http.StatusOK), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("get-feature"), + httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()), + httptransport.WithErrorEncoder(errorEncoder()), + )..., + ) +} diff --git a/api/v3/handlers/features/handler.go b/api/v3/handlers/features/handler.go new file mode 100644 index 0000000000000000000000000000000000000000..cc073dd4f4d25743f05d02cdbf3bf880d106e7a2 --- /dev/null +++ b/api/v3/handlers/features/handler.go @@ -0,0 +1,42 @@ +package features + +import ( + "context" + + "github.com/openmeterio/openmeter/openmeter/llmcost" + "github.com/openmeterio/openmeter/openmeter/meter" + "github.com/openmeterio/openmeter/openmeter/productcatalog/feature" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" +) + +type Handler interface { + ListFeatures() ListFeaturesHandler + GetFeature() GetFeatureHandler + CreateFeature() CreateFeatureHandler + UpdateFeature() UpdateFeatureHandler + DeleteFeature() DeleteFeatureHandler +} + +type handler struct { + resolveNamespace func(ctx context.Context) (string, error) + connector feature.FeatureConnector + meterService meter.Service + llmcostService llmcost.Service + options []httptransport.HandlerOption +} + +func New( + resolveNamespace func(ctx context.Context) (string, error), + connector feature.FeatureConnector, + meterService meter.Service, + llmcostService llmcost.Service, + options ...httptransport.HandlerOption, +) Handler { + return &handler{ + resolveNamespace: resolveNamespace, + connector: connector, + meterService: meterService, + llmcostService: llmcostService, + options: options, + } +} diff --git a/api/v3/handlers/features/list.go b/api/v3/handlers/features/list.go new file mode 100644 index 0000000000000000000000000000000000000000..6847fb19f997c893998ac60bb4f0bb3ab94d95d8 --- /dev/null +++ b/api/v3/handlers/features/list.go @@ -0,0 +1,131 @@ +package features + +import ( + "context" + "net/http" + + "github.com/samber/lo" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/apierrors" + "github.com/openmeterio/openmeter/api/v3/filters" + "github.com/openmeterio/openmeter/api/v3/request" + "github.com/openmeterio/openmeter/api/v3/response" + "github.com/openmeterio/openmeter/openmeter/productcatalog/feature" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" + "github.com/openmeterio/openmeter/pkg/pagination" +) + +type ( + ListFeaturesRequest = feature.ListFeaturesParams + ListFeaturesResponse = response.PagePaginationResponse[api.Feature] + ListFeaturesParams = api.ListFeaturesParams + ListFeaturesHandler httptransport.HandlerWithArgs[ListFeaturesRequest, ListFeaturesResponse, ListFeaturesParams] +) + +func (h *handler) ListFeatures() ListFeaturesHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, params ListFeaturesParams) (ListFeaturesRequest, error) { + ns, err := h.resolveNamespace(ctx) + if err != nil { + return ListFeaturesRequest{}, err + } + + page := pagination.NewPage(1, 20) + if params.Page != nil { + page = pagination.NewPage( + lo.FromPtrOr(params.Page.Number, 1), + lo.FromPtrOr(params.Page.Size, 20), + ) + } + + if err := page.Validate(); err != nil { + return ListFeaturesRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{ + apierrors.InvalidParameter{ + Field: "page", + Reason: err.Error(), + Source: apierrors.InvalidParamSourceQuery, + }, + }) + } + + req := ListFeaturesRequest{ + Namespace: ns, + Page: page, + } + + if params.Filter != nil { + meterIDs, err := filters.FromAPIFilterULID(params.Filter.MeterId) + if err != nil { + return ListFeaturesRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{ + {Field: "filter[meter_id]", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery}, + }) + } + req.MeterIDs = meterIDs + + key, err := filters.FromAPIFilterString(params.Filter.Key) + if err != nil { + return ListFeaturesRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{ + {Field: "filter[key]", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery}, + }) + } + req.Key = key + + name, err := filters.FromAPIFilterString(params.Filter.Name) + if err != nil { + return ListFeaturesRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{ + {Field: "filter[name]", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery}, + }) + } + req.Name = name + } + + if params.Sort != nil { + sort, err := request.ParseSortBy(*params.Sort) + if err != nil { + return ListFeaturesRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{ + {Field: "sort", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery}, + }) + } + + orderBy, err := FromAPIFeatureSortField(ctx, sort.Field) + if err != nil { + return ListFeaturesRequest{}, err + } + req.OrderBy = orderBy + req.Order = sort.Order.ToSortxOrder() + } + + return req, nil + }, + func(ctx context.Context, req ListFeaturesRequest) (ListFeaturesResponse, error) { + result, err := h.connector.ListFeatures(ctx, req) + if err != nil { + return ListFeaturesResponse{}, err + } + + items := make([]api.Feature, 0, len(result.Items)) + for _, f := range result.Items { + apiFeature, err := convertFeatureToAPI(f) + if err != nil { + return ListFeaturesResponse{}, err + } + items = append(items, apiFeature) + } + + return response.NewPagePaginationResponse(items, response.PageMetaPage{ + Size: req.Page.PageSize, + Number: req.Page.PageNumber, + Total: lo.ToPtr(result.TotalCount), + }), nil + }, + commonhttp.JSONResponseEncoderWithStatus[ListFeaturesResponse](http.StatusOK), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("list-features"), + httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()), + httptransport.WithErrorEncoder(errorEncoder()), + )..., + ) +} diff --git a/api/v3/handlers/features/update.go b/api/v3/handlers/features/update.go new file mode 100644 index 0000000000000000000000000000000000000000..d76773cf57d559e22cba8528390e19518e1cd8f8 --- /dev/null +++ b/api/v3/handlers/features/update.go @@ -0,0 +1,66 @@ +package features + +import ( + "context" + "net/http" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/apierrors" + "github.com/openmeterio/openmeter/api/v3/request" + "github.com/openmeterio/openmeter/openmeter/productcatalog/feature" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" +) + +type ( + UpdateFeatureRequest = feature.UpdateFeatureInputs + UpdateFeatureResponse = api.Feature + UpdateFeatureParams = string + UpdateFeatureHandler httptransport.HandlerWithArgs[UpdateFeatureRequest, UpdateFeatureResponse, UpdateFeatureParams] +) + +func (h *handler) UpdateFeature() UpdateFeatureHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, featureID UpdateFeatureParams) (UpdateFeatureRequest, error) { + body := api.UpdateFeatureRequest{} + if err := request.ParseBody(r, &body); err != nil { + return UpdateFeatureRequest{}, err + } + + ns, err := h.resolveNamespace(ctx) + if err != nil { + return UpdateFeatureRequest{}, err + } + + return convertUpdateRequestToDomain(ns, featureID, body) + }, + func(ctx context.Context, req UpdateFeatureRequest) (UpdateFeatureResponse, error) { + updated, err := h.connector.UpdateFeature(ctx, req) + if err != nil { + return UpdateFeatureResponse{}, err + } + + resp, err := convertFeatureToAPI(updated) + if err != nil { + return UpdateFeatureResponse{}, err + } + + // Resolve LLM pricing if applicable + if updated.UnitCost != nil && updated.UnitCost.Type == feature.UnitCostTypeLLM && h.llmcostService != nil { + pricing := resolveLLMPricing(ctx, h.llmcostService, &updated) + if pricing != nil { + enrichFeatureResponseWithPricing(&resp, pricing) + } + } + + return resp, nil + }, + commonhttp.JSONResponseEncoderWithStatus[UpdateFeatureResponse](http.StatusOK), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("update-feature"), + httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()), + httptransport.WithErrorEncoder(errorEncoder()), + )..., + ) +} diff --git a/api/v3/handlers/governance/handler.go b/api/v3/handlers/governance/handler.go new file mode 100644 index 0000000000000000000000000000000000000000..afc5b6d0f48cfde94982b771e6c37a37a94347f4 --- /dev/null +++ b/api/v3/handlers/governance/handler.go @@ -0,0 +1,30 @@ +package governance + +import ( + "context" + + "github.com/openmeterio/openmeter/openmeter/governance" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" +) + +type Handler interface { + QueryGovernanceAccess() QueryGovernanceAccessHandler +} + +type handler struct { + resolveNamespace func(ctx context.Context) (string, error) + governanceService governance.Service + options []httptransport.HandlerOption +} + +func New( + resolveNamespace func(ctx context.Context) (string, error), + governanceService governance.Service, + options ...httptransport.HandlerOption, +) Handler { + return &handler{ + resolveNamespace: resolveNamespace, + governanceService: governanceService, + options: options, + } +} diff --git a/api/v3/handlers/governance/mapping.go b/api/v3/handlers/governance/mapping.go new file mode 100644 index 0000000000000000000000000000000000000000..0c9e871ea5996bf5f2581f62f9f8a6bc3b3e0593 --- /dev/null +++ b/api/v3/handlers/governance/mapping.go @@ -0,0 +1,109 @@ +package governance + +import ( + "github.com/oapi-codegen/nullable" + "github.com/samber/lo" + + apiv3 "github.com/openmeterio/openmeter/api/v3" + customershandler "github.com/openmeterio/openmeter/api/v3/handlers/customers" + "github.com/openmeterio/openmeter/openmeter/governance" +) + +// ToAPIGovernanceQueryResponse maps a domain QueryResult to the API response. +func ToAPIGovernanceQueryResponse(res governance.QueryResult, pageSize int) apiv3.GovernanceQueryResponse { + data := make([]apiv3.GovernanceQueryResult, 0, len(res.Customers)) + + for _, c := range res.Customers { + features := make(map[string]apiv3.GovernanceFeatureAccess, len(c.Features)) + for key, fa := range c.Features { + features[key] = toAPIFeatureAccess(fa) + } + + data = append(data, apiv3.GovernanceQueryResult{ + Matched: c.Matched, + Customer: customershandler.ToAPIBillingCustomer(c.Customer), + Features: features, + UpdatedAt: c.UpdatedAt, + }) + } + + errs := make([]apiv3.GovernanceQueryError, 0, len(res.Errors)) + + for _, e := range res.Errors { + errs = append(errs, apiv3.GovernanceQueryError{ + Customer: lo.ToPtr(e.CustomerKey), + Code: toAPIQueryErrorCode(e.Code), + Message: e.Message, + }) + } + + return apiv3.GovernanceQueryResponse{ + Data: data, + Errors: errs, + Meta: toAPICursorMeta(res, pageSize), + } +} + +func toAPIFeatureAccess(fa governance.FeatureAccess) apiv3.GovernanceFeatureAccess { + out := apiv3.GovernanceFeatureAccess{HasAccess: fa.HasAccess} + + if fa.Reason != nil { + out.Reason = &apiv3.GovernanceFeatureAccessReason{ + Code: toAPIReasonCode(fa.Reason.Code), + Message: fa.Reason.Message, + } + } + + return out +} + +func toAPIReasonCode(code governance.ReasonCode) apiv3.GovernanceFeatureAccessReasonCode { + switch code { + case governance.ReasonCodeUsageLimitReached: + return apiv3.GovernanceFeatureAccessReasonCodeUsageLimitReached + case governance.ReasonCodeFeatureUnavailable: + return apiv3.GovernanceFeatureAccessReasonCodeFeatureUnavailable + case governance.ReasonCodeFeatureNotFound: + return apiv3.GovernanceFeatureAccessReasonCodeFeatureNotFound + case governance.ReasonCodeNoCreditAvailable: + return apiv3.GovernanceFeatureAccessReasonCodeNoCreditAvailable + default: + return apiv3.GovernanceFeatureAccessReasonCodeUnknown + } +} + +func toAPIQueryErrorCode(code governance.QueryErrorCode) apiv3.GovernanceQueryErrorCode { + switch code { + case governance.QueryErrorCustomerNotFound: + return apiv3.GovernanceQueryErrorCodeCustomerNotFound + default: + return apiv3.GovernanceQueryErrorCodeUnknown + } +} + +// toAPICursorMeta builds cursor pagination metadata from the domain result. +func toAPICursorMeta(res governance.QueryResult, pageSize int) apiv3.CursorMeta { + meta := apiv3.CursorMeta{ + Page: apiv3.CursorMetaPage{ + Next: nullable.NewNullNullable[string](), + Previous: nullable.NewNullNullable[string](), + Size: float32(pageSize), + }, + } + + if res.First != nil { + meta.Page.First = lo.ToPtr(res.First.Encode()) + if res.HasPrev { + meta.Page.Previous = nullable.NewNullableWithValue(res.First.Encode()) + } + } + + if res.Last != nil { + meta.Page.Last = lo.ToPtr(res.Last.Encode()) + if res.HasNext { + meta.Page.Next = nullable.NewNullableWithValue(res.Last.Encode()) + } + } + + return meta +} diff --git a/api/v3/handlers/governance/query.go b/api/v3/handlers/governance/query.go new file mode 100644 index 0000000000000000000000000000000000000000..ebc91ae5e64ba1e67786acdd2ffee6cc25934e43 --- /dev/null +++ b/api/v3/handlers/governance/query.go @@ -0,0 +1,141 @@ +package governance + +import ( + "context" + "fmt" + "net/http" + + apiv3 "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/apierrors" + "github.com/openmeterio/openmeter/openmeter/governance" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" + pagination "github.com/openmeterio/openmeter/pkg/pagination/v2" +) + +const ( + defaultPageSize = 100 + maxPageSize = 100 +) + +type ( + QueryGovernanceAccessParams = apiv3.QueryGovernanceAccessParams + QueryGovernanceAccessRequest = governance.QueryAccessInput + QueryGovernanceAccessResponse = apiv3.GovernanceQueryResponse + QueryGovernanceAccessHandler = httptransport.HandlerWithArgs[QueryGovernanceAccessRequest, QueryGovernanceAccessResponse, QueryGovernanceAccessParams] +) + +func (h *handler) QueryGovernanceAccess() QueryGovernanceAccessHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, params QueryGovernanceAccessParams) (QueryGovernanceAccessRequest, error) { + ns, err := h.resolveNamespace(ctx) + if err != nil { + return QueryGovernanceAccessRequest{}, err + } + + var body apiv3.GovernanceQueryRequest + + if err := commonhttp.JSONRequestBodyDecoder(r, &body); err != nil { + return QueryGovernanceAccessRequest{}, err + } + + req := QueryGovernanceAccessRequest{ + Namespace: ns, + CustomerKeys: body.Customer.Keys, + PageSize: defaultPageSize, + } + + if body.Feature != nil { + req.FeatureKeys = body.Feature.Keys + } + + if body.IncludeCredits != nil { + req.IncludeCredits = *body.IncludeCredits + } + + if err := applyPaging(ctx, &req, params); err != nil { + return QueryGovernanceAccessRequest{}, err + } + + return req, nil + }, + func(ctx context.Context, request QueryGovernanceAccessRequest) (QueryGovernanceAccessResponse, error) { + res, err := h.governanceService.QueryAccess(ctx, request) + if err != nil { + return QueryGovernanceAccessResponse{}, err + } + + return ToAPIGovernanceQueryResponse(res, request.PageSize), nil + }, + commonhttp.JSONResponseEncoderWithStatus[QueryGovernanceAccessResponse](http.StatusOK), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("query-governance-access"), + httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()), + )..., + ) +} + +// applyPaging parses page[size]/page[after]/page[before] into the service input. +func applyPaging(ctx context.Context, req *QueryGovernanceAccessRequest, params QueryGovernanceAccessParams) error { + if params.Page == nil { + return nil + } + + if params.Page.Size != nil { + if *params.Page.Size < 1 || *params.Page.Size > maxPageSize { + return apierrors.NewBadRequestError(ctx, + fmt.Errorf("page[size] must be between 1 and %d", maxPageSize), + apierrors.InvalidParameters{{ + Field: "page[size]", + Reason: fmt.Sprintf("must be between 1 and %d", maxPageSize), + Source: apierrors.InvalidParamSourceQuery, + }}, + ) + } + + req.PageSize = *params.Page.Size + } + + if params.Page.After != nil && params.Page.Before != nil { + return apierrors.NewBadRequestError(ctx, + fmt.Errorf("page[after] and page[before] are mutually exclusive"), + apierrors.InvalidParameters{{ + Field: "page[after]", + Reason: "cannot be combined with page[before]", + Source: apierrors.InvalidParamSourceQuery, + }}, + ) + } + + if params.Page.After != nil { + cursor, err := decodeCursorParam(ctx, "page[after]", *params.Page.After) + if err != nil { + return err + } + req.After = cursor + } + + if params.Page.Before != nil { + cursor, err := decodeCursorParam(ctx, "page[before]", *params.Page.Before) + if err != nil { + return err + } + req.Before = cursor + } + + return nil +} + +func decodeCursorParam(ctx context.Context, field, raw string) (*pagination.Cursor, error) { + cursor, err := pagination.DecodeCursor(raw) + if err != nil { + return nil, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{{ + Field: field, + Reason: err.Error(), + Source: apierrors.InvalidParamSourceQuery, + }}) + } + + return cursor, nil +} diff --git a/api/v3/handlers/llmcost/convert.go b/api/v3/handlers/llmcost/convert.go new file mode 100644 index 0000000000000000000000000000000000000000..c00aaad7452ab723d6a123099b62406d1e5d111b --- /dev/null +++ b/api/v3/handlers/llmcost/convert.go @@ -0,0 +1,216 @@ +package llmcost + +import ( + "context" + "fmt" + "strings" + + "github.com/alpacahq/alpacadecimal" + "github.com/samber/lo" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/apierrors" + "github.com/openmeterio/openmeter/openmeter/llmcost" + "github.com/openmeterio/openmeter/pkg/models" +) + +// providerDisplayNames maps canonical provider IDs to their formatted display names. +var providerDisplayNames = map[string]string{ + "amazon": "Amazon", + "anthropic": "Anthropic", + "azure": "Azure", + "bedrock": "Amazon Bedrock", + "cohere": "Cohere", + "deepseek": "DeepSeek", + "google": "Google", + "kilo": "Kilo", + "meta": "Meta", + "minimax": "MiniMax", + "mistral": "Mistral", + "nanogpt": "NanoGPT", + "openai": "OpenAI", + "vertex_ai": "Google Vertex AI", + "xai": "xAI", +} + +// formatProviderName returns the display name for a provider ID. +// Known providers get their canonical display name; unknown providers +// get their ID with the first letter capitalized. +func formatProviderName(id string) string { + if name, ok := providerDisplayNames[strings.ToLower(id)]; ok { + return name + } + + if id == "" { + return "" + } + + // Fallback: split on hyphens/underscores, capitalize each word + words := strings.FieldsFunc(id, func(r rune) bool { + return r == '-' || r == '_' + }) + for i, w := range words { + if len(w) > 0 { + words[i] = strings.ToUpper(w[:1]) + w[1:] + } + } + + return strings.Join(words, " ") +} + +func domainPriceToAPI(p llmcost.Price) api.LLMCostPrice { + // Map internal source to API source: manual stays manual, everything else is system. + source := api.LLMCostPriceSourceSystem + if p.Source == llmcost.PriceSourceManual { + source = api.LLMCostPriceSourceManual + } + + providerID := string(p.Provider) + + out := api.LLMCostPrice{ + Id: p.ID, + Provider: api.LLMCostProvider{ + Id: providerID, + Name: formatProviderName(providerID), + }, + Model: api.LLMCostModel{ + Id: p.ModelID, + Name: p.ModelName, + }, + Currency: p.Currency, + Source: source, + EffectiveFrom: p.EffectiveFrom, + EffectiveTo: p.EffectiveTo, + Pricing: domainPricingToAPI(p.Pricing), + } + + out.CreatedAt = p.CreatedAt + out.UpdatedAt = p.UpdatedAt + + return out +} + +func domainPricingToAPI(p llmcost.ModelPricing) api.LLMCostModelPricing { + out := api.LLMCostModelPricing{ + InputPerToken: p.InputPerToken.String(), + OutputPerToken: p.OutputPerToken.String(), + } + + if p.CacheReadPerToken != nil { + out.CacheReadPerToken = lo.ToPtr(p.CacheReadPerToken.String()) + } + + if p.CacheWritePerToken != nil { + out.CacheWritePerToken = lo.ToPtr(p.CacheWritePerToken.String()) + } + + if p.ReasoningPerToken != nil { + out.ReasoningPerToken = lo.ToPtr(p.ReasoningPerToken.String()) + } + + return out +} + +func apiPricingToDomain(p api.LLMCostModelPricing) (llmcost.ModelPricing, error) { + inputPerToken, err := decimalFromString(p.InputPerToken) + if err != nil { + return llmcost.ModelPricing{}, models.NewGenericValidationError( + fmt.Errorf("invalid input_per_token: %w", err), + ) + } + + outputPerToken, err := decimalFromString(p.OutputPerToken) + if err != nil { + return llmcost.ModelPricing{}, models.NewGenericValidationError( + fmt.Errorf("invalid output_per_token: %w", err), + ) + } + + out := llmcost.ModelPricing{ + InputPerToken: inputPerToken, + OutputPerToken: outputPerToken, + } + + if p.CacheReadPerToken != nil { + d, err := decimalFromString(*p.CacheReadPerToken) + if err != nil { + return llmcost.ModelPricing{}, models.NewGenericValidationError( + fmt.Errorf("invalid cache_read_per_token: %w", err), + ) + } + out.CacheReadPerToken = &d + } + + if p.ReasoningPerToken != nil { + d, err := decimalFromString(*p.ReasoningPerToken) + if err != nil { + return llmcost.ModelPricing{}, models.NewGenericValidationError( + fmt.Errorf("invalid reasoning_per_token: %w", err), + ) + } + out.ReasoningPerToken = &d + } + + if p.CacheWritePerToken != nil { + d, err := decimalFromString(*p.CacheWritePerToken) + if err != nil { + return llmcost.ModelPricing{}, models.NewGenericValidationError( + fmt.Errorf("invalid cache_write_per_token: %w", err), + ) + } + out.CacheWritePerToken = &d + } + + return out, nil +} + +func apiCreateOverrideToDomain(ns string, body api.LLMCostOverrideCreate) (llmcost.CreateOverrideInput, error) { + pricing, err := apiPricingToDomain(body.Pricing) + if err != nil { + return llmcost.CreateOverrideInput{}, err + } + + input := llmcost.CreateOverrideInput{ + Namespace: ns, + Provider: llmcost.Provider(body.Provider), + ModelID: body.ModelId, + Pricing: pricing, + Currency: body.Currency, + EffectiveFrom: body.EffectiveFrom, + } + + if body.ModelName != nil { + input.ModelName = *body.ModelName + } + + if body.EffectiveTo != nil { + input.EffectiveTo = body.EffectiveTo + } + + return input, nil +} + +func decimalFromString(s string) (alpacadecimal.Decimal, error) { + v, err := alpacadecimal.NewFromString(s) + if err != nil { + return alpacadecimal.Decimal{}, models.NewGenericValidationError( + fmt.Errorf("invalid decimal: %w", err), + ) + } + + return v, nil +} + +// FromAPILlmCostSortField validates a v3 (snake_case) LLM cost price sort field, +// returning it unchanged because the price adapter matches these wire strings +// directly. Returns a 400 for any unsupported field. +func FromAPILlmCostSortField(ctx context.Context, field string) (string, error) { + switch field { + case "id", "provider.id", "model.id", "effective_from", "effective_to": + return field, nil + default: + return "", apierrors.NewUnsupportedSortFieldError( + ctx, field, "id", "provider.id", "model.id", "effective_from", "effective_to", + ) + } +} diff --git a/api/v3/handlers/llmcost/convert_test.go b/api/v3/handlers/llmcost/convert_test.go new file mode 100644 index 0000000000000000000000000000000000000000..0994f9274fc9354ff40f9f0c8a32667f3ba91859 --- /dev/null +++ b/api/v3/handlers/llmcost/convert_test.go @@ -0,0 +1,22 @@ +package llmcost + +import ( + "testing" + + "github.com/samber/lo" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + api "github.com/openmeterio/openmeter/api/v3" +) + +func TestFilterSourceInListPricesParams(t *testing.T) { + // Compile-time assertion that the generated API type includes the Source field. + filter := &api.ListLLMCostPricesParamsFilter{ + Source: &api.StringFieldFilter{ + Eq: lo.ToPtr("system"), + }, + } + require.NotNil(t, filter.Source) + assert.Equal(t, lo.ToPtr("system"), filter.Source.Eq) +} diff --git a/api/v3/handlers/llmcost/create_override.go b/api/v3/handlers/llmcost/create_override.go new file mode 100644 index 0000000000000000000000000000000000000000..94af77fdca981e277400d6108a0b9dcdf6603b8b --- /dev/null +++ b/api/v3/handlers/llmcost/create_override.go @@ -0,0 +1,52 @@ +package llmcost + +import ( + "context" + "fmt" + "net/http" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/apierrors" + "github.com/openmeterio/openmeter/api/v3/request" + "github.com/openmeterio/openmeter/openmeter/llmcost" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" +) + +type ( + CreateOverrideRequest = llmcost.CreateOverrideInput + CreateOverrideResponse = api.LLMCostPrice + CreateOverrideHandler = httptransport.Handler[CreateOverrideRequest, CreateOverrideResponse] +) + +func (h *handler) CreateOverride() CreateOverrideHandler { + return httptransport.NewHandler( + func(ctx context.Context, r *http.Request) (CreateOverrideRequest, error) { + var body api.LLMCostOverrideCreate + if err := request.ParseBody(r, &body); err != nil { + return CreateOverrideRequest{}, err + } + + ns, err := h.resolveNamespace(ctx) + if err != nil { + return CreateOverrideRequest{}, err + } + + return apiCreateOverrideToDomain(ns, body) + }, + func(ctx context.Context, req CreateOverrideRequest) (CreateOverrideResponse, error) { + price, err := h.service.CreateOverride(ctx, req) + if err != nil { + return CreateOverrideResponse{}, fmt.Errorf("failed to create llm cost override: %w", err) + } + + return domainPriceToAPI(price), nil + }, + commonhttp.JSONResponseEncoderWithStatus[CreateOverrideResponse](http.StatusCreated), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("create-llm-cost-override"), + httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()), + )..., + ) +} diff --git a/api/v3/handlers/llmcost/delete_override.go b/api/v3/handlers/llmcost/delete_override.go new file mode 100644 index 0000000000000000000000000000000000000000..c2b400240a35a4cd74c17bbcef54ed2d0e2b7e35 --- /dev/null +++ b/api/v3/handlers/llmcost/delete_override.go @@ -0,0 +1,48 @@ +package llmcost + +import ( + "context" + "net/http" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/apierrors" + "github.com/openmeterio/openmeter/openmeter/llmcost" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" +) + +type ( + DeleteOverrideRequest = llmcost.DeleteOverrideInput + DeleteOverrideResponse = interface{} + DeleteOverrideHandler = httptransport.HandlerWithArgs[DeleteOverrideRequest, DeleteOverrideResponse, api.ULID] +) + +func (h *handler) DeleteOverride() DeleteOverrideHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, overrideID api.ULID) (DeleteOverrideRequest, error) { + ns, err := h.resolveNamespace(ctx) + if err != nil { + return DeleteOverrideRequest{}, err + } + + return DeleteOverrideRequest{ + ID: overrideID, + Namespace: ns, + }, nil + }, + func(ctx context.Context, req DeleteOverrideRequest) (DeleteOverrideResponse, error) { + err := h.service.DeleteOverride(ctx, req) + if err != nil { + return nil, err + } + + return nil, nil + }, + commonhttp.EmptyResponseEncoder[DeleteOverrideResponse](http.StatusNoContent), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("delete-llm-cost-override"), + httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()), + )..., + ) +} diff --git a/api/v3/handlers/llmcost/get_price.go b/api/v3/handlers/llmcost/get_price.go new file mode 100644 index 0000000000000000000000000000000000000000..cdde041cd25f4a8c9e7d4cfa9b6a03f155a599e8 --- /dev/null +++ b/api/v3/handlers/llmcost/get_price.go @@ -0,0 +1,48 @@ +package llmcost + +import ( + "context" + "net/http" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/apierrors" + "github.com/openmeterio/openmeter/openmeter/llmcost" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" +) + +type ( + GetPriceRequest = llmcost.GetPriceInput + GetPriceResponse = api.LLMCostPrice + GetPriceHandler = httptransport.HandlerWithArgs[GetPriceRequest, GetPriceResponse, api.ULID] +) + +func (h *handler) GetPrice() GetPriceHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, priceID api.ULID) (GetPriceRequest, error) { + ns, err := h.resolveNamespace(ctx) + if err != nil { + return GetPriceRequest{}, err + } + + return GetPriceRequest{ + ID: priceID, + Namespace: ns, + }, nil + }, + func(ctx context.Context, request GetPriceRequest) (GetPriceResponse, error) { + price, err := h.service.GetPrice(ctx, request) + if err != nil { + return GetPriceResponse{}, err + } + + return domainPriceToAPI(price), nil + }, + commonhttp.JSONResponseEncoderWithStatus[GetPriceResponse](http.StatusOK), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("get-llm-cost-price"), + httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()), + )..., + ) +} diff --git a/api/v3/handlers/llmcost/handler.go b/api/v3/handlers/llmcost/handler.go new file mode 100644 index 0000000000000000000000000000000000000000..9d8456e00178d793e4e82955e6582c8e276f5dfb --- /dev/null +++ b/api/v3/handlers/llmcost/handler.go @@ -0,0 +1,34 @@ +package llmcost + +import ( + "context" + + "github.com/openmeterio/openmeter/openmeter/llmcost" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" +) + +type Handler interface { + ListPrices() ListPricesHandler + GetPrice() GetPriceHandler + ListOverrides() ListOverridesHandler + CreateOverride() CreateOverrideHandler + DeleteOverride() DeleteOverrideHandler +} + +type handler struct { + resolveNamespace func(ctx context.Context) (string, error) + service llmcost.Service + options []httptransport.HandlerOption +} + +func New( + resolveNamespace func(ctx context.Context) (string, error), + service llmcost.Service, + options ...httptransport.HandlerOption, +) Handler { + return &handler{ + resolveNamespace: resolveNamespace, + service: service, + options: options, + } +} diff --git a/api/v3/handlers/llmcost/list_overrides.go b/api/v3/handlers/llmcost/list_overrides.go new file mode 100644 index 0000000000000000000000000000000000000000..c1ff50ff43077d5f9a85d39d78382fecf15e34c5 --- /dev/null +++ b/api/v3/handlers/llmcost/list_overrides.go @@ -0,0 +1,112 @@ +package llmcost + +import ( + "context" + "fmt" + "net/http" + + "github.com/samber/lo" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/apierrors" + "github.com/openmeterio/openmeter/api/v3/filters" + "github.com/openmeterio/openmeter/api/v3/response" + "github.com/openmeterio/openmeter/openmeter/llmcost" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" + "github.com/openmeterio/openmeter/pkg/pagination" +) + +type ( + ListOverridesRequest = llmcost.ListOverridesInput + ListOverridesResponse = response.PagePaginationResponse[api.LLMCostPrice] + ListOverridesParams = api.ListLlmCostOverridesParams + ListOverridesHandler = httptransport.HandlerWithArgs[ListOverridesRequest, ListOverridesResponse, ListOverridesParams] +) + +func (h *handler) ListOverrides() ListOverridesHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, params ListOverridesParams) (ListOverridesRequest, error) { + ns, err := h.resolveNamespace(ctx) + if err != nil { + return ListOverridesRequest{}, err + } + + req := ListOverridesRequest{ + Namespace: ns, + } + + // Pagination + req.Page = pagination.NewPage(1, 20) + + if params.Page != nil { + req.Page = pagination.NewPage( + lo.FromPtrOr(params.Page.Number, 1), + lo.FromPtrOr(params.Page.Size, 20), + ) + + if err := req.Page.Validate(); err != nil { + return req, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{ + {Field: "page", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery}, + }) + } + } + + // Filters + if params.Filter != nil { + provider, err := filters.FromAPIFilterString(params.Filter.Provider) + if err != nil { + return ListOverridesRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{ + {Field: "filter[provider]", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery}, + }) + } + req.Provider = provider + modelID, err := filters.FromAPIFilterString(params.Filter.ModelId) + if err != nil { + return ListOverridesRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{ + {Field: "filter[model_id]", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery}, + }) + } + req.ModelID = modelID + modelName, err := filters.FromAPIFilterString(params.Filter.ModelName) + if err != nil { + return ListOverridesRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{ + {Field: "filter[model_name]", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery}, + }) + } + req.ModelName = modelName + currency, err := filters.FromAPIFilterString(params.Filter.Currency) + if err != nil { + return ListOverridesRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{ + {Field: "filter[currency]", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery}, + }) + } + req.Currency = currency + } + + return req, nil + }, + func(ctx context.Context, request ListOverridesRequest) (ListOverridesResponse, error) { + result, err := h.service.ListOverrides(ctx, request) + if err != nil { + return ListOverridesResponse{}, fmt.Errorf("failed to list llm cost overrides: %w", err) + } + + items := lo.Map(result.Items, func(item llmcost.Price, _ int) api.LLMCostPrice { + return domainPriceToAPI(item) + }) + + return response.NewPagePaginationResponse(items, response.PageMetaPage{ + Size: request.Page.PageSize, + Number: request.Page.PageNumber, + Total: lo.ToPtr(result.TotalCount), + }), nil + }, + commonhttp.JSONResponseEncoderWithStatus[ListOverridesResponse](http.StatusOK), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("list-llm-cost-overrides"), + httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()), + )..., + ) +} diff --git a/api/v3/handlers/llmcost/list_prices.go b/api/v3/handlers/llmcost/list_prices.go new file mode 100644 index 0000000000000000000000000000000000000000..c20102de2ed6a3498763e7ad937652ebf52eee3b --- /dev/null +++ b/api/v3/handlers/llmcost/list_prices.go @@ -0,0 +1,138 @@ +package llmcost + +import ( + "context" + "fmt" + "net/http" + + "github.com/samber/lo" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/apierrors" + "github.com/openmeterio/openmeter/api/v3/filters" + "github.com/openmeterio/openmeter/api/v3/request" + "github.com/openmeterio/openmeter/api/v3/response" + "github.com/openmeterio/openmeter/openmeter/llmcost" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" + "github.com/openmeterio/openmeter/pkg/pagination" +) + +type ( + ListPricesRequest = llmcost.ListPricesInput + ListPricesResponse = response.PagePaginationResponse[api.LLMCostPrice] + ListPricesParams = api.ListLlmCostPricesParams + ListPricesHandler = httptransport.HandlerWithArgs[ListPricesRequest, ListPricesResponse, ListPricesParams] +) + +func (h *handler) ListPrices() ListPricesHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, params ListPricesParams) (ListPricesRequest, error) { + ns, err := h.resolveNamespace(ctx) + if err != nil { + return ListPricesRequest{}, err + } + + req := ListPricesRequest{ + Namespace: ns, + } + + // Pagination + req.Page = pagination.NewPage(1, 20) + + if params.Page != nil { + req.Page = pagination.NewPage( + lo.FromPtrOr(params.Page.Number, 1), + lo.FromPtrOr(params.Page.Size, 20), + ) + + if err := req.Page.Validate(); err != nil { + return req, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{ + {Field: "page", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery}, + }) + } + } + + // Sort + if params.Sort != nil { + sort, err := request.ParseSortBy(*params.Sort) + if err != nil { + return req, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{ + {Field: "sort", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery}, + }) + } + + orderBy, err := FromAPILlmCostSortField(ctx, sort.Field) + if err != nil { + return req, err + } + + req.OrderBy = orderBy + req.Order = sort.Order.ToSortxOrder() + } + + // Filters + if params.Filter != nil { + provider, err := filters.FromAPIFilterString(params.Filter.Provider) + if err != nil { + return ListPricesRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{ + {Field: "filter[provider]", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery}, + }) + } + req.Provider = provider + modelID, err := filters.FromAPIFilterString(params.Filter.ModelId) + if err != nil { + return ListPricesRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{ + {Field: "filter[model_id]", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery}, + }) + } + req.ModelID = modelID + modelName, err := filters.FromAPIFilterString(params.Filter.ModelName) + if err != nil { + return ListPricesRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{ + {Field: "filter[model_name]", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery}, + }) + } + req.ModelName = modelName + currency, err := filters.FromAPIFilterString(params.Filter.Currency) + if err != nil { + return ListPricesRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{ + {Field: "filter[currency]", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery}, + }) + } + req.Currency = currency + source, err := filters.FromAPIFilterString(params.Filter.Source) + if err != nil { + return ListPricesRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{ + {Field: "filter[source]", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery}, + }) + } + req.Source = source + } + + return req, nil + }, + func(ctx context.Context, request ListPricesRequest) (ListPricesResponse, error) { + result, err := h.service.ListPrices(ctx, request) + if err != nil { + return ListPricesResponse{}, fmt.Errorf("failed to list llm cost prices: %w", err) + } + + items := lo.Map(result.Items, func(item llmcost.Price, _ int) api.LLMCostPrice { + return domainPriceToAPI(item) + }) + + return response.NewPagePaginationResponse(items, response.PageMetaPage{ + Size: request.Page.PageSize, + Number: request.Page.PageNumber, + Total: lo.ToPtr(result.TotalCount), + }), nil + }, + commonhttp.JSONResponseEncoderWithStatus[ListPricesResponse](http.StatusOK), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("list-llm-cost-prices"), + httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()), + )..., + ) +} diff --git a/api/v3/handlers/meters/convert.gen.go b/api/v3/handlers/meters/convert.gen.go new file mode 100644 index 0000000000000000000000000000000000000000..5821f24541c0e02f913a22dbdbf99722ab2a8a21 --- /dev/null +++ b/api/v3/handlers/meters/convert.gen.go @@ -0,0 +1,79 @@ +// Code generated by github.com/jmattheis/goverter, DO NOT EDIT. +//go:build !goverter + +package meters + +import ( + v3 "github.com/openmeterio/openmeter/api/v3" + response "github.com/openmeterio/openmeter/api/v3/response" + meter "github.com/openmeterio/openmeter/openmeter/meter" + "time" +) + +func init() { + FromAPICreateMeterRequest = func(context string, source v3.CreateMeterRequest) (meter.CreateMeterInput, error) { + var meterCreateMeterInput meter.CreateMeterInput + meterCreateMeterInput.Namespace = NamespaceFromContext(context) + meterCreateMeterInput.Name = source.Name + meterCreateMeterInput.Key = source.Key + meterCreateMeterInput.Description = source.Description + meterCreateMeterInput.Aggregation = FromAPIMeterAggregation(source.Aggregation) + meterCreateMeterInput.EventType = source.EventType + meterCreateMeterInput.EventFrom = source.EventsFrom + meterCreateMeterInput.ValueProperty = source.ValueProperty + if source.Dimensions != nil { + meterCreateMeterInput.GroupBy = (*source.Dimensions) + } + modelsMetadata, err := ConvertLabelsToMetadata(source.Labels) + if err != nil { + return meterCreateMeterInput, err + } + meterCreateMeterInput.Metadata = modelsMetadata + return meterCreateMeterInput, nil + } + ToAPIMeter = func(source meter.Meter) v3.Meter { + var v3Meter v3.Meter + v3Meter.Aggregation = ToAPIMeterAggregation(source.Aggregation) + v3Meter.CreatedAt = timeTimeToTimeTime(source.ManagedResource.ManagedModel.CreatedAt) + v3Meter.DeletedAt = source.ManagedResource.ManagedModel.DeletedAt + v3Meter.Description = source.ManagedResource.Description + v3Meter.Dimensions = &source.GroupBy + v3Meter.EventType = source.EventType + v3Meter.EventsFrom = source.EventFrom + v3Meter.Id = source.ManagedResource.ID + v3Meter.Key = source.Key + v3Meter.Labels = ConvertMetadataAnnotationsToLabels(source) + v3Meter.Name = source.ManagedResource.Name + v3Meter.UpdatedAt = timeTimeToTimeTime(source.ManagedResource.ManagedModel.UpdatedAt) + v3Meter.ValueProperty = source.ValueProperty + return v3Meter + } + ToAPIMeterPagePaginatedResponse = func(source response.PagePaginationResponse[meter.Meter]) v3.MeterPagePaginatedResponse { + var v3MeterPagePaginatedResponse v3.MeterPagePaginatedResponse + if source.Data != nil { + v3MeterPagePaginatedResponse.Data = make([]v3.Meter, len(source.Data)) + for i := 0; i < len(source.Data); i++ { + v3MeterPagePaginatedResponse.Data[i] = ToAPIMeter(source.Data[i]) + } + } + v3MeterPagePaginatedResponse.Meta = responsePageMetaToV3PaginatedMeta(source.Meta) + return v3MeterPagePaginatedResponse + } +} +func responsePageMetaPageToV3PageMeta(source response.PageMetaPage) v3.PageMeta { + var v3PageMeta v3.PageMeta + v3PageMeta.Number = IntToFloat32(source.Number) + v3PageMeta.Size = IntToFloat32(source.Size) + if source.Total != nil { + v3PageMeta.Total = IntToFloat32(*source.Total) + } + return v3PageMeta +} +func responsePageMetaToV3PaginatedMeta(source response.PageMeta) v3.PaginatedMeta { + var v3PaginatedMeta v3.PaginatedMeta + v3PaginatedMeta.Page = responsePageMetaPageToV3PageMeta(source.Page) + return v3PaginatedMeta +} +func timeTimeToTimeTime(source time.Time) time.Time { + return source +} diff --git a/api/v3/handlers/meters/convert.go b/api/v3/handlers/meters/convert.go new file mode 100644 index 0000000000000000000000000000000000000000..b7f7810d816ea83360c1cdf24a2202420c5ea3c7 --- /dev/null +++ b/api/v3/handlers/meters/convert.go @@ -0,0 +1,204 @@ +//go:generate go run github.com/jmattheis/goverter/cmd/goverter gen ./ +package meters + +import ( + "context" + + "github.com/alpacahq/alpacadecimal" + "github.com/samber/lo" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/apierrors" + "github.com/openmeterio/openmeter/api/v3/handlers/meters/query" + "github.com/openmeterio/openmeter/api/v3/labels" + "github.com/openmeterio/openmeter/api/v3/response" + "github.com/openmeterio/openmeter/openmeter/meter" + "github.com/openmeterio/openmeter/pkg/models" +) + +// goverter:variables +// goverter:skipCopySameType +// goverter:output:file ./convert.gen.go +// goverter:useZeroValueOnPointerInconsistency +// goverter:useUnderlyingTypeMethods +// goverter:matchIgnoreCase +// goverter:extend FromAPIMeterAggregation +// goverter:extend ToAPIMeterAggregation +// goverter:extend ConvertMetadataAnnotationsToLabels +// goverter:extend ConvertLabelsToMetadata +// goverter:extend IntToFloat32 +var ( + // goverter:context namespace + // goverter:map Namespace | NamespaceFromContext + // goverter:map Dimensions GroupBy + // goverter:map Labels Metadata + // goverter:map EventsFrom EventFrom + // goverter:ignore Annotations + // goverter:ignore inputOptions + FromAPICreateMeterRequest func(namespace string, createMeterRequest api.CreateMeterRequest) (meter.CreateMeterInput, error) + // goverter:map GroupBy Dimensions + // goverter:map EventFrom EventsFrom + // goverter:map ManagedResource.ID Id + // goverter:map ManagedResource.Description Description + // goverter:map ManagedResource.Name Name + // goverter:map ManagedResource.ManagedModel.CreatedAt CreatedAt + // goverter:map ManagedResource.ManagedModel.UpdatedAt UpdatedAt + // goverter:map ManagedResource.ManagedModel.DeletedAt DeletedAt + // goverter:map . Labels | ConvertMetadataAnnotationsToLabels + ToAPIMeter func(meter.Meter) api.Meter + ToAPIMeterPagePaginatedResponse func(meters response.PagePaginationResponse[meter.Meter]) api.MeterPagePaginatedResponse +) + +var ConvertLabelsToMetadata = labels.ToMetadata + +func ConvertMetadataAnnotationsToLabels(source meter.Meter) *api.Labels { + return labels.FromMetadataAnnotations(source.Metadata, source.Annotations) +} + +//goverter:context namespace +func NamespaceFromContext(namespace string) string { + return namespace +} + +func ToAPIMeterAggregation(aggregation meter.MeterAggregation) api.MeterAggregation { + switch aggregation { + case meter.MeterAggregationSum: + return api.MeterAggregationSum + case meter.MeterAggregationCount: + return api.MeterAggregationCount + case meter.MeterAggregationUniqueCount: + return api.MeterAggregationUniqueCount + case meter.MeterAggregationAvg: + return api.MeterAggregationAvg + case meter.MeterAggregationMin: + return api.MeterAggregationMin + case meter.MeterAggregationMax: + return api.MeterAggregationMax + case meter.MeterAggregationLatest: + return api.MeterAggregationLatest + } + + return api.MeterAggregation("") +} + +func FromAPIMeterAggregation(aggregation api.MeterAggregation) meter.MeterAggregation { + switch aggregation { + case api.MeterAggregationSum: + return meter.MeterAggregationSum + case api.MeterAggregationCount: + return meter.MeterAggregationCount + case api.MeterAggregationUniqueCount: + return meter.MeterAggregationUniqueCount + case api.MeterAggregationAvg: + return meter.MeterAggregationAvg + case api.MeterAggregationMin: + return meter.MeterAggregationMin + case api.MeterAggregationMax: + return meter.MeterAggregationMax + case api.MeterAggregationLatest: + return meter.MeterAggregationLatest + } + + return "" +} + +func IntToFloat32(i int) float32 { + return float32(i) +} + +func FromAPIUpdateMeterRequest(namespace string, meterID string, body api.UpdateMeterRequest) (meter.UpdateMeterInput, error) { + input := meter.UpdateMeterInput{ + ID: models.NamespacedID{ + Namespace: namespace, + ID: meterID, + }, + } + + if body.Name != nil { + input.Name = *body.Name + } + + input.Description = body.Description + + if body.Dimensions != nil { + input.GroupBy = *body.Dimensions + } + + if body.Labels != nil { + metadata, err := labels.ToMetadata(body.Labels) + if err != nil { + return meter.UpdateMeterInput{}, err + } + + input.Metadata = metadata + } + + return input, nil +} + +// ConvertMetadataToLabels converts models.Metadata to api.Labels. +// Always returns an initialized map (never nil) so JSON serializes to {} instead of null. +func ConvertMetadataToLabels(source models.Metadata) *api.Labels { + labels := make(api.Labels) + for k, v := range source { + labels[k] = v + } + return &labels +} + +func ToAPIMeterQueryRow(row meter.MeterQueryRow) api.MeterQueryRow { + dimensions := make(map[string]string) + + if row.Subject != nil { + dimensions[query.DimensionSubject] = *row.Subject + } + + if row.CustomerID != nil { + dimensions[query.DimensionCustomerID] = *row.CustomerID + } + + for key, value := range row.GroupBy { + if key == query.DimensionSubject || key == query.DimensionCustomerID { + continue + } + if value != nil { + dimensions[key] = *value + } + } + + return api.MeterQueryRow{ + Value: alpacadecimal.NewFromFloat(row.Value).String(), + From: row.WindowStart, + To: row.WindowEnd, + Dimensions: dimensions, + } +} + +func ToAPIMeterQueryResult(from *api.DateTime, to *api.DateTime, rows []meter.MeterQueryRow) api.MeterQueryResult { + return api.MeterQueryResult{ + From: from, + To: to, + Data: lo.Map(rows, func(row meter.MeterQueryRow, _ int) api.MeterQueryRow { + return ToAPIMeterQueryRow(row) + }), + } +} + +func FromAPIMeterSortField(ctx context.Context, field string) (meter.OrderBy, error) { + switch field { + case "key": + return meter.OrderByKey, nil + case "name": + return meter.OrderByName, nil + case "aggregation": + return meter.OrderByAggregation, nil + case "created_at": + return meter.OrderByCreatedAt, nil + case "updated_at": + return meter.OrderByUpdatedAt, nil + default: + return "", apierrors.NewUnsupportedSortFieldError( + ctx, field, "key", "name", "aggregation", "created_at", "updated_at", + ) + } +} diff --git a/api/v3/handlers/meters/create.go b/api/v3/handlers/meters/create.go new file mode 100644 index 0000000000000000000000000000000000000000..569f4cea6ddf54c4657329412bff51d0e83c6e61 --- /dev/null +++ b/api/v3/handlers/meters/create.go @@ -0,0 +1,58 @@ +package meters + +import ( + "context" + "net/http" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/apierrors" + "github.com/openmeterio/openmeter/api/v3/request" + "github.com/openmeterio/openmeter/openmeter/meter" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" +) + +type ( + CreateMeterRequest = meter.CreateMeterInput + CreateMeterResponse = api.Meter + CreateMeterHandler httptransport.Handler[CreateMeterRequest, CreateMeterResponse] +) + +// CreateMeter returns a new httptransport.Handler for creating a meter. +func (h *handler) CreateMeter() CreateMeterHandler { + return httptransport.NewHandler( + func(ctx context.Context, r *http.Request) (CreateMeterRequest, error) { + body := api.CreateMeterRequest{} + if err := request.ParseBody(r, &body); err != nil { + return CreateMeterRequest{}, err + } + + if body.Dimensions != nil { + if err := validateDimensionsWithoutReserved(*body.Dimensions); err != nil { + return CreateMeterRequest{}, err + } + } + + ns, err := h.resolveNamespace(ctx) + if err != nil { + return CreateMeterRequest{}, err + } + + return FromAPICreateMeterRequest(ns, body) + }, + func(ctx context.Context, request CreateMeterRequest) (CreateMeterResponse, error) { + m, err := h.service.CreateMeter(ctx, request) + if err != nil { + return CreateMeterResponse{}, err + } + + return ToAPIMeter(m), nil + }, + commonhttp.JSONResponseEncoderWithStatus[CreateMeterResponse](http.StatusCreated), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("create-meter"), + httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()), + )..., + ) +} diff --git a/api/v3/handlers/meters/delete.go b/api/v3/handlers/meters/delete.go new file mode 100644 index 0000000000000000000000000000000000000000..8c8f4965af322557955c59c5c4b34c9a3d7f3a36 --- /dev/null +++ b/api/v3/handlers/meters/delete.go @@ -0,0 +1,56 @@ +package meters + +import ( + "context" + "net/http" + + "github.com/openmeterio/openmeter/api/v3/apierrors" + meter "github.com/openmeterio/openmeter/openmeter/meter" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" +) + +type ( + DeleteMeterRequest struct { + Namespace string + IDOrSlug string + } + DeleteMeterResponse = interface{} + DeleteMeterParams = string + DeleteMeterHandler httptransport.HandlerWithArgs[DeleteMeterRequest, DeleteMeterResponse, DeleteMeterParams] +) + +// DeleteMeter returns a handler for deleting a meter. +func (h *handler) DeleteMeter() DeleteMeterHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, meterID DeleteMeterParams) (DeleteMeterRequest, error) { + ns, err := h.resolveNamespace(ctx) + if err != nil { + return DeleteMeterRequest{}, err + } + + return DeleteMeterRequest{ + Namespace: ns, + IDOrSlug: meterID, + }, nil + }, + func(ctx context.Context, request DeleteMeterRequest) (DeleteMeterResponse, error) { + // FIXME: make delete idempotent, return 204 for repeated deletion + err := h.service.DeleteMeter(ctx, meter.DeleteMeterInput{ + Namespace: request.Namespace, + IDOrSlug: request.IDOrSlug, + }) + if err != nil { + return nil, err + } + + return nil, nil + }, + commonhttp.EmptyResponseEncoder[DeleteMeterResponse](http.StatusNoContent), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("delete-meter"), + httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()), + )..., + ) +} diff --git a/api/v3/handlers/meters/dimensions.go b/api/v3/handlers/meters/dimensions.go new file mode 100644 index 0000000000000000000000000000000000000000..33b423378af2a6e93d730e626fc131d37c6db91d --- /dev/null +++ b/api/v3/handlers/meters/dimensions.go @@ -0,0 +1,13 @@ +package meters + +import "github.com/openmeterio/openmeter/api/v3/handlers/meters/query" + +func validateDimensionsWithoutReserved[T any](dimensions map[string]T) error { + for dimension := range dimensions { + if query.IsReservedDimension(dimension) { + return NewReservedDimensionError(dimension) + } + } + + return nil +} diff --git a/api/v3/handlers/meters/errors.go b/api/v3/handlers/meters/errors.go new file mode 100644 index 0000000000000000000000000000000000000000..a47c0fdf0bb184411eb64b6246fc63caa9720f86 --- /dev/null +++ b/api/v3/handlers/meters/errors.go @@ -0,0 +1,22 @@ +package meters + +import ( + "net/http" + + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/models" +) + +const ErrCodeReservedDimension models.ErrorCode = "reserved_dimension" + +var ErrReservedDimension = models.NewValidationIssue( + ErrCodeReservedDimension, + "dimension name is reserved", + models.WithFieldString("dimensions"), + models.WithCriticalSeverity(), + commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest), +) + +func NewReservedDimensionError(dimension string) error { + return ErrReservedDimension.WithPathString("dimensions", dimension).WithAttr("value", dimension) +} diff --git a/api/v3/handlers/meters/get.go b/api/v3/handlers/meters/get.go new file mode 100644 index 0000000000000000000000000000000000000000..2fe309c2b9dcdb76cb629168d1042f93d4c05321 --- /dev/null +++ b/api/v3/handlers/meters/get.go @@ -0,0 +1,51 @@ +package meters + +import ( + "context" + "net/http" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/apierrors" + "github.com/openmeterio/openmeter/openmeter/meter" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" +) + +type ( + GetMeterRequest = meter.GetMeterInput + GetMeterResponse = api.Meter + GetMeterParams = string + GetMeterHandler httptransport.HandlerWithArgs[GetMeterRequest, GetMeterResponse, GetMeterParams] +) + +// GetMeter returns a handler for getting a meter. +func (h *handler) GetMeter() GetMeterHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, meterID GetMeterParams) (GetMeterRequest, error) { + ns, err := h.resolveNamespace(ctx) + if err != nil { + return GetMeterRequest{}, err + } + + return GetMeterRequest{ + Namespace: ns, + IDOrSlug: meterID, + }, nil + }, + func(ctx context.Context, request GetMeterRequest) (GetMeterResponse, error) { + // Get the meter + m, err := h.service.GetMeterByIDOrSlug(ctx, request) + if err != nil { + return GetMeterResponse{}, err + } + + return ToAPIMeter(m), nil + }, + commonhttp.JSONResponseEncoderWithStatus[GetMeterResponse](http.StatusOK), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("get-meter"), + httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()), + )..., + ) +} diff --git a/api/v3/handlers/meters/handler.go b/api/v3/handlers/meters/handler.go new file mode 100644 index 0000000000000000000000000000000000000000..2669d5020e1b1b61b84e6e02d9d8ad844243355b --- /dev/null +++ b/api/v3/handlers/meters/handler.go @@ -0,0 +1,44 @@ +package meters + +import ( + "context" + + "github.com/openmeterio/openmeter/openmeter/customer" + "github.com/openmeterio/openmeter/openmeter/meter" + "github.com/openmeterio/openmeter/openmeter/streaming" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" +) + +type Handler interface { + ListMeters() ListMetersHandler + GetMeter() GetMeterHandler + CreateMeter() CreateMeterHandler + UpdateMeter() UpdateMeterHandler + DeleteMeter() DeleteMeterHandler + QueryMeter() QueryMeterHandler + QueryMeterCSV() QueryMeterCSVHandler +} + +type handler struct { + resolveNamespace func(ctx context.Context) (string, error) + service meter.ManageService + streaming streaming.Connector + customerService customer.Service + options []httptransport.HandlerOption +} + +func New( + resolveNamespace func(ctx context.Context) (string, error), + service meter.ManageService, + streaming streaming.Connector, + customerService customer.Service, + options ...httptransport.HandlerOption, +) Handler { + return &handler{ + resolveNamespace: resolveNamespace, + service: service, + streaming: streaming, + customerService: customerService, + options: options, + } +} diff --git a/api/v3/handlers/meters/list.go b/api/v3/handlers/meters/list.go new file mode 100644 index 0000000000000000000000000000000000000000..dd3d0608e387a2a8b0ccca151b0cf13edc06513d --- /dev/null +++ b/api/v3/handlers/meters/list.go @@ -0,0 +1,118 @@ +package meters + +import ( + "context" + "net/http" + + "github.com/samber/lo" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/apierrors" + "github.com/openmeterio/openmeter/api/v3/filters" + "github.com/openmeterio/openmeter/api/v3/request" + "github.com/openmeterio/openmeter/api/v3/response" + "github.com/openmeterio/openmeter/openmeter/meter" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" + "github.com/openmeterio/openmeter/pkg/pagination" +) + +type ( + ListMetersRequest = meter.ListMetersParams + ListMetersResponse = response.PagePaginationResponse[api.Meter] + ListMetersParams = api.ListMetersParams + ListMetersHandler httptransport.HandlerWithArgs[ListMetersRequest, ListMetersResponse, ListMetersParams] +) + +func (h *handler) ListMeters() ListMetersHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, params ListMetersParams) (ListMetersRequest, error) { + ns, err := h.resolveNamespace(ctx) + if err != nil { + return ListMetersRequest{}, err + } + + page := pagination.NewPage(1, 20) + if params.Page != nil { + page = pagination.NewPage( + lo.FromPtrOr(params.Page.Number, 1), + lo.FromPtrOr(params.Page.Size, 20), + ) + } + + if err := page.Validate(); err != nil { + return ListMetersRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{ + apierrors.InvalidParameter{ + Field: "page", + Reason: err.Error(), + Source: apierrors.InvalidParamSourceQuery, + }, + }) + } + + req := ListMetersRequest{ + Namespace: ns, + Page: page, + } + + if params.Filter != nil { + key, err := filters.FromAPIFilterString(params.Filter.Key) + if err != nil { + return ListMetersRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{ + {Field: "filter[key]", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery}, + }) + } + req.Key = key + + name, err := filters.FromAPIFilterString(params.Filter.Name) + if err != nil { + return ListMetersRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{ + {Field: "filter[name]", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery}, + }) + } + req.Name = name + } + + if params.Sort != nil { + sort, err := request.ParseSortBy(*params.Sort) + if err != nil { + return ListMetersRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{ + {Field: "sort", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery}, + }) + } + orderBy, err := FromAPIMeterSortField(ctx, sort.Field) + if err != nil { + return ListMetersRequest{}, err + } + req.OrderBy = orderBy + req.Order = sort.Order.ToSortxOrder() + } + + return req, nil + }, + func(ctx context.Context, request ListMetersRequest) (ListMetersResponse, error) { + resp, err := h.service.ListMeters(ctx, request) + if err != nil { + return ListMetersResponse{}, err + } + + items := lo.Map(resp.Items, func(item meter.Meter, _ int) api.Meter { + return ToAPIMeter(item) + }) + + r := response.NewPagePaginationResponse(items, response.PageMetaPage{ + Size: request.Page.PageSize, + Number: request.Page.PageNumber, + Total: lo.ToPtr(resp.TotalCount), + }) + + return r, nil + }, + commonhttp.JSONResponseEncoderWithStatus[ListMetersResponse](http.StatusOK), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("list-meters"), + httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()), + )..., + ) +} diff --git a/api/v3/handlers/meters/query.go b/api/v3/handlers/meters/query.go new file mode 100644 index 0000000000000000000000000000000000000000..b0a5649c4bc2c801d30edc60055b4854cfb259b2 --- /dev/null +++ b/api/v3/handlers/meters/query.go @@ -0,0 +1,76 @@ +package meters + +import ( + "context" + "net/http" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/apierrors" + "github.com/openmeterio/openmeter/api/v3/handlers/meters/query" + "github.com/openmeterio/openmeter/api/v3/request" + "github.com/openmeterio/openmeter/openmeter/meter" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" + "github.com/openmeterio/openmeter/pkg/models" +) + +type ( + QueryMeterRequest struct { + models.NamespacedID + Body api.MeterQueryRequest + } + QueryMeterResponse = api.MeterQueryResult + QueryMeterParams = string + QueryMeterHandler httptransport.HandlerWithArgs[QueryMeterRequest, QueryMeterResponse, QueryMeterParams] +) + +func (h *handler) QueryMeter() QueryMeterHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, meterID QueryMeterParams) (QueryMeterRequest, error) { + ns, err := h.resolveNamespace(ctx) + if err != nil { + return QueryMeterRequest{}, err + } + + var body api.MeterQueryRequest + if err := request.ParseBody(r, &body); err != nil { + return QueryMeterRequest{}, err + } + + return QueryMeterRequest{ + NamespacedID: models.NamespacedID{ + Namespace: ns, + ID: meterID, + }, + Body: body, + }, nil + }, + func(ctx context.Context, req QueryMeterRequest) (QueryMeterResponse, error) { + m, err := h.service.GetMeterByIDOrSlug(ctx, meter.GetMeterInput{ + Namespace: req.Namespace, + IDOrSlug: req.ID, + }) + if err != nil { + return QueryMeterResponse{}, err + } + + params, err := query.BuildQueryParams(ctx, m, req.Body, query.NewCustomerResolver(h.customerService)) + if err != nil { + return QueryMeterResponse{}, err + } + + rows, err := h.streaming.QueryMeter(ctx, req.Namespace, m, params) + if err != nil { + return QueryMeterResponse{}, err + } + + return ToAPIMeterQueryResult(req.Body.From, req.Body.To, rows), nil + }, + commonhttp.JSONResponseEncoderWithStatus[QueryMeterResponse](http.StatusOK), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("query-meter"), + httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()), + )..., + ) +} diff --git a/api/v3/handlers/meters/query/convert.go b/api/v3/handlers/meters/query/convert.go new file mode 100644 index 0000000000000000000000000000000000000000..5e006d1ca2a242b84ab70bef1275f5ec9b5ddf21 --- /dev/null +++ b/api/v3/handlers/meters/query/convert.go @@ -0,0 +1,89 @@ +package query + +import ( + "fmt" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/openmeter/meter" +) + +var iso8601ToWindowSize = map[string]meter.WindowSize{ + "PT1M": meter.WindowSizeMinute, + "PT1H": meter.WindowSizeHour, + "P1D": meter.WindowSizeDay, + "P1M": meter.WindowSizeMonth, +} + +var windowSizeToISO8601 = map[meter.WindowSize]string{ + meter.WindowSizeMinute: "PT1M", + meter.WindowSizeHour: "PT1H", + meter.WindowSizeDay: "P1D", + meter.WindowSizeMonth: "P1M", +} + +func ConvertISO8601DurationToWindowSize(duration string) (meter.WindowSize, error) { + ws, ok := iso8601ToWindowSize[duration] + if !ok { + return "", NewInvalidWindowSizeError(duration) + } + return ws, nil +} + +func ConvertWindowSizeToISO8601Duration(ws meter.WindowSize) (string, error) { + if d, ok := windowSizeToISO8601[ws]; ok { + return d, nil + } + return "", fmt.Errorf("unknown WindowSize: %q", ws) +} + +// ExtractStringsFromQueryFilter extracts a flat list of string values from a QueryFilterString. +// Only the eq and in operators are supported; an error is returned if any other operator is set. +func ExtractStringsFromQueryFilter(f *api.QueryFilterString, fieldPath ...string) ([]string, error) { + if f == nil { + return nil, nil + } + + if f.Neq != nil || f.Nin != nil || + f.Contains != nil || f.Ncontains != nil || + f.And != nil || f.Or != nil { + return nil, NewUnsupportedFilterOperatorError(fieldPath...) + } + if f.Eq != nil && f.In != nil { + return nil, NewUnsupportedFilterOperatorError(fieldPath...) + } + + var result []string + if f.Eq != nil { + result = append(result, *f.Eq) + } + if f.In != nil { + result = append(result, *f.In...) + } + return result, nil +} + +// ExtractStringsFromQueryFilterMapItem extracts a flat list of string values from a QueryFilterStringMapItem. +// Only the eq and in operators are supported; an error is returned if any other operator is set. +func ExtractStringsFromQueryFilterMapItem(f *api.QueryFilterStringMapItem, fieldPath ...string) ([]string, error) { + if f == nil { + return nil, nil + } + + if f.Neq != nil || f.Nin != nil || + f.Contains != nil || f.Ncontains != nil || + f.And != nil || f.Or != nil || f.Exists != nil { + return nil, NewUnsupportedFilterOperatorError(fieldPath...) + } + if f.Eq != nil && f.In != nil { + return nil, NewUnsupportedFilterOperatorError(fieldPath...) + } + + var result []string + if f.Eq != nil { + result = append(result, *f.Eq) + } + if f.In != nil { + result = append(result, *f.In...) + } + return result, nil +} diff --git a/api/v3/handlers/meters/query/convert_test.go b/api/v3/handlers/meters/query/convert_test.go new file mode 100644 index 0000000000000000000000000000000000000000..0614af1ab715ee4ec62d7d6caa7180ba786448c6 --- /dev/null +++ b/api/v3/handlers/meters/query/convert_test.go @@ -0,0 +1,146 @@ +package query + +import ( + "testing" + + "github.com/samber/lo" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/openmeter/meter" +) + +func TestConvertISO8601DurationToWindowSize(t *testing.T) { + tests := []struct { + name string + duration string + want meter.WindowSize + wantErr bool + }{ + {name: "minute", duration: "PT1M", want: meter.WindowSizeMinute}, + {name: "hour", duration: "PT1H", want: meter.WindowSizeHour}, + {name: "day", duration: "P1D", want: meter.WindowSizeDay}, + {name: "month", duration: "P1M", want: meter.WindowSizeMonth}, + {name: "invalid", duration: "P1Y", wantErr: true}, + {name: "empty", duration: "", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ConvertISO8601DurationToWindowSize(tt.duration) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestConvertWindowSizeToISO8601Duration(t *testing.T) { + tests := []struct { + name string + ws meter.WindowSize + want string + wantErr bool + }{ + {name: "minute", ws: meter.WindowSizeMinute, want: "PT1M"}, + {name: "hour", ws: meter.WindowSizeHour, want: "PT1H"}, + {name: "day", ws: meter.WindowSizeDay, want: "P1D"}, + {name: "month", ws: meter.WindowSizeMonth, want: "P1M"}, + {name: "unknown", ws: "unknown", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ConvertWindowSizeToISO8601Duration(tt.ws) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestExtractStringsFromQueryFilter(t *testing.T) { + tests := []struct { + name string + filter *api.QueryFilterString + want []string + wantErr bool + }{ + { + name: "nil filter", + filter: nil, + want: nil, + }, + { + name: "eq operator", + filter: &api.QueryFilterString{Eq: lo.ToPtr("hello")}, + want: []string{"hello"}, + }, + { + name: "in operator", + filter: &api.QueryFilterString{In: &[]string{"a", "b", "c"}}, + want: []string{"a", "b", "c"}, + }, + { + name: "eq and in together rejected", + filter: &api.QueryFilterString{ + Eq: lo.ToPtr("x"), + In: &[]string{"y"}, + }, + wantErr: true, + }, + { + name: "neq rejected", + filter: &api.QueryFilterString{Neq: lo.ToPtr("x")}, + wantErr: true, + }, + { + name: "nin rejected", + filter: &api.QueryFilterString{Nin: &[]string{"x"}}, + wantErr: true, + }, + { + name: "contains rejected", + filter: &api.QueryFilterString{Contains: lo.ToPtr("x")}, + wantErr: true, + }, + { + name: "ncontains rejected", + filter: &api.QueryFilterString{Ncontains: lo.ToPtr("x")}, + wantErr: true, + }, + { + name: "and rejected", + filter: &api.QueryFilterString{ + And: &[]api.QueryFilterString{{Eq: lo.ToPtr("x")}}, + }, + wantErr: true, + }, + { + name: "or rejected", + filter: &api.QueryFilterString{ + Or: &[]api.QueryFilterString{{Eq: lo.ToPtr("x")}}, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ExtractStringsFromQueryFilter(tt.filter, "test") + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/api/v3/handlers/meters/query/customers.go b/api/v3/handlers/meters/query/customers.go new file mode 100644 index 0000000000000000000000000000000000000000..94fe91d45bdac44f5bbecc9f4e6c11aadcb20d8c --- /dev/null +++ b/api/v3/handlers/meters/query/customers.go @@ -0,0 +1,53 @@ +package query + +import ( + "context" + "errors" + "fmt" + + "github.com/samber/lo" + + "github.com/openmeterio/openmeter/openmeter/customer" + "github.com/openmeterio/openmeter/openmeter/streaming" +) + +// CustomerResolverFunc resolves customer IDs to streaming.Customer instances. +type CustomerResolverFunc func(ctx context.Context, namespace string, customerIDs []string) ([]customer.Customer, error) + +// NewCustomerResolver returns a CustomerResolverFunc that uses the given customer service. +func NewCustomerResolver(customerService customer.Service) CustomerResolverFunc { + return func(ctx context.Context, namespace string, customerIDs []string) ([]customer.Customer, error) { + if len(customerIDs) == 0 { + return nil, nil + } + + customers, err := customerService.ListCustomers(ctx, customer.ListCustomersInput{ + Namespace: namespace, + CustomerIDs: customerIDs, + IncludeDeleted: true, + }) + if err != nil { + return nil, fmt.Errorf("failed to list customers: %w", err) + } + + customersById := lo.KeyBy(customers.Items, func(c customer.Customer) string { + return c.ID + }) + + var errs []error + for _, id := range customerIDs { + if _, ok := customersById[id]; !ok { + errs = append(errs, NewCustomerNotFoundError(id)) + } + } + + return customers.Items, errors.Join(errs...) + } +} + +// CustomersToStreaming converts a slice of customer.Customer to streaming.Customer. +func CustomersToStreaming(customers []customer.Customer) []streaming.Customer { + return lo.Map(customers, func(c customer.Customer, _ int) streaming.Customer { + return c + }) +} diff --git a/api/v3/handlers/meters/query/dimensions.go b/api/v3/handlers/meters/query/dimensions.go new file mode 100644 index 0000000000000000000000000000000000000000..d5a8cf24ed075e893e0a62c771ebc52f94f9fb1f --- /dev/null +++ b/api/v3/handlers/meters/query/dimensions.go @@ -0,0 +1,27 @@ +package query + +import "github.com/openmeterio/openmeter/openmeter/meter" + +const ( + DimensionSubject = "subject" + DimensionCustomerID = "customer_id" +) + +func IsReservedDimension(dimension string) bool { + switch dimension { + case DimensionSubject, DimensionCustomerID: + return true + default: + return false + } +} + +func IsSupportedGroupByDimension(m meter.Meter, dimension string) bool { + if IsReservedDimension(dimension) { + return true + } + + _, ok := m.GroupBy[dimension] + + return ok +} diff --git a/api/v3/handlers/meters/query/dimensions_test.go b/api/v3/handlers/meters/query/dimensions_test.go new file mode 100644 index 0000000000000000000000000000000000000000..7bd13cb69252f7386677b0b235424cad2a1d405d --- /dev/null +++ b/api/v3/handlers/meters/query/dimensions_test.go @@ -0,0 +1,48 @@ +package query + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/openmeterio/openmeter/openmeter/meter" + "github.com/openmeterio/openmeter/pkg/models" +) + +func TestIsReservedDimension(t *testing.T) { + assert.True(t, IsReservedDimension("subject")) + assert.True(t, IsReservedDimension("customer_id")) + assert.False(t, IsReservedDimension("region")) + assert.False(t, IsReservedDimension("")) +} + +func TestIsSupportedGroupByDimension(t *testing.T) { + m := meter.Meter{ + ManagedResource: models.ManagedResource{ + NamespacedModel: models.NamespacedModel{Namespace: "test"}, + }, + GroupBy: map[string]string{ + "region": "$.region", + "zone": "$.zone", + }, + } + + tests := []struct { + name string + dimension string + want bool + }{ + {name: "reserved subject", dimension: "subject", want: true}, + {name: "reserved customer_id", dimension: "customer_id", want: true}, + {name: "valid group by", dimension: "region", want: true}, + {name: "valid group by zone", dimension: "zone", want: true}, + {name: "unknown dimension", dimension: "unknown", want: false}, + {name: "empty dimension", dimension: "", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, IsSupportedGroupByDimension(m, tt.dimension)) + }) + } +} diff --git a/api/v3/handlers/meters/query/errors.go b/api/v3/handlers/meters/query/errors.go new file mode 100644 index 0000000000000000000000000000000000000000..ccd199fde4d9fc8a12f3d4264cf10ff4fa89012f --- /dev/null +++ b/api/v3/handlers/meters/query/errors.go @@ -0,0 +1,91 @@ +package query + +import ( + "net/http" + + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/models" +) + +const ErrCodeInvalidWindowSize models.ErrorCode = "invalid_window_size" + +var ErrInvalidWindowSize = models.NewValidationIssue( + ErrCodeInvalidWindowSize, + "unsupported window size duration, supported durations are: PT1M (minute), PT1H (hour), P1D (day), P1M (month)", + models.WithFieldString("granularity"), + models.WithCriticalSeverity(), + commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest), +) + +func NewInvalidWindowSizeError(duration string) error { + return ErrInvalidWindowSize.WithAttr("value", duration) +} + +const ErrCodeInvalidTimeZone models.ErrorCode = "invalid_time_zone" + +var ErrInvalidTimeZone = models.NewValidationIssue( + ErrCodeInvalidTimeZone, + "invalid time zone", + models.WithFieldString("time_zone"), + models.WithCriticalSeverity(), + commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest), +) + +func NewInvalidTimeZoneError(tz string) error { + return ErrInvalidTimeZone.WithAttr("value", tz) +} + +const ErrCodeInvalidGroupBy models.ErrorCode = "invalid_group_by" + +var ErrInvalidGroupBy = models.NewValidationIssue( + ErrCodeInvalidGroupBy, + "invalid group by dimension", + models.WithFieldString("group_by_dimensions"), + models.WithCriticalSeverity(), + commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest), +) + +func NewInvalidGroupByError(dimension string) error { + return ErrInvalidGroupBy.WithAttr("value", dimension) +} + +const ErrCodeInvalidDimensionFilter models.ErrorCode = "invalid_dimension_filter" + +var ErrInvalidDimensionFilter = models.NewValidationIssue( + ErrCodeInvalidDimensionFilter, + "invalid dimension filter", + models.WithFieldString("filters", "dimensions"), + models.WithCriticalSeverity(), + commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest), +) + +func NewInvalidDimensionFilterError(dimension string) error { + return ErrInvalidDimensionFilter.WithPathString("filters", "dimensions", dimension) +} + +const ErrCodeUnsupportedFilterOperator models.ErrorCode = "unsupported_filter_operator" + +var ErrUnsupportedFilterOperator = models.NewValidationIssue( + ErrCodeUnsupportedFilterOperator, + "only eq and in filter operators are supported for this field", + models.WithCriticalSeverity(), + commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest), +) + +func NewUnsupportedFilterOperatorError(path ...string) error { + return ErrUnsupportedFilterOperator.WithPathString(append([]string{"filters"}, path...)...) +} + +const ErrCodeCustomerNotFound models.ErrorCode = "customer_not_found" + +var ErrCustomerNotFound = models.NewValidationIssue( + ErrCodeCustomerNotFound, + "customer not found", + models.WithFieldString("filters", "dimensions", DimensionCustomerID), + models.WithCriticalSeverity(), + commonhttp.WithHTTPStatusCodeAttribute(http.StatusNotFound), +) + +func NewCustomerNotFoundError(id string) error { + return ErrCustomerNotFound.WithAttr(DimensionCustomerID, id) +} diff --git a/api/v3/handlers/meters/query/params.go b/api/v3/handlers/meters/query/params.go new file mode 100644 index 0000000000000000000000000000000000000000..eb4f285f967f1ad59da9035096090588e3aeb377 --- /dev/null +++ b/api/v3/handlers/meters/query/params.go @@ -0,0 +1,107 @@ +package query + +import ( + "context" + "fmt" + "slices" + "time" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/request" + "github.com/openmeterio/openmeter/openmeter/meter" + "github.com/openmeterio/openmeter/openmeter/streaming" + "github.com/openmeterio/openmeter/pkg/filter" + "github.com/openmeterio/openmeter/pkg/models" +) + +const maxGroupByFilterComplexityDepth = 2 + +// BuildQueryParams converts a v3 MeterQueryRequest body into streaming.QueryParams. +// It validates groupBy dimensions against the given meter and resolves customer filters. +func BuildQueryParams(ctx context.Context, m meter.Meter, body api.MeterQueryRequest, resolveCustomers CustomerResolverFunc) (streaming.QueryParams, error) { + params := streaming.QueryParams{ + From: body.From, + To: body.To, + } + + if body.Granularity != nil { + ws, err := ConvertISO8601DurationToWindowSize(string(*body.Granularity)) + if err != nil { + return params, err + } + params.WindowSize = &ws + } + + if body.TimeZone != nil { + tz, err := time.LoadLocation(*body.TimeZone) + if err != nil { + return params, NewInvalidTimeZoneError(*body.TimeZone) + } + params.WindowTimeZone = tz + } + + if body.GroupByDimensions != nil { + for _, groupBy := range *body.GroupByDimensions { + if !IsSupportedGroupByDimension(m, groupBy) { + return params, NewInvalidGroupByError(groupBy) + } + if !slices.Contains(params.GroupBy, groupBy) { + params.GroupBy = append(params.GroupBy, groupBy) + } + } + } + + if filters := body.Filters; filters != nil { + if dimensions := filters.Dimensions; dimensions != nil { + for k, v := range *dimensions { + switch k { + case DimensionSubject: + subjects, err := ExtractStringsFromQueryFilterMapItem(&v, "dimensions", DimensionSubject) + if err != nil { + return params, err + } + + params.FilterSubject = subjects + + if len(subjects) > 0 && !slices.Contains(params.GroupBy, DimensionSubject) { + params.GroupBy = append(params.GroupBy, DimensionSubject) + } + + case DimensionCustomerID: + customerIDs, err := ExtractStringsFromQueryFilterMapItem(&v, "dimensions", DimensionCustomerID) + if err != nil { + return params, err + } + + if len(customerIDs) > 0 { + filterCustomers, err := resolveCustomers(ctx, m.Namespace, customerIDs) + if err != nil { + return params, err + } + + params.FilterCustomer = CustomersToStreaming(filterCustomers) + + if !slices.Contains(params.GroupBy, DimensionCustomerID) { + params.GroupBy = append(params.GroupBy, DimensionCustomerID) + } + } + + default: + if _, ok := m.GroupBy[k]; !ok { + return params, NewInvalidDimensionFilterError(k) + } + f := request.ConvertQueryFilterStringMapItem(v) + if err := f.ValidateWithComplexity(maxGroupByFilterComplexityDepth); err != nil { + return params, models.NewGenericValidationError(fmt.Errorf("dimension filter %q: %w", k, err)) + } + if params.FilterGroupBy == nil { + params.FilterGroupBy = make(map[string]filter.FilterString) + } + params.FilterGroupBy[k] = f + } + } + } + } + + return params, nil +} diff --git a/api/v3/handlers/meters/query/params_test.go b/api/v3/handlers/meters/query/params_test.go new file mode 100644 index 0000000000000000000000000000000000000000..35d4e3087535c62cbc0782df6281f4e0b8d98bc2 --- /dev/null +++ b/api/v3/handlers/meters/query/params_test.go @@ -0,0 +1,280 @@ +package query + +import ( + "context" + "testing" + "time" + + "github.com/samber/lo" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/openmeter/customer" + "github.com/openmeterio/openmeter/openmeter/meter" + "github.com/openmeterio/openmeter/pkg/models" +) + +func newTestMeter() meter.Meter { + return meter.Meter{ + ManagedResource: models.ManagedResource{ + NamespacedModel: models.NamespacedModel{Namespace: "test-ns"}, + }, + GroupBy: map[string]string{ + "region": "$.region", + "zone": "$.zone", + }, + } +} + +func noopCustomerResolver(_ context.Context, _ string, _ []string) ([]customer.Customer, error) { + return nil, nil +} + +func TestBuildQueryParams_Empty(t *testing.T) { + m := newTestMeter() + body := api.MeterQueryRequest{} + + params, err := BuildQueryParams(context.Background(), m, body, noopCustomerResolver) + require.NoError(t, err) + assert.Nil(t, params.From) + assert.Nil(t, params.To) + assert.Nil(t, params.WindowSize) + assert.Nil(t, params.WindowTimeZone) + assert.Empty(t, params.GroupBy) +} + +func TestBuildQueryParams_FromTo(t *testing.T) { + m := newTestMeter() + now := time.Now().UTC().Truncate(time.Second) + body := api.MeterQueryRequest{ + From: &now, + To: lo.ToPtr(now.Add(time.Hour)), + } + + params, err := BuildQueryParams(context.Background(), m, body, noopCustomerResolver) + require.NoError(t, err) + assert.Equal(t, &now, params.From) + assert.Equal(t, lo.ToPtr(now.Add(time.Hour)), params.To) +} + +func TestBuildQueryParams_Granularity(t *testing.T) { + m := newTestMeter() + + t.Run("valid granularity", func(t *testing.T) { + gran := api.MeterQueryGranularity("PT1H") + body := api.MeterQueryRequest{Granularity: &gran} + + params, err := BuildQueryParams(context.Background(), m, body, noopCustomerResolver) + require.NoError(t, err) + require.NotNil(t, params.WindowSize) + assert.Equal(t, meter.WindowSizeHour, *params.WindowSize) + }) + + t.Run("invalid granularity", func(t *testing.T) { + gran := api.MeterQueryGranularity("P1Y") + body := api.MeterQueryRequest{Granularity: &gran} + + _, err := BuildQueryParams(context.Background(), m, body, noopCustomerResolver) + require.Error(t, err) + }) +} + +func TestBuildQueryParams_TimeZone(t *testing.T) { + m := newTestMeter() + + t.Run("valid timezone", func(t *testing.T) { + body := api.MeterQueryRequest{TimeZone: lo.ToPtr("America/New_York")} + + params, err := BuildQueryParams(context.Background(), m, body, noopCustomerResolver) + require.NoError(t, err) + require.NotNil(t, params.WindowTimeZone) + assert.Equal(t, "America/New_York", params.WindowTimeZone.String()) + }) + + t.Run("invalid timezone", func(t *testing.T) { + body := api.MeterQueryRequest{TimeZone: lo.ToPtr("Invalid/Zone")} + + _, err := BuildQueryParams(context.Background(), m, body, noopCustomerResolver) + require.Error(t, err) + }) +} + +func TestBuildQueryParams_GroupByDimensions(t *testing.T) { + m := newTestMeter() + + t.Run("valid dimensions", func(t *testing.T) { + body := api.MeterQueryRequest{ + GroupByDimensions: &[]string{"subject", "region"}, + } + + params, err := BuildQueryParams(context.Background(), m, body, noopCustomerResolver) + require.NoError(t, err) + assert.Equal(t, []string{"subject", "region"}, params.GroupBy) + }) + + t.Run("invalid dimension", func(t *testing.T) { + body := api.MeterQueryRequest{ + GroupByDimensions: &[]string{"nonexistent"}, + } + + _, err := BuildQueryParams(context.Background(), m, body, noopCustomerResolver) + require.Error(t, err) + }) +} + +func TestBuildQueryParams_SubjectFilter(t *testing.T) { + m := newTestMeter() + + t.Run("eq filter adds subject to group by", func(t *testing.T) { + body := api.MeterQueryRequest{ + Filters: &api.MeterQueryFilters{ + Dimensions: &map[string]api.QueryFilterStringMapItem{ + DimensionSubject: {Eq: lo.ToPtr("user-1")}, + }, + }, + } + + params, err := BuildQueryParams(context.Background(), m, body, noopCustomerResolver) + require.NoError(t, err) + assert.Equal(t, []string{"user-1"}, params.FilterSubject) + assert.Contains(t, params.GroupBy, DimensionSubject) + }) + + t.Run("does not duplicate subject in group by", func(t *testing.T) { + body := api.MeterQueryRequest{ + GroupByDimensions: &[]string{"subject"}, + Filters: &api.MeterQueryFilters{ + Dimensions: &map[string]api.QueryFilterStringMapItem{ + DimensionSubject: {Eq: lo.ToPtr("user-1")}, + }, + }, + } + + params, err := BuildQueryParams(context.Background(), m, body, noopCustomerResolver) + require.NoError(t, err) + count := 0 + for _, g := range params.GroupBy { + if g == DimensionSubject { + count++ + } + } + assert.Equal(t, 1, count) + }) +} + +func TestBuildQueryParams_CustomerFilter(t *testing.T) { + m := newTestMeter() + + t.Run("resolves customers and adds to group by", func(t *testing.T) { + resolver := func(_ context.Context, _ string, ids []string) ([]customer.Customer, error) { + var customers []customer.Customer + for _, id := range ids { + customers = append(customers, customer.Customer{ + ManagedResource: models.ManagedResource{ + NamespacedModel: models.NamespacedModel{Namespace: "test-ns"}, + ID: id, + }, + }) + } + return customers, nil + } + + body := api.MeterQueryRequest{ + Filters: &api.MeterQueryFilters{ + Dimensions: &map[string]api.QueryFilterStringMapItem{ + DimensionCustomerID: {In: &[]string{"c1", "c2"}}, + }, + }, + } + + params, err := BuildQueryParams(context.Background(), m, body, resolver) + require.NoError(t, err) + assert.Len(t, params.FilterCustomer, 2) + assert.Contains(t, params.GroupBy, DimensionCustomerID) + }) +} + +func TestBuildQueryParams_DimensionFilters(t *testing.T) { + m := newTestMeter() + + t.Run("valid dimension filter", func(t *testing.T) { + body := api.MeterQueryRequest{ + Filters: &api.MeterQueryFilters{ + Dimensions: &map[string]api.QueryFilterStringMapItem{ + "region": {Eq: lo.ToPtr("us-east-1")}, + }, + }, + } + + params, err := BuildQueryParams(context.Background(), m, body, noopCustomerResolver) + require.NoError(t, err) + require.Contains(t, params.FilterGroupBy, "region") + }) + + t.Run("invalid dimension name", func(t *testing.T) { + body := api.MeterQueryRequest{ + Filters: &api.MeterQueryFilters{ + Dimensions: &map[string]api.QueryFilterStringMapItem{ + "nonexistent": {Eq: lo.ToPtr("val")}, + }, + }, + } + + _, err := BuildQueryParams(context.Background(), m, body, noopCustomerResolver) + require.Error(t, err) + }) + + t.Run("multiple operators in dimension filter", func(t *testing.T) { + body := api.MeterQueryRequest{ + Filters: &api.MeterQueryFilters{ + Dimensions: &map[string]api.QueryFilterStringMapItem{ + "region": { + Eq: lo.ToPtr("us-east"), + Neq: lo.ToPtr("us-west"), + }, + }, + }, + } + + _, err := BuildQueryParams(context.Background(), m, body, noopCustomerResolver) + require.Error(t, err) + }) +} + +func TestBuildQueryParams_MultipleSubjectFilterOperators(t *testing.T) { + m := newTestMeter() + body := api.MeterQueryRequest{ + Filters: &api.MeterQueryFilters{ + Dimensions: &map[string]api.QueryFilterStringMapItem{ + DimensionSubject: { + Eq: lo.ToPtr("a"), + Neq: lo.ToPtr("b"), + }, + }, + }, + } + + _, err := BuildQueryParams(context.Background(), m, body, noopCustomerResolver) + require.Error(t, err) +} + +func TestCustomersToStreaming(t *testing.T) { + customers := []customer.Customer{ + { + ManagedResource: models.ManagedResource{ + NamespacedModel: models.NamespacedModel{Namespace: "ns"}, + ID: "c1", + }, + }, + { + ManagedResource: models.ManagedResource{ + NamespacedModel: models.NamespacedModel{Namespace: "ns"}, + ID: "c2", + }, + }, + } + + result := CustomersToStreaming(customers) + require.Len(t, result, 2) +} diff --git a/api/v3/handlers/meters/query_csv.go b/api/v3/handlers/meters/query_csv.go new file mode 100644 index 0000000000000000000000000000000000000000..96d286430a053aefea5190fb80da92e328e855c5 --- /dev/null +++ b/api/v3/handlers/meters/query_csv.go @@ -0,0 +1,222 @@ +package meters + +import ( + "context" + "fmt" + "net/http" + "slices" + "strconv" + "time" + + "github.com/samber/lo" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/apierrors" + "github.com/openmeterio/openmeter/api/v3/handlers/meters/query" + "github.com/openmeterio/openmeter/api/v3/request" + "github.com/openmeterio/openmeter/openmeter/customer" + "github.com/openmeterio/openmeter/openmeter/meter" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" + "github.com/openmeterio/openmeter/pkg/models" +) + +type ( + QueryMeterCSVRequest = QueryMeterRequest + QueryMeterCSVResponse = commonhttp.CSVResponse + QueryMeterCSVParams = QueryMeterParams + QueryMeterCSVHandler httptransport.HandlerWithArgs[QueryMeterCSVRequest, QueryMeterCSVResponse, QueryMeterCSVParams] +) + +const ( + csvColumnFrom = "from" + csvColumnTo = "to" + csvColumnValue = "value" + csvColumnCustomerID = "customer_id" + csvColumnCustomerKey = "customer_key" + csvColumnCustomerName = "customer_name" +) + +func (h *handler) QueryMeterCSV() QueryMeterCSVHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, meterID QueryMeterCSVParams) (QueryMeterCSVRequest, error) { + ns, err := h.resolveNamespace(ctx) + if err != nil { + return QueryMeterCSVRequest{}, err + } + + var body api.MeterQueryRequest + if err := request.ParseBody(r, &body); err != nil { + return QueryMeterCSVRequest{}, err + } + + return QueryMeterCSVRequest{ + NamespacedID: models.NamespacedID{ + Namespace: ns, + ID: meterID, + }, + Body: body, + }, nil + }, + func(ctx context.Context, req QueryMeterCSVRequest) (QueryMeterCSVResponse, error) { + m, err := h.service.GetMeterByIDOrSlug(ctx, meter.GetMeterInput{ + Namespace: req.Namespace, + IDOrSlug: req.ID, + }) + if err != nil { + return nil, err + } + + params, err := query.BuildQueryParams(ctx, m, req.Body, query.NewCustomerResolver(h.customerService)) + if err != nil { + return nil, err + } + + rows, err := h.streaming.QueryMeter(ctx, req.Namespace, m, params) + if err != nil { + return nil, err + } + + // Enrich with customer metadata (key, name) when any row has a CustomerID. + customerIDs := collectCustomerIDs(rows) + + var customersByID map[string]customer.Customer + if len(customerIDs) > 0 { + result, err := h.customerService.ListCustomers(ctx, customer.ListCustomersInput{ + Namespace: req.Namespace, + CustomerIDs: customerIDs, + }) + if err != nil { + return nil, fmt.Errorf("failed to list customers for csv enrichment: %w", err) + } + + customersByID = lo.KeyBy(result.Items, func(c customer.Customer) string { + return c.ID + }) + } + + return newQueryMeterCSVResult(m.Key, params.GroupBy, rows, customersByID), nil + }, + commonhttp.CSVResponseEncoder[QueryMeterCSVResponse], + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("query-meter-csv"), + httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()), + )..., + ) +} + +// collectCustomerIDs returns the distinct non-nil customer IDs referenced by the rows. +func collectCustomerIDs(rows []meter.MeterQueryRow) []string { + ids := make([]string, 0) + for _, row := range rows { + if row.CustomerID == nil { + continue + } + ids = append(ids, *row.CustomerID) + } + return lo.Uniq(ids) +} + +// Column order: +// +// from, to, +// [subject,] +// [customer_id, customer_key, customer_name,] +// , +// value +type queryMeterCSVResult struct { + meterSlug string + groupBy []string + rows []meter.MeterQueryRow + customersByID map[string]customer.Customer +} + +var _ commonhttp.CSVResponse = &queryMeterCSVResult{} + +func newQueryMeterCSVResult( + meterSlug string, + groupBy []string, + rows []meter.MeterQueryRow, + customersByID map[string]customer.Customer, +) *queryMeterCSVResult { + return &queryMeterCSVResult{ + meterSlug: meterSlug, + groupBy: groupBy, + rows: rows, + customersByID: customersByID, + } +} + +func (r *queryMeterCSVResult) FileName() string { + return r.meterSlug +} + +func (r *queryMeterCSVResult) Records() [][]string { + hasSubjectColumn := slices.Contains(r.groupBy, query.DimensionSubject) + hasCustomerColumns := slices.Contains(r.groupBy, query.DimensionCustomerID) + + otherDimensions := make([]string, 0, len(r.groupBy)) + for _, k := range r.groupBy { + switch k { + case query.DimensionSubject, query.DimensionCustomerID: + // Handled as reserved columns. + default: + otherDimensions = append(otherDimensions, k) + } + } + + headers := []string{csvColumnFrom, csvColumnTo} + if hasSubjectColumn { + headers = append(headers, query.DimensionSubject) + } + if hasCustomerColumns { + headers = append(headers, csvColumnCustomerID, csvColumnCustomerKey, csvColumnCustomerName) + } + headers = append(headers, otherDimensions...) + headers = append(headers, csvColumnValue) + + records := make([][]string, 0, len(r.rows)+1) + records = append(records, headers) + + for _, row := range r.rows { + record := make([]string, 0, len(headers)) + record = append(record, + row.WindowStart.Format(time.RFC3339), + row.WindowEnd.Format(time.RFC3339), + ) + + if hasSubjectColumn { + record = append(record, lo.FromPtrOr(row.Subject, "")) + } + + if hasCustomerColumns { + var id, key, name string + if row.CustomerID != nil { + id = *row.CustomerID + if c, ok := r.customersByID[id]; ok { + key = lo.FromPtrOr(c.Key, "") + name = c.Name + } + } + record = append(record, + id, + key, + name, + ) + } + + for _, k := range otherDimensions { + var v string + if ptr, ok := row.GroupBy[k]; ok && ptr != nil { + v = *ptr + } + record = append(record, v) + } + + record = append(record, strconv.FormatFloat(row.Value, 'f', -1, 64)) + records = append(records, record) + } + + return records +} diff --git a/api/v3/handlers/meters/update.go b/api/v3/handlers/meters/update.go new file mode 100644 index 0000000000000000000000000000000000000000..9ca2d24a6f6b53715e987413b91f4d63263fbccd --- /dev/null +++ b/api/v3/handlers/meters/update.go @@ -0,0 +1,59 @@ +package meters + +import ( + "context" + "net/http" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/apierrors" + "github.com/openmeterio/openmeter/api/v3/request" + "github.com/openmeterio/openmeter/openmeter/meter" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" +) + +type ( + UpdateMeterRequest = meter.UpdateMeterInput + UpdateMeterResponse = api.Meter + UpdateMeterParams = string + UpdateMeterHandler httptransport.HandlerWithArgs[UpdateMeterRequest, UpdateMeterResponse, UpdateMeterParams] +) + +// UpdateMeter returns a new httptransport.Handler for updating a meter. +func (h *handler) UpdateMeter() UpdateMeterHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, meterID UpdateMeterParams) (UpdateMeterRequest, error) { + body := api.UpdateMeterRequest{} + if err := request.ParseBody(r, &body); err != nil { + return UpdateMeterRequest{}, err + } + + if body.Dimensions != nil { + if err := validateDimensionsWithoutReserved(*body.Dimensions); err != nil { + return UpdateMeterRequest{}, err + } + } + + ns, err := h.resolveNamespace(ctx) + if err != nil { + return UpdateMeterRequest{}, err + } + + return FromAPIUpdateMeterRequest(ns, meterID, body) + }, + func(ctx context.Context, request UpdateMeterRequest) (UpdateMeterResponse, error) { + m, err := h.service.UpdateMeter(ctx, request) + if err != nil { + return UpdateMeterResponse{}, err + } + + return ToAPIMeter(m), nil + }, + commonhttp.JSONResponseEncoderWithStatus[UpdateMeterResponse](http.StatusOK), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("update-meter"), + httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()), + )..., + ) +} diff --git a/api/v3/handlers/plans/archive.go b/api/v3/handlers/plans/archive.go new file mode 100644 index 0000000000000000000000000000000000000000..97027b514a04d499919dca5e5693b85213e54434 --- /dev/null +++ b/api/v3/handlers/plans/archive.go @@ -0,0 +1,59 @@ +package plans + +import ( + "context" + "fmt" + "net/http" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/apierrors" + "github.com/openmeterio/openmeter/openmeter/productcatalog/plan" + "github.com/openmeterio/openmeter/pkg/clock" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" + "github.com/openmeterio/openmeter/pkg/models" +) + +type ( + ArchivePlanRequest = plan.ArchivePlanInput + ArchivePlanResponse = api.BillingPlan + ArchivePlanParams = string + ArchivePlanHandler httptransport.HandlerWithArgs[ArchivePlanRequest, ArchivePlanResponse, ArchivePlanParams] +) + +func (h *handler) ArchivePlan() ArchivePlanHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, planID ArchivePlanParams) (ArchivePlanRequest, error) { + ns, err := h.resolveNamespace(ctx) + if err != nil { + return ArchivePlanRequest{}, err + } + + return ArchivePlanRequest{ + NamespacedID: models.NamespacedID{ + Namespace: ns, + ID: planID, + }, + EffectiveTo: clock.Now(), + }, nil + }, + func(ctx context.Context, request ArchivePlanRequest) (ArchivePlanResponse, error) { + p, err := h.service.ArchivePlan(ctx, request) + if err != nil { + return ArchivePlanResponse{}, err + } + + if p == nil { + return ArchivePlanResponse{}, fmt.Errorf("failed to archive plan") + } + + return ToAPIBillingPlan(*p) + }, + commonhttp.JSONResponseEncoderWithStatus[ArchivePlanResponse](http.StatusOK), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("archive-plan"), + httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()), + )..., + ) +} diff --git a/api/v3/handlers/plans/convert.go b/api/v3/handlers/plans/convert.go new file mode 100644 index 0000000000000000000000000000000000000000..f60f2f0313c4494ef9c73fe4a3557f10de0a761c --- /dev/null +++ b/api/v3/handlers/plans/convert.go @@ -0,0 +1,1077 @@ +package plans + +import ( + "context" + "encoding/json" + "fmt" + + decimal "github.com/alpacahq/alpacadecimal" + "github.com/invopop/gobl/currency" + "github.com/samber/lo" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/apierrors" + "github.com/openmeterio/openmeter/api/v3/labels" + "github.com/openmeterio/openmeter/openmeter/entitlement" + "github.com/openmeterio/openmeter/openmeter/productcatalog" + "github.com/openmeterio/openmeter/openmeter/productcatalog/plan" + "github.com/openmeterio/openmeter/openmeter/taxcode" + "github.com/openmeterio/openmeter/pkg/datetime" + "github.com/openmeterio/openmeter/pkg/models" +) + +func FromAPIPlanSortField(ctx context.Context, field string) (plan.OrderBy, error) { + switch field { + case "id": + return plan.OrderByID, nil + case "key": + return plan.OrderByKey, nil + case "version": + return plan.OrderByVersion, nil + case "created_at": + return plan.OrderByCreatedAt, nil + case "updated_at": + return plan.OrderByUpdatedAt, nil + default: + return "", apierrors.NewUnsupportedSortFieldError( + ctx, field, "id", "key", "version", "created_at", "updated_at", + ) + } +} + +func ToAPIBillingPlan(p plan.Plan) (api.BillingPlan, error) { + validationIssues, _ := p.AsProductCatalogPlan().ValidationErrors() + + resp := api.BillingPlan{ + BillingCadence: p.BillingCadence.String(), + CreatedAt: p.CreatedAt, + Currency: p.Currency.String(), + DeletedAt: p.DeletedAt, + Description: p.Description, + EffectiveFrom: p.EffectiveFrom, + EffectiveTo: p.EffectiveTo, + Id: p.ID, + Key: p.Key, + Name: p.Name, + UpdatedAt: p.UpdatedAt, + Version: p.Version, + ProRatingEnabled: lo.ToPtr(p.ProRatingConfig.Enabled), + SettlementMode: lo.ToPtr(api.BillingSettlementMode(p.SettlementMode)), + ValidationErrors: ToAPIProductCatalogValidationErrors(validationIssues), + } + + var status api.BillingPlanStatus + switch p.Status() { + case productcatalog.PlanStatusDraft: + status = api.BillingPlanStatusDraft + case productcatalog.PlanStatusActive: + status = api.BillingPlanStatusActive + case productcatalog.PlanStatusArchived: + status = api.BillingPlanStatusArchived + case productcatalog.PlanStatusScheduled: + status = api.BillingPlanStatusScheduled + default: + return resp, fmt.Errorf("invalid PlanStatus: %s", p.Status()) + } + + resp.Status = status + + resp.Phases = make([]api.BillingPlanPhase, 0, len(p.Phases)) + for _, phase := range p.Phases { + billingPhase, err := ToAPIBillingPlanPhase(phase) + if err != nil { + return resp, fmt.Errorf("failed to convert plan phase: %w", err) + } + + resp.Phases = append(resp.Phases, billingPhase) + } + + return resp, nil +} + +func ToAPIBillingPlanPhase(p plan.Phase) (api.BillingPlanPhase, error) { + phase := api.BillingPlanPhase{ + Description: p.Description, + Duration: (*api.ISO8601Duration)(p.Duration.ISOStringPtrOrNil()), + Key: p.Key, + Name: p.Name, + RateCards: make([]api.BillingRateCard, 0, len(p.RateCards)), + } + + for _, rc := range p.RateCards { + billingRC, err := ToAPIBillingRateCard(rc) + if err != nil { + return phase, fmt.Errorf("failed to convert rate card %q: %w", rc.Key(), err) + } + + phase.RateCards = append(phase.RateCards, billingRC) + } + + return phase, nil +} + +func ToAPIBillingRateCard(rc productcatalog.RateCard) (api.BillingRateCard, error) { + meta := rc.AsMeta() + + result := api.BillingRateCard{ + Key: meta.Key, + Name: meta.Name, + Description: meta.Description, + Discounts: ToAPIBillingRateCardDiscount(meta.Discounts), + TaxConfig: ToAPIBillingRateCardTaxConfig(meta.TaxConfig, meta.TaxCode), + } + + if meta.FeatureID != nil { + result.Feature = &api.FeatureReference{ + Id: *meta.FeatureID, + } + } + + switch rc.Type() { + case productcatalog.FlatFeeRateCardType: + if bc := rc.GetBillingCadence(); bc != nil { + result.BillingCadence = lo.ToPtr(bc.ISOString().String()) + } + + if meta.Price != nil { + flatPrice, err := meta.Price.AsFlat() + if err != nil { + return result, fmt.Errorf("failed to read flat price: %w", err) + } + + result.PaymentTerm = lo.ToPtr(api.BillingPricePaymentTerm(flatPrice.PaymentTerm)) + } + + case productcatalog.UsageBasedRateCardType: + bc := rc.GetBillingCadence() + if bc == nil { + return result, fmt.Errorf("usage-based rate card %q missing billing cadence", meta.Key) + } + + result.BillingCadence = lo.ToPtr(bc.ISOString().String()) + + if meta.Price != nil { + result.Commitments = ToAPIBillingSpendCommitments(meta.Price.GetCommitments()) + } + + default: + return result, fmt.Errorf("unknown rate card type: %s", rc.Type()) + } + + price, err := ToAPIBillingPrice(meta.Price) + if err != nil { + return result, fmt.Errorf("failed to convert price: %w", err) + } + + result.Price = price + + if meta.EntitlementTemplate != nil { + ent, err := ToAPIBillingRateCardEntitlement(meta.EntitlementTemplate) + if err != nil { + return result, fmt.Errorf("failed to convert entitlement template: %w", err) + } + + result.Entitlement = ent + } + + // Prefer a stored unit config; fall back to synthesizing one from a v1 + // dynamic/package price when none is stored. These two sources never coexist on + // a rate card reachable here: the v3 write path cannot author a package/dynamic + // price (only free/flat/unit/graduated/volume), and the v1 API has no unit_config + // field — so a stored unit_config and a package/dynamic price cannot be produced + // through either API, and there is no double-conversion ambiguity to resolve. + // (The RateCardMeta.Validate price-type rule is a publish-blocking warning, not a + // hard write-time reject, so it is not what guarantees this.) + if meta.UnitConfig != nil { + result.UnitConfig = lo.ToPtr(ToAPIBillingUnitConfig(*meta.UnitConfig)) + } else { + unitConfig, err := ToAPIBillingRateCardUnitConfig(meta.Price) + if err != nil { + return result, fmt.Errorf("failed to convert unit config: %w", err) + } + + result.UnitConfig = unitConfig + } + + return result, nil +} + +func ToAPIBillingRateCardEntitlement(t *productcatalog.EntitlementTemplate) (*api.BillingRateCardEntitlement, error) { + out := &api.BillingRateCardEntitlement{} + + switch t.Type() { + case entitlement.EntitlementTypeMetered: + metered, err := t.AsMetered() + if err != nil { + return nil, fmt.Errorf("failed to read metered entitlement template: %w", err) + } + + apiMetered := api.BillingRateCardMeteredEntitlement{ + Type: "metered", + IsSoftLimit: lo.ToPtr(metered.IsSoftLimit), + Limit: metered.IssueAfterReset, + UsagePeriod: lo.ToPtr(metered.UsagePeriod.ISOString().String()), + } + + if err := out.FromBillingRateCardMeteredEntitlement(apiMetered); err != nil { + return nil, fmt.Errorf("failed to set metered entitlement template: %w", err) + } + + case entitlement.EntitlementTypeStatic: + static, err := t.AsStatic() + if err != nil { + return nil, fmt.Errorf("failed to read static entitlement template: %w", err) + } + + // The domain stores the config as a JSON string token wrapping the JSON + // text (v1 convention, relied on by subscription materialization). Unwrap + // it so the v3 API returns the raw JSON value. Legacy values that are not + // string-wrapped are returned as stored so reads never hard-fail. + var config json.RawMessage + if len(static.Config) > 0 { + var text string + if err := json.Unmarshal(static.Config, &text); err == nil && json.Valid([]byte(text)) { + config = json.RawMessage(text) + } else { + config = static.Config + } + } + + if err := out.FromBillingRateCardStaticEntitlement(api.BillingRateCardStaticEntitlement{ + Type: "static", + Config: config, + }); err != nil { + return nil, fmt.Errorf("failed to set static entitlement template: %w", err) + } + + case entitlement.EntitlementTypeBoolean: + if err := out.FromBillingRateCardBooleanEntitlement(api.BillingRateCardBooleanEntitlement{ + Type: "boolean", + }); err != nil { + return nil, fmt.Errorf("failed to set boolean entitlement template: %w", err) + } + + default: + return nil, fmt.Errorf("unknown entitlement template type: %s", t.Type()) + } + + return out, nil +} + +// ToAPIBillingRateCardUnitConfig synthesizes a v3 unit config from a v1 dynamic +// or package price. v3 does not surface dynamic or package prices directly; +// instead they are rendered as a unit price paired with a unit config that +// describes the conversion that v1 applied implicitly. +func ToAPIBillingRateCardUnitConfig(p *productcatalog.Price) (*api.BillingUnitConfig, error) { + if p == nil { + return nil, nil + } + + switch p.Type() { + case productcatalog.DynamicPriceType: + dynamic, err := p.AsDynamic() + if err != nil { + return nil, fmt.Errorf("failed to read dynamic price: %w", err) + } + + return &api.BillingUnitConfig{ + Operation: api.BillingUnitConfigOperationMultiply, + ConversionFactor: dynamic.Multiplier.String(), + }, nil + + case productcatalog.PackagePriceType: + pkg, err := p.AsPackage() + if err != nil { + return nil, fmt.Errorf("failed to read package price: %w", err) + } + + return &api.BillingUnitConfig{ + Operation: api.BillingUnitConfigOperationDivide, + ConversionFactor: pkg.QuantityPerPackage.String(), + Rounding: lo.ToPtr(api.BillingUnitConfigRoundingModeCeiling), + }, nil + + default: + return nil, nil + } +} + +// ToAPIBillingUnitConfig maps a stored domain unit config to its API +// representation. Rounding and Precision are omitted when no rounding is applied, +// mirroring the domain semantics where both are inert for the "none" mode. +func ToAPIBillingUnitConfig(uc productcatalog.UnitConfig) api.BillingUnitConfig { + out := api.BillingUnitConfig{ + Operation: api.BillingUnitConfigOperation(uc.Operation), + ConversionFactor: uc.ConversionFactor.String(), + DisplayUnit: uc.DisplayUnit, + } + + if !uc.Rounding.IsNone() { + out.Rounding = lo.ToPtr(api.BillingUnitConfigRoundingMode(uc.Rounding)) + out.Precision = lo.ToPtr(uc.Precision) + } + + return out +} + +// FromAPIBillingUnitConfig maps the API unit config to the domain type. The enum +// values are identical across the two layers, so operation and rounding are direct +// casts; UnitConfig.Validate (run via RateCardMeta.Validate) rejects unknown enum +// values and a non-positive conversion factor. +func FromAPIBillingUnitConfig(uc api.BillingUnitConfig) (*productcatalog.UnitConfig, error) { + conversionFactor, err := decimal.NewFromString(uc.ConversionFactor) + if err != nil { + return nil, fmt.Errorf("invalid conversion factor: %w", err) + } + + out := &productcatalog.UnitConfig{ + Operation: productcatalog.UnitConfigOperation(uc.Operation), + ConversionFactor: conversionFactor, + DisplayUnit: uc.DisplayUnit, + } + + if uc.Rounding != nil { + out.Rounding = productcatalog.UnitConfigRoundingMode(*uc.Rounding) + } + + // Precision is inert without rounding; only carry it when rounding is active so + // it round-trips consistently with ToAPIBillingUnitConfig, which omits Precision + // in the "none" case. + if !out.Rounding.IsNone() { + out.Precision = lo.FromPtr(uc.Precision) + } + + return out, nil +} + +func ToAPIBillingPrice(p *productcatalog.Price) (api.BillingPrice, error) { + var result api.BillingPrice + + if p == nil { + if err := result.FromBillingPriceFree(api.BillingPriceFree{ + Type: api.BillingPriceFreeType("free"), + }); err != nil { + return result, fmt.Errorf("failed to set free price: %w", err) + } + + return result, nil + } + + switch p.Type() { + case productcatalog.FlatPriceType: + flat, err := p.AsFlat() + if err != nil { + return result, fmt.Errorf("failed to read flat price: %w", err) + } + + if err = result.FromBillingPriceFlat(api.BillingPriceFlat{ + Amount: flat.Amount.String(), + Type: api.BillingPriceFlatType("flat"), + }); err != nil { + return result, fmt.Errorf("failed to set flat price: %w", err) + } + + case productcatalog.UnitPriceType: + unit, err := p.AsUnit() + if err != nil { + return result, fmt.Errorf("failed to read unit price: %w", err) + } + + if err = result.FromBillingPriceUnit(api.BillingPriceUnit{ + Amount: unit.Amount.String(), + Type: api.BillingPriceUnitType("unit"), + }); err != nil { + return result, fmt.Errorf("failed to set unit price: %w", err) + } + + case productcatalog.TieredPriceType: + tiered, err := p.AsTiered() + if err != nil { + return result, fmt.Errorf("failed to read tiered price: %w", err) + } + + tiers := ToAPIBillingPriceTiers(tiered.Tiers) + + switch tiered.Mode { + case productcatalog.GraduatedTieredPrice: + if err = result.FromBillingPriceGraduated(api.BillingPriceGraduated{ + Tiers: tiers, + Type: api.BillingPriceGraduatedType("graduated"), + }); err != nil { + return result, fmt.Errorf("failed to set graduated price: %w", err) + } + + case productcatalog.VolumeTieredPrice: + if err = result.FromBillingPriceVolume(api.BillingPriceVolume{ + Tiers: tiers, + Type: api.BillingPriceVolumeType("volume"), + }); err != nil { + return result, fmt.Errorf("failed to set volume price: %w", err) + } + + default: + return result, fmt.Errorf("unknown tiered price mode: %s", tiered.Mode) + } + + case productcatalog.DynamicPriceType: + // Dynamic prices are surfaced in v3 as a unit price of amount 1; the + // multiplier is carried separately on the rate card's unit config. + if err := result.FromBillingPriceUnit(api.BillingPriceUnit{ + Amount: "1", + Type: api.BillingPriceUnitType("unit"), + }); err != nil { + return result, fmt.Errorf("failed to set unit price for dynamic price: %w", err) + } + + case productcatalog.PackagePriceType: + // Package prices are surfaced in v3 as a unit price; the package size + // is carried separately on the rate card's unit config. + pkg, err := p.AsPackage() + if err != nil { + return result, fmt.Errorf("failed to read package price: %w", err) + } + + if err = result.FromBillingPriceUnit(api.BillingPriceUnit{ + Amount: pkg.Amount.String(), + Type: api.BillingPriceUnitType("unit"), + }); err != nil { + return result, fmt.Errorf("failed to set unit price for package price: %w", err) + } + + default: + return result, fmt.Errorf("unknown price type: %s", p.Type()) + } + + return result, nil +} + +func ToAPIBillingPriceTiers(tiers []productcatalog.PriceTier) []api.BillingPriceTier { + result := make([]api.BillingPriceTier, 0, len(tiers)) + + for _, t := range tiers { + tier := api.BillingPriceTier{} + + if t.UpToAmount != nil { + tier.UpToAmount = lo.ToPtr(t.UpToAmount.String()) + } + + if t.FlatPrice != nil { + tier.FlatPrice = &api.BillingPriceFlat{ + Amount: t.FlatPrice.Amount.String(), + Type: api.BillingPriceFlatType("flat"), + } + } + + if t.UnitPrice != nil { + tier.UnitPrice = &api.BillingPriceUnit{ + Amount: t.UnitPrice.Amount.String(), + Type: api.BillingPriceUnitType("unit"), + } + } + + result = append(result, tier) + } + + return result +} + +func ToAPIBillingRateCardTaxConfig(c *productcatalog.TaxConfig, tc *taxcode.TaxCode) *api.BillingRateCardTaxConfig { + if c == nil || tc == nil { + return nil + } + + result := &api.BillingRateCardTaxConfig{ + Code: api.TaxCodeReference{ + Id: tc.ID, + }, + } + + if c.Behavior != nil { + result.Behavior = lo.ToPtr(api.BillingTaxBehavior(*c.Behavior)) + } + + return result +} + +func ToAPIBillingRateCardDiscount(d productcatalog.Discounts) *api.BillingRateCardDiscounts { + if d.Percentage == nil && d.Usage == nil { + return nil + } + + result := &api.BillingRateCardDiscounts{} + + if d.Percentage != nil { + pct := float32(d.Percentage.Percentage.InexactFloat64()) + result.Percentage = &pct + } + + if d.Usage != nil { + result.Usage = lo.ToPtr(d.Usage.Quantity.String()) + } + + return result +} + +func ToAPIBillingSpendCommitments(c productcatalog.Commitments) *api.BillingSpendCommitments { + if c.MinimumAmount == nil && c.MaximumAmount == nil { + return nil + } + + result := &api.BillingSpendCommitments{} + + if c.MinimumAmount != nil { + result.MinimumAmount = lo.ToPtr(c.MinimumAmount.String()) + } + + if c.MaximumAmount != nil { + result.MaximumAmount = lo.ToPtr(c.MaximumAmount.String()) + } + + return result +} + +func ToAPIProductCatalogValidationErrors(issues models.ValidationIssues) *[]api.ProductCatalogValidationError { + if len(issues) == 0 { + return nil + } + + result := make([]api.ProductCatalogValidationError, 0, len(issues)) + for _, issue := range issues { + result = append(result, api.ProductCatalogValidationError{ + Code: string(issue.Code()), + Field: issue.Field().JSONPath(), + Message: issue.Message(), + }) + } + + return &result +} + +func FromAPIUpsertPlanRequest(ns string, planID string, body api.UpsertPlanRequest) (plan.UpdatePlanInput, error) { + req := plan.UpdatePlanInput{ + NamespacedID: models.NamespacedID{ + Namespace: ns, + ID: planID, + }, + Name: &body.Name, + Description: body.Description, + ProRatingConfig: lo.ToPtr(ToProRatingConfig(body.ProRatingEnabled)), + } + + meta, err := labels.ToMetadata(body.Labels) + if err != nil { + return req, fmt.Errorf("failed to convert label metadata: %w", err) + } + + if body.Labels != nil { + m := meta + req.Metadata = &m + } + + phases := make([]productcatalog.Phase, 0, len(body.Phases)) + for _, phase := range body.Phases { + p, err := FromAPIBillingPlanPhase(phase) + if err != nil { + return req, fmt.Errorf("failed to convert phase: %w", err) + } + + phases = append(phases, p) + } + + req.Phases = &phases + + return req, nil +} + +func FromAPICreatePlanRequest(ns string, body api.CreatePlanRequest) (plan.CreatePlanInput, error) { + meta, err := labels.ToMetadata(body.Labels) + if err != nil { + return plan.CreatePlanInput{}, fmt.Errorf("failed to convert label metadata: %w", err) + } + + req := plan.CreatePlanInput{ + NamespacedModel: models.NamespacedModel{ + Namespace: ns, + }, + Plan: productcatalog.Plan{ + PlanMeta: productcatalog.PlanMeta{ + Key: body.Key, + Name: body.Name, + Description: body.Description, + Metadata: meta, + ProRatingConfig: ToProRatingConfig(body.ProRatingEnabled), + }, + }, + } + + req.Currency = currency.Code(body.Currency) + + billingCadence, err := datetime.ISODurationString(body.BillingCadence).Parse() + if err != nil { + return req, fmt.Errorf("invalid billing cadence: %w", err) + } + + req.BillingCadence = billingCadence + + if len(body.Phases) > 0 { + req.Phases = make([]productcatalog.Phase, 0, len(body.Phases)) + + for _, phase := range body.Phases { + p, err := FromAPIBillingPlanPhase(phase) + if err != nil { + return req, fmt.Errorf("failed to convert phase: %w", err) + } + + req.Phases = append(req.Phases, p) + } + } + + return req, nil +} + +func ToProRatingConfig(enabled *bool) productcatalog.ProRatingConfig { + if enabled == nil || *enabled { + return productcatalog.ProRatingConfig{ + Enabled: true, + Mode: productcatalog.ProRatingModeProratePrices, + } + } + + return productcatalog.ProRatingConfig{ + Enabled: false, + } +} + +func FromAPIBillingPlanPhase(p api.BillingPlanPhase) (productcatalog.Phase, error) { + meta, labelErr := labels.ToMetadata(p.Labels) + + if labelErr != nil { + return productcatalog.Phase{}, fmt.Errorf("failed to convert label metadata: %w", labelErr) + } + + phase := productcatalog.Phase{ + PhaseMeta: productcatalog.PhaseMeta{ + Key: p.Key, + Name: p.Name, + Description: p.Description, + Metadata: meta, + }, + } + + var err error + + phase.Duration, err = (*datetime.ISODurationString)(p.Duration).ParsePtrOrNil() + if err != nil { + return phase, fmt.Errorf("invalid duration: %w", err) + } + + if len(p.RateCards) > 0 { + phase.RateCards = make(productcatalog.RateCards, 0, len(p.RateCards)) + + for _, rc := range p.RateCards { + rateCard, err := FromAPIBillingRateCard(rc) + if err != nil { + return phase, fmt.Errorf("failed to convert rate card %q: %w", rc.Key, err) + } + + phase.RateCards = append(phase.RateCards, rateCard) + } + } + + return phase, nil +} + +func FromAPIBillingRateCard(rc api.BillingRateCard) (productcatalog.RateCard, error) { + priceType, err := rc.Price.Discriminator() + if err != nil { + return nil, fmt.Errorf("failed to read price type: %w", err) + } + + labelMeta, err := labels.ToMetadata(rc.Labels) + if err != nil { + return nil, fmt.Errorf("failed to convert metadata: %w", err) + } + + meta := productcatalog.RateCardMeta{ + Key: rc.Key, + Name: rc.Name, + Description: rc.Description, + Metadata: labelMeta, + } + + if rc.Feature != nil { + meta.FeatureID = &rc.Feature.Id + } + + if rc.TaxConfig != nil { + meta.TaxConfig = FromAPIBillingRateCardTaxConfig(*rc.TaxConfig) + } + + if rc.Discounts != nil { + discounts, err := FromAPIBillingRateCardDiscounts(*rc.Discounts) + if err != nil { + return nil, fmt.Errorf("failed to convert discounts: %w", err) + } + + meta.Discounts = discounts + } + + // The billing cadence doubles as the default usage period for metered + // entitlement templates, so parse it once up front. + var billingCadence *datetime.ISODuration + if rc.BillingCadence != nil { + bc, err := datetime.ISODurationString(*rc.BillingCadence).Parse() + if err != nil { + return nil, fmt.Errorf("invalid billing cadence: %w", err) + } + + billingCadence = &bc + } + + if rc.Entitlement != nil { + tmpl, err := FromAPIBillingRateCardEntitlement(*rc.Entitlement, billingCadence) + if err != nil { + return nil, fmt.Errorf("failed to convert entitlement template: %w", err) + } + + meta.EntitlementTemplate = tmpl + } + + // Set the unit config up front: meta is copied by value into both the flat and + // usage-based rate cards below, so it must be populated before the switch. We do + // not branch on price type here; the price-type restriction lives in + // RateCardMeta.Validate as a publish-blocking warning, so an invalid combination + // (e.g. unit_config on a flat price) is mapped through and surfaces as a + // validation issue on the draft rather than being rejected at create/update. + if rc.UnitConfig != nil { + unitConfig, err := FromAPIBillingUnitConfig(*rc.UnitConfig) + if err != nil { + return nil, fmt.Errorf("failed to convert unit config: %w", err) + } + + meta.UnitConfig = unitConfig + } + + switch priceType { + case "free", "flat": + price, err := FromAPIBillingPrice(rc.Price, rc.PaymentTerm) + if err != nil { + return nil, fmt.Errorf("failed to convert price: %w", err) + } + + meta.Price = price + + flatRC := &productcatalog.FlatFeeRateCard{ + RateCardMeta: meta, + BillingCadence: billingCadence, + } + + return flatRC, nil + + case "unit", "graduated", "volume": + if billingCadence == nil { + return nil, fmt.Errorf("billing cadence is required for usage-based rate card %q", rc.Key) + } + + bc := *billingCadence + + price, err := FromAPIBillingPriceWithCommitments(rc.Price, rc.Commitments) + if err != nil { + return nil, fmt.Errorf("failed to convert price: %w", err) + } + + meta.Price = price + + return &productcatalog.UsageBasedRateCard{ + RateCardMeta: meta, + BillingCadence: bc, + }, nil + + default: + return nil, fmt.Errorf("unsupported price type: %s", priceType) + } +} + +func FromAPIBillingRateCardEntitlement(e api.BillingRateCardEntitlement, billingCadence *datetime.ISODuration) (*productcatalog.EntitlementTemplate, error) { + disc, err := e.Discriminator() + if err != nil { + return nil, fmt.Errorf("failed to read entitlement type: %w", err) + } + + switch disc { + case "metered": + metered, err := e.AsBillingRateCardMeteredEntitlement() + if err != nil { + return nil, fmt.Errorf("failed to read metered entitlement template: %w", err) + } + + // usage_period defaults to the rate card billing cadence when omitted. + var usagePeriod datetime.ISODuration + if metered.UsagePeriod != nil { + usagePeriod, err = datetime.ISODurationString(*metered.UsagePeriod).Parse() + if err != nil { + return nil, models.NewGenericValidationError(fmt.Errorf("invalid usage period: %w", err)) + } + } + + if usagePeriod.IsZero() { + if billingCadence == nil || billingCadence.IsZero() { + return nil, models.NewGenericValidationError( + fmt.Errorf("metered entitlement requires usage_period when it cannot be inferred from billing_cadence"), + ) + } + + usagePeriod = *billingCadence + } + + tmpl := productcatalog.MeteredEntitlementTemplate{ + IsSoftLimit: lo.FromPtr(metered.IsSoftLimit), + IssueAfterReset: metered.Limit, + UsagePeriod: usagePeriod, + } + + return productcatalog.NewEntitlementTemplateFrom(tmpl), nil + + case "static": + // Extract the config's raw JSON bytes from the union so client values + // survive untouched (no float64 round-trip), then wrap them in a JSON + // string token: the domain-wide convention (shared with v1) stores the + // config as JSON-encoded text, which subscription materialization + // unwraps when instantiating the entitlement. + rawEnt, err := e.MarshalJSON() + if err != nil { + return nil, fmt.Errorf("failed to read static entitlement template: %w", err) + } + + var static struct { + Config json.RawMessage `json:"config"` + } + if err := json.Unmarshal(rawEnt, &static); err != nil { + return nil, fmt.Errorf("failed to read static entitlement template: %w", err) + } + + if len(static.Config) == 0 { + static.Config = json.RawMessage("null") + } + + token, err := json.Marshal(string(static.Config)) + if err != nil { + return nil, fmt.Errorf("failed to encode static entitlement config: %w", err) + } + + return productcatalog.NewEntitlementTemplateFrom(productcatalog.StaticEntitlementTemplate{ + Config: token, + }), nil + + case "boolean": + return productcatalog.NewEntitlementTemplateFrom(productcatalog.BooleanEntitlementTemplate{}), nil + + default: + return nil, fmt.Errorf("unsupported entitlement type: %s", disc) + } +} + +func FromAPIBillingPrice(p api.BillingPrice, paymentTerm *api.BillingPricePaymentTerm) (*productcatalog.Price, error) { + disc, err := p.Discriminator() + if err != nil { + return nil, fmt.Errorf("failed to read price type: %w", err) + } + + switch disc { + case "free": + return nil, nil + + case "flat": + flat, err := p.AsBillingPriceFlat() + if err != nil { + return nil, fmt.Errorf("failed to read flat price: %w", err) + } + + amount, err := decimal.NewFromString(flat.Amount) + if err != nil { + return nil, fmt.Errorf("invalid flat price amount: %w", err) + } + + term := productcatalog.DefaultPaymentTerm + if paymentTerm != nil { + term = productcatalog.PaymentTermType(*paymentTerm) + } + + return productcatalog.NewPriceFrom(productcatalog.FlatPrice{ + Amount: amount, + PaymentTerm: term, + }), nil + + default: + return nil, fmt.Errorf("toBillingPrice does not handle price type %q", disc) + } +} + +func FromAPIBillingPriceWithCommitments(p api.BillingPrice, commitments *api.BillingSpendCommitments) (*productcatalog.Price, error) { + disc, err := p.Discriminator() + if err != nil { + return nil, fmt.Errorf("failed to read price type: %w", err) + } + + c, err := ParseCommitments(commitments) + if err != nil { + return nil, err + } + + switch disc { + case "unit": + unit, err := p.AsBillingPriceUnit() + if err != nil { + return nil, fmt.Errorf("failed to read unit price: %w", err) + } + + amount, err := decimal.NewFromString(unit.Amount) + if err != nil { + return nil, fmt.Errorf("invalid unit price amount: %w", err) + } + + return productcatalog.NewPriceFrom(productcatalog.UnitPrice{ + Amount: amount, + Commitments: c, + }), nil + + case "graduated": + grad, err := p.AsBillingPriceGraduated() + if err != nil { + return nil, fmt.Errorf("failed to read graduated price: %w", err) + } + + tiers, err := FromAPIBillingPriceTiers(grad.Tiers) + if err != nil { + return nil, fmt.Errorf("failed to convert graduated tiers: %w", err) + } + + return productcatalog.NewPriceFrom(productcatalog.TieredPrice{ + Mode: productcatalog.GraduatedTieredPrice, + Tiers: tiers, + Commitments: c, + }), nil + + case "volume": + vol, err := p.AsBillingPriceVolume() + if err != nil { + return nil, fmt.Errorf("failed to read volume price: %w", err) + } + + tiers, err := FromAPIBillingPriceTiers(vol.Tiers) + if err != nil { + return nil, fmt.Errorf("failed to convert volume tiers: %w", err) + } + + return productcatalog.NewPriceFrom(productcatalog.TieredPrice{ + Mode: productcatalog.VolumeTieredPrice, + Tiers: tiers, + Commitments: c, + }), nil + + default: + return nil, fmt.Errorf("unsupported usage-based price type: %s", disc) + } +} + +func ParseCommitments(c *api.BillingSpendCommitments) (productcatalog.Commitments, error) { + if c == nil { + return productcatalog.Commitments{}, nil + } + + result := productcatalog.Commitments{} + + if c.MinimumAmount != nil { + min, err := decimal.NewFromString(*c.MinimumAmount) + if err != nil { + return result, fmt.Errorf("invalid minimum amount: %w", err) + } + + result.MinimumAmount = &min + } + + if c.MaximumAmount != nil { + max, err := decimal.NewFromString(*c.MaximumAmount) + if err != nil { + return result, fmt.Errorf("invalid maximum amount: %w", err) + } + + result.MaximumAmount = &max + } + + return result, nil +} + +func FromAPIBillingPriceTiers(tiers []api.BillingPriceTier) ([]productcatalog.PriceTier, error) { + result := make([]productcatalog.PriceTier, 0, len(tiers)) + + for _, t := range tiers { + tier := productcatalog.PriceTier{} + + if t.UpToAmount != nil { + amount, err := decimal.NewFromString(*t.UpToAmount) + if err != nil { + return nil, fmt.Errorf("invalid tier up-to amount: %w", err) + } + + tier.UpToAmount = &amount + } + + if t.FlatPrice != nil { + amount, err := decimal.NewFromString(t.FlatPrice.Amount) + if err != nil { + return nil, fmt.Errorf("invalid tier flat price amount: %w", err) + } + + tier.FlatPrice = &productcatalog.PriceTierFlatPrice{Amount: amount} + } + + if t.UnitPrice != nil { + amount, err := decimal.NewFromString(t.UnitPrice.Amount) + if err != nil { + return nil, fmt.Errorf("invalid tier unit price amount: %w", err) + } + + tier.UnitPrice = &productcatalog.PriceTierUnitPrice{Amount: amount} + } + + result = append(result, tier) + } + + return result, nil +} + +func FromAPIBillingRateCardTaxConfig(tc api.BillingRateCardTaxConfig) *productcatalog.TaxConfig { + result := &productcatalog.TaxConfig{ + TaxCodeID: &tc.Code.Id, + } + + if tc.Behavior != nil { + result.Behavior = lo.ToPtr(productcatalog.TaxBehavior(*tc.Behavior)) + } + + return result +} + +func FromAPIBillingRateCardDiscounts(d api.BillingRateCardDiscounts) (productcatalog.Discounts, error) { + result := productcatalog.Discounts{} + + if d.Percentage != nil { + result.Percentage = &productcatalog.PercentageDiscount{ + Percentage: models.NewPercentage(float64(*d.Percentage)), + } + } + + if d.Usage != nil { + qty, err := decimal.NewFromString(*d.Usage) + if err != nil { + return result, fmt.Errorf("invalid usage discount quantity: %w", err) + } + + result.Usage = &productcatalog.UsageDiscount{ + Quantity: qty, + } + } + + return result, nil +} diff --git a/api/v3/handlers/plans/convert_test.go b/api/v3/handlers/plans/convert_test.go new file mode 100644 index 0000000000000000000000000000000000000000..649d95c5701da9aff0553a7e9eb581664b1310e8 --- /dev/null +++ b/api/v3/handlers/plans/convert_test.go @@ -0,0 +1,1822 @@ +package plans + +import ( + "encoding/json" + "testing" + "time" + + decimal "github.com/alpacahq/alpacadecimal" + "github.com/invopop/gobl/currency" + "github.com/samber/lo" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/openmeter/productcatalog" + "github.com/openmeterio/openmeter/openmeter/productcatalog/plan" + "github.com/openmeterio/openmeter/openmeter/taxcode" + "github.com/openmeterio/openmeter/pkg/clock" + "github.com/openmeterio/openmeter/pkg/datetime" + "github.com/openmeterio/openmeter/pkg/models" +) + +func newTestPlan(t *testing.T) plan.Plan { + t.Helper() + + now := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + + billingCadence, err := datetime.ISODurationString("P1M").Parse() + require.NoError(t, err) + + return plan.Plan{ + NamespacedID: models.NamespacedID{ + Namespace: "test-ns", + ID: "01J8GFKQ0000000000000000", + }, + ManagedModel: models.ManagedModel{ + CreatedAt: now, + UpdatedAt: now, + }, + PlanMeta: productcatalog.PlanMeta{ + Key: "pro", + Version: 1, + Name: "Pro Plan", + Currency: currency.USD, + BillingCadence: billingCadence, + ProRatingConfig: productcatalog.ProRatingConfig{ + Enabled: true, + Mode: productcatalog.ProRatingModeProratePrices, + }, + }, + Phases: []plan.Phase{}, + } +} + +func TestFromPlan(t *testing.T) { + now := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + + t.Run("maps scalar fields correctly", func(t *testing.T) { + p := newTestPlan(t) + + result, err := ToAPIBillingPlan(p) + require.NoError(t, err) + + assert.Equal(t, "01J8GFKQ0000000000000000", result.Id) + assert.Equal(t, api.ResourceKey("pro"), result.Key) + assert.Equal(t, "Pro Plan", result.Name) + assert.Equal(t, api.CurrencyCode("USD"), result.Currency) + assert.Equal(t, api.ISO8601Duration("P1M"), result.BillingCadence) + assert.Equal(t, 1, result.Version) + assert.Equal(t, now, result.CreatedAt) + assert.Equal(t, now, result.UpdatedAt) + assert.Nil(t, result.DeletedAt) + assert.Nil(t, result.Description) + }) + + t.Run("maps optional description", func(t *testing.T) { + p := newTestPlan(t) + p.Description = lo.ToPtr("A great plan") + + result, err := ToAPIBillingPlan(p) + require.NoError(t, err) + require.NotNil(t, result.Description) + assert.Equal(t, "A great plan", *result.Description) + }) + + t.Run("maps deleted at", func(t *testing.T) { + p := newTestPlan(t) + deleted := now.Add(-time.Hour) + p.DeletedAt = &deleted + + result, err := ToAPIBillingPlan(p) + require.NoError(t, err) + require.NotNil(t, result.DeletedAt) + assert.Equal(t, deleted, *result.DeletedAt) + }) + + t.Run("pro rating enabled maps correctly", func(t *testing.T) { + p := newTestPlan(t) + p.ProRatingConfig.Enabled = false + + result, err := ToAPIBillingPlan(p) + require.NoError(t, err) + require.NotNil(t, result.ProRatingEnabled) + assert.False(t, *result.ProRatingEnabled) + }) + + t.Run("effective period maps correctly", func(t *testing.T) { + p := newTestPlan(t) + from := now.Add(-24 * time.Hour) + to := now.Add(30 * 24 * time.Hour) + p.EffectiveFrom = &from + p.EffectiveTo = &to + + result, err := ToAPIBillingPlan(p) + require.NoError(t, err) + require.NotNil(t, result.EffectiveFrom) + assert.Equal(t, from, *result.EffectiveFrom) + require.NotNil(t, result.EffectiveTo) + assert.Equal(t, to, *result.EffectiveTo) + }) + + t.Run("empty phases produces empty slice", func(t *testing.T) { + p := newTestPlan(t) + + result, err := ToAPIBillingPlan(p) + require.NoError(t, err) + assert.Empty(t, result.Phases) + }) +} + +func TestFromPlanStatus(t *testing.T) { + now := time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC) + clock.SetTime(now) + defer clock.ResetTime() + + tests := []struct { + name string + effectiveFrom *time.Time + effectiveTo *time.Time + wantStatus api.BillingPlanStatus + }{ + { + name: "draft — no effective dates", + wantStatus: api.BillingPlanStatusDraft, + }, + { + name: "active — started in past, no end", + effectiveFrom: lo.ToPtr(now.Add(-time.Hour)), + wantStatus: api.BillingPlanStatusActive, + }, + { + name: "scheduled — starts in future", + effectiveFrom: lo.ToPtr(now.Add(24 * time.Hour)), + wantStatus: api.BillingPlanStatusScheduled, + }, + { + name: "archived — both dates in past", + effectiveFrom: lo.ToPtr(now.Add(-48 * time.Hour)), + effectiveTo: lo.ToPtr(now.Add(-time.Hour)), + wantStatus: api.BillingPlanStatusArchived, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := newTestPlan(t) + p.EffectiveFrom = tt.effectiveFrom + p.EffectiveTo = tt.effectiveTo + + result, err := ToAPIBillingPlan(p) + require.NoError(t, err) + assert.Equal(t, tt.wantStatus, result.Status) + }) + } +} + +func TestFromPlanPhase(t *testing.T) { + t.Run("maps phase fields", func(t *testing.T) { + duration, err := datetime.ISODurationString("P3M").Parse() + require.NoError(t, err) + + phase := plan.Phase{ + Phase: productcatalog.Phase{ + PhaseMeta: productcatalog.PhaseMeta{ + Key: "trial", + Name: "Trial Phase", + Description: lo.ToPtr("First 3 months"), + Duration: lo.ToPtr(duration), + }, + }, + } + + result, err := ToAPIBillingPlanPhase(phase) + require.NoError(t, err) + + assert.Equal(t, api.ResourceKey("trial"), result.Key) + assert.Equal(t, "Trial Phase", result.Name) + require.NotNil(t, result.Description) + assert.Equal(t, "First 3 months", *result.Description) + require.NotNil(t, result.Duration) + assert.Equal(t, api.ISO8601Duration("P3M"), *result.Duration) + }) + + t.Run("nil duration maps to nil", func(t *testing.T) { + phase := plan.Phase{ + Phase: productcatalog.Phase{ + PhaseMeta: productcatalog.PhaseMeta{ + Key: "forever", + Name: "Forever Phase", + }, + }, + } + + result, err := ToAPIBillingPlanPhase(phase) + require.NoError(t, err) + assert.Nil(t, result.Duration) + }) + + t.Run("phase produces empty rate cards slice", func(t *testing.T) { + phase := plan.Phase{ + Phase: productcatalog.Phase{ + PhaseMeta: productcatalog.PhaseMeta{Key: "p1", Name: "Phase 1"}, + }, + } + + result, err := ToAPIBillingPlanPhase(phase) + require.NoError(t, err) + assert.NotNil(t, result.RateCards) + assert.Empty(t, result.RateCards) + }) +} + +func TestFromValidationErrors(t *testing.T) { + t.Run("nil when no issues", func(t *testing.T) { + result := ToAPIProductCatalogValidationErrors(nil) + assert.Nil(t, result) + }) + + t.Run("nil when empty issues", func(t *testing.T) { + result := ToAPIProductCatalogValidationErrors(models.ValidationIssues{}) + assert.Nil(t, result) + }) + + t.Run("maps issues to errors", func(t *testing.T) { + issues := models.ValidationIssues{ + models.NewValidationIssue("plan.missing_phase", "plan must have at least one phase"), + } + + result := ToAPIProductCatalogValidationErrors(issues) + require.NotNil(t, result) + require.Len(t, *result, 1) + assert.Equal(t, "plan.missing_phase", (*result)[0].Code) + assert.Equal(t, "plan must have at least one phase", (*result)[0].Message) + }) + + t.Run("maps multiple issues", func(t *testing.T) { + issues := models.ValidationIssues{ + models.NewValidationIssue("err.one", "first error"), + models.NewValidationIssue("err.two", "second error"), + } + + result := ToAPIProductCatalogValidationErrors(issues) + require.NotNil(t, result) + assert.Len(t, *result, 2) + }) +} + +func TestFromPlanWithPhases(t *testing.T) { + t.Run("multiple phases are all converted", func(t *testing.T) { + p := newTestPlan(t) + p.Phases = []plan.Phase{ + {Phase: productcatalog.Phase{PhaseMeta: productcatalog.PhaseMeta{Key: "p1", Name: "Phase 1"}}}, + {Phase: productcatalog.Phase{PhaseMeta: productcatalog.PhaseMeta{Key: "p2", Name: "Phase 2"}}}, + } + + result, err := ToAPIBillingPlan(p) + require.NoError(t, err) + require.Len(t, result.Phases, 2) + assert.Equal(t, api.ResourceKey("p1"), result.Phases[0].Key) + assert.Equal(t, api.ResourceKey("p2"), result.Phases[1].Key) + }) + + t.Run("phase order is preserved", func(t *testing.T) { + p := newTestPlan(t) + p.Phases = []plan.Phase{ + {Phase: productcatalog.Phase{PhaseMeta: productcatalog.PhaseMeta{Key: "first", Name: "First"}}}, + {Phase: productcatalog.Phase{PhaseMeta: productcatalog.PhaseMeta{Key: "second", Name: "Second"}}}, + {Phase: productcatalog.Phase{PhaseMeta: productcatalog.PhaseMeta{Key: "third", Name: "Third"}}}, + } + + result, err := ToAPIBillingPlan(p) + require.NoError(t, err) + require.Len(t, result.Phases, 3) + assert.Equal(t, api.ResourceKey("first"), result.Phases[0].Key) + assert.Equal(t, api.ResourceKey("second"), result.Phases[1].Key) + assert.Equal(t, api.ResourceKey("third"), result.Phases[2].Key) + }) + + t.Run("nil phases maps to empty slice", func(t *testing.T) { + p := newTestPlan(t) + p.Phases = nil + + result, err := ToAPIBillingPlan(p) + require.NoError(t, err) + assert.NotNil(t, result.Phases) + assert.Empty(t, result.Phases) + }) +} + +func TestFromRateCard(t *testing.T) { + t.Run("flat fee — no price, no cadence (one-time free)", func(t *testing.T) { + rc := &productcatalog.FlatFeeRateCard{ + RateCardMeta: productcatalog.RateCardMeta{ + Key: "setup", + Name: "Setup Fee", + }, + } + + result, err := ToAPIBillingRateCard(rc) + require.NoError(t, err) + + assert.Equal(t, "setup", result.Key) + assert.Equal(t, "Setup Fee", result.Name) + assert.Nil(t, result.BillingCadence) + assert.Nil(t, result.Feature) + assert.Nil(t, result.Discounts) + assert.Nil(t, result.TaxConfig) + + disc, err := result.Price.Discriminator() + require.NoError(t, err) + assert.Equal(t, "free", disc) + }) + + t.Run("flat fee — with price and cadence", func(t *testing.T) { + cadence, err := datetime.ISODurationString("P1M").Parse() + require.NoError(t, err) + + price := productcatalog.NewPriceFrom(productcatalog.FlatPrice{ + Amount: decimal.NewFromFloat(9.99), + PaymentTerm: productcatalog.InAdvancePaymentTerm, + }) + + rc := &productcatalog.FlatFeeRateCard{ + RateCardMeta: productcatalog.RateCardMeta{ + Key: "base", + Name: "Base Fee", + Price: price, + }, + BillingCadence: &cadence, + } + + result, err := ToAPIBillingRateCard(rc) + require.NoError(t, err) + + require.NotNil(t, result.BillingCadence) + assert.Equal(t, api.ISO8601Duration("P1M"), *result.BillingCadence) + require.NotNil(t, result.PaymentTerm) + assert.Equal(t, api.BillingPricePaymentTerm("in_advance"), *result.PaymentTerm) + + disc, err := result.Price.Discriminator() + require.NoError(t, err) + assert.Equal(t, "flat", disc) + + flat, err := result.Price.AsBillingPriceFlat() + require.NoError(t, err) + assert.Equal(t, api.Numeric("9.99"), flat.Amount) + }) + + t.Run("usage based — with unit price", func(t *testing.T) { + cadence, err := datetime.ISODurationString("P1M").Parse() + require.NoError(t, err) + + minAmt := decimal.NewFromFloat(10) + maxAmt := decimal.NewFromFloat(100) + price := productcatalog.NewPriceFrom(productcatalog.UnitPrice{ + Amount: decimal.NewFromFloat(0.05), + Commitments: productcatalog.Commitments{ + MinimumAmount: &minAmt, + MaximumAmount: &maxAmt, + }, + }) + + rc := &productcatalog.UsageBasedRateCard{ + RateCardMeta: productcatalog.RateCardMeta{ + Key: "api-calls", + Name: "API Calls", + Price: price, + }, + BillingCadence: cadence, + } + + result, err := ToAPIBillingRateCard(rc) + require.NoError(t, err) + + require.NotNil(t, result.BillingCadence) + assert.Equal(t, api.ISO8601Duration("P1M"), *result.BillingCadence) + + require.NotNil(t, result.Commitments) + assert.Equal(t, lo.ToPtr(api.Numeric("10")), result.Commitments.MinimumAmount) + assert.Equal(t, lo.ToPtr(api.Numeric("100")), result.Commitments.MaximumAmount) + + disc, err := result.Price.Discriminator() + require.NoError(t, err) + assert.Equal(t, "unit", disc) + + unit, err := result.Price.AsBillingPriceUnit() + require.NoError(t, err) + assert.Equal(t, api.Numeric("0.05"), unit.Amount) + }) + + t.Run("usage based — with feature ID", func(t *testing.T) { + cadence, err := datetime.ISODurationString("P1M").Parse() + require.NoError(t, err) + + featureID := "01J8FEATURE000000000000000" + rc := &productcatalog.UsageBasedRateCard{ + RateCardMeta: productcatalog.RateCardMeta{ + Key: "tokens", + Name: "Tokens", + FeatureID: &featureID, + }, + BillingCadence: cadence, + } + + result, err := ToAPIBillingRateCard(rc) + require.NoError(t, err) + require.NotNil(t, result.Feature) + assert.Equal(t, featureID, result.Feature.Id) + }) + + t.Run("usage based — no feature ID, no feature reference", func(t *testing.T) { + cadence, err := datetime.ISODurationString("P1M").Parse() + require.NoError(t, err) + + rc := &productcatalog.UsageBasedRateCard{ + RateCardMeta: productcatalog.RateCardMeta{Key: "rc", Name: "RC"}, + BillingCadence: cadence, + } + + result, err := ToAPIBillingRateCard(rc) + require.NoError(t, err) + assert.Nil(t, result.Feature) + }) +} + +func TestFromBillingPrice(t *testing.T) { + t.Run("nil price maps to free", func(t *testing.T) { + result, err := ToAPIBillingPrice(nil) + require.NoError(t, err) + + disc, err := result.Discriminator() + require.NoError(t, err) + assert.Equal(t, "free", disc) + }) + + t.Run("flat price", func(t *testing.T) { + p := productcatalog.NewPriceFrom(productcatalog.FlatPrice{ + Amount: decimal.NewFromFloat(5.00), + }) + + result, err := ToAPIBillingPrice(p) + require.NoError(t, err) + + disc, err := result.Discriminator() + require.NoError(t, err) + assert.Equal(t, "flat", disc) + + flat, err := result.AsBillingPriceFlat() + require.NoError(t, err) + assert.Equal(t, api.Numeric("5"), flat.Amount) + }) + + t.Run("unit price", func(t *testing.T) { + p := productcatalog.NewPriceFrom(productcatalog.UnitPrice{ + Amount: decimal.NewFromFloat(0.001), + }) + + result, err := ToAPIBillingPrice(p) + require.NoError(t, err) + + disc, err := result.Discriminator() + require.NoError(t, err) + assert.Equal(t, "unit", disc) + + unit, err := result.AsBillingPriceUnit() + require.NoError(t, err) + assert.Equal(t, api.Numeric("0.001"), unit.Amount) + }) + + t.Run("graduated tiered price", func(t *testing.T) { + upTo := decimal.NewFromInt(1000) + p := productcatalog.NewPriceFrom(productcatalog.TieredPrice{ + Mode: productcatalog.GraduatedTieredPrice, + Tiers: []productcatalog.PriceTier{ + { + UpToAmount: &upTo, + UnitPrice: &productcatalog.PriceTierUnitPrice{Amount: decimal.NewFromFloat(0.01)}, + }, + { + UnitPrice: &productcatalog.PriceTierUnitPrice{Amount: decimal.NewFromFloat(0.005)}, + }, + }, + }) + + result, err := ToAPIBillingPrice(p) + require.NoError(t, err) + + disc, err := result.Discriminator() + require.NoError(t, err) + assert.Equal(t, "graduated", disc) + + graduated, err := result.AsBillingPriceGraduated() + require.NoError(t, err) + require.Len(t, graduated.Tiers, 2) + assert.Equal(t, lo.ToPtr(api.Numeric("1000")), graduated.Tiers[0].UpToAmount) + assert.Nil(t, graduated.Tiers[1].UpToAmount) + }) + + t.Run("volume tiered price", func(t *testing.T) { + p := productcatalog.NewPriceFrom(productcatalog.TieredPrice{ + Mode: productcatalog.VolumeTieredPrice, + Tiers: []productcatalog.PriceTier{}, + }) + + result, err := ToAPIBillingPrice(p) + require.NoError(t, err) + + disc, err := result.Discriminator() + require.NoError(t, err) + assert.Equal(t, "volume", disc) + }) + + t.Run("dynamic price translates to unit price of amount 1", func(t *testing.T) { + p := productcatalog.NewPriceFrom(productcatalog.DynamicPrice{ + Multiplier: decimal.NewFromFloat(1.2), + }) + + result, err := ToAPIBillingPrice(p) + require.NoError(t, err) + + disc, err := result.Discriminator() + require.NoError(t, err) + assert.Equal(t, "unit", disc) + + unit, err := result.AsBillingPriceUnit() + require.NoError(t, err) + assert.Equal(t, api.Numeric("1"), unit.Amount) + }) + + t.Run("package price translates to unit price with package amount", func(t *testing.T) { + p := productcatalog.NewPriceFrom(productcatalog.PackagePrice{ + Amount: decimal.NewFromFloat(0.5), + QuantityPerPackage: decimal.NewFromInt(1000), + }) + + result, err := ToAPIBillingPrice(p) + require.NoError(t, err) + + disc, err := result.Discriminator() + require.NoError(t, err) + assert.Equal(t, "unit", disc) + + unit, err := result.AsBillingPriceUnit() + require.NoError(t, err) + assert.Equal(t, api.Numeric("0.5"), unit.Amount) + }) +} + +func TestToAPIBillingRateCardUnitConfig(t *testing.T) { + t.Run("nil price has no unit config", func(t *testing.T) { + result, err := ToAPIBillingRateCardUnitConfig(nil) + require.NoError(t, err) + assert.Nil(t, result) + }) + + t.Run("flat price has no unit config", func(t *testing.T) { + p := productcatalog.NewPriceFrom(productcatalog.FlatPrice{Amount: decimal.NewFromFloat(5)}) + + result, err := ToAPIBillingRateCardUnitConfig(p) + require.NoError(t, err) + assert.Nil(t, result) + }) + + t.Run("unit price has no unit config", func(t *testing.T) { + p := productcatalog.NewPriceFrom(productcatalog.UnitPrice{Amount: decimal.NewFromFloat(0.05)}) + + result, err := ToAPIBillingRateCardUnitConfig(p) + require.NoError(t, err) + assert.Nil(t, result) + }) + + t.Run("dynamic price produces multiply unit config", func(t *testing.T) { + p := productcatalog.NewPriceFrom(productcatalog.DynamicPrice{ + Multiplier: decimal.NewFromFloat(1.2), + }) + + result, err := ToAPIBillingRateCardUnitConfig(p) + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, api.BillingUnitConfigOperationMultiply, result.Operation) + assert.Equal(t, api.Numeric("1.2"), result.ConversionFactor) + assert.Nil(t, result.Rounding) + assert.Nil(t, result.Precision) + assert.Nil(t, result.DisplayUnit) + }) + + t.Run("package price produces divide unit config with ceiling rounding", func(t *testing.T) { + p := productcatalog.NewPriceFrom(productcatalog.PackagePrice{ + Amount: decimal.NewFromFloat(10), + QuantityPerPackage: decimal.NewFromInt(1000), + }) + + result, err := ToAPIBillingRateCardUnitConfig(p) + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, api.BillingUnitConfigOperationDivide, result.Operation) + assert.Equal(t, api.Numeric("1000"), result.ConversionFactor) + require.NotNil(t, result.Rounding) + assert.Equal(t, api.BillingUnitConfigRoundingModeCeiling, *result.Rounding) + }) +} + +func TestFromRateCard_DynamicAndPackagePrices(t *testing.T) { + cadence, err := datetime.ISODurationString("P1M").Parse() + require.NoError(t, err) + + t.Run("dynamic price renders as unit price plus multiply unit config and preserves commitments", func(t *testing.T) { + minAmt := decimal.NewFromFloat(10) + maxAmt := decimal.NewFromFloat(100) + price := productcatalog.NewPriceFrom(productcatalog.DynamicPrice{ + Multiplier: decimal.NewFromFloat(1.2), + Commitments: productcatalog.Commitments{ + MinimumAmount: &minAmt, + MaximumAmount: &maxAmt, + }, + }) + + rc := &productcatalog.UsageBasedRateCard{ + RateCardMeta: productcatalog.RateCardMeta{ + Key: "tokens", + Name: "Tokens", + Price: price, + }, + BillingCadence: cadence, + } + + result, err := ToAPIBillingRateCard(rc) + require.NoError(t, err) + + disc, err := result.Price.Discriminator() + require.NoError(t, err) + assert.Equal(t, "unit", disc) + + unit, err := result.Price.AsBillingPriceUnit() + require.NoError(t, err) + assert.Equal(t, api.Numeric("1"), unit.Amount) + + require.NotNil(t, result.UnitConfig) + assert.Equal(t, api.BillingUnitConfigOperationMultiply, result.UnitConfig.Operation) + assert.Equal(t, api.Numeric("1.2"), result.UnitConfig.ConversionFactor) + assert.Nil(t, result.UnitConfig.Rounding) + + require.NotNil(t, result.Commitments) + assert.Equal(t, lo.ToPtr(api.Numeric("10")), result.Commitments.MinimumAmount) + assert.Equal(t, lo.ToPtr(api.Numeric("100")), result.Commitments.MaximumAmount) + }) + + t.Run("package price renders as unit price plus divide unit config and preserves commitments", func(t *testing.T) { + minAmt := decimal.NewFromFloat(5) + price := productcatalog.NewPriceFrom(productcatalog.PackagePrice{ + Amount: decimal.NewFromFloat(0.5), + QuantityPerPackage: decimal.NewFromInt(1000), + Commitments: productcatalog.Commitments{ + MinimumAmount: &minAmt, + }, + }) + + rc := &productcatalog.UsageBasedRateCard{ + RateCardMeta: productcatalog.RateCardMeta{ + Key: "api-calls", + Name: "API Calls", + Price: price, + }, + BillingCadence: cadence, + } + + result, err := ToAPIBillingRateCard(rc) + require.NoError(t, err) + + disc, err := result.Price.Discriminator() + require.NoError(t, err) + assert.Equal(t, "unit", disc) + + unit, err := result.Price.AsBillingPriceUnit() + require.NoError(t, err) + assert.Equal(t, api.Numeric("0.5"), unit.Amount) + + require.NotNil(t, result.UnitConfig) + assert.Equal(t, api.BillingUnitConfigOperationDivide, result.UnitConfig.Operation) + assert.Equal(t, api.Numeric("1000"), result.UnitConfig.ConversionFactor) + require.NotNil(t, result.UnitConfig.Rounding) + assert.Equal(t, api.BillingUnitConfigRoundingModeCeiling, *result.UnitConfig.Rounding) + + require.NotNil(t, result.Commitments) + assert.Equal(t, lo.ToPtr(api.Numeric("5")), result.Commitments.MinimumAmount) + assert.Nil(t, result.Commitments.MaximumAmount) + }) + + t.Run("unit price has no unit config on rate card", func(t *testing.T) { + price := productcatalog.NewPriceFrom(productcatalog.UnitPrice{ + Amount: decimal.NewFromFloat(0.05), + }) + + rc := &productcatalog.UsageBasedRateCard{ + RateCardMeta: productcatalog.RateCardMeta{ + Key: "api-calls", + Name: "API Calls", + Price: price, + }, + BillingCadence: cadence, + } + + result, err := ToAPIBillingRateCard(rc) + require.NoError(t, err) + assert.Nil(t, result.UnitConfig) + }) +} + +func TestUnitConfigMapping(t *testing.T) { + t.Run("FromAPI maps all five fields", func(t *testing.T) { + uc, err := FromAPIBillingUnitConfig(api.BillingUnitConfig{ + Operation: api.BillingUnitConfigOperationDivide, + ConversionFactor: api.Numeric("1000"), + Rounding: lo.ToPtr(api.BillingUnitConfigRoundingModeCeiling), + Precision: lo.ToPtr(2), + DisplayUnit: lo.ToPtr("K"), + }) + require.NoError(t, err) + require.NotNil(t, uc) + assert.Equal(t, productcatalog.UnitConfigOperationDivide, uc.Operation) + assert.Equal(t, float64(1000), uc.ConversionFactor.InexactFloat64()) + assert.Equal(t, productcatalog.UnitConfigRoundingModeCeiling, uc.Rounding) + assert.Equal(t, 2, uc.Precision) + assert.Equal(t, "K", lo.FromPtr(uc.DisplayUnit)) + }) + + t.Run("FromAPI defaults rounding to none and precision to zero when omitted", func(t *testing.T) { + uc, err := FromAPIBillingUnitConfig(api.BillingUnitConfig{ + Operation: api.BillingUnitConfigOperationMultiply, + ConversionFactor: api.Numeric("1.2"), + }) + require.NoError(t, err) + require.NotNil(t, uc) + assert.True(t, uc.Rounding.IsNone()) + assert.Equal(t, 0, uc.Precision) + assert.Nil(t, uc.DisplayUnit) + }) + + t.Run("FromAPI drops precision when rounding is omitted, mirroring ToAPI", func(t *testing.T) { + // Precision is inert without rounding; carrying it would make From/To + // disagree about what is stored across a round-trip. + uc, err := FromAPIBillingUnitConfig(api.BillingUnitConfig{ + Operation: api.BillingUnitConfigOperationMultiply, + ConversionFactor: api.Numeric("1.2"), + Precision: lo.ToPtr(3), + }) + require.NoError(t, err) + require.NotNil(t, uc) + assert.True(t, uc.Rounding.IsNone()) + assert.Equal(t, 0, uc.Precision) + }) + + t.Run("FromAPI rejects a non-numeric conversion factor", func(t *testing.T) { + _, err := FromAPIBillingUnitConfig(api.BillingUnitConfig{ + Operation: api.BillingUnitConfigOperationDivide, + ConversionFactor: api.Numeric("not-a-number"), + }) + require.Error(t, err) + }) + + t.Run("ToAPI omits rounding and precision when rounding is none", func(t *testing.T) { + out := ToAPIBillingUnitConfig(productcatalog.UnitConfig{ + Operation: productcatalog.UnitConfigOperationMultiply, + ConversionFactor: decimal.NewFromFloat(1.2), + Precision: 3, // inert without rounding, must not surface + }) + assert.Equal(t, api.BillingUnitConfigOperationMultiply, out.Operation) + assert.Equal(t, api.Numeric("1.2"), out.ConversionFactor) + assert.Nil(t, out.Rounding) + assert.Nil(t, out.Precision) + }) + + t.Run("round-trips through FromAPI and ToAPI", func(t *testing.T) { + in := api.BillingUnitConfig{ + Operation: api.BillingUnitConfigOperationDivide, + ConversionFactor: api.Numeric("1000"), + Rounding: lo.ToPtr(api.BillingUnitConfigRoundingModeCeiling), + Precision: lo.ToPtr(0), + DisplayUnit: lo.ToPtr("GB"), + } + + domain, err := FromAPIBillingUnitConfig(in) + require.NoError(t, err) + + assert.Equal(t, in, ToAPIBillingUnitConfig(*domain)) + }) + + t.Run("a stored unit config is surfaced verbatim on read, ahead of v1 synthesis", func(t *testing.T) { + cadence, err := datetime.ISODurationString("P1M").Parse() + require.NoError(t, err) + + rc := &productcatalog.UsageBasedRateCard{ + RateCardMeta: productcatalog.RateCardMeta{ + Key: "storage", + Name: "Storage", + Price: productcatalog.NewPriceFrom(productcatalog.UnitPrice{Amount: decimal.NewFromFloat(0.05)}), + UnitConfig: &productcatalog.UnitConfig{ + Operation: productcatalog.UnitConfigOperationDivide, + ConversionFactor: decimal.NewFromInt(1_000_000_000), + Rounding: productcatalog.UnitConfigRoundingModeCeiling, + DisplayUnit: lo.ToPtr("GB"), + }, + }, + BillingCadence: cadence, + } + + result, err := ToAPIBillingRateCard(rc) + require.NoError(t, err) + require.NotNil(t, result.UnitConfig) + assert.Equal(t, api.BillingUnitConfigOperationDivide, result.UnitConfig.Operation) + assert.Equal(t, api.Numeric("1000000000"), result.UnitConfig.ConversionFactor) + require.NotNil(t, result.UnitConfig.Rounding) + assert.Equal(t, api.BillingUnitConfigRoundingModeCeiling, *result.UnitConfig.Rounding) + assert.Equal(t, lo.ToPtr("GB"), result.UnitConfig.DisplayUnit) + }) + + t.Run("FromAPIBillingRateCard propagates unit_config into the rate card meta", func(t *testing.T) { + // Guards the authoring wiring directly: a unit_config on an api.BillingRateCard + // must survive into meta.UnitConfig. Without this, dropping the propagation in + // FromAPIBillingRateCard would still leave the helper round-trip tests green. + var price api.BillingPrice + require.NoError(t, price.FromBillingPriceUnit(api.BillingPriceUnit{Amount: "0.05", Type: "unit"})) + + bc := api.ISO8601Duration("P1M") + rc := api.BillingRateCard{ + Key: "storage", + Name: "Storage", + Price: price, + BillingCadence: &bc, + UnitConfig: &api.BillingUnitConfig{ + Operation: api.BillingUnitConfigOperationDivide, + ConversionFactor: api.Numeric("1000000000"), + Rounding: lo.ToPtr(api.BillingUnitConfigRoundingModeCeiling), + Precision: lo.ToPtr(0), + DisplayUnit: lo.ToPtr("GB"), + }, + } + + result, err := FromAPIBillingRateCard(rc) + require.NoError(t, err) + + uc := result.AsMeta().UnitConfig + require.NotNil(t, uc) + assert.Equal(t, productcatalog.UnitConfigOperationDivide, uc.Operation) + assert.Equal(t, float64(1_000_000_000), uc.ConversionFactor.InexactFloat64()) + assert.Equal(t, productcatalog.UnitConfigRoundingModeCeiling, uc.Rounding) + assert.Equal(t, 0, uc.Precision) + assert.Equal(t, lo.ToPtr("GB"), uc.DisplayUnit) + }) +} + +func TestFromBillingDiscounts(t *testing.T) { + t.Run("nil when no discounts", func(t *testing.T) { + result := ToAPIBillingRateCardDiscount(productcatalog.Discounts{}) + assert.Nil(t, result) + }) + + t.Run("percentage discount", func(t *testing.T) { + result := ToAPIBillingRateCardDiscount(productcatalog.Discounts{ + Percentage: &productcatalog.PercentageDiscount{ + Percentage: models.NewPercentage(20), + }, + }) + + require.NotNil(t, result) + require.NotNil(t, result.Percentage) + assert.InDelta(t, float32(20), *result.Percentage, 0.001) + assert.Nil(t, result.Usage) + }) + + t.Run("usage discount", func(t *testing.T) { + result := ToAPIBillingRateCardDiscount(productcatalog.Discounts{ + Usage: &productcatalog.UsageDiscount{ + Quantity: decimal.NewFromInt(100), + }, + }) + + require.NotNil(t, result) + assert.Nil(t, result.Percentage) + require.NotNil(t, result.Usage) + assert.Equal(t, lo.ToPtr(api.Numeric("100")), result.Usage) + }) + + t.Run("both discounts", func(t *testing.T) { + result := ToAPIBillingRateCardDiscount(productcatalog.Discounts{ + Percentage: &productcatalog.PercentageDiscount{ + Percentage: models.NewPercentage(10), + }, + Usage: &productcatalog.UsageDiscount{ + Quantity: decimal.NewFromInt(50), + }, + }) + + require.NotNil(t, result) + require.NotNil(t, result.Percentage) + require.NotNil(t, result.Usage) + }) +} + +func TestFromBillingCommitments(t *testing.T) { + t.Run("nil when no commitments", func(t *testing.T) { + result := ToAPIBillingSpendCommitments(productcatalog.Commitments{}) + assert.Nil(t, result) + }) + + t.Run("minimum amount only", func(t *testing.T) { + min := decimal.NewFromFloat(10) + result := ToAPIBillingSpendCommitments(productcatalog.Commitments{MinimumAmount: &min}) + + require.NotNil(t, result) + assert.Equal(t, lo.ToPtr(api.Numeric("10")), result.MinimumAmount) + assert.Nil(t, result.MaximumAmount) + }) + + t.Run("maximum amount only", func(t *testing.T) { + max := decimal.NewFromFloat(500) + result := ToAPIBillingSpendCommitments(productcatalog.Commitments{MaximumAmount: &max}) + + require.NotNil(t, result) + assert.Nil(t, result.MinimumAmount) + assert.Equal(t, lo.ToPtr(api.Numeric("500")), result.MaximumAmount) + }) + + t.Run("both amounts", func(t *testing.T) { + min := decimal.NewFromFloat(10) + max := decimal.NewFromFloat(500) + result := ToAPIBillingSpendCommitments(productcatalog.Commitments{ + MinimumAmount: &min, + MaximumAmount: &max, + }) + + require.NotNil(t, result) + assert.Equal(t, lo.ToPtr(api.Numeric("10")), result.MinimumAmount) + assert.Equal(t, lo.ToPtr(api.Numeric("500")), result.MaximumAmount) + }) +} + +func TestFromBillingTaxConfig(t *testing.T) { + t.Run("nil when tax config is nil", func(t *testing.T) { + result := ToAPIBillingRateCardTaxConfig(nil, &taxcode.TaxCode{NamespacedID: models.NamespacedID{ID: "01TAXCODE"}}) + assert.Nil(t, result) + }) + + t.Run("nil when tax code is nil", func(t *testing.T) { + result := ToAPIBillingRateCardTaxConfig(&productcatalog.TaxConfig{}, nil) + assert.Nil(t, result) + }) + + t.Run("maps tax code ID", func(t *testing.T) { + tc := &taxcode.TaxCode{NamespacedID: models.NamespacedID{ID: "01TAXCODE000000000000000000"}} + result := ToAPIBillingRateCardTaxConfig(&productcatalog.TaxConfig{}, tc) + + require.NotNil(t, result) + assert.Equal(t, api.ULID("01TAXCODE000000000000000000"), result.Code.Id) + assert.Nil(t, result.Behavior) + }) + + t.Run("maps behavior", func(t *testing.T) { + tc := &taxcode.TaxCode{NamespacedID: models.NamespacedID{ID: "01TAXCODE000000000000000000"}} + cfg := &productcatalog.TaxConfig{ + Behavior: lo.ToPtr(productcatalog.InclusiveTaxBehavior), + } + + result := ToAPIBillingRateCardTaxConfig(cfg, tc) + require.NotNil(t, result) + require.NotNil(t, result.Behavior) + assert.Equal(t, api.BillingTaxBehavior("inclusive"), *result.Behavior) + }) +} + +func TestFromPlanPhaseWithRateCards(t *testing.T) { + t.Run("phase with flat fee rate card", func(t *testing.T) { + rc := &productcatalog.FlatFeeRateCard{ + RateCardMeta: productcatalog.RateCardMeta{ + Key: "setup", + Name: "Setup Fee", + }, + } + + phase := plan.Phase{ + Phase: productcatalog.Phase{ + PhaseMeta: productcatalog.PhaseMeta{Key: "p1", Name: "Phase 1"}, + RateCards: productcatalog.RateCards{rc}, + }, + } + + result, err := ToAPIBillingPlanPhase(phase) + require.NoError(t, err) + require.Len(t, result.RateCards, 1) + assert.Equal(t, "setup", result.RateCards[0].Key) + }) +} + +func TestToUpdatePlanInput(t *testing.T) { + t.Run("maps scalar fields correctly", func(t *testing.T) { + body := api.UpsertPlanRequest{ + Name: "Updated Plan", + Description: lo.ToPtr("New description"), + Phases: []api.BillingPlanPhase{}, + } + + result, err := FromAPIUpsertPlanRequest("test-ns", "plan-123", body) + require.NoError(t, err) + + assert.Equal(t, "test-ns", result.Namespace) + assert.Equal(t, "plan-123", result.ID) + require.NotNil(t, result.Name) + assert.Equal(t, "Updated Plan", *result.Name) + require.NotNil(t, result.Description) + assert.Equal(t, "New description", *result.Description) + require.NotNil(t, result.Phases) + assert.Empty(t, *result.Phases) + }) + + t.Run("labels map to metadata", func(t *testing.T) { + body := api.UpsertPlanRequest{ + Name: "Plan", + Labels: &api.Labels{"env": "prod"}, + Phases: []api.BillingPlanPhase{}, + } + + result, err := FromAPIUpsertPlanRequest("ns", "id", body) + require.NoError(t, err) + require.NotNil(t, result.Metadata) + assert.Equal(t, "prod", (*result.Metadata)["env"]) + }) + + t.Run("nil labels result in nil metadata", func(t *testing.T) { + body := api.UpsertPlanRequest{ + Name: "Plan", + Phases: []api.BillingPlanPhase{}, + } + + result, err := FromAPIUpsertPlanRequest("ns", "id", body) + require.NoError(t, err) + assert.Nil(t, result.Metadata) + }) + + t.Run("pro rating enabled maps correctly", func(t *testing.T) { + body := api.UpsertPlanRequest{ + Name: "Plan", + ProRatingEnabled: lo.ToPtr(false), + Phases: []api.BillingPlanPhase{}, + } + + result, err := FromAPIUpsertPlanRequest("ns", "id", body) + require.NoError(t, err) + require.NotNil(t, result.ProRatingConfig) + assert.False(t, result.ProRatingConfig.Enabled) + }) + + t.Run("phases with rate cards are converted", func(t *testing.T) { + var price api.BillingPrice + require.NoError(t, price.FromBillingPriceUnit(api.BillingPriceUnit{Amount: "0.05", Type: "unit"})) + + bc := api.ISO8601Duration("P1M") + body := api.UpsertPlanRequest{ + Name: "Plan", + Phases: []api.BillingPlanPhase{ + { + Key: "p1", + Name: "Phase 1", + RateCards: []api.BillingRateCard{ + {Key: "rc1", Name: "RC", Price: price, BillingCadence: &bc}, + }, + }, + }, + } + + result, err := FromAPIUpsertPlanRequest("ns", "id", body) + require.NoError(t, err) + require.NotNil(t, result.Phases) + require.Len(t, *result.Phases, 1) + assert.Equal(t, "p1", (*result.Phases)[0].Key) + require.Len(t, (*result.Phases)[0].RateCards, 1) + assert.Equal(t, productcatalog.UsageBasedRateCardType, (*result.Phases)[0].RateCards[0].Type()) + }) +} + +func TestToCreatePlanInput(t *testing.T) { + t.Run("maps scalar fields correctly", func(t *testing.T) { + body := api.CreatePlanRequest{ + Key: "pro", + Name: "Pro Plan", + Currency: "USD", + BillingCadence: "P1M", + Description: lo.ToPtr("A great plan"), + } + + result, err := FromAPICreatePlanRequest("test-ns", body) + require.NoError(t, err) + + assert.Equal(t, "test-ns", result.Namespace) + assert.Equal(t, "pro", result.Key) + assert.Equal(t, "Pro Plan", result.Name) + assert.Equal(t, "USD", result.Currency.String()) + assert.Equal(t, "P1M", result.BillingCadence.ISOString().String()) + require.NotNil(t, result.Description) + assert.Equal(t, "A great plan", *result.Description) + }) + + t.Run("labels map to metadata", func(t *testing.T) { + body := api.CreatePlanRequest{ + Key: "pro", + Name: "Pro", + Currency: "USD", + BillingCadence: "P1M", + Labels: &api.Labels{"env": "prod"}, + } + + result, err := FromAPICreatePlanRequest("ns", body) + require.NoError(t, err) + assert.Equal(t, "prod", result.Metadata["env"]) + }) + + t.Run("nil labels result in nil metadata", func(t *testing.T) { + body := api.CreatePlanRequest{ + Key: "pro", + Name: "Pro", + Currency: "USD", + BillingCadence: "P1M", + } + + result, err := FromAPICreatePlanRequest("ns", body) + require.NoError(t, err) + assert.Nil(t, result.Metadata) + }) + + t.Run("invalid billing cadence returns error", func(t *testing.T) { + body := api.CreatePlanRequest{ + Key: "pro", + Name: "Pro", + Currency: "USD", + BillingCadence: "not-a-duration", + } + + _, err := FromAPICreatePlanRequest("ns", body) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid billing cadence") + }) + + t.Run("pro rating enabled true", func(t *testing.T) { + body := api.CreatePlanRequest{ + Key: "pro", + Name: "Pro", + Currency: "USD", + BillingCadence: "P1M", + ProRatingEnabled: lo.ToPtr(true), + } + + result, err := FromAPICreatePlanRequest("ns", body) + require.NoError(t, err) + assert.True(t, result.ProRatingConfig.Enabled) + assert.Equal(t, productcatalog.ProRatingModeProratePrices, result.ProRatingConfig.Mode) + }) + + t.Run("pro rating enabled false", func(t *testing.T) { + body := api.CreatePlanRequest{ + Key: "pro", + Name: "Pro", + Currency: "USD", + BillingCadence: "P1M", + ProRatingEnabled: lo.ToPtr(false), + } + + result, err := FromAPICreatePlanRequest("ns", body) + require.NoError(t, err) + assert.False(t, result.ProRatingConfig.Enabled) + }) + + t.Run("pro rating nil defaults to enabled", func(t *testing.T) { + body := api.CreatePlanRequest{ + Key: "pro", + Name: "Pro", + Currency: "USD", + BillingCadence: "P1M", + } + + result, err := FromAPICreatePlanRequest("ns", body) + require.NoError(t, err) + assert.True(t, result.ProRatingConfig.Enabled) + }) + + t.Run("phases are converted", func(t *testing.T) { + body := api.CreatePlanRequest{ + Key: "pro", + Name: "Pro", + Currency: "USD", + BillingCadence: "P1M", + Phases: []api.BillingPlanPhase{ + {Key: "p1", Name: "Phase 1", RateCards: []api.BillingRateCard{}}, + {Key: "p2", Name: "Phase 2", RateCards: []api.BillingRateCard{}}, + }, + } + + result, err := FromAPICreatePlanRequest("ns", body) + require.NoError(t, err) + require.Len(t, result.Phases, 2) + assert.Equal(t, "p1", result.Phases[0].Key) + assert.Equal(t, "p2", result.Phases[1].Key) + }) +} + +func TestToPlanPhase(t *testing.T) { + t.Run("maps fields correctly", func(t *testing.T) { + dur := api.ISO8601Duration("P3M") + phase := api.BillingPlanPhase{ + Key: "trial", + Name: "Trial", + Description: lo.ToPtr("Free trial"), + Duration: &dur, + Labels: &api.Labels{"tier": "free"}, + RateCards: []api.BillingRateCard{}, + } + + result, err := FromAPIBillingPlanPhase(phase) + require.NoError(t, err) + + assert.Equal(t, "trial", result.Key) + assert.Equal(t, "Trial", result.Name) + require.NotNil(t, result.Description) + assert.Equal(t, "Free trial", *result.Description) + require.NotNil(t, result.Duration) + assert.Equal(t, "P3M", result.Duration.ISOString().String()) + assert.Equal(t, "free", result.Metadata["tier"]) + }) + + t.Run("nil duration maps to nil", func(t *testing.T) { + phase := api.BillingPlanPhase{ + Key: "main", + Name: "Main", + RateCards: []api.BillingRateCard{}, + } + + result, err := FromAPIBillingPlanPhase(phase) + require.NoError(t, err) + assert.Nil(t, result.Duration) + }) +} + +func TestToRateCard(t *testing.T) { + t.Run("free price creates flat fee rate card with nil price", func(t *testing.T) { + var price api.BillingPrice + require.NoError(t, price.FromBillingPriceFree(api.BillingPriceFree{Type: "free"})) + + rc := api.BillingRateCard{ + Key: "setup", + Name: "Setup", + Price: price, + } + + result, err := FromAPIBillingRateCard(rc) + require.NoError(t, err) + + assert.Equal(t, productcatalog.FlatFeeRateCardType, result.Type()) + assert.Nil(t, result.AsMeta().Price) + assert.Nil(t, result.GetBillingCadence()) + }) + + t.Run("flat price creates flat fee rate card", func(t *testing.T) { + var price api.BillingPrice + require.NoError(t, price.FromBillingPriceFlat(api.BillingPriceFlat{Amount: "9.99", Type: "flat"})) + + bc := api.ISO8601Duration("P1M") + pt := api.BillingPricePaymentTerm("in_advance") + + rc := api.BillingRateCard{ + Key: "base", + Name: "Base Fee", + Price: price, + BillingCadence: &bc, + PaymentTerm: &pt, + } + + result, err := FromAPIBillingRateCard(rc) + require.NoError(t, err) + + assert.Equal(t, productcatalog.FlatFeeRateCardType, result.Type()) + require.NotNil(t, result.AsMeta().Price) + + flat, err := result.AsMeta().Price.AsFlat() + require.NoError(t, err) + assert.Equal(t, "9.99", flat.Amount.String()) + assert.Equal(t, productcatalog.InAdvancePaymentTerm, flat.PaymentTerm) + require.NotNil(t, result.GetBillingCadence()) + }) + + t.Run("unit price creates usage based rate card", func(t *testing.T) { + var price api.BillingPrice + require.NoError(t, price.FromBillingPriceUnit(api.BillingPriceUnit{Amount: "0.05", Type: "unit"})) + + bc := api.ISO8601Duration("P1M") + + rc := api.BillingRateCard{ + Key: "api-calls", + Name: "API Calls", + Price: price, + BillingCadence: &bc, + } + + result, err := FromAPIBillingRateCard(rc) + require.NoError(t, err) + + assert.Equal(t, productcatalog.UsageBasedRateCardType, result.Type()) + require.NotNil(t, result.GetBillingCadence()) + assert.Equal(t, "P1M", result.GetBillingCadence().ISOString().String()) + + unit, err := result.AsMeta().Price.AsUnit() + require.NoError(t, err) + assert.Equal(t, "0.05", unit.Amount.String()) + }) + + t.Run("usage based without billing cadence returns error", func(t *testing.T) { + var price api.BillingPrice + require.NoError(t, price.FromBillingPriceUnit(api.BillingPriceUnit{Amount: "0.05", Type: "unit"})) + + rc := api.BillingRateCard{ + Key: "api-calls", + Name: "API Calls", + Price: price, + } + + _, err := FromAPIBillingRateCard(rc) + require.Error(t, err) + assert.Contains(t, err.Error(), "billing cadence is required") + }) + + t.Run("usage based with commitments", func(t *testing.T) { + var price api.BillingPrice + require.NoError(t, price.FromBillingPriceUnit(api.BillingPriceUnit{Amount: "0.05", Type: "unit"})) + + bc := api.ISO8601Duration("P1M") + + rc := api.BillingRateCard{ + Key: "api-calls", + Name: "API Calls", + Price: price, + BillingCadence: &bc, + Commitments: &api.BillingSpendCommitments{ + MinimumAmount: lo.ToPtr(api.Numeric("10")), + MaximumAmount: lo.ToPtr(api.Numeric("100")), + }, + } + + result, err := FromAPIBillingRateCard(rc) + require.NoError(t, err) + + c := result.AsMeta().Price.GetCommitments() + require.NotNil(t, c.MinimumAmount) + assert.Equal(t, "10", c.MinimumAmount.String()) + require.NotNil(t, c.MaximumAmount) + assert.Equal(t, "100", c.MaximumAmount.String()) + }) + + t.Run("feature ID is mapped", func(t *testing.T) { + var price api.BillingPrice + require.NoError(t, price.FromBillingPriceFree(api.BillingPriceFree{Type: "free"})) + + rc := api.BillingRateCard{ + Key: "rc", + Name: "RC", + Price: price, + Feature: &api.FeatureReference{Id: "01FEATURE00000000"}, + } + + result, err := FromAPIBillingRateCard(rc) + require.NoError(t, err) + require.NotNil(t, result.AsMeta().FeatureID) + assert.Equal(t, "01FEATURE00000000", *result.AsMeta().FeatureID) + }) + + t.Run("graduated price creates usage based rate card", func(t *testing.T) { + var price api.BillingPrice + require.NoError(t, price.FromBillingPriceGraduated(api.BillingPriceGraduated{ + Type: "graduated", + Tiers: []api.BillingPriceTier{ + {UpToAmount: lo.ToPtr(api.Numeric("100")), UnitPrice: &api.BillingPriceUnit{Amount: "0.10", Type: "unit"}}, + {UnitPrice: &api.BillingPriceUnit{Amount: "0.05", Type: "unit"}}, + }, + })) + + bc := api.ISO8601Duration("P1M") + + rc := api.BillingRateCard{ + Key: "usage", + Name: "Usage", + Price: price, + BillingCadence: &bc, + } + + result, err := FromAPIBillingRateCard(rc) + require.NoError(t, err) + + assert.Equal(t, productcatalog.UsageBasedRateCardType, result.Type()) + + tiered, err := result.AsMeta().Price.AsTiered() + require.NoError(t, err) + assert.Equal(t, productcatalog.GraduatedTieredPrice, tiered.Mode) + require.Len(t, tiered.Tiers, 2) + }) + + t.Run("volume price creates usage based rate card", func(t *testing.T) { + var price api.BillingPrice + require.NoError(t, price.FromBillingPriceVolume(api.BillingPriceVolume{ + Type: "volume", + Tiers: []api.BillingPriceTier{}, + })) + + bc := api.ISO8601Duration("P1M") + + rc := api.BillingRateCard{ + Key: "usage", + Name: "Usage", + Price: price, + BillingCadence: &bc, + } + + result, err := FromAPIBillingRateCard(rc) + require.NoError(t, err) + + assert.Equal(t, productcatalog.UsageBasedRateCardType, result.Type()) + + tiered, err := result.AsMeta().Price.AsTiered() + require.NoError(t, err) + assert.Equal(t, productcatalog.VolumeTieredPrice, tiered.Mode) + }) +} + +func TestToBillingPrice(t *testing.T) { + t.Run("free maps to nil", func(t *testing.T) { + var price api.BillingPrice + require.NoError(t, price.FromBillingPriceFree(api.BillingPriceFree{Type: "free"})) + + result, err := FromAPIBillingPrice(price, nil) + require.NoError(t, err) + assert.Nil(t, result) + }) + + t.Run("flat with payment term", func(t *testing.T) { + var price api.BillingPrice + require.NoError(t, price.FromBillingPriceFlat(api.BillingPriceFlat{Amount: "5.00", Type: "flat"})) + + pt := api.BillingPricePaymentTerm("in_arrears") + result, err := FromAPIBillingPrice(price, &pt) + require.NoError(t, err) + require.NotNil(t, result) + + flat, err := result.AsFlat() + require.NoError(t, err) + assert.Equal(t, "5", flat.Amount.String()) + assert.Equal(t, productcatalog.InArrearsPaymentTerm, flat.PaymentTerm) + }) + + t.Run("flat with nil payment term uses default", func(t *testing.T) { + var price api.BillingPrice + require.NoError(t, price.FromBillingPriceFlat(api.BillingPriceFlat{Amount: "5.00", Type: "flat"})) + + result, err := FromAPIBillingPrice(price, nil) + require.NoError(t, err) + require.NotNil(t, result) + + flat, err := result.AsFlat() + require.NoError(t, err) + assert.Equal(t, productcatalog.DefaultPaymentTerm, flat.PaymentTerm) + }) +} + +func TestToBillingPriceTiers(t *testing.T) { + t.Run("maps tiers correctly", func(t *testing.T) { + tiers := []api.BillingPriceTier{ + { + UpToAmount: lo.ToPtr(api.Numeric("1000")), + FlatPrice: &api.BillingPriceFlat{Amount: "5", Type: "flat"}, + UnitPrice: &api.BillingPriceUnit{Amount: "0.01", Type: "unit"}, + }, + { + UnitPrice: &api.BillingPriceUnit{Amount: "0.005", Type: "unit"}, + }, + } + + result, err := FromAPIBillingPriceTiers(tiers) + require.NoError(t, err) + require.Len(t, result, 2) + + require.NotNil(t, result[0].UpToAmount) + assert.Equal(t, "1000", result[0].UpToAmount.String()) + require.NotNil(t, result[0].FlatPrice) + assert.Equal(t, "5", result[0].FlatPrice.Amount.String()) + require.NotNil(t, result[0].UnitPrice) + assert.Equal(t, "0.01", result[0].UnitPrice.Amount.String()) + + assert.Nil(t, result[1].UpToAmount) + assert.Nil(t, result[1].FlatPrice) + require.NotNil(t, result[1].UnitPrice) + assert.Equal(t, "0.005", result[1].UnitPrice.Amount.String()) + }) +} + +func TestToBillingTaxConfig(t *testing.T) { + t.Run("maps code ID", func(t *testing.T) { + tc := api.BillingRateCardTaxConfig{ + Code: api.TaxCodeReference{Id: "01TAXCODE000"}, + } + + result := FromAPIBillingRateCardTaxConfig(tc) + require.NotNil(t, result) + require.NotNil(t, result.TaxCodeID) + assert.Equal(t, "01TAXCODE000", *result.TaxCodeID) + assert.Nil(t, result.Behavior) + }) + + t.Run("maps behavior", func(t *testing.T) { + tc := api.BillingRateCardTaxConfig{ + Code: api.TaxCodeReference{Id: "01TAXCODE000"}, + Behavior: lo.ToPtr(api.BillingTaxBehavior("inclusive")), + } + + result := FromAPIBillingRateCardTaxConfig(tc) + require.NotNil(t, result) + require.NotNil(t, result.Behavior) + assert.Equal(t, productcatalog.InclusiveTaxBehavior, *result.Behavior) + }) +} + +func TestToBillingDiscounts(t *testing.T) { + t.Run("percentage discount", func(t *testing.T) { + pct := float32(20) + result, err := FromAPIBillingRateCardDiscounts(api.BillingRateCardDiscounts{Percentage: &pct}) + require.NoError(t, err) + + require.NotNil(t, result.Percentage) + assert.InDelta(t, 20, result.Percentage.Percentage.InexactFloat64(), 0.001) + assert.Nil(t, result.Usage) + }) + + t.Run("usage discount", func(t *testing.T) { + result, err := FromAPIBillingRateCardDiscounts(api.BillingRateCardDiscounts{Usage: lo.ToPtr(api.Numeric("100"))}) + require.NoError(t, err) + + assert.Nil(t, result.Percentage) + require.NotNil(t, result.Usage) + assert.Equal(t, "100", result.Usage.Quantity.String()) + }) + + t.Run("both discounts", func(t *testing.T) { + pct := float32(10) + result, err := FromAPIBillingRateCardDiscounts(api.BillingRateCardDiscounts{ + Percentage: &pct, + Usage: lo.ToPtr(api.Numeric("50")), + }) + require.NoError(t, err) + + require.NotNil(t, result.Percentage) + require.NotNil(t, result.Usage) + }) +} + +func TestFromPlanInvalidStatus(t *testing.T) { + t.Run("invalid status returns error", func(t *testing.T) { + now := time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC) + clock.SetTime(now) + defer clock.ResetTime() + + p := newTestPlan(t) + // StatusAt returns PlanStatusInvalid when no conditions match + // Force it by setting effectiveTo before effectiveFrom + past := now.Add(-48 * time.Hour) + p.EffectiveFrom = &now // now, not in the past + p.EffectiveTo = &past // to before from → invalid + + _, err := ToAPIBillingPlan(p) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid PlanStatus") + }) +} + +func roundTripStaticConfig(t *testing.T, config interface{}) interface{} { + t.Helper() + + var apiEnt api.BillingRateCardEntitlement + require.NoError(t, apiEnt.FromBillingRateCardStaticEntitlement(api.BillingRateCardStaticEntitlement{ + Type: "static", + Config: config, + })) + + domain, err := FromAPIBillingRateCardEntitlement(apiEnt, nil) + require.NoError(t, err) + + out, err := ToAPIBillingRateCardEntitlement(domain) + require.NoError(t, err) + + static, err := out.AsBillingRateCardStaticEntitlement() + require.NoError(t, err) + + return static.Config +} + +func TestFromToAPIBillingRateCardEntitlement(t *testing.T) { + t.Run("static — object config round-trips as a JSON object, not a string", func(t *testing.T) { + config := map[string]interface{}{ + "integrations": []interface{}{"github", "slack"}, + "limit": float64(10), + } + + out := roundTripStaticConfig(t, config) + + assert.Equal(t, config, out) + + _, isString := out.(string) + assert.False(t, isString, "config must round-trip as a JSON object, not a string") + _, isObject := out.(map[string]interface{}) + assert.True(t, isObject, "config must round-trip as a JSON object") + }) + + t.Run("static — array config round-trips", func(t *testing.T) { + config := []interface{}{float64(1), float64(2), float64(3)} + assert.Equal(t, config, roundTripStaticConfig(t, config)) + }) + + t.Run("static — scalar string config round-trips", func(t *testing.T) { + config := "just-a-string" + assert.Equal(t, config, roundTripStaticConfig(t, config)) + }) + + t.Run("static — nil config round-trips as nil", func(t *testing.T) { + assert.Nil(t, roundTripStaticConfig(t, nil)) + }) + + t.Run("static — domain stores the v1 string-token convention", func(t *testing.T) { + // Subscription materialization (subscriptionspec.go) unwraps the stored + // config as a JSON string token containing JSON text. Pin that convention + // so v3-created static entitlements stay materializable. + var apiEnt api.BillingRateCardEntitlement + require.NoError(t, apiEnt.FromBillingRateCardStaticEntitlement(api.BillingRateCardStaticEntitlement{ + Type: "static", + Config: map[string]interface{}{"integrations": []interface{}{"github"}}, + })) + + domain, err := FromAPIBillingRateCardEntitlement(apiEnt, nil) + require.NoError(t, err) + + static, err := domain.AsStatic() + require.NoError(t, err) + + var text string + require.NoError(t, json.Unmarshal(static.Config, &text), + "stored config must be a JSON string token wrapping the JSON text") + assert.JSONEq(t, `{"integrations":["github"]}`, text) + }) + + t.Run("static — large integers survive without float64 precision loss", func(t *testing.T) { + var apiEnt api.BillingRateCardEntitlement + require.NoError(t, json.Unmarshal([]byte(`{"type":"static","config":{"id":9007199254740993}}`), &apiEnt)) + + domain, err := FromAPIBillingRateCardEntitlement(apiEnt, nil) + require.NoError(t, err) + + out, err := ToAPIBillingRateCardEntitlement(domain) + require.NoError(t, err) + + raw, err := out.MarshalJSON() + require.NoError(t, err) + assert.Contains(t, string(raw), "9007199254740993") + }) + + t.Run("static — legacy non-token stored config is returned as stored, not an error", func(t *testing.T) { + // Reads must never hard-fail on data that was valid to persist. + domain := productcatalog.NewEntitlementTemplateFrom(productcatalog.StaticEntitlementTemplate{ + Config: json.RawMessage(`{"a":1}`), + }) + + out, err := ToAPIBillingRateCardEntitlement(domain) + require.NoError(t, err) + + static, err := out.AsBillingRateCardStaticEntitlement() + require.NoError(t, err) + assert.Equal(t, map[string]interface{}{"a": float64(1)}, static.Config) + }) + + t.Run("metered — limit, soft limit, usage period round-trip", func(t *testing.T) { + usagePeriod := api.ISO8601Duration("P1M") + + var apiEnt api.BillingRateCardEntitlement + require.NoError(t, apiEnt.FromBillingRateCardMeteredEntitlement(api.BillingRateCardMeteredEntitlement{ + Type: "metered", + IsSoftLimit: lo.ToPtr(true), + UsagePeriod: &usagePeriod, + Limit: lo.ToPtr(float64(1000)), + })) + + domain, err := FromAPIBillingRateCardEntitlement(apiEnt, nil) + require.NoError(t, err) + + out, err := ToAPIBillingRateCardEntitlement(domain) + require.NoError(t, err) + + metered, err := out.AsBillingRateCardMeteredEntitlement() + require.NoError(t, err) + + assert.Equal(t, "metered", string(metered.Type)) + require.NotNil(t, metered.Limit) + assert.Equal(t, float64(1000), *metered.Limit) + require.NotNil(t, metered.IsSoftLimit) + assert.True(t, *metered.IsSoftLimit) + require.NotNil(t, metered.UsagePeriod) + assert.Equal(t, "P1M", *metered.UsagePeriod) + }) + + t.Run("metered — usage period defaults to billing cadence when omitted", func(t *testing.T) { + var apiEnt api.BillingRateCardEntitlement + require.NoError(t, apiEnt.FromBillingRateCardMeteredEntitlement(api.BillingRateCardMeteredEntitlement{ + Type: "metered", + Limit: lo.ToPtr(float64(5)), + })) + + cadence, err := datetime.ISODurationString("P3M").Parse() + require.NoError(t, err) + + domain, err := FromAPIBillingRateCardEntitlement(apiEnt, &cadence) + require.NoError(t, err) + + metered, err := domain.AsMetered() + require.NoError(t, err) + assert.Equal(t, "P3M", metered.UsagePeriod.ISOString().String()) + }) + + t.Run("metered — missing usage period with no billing cadence is a validation error", func(t *testing.T) { + var apiEnt api.BillingRateCardEntitlement + require.NoError(t, apiEnt.FromBillingRateCardMeteredEntitlement(api.BillingRateCardMeteredEntitlement{ + Type: "metered", + })) + + // No usage_period on the entitlement and no billing cadence to default from. + domain, err := FromAPIBillingRateCardEntitlement(apiEnt, nil) + require.Error(t, err) + assert.Nil(t, domain) + }) + + t.Run("metered — malformed usage period is a validation error", func(t *testing.T) { + bad := api.ISO8601Duration("not-a-duration") + var apiEnt api.BillingRateCardEntitlement + require.NoError(t, apiEnt.FromBillingRateCardMeteredEntitlement(api.BillingRateCardMeteredEntitlement{ + Type: "metered", + UsagePeriod: &bad, + })) + + domain, err := FromAPIBillingRateCardEntitlement(apiEnt, nil) + require.Error(t, err) + assert.ErrorContains(t, err, "invalid usage period") + assert.True(t, models.IsGenericValidationError(err), "malformed durations must surface as validation errors") + assert.Nil(t, domain) + }) + + t.Run("metered — omitted is_soft_limit round-trips as explicit false", func(t *testing.T) { + usagePeriod := api.ISO8601Duration("P1M") + var apiEnt api.BillingRateCardEntitlement + require.NoError(t, apiEnt.FromBillingRateCardMeteredEntitlement(api.BillingRateCardMeteredEntitlement{ + Type: "metered", + UsagePeriod: &usagePeriod, + Limit: lo.ToPtr(float64(5)), + })) + + domain, err := FromAPIBillingRateCardEntitlement(apiEnt, nil) + require.NoError(t, err) + + out, err := ToAPIBillingRateCardEntitlement(domain) + require.NoError(t, err) + + metered, err := out.AsBillingRateCardMeteredEntitlement() + require.NoError(t, err) + require.NotNil(t, metered.IsSoftLimit) + assert.False(t, *metered.IsSoftLimit) + }) + + t.Run("unknown entitlement type is rejected", func(t *testing.T) { + var apiEnt api.BillingRateCardEntitlement + require.NoError(t, json.Unmarshal([]byte(`{"type":"unknown"}`), &apiEnt)) + + domain, err := FromAPIBillingRateCardEntitlement(apiEnt, nil) + require.Error(t, err) + assert.ErrorContains(t, err, "unsupported entitlement type") + assert.Nil(t, domain) + }) + + t.Run("boolean — round-trips", func(t *testing.T) { + var apiEnt api.BillingRateCardEntitlement + require.NoError(t, apiEnt.FromBillingRateCardBooleanEntitlement(api.BillingRateCardBooleanEntitlement{ + Type: "boolean", + })) + + domain, err := FromAPIBillingRateCardEntitlement(apiEnt, nil) + require.NoError(t, err) + + out, err := ToAPIBillingRateCardEntitlement(domain) + require.NoError(t, err) + + // The boolean variant carries only its type — there is no value to assert. + boolean, err := out.AsBillingRateCardBooleanEntitlement() + require.NoError(t, err) + assert.Equal(t, "boolean", string(boolean.Type)) + }) +} diff --git a/api/v3/handlers/plans/create.go b/api/v3/handlers/plans/create.go new file mode 100644 index 0000000000000000000000000000000000000000..ddd8edb5417faa2fb44405a59377900c369185a9 --- /dev/null +++ b/api/v3/handlers/plans/create.go @@ -0,0 +1,75 @@ +package plans + +import ( + "context" + "fmt" + "net/http" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/apierrors" + "github.com/openmeterio/openmeter/api/v3/request" + "github.com/openmeterio/openmeter/openmeter/productcatalog/plan" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" + "github.com/openmeterio/openmeter/pkg/models" +) + +type ( + CreatePlanRequest = plan.CreatePlanInput + CreatePlanResponse = api.BillingPlan + CreatePlanHandler httptransport.Handler[CreatePlanRequest, CreatePlanResponse] +) + +func (h *handler) CreatePlan() CreatePlanHandler { + return httptransport.NewHandler( + func(ctx context.Context, r *http.Request) (CreatePlanRequest, error) { + body := api.CreatePlanRequest{} + if err := request.ParseBody(r, &body); err != nil { + return CreatePlanRequest{}, err + } + + // NOTE: We gate the plan authoring behind this config flag. It is applied for both create and update and will be removed when unit config is feature complete. + if !h.unitConfigEnabled { + for _, phase := range body.Phases { + for _, rc := range phase.RateCards { + if rc.UnitConfig != nil { + return CreatePlanRequest{}, models.NewGenericValidationError(fmt.Errorf("unit_config is not enabled on this deployment of OpenMeter")) + } + } + } + } + + ns, err := h.resolveNamespace(ctx) + if err != nil { + return CreatePlanRequest{}, err + } + + req, err := FromAPICreatePlanRequest(ns, body) + if err != nil { + return CreatePlanRequest{}, err + } + + req.IgnoreNonCriticalIssues = true + + return req, nil + }, + func(ctx context.Context, request CreatePlanRequest) (CreatePlanResponse, error) { + p, err := h.service.CreatePlan(ctx, request) + if err != nil { + return CreatePlanResponse{}, err + } + + if p == nil { + return CreatePlanResponse{}, fmt.Errorf("failed to create plan") + } + + return ToAPIBillingPlan(*p) + }, + commonhttp.JSONResponseEncoderWithStatus[CreatePlanResponse](http.StatusCreated), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("create-plan"), + httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()), + )..., + ) +} diff --git a/api/v3/handlers/plans/delete.go b/api/v3/handlers/plans/delete.go new file mode 100644 index 0000000000000000000000000000000000000000..9253fa18b128078f7d7ac61f81b84d161e755597 --- /dev/null +++ b/api/v3/handlers/plans/delete.go @@ -0,0 +1,50 @@ +package plans + +import ( + "context" + "net/http" + + "github.com/openmeterio/openmeter/api/v3/apierrors" + "github.com/openmeterio/openmeter/openmeter/productcatalog/plan" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" + "github.com/openmeterio/openmeter/pkg/models" +) + +type ( + DeletePlanRequest = plan.DeletePlanInput + DeletePlanResponse = any + DeletePlanParams = string + DeletePlanHandler httptransport.HandlerWithArgs[DeletePlanRequest, DeletePlanResponse, DeletePlanParams] +) + +func (h *handler) DeletePlan() DeletePlanHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, planID DeletePlanParams) (DeletePlanRequest, error) { + ns, err := h.resolveNamespace(ctx) + if err != nil { + return DeletePlanRequest{}, err + } + + return DeletePlanRequest{ + NamespacedID: models.NamespacedID{ + Namespace: ns, + ID: planID, + }, + }, nil + }, + func(ctx context.Context, request DeletePlanRequest) (DeletePlanResponse, error) { + if err := h.service.DeletePlan(ctx, request); err != nil { + return nil, err + } + + return nil, nil + }, + commonhttp.EmptyResponseEncoder[DeletePlanResponse](http.StatusNoContent), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("delete-plan"), + httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()), + )..., + ) +} diff --git a/api/v3/handlers/plans/get.go b/api/v3/handlers/plans/get.go new file mode 100644 index 0000000000000000000000000000000000000000..6f6b085806b89d2d1593b576920c4c4bb40370b5 --- /dev/null +++ b/api/v3/handlers/plans/get.go @@ -0,0 +1,52 @@ +package plans + +import ( + "context" + "net/http" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/apierrors" + "github.com/openmeterio/openmeter/openmeter/productcatalog/plan" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" + "github.com/openmeterio/openmeter/pkg/models" +) + +type ( + GetPlanRequest = plan.GetPlanInput + GetPlanResponse = api.BillingPlan + GetPlanParams = string + GetPlanHandler httptransport.HandlerWithArgs[GetPlanRequest, GetPlanResponse, GetPlanParams] +) + +func (h *handler) GetPlan() GetPlanHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, planID GetPlanParams) (GetPlanRequest, error) { + ns, err := h.resolveNamespace(ctx) + if err != nil { + return GetPlanRequest{}, err + } + + return GetPlanRequest{ + NamespacedID: models.NamespacedID{ + Namespace: ns, + ID: planID, + }, + }, nil + }, + func(ctx context.Context, request GetPlanRequest) (GetPlanResponse, error) { + p, err := h.service.GetPlan(ctx, request) + if err != nil { + return GetPlanResponse{}, err + } + + return ToAPIBillingPlan(*p) + }, + commonhttp.JSONResponseEncoderWithStatus[GetPlanResponse](http.StatusOK), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("get-plan"), + httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()), + )..., + ) +} diff --git a/api/v3/handlers/plans/handler.go b/api/v3/handlers/plans/handler.go new file mode 100644 index 0000000000000000000000000000000000000000..cf867a6b063de2442390bce14dbf0935b2e24fb5 --- /dev/null +++ b/api/v3/handlers/plans/handler.go @@ -0,0 +1,39 @@ +package plans + +import ( + "context" + + "github.com/openmeterio/openmeter/openmeter/productcatalog/plan" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" +) + +type Handler interface { + ListPlans() ListPlansHandler + GetPlan() GetPlanHandler + CreatePlan() CreatePlanHandler + UpdatePlan() UpdatePlanHandler + DeletePlan() DeletePlanHandler + PublishPlan() PublishPlanHandler + ArchivePlan() ArchivePlanHandler +} + +type handler struct { + resolveNamespace func(ctx context.Context) (string, error) + service plan.Service + unitConfigEnabled bool + options []httptransport.HandlerOption +} + +func New( + resolveNamespace func(ctx context.Context) (string, error), + service plan.Service, + unitConfigEnabled bool, + options ...httptransport.HandlerOption, +) Handler { + return &handler{ + resolveNamespace: resolveNamespace, + service: service, + unitConfigEnabled: unitConfigEnabled, + options: options, + } +} diff --git a/api/v3/handlers/plans/list.go b/api/v3/handlers/plans/list.go new file mode 100644 index 0000000000000000000000000000000000000000..afbd5d3d716ef3bf3ca1e9f0151c72fccacd641e --- /dev/null +++ b/api/v3/handlers/plans/list.go @@ -0,0 +1,139 @@ +package plans + +import ( + "context" + "net/http" + + "github.com/samber/lo" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/apierrors" + "github.com/openmeterio/openmeter/api/v3/filters" + "github.com/openmeterio/openmeter/api/v3/request" + "github.com/openmeterio/openmeter/api/v3/response" + "github.com/openmeterio/openmeter/openmeter/productcatalog" + "github.com/openmeterio/openmeter/openmeter/productcatalog/plan" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" + "github.com/openmeterio/openmeter/pkg/pagination" +) + +type ( + ListPlansRequest = plan.ListPlansInput + ListPlansResponse = response.PagePaginationResponse[api.BillingPlan] + ListPlansParams = api.ListPlansParams + ListPlansHandler httptransport.HandlerWithArgs[ListPlansRequest, ListPlansResponse, ListPlansParams] +) + +func (h *handler) ListPlans() ListPlansHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, params ListPlansParams) (ListPlansRequest, error) { + ns, err := h.resolveNamespace(ctx) + if err != nil { + return ListPlansRequest{}, err + } + + page := pagination.NewPage(1, 20) + if params.Page != nil { + page = pagination.NewPage( + lo.FromPtrOr(params.Page.Number, 1), + lo.FromPtrOr(params.Page.Size, 20), + ) + } + + if err := page.Validate(); err != nil { + return ListPlansRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{ + apierrors.InvalidParameter{ + Field: "page", + Reason: err.Error(), + Source: apierrors.InvalidParamSourceQuery, + }, + }) + } + + req := ListPlansRequest{ + Namespaces: []string{ns}, + Page: page, + } + + if params.Filter != nil { + key, err := filters.FromAPIFilterString(params.Filter.Key) + if err != nil { + return ListPlansRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{ + {Field: "filter[key]", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery}, + }) + } + req.Key = key + + name, err := filters.FromAPIFilterString(params.Filter.Name) + if err != nil { + return ListPlansRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{ + {Field: "filter[name]", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery}, + }) + } + req.Name = name + + currency, err := filters.FromAPIFilterStringExact(params.Filter.Currency) + if err != nil { + return ListPlansRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{ + {Field: "filter[currency]", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery}, + }) + } + req.Currency = currency + + status, err := filters.FromAPIStatusFilter[productcatalog.PlanStatus](ctx, params.Filter.Status) + if err != nil { + return ListPlansRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{ + {Field: "filter[status]", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery}, + }) + } + req.Status = status + } + + if params.Sort != nil { + sort, err := request.ParseSortBy(*params.Sort) + if err != nil { + return ListPlansRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{ + {Field: "sort", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery}, + }) + } + orderBy, err := FromAPIPlanSortField(ctx, sort.Field) + if err != nil { + return ListPlansRequest{}, err + } + req.OrderBy = orderBy + req.Order = sort.Order.ToSortxOrder() + } + + return req, nil + }, + func(ctx context.Context, req ListPlansRequest) (ListPlansResponse, error) { + result, err := h.service.ListPlans(ctx, req) + if err != nil { + return ListPlansResponse{}, err + } + + items := make([]api.BillingPlan, 0, len(result.Items)) + for _, p := range result.Items { + billingPlan, err := ToAPIBillingPlan(p) + if err != nil { + return ListPlansResponse{}, err + } + + items = append(items, billingPlan) + } + + return response.NewPagePaginationResponse(items, response.PageMetaPage{ + Size: req.Page.PageSize, + Number: req.Page.PageNumber, + Total: lo.ToPtr(result.TotalCount), + }), nil + }, + commonhttp.JSONResponseEncoderWithStatus[ListPlansResponse](http.StatusOK), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("list-plans"), + httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()), + )..., + ) +} diff --git a/api/v3/handlers/plans/planaddons/convert.go b/api/v3/handlers/plans/planaddons/convert.go new file mode 100644 index 0000000000000000000000000000000000000000..9801a3343e1f6b24d94d37a4a650ac831039afa7 --- /dev/null +++ b/api/v3/handlers/plans/planaddons/convert.go @@ -0,0 +1,41 @@ +package planaddons + +import ( + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/labels" + "github.com/openmeterio/openmeter/openmeter/productcatalog/planaddon" + "github.com/openmeterio/openmeter/pkg/models" +) + +func ToAPIPlanAddon(a planaddon.PlanAddon) (api.PlanAddon, error) { + validationIssues, _ := a.AsProductCatalogPlanAddon().ValidationErrors() + + return api.PlanAddon{ + Id: a.ID, + Addon: api.AddonReference{Id: a.Addon.ID}, + FromPlanPhase: a.PlanAddonConfig.FromPlanPhase, + MaxQuantity: a.PlanAddonConfig.MaxQuantity, + CreatedAt: a.CreatedAt, + UpdatedAt: a.UpdatedAt, + DeletedAt: a.DeletedAt, + Labels: labels.FromMetadata(a.Metadata), + ValidationErrors: ToAPIProductCatalogValidationErrors(validationIssues), + }, nil +} + +func ToAPIProductCatalogValidationErrors(issues models.ValidationIssues) *[]api.ProductCatalogValidationError { + if len(issues) == 0 { + return nil + } + + result := make([]api.ProductCatalogValidationError, 0, len(issues)) + for _, issue := range issues { + result = append(result, api.ProductCatalogValidationError{ + Code: string(issue.Code()), + Field: issue.Field().JSONPath(), + Message: issue.Message(), + }) + } + + return &result +} diff --git a/api/v3/handlers/plans/planaddons/create.go b/api/v3/handlers/plans/planaddons/create.go new file mode 100644 index 0000000000000000000000000000000000000000..03bc97f30de5d93daefbc7ae0e643142169e95b5 --- /dev/null +++ b/api/v3/handlers/plans/planaddons/create.go @@ -0,0 +1,73 @@ +package planaddons + +import ( + "context" + "fmt" + "net/http" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/apierrors" + "github.com/openmeterio/openmeter/api/v3/labels" + "github.com/openmeterio/openmeter/api/v3/request" + "github.com/openmeterio/openmeter/openmeter/productcatalog/planaddon" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" + "github.com/openmeterio/openmeter/pkg/models" +) + +type ( + CreatePlanAddonRequest = planaddon.CreatePlanAddonInput + CreatePlanAddonResponse = api.PlanAddon + CreatePlanAddonParams = string + CreatePlanAddonHandler httptransport.HandlerWithArgs[CreatePlanAddonRequest, CreatePlanAddonResponse, CreatePlanAddonParams] +) + +func (h *handler) CreatePlanAddon() CreatePlanAddonHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, planID CreatePlanAddonParams) (CreatePlanAddonRequest, error) { + body := api.CreatePlanAddonRequest{} + if err := request.ParseBody(r, &body); err != nil { + return CreatePlanAddonRequest{}, err + } + + ns, err := h.resolveNamespace(ctx) + if err != nil { + return CreatePlanAddonRequest{}, err + } + + meta, err := labels.ToMetadata(body.Labels) + if err != nil { + return CreatePlanAddonRequest{}, err + } + + return CreatePlanAddonRequest{ + NamespacedModel: models.NamespacedModel{ + Namespace: ns, + }, + PlanID: planID, + AddonID: body.Addon.Id, + FromPlanPhase: body.FromPlanPhase, + MaxQuantity: body.MaxQuantity, + Metadata: meta, + }, nil + }, + func(ctx context.Context, request CreatePlanAddonRequest) (CreatePlanAddonResponse, error) { + a, err := h.addonService.CreatePlanAddon(ctx, request) + if err != nil { + return CreatePlanAddonResponse{}, err + } + + if a == nil { + return CreatePlanAddonResponse{}, fmt.Errorf("failed to create plan addon") + } + + return ToAPIPlanAddon(*a) + }, + commonhttp.JSONResponseEncoderWithStatus[CreatePlanAddonResponse](http.StatusCreated), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("create-plan-addon"), + httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()), + )..., + ) +} diff --git a/api/v3/handlers/plans/planaddons/delete.go b/api/v3/handlers/plans/planaddons/delete.go new file mode 100644 index 0000000000000000000000000000000000000000..7aeb3e34376ac53f27f728243a03b72d69333df6 --- /dev/null +++ b/api/v3/handlers/plans/planaddons/delete.go @@ -0,0 +1,54 @@ +package planaddons + +import ( + "context" + "net/http" + + "github.com/openmeterio/openmeter/api/v3/apierrors" + "github.com/openmeterio/openmeter/openmeter/productcatalog/planaddon" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" + "github.com/openmeterio/openmeter/pkg/models" +) + +type ( + DeletePlanAddonRequest = planaddon.DeletePlanAddonInput + DeletePlanAddonParams struct { + PlanID string + PlanAddonID string + } + DeletePlanAddonResponse = any + DeletePlanAddonHandler httptransport.HandlerWithArgs[DeletePlanAddonRequest, DeletePlanAddonResponse, DeletePlanAddonParams] +) + +func (h *handler) DeletePlanAddon() DeletePlanAddonHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, params DeletePlanAddonParams) (DeletePlanAddonRequest, error) { + ns, err := h.resolveNamespace(ctx) + if err != nil { + return DeletePlanAddonRequest{}, err + } + + return DeletePlanAddonRequest{ + NamespacedModel: models.NamespacedModel{ + Namespace: ns, + }, + ID: params.PlanAddonID, + PlanID: params.PlanID, + }, nil + }, + func(ctx context.Context, request DeletePlanAddonRequest) (DeletePlanAddonResponse, error) { + if err := h.addonService.DeletePlanAddon(ctx, request); err != nil { + return nil, err + } + + return nil, nil + }, + commonhttp.EmptyResponseEncoder[DeletePlanAddonResponse](http.StatusNoContent), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("delete-plan-addon"), + httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()), + )..., + ) +} diff --git a/api/v3/handlers/plans/planaddons/get.go b/api/v3/handlers/plans/planaddons/get.go new file mode 100644 index 0000000000000000000000000000000000000000..c52b543db0382b348ed60bfd7c553b745efc2521 --- /dev/null +++ b/api/v3/handlers/plans/planaddons/get.go @@ -0,0 +1,55 @@ +package planaddons + +import ( + "context" + "net/http" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/apierrors" + "github.com/openmeterio/openmeter/openmeter/productcatalog/planaddon" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" + "github.com/openmeterio/openmeter/pkg/models" +) + +type ( + GetPlanAddonRequest = planaddon.GetPlanAddonInput + GetPlanAddonParams struct { + PlanID string + PlanAddonID string + } + GetPlanAddonResponse = api.PlanAddon + GetPlanAddonHandler httptransport.HandlerWithArgs[GetPlanAddonRequest, GetPlanAddonResponse, GetPlanAddonParams] +) + +func (h *handler) GetPlanAddon() GetPlanAddonHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, params GetPlanAddonParams) (GetPlanAddonRequest, error) { + ns, err := h.resolveNamespace(ctx) + if err != nil { + return GetPlanAddonRequest{}, err + } + + return GetPlanAddonRequest{ + NamespacedModel: models.NamespacedModel{ + Namespace: ns, + }, + ID: params.PlanAddonID, + }, nil + }, + func(ctx context.Context, request GetPlanAddonRequest) (GetPlanAddonResponse, error) { + a, err := h.addonService.GetPlanAddon(ctx, request) + if err != nil { + return GetPlanAddonResponse{}, err + } + + return ToAPIPlanAddon(*a) + }, + commonhttp.JSONResponseEncoderWithStatus[GetPlanAddonResponse](http.StatusOK), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("get-plan-addon"), + httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()), + )..., + ) +} diff --git a/api/v3/handlers/plans/planaddons/handler.go b/api/v3/handlers/plans/planaddons/handler.go new file mode 100644 index 0000000000000000000000000000000000000000..f31611fb21bbf3e422e2fe43fbdf01b3db4cd3da --- /dev/null +++ b/api/v3/handlers/plans/planaddons/handler.go @@ -0,0 +1,38 @@ +package planaddons + +import ( + "context" + + "github.com/openmeterio/openmeter/openmeter/productcatalog/plan" + "github.com/openmeterio/openmeter/openmeter/productcatalog/planaddon" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" +) + +type Handler interface { + ListPlanAddons() ListPlanAddonsHandler + GetPlanAddon() GetPlanAddonHandler + CreatePlanAddon() CreatePlanAddonHandler + UpdatePlanAddon() UpdatePlanAddonHandler + DeletePlanAddon() DeletePlanAddonHandler +} + +type handler struct { + resolveNamespace func(ctx context.Context) (string, error) + service plan.Service + addonService planaddon.Service + options []httptransport.HandlerOption +} + +func New( + resolveNamespace func(ctx context.Context) (string, error), + service plan.Service, + addonService planaddon.Service, + options ...httptransport.HandlerOption, +) Handler { + return &handler{ + resolveNamespace: resolveNamespace, + service: service, + addonService: addonService, + options: options, + } +} diff --git a/api/v3/handlers/plans/planaddons/lists.go b/api/v3/handlers/plans/planaddons/lists.go new file mode 100644 index 0000000000000000000000000000000000000000..c561dad1971c3fabcad1ff28f9b9c62d4b37eb3e --- /dev/null +++ b/api/v3/handlers/plans/planaddons/lists.go @@ -0,0 +1,89 @@ +package planaddons + +import ( + "context" + "net/http" + + "github.com/samber/lo" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/apierrors" + "github.com/openmeterio/openmeter/api/v3/response" + "github.com/openmeterio/openmeter/openmeter/productcatalog/planaddon" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" + "github.com/openmeterio/openmeter/pkg/pagination" +) + +type ( + ListPlanAddonsRequest = planaddon.ListPlanAddonsInput + ListPlanAddonsParams struct { + PlanID string + Params api.ListPlanAddonsParams + } + ListPlanAddonsResponse = response.PagePaginationResponse[api.PlanAddon] + ListPlanAddonsHandler httptransport.HandlerWithArgs[ListPlanAddonsRequest, ListPlanAddonsResponse, ListPlanAddonsParams] +) + +func (h *handler) ListPlanAddons() ListPlanAddonsHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, params ListPlanAddonsParams) (ListPlanAddonsRequest, error) { + ns, err := h.resolveNamespace(ctx) + if err != nil { + return ListPlanAddonsRequest{}, err + } + + page := pagination.NewPage(1, 20) + if params.Params.Page != nil { + page = pagination.NewPage( + lo.FromPtrOr(params.Params.Page.Number, 1), + lo.FromPtrOr(params.Params.Page.Size, 20), + ) + } + + if err := page.Validate(); err != nil { + return ListPlanAddonsRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{ + apierrors.InvalidParameter{ + Field: "page", + Reason: err.Error(), + Source: apierrors.InvalidParamSourceQuery, + }, + }) + } + + return ListPlanAddonsRequest{ + Namespaces: []string{ns}, + PlanIDs: []string{params.PlanID}, + Page: page, + }, nil + }, + func(ctx context.Context, req ListPlanAddonsRequest) (ListPlanAddonsResponse, error) { + result, err := h.addonService.ListPlanAddons(ctx, req) + if err != nil { + return ListPlanAddonsResponse{}, err + } + + items := make([]api.PlanAddon, 0, len(result.Items)) + for _, a := range result.Items { + planAddon, err := ToAPIPlanAddon(a) + if err != nil { + return ListPlanAddonsResponse{}, err + } + + items = append(items, planAddon) + } + + return response.NewPagePaginationResponse(items, response.PageMetaPage{ + Size: req.Page.PageSize, + Number: req.Page.PageNumber, + Total: lo.ToPtr(result.TotalCount), + }), nil + }, + commonhttp.JSONResponseEncoderWithStatus[ListPlanAddonsResponse](http.StatusOK), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("list-plan-addons"), + httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()), + )..., + ) +} diff --git a/api/v3/handlers/plans/planaddons/update.go b/api/v3/handlers/plans/planaddons/update.go new file mode 100644 index 0000000000000000000000000000000000000000..ef18c26f6d3c8fe990219a2512d578f3ce122b83 --- /dev/null +++ b/api/v3/handlers/plans/planaddons/update.go @@ -0,0 +1,80 @@ +package planaddons + +import ( + "context" + "fmt" + "net/http" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/apierrors" + "github.com/openmeterio/openmeter/api/v3/labels" + "github.com/openmeterio/openmeter/api/v3/request" + "github.com/openmeterio/openmeter/openmeter/productcatalog/planaddon" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" + "github.com/openmeterio/openmeter/pkg/models" +) + +type ( + UpdatePlanAddonRequest = planaddon.UpdatePlanAddonInput + UpdatePlanAddonParams struct { + PlanID string + PlanAddonID string + } + UpdatePlanAddonResponse = api.PlanAddon + UpdatePlanAddonHandler httptransport.HandlerWithArgs[UpdatePlanAddonRequest, UpdatePlanAddonResponse, UpdatePlanAddonParams] +) + +func (h *handler) UpdatePlanAddon() UpdatePlanAddonHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, params UpdatePlanAddonParams) (UpdatePlanAddonRequest, error) { + body := api.UpsertPlanAddonRequest{} + if err := request.ParseBody(r, &body); err != nil { + return UpdatePlanAddonRequest{}, err + } + + ns, err := h.resolveNamespace(ctx) + if err != nil { + return UpdatePlanAddonRequest{}, err + } + + req := UpdatePlanAddonRequest{ + NamespacedModel: models.NamespacedModel{ + Namespace: ns, + }, + ID: params.PlanAddonID, + PlanID: params.PlanID, + FromPlanPhase: &body.FromPlanPhase, + MaxQuantity: body.MaxQuantity, + } + + if body.Labels != nil { + m, err := labels.ToMetadata(body.Labels) + if err != nil { + return UpdatePlanAddonRequest{}, err + } + req.Metadata = &m + } + + return req, nil + }, + func(ctx context.Context, request UpdatePlanAddonRequest) (UpdatePlanAddonResponse, error) { + a, err := h.addonService.UpdatePlanAddon(ctx, request) + if err != nil { + return UpdatePlanAddonResponse{}, err + } + + if a == nil { + return UpdatePlanAddonResponse{}, fmt.Errorf("failed to update plan addon") + } + + return ToAPIPlanAddon(*a) + }, + commonhttp.JSONResponseEncoderWithStatus[UpdatePlanAddonResponse](http.StatusOK), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("update-plan-addon"), + httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()), + )..., + ) +} diff --git a/api/v3/handlers/plans/publish.go b/api/v3/handlers/plans/publish.go new file mode 100644 index 0000000000000000000000000000000000000000..5312dfcbeec29578825c2f956e166905d90b1b49 --- /dev/null +++ b/api/v3/handlers/plans/publish.go @@ -0,0 +1,64 @@ +package plans + +import ( + "context" + "fmt" + "net/http" + + "github.com/samber/lo" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/apierrors" + "github.com/openmeterio/openmeter/openmeter/productcatalog" + "github.com/openmeterio/openmeter/openmeter/productcatalog/plan" + "github.com/openmeterio/openmeter/pkg/clock" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" + "github.com/openmeterio/openmeter/pkg/models" +) + +type ( + PublishPlanRequest = plan.PublishPlanInput + PublishPlanResponse = api.BillingPlan + PublishPlanParams = string + PublishPlanHandler httptransport.HandlerWithArgs[PublishPlanRequest, PublishPlanResponse, PublishPlanParams] +) + +func (h *handler) PublishPlan() PublishPlanHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, planID PublishPlanParams) (PublishPlanRequest, error) { + ns, err := h.resolveNamespace(ctx) + if err != nil { + return PublishPlanRequest{}, err + } + + return PublishPlanRequest{ + NamespacedID: models.NamespacedID{ + Namespace: ns, + ID: planID, + }, + EffectivePeriod: productcatalog.EffectivePeriod{ + EffectiveFrom: lo.ToPtr(clock.Now()), + }, + }, nil + }, + func(ctx context.Context, request PublishPlanRequest) (PublishPlanResponse, error) { + p, err := h.service.PublishPlan(ctx, request) + if err != nil { + return PublishPlanResponse{}, err + } + + if p == nil { + return PublishPlanResponse{}, fmt.Errorf("failed to publish plan") + } + + return ToAPIBillingPlan(*p) + }, + commonhttp.JSONResponseEncoderWithStatus[PublishPlanResponse](http.StatusOK), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("publish-plan"), + httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()), + )..., + ) +} diff --git a/api/v3/handlers/plans/update.go b/api/v3/handlers/plans/update.go new file mode 100644 index 0000000000000000000000000000000000000000..1ea25e4c1c09224871ab82e8b6763bcc9422bd61 --- /dev/null +++ b/api/v3/handlers/plans/update.go @@ -0,0 +1,76 @@ +package plans + +import ( + "context" + "fmt" + "net/http" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/apierrors" + "github.com/openmeterio/openmeter/api/v3/request" + "github.com/openmeterio/openmeter/openmeter/productcatalog/plan" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" + "github.com/openmeterio/openmeter/pkg/models" +) + +type ( + UpdatePlanRequest = plan.UpdatePlanInput + UpdatePlanResponse = api.BillingPlan + UpdatePlanParams = string + UpdatePlanHandler httptransport.HandlerWithArgs[UpdatePlanRequest, UpdatePlanResponse, UpdatePlanParams] +) + +func (h *handler) UpdatePlan() UpdatePlanHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, planID UpdatePlanParams) (UpdatePlanRequest, error) { + body := api.UpsertPlanRequest{} + if err := request.ParseBody(r, &body); err != nil { + return UpdatePlanRequest{}, err + } + + // NOTE: We gate the plan authoring behind this config flag. It is applied for both create and update and will be removed when unit config is feature complete. + if !h.unitConfigEnabled { + for _, phase := range body.Phases { + for _, rc := range phase.RateCards { + if rc.UnitConfig != nil { + return UpdatePlanRequest{}, models.NewGenericValidationError(fmt.Errorf("unit_config is not enabled on this deployment of OpenMeter")) + } + } + } + } + + ns, err := h.resolveNamespace(ctx) + if err != nil { + return UpdatePlanRequest{}, err + } + + req, err := FromAPIUpsertPlanRequest(ns, planID, body) + if err != nil { + return UpdatePlanRequest{}, err + } + + req.IgnoreNonCriticalIssues = true + + return req, nil + }, + func(ctx context.Context, request UpdatePlanRequest) (UpdatePlanResponse, error) { + p, err := h.service.UpdatePlan(ctx, request) + if err != nil { + return UpdatePlanResponse{}, err + } + + if p == nil { + return UpdatePlanResponse{}, fmt.Errorf("failed to update plan") + } + + return ToAPIBillingPlan(*p) + }, + commonhttp.JSONResponseEncoderWithStatus[UpdatePlanResponse](http.StatusOK), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("update-plan"), + httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()), + )..., + ) +} diff --git a/api/v3/handlers/subscriptions/cancel.go b/api/v3/handlers/subscriptions/cancel.go new file mode 100644 index 0000000000000000000000000000000000000000..71ef53c962ae9cf052b33630bc7c32fc5002a1bb --- /dev/null +++ b/api/v3/handlers/subscriptions/cancel.go @@ -0,0 +1,77 @@ +package subscriptions + +import ( + "context" + "net/http" + + "github.com/samber/lo" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/apierrors" + "github.com/openmeterio/openmeter/api/v3/request" + "github.com/openmeterio/openmeter/openmeter/subscription" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" + models "github.com/openmeterio/openmeter/pkg/models" +) + +type ( + CancelSubscriptionRequest struct { + ID models.NamespacedID + Timing subscription.Timing + } + CancelSubscriptionResponse = api.BillingSubscription + CancelSubscriptionParams = string + CancelSubscriptionHandler httptransport.HandlerWithArgs[CancelSubscriptionRequest, CancelSubscriptionResponse, CancelSubscriptionParams] +) + +func (h *handler) CancelSubscription() CancelSubscriptionHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, subscriptionID CancelSubscriptionParams) (CancelSubscriptionRequest, error) { + // Parse body + body := api.BillingSubscriptionCancel{} + if err := request.ParseBody(r, &body); err != nil { + return CancelSubscriptionRequest{}, err + } + + // Resolve namespace + ns, err := h.resolveNamespace(ctx) + if err != nil { + return CancelSubscriptionRequest{}, err + } + + // Timing (defaults to immediate) + timing := subscription.Timing{} + if body.Timing == nil { + timing.Enum = lo.ToPtr(subscription.TimingImmediate) + } else { + timing, err = FromAPIBillingSubscriptionEditTiming(*body.Timing) + if err != nil { + return CancelSubscriptionRequest{}, err + } + } + + return CancelSubscriptionRequest{ + ID: models.NamespacedID{ + Namespace: ns, + ID: subscriptionID, + }, + Timing: timing, + }, nil + }, + func(ctx context.Context, req CancelSubscriptionRequest) (CancelSubscriptionResponse, error) { + sub, err := h.subscriptionService.Cancel(ctx, req.ID, req.Timing) + if err != nil { + return CancelSubscriptionResponse{}, err + } + + return ToAPIBillingSubscription(sub), nil + }, + commonhttp.JSONResponseEncoderWithStatus[CancelSubscriptionResponse](http.StatusOK), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("cancel-subscription"), + httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()), + )..., + ) +} diff --git a/api/v3/handlers/subscriptions/change.go b/api/v3/handlers/subscriptions/change.go new file mode 100644 index 0000000000000000000000000000000000000000..35c0786407609f507d7a28781ef43cd18cb8fc5a --- /dev/null +++ b/api/v3/handlers/subscriptions/change.go @@ -0,0 +1,149 @@ +package subscriptions + +import ( + "context" + "errors" + "net/http" + + "github.com/samber/lo" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/apierrors" + "github.com/openmeterio/openmeter/api/v3/request" + "github.com/openmeterio/openmeter/openmeter/productcatalog" + plansubscription "github.com/openmeterio/openmeter/openmeter/productcatalog/subscription" + subscriptionworkflow "github.com/openmeterio/openmeter/openmeter/subscription/workflow" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" + models "github.com/openmeterio/openmeter/pkg/models" +) + +type ( + ChangeSubscriptionRequest struct { + ID models.NamespacedID + PlanInput plansubscription.PlanInput + WorkflowInput subscriptionworkflow.ChangeSubscriptionWorkflowInput + SettlementMode *productcatalog.SettlementMode + } + ChangeSubscriptionResponse = api.BillingSubscriptionChangeResponse + ChangeSubscriptionParams = string + ChangeSubscriptionHandler httptransport.HandlerWithArgs[ChangeSubscriptionRequest, ChangeSubscriptionResponse, ChangeSubscriptionParams] +) + +func (h *handler) ChangeSubscription() ChangeSubscriptionHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, subscriptionID ChangeSubscriptionParams) (ChangeSubscriptionRequest, error) { + // Parse body + body := api.BillingSubscriptionChange{} + if err := request.ParseBody(r, &body); err != nil { + return ChangeSubscriptionRequest{}, err + } + + // Resolve namespace + ns, err := h.resolveNamespace(ctx) + if err != nil { + return ChangeSubscriptionRequest{}, err + } + + var settlementMode *productcatalog.SettlementMode + if body.SettlementMode != nil { + settlementMode = lo.ToPtr(productcatalog.SettlementMode(*body.SettlementMode)) + } + + id := models.NamespacedID{ + Namespace: ns, + ID: subscriptionID, + } + + // Fetch current subscription for defaults (name/desc/metadata) + curr, err := h.subscriptionService.Get(ctx, id) + if err != nil { + return ChangeSubscriptionRequest{}, err + } + + // Currently only plan-based subscription change is supported, so plan ref is required. + if body.Plan.Id == nil && body.Plan.Key == nil { + reason := "one of plan.id or plan.key is required" + return ChangeSubscriptionRequest{}, apierrors.NewBadRequestError( + ctx, + errors.New(reason), + []apierrors.InvalidParameter{ + { + Field: "plan.id", + Reason: reason, + Source: apierrors.InvalidParamSourceBody, + Rule: "required", + }, + { + Field: "plan.key", + Reason: reason, + Source: apierrors.InvalidParamSourceBody, + Rule: "required", + }, + }, + ) + } + + // Validate that plan exists and resolve to a concrete version + planEntity, err := h.getPlanByIDOrKey(ctx, ns, body.Plan.Id, body.Plan.Key, body.Plan.Version) + if err != nil { + return ChangeSubscriptionRequest{}, err + } + + planInput := plansubscription.PlanInput{} + planInput.FromRef(&plansubscription.PlanRefInput{ + Key: planEntity.Key, + Version: &planEntity.Version, + }) + + timing, err := FromAPIBillingSubscriptionEditTiming(body.Timing) + if err != nil { + return ChangeSubscriptionRequest{}, err + } + + metadataModel := curr.MetadataModel + if body.Labels != nil { + metadataModel = models.MetadataModel{ + Metadata: models.Metadata(*body.Labels), + } + } + + workflowInput := subscriptionworkflow.ChangeSubscriptionWorkflowInput{ + Timing: timing, + MetadataModel: metadataModel, + Name: curr.Name, + Description: curr.Description, + BillingAnchor: body.BillingAnchor, + } + + return ChangeSubscriptionRequest{ + ID: id, + PlanInput: planInput, + WorkflowInput: workflowInput, + SettlementMode: settlementMode, + }, nil + }, + func(ctx context.Context, req ChangeSubscriptionRequest) (ChangeSubscriptionResponse, error) { + resp, err := h.planSubscriptionService.Change(ctx, plansubscription.ChangeSubscriptionRequest{ + ID: req.ID, + WorkflowInput: req.WorkflowInput, + PlanInput: req.PlanInput, + SettlementMode: req.SettlementMode, + }) + if err != nil { + return ChangeSubscriptionResponse{}, err + } + + return ChangeSubscriptionResponse{ + Current: ToAPIBillingSubscription(resp.Current), + Next: ToAPIBillingSubscription(resp.Next.Subscription), + }, nil + }, + commonhttp.JSONResponseEncoderWithStatus[ChangeSubscriptionResponse](http.StatusOK), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("change-subscription"), + httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()), + )..., + ) +} diff --git a/api/v3/handlers/subscriptions/convert.go b/api/v3/handlers/subscriptions/convert.go new file mode 100644 index 0000000000000000000000000000000000000000..b084e49aea0d66c47172164f1545d465ce268398 --- /dev/null +++ b/api/v3/handlers/subscriptions/convert.go @@ -0,0 +1,111 @@ +package subscriptions + +import ( + "context" + "fmt" + + "github.com/samber/lo" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/apierrors" + "github.com/openmeterio/openmeter/api/v3/labels" + "github.com/openmeterio/openmeter/openmeter/customer" + "github.com/openmeterio/openmeter/openmeter/subscription" + subscriptionworkflow "github.com/openmeterio/openmeter/openmeter/subscription/workflow" + "github.com/openmeterio/openmeter/pkg/clock" + "github.com/openmeterio/openmeter/pkg/models" +) + +func FromAPISubscriptionSortField(ctx context.Context, field string) (subscription.OrderBy, error) { + switch field { + case "id": + return subscription.OrderByID, nil + case "active_from": + return subscription.OrderByActiveFrom, nil + case "active_to": + return subscription.OrderByActiveTo, nil + default: + return "", apierrors.NewUnsupportedSortFieldError( + ctx, field, "id", "active_from", "active_to", + ) + } +} + +func ToAPIBillingSubscription(subscription subscription.Subscription) api.BillingSubscription { + subscriptionAPI := api.BillingSubscription{ + Id: subscription.ID, + CustomerId: subscription.CustomerId, + BillingAnchor: subscription.BillingAnchor, + SettlementMode: lo.ToPtr(api.BillingSettlementMode(subscription.SettlementMode)), + Status: api.BillingSubscriptionStatus(subscription.GetStatusAt(clock.Now())), + Labels: labels.FromMetadataAnnotations(subscription.Metadata, subscription.Annotations), + CreatedAt: subscription.CreatedAt, + UpdatedAt: subscription.UpdatedAt, + DeletedAt: subscription.DeletedAt, + } + + // Only set if the subscription is created from a plan + if subscription.PlanRef != nil { + subscriptionAPI.PlanId = &subscription.PlanRef.Id + } + + return subscriptionAPI +} + +func FromAPIBillingSubscriptionEditTimingEnum(t api.BillingSubscriptionEditTimingEnum) (subscription.Timing, error) { + switch string(t) { + case "immediate": + return subscription.Timing{Enum: lo.ToPtr(subscription.TimingImmediate)}, nil + case "next_billing_cycle": + return subscription.Timing{Enum: lo.ToPtr(subscription.TimingNextBillingCycle)}, nil + default: + return subscription.Timing{}, models.NewGenericValidationError(fmt.Errorf("invalid timing: %s", t)) + } +} + +func FromAPIBillingSubscriptionEditTiming(t api.BillingSubscriptionEditTiming) (subscription.Timing, error) { + // Try decoding as a custom RFC3339 datetime first, otherwise it would also decode as a "string enum" + // and we'd never be able to distinguish enum vs datetime. + if custom, err := t.AsDateTime(); err == nil { + return subscription.Timing{Custom: &custom}, nil + } + + enum, err := t.AsBillingSubscriptionEditTimingEnum() + if err != nil { + return subscription.Timing{}, models.NewGenericValidationError(fmt.Errorf("invalid timing")) + } + + return FromAPIBillingSubscriptionEditTimingEnum(enum) +} + +// FromAPIBillingSubscriptionCreate converts a create subscription request to a create subscription workflow input. +func FromAPIBillingSubscriptionCreate( + namespace string, + customerID customer.CustomerID, + subscriptionName string, + createSubscriptionRequest api.BillingSubscriptionCreate, +) (subscriptionworkflow.CreateSubscriptionWorkflowInput, error) { + metadata, err := labels.ToMetadata(createSubscriptionRequest.Labels) + if err != nil { + return subscriptionworkflow.CreateSubscriptionWorkflowInput{}, err + } + + workflowInput := subscriptionworkflow.CreateSubscriptionWorkflowInput{ + Namespace: namespace, + CustomerID: customerID.ID, + BillingAnchor: createSubscriptionRequest.BillingAnchor, + ChangeSubscriptionWorkflowInput: subscriptionworkflow.ChangeSubscriptionWorkflowInput{ + Name: subscriptionName, + Timing: subscription.Timing{ + // TODO: accept from request + Enum: lo.ToPtr(subscription.TimingImmediate), + }, + BillingAnchor: createSubscriptionRequest.BillingAnchor, + MetadataModel: models.MetadataModel{ + Metadata: metadata, + }, + }, + } + + return workflowInput, nil +} diff --git a/api/v3/handlers/subscriptions/create.go b/api/v3/handlers/subscriptions/create.go new file mode 100644 index 0000000000000000000000000000000000000000..fd02f6bce4305ec239849b5fa61d842b3cc52263 --- /dev/null +++ b/api/v3/handlers/subscriptions/create.go @@ -0,0 +1,210 @@ +package subscriptions + +import ( + "context" + "errors" + "fmt" + "net/http" + + "github.com/samber/lo" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/apierrors" + "github.com/openmeterio/openmeter/api/v3/request" + "github.com/openmeterio/openmeter/openmeter/customer" + "github.com/openmeterio/openmeter/openmeter/productcatalog" + "github.com/openmeterio/openmeter/openmeter/productcatalog/plan" + plansubscription "github.com/openmeterio/openmeter/openmeter/productcatalog/subscription" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" + models "github.com/openmeterio/openmeter/pkg/models" +) + +type ( + CreateSubscriptionRequest = plansubscription.CreateSubscriptionRequest + CreateSubscriptionResponse = api.BillingSubscription + CreateSubscriptionHandler = httptransport.Handler[CreateSubscriptionRequest, CreateSubscriptionResponse] +) + +// CreateSubscription returns a new httptransport.Handler for creating a subscription. +func (h *handler) CreateSubscription() CreateSubscriptionHandler { + return httptransport.NewHandler( + func(ctx context.Context, r *http.Request) (plansubscription.CreateSubscriptionRequest, error) { + // Parse the request body + body := api.BillingSubscriptionCreate{} + if err := request.ParseBody(r, &body); err != nil { + return CreateSubscriptionRequest{}, err + } + + // Resolve the namespace + ns, err := h.resolveNamespace(ctx) + if err != nil { + return CreateSubscriptionRequest{}, err + } + + var settlementMode *productcatalog.SettlementMode + if body.SettlementMode != nil { + settlementMode = lo.ToPtr(productcatalog.SettlementMode(*body.SettlementMode)) + } + + // Validate that either customer ID or customer key is provided + if body.Customer.Id == nil && body.Customer.Key == nil { + reason := "one of customer.id or customer.key is required" + return CreateSubscriptionRequest{}, apierrors.NewBadRequestError( + ctx, + errors.New(reason), + []apierrors.InvalidParameter{ + { + Field: "customer.id", + Reason: reason, + Source: apierrors.InvalidParamSourceBody, + Rule: "required", + }, + { + Field: "customer.key", + Reason: reason, + Source: apierrors.InvalidParamSourceBody, + Rule: "required", + }, + }, + ) + } + + // Get the customer to validate it exists + customerEntity, err := h.getCustomerByIDOrKey(ctx, ns, body.Customer.Id, body.Customer.Key) + if err != nil { + return CreateSubscriptionRequest{}, fmt.Errorf("failed to get customer: %w", err) + } + + // TODO: implement custom subscription creation + if body.Plan.Id == nil && body.Plan.Key == nil { + reason := "one of plan.id or plan.key is required" + // We use bad request error because not implemented does not provide the error context + return CreateSubscriptionRequest{}, apierrors.NewBadRequestError( + ctx, + errors.New(reason), + []apierrors.InvalidParameter{ + { + Field: "plan.id", + Reason: reason, + Source: apierrors.InvalidParamSourceBody, + Rule: "required", + }, + { + Field: "plan.key", + Reason: reason, + Source: apierrors.InvalidParamSourceBody, + Rule: "required", + }, + }, + ) + } + + // Get the plan entity by ID or key to validate it exists + planEntity, err := h.getPlanByIDOrKey(ctx, ns, body.Plan.Id, body.Plan.Key, body.Plan.Version) + if err != nil { + return CreateSubscriptionRequest{}, fmt.Errorf("failed to get plan: %w", err) + } + + // Convert the plan entity to a plan input + planInput := plansubscription.PlanInput{} + planInput.FromRef(&plansubscription.PlanRefInput{ + Key: planEntity.Key, + Version: &planEntity.Version, + }) + + // Convert the request to a create subscription workflow input + subscriptionName := fmt.Sprintf("%s v%d", planEntity.Key, planEntity.Version) + workflowInput, err := FromAPIBillingSubscriptionCreate( + ns, + customerEntity.GetID(), + subscriptionName, + body, + ) + if err != nil { + return CreateSubscriptionRequest{}, err + } + + return plansubscription.CreateSubscriptionRequest{ + WorkflowInput: workflowInput, + PlanInput: planInput, + SettlementMode: settlementMode, + }, nil + }, + func(ctx context.Context, request plansubscription.CreateSubscriptionRequest) (CreateSubscriptionResponse, error) { + // Create the subscription from a plan + m, err := h.planSubscriptionService.Create(ctx, request) + if err != nil { + return CreateSubscriptionResponse{}, err + } + + // Convert the subscription to an API subscription + return ToAPIBillingSubscription(m), nil + }, + commonhttp.JSONResponseEncoderWithStatus[CreateSubscriptionResponse](http.StatusCreated), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("create-subscription"), + httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()), + )..., + ) +} + +// getCustomerByIDOrKey gets a customer by ID or key helper function +// TODO: move this to the customer service +func (h *handler) getCustomerByIDOrKey(ctx context.Context, namespace string, customerID *string, customerKey *string) (*customer.Customer, error) { + var getCustomerInput customer.GetCustomerInput + + if customerID != nil { + getCustomerInput = customer.GetCustomerInput{ + CustomerID: &customer.CustomerID{ + Namespace: namespace, + ID: *customerID, + }, + } + } else if customerKey != nil { + getCustomerInput = customer.GetCustomerInput{ + CustomerKey: &customer.CustomerKey{ + Namespace: namespace, + Key: *customerKey, + }, + } + } else { + return nil, fmt.Errorf("customer id or customer key is required") + } + + return h.customerService.GetCustomer(ctx, getCustomerInput) +} + +// getPlanByIDOrKey gets a plan by ID or key helper function +// TODO: move this to the plan service +func (h *handler) getPlanByIDOrKey(ctx context.Context, namespace string, planID *string, planKey *string, planVersion *int) (*plan.Plan, error) { + // Get the plan entity, to validate it exists + var getPlanInput plan.GetPlanInput + + if planID != nil { + getPlanInput = plan.GetPlanInput{ + NamespacedID: models.NamespacedID{ + Namespace: namespace, + ID: *planID, + }, + } + } else if planKey != nil { + getPlanInput = plan.GetPlanInput{} + // We use setters because namespace only exists on namespaced ID + // But here we don't have a namespaced ID + getPlanInput.Namespace = namespace + getPlanInput.Key = *planKey + + if planVersion != nil { + getPlanInput.Version = *planVersion + } else { + getPlanInput.IncludeLatest = true + } + } else { + return nil, errors.New("plan id or plan key must be set") + } + + // Get the plan entity + return h.planService.GetPlan(ctx, getPlanInput) +} diff --git a/api/v3/handlers/subscriptions/get.go b/api/v3/handlers/subscriptions/get.go new file mode 100644 index 0000000000000000000000000000000000000000..7fc8d205a108d63427ea2d82a21781c38acdb8d6 --- /dev/null +++ b/api/v3/handlers/subscriptions/get.go @@ -0,0 +1,51 @@ +package subscriptions + +import ( + "context" + "net/http" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/apierrors" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" + models "github.com/openmeterio/openmeter/pkg/models" +) + +type ( + GetSubscriptionRequest = models.NamespacedID + GetSubscriptionResponse = api.BillingSubscription + GetSubscriptionParams = string + GetSubscriptionHandler httptransport.HandlerWithArgs[GetSubscriptionRequest, GetSubscriptionResponse, GetSubscriptionParams] +) + +// GetSubscription returns a handler for getting a subscription. +func (h *handler) GetSubscription() GetSubscriptionHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, subscriptionID GetSubscriptionParams) (GetSubscriptionRequest, error) { + ns, err := h.resolveNamespace(ctx) + if err != nil { + return GetSubscriptionRequest{}, err + } + + return GetSubscriptionRequest{ + Namespace: ns, + ID: subscriptionID, + }, nil + }, + func(ctx context.Context, request GetSubscriptionRequest) (GetSubscriptionResponse, error) { + // Get the subscription + m, err := h.subscriptionService.Get(ctx, request) + if err != nil { + return GetSubscriptionResponse{}, err + } + + return ToAPIBillingSubscription(m), nil + }, + commonhttp.JSONResponseEncoderWithStatus[GetSubscriptionResponse](http.StatusOK), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("get-subscription"), + httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()), + )..., + ) +} diff --git a/api/v3/handlers/subscriptions/handler.go b/api/v3/handlers/subscriptions/handler.go new file mode 100644 index 0000000000000000000000000000000000000000..810f0aeb1ee6b27d948a6815b96afcb0055286ea --- /dev/null +++ b/api/v3/handlers/subscriptions/handler.go @@ -0,0 +1,47 @@ +package subscriptions + +import ( + "context" + + "github.com/openmeterio/openmeter/openmeter/customer" + "github.com/openmeterio/openmeter/openmeter/productcatalog/plan" + plansubscription "github.com/openmeterio/openmeter/openmeter/productcatalog/subscription" + "github.com/openmeterio/openmeter/openmeter/subscription" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" +) + +type Handler interface { + ListSubscriptions() ListSubscriptionsHandler + GetSubscription() GetSubscriptionHandler + CreateSubscription() CreateSubscriptionHandler + CancelSubscription() CancelSubscriptionHandler + UnscheduleCancelation() UnscheduleCancelationHandler + ChangeSubscription() ChangeSubscriptionHandler +} + +type handler struct { + resolveNamespace func(ctx context.Context) (string, error) + customerService customer.Service + planService plan.Service + planSubscriptionService plansubscription.PlanSubscriptionService + subscriptionService subscription.Service + options []httptransport.HandlerOption +} + +func New( + resolveNamespace func(ctx context.Context) (string, error), + customerService customer.Service, + planService plan.Service, + planSubscriptionService plansubscription.PlanSubscriptionService, + subscriptionService subscription.Service, + options ...httptransport.HandlerOption, +) Handler { + return &handler{ + resolveNamespace: resolveNamespace, + customerService: customerService, + planService: planService, + planSubscriptionService: planSubscriptionService, + subscriptionService: subscriptionService, + options: options, + } +} diff --git a/api/v3/handlers/subscriptions/list.go b/api/v3/handlers/subscriptions/list.go new file mode 100644 index 0000000000000000000000000000000000000000..d0a63e3ff66b3bff78bc69fd38def3cb3cd8c74c --- /dev/null +++ b/api/v3/handlers/subscriptions/list.go @@ -0,0 +1,147 @@ +package subscriptions + +import ( + "context" + "net/http" + + "github.com/samber/lo" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/apierrors" + "github.com/openmeterio/openmeter/api/v3/filters" + "github.com/openmeterio/openmeter/api/v3/request" + "github.com/openmeterio/openmeter/api/v3/response" + "github.com/openmeterio/openmeter/openmeter/subscription" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" + "github.com/openmeterio/openmeter/pkg/pagination" +) + +type ( + ListSubscriptionsRequest = subscription.ListSubscriptionsInput + ListSubscriptionsResponse = response.PagePaginationResponse[api.BillingSubscription] + ListSubscriptionsParams = api.ListSubscriptionsParams + ListSubscriptionsHandler = httptransport.HandlerWithArgs[ListSubscriptionsRequest, ListSubscriptionsResponse, ListSubscriptionsParams] +) + +func (h *handler) ListSubscriptions() ListSubscriptionsHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, params ListSubscriptionsParams) (ListSubscriptionsRequest, error) { + // Resolve namespace + ns, err := h.resolveNamespace(ctx) + if err != nil { + return ListSubscriptionsRequest{}, err + } + + // Pagination + page := pagination.NewPage(1, 20) + if params.Page != nil { + page = pagination.NewPage( + lo.FromPtrOr(params.Page.Number, 1), + lo.FromPtrOr(params.Page.Size, 20), + ) + } + + if err := page.Validate(); err != nil { + return ListSubscriptionsRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{ + apierrors.InvalidParameter{ + Field: "page", + Reason: err.Error(), + Source: apierrors.InvalidParamSourceQuery, + }, + }) + } + + // Build request + req := ListSubscriptionsRequest{ + Namespaces: []string{ns}, + Page: page, + } + + // Filters + if params.Filter != nil { + customerID, err := filters.FromAPIFilterULID(params.Filter.CustomerId) + if err != nil { + return ListSubscriptionsRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{ + {Field: "filter[customer_id]", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery}, + }) + } + req.CustomerID = customerID + + id, err := filters.FromAPIFilterULID(params.Filter.Id) + if err != nil { + return ListSubscriptionsRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{ + {Field: "filter[id]", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery}, + }) + } + req.ID = id + + status, err := filters.FromAPIStatusFilter[subscription.SubscriptionStatus](ctx, params.Filter.Status) + if err != nil { + return ListSubscriptionsRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{ + {Field: "filter[status]", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery}, + }) + } + req.Status = status + + planID, err := filters.FromAPIFilterULID(params.Filter.PlanId) + if err != nil { + return ListSubscriptionsRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{ + {Field: "filter[plan_id]", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery}, + }) + } + req.PlanID = planID + + planKey, err := filters.FromAPIFilterStringExact(params.Filter.PlanKey) + if err != nil { + return ListSubscriptionsRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{ + {Field: "filter[plan_key]", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery}, + }) + } + req.PlanKey = planKey + } + + // Sort + if params.Sort != nil { + sort, err := request.ParseSortBy(*params.Sort) + if err != nil { + return ListSubscriptionsRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{ + {Field: "sort", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery}, + }) + } + orderBy, err := FromAPISubscriptionSortField(ctx, sort.Field) + if err != nil { + return ListSubscriptionsRequest{}, err + } + req.OrderBy = orderBy + req.Order = sort.Order.ToSortxOrder() + } + + return req, nil + }, + func(ctx context.Context, request ListSubscriptionsRequest) (ListSubscriptionsResponse, error) { + resp, err := h.subscriptionService.List(ctx, request) + if err != nil { + return ListSubscriptionsResponse{}, err + } + + subscriptions := lo.Map(resp.Items, func(item subscription.Subscription, _ int) api.BillingSubscription { + return ToAPIBillingSubscription(item) + }) + + r := response.NewPagePaginationResponse(subscriptions, response.PageMetaPage{ + Size: request.Page.PageSize, + Number: request.Page.PageNumber, + Total: lo.ToPtr(resp.TotalCount), + }) + + return r, nil + }, + commonhttp.JSONResponseEncoderWithStatus[ListSubscriptionsResponse](http.StatusOK), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("list-subscriptions"), + httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()), + )..., + ) +} diff --git a/api/v3/handlers/subscriptions/subscriptionaddons/convert.go b/api/v3/handlers/subscriptions/subscriptionaddons/convert.go new file mode 100644 index 0000000000000000000000000000000000000000..0e4a35130349ffa30640719494adb9a579188ab8 --- /dev/null +++ b/api/v3/handlers/subscriptions/subscriptionaddons/convert.go @@ -0,0 +1,131 @@ +package subscriptionaddons + +import ( + "context" + "errors" + "fmt" + + "github.com/samber/lo" + + apiv3 "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/apierrors" + "github.com/openmeterio/openmeter/api/v3/handlers/plans" + "github.com/openmeterio/openmeter/api/v3/handlers/subscriptions" + "github.com/openmeterio/openmeter/api/v3/labels" + "github.com/openmeterio/openmeter/openmeter/subscription" + subscriptionaddon "github.com/openmeterio/openmeter/openmeter/subscription/addon" + addondiff "github.com/openmeterio/openmeter/openmeter/subscription/addon/diff" + subscriptionworkflow "github.com/openmeterio/openmeter/openmeter/subscription/workflow" + "github.com/openmeterio/openmeter/pkg/clock" + "github.com/openmeterio/openmeter/pkg/models" + "github.com/openmeterio/openmeter/pkg/slicesx" + "github.com/openmeterio/openmeter/pkg/timeutil" +) + +func FromAPISubscriptionAddonSortField(ctx context.Context, field string) (subscriptionaddon.OrderBy, error) { + switch field { + case "id": + return subscriptionaddon.OrderByID, nil + case "created_at": + return subscriptionaddon.OrderByCreatedAt, nil + case "updated_at": + return subscriptionaddon.OrderByUpdatedAt, nil + default: + return "", apierrors.NewUnsupportedSortFieldError( + ctx, field, "id", "created_at", "updated_at", + ) + } +} + +func FromAPICreateSubscriptionAddonRequest(req apiv3.CreateSubscriptionAddonRequest) (subscriptionworkflow.AddAddonWorkflowInput, error) { + timing, err := subscriptions.FromAPIBillingSubscriptionEditTiming(req.Timing) + if err != nil { + return subscriptionworkflow.AddAddonWorkflowInput{}, fmt.Errorf("failed to convert timing: %w", err) + } + + meta, err := labels.ToMetadata(req.Labels) + if err != nil { + return subscriptionworkflow.AddAddonWorkflowInput{}, err + } + + return subscriptionworkflow.AddAddonWorkflowInput{ + AddonID: req.Addon.Id, + InitialQuantity: req.Quantity, + Timing: timing, + MetadataModel: models.MetadataModel{ + Metadata: meta, + }, + }, nil +} + +func toAPISubscriptionAddon(view subscription.SubscriptionView, addon subscriptionaddon.SubscriptionAddon) (apiv3.SubscriptionAddon, error) { + now := clock.Now() + + // inst.Quantity is 0 when no instance is active at now (e.g. addon scheduled for next_billing_cycle). + inst, _ := addon.GetInstanceAt(now) + + pers := lo.Map(addon.GetInstances(), func(i subscriptionaddon.SubscriptionAddonInstance, _ int) timeutil.OpenPeriod { + return i.AsPeriod() + }) + + if len(pers) == 0 { + return apiv3.SubscriptionAddon{}, models.NewGenericNotFoundError(errors.New("no instances found for subscription addon")) + } + + union := lo.Reduce(pers, func(agg timeutil.OpenPeriod, item timeutil.OpenPeriod, _ int) timeutil.OpenPeriod { + return agg.Union(item) + }, pers[0]) + + affectedMap := addondiff.GetAffectedItemIDs(view, addon) + + rateCards, err := slicesx.MapWithErr(addon.RateCards, func(r subscriptionaddon.SubscriptionAddonRateCard) (apiv3.SubscriptionAddonRateCard, error) { + rc, err := plans.ToAPIBillingRateCard(r.AddonRateCard.RateCard) + if err != nil { + return apiv3.SubscriptionAddonRateCard{}, fmt.Errorf("failed to convert rate card: %w", err) + } + + // JSON encoders should emit [] not null when no items are affected. + ids := affectedMap[r.AddonRateCard.RateCard.Key()] + if ids == nil { + ids = []string{} + } + + return apiv3.SubscriptionAddonRateCard{ + RateCard: rc, + AffectedSubscriptionItemIds: ids, + }, nil + }) + if err != nil { + return apiv3.SubscriptionAddon{}, fmt.Errorf("failed to convert rate cards: %w", err) + } + + // Addons with no rate cards leave RateCards nil; emit [] so the response satisfies the array schema. + if rateCards == nil { + rateCards = []apiv3.SubscriptionAddonRateCard{} + } + + return apiv3.SubscriptionAddon{ + Id: addon.ID, + Name: addon.Name, + Description: addon.Description, + CreatedAt: addon.CreatedAt, + UpdatedAt: addon.UpdatedAt, + DeletedAt: addon.DeletedAt, + Addon: apiv3.AddonReference{ + Id: addon.Addon.ID, + }, + Labels: labels.FromMetadata(addon.Metadata), + Quantity: inst.Quantity, + QuantityAt: now, + ActiveFrom: lo.FromPtrOr(union.From, now), + ActiveTo: union.To, + Timeline: lo.Map(addon.GetInstances(), func(i subscriptionaddon.SubscriptionAddonInstance, _ int) apiv3.SubscriptionAddonTimelineSegment { + return apiv3.SubscriptionAddonTimelineSegment{ + Quantity: i.Quantity, + ActiveFrom: i.CadencedModel.ActiveFrom, + ActiveTo: i.CadencedModel.ActiveTo, + } + }), + RateCards: rateCards, + }, nil +} diff --git a/api/v3/handlers/subscriptions/subscriptionaddons/convert_test.go b/api/v3/handlers/subscriptions/subscriptionaddons/convert_test.go new file mode 100644 index 0000000000000000000000000000000000000000..dc3d00b14fdf72275f8d4e5a9ef9d4f10eea6964 --- /dev/null +++ b/api/v3/handlers/subscriptions/subscriptionaddons/convert_test.go @@ -0,0 +1,149 @@ +package subscriptionaddons + +import ( + "testing" + "time" + + "github.com/samber/lo" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + apiv3 "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/openmeter/subscription" + subscriptionaddon "github.com/openmeterio/openmeter/openmeter/subscription/addon" + "github.com/openmeterio/openmeter/pkg/clock" + "github.com/openmeterio/openmeter/pkg/models" + "github.com/openmeterio/openmeter/pkg/timeutil" +) + +func TestFromAPICreateSubscriptionAddonRequest(t *testing.T) { + t.Run("maps immediate timing and labels", func(t *testing.T) { + var timing apiv3.BillingSubscriptionEditTiming + require.NoError(t, timing.FromBillingSubscriptionEditTimingEnum(apiv3.BillingSubscriptionEditTimingEnum("immediate"))) + + labels := apiv3.Labels{"team": "billing"} + req := apiv3.CreateSubscriptionAddonRequest{ + Addon: apiv3.AddonReference{Id: "01J8GFKQ0000000000000000"}, + Labels: &labels, + Quantity: 2, + Timing: timing, + } + + input, err := FromAPICreateSubscriptionAddonRequest(req) + require.NoError(t, err) + + assert.Equal(t, "01J8GFKQ0000000000000000", input.AddonID) + assert.Equal(t, 2, input.InitialQuantity) + require.NotNil(t, input.Timing.Enum) + assert.Equal(t, subscription.TimingImmediate, *input.Timing.Enum) + assert.Equal(t, models.Metadata{"team": "billing"}, input.Metadata) + }) + + t.Run("maps next_billing_cycle timing", func(t *testing.T) { + var timing apiv3.BillingSubscriptionEditTiming + require.NoError(t, timing.FromBillingSubscriptionEditTimingEnum(apiv3.BillingSubscriptionEditTimingEnum("next_billing_cycle"))) + + req := apiv3.CreateSubscriptionAddonRequest{ + Addon: apiv3.AddonReference{Id: "addon-id"}, + Quantity: 1, + Timing: timing, + } + + input, err := FromAPICreateSubscriptionAddonRequest(req) + require.NoError(t, err) + require.NotNil(t, input.Timing.Enum) + assert.Equal(t, subscription.TimingNextBillingCycle, *input.Timing.Enum) + assert.Nil(t, input.Metadata) + }) + + t.Run("fails on invalid timing string", func(t *testing.T) { + var timing apiv3.BillingSubscriptionEditTiming + require.NoError(t, timing.FromBillingSubscriptionEditTimingEnum(apiv3.BillingSubscriptionEditTimingEnum("sometime_later"))) + + req := apiv3.CreateSubscriptionAddonRequest{ + Addon: apiv3.AddonReference{Id: "addon-id"}, + Quantity: 1, + Timing: timing, + } + + _, err := FromAPICreateSubscriptionAddonRequest(req) + require.Error(t, err) + }) +} + +// newTestSubscriptionAddon builds a SubscriptionAddon with a single quantity at activeFrom, +// no rate cards, and no soft-delete. The view stays empty — toAPISubscriptionAddon's only +// dependency on the view is through GetAffectedItemIDs, which returns an empty map for an +// empty view, exercising the nil-slice fallback path on rate cards. +func newTestSubscriptionAddon(activeFrom time.Time, qty int) subscriptionaddon.SubscriptionAddon { + now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + + return subscriptionaddon.SubscriptionAddon{ + NamespacedID: models.NamespacedID{Namespace: "ns", ID: "01J8GFKQ0000000000000000"}, + ManagedModel: models.ManagedModel{CreatedAt: now, UpdatedAt: now}, + Name: "Test addon", + Quantities: timeutil.NewTimeline([]timeutil.Timed[subscriptionaddon.SubscriptionAddonQuantity]{ + subscriptionaddon.SubscriptionAddonQuantity{ + ActiveFrom: activeFrom, + Quantity: qty, + }.AsTimed(), + }), + } +} + +func TestToAPISubscriptionAddon(t *testing.T) { + now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + clock.FreezeTime(now) + defer clock.UnFreeze() + + t.Run("active instance maps quantity from current instance", func(t *testing.T) { + addon := newTestSubscriptionAddon(now.Add(-time.Hour), 3) + + got, err := toAPISubscriptionAddon(subscription.SubscriptionView{}, addon) + require.NoError(t, err) + + assert.Equal(t, 3, got.Quantity) + assert.Equal(t, now, got.QuantityAt) + assert.Empty(t, got.RateCards) + require.Len(t, got.Timeline, 1) + assert.Equal(t, 3, got.Timeline[0].Quantity) + }) + + t.Run("future-scheduled instance returns quantity 0 instead of error", func(t *testing.T) { + // Reproduces the next_billing_cycle case where active_from is in the future: + // the previous code returned NotFound; the fix surfaces quantity 0. + addon := newTestSubscriptionAddon(now.Add(time.Hour), 5) + + got, err := toAPISubscriptionAddon(subscription.SubscriptionView{}, addon) + require.NoError(t, err) + + assert.Equal(t, 0, got.Quantity) + assert.Equal(t, now, got.QuantityAt) + require.Len(t, got.Timeline, 1) + assert.Equal(t, 5, got.Timeline[0].Quantity) + assert.Equal(t, now.Add(time.Hour), got.ActiveFrom) + }) + + t.Run("errors when addon has no instances", func(t *testing.T) { + addon := subscriptionaddon.SubscriptionAddon{ + NamespacedID: models.NamespacedID{Namespace: "ns", ID: "id"}, + Quantities: timeutil.NewTimeline([]timeutil.Timed[subscriptionaddon.SubscriptionAddonQuantity]{}), + } + + _, err := toAPISubscriptionAddon(subscription.SubscriptionView{}, addon) + require.Error(t, err) + }) + + t.Run("preserves description and labels round-trip", func(t *testing.T) { + addon := newTestSubscriptionAddon(now.Add(-time.Hour), 1) + addon.Description = lo.ToPtr("with desc") + addon.Metadata = models.Metadata{"team": "billing"} + + got, err := toAPISubscriptionAddon(subscription.SubscriptionView{}, addon) + require.NoError(t, err) + + assert.Equal(t, lo.ToPtr("with desc"), got.Description) + require.NotNil(t, got.Labels) + assert.Equal(t, "billing", (*got.Labels)["team"]) + }) +} diff --git a/api/v3/handlers/subscriptions/subscriptionaddons/create.go b/api/v3/handlers/subscriptions/subscriptionaddons/create.go new file mode 100644 index 0000000000000000000000000000000000000000..02497f1d87ab9c176cf63de025778ede061071ea --- /dev/null +++ b/api/v3/handlers/subscriptions/subscriptionaddons/create.go @@ -0,0 +1,67 @@ +package subscriptionaddons + +import ( + "context" + "net/http" + + apiv3 "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/apierrors" + "github.com/openmeterio/openmeter/api/v3/request" + subscriptionworkflow "github.com/openmeterio/openmeter/openmeter/subscription/workflow" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" + "github.com/openmeterio/openmeter/pkg/models" +) + +type ( + CreateSubscriptionAddonRequest = struct { + SubscriptionID models.NamespacedID + AddonInput subscriptionworkflow.AddAddonWorkflowInput + } + CreateSubscriptionAddonResponse = apiv3.SubscriptionAddon + CreateSubscriptionAddonParams = string + CreateSubscriptionAddonHandler = httptransport.HandlerWithArgs[CreateSubscriptionAddonRequest, CreateSubscriptionAddonResponse, CreateSubscriptionAddonParams] +) + +func (h *handler) CreateSubscriptionAddon() CreateSubscriptionAddonHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, subscriptionID CreateSubscriptionAddonParams) (CreateSubscriptionAddonRequest, error) { + body := apiv3.CreateSubscriptionAddonRequest{} + if err := request.ParseBody(r, &body); err != nil { + return CreateSubscriptionAddonRequest{}, err + } + + ns, err := h.resolveNamespace(ctx) + if err != nil { + return CreateSubscriptionAddonRequest{}, err + } + + addonInput, err := FromAPICreateSubscriptionAddonRequest(body) + if err != nil { + return CreateSubscriptionAddonRequest{}, err + } + + return CreateSubscriptionAddonRequest{ + SubscriptionID: models.NamespacedID{ + Namespace: ns, + ID: subscriptionID, + }, + AddonInput: addonInput, + }, nil + }, + func(ctx context.Context, request CreateSubscriptionAddonRequest) (CreateSubscriptionAddonResponse, error) { + view, added, err := h.subscriptionWorkflowService.AddAddon(ctx, request.SubscriptionID, request.AddonInput) + if err != nil { + return CreateSubscriptionAddonResponse{}, err + } + + return toAPISubscriptionAddon(view, added) + }, + commonhttp.JSONResponseEncoderWithStatus[CreateSubscriptionAddonResponse](http.StatusCreated), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("create-subscription-addon"), + httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()), + )..., + ) +} diff --git a/api/v3/handlers/subscriptions/subscriptionaddons/get.go b/api/v3/handlers/subscriptions/subscriptionaddons/get.go new file mode 100644 index 0000000000000000000000000000000000000000..970e650b77cf07886dd82454a6442a4be835a70d --- /dev/null +++ b/api/v3/handlers/subscriptions/subscriptionaddons/get.go @@ -0,0 +1,67 @@ +package subscriptionaddons + +import ( + "context" + "errors" + "net/http" + + apiv3 "github.com/openmeterio/openmeter/api/v3" + subscriptionaddon "github.com/openmeterio/openmeter/openmeter/subscription/addon" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" + "github.com/openmeterio/openmeter/pkg/models" +) + +type ( + GetSubscriptionAddonRequest = subscriptionaddon.GetSubscriptionAddonInput + GetSubscriptionAddonParams struct { + SubscriptionID string + SubscriptionAddonID string + } + GetSubscriptionAddonResponse = apiv3.SubscriptionAddon + GetSubscriptionAddonHandler httptransport.HandlerWithArgs[GetSubscriptionAddonRequest, GetSubscriptionAddonResponse, GetSubscriptionAddonParams] +) + +func (h *handler) GetSubscriptionAddon() GetSubscriptionAddonHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, params GetSubscriptionAddonParams) (GetSubscriptionAddonRequest, error) { + ns, err := h.resolveNamespace(ctx) + if err != nil { + return GetSubscriptionAddonRequest{}, err + } + + return GetSubscriptionAddonRequest{ + NamespacedID: models.NamespacedID{ + Namespace: ns, + ID: params.SubscriptionAddonID, + }, + SubscriptionID: params.SubscriptionID, + }, nil + }, + func(ctx context.Context, request GetSubscriptionAddonRequest) (GetSubscriptionAddonResponse, error) { + a, err := h.addonService.Get(ctx, request) + if err != nil { + return GetSubscriptionAddonResponse{}, err + } + + if a == nil { + return GetSubscriptionAddonResponse{}, models.NewGenericNotFoundError(errors.New("subscription addon not found")) + } + + view, err := h.subscriptionService.GetView(ctx, models.NamespacedID{ + Namespace: request.NamespacedID.Namespace, + ID: request.SubscriptionID, + }) + if err != nil { + return GetSubscriptionAddonResponse{}, err + } + + return toAPISubscriptionAddon(view, *a) + }, + commonhttp.JSONResponseEncoderWithStatus[GetSubscriptionAddonResponse](http.StatusOK), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("get-subscription-addon"), + )..., + ) +} diff --git a/api/v3/handlers/subscriptions/subscriptionaddons/handler.go b/api/v3/handlers/subscriptions/subscriptionaddons/handler.go new file mode 100644 index 0000000000000000000000000000000000000000..120c8618590bac1b162d3b412ff75fafb2a95f7b --- /dev/null +++ b/api/v3/handlers/subscriptions/subscriptionaddons/handler.go @@ -0,0 +1,40 @@ +package subscriptionaddons + +import ( + "context" + + "github.com/openmeterio/openmeter/openmeter/subscription" + subscriptionaddon "github.com/openmeterio/openmeter/openmeter/subscription/addon" + subscriptionworkflow "github.com/openmeterio/openmeter/openmeter/subscription/workflow" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" +) + +type Handler interface { + CreateSubscriptionAddon() CreateSubscriptionAddonHandler + ListSubscriptionAddons() ListSubscriptionAddonsHandler + GetSubscriptionAddon() GetSubscriptionAddonHandler +} + +type handler struct { + resolveNamespace func(ctx context.Context) (string, error) + addonService subscriptionaddon.Service + subscriptionService subscription.Service + subscriptionWorkflowService subscriptionworkflow.Service + options []httptransport.HandlerOption +} + +func New( + resolveNamespace func(ctx context.Context) (string, error), + addonService subscriptionaddon.Service, + subscriptionService subscription.Service, + subscriptionWorkflowService subscriptionworkflow.Service, + options ...httptransport.HandlerOption, +) Handler { + return &handler{ + resolveNamespace: resolveNamespace, + addonService: addonService, + subscriptionService: subscriptionService, + subscriptionWorkflowService: subscriptionWorkflowService, + options: options, + } +} diff --git a/api/v3/handlers/subscriptions/subscriptionaddons/list.go b/api/v3/handlers/subscriptions/subscriptionaddons/list.go new file mode 100644 index 0000000000000000000000000000000000000000..7142eaa5d46355749796d850c7b8dfbe8902227d --- /dev/null +++ b/api/v3/handlers/subscriptions/subscriptionaddons/list.go @@ -0,0 +1,123 @@ +package subscriptionaddons + +import ( + "context" + "net/http" + + "github.com/samber/lo" + + apiv3 "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/apierrors" + "github.com/openmeterio/openmeter/api/v3/request" + "github.com/openmeterio/openmeter/api/v3/response" + subscriptionaddon "github.com/openmeterio/openmeter/openmeter/subscription/addon" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" + "github.com/openmeterio/openmeter/pkg/models" + "github.com/openmeterio/openmeter/pkg/pagination" +) + +type ListSubscriptionAddonsParams struct { + SubscriptionID apiv3.ULID + Params apiv3.ListSubscriptionAddonsParams +} + +type listSubscriptionAddonsRequest struct { + SubscriptionID models.NamespacedID + Input subscriptionaddon.ListSubscriptionAddonsInput +} + +type ( + ListSubscriptionAddonsRequest = listSubscriptionAddonsRequest + ListSubscriptionAddonsResponse = response.PagePaginationResponse[apiv3.SubscriptionAddon] + ListSubscriptionAddonsHandler = httptransport.HandlerWithArgs[ListSubscriptionAddonsRequest, ListSubscriptionAddonsResponse, ListSubscriptionAddonsParams] +) + +func (h *handler) ListSubscriptionAddons() ListSubscriptionAddonsHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, params ListSubscriptionAddonsParams) (ListSubscriptionAddonsRequest, error) { + ns, err := h.resolveNamespace(ctx) + if err != nil { + return ListSubscriptionAddonsRequest{}, err + } + + page := pagination.NewPage(1, 20) + if params.Params.Page != nil { + page = pagination.NewPage( + lo.FromPtrOr(params.Params.Page.Number, 1), + lo.FromPtrOr(params.Params.Page.Size, 20), + ) + } + + if err := page.Validate(); err != nil { + return ListSubscriptionAddonsRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{ + {Field: "page", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery}, + }) + } + + input := subscriptionaddon.ListSubscriptionAddonsInput{ + SubscriptionID: params.SubscriptionID, + Page: page, + } + + if params.Params.Sort != nil { + sort, err := request.ParseSortBy(*params.Params.Sort) + if err != nil { + return ListSubscriptionAddonsRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{ + {Field: "sort", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery}, + }) + } + + orderBy, err := FromAPISubscriptionAddonSortField(ctx, sort.Field) + if err != nil { + return ListSubscriptionAddonsRequest{}, err + } + input.OrderBy = orderBy + input.Order = sort.Order.ToSortxOrder() + } + + return ListSubscriptionAddonsRequest{ + SubscriptionID: models.NamespacedID{Namespace: ns, ID: params.SubscriptionID}, + Input: input, + }, nil + }, + func(ctx context.Context, req ListSubscriptionAddonsRequest) (ListSubscriptionAddonsResponse, error) { + res, err := h.addonService.List(ctx, req.SubscriptionID.Namespace, req.Input) + if err != nil { + return ListSubscriptionAddonsResponse{}, err + } + + if len(res.Items) == 0 { + return response.NewPagePaginationResponse([]apiv3.SubscriptionAddon{}, response.PageMetaPage{ + Size: req.Input.Page.PageSize, + Number: req.Input.Page.PageNumber, + Total: lo.ToPtr(res.TotalCount), + }), nil + } + + view, err := h.subscriptionService.GetView(ctx, req.SubscriptionID) + if err != nil { + return ListSubscriptionAddonsResponse{}, err + } + + items, err := lo.MapErr(res.Items, func(item subscriptionaddon.SubscriptionAddon, _ int) (apiv3.SubscriptionAddon, error) { + return toAPISubscriptionAddon(view, item) + }) + if err != nil { + return ListSubscriptionAddonsResponse{}, err + } + + return response.NewPagePaginationResponse(items, response.PageMetaPage{ + Size: req.Input.Page.PageSize, + Number: req.Input.Page.PageNumber, + Total: lo.ToPtr(res.TotalCount), + }), nil + }, + commonhttp.JSONResponseEncoderWithStatus[ListSubscriptionAddonsResponse](http.StatusOK), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("list-subscription-addons"), + httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()), + )..., + ) +} diff --git a/api/v3/handlers/subscriptions/unschedule_cancelation.go b/api/v3/handlers/subscriptions/unschedule_cancelation.go new file mode 100644 index 0000000000000000000000000000000000000000..47ba0e3923603df8d65a24774271074c8520f769 --- /dev/null +++ b/api/v3/handlers/subscriptions/unschedule_cancelation.go @@ -0,0 +1,49 @@ +package subscriptions + +import ( + "context" + "net/http" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/apierrors" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" + models "github.com/openmeterio/openmeter/pkg/models" +) + +type ( + UnscheduleCancelationRequest = models.NamespacedID + UnscheduleCancelationResponse = api.BillingSubscription + UnscheduleCancelationParams = string + UnscheduleCancelationHandler httptransport.HandlerWithArgs[UnscheduleCancelationRequest, UnscheduleCancelationResponse, UnscheduleCancelationParams] +) + +func (h *handler) UnscheduleCancelation() UnscheduleCancelationHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, subscriptionID UnscheduleCancelationParams) (UnscheduleCancelationRequest, error) { + ns, err := h.resolveNamespace(ctx) + if err != nil { + return UnscheduleCancelationRequest{}, err + } + + return UnscheduleCancelationRequest{ + Namespace: ns, + ID: subscriptionID, + }, nil + }, + func(ctx context.Context, req UnscheduleCancelationRequest) (UnscheduleCancelationResponse, error) { + sub, err := h.subscriptionService.Continue(ctx, req) + if err != nil { + return UnscheduleCancelationResponse{}, err + } + + return ToAPIBillingSubscription(sub), nil + }, + commonhttp.JSONResponseEncoderWithStatus[UnscheduleCancelationResponse](http.StatusOK), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("unschedule-cancelation"), + httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()), + )..., + ) +} diff --git a/api/v3/handlers/taxcodes/convert.gen.go b/api/v3/handlers/taxcodes/convert.gen.go new file mode 100644 index 0000000000000000000000000000000000000000..560d44be2f0b8b98b161f8daebb2917cbd9653b2 --- /dev/null +++ b/api/v3/handlers/taxcodes/convert.gen.go @@ -0,0 +1,96 @@ +// Code generated by github.com/jmattheis/goverter, DO NOT EDIT. +//go:build !goverter + +package taxcodes + +import ( + v3 "github.com/openmeterio/openmeter/api/v3" + taxcode "github.com/openmeterio/openmeter/openmeter/taxcode" + models "github.com/openmeterio/openmeter/pkg/models" + "time" +) + +func init() { + FromAPICreateTaxCodeRequest = func(context string, source v3.CreateTaxCodeRequest) (taxcode.CreateTaxCodeInput, error) { + var taxcodeCreateTaxCodeInput taxcode.CreateTaxCodeInput + taxcodeCreateTaxCodeInput.Namespace = NamespaceFromContext(context) + taxcodeCreateTaxCodeInput.Key = source.Key + taxcodeCreateTaxCodeInput.Name = source.Name + taxcodeCreateTaxCodeInput.Description = source.Description + taxcodeCreateTaxCodeInput.AppMappings = v3BillingTaxCodeAppMappingListToTaxcodeTaxCodeAppMappings(source.AppMappings) + modelsMetadata, err := ConvertLabelsToMetadata(source.Labels) + if err != nil { + return taxcodeCreateTaxCodeInput, err + } + taxcodeCreateTaxCodeInput.Metadata = modelsMetadata + return taxcodeCreateTaxCodeInput, nil + } + FromAPIUpdateOrganizationDefaultTaxCodesRequest = func(context string, source v3.UpdateOrganizationDefaultTaxCodesRequest) (taxcode.UpsertOrganizationDefaultTaxCodesInput, error) { + var taxcodeUpsertOrganizationDefaultTaxCodesInput taxcode.UpsertOrganizationDefaultTaxCodesInput + taxcodeUpsertOrganizationDefaultTaxCodesInput.Namespace = NamespaceFromContext(context) + xstring, err := InvoicingTaxCodeReferenceToIDString(source.InvoicingTaxCode) + if err != nil { + return taxcodeUpsertOrganizationDefaultTaxCodesInput, err + } + taxcodeUpsertOrganizationDefaultTaxCodesInput.InvoicingTaxCodeID = xstring + xstring2, err := CreditGrantTaxCodeReferenceToIDString(source.CreditGrantTaxCode) + if err != nil { + return taxcodeUpsertOrganizationDefaultTaxCodesInput, err + } + taxcodeUpsertOrganizationDefaultTaxCodesInput.CreditGrantTaxCodeID = xstring2 + return taxcodeUpsertOrganizationDefaultTaxCodesInput, nil + } + FromAPIUpsertTaxCodeRequest = func(context models.NamespacedID, source v3.UpsertTaxCodeRequest) (taxcode.UpdateTaxCodeInput, error) { + var taxcodeUpdateTaxCodeInput taxcode.UpdateTaxCodeInput + taxcodeUpdateTaxCodeInput.NamespacedID = ResolveNamespacedIDFromContext(context) + taxcodeUpdateTaxCodeInput.Name = source.Name + taxcodeUpdateTaxCodeInput.Description = source.Description + taxcodeUpdateTaxCodeInput.AppMappings = v3BillingTaxCodeAppMappingListToTaxcodeTaxCodeAppMappings(source.AppMappings) + modelsMetadata, err := ConvertLabelsToMetadata(source.Labels) + if err != nil { + return taxcodeUpdateTaxCodeInput, err + } + taxcodeUpdateTaxCodeInput.Metadata = modelsMetadata + return taxcodeUpdateTaxCodeInput, nil + } + ToAPIBillingTaxCode = func(source taxcode.TaxCode) (v3.BillingTaxCode, error) { + var v3BillingTaxCode v3.BillingTaxCode + v3BillingTaxCode.AppMappings = ToAPIBillingTaxCodeAppMappings(source.AppMappings) + v3BillingTaxCode.CreatedAt = timeTimeToTimeTime(source.ManagedModel.CreatedAt) + v3BillingTaxCode.DeletedAt = source.ManagedModel.DeletedAt + v3BillingTaxCode.Description = source.Description + v3BillingTaxCode.Id = source.NamespacedID.ID + v3BillingTaxCode.Key = source.Key + v3BillingTaxCode.Labels = ConvertMetadataAnnotationsToLabels(source) + v3BillingTaxCode.Name = source.Name + v3BillingTaxCode.UpdatedAt = timeTimeToTimeTime(source.ManagedModel.UpdatedAt) + return v3BillingTaxCode, nil + } + ToAPIOrganizationDefaultTaxCodes = func(source taxcode.OrganizationDefaultTaxCodes) (v3.OrganizationDefaultTaxCodes, error) { + var v3OrganizationDefaultTaxCodes v3.OrganizationDefaultTaxCodes + v3OrganizationDefaultTaxCodes.CreatedAt = timeTimeToTimeTime(source.ManagedModel.CreatedAt) + v3OrganizationDefaultTaxCodes.CreditGrantTaxCode = IDStringToTaxCodeReference(source.CreditGrantTaxCodeID) + v3OrganizationDefaultTaxCodes.InvoicingTaxCode = IDStringToTaxCodeReference(source.InvoicingTaxCodeID) + v3OrganizationDefaultTaxCodes.UpdatedAt = timeTimeToTimeTime(source.ManagedModel.UpdatedAt) + return v3OrganizationDefaultTaxCodes, nil + } +} +func timeTimeToTimeTime(source time.Time) time.Time { + return source +} +func v3BillingTaxCodeAppMappingListToTaxcodeTaxCodeAppMappings(source []v3.BillingTaxCodeAppMapping) taxcode.TaxCodeAppMappings { + var taxcodeTaxCodeAppMappings taxcode.TaxCodeAppMappings + if source != nil { + taxcodeTaxCodeAppMappings = make(taxcode.TaxCodeAppMappings, len(source)) + for i := 0; i < len(source); i++ { + taxcodeTaxCodeAppMappings[i] = v3BillingTaxCodeAppMappingToTaxcodeTaxCodeAppMapping(source[i]) + } + } + return taxcodeTaxCodeAppMappings +} +func v3BillingTaxCodeAppMappingToTaxcodeTaxCodeAppMapping(source v3.BillingTaxCodeAppMapping) taxcode.TaxCodeAppMapping { + var taxcodeTaxCodeAppMapping taxcode.TaxCodeAppMapping + taxcodeTaxCodeAppMapping.AppType = FromAPIBillingAppType(source.AppType) + taxcodeTaxCodeAppMapping.TaxCode = source.TaxCode + return taxcodeTaxCodeAppMapping +} diff --git a/api/v3/handlers/taxcodes/convert.go b/api/v3/handlers/taxcodes/convert.go new file mode 100644 index 0000000000000000000000000000000000000000..46ce01fcdc2692aff432ecc3f70aa8baf877a175 --- /dev/null +++ b/api/v3/handlers/taxcodes/convert.go @@ -0,0 +1,130 @@ +//go:generate go run github.com/jmattheis/goverter/cmd/goverter gen ./ +package taxcodes + +import ( + "errors" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/labels" + "github.com/openmeterio/openmeter/openmeter/app" + "github.com/openmeterio/openmeter/openmeter/taxcode" + "github.com/openmeterio/openmeter/pkg/models" +) + +// goverter:variables +// goverter:skipCopySameType +// goverter:output:file ./convert.gen.go +// goverter:useZeroValueOnPointerInconsistency +// goverter:useUnderlyingTypeMethods +// goverter:matchIgnoreCase +// goverter:extend FromAPIBillingAppType +// goverter:extend ToAPIBillingAppType +// goverter:extend ToAPIBillingTaxCodeAppMappings +// goverter:extend ConvertLabelsToMetadata +// goverter:extend IDStringToTaxCodeReference +// goverter:extend InvoicingTaxCodeReferenceToIDString +// goverter:extend CreditGrantTaxCodeReferenceToIDString +var ( + // goverter:context namespace + // goverter:map Namespace | NamespaceFromContext + // goverter:map Labels Metadata + // goverter:ignore Annotations + FromAPICreateTaxCodeRequest func(namespace string, createTaxCodeRequest api.CreateTaxCodeRequest) (taxcode.CreateTaxCodeInput, error) + + // goverter:context namespacedID + // goverter:map NamespacedID | ResolveNamespacedIDFromContext + // goverter:map Labels Metadata + // goverter:ignore Annotations + // goverter:ignore inputOptions + FromAPIUpsertTaxCodeRequest func(namespacedID models.NamespacedID, upsertTaxCodeRequest api.UpsertTaxCodeRequest) (taxcode.UpdateTaxCodeInput, error) + + // goverter:map . Labels | ConvertMetadataAnnotationsToLabels + // goverter:map NamespacedID.ID Id + // goverter:map ManagedModel.CreatedAt CreatedAt + // goverter:map ManagedModel.UpdatedAt UpdatedAt + // goverter:map ManagedModel.DeletedAt DeletedAt + // goverter:map AppMappings | ToAPIBillingTaxCodeAppMappings + ToAPIBillingTaxCode func(taxcode.TaxCode) (api.BillingTaxCode, error) + + // goverter:map ManagedModel.CreatedAt CreatedAt + // goverter:map ManagedModel.UpdatedAt UpdatedAt + // goverter:map InvoicingTaxCodeID InvoicingTaxCode | IDStringToTaxCodeReference + // goverter:map CreditGrantTaxCodeID CreditGrantTaxCode | IDStringToTaxCodeReference + ToAPIOrganizationDefaultTaxCodes func(taxcode.OrganizationDefaultTaxCodes) (api.OrganizationDefaultTaxCodes, error) + + // goverter:context namespace + // goverter:map Namespace | NamespaceFromContext + // goverter:map InvoicingTaxCode InvoicingTaxCodeID | InvoicingTaxCodeReferenceToIDString + // goverter:map CreditGrantTaxCode CreditGrantTaxCodeID | CreditGrantTaxCodeReferenceToIDString + // goverter:ignore Expand + FromAPIUpdateOrganizationDefaultTaxCodesRequest func(namespace string, body api.UpdateOrganizationDefaultTaxCodesRequest) (taxcode.UpsertOrganizationDefaultTaxCodesInput, error) +) + +var ConvertLabelsToMetadata = labels.ToMetadata + +func ConvertMetadataAnnotationsToLabels(source taxcode.TaxCode) *api.Labels { + return labels.FromMetadataAnnotations(source.Metadata, source.Annotations) +} + +//goverter:context namespace +func NamespaceFromContext(namespace string) string { + return namespace +} + +// goverter:context namespacedID +func ResolveNamespacedIDFromContext(namespacedID models.NamespacedID) models.NamespacedID { + return namespacedID +} + +// FromAPIBillingAppType maps API app types to domain app types. +// Maps external_invoicing to custom_invoicing for backwards compatibility. +func FromAPIBillingAppType(source api.BillingAppType) app.AppType { + if source == "external_invoicing" { + return app.AppTypeCustomInvoicing + } + return app.AppType(source) +} + +// ToAPIBillingAppType maps domain app types to API app types. +// Maps custom_invoicing to external_invoicing for API responses. +func ToAPIBillingAppType(source app.AppType) api.BillingAppType { + if source == app.AppTypeCustomInvoicing { + return "external_invoicing" + } + return api.BillingAppType(source) +} + +func IDStringToTaxCodeReference(id string) api.TaxCodeReference { + return api.TaxCodeReference{Id: id} +} + +func InvoicingTaxCodeReferenceToIDString(ref *api.TaxCodeReference) (string, error) { + if ref == nil || ref.Id == "" { + return "", models.NewGenericValidationError(errors.New("invoicing_tax_code.id is required")) + } + return ref.Id, nil +} + +func CreditGrantTaxCodeReferenceToIDString(ref *api.TaxCodeReference) (string, error) { + if ref == nil || ref.Id == "" { + return "", models.NewGenericValidationError(errors.New("credit_grant_tax_code.id is required")) + } + return ref.Id, nil +} + +// ToAPIBillingTaxCodeAppMappings converts domain app mappings to API app mappings. +// Ensures that nil is converted to an empty array instead of null. +func ToAPIBillingTaxCodeAppMappings(source taxcode.TaxCodeAppMappings) []api.BillingTaxCodeAppMapping { + if source == nil { + return []api.BillingTaxCodeAppMapping{} + } + + result := make([]api.BillingTaxCodeAppMapping, len(source)) + for i, mapping := range source { + result[i] = api.BillingTaxCodeAppMapping{ + AppType: ToAPIBillingAppType(mapping.AppType), + TaxCode: mapping.TaxCode, + } + } + return result +} diff --git a/api/v3/handlers/taxcodes/create.go b/api/v3/handlers/taxcodes/create.go new file mode 100644 index 0000000000000000000000000000000000000000..d5d5ed26478211c8697e221aaf7b20e11a223ed9 --- /dev/null +++ b/api/v3/handlers/taxcodes/create.go @@ -0,0 +1,56 @@ +package taxcodes + +import ( + "context" + "net/http" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/apierrors" + "github.com/openmeterio/openmeter/api/v3/request" + "github.com/openmeterio/openmeter/openmeter/taxcode" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" +) + +type ( + CreateTaxCodeRequest = taxcode.CreateTaxCodeInput + CreateTaxCodeResponse = api.BillingTaxCode + CreateTaxCodeHandler httptransport.Handler[CreateTaxCodeRequest, CreateTaxCodeResponse] +) + +// CreateTaxCode returns a new httptransport.Handler for creating a tax code. +func (h *handler) CreateTaxCode() CreateTaxCodeHandler { + return httptransport.NewHandler( + func(ctx context.Context, r *http.Request) (CreateTaxCodeRequest, error) { + body := api.CreateTaxCodeRequest{} + if err := request.ParseBody(r, &body); err != nil { + return CreateTaxCodeRequest{}, err + } + + ns, err := h.resolveNamespace(ctx) + if err != nil { + return CreateTaxCodeRequest{}, err + } + + req, err := FromAPICreateTaxCodeRequest(ns, body) + if err != nil { + return CreateTaxCodeRequest{}, err + } + return req, nil + }, + func(ctx context.Context, request CreateTaxCodeRequest) (CreateTaxCodeResponse, error) { + t, err := h.service.CreateTaxCode(ctx, request) + if err != nil { + return CreateTaxCodeResponse{}, err + } + + return ToAPIBillingTaxCode(t) + }, + commonhttp.JSONResponseEncoderWithStatus[CreateTaxCodeResponse](http.StatusCreated), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("create-tax-code"), + httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()), + )..., + ) +} diff --git a/api/v3/handlers/taxcodes/delete.go b/api/v3/handlers/taxcodes/delete.go new file mode 100644 index 0000000000000000000000000000000000000000..55b280351f73f5e57f23ccf497c8cfa2df5a597d --- /dev/null +++ b/api/v3/handlers/taxcodes/delete.go @@ -0,0 +1,63 @@ +package taxcodes + +import ( + "context" + "net/http" + + "github.com/openmeterio/openmeter/api/v3/apierrors" + taxcode "github.com/openmeterio/openmeter/openmeter/taxcode" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" + "github.com/openmeterio/openmeter/pkg/models" +) + +type ( + DeleteTaxCodeRequest taxcode.DeleteTaxCodeInput + DeleteTaxCodeResponse = interface{} + DeleteTaxCodeParams = string + DeleteTaxCodeHandler = httptransport.HandlerWithArgs[DeleteTaxCodeRequest, DeleteTaxCodeResponse, DeleteTaxCodeParams] +) + +const deleteTaxCodeEnabled = false + +// DeleteTaxCode returns a handler for deleting a tax code. +func (h *handler) DeleteTaxCode() DeleteTaxCodeHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, taxCodeID DeleteTaxCodeParams) (DeleteTaxCodeRequest, error) { + ns, err := h.resolveNamespace(ctx) + if err != nil { + return DeleteTaxCodeRequest{}, err + } + + return DeleteTaxCodeRequest{ + NamespacedID: models.NamespacedID{ + Namespace: ns, + ID: taxCodeID, + }, + }, nil + }, + func(ctx context.Context, request DeleteTaxCodeRequest) (DeleteTaxCodeResponse, error) { + if !deleteTaxCodeEnabled { + return nil, apierrors.NewForbiddenErrorDetail(ctx, "deleting tax codes is not allowed") + } + + err := h.service.DeleteTaxCode(ctx, taxcode.DeleteTaxCodeInput{ + NamespacedID: models.NamespacedID{ + Namespace: request.Namespace, + ID: request.ID, + }, + }) + if err != nil { + return nil, err + } + + return nil, nil + }, + commonhttp.EmptyResponseEncoder[DeleteTaxCodeResponse](http.StatusNoContent), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("delete-tax-code"), + httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()), + )..., + ) +} diff --git a/api/v3/handlers/taxcodes/get.go b/api/v3/handlers/taxcodes/get.go new file mode 100644 index 0000000000000000000000000000000000000000..90def2f06cdb6b437a43d14b07974539b60b4334 --- /dev/null +++ b/api/v3/handlers/taxcodes/get.go @@ -0,0 +1,53 @@ +package taxcodes + +import ( + "context" + "net/http" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/apierrors" + "github.com/openmeterio/openmeter/openmeter/taxcode" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" + models "github.com/openmeterio/openmeter/pkg/models" +) + +type ( + GetTaxCodeRequest = taxcode.GetTaxCodeInput + GetTaxCodeResponse = api.BillingTaxCode + GetTaxCodeParams = string + GetTaxCodeHandler httptransport.HandlerWithArgs[GetTaxCodeRequest, GetTaxCodeResponse, GetTaxCodeParams] +) + +func (h *handler) GetTaxCode() GetTaxCodeHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, taxCodeID GetTaxCodeParams) (GetTaxCodeRequest, error) { + ns, err := h.resolveNamespace(ctx) + if err != nil { + return GetTaxCodeRequest{}, err + } + + return GetTaxCodeRequest{ + NamespacedID: models.NamespacedID{ + Namespace: ns, + ID: taxCodeID, + }, + }, nil + }, + func(ctx context.Context, request GetTaxCodeRequest) (GetTaxCodeResponse, error) { + // Call the service to get the tax code + taxCode, err := h.service.GetTaxCode(ctx, request) + if err != nil { + return GetTaxCodeResponse{}, err + } + // Convert to API response type + return ToAPIBillingTaxCode(taxCode) + }, + commonhttp.JSONResponseEncoderWithStatus[GetTaxCodeResponse](http.StatusOK), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("get-tax-code"), + httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()), + )..., + ) +} diff --git a/api/v3/handlers/taxcodes/get_organization_default_tax_codes.go b/api/v3/handlers/taxcodes/get_organization_default_tax_codes.go new file mode 100644 index 0000000000000000000000000000000000000000..231be5ef18ec98443d613b3339ab9cb2b7df50e5 --- /dev/null +++ b/api/v3/handlers/taxcodes/get_organization_default_tax_codes.go @@ -0,0 +1,43 @@ +package taxcodes + +import ( + "context" + "net/http" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/apierrors" + "github.com/openmeterio/openmeter/openmeter/taxcode" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" +) + +type ( + GetOrganizationDefaultTaxCodesRequest = taxcode.GetOrganizationDefaultTaxCodesInput + GetOrganizationDefaultTaxCodesResponse = api.OrganizationDefaultTaxCodes + GetOrganizationDefaultTaxCodesHandler = httptransport.Handler[GetOrganizationDefaultTaxCodesRequest, GetOrganizationDefaultTaxCodesResponse] +) + +func (h *handler) GetOrganizationDefaultTaxCodes() GetOrganizationDefaultTaxCodesHandler { + return httptransport.NewHandler( + func(ctx context.Context, r *http.Request) (GetOrganizationDefaultTaxCodesRequest, error) { + ns, err := h.resolveNamespace(ctx) + if err != nil { + return GetOrganizationDefaultTaxCodesRequest{}, err + } + return GetOrganizationDefaultTaxCodesRequest{Namespace: ns}, nil + }, + func(ctx context.Context, request GetOrganizationDefaultTaxCodesRequest) (GetOrganizationDefaultTaxCodesResponse, error) { + cfg, err := h.service.GetOrganizationDefaultTaxCodes(ctx, request) + if err != nil { + return GetOrganizationDefaultTaxCodesResponse{}, err + } + return ToAPIOrganizationDefaultTaxCodes(cfg) + }, + commonhttp.JSONResponseEncoderWithStatus[GetOrganizationDefaultTaxCodesResponse](http.StatusOK), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("get-organization-default-tax-codes"), + httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()), + )..., + ) +} diff --git a/api/v3/handlers/taxcodes/handler.go b/api/v3/handlers/taxcodes/handler.go new file mode 100644 index 0000000000000000000000000000000000000000..2388da5a79e5c0caf8338d13e4f6ac27d92a1469 --- /dev/null +++ b/api/v3/handlers/taxcodes/handler.go @@ -0,0 +1,37 @@ +package taxcodes + +import ( + "context" + + "github.com/openmeterio/openmeter/openmeter/taxcode" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" +) + +type Handler interface { + ListTaxCodes() ListTaxCodesHandler + GetTaxCode() GetTaxCodeHandler + CreateTaxCode() CreateTaxCodeHandler + UpdateTaxCode() UpdateTaxCodeHandler + DeleteTaxCode() DeleteTaxCodeHandler + + GetOrganizationDefaultTaxCodes() GetOrganizationDefaultTaxCodesHandler + UpsertOrganizationDefaultTaxCodes() UpsertOrganizationDefaultTaxCodesHandler +} + +type handler struct { + resolveNamespace func(ctx context.Context) (string, error) + service taxcode.Service + options []httptransport.HandlerOption +} + +func New( + resolveNamespace func(ctx context.Context) (string, error), + service taxcode.Service, + options ...httptransport.HandlerOption, +) Handler { + return &handler{ + resolveNamespace: resolveNamespace, + service: service, + options: options, + } +} diff --git a/api/v3/handlers/taxcodes/list.go b/api/v3/handlers/taxcodes/list.go new file mode 100644 index 0000000000000000000000000000000000000000..d3d14db4a4555e468d397bfeeeae2f5279e059b5 --- /dev/null +++ b/api/v3/handlers/taxcodes/list.go @@ -0,0 +1,89 @@ +package taxcodes + +import ( + "context" + "net/http" + + "github.com/samber/lo" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/apierrors" + "github.com/openmeterio/openmeter/api/v3/response" + taxcode "github.com/openmeterio/openmeter/openmeter/taxcode" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" + "github.com/openmeterio/openmeter/pkg/pagination" +) + +type ( + ListTaxCodesRequest = taxcode.ListTaxCodesInput + ListTaxCodesResponse = response.PagePaginationResponse[api.BillingTaxCode] + ListTaxCodesParams = api.ListTaxCodesParams + ListTaxCodesHandler = httptransport.HandlerWithArgs[ListTaxCodesRequest, ListTaxCodesResponse, ListTaxCodesParams] +) + +func (h *handler) ListTaxCodes() ListTaxCodesHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, params ListTaxCodesParams) (ListTaxCodesRequest, error) { + ns, err := h.resolveNamespace(ctx) + if err != nil { + return ListTaxCodesRequest{}, err + } + + page := pagination.NewPage(1, 20) + if params.Page != nil { + page = pagination.NewPage( + lo.FromPtrOr(params.Page.Number, 1), + lo.FromPtrOr(params.Page.Size, 20), + ) + } + + if err := page.Validate(); err != nil { + return ListTaxCodesRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{ + apierrors.InvalidParameter{ + Field: "page", + Reason: err.Error(), + Source: apierrors.InvalidParamSourceQuery, + }, + }) + } + + req := ListTaxCodesRequest{ + Namespace: ns, + Page: page, + IncludeDeleted: lo.FromPtrOr(params.IncludeDeleted, false), + } + + return req, nil + }, + func(ctx context.Context, request ListTaxCodesRequest) (ListTaxCodesResponse, error) { + resp, err := h.service.ListTaxCodes(ctx, request) + if err != nil { + return ListTaxCodesResponse{}, err + } + + taxcodes := make([]api.BillingTaxCode, 0, len(resp.Items)) + for _, item := range resp.Items { + apiTaxCode, err := ToAPIBillingTaxCode(item) + if err != nil { + return ListTaxCodesResponse{}, err + } + taxcodes = append(taxcodes, apiTaxCode) + } + + r := response.NewPagePaginationResponse(taxcodes, response.PageMetaPage{ + Size: request.Page.PageSize, + Number: request.Page.PageNumber, + Total: lo.ToPtr(resp.TotalCount), + }) + + return r, nil + }, + commonhttp.JSONResponseEncoderWithStatus[ListTaxCodesResponse](http.StatusOK), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("list-tax-codes"), + httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()), + )..., + ) +} diff --git a/api/v3/handlers/taxcodes/update.go b/api/v3/handlers/taxcodes/update.go new file mode 100644 index 0000000000000000000000000000000000000000..4fd49bdb6b475e78069e643e6f0fc5225162019f --- /dev/null +++ b/api/v3/handlers/taxcodes/update.go @@ -0,0 +1,62 @@ +package taxcodes + +import ( + "context" + "net/http" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/apierrors" + "github.com/openmeterio/openmeter/api/v3/request" + "github.com/openmeterio/openmeter/openmeter/taxcode" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" + "github.com/openmeterio/openmeter/pkg/models" +) + +type ( + UpdateTaxCodeRequest = taxcode.UpdateTaxCodeInput + UpdateTaxCodeResponse = api.BillingTaxCode + UpdateTaxCodeParams = string + UpdateTaxCodeHandler = httptransport.HandlerWithArgs[UpdateTaxCodeRequest, UpdateTaxCodeResponse, UpdateTaxCodeParams] +) + +// UpdateTaxCode returns a handler for updating a tax code. +func (h *handler) UpdateTaxCode() UpdateTaxCodeHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, taxCodeID UpdateTaxCodeParams) (UpdateTaxCodeRequest, error) { + body := api.UpsertTaxCodeRequest{} + if err := request.ParseBody(r, &body); err != nil { + return UpdateTaxCodeRequest{}, err + } + + ns, err := h.resolveNamespace(ctx) + if err != nil { + return UpdateTaxCodeRequest{}, err + } + + req, err := FromAPIUpsertTaxCodeRequest(models.NamespacedID{ + Namespace: ns, + ID: taxCodeID, + }, body) + if err != nil { + return UpdateTaxCodeRequest{}, err + } + + return req, nil + }, + func(ctx context.Context, request UpdateTaxCodeRequest) (UpdateTaxCodeResponse, error) { + t, err := h.service.UpdateTaxCode(ctx, request) + if err != nil { + return UpdateTaxCodeResponse{}, err + } + + return ToAPIBillingTaxCode(t) + }, + commonhttp.JSONResponseEncoderWithStatus[UpdateTaxCodeResponse](http.StatusOK), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("upsert-tax-code"), + httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()), + )..., + ) +} diff --git a/api/v3/handlers/taxcodes/upsert_organization_default_tax_codes.go b/api/v3/handlers/taxcodes/upsert_organization_default_tax_codes.go new file mode 100644 index 0000000000000000000000000000000000000000..2021dd3ff7141da0dd0d86dc11d1301531ef2637 --- /dev/null +++ b/api/v3/handlers/taxcodes/upsert_organization_default_tax_codes.go @@ -0,0 +1,50 @@ +package taxcodes + +import ( + "context" + "net/http" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/apierrors" + "github.com/openmeterio/openmeter/api/v3/request" + "github.com/openmeterio/openmeter/openmeter/taxcode" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" +) + +type ( + UpsertOrganizationDefaultTaxCodesRequest = taxcode.UpsertOrganizationDefaultTaxCodesInput + UpsertOrganizationDefaultTaxCodesResponse = api.OrganizationDefaultTaxCodes + UpsertOrganizationDefaultTaxCodesHandler = httptransport.Handler[UpsertOrganizationDefaultTaxCodesRequest, UpsertOrganizationDefaultTaxCodesResponse] +) + +func (h *handler) UpsertOrganizationDefaultTaxCodes() UpsertOrganizationDefaultTaxCodesHandler { + return httptransport.NewHandler( + func(ctx context.Context, r *http.Request) (UpsertOrganizationDefaultTaxCodesRequest, error) { + body := api.UpdateOrganizationDefaultTaxCodesJSONRequestBody{} + if err := request.ParseBody(r, &body); err != nil { + return UpsertOrganizationDefaultTaxCodesRequest{}, err + } + + ns, err := h.resolveNamespace(ctx) + if err != nil { + return UpsertOrganizationDefaultTaxCodesRequest{}, err + } + + return FromAPIUpdateOrganizationDefaultTaxCodesRequest(ns, body) + }, + func(ctx context.Context, request UpsertOrganizationDefaultTaxCodesRequest) (UpsertOrganizationDefaultTaxCodesResponse, error) { + cfg, err := h.service.UpsertOrganizationDefaultTaxCodes(ctx, request) + if err != nil { + return UpsertOrganizationDefaultTaxCodesResponse{}, err + } + return ToAPIOrganizationDefaultTaxCodes(cfg) + }, + commonhttp.JSONResponseEncoderWithStatus[UpsertOrganizationDefaultTaxCodesResponse](http.StatusOK), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("upsert-organization-default-tax-codes"), + httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()), + )..., + ) +} diff --git a/api/v3/labels/convert.go b/api/v3/labels/convert.go new file mode 100644 index 0000000000000000000000000000000000000000..a0a31d8a72ba07246408e1e5ff064ba2839197ef --- /dev/null +++ b/api/v3/labels/convert.go @@ -0,0 +1,106 @@ +package labels + +import ( + "encoding" + "errors" + "fmt" + "strings" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/pkg/models" +) + +const AnnotationsPrefix = "openmeter_" + +type MetadataAnnotations struct { + Metadata models.Metadata + Annotations models.Annotations +} + +func ToMetadataAnnotations(labels *api.Labels) (MetadataAnnotations, error) { + if labels == nil || len(*labels) == 0 { + return MetadataAnnotations{}, nil + } + + var ( + metadata models.Metadata + annotations models.Annotations + errs []error + ) + + for k, v := range *labels { + if err := ValidateLabel(k, v); err != nil { + errs = append(errs, err) + + continue + } + + if strings.HasPrefix(k, AnnotationsPrefix) { + if annotations == nil { + annotations = make(models.Annotations) + } + + annotations[strings.TrimPrefix(k, AnnotationsPrefix)] = v + } else { + if metadata == nil { + metadata = make(models.Metadata) + } + + metadata[k] = v + } + } + + return MetadataAnnotations{metadata, annotations}, models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +func FromMetadataAnnotations(metadata models.Metadata, annotations models.Annotations) *api.Labels { + labels := make(api.Labels, len(annotations)+len(metadata)) + + for k, v := range metadata { + if err := ValidateLabel(k, v); err != nil { + continue + } + + labels[k] = v + } + + for k, v := range annotations { + var val string + + switch vv := v.(type) { + case fmt.Stringer: + val = vv.String() + case string: + val = vv + case encoding.TextMarshaler: + b, err := vv.MarshalText() + if err != nil { + continue + } + + val = string(b) + } + + if err := ValidateLabel(k, val); err != nil { + continue + } + + if !strings.HasPrefix(k, AnnotationsPrefix) { + k = AnnotationsPrefix + k + } + + labels[k] = val + } + + return &labels +} + +func FromMetadata[T ~map[string]string](metadata T) *api.Labels { + return FromMetadataAnnotations(models.Metadata(metadata), nil) +} + +func ToMetadata(labels *api.Labels) (models.Metadata, error) { + m, err := ToMetadataAnnotations(labels) + + return m.Metadata, err +} diff --git a/api/v3/labels/convert_test.go b/api/v3/labels/convert_test.go new file mode 100644 index 0000000000000000000000000000000000000000..d4f42d0ee6b9a388b6c9507222008c7aecc26c0f --- /dev/null +++ b/api/v3/labels/convert_test.go @@ -0,0 +1,235 @@ +package labels + +import ( + "encoding" + "testing" + + "github.com/stretchr/testify/assert" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/pkg/models" +) + +// stringerValue implements fmt.Stringer for testing annotation conversion. +type stringerValue struct{ s string } + +func (v stringerValue) String() string { return v.s } + +// textMarshalerValue implements encoding.TextMarshaler for testing annotation conversion. +type textMarshalerValue struct{ s string } + +func (v textMarshalerValue) MarshalText() ([]byte, error) { return []byte(v.s), nil } + +// failingTextMarshaler always returns an error from MarshalText. +type failingTextMarshaler struct{} + +func (failingTextMarshaler) MarshalText() ([]byte, error) { + return nil, assert.AnError +} + +// Ensure interface compliance (compile-time check). +var ( + _ encoding.TextMarshaler = textMarshalerValue{} + _ encoding.TextMarshaler = failingTextMarshaler{} +) + +func TestToMetadataAnnotations(t *testing.T) { + tests := []struct { + name string + labels *api.Labels + wantMetadata models.Metadata + wantAnnotations models.Annotations + wantErr bool + }{ + { + name: "nil labels", + labels: nil, + wantMetadata: nil, + wantAnnotations: nil, + }, + { + name: "empty labels", + labels: &api.Labels{}, + wantMetadata: nil, + wantAnnotations: nil, + }, + { + name: "metadata only", + labels: &api.Labels{ + "env": "production", + "team": "platform", + }, + wantMetadata: models.Metadata{ + "env": "production", + "team": "platform", + }, + wantAnnotations: nil, + }, + { + name: "reserved openmeter prefix is rejected", + labels: &api.Labels{ + "openmeter_region": "us-east-1", + "openmeter_tier": "standard", + }, + wantMetadata: nil, + wantAnnotations: nil, + wantErr: true, + }, + { + name: "mixed metadata and reserved prefix", + labels: &api.Labels{ + "env": "production", + "openmeter_region": "us-east-1", + }, + wantMetadata: models.Metadata{ + "env": "production", + }, + wantAnnotations: nil, + wantErr: true, + }, + { + name: "reserved prefix label is rejected", + labels: &api.Labels{ + "openmeter_key": "value", + }, + wantMetadata: nil, + wantAnnotations: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := ToMetadataAnnotations(tt.labels) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + assert.Equal(t, tt.wantMetadata, result.Metadata) + assert.Equal(t, tt.wantAnnotations, result.Annotations) + }) + } +} + +func TestFromMetadataAnnotations(t *testing.T) { + tests := []struct { + name string + metadata models.Metadata + annotations models.Annotations + wantLabels *api.Labels + }{ + { + name: "nil inputs", + metadata: nil, + annotations: nil, + wantLabels: &api.Labels{}, + }, + { + name: "empty inputs", + metadata: models.Metadata{}, + annotations: models.Annotations{}, + wantLabels: &api.Labels{}, + }, + { + name: "metadata only", + metadata: models.Metadata{ + "env": "production", + "team": "platform", + }, + annotations: nil, + wantLabels: &api.Labels{ + "env": "production", + "team": "platform", + }, + }, + { + name: "annotation string value", + metadata: nil, + annotations: models.Annotations{ + "region": "us-east-1", + }, + wantLabels: &api.Labels{ + "openmeter_region": "us-east-1", + }, + }, + { + name: "annotation already has prefix is skipped due to reserved prefix validation", + metadata: nil, + annotations: models.Annotations{ + "openmeter_region": "us-east-1", + }, + wantLabels: &api.Labels{}, + }, + { + name: "annotation stringer value", + metadata: nil, + annotations: models.Annotations{ + "tier": stringerValue{"standard"}, + }, + wantLabels: &api.Labels{ + "openmeter_tier": "standard", + }, + }, + { + name: "annotation text marshaler value", + metadata: nil, + annotations: models.Annotations{ + "tier": textMarshalerValue{"enterprise"}, + }, + wantLabels: &api.Labels{ + "openmeter_tier": "enterprise", + }, + }, + { + name: "annotation failing text marshaler is skipped", + metadata: nil, + annotations: models.Annotations{ + "tier": failingTextMarshaler{}, + }, + wantLabels: &api.Labels{}, + }, + { + name: "invalid metadata key is skipped", + metadata: models.Metadata{ + "_invalid": "value", + "valid": "value", + }, + annotations: nil, + wantLabels: &api.Labels{ + "valid": "value", + }, + }, + { + name: "invalid annotation key is skipped", + metadata: nil, + annotations: models.Annotations{ + "_invalid": "value", + "valid": "ok", + }, + wantLabels: &api.Labels{ + "openmeter_valid": "ok", + }, + }, + { + name: "mixed metadata and annotations", + metadata: models.Metadata{ + "env": "production", + }, + annotations: models.Annotations{ + "region": "us-east-1", + }, + wantLabels: &api.Labels{ + "env": "production", + "openmeter_region": "us-east-1", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + labels := FromMetadataAnnotations(tt.metadata, tt.annotations) + assert.Equal(t, tt.wantLabels, labels) + }) + } +} diff --git a/api/v3/labels/validate.go b/api/v3/labels/validate.go new file mode 100644 index 0000000000000000000000000000000000000000..002a09cc360e194a5ade5966c42e8542b4c06dd5 --- /dev/null +++ b/api/v3/labels/validate.go @@ -0,0 +1,65 @@ +package labels + +import ( + "errors" + "net/http" + "regexp" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/models" +) + +// https://regex101.com/?regex=%5E%28%3F%3A%5Ba-zA-Z0-9%5D%28%3F%3A%5Ba-zA-Z0-9._-%5D%5Ba-zA-Z0-9%5D%29%3F%29%7B1%2C63%7D%24&testString=openmeter_good%0Aopenmeter.good%0Aopenmeter-good%0Aopenmeter_bad_%0Aopenmeter-bad-%0Aopenmeter.bad.%0A_openmeter-bad%0A-openmeter-bad%0A.openmeter.bad%0A.openmeter.way_toooooooooooooooooooooooooooooooooooooooooooooooooooooo_long&flags=gm&flavor=pcre2&delimiter=%2F +var keyValueFormat = regexp.MustCompile(`^(?:[a-zA-Z0-9](?:[a-zA-Z0-9._-]?[a-zA-Z0-9])?){1,63}$`) + +// https://regex101.com/?regex=%5E%28%3F%3A%5Ba-zA-Z0-9%5D%28%3F%3A%5Ba-zA-Z0-9._-%5D%5Ba-zA-Z0-9%5D%29%3F%29%7B1%2C63%7D%24&testString=openmeter_good%0Aopenmeter.good%0Aopenmeter-good%0Aopenmeter_bad_%0Aopenmeter-bad-%0Aopenmeter.bad.%0A_openmeter-bad%0A-openmeter-bad%0A.openmeter.bad%0A.openmeter.way_toooooooooooooooooooooooooooooooooooooooooooooooooooooo_long&flags=gm&flavor=pcre2&delimiter=%2F +var reservedPrefixMatcher = regexp.MustCompile(`^(_|kong|konnect|insomnia|mesh|kic|kuma|openmeter)`) + +func ValidateLabel(k, v string) error { + var errs []error + + if !keyValueFormat.MatchString(k) { + errs = append(errs, models.NewValidationIssue( + "invalid_label_key_format", + "label key must be in valid format", + models.WithFieldString("labels"), + models.WithCriticalSeverity(), + commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest), + )) + } + + if !keyValueFormat.MatchString(v) { + errs = append(errs, models.NewValidationIssue( + "invalid_label_value_format", + "label value must be in valid format", + models.WithFieldString("labels"), + models.WithCriticalSeverity(), + commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest), + )) + } + + if reservedPrefixMatcher.MatchString(k) { + errs = append(errs, models.NewValidationIssue( + "invalid_label_key_prefix", + "label key must not have reserved prefix", + models.WithFieldString("labels"), + models.WithCriticalSeverity(), + commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest), + )) + } + + return errors.Join(errs...) +} + +func ValidateLabels(l api.Labels) error { + var errs []error + + for k, v := range l { + if err := ValidateLabel(k, v); err != nil { + errs = append(errs, err) + } + } + + return errors.Join(errs...) +} diff --git a/api/v3/labels/validate_test.go b/api/v3/labels/validate_test.go new file mode 100644 index 0000000000000000000000000000000000000000..a19955d3cbae27dfe79d04d49b2de940afb5c421 --- /dev/null +++ b/api/v3/labels/validate_test.go @@ -0,0 +1,237 @@ +package labels + +import ( + "testing" + + "github.com/stretchr/testify/require" + + api "github.com/openmeterio/openmeter/api/v3" +) + +func TestValidateLabel(t *testing.T) { + tests := []struct { + name string + key string + value string + wantErr bool + }{ + { + name: "valid alphanumeric", + key: "key", + value: "value", + }, + { + name: "valid with underscore", + key: "my_good", + value: "my_good", + }, + { + name: "valid with dot", + key: "my.good", + value: "my.good", + }, + { + name: "valid with hyphen", + key: "my-good", + value: "my-good", + }, + { + name: "valid single character", + key: "a", + value: "b", + }, + { + name: "valid two characters", + key: "ab", + value: "cd", + }, + { + name: "valid uppercase", + key: "MyKey", + value: "MyValue", + }, + { + name: "valid digits", + key: "key123", + value: "value456", + }, + { + name: "valid starts and ends with digit", + key: "1key1", + value: "2value2", + }, + { + name: "invalid key trailing underscore", + key: "bad_", + value: "good", + wantErr: true, + }, + { + name: "invalid key trailing hyphen", + key: "bad-", + value: "good", + wantErr: true, + }, + { + name: "invalid key trailing dot", + key: "bad.", + value: "good", + wantErr: true, + }, + { + name: "invalid key leading underscore", + key: "_bad", + value: "good", + wantErr: true, + }, + { + name: "invalid key leading hyphen", + key: "-bad", + value: "good", + wantErr: true, + }, + { + name: "invalid key leading dot", + key: ".bad", + value: "good", + wantErr: true, + }, + { + name: "invalid key empty", + key: "", + value: "good", + wantErr: true, + }, + { + name: "invalid value trailing underscore", + key: "good", + value: "bad_", + wantErr: true, + }, + { + name: "invalid value leading dot", + key: "good", + value: ".bad", + wantErr: true, + }, + { + name: "invalid value empty", + key: "good", + value: "", + wantErr: true, + }, + { + name: "both key and value invalid", + key: "_bad", + value: "bad_", + wantErr: true, + }, + { + name: "reserved openmeter prefix", + key: "openmeter_key", + value: "value", + wantErr: true, + }, + { + name: "reserved kong prefix", + key: "kong_key", + value: "value", + wantErr: true, + }, + { + name: "reserved konnect prefix", + key: "konnect_key", + value: "value", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateLabel(tt.key, tt.value) + if !tt.wantErr { + require.NoError(t, err) + return + } + + require.Error(t, err) + }) + } +} + +func TestValidateLabels(t *testing.T) { + tests := []struct { + name string + labels api.Labels + wantErr bool + }{ + { + name: "nil labels", + labels: nil, + wantErr: false, + }, + { + name: "empty labels", + labels: api.Labels{}, + wantErr: false, + }, + { + name: "all valid", + labels: api.Labels{ + "env": "production", + "team": "platform", + "version": "v1", + }, + wantErr: false, + }, + { + name: "one invalid key", + labels: api.Labels{ + "_bad": "value", + "good": "value", + }, + wantErr: true, + }, + { + name: "one invalid value", + labels: api.Labels{ + "good": "bad_", + }, + wantErr: true, + }, + { + name: "both key and value invalid in same entry", + labels: api.Labels{ + "_bad": "bad_", + }, + wantErr: true, + }, + { + name: "multiple invalid keys", + labels: api.Labels{ + "_bad1": "good", + "_bad2": "good", + }, + wantErr: true, + }, + { + name: "reserved prefix key", + labels: api.Labels{ + "openmeter_key": "value", + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateLabels(tt.labels) + if !tt.wantErr { + require.NoError(t, err) + return + } + + require.Error(t, err) + }) + } +} diff --git a/api/v3/oasmiddleware/decoder.go b/api/v3/oasmiddleware/decoder.go new file mode 100644 index 0000000000000000000000000000000000000000..60e11b895d8a1ca787e65a947ef8201c781abd3b --- /dev/null +++ b/api/v3/oasmiddleware/decoder.go @@ -0,0 +1,25 @@ +package oasmiddleware + +import ( + "encoding/json" + "io" + "net/http" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/getkin/kin-openapi/openapi3filter" +) + +// ref: https://github.com/getkin/kin-openapi/blob/994d4f01c1e8dd613805668a7c10b568547f7789/openapi3filter/req_resp_decoder.go#L1031-L1047 + +// JsonBodyDecoder is meant to be used with openapi3filter.RegisterBodyDecoder +// to register a decoder for a custom vendor type like "application/konnect.foo+json" +func JsonBodyDecoder(body io.Reader, _ http.Header, _ *openapi3.SchemaRef, _ openapi3filter.EncodingFn) (any, error) { + var value any + dec := json.NewDecoder(body) + dec.UseNumber() + if err := dec.Decode(&value); err != nil { + return nil, &openapi3filter.ParseError{Kind: openapi3filter.KindInvalidFormat, Cause: err} + } + + return value, nil +} diff --git a/api/v3/oasmiddleware/error.go b/api/v3/oasmiddleware/error.go new file mode 100644 index 0000000000000000000000000000000000000000..be0dc0d1db056072dadaba971be77ce61ed75cdd --- /dev/null +++ b/api/v3/oasmiddleware/error.go @@ -0,0 +1,143 @@ +package oasmiddleware + +import ( + "errors" + "fmt" + "strings" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/getkin/kin-openapi/openapi3filter" + + "github.com/openmeterio/openmeter/api/v3/apierrors" +) + +var oasRuleToAip = map[string]string{ + "minLength": "min_length", + "maxLength": "max_length", + "minItems": "min_items", + "maxItems": "max_items", +} + +func ToAipError(me openapi3.MultiError) []apierrors.InvalidParameter { + return aipMapper(me, nil) +} + +func aipMapper(me openapi3.MultiError, parent *apierrors.InvalidParameter) []apierrors.InvalidParameter { + var ipErrs []apierrors.InvalidParameter + for _, err := range me { + var i *apierrors.InvalidParameter + if parent != nil { + i = parent + } else { + i = &apierrors.InvalidParameter{} + } + switch err := err.(type) { + case *openapi3.SchemaError: + i.Reason = err.Reason + ipErrs = append(ipErrs, invalidParamFromSchemaError(err, i)) + case *openapi3filter.RequestError: + if err.Parameter != nil { + if err.Parameter.Name != "" { + i.Field = err.Parameter.Name + } + if err.Parameter.In != "" { + i.Source = apierrors.ToInvalid(err.Parameter.In) + } + if err.Parameter.Required { + i.Rule = "required" + } + } + i.Reason = err.Reason + if err.Reason == "" || err.RequestBody != nil { + i.Reason = err.Error() + } + + if err, ok := err.Err.(openapi3.MultiError); ok { + ipErrs = append(ipErrs, aipMapper(err, i)...) + continue + } + + if err, ok := err.Err.(*openapi3.SchemaError); ok { + i.Choices = make([]string, 0) + if err.SchemaField == "enum" { + i.Rule = "enum" + for _, v := range err.Schema.Enum { + i.Choices = append(i.Choices, fmt.Sprintf("%v", v)) + } + i.Reason = fmt.Sprintf("must be one of: [%s]", strings.Join(i.Choices, ",")) + } else if err.SchemaField == "oneOf" { + ipErrs = append(ipErrs, collectFromSchemaError(err)...) + continue + } + } + ipErrs = append(ipErrs, *i) + } + } + return ipErrs +} + +// collectFromSchemaError looks at schemaErr.Origin. If there are deeper +// child errors (via unwrapOriginError), it returns those. Otherwise, it +// returns a single InvalidParameter built from schemaErr itself. +func collectFromSchemaError(se *openapi3.SchemaError) []apierrors.InvalidParameter { + childParams := unwrapOriginError(se) + if len(childParams) == 0 { + return []apierrors.InvalidParameter{ + invalidParamFromSchemaError(se, nil), + } + } + return childParams +} + +// unwrapOriginError traverses schemaErr.Origin (which may be a wrapped multiErrorForOneOf) +// and returns a flat slice of InvalidParameter entries for each underlying *SchemaError. +func unwrapOriginError(schemaErr *openapi3.SchemaError) []apierrors.InvalidParameter { + if schemaErr == nil || schemaErr.Origin == nil { + return nil + } + + // 1) First, try to pull out a MultiError (or multiErrorForOneOf) from the wrapper chain. + var me openapi3.MultiError + if errors.As(schemaErr.Origin, &me) { + var result []apierrors.InvalidParameter + for _, subErr := range me { + var subSE *openapi3.SchemaError + if errors.As(subErr, &subSE) { + result = append(result, collectFromSchemaError(subSE)...) + } + } + return result + } + + // 2) If there are no multi-errors and Origin wraps another *SchemaError somewhere in its chain, dive into that. + var innerSE *openapi3.SchemaError + if errors.As(schemaErr.Origin, &innerSE) { + return collectFromSchemaError(innerSE) + } + + // 3) If we reach here, Origin was neither a nested *SchemaError nor a MultiError. + return nil +} + +func invalidParamFromSchemaError( + schemaErr *openapi3.SchemaError, + parent *apierrors.InvalidParameter, +) apierrors.InvalidParameter { + var ip *apierrors.InvalidParameter + if parent != nil { + ip = parent + } else { + ip = &apierrors.InvalidParameter{ + Reason: schemaErr.Reason, + } + } + if rule, ok := oasRuleToAip[schemaErr.SchemaField]; ok { + ip.Rule = rule + } else { + ip.Rule = schemaErr.SchemaField + } + if path := schemaErr.JSONPointer(); len(path) > 0 { + ip.Field = strings.Join(path, ".") + } + return *ip +} diff --git a/api/v3/oasmiddleware/hook.go b/api/v3/oasmiddleware/hook.go new file mode 100644 index 0000000000000000000000000000000000000000..480356a9f071a04675d480aa71d86703648e0d5c --- /dev/null +++ b/api/v3/oasmiddleware/hook.go @@ -0,0 +1,106 @@ +package oasmiddleware + +import ( + "context" + "errors" + "net/http" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/getkin/kin-openapi/openapi3filter" + + "github.com/openmeterio/openmeter/api/v3/apierrors" +) + +var ErrRouteNotFound = errors.New("route not found") + +// OasRouteNotFoundErrorHook handles the error when a route is not found in a validation +// router. This will stop the request lifecycle and return an AIP compliant 404 response +func OasRouteNotFoundErrorHook(err error, w http.ResponseWriter, r *http.Request) bool { + if err != nil { + apierrors. + NewNotFoundError(r.Context(), ErrRouteNotFound, "route"). + HandleAPIError(w, r) + return true + } + return false +} + +// OasValidationErrorHook handles the error when a request is not matching the +// OAS spec definition for a given route in the validation router. +// This will stop the request lifecycle and return an AIP compliant 400 response +func OasValidationErrorHook(ctx context.Context, err error, w http.ResponseWriter, r *http.Request) bool { + switch err := err.(type) { + case nil: + return false + case openapi3.MultiError: + invalidParams := ToAipError(err) + sourcePath := false + for _, v := range invalidParams { + if v.Source == apierrors.InvalidParamSourcePath { + sourcePath = true + break + } + } + if sourcePath { + apierrors. + NewNotFoundError(ctx, err, "entity"). + HandleAPIError(w, r) + } else { + apierrors. + NewBadRequestError(ctx, SanitizeSensitiveFieldValues(err), invalidParams). + HandleAPIError(w, r) + } + return true + case *openapi3filter.RequestError: + if err.Parameter != nil && err.Parameter.In == "path" { + apierrors. + NewNotFoundError(ctx, err, "entity"). + HandleAPIError(w, r) + return true + } + } + apierrors. + NewBadRequestError(ctx, err, nil). + HandleAPIError(w, r) + return true +} + +func SanitizeSensitiveFieldValues(err error) error { + switch err := err.(type) { + case nil: + return nil + case openapi3.MultiError: + sanitizedMultiErr := make(openapi3.MultiError, 0) + for _, vErr := range err { + sanitizedMultiErr = append(sanitizedMultiErr, SanitizeSensitiveFieldValues(vErr)) + } + return sanitizedMultiErr + case *openapi3filter.RequestError: + err.Err = SanitizeSensitiveFieldValues(err.Err) + return err + case *openapi3.SchemaError: + if err.Schema != nil && err.Schema.Extensions != nil { + xSensitive, ok := err.Schema.Extensions["x-sensitive"] + if ok && isSensitive(xSensitive) { + err.Value = "********" + } + } + return err + default: + return err + } +} + +func isSensitive(sensitive any) bool { + switch v := sensitive.(type) { + case string: + if v == "true" { + return true + } + return false + case bool: + return v + default: + return false + } +} diff --git a/api/v3/oasmiddleware/response.go b/api/v3/oasmiddleware/response.go new file mode 100644 index 0000000000000000000000000000000000000000..35fa28b8db7072e497d7fafc1c496f760c4e0b89 --- /dev/null +++ b/api/v3/oasmiddleware/response.go @@ -0,0 +1,56 @@ +package oasmiddleware + +import ( + "bytes" + "net/http" +) + +// ResponseWriterWrapper leverage to the original response write to be able to access +// the written body and the status code of the response. This is used in the response middleware +// but can also be used in a logging middleware to log the status code of the response. +type ResponseWriterWrapper struct { + w *http.ResponseWriter + body *bytes.Buffer + statusCode *int +} + +func (rww ResponseWriterWrapper) Body() *bytes.Buffer { + return rww.body +} + +func (rww ResponseWriterWrapper) StatusCode() *int { + return rww.statusCode +} + +func NewResponseWriterWrapper(w http.ResponseWriter) ResponseWriterWrapper { + var ( + buf bytes.Buffer + statusCode = 200 + ) + return ResponseWriterWrapper{ + w: &w, + body: &buf, + statusCode: &statusCode, + } +} + +// Write function overwrites the http.ResponseWriter Header() function +func (rww ResponseWriterWrapper) Write(buf []byte) (int, error) { + rww.body.Write(buf) + return (*rww.w).Write(buf) +} + +// Header function overwrites the http.ResponseWriter Header() function +func (rww ResponseWriterWrapper) Header() http.Header { + return (*rww.w).Header() +} + +// WriteHeader function overwrites the http.ResponseWriter WriteHeader() function +func (rww ResponseWriterWrapper) WriteHeader(statusCode int) { + *rww.statusCode = statusCode + (*rww.w).WriteHeader(statusCode) +} + +func (rww ResponseWriterWrapper) Unwrap() http.ResponseWriter { + return *rww.w +} diff --git a/api/v3/oasmiddleware/router.go b/api/v3/oasmiddleware/router.go new file mode 100644 index 0000000000000000000000000000000000000000..98de3d5320011139b6dd91813c14e1204f8646a4 --- /dev/null +++ b/api/v3/oasmiddleware/router.go @@ -0,0 +1,60 @@ +package oasmiddleware + +import ( + "context" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/getkin/kin-openapi/routers" + "github.com/getkin/kin-openapi/routers/gorillamux" +) + +// ValidationRouterOpts represents the options to be passed to the validation router for processing the requests. +type ValidationRouterOpts struct { + // DeleteServers removes the `Servers` property from the parsed OAS spec to be used within test or even at runtime. + // If you want to use it at runtime please read the doc for `ServerPrefix` first. + DeleteServers bool + // ServerPrefix adds a server entry with the desired prefix + // eg: the service expose domain.com/foo/v0/entity . Your spec path are defined at the /entity level and then + // /foo/v0 is part of the server entry in the OAS spec. If no prefix is provided, the validation router will either + // take the whole server entry like domain.com/foo/v0/entity to validate or if `DeleteServers` is to true it will + // only validate `/entity` + ServerPrefix string +} + +// NewValidationRouter creates a validation router to be injected in the middlewares +// to validate requests or responses. In a case of a bad spec it returns an error +func NewValidationRouter(ctx context.Context, doc *openapi3.T, opts *ValidationRouterOpts) (routers.Router, error) { + if opts == nil { + opts = &ValidationRouterOpts{ + DeleteServers: true, + } + } + + if opts.DeleteServers { + doc.Servers = nil + + for key, pathItem := range doc.Paths.Map() { + pathItem.Servers = nil + doc.Paths.Set(key, pathItem) + } + } + + if opts.ServerPrefix != "" { + doc.Servers = openapi3.Servers{ + &openapi3.Server{ + URL: opts.ServerPrefix, + }, + } + } + + if err := doc.Validate(ctx); err != nil { + return nil, err + } + + validationRouter, err := gorillamux.NewRouter(doc) + if err != nil { + return nil, err + } + + return validationRouter, err +} diff --git a/api/v3/oasmiddleware/validator.go b/api/v3/oasmiddleware/validator.go new file mode 100644 index 0000000000000000000000000000000000000000..3e0c3e6e017ab7f7bc3e0b86f44a1367f22c24ff --- /dev/null +++ b/api/v3/oasmiddleware/validator.go @@ -0,0 +1,127 @@ +package oasmiddleware + +import ( + "bytes" + "io" + "net/http" + + "github.com/getkin/kin-openapi/openapi3filter" + "github.com/getkin/kin-openapi/routers" +) + +type ( + RequestNotFoundHookFunc = func(error, http.ResponseWriter, *http.Request) bool + RequestValidationErrorFunc = func(error, http.ResponseWriter, *http.Request) bool + ResponseValidationFunc = func(error, *http.Request) +) + +// ValidateRequestOption provides the hook functions and the openapi3filter +// option to be passed in to the underlying library +type ValidateRequestOption struct { + // RouteNotFoundHook is called when the route is not found at the spec level + // if the hook returns `true` the request flow is stopped + RouteNotFoundHook RequestNotFoundHookFunc + // RouteValidationErrorHook is called when the route parameters or body are + // not validated. if the hook returns `true` the request flow is stopped + RouteValidationErrorHook RequestValidationErrorFunc + // FilterOptions are the openapi3filter option to pass to the underlying lib + FilterOptions *openapi3filter.Options +} + +// ValidateRequest is the middleware to be used to validate the request to the spec +// passed in for the validation router +func ValidateRequest(validationRouter routers.Router, opts ValidateRequestOption) func(h http.Handler) http.Handler { + return func(h http.Handler) http.Handler { + fn := func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + skipServe := false + route, pathParams, err := validationRouter.FindRoute(r.WithContext(ctx)) + if err != nil { + if opts.RouteNotFoundHook != nil { + skipServe = opts.RouteNotFoundHook(err, w, r) + } + } else { + requestValidationInput := &openapi3filter.RequestValidationInput{ + Request: r, + PathParams: pathParams, + Route: route, + Options: opts.FilterOptions, + } + if err := openapi3filter.ValidateRequest(ctx, requestValidationInput); err != nil { + if opts.RouteValidationErrorHook != nil { + skipServe = opts.RouteValidationErrorHook(err, w, r) + } + } + } + if !skipServe { + h.ServeHTTP(w, r) + } + } + return http.HandlerFunc(fn) + } +} + +// ValidateResponseOption provides the hook function and the openapi3filter +// option to be passed in to the underlying library +type ValidateResponseOption struct { + // ResponseValidationErrorHook is called when the route response body is not validated + ResponseValidationErrorHook ResponseValidationFunc + // RouteFilterHook is called after the route is found; return false to skip validation for that route. + // If nil, all matched routes are validated. + RouteFilterHook func(*routers.Route) bool + // FilterOptions are the openapi3filter option to pass to the underlying lib + FilterOptions *openapi3filter.Options +} + +// ValidateResponse is the middleware to be used to validate the response to the spec +// passed in for the validation router +func ValidateResponse(validationRouter routers.Router, opts ValidateResponseOption) func(h http.Handler) http.Handler { + return func(h http.Handler) http.Handler { + fn := func(w http.ResponseWriter, r *http.Request) { + route, pathParams, err := validationRouter.FindRoute(r) + + if err != nil { + h.ServeHTTP(w, r) + if opts.ResponseValidationErrorHook != nil { + opts.ResponseValidationErrorHook(err, r) + } + } else { + if opts.RouteFilterHook != nil && !opts.RouteFilterHook(route) { + h.ServeHTTP(w, r) + return + } + + // need to wrap std lib response to access the body + rww := NewResponseWriterWrapper(w) + + h.ServeHTTP(rww, r) + + b := new(bytes.Buffer) + _, err := b.ReadFrom(rww.Body()) + if err != nil { + return + } + bodyReader := bytes.NewReader(b.Bytes()) + + responseValidationInput := &openapi3filter.ResponseValidationInput{ + RequestValidationInput: &openapi3filter.RequestValidationInput{ + Request: r, + PathParams: pathParams, + Route: route, + Options: opts.FilterOptions, + }, + Header: rww.Header(), + Body: io.NopCloser(bodyReader), + Status: *rww.StatusCode(), + } + + if err := openapi3filter.ValidateResponse(r.Context(), responseValidationInput); err != nil { + if opts.ResponseValidationErrorHook != nil { + opts.ResponseValidationErrorHook(err, r) + } + } + } + } + return http.HandlerFunc(fn) + } +} diff --git a/api/v3/oasmiddleware/validator_test.go b/api/v3/oasmiddleware/validator_test.go new file mode 100644 index 0000000000000000000000000000000000000000..58f8f893c517985d5b47eb35dcb9b9f04716fec7 --- /dev/null +++ b/api/v3/oasmiddleware/validator_test.go @@ -0,0 +1,133 @@ +package oasmiddleware_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/getkin/kin-openapi/routers" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/oasmiddleware" +) + +// TestValidateResponse_Violation proves that the ValidateResponse middleware fires its +// error hook when a handler returns a response body that violates the OpenAPI spec. +// +// GET /openmeter/addons requires a 200 body with both "data" and "meta" fields. +// Returning {} omits both required fields and must trigger a violation. +func TestValidateResponse_Violation(t *testing.T) { + swagger, err := api.GetSwagger() + require.NoError(t, err) + + swagger.Servers = nil + + router, err := oasmiddleware.NewValidationRouter(t.Context(), swagger, &oasmiddleware.ValidationRouterOpts{ + DeleteServers: true, + }) + require.NoError(t, err) + + var gotErr error + mw := oasmiddleware.ValidateResponse(router, oasmiddleware.ValidateResponseOption{ + ResponseValidationErrorHook: func(err error, r *http.Request) { + gotErr = err + }, + }) + + badHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{}`)) + }) + + req := httptest.NewRequest(http.MethodGet, "/openmeter/addons", nil) + rec := httptest.NewRecorder() + + mw(badHandler).ServeHTTP(rec, req) + + assert.Error(t, gotErr, "expected a validation error for missing required fields") + t.Logf("validation error (expected): %v", gotErr) +} + +// TestValidateResponse_Clean proves that a well-formed response does not trigger the error hook. +func TestValidateResponse_Clean(t *testing.T) { + swagger, err := api.GetSwagger() + require.NoError(t, err) + + swagger.Servers = nil + + router, err := oasmiddleware.NewValidationRouter(t.Context(), swagger, &oasmiddleware.ValidationRouterOpts{ + DeleteServers: true, + }) + require.NoError(t, err) + + var gotErr error + mw := oasmiddleware.ValidateResponse(router, oasmiddleware.ValidateResponseOption{ + ResponseValidationErrorHook: func(err error, r *http.Request) { + gotErr = err + }, + }) + + goodHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"data":[],"meta":{"page":{"number":0,"size":100,"total":0}}}`)) + }) + + req := httptest.NewRequest(http.MethodGet, "/openmeter/addons", nil) + rec := httptest.NewRecorder() + + mw(goodHandler).ServeHTTP(rec, req) + + assert.NoError(t, gotErr, "expected no validation error for a well-formed response") +} + +// TestValidateResponse_RouteFilterSkipsValidation proves that when RouteFilterHook returns false, +// the response body is neither buffered nor validated — even if it would otherwise violate the spec. +// The filter is the per-route gate that lets callers (e.g. unstable-only mode) avoid the +// buffering overhead on routes they don't care about. +func TestValidateResponse_RouteFilterSkipsValidation(t *testing.T) { + swagger, err := api.GetSwagger() + require.NoError(t, err) + + swagger.Servers = nil + + router, err := oasmiddleware.NewValidationRouter(t.Context(), swagger, &oasmiddleware.ValidationRouterOpts{ + DeleteServers: true, + }) + require.NoError(t, err) + + var ( + gotErr error + filteredRoute *routers.Route + ) + mw := oasmiddleware.ValidateResponse(router, oasmiddleware.ValidateResponseOption{ + RouteFilterHook: func(route *routers.Route) bool { + filteredRoute = route + return false + }, + ResponseValidationErrorHook: func(err error, r *http.Request) { + gotErr = err + }, + }) + + // Body that would fail validation if the filter let it through. + badHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{}`)) + }) + + req := httptest.NewRequest(http.MethodGet, "/openmeter/addons", nil) + rec := httptest.NewRecorder() + + mw(badHandler).ServeHTTP(rec, req) + + require.NotNil(t, filteredRoute, "RouteFilterHook should have been invoked with the matched route") + assert.Equal(t, "/openmeter/addons", filteredRoute.Path) + assert.NoError(t, gotErr, "validation error hook must not fire when the filter returns false") + assert.Equal(t, http.StatusOK, rec.Code, "client response should still be served") + assert.Equal(t, `{}`, rec.Body.String(), "client response body should still be served") +} diff --git a/api/v3/openapi.yaml b/api/v3/openapi.yaml new file mode 100644 index 0000000000000000000000000000000000000000..5092658bb3ade930fc92c3725e93ba20d66992df --- /dev/null +++ b/api/v3/openapi.yaml @@ -0,0 +1,13386 @@ +openapi: 3.0.0 +info: + title: OpenMeter and Konnect Metering & Billing API + version: 0.0.1 + description: |- + OpenMeter is a cloud native usage metering and billing service. The API allows + you to ingest events, query meter usage, and manage resources. +servers: + - url: http://localhost:{port}/api/v3 + description: Local + variables: + port: + default: '8888' + - url: https://{region}.api.konghq.com/v3 + description: Production region + variables: + region: + default: us + enum: + - us + - eu + - au + - me + - in + - url: https://openmeter.cloud/api/v3 + description: OpenMeter Cloud + variables: {} +tags: + - name: OpenMeter Governance + description: Governance evaluation of customers to check their feature access. + - name: OpenMeter Defaults + description: Organization-level default configuration. + - name: OpenMeter Product Catalog + description: Product catalog manages plans, add-ons, and their associations for subscription-based billing. + - name: OpenMeter LLM Cost + description: LLM cost database providing normalized pricing data for language models across providers. Prices are synced from multiple external sources and auto-confirmed when they agree. + - name: OpenMeter Features + description: Features represent product capabilities backed by meters, with optional per-unit cost configuration. + - name: OpenMeter Tax + description: Tax codes are used to calculate taxes for customers. + - name: OpenMeter Currencies + description: Currencies and cost bases for customers. + - name: OpenMeter Billing Settings + description: Billing settings manages the billing profiles and invoices for customers. + - name: OpenMeter Apps + description: Apps enable you to extend and customize billing and usage workflows by integrating with external systems and services. Apps can automate and enhance your billing ecosystem by supporting capabilities such as synchronizing usage data with third-party platforms, calculating taxes, generating and delivering invoices, handling payment collection, and other billing-related tasks. + - name: OpenMeter Entitlements + description: Entitlements are used to control access to features for customers. + - name: OpenMeter Subscriptions + description: Subscriptions are used to track usage of your product or service. Subscriptions can be individuals or organizations that can subscribe to plans and have access to features. + - name: OpenMeter Customers + description: Customers are used to track usage of your product or service. Customers can be individuals or organizations that can subscribe to plans and have access to features. + - name: Metering Events + description: Metering events are used to track usage of your product or service. Events are processed asynchronously by the meters, so they may not be immediately available for querying. + - name: Meters + description: Meters specify how to aggregate events for billing and analytics purposes. Meters can be configured with multiple aggregation methods and groupings. Multiple meters can be created for the same event type, enabling flexible metering scenarios. +paths: + /openmeter/addons: + get: + operationId: list-addons + summary: List add-ons + description: List all add-ons. + parameters: + - $ref: '#/components/parameters/PagePaginationQuery' + - name: sort + in: query + required: false + description: |- + Sort add-ons returned in the response. Supported sort attributes are: + + - `id` + - `key` + - `name` + - `created_at` (default) + - `updated_at` + + The `asc` suffix is optional as the default sort order is ascending. The `desc` + suffix is used to specify a descending order. + schema: + $ref: '#/components/schemas/SortQuery' + explode: false + style: form + - name: filter + in: query + required: false + description: Filter add-ons returned in the response. + schema: + $ref: '#/components/schemas/ListAddonsParamsFilter' + style: deepObject + responses: + '200': + description: Page paginated response. + content: + application/json: + schema: + $ref: '#/components/schemas/AddonPagePaginatedResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + tags: + - OpenMeter Product Catalog + x-unstable: true + post: + operationId: create-addon + summary: Create add-on + description: Create a new add-on. + responses: + '201': + description: Addon created response. + content: + application/json: + schema: + $ref: '#/components/schemas/Addon' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + tags: + - OpenMeter Product Catalog + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateAddonRequest' + x-unstable: true + /openmeter/addons/{addonId}: + put: + operationId: update-addon + summary: Update add-on + description: Update an add-on by id. + parameters: + - name: addonId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + responses: + '200': + description: Addon upsert response. + content: + application/json: + schema: + $ref: '#/components/schemas/Addon' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '410': + $ref: '#/components/responses/Gone' + tags: + - OpenMeter Product Catalog + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpsertAddonRequest' + x-unstable: true + get: + operationId: get-addon + summary: Get add-on + description: Get add-on by id. + parameters: + - name: addonId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + responses: + '200': + description: Addon response. + content: + application/json: + schema: + $ref: '#/components/schemas/Addon' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '410': + $ref: '#/components/responses/Gone' + tags: + - OpenMeter Product Catalog + x-unstable: true + delete: + operationId: delete-addon + summary: Soft delete add-on + description: Soft delete add-on by id. + parameters: + - name: addonId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + responses: + '204': + description: Deleted response. + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + tags: + - OpenMeter Product Catalog + x-unstable: true + /openmeter/addons/{addonId}/archive: + post: + operationId: archive-addon + summary: Archive add-on version + description: Archive an add-on version. + parameters: + - name: addonId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + responses: + '200': + description: Addon updated response. + content: + application/json: + schema: + $ref: '#/components/schemas/Addon' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + tags: + - OpenMeter Product Catalog + x-unstable: true + /openmeter/addons/{addonId}/publish: + post: + operationId: publish-addon + summary: Publish add-on version + description: Publish an add-on version. + parameters: + - name: addonId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + responses: + '200': + description: Addon updated response. + content: + application/json: + schema: + $ref: '#/components/schemas/Addon' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + tags: + - OpenMeter Product Catalog + x-unstable: true + /openmeter/app-catalog: + get: + operationId: list-app-catalog + summary: List app catalog + description: List available apps. + parameters: + - $ref: '#/components/parameters/PagePaginationQuery' + responses: + '200': + description: Page paginated response. + content: + application/json: + schema: + $ref: '#/components/schemas/AppCatalogItemPagePaginatedResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + tags: + - OpenMeter Apps + /openmeter/app-catalog/install: + post: + operationId: install-app + summary: Install app from the catalog + description: Install an app from the catalog. + responses: + '201': + description: InstallAppResponse created response. + content: + application/json: + schema: + $ref: '#/components/schemas/BillingInstallAppResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + tags: + - OpenMeter Apps + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BillingInstallAppRequest' + /openmeter/app-catalog/{appType}: + get: + operationId: get-app-catalog-item + summary: Get app catalog item by type + description: Get an app catalog item by type. + parameters: + - name: appType + in: path + required: true + schema: + $ref: '#/components/schemas/BillingAppType' + responses: + '200': + description: AppCatalogItem response. + content: + application/json: + schema: + $ref: '#/components/schemas/BillingAppCatalogItem' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + tags: + - OpenMeter Apps + /openmeter/apps: + get: + operationId: list-apps + summary: List apps + description: List installed apps. + parameters: + - $ref: '#/components/parameters/PagePaginationQuery' + responses: + '200': + description: Page paginated response. + content: + application/json: + schema: + $ref: '#/components/schemas/AppPagePaginatedResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + tags: + - OpenMeter Apps + /openmeter/apps/{appId}: + get: + operationId: get-app + summary: Get app + description: Get an installed app. + parameters: + - name: appId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + responses: + '200': + description: App response. + content: + application/json: + schema: + $ref: '#/components/schemas/BillingApp' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + tags: + - OpenMeter Apps + /openmeter/billing/invoices: + get: + operationId: list-invoices + summary: List billing invoices + description: |- + List billing invoices. + + Returns a page of invoices. Gathering invoices are never included. Use `filter` + to narrow by status, customer, dates, or service period start. Use `sort` to + control ordering. + parameters: + - $ref: '#/components/parameters/PagePaginationQuery' + - name: sort + in: query + required: false + description: |- + Sort invoices returned in the response. Supported sort attributes: + + - `issued_at` + - `created_at` (default) + - `service_period_start` + + The `asc` suffix is optional as the default sort order is ascending. The `desc` + suffix is used to specify a descending order. + schema: + $ref: '#/components/schemas/SortQuery' + explode: false + style: form + - name: filter + in: query + required: false + description: |- + Filter invoices returned in the response. + + Examples: + + - `filter[status][oeq]=draft,issued` + - `filter[customer_id]=01KPDB8K...` + - `filter[issued_at][gte]=2024-01-01T00:00:00Z` + schema: + $ref: '#/components/schemas/ListInvoicesParamsFilter' + style: deepObject + responses: + '200': + description: Page paginated response. + content: + application/json: + schema: + $ref: '#/components/schemas/InvoicePagePaginatedResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + tags: + - OpenMeter Billing Settings + x-private: true + x-internal: true + x-unstable: true + /openmeter/billing/invoices/{invoiceId}: + get: + operationId: get-invoice + summary: Get a billing invoice + description: |- + Get a billing invoice by ID. + + Returns the full invoice resource including line items, status details, totals, + and workflow configuration snapshot. + parameters: + - name: invoiceId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + responses: + '200': + description: Invoice response. + content: + application/json: + schema: + $ref: '#/components/schemas/BillingInvoice' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + tags: + - OpenMeter Billing Settings + x-private: true + x-internal: true + x-unstable: true + put: + operationId: update-invoice + summary: Update a billing invoice + description: |- + Update a billing invoice. + + Only the mutable fields of the invoice can be edited: description, labels, + supplier, customer, workflow settings, and top-level lines. Top-level lines are + matched by `id`; lines without an `id` are created, and existing lines omitted + from `lines` are deleted. Detailed (child) lines are always computed and cannot + be edited directly. Only invoices in draft status can be updated. + parameters: + - name: invoiceId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + responses: + '200': + description: Invoice updated response. + content: + application/json: + schema: + $ref: '#/components/schemas/BillingInvoice' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + tags: + - OpenMeter Billing Settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateInvoiceRequest' + x-private: true + x-internal: true + x-unstable: true + delete: + operationId: delete-invoice + summary: Delete a billing invoice + description: |- + Delete a billing invoice. + + Only standard invoices in draft status can be deleted. Deleting an invoice will + also delete all associated line items and workflow configuration. + parameters: + - name: invoiceId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + responses: + '204': + description: Deleted response. + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + tags: + - OpenMeter Billing Settings + x-private: true + x-internal: true + x-unstable: true + /openmeter/billing/invoices/{invoiceId}/advance: + post: + operationId: advance-invoice + summary: Advance billing invoice's next status + description: |- + Advance a billing invoice. + + Advances the invoice to the next workflow state. The next state is determined by + the invoice's current status and workflow configuration. Only invoices in draft + or issued status can be advanced. + parameters: + - name: invoiceId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + responses: + '200': + description: The updated invoice after advancing to the next state. + content: + application/json: + schema: + $ref: '#/components/schemas/BillingInvoice' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + tags: + - OpenMeter Billing Settings + x-private: true + x-internal: true + x-unstable: true + /openmeter/billing/invoices/{invoiceId}/approve: + post: + operationId: approve-invoice + summary: Send the invoice to the customer + description: |- + Approve a billing invoice. + + This call instantly sends the invoice to the customer using the configured + billing profile app. + + This call is valid in two invoice statuses: + + - draft: the invoice will be sent to the customer, the invoice state becomes + issued + - manual_approval_needed: the invoice will be sent to the customer, the invoice + state becomes issued + parameters: + - name: invoiceId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + responses: + '200': + description: The updated invoice after sending to the customer. + content: + application/json: + schema: + $ref: '#/components/schemas/BillingInvoice' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + tags: + - OpenMeter Billing Settings + x-private: true + x-internal: true + x-unstable: true + /openmeter/billing/invoices/{invoiceId}/retry: + post: + operationId: retry-invoice + summary: Retry advancing the invoice after a failed attempt + description: |- + Retry sending a billing invoice. + + Retry advancing the invoice after a failed attempt. + + The action can be called when the invoice's statusDetails' actions field contain + the "retry" action. + parameters: + - name: invoiceId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + responses: + '200': + description: The updated invoice after retrying the action. + content: + application/json: + schema: + $ref: '#/components/schemas/BillingInvoice' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + tags: + - OpenMeter Billing Settings + x-private: true + x-internal: true + x-unstable: true + /openmeter/billing/invoices/{invoiceId}/snapshot-quantities: + post: + operationId: snapshot-quantities-invoice + summary: Snapshot quantities for usage based line items + description: |- + Snapshot quantities for usage-based line items. + + This call will snapshot the quantities for all usage based line items in the + invoice. + + This call is only valid in draft.waiting_for_collection status, where the + collection period can be skipped using this action. + parameters: + - name: invoiceId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + responses: + '200': + description: |- + The updated invoice with the snapshot quantities for usage based line + items. + content: + application/json: + schema: + $ref: '#/components/schemas/BillingInvoice' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + tags: + - OpenMeter Billing Settings + x-private: true + x-internal: true + x-unstable: true + /openmeter/currencies: + get: + operationId: list-currencies + summary: List currencies + description: List currencies supported by the billing system. + parameters: + - $ref: '#/components/parameters/PagePaginationQuery' + - name: sort + in: query + required: false + description: |- + Sort currencies returned in the response. Supported sort attributes are: + + - `code` (default) + - `name` + + The `asc` suffix is optional as the default sort order is ascending. The `desc` + suffix is used to specify a descending order. + schema: + $ref: '#/components/schemas/SortQuery' + explode: false + style: form + - name: filter + in: query + required: false + description: |- + Filter currencies returned in the response. + + To filter currencies by type add the following query param: filter[type]=custom + schema: + $ref: '#/components/schemas/ListCurrenciesParamsFilter' + style: deepObject + responses: + '200': + description: Page paginated response. + content: + application/json: + schema: + $ref: '#/components/schemas/CurrencyPagePaginatedResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + tags: + - OpenMeter Currencies + x-internal: true + x-unstable: true + /openmeter/currencies/custom: + post: + operationId: create-custom-currency + summary: Create custom currency + description: |- + Create a custom currency. This operation allows defining your own custom + currency for billing purposes. + responses: + '201': + description: CurrencyCustom created response. + content: + application/json: + schema: + $ref: '#/components/schemas/BillingCurrencyCustom' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + tags: + - OpenMeter Currencies + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateCurrencyCustomRequest' + x-internal: true + x-unstable: true + /openmeter/currencies/custom/{currencyId}/cost-bases: + get: + operationId: list-cost-bases + summary: List cost bases + description: |- + List cost bases for a currency. For custom currencies, there can be multiple + cost bases with different `effective_from` dates. + parameters: + - name: currencyId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + - name: filter + in: query + required: false + description: |- + Filter cost bases returned in the response. + + To filter cost bases by fiat currency code add the following query param: + filter[fiat_code]=USD + schema: + $ref: '#/components/schemas/ListCostBasesParamsFilter' + style: deepObject + - $ref: '#/components/parameters/PagePaginationQuery' + responses: + '200': + description: Page paginated response. + content: + application/json: + schema: + $ref: '#/components/schemas/CostBasisPagePaginatedResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + tags: + - OpenMeter Currencies + x-internal: true + x-unstable: true + post: + operationId: create-cost-basis + summary: Create cost basis + description: Create a cost basis for a currency. + parameters: + - name: currencyId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + responses: + '201': + description: CostBasis created response. + content: + application/json: + schema: + $ref: '#/components/schemas/BillingCostBasis' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + tags: + - OpenMeter Currencies + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateCostBasisRequest' + x-internal: true + x-unstable: true + /openmeter/customers: + post: + operationId: create-customer + summary: Create customer + responses: + '201': + description: Customer created response. + content: + application/json: + schema: + $ref: '#/components/schemas/BillingCustomer' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + tags: + - OpenMeter Customers + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateCustomerRequest' + get: + operationId: list-customers + summary: List customers + parameters: + - $ref: '#/components/parameters/PagePaginationQuery' + - name: sort + in: query + required: false + description: |- + Sort customers returned in the response. Supported sort attributes are: + + - `id` + - `name` (default) + - `created_at` + + The `asc` suffix is optional as the default sort order is ascending. The `desc` + suffix is used to specify a descending order. + schema: + $ref: '#/components/schemas/SortQuery' + explode: false + style: form + - name: filter + in: query + required: false + description: |- + Filter customers returned in the response. + + To filter customers by key add the following query param: filter[key]=my-db-id + schema: + $ref: '#/components/schemas/ListCustomersParamsFilter' + style: deepObject + responses: + '200': + description: Page paginated response. + content: + application/json: + schema: + $ref: '#/components/schemas/CustomerPagePaginatedResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + tags: + - OpenMeter Customers + /openmeter/customers/{customerId}: + get: + operationId: get-customer + summary: Get customer + parameters: + - name: customerId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + responses: + '200': + description: Customer response. + content: + application/json: + schema: + $ref: '#/components/schemas/BillingCustomer' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + tags: + - OpenMeter Customers + put: + operationId: upsert-customer + summary: Upsert customer + parameters: + - name: customerId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + responses: + '200': + description: Customer upsert response. + content: + application/json: + schema: + $ref: '#/components/schemas/BillingCustomer' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '410': + $ref: '#/components/responses/Gone' + tags: + - OpenMeter Customers + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpsertCustomerRequest' + delete: + operationId: delete-customer + summary: Delete customer + parameters: + - name: customerId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + responses: + '204': + description: Deleted response. + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + tags: + - OpenMeter Customers + /openmeter/customers/{customerId}/billing: + get: + operationId: get-customer-billing + summary: Get customer billing data + parameters: + - name: customerId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + responses: + '200': + description: CustomerBillingData response. + content: + application/json: + schema: + $ref: '#/components/schemas/BillingCustomerData' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + tags: + - OpenMeter Customers + put: + operationId: update-customer-billing + summary: Update customer billing data + parameters: + - name: customerId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + responses: + '200': + description: CustomerBillingData upsert response. + content: + application/json: + schema: + $ref: '#/components/schemas/BillingCustomerData' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '410': + $ref: '#/components/responses/Gone' + tags: + - OpenMeter Customers + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpsertCustomerBillingDataRequest' + /openmeter/customers/{customerId}/billing/app-data: + put: + operationId: update-customer-billing-app-data + summary: Update customer billing app data + parameters: + - name: customerId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + responses: + '200': + description: AppCustomerData upsert response. + content: + application/json: + schema: + $ref: '#/components/schemas/BillingAppCustomerData' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '410': + $ref: '#/components/responses/Gone' + tags: + - OpenMeter Customers + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpsertAppCustomerDataRequest' + /openmeter/customers/{customerId}/billing/stripe/checkout-sessions: + post: + operationId: create-customer-stripe-checkout-session + summary: Create Stripe Checkout Session + description: |- + Create a [Stripe Checkout Session](https://docs.stripe.com/payments/checkout) + for the customer. + + Creates a Checkout Session for collecting payment method information from + customers. The session operates in "setup" mode, which collects payment details + without charging the customer immediately. The collected payment method can be + used for future subscription billing. + + For hosted checkout sessions, redirect customers to the returned URL. For + embedded sessions, use the client_secret to initialize Stripe.js in your + application. + parameters: + - name: customerId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + responses: + '201': + description: CreateStripeCheckoutSessionResult created response. + content: + application/json: + schema: + $ref: '#/components/schemas/BillingAppStripeCreateCheckoutSessionResult' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '410': + $ref: '#/components/responses/Gone' + tags: + - OpenMeter Customers + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BillingCustomerStripeCreateCheckoutSessionRequest' + /openmeter/customers/{customerId}/billing/stripe/portal-sessions: + post: + operationId: create-customer-stripe-portal-session + summary: Create Stripe customer portal session + description: |- + Create Stripe Customer Portal Session. + + Useful to redirect the customer to the Stripe Customer Portal to manage their + payment methods, change their billing address and access their invoice history. + Only returns URL if the customer billing profile is linked to a stripe app and + customer. + parameters: + - name: customerId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + responses: + '201': + description: CreateStripeCustomerPortalSessionResult created response. + content: + application/json: + schema: + $ref: '#/components/schemas/BillingAppStripeCreateCustomerPortalSessionResult' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '410': + $ref: '#/components/responses/Gone' + tags: + - OpenMeter Customers + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BillingCustomerStripeCreateCustomerPortalSessionRequest' + /openmeter/customers/{customerId}/charges: + get: + operationId: list-customer-charges + summary: List customer charges + description: |- + List customer charges. + + Returns the customer's charges that are represented as either flat fee or + usage-based charges. + parameters: + - $ref: '#/components/parameters/PagePaginationQuery' + - name: customerId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + - name: sort + in: query + required: false + description: |- + Sort charges returned in the response. + + Supported sort attributes are: + + - `id` + - `created_at` + - `service_period.from` + - `billing_period.from` + schema: + $ref: '#/components/schemas/SortQuery' + explode: false + style: form + - name: filter + in: query + required: false + description: |- + Filter charges. + + To filter charges by status add the following query param: + `filter[status][oeq]=created,active` + schema: + $ref: '#/components/schemas/ListChargesParamsFilter' + style: deepObject + - name: expand + in: query + required: false + description: |- + Expand full objects for referenced entities. + + Supported values are: + + - `real_time_usage`: Expand the charge's real-time usage. + schema: + type: array + items: + $ref: '#/components/schemas/BillingChargesExpand' + style: form + responses: + '200': + description: Page paginated response. + content: + application/json: + schema: + $ref: '#/components/schemas/ChargePagePaginatedResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + tags: + - OpenMeter Customers + x-unstable: true + post: + operationId: create-customer-charges + summary: Create customer charge + description: Create customer charge. + parameters: + - name: customerId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + responses: + '201': + description: Charge created response. + content: + application/json: + schema: + $ref: '#/components/schemas/BillingCharge' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + tags: + - OpenMeter Customers + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateChargeRequest' + x-unstable: true + /openmeter/customers/{customerId}/credits/adjustments: + post: + operationId: create-credit-adjustment + summary: Create a credit adjustment + description: |- + A credit adjustment can be used to make manual adjustments to a customer's + credit balance. + + Supported use-cases: + + - Usage correction + parameters: + - name: customerId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + responses: + '201': + description: CreditAdjustment created response. + content: + application/json: + schema: + $ref: '#/components/schemas/BillingCreditAdjustment' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + tags: + - OpenMeter Customers + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateCreditAdjustmentRequest' + x-unstable: true + /openmeter/customers/{customerId}/credits/balance: + get: + operationId: get-customer-credit-balance + summary: Get a customer's credit balance + description: Get a credit balance. + parameters: + - name: customerId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + - name: timestamp + in: query + required: false + description: |- + Return the credit balance as of this timestamp. + + Defaults to the current time. + schema: + $ref: '#/components/schemas/DateTime' + explode: false + style: form + - name: filter + in: query + required: false + schema: + $ref: '#/components/schemas/GetCreditBalanceParamsFilter' + style: deepObject + responses: + '200': + description: CreditBalances response. + content: + application/json: + schema: + $ref: '#/components/schemas/BillingCreditBalances' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + tags: + - OpenMeter Customers + x-unstable: true + /openmeter/customers/{customerId}/credits/grants: + post: + operationId: create-credit-grant + summary: Create a new credit grant + description: |- + Create a new credit grant. A credit grant represents an allocation of prepaid + credits to a customer. + parameters: + - name: customerId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + responses: + '201': + description: CreditGrant created response. + content: + application/json: + schema: + $ref: '#/components/schemas/BillingCreditGrant' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + tags: + - OpenMeter Customers + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateCreditGrantRequest' + x-unstable: true + get: + operationId: list-credit-grants + summary: List credit grants + description: List credit grants. + parameters: + - name: customerId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + - $ref: '#/components/parameters/PagePaginationQuery' + - name: filter + in: query + required: false + description: Filter credit grants returned in the response. + schema: + $ref: '#/components/schemas/ListCreditGrantsParamsFilter' + style: deepObject + responses: + '200': + description: Page paginated response. + content: + application/json: + schema: + $ref: '#/components/schemas/CreditGrantPagePaginatedResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + tags: + - OpenMeter Customers + x-unstable: true + /openmeter/customers/{customerId}/credits/grants/{creditGrantId}: + get: + operationId: get-credit-grant + summary: Get a credit grant + description: Get a credit grant. + parameters: + - name: customerId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + - name: creditGrantId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + responses: + '200': + description: CreditGrant response. + content: + application/json: + schema: + $ref: '#/components/schemas/BillingCreditGrant' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + tags: + - OpenMeter Customers + x-unstable: true + /openmeter/customers/{customerId}/credits/grants/{creditGrantId}/settlement/external: + post: + operationId: update-credit-grant-external-settlement + summary: Update credit grant external settlement status + description: |- + Update the payment settlement status of an externally funded credit grant. + + Use this endpoint to synchronize the payment state of an external payment with + the system so that revenue recognition and credit availability work as expected. + parameters: + - name: customerId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + - name: creditGrantId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + responses: + '200': + description: CreditGrant updated response. + content: + application/json: + schema: + $ref: '#/components/schemas/BillingCreditGrant' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + tags: + - OpenMeter Customers + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateCreditGrantExternalSettlementRequest' + x-unstable: true + /openmeter/customers/{customerId}/credits/grants/{creditGrantId}/void: + post: + operationId: void-credit-grant + summary: Void credit grant + description: |- + Void a credit grant, forfeiting the remaining unused balance. + + Voiding is a forward-looking, irreversible operation. Credits already consumed + by usage remain unaffected — only the remaining balance is forfeited. The grant + reads as `voided` status afterwards. Payment state is not adjusted when + `payment_adjustment` is `none`, so invoice-backed or externally collected + payments may still collect the original amount. Only `active` grants can be + voided; voiding a pending, expired, or fully consumed grant returns a conflict. + Retrying a successful void is an idempotent success. + parameters: + - name: customerId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + - name: creditGrantId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + responses: + '200': + description: CreditGrant updated response. + content: + application/json: + schema: + $ref: '#/components/schemas/BillingCreditGrant' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + tags: + - OpenMeter Customers + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/VoidCreditGrantRequest' + x-unstable: true + /openmeter/customers/{customerId}/credits/transactions: + get: + operationId: list-credit-transactions + summary: List credit transactions + description: |- + List credit transactions for a customer. + + Returns an immutable, chronological record of credit movements: funded credits + and consumed credits. Transactions are returned in reverse chronological order + by default. + parameters: + - name: customerId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + - $ref: '#/components/parameters/CursorPaginationQuery' + - name: filter + in: query + required: false + description: Filter credit transactions returned in the response. + schema: + $ref: '#/components/schemas/ListCreditTransactionsParamsFilter' + style: deepObject + responses: + '200': + description: Cursor paginated response. + content: + application/json: + schema: + $ref: '#/components/schemas/CreditTransactionPaginatedResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + tags: + - OpenMeter Customers + x-unstable: true + /openmeter/customers/{customerId}/entitlement-access: + get: + operationId: list-customer-entitlement-access + summary: List customer entitlement access + parameters: + - name: customerId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + responses: + '200': + description: List the customer's active features and their access. + content: + application/json: + schema: + $ref: '#/components/schemas/ListCustomerEntitlementAccessResponseData' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + tags: + - OpenMeter Entitlements + /openmeter/defaults/tax-codes: + get: + operationId: get-organization-default-tax-codes + summary: Get organization default tax codes + responses: + '200': + description: OrganizationDefaultTaxCodes response. + content: + application/json: + schema: + $ref: '#/components/schemas/OrganizationDefaultTaxCodes' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + tags: + - OpenMeter Defaults + x-unstable: true + put: + operationId: update-organization-default-tax-codes + summary: Update organization default tax codes + responses: + '200': + description: OrganizationDefaultTaxCodes upsert response. + content: + application/json: + schema: + $ref: '#/components/schemas/OrganizationDefaultTaxCodes' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + tags: + - OpenMeter Defaults + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateOrganizationDefaultTaxCodesRequest' + x-unstable: true + /openmeter/events: + get: + operationId: list-metering-events + summary: List metering events + description: List ingested events. + parameters: + - $ref: '#/components/parameters/CursorPaginationQuery' + - name: filter + in: query + required: false + description: |- + Filter events returned in the response. + + To filter events by subject add the following query param: + filter[subject][eq]=customer-1 + schema: + $ref: '#/components/schemas/ListEventsParamsFilter' + style: deepObject + - name: sort + in: query + required: false + description: |- + Sort events returned in the response. Supported sort attributes are: + + - `time` (default) + - `ingested_at` + - `stored_at` + + When omitted, events are sorted by `time desc` (most recent first). When a sort + field is provided without a suffix, it sorts descending. Append the `asc` suffix + to sort ascending, or the `desc` suffix to sort descending. + schema: + $ref: '#/components/schemas/SortQuery' + explode: false + style: form + responses: + '200': + description: Cursor paginated response. + content: + application/json: + schema: + $ref: '#/components/schemas/IngestedEventPaginatedResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + tags: + - Metering Events + x-unstable: true + post: + operationId: ingest-metering-events + description: Ingests an event or batch of events following the CloudEvents specification. + summary: Ingest metering events + responses: + '202': + description: The events have been ingested and are being processed asynchronously. + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + tags: + - Metering Events + requestBody: + required: true + content: + application/cloudevents+json: + schema: + $ref: '#/components/schemas/MeteringEvent' + application/cloudevents-batch+json: + schema: + type: array + items: + $ref: '#/components/schemas/MeteringEvent' + application/json: + schema: + anyOf: + - $ref: '#/components/schemas/MeteringEvent' + - type: array + items: + $ref: '#/components/schemas/MeteringEvent' + /openmeter/features: + get: + operationId: list-features + summary: List features + description: List all features. + parameters: + - $ref: '#/components/parameters/PagePaginationQuery' + - name: sort + in: query + required: false + description: |- + Sort features returned in the response. Supported sort attributes are: + + - `key` + - `name` + - `created_at` (default) + - `updated_at` + + The `asc` suffix is optional as the default sort order is ascending. The `desc` + suffix is used to specify a descending order. + schema: + $ref: '#/components/schemas/SortQuery' + explode: false + style: form + - name: filter + in: query + required: false + description: |- + Filter features returned in the response. + + To filter features by meter_id add the following query param: + filter[meter_id][oeq]= + schema: + $ref: '#/components/schemas/ListFeatureParamsFilter' + style: deepObject + responses: + '200': + description: Page paginated response. + content: + application/json: + schema: + $ref: '#/components/schemas/FeaturePagePaginatedResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + tags: + - OpenMeter Features + x-unstable: true + post: + operationId: create-feature + summary: Create feature + description: Create a feature. + responses: + '201': + description: Feature created response. + content: + application/json: + schema: + $ref: '#/components/schemas/Feature' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + tags: + - OpenMeter Features + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateFeatureRequest' + x-unstable: true + /openmeter/features/{featureId}: + get: + operationId: get-feature + summary: Get feature + description: Get a feature by id. + parameters: + - name: featureId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + responses: + '200': + description: Feature response. + content: + application/json: + schema: + $ref: '#/components/schemas/Feature' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '410': + $ref: '#/components/responses/Gone' + tags: + - OpenMeter Features + x-unstable: true + patch: + operationId: update-feature + summary: Update feature + description: Update a feature by id. Currently only the unit_cost field can be updated. + parameters: + - name: featureId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + responses: + '200': + description: Feature updated response. + content: + application/json: + schema: + $ref: '#/components/schemas/Feature' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + tags: + - OpenMeter Features + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateFeatureRequest' + x-unstable: true + delete: + operationId: delete-feature + summary: Delete feature + description: Delete a feature by id. + parameters: + - name: featureId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + responses: + '204': + description: Deleted response. + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + tags: + - OpenMeter Features + x-unstable: true + /openmeter/features/{featureId}/cost/query: + post: + operationId: query-feature-cost + summary: Query feature cost + description: Query the cost of a feature. + parameters: + - name: featureId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + responses: + '200': + description: The request has succeeded. + content: + application/json: + schema: + $ref: '#/components/schemas/FeatureCostQueryResult' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + tags: + - OpenMeter Features + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/MeterQueryRequest' + x-unstable: true + /openmeter/governance/query: + post: + operationId: query-governance-access + summary: Query governance access + description: |- + Query feature access for a list of customers. + + The endpoint resolves each provided identifier to a customer and returns the + access status for the requested features, plus optional credit balance + availability. + + _Designed to be called on a fixed refresh interval and the query response is + intended to be cached._ + parameters: + - $ref: '#/components/parameters/CursorPaginationQuery' + responses: + '200': + description: The request has succeeded. + content: + application/json: + schema: + $ref: '#/components/schemas/GovernanceQueryResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + tags: + - OpenMeter Governance + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/GovernanceQueryRequest' + x-private: true + x-internal: true + x-unstable: true + /openmeter/llm-cost/overrides: + get: + operationId: list-llm-cost-overrides + summary: List LLM cost overrides + description: List per-namespace price overrides. + parameters: + - name: filter + in: query + required: false + schema: + $ref: '#/components/schemas/ListLLMCostPricesParamsFilter' + style: deepObject + - $ref: '#/components/parameters/PagePaginationQuery' + responses: + '200': + description: Page paginated response. + content: + application/json: + schema: + $ref: '#/components/schemas/PricePagePaginatedResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + tags: + - OpenMeter LLM Cost + x-unstable: true + post: + operationId: create-llm-cost-override + summary: Create LLM cost override + description: Create a per-namespace price override. + responses: + '201': + description: Price created response. + content: + application/json: + schema: + $ref: '#/components/schemas/LLMCostPrice' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + tags: + - OpenMeter LLM Cost + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/LLMCostOverrideCreate' + x-unstable: true + /openmeter/llm-cost/overrides/{priceId}: + delete: + operationId: delete-llm-cost-override + summary: Delete LLM cost override + description: Delete a per-namespace price override. + parameters: + - name: priceId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + responses: + '204': + description: Deleted response. + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + tags: + - OpenMeter LLM Cost + x-unstable: true + /openmeter/llm-cost/prices: + get: + operationId: list-llm-cost-prices + summary: List LLM cost prices + description: List global LLM cost prices. Returns prices with overrides applied if any. + parameters: + - name: filter + in: query + required: false + description: Filter prices. + schema: + $ref: '#/components/schemas/ListLLMCostPricesParamsFilter' + style: deepObject + - name: sort + in: query + required: false + description: |- + Sort prices returned in the response. Supported sort attributes are: + + - `id` + - `provider.id` + - `model.id` (default) + - `effective_from` + - `effective_to` + + The `asc` suffix is optional as the default sort order is ascending. The `desc` + suffix is used to specify a descending order. + schema: + $ref: '#/components/schemas/SortQuery' + explode: false + style: form + - $ref: '#/components/parameters/PagePaginationQuery' + responses: + '200': + description: Page paginated response. + content: + application/json: + schema: + $ref: '#/components/schemas/PricePagePaginatedResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + tags: + - OpenMeter LLM Cost + x-unstable: true + /openmeter/llm-cost/prices/{priceId}: + get: + operationId: get-llm-cost-price + summary: Get LLM cost price + description: |- + Get a specific LLM cost price by ID. Returns the price with overrides applied if + any. + parameters: + - name: priceId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + responses: + '200': + description: The request has succeeded. + content: + application/json: + schema: + $ref: '#/components/schemas/LLMCostPrice' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + tags: + - OpenMeter LLM Cost + x-unstable: true + /openmeter/meters: + post: + operationId: create-meter + summary: Create meter + description: Create a meter. + responses: + '201': + description: Meter created response. + content: + application/json: + schema: + $ref: '#/components/schemas/Meter' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + tags: + - Meters + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateMeterRequest' + get: + operationId: list-meters + summary: List meters + description: List meters. + parameters: + - $ref: '#/components/parameters/PagePaginationQuery' + - name: sort + in: query + required: false + description: |- + Sort meters returned in the response. Supported sort attributes are: + + - `key` + - `name` + - `aggregation` + - `created_at` (default) + - `updated_at` + + The `asc` suffix is optional as the default sort order is ascending. The `desc` + suffix is used to specify a descending order. + schema: + $ref: '#/components/schemas/SortQuery' + explode: false + style: form + - name: filter + in: query + required: false + description: |- + Filter meters returned in the response. + + To filter meters by key add the following query param: filter[key]=my-meter-key + schema: + $ref: '#/components/schemas/ListMetersParamsFilter' + style: deepObject + responses: + '200': + description: Page paginated response. + content: + application/json: + schema: + $ref: '#/components/schemas/MeterPagePaginatedResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + tags: + - Meters + /openmeter/meters/{meterId}: + get: + operationId: get-meter + summary: Get meter + description: Get a meter by ID. + parameters: + - name: meterId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + responses: + '200': + description: Meter response. + content: + application/json: + schema: + $ref: '#/components/schemas/Meter' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + tags: + - Meters + put: + operationId: update-meter + summary: Update meter + description: Update a meter. + parameters: + - name: meterId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + responses: + '200': + description: Meter updated response. + content: + application/json: + schema: + $ref: '#/components/schemas/Meter' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + tags: + - Meters + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateMeterRequest' + x-unstable: true + delete: + operationId: delete-meter + summary: Delete meter + description: Delete a meter. + parameters: + - name: meterId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + responses: + '204': + description: Deleted response. + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + tags: + - Meters + /openmeter/meters/{meterId}/query: + post: + operationId: query-meter + parameters: + - name: meterId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + description: |- + Query a meter for usage. + + Set `Accept: application/json` (the default) to get a structured JSON response. + Set `Accept: text/csv` to download the same data as a CSV file suitable for + spreadsheets. The CSV columns, in order, are: + + `from, to, [subject,] [customer_id, customer_key, customer_name,] , value` + + The `subject` column is emitted only when `subject` is in the query's + `group_by_dimensions`. The three `customer_*` columns are emitted together only + when `customer_id` is in the query's `group_by_dimensions`. + summary: Query meter + responses: + '200': + description: The request has succeeded. + content: + application/json: + schema: + $ref: '#/components/schemas/MeterQueryResult' + text/csv: + schema: + type: string + examples: + Grouped by customer and dimensions: + summary: Grouped by customer and dimensions + value: |- + from,to,customer_id,customer_key,customer_name,model,type,value + 2023-01-01T00:00:00Z,2023-01-01T00:01:00Z,01G65Z755AFWAKHE12NY0CQ9FH,acme-inc,Acme Inc.,gpt-4-turbo,input,12 + 2023-01-01T00:01:00Z,2023-01-01T00:02:00Z,01G65Z755AFWAKHE12NY0CQ9FH,acme-inc,Acme Inc.,gpt-4-turbo,input,20 + 2023-01-01T00:02:00Z,2023-01-01T00:03:00Z,01G65Z755B3YZ1KRA9W8V7PS2D,globex,Globex Corp,gpt-4-turbo,output,4 + No group-by dimensions: + summary: No group-by dimensions + value: |- + from,to,value + 2023-01-01T00:00:00Z,2023-01-01T00:01:00Z,12 + 2023-01-01T00:01:00Z,2023-01-01T00:02:00Z,20 + 2023-01-01T00:02:00Z,2023-01-01T00:03:00Z,4 + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + x-unstable: true + tags: + - Meters + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/MeterQueryRequest' + /openmeter/plans: + get: + operationId: list-plans + summary: List plans + description: List all plans. + parameters: + - $ref: '#/components/parameters/PagePaginationQuery' + - name: sort + in: query + required: false + description: |- + Sort plans returned in the response. Supported sort attributes are: + + - `id` + - `key` + - `version` + - `created_at` (default) + - `updated_at` + schema: + $ref: '#/components/schemas/SortQuery' + explode: false + style: form + - name: filter + in: query + required: false + description: Filter plans returned in the response. + schema: + $ref: '#/components/schemas/ListPlansParamsFilter' + style: deepObject + responses: + '200': + description: Page paginated response. + content: + application/json: + schema: + $ref: '#/components/schemas/PlanPagePaginatedResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + tags: + - OpenMeter Product Catalog + x-unstable: true + post: + operationId: create-plan + summary: Create plan + description: Create a new plan. + responses: + '201': + description: Plan created response. + content: + application/json: + schema: + $ref: '#/components/schemas/BillingPlan' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + tags: + - OpenMeter Product Catalog + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreatePlanRequest' + x-unstable: true + /openmeter/plans/{planId}: + put: + operationId: update-plan + summary: Update plan + description: Update a plan by id. + parameters: + - name: planId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + responses: + '200': + description: Plan upsert response. + content: + application/json: + schema: + $ref: '#/components/schemas/BillingPlan' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '410': + $ref: '#/components/responses/Gone' + tags: + - OpenMeter Product Catalog + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpsertPlanRequest' + x-unstable: true + get: + operationId: get-plan + summary: Get plan + description: Get a plan by id. + parameters: + - name: planId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + responses: + '200': + description: Plan response. + content: + application/json: + schema: + $ref: '#/components/schemas/BillingPlan' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '410': + $ref: '#/components/responses/Gone' + tags: + - OpenMeter Product Catalog + x-unstable: true + delete: + operationId: delete-plan + summary: Delete plan + description: Delete a plan by id. + parameters: + - name: planId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + responses: + '204': + description: Deleted response. + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + tags: + - OpenMeter Product Catalog + x-unstable: true + /openmeter/plans/{planId}/addons: + get: + operationId: list-plan-addons + summary: List add-ons for plan + description: List add-ons associated with a plan. + parameters: + - name: planId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + - $ref: '#/components/parameters/PagePaginationQuery' + responses: + '200': + description: Page paginated response. + content: + application/json: + schema: + $ref: '#/components/schemas/PlanAddonPagePaginatedResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + tags: + - OpenMeter Product Catalog + x-unstable: true + post: + operationId: create-plan-addon + summary: Add add-on to plan + description: Add an add-on to a plan. + parameters: + - name: planId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + responses: + '201': + description: PlanAddon created response. + content: + application/json: + schema: + $ref: '#/components/schemas/PlanAddon' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + tags: + - OpenMeter Product Catalog + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreatePlanAddonRequest' + x-unstable: true + /openmeter/plans/{planId}/addons/{planAddonId}: + get: + operationId: get-plan-addon + summary: Get add-on association for plan + description: Get an add-on association for a plan. + parameters: + - name: planId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + - name: planAddonId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + responses: + '200': + description: PlanAddon response. + content: + application/json: + schema: + $ref: '#/components/schemas/PlanAddon' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + tags: + - OpenMeter Product Catalog + x-unstable: true + put: + operationId: update-plan-addon + summary: Update add-on association for plan + description: Update an add-on association for a plan. + parameters: + - name: planId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + - name: planAddonId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + responses: + '200': + description: PlanAddon upsert response. + content: + application/json: + schema: + $ref: '#/components/schemas/PlanAddon' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + tags: + - OpenMeter Product Catalog + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpsertPlanAddonRequest' + x-unstable: true + delete: + operationId: delete-plan-addon + summary: Remove add-on from plan + description: Remove an add-on from a plan. + parameters: + - name: planId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + - name: planAddonId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + responses: + '204': + description: Deleted response. + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + tags: + - OpenMeter Product Catalog + x-unstable: true + /openmeter/plans/{planId}/archive: + post: + operationId: archive-plan + summary: Archive plan version + description: Archive a plan version. + parameters: + - name: planId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + responses: + '200': + description: Plan updated response. + content: + application/json: + schema: + $ref: '#/components/schemas/BillingPlan' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + tags: + - OpenMeter Product Catalog + x-unstable: true + /openmeter/plans/{planId}/publish: + post: + operationId: publish-plan + summary: Publish plan version + description: Publish a plan version. + parameters: + - name: planId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + responses: + '200': + description: Plan updated response. + content: + application/json: + schema: + $ref: '#/components/schemas/BillingPlan' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + tags: + - OpenMeter Product Catalog + x-unstable: true + /openmeter/profiles: + get: + operationId: list-billing-profiles + summary: List billing profiles + description: List billing profiles. + parameters: + - $ref: '#/components/parameters/PagePaginationQuery' + responses: + '200': + description: Page paginated response. + content: + application/json: + schema: + $ref: '#/components/schemas/BillingProfilePagePaginatedResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + tags: + - OpenMeter Billing Settings + post: + operationId: create-billing-profile + summary: Create a new billing profile + description: |- + Create a new billing profile. + + Billing profiles contain the settings for billing and controls invoice + generation. An organization can have multiple billing profiles defined. A + billing profile is linked to a specific app. This association is established + during the billing profile's creation and remains immutable. + responses: + '201': + description: BillingProfile created response. + content: + application/json: + schema: + $ref: '#/components/schemas/BillingProfile' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + tags: + - OpenMeter Billing Settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateBillingProfileRequest' + /openmeter/profiles/{id}: + get: + operationId: get-billing-profile + summary: Get a billing profile + description: Get a billing profile. + parameters: + - name: id + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + responses: + '200': + description: BillingProfile response. + content: + application/json: + schema: + $ref: '#/components/schemas/BillingProfile' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + tags: + - OpenMeter Billing Settings + put: + operationId: update-billing-profile + summary: Update a billing profile + description: Update a billing profile. + parameters: + - name: id + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + responses: + '200': + description: BillingProfile updated response. + content: + application/json: + schema: + $ref: '#/components/schemas/BillingProfile' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + tags: + - OpenMeter Billing Settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpsertBillingProfileRequest' + delete: + operationId: delete-billing-profile + summary: Delete a billing profile + description: |- + Delete a billing profile. + + Only such billing profiles can be deleted that are: + + - not the default profile + - not pinned to any customer using customer overrides + - only have finalized invoices + parameters: + - name: id + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + responses: + '204': + description: Deleted response. + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + tags: + - OpenMeter Billing Settings + /openmeter/subscriptions: + post: + operationId: create-subscription + summary: Create subscription + responses: + '201': + description: Subscription created response. + content: + application/json: + schema: + $ref: '#/components/schemas/BillingSubscription' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + tags: + - OpenMeter Subscriptions + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BillingSubscriptionCreate' + get: + operationId: list-subscriptions + summary: List subscriptions + parameters: + - $ref: '#/components/parameters/PagePaginationQuery' + - name: sort + in: query + required: false + description: |- + Sort subscriptions returned in the response. Supported sort attributes are: + + - `id` + - `active_from` (default) + - `active_to` + + The `asc` suffix is optional as the default sort order is ascending. The `desc` + suffix is used to specify a descending order. + schema: + $ref: '#/components/schemas/SortQuery' + explode: false + style: form + - name: filter + in: query + required: false + description: Filter subscriptions. + schema: + $ref: '#/components/schemas/ListSubscriptionsParamsFilter' + style: deepObject + responses: + '200': + description: Page paginated response. + content: + application/json: + schema: + $ref: '#/components/schemas/SubscriptionPagePaginatedResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + tags: + - OpenMeter Subscriptions + /openmeter/subscriptions/{subscriptionId}: + get: + operationId: get-subscription + summary: Get subscription + parameters: + - name: subscriptionId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + responses: + '200': + description: Subscription response. + content: + application/json: + schema: + $ref: '#/components/schemas/BillingSubscription' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + tags: + - OpenMeter Subscriptions + /openmeter/subscriptions/{subscriptionId}/addons: + post: + operationId: create-subscription-addon + summary: Create a new subscription add-on + description: Add add-on to a subscription. + parameters: + - name: subscriptionId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + responses: + '201': + description: SubscriptionAddon created response. + content: + application/json: + schema: + $ref: '#/components/schemas/SubscriptionAddon' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + tags: + - OpenMeter Subscriptions + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateSubscriptionAddonRequest' + x-internal: true + x-unstable: true + get: + operationId: list-subscription-addons + summary: List subscription addons + description: List the add-ons of a subscription. + parameters: + - name: subscriptionId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + - $ref: '#/components/parameters/PagePaginationQuery' + - name: sort + in: query + required: false + description: |- + Sort subscription addons returned in the response. Supported sort attributes + are: + + - `id` + - `created_at` (default) + - `updated_at` + + The `asc` suffix is optional as the default sort order is ascending. The `desc` + suffix is used to specify a descending order. + schema: + $ref: '#/components/schemas/SortQuery' + explode: false + style: form + responses: + '200': + description: Page paginated response. + content: + application/json: + schema: + $ref: '#/components/schemas/SubscriptionAddonPagePaginatedResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + tags: + - OpenMeter Subscriptions + x-unstable: true + /openmeter/subscriptions/{subscriptionId}/addons/{subscriptionAddonId}: + get: + operationId: get-subscription-addon + summary: Get add-on association for subscription + description: Get an add-on association for a subscription. + parameters: + - name: subscriptionId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + - name: subscriptionAddonId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + responses: + '200': + description: SubscriptionAddon response. + content: + application/json: + schema: + $ref: '#/components/schemas/SubscriptionAddon' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + tags: + - OpenMeter Subscriptions + x-unstable: true + /openmeter/subscriptions/{subscriptionId}/cancel: + post: + operationId: cancel-subscription + summary: Cancel subscription + description: |- + Cancels the subscription. Will result in a scheduling conflict if there are + other subscriptions scheduled to start after the cancelation time. + parameters: + - name: subscriptionId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + responses: + '200': + description: Subscription updated response. + content: + application/json: + schema: + $ref: '#/components/schemas/BillingSubscription' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + tags: + - OpenMeter Subscriptions + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BillingSubscriptionCancel' + /openmeter/subscriptions/{subscriptionId}/change: + post: + operationId: change-subscription + summary: Change subscription + description: |- + Closes a running subscription and starts a new one according to the + specification. Can be used for upgrades, downgrades, and plan changes. + parameters: + - name: subscriptionId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + responses: + '200': + description: The request has succeeded. + content: + application/json: + schema: + $ref: '#/components/schemas/BillingSubscriptionChangeResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + tags: + - OpenMeter Subscriptions + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BillingSubscriptionChange' + /openmeter/subscriptions/{subscriptionId}/unschedule-cancelation: + post: + operationId: unschedule-cancelation + summary: Unschedule subscription cancelation + description: Unschedules the subscription cancelation. + parameters: + - name: subscriptionId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + responses: + '200': + description: Subscription updated response. + content: + application/json: + schema: + $ref: '#/components/schemas/BillingSubscription' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + tags: + - OpenMeter Subscriptions + /openmeter/tax-codes: + post: + operationId: create-tax-code + summary: Create tax code + responses: + '201': + description: TaxCode created response. + content: + application/json: + schema: + $ref: '#/components/schemas/BillingTaxCode' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + tags: + - OpenMeter Tax + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateTaxCodeRequest' + x-unstable: true + get: + operationId: list-tax-codes + summary: List tax codes + parameters: + - $ref: '#/components/parameters/PagePaginationQuery' + - name: include_deleted + in: query + required: false + description: Include deleted tax codes in the response. + schema: + type: boolean + explode: false + style: form + responses: + '200': + description: Page paginated response. + content: + application/json: + schema: + $ref: '#/components/schemas/TaxCodePagePaginatedResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + tags: + - OpenMeter Tax + x-unstable: true + /openmeter/tax-codes/{taxCodeId}: + get: + operationId: get-tax-code + summary: Get tax code + parameters: + - name: taxCodeId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + responses: + '200': + description: TaxCode response. + content: + application/json: + schema: + $ref: '#/components/schemas/BillingTaxCode' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + tags: + - OpenMeter Tax + x-unstable: true + put: + operationId: upsert-tax-code + summary: Upsert tax code + parameters: + - name: taxCodeId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + responses: + '200': + description: TaxCode upsert response. + content: + application/json: + schema: + $ref: '#/components/schemas/BillingTaxCode' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '410': + $ref: '#/components/responses/Gone' + tags: + - OpenMeter Tax + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpsertTaxCodeRequest' + x-unstable: true + delete: + operationId: delete-tax-code + summary: Delete tax code + parameters: + - name: taxCodeId + in: path + required: true + schema: + $ref: '#/components/schemas/ULID' + responses: + '204': + description: Deleted response. + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + tags: + - OpenMeter Tax + x-unstable: true +components: + parameters: + CursorPaginationQuery: + name: page + in: query + required: false + schema: + $ref: '#/components/schemas/CursorPaginationQueryPage' + style: deepObject + PagePaginationQuery: + name: page + in: query + required: false + description: Determines which page of the collection to retrieve. + schema: + type: object + properties: + size: + type: integer + description: The number of items to include per page. + number: + type: integer + description: The page number. + additionalProperties: false + style: deepObject + schemas: + Addon: + type: object + required: + - id + - name + - created_at + - updated_at + - key + - version + - instance_type + - currency + - status + - rate_cards + properties: + id: + allOf: + - $ref: '#/components/schemas/ULID' + readOnly: true + name: + type: string + minLength: 1 + maxLength: 256 + description: |- + Display name of the resource. + + Between 1 and 256 characters. + description: + type: string + maxLength: 1024 + description: |- + Optional description of the resource. + + Maximum 1024 characters. + labels: + $ref: '#/components/schemas/Labels' + created_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: An ISO-8601 timestamp representation of entity creation date. + readOnly: true + updated_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: An ISO-8601 timestamp representation of entity last update date. + readOnly: true + deleted_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: An ISO-8601 timestamp representation of entity deletion date. + readOnly: true + key: + allOf: + - $ref: '#/components/schemas/ResourceKey' + description: |- + A key is a semi-unique string that is used to identify the add-on. It is used to + reference the latest `active` version of the add-on and is unique with the + version number. + title: Key + version: + type: integer + minimum: 1 + description: Version of the add-on. Incremented when the add-on is updated. + title: Version + default: 1 + readOnly: true + instance_type: + allOf: + - $ref: '#/components/schemas/AddonInstanceType' + description: The InstanceType of the add-ons. Can be "single" or "multiple". + title: The InstanceType of the add-ons. Can be "single" or "multiple". + currency: + allOf: + - $ref: '#/components/schemas/BillingCurrencyCode' + description: The currency code of the add-on. + title: Currency + effective_from: + allOf: + - $ref: '#/components/schemas/DateTime' + description: |- + The date and time when the add-on becomes effective. When not specified, the + add-on is a draft. + title: Effective start date + readOnly: true + effective_to: + allOf: + - $ref: '#/components/schemas/DateTime' + description: |- + The date and time when the add-on is no longer effective. When not specified, + the add-on is effective indefinitely. + title: Effective end date + readOnly: true + status: + allOf: + - $ref: '#/components/schemas/AddonStatus' + description: |- + The status of the add-on. Computed based on the effective start and end dates: + + - `draft`: `effective_from` is not set. + - `active`: `effective_from <= now` and (`effective_to` is not set or + `now < effective_to`). + - `archived`: `effective_to <= now`. + title: Status + readOnly: true + rate_cards: + type: array + items: + $ref: '#/components/schemas/BillingRateCard' + description: The rate cards of the add-on. + title: Rate cards + validation_errors: + type: array + items: + $ref: '#/components/schemas/ProductCatalogValidationError' + description: List of validation errors. + title: Validation errors + readOnly: true + additionalProperties: false + description: |- + Add-on allows extending subscriptions with compatible plans with additional + ratecards. + AddonInstanceType: + type: string + enum: + - single + - multiple + description: |- + The instanceType of the add-on. + + - `single`: Can be added to a subscription only once. + - `multiple`: Can be added to a subscription more than once. + AddonPagePaginatedResponse: + type: object + required: + - data + - meta + properties: + data: + type: array + items: + $ref: '#/components/schemas/Addon' + meta: + $ref: '#/components/schemas/PaginatedMeta' + additionalProperties: false + description: Page paginated response. + AddonReference: + type: object + required: + - id + properties: + id: + $ref: '#/components/schemas/ULID' + additionalProperties: false + description: Addon reference. + AddonStatus: + type: string + enum: + - draft + - active + - archived + description: |- + The status of the add-on defined by the `effective_from` and `effective_to` + properties. + + - `draft`: The add-on has not yet been published and can be edited. + - `active`: The add-on is published and available for use. + - `archived`: The add-on is no longer available for use. + Address: + type: object + properties: + country: + allOf: + - $ref: '#/components/schemas/CountryCode' + description: |- + Country code in [ISO 3166-1](https://www.iso.org/iso-3166-country-codes.html) + alpha-2 format. + title: Country + postal_code: + type: string + description: Postal code. + title: Postal Code + state: + type: string + description: State or province. + title: State + city: + type: string + description: City. + title: City + line1: + type: string + description: First line of the address. + title: Line 1 + line2: + type: string + description: Second line of the address. + title: Line 2 + phone_number: + type: string + description: Phone number. + title: Phone Number + additionalProperties: false + description: Address + AppCatalogItemPagePaginatedResponse: + type: object + required: + - data + - meta + properties: + data: + type: array + items: + $ref: '#/components/schemas/BillingAppCatalogItem' + meta: + $ref: '#/components/schemas/PaginatedMeta' + additionalProperties: false + description: Page paginated response. + AppPagePaginatedResponse: + type: object + required: + - data + - meta + properties: + data: + type: array + items: + $ref: '#/components/schemas/BillingApp' + meta: + $ref: '#/components/schemas/PaginatedMeta' + additionalProperties: false + description: Page paginated response. + BillingApp: + type: object + oneOf: + - $ref: '#/components/schemas/BillingAppStripe' + - $ref: '#/components/schemas/BillingAppSandbox' + - $ref: '#/components/schemas/BillingAppExternalInvoicing' + discriminator: + propertyName: type + mapping: + stripe: '#/components/schemas/BillingAppStripe' + sandbox: '#/components/schemas/BillingAppSandbox' + external_invoicing: '#/components/schemas/BillingAppExternalInvoicing' + description: Installed application. + BillingAppCapability: + type: object + required: + - type + - key + - name + - description + properties: + type: + allOf: + - $ref: '#/components/schemas/BillingAppCapabilityType' + description: Type of the capability. + key: + allOf: + - $ref: '#/components/schemas/ResourceKey' + description: Key of the capability. + name: + type: string + description: Name of the capability. + description: + type: string + description: Description of the capability. + additionalProperties: false + description: App capability describes a function that an App can perform. + example: + type: collect_payments + key: stripe_collect_payment + name: Collect Payments + description: Stripe payments collects outstanding revenue with Stripe customer's default payment method. + BillingAppCapabilityType: + type: string + enum: + - report_usage + - report_events + - calculate_tax + - invoice_customers + - collect_payments + description: |- + Supported capability types for an App. + + Each capability defines an integration function that an App can perform. + BillingAppCatalogItem: + type: object + required: + - type + - name + - description + - capabilities + - install_methods + properties: + type: + allOf: + - $ref: '#/components/schemas/BillingAppType' + description: Type of the app. + readOnly: true + name: + type: string + description: Name of the app. + readOnly: true + description: + type: string + description: Description of the app. + readOnly: true + capabilities: + type: array + items: + $ref: '#/components/schemas/BillingAppCapability' + description: Capabilities of the app. + readOnly: true + install_methods: + type: array + items: + $ref: '#/components/schemas/BillingAppInstallMethods' + description: Available install methods of the app. + readOnly: true + additionalProperties: false + description: |- + Available apps for billing integrations to connect with third-party services. + Apps can have various capabilities like syncing data from or to external + systems, integrating with third-party services for tax calculation, delivery of + invoices, collection of payments, etc. + example: + type: stripe + name: Stripe + description: Stripe integration allows you to collect payments with Stripe. + capabilities: + - type: calculate_tax + key: stripe_calculate_tax + name: Calculate Tax + description: Stripe Tax calculates tax portion of the invoices. + - type: invoice_customers + key: stripe_invoice_customers + name: Invoice Customers + description: Stripe invoices customers with due amount. + - type: collect_payments + key: stripe_collect_payments + name: Collect Payments + description: Stripe payments collects outstanding revenue with Stripe customer's default payment method. + install_methods: + - with_oauth2 + - with_api_key + BillingAppCustomerData: + type: object + properties: + stripe: + allOf: + - $ref: '#/components/schemas/BillingAppCustomerDataStripe' + description: Used if the customer has a linked Stripe app. + title: Stripe + external_invoicing: + allOf: + - $ref: '#/components/schemas/BillingAppCustomerDataExternalInvoicing' + description: Used if the customer has a linked external invoicing app. + title: External invoicing + additionalProperties: false + description: App customer data. + BillingAppCustomerDataExternalInvoicing: + type: object + properties: + labels: + allOf: + - $ref: '#/components/schemas/Labels' + description: Labels for this external invoicing integration on the customer. + title: Labels + additionalProperties: false + description: External invoicing customer data. + BillingAppCustomerDataStripe: + type: object + properties: + customer_id: + type: string + description: The Stripe customer ID used. + title: Stripe customer ID + example: cus_1234567890 + default_payment_method_id: + type: string + description: The Stripe default payment method ID. + title: Stripe default payment method ID + example: pm_1234567890 + labels: + allOf: + - $ref: '#/components/schemas/Labels' + description: Labels for this Stripe integration on the customer. + title: Labels + additionalProperties: false + description: Stripe customer data. + BillingAppExternalInvoicing: + type: object + required: + - id + - name + - created_at + - updated_at + - type + - definition + - status + - enable_draft_sync_hook + - enable_issuing_sync_hook + properties: + id: + allOf: + - $ref: '#/components/schemas/ULID' + readOnly: true + name: + type: string + minLength: 1 + maxLength: 256 + description: |- + Display name of the resource. + + Between 1 and 256 characters. + description: + type: string + maxLength: 1024 + description: |- + Optional description of the resource. + + Maximum 1024 characters. + labels: + $ref: '#/components/schemas/Labels' + created_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: An ISO-8601 timestamp representation of entity creation date. + readOnly: true + updated_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: An ISO-8601 timestamp representation of entity last update date. + readOnly: true + deleted_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: An ISO-8601 timestamp representation of entity deletion date. + readOnly: true + type: + type: string + enum: + - external_invoicing + description: The app type. + readOnly: true + definition: + allOf: + - $ref: '#/components/schemas/BillingAppCatalogItem' + description: The app catalog definition that this installed app is based on. + readOnly: true + status: + allOf: + - $ref: '#/components/schemas/BillingAppStatus' + description: Status of the app connection. + readOnly: true + enable_draft_sync_hook: + type: boolean + description: |- + Enable draft synchronization hook. + + When enabled, invoices will pause at the draft state and wait for the + integration to call the draft synchronized endpoint before progressing to the + issuing state. This allows the external system to validate and prepare the + invoice data. + + When disabled, invoices automatically progress through the draft state based on + the configured workflow timing. + enable_issuing_sync_hook: + type: boolean + description: |- + Enable issuing synchronization hook. + + When enabled, invoices will pause at the issuing state and wait for the + integration to call the issuing synchronized endpoint before progressing to the + issued state. This ensures the external invoicing system has successfully + created and finalized the invoice before it is marked as issued. + + When disabled, invoices automatically progress through the issuing state and are + immediately marked as issued. + additionalProperties: false + description: |- + External Invoicing app enables integration with third-party invoicing or payment + system. + + The app supports a bi-directional synchronization pattern where OpenMeter + Billing manages the invoice lifecycle while the external system handles invoice + presentation and payment collection. + + Integration workflow: + + 1. The billing system creates invoices and transitions them through lifecycle + states (draft → issuing → issued) + 2. The integration receives webhook notifications about invoice state changes + 3. The integration calls back to provide external system IDs and metadata + 4. The integration reports payment events back via the payment status API + + State synchronization is controlled by hooks that pause invoice progression + until the external system confirms synchronization via API callbacks. + BillingAppInstallMethods: + type: string + enum: + - with_oauth2 + - with_api_key + - no_credentials_required + description: Supported installation methods for an app. + BillingAppReference: + type: object + required: + - id + properties: + id: + allOf: + - $ref: '#/components/schemas/ULID' + description: The ID of the app. + additionalProperties: false + description: App reference. + BillingAppSandbox: + type: object + required: + - id + - name + - created_at + - updated_at + - type + - definition + - status + properties: + id: + allOf: + - $ref: '#/components/schemas/ULID' + readOnly: true + name: + type: string + minLength: 1 + maxLength: 256 + description: |- + Display name of the resource. + + Between 1 and 256 characters. + description: + type: string + maxLength: 1024 + description: |- + Optional description of the resource. + + Maximum 1024 characters. + labels: + $ref: '#/components/schemas/Labels' + created_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: An ISO-8601 timestamp representation of entity creation date. + readOnly: true + updated_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: An ISO-8601 timestamp representation of entity last update date. + readOnly: true + deleted_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: An ISO-8601 timestamp representation of entity deletion date. + readOnly: true + type: + type: string + enum: + - sandbox + description: The app type. + readOnly: true + definition: + allOf: + - $ref: '#/components/schemas/BillingAppCatalogItem' + description: The app catalog definition that this installed app is based on. + readOnly: true + status: + allOf: + - $ref: '#/components/schemas/BillingAppStatus' + description: Status of the app connection. + readOnly: true + additionalProperties: false + description: Sandbox app can be used for testing billing features. + BillingAppStatus: + type: string + enum: + - ready + - unauthorized + description: Connection status of an installed app. + BillingAppStripe: + type: object + required: + - id + - name + - created_at + - updated_at + - type + - definition + - status + - account_id + - livemode + - masked_api_key + properties: + id: + allOf: + - $ref: '#/components/schemas/ULID' + readOnly: true + name: + type: string + minLength: 1 + maxLength: 256 + description: |- + Display name of the resource. + + Between 1 and 256 characters. + description: + type: string + maxLength: 1024 + description: |- + Optional description of the resource. + + Maximum 1024 characters. + labels: + $ref: '#/components/schemas/Labels' + created_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: An ISO-8601 timestamp representation of entity creation date. + readOnly: true + updated_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: An ISO-8601 timestamp representation of entity last update date. + readOnly: true + deleted_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: An ISO-8601 timestamp representation of entity deletion date. + readOnly: true + type: + type: string + enum: + - stripe + description: The app type. + readOnly: true + definition: + allOf: + - $ref: '#/components/schemas/BillingAppCatalogItem' + description: The app catalog definition that this installed app is based on. + readOnly: true + status: + allOf: + - $ref: '#/components/schemas/BillingAppStatus' + description: Status of the app connection. + readOnly: true + account_id: + type: string + description: The Stripe account ID associated with the connected Stripe account. + readOnly: true + livemode: + type: boolean + description: Indicates whether the app is connected to a live Stripe account. + readOnly: true + masked_api_key: + type: string + description: The masked Stripe API key that only exposes the first and last few characters. + readOnly: true + additionalProperties: false + description: Stripe app. + BillingAppStripeCheckoutSessionCustomTextParams: + type: object + properties: + after_submit: + type: object + properties: + message: + type: string + maxLength: 1200 + description: The custom message text (max 1200 characters). + additionalProperties: false + description: Text displayed after the payment confirmation button. + shipping_address: + type: object + properties: + message: + type: string + maxLength: 1200 + description: The custom message text (max 1200 characters). + additionalProperties: false + description: Text displayed alongside shipping address collection. + submit: + type: object + properties: + message: + type: string + maxLength: 1200 + description: The custom message text (max 1200 characters). + additionalProperties: false + description: Text displayed alongside the payment confirmation button. + terms_of_service_acceptance: + type: object + properties: + message: + type: string + maxLength: 1200 + description: The custom message text (max 1200 characters). + additionalProperties: false + description: Text replacing the default terms of service agreement text. + additionalProperties: false + description: Custom text displayed at various stages of the checkout flow. + BillingAppStripeCheckoutSessionMode: + type: string + enum: + - setup + description: |- + Stripe Checkout Session mode. + + Determines the primary purpose of the checkout session. + BillingAppStripeCheckoutSessionUIMode: + type: string + enum: + - embedded + - hosted + description: Checkout Session UI mode. + BillingAppStripeCreateCheckoutSessionBillingAddressCollection: + type: string + enum: + - auto + - required + description: Controls whether Checkout collects the customer's billing address. + BillingAppStripeCreateCheckoutSessionConsentCollection: + type: object + properties: + payment_method_reuse_agreement: + allOf: + - $ref: '#/components/schemas/BillingAppStripeCreateCheckoutSessionConsentCollectionPaymentMethodReuseAgreement' + description: Controls the visibility of payment method reuse agreement. + promotions: + allOf: + - $ref: '#/components/schemas/BillingAppStripeCreateCheckoutSessionConsentCollectionPromotions' + description: |- + Enables collection of promotional communication consent. + + Only available to US merchants. When set to "auto", Checkout determines whether + to show the option based on the customer's locale. + terms_of_service: + allOf: + - $ref: '#/components/schemas/BillingAppStripeCreateCheckoutSessionConsentCollectionTermsOfService' + description: |- + Requires customers to accept terms of service before payment. + + Requires a valid terms of service URL in your Stripe Dashboard settings. + additionalProperties: false + description: Checkout Session consent collection configuration. + BillingAppStripeCreateCheckoutSessionConsentCollectionPaymentMethodReuseAgreement: + type: object + properties: + position: + allOf: + - $ref: '#/components/schemas/BillingAppStripeCreateCheckoutSessionConsentCollectionPaymentMethodReuseAgreementPosition' + description: Position and visibility of the payment method reuse agreement. + additionalProperties: false + description: Payment method reuse agreement configuration. + BillingAppStripeCreateCheckoutSessionConsentCollectionPaymentMethodReuseAgreementPosition: + type: string + enum: + - auto + - hidden + description: Position of payment method reuse agreement in the UI. + BillingAppStripeCreateCheckoutSessionConsentCollectionPromotions: + type: string + enum: + - auto + - none + description: Promotional communication consent collection setting. + BillingAppStripeCreateCheckoutSessionConsentCollectionTermsOfService: + type: string + enum: + - none + - required + description: Terms of service acceptance requirement. + BillingAppStripeCreateCheckoutSessionCustomerUpdate: + type: object + properties: + address: + allOf: + - $ref: '#/components/schemas/BillingAppStripeCreateCheckoutSessionCustomerUpdateBehavior' + description: |- + Whether to save the billing address to customer.address. + + Defaults to "never". + default: never + name: + allOf: + - $ref: '#/components/schemas/BillingAppStripeCreateCheckoutSessionCustomerUpdateBehavior' + description: |- + Whether to save the customer name to customer.name. + + Defaults to "never". + default: never + shipping: + allOf: + - $ref: '#/components/schemas/BillingAppStripeCreateCheckoutSessionCustomerUpdateBehavior' + description: |- + Whether to save shipping information to customer.shipping. + + Defaults to "never". + default: never + additionalProperties: false + description: Controls which customer fields can be updated by the checkout session. + BillingAppStripeCreateCheckoutSessionCustomerUpdateBehavior: + type: string + enum: + - auto + - never + description: Behavior for updating customer fields from checkout session. + BillingAppStripeCreateCheckoutSessionRedirectOnCompletion: + type: string + enum: + - always + - if_required + - never + description: Redirect behavior for embedded checkout sessions. + BillingAppStripeCreateCheckoutSessionRequestOptions: + type: object + properties: + billing_address_collection: + allOf: + - $ref: '#/components/schemas/BillingAppStripeCreateCheckoutSessionBillingAddressCollection' + description: |- + Whether to collect the customer's billing address. + + Defaults to auto, which only collects the address when necessary for tax + calculation. + default: auto + cancel_url: + type: string + description: |- + URL to redirect customers who cancel the checkout session. + + Not allowed when ui_mode is "embedded". + client_reference_id: + type: string + description: |- + Unique reference string for reconciling sessions with internal systems. + + Can be a customer ID, cart ID, or any other identifier. + customer_update: + allOf: + - $ref: '#/components/schemas/BillingAppStripeCreateCheckoutSessionCustomerUpdate' + description: Controls which customer fields can be updated by the checkout session. + consent_collection: + allOf: + - $ref: '#/components/schemas/BillingAppStripeCreateCheckoutSessionConsentCollection' + description: Configuration for collecting customer consent during checkout. + currency: + allOf: + - $ref: '#/components/schemas/CurrencyCode' + description: |- + Three-letter ISO 4217 currency code in uppercase. + + Required for payment mode sessions. Optional for setup mode sessions. + custom_text: + allOf: + - $ref: '#/components/schemas/BillingAppStripeCheckoutSessionCustomTextParams' + description: Custom text to display during checkout at various stages. + expires_at: + type: integer + format: int64 + description: |- + Unix timestamp when the checkout session expires. + + Can be 30 minutes to 24 hours from creation. Defaults to 24 hours. + locale: + type: string + description: |- + IETF language tag for the checkout UI locale. + + If blank or "auto", uses the browser's locale. Example: "en", "fr", "de". + metadata: + type: object + additionalProperties: + type: string + description: |- + Set of key-value pairs to attach to the checkout session. + + Useful for storing additional structured information. + return_url: + type: string + description: |- + Return URL for embedded checkout sessions after payment authentication. + + Required if ui_mode is "embedded" and redirect-based payment methods are + enabled. + success_url: + type: string + description: |- + Success URL to redirect customers after completing payment or setup. + + Not allowed when ui_mode is "embedded". See: + https://docs.stripe.com/payments/checkout/custom-success-page + ui_mode: + allOf: + - $ref: '#/components/schemas/BillingAppStripeCheckoutSessionUIMode' + description: |- + The UI mode for the checkout session. + + "hosted" displays a Stripe-hosted page. "embedded" integrates directly into your + app. Defaults to "hosted". + default: hosted + payment_method_types: + type: array + items: + type: string + description: |- + List of payment method types to enable (e.g., "card", "us_bank_account"). + + If not specified, Stripe enables all relevant payment methods. + redirect_on_completion: + allOf: + - $ref: '#/components/schemas/BillingAppStripeCreateCheckoutSessionRedirectOnCompletion' + description: |- + Redirect behavior for embedded checkout sessions. + + Controls when to redirect users after completion. See: + https://docs.stripe.com/payments/checkout/custom-success-page?payment-ui=embedded-form + tax_id_collection: + allOf: + - $ref: '#/components/schemas/BillingAppStripeCreateCheckoutSessionTaxIdCollection' + description: Configuration for collecting tax IDs during checkout. + additionalProperties: false + description: |- + Configuration options for creating a Stripe Checkout Session. + + Based on Stripe's + [Checkout Session API parameters](https://docs.stripe.com/api/checkout/sessions/create). + BillingAppStripeCreateCheckoutSessionResult: + type: object + required: + - customer_id + - stripe_customer_id + - session_id + - setup_intent_id + - created_at + - mode + properties: + customer_id: + allOf: + - $ref: '#/components/schemas/ULID' + description: The customer ID in the billing system. + stripe_customer_id: + type: string + description: The Stripe customer ID. + session_id: + type: string + description: The Stripe checkout session ID. + setup_intent_id: + type: string + description: The setup intent ID created for collecting the payment method. + client_secret: + type: string + description: |- + Client secret for initializing Stripe.js on the client side. + + Required for embedded checkout sessions. See: + https://docs.stripe.com/payments/checkout/custom-success-page + client_reference_id: + type: string + description: |- + The client reference ID provided in the request. + + Useful for reconciling the session with your internal systems. + customer_email: + type: string + description: Customer's email address if provided to Stripe. + currency: + allOf: + - $ref: '#/components/schemas/CurrencyCode' + description: Currency code for the checkout session. + created_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: Timestamp when the checkout session was created. + expires_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: Timestamp when the checkout session will expire. + metadata: + type: object + additionalProperties: + type: string + description: Metadata attached to the checkout session. + status: + type: string + description: |- + The status of the checkout session. + + See: + https://docs.stripe.com/api/checkout/sessions/object#checkout_session_object-status + url: + type: string + description: URL to redirect customers to the checkout page (for hosted mode). + mode: + allOf: + - $ref: '#/components/schemas/BillingAppStripeCheckoutSessionMode' + description: |- + Mode of the checkout session. + + Currently only "setup" mode is supported for collecting payment methods. + cancel_url: + type: string + description: The cancel URL where customers are redirected if they cancel. + success_url: + type: string + description: The success URL where customers are redirected after completion. + return_url: + type: string + description: The return URL for embedded sessions after authentication. + additionalProperties: false + description: |- + Result of creating a Stripe Checkout Session. + + Contains all the information needed to redirect customers to the checkout or + initialize an embedded checkout flow. + BillingAppStripeCreateCheckoutSessionTaxIdCollection: + type: object + properties: + enabled: + type: boolean + description: |- + Enable tax ID collection during checkout. + + Defaults to false. + default: false + required: + allOf: + - $ref: '#/components/schemas/BillingAppStripeCreateCheckoutSessionTaxIdCollectionRequired' + description: |- + Whether tax ID collection is required. + + Defaults to "never". + default: never + additionalProperties: false + description: Tax ID collection configuration for checkout sessions. + BillingAppStripeCreateCheckoutSessionTaxIdCollectionRequired: + type: string + enum: + - if_supported + - never + description: Tax ID collection requirement level. + BillingAppStripeCreateCustomerPortalSessionOptions: + type: object + properties: + configuration_id: + type: string + description: |- + The ID of an existing + [Stripe configuration](https://docs.stripe.com/api/customer_portal/configurations) + to use for this session, describing its functionality and features. If not + specified, the session uses the default configuration. + locale: + type: string + description: |- + The IETF + [language tag](https://docs.stripe.com/api/customer_portal/sessions/create#create_portal_session-locale) + of the locale customer portal is displayed in. If blank or `auto`, the + customer's preferred_locales or browser's locale is used. + return_url: + type: string + description: |- + The + [URL to redirect](https://docs.stripe.com/api/customer_portal/sessions/create#create_portal_session-return_url) + the customer to after they have completed their requested actions. + additionalProperties: false + description: Request to create a Stripe Customer Portal Session. + BillingAppStripeCreateCustomerPortalSessionResult: + type: object + required: + - id + - stripe_customer_id + - configuration_id + - livemode + - created_at + - return_url + - locale + - url + properties: + id: + type: string + description: |- + The ID of the customer portal session. + + See: + https://docs.stripe.com/api/customer_portal/sessions/object#portal_session_object-id + stripe_customer_id: + type: string + description: The ID of the stripe customer. + configuration_id: + type: string + description: |- + Configuration used to customize the customer portal. + + See: + https://docs.stripe.com/api/customer_portal/sessions/object#portal_session_object-configuration + livemode: + type: boolean + description: |- + Livemode. + + See: + https://docs.stripe.com/api/customer_portal/sessions/object#portal_session_object-livemode + created_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: |- + Created at. + + See: + https://docs.stripe.com/api/customer_portal/sessions/object#portal_session_object-created + return_url: + type: string + description: |- + Return URL. + + See: + https://docs.stripe.com/api/customer_portal/sessions/object#portal_session_object-return_url + locale: + type: string + description: |- + The IETF language tag of the locale customer portal is displayed in. + + See: + https://docs.stripe.com/api/customer_portal/sessions/object#portal_session_object-locale + url: + type: string + description: |- + The URL to redirect the customer to after they have completed their requested + actions. + additionalProperties: false + description: |- + Result of creating a + [Stripe Customer Portal Session](https://docs.stripe.com/api/customer_portal/sessions/object). + + Contains all the information needed to redirect the customer to the Stripe + Customer Portal. + BillingAppType: + type: string + enum: + - sandbox + - stripe + - external_invoicing + description: The type of the app. + BillingCharge: + type: object + oneOf: + - $ref: '#/components/schemas/BillingChargeFlatFee' + - $ref: '#/components/schemas/BillingChargeUsageBased' + discriminator: + propertyName: type + mapping: + flat_fee: '#/components/schemas/BillingChargeFlatFee' + usage_based: '#/components/schemas/BillingChargeUsageBased' + description: Customer charge. + title: Customer charge + BillingChargeFlatFee: + type: object + required: + - id + - name + - created_at + - updated_at + - type + - customer + - lifecycle_controller + - currency + - status + - invoice_at + - service_period + - full_service_period + - billing_period + - settlement_mode + - payment_term + - proration_configuration + - amount_after_proration + - price + properties: + id: + allOf: + - $ref: '#/components/schemas/ULID' + readOnly: true + name: + type: string + minLength: 1 + maxLength: 256 + description: |- + Display name of the resource. + + Between 1 and 256 characters. + description: + type: string + maxLength: 1024 + description: |- + Optional description of the resource. + + Maximum 1024 characters. + labels: + $ref: '#/components/schemas/Labels' + created_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: An ISO-8601 timestamp representation of entity creation date. + readOnly: true + updated_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: An ISO-8601 timestamp representation of entity last update date. + readOnly: true + deleted_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: An ISO-8601 timestamp representation of entity deletion date. + readOnly: true + type: + type: string + enum: + - flat_fee + description: The type of the charge. + title: Type + customer: + allOf: + - $ref: '#/components/schemas/BillingCustomerReference' + description: The customer owning the charge. + title: Customer + readOnly: true + lifecycle_controller: + allOf: + - $ref: '#/components/schemas/BillingLifecycleController' + description: |- + Indicates whether the charge lifecycle is controlled by OpenMeter or manually + overridden by the API user. + title: Lifecycle controller + readOnly: true + subscription: + allOf: + - $ref: '#/components/schemas/BillingSubscriptionReference' + description: |- + The subscription that originated the charge, when the charge was created from a + subscription item. + title: Subscription + readOnly: true + currency: + allOf: + - $ref: '#/components/schemas/CurrencyCode' + description: The currency of the charge. + title: Currency + status: + allOf: + - $ref: '#/components/schemas/BillingChargeStatus' + description: The lifecycle status of the charge. + title: Status + readOnly: true + invoice_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: The timestamp when the charge is intended to be invoiced. + title: Invoice at + service_period: + allOf: + - $ref: '#/components/schemas/ClosedPeriod' + description: The effective service period covered by the charge. + title: Service period + full_service_period: + allOf: + - $ref: '#/components/schemas/ClosedPeriod' + description: The full, unprorated service period of the charge. + title: Full service period + readOnly: true + billing_period: + allOf: + - $ref: '#/components/schemas/ClosedPeriod' + description: The billing period the charge belongs to. + title: Billing period + readOnly: true + advance_after: + allOf: + - $ref: '#/components/schemas/DateTime' + description: |- + The earliest time when the charge should be advanced again by background + processing. + title: Advance after + readOnly: true + unique_reference_id: + type: string + description: Unique reference ID of the charge. + title: Unique reference ID + settlement_mode: + allOf: + - $ref: '#/components/schemas/BillingSettlementMode' + description: Settlement mode of the charge. + title: Settlement mode + tax_config: + allOf: + - $ref: '#/components/schemas/BillingTaxConfig' + description: Tax configuration of the charge. + title: Tax configuration + payment_term: + allOf: + - $ref: '#/components/schemas/BillingPricePaymentTerm' + description: Payment term of the flat fee charge. + title: Payment term + discounts: + allOf: + - $ref: '#/components/schemas/BillingChargeFlatFeeDiscounts' + description: The discounts applied to the charge. + title: Discounts + feature_key: + type: string + description: The feature associated with the charge, when applicable. + title: Feature key + proration_configuration: + allOf: + - $ref: '#/components/schemas/BillingRateCardProrationConfiguration' + description: The proration configuration of the charge. + title: Proration configuration + amount_after_proration: + allOf: + - $ref: '#/components/schemas/CurrencyAmount' + description: The amount after proration of the charge. + title: Amount after proration + readOnly: true + price: + allOf: + - $ref: '#/components/schemas/BillingPrice' + description: The price of the charge. + title: Price + readOnly: true + system_intent: + allOf: + - $ref: '#/components/schemas/BillingChargeFlatFeeSystemIntent' + description: |- + Current intent from the system lifecycle controller for a charge that has an + active manual override. The top-level charge fields remain the effective + customer-facing intent. + title: System intent + readOnly: true + additionalProperties: false + description: A flat fee charge for a customer. + title: Flat fee charge + BillingChargeFlatFeeDiscounts: + type: object + properties: + percentage: + type: number + minimum: 0 + maximum: 100 + description: Percentage discount applied to the price (0–100). + additionalProperties: false + description: |- + Discounts applicable to flat fee charges. + + This is the same as `ProductCatalog.Discounts` but without the `usage` field, + which is not applicable to flat fee charges. + title: Flat fee charge discounts + BillingChargeFlatFeeSystemIntent: + type: object + required: + - name + - invoice_at + - service_period + - full_service_period + - billing_period + - payment_term + - proration_configuration + - amount_before_proration + properties: + name: + type: string + minLength: 1 + maxLength: 256 + description: |- + Display name of the resource. + + Between 1 and 256 characters. + description: + type: string + maxLength: 1024 + description: |- + Optional description of the resource. + + Maximum 1024 characters. + labels: + $ref: '#/components/schemas/Labels' + invoice_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: The timestamp when the charge is intended to be invoiced. + title: Invoice at + service_period: + allOf: + - $ref: '#/components/schemas/ClosedPeriod' + description: The effective service period covered by the charge. + title: Service period + full_service_period: + allOf: + - $ref: '#/components/schemas/ClosedPeriod' + description: The full, unprorated service period of the charge. + title: Full service period + readOnly: true + billing_period: + allOf: + - $ref: '#/components/schemas/ClosedPeriod' + description: The billing period the charge belongs to. + title: Billing period + readOnly: true + payment_term: + allOf: + - $ref: '#/components/schemas/BillingPricePaymentTerm' + description: Payment term of the flat fee charge. + title: Payment term + discounts: + allOf: + - $ref: '#/components/schemas/BillingChargeFlatFeeDiscounts' + description: The discounts applied to the charge. + title: Discounts + proration_configuration: + allOf: + - $ref: '#/components/schemas/BillingRateCardProrationConfiguration' + description: The proration configuration of the charge. + title: Proration configuration + amount_before_proration: + allOf: + - $ref: '#/components/schemas/CurrencyAmount' + description: The amount before proration of the system lifecycle controller flat fee intent. + title: Amount before proration + readOnly: true + deleted_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: |- + The timestamp when the system lifecycle controller intent was deleted. The + effective charge can remain visible while a manual override is active. + title: Deleted at + readOnly: true + additionalProperties: false + description: |- + Flat fee intent fields from the system lifecycle controller shadowed by a manual + override. + title: Flat fee system lifecycle controller charge intent + BillingChargeReference: + type: object + required: + - id + properties: + id: + allOf: + - $ref: '#/components/schemas/ULID' + description: Unique identifier for the charge. + title: ID + readOnly: true + additionalProperties: false + description: Reference to a charge associated with an invoice line. + BillingChargeStatus: + type: string + enum: + - created + - active + - final + - deleted + description: |- + Lifecycle status of a charge. + + Values: + + - `created`: The charge has been created but is not active yet. + - `active`: The charge is active. + - `final`: The charge is fully finalized and no further changes are expected. + - `deleted`: The charge has been deleted. + title: Charge status + BillingChargeTotals: + type: object + required: + - booked + properties: + booked: + allOf: + - $ref: '#/components/schemas/BillingTotals' + description: The amount of the charge already booked to the internal accounting system. + title: Booked + readOnly: true + realtime: + allOf: + - $ref: '#/components/schemas/BillingTotals' + description: |- + The realtime amount of the charge. + + Requires the `realtime_usage` expand. + title: Realtime totals + readOnly: true + additionalProperties: false + description: |- + The totals of a change. + + RealTime is only expanded when the `real_time_usage` expand is used. + BillingChargeUsageBased: + type: object + required: + - id + - name + - created_at + - updated_at + - type + - customer + - lifecycle_controller + - currency + - status + - invoice_at + - service_period + - full_service_period + - billing_period + - settlement_mode + - feature_key + - totals + - price + properties: + id: + allOf: + - $ref: '#/components/schemas/ULID' + readOnly: true + name: + type: string + minLength: 1 + maxLength: 256 + description: |- + Display name of the resource. + + Between 1 and 256 characters. + description: + type: string + maxLength: 1024 + description: |- + Optional description of the resource. + + Maximum 1024 characters. + labels: + $ref: '#/components/schemas/Labels' + created_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: An ISO-8601 timestamp representation of entity creation date. + readOnly: true + updated_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: An ISO-8601 timestamp representation of entity last update date. + readOnly: true + deleted_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: An ISO-8601 timestamp representation of entity deletion date. + readOnly: true + type: + type: string + enum: + - usage_based + description: The type of the charge. + title: Type + customer: + allOf: + - $ref: '#/components/schemas/BillingCustomerReference' + description: The customer owning the charge. + title: Customer + readOnly: true + lifecycle_controller: + allOf: + - $ref: '#/components/schemas/BillingLifecycleController' + description: |- + Indicates whether the charge lifecycle is controlled by OpenMeter or manually + overridden by the API user. + title: Lifecycle controller + readOnly: true + subscription: + allOf: + - $ref: '#/components/schemas/BillingSubscriptionReference' + description: |- + The subscription that originated the charge, when the charge was created from a + subscription item. + title: Subscription + readOnly: true + currency: + allOf: + - $ref: '#/components/schemas/CurrencyCode' + description: The currency of the charge. + title: Currency + status: + allOf: + - $ref: '#/components/schemas/BillingChargeStatus' + description: The lifecycle status of the charge. + title: Status + readOnly: true + invoice_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: The timestamp when the charge is intended to be invoiced. + title: Invoice at + service_period: + allOf: + - $ref: '#/components/schemas/ClosedPeriod' + description: The effective service period covered by the charge. + title: Service period + full_service_period: + allOf: + - $ref: '#/components/schemas/ClosedPeriod' + description: The full, unprorated service period of the charge. + title: Full service period + readOnly: true + billing_period: + allOf: + - $ref: '#/components/schemas/ClosedPeriod' + description: The billing period the charge belongs to. + title: Billing period + readOnly: true + advance_after: + allOf: + - $ref: '#/components/schemas/DateTime' + description: |- + The earliest time when the charge should be advanced again by background + processing. + title: Advance after + readOnly: true + unique_reference_id: + type: string + description: Unique reference ID of the charge. + title: Unique reference ID + settlement_mode: + allOf: + - $ref: '#/components/schemas/BillingSettlementMode' + description: Settlement mode of the charge. + title: Settlement mode + tax_config: + allOf: + - $ref: '#/components/schemas/BillingTaxConfig' + description: Tax configuration of the charge. + title: Tax configuration + discounts: + allOf: + - $ref: '#/components/schemas/BillingRateCardDiscounts' + description: Discounts applied to the usage-based charge. + title: Discounts + feature_key: + type: string + description: The feature associated with the charge. + title: Feature key + totals: + allOf: + - $ref: '#/components/schemas/BillingChargeTotals' + description: Aggregated booked and realtime totals for the charge. + title: Totals for the charge + readOnly: true + price: + allOf: + - $ref: '#/components/schemas/BillingPrice' + description: The price of the charge. + title: Price + system_intent: + allOf: + - $ref: '#/components/schemas/BillingChargeUsageBasedSystemIntent' + description: |- + Current intent from the system lifecycle controller for a charge that has an + active manual override. The top-level charge fields remain the effective + customer-facing intent. + title: System intent + readOnly: true + additionalProperties: false + description: A usage-based charge for a customer. + title: Usage-based charge + BillingChargeUsageBasedSystemIntent: + type: object + required: + - name + - invoice_at + - service_period + - full_service_period + - billing_period + - price + properties: + name: + type: string + minLength: 1 + maxLength: 256 + description: |- + Display name of the resource. + + Between 1 and 256 characters. + description: + type: string + maxLength: 1024 + description: |- + Optional description of the resource. + + Maximum 1024 characters. + labels: + $ref: '#/components/schemas/Labels' + invoice_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: The timestamp when the charge is intended to be invoiced. + title: Invoice at + service_period: + allOf: + - $ref: '#/components/schemas/ClosedPeriod' + description: The effective service period covered by the charge. + title: Service period + full_service_period: + allOf: + - $ref: '#/components/schemas/ClosedPeriod' + description: The full, unprorated service period of the charge. + title: Full service period + readOnly: true + billing_period: + allOf: + - $ref: '#/components/schemas/ClosedPeriod' + description: The billing period the charge belongs to. + title: Billing period + readOnly: true + discounts: + allOf: + - $ref: '#/components/schemas/BillingRateCardDiscounts' + description: Discounts applied to the usage-based charge. + title: Discounts + price: + allOf: + - $ref: '#/components/schemas/BillingPrice' + description: The price of the charge. + title: Price + deleted_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: |- + The timestamp when the system lifecycle controller intent was deleted. The + effective charge can remain visible while a manual override is active. + title: Deleted at + readOnly: true + additionalProperties: false + description: |- + Usage-based intent fields from the system lifecycle controller shadowed by a + manual override. + title: Usage-based system lifecycle controller charge intent + BillingChargesExpand: + type: string + enum: + - real_time_usage + description: |- + Expands for customer charges. + + Values: + + - `real_time_usage`: The charge's real-time usage. + title: Customer charge expands + BillingCostBasis: + type: object + required: + - id + - fiat_code + - rate + - created_at + properties: + id: + allOf: + - $ref: '#/components/schemas/ULID' + readOnly: true + fiat_code: + allOf: + - $ref: '#/components/schemas/CurrencyCode' + description: The fiat currency code for the cost basis. + rate: + allOf: + - $ref: '#/components/schemas/Numeric' + description: The cost rate for the currency. + effective_from: + allOf: + - $ref: '#/components/schemas/DateTime' + description: |- + An ISO-8601 timestamp representation of the date from which the cost basis is + effective. If not provided, it will be effective immediately and will be set to + `now` by the system. + effective_to: + allOf: + - $ref: '#/components/schemas/DateTime' + description: |- + An ISO-8601 timestamp representation of the date until which the cost basis is + effective. If provided, it must be later than `effective_from`. If not provided, + it remains effective until superseded. + created_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: An ISO-8601 timestamp representation of entity creation date. + readOnly: true + additionalProperties: false + description: Describes currency basis supported by billing system. + BillingCreditAdjustment: + type: object + required: + - name + properties: + name: + type: string + minLength: 1 + maxLength: 256 + description: |- + Display name of the resource. + + Between 1 and 256 characters. + description: + type: string + maxLength: 1024 + description: |- + Optional description of the resource. + + Maximum 1024 characters. + labels: + $ref: '#/components/schemas/Labels' + additionalProperties: false + description: |- + A credit adjustment can be used to make manual adjustments to a customer's + credit balance. + + Supported use-cases: + + - Usage correction + title: Credit adjustment + BillingCreditAvailabilityPolicy: + type: string + enum: + - on_creation + description: |- + When credits become available for consumption. + + - `on_creation`: Credits are available as soon as the grant is created. + - `on_authorization`: Credits are available once the payment is authorized. + - `on_settlement`: Credits are available once the payment is settled. + title: Credit availability policy + BillingCreditBalances: + type: object + required: + - retrieved_at + - balances + properties: + retrieved_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: The timestamp of the balance retrieval. + readOnly: true + balances: + type: array + items: + $ref: '#/components/schemas/CreditBalance' + description: The balances by currencies. + readOnly: true + additionalProperties: false + description: The balances of the credits of a customer. + title: Credit balances + BillingCreditFundingMethod: + type: string + enum: + - none + - invoice + - external + description: |- + The funding method describes how the grant is funded. + + - `none`: No funding workflow applies, for example promotional grants + - `invoice`: The grant is funded by an in-system invoice flow + - `external`: The grant is funded outside the system (e.g., wire transfer, + external invoice, or manual reconciliation) + title: Credit funding method + BillingCreditGrant: + type: object + required: + - id + - name + - created_at + - updated_at + - funding_method + - currency + - amount + - status + properties: + id: + allOf: + - $ref: '#/components/schemas/ULID' + readOnly: true + name: + type: string + minLength: 1 + maxLength: 256 + description: |- + Display name of the resource. + + Between 1 and 256 characters. + description: + type: string + maxLength: 1024 + description: |- + Optional description of the resource. + + Maximum 1024 characters. + labels: + $ref: '#/components/schemas/Labels' + created_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: An ISO-8601 timestamp representation of entity creation date. + readOnly: true + updated_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: An ISO-8601 timestamp representation of entity last update date. + readOnly: true + deleted_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: An ISO-8601 timestamp representation of entity deletion date. + readOnly: true + funding_method: + allOf: + - $ref: '#/components/schemas/BillingCreditFundingMethod' + description: Funding method of the grant. + currency: + allOf: + - $ref: '#/components/schemas/BillingCurrencyCode' + description: The currency of the granted credits. + amount: + allOf: + - $ref: '#/components/schemas/Numeric' + description: Granted credit amount. + purchase: + allOf: + - $ref: '#/components/schemas/BillingCreditGrantPurchase' + description: Present when a funding workflow applies (funding_method is not `none`). + tax_config: + allOf: + - $ref: '#/components/schemas/BillingCreditGrantTaxConfig' + description: |- + Tax configuration for the grant. + + For `invoice` and `external` funding methods, tax configuration should be + provided to ensure correct revenue recognition. When not provided, the default + credit grant tax code is applied, if that's not set the global default taxcode + is used. + invoice: + allOf: + - $ref: '#/components/schemas/BillingCreditGrantInvoiceReference' + description: Available when `funding_method` is `invoice`. + readOnly: true + filters: + $ref: '#/components/schemas/BillingCreditGrantFilters' + priority: + type: integer + format: int16 + minimum: 1 + maximum: 1000 + description: Draw-down priority of the grant. Lower values have higher priority. + default: 10 + effective_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: |- + The timestamp when the credit grant becomes effective. + + Defaults to the current date and time. + key: + allOf: + - $ref: '#/components/schemas/ExternalResourceKey' + description: |- + Idempotency key for the credit grant creation request. + + When provided, reusing the same key returns an HTTP 409 Conflict instead of + creating a duplicate grant, which makes create requests safe to retry. + expires_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: |- + The timestamp when the credit grant expires. + + Calculated from the grant effective time and `expires_after` if provided. + readOnly: true + voided_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: Timestamp when the grant was voided. + readOnly: true + status: + allOf: + - $ref: '#/components/schemas/BillingCreditGrantStatus' + description: Current lifecycle status of the grant. + readOnly: true + additionalProperties: false + description: |- + A credit grant allocates credits to a customer. + + Credits are drawn down against charges according to the settlement mode + configured on the rate card. + title: Credit grant + BillingCreditGrantFilters: + type: object + properties: + features: + type: array + items: + $ref: '#/components/schemas/ResourceKey' + description: |- + Limit the credit grant to specific features. If no features are specified, the + credit grant can be used for any feature. + example: + - input_tokens + - output_tokens + additionalProperties: false + description: Filters for the credit grant. + BillingCreditGrantInvoiceReference: + type: object + properties: + id: + allOf: + - $ref: '#/components/schemas/ULID' + description: Identifier of the invoice associated with the grant. + readOnly: true + line: + type: object + properties: + id: + $ref: '#/components/schemas/ULID' + required: + - id + additionalProperties: false + description: Identifier of the invoice line associated with the grant. + readOnly: true + additionalProperties: false + description: Invoice references for the grant. + BillingCreditGrantPurchase: + type: object + required: + - currency + - amount + properties: + currency: + allOf: + - $ref: '#/components/schemas/CurrencyCode' + description: Currency of the purchase amount. + per_unit_cost_basis: + allOf: + - $ref: '#/components/schemas/Numeric' + description: |- + Cost basis per credit unit used to calculate the purchase amount. + + If `per_unit_cost_basis` is 0.50 and credit amount is $100.00, the total charge + is $50.00. The value must be greater than 0. If the cost basis is 0, use + `funding_method=none` instead. + + Defaults to 1.0. + default: '1.0' + amount: + allOf: + - $ref: '#/components/schemas/Numeric' + description: The purchase amount. Calculated from `per_unit_cost_basis` and credit `amount`. + readOnly: true + availability_policy: + allOf: + - $ref: '#/components/schemas/BillingCreditAvailabilityPolicy' + description: |- + Controls when credits become available for consumption. + + Defaults to `on_creation`. + default: on_creation + settlement_status: + allOf: + - $ref: '#/components/schemas/BillingCreditPurchasePaymentSettlementStatus' + description: Current payment settlement status. + readOnly: true + additionalProperties: false + description: Purchase and payment terms of the grant. + BillingCreditGrantStatus: + type: string + enum: + - pending + - active + - expired + - voided + description: |- + Credit grant lifecycle status. + + - `pending`: The credit block has been created but is not yet valid. + (`effective_at` is in the future or availability_policy is not met) + - `active`: The credit block is currently valid and eligible for consumption. + (`effective_at` is in the past, `expires_at` is in the future and + availability_policy is met) + - `expired`: The credit block expired with remaining unused balance, + `expires_at` time has passed. + - `voided`: The credit block was voided. Remaining balance is forfeited. + title: Credit grant lifecycle status + BillingCreditGrantTaxConfig: + type: object + properties: + behavior: + allOf: + - $ref: '#/components/schemas/BillingTaxBehavior' + description: Tax behavior applied to the invoice line item. + tax_code: + allOf: + - $ref: '#/components/schemas/TaxCodeReference' + description: Tax code applied to the invoice line item. + additionalProperties: false + description: |- + Tax configuration for a credit grant. + + Tax configuration should be provided to ensure correct revenue recognition, + including for externally funded grants. + title: Tax configuration for a credit grant + BillingCreditGrantVoidPaymentAdjustment: + type: string + enum: + - none + description: |- + Describes how voiding a credit grant adjusts related payment state. + + - `none`: Voiding does not adjust invoices, payment authorization, settlement, + payment intents, or external collection state. + title: Credit grant void payment adjustment + BillingCreditPurchasePaymentSettlementStatus: + type: string + enum: + - pending + - authorized + - settled + description: |- + Credit purchase payment settlement status. + + - `pending`: Payment has been initiated and is not yet authorized. + - `authorized`: Payment has been authorized. + - `settled`: Payment has been settled. + title: Credit purchase payment settlement status + BillingCreditTransaction: + type: object + required: + - id + - name + - created_at + - booked_at + - type + - currency + - amount + - available_balance + properties: + id: + allOf: + - $ref: '#/components/schemas/ULID' + readOnly: true + name: + type: string + minLength: 1 + maxLength: 256 + description: |- + Display name of the resource. + + Between 1 and 256 characters. + description: + type: string + maxLength: 1024 + description: |- + Optional description of the resource. + + Maximum 1024 characters. + labels: + $ref: '#/components/schemas/Labels' + created_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: An ISO-8601 timestamp representation of entity creation date. + readOnly: true + booked_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: The date and time the transaction was booked. + readOnly: true + type: + allOf: + - $ref: '#/components/schemas/BillingCreditTransactionType' + description: The type of credit transaction. + readOnly: true + currency: + allOf: + - $ref: '#/components/schemas/BillingCurrencyCode' + description: Currency of the balance affected by the transaction. + readOnly: true + amount: + allOf: + - $ref: '#/components/schemas/Numeric' + description: |- + Signed amount of the credit movement. Positive values add balance, negative + values reduce balance. + readOnly: true + available_balance: + type: object + properties: + before: + $ref: '#/components/schemas/Numeric' + after: + $ref: '#/components/schemas/Numeric' + required: + - before + - after + additionalProperties: false + description: The available balance before and after the transaction. + readOnly: true + additionalProperties: false + description: |- + A credit transaction represents a single credit movement on the customer's + balance. + + Credit transactions are immutable. + title: Credit transaction + BillingCreditTransactionType: + type: string + enum: + - funded + - consumed + - expired + - voided + description: |- + The type of the credit transaction. + + - `funded`: Credit granted and available for consumption. + - `consumed`: Credit consumed by usage or fees. + - `expired`: Credit removed because it expired before being used. + - `voided`: Credit removed because the grant was voided before being used. + BillingCurrency: + type: object + oneOf: + - $ref: '#/components/schemas/BillingCurrencyFiat' + - $ref: '#/components/schemas/BillingCurrencyCustom' + discriminator: + propertyName: type + mapping: + fiat: '#/components/schemas/BillingCurrencyFiat' + custom: '#/components/schemas/BillingCurrencyCustom' + description: Fiat or custom currency. + BillingCurrencyCode: + type: string + allOf: + - $ref: '#/components/schemas/CurrencyCode' + description: Fiat or custom currency code. + x-go-type: string + BillingCurrencyCodeCustom: + type: string + minLength: 3 + maxLength: 24 + description: |- + Custom currency code. It should be a unique code but not conflicting with any + existing fiat currency codes. + BillingCurrencyCustom: + type: object + required: + - type + - name + - id + - code + - created_at + properties: + type: + type: string + enum: + - custom + description: The type of the currency. + readOnly: true + name: + type: string + minLength: 1 + maxLength: 256 + description: |- + The name of the currency. It should be a human-readable string that represents + the name of the currency, such as "US Dollar" or "Euro". + symbol: + type: string + minLength: 1 + description: |- + The symbol of the currency. It should be a string that represents the symbol of + the currency, such as "$" for US Dollar or "€" for Euro. + id: + allOf: + - $ref: '#/components/schemas/ULID' + readOnly: true + code: + $ref: '#/components/schemas/BillingCurrencyCodeCustom' + created_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: An ISO-8601 timestamp representation of the custom currency creation date. + readOnly: true + additionalProperties: false + description: Describes custom currency. + BillingCurrencyFiat: + type: object + required: + - type + - name + - code + properties: + type: + type: string + enum: + - fiat + description: The type of the currency. + readOnly: true + name: + type: string + minLength: 1 + maxLength: 256 + description: |- + The name of the currency. It should be a human-readable string that represents + the name of the currency, such as "US Dollar" or "Euro". + symbol: + type: string + minLength: 1 + description: |- + The symbol of the currency. It should be a string that represents the symbol of + the currency, such as "$" for US Dollar or "€" for Euro. + code: + allOf: + - $ref: '#/components/schemas/CurrencyCode' + readOnly: true + additionalProperties: false + description: Currency describes a currency supported by the billing system. + BillingCurrencyType: + type: string + enum: + - fiat + - custom + description: |- + Currency type for custom currencies. It should be a unique code but not + conflicting with any existing standard currency codes. + BillingCustomer: + type: object + required: + - id + - name + - created_at + - updated_at + - key + properties: + id: + allOf: + - $ref: '#/components/schemas/ULID' + readOnly: true + name: + type: string + minLength: 1 + maxLength: 256 + description: |- + Display name of the resource. + + Between 1 and 256 characters. + description: + type: string + maxLength: 1024 + description: |- + Optional description of the resource. + + Maximum 1024 characters. + labels: + $ref: '#/components/schemas/Labels' + created_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: An ISO-8601 timestamp representation of entity creation date. + readOnly: true + updated_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: An ISO-8601 timestamp representation of entity last update date. + readOnly: true + deleted_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: An ISO-8601 timestamp representation of entity deletion date. + readOnly: true + key: + $ref: '#/components/schemas/ExternalResourceKey' + usage_attribution: + allOf: + - $ref: '#/components/schemas/BillingCustomerUsageAttribution' + description: Mapping to attribute metered usage to the customer by the event subject. + title: Usage Attribution + primary_email: + type: string + description: The primary email address of the customer. + title: Primary Email + currency: + allOf: + - $ref: '#/components/schemas/CurrencyCode' + description: Currency of the customer. Used for billing, tax and invoicing. + title: Currency + billing_address: + allOf: + - $ref: '#/components/schemas/Address' + description: The billing address of the customer. Used for tax and invoicing. + title: Billing Address + additionalProperties: false + description: |- + Customers can be individuals or organizations that can subscribe to plans and + have access to features. + BillingCustomerData: + type: object + properties: + billing_profile: + allOf: + - $ref: '#/components/schemas/BillingProfileReference' + description: |- + The billing profile for the customer. + + If not provided, the default billing profile will be used. + title: Billing profile + app_data: + allOf: + - $ref: '#/components/schemas/BillingAppCustomerData' + description: App customer data. + title: App customer data + additionalProperties: false + description: Billing customer data. + BillingCustomerReference: + type: object + required: + - id + properties: + id: + allOf: + - $ref: '#/components/schemas/ULID' + description: The ID of the customer. + additionalProperties: false + description: Customer reference. + BillingCustomerStripeCreateCheckoutSessionRequest: + type: object + required: + - stripe_options + properties: + stripe_options: + allOf: + - $ref: '#/components/schemas/BillingAppStripeCreateCheckoutSessionRequestOptions' + description: |- + Options for configuring the Stripe Checkout Session. + + These options are passed directly to Stripe's + [checkout session creation API](https://docs.stripe.com/api/checkout/sessions/create). + additionalProperties: false + description: |- + Request to create a Stripe Checkout Session for the customer. + + Checkout Sessions are used to collect payment method information from customers + in a secure, Stripe-hosted interface. This integration uses setup mode to + collect payment methods that can be charged later for subscription billing. + BillingCustomerStripeCreateCustomerPortalSessionRequest: + type: object + required: + - stripe_options + properties: + stripe_options: + allOf: + - $ref: '#/components/schemas/BillingAppStripeCreateCustomerPortalSessionOptions' + description: Options for configuring the Stripe Customer Portal Session. + additionalProperties: false + description: |- + Request to create a Stripe Customer Portal Session for the customer. + + Useful to redirect the customer to the Stripe Customer Portal to manage their + payment methods, change their billing address and access their invoice history. + Only returns URL if the customer billing profile is linked to a stripe app and + customer. + BillingCustomerUsageAttribution: + type: object + required: + - subject_keys + properties: + subject_keys: + type: array + items: + $ref: '#/components/schemas/UsageAttributionSubjectKey' + minItems: 0 + description: |- + The subjects that are attributed to the customer. Can be empty when no usage + event subjects are associated with the customer. + title: Subject Keys + additionalProperties: false + description: |- + Mapping to attribute metered usage to the customer. One customer can have zero + or more subjects, but one subject can only belong to one customer. + BillingEntitlementAccessResult: + type: object + required: + - type + - feature_key + - has_access + properties: + type: + allOf: + - $ref: '#/components/schemas/BillingEntitlementType' + description: The type of the entitlement. + example: static + readOnly: true + feature_key: + allOf: + - $ref: '#/components/schemas/ResourceKey' + description: The feature key of the entitlement. + example: available_models + readOnly: true + has_access: + type: boolean + description: |- + Whether the customer has access to the feature. Always true for `boolean` and + `static` entitlements. Depends on balance for `metered` entitlements. + example: true + readOnly: true + config: + type: string + description: |- + Only available for static entitlements. Config is the JSON parsable + configuration of the entitlement. Useful to describe per customer configuration. + example: '{ "availableModels": ["gpt-5", "gpt-4o"] }' + readOnly: true + additionalProperties: false + description: Entitlement access result. + BillingEntitlementType: + type: string + enum: + - metered + - static + - boolean + description: The type of the entitlement. + BillingFeatureLLMTokenType: + type: string + enum: + - input + - output + - cache_read + - cache_write + - reasoning + - request + - response + description: Token type for LLM cost lookup. + BillingFeatureLLMUnitCost: + type: object + required: + - type + properties: + type: + type: string + enum: + - llm + description: The type discriminator for LLM unit cost. + provider_property: + type: string + description: |- + Meter group-by property that holds the LLM provider. Use this when the meter has + a group-by dimension for provider. Mutually exclusive with `provider`. + title: Provider property + provider: + type: string + description: |- + Static LLM provider value (e.g., "openai", "anthropic"). Use this when the + feature tracks a single provider. Mutually exclusive with `provider_property`. + title: Provider + model_property: + type: string + description: |- + Meter group-by property that holds the model ID. Use this when the meter has a + group-by dimension for model. Mutually exclusive with `model`. + title: Model property + model: + type: string + description: |- + Static model ID value (e.g., "gpt-4", "claude-3-5-sonnet"). Use this when the + feature tracks a single model. Mutually exclusive with `model_property`. + title: Model + token_type_property: + type: string + description: |- + Meter group-by property that holds the token type. Use this when the meter has a + group-by dimension for token type. Mutually exclusive with `token_type`. + title: Token type property + token_type: + allOf: + - $ref: '#/components/schemas/BillingFeatureLLMTokenType' + description: |- + Static token type value. Use this when the feature tracks a single token type + (e.g., only input tokens). `request` is an alias for `input`, `response` is an + alias for `output`. Mutually exclusive with `token_type_property`. + title: Token type + pricing: + allOf: + - $ref: '#/components/schemas/BillingFeatureLLMUnitCostPricing' + description: |- + Resolved per-token pricing from the LLM cost database. Populated in responses + when the provider and model can be determined, either from static values or from + meter group-by filters with exact matches. + title: Resolved pricing + readOnly: true + additionalProperties: false + description: |- + LLM cost lookup configuration. Each dimension (provider, model, token type) can + be specified as either a static value or a meter group-by property name + (mutually exclusive). + BillingFeatureLLMUnitCostPricing: + type: object + required: + - input_per_token + - output_per_token + properties: + input_per_token: + allOf: + - $ref: '#/components/schemas/Numeric' + description: Cost per input token in USD. + title: Input per token + output_per_token: + allOf: + - $ref: '#/components/schemas/Numeric' + description: Cost per output token in USD. + title: Output per token + cache_read_per_token: + allOf: + - $ref: '#/components/schemas/Numeric' + description: Cost per cache read token in USD. + title: Cache read per token + reasoning_per_token: + allOf: + - $ref: '#/components/schemas/Numeric' + description: Cost per reasoning token in USD. + title: Reasoning per token + cache_write_per_token: + allOf: + - $ref: '#/components/schemas/Numeric' + description: Cost per cache write token in USD. + title: Cache write per token + additionalProperties: false + description: Resolved per-token pricing from the LLM cost database. + BillingFeatureManualUnitCost: + type: object + required: + - type + - amount + properties: + type: + type: string + enum: + - manual + description: The type discriminator for manual unit cost. + amount: + allOf: + - $ref: '#/components/schemas/Numeric' + description: Fixed per-unit cost amount in USD. + additionalProperties: false + description: A fixed per-unit cost amount. + BillingFeatureUnitCost: + type: object + oneOf: + - $ref: '#/components/schemas/BillingFeatureManualUnitCost' + - $ref: '#/components/schemas/BillingFeatureLLMUnitCost' + discriminator: + propertyName: type + mapping: + manual: '#/components/schemas/BillingFeatureManualUnitCost' + llm: '#/components/schemas/BillingFeatureLLMUnitCost' + description: |- + Per-unit cost configuration for a feature. Either a fixed manual amount or a + dynamic LLM cost lookup. + BillingInstallAppExternalInvoicing: + type: object + required: + - type + - name + - create_billing_profile + properties: + type: + type: string + enum: + - external_invoicing + description: Type of the app. + name: + type: string + description: Name of the app. + create_billing_profile: + type: boolean + description: |- + If true, a billing profile will be created for the app. The Stripe app will be + also set as the default billing profile if the current default is a Sandbox app. + additionalProperties: false + description: Base model for installing an app from the catalog. + BillingInstallAppRequest: + type: object + oneOf: + - $ref: '#/components/schemas/BillingInstallAppStripeWithApiKey' + - $ref: '#/components/schemas/BillingInstallAppSandbox' + - $ref: '#/components/schemas/BillingInstallAppExternalInvoicing' + discriminator: + propertyName: type + mapping: + stripe: '#/components/schemas/BillingInstallAppStripeWithApiKey' + sandbox: '#/components/schemas/BillingInstallAppSandbox' + external_invoicing: '#/components/schemas/BillingInstallAppExternalInvoicing' + description: Request to install an app from the catalog. + BillingInstallAppResponse: + type: object + required: + - app + - default_for_capability_types + properties: + app: + allOf: + - $ref: '#/components/schemas/BillingApp' + readOnly: true + default_for_capability_types: + type: array + items: + $ref: '#/components/schemas/BillingAppCapabilityType' + readOnly: true + additionalProperties: false + description: Response of the app install. + BillingInstallAppSandbox: + type: object + required: + - type + - name + - create_billing_profile + properties: + type: + type: string + enum: + - sandbox + description: Type of the app. + name: + type: string + description: Name of the app. + create_billing_profile: + type: boolean + description: |- + If true, a billing profile will be created for the app. The Stripe app will be + also set as the default billing profile if the current default is a Sandbox app. + additionalProperties: false + description: Base model for installing an app from the catalog. + BillingInstallAppStripeWithApiKey: + type: object + required: + - type + - name + - create_billing_profile + - api_key + properties: + type: + type: string + enum: + - stripe + description: Type of the app. + name: + type: string + description: Name of the app. + create_billing_profile: + type: boolean + description: |- + If true, a billing profile will be created for the app. The Stripe app will be + also set as the default billing profile if the current default is a Sandbox app. + api_key: + type: string + description: API key for the app. + additionalProperties: false + description: Model for installing an app from the catalog with an API key. + BillingInvoice: + type: object + oneOf: + - $ref: '#/components/schemas/BillingInvoiceStandard' + discriminator: + propertyName: type + mapping: + standard: '#/components/schemas/BillingInvoiceStandard' + description: |- + An invoice issued to a customer. + + The `type` field determines the concrete variant: + + - `standard`: a standard invoice for charges owed. + BillingInvoiceAvailableActionDetails: + type: object + required: + - resulting_state + properties: + resulting_state: + type: string + description: The extended status the invoice will transition to after performing this action. + title: Resulting state + readOnly: true + additionalProperties: false + description: Details about an available invoice action including the resulting state. + BillingInvoiceAvailableActions: + type: object + properties: + advance: + allOf: + - $ref: '#/components/schemas/BillingInvoiceAvailableActionDetails' + description: Advance the invoice to the next workflow step. + title: Advance + readOnly: true + approve: + allOf: + - $ref: '#/components/schemas/BillingInvoiceAvailableActionDetails' + description: Approve the invoice for issuance. + title: Approve + readOnly: true + delete: + allOf: + - $ref: '#/components/schemas/BillingInvoiceAvailableActionDetails' + description: Delete the invoice. + title: Delete + readOnly: true + retry: + allOf: + - $ref: '#/components/schemas/BillingInvoiceAvailableActionDetails' + description: Retry a failed workflow step. + title: Retry + readOnly: true + snapshot_quantities: + allOf: + - $ref: '#/components/schemas/BillingInvoiceAvailableActionDetails' + description: Snapshot the current usage quantities. + title: Snapshot quantities + readOnly: true + additionalProperties: false + description: |- + The set of state-transition actions available for an invoice in its current + status. + + A field is present only when that action is permitted from the current state. + BillingInvoiceCustomer: + type: object + required: + - name + - id + properties: + name: + type: string + minLength: 1 + maxLength: 256 + description: |- + Display name of the resource. + + Between 1 and 256 characters. + usage_attribution: + allOf: + - $ref: '#/components/schemas/BillingCustomerUsageAttribution' + description: Mapping to attribute metered usage to the customer by the event subject. + title: Usage Attribution + billing_address: + allOf: + - $ref: '#/components/schemas/Address' + description: The billing address of the customer. Used for tax and invoicing. + title: Billing Address + id: + $ref: '#/components/schemas/ULID' + key: + allOf: + - $ref: '#/components/schemas/ExternalResourceKey' + description: |- + Optional external resource key for the customer. + + Omitted when the customer was created without a key. Unlike on the customer + resource itself, the key is optional here because the invoice snapshot may + predate or omit it. + additionalProperties: false + description: Snapshot of the customer's information at the time the invoice was issued. + BillingInvoiceDetailedLine: + type: object + required: + - id + - name + - created_at + - updated_at + - service_period + - totals + - category + - quantity + - unit_price + properties: + id: + allOf: + - $ref: '#/components/schemas/ULID' + readOnly: true + name: + type: string + minLength: 1 + maxLength: 256 + description: |- + Display name of the resource. + + Between 1 and 256 characters. + description: + type: string + maxLength: 1024 + description: |- + Optional description of the resource. + + Maximum 1024 characters. + labels: + $ref: '#/components/schemas/Labels' + created_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: An ISO-8601 timestamp representation of entity creation date. + readOnly: true + updated_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: An ISO-8601 timestamp representation of entity last update date. + readOnly: true + deleted_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: An ISO-8601 timestamp representation of entity deletion date. + readOnly: true + service_period: + allOf: + - $ref: '#/components/schemas/ClosedPeriod' + description: The service period covered by this detailed line. + title: Service period + readOnly: true + totals: + allOf: + - $ref: '#/components/schemas/BillingTotals' + description: Aggregated financial totals for the detailed line. + title: Totals + readOnly: true + category: + allOf: + - $ref: '#/components/schemas/BillingInvoiceDetailedLineCostCategory' + description: The cost category of this detailed line. + title: Category + default: regular + readOnly: true + discounts: + allOf: + - $ref: '#/components/schemas/BillingInvoiceLineDiscounts' + description: Discounts applied to this detailed line. + title: Discounts + readOnly: true + credits_applied: + type: array + items: + $ref: '#/components/schemas/BillingInvoiceLineCreditsApplied' + description: Credit applied to this detailed line. + title: Credit applied + readOnly: true + external_references: + allOf: + - $ref: '#/components/schemas/BillingInvoiceLineExternalReferences' + description: External identifiers for this detailed line. + title: External references + readOnly: true + quantity: + allOf: + - $ref: '#/components/schemas/Numeric' + description: The quantity of the detailed line. + title: Quantity + readOnly: true + unit_price: + allOf: + - $ref: '#/components/schemas/Numeric' + description: The unit price of the detailed line. + title: Unit Price + readOnly: true + additionalProperties: false + description: |- + A detailed (child) sub-line belonging to a parent invoice line. + + Detailed lines represent the individual flat-fee components that make up a + usage-based parent line after quantity snapshotting. + BillingInvoiceDetailedLineCostCategory: + type: string + enum: + - regular + - commitment + description: Cost category of a detailed invoice line item. + BillingInvoiceDiscountReason: + type: string + enum: + - maximum_spend + - ratecard_percentage + - ratecard_usage + description: The reason a discount was applied to an invoice line. + BillingInvoiceExternalReferences: + type: object + properties: + invoicing_id: + type: string + description: The ID assigned by the external invoicing app (e.g. Stripe invoice ID). + title: Invoicing ID + readOnly: true + payment_id: + type: string + description: The ID assigned by the external payment app (e.g. Stripe payment intent ID). + title: Payment ID + readOnly: true + additionalProperties: false + description: External identifiers assigned to an invoice by third-party systems. + BillingInvoiceLine: + type: object + oneOf: + - $ref: '#/components/schemas/BillingInvoiceStandardLine' + discriminator: + propertyName: type + mapping: + standard_line: '#/components/schemas/BillingInvoiceStandardLine' + description: |- + A top-level line item on an invoice. + + Each line represents a single charge, typically associated with a rate card from + a subscription. Detailed (child) lines are nested under `detailed_lines` when + present. + BillingInvoiceLineAmountDiscount: + type: object + required: + - id + - reason + - amount + properties: + id: + allOf: + - $ref: '#/components/schemas/ULID' + description: Unique identifier for the discount. + title: ID + readOnly: true + reason: + allOf: + - $ref: '#/components/schemas/BillingInvoiceDiscountReason' + description: The reason this discount was applied. + title: Reason + readOnly: true + description: + type: string + description: Optional human-readable description of the discount. + title: Description + readOnly: true + external_references: + allOf: + - $ref: '#/components/schemas/BillingInvoiceLineExternalReferences' + description: External identifiers for this discount. + title: External references + readOnly: true + amount: + allOf: + - $ref: '#/components/schemas/Numeric' + description: The monetary amount deducted. + title: Amount + readOnly: true + additionalProperties: false + description: A monetary amount discount applied to an invoice line item. + BillingInvoiceLineCreditsApplied: + type: object + required: + - amount + properties: + amount: + allOf: + - $ref: '#/components/schemas/Numeric' + description: The monetary amount credited. + title: Amount + readOnly: true + description: + type: string + description: Optional human-readable description of the credit allocation. + title: Description + readOnly: true + additionalProperties: false + description: A credit allocation applied to an invoice line item. + BillingInvoiceLineDiscounts: + type: object + properties: + amount: + type: array + items: + $ref: '#/components/schemas/BillingInvoiceLineAmountDiscount' + description: Monetary amount discounts (e.g. from maximum spend commitments). + title: Amount discounts + readOnly: true + usage: + type: array + items: + $ref: '#/components/schemas/BillingInvoiceLineUsageDiscount' + description: Usage quantity discounts (e.g. free tier usage allowances). + title: Usage discounts + readOnly: true + additionalProperties: false + description: Discounts applied to an invoice line item. + BillingInvoiceLineExternalReferences: + type: object + properties: + invoicing_id: + type: string + description: The ID assigned by the external invoicing app. + title: Invoicing ID + readOnly: true + additionalProperties: false + description: External identifiers for an invoice line item assigned by third-party systems. + BillingInvoiceLineRateCard: + type: object + required: + - price + properties: + price: + allOf: + - $ref: '#/components/schemas/BillingPrice' + description: The price definition used to calculate charges for this line. + title: Price + tax_config: + allOf: + - $ref: '#/components/schemas/BillingRateCardTaxConfig' + description: Tax configuration snapshot for this line. + title: Tax config + feature_key: + allOf: + - $ref: '#/components/schemas/ResourceKey' + description: The feature key associated with this line's rate card. + title: Feature key + discounts: + allOf: + - $ref: '#/components/schemas/BillingRateCardDiscounts' + description: Discount configuration from the rate card. + title: Discounts + additionalProperties: false + description: Rate card configuration snapshot for a usage-based invoice line. + BillingInvoiceLineUsageDiscount: + type: object + required: + - id + - reason + - quantity + properties: + id: + allOf: + - $ref: '#/components/schemas/ULID' + description: Unique identifier for the discount. + title: ID + readOnly: true + reason: + allOf: + - $ref: '#/components/schemas/BillingInvoiceDiscountReason' + description: The reason this discount was applied. + title: Reason + readOnly: true + description: + type: string + description: Optional human-readable description of the discount. + title: Description + readOnly: true + external_references: + allOf: + - $ref: '#/components/schemas/BillingInvoiceLineExternalReferences' + description: External identifiers for this discount. + title: External references + readOnly: true + quantity: + allOf: + - $ref: '#/components/schemas/Numeric' + description: The usage quantity deducted (in billing units). + title: Quantity + readOnly: true + additionalProperties: false + description: A usage quantity discount applied to an invoice line item. + BillingInvoiceNumber: + type: string + minLength: 1 + maxLength: 256 + description: |- + InvoiceNumber is a unique identifier for the invoice, generated by the invoicing + app. + + The uniqueness depends on a lot of factors: + + - app setting (unique per app or unique per customer) + - multiple app scenarios (multiple apps generating invoices with the same + prefix) + example: INV-2024-01-01-01 + BillingInvoiceStandard: + type: object + required: + - id + - created_at + - updated_at + - number + - currency + - supplier + - customer + - totals + - service_period + - type + - status + - status_details + - workflow + properties: + id: + allOf: + - $ref: '#/components/schemas/ULID' + readOnly: true + description: + type: string + maxLength: 1024 + description: |- + Optional description of the resource. + + Maximum 1024 characters. + labels: + $ref: '#/components/schemas/Labels' + created_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: An ISO-8601 timestamp representation of entity creation date. + readOnly: true + updated_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: An ISO-8601 timestamp representation of entity last update date. + readOnly: true + deleted_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: An ISO-8601 timestamp representation of entity deletion date. + readOnly: true + number: + allOf: + - $ref: '#/components/schemas/BillingInvoiceNumber' + description: Human-readable invoice number generated by the invoicing app. + title: Number + readOnly: true + currency: + allOf: + - $ref: '#/components/schemas/CurrencyCode' + description: Three-letter ISO 4217 currency code for the invoice. + title: Currency + readOnly: true + supplier: + allOf: + - $ref: '#/components/schemas/BillingSupplier' + description: |- + Snapshot of the supplier's contact information at the time the invoice was + issued. + title: Supplier + customer: + allOf: + - $ref: '#/components/schemas/BillingInvoiceCustomer' + description: Snapshot of the customer's information at the time the invoice was issued. + title: Customer + totals: + allOf: + - $ref: '#/components/schemas/BillingTotals' + description: Aggregated financial totals for the invoice. + title: Totals + readOnly: true + service_period: + allOf: + - $ref: '#/components/schemas/ClosedPeriod' + description: |- + The service period covered by this invoice. + + For flat fee the service period can be empty which means `from` will be equals + to `to`. In other cases those fields will be filled with the actual service + period. + title: Service period + readOnly: true + validation_issues: + type: array + items: + $ref: '#/components/schemas/BillingInvoiceValidationIssue' + description: |- + Validation issues found during invoice processing. + + Present only when there are one or more validation findings. An empty list is + omitted. + title: Validation issues + readOnly: true + external_references: + allOf: + - $ref: '#/components/schemas/BillingInvoiceExternalReferences' + description: External identifiers assigned to this invoice by third-party systems. + title: External references + readOnly: true + type: + type: string + enum: + - standard + description: Discriminator field identifying this as a standard invoice. + title: Type + status: + allOf: + - $ref: '#/components/schemas/BillingInvoiceStandardStatus' + description: Current lifecycle status of the invoice. + title: Status + readOnly: true + status_details: + allOf: + - $ref: '#/components/schemas/BillingInvoiceStatusDetails' + description: Detailed status information including available actions and workflow state. + title: Status details + readOnly: true + issued_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: Timestamp when the invoice was issued to the customer. + title: Issued at + readOnly: true + draft_until: + allOf: + - $ref: '#/components/schemas/DateTime' + description: |- + Timestamp until which the invoice remains in draft state. + + The invoice advances automatically once this time is reached. + title: Draft until + readOnly: true + quantity_snapshotted_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: Timestamp when usage quantities were last snapshotted for this invoice. + title: Quantity snapshotted at + readOnly: true + collection_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: Timestamp when collection was initiated for this invoice. + title: Collection at + readOnly: true + due_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: Timestamp when payment is due. + title: Due at + readOnly: true + sent_to_customer_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: Timestamp when the invoice was sent to the customer. + title: Sent to customer at + readOnly: true + workflow: + allOf: + - $ref: '#/components/schemas/BillingInvoiceWorkflowSettings' + description: Workflow configuration snapshot captured at invoice creation time. + title: Workflow + lines: + type: array + items: + $ref: '#/components/schemas/BillingInvoiceLine' + description: |- + Line items on this invoice. + + Always returned on single-resource GET; omitted on list endpoints unless + explicitly expanded. Editable via update: existing lines are matched by `id`, + lines without an `id` are created, and lines present on the invoice but omitted + from the update request are deleted. Detailed (child) lines are always computed + and cannot be edited directly. + title: Lines + additionalProperties: false + description: A standard invoice for charges owed by the customer. + BillingInvoiceStandardLine: + type: object + required: + - name + - created_at + - updated_at + - type + - lifecycle_controller + - service_period + - totals + - rate_card + - detailed_lines + properties: + name: + type: string + minLength: 1 + maxLength: 256 + description: |- + Display name of the resource. + + Between 1 and 256 characters. + description: + type: string + maxLength: 1024 + description: |- + Optional description of the resource. + + Maximum 1024 characters. + labels: + $ref: '#/components/schemas/Labels' + created_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: An ISO-8601 timestamp representation of entity creation date. + readOnly: true + updated_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: An ISO-8601 timestamp representation of entity last update date. + readOnly: true + deleted_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: An ISO-8601 timestamp representation of entity deletion date. + readOnly: true + id: + allOf: + - $ref: '#/components/schemas/ULID' + description: |- + ID of the line. + + Optional on update: omit to create a new line, or supply the ID of an existing + line to edit it. Existing lines omitted from an update's `lines` array are + deleted. + title: ID + type: + type: string + enum: + - standard_line + description: The type of charge this line item represents. + title: Type + lifecycle_controller: + allOf: + - $ref: '#/components/schemas/BillingLifecycleController' + description: |- + Indicates whether this line item's lifecycle is controlled by OpenMeter or + manually overridden by the API user. + title: Lifecycle controller + readOnly: true + service_period: + allOf: + - $ref: '#/components/schemas/ClosedPeriod' + description: |- + The service period covered by this invoice, spanning the earliest line start to + the latest line end across all of its lines. + + For an invoice with no lines the period is empty, which means `from` will be + equal to `to`. + title: Service period + totals: + allOf: + - $ref: '#/components/schemas/BillingTotals' + description: Aggregated financial totals for the line item. + title: Totals + readOnly: true + discounts: + allOf: + - $ref: '#/components/schemas/BillingInvoiceLineDiscounts' + description: Discounts applied to this line item. + title: Discounts + readOnly: true + credits_applied: + type: array + items: + $ref: '#/components/schemas/BillingInvoiceLineCreditsApplied' + description: Credit applied to this line item. + title: Credit applied + readOnly: true + external_references: + allOf: + - $ref: '#/components/schemas/BillingInvoiceLineExternalReferences' + description: External identifiers for this line item assigned by third-party systems. + title: External references + readOnly: true + subscription: + allOf: + - $ref: '#/components/schemas/BillingSubscriptionReference' + description: Reference to the subscription item that generated this line. + title: Subscription reference + readOnly: true + rate_card: + allOf: + - $ref: '#/components/schemas/BillingInvoiceLineRateCard' + description: The rate card configuration snapshot used to price this line item. + title: Rate card + detailed_lines: + type: array + items: + $ref: '#/components/schemas/BillingInvoiceDetailedLine' + description: |- + Detailed sub-lines that this line has been broken down into. + + Present when line has individual details. + title: Detailed lines + readOnly: true + charge: + allOf: + - $ref: '#/components/schemas/BillingChargeReference' + description: Reference to the charge associated with this line item. + title: Charge + readOnly: true + additionalProperties: false + description: |- + A top-level line item on an invoice. + + Each line represents a single charge, typically associated with a rate card from + a subscription. Detailed (child) lines are nested under `detailed_lines` when + present. + BillingInvoiceStandardStatus: + type: string + enum: + - draft + - issuing + - issued + - payment_processing + - overdue + - paid + - uncollectible + - voided + description: Lifecycle status of a standard invoice. + BillingInvoiceStatusDetails: + type: object + required: + - immutable + - failed + - extended_status + - available_actions + properties: + immutable: + type: boolean + description: Whether the invoice is immutable (i.e. cannot be modified or deleted). + title: Immutable + readOnly: true + failed: + type: boolean + description: Whether the invoice is in a failed state. + title: Failed + readOnly: true + extended_status: + type: string + description: |- + Fine-grained internal status string providing additional workflow detail beyond + the top-level status enum. + title: Extended status + readOnly: true + available_actions: + allOf: + - $ref: '#/components/schemas/BillingInvoiceAvailableActions' + description: The set of state-transition actions currently available for this invoice. + title: Available actions + readOnly: true + additionalProperties: false + description: Detailed status information for a standard invoice. + BillingInvoiceValidationIssue: + type: object + required: + - code + - message + - severity + properties: + code: + type: string + description: Machine-readable error code. + title: Code + readOnly: true + message: + type: string + description: Human-readable description of the error. + title: Message + readOnly: true + attributes: + type: object + additionalProperties: {} + description: Additional structured context. + title: Attributes + readOnly: true + severity: + allOf: + - $ref: '#/components/schemas/BillingInvoiceValidationIssueSeverity' + description: Severity of the validation issue. + title: Severity + readOnly: true + field: + type: string + description: |- + JSON path to the field that caused this validation issue, if applicable. + + For example: `lines/0/rate_card/price`. + title: Field path + readOnly: true + additionalProperties: false + description: |- + A validation issue found during invoice processing. + + Converges on the same structure used by plan and subscription validation errors: + a machine-readable `code`, a human-readable `message`, optional structured + `attributes`, plus a `severity` and optional `field` path. + BillingInvoiceValidationIssueSeverity: + type: string + enum: + - critical + - warning + description: Severity level of an invoice validation issue. + BillingInvoiceWorkflow: + type: object + properties: + invoicing: + allOf: + - $ref: '#/components/schemas/BillingInvoiceWorkflowInvoicingSettings' + description: Invoicing settings for this invoice. + title: Invoicing settings + payment: + allOf: + - $ref: '#/components/schemas/BillingWorkflowPaymentSettings' + description: Payment settings for this invoice. + title: Payment settings + additionalProperties: false + description: |- + Invoice-level snapshot of the workflow configuration. + + Contains only the settings that are meaningful for an already-created invoice: + invoicing behaviour and payment settings. Collection alignment and tax policy + are gather-time / profile-wide concerns and are not included. + BillingInvoiceWorkflowAppsReferences: + type: object + required: + - tax + - invoicing + - payment + properties: + tax: + allOf: + - $ref: '#/components/schemas/BillingAppReference' + description: The tax app used for this workflow + readOnly: true + invoicing: + allOf: + - $ref: '#/components/schemas/BillingAppReference' + description: The invoicing app used for this workflow + readOnly: true + payment: + allOf: + - $ref: '#/components/schemas/BillingAppReference' + description: The payment app used for this workflow + readOnly: true + additionalProperties: false + description: |- + BillingInvoiceWorkflowAppsReferences represents the references (id) to the apps + used by a billing profile + BillingInvoiceWorkflowInvoicingSettings: + type: object + properties: + auto_advance: + type: boolean + description: Whether to automatically issue the invoice after the draft_period has passed. + default: true + draft_period: + type: string + format: ISO8601 + description: The period for the invoice to be kept in draft status for manual reviews. + example: P1D + default: P0D + due_after: + type: string + format: ISO8601 + description: The period after which the invoice is considered overdue if not paid. + example: P30D + readOnly: true + additionalProperties: false + description: |- + Invoice-level invoicing settings. + + A subset of BillingWorkflowInvoicingSettings limited to fields that are + meaningful per-invoice. progressive_billing is omitted as it is a gather-time / + profile-level decision. + BillingInvoiceWorkflowSettings: + type: object + required: + - source_billing_profile + - workflow + properties: + apps: + allOf: + - $ref: '#/components/schemas/BillingInvoiceWorkflowAppsReferences' + description: The apps that will be used to orchestrate the invoice's workflow. + title: Application that generated the invoice + readOnly: true + source_billing_profile: + allOf: + - $ref: '#/components/schemas/BillingProfileReference' + description: The billing profile that was the source of this workflow snapshot. + title: Source billing profile + readOnly: true + workflow: + allOf: + - $ref: '#/components/schemas/BillingInvoiceWorkflow' + description: |- + The workflow configuration that was active when the invoice was created. + + Only the fields that are meaningful at the per-invoice level are included: + invoicing behaviour (auto-advance, draft period) and payment settings + (collection method, due date). Profile-wide settings such as collection + alignment, progressive billing, and tax policy are omitted. + title: Workflow config + additionalProperties: false + description: Snapshot of the billing workflow configuration captured at invoice creation. + BillingLifecycleController: + type: string + enum: + - system + - manual + description: |- + Identifies whether a resource lifecycle is controlled by OpenMeter or manually + overridden by the API user. + + Values: + + - `system`: The resource lifecycle is controlled by OpenMeter. + - `manual`: The resource lifecycle was manually overridden by the API user. + title: Lifecycle controller + BillingParty: + type: object + properties: + id: + type: string + description: Unique identifier for the party. + readOnly: true + key: + allOf: + - $ref: '#/components/schemas/ExternalResourceKey' + description: An optional unique key of the party. + name: + type: string + description: Legal name or representation of the party. + tax_id: + allOf: + - $ref: '#/components/schemas/BillingPartyTaxIdentity' + description: |- + The entity's legal identification used for tax purposes. They may have other + numbers, but we're only interested in those valid for tax purposes. + addresses: + allOf: + - $ref: '#/components/schemas/BillingPartyAddresses' + description: Address for where information should be sent if needed. + additionalProperties: false + description: Party represents a person or business entity. + BillingPartyAddresses: + type: object + required: + - billing_address + properties: + billing_address: + allOf: + - $ref: '#/components/schemas/Address' + description: Billing address. + additionalProperties: false + description: A collection of addresses for the party. + BillingPartyTaxIdentity: + type: object + properties: + code: + allOf: + - $ref: '#/components/schemas/BillingTaxIdentificationCode' + description: Normalized tax identification code shown on the original identity document. + additionalProperties: false + description: |- + Identity stores the details required to identify an entity for tax purposes in a + specific country. + BillingPlan: + type: object + required: + - id + - name + - created_at + - updated_at + - key + - version + - currency + - billing_cadence + - status + - phases + properties: + id: + allOf: + - $ref: '#/components/schemas/ULID' + readOnly: true + name: + type: string + minLength: 1 + maxLength: 256 + description: |- + Display name of the resource. + + Between 1 and 256 characters. + description: + type: string + maxLength: 1024 + description: |- + Optional description of the resource. + + Maximum 1024 characters. + labels: + $ref: '#/components/schemas/Labels' + created_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: An ISO-8601 timestamp representation of entity creation date. + readOnly: true + updated_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: An ISO-8601 timestamp representation of entity last update date. + readOnly: true + deleted_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: An ISO-8601 timestamp representation of entity deletion date. + readOnly: true + key: + allOf: + - $ref: '#/components/schemas/ResourceKey' + description: |- + A key is a semi-unique string that is used to identify the plan. It is used to + reference the latest `active` version of the plan and is unique with the version + number. + title: Key + version: + type: integer + minimum: 1 + description: |- + Plans are versioned to allow you to make changes without affecting running + subscriptions. + title: Version + default: 1 + readOnly: true + currency: + allOf: + - $ref: '#/components/schemas/CurrencyCode' + description: The currency code of the plan. + title: Currency + billing_cadence: + allOf: + - $ref: '#/components/schemas/ISO8601Duration' + description: The billing cadence for subscriptions using this plan. + title: Billing cadence + pro_rating_enabled: + type: boolean + description: Whether pro-rating is enabled for this plan. + title: Pro-rating enabled + default: true + effective_from: + allOf: + - $ref: '#/components/schemas/DateTime' + description: |- + The date and time when the plan becomes `active`. When not specified, the plan + is in `draft` status. + title: Effective start date + readOnly: true + effective_to: + allOf: + - $ref: '#/components/schemas/DateTime' + description: |- + A scheduled date and time when the plan becomes `archived`. When not specified, + the plan is in `active` status indefinitely. + title: Effective end date + readOnly: true + status: + allOf: + - $ref: '#/components/schemas/BillingPlanStatus' + description: |- + The status of the plan. Computed based on the effective start and end dates: + + - `draft`: `effective_from` is not set. + - `scheduled`: `now < effective_from`. + - `active`: `effective_from <= now` and (`effective_to` is not set or + `now < effective_to`). + - `archived`: `effective_to <= now`. + title: Status + readOnly: true + phases: + type: array + items: + $ref: '#/components/schemas/BillingPlanPhase' + minItems: 1 + description: |- + The plan phases define the pricing ramp for a subscription. A phase switch + occurs only at the end of a billing period. At least one phase is required. + title: Plan phases + settlement_mode: + allOf: + - $ref: '#/components/schemas/BillingSettlementMode' + description: |- + Settlement mode for plan. + + Values: + + - `credit_then_invoice`: Credits are applied first, then any remainder is + invoiced. + - `credit_only`: Usage is settled exclusively against credits. + title: Settlement Mode + default: credit_then_invoice + readOnly: true + validation_errors: + type: array + items: + $ref: '#/components/schemas/ProductCatalogValidationError' + description: |- + List of validation errors in `draft` state that prevent the plan from being + published. + title: Validation errors + readOnly: true + additionalProperties: false + description: Plans provide a template for subscriptions. + BillingPlanPhase: + type: object + required: + - name + - key + - rate_cards + properties: + name: + type: string + minLength: 1 + maxLength: 256 + description: |- + Display name of the resource. + + Between 1 and 256 characters. + description: + type: string + maxLength: 1024 + description: |- + Optional description of the resource. + + Maximum 1024 characters. + labels: + $ref: '#/components/schemas/Labels' + key: + $ref: '#/components/schemas/ResourceKey' + duration: + allOf: + - $ref: '#/components/schemas/ISO8601Duration' + description: |- + The duration of the phase. When not specified, the phase runs indefinitely. Only + the last phase may omit the duration. + title: Duration + rate_cards: + type: array + items: + $ref: '#/components/schemas/BillingRateCard' + description: The rate cards of the plan. + title: Rate cards + additionalProperties: false + description: |- + The plan phase or pricing ramp allows changing a plan's rate cards over time as + a subscription progresses. + BillingPlanStatus: + type: string + enum: + - draft + - active + - archived + - scheduled + description: |- + The status of a plan. + + - `draft`: The plan has not yet been published and can be edited. + - `active`: The plan is published and can be used in subscriptions. + - `archived`: The plan is no longer available for use. + - `scheduled`: The plan is scheduled to be published at a future date. + BillingPrice: + type: object + oneOf: + - $ref: '#/components/schemas/BillingPriceFree' + - $ref: '#/components/schemas/BillingPriceFlat' + - $ref: '#/components/schemas/BillingPriceUnit' + - $ref: '#/components/schemas/BillingPriceGraduated' + - $ref: '#/components/schemas/BillingPriceVolume' + discriminator: + propertyName: type + mapping: + free: '#/components/schemas/BillingPriceFree' + flat: '#/components/schemas/BillingPriceFlat' + unit: '#/components/schemas/BillingPriceUnit' + graduated: '#/components/schemas/BillingPriceGraduated' + volume: '#/components/schemas/BillingPriceVolume' + description: Price. + BillingPriceFlat: + type: object + required: + - type + - amount + properties: + type: + type: string + enum: + - flat + description: The type of the price. + title: Type + amount: + allOf: + - $ref: '#/components/schemas/Numeric' + description: The amount of the flat price. + title: Amount + additionalProperties: false + description: Flat price. + BillingPriceFree: + type: object + required: + - type + properties: + type: + type: string + enum: + - free + description: The type of the price. + title: Type + additionalProperties: false + description: Free price. + BillingPriceGraduated: + type: object + required: + - type + - tiers + properties: + type: + type: string + enum: + - graduated + description: The type of the price. + title: Type + tiers: + type: array + items: + $ref: '#/components/schemas/BillingPriceTier' + minItems: 1 + description: The tiers of the graduated price. At least one tier is required. + title: Tiers + additionalProperties: false + description: |- + Graduated tiered price. + + Each tier's rate applies only to the usage within that tier. Pricing can change + as cumulative usage crosses tier boundaries. + + When UnitConfig is present on the rate card, tier boundaries (up_to_amount) are + expressed in converted billing units. + BillingPricePaymentTerm: + type: string + enum: + - in_advance + - in_arrears + description: The payment term of a flat price. + BillingPriceTier: + type: object + properties: + up_to_amount: + allOf: + - $ref: '#/components/schemas/Numeric' + description: |- + Up to and including this quantity will be contained in the tier. If undefined, + the tier is open-ended (the last tier). + title: Up to quantity + flat_price: + allOf: + - $ref: '#/components/schemas/BillingPriceFlat' + description: The flat price component of the tier. Charged once when the tier is entered. + title: Flat price component + unit_price: + allOf: + - $ref: '#/components/schemas/BillingPriceUnit' + description: The unit price component of the tier. Charged per billing unit within the tier. + title: Unit price component + additionalProperties: false + description: |- + A price tier used in graduated and volume pricing. + + At least one price component (flat_price or unit_price) must be set. When + UnitConfig is present on the rate card, up_to_amount is expressed in converted + billing units. + BillingPriceUnit: + type: object + required: + - type + - amount + properties: + type: + type: string + enum: + - unit + description: The type of the price. + title: Type + amount: + allOf: + - $ref: '#/components/schemas/Numeric' + description: The amount of the unit price. + title: Amount + additionalProperties: false + description: |- + Unit price. + + Charges a fixed rate per billing unit. When UnitConfig is present on the rate + card, billing units are the converted quantities (e.g. GB instead of bytes). + BillingPriceVolume: + type: object + required: + - type + - tiers + properties: + type: + type: string + enum: + - volume + description: The type of the price. + title: Type + tiers: + type: array + items: + $ref: '#/components/schemas/BillingPriceTier' + minItems: 1 + description: The tiers of the volume price. At least one tier is required. + title: Tiers + additionalProperties: false + description: |- + Volume tiered price. + + The maximum quantity within a period determines the per-unit price for all units + in that period. + + When UnitConfig is present on the rate card, tier boundaries (up_to_amount) are + expressed in converted billing units. + BillingProfile: + type: object + required: + - id + - name + - created_at + - updated_at + - supplier + - workflow + - apps + - default + properties: + id: + allOf: + - $ref: '#/components/schemas/ULID' + readOnly: true + name: + type: string + minLength: 1 + maxLength: 256 + description: |- + Display name of the resource. + + Between 1 and 256 characters. + description: + type: string + maxLength: 1024 + description: |- + Optional description of the resource. + + Maximum 1024 characters. + labels: + $ref: '#/components/schemas/Labels' + created_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: An ISO-8601 timestamp representation of entity creation date. + readOnly: true + updated_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: An ISO-8601 timestamp representation of entity last update date. + readOnly: true + deleted_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: An ISO-8601 timestamp representation of entity deletion date. + readOnly: true + supplier: + allOf: + - $ref: '#/components/schemas/BillingParty' + description: |- + The name and contact information for the supplier this billing profile + represents + workflow: + allOf: + - $ref: '#/components/schemas/BillingWorkflow' + description: The billing workflow settings for this profile + apps: + allOf: + - $ref: '#/components/schemas/BillingProfileAppReferences' + description: The applications used by this billing profile. + default: + type: boolean + description: Whether this is the default profile. + additionalProperties: false + description: |- + Billing profiles contain the settings for billing and controls invoice + generation. + BillingProfileAppReferences: + type: object + required: + - tax + - invoicing + - payment + properties: + tax: + allOf: + - $ref: '#/components/schemas/BillingAppReference' + description: The tax app used for this workflow. + invoicing: + allOf: + - $ref: '#/components/schemas/BillingAppReference' + description: The invoicing app used for this workflow. + payment: + allOf: + - $ref: '#/components/schemas/BillingAppReference' + description: The payment app used for this workflow. + additionalProperties: false + description: References to the applications used by a billing profile. + BillingProfilePagePaginatedResponse: + type: object + required: + - data + - meta + properties: + data: + type: array + items: + $ref: '#/components/schemas/BillingProfile' + meta: + $ref: '#/components/schemas/PaginatedMeta' + additionalProperties: false + description: Page paginated response. + BillingProfileReference: + type: object + required: + - id + properties: + id: + allOf: + - $ref: '#/components/schemas/ULID' + description: The ID of the billing profile. + additionalProperties: false + description: Billing profile reference. + BillingRateCard: + type: object + required: + - name + - key + - price + properties: + name: + type: string + minLength: 1 + maxLength: 256 + description: |- + Display name of the resource. + + Between 1 and 256 characters. + description: + type: string + maxLength: 1024 + description: |- + Optional description of the resource. + + Maximum 1024 characters. + labels: + $ref: '#/components/schemas/Labels' + key: + $ref: '#/components/schemas/ResourceKey' + feature: + allOf: + - $ref: '#/components/schemas/FeatureReference' + description: The feature associated with the rate card. + title: Feature reference + billing_cadence: + allOf: + - $ref: '#/components/schemas/ISO8601Duration' + description: |- + The billing cadence of the rate card. When null, the charge is one-time + (non-recurring). Only valid for flat prices. + title: Billing cadence + price: + allOf: + - $ref: '#/components/schemas/BillingPrice' + description: The price of the rate card. + title: Price + unit_config: + allOf: + - $ref: '#/components/schemas/BillingUnitConfig' + description: |- + Unit conversion configuration for the rate card. + + Synthesized on read for plans authored with v1 dynamic or package prices: + dynamic prices map to a unit price with a multiply unit config, and package + prices map to a unit price with a divide unit config. + + Accepted on create and update only when the UnitConfig feature is enabled on the + deployment; otherwise rejected. + title: Unit config + payment_term: + allOf: + - $ref: '#/components/schemas/BillingPricePaymentTerm' + description: |- + The payment term of the rate card. In advance payment term can only be used for + flat prices. + title: Payment term + default: in_arrears + commitments: + allOf: + - $ref: '#/components/schemas/BillingSpendCommitments' + description: |- + Spend commitments for this rate card. Only applicable to usage-based prices + (unit, graduated, volume). + title: Commitments + discounts: + allOf: + - $ref: '#/components/schemas/BillingRateCardDiscounts' + description: The discounts of the rate card. + title: Discounts + tax_config: + allOf: + - $ref: '#/components/schemas/BillingRateCardTaxConfig' + description: The tax config of the rate card. + title: Tax config + entitlement: + allOf: + - $ref: '#/components/schemas/BillingRateCardEntitlement' + description: |- + The entitlement template granted to subscribers of a plan or addon containing + this rate card. Requires `feature` to be set. + title: Entitlement template + additionalProperties: false + description: A rate card defines the pricing and entitlement of a feature or service. + BillingRateCardBooleanEntitlement: + type: object + required: + - type + properties: + type: + type: string + enum: + - boolean + description: The type of the entitlement template. + title: Type + additionalProperties: false + description: The entitlement template of a boolean entitlement. + BillingRateCardDiscounts: + type: object + properties: + percentage: + type: number + minimum: 0 + maximum: 100 + description: Percentage discount applied to the price (0–100). + usage: + allOf: + - $ref: '#/components/schemas/Numeric' + description: |- + Number of usage units granted free before billing starts. Only applies to + usage-based lines (not flat fees). Usage is treated as zero until this amount is + exhausted. + additionalProperties: false + description: Discount configuration for a rate card. + BillingRateCardEntitlement: + type: object + oneOf: + - $ref: '#/components/schemas/BillingRateCardMeteredEntitlement' + - $ref: '#/components/schemas/BillingRateCardStaticEntitlement' + - $ref: '#/components/schemas/BillingRateCardBooleanEntitlement' + discriminator: + propertyName: type + mapping: + metered: '#/components/schemas/BillingRateCardMeteredEntitlement' + static: '#/components/schemas/BillingRateCardStaticEntitlement' + boolean: '#/components/schemas/BillingRateCardBooleanEntitlement' + description: |- + Entitlement template configured on a rate card. The feature is taken from the + rate card itself, so it is omitted here. + BillingRateCardMeteredEntitlement: + type: object + required: + - type + properties: + type: + type: string + enum: + - metered + description: The type of the entitlement template. + title: Type + is_soft_limit: + type: boolean + description: |- + If soft limit is true, the subject can use the feature even if the entitlement + is exhausted; access remains granted. + title: Soft limit + default: false + limit: + type: number + format: double + minimum: 0 + description: |- + The amount of usage granted each usage period, in the feature's unit. Usage is + counted against this allowance and the balance resets every usage period. When + `is_soft_limit` is true the subject keeps access after the limit is reached; + otherwise access is denied once the allowance is exhausted. + title: Usage limit + usage_period: + allOf: + - $ref: '#/components/schemas/ISO8601Duration' + description: |- + The reset interval of the metered entitlement in ISO8601 format. Defaults to the + billing cadence of the rate card. + title: Usage period + additionalProperties: false + description: The entitlement template of a metered entitlement. + BillingRateCardProrationConfiguration: + type: object + required: + - mode + properties: + mode: + allOf: + - $ref: '#/components/schemas/BillingRateCardProrationMode' + description: The proration mode of the rate card. + title: Proration mode + additionalProperties: false + description: The proration configuration of the rate card. + BillingRateCardProrationMode: + type: string + enum: + - no_proration + - prorate_prices + description: |- + The proration mode of the rate card. + + Values: + + - `no_proration`: No proration. + - `prorate_prices`: Prorate the price based on the time remaining in the billing + period. + BillingRateCardStaticEntitlement: + type: object + required: + - type + - config + properties: + type: + type: string + enum: + - static + description: The type of the entitlement template. + title: Type + config: + description: |- + The entitlement config as a JSON object. Returned when checking entitlement + access; useful for configuring fine-grained access settings implemented in your + own system. + title: Config + additionalProperties: false + description: The entitlement template of a static entitlement. + BillingRateCardTaxConfig: + type: object + required: + - code + properties: + behavior: + $ref: '#/components/schemas/BillingTaxBehavior' + code: + $ref: '#/components/schemas/TaxCodeReference' + additionalProperties: false + description: The tax config of the rate card. + BillingSettlementMode: + type: string + enum: + - credit_then_invoice + - credit_only + description: |- + Settlement mode for billing. + + Values: + + - `credit_then_invoice`: Credits are applied first, then any remainder is + invoiced. + - `credit_only`: Usage is settled exclusively against credits. + title: Settlement mode + BillingSpendCommitments: + type: object + properties: + minimum_amount: + allOf: + - $ref: '#/components/schemas/Numeric' + description: The customer is committed to spend at least the amount. + title: Minimum amount + maximum_amount: + allOf: + - $ref: '#/components/schemas/Numeric' + description: The customer is limited to spend at most the amount. + title: Maximum amount + additionalProperties: false + description: |- + Spend commitments for a rate card. The customer is committed to spend at least + the minimum amount and at most the maximum amount. + BillingSubscription: + type: object + required: + - id + - created_at + - updated_at + - customer_id + - billing_anchor + - status + properties: + id: + allOf: + - $ref: '#/components/schemas/ULID' + readOnly: true + labels: + $ref: '#/components/schemas/Labels' + created_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: An ISO-8601 timestamp representation of entity creation date. + readOnly: true + updated_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: An ISO-8601 timestamp representation of entity last update date. + readOnly: true + deleted_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: An ISO-8601 timestamp representation of entity deletion date. + readOnly: true + customer_id: + allOf: + - $ref: '#/components/schemas/ULID' + description: The customer ID of the subscription. + title: Customer ID + readOnly: true + plan_id: + allOf: + - $ref: '#/components/schemas/ULID' + description: The plan ID of the subscription. Set if subscription is created from a plan. + title: Plan ID + readOnly: true + billing_anchor: + allOf: + - $ref: '#/components/schemas/DateTime' + description: |- + A billing anchor is the fixed point in time that determines the subscription's + recurring billing cycle. It affects when charges occur and how prorations are + calculated. Common anchors: + + - Calendar month (1st of each month): `2025-01-01T00:00:00Z` + - Subscription anniversary (day customer signed up) + - Custom date (customer-specified day) + title: Billing anchor + readOnly: true + status: + allOf: + - $ref: '#/components/schemas/BillingSubscriptionStatus' + description: The status of the subscription. + title: Status + readOnly: true + settlement_mode: + allOf: + - $ref: '#/components/schemas/BillingSettlementMode' + description: |- + Settlement mode for billing. + + Values: + + - `credit_then_invoice`: Credits are applied first, then any remainder is + invoiced. + - `credit_only`: Usage is settled exclusively against credits. + title: Settlement Mode + additionalProperties: false + description: Subscription. + BillingSubscriptionCancel: + type: object + properties: + timing: + allOf: + - $ref: '#/components/schemas/BillingSubscriptionEditTiming' + description: If not provided the subscription is canceled immediately. + default: immediate + additionalProperties: false + description: Request for canceling a subscription. + BillingSubscriptionChange: + type: object + required: + - customer + - plan + - timing + properties: + labels: + $ref: '#/components/schemas/Labels' + settlement_mode: + allOf: + - $ref: '#/components/schemas/BillingSettlementMode' + description: |- + Settlement mode for billing. + + Values: + + - `credit_then_invoice`: Credits are applied first, then any remainder is + invoiced. + - `credit_only`: Usage is settled exclusively against credits. + title: Settlement Mode + customer: + type: object + properties: + id: + allOf: + - $ref: '#/components/schemas/ULID' + description: |- + The ID of the customer to create the subscription for. + + Either customer ID or customer key must be provided. If both are provided, the + ID will be used. + title: Customer ID + key: + allOf: + - $ref: '#/components/schemas/ExternalResourceKey' + description: |- + The key of the customer to create the subscription for. + + Either customer ID or customer key must be provided. If both are provided, the + ID will be used. + title: Customer Key + additionalProperties: false + description: The customer to create the subscription for. + plan: + type: object + properties: + id: + allOf: + - $ref: '#/components/schemas/ULID' + description: |- + The plan ID of the subscription. Set if subscription is created from a plan. + + ID or Key of the plan is required if creating a subscription from a plan. If + both are provided, the ID will be used. + title: Plan ID + key: + allOf: + - $ref: '#/components/schemas/ResourceKey' + description: |- + The plan Key of the subscription, if any. Set if subscription is created from a + plan. + + ID or Key of the plan is required if creating a subscription from a plan. If + both are provided, the ID will be used. + title: Plan Key + version: + type: integer + description: |- + The plan version of the subscription, if any. If not provided, the latest + version of the plan will be used. + title: Plan Version + additionalProperties: false + description: The plan reference of the subscription. + billing_anchor: + allOf: + - $ref: '#/components/schemas/DateTime' + description: |- + A billing anchor is the fixed point in time that determines the subscription's + recurring billing cycle. It affects when charges occur and how prorations are + calculated. Common anchors: + + - Calendar month (1st of each month): `2025-01-01T00:00:00Z` + - Subscription anniversary (day customer signed up) + - Custom date (customer-specified day) + + If not provided, the subscription will be created with the subscription's + creation time as the billing anchor. + title: Billing anchor + timing: + allOf: + - $ref: '#/components/schemas/BillingSubscriptionEditTiming' + description: |- + Timing configuration for the change, when the change should take effect. For + changing a subscription, the accepted values depend on the subscription + configuration. + additionalProperties: false + description: Request for changing a subscription. + BillingSubscriptionChangeResponse: + type: object + required: + - current + - next + properties: + current: + allOf: + - $ref: '#/components/schemas/BillingSubscription' + description: The current subscription before the change. + next: + allOf: + - $ref: '#/components/schemas/BillingSubscription' + description: The new state of the subscription after the change. + additionalProperties: false + description: Response for changing a subscription. + BillingSubscriptionCreate: + type: object + required: + - customer + - plan + properties: + labels: + $ref: '#/components/schemas/Labels' + settlement_mode: + allOf: + - $ref: '#/components/schemas/BillingSettlementMode' + description: |- + Settlement mode for billing. + + Values: + + - `credit_then_invoice`: Credits are applied first, then any remainder is + invoiced. + - `credit_only`: Usage is settled exclusively against credits. + title: Settlement Mode + customer: + type: object + properties: + id: + allOf: + - $ref: '#/components/schemas/ULID' + description: |- + The ID of the customer to create the subscription for. + + Either customer ID or customer key must be provided. If both are provided, the + ID will be used. + title: Customer ID + key: + allOf: + - $ref: '#/components/schemas/ExternalResourceKey' + description: |- + The key of the customer to create the subscription for. + + Either customer ID or customer key must be provided. If both are provided, the + ID will be used. + title: Customer Key + additionalProperties: false + description: The customer to create the subscription for. + plan: + type: object + properties: + id: + allOf: + - $ref: '#/components/schemas/ULID' + description: |- + The plan ID of the subscription. Set if subscription is created from a plan. + + ID or Key of the plan is required if creating a subscription from a plan. If + both are provided, the ID will be used. + title: Plan ID + key: + allOf: + - $ref: '#/components/schemas/ResourceKey' + description: |- + The plan Key of the subscription, if any. Set if subscription is created from a + plan. + + ID or Key of the plan is required if creating a subscription from a plan. If + both are provided, the ID will be used. + title: Plan Key + version: + type: integer + description: |- + The plan version of the subscription, if any. If not provided, the latest + version of the plan will be used. + title: Plan Version + additionalProperties: false + description: The plan reference of the subscription. + billing_anchor: + allOf: + - $ref: '#/components/schemas/DateTime' + description: |- + A billing anchor is the fixed point in time that determines the subscription's + recurring billing cycle. It affects when charges occur and how prorations are + calculated. Common anchors: + + - Calendar month (1st of each month): `2025-01-01T00:00:00Z` + - Subscription anniversary (day customer signed up) + - Custom date (customer-specified day) + + If not provided, the subscription will be created with the subscription's + creation time as the billing anchor. + title: Billing anchor + additionalProperties: false + description: Subscription create request. + BillingSubscriptionEditTiming: + oneOf: + - $ref: '#/components/schemas/BillingSubscriptionEditTimingEnum' + - $ref: '#/components/schemas/DateTime' + description: |- + Subscription edit timing defined when the changes should take effect. If the + provided configuration is not supported by the subscription, an error will be + returned. + example: immediate + BillingSubscriptionEditTimingEnum: + type: string + enum: + - immediate + - next_billing_cycle + description: |- + Subscription edit timing. When immediate, the requested changes take effect + immediately. When next_billing_cycle, the requested changes take effect at the + next billing cycle. + BillingSubscriptionReference: + type: object + required: + - id + - phase + properties: + id: + allOf: + - $ref: '#/components/schemas/ULID' + description: The ID of the subscription. + title: Subscription ID + readOnly: true + phase: + type: object + properties: + id: + allOf: + - $ref: '#/components/schemas/ULID' + description: The ID of the phase. + title: Phase ID + readOnly: true + item: + type: object + properties: + id: + allOf: + - $ref: '#/components/schemas/ULID' + description: The ID of the item. + title: Item ID + readOnly: true + required: + - id + additionalProperties: false + description: The item of the phase. + title: Item ID + readOnly: true + required: + - id + - item + additionalProperties: false + description: The phase of the subscription. + title: Phase ID + readOnly: true + additionalProperties: false + description: |- + Subscription reference represents a reference to the specific subscription item + this entity represents. + BillingSubscriptionStatus: + type: string + enum: + - active + - inactive + - canceled + - scheduled + description: Subscription status. + BillingSupplier: + type: object + properties: + name: + type: string + description: Legal name or representation of the party. + tax_id: + allOf: + - $ref: '#/components/schemas/BillingPartyTaxIdentity' + description: |- + The entity's legal identification used for tax purposes. They may have other + numbers, but we're only interested in those valid for tax purposes. + addresses: + allOf: + - $ref: '#/components/schemas/BillingPartyAddresses' + description: Address for where information should be sent if needed. + id: + type: string + description: Unique identifier for the party. + additionalProperties: false + description: |- + Snapshot of the supplier's information at the time the invoice was issued. + + Structurally a read-only subset of `BillingParty` (the type configured on the + billing profile), so the snapshot stays aligned with the source. `key` is + omitted because it is not part of the snapshotted supplier data. + BillingTaxBehavior: + type: string + enum: + - inclusive + - exclusive + description: |- + Tax behavior. + + This enum is used to specify whether tax is included in the price or excluded + from the price. + BillingTaxCode: + type: object + required: + - id + - name + - created_at + - updated_at + - key + - app_mappings + properties: + id: + allOf: + - $ref: '#/components/schemas/ULID' + readOnly: true + name: + type: string + minLength: 1 + maxLength: 256 + description: |- + Display name of the resource. + + Between 1 and 256 characters. + description: + type: string + maxLength: 1024 + description: |- + Optional description of the resource. + + Maximum 1024 characters. + labels: + $ref: '#/components/schemas/Labels' + created_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: An ISO-8601 timestamp representation of entity creation date. + readOnly: true + updated_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: An ISO-8601 timestamp representation of entity last update date. + readOnly: true + deleted_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: An ISO-8601 timestamp representation of entity deletion date. + readOnly: true + key: + $ref: '#/components/schemas/ResourceKey' + app_mappings: + type: array + items: + $ref: '#/components/schemas/BillingTaxCodeAppMapping' + description: Mapping of app types to tax codes. + title: App type to tax code mappings + additionalProperties: false + description: Tax codes by provider. + BillingTaxCodeAppMapping: + type: object + required: + - app_type + - tax_code + properties: + app_type: + allOf: + - $ref: '#/components/schemas/BillingAppType' + description: The app type that the tax code is associated with. + title: App type + tax_code: + type: string + description: Tax code. + title: Tax code + additionalProperties: false + description: Mapping of app types to tax codes. + BillingTaxConfig: + type: object + properties: + behavior: + allOf: + - $ref: '#/components/schemas/BillingTaxBehavior' + description: |- + Tax behavior. + + If not specified the billing profile is used to determine the tax behavior. If + not specified in the billing profile, the provider's default behavior is used. + title: Tax behavior + stripe: + allOf: + - $ref: '#/components/schemas/BillingTaxConfigStripe' + description: Stripe tax config. + title: Stripe tax config + deprecated: true + external_invoicing: + allOf: + - $ref: '#/components/schemas/BillingTaxConfigExternalInvoicing' + description: External invoicing tax config. + title: External invoicing tax config + deprecated: true + tax_code_id: + allOf: + - $ref: '#/components/schemas/ULID' + description: Tax code ID. + title: Tax code ID + deprecated: true + tax_code: + allOf: + - $ref: '#/components/schemas/TaxCodeReference' + description: |- + Tax code reference. + + When both `tax_code` and `tax_code_id` are provided, `tax_code` takes + precedence. When `stripe.code` is also provided, `tax_code` still wins and + `stripe.code` is ignored. + title: Tax code + additionalProperties: false + description: Set of provider specific tax configs. + BillingTaxConfigExternalInvoicing: + type: object + required: + - code + properties: + code: + type: string + maxLength: 64 + description: The tax code should be interpreted by the external invoicing provider. + title: Tax code + additionalProperties: false + description: External invoicing tax config. + BillingTaxConfigStripe: + type: object + required: + - code + properties: + code: + type: string + pattern: ^txcd_\d{8}$ + description: Product [tax code](https://docs.stripe.com/tax/tax-codes). + title: Tax code + example: txcd_10000000 + additionalProperties: false + description: The tax config for Stripe. + BillingTaxIdentificationCode: + type: string + minLength: 1 + maxLength: 32 + description: |- + Tax identifier code is a normalized tax code shown on the original identity + document. + BillingTotals: + type: object + required: + - amount + - taxes_total + - taxes_inclusive_total + - taxes_exclusive_total + - charges_total + - discounts_total + - credits_total + - total + properties: + amount: + allOf: + - $ref: '#/components/schemas/Numeric' + description: The total value of the resource before taxes, discounts and commitments. + title: Amount + readOnly: true + taxes_total: + allOf: + - $ref: '#/components/schemas/Numeric' + description: The total tax amount applied to the resource. + title: Taxes total + readOnly: true + taxes_inclusive_total: + allOf: + - $ref: '#/components/schemas/Numeric' + description: The total tax amount already included in the resource amount. + title: Inclusive taxes total + readOnly: true + taxes_exclusive_total: + allOf: + - $ref: '#/components/schemas/Numeric' + description: The total tax amount added on top of the resource amount. + title: Exclusive taxes total + readOnly: true + charges_total: + allOf: + - $ref: '#/components/schemas/Numeric' + description: The total amount contributed by additional charges. + title: Charges total + readOnly: true + discounts_total: + allOf: + - $ref: '#/components/schemas/Numeric' + description: The total amount deducted through discounts. + title: Discounts total + readOnly: true + credits_total: + allOf: + - $ref: '#/components/schemas/Numeric' + description: The total amount deducted through credits before taxes are applied. + title: Credits total + readOnly: true + total: + allOf: + - $ref: '#/components/schemas/Numeric' + description: The final total value of the resource after taxes, discounts and commitments. + title: Total + readOnly: true + additionalProperties: false + description: Totals contains the summaries of all calculations for a billing resource. + BillingUnitConfig: + type: object + required: + - operation + - conversion_factor + properties: + operation: + allOf: + - $ref: '#/components/schemas/BillingUnitConfigOperation' + description: The arithmetic operation to apply to the raw metered quantity. + title: Conversion operation + conversion_factor: + allOf: + - $ref: '#/components/schemas/Numeric' + description: |- + The factor used in the conversion operation. + + - For `divide`: `converted = raw / conversionFactor`. + - For `multiply`: `converted = raw × conversionFactor`. + + Must be a positive non-zero value. + title: Conversion factor + rounding: + allOf: + - $ref: '#/components/schemas/BillingUnitConfigRoundingMode' + description: |- + The rounding mode applied to the converted quantity for invoicing. + + Defaults to none (no rounding). Entitlement checks always use the precise + (unrounded) value. + title: Rounding mode + default: none + precision: + type: integer + description: |- + The number of decimal places to retain after rounding. + + Only meaningful when rounding is not "none". Defaults to 0 (round to whole + numbers). + title: Rounding precision (decimal places) + default: 0 + display_unit: + type: string + description: |- + A human-readable label for the converted unit shown on invoices and in the + customer portal (e.g., "GB", "hours", "M tokens"). + + Optional. When omitted, no unit label is rendered. + title: Display unit label + additionalProperties: false + description: |- + Unit conversion configuration. + + Transforms raw metered quantities into billing-ready units before pricing and + entitlement evaluation. Applied at the rate card level so the same feature can + be billed in different units across plans. + + Examples: + + - Meter bytes, bill GB: operation=divide, conversionFactor=1e9, + rounding=ceiling, displayUnit="GB" + - Meter seconds, bill hours: operation=divide, conversionFactor=3600, + rounding=ceiling, displayUnit="hours" + - Cost + 20% margin: operation=multiply, conversionFactor=1.2 + - Bill per million tokens: operation=divide, conversionFactor=1e6, + rounding=ceiling, displayUnit="M" + + v1 equivalents: + + - DynamicPrice(multiplier): operation=multiply, conversionFactor=multiplier + + UnitPrice(amount=1) + - PackagePrice(amount, quantityPerPkg): operation=divide, + conversionFactor=quantityPerPkg, rounding=ceiling + UnitPrice(amount) + BillingUnitConfigOperation: + type: string + enum: + - divide + - multiply + description: |- + The arithmetic operation used to convert raw metered units into billing units. + + - `divide`: Divide the metered quantity by the conversion factor (e.g., bytes ÷ + 1e9 = GB). + - `multiply`: Multiply the metered quantity by the conversion factor (e.g., cost + × 1.2 = cost + 20% margin). + BillingUnitConfigRoundingMode: + type: string + enum: + - ceiling + - floor + - half_up + - none + description: |- + The rounding mode applied to the converted quantity for invoicing. + + Rounding is applied only to the invoiced quantity. Entitlement balance checks + use the precise decimal value after conversion. + + - `ceiling`: Round up to the next integer (typical for package-style billing). + - `floor`: Round down to the previous integer. + - `half_up`: Round to the nearest integer, with 0.5 rounding up. + - `none`: No rounding; the converted value is used as-is. + BillingWorkflow: + type: object + properties: + collection: + allOf: + - $ref: '#/components/schemas/BillingWorkflowCollectionSettings' + description: The collection settings for this workflow + invoicing: + allOf: + - $ref: '#/components/schemas/BillingWorkflowInvoicingSettings' + description: The invoicing settings for this workflow + payment: + allOf: + - $ref: '#/components/schemas/BillingWorkflowPaymentSettings' + description: The payment settings for this workflow + tax: + allOf: + - $ref: '#/components/schemas/BillingWorkflowTaxSettings' + description: The tax settings for this workflow + additionalProperties: false + description: Billing workflow settings. + BillingWorkflowCollectionAlignment: + type: object + oneOf: + - $ref: '#/components/schemas/BillingWorkflowCollectionAlignmentSubscription' + - $ref: '#/components/schemas/BillingWorkflowCollectionAlignmentAnchored' + discriminator: + propertyName: type + mapping: + subscription: '#/components/schemas/BillingWorkflowCollectionAlignmentSubscription' + anchored: '#/components/schemas/BillingWorkflowCollectionAlignmentAnchored' + description: |- + The alignment for collecting the pending line items into an invoice. + + Defaults to subscription, which means that we are to create a new invoice every + time the a subscription period starts (for in advance items) or ends (for in + arrears items). + BillingWorkflowCollectionAlignmentAnchored: + type: object + required: + - type + - recurring_period + properties: + type: + type: string + enum: + - anchored + description: The type of alignment. + recurring_period: + allOf: + - $ref: '#/components/schemas/RecurringPeriod' + description: The recurring period for the alignment. + additionalProperties: false + description: |- + BillingWorkflowCollectionAlignmentAnchored specifies the alignment for + collecting the pending line items into an invoice. + BillingWorkflowCollectionAlignmentSubscription: + type: object + required: + - type + properties: + type: + type: string + enum: + - subscription + description: The type of alignment. + additionalProperties: false + description: |- + BillingWorkflowCollectionAlignmentSubscription specifies the alignment for + collecting the pending line items into an invoice. + BillingWorkflowCollectionSettings: + type: object + properties: + alignment: + allOf: + - $ref: '#/components/schemas/BillingWorkflowCollectionAlignment' + description: The alignment for collecting the pending line items into an invoice. + default: + type: subscription + interval: + type: string + format: ISO8601 + description: |- + This grace period can be used to delay the collection of the pending line items + specified in alignment. + + This is useful, in case of multiple subscriptions having slightly different + billing periods. + example: P1D + default: PT1H + additionalProperties: false + description: |- + Workflow collection specifies how to collect the pending line items for an + invoice. + title: Workflow collection settings + BillingWorkflowInvoicingSettings: + type: object + properties: + auto_advance: + type: boolean + description: Whether to automatically issue the invoice after the draftPeriod has passed. + default: true + draft_period: + type: string + format: ISO8601 + description: The period for the invoice to be kept in draft status for manual reviews. + example: P1D + default: P0D + progressive_billing: + type: boolean + description: Should progressive billing be allowed for this workflow? + default: true + subscription_end_proration_mode: + allOf: + - $ref: '#/components/schemas/BillingWorkflowInvoicingSubscriptionEndProrationMode' + description: Controls how subscription-ending shortened service periods are billed. + default: bill_actual_period + additionalProperties: false + description: Invoice settings for a billing workflow. + title: Workflow invoice settings + BillingWorkflowInvoicingSubscriptionEndProrationMode: + type: string + enum: + - bill_full_period + - bill_actual_period + description: Billing workflow subscription end proration mode. + BillingWorkflowPaymentChargeAutomaticallySettings: + type: object + required: + - collection_method + properties: + collection_method: + type: string + enum: + - charge_automatically + description: The collection method for the invoice. + additionalProperties: false + description: |- + Payment settings for a billing workflow when the collection method is charge + automatically. + BillingWorkflowPaymentSendInvoiceSettings: + type: object + required: + - collection_method + properties: + collection_method: + type: string + enum: + - send_invoice + description: The collection method for the invoice. + due_after: + type: string + format: ISO8601 + description: |- + The period after which the invoice is due. With some payment solutions it's only + applicable for manual collection method. + example: P30D + default: P30D + additionalProperties: false + description: |- + Payment settings for a billing workflow when the collection method is send + invoice. + BillingWorkflowPaymentSettings: + type: object + oneOf: + - $ref: '#/components/schemas/BillingWorkflowPaymentChargeAutomaticallySettings' + - $ref: '#/components/schemas/BillingWorkflowPaymentSendInvoiceSettings' + discriminator: + propertyName: collection_method + mapping: + charge_automatically: '#/components/schemas/BillingWorkflowPaymentChargeAutomaticallySettings' + send_invoice: '#/components/schemas/BillingWorkflowPaymentSendInvoiceSettings' + description: Payment settings for a billing workflow. + BillingWorkflowTaxSettings: + type: object + properties: + enabled: + type: boolean + description: |- + Enable automatic tax calculation when tax is supported by the app. For example, + with Stripe Invoicing when enabled, tax is calculated via Stripe Tax. + default: true + enforced: + type: boolean + description: |- + Enforce tax calculation when tax is supported by the app. When enabled, the + billing system will not allow to create an invoice without tax calculation. + Enforcement is different per apps, for example, Stripe app requires customer to + have a tax location when starting a paid subscription. + default: false + default_tax_config: + allOf: + - $ref: '#/components/schemas/BillingTaxConfig' + description: |- + Default tax configuration to apply to the invoices for line items. + + Setting a tax code (`stripe.code` / `taxCodeId`) on a profile's default tax + config is deprecated and can no longer be added or changed: the organization + default tax code is used instead. Existing tax-code values may still be removed, + and `behavior` remains fully supported. + additionalProperties: false + description: Tax settings for a billing workflow. + title: Workflow tax settings + ChargePagePaginatedResponse: + type: object + required: + - data + - meta + properties: + data: + type: array + items: + $ref: '#/components/schemas/BillingCharge' + meta: + $ref: '#/components/schemas/PaginatedMeta' + additionalProperties: false + description: Page paginated response. + ClosedPeriod: + type: object + required: + - from + - to + properties: + from: + allOf: + - $ref: '#/components/schemas/DateTime' + description: |- + The start of the period. + + The period is inclusive at the start. + title: Start + example: '2023-01-01T01:01:01.001Z' + to: + allOf: + - $ref: '#/components/schemas/DateTime' + description: |- + The end of the period. + + The period is exclusive at the end. + title: End + example: '2023-01-01T01:01:01.001Z' + additionalProperties: false + description: |- + A period with defined start and end dates. + + The period is always inclusive at the start and exclusive at the end. + CostBasisPagePaginatedResponse: + type: object + required: + - data + - meta + properties: + data: + type: array + items: + $ref: '#/components/schemas/BillingCostBasis' + meta: + $ref: '#/components/schemas/PaginatedMeta' + additionalProperties: false + description: Page paginated response. + CountryCode: + type: string + minLength: 2 + maxLength: 2 + pattern: ^[A-Z]{2}$ + description: |- + [ISO 3166-1](https://www.iso.org/iso-3166-country-codes.html) alpha-2 country + code. Custom two-letter country codes are also supported for convenience. + example: US + CreateAddonRequest: + type: object + required: + - name + - key + - instance_type + - currency + - rate_cards + properties: + name: + type: string + minLength: 1 + maxLength: 256 + description: |- + Display name of the resource. + + Between 1 and 256 characters. + description: + type: string + maxLength: 1024 + description: |- + Optional description of the resource. + + Maximum 1024 characters. + labels: + $ref: '#/components/schemas/Labels' + key: + allOf: + - $ref: '#/components/schemas/ResourceKey' + description: |- + A key is a semi-unique string that is used to identify the add-on. It is used to + reference the latest `active` version of the add-on and is unique with the + version number. + title: Key + instance_type: + allOf: + - $ref: '#/components/schemas/AddonInstanceType' + description: The InstanceType of the add-ons. Can be "single" or "multiple". + title: The InstanceType of the add-ons. Can be "single" or "multiple". + currency: + allOf: + - $ref: '#/components/schemas/BillingCurrencyCode' + description: The currency code of the add-on. + title: Currency + rate_cards: + type: array + items: + $ref: '#/components/schemas/BillingRateCard' + description: The rate cards of the add-on. + title: Rate cards + additionalProperties: false + description: Addon create request. + CreateBillingProfileRequest: + type: object + required: + - name + - supplier + - workflow + - apps + - default + properties: + name: + type: string + minLength: 1 + maxLength: 256 + description: |- + Display name of the resource. + + Between 1 and 256 characters. + description: + type: string + maxLength: 1024 + description: |- + Optional description of the resource. + + Maximum 1024 characters. + labels: + $ref: '#/components/schemas/Labels' + supplier: + allOf: + - $ref: '#/components/schemas/BillingParty' + description: |- + The name and contact information for the supplier this billing profile + represents + workflow: + allOf: + - $ref: '#/components/schemas/BillingWorkflow' + description: The billing workflow settings for this profile + apps: + allOf: + - $ref: '#/components/schemas/BillingProfileAppReferences' + description: The applications used by this billing profile. + default: + type: boolean + description: Whether this is the default profile. + additionalProperties: false + description: BillingProfile create request. + CreateChargeFlatFeeRequest: + type: object + required: + - name + - type + - currency + - invoice_at + - service_period + - settlement_mode + - payment_term + - proration_configuration + - amount_before_proration + properties: + name: + type: string + minLength: 1 + maxLength: 256 + description: |- + Display name of the resource. + + Between 1 and 256 characters. + description: + type: string + maxLength: 1024 + description: |- + Optional description of the resource. + + Maximum 1024 characters. + labels: + $ref: '#/components/schemas/Labels' + type: + type: string + enum: + - flat_fee + description: The type of the charge. + title: Type + currency: + allOf: + - $ref: '#/components/schemas/CurrencyCode' + description: The currency of the charge. + title: Currency + invoice_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: The timestamp when the charge is intended to be invoiced. + title: Invoice at + service_period: + allOf: + - $ref: '#/components/schemas/ClosedPeriod' + description: The effective service period covered by the charge. + title: Service period + unique_reference_id: + type: string + description: Unique reference ID of the charge. + title: Unique reference ID + settlement_mode: + allOf: + - $ref: '#/components/schemas/BillingSettlementMode' + description: Settlement mode of the charge. + title: Settlement mode + tax_config: + allOf: + - $ref: '#/components/schemas/BillingTaxConfig' + description: Tax configuration of the charge. + title: Tax configuration + payment_term: + allOf: + - $ref: '#/components/schemas/BillingPricePaymentTerm' + description: Payment term of the flat fee charge. + title: Payment term + discounts: + allOf: + - $ref: '#/components/schemas/BillingChargeFlatFeeDiscounts' + description: The discounts applied to the charge. + title: Discounts + feature_key: + type: string + description: The feature associated with the charge, when applicable. + title: Feature key + proration_configuration: + allOf: + - $ref: '#/components/schemas/BillingRateCardProrationConfiguration' + description: The proration configuration of the charge. + title: Proration configuration + amount_before_proration: + allOf: + - $ref: '#/components/schemas/CurrencyAmount' + description: The amount before proration of the charge. + title: Amount before proration + full_service_period: + allOf: + - $ref: '#/components/schemas/ClosedPeriod' + description: The full, unprorated service period of the charge. + title: Full service period + billing_period: + allOf: + - $ref: '#/components/schemas/ClosedPeriod' + description: The billing period the charge belongs to. + title: Billing period + additionalProperties: false + description: Flat fee charge create request. + CreateChargeRequest: + type: object + oneOf: + - $ref: '#/components/schemas/CreateChargeFlatFeeRequest' + - $ref: '#/components/schemas/CreateChargeUsageBasedRequest' + discriminator: + propertyName: type + mapping: + flat_fee: '#/components/schemas/CreateChargeFlatFeeRequest' + usage_based: '#/components/schemas/CreateChargeUsageBasedRequest' + description: Customer charge. + title: Customer charge create request. + CreateChargeUsageBasedRequest: + type: object + required: + - name + - type + - currency + - invoice_at + - service_period + - settlement_mode + - feature_key + - price + properties: + name: + type: string + minLength: 1 + maxLength: 256 + description: |- + Display name of the resource. + + Between 1 and 256 characters. + description: + type: string + maxLength: 1024 + description: |- + Optional description of the resource. + + Maximum 1024 characters. + labels: + $ref: '#/components/schemas/Labels' + type: + type: string + enum: + - usage_based + description: The type of the charge. + title: Type + currency: + allOf: + - $ref: '#/components/schemas/CurrencyCode' + description: The currency of the charge. + title: Currency + invoice_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: The timestamp when the charge is intended to be invoiced. + title: Invoice at + service_period: + allOf: + - $ref: '#/components/schemas/ClosedPeriod' + description: The effective service period covered by the charge. + title: Service period + unique_reference_id: + type: string + description: Unique reference ID of the charge. + title: Unique reference ID + settlement_mode: + allOf: + - $ref: '#/components/schemas/BillingSettlementMode' + description: Settlement mode of the charge. + title: Settlement mode + tax_config: + allOf: + - $ref: '#/components/schemas/BillingTaxConfig' + description: Tax configuration of the charge. + title: Tax configuration + discounts: + allOf: + - $ref: '#/components/schemas/BillingRateCardDiscounts' + description: Discounts applied to the usage-based charge. + title: Discounts + feature_key: + type: string + description: The feature associated with the charge. + title: Feature key + price: + allOf: + - $ref: '#/components/schemas/BillingPrice' + description: The price of the charge. + title: Price + full_service_period: + allOf: + - $ref: '#/components/schemas/ClosedPeriod' + description: The full, unprorated service period of the charge. + title: Full service period + billing_period: + allOf: + - $ref: '#/components/schemas/ClosedPeriod' + description: The billing period the charge belongs to. + title: Billing period + additionalProperties: false + description: Usage-based charge create request. + CreateCostBasisRequest: + type: object + required: + - fiat_code + - rate + properties: + fiat_code: + allOf: + - $ref: '#/components/schemas/CurrencyCode' + description: The fiat currency code for the cost basis. + rate: + allOf: + - $ref: '#/components/schemas/Numeric' + description: The cost rate for the currency. + effective_from: + allOf: + - $ref: '#/components/schemas/DateTime' + description: |- + An ISO-8601 timestamp representation of the date from which the cost basis is + effective. If not provided, it will be effective immediately and will be set to + `now` by the system. + effective_to: + allOf: + - $ref: '#/components/schemas/DateTime' + description: |- + An ISO-8601 timestamp representation of the date until which the cost basis is + effective. If provided, it must be later than `effective_from`. If not provided, + it remains effective until superseded. + additionalProperties: false + description: CostBasis create request. + CreateCreditAdjustmentRequest: + type: object + required: + - name + - currency + - amount + properties: + name: + type: string + minLength: 1 + maxLength: 256 + description: |- + Display name of the resource. + + Between 1 and 256 characters. + description: + type: string + maxLength: 1024 + description: |- + Optional description of the resource. + + Maximum 1024 characters. + labels: + $ref: '#/components/schemas/Labels' + currency: + allOf: + - $ref: '#/components/schemas/BillingCurrencyCode' + description: The currency of the granted credits. + amount: + allOf: + - $ref: '#/components/schemas/Numeric' + description: Granted credit amount. + additionalProperties: false + description: CreditAdjustment create request. + CreateCreditGrantFilters: + type: object + properties: + features: + type: array + items: + $ref: '#/components/schemas/ResourceKey' + description: |- + Limit the credit grant to specific features. If no features are specified, the + credit grant can be used for any feature. + example: + - input_tokens + - output_tokens + additionalProperties: false + description: Filters for the credit grant. + CreateCreditGrantPurchase: + type: object + required: + - currency + properties: + currency: + allOf: + - $ref: '#/components/schemas/CurrencyCode' + description: Currency of the purchase amount. + per_unit_cost_basis: + allOf: + - $ref: '#/components/schemas/Numeric' + description: |- + Cost basis per credit unit used to calculate the purchase amount. + + If `per_unit_cost_basis` is 0.50 and credit amount is $100.00, the total charge + is $50.00. The value must be greater than 0. If the cost basis is 0, use + `funding_method=none` instead. + + Defaults to 1.0. + default: '1.0' + availability_policy: + allOf: + - $ref: '#/components/schemas/BillingCreditAvailabilityPolicy' + description: |- + Controls when credits become available for consumption. + + Defaults to `on_creation`. + default: on_creation + additionalProperties: false + description: Purchase and payment terms of the grant. + CreateCreditGrantRequest: + type: object + required: + - name + - funding_method + - currency + - amount + properties: + name: + type: string + minLength: 1 + maxLength: 256 + description: |- + Display name of the resource. + + Between 1 and 256 characters. + description: + type: string + maxLength: 1024 + description: |- + Optional description of the resource. + + Maximum 1024 characters. + labels: + $ref: '#/components/schemas/Labels' + funding_method: + allOf: + - $ref: '#/components/schemas/BillingCreditFundingMethod' + description: Funding method of the grant. + currency: + allOf: + - $ref: '#/components/schemas/CreateCurrencyCode' + description: The currency of the granted credits. + amount: + allOf: + - $ref: '#/components/schemas/Numeric' + description: Granted credit amount. + purchase: + allOf: + - $ref: '#/components/schemas/CreateCreditGrantPurchase' + description: Present when a funding workflow applies (funding_method is not `none`). + tax_config: + allOf: + - $ref: '#/components/schemas/CreateCreditGrantTaxConfig' + description: |- + Tax configuration for the grant. + + For `invoice` and `external` funding methods, tax configuration should be + provided to ensure correct revenue recognition. When not provided, the default + credit grant tax code is applied, if that's not set the global default taxcode + is used. + filters: + $ref: '#/components/schemas/CreateCreditGrantFilters' + priority: + type: integer + format: int16 + minimum: 1 + maximum: 1000 + description: Draw-down priority of the grant. Lower values have higher priority. + default: 10 + effective_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: |- + The timestamp when the credit grant becomes effective. + + Defaults to the current date and time. + expires_after: + allOf: + - $ref: '#/components/schemas/ISO8601Duration' + description: |- + The duration after which the credit grant expires. + + Defaults to never expiring. + key: + allOf: + - $ref: '#/components/schemas/ExternalResourceKey' + description: |- + Idempotency key for the credit grant creation request. + + When provided, reusing the same key returns an HTTP 409 Conflict instead of + creating a duplicate grant, which makes create requests safe to retry. + additionalProperties: false + description: CreditGrant create request. + CreateCreditGrantTaxConfig: + type: object + properties: + behavior: + allOf: + - $ref: '#/components/schemas/BillingTaxBehavior' + description: Tax behavior applied to the invoice line item. + tax_code: + allOf: + - $ref: '#/components/schemas/CreateResourceReference' + description: Tax code applied to the invoice line item. + additionalProperties: false + description: |- + Tax configuration for a credit grant. + + Tax configuration should be provided to ensure correct revenue recognition, + including for externally funded grants. + title: Tax configuration for a credit grant + CreateCurrencyCode: + type: string + allOf: + - $ref: '#/components/schemas/CurrencyCode' + description: Fiat or custom currency code. + x-go-type: string + CreateCurrencyCustomRequest: + type: object + required: + - name + - code + properties: + name: + type: string + minLength: 1 + maxLength: 256 + description: |- + The name of the currency. It should be a human-readable string that represents + the name of the currency, such as "US Dollar" or "Euro". + symbol: + type: string + minLength: 1 + description: |- + The symbol of the currency. It should be a string that represents the symbol of + the currency, such as "$" for US Dollar or "€" for Euro. + code: + $ref: '#/components/schemas/BillingCurrencyCodeCustom' + additionalProperties: false + description: CurrencyCustom create request. + CreateCustomerRequest: + type: object + required: + - name + - key + properties: + name: + type: string + minLength: 1 + maxLength: 256 + description: |- + Display name of the resource. + + Between 1 and 256 characters. + description: + type: string + maxLength: 1024 + description: |- + Optional description of the resource. + + Maximum 1024 characters. + labels: + $ref: '#/components/schemas/Labels' + key: + $ref: '#/components/schemas/ExternalResourceKey' + usage_attribution: + allOf: + - $ref: '#/components/schemas/BillingCustomerUsageAttribution' + description: Mapping to attribute metered usage to the customer by the event subject. + title: Usage Attribution + primary_email: + type: string + description: The primary email address of the customer. + title: Primary Email + currency: + allOf: + - $ref: '#/components/schemas/CurrencyCode' + description: Currency of the customer. Used for billing, tax and invoicing. + title: Currency + billing_address: + allOf: + - $ref: '#/components/schemas/Address' + description: The billing address of the customer. Used for tax and invoicing. + title: Billing Address + additionalProperties: false + description: Customer create request. + CreateFeatureRequest: + type: object + required: + - name + - key + properties: + name: + type: string + minLength: 1 + maxLength: 256 + description: |- + Display name of the resource. + + Between 1 and 256 characters. + description: + type: string + maxLength: 1024 + description: |- + Optional description of the resource. + + Maximum 1024 characters. + labels: + $ref: '#/components/schemas/Labels' + key: + $ref: '#/components/schemas/ResourceKey' + meter: + allOf: + - $ref: '#/components/schemas/FeatureMeterReference' + description: |- + The meter that the feature is associated with and based on which usage is + calculated. If not specified, the feature is static. + title: Meter reference + unit_cost: + allOf: + - $ref: '#/components/schemas/BillingFeatureUnitCost' + description: |- + Optional per-unit cost configuration. Use "manual" for a fixed per-unit cost, or + "llm" to look up cost from the LLM cost database based on meter group-by + properties. + title: Unit cost + additionalProperties: false + description: Feature create request. + CreateMeterRequest: + type: object + required: + - name + - key + - aggregation + - event_type + properties: + name: + type: string + minLength: 1 + maxLength: 256 + description: |- + Display name of the resource. + + Between 1 and 256 characters. + description: + type: string + maxLength: 1024 + description: |- + Optional description of the resource. + + Maximum 1024 characters. + labels: + $ref: '#/components/schemas/Labels' + key: + $ref: '#/components/schemas/ResourceKey' + aggregation: + allOf: + - $ref: '#/components/schemas/MeterAggregation' + description: The aggregation type to use for the meter. + event_type: + type: string + minLength: 1 + description: The event type to include in the aggregation. + example: prompt + events_from: + allOf: + - $ref: '#/components/schemas/DateTime' + description: |- + The date since the meter should include events. Useful to skip old events. If + not specified, all historical events are included. + value_property: + type: string + minLength: 1 + description: |- + JSONPath expression to extract the value from the ingested event's data + property. + + The ingested value for sum, avg, min, and max aggregations is a number or a + string that can be parsed to a number. + + For unique_count aggregation, the ingested value must be a string. For count + aggregation the value_property is ignored. + example: $.tokens + dimensions: + type: object + additionalProperties: + type: string + description: |- + Named JSONPath expressions to extract the group by values from the event data. + + Keys must be unique and consist only alphanumeric and underscore characters. + example: + type: $.type + additionalProperties: false + description: Meter create request. + CreatePlanAddonRequest: + type: object + required: + - name + - addon + - from_plan_phase + properties: + name: + type: string + minLength: 1 + maxLength: 256 + description: |- + Display name of the resource. + + Between 1 and 256 characters. + description: + type: string + maxLength: 1024 + description: |- + Optional description of the resource. + + Maximum 1024 characters. + labels: + $ref: '#/components/schemas/Labels' + addon: + allOf: + - $ref: '#/components/schemas/AddonReference' + description: The add-on associated with the plan. + title: Add-on + from_plan_phase: + allOf: + - $ref: '#/components/schemas/ResourceKey' + description: The key of the plan phase from which the add-on becomes available for purchase. + title: From plan phase + max_quantity: + type: integer + minimum: 1 + description: |- + The maximum number of times the add-on can be purchased for the plan. For + single-instance add-ons this field must be omitted. For multi-instance add-ons + when omitted, unlimited quantity can be purchased. + title: Max quantity + additionalProperties: false + description: PlanAddon create request. + CreatePlanRequest: + type: object + required: + - name + - key + - currency + - billing_cadence + - phases + properties: + name: + type: string + minLength: 1 + maxLength: 256 + description: |- + Display name of the resource. + + Between 1 and 256 characters. + description: + type: string + maxLength: 1024 + description: |- + Optional description of the resource. + + Maximum 1024 characters. + labels: + $ref: '#/components/schemas/Labels' + key: + allOf: + - $ref: '#/components/schemas/ResourceKey' + description: |- + A key is a semi-unique string that is used to identify the plan. It is used to + reference the latest `active` version of the plan and is unique with the version + number. + title: Key + currency: + allOf: + - $ref: '#/components/schemas/CurrencyCode' + description: The currency code of the plan. + title: Currency + billing_cadence: + allOf: + - $ref: '#/components/schemas/ISO8601Duration' + description: The billing cadence for subscriptions using this plan. + title: Billing cadence + pro_rating_enabled: + type: boolean + description: Whether pro-rating is enabled for this plan. + title: Pro-rating enabled + default: true + phases: + type: array + items: + $ref: '#/components/schemas/BillingPlanPhase' + minItems: 1 + description: |- + The plan phases define the pricing ramp for a subscription. A phase switch + occurs only at the end of a billing period. At least one phase is required. + title: Plan phases + additionalProperties: false + description: Plan create request. + CreateResourceReference: + type: object + required: + - id + properties: + id: + $ref: '#/components/schemas/ULID' + additionalProperties: false + description: TaxCode reference. + CreateSubscriptionAddonRequest: + type: object + required: + - addon + - quantity + - timing + properties: + labels: + $ref: '#/components/schemas/Labels' + addon: + allOf: + - $ref: '#/components/schemas/AddonReference' + description: The add-on associated with the subscription. + title: Add-on + quantity: + type: integer + minimum: 1 + description: The quantity of the add-on. Always 1 for single instance add-ons. + title: Quantity + timing: + allOf: + - $ref: '#/components/schemas/BillingSubscriptionEditTiming' + description: |- + The timing of the operation. After the create or update, a new entry will be + created in the timeline. + title: Timing + additionalProperties: false + description: SubscriptionAddon create request. + CreateTaxCodeRequest: + type: object + required: + - name + - key + - app_mappings + properties: + name: + type: string + minLength: 1 + maxLength: 256 + description: |- + Display name of the resource. + + Between 1 and 256 characters. + description: + type: string + maxLength: 1024 + description: |- + Optional description of the resource. + + Maximum 1024 characters. + labels: + $ref: '#/components/schemas/Labels' + key: + $ref: '#/components/schemas/ResourceKey' + app_mappings: + type: array + items: + $ref: '#/components/schemas/BillingTaxCodeAppMapping' + description: Mapping of app types to tax codes. + title: App type to tax code mappings + additionalProperties: false + description: TaxCode create request. + CreditBalance: + type: object + required: + - currency + - live + - settled + - pending + properties: + currency: + allOf: + - $ref: '#/components/schemas/BillingCurrencyCode' + readOnly: true + live: + allOf: + - $ref: '#/components/schemas/Numeric' + description: Credits available after applying currently live charge impacts. + example: '200.00' + readOnly: true + settled: + allOf: + - $ref: '#/components/schemas/Numeric' + description: Credits that have been booked on the ledger as of the balance timestamp. + example: '150.00' + readOnly: true + pending: + allOf: + - $ref: '#/components/schemas/Numeric' + description: |- + Credits that have been granted but are not yet written to the ledger, or are + written to the ledger with a future booked time. + example: '50.00' + readOnly: true + additionalProperties: false + description: The credit balance by currency. + title: Credit balance + CreditGrantPagePaginatedResponse: + type: object + required: + - data + - meta + properties: + data: + type: array + items: + $ref: '#/components/schemas/BillingCreditGrant' + meta: + $ref: '#/components/schemas/PaginatedMeta' + additionalProperties: false + description: Page paginated response. + CreditTransactionPaginatedResponse: + type: object + required: + - data + - meta + properties: + data: + type: array + items: + $ref: '#/components/schemas/BillingCreditTransaction' + meta: + $ref: '#/components/schemas/CursorMeta' + additionalProperties: false + description: Cursor paginated response. + CurrencyAmount: + type: object + required: + - amount + - currency + properties: + amount: + $ref: '#/components/schemas/Numeric' + currency: + $ref: '#/components/schemas/CurrencyCode' + additionalProperties: false + description: Monetary amount in a specific currency. + CurrencyCode: + type: string + minLength: 3 + maxLength: 3 + pattern: ^[A-Z]{3}$ + description: |- + Three-letter [ISO4217](https://www.iso.org/iso-4217-currency-codes.html) + currency code. Custom three-letter currency codes are also supported for + convenience. + example: USD + CurrencyPagePaginatedResponse: + type: object + required: + - data + - meta + properties: + data: + type: array + items: + $ref: '#/components/schemas/BillingCurrency' + meta: + $ref: '#/components/schemas/PaginatedMeta' + additionalProperties: false + description: Page paginated response. + CursorPaginationQueryPage: + type: object + properties: + size: + type: integer + description: The number of items to include per page. + after: + type: string + description: Request the next page of data, starting with the item after this parameter. + before: + type: string + description: Request the previous page of data, starting with the item before this parameter. + additionalProperties: false + description: Determines which page of the collection to retrieve. + CustomerPagePaginatedResponse: + type: object + required: + - data + - meta + properties: + data: + type: array + items: + $ref: '#/components/schemas/BillingCustomer' + meta: + $ref: '#/components/schemas/PaginatedMeta' + additionalProperties: false + description: Page paginated response. + CustomerReference: + type: object + required: + - id + properties: + id: + $ref: '#/components/schemas/ULID' + additionalProperties: false + description: Customer reference. + DateTime: + type: string + format: date-time + description: |- + [RFC3339](https://tools.ietf.org/html/rfc3339) formatted date-time string in + UTC. + title: RFC3339 Date-Time + example: '2023-01-01T01:01:01.001Z' + DateTimeFieldFilter: + anyOf: + - $ref: '#/components/schemas/DateTime' + - type: object + properties: + eq: + allOf: + - $ref: '#/components/schemas/DateTime' + description: Value strictly equals given RFC-3339 formatted timestamp in UTC. + lt: + allOf: + - $ref: '#/components/schemas/DateTime' + description: Value is less than the given RFC-3339 formatted timestamp in UTC. + lte: + allOf: + - $ref: '#/components/schemas/DateTime' + description: Value is less than or equal to the given RFC-3339 formatted timestamp in UTC. + gt: + allOf: + - $ref: '#/components/schemas/DateTime' + description: Value is greater than the given RFC-3339 formatted timestamp in UTC. + gte: + allOf: + - $ref: '#/components/schemas/DateTime' + description: Value is greater than or equal to the given RFC-3339 formatted timestamp in UTC. + additionalProperties: false + description: |- + Filters on the given datetime (RFC-3339) field value. All properties are + optional; provide exactly one to specify the comparison. + title: DateTime Field Filter + x-go-type-import: + path: github.com/openmeterio/openmeter/api/v3/filters + x-go-type: filters.FilterDateTime + ExternalResourceKey: + type: string + minLength: 1 + maxLength: 256 + description: |- + ExternalResourceKey is a unique string that is used to identify a resource in an + external system. + title: External Resource Key + example: 019ae40f-4258-7f15-9491-842f42a7d6ac + Feature: + type: object + required: + - id + - name + - created_at + - updated_at + - key + properties: + id: + allOf: + - $ref: '#/components/schemas/ULID' + readOnly: true + name: + type: string + minLength: 1 + maxLength: 256 + description: |- + Display name of the resource. + + Between 1 and 256 characters. + description: + type: string + maxLength: 1024 + description: |- + Optional description of the resource. + + Maximum 1024 characters. + labels: + $ref: '#/components/schemas/Labels' + created_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: An ISO-8601 timestamp representation of entity creation date. + readOnly: true + updated_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: An ISO-8601 timestamp representation of entity last update date. + readOnly: true + deleted_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: An ISO-8601 timestamp representation of entity deletion date. + readOnly: true + key: + $ref: '#/components/schemas/ResourceKey' + meter: + allOf: + - $ref: '#/components/schemas/FeatureMeterReference' + description: |- + The meter that the feature is associated with and based on which usage is + calculated. If not specified, the feature is static. + title: Meter reference + unit_cost: + allOf: + - $ref: '#/components/schemas/BillingFeatureUnitCost' + description: |- + Optional per-unit cost configuration. Use "manual" for a fixed per-unit cost, or + "llm" to look up cost from the LLM cost database based on meter group-by + properties. + title: Unit cost + additionalProperties: false + description: A capability or billable dimension offered by a provider. + FeatureCostQueryResult: + type: object + required: + - data + properties: + from: + allOf: + - $ref: '#/components/schemas/DateTime' + description: Start of the queried period. + to: + allOf: + - $ref: '#/components/schemas/DateTime' + description: End of the queried period. + data: + type: array + items: + $ref: '#/components/schemas/FeatureCostQueryRow' + description: The cost data rows. + additionalProperties: false + description: Result of a feature cost query. + FeatureCostQueryRow: + type: object + required: + - usage + - cost + - currency + - from + - to + - dimensions + properties: + usage: + allOf: + - $ref: '#/components/schemas/Numeric' + description: The metered usage value for the period. + cost: + type: string + allOf: + - $ref: '#/components/schemas/Numeric' + nullable: true + description: |- + The computed cost amount (usage × unit cost). Null when pricing is not available + for the given combination of dimensions. + currency: + allOf: + - $ref: '#/components/schemas/CurrencyCode' + description: The currency code of the cost amount. + detail: + type: string + description: |- + Detail message when cost amount is null, explaining why the cost could not be + resolved. + from: + allOf: + - $ref: '#/components/schemas/DateTime' + description: The start of the time bucket the value is aggregated over. + to: + allOf: + - $ref: '#/components/schemas/DateTime' + description: The end of the time bucket the value is aggregated over. + dimensions: + type: object + additionalProperties: + type: string + description: |- + The dimensions the value is aggregated over. `subject` and `customer_id` are + reserved dimensions. + additionalProperties: false + description: A row in the result of a feature cost query. + FeatureMeterReference: + type: object + required: + - id + properties: + id: + allOf: + - $ref: '#/components/schemas/ULID' + description: The ID of the meter to associate with this feature. + title: Meter ID + filters: + type: object + additionalProperties: + $ref: '#/components/schemas/QueryFilterStringMapItem' + maxProperties: 10 + description: Filters to apply to the dimensions of the meter. + title: Meter dimensions filters + additionalProperties: false + description: Reference to a meter associated with a feature. + FeaturePagePaginatedResponse: + type: object + required: + - data + - meta + properties: + data: + type: array + items: + $ref: '#/components/schemas/Feature' + meta: + $ref: '#/components/schemas/PaginatedMeta' + additionalProperties: false + description: Page paginated response. + FeatureReference: + type: object + required: + - id + properties: + id: + $ref: '#/components/schemas/ULID' + additionalProperties: false + description: Feature reference. + GetCreditBalanceParamsFilter: + type: object + properties: + currency: + allOf: + - $ref: '#/components/schemas/StringFieldFilterExact' + description: Filter credit balance by currency. + feature_key: + allOf: + - $ref: '#/components/schemas/StringFieldFilter' + description: |- + Filter credit balance by feature key. Omit to return the total portfolio value. + Use `exists=false` to return only unrestricted balance. + additionalProperties: false + description: Filter options for getting a credit balance. + GovernanceFeatureAccess: + type: object + required: + - has_access + properties: + has_access: + type: boolean + description: |- + Whether the customer currently has access to the feature. + + `true` for boolean and static entitlements that are available, and for metered + entitlements with remaining balance. `false` when the feature is unavailable, + the usage limit has been reached, or (when applicable) credits have been + exhausted. + title: Has access + readOnly: true + reason: + allOf: + - $ref: '#/components/schemas/GovernanceFeatureAccessReason' + description: |- + Optional reason when the customer does not have access to the feature. Populated + when `has_access` is `false`. + title: Reason + readOnly: true + additionalProperties: false + description: Access status for a single feature. + GovernanceFeatureAccessReason: + type: object + required: + - code + - message + properties: + code: + allOf: + - $ref: '#/components/schemas/GovernanceFeatureAccessReasonCode' + description: Machine-readable error code. + title: Code + readOnly: true + message: + type: string + description: Human-readable description of the error. + title: Message + readOnly: true + attributes: + type: object + additionalProperties: {} + description: Additional structured context. + title: Attributes + readOnly: true + additionalProperties: false + description: Reason a feature is not accessible to a customer. + GovernanceFeatureAccessReasonCode: + type: string + enum: + - unknown + - usage_limit_reached + - feature_unavailable + - feature_not_found + - no_credit_available + description: Machine-readable reason code for denied feature access. + GovernanceQueryError: + type: object + required: + - code + - message + properties: + code: + allOf: + - $ref: '#/components/schemas/GovernanceQueryErrorCode' + description: Machine-readable error code. + title: Code + readOnly: true + message: + type: string + description: Human-readable description of the error. + title: Message + readOnly: true + attributes: + type: object + additionalProperties: {} + description: Additional structured context. + title: Attributes + readOnly: true + customer: + type: string + description: The customer identifier from the request that produced this error. + title: Customer identifier + readOnly: true + additionalProperties: false + description: Query error within a partially successful governance query response. + GovernanceQueryErrorCode: + type: string + enum: + - unknown + - customer_not_found + description: Error code for a governance query failure. + GovernanceQueryRequest: + type: object + required: + - customer + properties: + include_credits: + type: boolean + description: |- + Whether to include credit balance availability for each resolved customer. When + true, each feature evaluation includes credit balance checks. + + Defaults to `false`. + title: Include credits + default: false + customer: + allOf: + - $ref: '#/components/schemas/GovernanceQueryRequestCustomers' + title: Customer + feature: + allOf: + - $ref: '#/components/schemas/GovernanceQueryRequestFeatures' + title: Feature + additionalProperties: false + description: Query to evaluate feature access for a list of customers. + GovernanceQueryRequestCustomers: + type: object + required: + - keys + properties: + keys: + type: array + items: + type: string + minItems: 1 + maxItems: 100 + description: |- + Each entry can be a customer `key` or a usage-attribution subject `key`. + Identifiers that cannot be resolved to a customer are reported in the response + `errors` array. + title: Customer keys and usage-attribution subjects + additionalProperties: false + description: List of customer identifiers to evaluate access for. + GovernanceQueryRequestFeatures: + type: object + required: + - keys + properties: + keys: + type: array + items: + type: string + minItems: 1 + maxItems: 100 + description: List of feature keys to evaluate access for. + title: Feature Keys + additionalProperties: false + description: |- + Optional list of feature keys to evaluate access for. If omitted, all features + available in the organization are returned. Providing this list is recommended + to reduce the response size and the load on the backend services. + GovernanceQueryResponse: + type: object + required: + - data + - errors + - meta + properties: + data: + type: array + items: + $ref: '#/components/schemas/GovernanceQueryResult' + description: Access evaluation results, one entry per resolved customer. + title: Data + readOnly: true + errors: + type: array + items: + $ref: '#/components/schemas/GovernanceQueryError' + description: Partial errors encountered while processing the request. + title: Errors + readOnly: true + meta: + allOf: + - $ref: '#/components/schemas/CursorMeta' + description: |- + Pagination metadata. The endpoint may return a partial response if the full + response would exceed server-side limits. + title: Meta + readOnly: true + additionalProperties: false + description: Response of the governance query. + GovernanceQueryResult: + type: object + required: + - matched + - customer + - features + - updated_at + properties: + matched: + type: array + items: + type: string + description: |- + The list of identifiers from the request that resolved to this customer. Each + entry is either the customer `key` or one of its usage-attribution subject + `key`s. + + Duplicate or aliased identifiers that resolve to the same customer collapse to a + single result entry, with every requested identifier listed here. + title: Matched identifiers + readOnly: true + customer: + allOf: + - $ref: '#/components/schemas/BillingCustomer' + description: The customer the matched identifiers resolved to. + title: Customer + readOnly: true + features: + type: object + additionalProperties: + $ref: '#/components/schemas/GovernanceFeatureAccess' + description: |- + Map of features with their access status. + + Map keys are the feature keys requested in `feature.keys`, or every feature + `key` available in the organization when the feature filter was omitted. + title: Features + readOnly: true + updated_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: |- + Timestamp of the most recent change to the customer's access state reflected in + this result. + title: Updated at + readOnly: true + additionalProperties: false + description: Access evaluation result for a single resolved customer. + ISO8601Duration: + type: string + pattern: ^P(?:\d+(?:\.\d+)?Y)?(?:\d+(?:\.\d+)?M)?(?:\d+(?:\.\d+)?W)?(?:\d+(?:\.\d+)?D)?(?:T(?:\d+(?:\.\d+)?H)?(?:\d+(?:\.\d+)?M)?(?:\d+(?:\.\d+)?S)?)?$ + description: |- + [ISO 8601 Duration](https://docs.digi.com/resources/documentation/digidocs/90001488-13/reference/r_iso_8601_duration_format.htm) + string. + title: ISO 8601 Duration + example: P1Y + format: ISO8601 + IngestedEventPaginatedResponse: + type: object + required: + - data + - meta + properties: + data: + type: array + items: + $ref: '#/components/schemas/MeteringIngestedEvent' + meta: + $ref: '#/components/schemas/CursorMeta' + additionalProperties: false + description: Cursor paginated response. + InvoicePagePaginatedResponse: + type: object + required: + - data + - meta + properties: + data: + type: array + items: + $ref: '#/components/schemas/BillingInvoice' + meta: + $ref: '#/components/schemas/PaginatedMeta' + additionalProperties: false + description: Page paginated response. + LLMCostModel: + type: object + required: + - id + - name + properties: + id: + type: string + description: Identifier of the model, e.g., "gpt-4", "claude-3-5-sonnet". + name: + type: string + description: Name of the model, e.g., "GPT-4", "Claude 3.5 Sonnet". + additionalProperties: false + description: LLM Model + LLMCostModelPricing: + type: object + required: + - input_per_token + - output_per_token + properties: + input_per_token: + allOf: + - $ref: '#/components/schemas/Numeric' + description: Input price per token (USD). + output_per_token: + allOf: + - $ref: '#/components/schemas/Numeric' + description: Output price per token (USD). + cache_read_per_token: + allOf: + - $ref: '#/components/schemas/Numeric' + description: Cache read price per token (USD). + cache_write_per_token: + allOf: + - $ref: '#/components/schemas/Numeric' + description: Cache write price per token (USD). + reasoning_per_token: + allOf: + - $ref: '#/components/schemas/Numeric' + description: Reasoning output price per token (USD). + additionalProperties: false + description: Token pricing for an LLM model, denominated per token. + LLMCostOverrideCreate: + type: object + required: + - provider + - model_id + - pricing + - currency + - effective_from + properties: + provider: + type: string + description: Provider/vendor of the model. + model_id: + type: string + description: Canonical model identifier. + model_name: + type: string + description: Human-readable model name. + pricing: + allOf: + - $ref: '#/components/schemas/LLMCostModelPricing' + description: Token pricing data. + currency: + allOf: + - $ref: '#/components/schemas/CurrencyCode' + description: Currency code. + effective_from: + allOf: + - $ref: '#/components/schemas/DateTime' + description: When this override becomes effective. + effective_to: + allOf: + - $ref: '#/components/schemas/DateTime' + description: When this override expires. + additionalProperties: false + description: |- + Input for creating a per-namespace price override. Unique per provider, model + and currency. If an override already exists for the given provider, model and + currency, it will be updated. If an override does not exist, it will be created. + LLMCostPrice: + type: object + required: + - id + - provider + - model + - pricing + - currency + - source + - effective_from + - created_at + - updated_at + properties: + id: + allOf: + - $ref: '#/components/schemas/ULID' + description: Unique identifier. + readOnly: true + provider: + allOf: + - $ref: '#/components/schemas/LLMCostProvider' + description: Provider of the model. + readOnly: true + model: + allOf: + - $ref: '#/components/schemas/LLMCostModel' + description: The model. + readOnly: true + pricing: + allOf: + - $ref: '#/components/schemas/LLMCostModelPricing' + description: Token pricing data. + readOnly: true + currency: + allOf: + - $ref: '#/components/schemas/CurrencyCode' + description: Currency code (currently always "USD"). + readOnly: true + source: + allOf: + - $ref: '#/components/schemas/LLMCostPriceSource' + description: Where this price came from. + readOnly: true + effective_from: + allOf: + - $ref: '#/components/schemas/DateTime' + description: When this price becomes effective. + readOnly: true + effective_to: + allOf: + - $ref: '#/components/schemas/DateTime' + description: When this price expires. Omitted when the price is currently effective. + readOnly: true + created_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: Creation timestamp. + readOnly: true + updated_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: Last update timestamp. + readOnly: true + additionalProperties: false + description: |- + An LLM cost price record, representing the cost per token for a specific model + from a specific provider. + LLMCostPriceSource: + type: string + enum: + - manual + - system + description: Identifies where an LLM cost price came from. + LLMCostProvider: + type: object + required: + - id + - name + properties: + id: + type: string + description: Identifier of the provider, e.g., "openai", "anthropic". + name: + type: string + description: Name of the provider, e.g., "OpenAI", "Anthropic". + additionalProperties: false + description: LLM Provider + ListAddonsParamsFilter: + type: object + properties: + id: + $ref: '#/components/schemas/ULIDFieldFilter' + key: + $ref: '#/components/schemas/StringFieldFilter' + name: + $ref: '#/components/schemas/StringFieldFilter' + status: + $ref: '#/components/schemas/StringFieldFilterExact' + currency: + $ref: '#/components/schemas/StringFieldFilterExact' + additionalProperties: false + description: Filter options for listing add-ons. + ListChargesParamsFilter: + type: object + properties: + status: + allOf: + - $ref: '#/components/schemas/StringFieldFilterExact' + description: |- + Filter charges by status. + + Supported statuses are: + + - `created` + - `active` + - `final` + - `deleted` + + If omitted, all statuses are returned except for `deleted`. + additionalProperties: false + description: Filter options for listing charges. + ListCostBasesParamsFilter: + type: object + properties: + fiat_code: + allOf: + - $ref: '#/components/schemas/CurrencyCode' + description: Filter cost bases by fiat currency code. + additionalProperties: false + description: Filter options for listing cost bases. + ListCreditGrantsParamsFilter: + type: object + properties: + status: + allOf: + - $ref: '#/components/schemas/BillingCreditGrantStatus' + description: Filter credit grants by status. + currency: + allOf: + - $ref: '#/components/schemas/CurrencyCode' + description: Filter credit grants by currency. + key: + allOf: + - $ref: '#/components/schemas/StringFieldFilter' + description: Filter credit grants by key. + additionalProperties: false + description: Filter options for listing credit grants. + ListCreditTransactionsParamsFilter: + type: object + properties: + type: + allOf: + - $ref: '#/components/schemas/BillingCreditTransactionType' + description: Filter credit transactions by type. + currency: + allOf: + - $ref: '#/components/schemas/BillingCurrencyCode' + description: Filter credit transactions by currency. + feature_key: + allOf: + - $ref: '#/components/schemas/StringFieldFilter' + description: |- + Filter credit transactions by feature key. Omit to return all credit + transactions. Use `exists=false` to return only unrestricted credit + transactions. + additionalProperties: false + description: Filter options for listing credit transactions. + ListCurrenciesParamsFilter: + type: object + properties: + type: + $ref: '#/components/schemas/BillingCurrencyType' + code: + $ref: '#/components/schemas/StringFieldFilter' + additionalProperties: false + description: Filter options for listing currencies. + ListCustomerEntitlementAccessResponseData: + type: object + required: + - data + properties: + data: + type: array + items: + $ref: '#/components/schemas/BillingEntitlementAccessResult' + description: The list of entitlement access results. + readOnly: true + additionalProperties: false + description: List customer entitlement access response data. + ListCustomersParamsFilter: + type: object + properties: + key: + $ref: '#/components/schemas/StringFieldFilter' + name: + $ref: '#/components/schemas/StringFieldFilter' + primary_email: + $ref: '#/components/schemas/StringFieldFilter' + usage_attribution_subject_key: + $ref: '#/components/schemas/StringFieldFilter' + plan_key: + $ref: '#/components/schemas/StringFieldFilter' + billing_profile_id: + $ref: '#/components/schemas/ULIDFieldFilter' + additionalProperties: false + description: Filter options for listing customers. + ListEventsParamsFilter: + type: object + properties: + id: + allOf: + - $ref: '#/components/schemas/StringFieldFilter' + description: Filter events by ID. + source: + allOf: + - $ref: '#/components/schemas/StringFieldFilter' + description: Filter events by source. + subject: + allOf: + - $ref: '#/components/schemas/StringFieldFilter' + description: Filter events by subject. + type: + allOf: + - $ref: '#/components/schemas/StringFieldFilter' + description: Filter events by type. + customer_id: + allOf: + - $ref: '#/components/schemas/ULIDFieldFilter' + description: Filter events by the associated customer ID. + time: + allOf: + - $ref: '#/components/schemas/DateTimeFieldFilter' + description: Filter events by event time. + ingested_at: + allOf: + - $ref: '#/components/schemas/DateTimeFieldFilter' + description: Filter events by the time the event was ingested. + stored_at: + allOf: + - $ref: '#/components/schemas/DateTimeFieldFilter' + description: Filter events by the time the event was stored. + additionalProperties: false + description: Filter options for listing ingested events. + ListFeatureParamsFilter: + type: object + properties: + meter_id: + $ref: '#/components/schemas/ULIDFieldFilter' + key: + $ref: '#/components/schemas/StringFieldFilter' + name: + $ref: '#/components/schemas/StringFieldFilter' + additionalProperties: false + description: Filter options for listing features. + ListInvoicesParamsFilter: + type: object + properties: + status: + allOf: + - $ref: '#/components/schemas/StringFieldFilterExact' + description: Filter by invoice status. + customer_id: + allOf: + - $ref: '#/components/schemas/ULIDFieldFilter' + description: Filter by customer ID. + issued_at: + allOf: + - $ref: '#/components/schemas/DateTimeFieldFilter' + description: Filter by the time the invoice was issued. + service_period_start: + allOf: + - $ref: '#/components/schemas/DateTimeFieldFilter' + description: Filter by service period start. + created_at: + allOf: + - $ref: '#/components/schemas/DateTimeFieldFilter' + description: Filter by invoice creation time. + additionalProperties: false + description: Filter options for listing invoices. + ListLLMCostPricesParamsFilter: + type: object + properties: + provider: + allOf: + - $ref: '#/components/schemas/StringFieldFilter' + description: Filter by provider. e.g. ?filter[provider][eq]=openai + model_id: + allOf: + - $ref: '#/components/schemas/StringFieldFilter' + description: Filter by model ID. e.g. ?filter[model_id][eq]=gpt-4 + model_name: + allOf: + - $ref: '#/components/schemas/StringFieldFilter' + description: Filter by model name. e.g. ?filter[model_name][contains]=gpt + currency: + allOf: + - $ref: '#/components/schemas/StringFieldFilter' + description: Filter by currency code. e.g. ?filter[currency][eq]=USD + source: + allOf: + - $ref: '#/components/schemas/StringFieldFilter' + description: Filter by source. e.g. ?filter[source][eq]=system + additionalProperties: false + description: Filter options for listing LLM cost prices. + ListMetersParamsFilter: + type: object + properties: + key: + allOf: + - $ref: '#/components/schemas/StringFieldFilter' + description: Filter meters by key. + name: + allOf: + - $ref: '#/components/schemas/StringFieldFilter' + description: Filter meters by name. + additionalProperties: false + description: Filter options for listing meters. + ListPlansParamsFilter: + type: object + properties: + key: + $ref: '#/components/schemas/StringFieldFilter' + name: + $ref: '#/components/schemas/StringFieldFilter' + status: + $ref: '#/components/schemas/StringFieldFilterExact' + currency: + $ref: '#/components/schemas/StringFieldFilterExact' + additionalProperties: false + description: Filter options for listing plans. + ListSubscriptionsParamsFilter: + type: object + properties: + id: + $ref: '#/components/schemas/ULIDFieldFilter' + customer_id: + $ref: '#/components/schemas/ULIDFieldFilter' + status: + $ref: '#/components/schemas/StringFieldFilterExact' + plan_id: + $ref: '#/components/schemas/ULIDFieldFilter' + plan_key: + $ref: '#/components/schemas/StringFieldFilterExact' + additionalProperties: false + description: Filter options for listing subscriptions. + Meter: + type: object + required: + - id + - name + - created_at + - updated_at + - key + - aggregation + - event_type + properties: + id: + allOf: + - $ref: '#/components/schemas/ULID' + readOnly: true + name: + type: string + minLength: 1 + maxLength: 256 + description: |- + Display name of the resource. + + Between 1 and 256 characters. + description: + type: string + maxLength: 1024 + description: |- + Optional description of the resource. + + Maximum 1024 characters. + labels: + $ref: '#/components/schemas/Labels' + created_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: An ISO-8601 timestamp representation of entity creation date. + readOnly: true + updated_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: An ISO-8601 timestamp representation of entity last update date. + readOnly: true + deleted_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: An ISO-8601 timestamp representation of entity deletion date. + readOnly: true + key: + $ref: '#/components/schemas/ResourceKey' + aggregation: + allOf: + - $ref: '#/components/schemas/MeterAggregation' + description: The aggregation type to use for the meter. + event_type: + type: string + minLength: 1 + description: The event type to include in the aggregation. + example: prompt + events_from: + allOf: + - $ref: '#/components/schemas/DateTime' + description: |- + The date since the meter should include events. Useful to skip old events. If + not specified, all historical events are included. + value_property: + type: string + minLength: 1 + description: |- + JSONPath expression to extract the value from the ingested event's data + property. + + The ingested value for sum, avg, min, and max aggregations is a number or a + string that can be parsed to a number. + + For unique_count aggregation, the ingested value must be a string. For count + aggregation the value_property is ignored. + example: $.tokens + dimensions: + type: object + additionalProperties: + type: string + description: |- + Named JSONPath expressions to extract the group by values from the event data. + + Keys must be unique and consist only alphanumeric and underscore characters. + example: + type: $.type + additionalProperties: false + description: A meter is a configuration that defines how to match and aggregate events. + example: + id: 01G65Z755AFWAKHE12NY0CQ9FH + key: tokens_total + name: Tokens Total + description: AI Token Usage + aggregation: sum + event_type: prompt + value_property: $.tokens + dimensions: + model: $.model + type: $.type + created_at: '2024-01-01T01:01:01.001Z' + updated_at: '2024-01-01T01:01:01.001Z' + MeterAggregation: + type: string + enum: + - sum + - count + - unique_count + - avg + - min + - max + - latest + description: The aggregation type to use for the meter. + MeterPagePaginatedResponse: + type: object + required: + - data + - meta + properties: + data: + type: array + items: + $ref: '#/components/schemas/Meter' + meta: + $ref: '#/components/schemas/PaginatedMeta' + additionalProperties: false + description: Page paginated response. + MeterQueryFilters: + type: object + properties: + dimensions: + type: object + additionalProperties: + $ref: '#/components/schemas/QueryFilterStringMapItem' + maxProperties: 10 + description: |- + Filters to apply to the dimensions of the query. For `subject` and `customer_id` + only equals ("eq", "in") comparisons are supported. + additionalProperties: false + description: Filters to apply to a meter query. + MeterQueryGranularity: + type: string + enum: + - PT1M + - PT1H + - P1D + - P1M + description: |- + The granularity of the time grouping. Time durations are specified in ISO 8601 + format. + MeterQueryRequest: + type: object + properties: + from: + allOf: + - $ref: '#/components/schemas/DateTime' + description: The start of the period the usage is queried from. + to: + allOf: + - $ref: '#/components/schemas/DateTime' + description: The end of the period the usage is queried to. + granularity: + allOf: + - $ref: '#/components/schemas/MeterQueryGranularity' + description: |- + The size of the time buckets to group the usage into. If not specified, the + usage is aggregated over the entire period. + time_zone: + type: string + description: |- + The value is the name of the time zone as defined in the IANA Time Zone Database + (http://www.iana.org/time-zones). The time zone is used to determine the start + and end of the time buckets. If not specified, the UTC timezone will be used. + default: UTC + group_by_dimensions: + type: array + items: + type: string + maxItems: 100 + description: The dimensions to group the results by. + example: + - model + - type + filters: + allOf: + - $ref: '#/components/schemas/MeterQueryFilters' + description: Filters to apply to the query. + additionalProperties: false + description: A meter query request. + example: + from: '2023-01-01T00:00:00Z' + to: '2023-01-02T00:00:00Z' + granularity: P1D + time_zone: UTC + MeterQueryResult: + type: object + required: + - data + properties: + from: + allOf: + - $ref: '#/components/schemas/DateTime' + description: The start of the period the usage is queried from. + to: + allOf: + - $ref: '#/components/schemas/DateTime' + description: The end of the period the usage is queried to. + data: + type: array + items: + $ref: '#/components/schemas/MeterQueryRow' + description: The usage data. If no data is available, an empty array is returned. + additionalProperties: false + description: Meter query result. + example: + from: '2023-01-01T00:00:00Z' + to: '2023-01-02T00:00:00Z' + data: + - value: '12.3456' + from: '2023-01-01T00:00:00Z' + to: '2023-01-02T00:00:00Z' + dimensions: + customer_id: 01G65Z755AFWAKHE12NY0CQ9FH + model: gpt-4-turbo + type: input + MeterQueryRow: + type: object + required: + - value + - from + - to + - dimensions + properties: + value: + allOf: + - $ref: '#/components/schemas/Numeric' + description: The aggregated value. + from: + allOf: + - $ref: '#/components/schemas/DateTime' + description: The start of the time bucket the value is aggregated over. + to: + allOf: + - $ref: '#/components/schemas/DateTime' + description: The end of the time bucket the value is aggregated over. + dimensions: + type: object + additionalProperties: + type: string + description: |- + The dimensions the value is aggregated over. `subject` and `customer_id` are + reserved dimensions. + additionalProperties: false + description: A row in the result of a meter query. + example: + value: '12.3456' + from: '2023-01-01T00:00:00Z' + to: '2023-01-02T00:00:00Z' + dimensions: + customer_id: 01G65Z755AFWAKHE12NY0CQ9FH + model: gpt-4-turbo + type: input + MeteringEvent: + type: object + required: + - id + - source + - specversion + - type + - subject + properties: + id: + type: string + minLength: 1 + description: Identifies the event. + example: 5c10fade-1c9e-4d6c-8275-c52c36731d3c + source: + type: string + minLength: 1 + format: uri-reference + description: Identifies the context in which an event happened. + example: service-name + specversion: + type: string + minLength: 1 + description: The version of the CloudEvents specification which the event uses. + example: '1.0' + default: '1.0' + type: + type: string + minLength: 1 + description: |- + Contains a value describing the type of event related to the originating + occurrence. + example: com.example.someevent + datacontenttype: + type: string + enum: + - application/json + nullable: true + description: |- + Content type of the CloudEvents data value. Only the value "application/json" is + allowed over HTTP. + example: application/json + dataschema: + type: string + format: uri + nullable: true + minLength: 1 + description: Identifies the schema that data adheres to. + subject: + type: string + minLength: 1 + description: |- + Describes the subject of the event in the context of the event producer + (identified by source). + example: customer-id + time: + type: string + allOf: + - $ref: '#/components/schemas/DateTime' + nullable: true + description: Timestamp of when the occurrence happened. Must adhere to RFC 3339. + example: '2023-01-01T01:01:01.001Z' + data: + type: object + additionalProperties: {} + nullable: true + description: The event payload. Optional, if present it must be a JSON object. + additionalProperties: false + description: Metering event following the CloudEvents specification. + title: Metering Event + example: + specversion: '1.0' + id: 5c10fade-1c9e-4d6c-8275-c52c36731d3c + source: service-name + type: prompt + subject: customer-id + time: '2023-01-01T01:01:01.001Z' + data: + prompt: Hello, world! + tokens: 100 + model: gpt-4o + type: input + MeteringIngestedEvent: + type: object + required: + - event + - ingested_at + - stored_at + properties: + event: + allOf: + - $ref: '#/components/schemas/MeteringEvent' + description: The original event ingested. + customer: + allOf: + - $ref: '#/components/schemas/CustomerReference' + description: The customer if the event is associated with a customer. + ingested_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: The date and time the event was ingested and its processing started. + stored_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: The date and time the event was stored in the database. + validation_errors: + type: array + items: + $ref: '#/components/schemas/MeteringIngestedEventValidationError' + description: The validation errors of the ingested event. + additionalProperties: false + description: An ingested metering event with ingestion metadata. + title: Ingested Event + example: + event: + id: 5c10fade-1c9e-4d6c-8275-c52c36731d3c + source: service-name + specversion: '1.0' + type: prompt + subject: customer_key + time: '2023-01-01T01:01:01.001Z' + customer: + id: 01G65Z755AFWAKHE12NY0CQ9FH + ingested_at: '2023-01-01T01:01:01.001Z' + stored_at: '2023-01-01T01:01:02.001Z' + MeteringIngestedEventValidationError: + type: object + required: + - code + - message + properties: + code: + type: string + description: The machine readable code of the error. + title: Code + readOnly: true + message: + type: string + description: The human readable description of the error. + title: Message + readOnly: true + attributes: + type: object + additionalProperties: {} + description: Additional attributes. + title: Attributes + readOnly: true + additionalProperties: false + description: Event validation errors. + Numeric: + type: string + pattern: ^\-?[0-9]+(\.[0-9]+)?$ + description: Numeric represents an arbitrary precision number. + OrganizationDefaultTaxCodes: + type: object + required: + - invoicing_tax_code + - credit_grant_tax_code + - created_at + - updated_at + properties: + invoicing_tax_code: + allOf: + - $ref: '#/components/schemas/TaxCodeReference' + description: Default tax code for invoicing. + title: Invoicing tax code + credit_grant_tax_code: + allOf: + - $ref: '#/components/schemas/TaxCodeReference' + description: Default tax code for credit grants. + title: Credit grant tax code + created_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: Timestamp of creation. + readOnly: true + updated_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: Timestamp of last update. + readOnly: true + additionalProperties: false + description: |- + Organization-level default tax code references. + + Stores the default tax codes applied to specific billing contexts for this + organization. Provisioned automatically when the organization is created. + PlanAddon: + type: object + required: + - id + - name + - created_at + - updated_at + - addon + - from_plan_phase + properties: + id: + allOf: + - $ref: '#/components/schemas/ULID' + readOnly: true + name: + type: string + minLength: 1 + maxLength: 256 + description: |- + Display name of the resource. + + Between 1 and 256 characters. + description: + type: string + maxLength: 1024 + description: |- + Optional description of the resource. + + Maximum 1024 characters. + labels: + $ref: '#/components/schemas/Labels' + created_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: An ISO-8601 timestamp representation of entity creation date. + readOnly: true + updated_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: An ISO-8601 timestamp representation of entity last update date. + readOnly: true + deleted_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: An ISO-8601 timestamp representation of entity deletion date. + readOnly: true + addon: + allOf: + - $ref: '#/components/schemas/AddonReference' + description: The add-on associated with the plan. + title: Add-on + from_plan_phase: + allOf: + - $ref: '#/components/schemas/ResourceKey' + description: The key of the plan phase from which the add-on becomes available for purchase. + title: From plan phase + max_quantity: + type: integer + minimum: 1 + description: |- + The maximum number of times the add-on can be purchased for the plan. For + single-instance add-ons this field must be omitted. For multi-instance add-ons + when omitted, unlimited quantity can be purchased. + title: Max quantity + validation_errors: + type: array + items: + $ref: '#/components/schemas/ProductCatalogValidationError' + description: List of validation errors. + title: Validation errors + readOnly: true + additionalProperties: false + description: |- + PlanAddon represents an association between a plan and an add-on, controlling + which add-ons are available for purchase within a plan. + PlanAddonPagePaginatedResponse: + type: object + required: + - data + - meta + properties: + data: + type: array + items: + $ref: '#/components/schemas/PlanAddon' + meta: + $ref: '#/components/schemas/PaginatedMeta' + additionalProperties: false + description: Page paginated response. + PlanPagePaginatedResponse: + type: object + required: + - data + - meta + properties: + data: + type: array + items: + $ref: '#/components/schemas/BillingPlan' + meta: + $ref: '#/components/schemas/PaginatedMeta' + additionalProperties: false + description: Page paginated response. + PricePagePaginatedResponse: + type: object + required: + - data + - meta + properties: + data: + type: array + items: + $ref: '#/components/schemas/LLMCostPrice' + meta: + $ref: '#/components/schemas/PaginatedMeta' + additionalProperties: false + description: Page paginated response. + ProductCatalogValidationError: + type: object + required: + - code + - message + - field + properties: + code: + type: string + description: Machine-readable error code. + title: Code + readOnly: true + message: + type: string + description: Human-readable description of the error. + title: Message + readOnly: true + attributes: + type: object + additionalProperties: {} + description: Additional structured context. + title: Attributes + readOnly: true + field: + type: string + description: The path to the field. + title: Field + example: addons/pro/ratecards/token/featureKey + readOnly: true + additionalProperties: false + description: Validation errors providing detailed description of the issue. + QueryFilterString: + type: object + properties: + eq: + type: string + description: The attribute equals the provided value. + neq: + type: string + description: The attribute does not equal the provided value. + in: + type: array + items: + type: string + minItems: 1 + maxItems: 100 + description: The attribute is one of the provided values. + nin: + type: array + items: + type: string + minItems: 1 + maxItems: 100 + description: The attribute is not one of the provided values. + contains: + type: string + description: The attribute contains the provided value. + ncontains: + type: string + description: The attribute does not contain the provided value. + and: + minItems: 1 + maxItems: 10 + description: Combines the provided filters with a logical AND. + type: array + items: + $ref: '#/components/schemas/QueryFilterString' + or: + minItems: 1 + maxItems: 10 + description: Combines the provided filters with a logical OR. + type: array + items: + $ref: '#/components/schemas/QueryFilterString' + additionalProperties: false + description: |- + A query filter for a string attribute. Operators are mutually exclusive, only + one operator is allowed at a time. + title: Query String Filter + QueryFilterStringMapItem: + type: object + properties: + exists: + type: boolean + description: The attribute exists. + eq: + type: string + description: The attribute equals the provided value. + neq: + type: string + description: The attribute does not equal the provided value. + in: + type: array + items: + type: string + minItems: 1 + maxItems: 100 + description: The attribute is one of the provided values. + nin: + type: array + items: + type: string + minItems: 1 + maxItems: 100 + description: The attribute is not one of the provided values. + contains: + type: string + description: The attribute contains the provided value. + ncontains: + type: string + description: The attribute does not contain the provided value. + and: + type: array + items: + $ref: '#/components/schemas/QueryFilterString' + minItems: 1 + maxItems: 10 + description: Combines the provided filters with a logical AND. + or: + type: array + items: + $ref: '#/components/schemas/QueryFilterString' + minItems: 1 + maxItems: 10 + description: Combines the provided filters with a logical OR. + additionalProperties: false + description: |- + A query filter for an item in a string map attribute. Operators are mutually + exclusive, only one operator is allowed at a time. + title: Query String Map Item Filter + RecurringPeriod: + type: object + required: + - anchor + - interval + properties: + anchor: + allOf: + - $ref: '#/components/schemas/DateTime' + description: A date-time anchor to base the recurring period on. + title: Anchor time + example: '2023-01-01T01:01:01.001Z' + interval: + allOf: + - $ref: '#/components/schemas/ISO8601Duration' + description: The interval duration in ISO 8601 format. + title: Interval in ISO 8601 duration format + example: P1M + additionalProperties: false + description: Recurring period with an anchor and an interval. + ResourceKey: + type: string + minLength: 1 + maxLength: 64 + pattern: ^[a-z0-9]+(?:_[a-z0-9]+)*$ + description: A key is a unique string that is used to identify a resource. + title: Resource Key + example: resource_key + StringFieldFilter: + anyOf: + - type: string + - type: object + properties: + eq: + type: string + description: Value strictly equals the given string value. + neq: + type: string + description: Value does not equal the given string value. + contains: + type: string + description: Value contains the given string value (fuzzy match). + ocontains: + type: string + description: |- + Returns entities that fuzzy-match any of the comma-delimited phrases in the + filter string. + oeq: + type: string + description: |- + Returns entities that exact match any of the comma-delimited phrases in the + filter string. + gt: + type: string + description: Value is greater than the given string value (lexicographic compare). + gte: + type: string + description: |- + Value is greater than or equal to the given string value (lexicographic + compare). + lt: + type: string + description: Value is less than the given string value (lexicographic compare). + lte: + type: string + description: Value is less than or equal to the given string value (lexicographic compare). + exists: + type: boolean + description: |- + When true, the field must be present (non-null); when false, the field must be + absent (null). + additionalProperties: false + description: |- + Filters on the given string field value by either exact or fuzzy match. All + properties are optional; provide exactly one to specify the comparison. + x-go-type-import: + path: github.com/openmeterio/openmeter/api/v3/filters + x-go-type: filters.FilterString + StringFieldFilterExact: + anyOf: + - type: string + - type: object + properties: + eq: + type: string + description: Value strictly equals the given string value. + oeq: + type: string + description: |- + Returns entities that exact match any of the comma-delimited phrases in the + filter string. + neq: + type: string + description: Value does not equal the given string value. + additionalProperties: false + description: |- + Filters on the given string field value by exact match. All properties are + optional; provide exactly one to specify the comparison. + title: String Field Filter Exact + x-go-type-import: + path: github.com/openmeterio/openmeter/api/v3/filters + x-go-type: filters.FilterStringExact + SubscriptionAddon: + type: object + required: + - id + - created_at + - updated_at + - name + - addon + - quantity + - quantity_at + - active_from + - timeline + - rate_cards + properties: + id: + allOf: + - $ref: '#/components/schemas/ULID' + readOnly: true + labels: + $ref: '#/components/schemas/Labels' + created_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: An ISO-8601 timestamp representation of entity creation date. + readOnly: true + updated_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: An ISO-8601 timestamp representation of entity last update date. + readOnly: true + deleted_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: An ISO-8601 timestamp representation of entity deletion date. + readOnly: true + name: + type: string + minLength: 1 + maxLength: 256 + description: |- + Display name of the resource. + + Between 1 and 256 characters. + readOnly: true + description: + type: string + maxLength: 1024 + description: |- + Optional description of the resource. + + Maximum 1024 characters. + readOnly: true + addon: + allOf: + - $ref: '#/components/schemas/AddonReference' + description: The add-on associated with the subscription. + title: Add-on + quantity: + type: integer + minimum: 1 + description: The quantity of the add-on. Always 1 for single instance add-ons. + title: Quantity + quantity_at: + allOf: + - $ref: '#/components/schemas/DateTime' + description: |- + An ISO-8601 timestamp representation of which point in time the quantity was + resolved to. + readOnly: true + active_from: + allOf: + - $ref: '#/components/schemas/DateTime' + description: An ISO-8601 timestamp representation of the cadence start of the resource. + readOnly: true + active_to: + allOf: + - $ref: '#/components/schemas/DateTime' + description: An ISO-8601 timestamp representation of the cadence end of the resource. + readOnly: true + timeline: + type: array + items: + $ref: '#/components/schemas/SubscriptionAddonTimelineSegment' + description: The timeline of the add-on. The returned periods are sorted and continuous. + title: Timeline + example: + - quantity: 1 + active_from: '2025-01-01T00:00:00Z' + active_to: '2025-01-02T00:00:00Z' + - quantity: 0 + active_from: '2025-01-02T00:00:00Z' + active_to: '2025-01-03T00:00:00Z' + - quantity: 1 + active_from: '2025-01-03T00:00:00Z' + readOnly: true + rate_cards: + type: array + items: + $ref: '#/components/schemas/SubscriptionAddonRateCard' + description: The rate cards of the add-on. + title: Rate cards + readOnly: true + additionalProperties: false + description: Addon purchased with a subscription. + SubscriptionAddonPagePaginatedResponse: + type: object + required: + - data + - meta + properties: + data: + type: array + items: + $ref: '#/components/schemas/SubscriptionAddon' + meta: + $ref: '#/components/schemas/PaginatedMeta' + additionalProperties: false + description: Page paginated response. + SubscriptionAddonRateCard: + type: object + required: + - rate_card + - affected_subscription_item_ids + properties: + rate_card: + allOf: + - $ref: '#/components/schemas/BillingRateCard' + description: The rate card. + title: Rate card + affected_subscription_item_ids: + type: array + items: + $ref: '#/components/schemas/ULID' + description: The IDs of the subscription items that this rate card belongs to. + title: Affected subscription item IDs + readOnly: true + additionalProperties: false + description: A rate card for a subscription add-on. + SubscriptionAddonTimelineSegment: + type: object + required: + - active_from + - quantity + properties: + active_from: + allOf: + - $ref: '#/components/schemas/DateTime' + description: An ISO-8601 timestamp representation of the cadence start of the resource. + readOnly: true + active_to: + allOf: + - $ref: '#/components/schemas/DateTime' + description: An ISO-8601 timestamp representation of the cadence end of the resource. + readOnly: true + quantity: + type: integer + minimum: 0 + description: The quantity of the add-on for the given period. + title: Quantity + example: 1 + readOnly: true + additionalProperties: false + description: A subscription add-on event. + SubscriptionPagePaginatedResponse: + type: object + required: + - data + - meta + properties: + data: + type: array + items: + $ref: '#/components/schemas/BillingSubscription' + meta: + $ref: '#/components/schemas/PaginatedMeta' + additionalProperties: false + description: Page paginated response. + TaxCodePagePaginatedResponse: + type: object + required: + - data + - meta + properties: + data: + type: array + items: + $ref: '#/components/schemas/BillingTaxCode' + meta: + $ref: '#/components/schemas/PaginatedMeta' + additionalProperties: false + description: Page paginated response. + TaxCodeReference: + type: object + required: + - id + properties: + id: + $ref: '#/components/schemas/ULID' + additionalProperties: false + description: TaxCode reference. + ULID: + type: string + pattern: ^[0-7][0-9A-HJKMNP-TV-Z]{25}$ + description: ULID (Universally Unique Lexicographically Sortable Identifier). + title: ULID + example: 01G65Z755AFWAKHE12NY0CQ9FH + ULIDFieldFilter: + anyOf: + - $ref: '#/components/schemas/ULID' + - type: object + properties: + eq: + allOf: + - $ref: '#/components/schemas/ULID' + description: Value strictly equals the given ULID value. + oeq: + type: string + description: |- + Returns entities that exact match any of the comma-delimited ULIDs in the filter + string. + neq: + allOf: + - $ref: '#/components/schemas/ULID' + description: Value does not equal the given ULID value. + additionalProperties: false + description: |- + Filters on the given ULID field value by exact match. All properties are + optional; provide exactly one to specify the comparison. + title: ULID Field Filter + x-go-type-import: + path: github.com/openmeterio/openmeter/api/v3/filters + x-go-type: filters.FilterULID + UpdateAddress: + type: object + properties: + country: + allOf: + - $ref: '#/components/schemas/CountryCode' + description: |- + Country code in [ISO 3166-1](https://www.iso.org/iso-3166-country-codes.html) + alpha-2 format. + title: Country + postal_code: + type: string + description: Postal code. + title: Postal Code + state: + type: string + description: State or province. + title: State + city: + type: string + description: City. + title: City + line1: + type: string + description: First line of the address. + title: Line 1 + line2: + type: string + description: Second line of the address. + title: Line 2 + phone_number: + type: string + description: Phone number. + title: Phone Number + additionalProperties: false + description: Address + UpdateBillingInvoiceWorkflow: + type: object + properties: + invoicing: + allOf: + - $ref: '#/components/schemas/UpdateBillingInvoiceWorkflowInvoicingSettings' + description: Invoicing settings for this invoice. + title: Invoicing settings + payment: + allOf: + - $ref: '#/components/schemas/UpdateBillingWorkflowPaymentSettings' + description: Payment settings for this invoice. + title: Payment settings + additionalProperties: false + description: |- + Invoice-level snapshot of the workflow configuration. + + Contains only the settings that are meaningful for an already-created invoice: + invoicing behaviour and payment settings. Collection alignment and tax policy + are gather-time / profile-wide concerns and are not included. + UpdateBillingInvoiceWorkflowInvoicingSettings: + type: object + properties: + auto_advance: + type: boolean + description: Whether to automatically issue the invoice after the draft_period has passed. + default: true + draft_period: + type: string + format: ISO8601 + description: The period for the invoice to be kept in draft status for manual reviews. + example: P1D + default: P0D + additionalProperties: false + description: |- + Invoice-level invoicing settings. + + A subset of BillingWorkflowInvoicingSettings limited to fields that are + meaningful per-invoice. progressive_billing is omitted as it is a gather-time / + profile-level decision. + UpdateBillingPartyAddresses: + type: object + required: + - billing_address + properties: + billing_address: + allOf: + - $ref: '#/components/schemas/UpdateAddress' + description: Billing address. + additionalProperties: false + description: A collection of addresses for the party. + UpdateBillingPartyTaxIdentity: + type: object + properties: + code: + allOf: + - $ref: '#/components/schemas/BillingTaxIdentificationCode' + description: Normalized tax identification code shown on the original identity document. + additionalProperties: false + description: |- + Identity stores the details required to identify an entity for tax purposes in a + specific country. + UpdateBillingWorkflowPaymentChargeAutomaticallySettings: + type: object + required: + - collection_method + properties: + collection_method: + type: string + enum: + - charge_automatically + description: The collection method for the invoice. + additionalProperties: false + description: |- + Payment settings for a billing workflow when the collection method is charge + automatically. + UpdateBillingWorkflowPaymentSendInvoiceSettings: + type: object + required: + - collection_method + properties: + collection_method: + type: string + enum: + - send_invoice + description: The collection method for the invoice. + due_after: + type: string + format: ISO8601 + description: |- + The period after which the invoice is due. With some payment solutions it's only + applicable for manual collection method. + example: P30D + default: P30D + additionalProperties: false + description: |- + Payment settings for a billing workflow when the collection method is send + invoice. + UpdateBillingWorkflowPaymentSettings: + type: object + oneOf: + - $ref: '#/components/schemas/UpdateBillingWorkflowPaymentChargeAutomaticallySettings' + - $ref: '#/components/schemas/UpdateBillingWorkflowPaymentSendInvoiceSettings' + discriminator: + propertyName: collection_method + mapping: + charge_automatically: '#/components/schemas/UpdateBillingWorkflowPaymentChargeAutomaticallySettings' + send_invoice: '#/components/schemas/UpdateBillingWorkflowPaymentSendInvoiceSettings' + description: Payment settings for a billing workflow. + UpdateClosedPeriod: + type: object + required: + - from + - to + properties: + from: + allOf: + - $ref: '#/components/schemas/DateTime' + description: |- + The start of the period. + + The period is inclusive at the start. + title: Start + example: '2023-01-01T01:01:01.001Z' + to: + allOf: + - $ref: '#/components/schemas/DateTime' + description: |- + The end of the period. + + The period is exclusive at the end. + title: End + example: '2023-01-01T01:01:01.001Z' + additionalProperties: false + description: |- + A period with defined start and end dates. + + The period is always inclusive at the start and exclusive at the end. + UpdateCreditGrantExternalSettlementRequest: + type: object + required: + - status + properties: + status: + allOf: + - $ref: '#/components/schemas/BillingCreditPurchasePaymentSettlementStatus' + description: The new payment settlement status. + additionalProperties: false + description: |- + Request body for updating the external payment settlement status of a credit + grant. + UpdateCustomerUsageAttribution: + type: object + required: + - subject_keys + properties: + subject_keys: + type: array + items: + $ref: '#/components/schemas/UsageAttributionSubjectKey' + minItems: 0 + description: |- + The subjects that are attributed to the customer. Can be empty when no usage + event subjects are associated with the customer. + title: Subject Keys + additionalProperties: false + description: |- + Mapping to attribute metered usage to the customer. One customer can have zero + or more subjects, but one subject can only belong to one customer. + UpdateDiscounts: + type: object + properties: + percentage: + type: number + minimum: 0 + maximum: 100 + description: Percentage discount applied to the price (0–100). + usage: + allOf: + - $ref: '#/components/schemas/Numeric' + description: |- + Number of usage units granted free before billing starts. Only applies to + usage-based lines (not flat fees). Usage is treated as zero until this amount is + exhausted. + additionalProperties: false + description: Discount configuration for a rate card. + UpdateFeatureRequest: + type: object + properties: + unit_cost: + allOf: + - $ref: '#/components/schemas/BillingFeatureUnitCost' + nullable: true + description: |- + Optional per-unit cost configuration. Use "manual" for a fixed per-unit cost, or + "llm" to look up cost from the LLM cost database based on meter group-by + properties. Set to `null` to clear the existing unit cost; omit to leave it + unchanged. + title: Unit cost + additionalProperties: false + description: |- + Request body for updating a feature. Currently only the unit_cost field can be + updated. + UpdateInvoiceCustomer: + type: object + required: + - name + - id + properties: + name: + type: string + minLength: 1 + maxLength: 256 + description: |- + Display name of the resource. + + Between 1 and 256 characters. + usage_attribution: + allOf: + - $ref: '#/components/schemas/UpdateCustomerUsageAttribution' + description: Mapping to attribute metered usage to the customer by the event subject. + title: Usage Attribution + billing_address: + allOf: + - $ref: '#/components/schemas/UpdateAddress' + description: The billing address of the customer. Used for tax and invoicing. + title: Billing Address + id: + $ref: '#/components/schemas/ULID' + key: + allOf: + - $ref: '#/components/schemas/ExternalResourceKey' + description: |- + Optional external resource key for the customer. + + Omitted when the customer was created without a key. Unlike on the customer + resource itself, the key is optional here because the invoice snapshot may + predate or omit it. + additionalProperties: false + description: Snapshot of the customer's information at the time the invoice was issued. + UpdateInvoiceLine: + type: object + oneOf: + - $ref: '#/components/schemas/UpdateInvoiceStandardLine' + discriminator: + propertyName: type + mapping: + standard_line: '#/components/schemas/UpdateInvoiceStandardLine' + description: |- + A top-level line item on an invoice. + + Each line represents a single charge, typically associated with a rate card from + a subscription. Detailed (child) lines are nested under `detailed_lines` when + present. + UpdateInvoiceLineRateCard: + type: object + required: + - price + properties: + price: + allOf: + - $ref: '#/components/schemas/UpdatePrice' + description: The price definition used to calculate charges for this line. + title: Price + tax_config: + allOf: + - $ref: '#/components/schemas/UpdateRateCardTaxConfig' + description: Tax configuration snapshot for this line. + title: Tax config + feature_key: + allOf: + - $ref: '#/components/schemas/ResourceKey' + description: The feature key associated with this line's rate card. + title: Feature key + discounts: + allOf: + - $ref: '#/components/schemas/UpdateDiscounts' + description: Discount configuration from the rate card. + title: Discounts + additionalProperties: false + description: Rate card configuration snapshot for a usage-based invoice line. + UpdateInvoiceRequest: + type: object + oneOf: + - $ref: '#/components/schemas/UpdateInvoiceStandardRequest' + discriminator: + propertyName: type + mapping: + standard: '#/components/schemas/UpdateInvoiceStandardRequest' + description: UpdateInvoiceRequest update request. + UpdateInvoiceStandardLine: + type: object + required: + - name + - type + - service_period + - rate_card + properties: + name: + type: string + minLength: 1 + maxLength: 256 + description: |- + Display name of the resource. + + Between 1 and 256 characters. + description: + type: string + maxLength: 1024 + description: |- + Optional description of the resource. + + Maximum 1024 characters. + labels: + $ref: '#/components/schemas/Labels' + id: + allOf: + - $ref: '#/components/schemas/ULID' + description: |- + ID of the line. + + Optional on update: omit to create a new line, or supply the ID of an existing + line to edit it. Existing lines omitted from an update's `lines` array are + deleted. + title: ID + type: + type: string + enum: + - standard_line + description: The type of charge this line item represents. + title: Type + service_period: + allOf: + - $ref: '#/components/schemas/UpdateClosedPeriod' + description: |- + The service period covered by this invoice, spanning the earliest line start to + the latest line end across all of its lines. + + For an invoice with no lines the period is empty, which means `from` will be + equal to `to`. + title: Service period + rate_card: + allOf: + - $ref: '#/components/schemas/UpdateInvoiceLineRateCard' + description: The rate card configuration snapshot used to price this line item. + title: Rate card + additionalProperties: false + description: |- + A top-level line item on an invoice. + + Each line represents a single charge, typically associated with a rate card from + a subscription. Detailed (child) lines are nested under `detailed_lines` when + present. + UpdateInvoiceStandardRequest: + type: object + required: + - supplier + - customer + - type + - workflow + properties: + description: + type: string + maxLength: 1024 + description: |- + Optional description of the resource. + + Maximum 1024 characters. + labels: + $ref: '#/components/schemas/Labels' + supplier: + allOf: + - $ref: '#/components/schemas/UpdateSupplier' + description: |- + Snapshot of the supplier's contact information at the time the invoice was + issued. + title: Supplier + customer: + allOf: + - $ref: '#/components/schemas/UpdateInvoiceCustomer' + description: Snapshot of the customer's information at the time the invoice was issued. + title: Customer + type: + type: string + enum: + - standard + description: Discriminator field identifying this as a standard invoice. + title: Type + workflow: + allOf: + - $ref: '#/components/schemas/UpdateInvoiceWorkflowSettings' + description: Workflow configuration snapshot captured at invoice creation time. + title: Workflow + lines: + type: array + items: + $ref: '#/components/schemas/UpdateInvoiceLine' + description: |- + Line items on this invoice. + + Always returned on single-resource GET; omitted on list endpoints unless + explicitly expanded. Editable via update: existing lines are matched by `id`, + lines without an `id` are created, and lines present on the invoice but omitted + from the update request are deleted. Detailed (child) lines are always computed + and cannot be edited directly. + title: Lines + additionalProperties: false + description: InvoiceStandard update request. + UpdateInvoiceWorkflowSettings: + type: object + required: + - workflow + properties: + workflow: + allOf: + - $ref: '#/components/schemas/UpdateBillingInvoiceWorkflow' + description: |- + The workflow configuration that was active when the invoice was created. + + Only the fields that are meaningful at the per-invoice level are included: + invoicing behaviour (auto-advance, draft period) and payment settings + (collection method, due date). Profile-wide settings such as collection + alignment, progressive billing, and tax policy are omitted. + title: Workflow config + additionalProperties: false + description: Snapshot of the billing workflow configuration captured at invoice creation. + UpdateMeterRequest: + type: object + properties: + name: + type: string + minLength: 1 + maxLength: 256 + description: |- + Display name of the resource. + + Between 1 and 256 characters. + description: + type: string + maxLength: 1024 + description: |- + Optional description of the resource. + + Maximum 1024 characters. + labels: + $ref: '#/components/schemas/Labels' + dimensions: + type: object + additionalProperties: + type: string + description: |- + Named JSONPath expressions to extract the group by values from the event data. + + Keys must be unique and consist only alphanumeric and underscore characters. + example: + type: $.type + additionalProperties: false + description: Meter update request. + UpdateOrganizationDefaultTaxCodesRequest: + type: object + properties: + invoicing_tax_code: + allOf: + - $ref: '#/components/schemas/TaxCodeReference' + description: Default tax code for invoicing. + title: Invoicing tax code + credit_grant_tax_code: + allOf: + - $ref: '#/components/schemas/TaxCodeReference' + description: Default tax code for credit grants. + title: Credit grant tax code + additionalProperties: false + description: OrganizationDefaultTaxCodes update request. + UpdatePrice: + type: object + oneOf: + - $ref: '#/components/schemas/UpdatePriceFree' + - $ref: '#/components/schemas/UpdatePriceFlat' + - $ref: '#/components/schemas/UpdatePriceUnit' + - $ref: '#/components/schemas/UpdatePriceGraduated' + - $ref: '#/components/schemas/UpdatePriceVolume' + discriminator: + propertyName: type + mapping: + free: '#/components/schemas/UpdatePriceFree' + flat: '#/components/schemas/UpdatePriceFlat' + unit: '#/components/schemas/UpdatePriceUnit' + graduated: '#/components/schemas/UpdatePriceGraduated' + volume: '#/components/schemas/UpdatePriceVolume' + description: Price. + UpdatePriceFlat: + type: object + required: + - type + - amount + properties: + type: + type: string + enum: + - flat + description: The type of the price. + title: Type + amount: + allOf: + - $ref: '#/components/schemas/Numeric' + description: The amount of the flat price. + title: Amount + additionalProperties: false + description: Flat price. + UpdatePriceFree: + type: object + required: + - type + properties: + type: + type: string + enum: + - free + description: The type of the price. + title: Type + additionalProperties: false + description: Free price. + UpdatePriceGraduated: + type: object + required: + - type + - tiers + properties: + type: + type: string + enum: + - graduated + description: The type of the price. + title: Type + tiers: + type: array + items: + $ref: '#/components/schemas/UpdatePriceTier' + minItems: 1 + description: The tiers of the graduated price. At least one tier is required. + title: Tiers + additionalProperties: false + description: |- + Graduated tiered price. + + Each tier's rate applies only to the usage within that tier. Pricing can change + as cumulative usage crosses tier boundaries. + + When UnitConfig is present on the rate card, tier boundaries (up_to_amount) are + expressed in converted billing units. + UpdatePriceTier: + type: object + properties: + up_to_amount: + allOf: + - $ref: '#/components/schemas/Numeric' + description: |- + Up to and including this quantity will be contained in the tier. If undefined, + the tier is open-ended (the last tier). + title: Up to quantity + flat_price: + allOf: + - $ref: '#/components/schemas/UpdatePriceFlat' + description: The flat price component of the tier. Charged once when the tier is entered. + title: Flat price component + unit_price: + allOf: + - $ref: '#/components/schemas/UpdatePriceUnit' + description: The unit price component of the tier. Charged per billing unit within the tier. + title: Unit price component + additionalProperties: false + description: |- + A price tier used in graduated and volume pricing. + + At least one price component (flat_price or unit_price) must be set. When + UnitConfig is present on the rate card, up_to_amount is expressed in converted + billing units. + UpdatePriceUnit: + type: object + required: + - type + - amount + properties: + type: + type: string + enum: + - unit + description: The type of the price. + title: Type + amount: + allOf: + - $ref: '#/components/schemas/Numeric' + description: The amount of the unit price. + title: Amount + additionalProperties: false + description: |- + Unit price. + + Charges a fixed rate per billing unit. When UnitConfig is present on the rate + card, billing units are the converted quantities (e.g. GB instead of bytes). + UpdatePriceVolume: + type: object + required: + - type + - tiers + properties: + type: + type: string + enum: + - volume + description: The type of the price. + title: Type + tiers: + type: array + items: + $ref: '#/components/schemas/UpdatePriceTier' + minItems: 1 + description: The tiers of the volume price. At least one tier is required. + title: Tiers + additionalProperties: false + description: |- + Volume tiered price. + + The maximum quantity within a period determines the per-unit price for all units + in that period. + + When UnitConfig is present on the rate card, tier boundaries (up_to_amount) are + expressed in converted billing units. + UpdateRateCardTaxConfig: + type: object + required: + - code + properties: + behavior: + $ref: '#/components/schemas/BillingTaxBehavior' + code: + $ref: '#/components/schemas/UpdateResourceReference' + additionalProperties: false + description: The tax config of the rate card. + UpdateResourceReference: + type: object + required: + - id + properties: + id: + $ref: '#/components/schemas/ULID' + additionalProperties: false + description: TaxCode reference. + UpdateSupplier: + type: object + properties: + name: + type: string + description: Legal name or representation of the party. + tax_id: + allOf: + - $ref: '#/components/schemas/UpdateBillingPartyTaxIdentity' + description: |- + The entity's legal identification used for tax purposes. They may have other + numbers, but we're only interested in those valid for tax purposes. + addresses: + allOf: + - $ref: '#/components/schemas/UpdateBillingPartyAddresses' + description: Address for where information should be sent if needed. + id: + type: string + description: Unique identifier for the party. + additionalProperties: false + description: |- + Snapshot of the supplier's information at the time the invoice was issued. + + Structurally a read-only subset of `BillingParty` (the type configured on the + billing profile), so the snapshot stays aligned with the source. `key` is + omitted because it is not part of the snapshotted supplier data. + UpsertAddonRequest: + type: object + required: + - name + - instance_type + - rate_cards + properties: + name: + type: string + minLength: 1 + maxLength: 256 + description: |- + Display name of the resource. + + Between 1 and 256 characters. + description: + type: string + maxLength: 1024 + description: |- + Optional description of the resource. + + Maximum 1024 characters. + labels: + $ref: '#/components/schemas/Labels' + instance_type: + allOf: + - $ref: '#/components/schemas/AddonInstanceType' + description: The InstanceType of the add-ons. Can be "single" or "multiple". + title: The InstanceType of the add-ons. Can be "single" or "multiple". + rate_cards: + type: array + items: + $ref: '#/components/schemas/BillingRateCard' + description: The rate cards of the add-on. + title: Rate cards + additionalProperties: false + description: Addon upsert request. + UpsertAppCustomerDataRequest: + type: object + properties: + stripe: + allOf: + - $ref: '#/components/schemas/BillingAppCustomerDataStripe' + description: Used if the customer has a linked Stripe app. + title: Stripe + external_invoicing: + allOf: + - $ref: '#/components/schemas/BillingAppCustomerDataExternalInvoicing' + description: Used if the customer has a linked external invoicing app. + title: External invoicing + additionalProperties: false + description: AppCustomerData upsert request. + UpsertBillingProfileRequest: + type: object + required: + - name + - supplier + - workflow + - default + properties: + name: + type: string + minLength: 1 + maxLength: 256 + description: |- + Display name of the resource. + + Between 1 and 256 characters. + description: + type: string + maxLength: 1024 + description: |- + Optional description of the resource. + + Maximum 1024 characters. + labels: + $ref: '#/components/schemas/Labels' + supplier: + allOf: + - $ref: '#/components/schemas/BillingParty' + description: |- + The name and contact information for the supplier this billing profile + represents + workflow: + allOf: + - $ref: '#/components/schemas/BillingWorkflow' + description: The billing workflow settings for this profile + default: + type: boolean + description: Whether this is the default profile. + additionalProperties: false + description: BillingProfile upsert request. + UpsertCustomerBillingDataRequest: + type: object + properties: + billing_profile: + allOf: + - $ref: '#/components/schemas/BillingProfileReference' + description: |- + The billing profile for the customer. + + If not provided, the default billing profile will be used. + title: Billing profile + app_data: + allOf: + - $ref: '#/components/schemas/BillingAppCustomerData' + description: App customer data. + title: App customer data + additionalProperties: false + description: CustomerBillingData upsert request. + UpsertCustomerRequest: + type: object + required: + - name + properties: + name: + type: string + minLength: 1 + maxLength: 256 + description: |- + Display name of the resource. + + Between 1 and 256 characters. + description: + type: string + maxLength: 1024 + description: |- + Optional description of the resource. + + Maximum 1024 characters. + labels: + $ref: '#/components/schemas/Labels' + usage_attribution: + allOf: + - $ref: '#/components/schemas/BillingCustomerUsageAttribution' + description: Mapping to attribute metered usage to the customer by the event subject. + title: Usage Attribution + primary_email: + type: string + description: The primary email address of the customer. + title: Primary Email + currency: + allOf: + - $ref: '#/components/schemas/CurrencyCode' + description: Currency of the customer. Used for billing, tax and invoicing. + title: Currency + billing_address: + allOf: + - $ref: '#/components/schemas/Address' + description: The billing address of the customer. Used for tax and invoicing. + title: Billing Address + additionalProperties: false + description: Customer upsert request. + UpsertPlanAddonRequest: + type: object + required: + - name + - from_plan_phase + properties: + name: + type: string + minLength: 1 + maxLength: 256 + description: |- + Display name of the resource. + + Between 1 and 256 characters. + description: + type: string + maxLength: 1024 + description: |- + Optional description of the resource. + + Maximum 1024 characters. + labels: + $ref: '#/components/schemas/Labels' + from_plan_phase: + allOf: + - $ref: '#/components/schemas/ResourceKey' + description: The key of the plan phase from which the add-on becomes available for purchase. + title: From plan phase + max_quantity: + type: integer + minimum: 1 + description: |- + The maximum number of times the add-on can be purchased for the plan. For + single-instance add-ons this field must be omitted. For multi-instance add-ons + when omitted, unlimited quantity can be purchased. + title: Max quantity + additionalProperties: false + description: PlanAddon upsert request. + UpsertPlanRequest: + type: object + required: + - name + - phases + properties: + name: + type: string + minLength: 1 + maxLength: 256 + description: |- + Display name of the resource. + + Between 1 and 256 characters. + description: + type: string + maxLength: 1024 + description: |- + Optional description of the resource. + + Maximum 1024 characters. + labels: + $ref: '#/components/schemas/Labels' + pro_rating_enabled: + type: boolean + description: Whether pro-rating is enabled for this plan. + title: Pro-rating enabled + default: true + phases: + type: array + items: + $ref: '#/components/schemas/BillingPlanPhase' + minItems: 1 + description: |- + The plan phases define the pricing ramp for a subscription. A phase switch + occurs only at the end of a billing period. At least one phase is required. + title: Plan phases + additionalProperties: false + description: Plan upsert request. + UpsertTaxCodeRequest: + type: object + required: + - name + - app_mappings + properties: + name: + type: string + minLength: 1 + maxLength: 256 + description: |- + Display name of the resource. + + Between 1 and 256 characters. + description: + type: string + maxLength: 1024 + description: |- + Optional description of the resource. + + Maximum 1024 characters. + labels: + $ref: '#/components/schemas/Labels' + app_mappings: + type: array + items: + $ref: '#/components/schemas/BillingTaxCodeAppMapping' + description: Mapping of app types to tax codes. + title: App type to tax code mappings + additionalProperties: false + description: TaxCode upsert request. + UsageAttributionSubjectKey: + type: string + minLength: 1 + description: Subject key. + VoidCreditGrantRequest: + type: object + properties: + payment_adjustment: + allOf: + - $ref: '#/components/schemas/BillingCreditGrantVoidPaymentAdjustment' + description: |- + How voiding adjusts payment state related to the grant. + + Currently only `none` is supported: voiding does not adjust invoices, payment + authorization, settlement, payment intents, or external collection state. If + payment later completes, the original invoiced amount may still be collected. + default: none + additionalProperties: false + description: Request body for voiding a credit grant. + SortQuery: + title: SortQuery + type: string + example: created_at desc + description: | + The `asc` suffix is optional as the default sort order is ascending. + The `desc` suffix is used to specify a descending order. + Multiple sort attributes may be provided via a comma separated list. + JSONPath notation may be used to specify a sub-attribute (eg: 'foo.bar desc'). + Labels: + title: Labels + type: object + example: + env: test + maxProperties: 50 + description: | + Labels store metadata of an entity that can be used for filtering an entity list or for searching across entity types. + + Keys must be of length 1-63 characters, and cannot start with "kong", "konnect", "mesh", "kic", or "_". + additionalProperties: + type: string + pattern: ^[a-z0-9A-Z]{1}([a-z0-9A-Z-._]*[a-z0-9A-Z]+)?$ + minLength: 1 + maxLength: 63 + PageMeta: + type: object + description: Contains pagination query parameters and the total number of objects returned. + required: + - number + - size + - total + properties: + number: + type: number + example: 1 + x-speakeasy-terraform-ignore: true + size: + type: number + example: 10 + x-speakeasy-terraform-ignore: true + total: + type: number + example: 100 + x-speakeasy-terraform-ignore: true + PaginatedMeta: + type: object + title: PaginatedMeta + x-speakeasy-terraform-ignore: true + description: returns the pagination information + properties: + page: + $ref: '#/components/schemas/PageMeta' + required: + - page + BaseError: + type: object + title: Error + description: standard error + required: + - status + - title + - instance + - detail + properties: + status: + type: integer + description: | + The HTTP status code of the error. Useful when passing the response + body to child properties in a frontend UI. Must be returned as an integer. + readOnly: true + title: + type: string + description: | + A short, human-readable summary of the problem. It should not + change between occurences of a problem, except for localization. + Should be provided as "Sentence case" for direct use in the UI. + readOnly: true + type: + type: string + description: The error type. + readOnly: true + instance: + type: string + description: | + Used to return the correlation ID back to the user, in the format + kong:trace:. This helps us find the relevant logs + when a customer reports an issue. + readOnly: true + detail: + type: string + description: | + A human readable explanation specific to this occurence of the problem. + This field may contain request/entity data to help the user understand + what went wrong. Enclose variable values in square brackets. Should be + provided as "Sentence case" for direct use in the UI. + readOnly: true + InvalidRules: + description: invalid parameters rules + type: string + readOnly: true + nullable: true + enum: + - required + - is_array + - is_base64 + - is_boolean + - is_date_time + - is_integer + - is_null + - is_number + - is_object + - is_string + - is_uuid + - is_fqdn + - is_arn + - unknown_property + - missing_reference + - is_label + - matches_regex + - invalid + - is_supported_network_availability_zone_list + - is_supported_network_cidr_block + - is_supported_provider_region + - type + InvalidParameterStandard: + type: object + additionalProperties: false + properties: + field: + type: string + example: name + readOnly: true + rule: + $ref: '#/components/schemas/InvalidRules' + source: + type: string + example: body + reason: + type: string + example: is a required field + readOnly: true + required: + - field + - reason + InvalidParameterMinimumLength: + type: object + additionalProperties: false + properties: + field: + type: string + example: name + readOnly: true + rule: + description: invalid parameters rules + type: string + readOnly: true + nullable: false + enum: + - min_length + - min_digits + - min_lowercase + - min_uppercase + - min_symbols + - min_items + - min + minimum: + type: integer + example: 8 + source: + type: string + example: body + reason: + type: string + example: must have at least 8 characters + readOnly: true + required: + - field + - reason + - rule + - minimum + InvalidParameterMaximumLength: + type: object + additionalProperties: false + properties: + field: + type: string + example: name + readOnly: true + rule: + description: invalid parameters rules + type: string + readOnly: true + nullable: false + enum: + - max_length + - max_items + - max + maximum: + type: integer + example: 8 + source: + type: string + example: body + reason: + type: string + example: must not have more than 8 characters + readOnly: true + required: + - field + - reason + - rule + - maximum + InvalidParameterChoiceItem: + type: object + additionalProperties: false + properties: + field: + type: string + example: name + readOnly: true + rule: + description: invalid parameters rules + type: string + readOnly: true + nullable: false + enum: + - enum + reason: + type: string + example: is a required field + readOnly: true + choices: + type: array + uniqueItems: true + readOnly: true + nullable: false + minItems: 1 + items: {} + source: + type: string + example: body + required: + - field + - reason + - rule + - choices + InvalidParameterDependentItem: + type: object + additionalProperties: false + properties: + field: + type: string + example: name + readOnly: true + rule: + description: invalid parameters rules + type: string + readOnly: true + nullable: true + enum: + - dependent_fields + reason: + type: string + example: is a required field + readOnly: true + dependents: + type: array + uniqueItems: true + nullable: true + items: {} + readOnly: true + source: + type: string + example: body + required: + - field + - rule + - reason + - dependents + InvalidParameters: + type: array + nullable: false + uniqueItems: true + minItems: 1 + description: invalid parameters + items: + oneOf: + - $ref: '#/components/schemas/InvalidParameterStandard' + - $ref: '#/components/schemas/InvalidParameterMinimumLength' + - $ref: '#/components/schemas/InvalidParameterMaximumLength' + - $ref: '#/components/schemas/InvalidParameterChoiceItem' + - $ref: '#/components/schemas/InvalidParameterDependentItem' + BadRequestError: + allOf: + - $ref: '#/components/schemas/BaseError' + - type: object + required: + - invalid_parameters + properties: + invalid_parameters: + $ref: '#/components/schemas/InvalidParameters' + UnauthorizedError: + allOf: + - $ref: '#/components/schemas/BaseError' + - type: object + properties: + status: + example: 401 + title: + example: Unauthorized + type: + example: https://httpstatuses.com/401 + instance: + example: kong:trace:1234567890 + detail: + example: Invalid credentials + ForbiddenError: + allOf: + - $ref: '#/components/schemas/BaseError' + - type: object + properties: + status: + example: 403 + title: + example: Forbidden + type: + example: https://httpstatuses.com/403 + instance: + example: kong:trace:1234567890 + detail: + example: Forbidden + NotFoundError: + allOf: + - $ref: '#/components/schemas/BaseError' + - type: object + properties: + status: + example: 404 + title: + example: Not Found + type: + example: https://httpstatuses.com/404 + instance: + example: kong:trace:1234567890 + detail: + example: Not found + GoneError: + allOf: + - $ref: '#/components/schemas/BaseError' + - type: object + properties: + status: + example: 410 + title: + example: Gone + type: + example: https://httpstatuses.com/410 + instance: + example: kong:trace:1234567890 + detail: + example: Gone + ConflictError: + allOf: + - $ref: '#/components/schemas/BaseError' + - type: object + properties: + status: + example: 409 + title: + example: Conflict + type: + example: https://httpstatuses.com/409 + instance: + example: kong:trace:1234567890 + detail: + example: Conflict + CursorMetaPage: + type: object + required: + - size + - next + - previous + properties: + first: + description: URI to the first page + type: string + format: path + last: + description: URI to the last page + type: string + format: path + next: + description: URI to the next page + type: string + format: path + nullable: true + previous: + description: URI to the previous page + type: string + format: path + nullable: true + size: + description: Requested page size + type: number + example: 10 + CursorMeta: + type: object + description: Pagination metadata. + required: + - page + properties: + page: + $ref: '#/components/schemas/CursorMetaPage' + responses: + BadRequest: + description: Bad Request + content: + application/problem+json: + schema: + $ref: '#/components/schemas/BadRequestError' + Unauthorized: + description: Unauthorized + content: + application/problem+json: + schema: + $ref: '#/components/schemas/UnauthorizedError' + examples: + UnauthorizedExample: + $ref: '#/components/examples/UnauthorizedExample' + Forbidden: + description: Forbidden + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ForbiddenError' + examples: + UnauthorizedExample: + $ref: '#/components/examples/ForbiddenExample' + NotFound: + description: Not Found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/NotFoundError' + examples: + NotFoundExample: + $ref: '#/components/examples/NotFoundExample' + Gone: + description: Gone + content: + application/problem+json: + schema: + $ref: '#/components/schemas/GoneError' + Conflict: + description: Conflict + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ConflictError' + examples: + UnauthorizedExample: + value: + status: 401 + title: Unauthorized + instance: kong:trace:8347343766220159418 + detail: Unauthorized + ForbiddenExample: + value: + status: 403 + title: Forbidden + instance: kong:trace:2723154947768991354 + detail: You do not have permission to perform this action + NotFoundExample: + value: + status: 404 + title: Not Found + instance: kong:trace:6816496025408232265 + detail: Not Found diff --git a/api/v3/render/render.go b/api/v3/render/render.go new file mode 100644 index 0000000000000000000000000000000000000000..de355d1db6d2363c9e9e0d041da7a721d82765ea --- /dev/null +++ b/api/v3/render/render.go @@ -0,0 +1,86 @@ +package render + +import ( + "encoding/json" + "net/http" + + "github.com/invopop/yaml" +) + +const ( + ContentTypeKey = "Content-Type" + ContentTypeJSONValue = "application/json" + ContentTypeYAMLValue = "application/yaml" + defaultStatusCode = http.StatusOK +) + +type config struct { + statusCode int +} + +type Option func(w http.ResponseWriter, c *config) + +func WithStatus(status int) Option { + return func(_ http.ResponseWriter, c *config) { + c.statusCode = status + } +} + +func WithHeader(key, value string) Option { + return func(w http.ResponseWriter, _ *config) { + w.Header().Set(key, value) + } +} + +func WithContentType(contentType string) Option { + return WithHeader(ContentTypeKey, contentType) +} + +// RenderJSON renders json object with returned apierrors +func RenderJSON[O any](w http.ResponseWriter, o O, opts ...Option) error { + c := &config{ + statusCode: defaultStatusCode, + } + + for _, opt := range opts { + opt(w, c) + } + + if w.Header().Get(ContentTypeKey) == "" { + w.Header().Set(ContentTypeKey, ContentTypeJSONValue) + } + + w.WriteHeader(c.statusCode) + + body, err := json.Marshal(o) + if err != nil { + return err + } + + _, err = w.Write(body) + return err +} + +func RenderYAML[O any](w http.ResponseWriter, o O, opts ...Option) error { + c := &config{ + statusCode: defaultStatusCode, + } + + for _, opt := range opts { + opt(w, c) + } + + if w.Header().Get(ContentTypeKey) == "" { + w.Header().Set(ContentTypeKey, ContentTypeYAMLValue) + } + + w.WriteHeader(c.statusCode) + + body, err := yaml.Marshal(o) + if err != nil { + return err + } + + _, err = w.Write(body) + return err +} diff --git a/api/v3/request/body.go b/api/v3/request/body.go new file mode 100644 index 0000000000000000000000000000000000000000..6cf1cf1fc6b9d1ae4f51ce3888e18a4e4a3a70f6 --- /dev/null +++ b/api/v3/request/body.go @@ -0,0 +1,51 @@ +package request + +import ( + "encoding/json" + "errors" + "io" + "net/http" + + "github.com/openmeterio/openmeter/api/v3/apierrors" +) + +func ParseBody(r *http.Request, payload any) *apierrors.BaseAPIError { + decoder := json.NewDecoder(r.Body) + if err := decoder.Decode(&payload); err != nil { + return apierrors.NewBadRequestError(r.Context(), err, + apierrors.InvalidParameters{ + apierrors.InvalidParameter{ + Reason: "unable to parse body", + Source: apierrors.InvalidParamSourceBody, + }, + }, + ) + } + + return nil +} + +// ParseOptionalBody parses the request body if present, leaving payload unchanged if the body is empty. +func ParseOptionalBody(r *http.Request, payload any) *apierrors.BaseAPIError { + if r.Body == nil { + return nil + } + + decoder := json.NewDecoder(r.Body) + if err := decoder.Decode(&payload); err != nil { + if errors.Is(err, io.EOF) { + return nil + } + + return apierrors.NewBadRequestError(r.Context(), err, + apierrors.InvalidParameters{ + apierrors.InvalidParameter{ + Reason: "unable to parse body", + Source: apierrors.InvalidParamSourceBody, + }, + }, + ) + } + + return nil +} diff --git a/api/v3/request/filter.go b/api/v3/request/filter.go new file mode 100644 index 0000000000000000000000000000000000000000..82d1e27bb0f859a63c61437175218feac9185694 --- /dev/null +++ b/api/v3/request/filter.go @@ -0,0 +1,67 @@ +package request + +import ( + "github.com/samber/lo" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/pkg/filter" +) + +func convertContainsOperator(value *string) *string { + if value == nil { + return nil + } + + return lo.ToPtr(filter.ContainsPattern(*value)) +} + +func convertQueryFilterStringList(source *[]api.QueryFilterString) *[]filter.FilterString { + if source == nil { + return nil + } + + items := make([]filter.FilterString, len(*source)) + for i := range *source { + items[i] = ConvertQueryFilterString((*source)[i]) + } + + return &items +} + +// ConvertQueryFilterString converts an API QueryFilterString to internal filter.FilterString. +func ConvertQueryFilterString(source api.QueryFilterString) filter.FilterString { + return filter.FilterString{ + Eq: source.Eq, + Ne: source.Neq, + In: source.In, + Nin: source.Nin, + Like: convertContainsOperator(source.Contains), + Nlike: convertContainsOperator(source.Ncontains), + And: convertQueryFilterStringList(source.And), + Or: convertQueryFilterStringList(source.Or), + } +} + +// ConvertQueryFilterStringPtr converts an API QueryFilterString pointer to internal filter.FilterString pointer. +func ConvertQueryFilterStringPtr(source *api.QueryFilterString) *filter.FilterString { + if source == nil { + return nil + } + + return lo.ToPtr(ConvertQueryFilterString(*source)) +} + +// ConvertQueryFilterStringMapItem converts an API string query filter to internal filter.FilterString. +func ConvertQueryFilterStringMapItem(source api.QueryFilterStringMapItem) filter.FilterString { + return filter.FilterString{ + Exists: source.Exists, + Eq: source.Eq, + Ne: source.Neq, + In: source.In, + Nin: source.Nin, + Like: convertContainsOperator(source.Contains), + Nlike: convertContainsOperator(source.Ncontains), + And: convertQueryFilterStringList(source.And), + Or: convertQueryFilterStringList(source.Or), + } +} diff --git a/api/v3/request/filter_test.go b/api/v3/request/filter_test.go new file mode 100644 index 0000000000000000000000000000000000000000..0e41db800f04434d7e236e4df571043b71885ae6 --- /dev/null +++ b/api/v3/request/filter_test.go @@ -0,0 +1,127 @@ +package request_test + +import ( + "testing" + + "github.com/samber/lo" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/request" + "github.com/openmeterio/openmeter/pkg/filter" +) + +func TestConvertQueryFilterString(t *testing.T) { + t.Run("maps direct operators", func(t *testing.T) { + in := api.QueryFilterString{ + Eq: lo.ToPtr("eq"), + Neq: lo.ToPtr("ne"), + In: &[]string{"a", "b"}, + Nin: &[]string{"c", "d"}, + } + + got := request.ConvertQueryFilterString(in) + + assert.Equal(t, filter.FilterString{ + Eq: lo.ToPtr("eq"), + Ne: lo.ToPtr("ne"), + In: &[]string{"a", "b"}, + Nin: &[]string{"c", "d"}, + }, got) + }) + + t.Run("maps contains operators", func(t *testing.T) { + in := api.QueryFilterString{ + Contains: lo.ToPtr("abc"), + Ncontains: lo.ToPtr("xyz"), + } + + got := request.ConvertQueryFilterString(in) + + assert.Equal(t, filter.FilterString{ + Like: lo.ToPtr("%abc%"), + Nlike: lo.ToPtr("%xyz%"), + }, got) + }) + + t.Run("escapes like metacharacters in contains operators", func(t *testing.T) { + in := api.QueryFilterString{ + Contains: lo.ToPtr(`100%\path_name`), + Ncontains: lo.ToPtr(`a_b\c%d`), + } + + got := request.ConvertQueryFilterString(in) + + assert.Equal(t, filter.FilterString{ + Like: lo.ToPtr(`%100\%\\path\_name%`), + Nlike: lo.ToPtr(`%a\_b\\c\%d%`), + }, got) + }) + + t.Run("maps and or recursively", func(t *testing.T) { + in := api.QueryFilterString{ + And: &[]api.QueryFilterString{ + {Eq: lo.ToPtr("a")}, + {Contains: lo.ToPtr("b")}, + }, + Or: &[]api.QueryFilterString{ + {Neq: lo.ToPtr("c")}, + {Ncontains: lo.ToPtr("d")}, + }, + } + + got := request.ConvertQueryFilterString(in) + + assert.Equal(t, filter.FilterString{ + And: &[]filter.FilterString{ + {Eq: lo.ToPtr("a")}, + {Like: lo.ToPtr("%b%")}, + }, + Or: &[]filter.FilterString{ + {Ne: lo.ToPtr("c")}, + {Nlike: lo.ToPtr("%d%")}, + }, + }, got) + }) + + t.Run("maps deeply nested and or", func(t *testing.T) { + in := api.QueryFilterString{ + And: &[]api.QueryFilterString{ + { + Or: &[]api.QueryFilterString{ + {Eq: lo.ToPtr("nested")}, + }, + }, + }, + } + + got := request.ConvertQueryFilterString(in) + + assert.Equal(t, filter.FilterString{ + And: &[]filter.FilterString{ + { + Or: &[]filter.FilterString{ + {Eq: lo.ToPtr("nested")}, + }, + }, + }, + }, got) + }) +} + +func TestConvertQueryFilterStringPtr(t *testing.T) { + t.Run("nil pointer", func(t *testing.T) { + assert.Nil(t, request.ConvertQueryFilterStringPtr(nil)) + }) + + t.Run("non nil pointer", func(t *testing.T) { + in := &api.QueryFilterString{Contains: lo.ToPtr(`abc\_%`)} + + got := request.ConvertQueryFilterStringPtr(in) + require.NotNil(t, got) + assert.Equal(t, &filter.FilterString{ + Like: lo.ToPtr(`%abc\\\_\%%`), + }, got) + }) +} diff --git a/api/v3/request/sort.go b/api/v3/request/sort.go new file mode 100644 index 0000000000000000000000000000000000000000..b4e7d99e0024836fca55a0167b26a187bf3c5078 --- /dev/null +++ b/api/v3/request/sort.go @@ -0,0 +1,84 @@ +package request + +import ( + "errors" + "strings" + + "github.com/openmeterio/openmeter/pkg/sortx" +) + +type SortOrder string + +const ( + SortOrderAsc SortOrder = "asc" + SortOrderDesc SortOrder = "desc" +) + +var ( + ErrSortByInvalid = errors.New("invalid sort by") + ErrSortFieldRequired = errors.New("sort field is required") + ErrSortOrderInvalid = errors.New("sort order must be either asc or desc") + defaultOrder = SortOrderAsc +) + +func (s SortOrder) Validate() error { + if s != SortOrderAsc && s != SortOrderDesc { + return ErrSortOrderInvalid + } + + return nil +} + +type SortBy struct { + Field string + Order SortOrder +} + +func ParseSortBy(sort string) (*SortBy, error) { + var s SortBy + err := s.UnmarshalText([]byte(sort)) + if err != nil { + return nil, err + } + + return &s, nil +} + +func (s SortBy) Validate() error { + if s.Field == "" { + return ErrSortFieldRequired + } + + if err := s.Order.Validate(); err != nil { + return err + } + + return nil +} + +func (s *SortBy) UnmarshalText(text []byte) error { + parts := strings.Fields(string(text)) + if len(parts) == 0 { + return ErrSortByInvalid + } + if len(parts) > 2 { + return ErrSortByInvalid + } + + s.Field = parts[0] + if len(parts) == 2 { + s.Order = SortOrder(parts[1]) + } else { + s.Order = defaultOrder + } + + return s.Validate() +} + +func (s SortOrder) ToSortxOrder() sortx.Order { + if s == SortOrderAsc { + return sortx.OrderAsc + } + + return sortx.OrderDesc +} diff --git a/api/v3/response/cursorpagination.go b/api/v3/response/cursorpagination.go new file mode 100644 index 0000000000000000000000000000000000000000..87582b679006fed61493c34b8db5fcfb00ecec55 --- /dev/null +++ b/api/v3/response/cursorpagination.go @@ -0,0 +1,68 @@ +package response + +import ( + "github.com/oapi-codegen/nullable" + "github.com/samber/lo" + + "github.com/openmeterio/openmeter/pkg/pagination/v2" +) + +// CursorMeta Pagination metadata. +type CursorMeta struct { + Page CursorMetaPage `json:"page"` +} + +// CursorMetaPage defines model for CursorMetaPage. +type CursorMetaPage struct { + // First cursor + First *string `json:"first,omitempty"` + + // Last cursor + Last *string `json:"last,omitempty"` + + // Next URI to the next page + Next nullable.Nullable[string] `json:"next"` + + // Previous URI to the previous page + Previous nullable.Nullable[string] `json:"previous"` + + // Size of the requested page + Size int `json:"size"` +} + +// CursorPaginationResponse represents the response structure for cursor-based pagination +type CursorPaginationResponse[T any] struct { + // The data returned + Data []T `json:"data"` + + Meta CursorMeta `json:"meta"` +} + +// NewCursorPaginationResponse creates a new pagination response from an ordered list of items. +// T must implement the Item interface for cursor generation. +func NewCursorPaginationResponse[T pagination.Item](items []T, pageSize int) CursorPaginationResponse[T] { + result := CursorPaginationResponse[T]{ + Data: items, + Meta: CursorMeta{ + Page: CursorMetaPage{ + Next: nullable.NewNullNullable[string](), + Previous: nullable.NewNullNullable[string](), + Size: pageSize, + }, + }, + } + + // Generate first and last cursor from the first and last item if there are any items + if len(items) > 0 { + firstItem := items[0] + lastItem := items[len(items)-1] + result.Meta.Page.First = lo.ToPtr(firstItem.Cursor().Encode()) + result.Meta.Page.Last = lo.ToPtr(lastItem.Cursor().Encode()) + // Forward pagination only while a full page was returned. + if len(items) == pageSize { + result.Meta.Page.Next = nullable.NewNullableWithValue(lastItem.Cursor().Encode()) + } + } + + return result +} diff --git a/api/v3/response/pagepagination.go b/api/v3/response/pagepagination.go new file mode 100644 index 0000000000000000000000000000000000000000..9e684c0cb9caa1409b3396643be1f78dc6948179 --- /dev/null +++ b/api/v3/response/pagepagination.go @@ -0,0 +1,25 @@ +package response + +type PagePaginationResponse[T any] struct { + Data []T `json:"data"` + Meta PageMeta `json:"meta"` +} + +type PageMeta struct { + Page PageMetaPage `json:"page"` +} + +type PageMetaPage struct { + Size int `json:"size"` + Number int `json:"number"` + Total *int `json:"total,omitempty"` +} + +func NewPagePaginationResponse[T any](items []T, page PageMetaPage) PagePaginationResponse[T] { + return PagePaginationResponse[T]{ + Data: items, + Meta: PageMeta{ + Page: page, + }, + } +} diff --git a/api/v3/server/routes.go b/api/v3/server/routes.go new file mode 100644 index 0000000000000000000000000000000000000000..33cd61850ee02f3353d0cda811331288e140d07d --- /dev/null +++ b/api/v3/server/routes.go @@ -0,0 +1,569 @@ +package server + +import ( + "net/http" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/handlers/billinginvoices" + currencieshandler "github.com/openmeterio/openmeter/api/v3/handlers/currencies" + chargeshandler "github.com/openmeterio/openmeter/api/v3/handlers/customers/charges" + customerscreditshandler "github.com/openmeterio/openmeter/api/v3/handlers/customers/credits" + planhandler "github.com/openmeterio/openmeter/api/v3/handlers/plans" + planaddonshandler "github.com/openmeterio/openmeter/api/v3/handlers/plans/planaddons" + subscriptionhandler "github.com/openmeterio/openmeter/api/v3/handlers/subscriptions" + subscriptionaddonshandler "github.com/openmeterio/openmeter/api/v3/handlers/subscriptions/subscriptionaddons" + "github.com/openmeterio/openmeter/pkg/featuregate" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" +) + +// Meters + +func (s *Server) CreateMeter(w http.ResponseWriter, r *http.Request) { + s.metersHandler.CreateMeter().ServeHTTP(w, r) +} + +func (s *Server) GetMeter(w http.ResponseWriter, r *http.Request, meterId api.ULID) { + s.metersHandler.GetMeter().With(meterId).ServeHTTP(w, r) +} + +func (s *Server) ListMeters(w http.ResponseWriter, r *http.Request, params api.ListMetersParams) { + s.metersHandler.ListMeters().With(params).ServeHTTP(w, r) +} + +func (s *Server) UpdateMeter(w http.ResponseWriter, r *http.Request, meterId api.ULID) { + s.metersHandler.UpdateMeter().With(meterId).ServeHTTP(w, r) +} + +func (s *Server) DeleteMeter(w http.ResponseWriter, r *http.Request, meterId api.ULID) { + s.metersHandler.DeleteMeter().With(meterId).ServeHTTP(w, r) +} + +func (s *Server) QueryMeter(w http.ResponseWriter, r *http.Request, meterId api.ULID) { + // Content negotiation: Accept: text/csv switches to the CSV encoder. + // All other media types (including a missing Accept header) use JSON. + if mediaType, _ := commonhttp.GetMediaType(r); mediaType == "text/csv" { + s.metersHandler.QueryMeterCSV().With(meterId).ServeHTTP(w, r) + return + } + + s.metersHandler.QueryMeter().With(meterId).ServeHTTP(w, r) +} + +// Events + +func (s *Server) IngestMeteringEvents(w http.ResponseWriter, r *http.Request) { + s.eventsHandler.IngestEvents().ServeHTTP(w, r) +} + +func (s *Server) ListMeteringEvents(w http.ResponseWriter, r *http.Request, params api.ListMeteringEventsParams) { + s.eventsHandler.ListMeteringEvents().With(params).ServeHTTP(w, r) +} + +// Customers + +func (s *Server) CreateCustomer(w http.ResponseWriter, r *http.Request) { + s.customersHandler.CreateCustomer().ServeHTTP(w, r) +} + +func (s *Server) GetCustomer(w http.ResponseWriter, r *http.Request, customerId api.ULID) { + s.customersHandler.GetCustomer().With(customerId).ServeHTTP(w, r) +} + +func (s *Server) ListCustomers(w http.ResponseWriter, r *http.Request, params api.ListCustomersParams) { + s.customersHandler.ListCustomers().With(params).ServeHTTP(w, r) +} + +func (s *Server) UpsertCustomer(w http.ResponseWriter, r *http.Request, customerId api.ULID) { + s.customersHandler.UpsertCustomer().With(customerId).ServeHTTP(w, r) +} + +func (s *Server) DeleteCustomer(w http.ResponseWriter, r *http.Request, customerId api.ULID) { + s.customersHandler.DeleteCustomer().With(customerId).ServeHTTP(w, r) +} + +// Customers Entitlement Access + +func (s *Server) ListCustomerEntitlementAccess(w http.ResponseWriter, r *http.Request, customerId api.ULID) { + s.customersEntitlementHandler.ListCustomerEntitlementAccess().With(customerId).ServeHTTP(w, r) +} + +// Subscriptions + +func (s *Server) CreateSubscription(w http.ResponseWriter, r *http.Request) { + s.subscriptionsHandler.CreateSubscription(). + Chain(featuregate.NewMiddleware[subscriptionhandler.CreateSubscriptionRequest, subscriptionhandler.CreateSubscriptionResponse]( + s.NamespaceDecoder.GetNamespace, + s.FeatureGate, + )).ServeHTTP(w, r) +} + +func (s *Server) ListSubscriptions(w http.ResponseWriter, r *http.Request, params api.ListSubscriptionsParams) { + s.subscriptionsHandler.ListSubscriptions().With(params).ServeHTTP(w, r) +} + +func (s *Server) GetSubscription(w http.ResponseWriter, r *http.Request, subscriptionId api.ULID) { + s.subscriptionsHandler.GetSubscription().With(subscriptionId).ServeHTTP(w, r) +} + +func (s *Server) CancelSubscription(w http.ResponseWriter, r *http.Request, subscriptionId api.ULID) { + s.subscriptionsHandler.CancelSubscription().With(subscriptionId).ServeHTTP(w, r) +} + +func (s *Server) UnscheduleCancelation(w http.ResponseWriter, r *http.Request, subscriptionId api.ULID) { + s.subscriptionsHandler.UnscheduleCancelation().With(subscriptionId).ServeHTTP(w, r) +} + +func (s *Server) ChangeSubscription(w http.ResponseWriter, r *http.Request, subscriptionId api.ULID) { + s.subscriptionsHandler.ChangeSubscription(). + Chain(featuregate.NewMiddleware[subscriptionhandler.ChangeSubscriptionRequest, subscriptionhandler.ChangeSubscriptionResponse]( + s.NamespaceDecoder.GetNamespace, + s.FeatureGate, + )).With(subscriptionId).ServeHTTP(w, r) +} + +// Subscription Addons +func (s *Server) CreateSubscriptionAddon(w http.ResponseWriter, r *http.Request, subscriptionId api.ULID) { + s.subscriptionAddonsHandler.CreateSubscriptionAddon().With(subscriptionId).ServeHTTP(w, r) +} + +func (s *Server) ListSubscriptionAddons(w http.ResponseWriter, r *http.Request, subscriptionId api.ULID, params api.ListSubscriptionAddonsParams) { + s.subscriptionAddonsHandler.ListSubscriptionAddons().With(subscriptionaddonshandler.ListSubscriptionAddonsParams{ + SubscriptionID: subscriptionId, + Params: params, + }).ServeHTTP(w, r) +} + +func (s *Server) GetSubscriptionAddon(w http.ResponseWriter, r *http.Request, subscriptionId api.ULID, subscriptionAddonId api.ULID) { + s.subscriptionAddonsHandler.GetSubscriptionAddon().With(subscriptionaddonshandler.GetSubscriptionAddonParams{ + SubscriptionID: subscriptionId, + SubscriptionAddonID: subscriptionAddonId, + }).ServeHTTP(w, r) +} + +// Apps + +func (s *Server) ListApps(w http.ResponseWriter, r *http.Request, params api.ListAppsParams) { + s.appsHandler.ListApps().With(params).ServeHTTP(w, r) +} + +func (s *Server) GetApp(w http.ResponseWriter, r *http.Request, appId api.ULID) { + s.appsHandler.GetApp().With(appId).ServeHTTP(w, r) +} + +func (s *Server) ListAppCatalog(w http.ResponseWriter, r *http.Request, params api.ListAppCatalogParams) { + s.appsHandler.ListAppCatalog().With(params).ServeHTTP(w, r) +} + +func (s *Server) GetAppCatalogItem(w http.ResponseWriter, r *http.Request, pType api.BillingAppType) { + s.appsHandler.GetAppCatalog().With(pType).ServeHTTP(w, r) +} + +func (s *Server) InstallApp(w http.ResponseWriter, r *http.Request) { + s.appsHandler.InstallApp().ServeHTTP(w, r) +} + +// Billing Profiles + +func (s *Server) ListBillingProfiles(w http.ResponseWriter, r *http.Request, params api.ListBillingProfilesParams) { + s.billingProfilesHandler.ListBillingProfiles().With(params).ServeHTTP(w, r) +} + +func (s *Server) CreateBillingProfile(w http.ResponseWriter, r *http.Request) { + s.billingProfilesHandler.CreateBillingProfile().ServeHTTP(w, r) +} + +func (s *Server) DeleteBillingProfile(w http.ResponseWriter, r *http.Request, id api.ULID) { + s.billingProfilesHandler.DeleteBillingProfile().With(id).ServeHTTP(w, r) +} + +func (s *Server) GetBillingProfile(w http.ResponseWriter, r *http.Request, id api.ULID) { + s.billingProfilesHandler.GetBillingProfile().With(id).ServeHTTP(w, r) +} + +func (s *Server) UpdateBillingProfile(w http.ResponseWriter, r *http.Request, id api.ULID) { + s.billingProfilesHandler.UpdateBillingProfile().With(id).ServeHTTP(w, r) +} + +// Billing Invoices + +func (s *Server) ListInvoices(w http.ResponseWriter, r *http.Request, params api.ListInvoicesParams) { + s.billingInvoicesHandler.ListBillingInvoices().With(params).ServeHTTP(w, r) +} + +func (s *Server) GetInvoice(w http.ResponseWriter, r *http.Request, invoiceId api.ULID) { + s.billingInvoicesHandler.GetBillingInvoice().With(invoiceId).ServeHTTP(w, r) +} + +func (s *Server) UpdateInvoice(w http.ResponseWriter, r *http.Request, invoiceId api.ULID) { + s.billingInvoicesHandler.UpdateBillingInvoice().With(invoiceId).ServeHTTP(w, r) +} + +func (s *Server) DeleteInvoice(w http.ResponseWriter, r *http.Request, invoiceId api.ULID) { + s.billingInvoicesHandler.DeleteBillingInvoice().With(invoiceId).ServeHTTP(w, r) +} + +func (s *Server) AdvanceInvoice(w http.ResponseWriter, r *http.Request, invoiceId api.ULID) { + s.billingInvoicesHandler.ProgressInvoice(billinginvoices.InvoiceProgressActionAdvance). + With(invoiceId).ServeHTTP(w, r) +} + +func (s *Server) ApproveInvoice(w http.ResponseWriter, r *http.Request, invoiceId api.ULID) { + s.billingInvoicesHandler.ProgressInvoice(billinginvoices.InvoiceProgressActionApprove). + With(invoiceId).ServeHTTP(w, r) +} + +func (s *Server) RetryInvoice(w http.ResponseWriter, r *http.Request, invoiceId api.ULID) { + s.billingInvoicesHandler.ProgressInvoice(billinginvoices.InvoiceProgressActionRetry). + With(invoiceId).ServeHTTP(w, r) +} + +func (s *Server) SnapshotQuantitiesInvoice(w http.ResponseWriter, r *http.Request, invoiceId api.ULID) { + s.billingInvoicesHandler.ProgressInvoice(billinginvoices.InvoiceProgressActionSnapshotQuantities). + With(invoiceId).ServeHTTP(w, r) +} + +// Customer Billing + +func (s *Server) GetCustomerBilling(w http.ResponseWriter, r *http.Request, customerId api.ULID) { + s.customersBillingHandler.GetCustomerBilling().With(customerId).ServeHTTP(w, r) +} + +func (s *Server) UpdateCustomerBilling(w http.ResponseWriter, r *http.Request, customerId api.ULID) { + s.customersBillingHandler.UpdateCustomerBilling().With(customerId).ServeHTTP(w, r) +} + +func (s *Server) UpdateCustomerBillingAppData(w http.ResponseWriter, r *http.Request, customerId api.ULID) { + s.customersBillingHandler.UpdateCustomerBillingAppData().With(customerId).ServeHTTP(w, r) +} + +func (s *Server) CreateCustomerStripeCheckoutSession(w http.ResponseWriter, r *http.Request, customerId api.ULID) { + s.customersBillingHandler.CreateCustomerStripeCheckoutSession().With(customerId).ServeHTTP(w, r) +} + +func (s *Server) CreateCustomerStripePortalSession(w http.ResponseWriter, r *http.Request, customerId api.ULID) { + s.customersBillingHandler.CreateCustomerStripePortalSession().With(customerId).ServeHTTP(w, r) +} + +// Tax Codes + +func (s *Server) ListTaxCodes(w http.ResponseWriter, r *http.Request, params api.ListTaxCodesParams) { + s.taxcodesHandler.ListTaxCodes().With(params).ServeHTTP(w, r) +} + +func (s *Server) CreateTaxCode(w http.ResponseWriter, r *http.Request) { + s.taxcodesHandler.CreateTaxCode().ServeHTTP(w, r) +} + +func (s *Server) DeleteTaxCode(w http.ResponseWriter, r *http.Request, taxCodeId api.ULID) { + s.taxcodesHandler.DeleteTaxCode().With(taxCodeId).ServeHTTP(w, r) +} + +func (s *Server) GetTaxCode(w http.ResponseWriter, r *http.Request, taxCodeId api.ULID) { + s.taxcodesHandler.GetTaxCode().With(taxCodeId).ServeHTTP(w, r) +} + +func (s *Server) UpsertTaxCode(w http.ResponseWriter, r *http.Request, taxCodeId api.ULID) { + s.taxcodesHandler.UpdateTaxCode().With(taxCodeId).ServeHTTP(w, r) +} + +// Currencies + +func (s *Server) ListCurrencies(w http.ResponseWriter, r *http.Request, params api.ListCurrenciesParams) { + s.currenciesHandler.ListCurrencies().With(params).ServeHTTP(w, r) +} + +func (s *Server) CreateCustomCurrency(w http.ResponseWriter, r *http.Request) { + s.currenciesHandler.CreateCurrency().ServeHTTP(w, r) +} + +func (s *Server) CreateCostBasis(w http.ResponseWriter, r *http.Request, currencyId api.ULID) { + s.currenciesHandler.CreateCostBasis().With(currencyId).ServeHTTP(w, r) +} + +func (s *Server) ListCostBases(w http.ResponseWriter, r *http.Request, currencyId api.ULID, params api.ListCostBasesParams) { + s.currenciesHandler.ListCostBases().With(currencieshandler.ListCostBasesArgs{CurrencyID: currencyId, Params: params}).ServeHTTP(w, r) +} + +// Features + +func (s *Server) ListFeatures(w http.ResponseWriter, r *http.Request, params api.ListFeaturesParams) { + s.featuresHandler.ListFeatures().With(params).ServeHTTP(w, r) +} + +func (s *Server) CreateFeature(w http.ResponseWriter, r *http.Request) { + s.featuresHandler.CreateFeature().ServeHTTP(w, r) +} + +func (s *Server) GetFeature(w http.ResponseWriter, r *http.Request, featureId api.ULID) { + s.featuresHandler.GetFeature().With(featureId).ServeHTTP(w, r) +} + +func (s *Server) UpdateFeature(w http.ResponseWriter, r *http.Request, featureId api.ULID) { + s.featuresHandler.UpdateFeature().With(featureId).ServeHTTP(w, r) +} + +func (s *Server) DeleteFeature(w http.ResponseWriter, r *http.Request, featureId api.ULID) { + s.featuresHandler.DeleteFeature().With(featureId).ServeHTTP(w, r) +} + +// Feature Cost + +func (s *Server) QueryFeatureCost(w http.ResponseWriter, r *http.Request, featureId api.ULID) { + s.featureCostHandler.QueryFeatureCost().With(featureId).ServeHTTP(w, r) +} + +// LLM Cost Prices + +func (s *Server) ListLlmCostPrices(w http.ResponseWriter, r *http.Request, params api.ListLlmCostPricesParams) { + s.llmcostHandler.ListPrices().With(params).ServeHTTP(w, r) +} + +func (s *Server) GetLlmCostPrice(w http.ResponseWriter, r *http.Request, priceId api.ULID) { + s.llmcostHandler.GetPrice().With(priceId).ServeHTTP(w, r) +} + +// LLM Cost Overrides + +func (s *Server) ListLlmCostOverrides(w http.ResponseWriter, r *http.Request, params api.ListLlmCostOverridesParams) { + s.llmcostHandler.ListOverrides().With(params).ServeHTTP(w, r) +} + +func (s *Server) CreateLlmCostOverride(w http.ResponseWriter, r *http.Request) { + s.llmcostHandler.CreateOverride().ServeHTTP(w, r) +} + +func (s *Server) DeleteLlmCostOverride(w http.ResponseWriter, r *http.Request, priceId api.ULID) { + s.llmcostHandler.DeleteOverride().With(priceId).ServeHTTP(w, r) +} + +// Plans + +func (s *Server) ListPlans(w http.ResponseWriter, r *http.Request, params api.ListPlansParams) { + s.plansHandler.ListPlans().With(params).ServeHTTP(w, r) +} + +func (s *Server) CreatePlan(w http.ResponseWriter, r *http.Request) { + s.plansHandler.CreatePlan().Chain(featuregate.NewMiddleware[planhandler.CreatePlanRequest, planhandler.CreatePlanResponse]( + s.NamespaceDecoder.GetNamespace, + s.FeatureGate, + )).ServeHTTP(w, r) +} + +func (s *Server) GetPlan(w http.ResponseWriter, r *http.Request, planId api.ULID) { + s.plansHandler.GetPlan().With(planId).ServeHTTP(w, r) +} + +func (s *Server) UpdatePlan(w http.ResponseWriter, r *http.Request, planId api.ULID) { + s.plansHandler.UpdatePlan().Chain(featuregate.NewMiddleware[planhandler.UpdatePlanRequest, planhandler.UpdatePlanResponse]( + s.NamespaceDecoder.GetNamespace, + s.FeatureGate, + )).With(planId).ServeHTTP(w, r) +} + +func (s *Server) DeletePlan(w http.ResponseWriter, r *http.Request, planId api.ULID) { + s.plansHandler.DeletePlan().With(planId).ServeHTTP(w, r) +} + +func (s *Server) ArchivePlan(w http.ResponseWriter, r *http.Request, planId api.ULID) { + s.plansHandler.ArchivePlan().With(planId).ServeHTTP(w, r) +} + +func (s *Server) PublishPlan(w http.ResponseWriter, r *http.Request, planId api.ULID) { + s.plansHandler.PublishPlan().With(planId).ServeHTTP(w, r) +} + +// Addons + +func (s *Server) ListAddons(w http.ResponseWriter, r *http.Request, params api.ListAddonsParams) { + s.addonHandler.ListAddons().With(params).ServeHTTP(w, r) +} + +func (s *Server) CreateAddon(w http.ResponseWriter, r *http.Request) { + s.addonHandler.CreateAddon().ServeHTTP(w, r) +} + +func (s *Server) GetAddon(w http.ResponseWriter, r *http.Request, addonId api.ULID) { + s.addonHandler.GetAddon().With(addonId).ServeHTTP(w, r) +} + +func (s *Server) UpdateAddon(w http.ResponseWriter, r *http.Request, addonId api.ULID) { + s.addonHandler.UpdateAddon().With(addonId).ServeHTTP(w, r) +} + +func (s *Server) DeleteAddon(w http.ResponseWriter, r *http.Request, addonId api.ULID) { + s.addonHandler.DeleteAddon().With(addonId).ServeHTTP(w, r) +} + +func (s *Server) ArchiveAddon(w http.ResponseWriter, r *http.Request, addonId api.ULID) { + s.addonHandler.ArchiveAddon().With(addonId).ServeHTTP(w, r) +} + +func (s *Server) PublishAddon(w http.ResponseWriter, r *http.Request, addonId api.ULID) { + s.addonHandler.PublishAddon().With(addonId).ServeHTTP(w, r) +} + +// Plan Addons + +func (s *Server) ListPlanAddons(w http.ResponseWriter, r *http.Request, planId api.ULID, params api.ListPlanAddonsParams) { + s.planAddonsHandler.ListPlanAddons().With(planaddonshandler.ListPlanAddonsParams{ + PlanID: planId, + Params: params, + }).ServeHTTP(w, r) +} + +func (s *Server) CreatePlanAddon(w http.ResponseWriter, r *http.Request, planId api.ULID) { + s.planAddonsHandler.CreatePlanAddon().With(planId).ServeHTTP(w, r) +} + +func (s *Server) GetPlanAddon(w http.ResponseWriter, r *http.Request, planId api.ULID, planAddonId api.ULID) { + s.planAddonsHandler.GetPlanAddon().With(planaddonshandler.GetPlanAddonParams{ + PlanID: planId, + PlanAddonID: planAddonId, + }).ServeHTTP(w, r) +} + +func (s *Server) UpdatePlanAddon(w http.ResponseWriter, r *http.Request, planId api.ULID, planAddonId api.ULID) { + s.planAddonsHandler.UpdatePlanAddon().With(planaddonshandler.UpdatePlanAddonParams{ + PlanID: planId, + PlanAddonID: planAddonId, + }).ServeHTTP(w, r) +} + +func (s *Server) DeletePlanAddon(w http.ResponseWriter, r *http.Request, planId api.ULID, planAddonId api.ULID) { + s.planAddonsHandler.DeletePlanAddon().With(planaddonshandler.DeletePlanAddonParams{ + PlanID: planId, + PlanAddonID: planAddonId, + }).ServeHTTP(w, r) +} + +// Credits + +var unimplemented = api.Unimplemented{} + +func (s *Server) GetCustomerCreditBalance(w http.ResponseWriter, r *http.Request, customerId api.ULID, params api.GetCustomerCreditBalanceParams) { + if !s.Credits.Enabled || s.customersCreditsHandler == nil { + unimplemented.GetCustomerCreditBalance(w, r, customerId, params) + return + } + + s.customersCreditsHandler.GetCustomerCreditBalance().With(customerscreditshandler.GetCustomerCreditBalanceParams{ + CustomerID: customerId, + Params: params, + }).ServeHTTP(w, r) +} + +func (s *Server) ListCreditGrants(w http.ResponseWriter, r *http.Request, customerId api.ULID, params api.ListCreditGrantsParams) { + if !s.Credits.Enabled || s.customersCreditsHandler == nil || s.CreditGrantService == nil { + unimplemented.ListCreditGrants(w, r, customerId, params) + return + } + + s.customersCreditsHandler.ListCreditGrants().With(customerscreditshandler.ListCreditGrantsParams{ + CustomerID: customerId, + Params: params, + }).ServeHTTP(w, r) +} + +func (s *Server) CreateCreditGrant(w http.ResponseWriter, r *http.Request, customerId api.ULID) { + if !s.Credits.Enabled || s.customersCreditsHandler == nil || s.CreditGrantService == nil { + unimplemented.CreateCreditGrant(w, r, customerId) + return + } + + s.customersCreditsHandler.CreateCreditGrant().With(customerscreditshandler.CreateCreditGrantParams{ + CustomerID: customerId, + }).ServeHTTP(w, r) +} + +func (s *Server) GetCreditGrant(w http.ResponseWriter, r *http.Request, customerId api.ULID, creditGrantId api.ULID) { + if !s.Credits.Enabled || s.customersCreditsHandler == nil || s.CreditGrantService == nil { + unimplemented.GetCreditGrant(w, r, customerId, creditGrantId) + return + } + + s.customersCreditsHandler.GetCreditGrant().With(customerscreditshandler.GetCreditGrantParams{ + CustomerID: customerId, + CreditGrantID: creditGrantId, + }).ServeHTTP(w, r) +} + +func (s *Server) CreateCreditAdjustment(w http.ResponseWriter, r *http.Request, customerId api.ULID) { + unimplemented.CreateCreditAdjustment(w, r, customerId) +} + +func (s *Server) VoidCreditGrant(w http.ResponseWriter, r *http.Request, customerId api.ULID, creditGrantId api.ULID) { + if !s.Credits.Enabled || s.customersCreditsHandler == nil || s.CreditGrantService == nil { + unimplemented.VoidCreditGrant(w, r, customerId, creditGrantId) + return + } + + s.customersCreditsHandler.VoidCreditGrant().With(customerscreditshandler.VoidCreditGrantParams{ + CustomerID: customerId, + CreditGrantID: creditGrantId, + }).ServeHTTP(w, r) +} + +func (s *Server) UpdateCreditGrantExternalSettlement(w http.ResponseWriter, r *http.Request, customerId api.ULID, creditGrantId api.ULID) { + if !s.Credits.Enabled || s.customersCreditsHandler == nil || s.CreditGrantService == nil { + unimplemented.UpdateCreditGrantExternalSettlement(w, r, customerId, creditGrantId) + return + } + + s.customersCreditsHandler.UpdateCreditGrantExternalSettlement().With(customerscreditshandler.UpdateCreditGrantExternalSettlementParams{ + CustomerID: customerId, + CreditGrantID: creditGrantId, + }).ServeHTTP(w, r) +} + +func (s *Server) ListCreditTransactions(w http.ResponseWriter, r *http.Request, customerId api.ULID, params api.ListCreditTransactionsParams) { + if !s.Credits.Enabled || s.customersCreditsHandler == nil || s.Ledger == nil { + unimplemented.ListCreditTransactions(w, r, customerId, params) + return + } + + s.customersCreditsHandler.ListCreditTransactions().With(customerscreditshandler.ListCreditTransactionsParams{ + CustomerID: customerId, + Params: params, + }).ServeHTTP(w, r) +} + +// Charges + +func (s *Server) ListCustomerCharges(w http.ResponseWriter, r *http.Request, customerId api.ULID, params api.ListCustomerChargesParams) { + if s.chargesHandler == nil { + unimplemented.ListCustomerCharges(w, r, customerId, params) + return + } + + s.chargesHandler.ListCustomerCharges().With(chargeshandler.ListCustomerChargesParams{ + CustomerID: customerId, + Params: params, + }).ServeHTTP(w, r) +} + +func (s *Server) CreateCustomerCharges(w http.ResponseWriter, r *http.Request, customerId api.ULID) { + if s.chargesHandler == nil { + unimplemented.CreateCustomerCharges(w, r, customerId) + return + } + + s.chargesHandler.CreateCustomerCharge().With(chargeshandler.CreateCustomerChargesParams{ + CustomerID: customerId, + }).ServeHTTP(w, r) +} + +// Organization Default Tax Codes + +func (s *Server) GetOrganizationDefaultTaxCodes(w http.ResponseWriter, r *http.Request) { + s.taxcodesHandler.GetOrganizationDefaultTaxCodes().ServeHTTP(w, r) +} + +func (s *Server) UpdateOrganizationDefaultTaxCodes(w http.ResponseWriter, r *http.Request) { + s.taxcodesHandler.UpsertOrganizationDefaultTaxCodes().ServeHTTP(w, r) +} + +// Governance + +func (s *Server) QueryGovernanceAccess(w http.ResponseWriter, r *http.Request, params api.QueryGovernanceAccessParams) { + s.governanceHandler.QueryGovernanceAccess().With(params).ServeHTTP(w, r) +} diff --git a/api/v3/server/server.go b/api/v3/server/server.go new file mode 100644 index 0000000000000000000000000000000000000000..0651ae48525dba7532b3f95de4262d02c6cbc315 --- /dev/null +++ b/api/v3/server/server.go @@ -0,0 +1,489 @@ +package server + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/getkin/kin-openapi/openapi3filter" + "github.com/getkin/kin-openapi/routers" + "github.com/go-chi/chi/v5" + "github.com/samber/lo" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/apierrors" + addonshandler "github.com/openmeterio/openmeter/api/v3/handlers/addons" + appshandler "github.com/openmeterio/openmeter/api/v3/handlers/apps" + billinginvoiceshandler "github.com/openmeterio/openmeter/api/v3/handlers/billinginvoices" + billingprofileshandler "github.com/openmeterio/openmeter/api/v3/handlers/billingprofiles" + currencieshandler "github.com/openmeterio/openmeter/api/v3/handlers/currencies" + customershandler "github.com/openmeterio/openmeter/api/v3/handlers/customers" + customersbillinghandler "github.com/openmeterio/openmeter/api/v3/handlers/customers/billing" + chargeshandler "github.com/openmeterio/openmeter/api/v3/handlers/customers/charges" + customerscreditshandler "github.com/openmeterio/openmeter/api/v3/handlers/customers/credits" + customersentitlementhandler "github.com/openmeterio/openmeter/api/v3/handlers/customers/entitlementaccess" + eventshandler "github.com/openmeterio/openmeter/api/v3/handlers/events" + featurecosthandler "github.com/openmeterio/openmeter/api/v3/handlers/featurecost" + featureshandler "github.com/openmeterio/openmeter/api/v3/handlers/features" + governancehandler "github.com/openmeterio/openmeter/api/v3/handlers/governance" + llmcosthandler "github.com/openmeterio/openmeter/api/v3/handlers/llmcost" + metershandler "github.com/openmeterio/openmeter/api/v3/handlers/meters" + planshandler "github.com/openmeterio/openmeter/api/v3/handlers/plans" + planaddonshandler "github.com/openmeterio/openmeter/api/v3/handlers/plans/planaddons" + subscriptionshandler "github.com/openmeterio/openmeter/api/v3/handlers/subscriptions" + subscriptionaddonshandler "github.com/openmeterio/openmeter/api/v3/handlers/subscriptions/subscriptionaddons" + taxcodeshandler "github.com/openmeterio/openmeter/api/v3/handlers/taxcodes" + "github.com/openmeterio/openmeter/api/v3/oasmiddleware" + "github.com/openmeterio/openmeter/api/v3/render" + "github.com/openmeterio/openmeter/app/config" + "github.com/openmeterio/openmeter/openmeter/app" + appstripe "github.com/openmeterio/openmeter/openmeter/app/stripe" + "github.com/openmeterio/openmeter/openmeter/billing" + billingcharges "github.com/openmeterio/openmeter/openmeter/billing/charges" + "github.com/openmeterio/openmeter/openmeter/billing/creditgrant" + "github.com/openmeterio/openmeter/openmeter/cost" + "github.com/openmeterio/openmeter/openmeter/currencies" + "github.com/openmeterio/openmeter/openmeter/customer" + "github.com/openmeterio/openmeter/openmeter/entitlement" + "github.com/openmeterio/openmeter/openmeter/governance" + "github.com/openmeterio/openmeter/openmeter/ingest" + "github.com/openmeterio/openmeter/openmeter/ledger" + "github.com/openmeterio/openmeter/openmeter/ledger/customerbalance" + ledgernoop "github.com/openmeterio/openmeter/openmeter/ledger/noop" + "github.com/openmeterio/openmeter/openmeter/llmcost" + "github.com/openmeterio/openmeter/openmeter/meter" + "github.com/openmeterio/openmeter/openmeter/meterevent" + "github.com/openmeterio/openmeter/openmeter/namespace/namespacedriver" + "github.com/openmeterio/openmeter/openmeter/productcatalog/addon" + "github.com/openmeterio/openmeter/openmeter/productcatalog/feature" + "github.com/openmeterio/openmeter/openmeter/productcatalog/plan" + "github.com/openmeterio/openmeter/openmeter/productcatalog/planaddon" + plansubscription "github.com/openmeterio/openmeter/openmeter/productcatalog/subscription" + "github.com/openmeterio/openmeter/openmeter/streaming" + "github.com/openmeterio/openmeter/openmeter/subscription" + subscriptionaddon "github.com/openmeterio/openmeter/openmeter/subscription/addon" + subscriptionworkflow "github.com/openmeterio/openmeter/openmeter/subscription/workflow" + "github.com/openmeterio/openmeter/openmeter/taxcode" + "github.com/openmeterio/openmeter/pkg/errorsx" + "github.com/openmeterio/openmeter/pkg/featuregate" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" + "github.com/openmeterio/openmeter/pkg/server" +) + +type Config struct { + BaseURL string + NamespaceDecoder namespacedriver.NamespaceDecoder + ErrorHandler errorsx.Handler + Middlewares []server.MiddlewareFunc + PostAuthMiddlewares []server.MiddlewareFunc + Credits config.CreditsConfiguration + ResponseValidation config.ResponseValidationConfig + UnitConfig config.UnitConfigConfiguration + + // services + AddonService addon.Service + AppService app.Service + BillingService billing.Service + LLMCostService llmcost.Service + MeterService meter.ManageService + StreamingConnector streaming.Connector + IngestService ingest.Service + MeterEventService meterevent.Service + CustomerService customer.Service + CreditGrantService creditgrant.Service + Ledger ledger.Ledger + AccountResolver ledger.AccountResolver + CustomerBalanceFacade *customerbalance.Facade + EntitlementService entitlement.Service + GovernanceService governance.Service + PlanService plan.Service + PlanAddonService planaddon.Service + PlanSubscriptionService plansubscription.PlanSubscriptionService + StripeService appstripe.Service + SubscriptionService subscription.Service + SubscriptionAddonService subscriptionaddon.Service + SubscriptionWorkflowService subscriptionworkflow.Service + TaxCodeService taxcode.Service + CurrencyService currencies.Service + ChargeService billingcharges.ChargeService + CostService cost.Service + FeatureConnector feature.FeatureConnector + + FeatureGate *featuregate.FeatureGateChecker +} + +func (c *Config) Validate() error { + var errs []error + + if err := c.ResponseValidation.Mode.Validate(); err != nil { + errs = append(errs, err) + } + + if c.BaseURL == "" { + errs = append(errs, errors.New("base URL is required")) + } + + if c.NamespaceDecoder == nil { + errs = append(errs, errors.New("namespace decoder is required")) + } + + if c.ErrorHandler == nil { + errs = append(errs, errors.New("error handler is required")) + } + + if c.AppService == nil { + errs = append(errs, errors.New("app service is required")) + } + + if c.BillingService == nil { + errs = append(errs, errors.New("billing service is required")) + } + + if c.MeterService == nil { + errs = append(errs, errors.New("meter service is required")) + } + + if c.StreamingConnector == nil { + errs = append(errs, errors.New("streaming connector is required")) + } + + if c.IngestService == nil { + errs = append(errs, errors.New("ingest service is required")) + } + + if c.MeterEventService == nil { + errs = append(errs, errors.New("meter event service is required")) + } + + if c.CustomerService == nil { + errs = append(errs, errors.New("customer service is required")) + } + + if c.EntitlementService == nil { + errs = append(errs, errors.New("entitlement service is required")) + } + + if c.GovernanceService == nil { + errs = append(errs, errors.New("governance service is required")) + } + + if c.PlanService == nil { + errs = append(errs, errors.New("plan service is required")) + } + + if c.PlanAddonService == nil { + errs = append(errs, errors.New("plan addon service is required")) + } + + if c.PlanSubscriptionService == nil { + errs = append(errs, errors.New("plan subscription service is required")) + } + + if c.StripeService == nil { + errs = append(errs, errors.New("stripe service is required")) + } + + if c.SubscriptionService == nil { + errs = append(errs, errors.New("subscription service is required")) + } + + if c.SubscriptionAddonService == nil { + errs = append(errs, errors.New("subscription addon service is required")) + } + + if c.TaxCodeService == nil { + errs = append(errs, errors.New("tax code service is required")) + } + + if c.CurrencyService == nil { + errs = append(errs, errors.New("currency service is required")) + } + + if c.FeatureConnector == nil { + errs = append(errs, errors.New("feature connector is required")) + } + + if c.Credits.Enabled { + if c.CustomerBalanceFacade == nil { + errs = append(errs, errors.New("customer balance facade is required when credits are enabled")) + } + + if c.CreditGrantService == nil { + errs = append(errs, errors.New("credit grant service is required when credits are enabled")) + } + + if c.Ledger == nil { + errs = append(errs, errors.New("ledger is required when credits are enabled")) + } + + if c.AccountResolver == nil { + errs = append(errs, errors.New("account resolver is required when credits are enabled")) + } + } + + if c.AddonService == nil { + errs = append(errs, errors.New("addon service is required")) + } + + if c.SubscriptionAddonService == nil { + errs = append(errs, errors.New("subscription addon service is required")) + } + + if err := c.FeatureGate.Validate(); err != nil { + errs = append(errs, err) + } + + if c.SubscriptionWorkflowService == nil { + errs = append(errs, errors.New("subscription workflow service is required")) + } + + return errors.Join(errs...) +} + +type Server struct { + *Config + + swagger *openapi3.T + + // handlers + addonHandler addonshandler.Handler + appsHandler appshandler.Handler + eventsHandler eventshandler.Handler + llmcostHandler llmcosthandler.Handler + customersHandler customershandler.Handler + customersBillingHandler customersbillinghandler.Handler + customersCreditsHandler customerscreditshandler.Handler + customersEntitlementHandler customersentitlementhandler.Handler + governanceHandler governancehandler.Handler + metersHandler metershandler.Handler + subscriptionsHandler subscriptionshandler.Handler + subscriptionAddonsHandler subscriptionaddonshandler.Handler + billingProfilesHandler billingprofileshandler.Handler + billingInvoicesHandler billinginvoiceshandler.Handler + plansHandler planshandler.Handler + planAddonsHandler planaddonshandler.Handler + chargesHandler chargeshandler.Handler + taxcodesHandler taxcodeshandler.Handler + currenciesHandler currencieshandler.Handler + featuresHandler featureshandler.Handler + featureCostHandler featurecosthandler.Handler +} + +// Make sure we conform to ServerInterface +var _ api.ServerInterface = (*Server)(nil) + +func NewServer(config *Config) (*Server, error) { + if err := config.Validate(); err != nil { + return nil, fmt.Errorf("invalid v3 server config: %w", err) + } + + // Get the OpenAPI spec + swagger, err := api.GetSwagger() + if err != nil { + slog.Error("failed to get swagger", "error", err) + return nil, err + } + + // Set the server URL to the base URL to make validation work on the base URL + swagger.Servers = []*openapi3.Server{ + { + URL: config.BaseURL, + }, + } + + resolveNamespace := func(ctx context.Context) (string, error) { + ns, ok := config.NamespaceDecoder.GetNamespace(ctx) + if !ok { + return "", apierrors.NewInternalError(ctx, errors.New("failed to resolve namespace")) + } + + return ns, nil + } + + addonHandler := addonshandler.New(resolveNamespace, config.AddonService, httptransport.WithErrorHandler(config.ErrorHandler)) + appsHandler := appshandler.New(resolveNamespace, config.AppService, config.BillingService, config.StripeService, httptransport.WithErrorHandler(config.ErrorHandler)) + eventsHandler := eventshandler.New(resolveNamespace, config.IngestService, config.MeterEventService, httptransport.WithErrorHandler(config.ErrorHandler)) + customersHandler := customershandler.New(resolveNamespace, config.CustomerService, httptransport.WithErrorHandler(config.ErrorHandler)) + customersBillingHandler := customersbillinghandler.New(resolveNamespace, config.BillingService, config.CustomerService, config.StripeService, httptransport.WithErrorHandler(config.ErrorHandler)) + customerBalanceFacade := config.CustomerBalanceFacade + creditGrantService := config.CreditGrantService + ledgerService := config.Ledger + accountResolver := config.AccountResolver + if !config.Credits.Enabled { + customerBalanceFacade, err = customerbalance.NewFacade(customerbalance.NewNoopService()) + if err != nil { + return nil, fmt.Errorf("create noop customer balance facade: %w", err) + } + + creditGrantService = creditgrant.NewNoopService() + ledgerService = ledgernoop.Ledger{} + accountResolver = ledgernoop.AccountResolver{} + } + customersCreditsHandler := customerscreditshandler.New(resolveNamespace, config.CustomerService, customerBalanceFacade, creditGrantService, ledgerService, accountResolver, httptransport.WithErrorHandler(config.ErrorHandler)) + customersEntitlementHandler := customersentitlementhandler.New(resolveNamespace, config.CustomerService, config.EntitlementService, httptransport.WithErrorHandler(config.ErrorHandler)) + metersHandler := metershandler.New(resolveNamespace, config.MeterService, config.StreamingConnector, config.CustomerService, httptransport.WithErrorHandler(config.ErrorHandler)) + subscriptionsHandler := subscriptionshandler.New(resolveNamespace, config.CustomerService, config.PlanService, config.PlanSubscriptionService, config.SubscriptionService, httptransport.WithErrorHandler(config.ErrorHandler)) + subscriptionAddonsHandler := subscriptionaddonshandler.New(resolveNamespace, config.SubscriptionAddonService, config.SubscriptionService, config.SubscriptionWorkflowService, httptransport.WithErrorHandler(config.ErrorHandler)) + billingProfilesHandler := billingprofileshandler.New(resolveNamespace, config.BillingService, httptransport.WithErrorHandler(config.ErrorHandler)) + billingInvoicesHandler := billinginvoiceshandler.New(resolveNamespace, config.BillingService, httptransport.WithErrorHandler(config.ErrorHandler)) + plansHandler := planshandler.New(resolveNamespace, config.PlanService, config.UnitConfig.Enabled, httptransport.WithErrorHandler(config.ErrorHandler)) + planAddonsHandler := planaddonshandler.New(resolveNamespace, config.PlanService, config.PlanAddonService, httptransport.WithErrorHandler(config.ErrorHandler)) + taxcodesHandler := taxcodeshandler.New(resolveNamespace, config.TaxCodeService, httptransport.WithErrorHandler(config.ErrorHandler)) + currenciesHandler := currencieshandler.New(resolveNamespace, config.CurrencyService, httptransport.WithErrorHandler(config.ErrorHandler)) + + var chargesH chargeshandler.Handler + if config.ChargeService != nil { + chargesH = chargeshandler.New(resolveNamespace, config.ChargeService, httptransport.WithErrorHandler(config.ErrorHandler)) + } + + featuresH := featureshandler.New(resolveNamespace, config.FeatureConnector, config.MeterService, config.LLMCostService, httptransport.WithErrorHandler(config.ErrorHandler)) + governanceHandler := governancehandler.New(resolveNamespace, config.GovernanceService, httptransport.WithErrorHandler(config.ErrorHandler)) + + var llmcostH llmcosthandler.Handler + if config.LLMCostService != nil { + llmcostH = llmcosthandler.New(resolveNamespace, config.LLMCostService, httptransport.WithErrorHandler(config.ErrorHandler)) + } + + var featureCostH featurecosthandler.Handler + if config.CostService != nil && config.FeatureConnector != nil { + featureCostH = featurecosthandler.New(resolveNamespace, config.CostService, config.FeatureConnector, config.MeterService, config.CustomerService, httptransport.WithErrorHandler(config.ErrorHandler)) + } + + return &Server{ + Config: config, + swagger: swagger, + addonHandler: addonHandler, + appsHandler: appsHandler, + eventsHandler: eventsHandler, + llmcostHandler: llmcostH, + customersHandler: customersHandler, + customersBillingHandler: customersBillingHandler, + customersCreditsHandler: customersCreditsHandler, + customersEntitlementHandler: customersEntitlementHandler, + metersHandler: metersHandler, + subscriptionsHandler: subscriptionsHandler, + subscriptionAddonsHandler: subscriptionAddonsHandler, + billingProfilesHandler: billingProfilesHandler, + billingInvoicesHandler: billingInvoicesHandler, + plansHandler: plansHandler, + planAddonsHandler: planAddonsHandler, + chargesHandler: chargesH, + taxcodesHandler: taxcodesHandler, + currenciesHandler: currenciesHandler, + featuresHandler: featuresH, + featureCostHandler: featureCostH, + governanceHandler: governanceHandler, + }, nil +} + +func (s *Server) RegisterRoutes(r chi.Router) error { + validationRouter, err := oasmiddleware.NewValidationRouter( + context.Background(), + s.swagger, + &oasmiddleware.ValidationRouterOpts{ + DeleteServers: true, + ServerPrefix: s.BaseURL, + }, + ) + if err != nil { + return fmt.Errorf("create validation router: %w", err) + } + + validationMiddleware := oasmiddleware.ValidateRequest(validationRouter, oasmiddleware.ValidateRequestOption{ + RouteNotFoundHook: oasmiddleware.OasRouteNotFoundErrorHook, + RouteValidationErrorHook: func(err error, w http.ResponseWriter, r *http.Request) bool { + return oasmiddleware.OasValidationErrorHook(r.Context(), err, w, r) + }, + FilterOptions: &openapi3filter.Options{ + // No-op auth: auth is handled by other middleware. + AuthenticationFunc: openapi3filter.NoopAuthenticationFunc, + MultiError: true, + }, + }) + + r.Route(s.BaseURL, func(r chi.Router) { + for _, mw := range s.Middlewares { + r.Use(mw) + } + r.NotFound(func(w http.ResponseWriter, r *http.Request) { + apierrors. + NewNotFoundError(r.Context(), errors.New("route not found"), "route"). + HandleAPIError(w, r) + }) + r.MethodNotAllowed(func(w http.ResponseWriter, r *http.Request) { + apierrors. + NewMethodNotAllowedError(r.Context()). + HandleAPIError(w, r) + }) + + // Serve the OpenAPI spec + r.Get("/openapi.json", func(w http.ResponseWriter, r *http.Request) { + _ = render.RenderJSON(w, s.swagger) + }) + + r.Get("/openapi.yaml", func(w http.ResponseWriter, r *http.Request) { + _ = render.RenderYAML(w, s.swagger) + }) + + middlewares := []api.MiddlewareFunc{ + validationMiddleware, + } + + if s.ResponseValidation.Mode.Enabled() { + middlewares = append(middlewares, oasmiddleware.ValidateResponse(validationRouter, oasmiddleware.ValidateResponseOption{ + RouteFilterHook: buildResponseValidationRouteFilter(s.ResponseValidation), + ResponseValidationErrorHook: func(err error, r *http.Request) { + // Raw err can echo offending response field values (customer PII, billing identifiers). + // Keep that detail behind DEBUG; emit a sanitized summary at WARN. + slog.WarnContext( + r.Context(), "response validation failed", + slog.String("method", r.Method), + slog.String("path", r.URL.Path), + slog.String("error_type", fmt.Sprintf("%T", err)), + ) + slog.DebugContext( + r.Context(), "response validation details", + slog.String("method", r.Method), + slog.String("path", r.URL.Path), + slog.Any("error", err), + ) + }, + })) + } + + postAuthMiddlewares := lo.Map(s.PostAuthMiddlewares, func(mwf server.MiddlewareFunc, _ int) api.MiddlewareFunc { + return api.MiddlewareFunc(mwf) + }) + + middlewares = append(middlewares, postAuthMiddlewares...) + + _ = api.HandlerWithOptions(s, api.ChiServerOptions{ + BaseRouter: r, + Middlewares: middlewares, + ErrorHandlerFunc: apierrors.NewV3ErrorHandlerFunc(s.ErrorHandler), + }) + }) + + return nil +} + +// buildResponseValidationRouteFilter returns a route filter for response validation. +// In "all" mode the filter is nil (every route is validated). In "unstable" mode only +// operations marked x-unstable: true in the spec are validated. +func buildResponseValidationRouteFilter(cfg config.ResponseValidationConfig) func(*routers.Route) bool { + if cfg.Mode != config.ResponseValidationModeUnstable { + return nil + } + return func(route *routers.Route) bool { + if route.Operation == nil { + return false + } + // kin-openapi unmarshals JSON booleans directly into map[string]any, + // so the extension value is a plain bool here. + v, _ := route.Operation.Extensions["x-unstable"].(bool) + return v + } +} diff --git a/api/v3/templates/chi-middleware.tmpl.patch b/api/v3/templates/chi-middleware.tmpl.patch new file mode 100644 index 0000000000000000000000000000000000000000..3934fcc45cb97f6f31f73236257adf91d469c575 --- /dev/null +++ b/api/v3/templates/chi-middleware.tmpl.patch @@ -0,0 +1,23 @@ +--- a/chi-middleware.tmpl ++++ b/chi-middleware.tmpl +@@ -77,11 +77,21 @@ + }{{end}} + {{end}} + {{if .IsStyled}} ++ {{- /* For "filter" deepObject params, use the custom filter parser which ++ correctly handles all filter types including union types. */ -}} ++ {{- if and (eq .ParamName "filter") (eq .Style "deepObject") }} ++ err = filters.Parse(r.URL.Query(), ¶ms.{{.GoName}}) ++ if err != nil { ++ siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "{{.ParamName}}", Err: err}) ++ return ++ } ++ {{- else }} + err = runtime.BindQueryParameterWithOptions("{{.Style}}", {{.Explode}}, {{.Required}}, "{{.ParamName}}", r.URL.Query(), ¶ms.{{.GoName}}, runtime.BindQueryParameterOptions{Type: "{{.SchemaType}}", Format: "{{.SchemaFormat}}"}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "{{.ParamName}}", Err: err}) + return + } ++ {{- end }} + {{end}} + {{end}} diff --git a/api/v3/test/embed.go b/api/v3/test/embed.go new file mode 100644 index 0000000000000000000000000000000000000000..741f1548d0b2942d5516c391e8d45e4041be5cef --- /dev/null +++ b/api/v3/test/embed.go @@ -0,0 +1,6 @@ +package test + +import _ "embed" + +//go:embed openapi.test.yaml +var OpenAPITestSpec []byte diff --git a/api/v3/test/filters_test.go b/api/v3/test/filters_test.go new file mode 100644 index 0000000000000000000000000000000000000000..6c3289ef6b9a4bca52126b9983c6ce2c13c64370 --- /dev/null +++ b/api/v3/test/filters_test.go @@ -0,0 +1,660 @@ +package test_test + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/getkin/kin-openapi/openapi3filter" + "github.com/go-chi/chi/v5" + "github.com/samber/lo" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/openmeterio/openmeter/api/v3/filters" + "github.com/openmeterio/openmeter/api/v3/oasmiddleware" + apiv3test "github.com/openmeterio/openmeter/api/v3/test" +) + +// fieldFiltersTarget mirrors the FieldFilters schema in openapi.Test.yaml so +// filters.Parse can populate it from incoming query strings. +type fieldFiltersTarget struct { + Boolean *filters.FilterBoolean `json:"boolean,omitempty"` + Numeric *filters.FilterNumeric `json:"numeric,omitempty"` + String *filters.FilterString `json:"string,omitempty"` + StringExact *filters.FilterStringExact `json:"string_exact,omitempty"` + ULID *filters.FilterULID `json:"ulid,omitempty"` + DateTime *filters.FilterDateTime `json:"datetime,omitempty"` + Labels *filters.FilterLabels `json:"labels,omitempty"` +} + +// validatorErrorResponse mirrors the AIP-style error body produced by +// oasmiddleware.OasValidationErrorHook so tests can assert on individual fields. +type validatorErrorResponse struct { + Type string `json:"type"` + Status int `json:"status"` + Title string `json:"title"` + Detail string `json:"detail"` + InvalidParameters []validatorInvalidParameter `json:"invalid_parameters"` +} + +type validatorInvalidParameter struct { + Field string `json:"field"` + Rule string `json:"rule"` + Reason string `json:"reason"` + Source string `json:"source"` +} + +func newTestServer(t *testing.T, handler http.HandlerFunc) *httptest.Server { + t.Helper() + + doc, err := openapi3.NewLoader().LoadFromData(apiv3test.OpenAPITestSpec) + require.NoError(t, err) + + validationRouter, err := oasmiddleware.NewValidationRouter(t.Context(), doc, &oasmiddleware.ValidationRouterOpts{ + DeleteServers: true, + }) + require.NoError(t, err) + + r := chi.NewRouter() + r.Use(oasmiddleware.ValidateRequest(validationRouter, oasmiddleware.ValidateRequestOption{ + RouteNotFoundHook: oasmiddleware.OasRouteNotFoundErrorHook, + RouteValidationErrorHook: func(err error, w http.ResponseWriter, req *http.Request) bool { + return oasmiddleware.OasValidationErrorHook(req.Context(), err, w, req) + }, + FilterOptions: &openapi3filter.Options{ + AuthenticationFunc: openapi3filter.NoopAuthenticationFunc, + MultiError: true, + }, + })) + r.Get("/field-filters", handler) + + srv := httptest.NewServer(r) + t.Cleanup(srv.Close) + + return srv +} + +func noopHandler(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) +} + +func parseHandler(w http.ResponseWriter, r *http.Request) { + var target fieldFiltersTarget + if err := filters.Parse(r.URL.Query(), &target); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(target) +} + +// TestFieldFilterValidation exercises the kin-openapi validator against the +// generated FieldFilters schemas. Every filter type is sent in both short +// (filter[field]=value) and object (filter[field][op]=value) form. +func TestFieldFilterValidation(t *testing.T) { + srv := newTestServer(t, noopHandler) + + cases := []struct { + name string + // query is the raw query string sent to /field-filters. + query string + // wantStatus is 204 for accepted requests, 400 for rejected. + wantStatus int + // wantField, wantRule, wantReasonSubstr are asserted on the validator's + // invalid_parameters[0] entry when wantStatus == 400. Empty string skips + // the assertion for that field. + wantField string + wantRule string + wantReasonSubstr string + }{ + // Empty query — no filter parameter is supplied; the schema marks + // filter as optional, so the request must be accepted. + {name: "empty filter", query: "", wantStatus: http.StatusNoContent}, + + // BooleanFieldFilter + {name: "boolean valid short", query: "filter[boolean]=true", wantStatus: http.StatusNoContent}, + {name: "boolean valid short false", query: "filter[boolean]=false", wantStatus: http.StatusNoContent}, + {name: "boolean valid eq true", query: "filter[boolean][eq]=true", wantStatus: http.StatusNoContent}, + {name: "boolean valid eq false", query: "filter[boolean][eq]=false", wantStatus: http.StatusNoContent}, + + // NumericFieldFilter — every documented operator + {name: "numeric valid short", query: "filter[numeric]=42", wantStatus: http.StatusNoContent}, + {name: "numeric valid eq", query: "filter[numeric][eq]=42", wantStatus: http.StatusNoContent}, + {name: "numeric valid neq", query: "filter[numeric][neq]=7", wantStatus: http.StatusNoContent}, + {name: "numeric valid lt", query: "filter[numeric][lt]=10.5", wantStatus: http.StatusNoContent}, + {name: "numeric valid lte", query: "filter[numeric][lte]=10.5", wantStatus: http.StatusNoContent}, + {name: "numeric valid gt", query: "filter[numeric][gt]=0", wantStatus: http.StatusNoContent}, + {name: "numeric valid gte", query: "filter[numeric][gte]=0", wantStatus: http.StatusNoContent}, + {name: "numeric valid oeq", query: "filter[numeric][oeq]=1,2,3", wantStatus: http.StatusNoContent}, + {name: "numeric valid negative", query: "filter[numeric][eq]=-3.14", wantStatus: http.StatusNoContent}, + + // StringFieldFilter — every documented operator + {name: "string valid short", query: "filter[string]=hello", wantStatus: http.StatusNoContent}, + {name: "string valid eq", query: "filter[string][eq]=hello", wantStatus: http.StatusNoContent}, + {name: "string valid neq", query: "filter[string][neq]=hello", wantStatus: http.StatusNoContent}, + {name: "string valid contains", query: "filter[string][contains]=foo", wantStatus: http.StatusNoContent}, + {name: "string valid ocontains", query: "filter[string][ocontains]=a,b", wantStatus: http.StatusNoContent}, + {name: "string valid oeq", query: "filter[string][oeq]=a,b,c", wantStatus: http.StatusNoContent}, + {name: "string valid gt", query: "filter[string][gt]=alpha", wantStatus: http.StatusNoContent}, + {name: "string valid gte", query: "filter[string][gte]=alpha", wantStatus: http.StatusNoContent}, + {name: "string valid lt", query: "filter[string][lt]=zeta", wantStatus: http.StatusNoContent}, + {name: "string valid lte", query: "filter[string][lte]=zeta", wantStatus: http.StatusNoContent}, + {name: "string valid exists true", query: "filter[string][exists]=true", wantStatus: http.StatusNoContent}, + {name: "string valid exists false", query: "filter[string][exists]=false", wantStatus: http.StatusNoContent}, + + // StringFieldFilterExact — limited operator set + {name: "string_exact valid short", query: "filter[string_exact]=hello", wantStatus: http.StatusNoContent}, + {name: "string_exact valid eq", query: "filter[string_exact][eq]=hello", wantStatus: http.StatusNoContent}, + {name: "string_exact valid neq", query: "filter[string_exact][neq]=hello", wantStatus: http.StatusNoContent}, + {name: "string_exact valid oeq", query: "filter[string_exact][oeq]=a,b,c", wantStatus: http.StatusNoContent}, + + // ULIDFieldFilter + {name: "ulid valid short", query: "filter[ulid]=01G65Z755AFWAKHE12NY0CQ9FH", wantStatus: http.StatusNoContent}, + {name: "ulid valid eq", query: "filter[ulid][eq]=01G65Z755AFWAKHE12NY0CQ9FH", wantStatus: http.StatusNoContent}, + {name: "ulid valid neq", query: "filter[ulid][neq]=01G65Z755AFWAKHE12NY0CQ9FH", wantStatus: http.StatusNoContent}, + {name: "ulid valid oeq", query: "filter[ulid][oeq]=01G65Z755AFWAKHE12NY0CQ9FH,01G65Z755AFWAKHE12NY0CQ9FJ", wantStatus: http.StatusNoContent}, + // Pattern violation — ULID schema enforces ^[0-7][0-9A-HJKMNP-TV-Z]{25}$. + // kin-openapi collapses the underlying pattern miss into a generic + // anyOf-failure entry on the parent union; we still get the right + // field name back, just not the per-branch rule. + { + name: "ulid invalid short pattern", + query: "filter[ulid]=not-a-ulid", + wantStatus: http.StatusBadRequest, + wantField: "ulid", + wantRule: "anyOf", + wantReasonSubstr: "doesn't match any schema", + }, + { + name: "ulid invalid eq pattern", + query: "filter[ulid][eq]=not-a-ulid", + wantStatus: http.StatusBadRequest, + wantField: "ulid", + wantRule: "anyOf", + wantReasonSubstr: "doesn't match any schema", + }, + + // DateTimeFieldFilter + {name: "datetime valid short", query: "filter[datetime]=2024-01-01T00:00:00Z", wantStatus: http.StatusNoContent}, + {name: "datetime valid eq", query: "filter[datetime][eq]=2024-01-01T00:00:00Z", wantStatus: http.StatusNoContent}, + {name: "datetime valid lt", query: "filter[datetime][lt]=2024-01-01T00:00:00Z", wantStatus: http.StatusNoContent}, + {name: "datetime valid lte", query: "filter[datetime][lte]=2024-01-01T00:00:00Z", wantStatus: http.StatusNoContent}, + {name: "datetime valid gt", query: "filter[datetime][gt]=2024-01-01T00:00:00Z", wantStatus: http.StatusNoContent}, + {name: "datetime valid gte", query: "filter[datetime][gte]=2024-01-01T00:00:00Z", wantStatus: http.StatusNoContent}, + // Format violation — DateTime schema enforces RFC-3339; like the ULID + // pattern miss, the per-branch failure surfaces as an anyOf collapse. + { + name: "datetime invalid short format", + query: "filter[datetime]=not-a-datetime", + wantStatus: http.StatusBadRequest, + wantField: "datetime", + wantRule: "anyOf", + wantReasonSubstr: "doesn't match any schema", + }, + { + name: "datetime invalid eq format", + query: "filter[datetime][eq]=not-a-datetime", + wantStatus: http.StatusBadRequest, + wantField: "datetime", + wantRule: "anyOf", + wantReasonSubstr: "doesn't match any schema", + }, + + // LabelsFieldFilter — schema is Record, so each + // label key is itself a StringFieldFilter. Both nested and dot-notation + // forms are accepted by the validator. + {name: "labels nested short", query: "filter[labels][owner]=team-a", wantStatus: http.StatusNoContent}, + {name: "labels nested eq", query: "filter[labels][owner][eq]=team-a", wantStatus: http.StatusNoContent}, + {name: "labels nested contains", query: "filter[labels][owner][contains]=team", wantStatus: http.StatusNoContent}, + {name: "labels nested ocontains", query: "filter[labels][owner][ocontains]=a,b", wantStatus: http.StatusNoContent}, + {name: "labels nested oeq", query: "filter[labels][owner][oeq]=a,b", wantStatus: http.StatusNoContent}, + {name: "labels nested neq", query: "filter[labels][owner][neq]=team-a", wantStatus: http.StatusNoContent}, + {name: "labels dot short", query: "filter[labels.owner]=team-a", wantStatus: http.StatusNoContent}, + {name: "labels dot eq", query: "filter[labels.owner][eq]=team-a", wantStatus: http.StatusNoContent}, + {name: "labels dot contains", query: "filter[labels.owner][contains]=team", wantStatus: http.StatusNoContent}, + // `labels` must be a map, not a bare scalar — the schema (Record<…>) + // enforces this at the validator level. + { + name: "labels bare scalar rejected", + query: "filter[labels]=team-a", + wantStatus: http.StatusBadRequest, + wantField: "labels", + wantRule: "type", + wantReasonSubstr: "must be an object", + }, + + // Multiple filters in one request — independent fields can be combined. + { + name: "combined boolean+numeric+string", + query: "filter[boolean][eq]=true&filter[numeric][gt]=5&filter[string][contains]=foo", + wantStatus: http.StatusNoContent, + }, + { + name: "combined ulid+datetime", + query: "filter[ulid][eq]=01G65Z755AFWAKHE12NY0CQ9FH&filter[datetime][lt]=2024-01-01T00:00:00Z", + wantStatus: http.StatusNoContent, + }, + + // Multiple operators on the same field — the schema's object branch lists + // every operator as optional, so combinations like a closed range are + // valid against the validator. + { + name: "datetime gte and lte on same field", + query: "filter[datetime][gte]=2024-01-01T00:00:00Z&filter[datetime][lte]=2024-12-31T23:59:59Z", + wantStatus: http.StatusNoContent, + }, + { + name: "numeric gte and lt on same field", + query: "filter[numeric][gte]=1&filter[numeric][lt]=10", + wantStatus: http.StatusNoContent, + }, + { + name: "string gte and lte on same field", + query: "filter[string][gte]=alpha&filter[string][lte]=zeta", + wantStatus: http.StatusNoContent, + }, + { + name: "string neq and contains on same field", + query: "filter[string][neq]=foo&filter[string][contains]=bar", + wantStatus: http.StatusNoContent, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + url := srv.URL + "/field-filters" + if tc.query != "" { + url += "?" + tc.query + } + + resp, err := http.Get(url) + require.NoError(t, err) + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + + require.Equalf(t, tc.wantStatus, resp.StatusCode, "query=%q body=%s", tc.query, string(body)) + + if tc.wantStatus != http.StatusBadRequest { + return + } + + var got validatorErrorResponse + require.NoError(t, json.Unmarshal(body, &got), "validator error body must be JSON, got %q", string(body)) + require.Equal(t, http.StatusBadRequest, got.Status) + require.Equal(t, "Bad Request", got.Title) + require.NotEmpty(t, got.InvalidParameters, "expected at least one invalid_parameters entry") + + ip := got.InvalidParameters[0] + assert.Equal(t, "query", ip.Source) + if tc.wantField != "" { + assert.Equal(t, tc.wantField, ip.Field) + } + if tc.wantRule != "" { + assert.Equal(t, tc.wantRule, ip.Rule) + } + if tc.wantReasonSubstr != "" { + assert.Contains(t, ip.Reason, tc.wantReasonSubstr) + } + }) + } +} + +// TestFieldFilterParse runs each request through the OAS validator and then +// through filters.Parse, asserting both that the request validates and that +// the parser produces the expected typed Go struct. Negative cases assert on +// the parser's error message body. +func TestFieldFilterParse(t *testing.T) { + srv := newTestServer(t, parseHandler) + + dt := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC) + ulid1 := "01G65Z755AFWAKHE12NY0CQ9FH" + ulid2 := "01G65Z755AFWAKHE12NY0CQ9FJ" + + cases := []struct { + name string + query string + wantParse fieldFiltersTarget + // wantErr indicates the parser is expected to reject the request with 400. + wantErr bool + // wantBodySubstr, when set, must appear in the response body. For success + // cases this checks the JSON output; for error cases it checks the parser + // error message. + wantBodySubstr string + }{ + // BooleanFieldFilter + { + name: "boolean short", + query: "filter[boolean]=true", + wantParse: fieldFiltersTarget{Boolean: &filters.FilterBoolean{Eq: lo.ToPtr(true)}}, + }, + { + name: "boolean eq false", + query: "filter[boolean][eq]=false", + wantParse: fieldFiltersTarget{Boolean: &filters.FilterBoolean{Eq: lo.ToPtr(false)}}, + }, + + // NumericFieldFilter + { + name: "numeric short", + query: "filter[numeric]=42", + wantParse: fieldFiltersTarget{Numeric: &filters.FilterNumeric{Eq: lo.ToPtr(42.0)}}, + }, + { + name: "numeric eq decimal", + query: "filter[numeric][eq]=12.5", + wantParse: fieldFiltersTarget{Numeric: &filters.FilterNumeric{Eq: lo.ToPtr(12.5)}}, + }, + { + name: "numeric neq", + query: "filter[numeric][neq]=0", + wantParse: fieldFiltersTarget{Numeric: &filters.FilterNumeric{Neq: lo.ToPtr(0.0)}}, + }, + { + name: "numeric lt", + query: "filter[numeric][lt]=10", + wantParse: fieldFiltersTarget{Numeric: &filters.FilterNumeric{Lt: lo.ToPtr(10.0)}}, + }, + { + name: "numeric lte", + query: "filter[numeric][lte]=10", + wantParse: fieldFiltersTarget{Numeric: &filters.FilterNumeric{Lte: lo.ToPtr(10.0)}}, + }, + { + name: "numeric gt", + query: "filter[numeric][gt]=10", + wantParse: fieldFiltersTarget{Numeric: &filters.FilterNumeric{Gt: lo.ToPtr(10.0)}}, + }, + { + name: "numeric gte", + query: "filter[numeric][gte]=10", + wantParse: fieldFiltersTarget{Numeric: &filters.FilterNumeric{Gte: lo.ToPtr(10.0)}}, + }, + { + name: "numeric oeq comma list", + query: "filter[numeric][oeq]=1,2,3", + wantParse: fieldFiltersTarget{Numeric: &filters.FilterNumeric{Oeq: []float64{1, 2, 3}}}, + }, + { + name: "numeric negative", + query: "filter[numeric][eq]=-3.14", + wantParse: fieldFiltersTarget{Numeric: &filters.FilterNumeric{Eq: lo.ToPtr(-3.14)}}, + }, + + // StringFieldFilter + { + name: "string short", + query: "filter[string]=hello", + wantParse: fieldFiltersTarget{String: &filters.FilterString{Eq: lo.ToPtr("hello")}}, + }, + { + name: "string eq", + query: "filter[string][eq]=hello", + wantParse: fieldFiltersTarget{String: &filters.FilterString{Eq: lo.ToPtr("hello")}}, + }, + { + name: "string neq", + query: "filter[string][neq]=hello", + wantParse: fieldFiltersTarget{String: &filters.FilterString{Neq: lo.ToPtr("hello")}}, + }, + { + name: "string contains", + query: "filter[string][contains]=foo", + wantParse: fieldFiltersTarget{String: &filters.FilterString{Contains: lo.ToPtr("foo")}}, + }, + { + name: "string ocontains comma list", + query: "filter[string][ocontains]=a,b,c", + wantParse: fieldFiltersTarget{String: &filters.FilterString{Ocontains: []string{"a", "b", "c"}}}, + }, + { + name: "string oeq comma list", + query: "filter[string][oeq]=a,b,c", + wantParse: fieldFiltersTarget{String: &filters.FilterString{Oeq: []string{"a", "b", "c"}}}, + }, + { + // Spaces are URL-encoded so the URL parser doesn't truncate the + // query value at the first literal space. parseCommaSeparated trims + // surrounding whitespace from each item. + name: "string oeq trims spaces", + query: "filter[string][oeq]=foo%20,%20bar,baz", + wantParse: fieldFiltersTarget{String: &filters.FilterString{Oeq: []string{"foo", "bar", "baz"}}}, + }, + { + name: "string gt", + query: "filter[string][gt]=alpha", + wantParse: fieldFiltersTarget{String: &filters.FilterString{Gt: lo.ToPtr("alpha")}}, + }, + { + name: "string lte", + query: "filter[string][lte]=zeta", + wantParse: fieldFiltersTarget{String: &filters.FilterString{Lte: lo.ToPtr("zeta")}}, + }, + { + name: "string exists", + query: "filter[string][exists]=true", + wantParse: fieldFiltersTarget{String: &filters.FilterString{Exists: lo.ToPtr(true)}}, + }, + { + name: "string exists false", + query: "filter[string][exists]=false", + wantParse: fieldFiltersTarget{String: &filters.FilterString{Exists: lo.ToPtr(false)}}, + }, + { + name: "string nexists", + query: "filter[string][nexists]=true", + wantParse: fieldFiltersTarget{String: &filters.FilterString{Exists: lo.ToPtr(false)}}, + }, + { + name: "string nexists false", + query: "filter[string][nexists]=false", + wantParse: fieldFiltersTarget{String: &filters.FilterString{Exists: lo.ToPtr(true)}}, + }, + + // StringFieldFilterExact + { + name: "string_exact short", + query: "filter[string_exact]=hello", + wantParse: fieldFiltersTarget{StringExact: &filters.FilterStringExact{Eq: lo.ToPtr("hello")}}, + }, + { + name: "string_exact neq", + query: "filter[string_exact][neq]=excluded", + wantParse: fieldFiltersTarget{StringExact: &filters.FilterStringExact{Neq: lo.ToPtr("excluded")}}, + }, + { + name: "string_exact oeq", + query: "filter[string_exact][oeq]=a,b", + wantParse: fieldFiltersTarget{StringExact: &filters.FilterStringExact{Oeq: []string{"a", "b"}}}, + }, + + // ULIDFieldFilter + { + name: "ulid short", + query: "filter[ulid]=" + ulid1, + wantParse: fieldFiltersTarget{ULID: &filters.FilterULID{Eq: &ulid1}}, + }, + { + name: "ulid eq", + query: "filter[ulid][eq]=" + ulid1, + wantParse: fieldFiltersTarget{ULID: &filters.FilterULID{Eq: &ulid1}}, + }, + { + name: "ulid neq", + query: "filter[ulid][neq]=" + ulid1, + wantParse: fieldFiltersTarget{ULID: &filters.FilterULID{Neq: &ulid1}}, + }, + { + name: "ulid oeq", + query: "filter[ulid][oeq]=" + ulid1 + "," + ulid2, + wantParse: fieldFiltersTarget{ULID: &filters.FilterULID{Oeq: []string{ulid1, ulid2}}}, + }, + + // DateTimeFieldFilter + { + name: "datetime short", + query: "filter[datetime]=2024-01-02T03:04:05Z", + wantParse: fieldFiltersTarget{DateTime: &filters.FilterDateTime{Eq: &dt}}, + }, + { + name: "datetime eq", + query: "filter[datetime][eq]=2024-01-02T03:04:05Z", + wantParse: fieldFiltersTarget{DateTime: &filters.FilterDateTime{Eq: &dt}}, + }, + { + name: "datetime gt", + query: "filter[datetime][gt]=2024-01-02T03:04:05Z", + wantParse: fieldFiltersTarget{DateTime: &filters.FilterDateTime{Gt: &dt}}, + }, + { + name: "datetime lte", + query: "filter[datetime][lte]=2024-01-02T03:04:05Z", + wantParse: fieldFiltersTarget{DateTime: &filters.FilterDateTime{Lte: &dt}}, + }, + + // LabelsFieldFilter — handled here as *FilterString since the test + // fixture wires the labels field as a single filter, not a labels map. + { + name: "labels short", + query: "filter[labels.key]=team-a", + wantParse: fieldFiltersTarget{Labels: &filters.FilterLabels{"key": {Eq: lo.ToPtr("team-a")}}}, + }, + { + name: "labels contains", + query: "filter[labels.key][contains]=team", + wantParse: fieldFiltersTarget{Labels: &filters.FilterLabels{"key": {Contains: lo.ToPtr("team")}}}, + }, + + // Multiple independent filters in one request. + { + name: "combined boolean+numeric+string", + query: "filter[boolean][eq]=true&filter[numeric][gt]=5&filter[string][contains]=foo", + wantParse: fieldFiltersTarget{ + Boolean: &filters.FilterBoolean{Eq: lo.ToPtr(true)}, + Numeric: &filters.FilterNumeric{Gt: lo.ToPtr(5.0)}, + String: &filters.FilterString{Contains: lo.ToPtr("foo")}, + }, + }, + { + name: "combined ulid+datetime+labels", + query: "filter[ulid][eq]=" + ulid1 + "&filter[datetime][lt]=2024-01-02T03:04:05Z&filter[labels.key]=team-a", + wantParse: fieldFiltersTarget{ + ULID: &filters.FilterULID{Eq: &ulid1}, + DateTime: &filters.FilterDateTime{Lt: &dt}, + Labels: &filters.FilterLabels{"key": {Eq: lo.ToPtr("team-a")}}, + }, + }, + + // Multiple operators on the same field — the parser populates each + // operator on the shared filter struct so the resulting predicate is + // the AND of all branches (e.g. a half-open or closed range). + { + name: "datetime gte and lte on same field", + query: "filter[datetime][gte]=2024-01-02T03:04:05Z&filter[datetime][lte]=2024-01-02T03:04:05Z", + wantParse: fieldFiltersTarget{ + DateTime: &filters.FilterDateTime{Gte: &dt, Lte: &dt}, + }, + }, + { + name: "numeric gte and lt on same field", + query: "filter[numeric][gte]=1&filter[numeric][lt]=10", + wantParse: fieldFiltersTarget{ + Numeric: &filters.FilterNumeric{Gte: lo.ToPtr(1.0), Lt: lo.ToPtr(10.0)}, + }, + }, + { + name: "string neq and contains on same field", + query: "filter[string][neq]=foo&filter[string][contains]=bar", + wantParse: fieldFiltersTarget{ + String: &filters.FilterString{Neq: lo.ToPtr("foo"), Contains: lo.ToPtr("bar")}, + }, + }, + + // Negative cases — the OAS validator's deepObject scalar coercion is + // permissive, but filters.Parse is strict and rejects malformed values + // or unknown operators with a descriptive message. + { + name: "boolean rejects non-bool", + query: "filter[boolean][eq]=notabool", + wantErr: true, + wantBodySubstr: "filter[boolean][eq]: invalid boolean", + }, + { + name: "numeric rejects non-number", + query: "filter[numeric][eq]=not-a-number", + wantErr: true, + wantBodySubstr: "filter[numeric][eq]: invalid number", + }, + // Note: malformed datetime values are caught by the OAS validator + // before reaching the parser (the date-time format check in oneOf + // fires first), so we don't have a parser-side negative case for + // datetime here. + { + name: "string_exact rejects unsupported operator", + query: "filter[string_exact][contains]=foo", + wantErr: true, + wantBodySubstr: "unsupported operator", + }, + { + name: "boolean rejects unsupported operator", + query: "filter[boolean][gt]=true", + wantErr: true, + wantBodySubstr: "unsupported operator", + }, + { + name: "ulid rejects unsupported operator", + query: "filter[ulid][contains]=anything", + wantErr: true, + wantBodySubstr: "unsupported operator", + }, + { + name: "datetime rejects unsupported operator", + query: "filter[datetime][contains]=anything", + wantErr: true, + wantBodySubstr: "unsupported operator", + }, + { + name: "unknown filter field rejected", + query: "filter[unknown][eq]=foo", + wantErr: true, + wantBodySubstr: "unknown", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + url := srv.URL + "/field-filters" + if tc.query != "" { + url += "?" + tc.query + } + + resp, err := http.Get(url) + require.NoError(t, err) + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + + if tc.wantErr { + require.Equalf(t, http.StatusBadRequest, resp.StatusCode, "query=%q body=%s", tc.query, string(body)) + if tc.wantBodySubstr != "" { + assert.Contains(t, string(body), tc.wantBodySubstr) + } + return + } + + require.Equalf(t, http.StatusOK, resp.StatusCode, "query=%q body=%s", tc.query, string(body)) + + var got fieldFiltersTarget + require.NoError(t, json.Unmarshal(body, &got)) + assert.Equal(t, tc.wantParse, got) + + if tc.wantBodySubstr != "" { + assert.Contains(t, string(body), tc.wantBodySubstr) + } + }) + } +} diff --git a/api/v3/test/openapi.test.yaml b/api/v3/test/openapi.test.yaml new file mode 100644 index 0000000000000000000000000000000000000000..5166c108d8da89190ae91dfa38b42057a36d0360 --- /dev/null +++ b/api/v3/test/openapi.test.yaml @@ -0,0 +1,260 @@ +openapi: 3.0.0 +info: + title: Filter Test API + version: 1.0.0 +tags: [] +paths: + /field-filters: + get: + operationId: get-field-filters-with-all-supported-types + summary: Field filters + parameters: + - name: filter + in: query + required: false + schema: + $ref: "#/components/schemas/FieldFilters" + style: deepObject + responses: + "204": + description: "There is no content to send for this request, but the headers may be useful. " +components: + schemas: + BooleanFieldFilter: + anyOf: + - type: boolean + - type: object + properties: + eq: + type: boolean + description: Value strictly equals the given boolean value. + required: + - eq + additionalProperties: false + description: Filter by a boolean value (true/false). + title: Boolean Field Filter + x-go-type-import: + path: github.com/openmeterio/openmeter/api/v3/filters + x-go-type: filters.FilterBoolean + DateTime: + type: string + format: date-time + description: |- + [RFC3339](https://tools.ietf.org/html/rfc3339) formatted date-time string in + UTC. + title: RFC3339 Date-Time + example: 2023-01-01T01:01:01.001Z + DateTimeFieldFilter: + anyOf: + - $ref: "#/components/schemas/DateTime" + - type: object + properties: + eq: + allOf: + - $ref: "#/components/schemas/DateTime" + description: Value strictly equals given RFC-3339 formatted timestamp in UTC. + lt: + allOf: + - $ref: "#/components/schemas/DateTime" + description: Value is less than the given RFC-3339 formatted timestamp in UTC. + lte: + allOf: + - $ref: "#/components/schemas/DateTime" + description: Value is less than or equal to the given RFC-3339 formatted timestamp in UTC. + gt: + allOf: + - $ref: "#/components/schemas/DateTime" + description: Value is greater than the given RFC-3339 formatted timestamp in UTC. + gte: + allOf: + - $ref: "#/components/schemas/DateTime" + description: Value is greater than or equal to the given RFC-3339 formatted timestamp in UTC. + additionalProperties: false + description: |- + Filters on the given datetime (RFC-3339) field value. All properties are + optional; provide exactly one to specify the comparison. + title: DateTime Field Filter + x-go-type-import: + path: github.com/openmeterio/openmeter/api/v3/filters + x-go-type: filters.FilterDateTime + FieldFilters: + type: object + properties: + boolean: + $ref: "#/components/schemas/BooleanFieldFilter" + numeric: + $ref: "#/components/schemas/NumericFieldFilter" + string: + $ref: "#/components/schemas/StringFieldFilter" + string_exact: + $ref: "#/components/schemas/StringFieldFilterExact" + ulid: + $ref: "#/components/schemas/ULIDFieldFilter" + datetime: + $ref: "#/components/schemas/DateTimeFieldFilter" + labels: + $ref: "#/components/schemas/LabelsFieldFilter" + additionalProperties: false + description: Field filters with all supported types. + LabelsFieldFilter: + type: object + additionalProperties: + $ref: "#/components/schemas/StringFieldFilter" + description: |- + Filters on the resource's `labels` field. + + The schema is a map keyed by the label name; each value is a + `StringFieldFilter`. Both deepObject forms are accepted: + `filter[labels][key]=value` (nested) and `filter[labels.key]=value` + (dot-notation). + title: Labels Field Filter + x-go-type-import: + path: github.com/openmeterio/openmeter/api/v3/filters + x-go-type: filters.FilterLabels + NumericFieldFilter: + anyOf: + - type: number + format: double + - type: object + properties: + eq: + type: number + format: double + description: Value strictly equals the given numeric value. + neq: + type: number + format: double + description: Value does not equal the given numeric value. + oeq: + type: string + description: Returns entities that match any of the comma-delimited numeric values. + lt: + type: number + format: double + description: Value is less than the given numeric value. + lte: + type: number + format: double + description: Value is less than or equal to the given numeric value. + gt: + type: number + format: double + description: Value is greater than the given numeric value. + gte: + type: number + format: double + description: Value is greater than or equal to the given numeric value. + additionalProperties: false + description: |- + Filter by a numeric value. All properties are optional; provide exactly one to + specify the comparison. + title: Numeric Field Filter + x-go-type-import: + path: github.com/openmeterio/openmeter/api/v3/filters + x-go-type: filters.FilterNumeric + StringFieldFilter: + anyOf: + - type: string + - type: object + properties: + eq: + type: string + description: Value strictly equals the given string value. + neq: + type: string + description: Value does not equal the given string value. + contains: + type: string + description: Value contains the given string value (fuzzy match). + ocontains: + type: string + description: |- + Returns entities that fuzzy-match any of the comma-delimited phrases in the + filter string. + oeq: + type: string + description: |- + Returns entities that exact match any of the comma-delimited phrases in the + filter string. + gt: + type: string + description: Value is greater than the given string value (lexicographic compare). + gte: + type: string + description: |- + Value is greater than or equal to the given string value (lexicographic + compare). + lt: + type: string + description: Value is less than the given string value (lexicographic compare). + lte: + type: string + description: Value is less than or equal to the given string value (lexicographic compare). + exists: + type: boolean + description: |- + When true, the field must be present (non-null); when false, the field must be + absent (null). + additionalProperties: false + description: |- + Filters on the given string field value by either exact or fuzzy match. All + properties are optional; provide exactly one to specify the comparison. + x-go-type-import: + path: github.com/openmeterio/openmeter/api/v3/filters + x-go-type: filters.FilterString + StringFieldFilterExact: + anyOf: + - type: string + - type: object + properties: + eq: + type: string + description: Value strictly equals the given string value. + oeq: + type: string + description: |- + Returns entities that exact match any of the comma-delimited phrases in the + filter string. + neq: + type: string + description: Value does not equal the given string value. + additionalProperties: false + description: |- + Filters on the given string field value by exact match. All properties are + optional; provide exactly one to specify the comparison. + title: String Field Filter Exact + x-go-type-import: + path: github.com/openmeterio/openmeter/api/v3/filters + x-go-type: filters.FilterStringExact + ULID: + type: string + pattern: ^[0-7][0-9A-HJKMNP-TV-Z]{25}$ + description: ULID (Universally Unique Lexicographically Sortable Identifier). + title: ULID + example: 01G65Z755AFWAKHE12NY0CQ9FH + ULIDFieldFilter: + anyOf: + - $ref: "#/components/schemas/ULID" + - type: object + properties: + eq: + allOf: + - $ref: "#/components/schemas/ULID" + description: Value strictly equals the given ULID value. + oeq: + type: string + description: |- + Returns entities that exact match any of the comma-delimited ULIDs in the filter + string. + neq: + allOf: + - $ref: "#/components/schemas/ULID" + description: Value does not equal the given ULID value. + additionalProperties: false + description: |- + Filters on the given ULID field value by exact match. All properties are + optional; provide exactly one to specify the comparison. + title: ULID Field Filter + x-go-type-import: + path: github.com/openmeterio/openmeter/api/v3/filters + x-go-type: filters.FilterULID diff --git a/app/common/app.go b/app/common/app.go new file mode 100644 index 0000000000000000000000000000000000000000..f5e2c4c7542c8099d73287a7c75e47b964b5dd65 --- /dev/null +++ b/app/common/app.go @@ -0,0 +1,177 @@ +package common + +import ( + "context" + "fmt" + "log/slog" + + "github.com/google/wire" + + "github.com/openmeterio/openmeter/app/config" + "github.com/openmeterio/openmeter/openmeter/app" + appadapter "github.com/openmeterio/openmeter/openmeter/app/adapter" + appcustominvoicing "github.com/openmeterio/openmeter/openmeter/app/custominvoicing" + appcustominvoicingadapter "github.com/openmeterio/openmeter/openmeter/app/custominvoicing/adapter" + appcustominvoicingservice "github.com/openmeterio/openmeter/openmeter/app/custominvoicing/service" + appsandbox "github.com/openmeterio/openmeter/openmeter/app/sandbox" + appservice "github.com/openmeterio/openmeter/openmeter/app/service" + appstripe "github.com/openmeterio/openmeter/openmeter/app/stripe" + appstripeadapter "github.com/openmeterio/openmeter/openmeter/app/stripe/adapter" + appstripeservice "github.com/openmeterio/openmeter/openmeter/app/stripe/service" + "github.com/openmeterio/openmeter/openmeter/customer" + entdb "github.com/openmeterio/openmeter/openmeter/ent/db" + "github.com/openmeterio/openmeter/openmeter/namespace" + "github.com/openmeterio/openmeter/openmeter/secret" + "github.com/openmeterio/openmeter/openmeter/watermill/eventbus" +) + +var App = wire.NewSet( + NewAppRegistry, + NewAppService, + NewAppStripeService, + NewAppSandboxFactory, + NewAppSandboxProvisioner, + NewAppCustomInvoicingService, +) + +type AppSandboxProvisioner func(ctx context.Context, orgID string) error + +func NewAppService( + logger *slog.Logger, + db *entdb.Client, + publisher eventbus.Publisher, +) (app.Service, error) { + appAdapter, err := appadapter.New(appadapter.Config{ + Client: db, + }) + if err != nil { + return nil, fmt.Errorf("failed to create app adapter: %w", err) + } + + return appservice.New(appservice.Config{ + Adapter: appAdapter, + Publisher: publisher, + }) +} + +func NewAppStripeService(logger *slog.Logger, db *entdb.Client, appsConfig config.AppsConfiguration, appService app.Service, customerService customer.Service, secretService secret.Service, billingRegistry BillingRegistry, publisher eventbus.Publisher) (appstripe.Service, error) { + appStripeAdapter, err := appstripeadapter.New(appstripeadapter.Config{ + Client: db, + AppService: appService, + CustomerService: customerService, + SecretService: secretService, + Logger: logger, + }) + if err != nil { + return nil, fmt.Errorf("failed to create appstripe adapter: %w", err) + } + + webhookGenerator, err := appstripeservice.NewBaseURLWebhookURLGenerator(appsConfig.BaseURL) + if err != nil { + return nil, fmt.Errorf("failed to create webhook generator: %w", err) + } + + if appsConfig.Stripe.WebhookURLPattern != "" { + webhookGenerator, err = appstripeservice.NewPatternWebhookURLGenerator(appsConfig.Stripe.WebhookURLPattern) + if err != nil { + return nil, fmt.Errorf("failed to create webhook generator: %w", err) + } + } + + return appstripeservice.New(appstripeservice.Config{ + Adapter: appStripeAdapter, + AppService: appService, + SecretService: secretService, + BillingService: billingRegistry.Billing, + Logger: logger, + DisableWebhookRegistration: appsConfig.Stripe.DisableWebhookRegistration, + Publisher: publisher, + WebhookURLGenerator: webhookGenerator, + }) +} + +func NewAppSandboxFactory( + appsConfig config.AppsConfiguration, + appService app.Service, + billing BillingRegistry, +) (*appsandbox.Factory, error) { + factory, err := appsandbox.NewFactory(appsandbox.Config{ + AppService: appService, + SequenceService: billing.Sequence, + }) + if err != nil { + return nil, fmt.Errorf("failed to initialize app sandbox factory: %w", err) + } + + return factory, nil +} + +func NewAppSandboxProvisioner(ctx context.Context, logger *slog.Logger, appsConfig config.AppsConfiguration, appService app.Service, namespaceManager *namespace.Manager, billingRegistry BillingRegistry, _ *appsandbox.Factory, +) (AppSandboxProvisioner, error) { + return func(ctx context.Context, orgID string) error { + app, err := appsandbox.AutoProvision(ctx, appsandbox.AutoProvisionInput{ + Namespace: orgID, + AppService: appService, + }) + if err != nil { + return fmt.Errorf("failed to auto-provision sandbox app: %w", err) + } + + logger.Info("sandbox app auto-provisioned", "app_id", app.GetID().ID, "org_id", orgID) + + return nil + }, nil +} + +func NewAppCustomInvoicingService(logger *slog.Logger, db *entdb.Client, appsConfig config.AppsConfiguration, appService app.Service, customerService customer.Service, secretService secret.Service, billingRegistry BillingRegistry, publisher eventbus.Publisher) (appcustominvoicing.Service, error) { + appCustomInvoicingAdapter, err := appcustominvoicingadapter.New(appcustominvoicingadapter.Config{ + Client: db, + Logger: logger, + }) + if err != nil { + return nil, fmt.Errorf("failed to create appcustominvoicing adapter: %w", err) + } + + service, err := appcustominvoicingservice.New(appcustominvoicingservice.Config{ + Adapter: appCustomInvoicingAdapter, + Logger: logger, + AppService: appService, + BillingService: billingRegistry.Billing, + }) + if err != nil { + return nil, fmt.Errorf("failed to create appcustominvoicing service: %w", err) + } + + // This registers the app with the marketplace as a side-effect + _, err = appcustominvoicing.NewFactory(appcustominvoicing.FactoryConfig{ + AppService: appService, + CustomInvoicingService: service, + SequenceService: billingRegistry.Sequence, + }) + if err != nil { + return nil, fmt.Errorf("failed to create appcustominvoicing factory: %w", err) + } + + return service, nil +} + +type AppRegistry struct { + Service app.Service + SandboxProvisioner AppSandboxProvisioner + Stripe appstripe.Service + CustomInvoicing appcustominvoicing.Service +} + +func NewAppRegistry( + Service app.Service, + SandboxProvisioner AppSandboxProvisioner, + Stripe appstripe.Service, + CustomInvoicing appcustominvoicing.Service, +) AppRegistry { + return AppRegistry{ + Service: Service, + SandboxProvisioner: SandboxProvisioner, + Stripe: Stripe, + CustomInvoicing: CustomInvoicing, + } +} diff --git a/app/common/billing.go b/app/common/billing.go new file mode 100644 index 0000000000000000000000000000000000000000..27d457a38f8f6303b9af48505f14cb079e3a9bd3 --- /dev/null +++ b/app/common/billing.go @@ -0,0 +1,344 @@ +package common + +import ( + "log/slog" + + "github.com/google/wire" + otelmetric "go.opentelemetry.io/otel/metric" + "go.opentelemetry.io/otel/trace" + + "github.com/openmeterio/openmeter/app/config" + "github.com/openmeterio/openmeter/openmeter/app" + "github.com/openmeterio/openmeter/openmeter/billing" + billingadapter "github.com/openmeterio/openmeter/openmeter/billing/adapter" + "github.com/openmeterio/openmeter/openmeter/billing/charges" + "github.com/openmeterio/openmeter/openmeter/billing/charges/creditpurchase" + "github.com/openmeterio/openmeter/openmeter/billing/charges/flatfee" + "github.com/openmeterio/openmeter/openmeter/billing/charges/usagebased" + chargesworkeradvance "github.com/openmeterio/openmeter/openmeter/billing/charges/worker/advance" + "github.com/openmeterio/openmeter/openmeter/billing/rating" + billingratingservice "github.com/openmeterio/openmeter/openmeter/billing/rating/service" + billingsequence "github.com/openmeterio/openmeter/openmeter/billing/sequence" + billingsequenceadapter "github.com/openmeterio/openmeter/openmeter/billing/sequence/adapter" + billingsequenceservice "github.com/openmeterio/openmeter/openmeter/billing/sequence/service" + billingservice "github.com/openmeterio/openmeter/openmeter/billing/service" + billingcustomer "github.com/openmeterio/openmeter/openmeter/billing/validators/customer" + billingsubscription "github.com/openmeterio/openmeter/openmeter/billing/validators/subscription" + billingworkerautoadvance "github.com/openmeterio/openmeter/openmeter/billing/worker/advance" + billingworkercollect "github.com/openmeterio/openmeter/openmeter/billing/worker/collect" + "github.com/openmeterio/openmeter/openmeter/billing/worker/subscriptionsync" + subscriptionsyncadapter "github.com/openmeterio/openmeter/openmeter/billing/worker/subscriptionsync/adapter" + "github.com/openmeterio/openmeter/openmeter/billing/worker/subscriptionsync/reconciler" + subscriptionsyncservice "github.com/openmeterio/openmeter/openmeter/billing/worker/subscriptionsync/service" + "github.com/openmeterio/openmeter/openmeter/customer" + entdb "github.com/openmeterio/openmeter/openmeter/ent/db" + "github.com/openmeterio/openmeter/openmeter/ledger" + ledgeraccount "github.com/openmeterio/openmeter/openmeter/ledger/account" + ledgerbreakage "github.com/openmeterio/openmeter/openmeter/ledger/breakage" + "github.com/openmeterio/openmeter/openmeter/ledger/recognizer" + "github.com/openmeterio/openmeter/openmeter/meter" + "github.com/openmeterio/openmeter/openmeter/productcatalog/feature" + "github.com/openmeterio/openmeter/openmeter/streaming" + "github.com/openmeterio/openmeter/openmeter/taxcode" + "github.com/openmeterio/openmeter/openmeter/watermill/eventbus" + "github.com/openmeterio/openmeter/pkg/featuregate" + "github.com/openmeterio/openmeter/pkg/framework/lockr" +) + +// BillingRegistry bundles the billing and charges services. External callers that need +// billing or charges should depend on BillingRegistry rather than individual services. +type BillingRegistry struct { + Billing billing.Service + Sequence billingsequence.Service + Charges *ChargesRegistry +} + +func (r BillingRegistry) ChargesServiceOrNil() charges.Service { + if r.Charges == nil { + return nil + } + + return r.Charges.Service +} + +// ChargesRegistry groups all charge-type services. +type ChargesRegistry struct { + Service charges.Service + FlatFeeService flatfee.Service + UsageBasedService usagebased.Service + CreditPurchaseService creditpurchase.Service + RecognizerService recognizer.Service +} + +// Billing is the Wire provider set for the billing and charges stack. +// Downstream consumers should depend on BillingRegistry. +var Billing = wire.NewSet( + BillingAdapter, + NewBillingRatingService, + NewLedgerBreakageService, + NewBillingRegistry, + NewBillingCustomerOverrideService, +) + +func BillingAdapter( + logger *slog.Logger, + db *entdb.Client, +) (billing.Adapter, error) { + return billingadapter.New(billingadapter.Config{ + Client: db, + Logger: logger, + }) +} + +func NewBillingSequenceService( + logger *slog.Logger, + db *entdb.Client, + metricMeter otelmetric.Meter, +) (billingsequence.Service, error) { + adapter, err := billingsequenceadapter.New(billingsequenceadapter.Config{ + Client: db, + Logger: logger.With("subsystem", "billing.sequence"), + }) + if err != nil { + return nil, err + } + + return billingsequenceservice.New(billingsequenceservice.Config{ + Adapter: adapter, + Meter: metricMeter, + }) +} + +// newBillingService creates the billing service and registers validators/hooks. +// Downstream consumers should use BillingRegistry. +func newBillingService( + logger *slog.Logger, + appService app.Service, + billingAdapter billing.Adapter, + billingRatingService rating.Service, + sequenceService billingsequence.Service, + customerService customer.Service, + featureConnector feature.FeatureConnector, + meterService meter.Service, + streamingConnector streaming.Connector, + eventPublisher eventbus.Publisher, + billingConfig config.BillingConfiguration, + subscriptionServices SubscriptionServiceWithWorkflow, + db *entdb.Client, + fsConfig config.BillingFeatureSwitchesConfiguration, + tracer trace.Tracer, + taxCodeService taxcode.Service, +) (billing.Service, error) { + service, err := billingservice.New(billingservice.Config{ + Adapter: billingAdapter, + SequenceService: sequenceService, + RatingService: billingRatingService, + AppService: appService, + CustomerService: customerService, + TaxCodeService: taxCodeService, + FeatureService: featureConnector, + Logger: logger, + MeterService: meterService, + StreamingConnector: streamingConnector, + Publisher: eventPublisher, + AdvancementStrategy: billingConfig.AdvancementStrategy, + FSNamespaceLockdown: fsConfig.NamespaceLockdown, + MaxParallelQuantitySnapshots: billingConfig.MaxParallelQuantitySnapshots, + }) + if err != nil { + return nil, err + } + + return service, nil +} + +// NewBillingRegistry assembles the billing and optional charges services. +func NewBillingRegistry( + logger *slog.Logger, + appService app.Service, + billingAdapter billing.Adapter, + billingRatingService rating.Service, + customerService customer.Service, + featureConnector feature.FeatureConnector, + meterService meter.Service, + metricMeter otelmetric.Meter, + streamingConnector streaming.Connector, + eventPublisher eventbus.Publisher, + billingConfig config.BillingConfiguration, + subscriptionServices SubscriptionServiceWithWorkflow, + db *entdb.Client, + fsConfig config.BillingFeatureSwitchesConfiguration, + creditsConfig config.CreditsConfiguration, + tracer trace.Tracer, + taxCodeService taxcode.Service, + locker *lockr.Locker, + ledgerService ledger.Ledger, + balanceQuerier ledger.BalanceQuerier, + accountResolver ledger.AccountResolver, + accountService ledgeraccount.Service, + breakageService ledgerbreakage.Service, + featureGate *featuregate.FeatureGateChecker, +) (BillingRegistry, error) { + sequenceService, err := NewBillingSequenceService(logger, db, metricMeter) + if err != nil { + return BillingRegistry{}, err + } + + billingService, err := newBillingService( + logger, + appService, + billingAdapter, + billingRatingService, + sequenceService, + customerService, + featureConnector, + meterService, + streamingConnector, + eventPublisher, + billingConfig, + subscriptionServices, + db, + fsConfig, + tracer, + taxCodeService, + ) + if err != nil { + return BillingRegistry{}, err + } + + var chargesRegistry *ChargesRegistry + + if creditsConfig.Enabled { + chargesRegistry, err = newChargesRegistry( + db, + logger, + locker, + billingService, + billingRatingService, + featureConnector, + streamingConnector, + ledgerService, + balanceQuerier, + accountResolver, + accountService, + breakageService, + taxCodeService, + fsConfig.NamespaceLockdown, + creditsConfig, + featureGate, + ) + if err != nil { + return BillingRegistry{}, err + } + } + + billingRegistry := BillingRegistry{ + Billing: billingService, + Sequence: sequenceService, + Charges: chargesRegistry, + } + + // Hook registration + + // Customer validate (and sync subscription on delete) + // To prevent circular dependencies, we create the validator here + subscriptionSyncAdapter, err := NewBillingSubscriptionSyncAdapter(db) + if err != nil { + return BillingRegistry{}, err + } + subscriptionSyncService, err := NewBillingSubscriptionSyncService(logger, subscriptionServices, billingRegistry, subscriptionSyncAdapter, tracer, creditsConfig, fsConfig, featureGate) + if err != nil { + return BillingRegistry{}, err + } + + validator, err := billingcustomer.NewValidator(billingRegistry.Billing, subscriptionSyncService, subscriptionServices.Service) + if err != nil { + return BillingRegistry{}, err + } + + customerService.RegisterRequestValidator(validator) + + // Subscription validate + + subscriptionValidator, err := billingsubscription.NewValidator(billingRegistry.Billing) + if err != nil { + return BillingRegistry{}, err + } + + if err = subscriptionServices.Service.RegisterHook(subscriptionValidator); err != nil { + return BillingRegistry{}, err + } + + return billingRegistry, nil +} + +func NewBillingCustomerOverrideService(billingRegistry BillingRegistry) billing.CustomerOverrideService { + return billingRegistry.Billing +} + +func NewBillingRatingService(unitConfig config.UnitConfigConfiguration) rating.Service { + return billingratingservice.New(billingratingservice.Config{ + UnitConfigEnabled: unitConfig.Enabled, + }) +} + +func NewBillingAutoAdvancer(logger *slog.Logger, billingRegistry BillingRegistry) (*billingworkerautoadvance.AutoAdvancer, error) { + return billingworkerautoadvance.NewAdvancer(billingworkerautoadvance.Config{ + BillingService: billingRegistry.Billing, + Logger: logger, + }) +} + +func NewChargesAutoAdvancer(logger *slog.Logger, billingRegistry BillingRegistry) (*chargesworkeradvance.AutoAdvancer, error) { + chargesService := billingRegistry.ChargesServiceOrNil() + if chargesService == nil { + return nil, nil + } + + return chargesworkeradvance.NewAdvancer(chargesworkeradvance.Config{ + ChargesService: chargesService, + Logger: logger, + }) +} + +func NewBillingCollector(logger *slog.Logger, billingRegistry BillingRegistry, fs config.BillingFeatureSwitchesConfiguration) (*billingworkercollect.InvoiceCollector, error) { + return billingworkercollect.NewInvoiceCollector(billingworkercollect.Config{ + GatheringInvoiceService: billingRegistry.Billing, + BillingService: billingRegistry.Billing, + Logger: logger, + LockedNamespaces: fs.NamespaceLockdown, + MaxLinesPerInvoice: fs.MaxLinesPerCollectedInvoice, + }) +} + +func NewBillingSubscriptionReconciler(logger *slog.Logger, subsServices SubscriptionServiceWithWorkflow, subscriptionSync subscriptionsync.Service, customerService customer.Service) (*reconciler.Reconciler, error) { + return reconciler.NewReconciler(reconciler.ReconcilerConfig{ + SubscriptionService: subsServices.Service, + SubscriptionSync: subscriptionSync, + Logger: logger, + CustomerService: customerService, + }) +} + +func NewBillingSubscriptionSyncAdapter(db *entdb.Client) (subscriptionsync.Adapter, error) { + return subscriptionsyncadapter.New(subscriptionsyncadapter.Config{ + Client: db, + }) +} + +func NewBillingSubscriptionSyncService(logger *slog.Logger, subsServices SubscriptionServiceWithWorkflow, billingRegistry BillingRegistry, subscriptionSyncAdapter subscriptionsync.Adapter, tracer trace.Tracer, creditsConfig config.CreditsConfiguration, billingFsConfig config.BillingFeatureSwitchesConfiguration, featureGate *featuregate.FeatureGateChecker) (subscriptionsync.Service, error) { + return subscriptionsyncservice.New(subscriptionsyncservice.Config{ + SubscriptionService: subsServices.Service, + BillingService: billingRegistry.Billing, + ChargesService: billingRegistry.ChargesServiceOrNil(), + SubscriptionSyncAdapter: subscriptionSyncAdapter, + FeatureFlags: subscriptionsyncservice.FeatureFlags{ + EnableCreditThenInvoice: creditsConfig.EnableCreditThenInvoice, + MaxLinesPerCollectedInvoice: billingFsConfig.MaxLinesPerCollectedInvoice, + }, + ForceAsyncInvoicePendingLines: billingFsConfig.SubscriptionSyncForceAsyncAdvance, + Logger: logger, + Tracer: tracer, + FeatureGate: featureGate, + }) +} diff --git a/app/common/charges.go b/app/common/charges.go new file mode 100644 index 0000000000000000000000000000000000000000..93b2c1912e9900a7fd1ea4885aad3ecaa41b9640 --- /dev/null +++ b/app/common/charges.go @@ -0,0 +1,573 @@ +package common + +import ( + "fmt" + "log/slog" + + "github.com/openmeterio/openmeter/app/config" + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/billing/charges" + chargesadapter "github.com/openmeterio/openmeter/openmeter/billing/charges/adapter" + "github.com/openmeterio/openmeter/openmeter/billing/charges/creditpurchase" + creditpurchaseadapter "github.com/openmeterio/openmeter/openmeter/billing/charges/creditpurchase/adapter" + creditpurchaselineengine "github.com/openmeterio/openmeter/openmeter/billing/charges/creditpurchase/lineengine" + creditpurchaseservice "github.com/openmeterio/openmeter/openmeter/billing/charges/creditpurchase/service" + "github.com/openmeterio/openmeter/openmeter/billing/charges/flatfee" + flatfeeadapter "github.com/openmeterio/openmeter/openmeter/billing/charges/flatfee/adapter" + flatfeeservice "github.com/openmeterio/openmeter/openmeter/billing/charges/flatfee/service" + "github.com/openmeterio/openmeter/openmeter/billing/charges/invoiceupdater" + "github.com/openmeterio/openmeter/openmeter/billing/charges/lineage" + lineageadapter "github.com/openmeterio/openmeter/openmeter/billing/charges/lineage/adapter" + lineageservice "github.com/openmeterio/openmeter/openmeter/billing/charges/lineage/service" + chargeslinerouter "github.com/openmeterio/openmeter/openmeter/billing/charges/linerouter" + "github.com/openmeterio/openmeter/openmeter/billing/charges/meta" + metaadapter "github.com/openmeterio/openmeter/openmeter/billing/charges/meta/adapter" + chargesservice "github.com/openmeterio/openmeter/openmeter/billing/charges/service" + "github.com/openmeterio/openmeter/openmeter/billing/charges/usagebased" + usagebasedadapter "github.com/openmeterio/openmeter/openmeter/billing/charges/usagebased/adapter" + usagebasedservice "github.com/openmeterio/openmeter/openmeter/billing/charges/usagebased/service" + "github.com/openmeterio/openmeter/openmeter/billing/rating" + entdb "github.com/openmeterio/openmeter/openmeter/ent/db" + enttx "github.com/openmeterio/openmeter/openmeter/ent/tx" + "github.com/openmeterio/openmeter/openmeter/ledger" + ledgeraccount "github.com/openmeterio/openmeter/openmeter/ledger/account" + ledgerbreakage "github.com/openmeterio/openmeter/openmeter/ledger/breakage" + ledgerbreakageadapter "github.com/openmeterio/openmeter/openmeter/ledger/breakage/adapter" + ledgerchargeadapter "github.com/openmeterio/openmeter/openmeter/ledger/chargeadapter" + ledgercollector "github.com/openmeterio/openmeter/openmeter/ledger/collector" + "github.com/openmeterio/openmeter/openmeter/ledger/recognizer" + "github.com/openmeterio/openmeter/openmeter/ledger/transactions" + "github.com/openmeterio/openmeter/openmeter/productcatalog/feature" + "github.com/openmeterio/openmeter/openmeter/streaming" + "github.com/openmeterio/openmeter/openmeter/taxcode" + "github.com/openmeterio/openmeter/pkg/featuregate" + "github.com/openmeterio/openmeter/pkg/framework/lockr" + "github.com/openmeterio/openmeter/pkg/framework/transaction" +) + +func NewChargesMetaAdapter( + db *entdb.Client, + logger *slog.Logger, +) (meta.Adapter, error) { + metaAdapter, err := metaadapter.New(metaadapter.Config{ + Client: db, + Logger: logger, + }) + if err != nil { + return nil, fmt.Errorf("failed to create charges meta adapter: %w", err) + } + + return metaAdapter, nil +} + +func NewChargesCollectorService( + db *entdb.Client, + ledgerService ledger.Ledger, + balanceQuerier ledger.BalanceQuerier, + accountResolver ledger.AccountResolver, + accountService ledgeraccount.Service, +) (ledgercollector.Service, error) { + collectorService, err := ledgercollector.NewService(ledgercollector.Config{ + Ledger: ledgerService, + Dependencies: transactions.ResolverDependencies{ + AccountService: accountResolver, + AccountCatalog: accountService, + BalanceQuerier: balanceQuerier, + }, + AccountLocker: accountService, + TransactionManager: enttx.NewCreator(db), + }) + if err != nil { + return nil, fmt.Errorf("failed to create charges collector service: %w", err) + } + + return collectorService, nil +} + +func NewLedgerBreakageService( + creditsConfig config.CreditsConfiguration, + db *entdb.Client, + balanceQuerier ledger.BalanceQuerier, + accountResolver ledger.AccountResolver, + accountService ledgeraccount.Service, +) (ledgerbreakage.Service, error) { + if !creditsConfig.Enabled { + return ledgerbreakage.NewNoopService(), nil + } + + breakageAdapter, err := ledgerbreakageadapter.New(ledgerbreakageadapter.Config{ + Client: db, + }) + if err != nil { + return nil, fmt.Errorf("failed to create ledger breakage adapter: %w", err) + } + + breakageService, err := ledgerbreakage.NewService(ledgerbreakage.Config{ + Adapter: breakageAdapter, + Dependencies: transactions.ResolverDependencies{ + AccountService: accountResolver, + AccountCatalog: accountService, + BalanceQuerier: balanceQuerier, + }, + }) + if err != nil { + return nil, fmt.Errorf("failed to create ledger breakage service: %w", err) + } + + return breakageService, nil +} + +func NewChargesFlatFeeHandler( + ledgerService ledger.Ledger, + balanceQuerier ledger.BalanceQuerier, + accountResolver ledger.AccountResolver, + accountService ledgeraccount.Service, + collectorService ledgercollector.Service, +) flatfee.Handler { + return ledgerchargeadapter.NewFlatFeeHandler(ledgerService, transactions.ResolverDependencies{ + AccountService: accountResolver, + AccountCatalog: accountService, + BalanceQuerier: balanceQuerier, + }, collectorService) +} + +func NewChargesCreditPurchaseHandler( + ledgerService ledger.Ledger, + balanceQuerier ledger.BalanceQuerier, + accountResolver ledger.AccountResolver, + accountService ledgeraccount.Service, + breakageService ledgerbreakage.Service, + transactionManager transaction.Creator, +) (creditpurchase.Handler, error) { + handler, err := ledgerchargeadapter.NewCreditPurchaseHandler(ledgerService, balanceQuerier, accountResolver, accountService, breakageService, transactionManager) + if err != nil { + return nil, fmt.Errorf("failed to create charges credit purchase handler: %w", err) + } + + return handler, nil +} + +func NewChargesUsageBasedHandler( + ledgerService ledger.Ledger, + balanceQuerier ledger.BalanceQuerier, + accountResolver ledger.AccountResolver, + accountService ledgeraccount.Service, + collectorService ledgercollector.Service, +) usagebased.Handler { + return ledgerchargeadapter.NewUsageBasedHandler(ledgerService, transactions.ResolverDependencies{ + AccountService: accountResolver, + AccountCatalog: accountService, + BalanceQuerier: balanceQuerier, + }, collectorService) +} + +func NewChargesFlatFeeAdapter( + db *entdb.Client, + logger *slog.Logger, + metaAdapter meta.Adapter, +) (flatfee.Adapter, error) { + flatFeeAdapter, err := flatfeeadapter.New(flatfeeadapter.Config{ + Client: db, + Logger: logger, + MetaAdapter: metaAdapter, + }) + if err != nil { + return nil, fmt.Errorf("failed to create charges flat fee adapter: %w", err) + } + + return flatFeeAdapter, nil +} + +func NewChargesLineageAdapter( + db *entdb.Client, +) (lineage.Adapter, error) { + lineageAdapter, err := lineageadapter.New(lineageadapter.Config{ + Client: db, + }) + if err != nil { + return nil, fmt.Errorf("failed to create charges lineage adapter: %w", err) + } + + return lineageAdapter, nil +} + +func NewChargesLineageService( + lineageAdapter lineage.Adapter, +) (lineage.Service, error) { + lineageService, err := lineageservice.New(lineageservice.Config{ + Adapter: lineageAdapter, + }) + if err != nil { + return nil, fmt.Errorf("failed to create charges lineage service: %w", err) + } + + return lineageService, nil +} + +func NewChargesFlatFeeService( + flatFeeAdapter flatfee.Adapter, + flatFeeHandler flatfee.Handler, + lineageService lineage.Service, + metaAdapter meta.Adapter, + locker *lockr.Locker, + ratingService rating.Service, +) (flatfee.Service, error) { + flatFeeSvc, err := flatfeeservice.New(flatfeeservice.Config{ + Adapter: flatFeeAdapter, + Handler: flatFeeHandler, + Lineage: lineageService, + MetaAdapter: metaAdapter, + Locker: locker, + RatingService: ratingService, + }) + if err != nil { + return nil, fmt.Errorf("failed to create charges flat fee service: %w", err) + } + + return flatFeeSvc, nil +} + +func NewChargesUsageBasedAdapter( + db *entdb.Client, + logger *slog.Logger, + metaAdapter meta.Adapter, +) (usagebased.Adapter, error) { + usageBasedAdapter, err := usagebasedadapter.New(usagebasedadapter.Config{ + Client: db, + Logger: logger, + MetaAdapter: metaAdapter, + }) + if err != nil { + return nil, fmt.Errorf("failed to create charges usage based adapter: %w", err) + } + + return usageBasedAdapter, nil +} + +func NewChargesUsageBasedService( + usageBasedAdapter usagebased.Adapter, + usageBasedHandler usagebased.Handler, + lineageService lineage.Service, + locker *lockr.Locker, + metaAdapter meta.Adapter, + invoiceUpdater invoiceupdater.Updater, + billingService billing.Service, + featureService feature.FeatureConnector, + ratingService rating.Service, + streamingConnector streaming.Connector, +) (usagebased.Service, error) { + usageBasedSvc, err := usagebasedservice.New(usagebasedservice.Config{ + Adapter: usageBasedAdapter, + Handler: usageBasedHandler, + Lineage: lineageService, + Locker: locker, + MetaAdapter: metaAdapter, + InvoiceUpdater: invoiceUpdater, + CustomerOverrideService: billingService, + FeatureService: featureService, + RatingService: ratingService, + StreamingConnector: streamingConnector, + }) + if err != nil { + return nil, fmt.Errorf("failed to create charges usage based service: %w", err) + } + + return usageBasedSvc, nil +} + +func NewChargesInvoiceUpdater( + billingService billing.Service, + logger *slog.Logger, +) (invoiceupdater.Updater, error) { + updater, err := invoiceupdater.New(invoiceupdater.Config{ + BillingService: billingService, + Logger: logger, + }) + if err != nil { + return nil, fmt.Errorf("failed to create charges invoice updater: %w", err) + } + + return updater, nil +} + +func NewChargesCreditPurchaseAdapter( + db *entdb.Client, + logger *slog.Logger, + metaAdapter meta.Adapter, +) (creditpurchase.Adapter, error) { + creditPurchaseAdapter, err := creditpurchaseadapter.New(creditpurchaseadapter.Config{ + Client: db, + Logger: logger, + MetaAdapter: metaAdapter, + }) + if err != nil { + return nil, fmt.Errorf("failed to create charges credit purchase adapter: %w", err) + } + + return creditPurchaseAdapter, nil +} + +func NewChargesCreditPurchaseService( + creditPurchaseAdapter creditpurchase.Adapter, + creditPurchaseHandler creditpurchase.Handler, + lineageService lineage.Service, + metaAdapter meta.Adapter, +) (creditpurchase.Service, error) { + creditPurchaseSvc, err := creditpurchaseservice.New(creditpurchaseservice.Config{ + Adapter: creditPurchaseAdapter, + Handler: creditPurchaseHandler, + Lineage: lineageService, + MetaAdapter: metaAdapter, + }) + if err != nil { + return nil, fmt.Errorf("failed to create charges credit purchase service: %w", err) + } + + return creditPurchaseSvc, nil +} + +func NewChargesAdapter( + db *entdb.Client, + logger *slog.Logger, +) (charges.Adapter, error) { + rootAdapter, err := chargesadapter.New(chargesadapter.Config{ + Client: db, + Logger: logger, + }) + if err != nil { + return nil, fmt.Errorf("failed to create charges adapter: %w", err) + } + + return rootAdapter, nil +} + +func NewChargesService( + logger *slog.Logger, + rootAdapter charges.Adapter, + metaAdapter meta.Adapter, + featureService feature.FeatureConnector, + flatFeeSvc flatfee.Service, + creditPurchaseSvc creditpurchase.Service, + usageBasedSvc usagebased.Service, + billingService billing.Service, + recognizerService recognizer.Service, + taxCodeService taxcode.Service, + fsNamespaceLockdown []string, +) (charges.Service, error) { + chargesSvc, err := chargesservice.New(chargesservice.Config{ + Logger: logger, + Adapter: rootAdapter, + MetaAdapter: metaAdapter, + FeatureService: featureService, + FlatFeeService: flatFeeSvc, + CreditPurchaseService: creditPurchaseSvc, + UsageBasedService: usageBasedSvc, + BillingService: billingService, + RecognizerService: recognizerService, + TaxCodeService: taxCodeService, + FSNamespaceLockdown: fsNamespaceLockdown, + }) + if err != nil { + return nil, fmt.Errorf("failed to create charges service: %w", err) + } + + return chargesSvc, nil +} + +func NewRecognizerService( + db *entdb.Client, + ledgerService ledger.Ledger, + balanceQuerier ledger.BalanceQuerier, + accountResolver ledger.AccountResolver, + accountService ledgeraccount.Service, + lineageService lineage.Service, +) (recognizer.Service, error) { + return recognizer.NewService(recognizer.Config{ + Ledger: ledgerService, + Dependencies: transactions.ResolverDependencies{ + AccountService: accountResolver, + AccountCatalog: accountService, + BalanceQuerier: balanceQuerier, + }, + Lineage: lineageService, + TransactionManager: enttx.NewCreator(db), + }) +} + +// newChargesRegistry constructs the full charges stack. +// Private: must be initialized via NewBillingRegistry. +func newChargesRegistry( + db *entdb.Client, + logger *slog.Logger, + locker *lockr.Locker, + billingService billing.Service, + ratingService rating.Service, + featureService feature.FeatureConnector, + streamingConnector streaming.Connector, + ledgerService ledger.Ledger, + balanceQuerier ledger.BalanceQuerier, + accountResolver ledger.AccountResolver, + accountService ledgeraccount.Service, + breakageService ledgerbreakage.Service, + taxCodeService taxcode.Service, + fsNamespaceLockdown []string, + creditsConfig config.CreditsConfiguration, + featureGate *featuregate.FeatureGateChecker, +) (*ChargesRegistry, error) { + metaAdapter, err := NewChargesMetaAdapter(db, logger) + if err != nil { + return nil, err + } + + lineageAdapter, err := NewChargesLineageAdapter(db) + if err != nil { + return nil, err + } + + lineageService, err := NewChargesLineageService(lineageAdapter) + if err != nil { + return nil, err + } + + transactionManager := enttx.NewCreator(db) + collectorService, err := ledgercollector.NewService(ledgercollector.Config{ + Ledger: ledgerService, + Dependencies: transactions.ResolverDependencies{ + AccountService: accountResolver, + AccountCatalog: accountService, + BalanceQuerier: balanceQuerier, + }, + Breakage: breakageService, + AccountLocker: accountService, + TransactionManager: transactionManager, + }) + if err != nil { + return nil, fmt.Errorf("failed to create charges collector service: %w", err) + } + + recognizerService, err := NewRecognizerService(db, ledgerService, balanceQuerier, accountResolver, accountService, lineageService) + if err != nil { + return nil, err + } + + flatFeeHandler := NewChargesFlatFeeHandler(ledgerService, balanceQuerier, accountResolver, accountService, collectorService) + usageBasedHandler := NewChargesUsageBasedHandler(ledgerService, balanceQuerier, accountResolver, accountService, collectorService) + creditPurchaseHandler, err := NewChargesCreditPurchaseHandler( + ledgerService, + balanceQuerier, + accountResolver, + accountService, + breakageService, + transactionManager, + ) + if err != nil { + return nil, err + } + + flatFeeAdapter, err := NewChargesFlatFeeAdapter(db, logger, metaAdapter) + if err != nil { + return nil, err + } + + flatFeeSvc, err := NewChargesFlatFeeService(flatFeeAdapter, flatFeeHandler, lineageService, metaAdapter, locker, ratingService) + if err != nil { + return nil, err + } + + if err := billingService.RegisterLineEngine(flatFeeSvc.GetLineEngine()); err != nil { + return nil, fmt.Errorf("failed to register charges flat fee line engine: %w", err) + } + + usageBasedAdapter, err := NewChargesUsageBasedAdapter(db, logger, metaAdapter) + if err != nil { + return nil, err + } + + invoiceUpdater, err := NewChargesInvoiceUpdater(billingService, logger) + if err != nil { + return nil, err + } + + usageBasedSvc, err := NewChargesUsageBasedService( + usageBasedAdapter, + usageBasedHandler, + lineageService, + locker, + metaAdapter, + invoiceUpdater, + billingService, + featureService, + ratingService, + streamingConnector, + ) + if err != nil { + return nil, err + } + + if err := billingService.RegisterLineEngine(usageBasedSvc.GetLineEngine()); err != nil { + return nil, fmt.Errorf("failed to register charges usage based line engine: %w", err) + } + + creditPurchaseAdapter, err := NewChargesCreditPurchaseAdapter(db, logger, metaAdapter) + if err != nil { + return nil, err + } + + creditPurchaseSvc, err := NewChargesCreditPurchaseService(creditPurchaseAdapter, creditPurchaseHandler, lineageService, metaAdapter) + if err != nil { + return nil, err + } + + creditPurchaseLineEngine, err := creditpurchaselineengine.New(creditpurchaselineengine.Config{ + RatingService: ratingService, + }) + if err != nil { + return nil, fmt.Errorf("failed to create charges credit purchase line engine: %w", err) + } + + if err := billingService.RegisterLineEngine(creditPurchaseLineEngine); err != nil { + return nil, fmt.Errorf("failed to register charges credit purchase line engine: %w", err) + } + createLineRouter, err := chargeslinerouter.New(chargeslinerouter.Config{ + CreditsEnabled: creditsConfig.Enabled, + CreditThenInvoiceEnabled: creditsConfig.EnableCreditThenInvoice, + FeatureGate: featureGate, + }) + if err != nil { + return nil, fmt.Errorf("failed to create charges create line router: %w", err) + } + + if err := billingService.RegisterCreateLineRouter(createLineRouter); err != nil { + return nil, fmt.Errorf("failed to register charges create line router: %w", err) + } + + rootAdapter, err := NewChargesAdapter(db, logger) + if err != nil { + return nil, err + } + + chargesSvc, err := NewChargesService( + logger, + rootAdapter, + metaAdapter, + featureService, + flatFeeSvc, + creditPurchaseSvc, + usageBasedSvc, + billingService, + recognizerService, + taxCodeService, + fsNamespaceLockdown, + ) + if err != nil { + return nil, err + } + + return &ChargesRegistry{ + Service: chargesSvc, + FlatFeeService: flatFeeSvc, + UsageBasedService: usageBasedSvc, + CreditPurchaseService: creditPurchaseSvc, + RecognizerService: recognizerService, + }, nil +} diff --git a/app/common/clickhouse.go b/app/common/clickhouse.go new file mode 100644 index 0000000000000000000000000000000000000000..078bfec245403f38876420ab579e5581b22190f5 --- /dev/null +++ b/app/common/clickhouse.go @@ -0,0 +1,74 @@ +package common + +import ( + "context" + "fmt" + "log/slog" + + "github.com/ClickHouse/clickhouse-go/v2" + "github.com/google/wire" + "go.opentelemetry.io/otel/metric" + "go.opentelemetry.io/otel/trace" + + "github.com/openmeterio/openmeter/app/config" + "github.com/openmeterio/openmeter/pkg/framework/clickhouseotel" +) + +var ClickHouse = wire.NewSet( + NewClickHouse, +) + +func NewClickHouse(ctx context.Context, conf config.ClickHouseAggregationConfiguration, tracer trace.Tracer, meter metric.Meter, logger *slog.Logger) (clickhouse.Conn, func(), error) { + noopClose := func() {} + + closers := []func(){} + + conn, err := clickhouse.Open(conf.GetClientOptions()) + if err != nil { + return nil, noopClose, fmt.Errorf("failed to initialize clickhouse client: %w", err) + } + + closers = append(closers, func() { + if err := conn.Close(); err != nil { + logger.Error("failed to close clickhouse client", "error", err) + } + }) + + if conf.Tracing { + conn, err = clickhouseotel.NewClickHouseTracer(clickhouseotel.ClickHouseTracerConfig{ + Conn: conn, + Tracer: tracer, + }) + if err != nil { + return nil, noopClose, fmt.Errorf("failed to initialize clickhouse tracer: %w", err) + } + } + + if conf.PoolMetrics.Enabled { + connPoolMetrics, err := clickhouseotel.NewConnPoolMetrics(clickhouseotel.ConnPoolMetricsConfig{ + Conn: conn, + Meter: meter, + PollInterval: conf.PoolMetrics.PollInterval, + Logger: logger, + }) + if err != nil { + return nil, noopClose, fmt.Errorf("failed to initialize clickhouse pool metrics: %w", err) + } + + if err := connPoolMetrics.Start(ctx); err != nil { + return nil, noopClose, fmt.Errorf("failed to start clickhouse pool metrics: %w", err) + } + + closers = append(closers, func() { + if err := connPoolMetrics.Shutdown(); err != nil { + logger.Error("failed to shutdown clickhouse pool metrics", "error", err) + } + }) + } + + return conn, func() { + for _, closer := range closers { + closer() + } + }, nil +} diff --git a/app/common/config.go b/app/common/config.go new file mode 100644 index 0000000000000000000000000000000000000000..f761c70da892bd038b64f3baa3aac356d51d4a02 --- /dev/null +++ b/app/common/config.go @@ -0,0 +1,67 @@ +package common + +import ( + "github.com/google/wire" + + "github.com/openmeterio/openmeter/app/config" +) + +// We have configs separatly to be able to reuse wires in other projects +var Config = wire.NewSet( + // App + wire.FieldsOf(new(config.Configuration), "Apps"), + // Aggregation + wire.FieldsOf(new(config.Configuration), "Aggregation"), + // Billing + wire.FieldsOf(new(config.Configuration), "Billing"), + wire.FieldsOf(new(config.BillingConfiguration), "AdvancementStrategy"), + wire.FieldsOf(new(config.BillingConfiguration), "FeatureSwitches"), + // ClickHouse + wire.FieldsOf(new(config.AggregationConfiguration), "ClickHouse"), + // Customer + wire.FieldsOf(new(config.Configuration), "Customer"), + // Credit + wire.FieldsOf(new(config.Configuration), "Credits"), + // Database + wire.FieldsOf(new(config.Configuration), "Postgres"), + // Entitlement + wire.FieldsOf(new(config.Configuration), "Entitlements"), + // Events + wire.FieldsOf(new(config.Configuration), "Events"), + wire.FieldsOf(new(config.Configuration), "Dedupe"), + // Kafka + // TODO: refactor to move out Kafka config from ingest and consolidate + wire.FieldsOf(new(config.KafkaIngestConfiguration), "KafkaConfiguration"), + wire.FieldsOf(new(config.Configuration), "Ingest"), + wire.FieldsOf(new(config.IngestConfiguration), "Kafka"), + wire.FieldsOf(new(config.KafkaIngestConfiguration), "TopicProvisioner"), + // Namespace + wire.FieldsOf(new(config.Configuration), "Namespace"), + // Notification + wire.FieldsOf(new(config.Configuration), "Notification"), + wire.FieldsOf(new(config.NotificationConfiguration), "Webhook"), + // Subscription + wire.FieldsOf(new(config.ProductCatalogConfiguration), "Subscription"), + // Portal + wire.FieldsOf(new(config.Configuration), "Portal"), + // ProductCatalog + wire.FieldsOf(new(config.Configuration), "ProductCatalog"), + // ProgressManager + wire.FieldsOf(new(config.Configuration), "ProgressManager"), + // Reserved Event Types + wire.FieldsOf(new(config.Configuration), "ReservedEventTypes"), + // Svix + wire.FieldsOf(new(config.Configuration), "Svix"), + // UnitConfig + wire.FieldsOf(new(config.Configuration), "UnitConfig"), + // Telemetry + wire.FieldsOf(new(config.Configuration), "Telemetry"), + wire.FieldsOf(new(config.TelemetryConfig), "Metrics"), + wire.FieldsOf(new(config.TelemetryConfig), "Trace"), + wire.FieldsOf(new(config.TelemetryConfig), "Log"), + // Termination + wire.FieldsOf(new(config.Configuration), "Termination"), + // Server + wire.FieldsOf(new(config.Configuration), "Server"), + wire.FieldsOf(new(config.ServerConfig), "ClientIPMiddleware"), +) diff --git a/app/common/creditgrant.go b/app/common/creditgrant.go new file mode 100644 index 0000000000000000000000000000000000000000..131cb89e89090abec1d1ea6df9b9cbe15aeb4c10 --- /dev/null +++ b/app/common/creditgrant.go @@ -0,0 +1,89 @@ +package common + +import ( + "fmt" + + "github.com/google/wire" + + "github.com/openmeterio/openmeter/app/config" + "github.com/openmeterio/openmeter/openmeter/billing/creditgrant" + creditgrantservice "github.com/openmeterio/openmeter/openmeter/billing/creditgrant/service" + "github.com/openmeterio/openmeter/openmeter/customer" + entdb "github.com/openmeterio/openmeter/openmeter/ent/db" + enttx "github.com/openmeterio/openmeter/openmeter/ent/tx" + "github.com/openmeterio/openmeter/openmeter/ledger" + ledgeraccount "github.com/openmeterio/openmeter/openmeter/ledger/account" + ledgerbreakage "github.com/openmeterio/openmeter/openmeter/ledger/breakage" + "github.com/openmeterio/openmeter/openmeter/ledger/creditvoid" + creditvoidadapter "github.com/openmeterio/openmeter/openmeter/ledger/creditvoid/adapter" + "github.com/openmeterio/openmeter/openmeter/ledger/transactions" +) + +var CreditGrant = wire.NewSet( + NewCreditVoidService, + NewCreditGrantService, +) + +func NewCreditVoidService( + creditsConfig config.CreditsConfiguration, + db *entdb.Client, + ledgerService ledger.Ledger, + balanceQuerier ledger.BalanceQuerier, + accountResolver ledger.AccountResolver, + accountService ledgeraccount.Service, + breakageService ledgerbreakage.Service, +) (creditvoid.Service, error) { + if !creditsConfig.Enabled { + return creditvoid.NewNoopService(), nil + } + + adapter, err := creditvoidadapter.New(creditvoidadapter.Config{ + Client: db, + }) + if err != nil { + return nil, fmt.Errorf("failed to create credit void adapter: %w", err) + } + + svc, err := creditvoid.NewService(creditvoid.Config{ + Adapter: adapter, + Ledger: ledgerService, + Dependencies: transactions.ResolverDependencies{ + AccountService: accountResolver, + AccountCatalog: accountService, + BalanceQuerier: balanceQuerier, + }, + Breakage: breakageService, + AccountLocker: accountService, + TransactionManager: enttx.NewCreator(db), + }) + if err != nil { + return nil, fmt.Errorf("failed to create credit void service: %w", err) + } + + return svc, nil +} + +func NewCreditGrantService( + db *entdb.Client, + billingRegistry BillingRegistry, + customerService customer.Service, + creditVoidService creditvoid.Service, +) (creditgrant.Service, error) { + if billingRegistry.Charges == nil { + return nil, nil + } + + svc, err := creditgrantservice.New(creditgrantservice.Config{ + CreditPurchaseService: billingRegistry.Charges.CreditPurchaseService, + ChargesService: billingRegistry.Charges.Service, + BillingService: billingRegistry.Billing, + CustomerService: customerService, + CreditVoidService: creditVoidService, + TransactionManager: enttx.NewCreator(db), + }) + if err != nil { + return nil, fmt.Errorf("failed to create credit grant service: %w", err) + } + + return svc, nil +} diff --git a/app/common/currency.go b/app/common/currency.go new file mode 100644 index 0000000000000000000000000000000000000000..e5d0ea21841a9ed1e78ce6296ae40917a6d1222a --- /dev/null +++ b/app/common/currency.go @@ -0,0 +1,37 @@ +package common + +import ( + "fmt" + + "github.com/google/wire" + + "github.com/openmeterio/openmeter/openmeter/currencies" + currencyadapter "github.com/openmeterio/openmeter/openmeter/currencies/adapter" + "github.com/openmeterio/openmeter/openmeter/currencies/service" + entdb "github.com/openmeterio/openmeter/openmeter/ent/db" +) + +var Currency = wire.NewSet( + NewCurrencyAdapter, + NewCurrencyService, +) + +func NewCurrencyAdapter(db *entdb.Client) (currencies.Repository, error) { + repo, err := currencyadapter.New(currencyadapter.Config{ + Client: db, + }) + if err != nil { + return nil, fmt.Errorf("failed to create currency adapter: %w", err) + } + + return repo, nil +} + +func NewCurrencyService(repo currencies.Repository) (currencies.Service, error) { + s, err := service.New(repo) + if err != nil { + return nil, fmt.Errorf("failed to create currency service: %w", err) + } + + return s, nil +} diff --git a/app/common/customer.go b/app/common/customer.go new file mode 100644 index 0000000000000000000000000000000000000000..9f465e6caa125fd941582df7c790d06326553048 --- /dev/null +++ b/app/common/customer.go @@ -0,0 +1,124 @@ +package common + +import ( + "fmt" + "log/slog" + + "github.com/google/wire" + "go.opentelemetry.io/otel/trace" + + "github.com/openmeterio/openmeter/app/config" + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/customer" + customeradapter "github.com/openmeterio/openmeter/openmeter/customer/adapter" + customerservice "github.com/openmeterio/openmeter/openmeter/customer/service" + customerservicehooks "github.com/openmeterio/openmeter/openmeter/customer/service/hooks" + entdb "github.com/openmeterio/openmeter/openmeter/ent/db" + ledgerresolvers "github.com/openmeterio/openmeter/openmeter/ledger/resolvers" + "github.com/openmeterio/openmeter/openmeter/registry" + "github.com/openmeterio/openmeter/openmeter/subject" + "github.com/openmeterio/openmeter/openmeter/watermill/eventbus" +) + +var Customer = wire.NewSet( + NewCustomerService, +) + +func NewCustomerService( + logger *slog.Logger, + db *entdb.Client, + eventPublisher eventbus.Publisher, +) (customer.Service, error) { + customerAdapter, err := customeradapter.New(customeradapter.Config{ + Client: db, + Logger: logger.WithGroup("customer.postgres"), + }) + if err != nil { + return nil, fmt.Errorf("failed to create customer adapter: %w", err) + } + + service, err := customerservice.New(customerservice.Config{ + Adapter: customerAdapter, + Publisher: eventPublisher, + }) + if err != nil { + return nil, fmt.Errorf("failed to create customer service: %w", err) + } + + return service, nil +} + +type CustomerSubjectHook customerservicehooks.SubjectCustomerHook + +type CustomerLedgerHook ledgerresolvers.CustomerLedgerHook + +func NewCustomerLedgerServiceHook( + creditsConfig config.CreditsConfiguration, + tracer trace.Tracer, + accountResolver customerLedgerProvisioner, + customerService customer.Service, +) (CustomerLedgerHook, error) { + if !creditsConfig.Enabled { + return ledgerresolvers.NoopCustomerLedgerHook{}, nil + } + + h, err := ledgerresolvers.NewCustomerLedgerHook(ledgerresolvers.CustomerLedgerHookConfig{ + Service: accountResolver, + Tracer: tracer, + }) + if err != nil { + return nil, fmt.Errorf("failed to create customer ledger hook: %w", err) + } + + customerService.RegisterHooks(h) + + return h, nil +} + +func NewCustomerSubjectServiceHook( + config config.CustomerConfiguration, + logger *slog.Logger, + tracer trace.Tracer, + subjectService subject.Service, + customerService customer.Service, + customerOverrideService billing.CustomerOverrideService, +) (CustomerSubjectHook, error) { + if !config.EnableSubjectHook { + return new(customerservicehooks.NoopSubjectCustomerHook), nil + } + + // Initialize the subject customer hook and register it for Subject service + h, err := customerservicehooks.NewSubjectCustomerHook(customerservicehooks.SubjectCustomerHookConfig{ + Customer: customerService, + CustomerOverride: customerOverrideService, + Logger: logger, + Tracer: tracer, + IgnoreErrors: config.IgnoreErrors, + }) + if err != nil { + return nil, fmt.Errorf("failed to create customer subject hook: %w", err) + } + + subjectService.RegisterHooks(h) + + return h, nil +} + +type CustomerEntitlementValidatorHook customerservicehooks.EntitlementValidatorHook + +func NewCustomerEntitlementValidatorServiceHook( + logger *slog.Logger, + entitlementRegistry *registry.Entitlement, + customerService customer.Service, +) (CustomerEntitlementValidatorHook, error) { + h, err := customerservicehooks.NewEntitlementValidatorHook(customerservicehooks.EntitlementValidatorHookConfig{ + EntitlementService: entitlementRegistry.Entitlement, + }) + if err != nil { + return nil, fmt.Errorf("failed to create customer entitlement validator hook: %w", err) + } + + customerService.RegisterHooks(h) + + return h, nil +} diff --git a/app/common/customerbalance.go b/app/common/customerbalance.go new file mode 100644 index 0000000000000000000000000000000000000000..7d739c65a7422e7107cc16b3cd2dd24e6cb29c1b --- /dev/null +++ b/app/common/customerbalance.go @@ -0,0 +1,48 @@ +package common + +import ( + "github.com/google/wire" + + "github.com/openmeterio/openmeter/app/config" + "github.com/openmeterio/openmeter/openmeter/ledger" + ledgeraccount "github.com/openmeterio/openmeter/openmeter/ledger/account" + ledgerbreakage "github.com/openmeterio/openmeter/openmeter/ledger/breakage" + "github.com/openmeterio/openmeter/openmeter/ledger/creditvoid" + "github.com/openmeterio/openmeter/openmeter/ledger/customerbalance" +) + +var CustomerBalance = wire.NewSet( + NewCustomerBalanceService, + NewCustomerBalanceFacade, +) + +func NewCustomerBalanceService( + creditsConfig config.CreditsConfiguration, + historicalLedger ledger.Ledger, + balanceQuerier ledger.BalanceQuerier, + accountResolver ledger.AccountResolver, + accountService ledgeraccount.Service, + billingRegistry BillingRegistry, + breakageService ledgerbreakage.Service, + creditVoidService creditvoid.Service, +) (customerbalance.Service, error) { + if !creditsConfig.Enabled { + return customerbalance.NewNoopService(), nil + } + + return customerbalance.New(customerbalance.Config{ + AccountResolver: accountResolver, + SubAccountService: accountService, + ChargesService: billingRegistry.Charges.Service, + CreditPurchaseSvc: billingRegistry.Charges.CreditPurchaseService, + UsageBasedService: billingRegistry.Charges.UsageBasedService, + Ledger: historicalLedger, + BalanceQuerier: balanceQuerier, + Breakage: breakageService, + CreditVoid: creditVoidService, + }) +} + +func NewCustomerBalanceFacade(service customerbalance.Service) (*customerbalance.Facade, error) { + return customerbalance.NewFacade(service) +} diff --git a/app/common/database.go b/app/common/database.go new file mode 100644 index 0000000000000000000000000000000000000000..76ce44c284df6cb5e559b02aa792172e38fc35a6 --- /dev/null +++ b/app/common/database.go @@ -0,0 +1,134 @@ +package common + +import ( + "context" + "database/sql" + "fmt" + "log/slog" + + "github.com/XSAM/otelsql" + "github.com/google/wire" + "go.opentelemetry.io/otel/metric" + "go.opentelemetry.io/otel/trace" + + "github.com/openmeterio/openmeter/app/config" + "github.com/openmeterio/openmeter/openmeter/ent/db" + "github.com/openmeterio/openmeter/pkg/framework/entutils/entdriver" + "github.com/openmeterio/openmeter/pkg/framework/pgdriver" + "github.com/openmeterio/openmeter/tools/migrate" +) + +var Database = wire.NewSet( + wire.Struct(new(Migrator), "*"), + + NewPostgresDriver, + NewDB, + NewEntPostgresDriver, + NewEntClient, +) + +// Migrator executes database migrations. +type Migrator struct { + Config config.PostgresConfig + Client *db.Client + Logger *slog.Logger +} + +func (m Migrator) Migrate(ctx context.Context) error { + if !m.Config.AutoMigrate.Enabled() { + m.Logger.Debug("auto migration is disabled") + + return nil + } + + m.Logger.Info("running migrations", slog.String("strategy", string(m.Config.AutoMigrate))) + + migrator, err := migrate.New(migrate.MigrateOptions{ + ConnectionString: m.Config.AsURL(), + Migrations: migrate.OMMigrationsConfig, + Logger: m.Logger, + }) + if err != nil { + return fmt.Errorf("failed to create migrator: %w", err) + } + defer migrator.CloseOrLogError() + + switch m.Config.AutoMigrate { + case config.AutoMigrateMigration: + if err := migrator.Up(); err != nil { + return fmt.Errorf("failed to migrate db: %w", err) + } + case config.AutoMigrateMigrationJob: + if err := migrator.WaitForMigrationJob(); err != nil { + return fmt.Errorf("failed to wait for migration job: %w", err) + } + } + + m.Logger.Info("database initialized") + + return nil +} + +// AdoptLegacyEnt is the explicit upgrade-job entrypoint for databases previously managed by Ent. +func (m Migrator) AdoptLegacyEnt(ctx context.Context) error { + driver, err := pgdriver.NewPostgresDriver(ctx, m.Config.AsURL()) + if err != nil { + return fmt.Errorf("open database for legacy Ent adoption: %w", err) + } + defer driver.Close() + + return migrate.AdoptLegacyEnt(ctx, driver.DB(), m.Config.AsURL(), m.Logger) +} + +func NewPostgresDriver( + ctx context.Context, + conf config.PostgresConfig, + meterProvider metric.MeterProvider, + meter metric.Meter, + tracerProvider trace.TracerProvider, + logger *slog.Logger, +) (*pgdriver.Driver, func(), error) { + driver, err := pgdriver.NewPostgresDriver( + ctx, + conf.AsURL(), + pgdriver.WithMetricMeter(meter), + pgdriver.WithTracerProvider(tracerProvider), + pgdriver.WithMeterProvider(meterProvider), + pgdriver.WithSpanOptions(otelsql.SpanOptions{ + OmitConnPrepare: true, + OmitRows: true, + OmitConnectorConnect: true, + }), + ) + if err != nil { + return nil, nil, fmt.Errorf("failed to initialize postgres driver: %w", err) + } + + return driver, func() { + err := driver.Close() + if err != nil { + logger.Error("failed to close postgres driver", "error", err) + } + }, nil +} + +// TODO: add closer function? +func NewDB(driver *pgdriver.Driver) *sql.DB { + return driver.DB() +} + +func NewEntPostgresDriver(db *sql.DB, logger *slog.Logger) (*entdriver.EntPostgresDriver, func()) { + driver := entdriver.NewEntPostgresDriver(db) + + return driver, func() { + err := driver.Close() + if err != nil { + logger.Error("failed to close ent driver", "error", err) + } + } +} + +// TODO: add closer function? +func NewEntClient(driver *entdriver.EntPostgresDriver) *db.Client { + return driver.Client() +} diff --git a/app/common/entitlement.go b/app/common/entitlement.go new file mode 100644 index 0000000000000000000000000000000000000000..ead626f14872496f224a738bb3466feff758824b --- /dev/null +++ b/app/common/entitlement.go @@ -0,0 +1,58 @@ +package common + +import ( + "fmt" + "log/slog" + + "github.com/google/wire" + "go.opentelemetry.io/otel/trace" + + "github.com/openmeterio/openmeter/app/config" + "github.com/openmeterio/openmeter/openmeter/customer" + entdb "github.com/openmeterio/openmeter/openmeter/ent/db" + entitlementvalidator "github.com/openmeterio/openmeter/openmeter/entitlement/validators/customer" + "github.com/openmeterio/openmeter/openmeter/meter" + "github.com/openmeterio/openmeter/openmeter/registry" + registrybuilder "github.com/openmeterio/openmeter/openmeter/registry/builder" + "github.com/openmeterio/openmeter/openmeter/streaming" + "github.com/openmeterio/openmeter/openmeter/watermill/eventbus" + "github.com/openmeterio/openmeter/pkg/framework/lockr" +) + +var Entitlement = wire.NewSet( + NewEntitlementRegistry, +) + +func NewEntitlementRegistry( + logger *slog.Logger, + db *entdb.Client, + tracer trace.Tracer, + entitlementConfig config.EntitlementsConfiguration, + streamingConnector streaming.Connector, + meterService meter.Service, + eventPublisher eventbus.Publisher, + locker *lockr.Locker, + customerService customer.Service, +) (*registry.Entitlement, error) { + entRegistry := registrybuilder.GetEntitlementRegistry(registrybuilder.EntitlementOptions{ + DatabaseClient: db, + StreamingConnector: streamingConnector, + MeterService: meterService, + CustomerService: customerService, + Logger: logger, + Publisher: eventPublisher, + EntitlementsConfiguration: entitlementConfig, + Tracer: tracer, + Locker: locker, + }) + + // Create and register the entitlement validator + validator, err := entitlementvalidator.NewValidator(entRegistry.EntitlementRepo) + if err != nil { + return nil, fmt.Errorf("failed to create entitlement validator: %w", err) + } + + customerService.RegisterRequestValidator(validator) + + return entRegistry, nil +} diff --git a/app/common/featuregate.go b/app/common/featuregate.go new file mode 100644 index 0000000000000000000000000000000000000000..47c8570cb0107af252e85a31df5a644eb7a6d34a --- /dev/null +++ b/app/common/featuregate.go @@ -0,0 +1,31 @@ +package common + +import ( + "github.com/google/wire" + + "github.com/openmeterio/openmeter/app/config" + "github.com/openmeterio/openmeter/pkg/featuregate" +) + +var FeatureGateChecker = wire.NewSet( + featuregate.NewNoop, + wire.FieldsOf(new(config.Configuration), "FeatureGate"), + NewFeatureGateChecker, +) + +func NewFeatureGateChecker(gate featuregate.Gate, config config.FeatureGateConfiguration, creditsConfig config.CreditsConfiguration) *featuregate.FeatureGateChecker { + flags := make(featuregate.Flags) + if config.Flags != nil { + flags = config.Flags + } + + flagOverrides := map[featuregate.FeatureFlag]bool{ + featuregate.CtxKeyCredits: creditsConfig.Enabled, + } + + if !config.Enabled { + return featuregate.NewFeatureGateChecker(featuregate.NewNoop(), flags, flagOverrides) + } + + return featuregate.NewFeatureGateChecker(gate, flags, flagOverrides) +} diff --git a/app/common/featuregate_test.go b/app/common/featuregate_test.go new file mode 100644 index 0000000000000000000000000000000000000000..9fa4d324813f2af73614054e7dde527ab1506b50 --- /dev/null +++ b/app/common/featuregate_test.go @@ -0,0 +1,45 @@ +package common_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/openmeterio/openmeter/app/common" + "github.com/openmeterio/openmeter/app/config" + "github.com/openmeterio/openmeter/pkg/featuregate" +) + +// alwaysFalseGate is a Gate that always returns false for any flag evaluation. +type alwaysFalseGate struct{} + +func (alwaysFalseGate) EvaluateBool(_, _ string, _ bool) (bool, error) { + return false, nil +} + +func TestNewFeatureGateChecker_DisabledUsesNoop(t *testing.T) { + checker := common.NewFeatureGateChecker(alwaysFalseGate{}, config.FeatureGateConfiguration{ + Enabled: false, + Flags: featuregate.Flags{featuregate.FeatureFlag("om_ff_credits_enabled"): "credits-flag"}, + }, config.CreditsConfiguration{Enabled: true}) + require.NotNil(t, checker) + + // With Enabled=false the checker wraps a Noop gate, so any flag evaluates to true + got, err := checker.Enabled("ns", "credits-flag") + require.NoError(t, err) + assert.True(t, got, "disabled feature gate should use noop and return true") +} + +func TestNewFeatureGateChecker_EnabledUsesRealGate(t *testing.T) { + checker := common.NewFeatureGateChecker(alwaysFalseGate{}, config.FeatureGateConfiguration{ + Enabled: true, + Flags: featuregate.Flags{featuregate.FeatureFlag("om_ff_credits_enabled"): "credits-flag"}, + }, config.CreditsConfiguration{Enabled: true}) + require.NotNil(t, checker) + + // With Enabled=true the real gate is used, which always returns false + got, err := checker.Enabled("ns", "credits-flag") + require.NoError(t, err) + assert.False(t, got, "enabled feature gate should use the real gate") +} diff --git a/app/common/ffx.go b/app/common/ffx.go new file mode 100644 index 0000000000000000000000000000000000000000..72ac94f341c60b9e6d4c4137e3c99bc356c445c4 --- /dev/null +++ b/app/common/ffx.go @@ -0,0 +1,59 @@ +package common + +import ( + "log/slog" + "net/http" + + "github.com/google/wire" + + "github.com/openmeterio/openmeter/api" + "github.com/openmeterio/openmeter/app/config" + "github.com/openmeterio/openmeter/openmeter/namespace/namespacedriver" + "github.com/openmeterio/openmeter/openmeter/subscription" + "github.com/openmeterio/openmeter/pkg/ffx" +) + +var FFX = wire.NewSet( + ffx.NewContextService, +) + +type FFXConfigContextMiddleware api.MiddlewareFunc + +// NewFFXConfigContextMiddleware creates a middleware hook that sets the feature flag access context on the request context. +// This hook MUST register after any session authentication step so user namespaces are available. +func NewFFXConfigContextMiddleware( + subsConfig config.SubscriptionConfiguration, + namespaceDriver namespacedriver.NamespaceDecoder, + logger *slog.Logger, +) FFXConfigContextMiddleware { + return func(next http.Handler) http.Handler { + accessMap := make(map[string]ffx.AccessConfig) + for _, ns := range subsConfig.MultiSubscriptionNamespaces { + acc := make(ffx.AccessConfig) + acc[subscription.MultiSubscriptionEnabledFF] = true + + accessMap[ns] = acc + } + + noAccess := make(ffx.AccessConfig) + noAccess[subscription.MultiSubscriptionEnabledFF] = false + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + // Let's try to figure out which namespace we're in + namespace, ok := namespaceDriver.GetNamespace(ctx) + if !ok { + logger.WarnContext(ctx, "no namespace found in request, continuing without feature flag access") + } + + acc, ok := accessMap[namespace] + if !ok { + acc = noAccess + } + + ctx = ffx.SetAccessOnContext(ctx, acc) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} diff --git a/app/common/framework.go b/app/common/framework.go new file mode 100644 index 0000000000000000000000000000000000000000..a7e9b2c6ad7fcea2f7183097fa821b99f43fa71e --- /dev/null +++ b/app/common/framework.go @@ -0,0 +1,8 @@ +package common + +import "github.com/google/wire" + +var Framework = wire.NewSet( + wire.Struct(new(GlobalInitializer), "*"), + wire.Struct(new(Runner), "*"), +) diff --git a/app/common/globals.go b/app/common/globals.go new file mode 100644 index 0000000000000000000000000000000000000000..ab9d1bc0076e8035083c64cce89cc72b99352750 --- /dev/null +++ b/app/common/globals.go @@ -0,0 +1,30 @@ +package common + +import ( + "log/slog" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/metric" + "go.opentelemetry.io/otel/propagation" + "go.opentelemetry.io/otel/trace" +) + +// TODO: move this to framework? + +// GlobalInitializer initializes global variables for the application. +type GlobalInitializer struct { + Logger *slog.Logger + MeterProvider metric.MeterProvider + TracerProvider trace.TracerProvider + TextMapPropagator propagation.TextMapPropagator +} + +// SetGlobals initializes global variables for the application. +// +// It is intended to be embedded into application structs and be called from func main. +func (i *GlobalInitializer) SetGlobals() { + slog.SetDefault(i.Logger) + otel.SetMeterProvider(i.MeterProvider) + otel.SetTracerProvider(i.TracerProvider) + otel.SetTextMapPropagator(i.TextMapPropagator) +} diff --git a/app/common/governance.go b/app/common/governance.go new file mode 100644 index 0000000000000000000000000000000000000000..43f83b5726c4ea617f13968ac0d867ad5b07fa66 --- /dev/null +++ b/app/common/governance.go @@ -0,0 +1,31 @@ +package common + +import ( + "github.com/google/wire" + "go.opentelemetry.io/otel/metric" + "go.opentelemetry.io/otel/trace" + + "github.com/openmeterio/openmeter/openmeter/customer" + "github.com/openmeterio/openmeter/openmeter/governance" + governanceservice "github.com/openmeterio/openmeter/openmeter/governance/service" + "github.com/openmeterio/openmeter/openmeter/registry" +) + +var Governance = wire.NewSet( + NewGovernanceService, +) + +func NewGovernanceService( + customer customer.Service, + entitlementRegistry *registry.Entitlement, + tracer trace.Tracer, + meter metric.Meter, +) (governance.Service, error) { + return governanceservice.New(governanceservice.Config{ + Customer: customer, + Entitlement: entitlementRegistry.Entitlement, + Feature: entitlementRegistry.Feature, + Tracer: tracer, + Meter: meter, + }) +} diff --git a/app/common/kafka.go b/app/common/kafka.go new file mode 100644 index 0000000000000000000000000000000000000000..8a72568cbcb8ce9872d48d2b7e78ae16078695e8 --- /dev/null +++ b/app/common/kafka.go @@ -0,0 +1,168 @@ +package common + +import ( + "fmt" + "log/slog" + + "github.com/confluentinc/confluent-kafka-go/v2/kafka" + "github.com/google/wire" + "go.opentelemetry.io/otel/metric" + + "github.com/openmeterio/openmeter/app/config" + "github.com/openmeterio/openmeter/openmeter/ingest/kafkaingest" + "github.com/openmeterio/openmeter/openmeter/ingest/kafkaingest/topicresolver" + pkgkafka "github.com/openmeterio/openmeter/pkg/kafka" + kafkametrics "github.com/openmeterio/openmeter/pkg/kafka/metrics" +) + +var Kafka = wire.NewSet( + NewKafkaProducer, + NewKafkaMetrics, + + NewKafkaTopicProvisioner, +) + +var KafkaIngest = wire.NewSet( + NewKafkaIngestNamespaceHandler, +) + +var KafkaNamespaceResolver = wire.NewSet( + NewNamespacedTopicResolver, + wire.Bind(new(topicresolver.Resolver), new(*topicresolver.NamespacedTopicResolver)), +) + +// TODO: add closer function? +func NewKafkaProducer(conf config.KafkaIngestConfiguration, logger *slog.Logger, meta Metadata) (*kafka.Producer, error) { + kafkaConfig := conf.CreateKafkaConfig() + _ = kafkaConfig.SetKey("client.id", meta.ServiceName) + + producer, err := kafka.NewProducer(&kafkaConfig) + if err != nil { + return nil, fmt.Errorf("failed to initialize kafka producer: %w", err) + } + + go pkgkafka.ConsumeLogChannel(producer, logger.WithGroup("kafka").WithGroup("producer")) + + return producer, nil +} + +func NewKafkaMetrics(meter metric.Meter) (*kafkametrics.Metrics, error) { + metrics, err := kafkametrics.New(meter) + if err != nil { + return nil, fmt.Errorf("failed to create Kafka client metrics: %w", err) + } + + return metrics, nil +} + +func NewKafkaAdminClient(conf config.KafkaConfiguration) (*kafka.AdminClient, error) { + kafkaConfigMap := conf.CreateKafkaConfig() + // NOTE(chrisgacsal): remove 'go.logs.channel.enable' configuration parameter as it is not supported by AdminClient + // and initializing the client fails if this parameter is set. + delete(kafkaConfigMap, "go.logs.channel.enable") + + // NOTE(chrisgacsal): disable collecting statistics as data is collected in an internal queue which needs to be polled, + // but the AdminClient does not expose interface for that. + delete(kafkaConfigMap, "statistics.interval.ms") + + adminClient, err := kafka.NewAdminClient(&kafkaConfigMap) + if err != nil { + return nil, fmt.Errorf("failed to initialize Kafka admin client: %w", err) + } + + return adminClient, nil +} + +func NewKafkaTopicProvisioner( + kafkaConfig config.KafkaConfiguration, + settings config.TopicProvisionerConfig, + logger *slog.Logger, + meter metric.Meter, +) (pkgkafka.TopicProvisioner, error) { + if !settings.Enabled { + return &pkgkafka.TopicProvisionerNoop{}, nil + } + + adminClient, err := NewKafkaAdminClient(kafkaConfig) + if err != nil { + return nil, fmt.Errorf("failed to initialize Kafka admin client: %w", err) + } + + provisionerConfig := pkgkafka.TopicProvisionerConfig{ + AdminClient: adminClient, + Logger: logger, + Meter: meter, + CacheSize: settings.CacheSize, + CacheTTL: settings.CacheTTL, + ProtectedTopics: settings.ProtectedTopics, + } + + topicProvisioner, err := pkgkafka.NewTopicProvisioner(provisionerConfig) + if err != nil { + return nil, fmt.Errorf("failed to initialize topic provisioner: %w", err) + } + + return topicProvisioner, nil +} + +func NewNoopKafkaTopicProvisioner() pkgkafka.TopicProvisioner { + return &pkgkafka.TopicProvisionerNoop{} +} + +func NewNamespacedTopicResolver(config config.KafkaIngestConfiguration) (*topicresolver.NamespacedTopicResolver, error) { + topicResolver, err := topicresolver.NewNamespacedTopicResolver(config.EventsTopicTemplate) + if err != nil { + return nil, fmt.Errorf("failed to create topic name resolver: %w", err) + } + + return topicResolver, nil +} + +func NewKafkaIngestNamespaceHandler( + topicResolver topicresolver.Resolver, + topicProvisioner pkgkafka.TopicProvisioner, + ingestConfig config.KafkaIngestConfiguration, +) (*kafkaingest.NamespaceHandler, error) { + handler := &kafkaingest.NamespaceHandler{ + TopicResolver: topicResolver, + TopicProvisioner: topicProvisioner, + Partitions: ingestConfig.Partitions, + DeletionEnabled: ingestConfig.NamespaceDeletionEnabled, + } + + return handler, nil +} + +func NewKafkaConsumer(conf pkgkafka.ConsumerConfig, logger *slog.Logger) (*kafka.Consumer, func(), error) { + if err := conf.Validate(); err != nil { + return nil, nil, fmt.Errorf("invalid Kafka consumer configuration: %w", err) + } + + consumerConfigMap, err := conf.AsConfigMap() + if err != nil { + return nil, nil, fmt.Errorf("failed to generate Kafka consumer configuration map: %w", err) + } + + consumer, err := kafka.NewConsumer(&consumerConfigMap) + if err != nil { + return nil, nil, fmt.Errorf("failed to initialize Kafka consumer: %w", err) + } + + kLogger := logger.WithGroup("kafka").WithGroup("consumer").With( + "group.id", conf.ConsumerGroupID, + "group.instance.id", conf.ConsumerGroupInstanceID, + "client.id", conf.ClientID, + ) + + // Enable Kafka client logging + // TODO: refactor ConsumeLogChannel to allow graceful shutdown + go pkgkafka.ConsumeLogChannel(consumer, kLogger) + + closer := func() { + if err = consumer.Close(); err != nil { + kLogger.Error("failed to close Kafka consumer", slog.String("err", err.Error())) + } + } + + return consumer, closer, nil +} diff --git a/app/common/ledger.go b/app/common/ledger.go new file mode 100644 index 0000000000000000000000000000000000000000..6f08dd0c0b18f91b700e16da001117ace382cc0d --- /dev/null +++ b/app/common/ledger.go @@ -0,0 +1,138 @@ +package common + +import ( + "context" + + "github.com/google/wire" + + "github.com/openmeterio/openmeter/app/config" + "github.com/openmeterio/openmeter/openmeter/customer" + entdb "github.com/openmeterio/openmeter/openmeter/ent/db" + "github.com/openmeterio/openmeter/openmeter/ledger" + ledgeraccount "github.com/openmeterio/openmeter/openmeter/ledger/account" + accountadapter "github.com/openmeterio/openmeter/openmeter/ledger/account/adapter" + accountservice "github.com/openmeterio/openmeter/openmeter/ledger/account/service" + historical "github.com/openmeterio/openmeter/openmeter/ledger/historical" + historicaladapter "github.com/openmeterio/openmeter/openmeter/ledger/historical/adapter" + ledgernoop "github.com/openmeterio/openmeter/openmeter/ledger/noop" + "github.com/openmeterio/openmeter/openmeter/ledger/resolvers" + resolversadapter "github.com/openmeterio/openmeter/openmeter/ledger/resolvers/adapter" + "github.com/openmeterio/openmeter/openmeter/ledger/routingrules" + "github.com/openmeterio/openmeter/openmeter/namespace" + "github.com/openmeterio/openmeter/pkg/framework/lockr" +) + +type ledgerReadWriter interface { + ledger.Ledger + ledger.BalanceQuerier +} + +type customerLedgerProvisioner interface { + ledger.AccountResolver + CreateCustomerAccounts(ctx context.Context, customerID customer.CustomerID) (ledger.CustomerAccounts, error) +} + +// LedgerStack is the full provider set for the ledger stack. +// Callers must provide *entdb.Client and *lockr.Locker (e.g. via common.Lockr). +var LedgerStack = wire.NewSet( + NewLedgerRoutingValidator, + NewLedgerAccountRepo, + NewLedgerHistoricalRepo, + NewLedgerResolversRepo, + NewLedgerHistoricalLedger, + NewLedgerAccountService, + NewLedgerAccountCatalog, + NewLedgerAccountLocker, + NewLedgerBalanceQuerier, + NewLedgerAccountResolver, + NewLedgerService, + NewLedgerNamespaceHandler, + NewLedgerResolversService, +) + +func NewLedgerRoutingValidator() ledger.RoutingValidator { + return routingrules.DefaultValidator +} + +func NewLedgerAccountRepo(db *entdb.Client) ledgeraccount.Repo { + return accountadapter.NewRepo(db) +} + +func NewLedgerHistoricalRepo(db *entdb.Client) historical.Repo { + return historicaladapter.NewRepo(db) +} + +func NewLedgerResolversRepo(db *entdb.Client) resolvers.CustomerAccountRepo { + return resolversadapter.NewRepo(db) +} + +func NewLedgerAccountService( + creditsConfig config.CreditsConfiguration, + repo ledgeraccount.Repo, + locker *lockr.Locker, +) ledgeraccount.Service { + if !creditsConfig.Enabled { + return ledgernoop.AccountService{} + } + + return accountservice.New(repo, locker) +} + +func NewLedgerHistoricalLedger( + creditsConfig config.CreditsConfiguration, + repo historical.Repo, + accountCatalog ledger.AccountCatalog, + accountLocker ledger.AccountLocker, + routingValidator ledger.RoutingValidator, +) ledgerReadWriter { + if !creditsConfig.Enabled { + return ledgernoop.Ledger{} + } + + return historical.NewLedger(repo, accountCatalog, accountLocker, routingValidator) +} + +func NewLedgerBalanceQuerier(historicalLedger ledgerReadWriter) ledger.BalanceQuerier { + return historicalLedger +} + +func NewLedgerAccountCatalog(accountSvc ledgeraccount.Service) ledger.AccountCatalog { + return accountSvc +} + +func NewLedgerAccountLocker(accountSvc ledgeraccount.Service) ledger.AccountLocker { + return accountSvc +} + +func NewLedgerResolversService( + creditsConfig config.CreditsConfiguration, + accountSvc ledgeraccount.Service, + repo resolvers.CustomerAccountRepo, + locker *lockr.Locker, +) customerLedgerProvisioner { + if !creditsConfig.Enabled { + return ledgernoop.AccountResolver{} + } + + return resolvers.NewAccountResolver(resolvers.AccountResolverConfig{ + AccountService: accountSvc, + Repo: repo, + Locker: locker, + }) +} + +func NewLedgerAccountResolver(accountResolver customerLedgerProvisioner) ledger.AccountResolver { + return accountResolver +} + +func NewLedgerService(historicalLedger ledgerReadWriter) ledger.Ledger { + return historicalLedger +} + +func NewLedgerNamespaceHandler(accountResolver ledger.AccountResolver) namespace.Handler { + if _, ok := accountResolver.(ledgernoop.AccountResolver); ok { + return ledgernoop.NamespaceHandler{} + } + + return resolvers.NewNamespaceHandler(accountResolver) +} diff --git a/app/common/ledger_test.go b/app/common/ledger_test.go new file mode 100644 index 0000000000000000000000000000000000000000..4cfca7ea635b4d5cce460f79b04a1ce35cc6e5ce --- /dev/null +++ b/app/common/ledger_test.go @@ -0,0 +1,44 @@ +package common + +import ( + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/require" + + ledgeraccountdb "github.com/openmeterio/openmeter/openmeter/ent/db/ledgeraccount" + "github.com/openmeterio/openmeter/openmeter/ledger" + ledgertestutils "github.com/openmeterio/openmeter/openmeter/ledger/testutils" + "github.com/openmeterio/openmeter/openmeter/testutils" +) + +func TestLedgerNamespaceHandlerIsSafeForRepeatedCreateNamespaceCalls(t *testing.T) { + testDB := testutils.InitPostgresDB(t, testutils.PostgresDBStateAtlasMigrated) + t.Cleanup(func() { + require.NoError(t, testDB.EntDriver.Close()) + require.NoError(t, testDB.PGDriver.Close()) + }) + + deps, err := ledgertestutils.InitDeps(testDB.EntDriver.Client(), testutils.NewDiscardLogger(t)) + require.NoError(t, err) + + handler := NewLedgerNamespaceHandler(deps.ResolversService) + namespace := fmt.Sprintf("startup-test-%d", time.Now().UnixNano()) + + require.NoError(t, handler.CreateNamespace(t.Context(), namespace)) + require.NoError(t, handler.CreateNamespace(t.Context(), namespace)) + + count, err := testDB.EntDriver.Client().LedgerAccount.Query(). + Where( + ledgeraccountdb.Namespace(namespace), + ledgeraccountdb.AccountTypeIn( + ledger.AccountTypeWash, + ledger.AccountTypeEarnings, + ledger.AccountTypeBrokerage, + ), + ). + Count(t.Context()) + require.NoError(t, err) + require.Equal(t, 3, count) +} diff --git a/app/common/llmcost.go b/app/common/llmcost.go new file mode 100644 index 0000000000000000000000000000000000000000..b8d58b05b9f08433eb6ad0daff19cae24749a717 --- /dev/null +++ b/app/common/llmcost.go @@ -0,0 +1,48 @@ +package common + +import ( + "fmt" + "log/slog" + "net/http" + + "github.com/google/wire" + + entdb "github.com/openmeterio/openmeter/openmeter/ent/db" + "github.com/openmeterio/openmeter/openmeter/llmcost" + llmcostadapter "github.com/openmeterio/openmeter/openmeter/llmcost/adapter" + llmcostservice "github.com/openmeterio/openmeter/openmeter/llmcost/service" + llmcostsync "github.com/openmeterio/openmeter/openmeter/llmcost/sync" +) + +var LLMCost = wire.NewSet( + NewLLMCostService, + NewLLMCostSyncJob, +) + +func NewLLMCostService(logger *slog.Logger, db *entdb.Client) (llmcost.Service, error) { + adapter, err := llmcostadapter.New(llmcostadapter.Config{ + Client: db, + Logger: logger.With("subsystem", "llmcost"), + }) + if err != nil { + return nil, fmt.Errorf("failed to initialize llmcost adapter: %w", err) + } + + return llmcostservice.New(adapter, logger.With("subsystem", "llmcost")), nil +} + +func NewLLMCostSyncJob(logger *slog.Logger, db *entdb.Client) (*llmcostsync.SyncJob, error) { + adapter, err := llmcostadapter.New(llmcostadapter.Config{ + Client: db, + Logger: logger.With("subsystem", "llmcost.sync"), + }) + if err != nil { + return nil, fmt.Errorf("failed to initialize llmcost sync adapter: %w", err) + } + + return llmcostsync.NewSyncJob(llmcostsync.SyncJobConfig{ + HTTPClient: &http.Client{}, + Repo: adapter, + Logger: logger.With("subsystem", "llmcost.sync"), + }), nil +} diff --git a/app/common/locker.go b/app/common/locker.go new file mode 100644 index 0000000000000000000000000000000000000000..6a56f4e9da818db914433ff9a6874cb243c5f483 --- /dev/null +++ b/app/common/locker.go @@ -0,0 +1,21 @@ +package common + +import ( + "log/slog" + + "github.com/google/wire" + + "github.com/openmeterio/openmeter/pkg/framework/lockr" +) + +var Lockr = wire.NewSet( + NewLocker, +) + +func NewLocker( + logger *slog.Logger, +) (*lockr.Locker, error) { + return lockr.NewLocker(&lockr.LockerConfig{ + Logger: logger, + }) +} diff --git a/app/common/metadata.go b/app/common/metadata.go new file mode 100644 index 0000000000000000000000000000000000000000..f1ec82ba4dcbfa1d1e3be9c3e30cd282ae6485be --- /dev/null +++ b/app/common/metadata.go @@ -0,0 +1,29 @@ +package common + +import ( + "fmt" + + "go.opentelemetry.io/otel/attribute" + + "github.com/openmeterio/openmeter/app/config" +) + +// Metadata provides information about the service to components that need it (eg. telemetry). +type Metadata struct { + ServiceName string + Version string + Environment string + OpenTelemetryName string + + AdditionalAttributes []attribute.KeyValue +} + +func NewMetadata(conf config.Configuration, version string, serviceName string, additionalAttributes ...attribute.KeyValue) Metadata { + return Metadata{ + ServiceName: fmt.Sprintf("openmeter-%s", serviceName), + Version: version, + Environment: conf.Environment, + OpenTelemetryName: fmt.Sprintf("openmeter.io/%s", serviceName), + AdditionalAttributes: additionalAttributes, + } +} diff --git a/app/common/meter.go b/app/common/meter.go new file mode 100644 index 0000000000000000000000000000000000000000..cb6b8b291836d99b6de8bb38de3a34c37e630b14 --- /dev/null +++ b/app/common/meter.go @@ -0,0 +1,172 @@ +package common + +import ( + "context" + "errors" + "fmt" + "log/slog" + "regexp" + + "github.com/google/wire" + "github.com/samber/lo" + + "github.com/openmeterio/openmeter/app/config" + entdb "github.com/openmeterio/openmeter/openmeter/ent/db" + "github.com/openmeterio/openmeter/openmeter/meter" + "github.com/openmeterio/openmeter/openmeter/meter/adapter" + "github.com/openmeterio/openmeter/openmeter/meter/service" + "github.com/openmeterio/openmeter/openmeter/namespace" + "github.com/openmeterio/openmeter/openmeter/watermill/eventbus" + "github.com/openmeterio/openmeter/pkg/filter" +) + +type MeterConfigInitializer = func(ctx context.Context) error + +var Meter = wire.NewSet( + NewMeterService, + NewMeterAdapter, +) + +var MeterManage = wire.NewSet( + Meter, + NewMeterManageService, + NewReservedEventTypePatterns, +) + +var MeterManageWithConfigMeters = wire.NewSet( + wire.FieldsOf(new(config.Configuration), "Meters"), + + Meter, + NewMeterManageService, + NewReservedEventTypePatterns, + NewMeterConfigInitializer, +) + +func NewMeterService( + meterAdapter *adapter.Adapter, +) meter.Service { + return service.New(meterAdapter) +} + +func NewReservedEventTypePatterns(reserved []config.ReservedEventTypePattern) ([]*meter.EventTypePattern, error) { + var errs []error + + patterns := make([]*meter.EventTypePattern, 0, len(reserved)) + + for _, r := range reserved { + pattern, err := regexp.Compile(r) + if err != nil { + errs = append(errs, fmt.Errorf("invalid reserved event type pattern %q: %w", r, err)) + + continue + } + + patterns = append(patterns, pattern) + } + + return patterns, errors.Join(errs...) +} + +func NewMeterManageService( + meterAdapter *adapter.Adapter, + namespaceManager *namespace.Manager, + publisher eventbus.Publisher, + reservedEventTypes []*meter.EventTypePattern, +) meter.ManageService { + return service.NewManage( + meterAdapter, + publisher, + namespaceManager, + reservedEventTypes, + ) +} + +func NewMeterAdapter( + logger *slog.Logger, + db *entdb.Client, +) (*adapter.Adapter, error) { + return adapter.New(adapter.Config{ + Client: db, + Logger: logger, + }) +} + +func NewMeterConfigInitializer( + logger *slog.Logger, + configMeters []*meter.Meter, + meterManagerService meter.ManageService, + namespaceManager *namespace.Manager, +) MeterConfigInitializer { + return func(ctx context.Context) error { + // Create config meters if they don't exist in the database + if len(configMeters) > 0 { + err := createConfigMetersInDatabase(ctx, logger, configMeters, namespaceManager, meterManagerService) + if err != nil { + return fmt.Errorf("failed to create config meters in database: %w", err) + } + } + + return nil + } +} + +// createConfigMetersInDatabase creates meters in the database if they don't exist +func createConfigMetersInDatabase( + ctx context.Context, + logger *slog.Logger, + configMeters []*meter.Meter, + namespaceManager *namespace.Manager, + meterService meter.ManageService, +) error { + configMeterSlugs := lo.Map(configMeters, func(meter *meter.Meter, _ int) string { + return meter.Key + }) + + meterList, err := meterService.ListMeters(ctx, meter.ListMetersParams{ + Namespace: namespaceManager.GetDefaultNamespace(), + Key: &filter.FilterString{In: &configMeterSlugs}, + }) + if err != nil { + return fmt.Errorf("failed to list meters: %w", err) + } + + metersBySlug := lo.KeyBy(meterList.Items, func(meter meter.Meter) string { + return meter.Key + }) + + for _, configMeter := range configMeters { + configMeter.Namespace = namespaceManager.GetDefaultNamespace() + + // Backfill the name if it's missing + if configMeter.Name == "" { + configMeter.Name = configMeter.Key + } + + // Create the meter if it doesn't exist + if dbMeter, ok := metersBySlug[configMeter.Key]; !ok { + _, err := meterService.CreateMeter(ctx, meter.CreateMeterInput{ + Namespace: configMeter.Namespace, + Key: configMeter.Key, + Name: configMeter.Name, + Description: configMeter.Description, + Aggregation: configMeter.Aggregation, + EventType: configMeter.EventType, + ValueProperty: configMeter.ValueProperty, + GroupBy: configMeter.GroupBy, + }) + if err != nil { + return fmt.Errorf("failed to create meter: %w", err) + } + + logger.InfoContext(ctx, "created meter in database", "meter", configMeter.Key) + } else { + if err := dbMeter.Equal(*configMeter); err != nil { + return fmt.Errorf("meter %s in database is not equal to the meter in config: %w", dbMeter.Key, err) + } + + logger.InfoContext(ctx, "meter in config already exists in database", "meter", configMeter.Key) + } + } + + return nil +} diff --git a/app/common/meterevent.go b/app/common/meterevent.go new file mode 100644 index 0000000000000000000000000000000000000000..bbe0416e620ea418423b023cb5385a76591f4483 --- /dev/null +++ b/app/common/meterevent.go @@ -0,0 +1,23 @@ +package common + +import ( + "github.com/google/wire" + + "github.com/openmeterio/openmeter/openmeter/customer" + "github.com/openmeterio/openmeter/openmeter/meter" + "github.com/openmeterio/openmeter/openmeter/meterevent" + "github.com/openmeterio/openmeter/openmeter/meterevent/adapter" + "github.com/openmeterio/openmeter/openmeter/streaming" +) + +var MeterEvent = wire.NewSet( + NewMeterEventService, +) + +func NewMeterEventService( + streamingConnector streaming.Connector, + customerService customer.Service, + meterService meter.Service, +) meterevent.Service { + return adapter.New(streamingConnector, customerService, meterService) +} diff --git a/app/common/namespace.go b/app/common/namespace.go new file mode 100644 index 0000000000000000000000000000000000000000..0f59c5a7c304bbd74bff40e021ad0e31fb5a5a2a --- /dev/null +++ b/app/common/namespace.go @@ -0,0 +1,39 @@ +package common + +import ( + "fmt" + + "github.com/google/wire" + + "github.com/openmeterio/openmeter/app/config" + "github.com/openmeterio/openmeter/openmeter/namespace" + "github.com/openmeterio/openmeter/openmeter/namespace/namespacedriver" +) + +var Namespace = wire.NewSet( + NewNamespaceManager, +) + +func NewNamespaceManager( + conf config.NamespaceConfiguration, +) (*namespace.Manager, error) { + manager, err := namespace.NewManager(namespace.ManagerConfig{ + DefaultNamespace: conf.Default, + DisableManagement: conf.DisableManagement, + }) + if err != nil { + return nil, fmt.Errorf("create namespace manager: %v", err) + } + + return manager, nil +} + +var StaticNamespace = wire.NewSet( + NewStaticNamespaceDecoder, +) + +func NewStaticNamespaceDecoder( + conf config.NamespaceConfiguration, +) namespacedriver.NamespaceDecoder { + return namespacedriver.StaticNamespaceDecoder(conf.Default) +} diff --git a/app/common/notification.go b/app/common/notification.go new file mode 100644 index 0000000000000000000000000000000000000000..2ef49bcea38336c41708068b873b461eeb719877 --- /dev/null +++ b/app/common/notification.go @@ -0,0 +1,144 @@ +package common + +import ( + "fmt" + "log/slog" + "math/rand/v2" + + "github.com/google/wire" + svix "github.com/svix/svix-webhooks/go" + "go.opentelemetry.io/otel/trace" + + "github.com/openmeterio/openmeter/app/config" + entdb "github.com/openmeterio/openmeter/openmeter/ent/db" + "github.com/openmeterio/openmeter/openmeter/notification" + notificationadapter "github.com/openmeterio/openmeter/openmeter/notification/adapter" + "github.com/openmeterio/openmeter/openmeter/notification/eventhandler" + notificationservice "github.com/openmeterio/openmeter/openmeter/notification/service" + notificationwebhook "github.com/openmeterio/openmeter/openmeter/notification/webhook" + webhooknoop "github.com/openmeterio/openmeter/openmeter/notification/webhook/noop" + webhooksvix "github.com/openmeterio/openmeter/openmeter/notification/webhook/svix" + "github.com/openmeterio/openmeter/openmeter/productcatalog/feature" + "github.com/openmeterio/openmeter/pkg/framework/pgdriver" + "github.com/openmeterio/openmeter/pkg/pglockx" +) + +var Notification = wire.NewSet( + NewNotificationAdapter, + NewNotificationService, + NewNotificationWebhookHandler, + NewNotificationEventHandler, +) + +// NotificationService is a wire set for the notification service, it can be used at +// places where only the service is required without svix and event handler. +var NotificationService = wire.NewSet( + NewNotificationAdapter, + NewNotificationService, + NewNoopNotificationWebhookHandler, +) + +func NewNotificationAdapter( + logger *slog.Logger, + db *entdb.Client, +) (notification.Repository, error) { + adapter, err := notificationadapter.New(notificationadapter.Config{ + Client: db, + Logger: logger, + }) + if err != nil { + return nil, fmt.Errorf("failed to initialize notification adapter: %w", err) + } + + return adapter, nil +} + +func NewNotificationEventHandler( + config config.NotificationConfiguration, + logger *slog.Logger, + tracer trace.Tracer, + adapter notification.Repository, + webhook notificationwebhook.Handler, + driver *pgdriver.Driver, +) (notification.EventHandler, error) { + config.Lock.Owner = fmt.Sprintf("notification.event_handler-%v", rand.Int()) + config.Lock.HeartbeatInterval = max(pglockx.DefaultHeartbeatInterval, config.Lock.HeartbeatInterval) + config.Lock.LeaseTime = max(config.Lock.HeartbeatInterval*2, config.Lock.LeaseTime) + + logger.Debug("initializing notification lock client", + "lock.leaseTime", config.Lock.LeaseTime, "lock.heartbeatInterval", config.Lock.HeartbeatInterval, "lock.owner", config.Lock.Owner) + + lockClient, err := pglockx.New(driver.DB(), config.Lock) + if err != nil { + return nil, fmt.Errorf("failed to initialize notification lock client: %w", err) + } + + eventHandler, err := eventhandler.New(eventhandler.Config{ + Repository: adapter, + Webhook: webhook, + Logger: logger, + Tracer: tracer, + ReconcileInterval: config.ReconcileInterval, + SendingTimeout: config.SendingTimeout, + PendingTimeout: config.PendingTimeout, + ReconcilerWorkers: config.ReconcilerWorkers, + LockClient: lockClient, + }) + if err != nil { + return nil, fmt.Errorf("failed to initialize notification event handler: %w", err) + } + + return eventHandler, nil +} + +func NewNotificationService( + logger *slog.Logger, + adapter notification.Repository, + webhook notificationwebhook.Handler, + featureConnector feature.FeatureConnector, +) (notification.Service, error) { + notificationService, err := notificationservice.New(notificationservice.Config{ + Adapter: adapter, + Webhook: webhook, + FeatureConnector: featureConnector, + Logger: logger.With(slog.String("subsystem", "notification")), + }) + if err != nil { + return nil, fmt.Errorf("failed to initialize notification service: %w", err) + } + + return notificationService, nil +} + +func NewNoopNotificationWebhookHandler( + logger *slog.Logger, +) (notificationwebhook.Handler, error) { + return webhooknoop.New(logger), nil +} + +func NewNotificationWebhookHandler( + logger *slog.Logger, + tracer trace.Tracer, + webhookConfig config.WebhookConfiguration, + svixClient *svix.Svix, +) (notificationwebhook.Handler, error) { + if svixClient == nil { + logger.Warn("svix client not configured, using noop handler") + + return webhooknoop.New(logger), nil + } + + handler, err := webhooksvix.New(webhooksvix.Config{ + SvixAPIClient: svixClient, + RegisterEventTypes: notificationwebhook.NotificationEventTypes, + RegistrationTimeout: webhookConfig.EventTypeRegistrationTimeout, + SkipRegistrationOnError: webhookConfig.SkipEventTypeRegistrationOnError, + Logger: logger.WithGroup("notification.webhook"), + Tracer: tracer, + }) + if err != nil { + return nil, fmt.Errorf("failed to initialize notification webhook handler: %w", err) + } + + return handler, nil +} diff --git a/app/common/openmeter_balanceworker.go b/app/common/openmeter_balanceworker.go new file mode 100644 index 0000000000000000000000000000000000000000..a1323e5170ff5de5b3a81e1acb275dfc54105d65 --- /dev/null +++ b/app/common/openmeter_balanceworker.go @@ -0,0 +1,144 @@ +package common + +import ( + "context" + "fmt" + "log/slog" + "syscall" + + "github.com/ThreeDotsLabs/watermill/message" + "github.com/google/wire" + "github.com/oklog/run" + + "github.com/openmeterio/openmeter/app/config" + "github.com/openmeterio/openmeter/openmeter/customer" + "github.com/openmeterio/openmeter/openmeter/ent/db" + "github.com/openmeterio/openmeter/openmeter/entitlement" + entitlementadapter "github.com/openmeterio/openmeter/openmeter/entitlement/adapter" + "github.com/openmeterio/openmeter/openmeter/entitlement/balanceworker" + "github.com/openmeterio/openmeter/openmeter/notification" + "github.com/openmeterio/openmeter/openmeter/registry" + "github.com/openmeterio/openmeter/openmeter/subject" + watermillkafka "github.com/openmeterio/openmeter/openmeter/watermill/driver/kafka" + "github.com/openmeterio/openmeter/openmeter/watermill/eventbus" + "github.com/openmeterio/openmeter/openmeter/watermill/router" + pkgkafka "github.com/openmeterio/openmeter/pkg/kafka" +) + +var BalanceWorker = wire.NewSet( + BalanceWorkerProvisionTopics, + BalanceWorkerSubscriber, + NewEntitlementRegistry, + NewBalanceWorkerOptions, + NewBalanceWorker, + BalanceWorkerGroup, +) + +var BalanceWorkerAdapter = wire.NewSet( + NewBalanceWorkerEntitlementRepo, + + wire.Bind(new(balanceworker.BalanceWorkerRepository), new(BalanceWorkerEntitlementRepo)), +) + +type BalanceWorkerEntitlementRepo interface { + entitlement.EntitlementRepo + balanceworker.BalanceWorkerRepository +} + +func NewBalanceWorkerEntitlementRepo(db *db.Client) BalanceWorkerEntitlementRepo { + return entitlementadapter.NewPostgresEntitlementRepo(db) +} + +func BalanceWorkerProvisionTopics(conf config.BalanceWorkerConfiguration, eventsConfig config.EventsConfiguration) []pkgkafka.TopicConfig { + var provisionTopics []pkgkafka.TopicConfig + + if conf.DLQ.AutoProvision.Enabled { + provisionTopics = append(provisionTopics, pkgkafka.TopicConfig{ + Name: conf.DLQ.Topic, + Partitions: conf.DLQ.AutoProvision.Partitions, + RetentionTime: pkgkafka.TimeDurationMilliSeconds(conf.DLQ.AutoProvision.Retention), + }) + } + + if eventsConfig.BalanceWorkerEvents.AutoProvision.Enabled { + provisionTopics = append(provisionTopics, pkgkafka.TopicConfig{ + Name: eventsConfig.BalanceWorkerEvents.Topic, + Partitions: eventsConfig.BalanceWorkerEvents.AutoProvision.Partitions, + }) + } + + return provisionTopics +} + +// no closer function: the subscriber is closed by the router/worker +func BalanceWorkerSubscriber(conf config.BalanceWorkerConfiguration, brokerOptions watermillkafka.BrokerOptions) (message.Subscriber, error) { + subscriber, err := watermillkafka.NewSubscriber(watermillkafka.SubscriberOptions{ + Broker: brokerOptions, + ConsumerGroupName: conf.ConsumerGroupName, + }) + if err != nil { + return nil, fmt.Errorf("failed to initialize Kafka subscriber: %w", err) + } + + return subscriber, nil +} + +func NewBalanceWorkerOptions( + eventConfig config.EventsConfiguration, + routerOptions router.Options, + eventBus eventbus.Publisher, + entitlements *registry.Entitlement, + repo balanceworker.BalanceWorkerRepository, + notificationService notification.Service, + subjectService subject.Service, + customerService customer.Service, + logger *slog.Logger, + balanceWorkerConfiguration config.BalanceWorkerConfiguration, +) balanceworker.WorkerOptions { + return balanceworker.WorkerOptions{ + SystemEventsTopic: eventConfig.SystemEvents.Topic, + IngestEventsTopic: eventConfig.IngestEvents.Topic, + BalanceWorkerEventsTopic: eventConfig.BalanceWorkerEvents.Topic, + Router: routerOptions, + EventBus: eventBus, + Entitlement: entitlements, + Repo: repo, + Subject: subjectService, + Customer: customerService, + Logger: logger, + MetricMeter: routerOptions.MetricMeter, + NotificationService: notificationService, + HighWatermarkCacheSize: balanceWorkerConfiguration.StateStorage.HighWatermarkCache.LRUCacheSize, + } +} + +func NewBalanceWorker(workerOptions balanceworker.WorkerOptions) (*balanceworker.Worker, error) { + worker, err := balanceworker.New(workerOptions) + if err != nil { + return nil, fmt.Errorf("failed to initialize worker: %w", err) + } + + return worker, nil +} + +func BalanceWorkerGroup( + ctx context.Context, + worker *balanceworker.Worker, + telemetryServer TelemetryServer, +) run.Group { + var group run.Group + + group.Add( + func() error { return telemetryServer.ListenAndServe() }, + func(err error) { _ = telemetryServer.Shutdown(ctx) }, + ) + + group.Add( + func() error { return worker.Run(ctx) }, + func(err error) { _ = worker.Close() }, + ) + + group.Add(run.SignalHandler(ctx, syscall.SIGINT, syscall.SIGTERM)) + + return group +} diff --git a/app/common/openmeter_billingworker.go b/app/common/openmeter_billingworker.go new file mode 100644 index 0000000000000000000000000000000000000000..18c4e71db46b10c7b014666574753d9c349c80ff --- /dev/null +++ b/app/common/openmeter_billingworker.go @@ -0,0 +1,171 @@ +package common + +import ( + "context" + "fmt" + "log/slog" + "syscall" + "time" + + "github.com/ThreeDotsLabs/watermill/message" + "github.com/google/wire" + "github.com/oklog/run" + otelmetric "go.opentelemetry.io/otel/metric" + + "github.com/openmeterio/openmeter/app/config" + "github.com/openmeterio/openmeter/openmeter/billing" + billingworker "github.com/openmeterio/openmeter/openmeter/billing/worker" + billingworkercollect "github.com/openmeterio/openmeter/openmeter/billing/worker/collect" + "github.com/openmeterio/openmeter/openmeter/billing/worker/invoicemetrics" + billingworkermetricsadapter "github.com/openmeterio/openmeter/openmeter/billing/worker/invoicemetrics/adapter" + billingworkermetricsservice "github.com/openmeterio/openmeter/openmeter/billing/worker/invoicemetrics/service" + "github.com/openmeterio/openmeter/openmeter/billing/worker/subscriptionsync" + entdb "github.com/openmeterio/openmeter/openmeter/ent/db" + watermillkafka "github.com/openmeterio/openmeter/openmeter/watermill/driver/kafka" + "github.com/openmeterio/openmeter/openmeter/watermill/eventbus" + "github.com/openmeterio/openmeter/openmeter/watermill/router" + pkgkafka "github.com/openmeterio/openmeter/pkg/kafka" +) + +var BillingWorker = wire.NewSet( + App, + Customer, + Secret, + + BillingWorkerProvisionTopics, + BillingWorkerSubscriber, + + Lockr, + FFX, + + Subscription, + ProductCatalog, + Entitlement, + Billing, + LedgerStack, + + NewBillingWorkerOptions, + NewBillingWorker, + NewBillingCollector, + NewBillingWorkerInvoiceMetricsAdapter, + NewBillingWorkerInvoiceMetricsService, + NewBillingSubscriptionSyncAdapter, + NewBillingSubscriptionSyncService, + BillingWorkerGroup, +) + +func BillingWorkerProvisionTopics(conf config.BillingConfiguration) []pkgkafka.TopicConfig { + var provisionTopics []pkgkafka.TopicConfig + + if conf.Worker.DLQ.AutoProvision.Enabled { + provisionTopics = append(provisionTopics, pkgkafka.TopicConfig{ + Name: conf.Worker.DLQ.Topic, + Partitions: conf.Worker.DLQ.AutoProvision.Partitions, + RetentionTime: pkgkafka.TimeDurationMilliSeconds(conf.Worker.DLQ.AutoProvision.Retention), + }) + } + + return provisionTopics +} + +// no closer function: the subscriber is closed by the router/worker +func BillingWorkerSubscriber(conf config.BillingConfiguration, brokerOptions watermillkafka.BrokerOptions) (message.Subscriber, error) { + subscriber, err := watermillkafka.NewSubscriber(watermillkafka.SubscriberOptions{ + Broker: brokerOptions, + ConsumerGroupName: conf.Worker.ConsumerGroupName, + }) + if err != nil { + return nil, fmt.Errorf("failed to initialize Kafka subscriber: %w", err) + } + + return subscriber, nil +} + +func NewBillingWorkerOptions( + eventConfig config.EventsConfiguration, + routerOptions router.Options, + eventBus eventbus.Publisher, + billingRegistry BillingRegistry, + subscriptionServices SubscriptionServiceWithWorkflow, + subscriptionSyncService subscriptionsync.Service, + billingCollector *billingworkercollect.InvoiceCollector, + billingFsConfig config.BillingFeatureSwitchesConfiguration, + logger *slog.Logger, +) billingworker.WorkerOptions { + return billingworker.WorkerOptions{ + SystemEventsTopic: eventConfig.SystemEvents.Topic, + + Router: routerOptions, + EventBus: eventBus, + BillingService: billingRegistry.Billing, + BillingCollector: billingCollector, + ChargesService: billingRegistry.ChargesServiceOrNil(), + SubscriptionService: subscriptionServices.Service, + BillingSubscriptionSync: subscriptionSyncService, + Logger: logger, + + // Feature switches + LockdownNamespaces: billingFsConfig.NamespaceLockdown, + } +} + +func NewBillingWorker(workerOptions billingworker.WorkerOptions) (*billingworker.Worker, error) { + worker, err := billingworker.New(workerOptions) + if err != nil { + return nil, fmt.Errorf("failed to initialize worker: %w", err) + } + + return worker, nil +} + +func NewBillingWorkerInvoiceMetricsAdapter(db *entdb.Client, billingAdapter billing.Adapter) (invoicemetrics.Adapter, error) { + return billingworkermetricsadapter.New(billingworkermetricsadapter.Config{ + Client: db, + BillingAdapter: billingAdapter, + }) +} + +func NewBillingWorkerInvoiceMetricsService( + adapter invoicemetrics.Adapter, + meter otelmetric.Meter, + logger *slog.Logger, + billingFsConfig config.BillingFeatureSwitchesConfiguration, +) (invoicemetrics.Service, error) { + return billingworkermetricsservice.New(billingworkermetricsservice.Config{ + Adapter: adapter, + Meter: meter, + Logger: logger, + ReportInterval: time.Minute, + OverdueThreshold: 10 * time.Minute, + QueryTimeout: 30 * time.Second, + ExcludedNamespaces: billingFsConfig.NamespaceLockdown, + }) +} + +func BillingWorkerGroup( + ctx context.Context, + worker *billingworker.Worker, + invoiceMetrics invoicemetrics.Service, + telemetryServer TelemetryServer, +) run.Group { + var group run.Group + + group.Add( + func() error { return telemetryServer.ListenAndServe() }, + func(err error) { _ = telemetryServer.Shutdown(ctx) }, + ) + + group.Add( + func() error { return worker.Run(ctx) }, + func(err error) { _ = worker.Close() }, + ) + + group.Add( + func() error { return invoiceMetrics.Start(ctx) }, + func(err error) { invoiceMetrics.Stop() }, + ) + + group.Add(run.SignalHandler(ctx, syscall.SIGINT, syscall.SIGTERM)) + + return group +} diff --git a/app/common/openmeter_notification.go b/app/common/openmeter_notification.go new file mode 100644 index 0000000000000000000000000000000000000000..09026bf3c35e0981e51ebc4151f025c13dfc5635 --- /dev/null +++ b/app/common/openmeter_notification.go @@ -0,0 +1,20 @@ +package common + +import ( + "github.com/openmeterio/openmeter/app/config" + pkgkafka "github.com/openmeterio/openmeter/pkg/kafka" +) + +func NotificationServiceProvisionTopics(conf config.NotificationConfiguration) []pkgkafka.TopicConfig { + var provisionTopics []pkgkafka.TopicConfig + + if conf.Consumer.DLQ.AutoProvision.Enabled { + provisionTopics = append(provisionTopics, pkgkafka.TopicConfig{ + Name: conf.Consumer.DLQ.Topic, + Partitions: conf.Consumer.DLQ.AutoProvision.Partitions, + RetentionTime: pkgkafka.TimeDurationMilliSeconds(conf.Consumer.DLQ.AutoProvision.Retention), + }) + } + + return provisionTopics +} diff --git a/app/common/openmeter_server.go b/app/common/openmeter_server.go new file mode 100644 index 0000000000000000000000000000000000000000..2f54f57d8386221dad56eb1cc519934069fa89e9 --- /dev/null +++ b/app/common/openmeter_server.go @@ -0,0 +1,115 @@ +package common + +import ( + "context" + "fmt" + "log/slog" + + "github.com/ThreeDotsLabs/watermill/message" + "github.com/confluentinc/confluent-kafka-go/v2/kafka" + "go.opentelemetry.io/otel/metric" + "go.opentelemetry.io/otel/trace" + + "github.com/openmeterio/openmeter/app/config" + "github.com/openmeterio/openmeter/openmeter/ingest" + "github.com/openmeterio/openmeter/openmeter/ingest/ingestadapter" + "github.com/openmeterio/openmeter/openmeter/ingest/kafkaingest" + "github.com/openmeterio/openmeter/openmeter/ingest/kafkaingest/serializer" + "github.com/openmeterio/openmeter/openmeter/ingest/kafkaingest/topicresolver" + watermillkafka "github.com/openmeterio/openmeter/openmeter/watermill/driver/kafka" + pkgkafka "github.com/openmeterio/openmeter/pkg/kafka" +) + +func NewKafkaIngestCollector( + config config.KafkaIngestConfiguration, + producer *kafka.Producer, + topicResolver topicresolver.Resolver, + topicProvisioner pkgkafka.TopicProvisioner, + logger *slog.Logger, + tracer trace.Tracer, +) (*kafkaingest.Collector, error) { + collector, err := kafkaingest.NewCollector( + producer, + serializer.NewJSONSerializer(), + topicResolver, + topicProvisioner, + config.Partitions, + logger, + tracer, + ) + if err != nil { + return nil, fmt.Errorf("failed to initialize kafka ingest: %w", err) + } + + return collector, nil +} + +func NewIngestCollector( + dedupeConfig config.DedupeConfiguration, + kafkaCollector *kafkaingest.Collector, + logger *slog.Logger, + meter metric.Meter, + tracer trace.Tracer, +) (ingest.Collector, func(), error) { + collector, err := ingestadapter.WithTelemetry(kafkaCollector, meter, tracer) + if err != nil { + return nil, nil, fmt.Errorf("init kafka ingest: %w", err) + } + + if dedupeConfig.Enabled { + deduplicator, err := dedupeConfig.NewDeduplicator() + if err != nil { + return nil, nil, fmt.Errorf("failed to initialize deduplicator: %w", err) + } + + return ingest.DeduplicatingCollector{ + Collector: collector, + Deduplicator: deduplicator, + }, func() { + collector.Close() + + logger.Info("closing deduplicator") + + err := deduplicator.Close() + if err != nil { + logger.Error("failed to close deduplicator", "error", err) + } + }, nil + } + + // Note: closing function is called by dedupe as well + return collector, func() { collector.Close() }, nil +} + +// TODO: create a separate file or package for each application instead + +func NewServerPublisher( + ctx context.Context, + options watermillkafka.PublisherOptions, + logger *slog.Logger, +) (message.Publisher, func(), error) { + return NewPublisher(ctx, options, logger) +} + +func ServerProvisionTopics(conf config.EventsConfiguration) []pkgkafka.TopicConfig { + var provisionTopics []pkgkafka.TopicConfig + + if conf.SystemEvents.AutoProvision.Enabled { + provisionTopics = append(provisionTopics, pkgkafka.TopicConfig{ + Name: conf.SystemEvents.Topic, + Partitions: conf.SystemEvents.AutoProvision.Partitions, + }) + } + + return provisionTopics +} + +func NewIngestService( + collector ingest.Collector, + logger *slog.Logger, +) (ingest.Service, error) { + return ingest.NewService(ingest.Config{ + Collector: collector, + Logger: logger, + }) +} diff --git a/app/common/openmeter_sinkworker.go b/app/common/openmeter_sinkworker.go new file mode 100644 index 0000000000000000000000000000000000000000..2046506697e2d57f3ded800318a0517df7055a93 --- /dev/null +++ b/app/common/openmeter_sinkworker.go @@ -0,0 +1,212 @@ +package common + +import ( + "context" + "fmt" + "log/slog" + + "github.com/ThreeDotsLabs/watermill/message" + "github.com/confluentinc/confluent-kafka-go/v2/kafka" + "github.com/google/wire" + "go.opentelemetry.io/otel/metric" + "go.opentelemetry.io/otel/trace" + + "github.com/openmeterio/openmeter/app/config" + "github.com/openmeterio/openmeter/openmeter/dedupe" + "github.com/openmeterio/openmeter/openmeter/ingest/kafkaingest/topicresolver" + "github.com/openmeterio/openmeter/openmeter/meter" + "github.com/openmeterio/openmeter/openmeter/sink" + "github.com/openmeterio/openmeter/openmeter/sink/flushhandler" + "github.com/openmeterio/openmeter/openmeter/sink/flushhandler/ingestnotification" + "github.com/openmeterio/openmeter/openmeter/streaming" + watermillkafka "github.com/openmeterio/openmeter/openmeter/watermill/driver/kafka" + "github.com/openmeterio/openmeter/openmeter/watermill/eventbus" + pkgkafka "github.com/openmeterio/openmeter/pkg/kafka" +) + +var Sink = wire.NewSet( + NewIngestNotificationHandler, + NewFlushHandlers, + NewFlushHandlerManager, + NewSinkWorkerPublisher, + NewSinkKafkaConsumer, + NewSinkDeduplicator, + NewSinkStorage, + NewSink, +) + +// the sink-worker requires control over how the publisher is closed +func NewSinkWorkerPublisher( + ctx context.Context, + options watermillkafka.PublisherOptions, + logger *slog.Logger, +) (message.Publisher, func(), error) { + publisher, _, err := NewPublisher(ctx, options, logger) + + return publisher, func() {}, err +} + +type IngestNotificationHandler flushhandler.FlushEventHandler + +func NewIngestNotificationHandler( + sinkConfig config.SinkConfiguration, + eventPublisher eventbus.Publisher, + meter metric.Meter, + logger *slog.Logger, +) (IngestNotificationHandler, error) { + handler, err := ingestnotification.NewHandler(logger, meter, eventPublisher, ingestnotification.HandlerConfig{ + MaxEventsInBatch: sinkConfig.IngestNotifications.MaxEventsInBatch, + }) + if err != nil { + return nil, fmt.Errorf("failed to initialize ingest notification handler: %w", err) + } + + return handler, nil +} + +func NewFlushHandlers( + ingest IngestNotificationHandler, +) []flushhandler.FlushEventHandler { + return []flushhandler.FlushEventHandler{ingest} +} + +func NewFlushHandlerManager( + sinkConfig config.SinkConfiguration, + messagePublisher message.Publisher, + logger *slog.Logger, + handlers []flushhandler.FlushEventHandler, +) (flushhandler.FlushEventHandler, func(), error) { + flushHandlerMux := flushhandler.NewFlushEventHandlers() + + // We should only close the producer once the ingest events are fully processed + flushHandlerMux.OnDrainComplete(func() { + logger.Info("shutting down kafka producer") + if err := messagePublisher.Close(); err != nil { + logger.Error("failed to close kafka producer", slog.Any("error", err)) + } + }) + + for _, handler := range handlers { + if handler != nil { + flushHandlerMux.AddHandler(handler) + } + } + + closer := func() { + logger.Info("shutting down flush success handlers") + + if err := flushHandlerMux.Close(); err != nil { + logger.Error("failed to close flush success handler", slog.Any("error", err)) + } + + drainCtx, cancel := context.WithTimeout(context.Background(), sinkConfig.DrainTimeout) + defer cancel() + + if err := flushHandlerMux.WaitForDrain(drainCtx); err != nil { + logger.Error("failed to drain flush success handlers", slog.String("err", err.Error())) + } + } + + return flushHandlerMux, closer, nil +} + +func SinkWorkerProvisionTopics(conf config.EventsConfiguration) []pkgkafka.TopicConfig { + return []pkgkafka.TopicConfig{ + { + Name: conf.IngestEvents.Topic, + Partitions: conf.IngestEvents.AutoProvision.Partitions, + }, + } +} + +func NewSinkStorage( + streaming streaming.Connector, +) (sink.Storage, error) { + return sink.NewClickhouseStorage(sink.ClickHouseStorageConfig{ + Streaming: streaming, + }) +} + +func NewSinkDeduplicator(conf config.SinkConfiguration, logger *slog.Logger) (dedupe.Deduplicator, func(), error) { + closer := func() {} + + if conf.Dedupe.Enabled { + deduplicator, err := conf.Dedupe.NewDeduplicator() + if err != nil { + return nil, nil, fmt.Errorf("failed to initialize deduplicator: %w", err) + } + + closer = func() { + err = deduplicator.Close() + if err != nil { + logger.Error("failed to close sink deduplicator", slog.String("err", err.Error())) + } + } + + return deduplicator, closer, nil + } + + return nil, closer, nil +} + +type SinkKafkaConsumer = kafka.Consumer + +func NewSinkKafkaConsumer(conf config.SinkConfiguration, logger *slog.Logger) (*kafka.Consumer, func(), error) { + consumerConfig := conf.Kafka.AsConsumerConfig() + + // Override the following Kafka consumer configuration parameters with hardcoded values + // as the Sink implementation relies on these to be set to a specific value. + consumerConfig.EnableAutoCommit = true + consumerConfig.EnableAutoOffsetStore = false + // Used when offset retention resets the offset. In this case we want to consume from the latest offset + // as everything before should be already processed. + consumerConfig.AutoOffsetReset = pkgkafka.AutoOffsetResetLatest + + return NewKafkaConsumer(consumerConfig, logger) +} + +func NewSink( + conf config.SinkConfiguration, + logger *slog.Logger, + metricMeter metric.Meter, + tracer trace.Tracer, + kafkaConsumer *SinkKafkaConsumer, + sinkStorage sink.Storage, + deduplicator dedupe.Deduplicator, + meterService meter.Service, + topicResolver topicresolver.Resolver, + flushHandler flushhandler.FlushEventHandler, +) (*sink.Sink, func(), error) { + s, err := sink.NewSink(sink.SinkConfig{ + Logger: logger, + Tracer: tracer, + MetricMeter: metricMeter, + Storage: sinkStorage, + Deduplicator: deduplicator, + Consumer: kafkaConsumer, + MinCommitCount: conf.MinCommitCount, + MaxCommitWait: conf.MaxCommitWait, + MaxPollTimeout: conf.MaxPollTimeout, + NamespaceRefetch: conf.NamespaceRefetch, + NamespaceRefetchTimeout: conf.NamespaceRefetchTimeout, + NamespaceTopicRegexp: conf.NamespaceTopicRegexp, + FlushEventHandler: flushHandler, + FlushSuccessTimeout: conf.FlushSuccessTimeout, + DrainTimeout: conf.DrainTimeout, + TopicResolver: topicResolver, + MeterRefetchInterval: conf.MeterRefetchInterval, + MeterService: meterService, + LogDroppedEvents: conf.LogDroppedEvents, + }) + if err != nil { + return nil, nil, fmt.Errorf("failed to initialize sink: %w", err) + } + + closer := func() { + if err = s.Close(); err != nil { + logger.Error("failed to close sink worker", slog.String("err", err.Error())) + } + } + + return s, closer, nil +} diff --git a/app/common/portal.go b/app/common/portal.go new file mode 100644 index 0000000000000000000000000000000000000000..8e2ea89fd535adbde34c1ff7d137d8c0887084c3 --- /dev/null +++ b/app/common/portal.go @@ -0,0 +1,31 @@ +package common + +import ( + "fmt" + + "github.com/google/wire" + + "github.com/openmeterio/openmeter/app/config" + "github.com/openmeterio/openmeter/openmeter/portal" + portaladapter "github.com/openmeterio/openmeter/openmeter/portal/adapter" +) + +var Portal = wire.NewSet( + NewPortalService, +) + +func NewPortalService(conf config.PortalConfiguration) (portal.Service, error) { + if !conf.Enabled { + return portaladapter.NewNoop(), nil + } + + p, err := portaladapter.New(portaladapter.Config{ + Secret: conf.TokenSecret, + Expire: conf.TokenExpiration, + }) + if err != nil { + return nil, fmt.Errorf("failed to create portal adapter: %w", err) + } + + return p, nil +} diff --git a/app/common/productcatalog.go b/app/common/productcatalog.go new file mode 100644 index 0000000000000000000000000000000000000000..abf6ac1ef74e25b152d06fd18f46d1f9b32050ec --- /dev/null +++ b/app/common/productcatalog.go @@ -0,0 +1,157 @@ +package common + +import ( + "fmt" + "log/slog" + + "github.com/google/wire" + + "github.com/openmeterio/openmeter/openmeter/cost" + costadapter "github.com/openmeterio/openmeter/openmeter/cost/adapter" + costservice "github.com/openmeterio/openmeter/openmeter/cost/service" + entdb "github.com/openmeterio/openmeter/openmeter/ent/db" + "github.com/openmeterio/openmeter/openmeter/llmcost" + "github.com/openmeterio/openmeter/openmeter/meter" + "github.com/openmeterio/openmeter/openmeter/productcatalog" + productcatalogpgadapter "github.com/openmeterio/openmeter/openmeter/productcatalog/adapter" + "github.com/openmeterio/openmeter/openmeter/productcatalog/addon" + addonadapter "github.com/openmeterio/openmeter/openmeter/productcatalog/addon/adapter" + addonservice "github.com/openmeterio/openmeter/openmeter/productcatalog/addon/service" + "github.com/openmeterio/openmeter/openmeter/productcatalog/feature" + "github.com/openmeterio/openmeter/openmeter/productcatalog/featureresolver" + "github.com/openmeterio/openmeter/openmeter/productcatalog/plan" + planadapter "github.com/openmeterio/openmeter/openmeter/productcatalog/plan/adapter" + planservice "github.com/openmeterio/openmeter/openmeter/productcatalog/plan/service" + "github.com/openmeterio/openmeter/openmeter/productcatalog/planaddon" + planaddonadapter "github.com/openmeterio/openmeter/openmeter/productcatalog/planaddon/adapter" + planaddonservice "github.com/openmeterio/openmeter/openmeter/productcatalog/planaddon/service" + "github.com/openmeterio/openmeter/openmeter/streaming" + "github.com/openmeterio/openmeter/openmeter/taxcode" + "github.com/openmeterio/openmeter/openmeter/watermill/eventbus" +) + +var ProductCatalog = wire.NewSet( + Feature, + Cost, + Plan, + Addon, + PlanAddon, +) + +var Feature = wire.NewSet( + NewFeatureConnector, + NewFeatureResolver, +) + +var Cost = wire.NewSet( + NewCostService, +) + +var Plan = wire.NewSet( + NewPlanService, +) + +var Addon = wire.NewSet( + NewAddonService, +) + +var PlanAddon = wire.NewSet( + NewPlanAddonService, +) + +func NewFeatureConnector( + logger *slog.Logger, + db *entdb.Client, + meterService meter.Service, + publisher eventbus.Publisher, +) feature.FeatureConnector { + featureRepo := productcatalogpgadapter.NewPostgresFeatureRepo(db, logger) + return feature.NewFeatureConnector(featureRepo, meterService, publisher) +} + +var NewFeatureResolver = featureresolver.New + +func NewCostService( + featureConnector feature.FeatureConnector, + meterService meter.Service, + streamingConnector streaming.Connector, + llmcostService llmcost.Service, +) (cost.Service, error) { + adapter := costadapter.New(featureConnector, meterService, streamingConnector, llmcostService) + + return costservice.New(costservice.Config{ + Adapter: adapter, + }) +} + +func NewPlanService( + logger *slog.Logger, + db *entdb.Client, + featureResolver productcatalog.FeatureResolver, + taxCodeService taxcode.Service, + publisher eventbus.Publisher, +) (plan.Service, error) { + adapter, err := planadapter.New(planadapter.Config{ + Client: db, + Logger: logger.With("subsystem", "productcatalog.plan"), + }) + if err != nil { + return nil, fmt.Errorf("failed to initialize plan adapter: %w", err) + } + + return planservice.New(planservice.Config{ + Adapter: adapter, + FeatureResolver: featureResolver, + TaxCode: taxCodeService, + Logger: logger.With("subsystem", "productcatalog.plan"), + Publisher: publisher, + }) +} + +func NewAddonService( + logger *slog.Logger, + db *entdb.Client, + featureResolver productcatalog.FeatureResolver, + taxCodeService taxcode.Service, + publisher eventbus.Publisher, +) (addon.Service, error) { + adapter, err := addonadapter.New(addonadapter.Config{ + Client: db, + Logger: logger.With("subsystem", "productcatalog.addon"), + }) + if err != nil { + return nil, fmt.Errorf("failed to initialize add-on adapter: %w", err) + } + + return addonservice.New(addonservice.Config{ + Adapter: adapter, + FeatureResolver: featureResolver, + TaxCode: taxCodeService, + Logger: logger.With("subsystem", "productcatalog.addon"), + Publisher: publisher, + }) +} + +func NewPlanAddonService( + logger *slog.Logger, + db *entdb.Client, + planService plan.Service, + addonService addon.Service, + publisher eventbus.Publisher, +) (planaddon.Service, error) { + adapter, err := planaddonadapter.New(planaddonadapter.Config{ + Client: db, + Logger: logger.With("subsystem", "productcatalog.planaddon"), + }) + if err != nil { + return nil, fmt.Errorf("failed to initialize add-on adapter: %w", err) + } + + return planaddonservice.New(planaddonservice.Config{ + Adapter: adapter, + Plan: planService, + Addon: addonService, + Logger: logger.With("subsystem", "productcatalog.addon"), + Publisher: publisher, + }) +} diff --git a/app/common/progressmanager.go b/app/common/progressmanager.go new file mode 100644 index 0000000000000000000000000000000000000000..8a46fea05a3496d10eb4ffb8f3f30a65301664f6 --- /dev/null +++ b/app/common/progressmanager.go @@ -0,0 +1,40 @@ +package common + +import ( + "fmt" + "log/slog" + + "github.com/google/wire" + + "github.com/openmeterio/openmeter/app/config" + "github.com/openmeterio/openmeter/openmeter/progressmanager" + "github.com/openmeterio/openmeter/openmeter/progressmanager/adapter" +) + +var ProgressManager = wire.NewSet( + NewProgressManager, +) + +// NewProgressManager creates a new progress manager service. +func NewProgressManager(logger *slog.Logger, conf config.ProgressManagerConfiguration) (progressmanager.Service, error) { + if !conf.Enabled { + return adapter.NewNoop(), nil + } + + redisClient, err := conf.Redis.NewClient() + if err != nil { + return nil, fmt.Errorf("progress manager redis: %w", err) + } + + pm, err := adapter.New(adapter.Config{ + Expiration: conf.Expiration, + KeyPrefix: conf.KeyPrefix, + Logger: logger, + Redis: redisClient, + }) + if err != nil { + return nil, fmt.Errorf("progress manager adapter: %w", err) + } + + return pm, nil +} diff --git a/app/common/runner.go b/app/common/runner.go new file mode 100644 index 0000000000000000000000000000000000000000..f94feb1fdfdfecb9fc1db156fd8e6aa482a9f27a --- /dev/null +++ b/app/common/runner.go @@ -0,0 +1,24 @@ +package common + +import ( + "errors" + "log/slog" + "net/http" + + "github.com/oklog/run" +) + +// Runner is a helper struct that runs a group of services. +type Runner struct { + Group run.Group + Logger *slog.Logger +} + +func (r Runner) Run() { + err := r.Group.Run(run.WithReverseShutdownOrder()) + if e := &(run.SignalError{}); errors.As(err, &e) { + r.Logger.Info("received signal: shutting down", slog.String("signal", e.Signal.String())) + } else if !errors.Is(err, http.ErrServerClosed) { + r.Logger.Error("application stopped due to error", slog.Any("error", err)) + } +} diff --git a/app/common/secret.go b/app/common/secret.go new file mode 100644 index 0000000000000000000000000000000000000000..327095a0cf11423269e987051757767e0c3ab436 --- /dev/null +++ b/app/common/secret.go @@ -0,0 +1,26 @@ +package common + +import ( + "log/slog" + + "github.com/google/wire" + + entdb "github.com/openmeterio/openmeter/openmeter/ent/db" + "github.com/openmeterio/openmeter/openmeter/secret" + secretadapter "github.com/openmeterio/openmeter/openmeter/secret/adapter" + secretservice "github.com/openmeterio/openmeter/openmeter/secret/service" +) + +var Secret = wire.NewSet( + wire.Bind(new(secret.Service), new(*secretservice.Service)), + + NewUnsafeSecretService, +) + +func NewUnsafeSecretService(logger *slog.Logger, db *entdb.Client) (*secretservice.Service, error) { + secretAdapter := secretadapter.New() + + return secretservice.New(secretservice.Config{ + Adapter: secretAdapter, + }) +} diff --git a/app/common/server.go b/app/common/server.go new file mode 100644 index 0000000000000000000000000000000000000000..734bdd298072e783d5f7fa80d7e8e6077d2db8d9 --- /dev/null +++ b/app/common/server.go @@ -0,0 +1,66 @@ +package common + +import ( + "fmt" + "net/http" + + "github.com/go-chi/chi/v5/middleware" + "github.com/google/wire" + + "github.com/openmeterio/openmeter/app/config" + "github.com/openmeterio/openmeter/openmeter/server" + pkgserver "github.com/openmeterio/openmeter/pkg/server" +) + +var Server = wire.NewSet( + NewTelemetryRouterHook, + NewFFXConfigContextMiddleware, + NewRouterHooks, + NewPostAuthMiddlewares, + NewClientIPMiddleware, +) + +func NewRouterHooks( + telemetry TelemetryMiddlewareHook, +) *server.RouterHooks { + return &server.RouterHooks{ + Middlewares: []server.MiddlewareHook{ + server.MiddlewareHook(telemetry), + }, + } +} + +func NewPostAuthMiddlewares( + ffx FFXConfigContextMiddleware, +) server.PostAuthMiddlewares { + return server.PostAuthMiddlewares{ + func(h http.Handler) http.Handler { + return ffx(h) + }, + } +} + +// ClientIPMiddleware is a defined type (not an alias) so the wire graph does not +// provide the ubiquitous pkgserver.MiddlewareFunc type directly. +type ClientIPMiddleware pkgserver.MiddlewareFunc + +func NewClientIPMiddleware(cfg config.ClientIPMiddlewareConfig) (ClientIPMiddleware, error) { + if err := cfg.Validate(); err != nil { + return nil, fmt.Errorf("invalid client ip middleware config: %w", err) + } + + switch cfg.Source { + case config.ClientIPSourceRemoteAddr: + return middleware.ClientIPFromRemoteAddr, nil + case config.ClientIPSourceHeader: + return middleware.ClientIPFromHeader(cfg.Header), nil + case config.ClientIPSourceXFF: + if len(cfg.TrustedIPPrefixes) > 0 { + return middleware.ClientIPFromXFF(cfg.TrustedIPPrefixes...), nil + } + + return middleware.ClientIPFromXFFTrustedProxies(cfg.TrustedProxies), nil + default: + return nil, fmt.Errorf("invalid client ip middleware source: %s", cfg.Source) + } +} diff --git a/app/common/server_test.go b/app/common/server_test.go new file mode 100644 index 0000000000000000000000000000000000000000..cdcbd4d4f51e676752e35c3c0d4d7c3b7f2483a1 --- /dev/null +++ b/app/common/server_test.go @@ -0,0 +1,100 @@ +package common + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5/middleware" + "github.com/stretchr/testify/require" + + "github.com/openmeterio/openmeter/app/config" +) + +// resolveClientIP drives a request through the middleware built from cfg and +// returns the client IP chi resolved for it. +func resolveClientIP(t *testing.T, cfg config.ClientIPMiddlewareConfig, mutate func(r *http.Request)) string { + t.Helper() + + mw, err := NewClientIPMiddleware(cfg) + require.NoError(t, err) + + var got string + + h := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = middleware.GetClientIP(r.Context()) + })) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + mutate(req) + h.ServeHTTP(httptest.NewRecorder(), req) + + return got +} + +func TestNewClientIPMiddleware(t *testing.T) { + t.Run("invalid config returns error", func(t *testing.T) { + _, err := NewClientIPMiddleware(config.ClientIPMiddlewareConfig{Source: "bogus"}) + require.ErrorContains(t, err, "invalid client ip middleware config") + }) + + t.Run("remote address source resolves the socket peer", func(t *testing.T) { + got := resolveClientIP(t, config.ClientIPMiddlewareConfig{ + Source: config.ClientIPSourceRemoteAddr, + }, func(r *http.Request) { + r.RemoteAddr = "203.0.113.7:1234" + r.Header.Set("X-Forwarded-For", "198.51.100.9") // must be ignored + }) + + require.Equal(t, "203.0.113.7", got) + }) + + t.Run("header source resolves the trusted header", func(t *testing.T) { + got := resolveClientIP(t, config.ClientIPMiddlewareConfig{ + Source: config.ClientIPSourceHeader, + Header: "X-Real-IP", + }, func(r *http.Request) { + r.RemoteAddr = "10.0.0.1:1234" + r.Header.Set("X-Real-IP", "203.0.113.7") + }) + + require.Equal(t, "203.0.113.7", got) + }) + + t.Run("xff source with trusted prefixes skips trusted hops", func(t *testing.T) { + got := resolveClientIP(t, config.ClientIPMiddlewareConfig{ + Source: config.ClientIPSourceXFF, + TrustedIPPrefixes: []string{"10.0.0.0/8"}, + }, func(r *http.Request) { + r.Header.Set("X-Forwarded-For", "203.0.113.7, 10.0.0.1") + }) + + require.Equal(t, "203.0.113.7", got) + }) + + t.Run("xff source prefers trusted prefixes over trusted proxies when both are set", func(t *testing.T) { + // With the prefixes branch the trusted 10.0.0.1 hop is skipped and the + // client is 203.0.113.7; the trusted-proxies=3 branch would resolve no IP + // at all for this two-entry chain. Pins the silent precedence. + got := resolveClientIP(t, config.ClientIPMiddlewareConfig{ + Source: config.ClientIPSourceXFF, + TrustedIPPrefixes: []string{"10.0.0.0/8"}, + TrustedProxies: 3, + }, func(r *http.Request) { + r.Header.Set("X-Forwarded-For", "203.0.113.7, 10.0.0.1") + }) + + require.Equal(t, "203.0.113.7", got) + }) + + t.Run("xff source with trusted proxies resolves the proxy-added entry", func(t *testing.T) { + got := resolveClientIP(t, config.ClientIPMiddlewareConfig{ + Source: config.ClientIPSourceXFF, + TrustedProxies: 1, + }, func(r *http.Request) { + r.Header.Set("X-Forwarded-For", "203.0.113.7") + }) + + require.Equal(t, "203.0.113.7", got) + }) +} diff --git a/app/common/streaming.go b/app/common/streaming.go new file mode 100644 index 0000000000000000000000000000000000000000..19480defd1f4111d2d6a1f3b29aa72711a364792 --- /dev/null +++ b/app/common/streaming.go @@ -0,0 +1,70 @@ +package common + +import ( + "context" + "fmt" + "log/slog" + + "github.com/ClickHouse/clickhouse-go/v2" + "github.com/google/wire" + + "github.com/openmeterio/openmeter/app/config" + "github.com/openmeterio/openmeter/openmeter/namespace" + "github.com/openmeterio/openmeter/openmeter/progressmanager" + "github.com/openmeterio/openmeter/openmeter/streaming" + clickhouseconnector "github.com/openmeterio/openmeter/openmeter/streaming/clickhouse" + streamingretry "github.com/openmeterio/openmeter/openmeter/streaming/retry" +) + +var Streaming = wire.NewSet( + NewStreamingConnector, +) + +func NewStreamingConnector( + ctx context.Context, + conf config.AggregationConfiguration, + clickHouse clickhouse.Conn, + logger *slog.Logger, + progressmanager progressmanager.Service, + namespaceManager *namespace.Manager, +) (streaming.Connector, error) { + var connector streaming.Connector + var err error + + connector, err = clickhouseconnector.New(ctx, clickhouseconnector.Config{ + ClickHouse: clickHouse, + Database: conf.ClickHouse.Database, + EventsTableName: conf.EventsTableName, + Logger: logger, + AsyncInsert: conf.AsyncInsert, + AsyncInsertWait: conf.AsyncInsertWait, + InsertQuerySettings: conf.InsertQuerySettings, + MeterQuerySettings: conf.MeterQuerySettings, + EnablePrewhere: conf.EnablePrewhere, + EnableDecimalPrecision: conf.EnableDecimalPrecision, + ProgressManager: progressmanager, + }) + if err != nil { + return nil, fmt.Errorf("init clickhouse connector: %w", err) + } + + if conf.ClickHouse.Retry.Enabled { + connector, err = streamingretry.New(streamingretry.Config{ + DownstreamConnector: connector, + Logger: logger, + RetryWaitDuration: conf.ClickHouse.Retry.RetryWaitDuration, + MaxTries: conf.ClickHouse.Retry.MaxTries, + MaxDelay: conf.ClickHouse.Retry.MaxDelay, + }) + if err != nil { + return nil, fmt.Errorf("init retry connector: %w", err) + } + } + + err = namespaceManager.RegisterHandler(connector) + if err != nil { + return nil, fmt.Errorf("failed to register streaming namespace handler: %w", err) + } + + return connector, nil +} diff --git a/app/common/subject.go b/app/common/subject.go new file mode 100644 index 0000000000000000000000000000000000000000..b5b80f489167076ad1273b039ce6eaabf638a73a --- /dev/null +++ b/app/common/subject.go @@ -0,0 +1,53 @@ +package common + +import ( + "fmt" + "log/slog" + + "github.com/google/wire" + "go.opentelemetry.io/otel/trace" + + "github.com/openmeterio/openmeter/openmeter/customer" + entdb "github.com/openmeterio/openmeter/openmeter/ent/db" + "github.com/openmeterio/openmeter/openmeter/subject" + "github.com/openmeterio/openmeter/openmeter/subject/adapter" + "github.com/openmeterio/openmeter/openmeter/subject/service" + subjecthooks "github.com/openmeterio/openmeter/openmeter/subject/service/hooks" +) + +var Subject = wire.NewSet( + NewSubjectService, + NewSubjectAdapter, +) + +func NewSubjectService( + adapter subject.Adapter, +) (subject.Service, error) { + return service.New(adapter) +} + +func NewSubjectAdapter( + db *entdb.Client, +) (subject.Adapter, error) { + return adapter.New(db) +} + +func NewSubjectCustomerHook( + subject subject.Service, + customer customer.Service, + logger *slog.Logger, + tracer trace.Tracer, +) (subjecthooks.CustomerSubjectHook, error) { + h, err := subjecthooks.NewCustomerSubjectHook(subjecthooks.CustomerSubjectHookConfig{ + Subject: subject, + Logger: logger, + Tracer: tracer, + }) + if err != nil { + return nil, fmt.Errorf("failed to create customer subject hook: %w", err) + } + + customer.RegisterHooks(h) + + return h, nil +} diff --git a/app/common/subscription.go b/app/common/subscription.go new file mode 100644 index 0000000000000000000000000000000000000000..a0d096391640e98d7b908cd29bf5136c98a80a44 --- /dev/null +++ b/app/common/subscription.go @@ -0,0 +1,144 @@ +package common + +import ( + "log/slog" + + "github.com/google/wire" + + "github.com/openmeterio/openmeter/openmeter/customer" + entdb "github.com/openmeterio/openmeter/openmeter/ent/db" + "github.com/openmeterio/openmeter/openmeter/productcatalog/addon" + "github.com/openmeterio/openmeter/openmeter/productcatalog/feature" + "github.com/openmeterio/openmeter/openmeter/productcatalog/plan" + "github.com/openmeterio/openmeter/openmeter/productcatalog/planaddon" + plansubscription "github.com/openmeterio/openmeter/openmeter/productcatalog/subscription" + subscriptionchangeservice "github.com/openmeterio/openmeter/openmeter/productcatalog/subscription/service" + "github.com/openmeterio/openmeter/openmeter/registry" + "github.com/openmeterio/openmeter/openmeter/subscription" + subscriptionaddon "github.com/openmeterio/openmeter/openmeter/subscription/addon" + subscriptionaddonrepo "github.com/openmeterio/openmeter/openmeter/subscription/addon/repo" + subscriptionaddonservice "github.com/openmeterio/openmeter/openmeter/subscription/addon/service" + subscriptionentitlement "github.com/openmeterio/openmeter/openmeter/subscription/entitlement" + annotationhook "github.com/openmeterio/openmeter/openmeter/subscription/hooks/annotations" + subscriptionrepo "github.com/openmeterio/openmeter/openmeter/subscription/repo" + subscriptionservice "github.com/openmeterio/openmeter/openmeter/subscription/service" + subscriptioncustomer "github.com/openmeterio/openmeter/openmeter/subscription/validators/customer" + subscriptionworkflow "github.com/openmeterio/openmeter/openmeter/subscription/workflow" + subscriptionworkflowservice "github.com/openmeterio/openmeter/openmeter/subscription/workflow/service" + "github.com/openmeterio/openmeter/openmeter/taxcode" + "github.com/openmeterio/openmeter/openmeter/watermill/eventbus" + "github.com/openmeterio/openmeter/pkg/ffx" + "github.com/openmeterio/openmeter/pkg/framework/lockr" +) + +var Subscription = wire.NewSet( + NewSubscriptionServices, +) + +// TODO: break up to multiple initializers +type SubscriptionServiceWithWorkflow struct { + Service subscription.Service + WorkflowService subscriptionworkflow.Service + PlanSubscriptionService plansubscription.PlanSubscriptionService + SubscriptionAddonService subscriptionaddon.Service +} + +func NewSubscriptionServices( + logger *slog.Logger, + db *entdb.Client, + featureConnector feature.FeatureConnector, + entitlementRegistry *registry.Entitlement, + customerService customer.Service, + planService plan.Service, + planAddonService planaddon.Service, + addonService addon.Service, + eventPublisher eventbus.Publisher, + lockr *lockr.Locker, + featureFlags ffx.Service, + taxCodeService taxcode.Service, +) (SubscriptionServiceWithWorkflow, error) { + subscriptionRepo := subscriptionrepo.NewSubscriptionRepo(db) + subscriptionPhaseRepo := subscriptionrepo.NewSubscriptionPhaseRepo(db) + subscriptionItemRepo := subscriptionrepo.NewSubscriptionItemRepo(db) + + subscriptionEntitlementAdapter := subscriptionentitlement.NewSubscriptionEntitlementAdapter( + entitlementRegistry.Entitlement, + subscriptionItemRepo, + subscriptionItemRepo, + ) + + subscriptionService, err := subscriptionservice.New(subscriptionservice.ServiceConfig{ + SubscriptionRepo: subscriptionRepo, + SubscriptionPhaseRepo: subscriptionPhaseRepo, + SubscriptionItemRepo: subscriptionItemRepo, + CustomerService: customerService, + EntitlementAdapter: subscriptionEntitlementAdapter, + FeatureService: featureConnector, + TransactionManager: subscriptionRepo, + Publisher: eventPublisher, + FeatureFlags: featureFlags, + Lockr: lockr, + TaxCode: taxCodeService, + }) + if err != nil { + return SubscriptionServiceWithWorkflow{}, err + } + + subAddRepo := subscriptionaddonrepo.NewSubscriptionAddonRepo(db) + subAddQtyRepo := subscriptionaddonrepo.NewSubscriptionAddonQuantityRepo(db) + + subAddSvc, err := subscriptionaddonservice.NewService(subscriptionaddonservice.Config{ + TxManager: subAddRepo, + Logger: logger, + AddonService: addonService, + SubService: subscriptionService, + SubAddRepo: subAddRepo, + SubAddQtyRepo: subAddQtyRepo, + PlanAddonService: planAddonService, + Publisher: eventPublisher, + }) + if err != nil { + return SubscriptionServiceWithWorkflow{}, err + } + + subscriptionWorkflowService := subscriptionworkflowservice.NewWorkflowService(subscriptionworkflowservice.WorkflowServiceConfig{ + Service: subscriptionService, + CustomerService: customerService, + TransactionManager: subscriptionRepo, + AddonService: subAddSvc, + Logger: logger.With("subsystem", "subscription.workflow.service"), + Lockr: lockr, + FeatureFlags: featureFlags, + }) + + planSubscriptionService := subscriptionchangeservice.New(subscriptionchangeservice.Config{ + WorkflowService: subscriptionWorkflowService, + SubscriptionService: subscriptionService, + PlanService: planService, + CustomerService: customerService, + Logger: logger.With("subsystem", "subscription.change.service"), + }) + + validator, err := subscriptioncustomer.NewValidator(subscriptionService, customerService) + if err != nil { + return SubscriptionServiceWithWorkflow{}, err + } + + annotationCleanupHook, err := annotationhook.NewAnnotationCleanupHook(subscriptionService, subscriptionRepo, logger) + if err != nil { + return SubscriptionServiceWithWorkflow{}, err + } + + if err := subscriptionService.RegisterHook(annotationCleanupHook); err != nil { + return SubscriptionServiceWithWorkflow{}, err + } + + customerService.RegisterRequestValidator(validator) + + return SubscriptionServiceWithWorkflow{ + Service: subscriptionService, + WorkflowService: subscriptionWorkflowService, + PlanSubscriptionService: planSubscriptionService, + SubscriptionAddonService: subAddSvc, + }, nil +} diff --git a/app/common/svix.go b/app/common/svix.go new file mode 100644 index 0000000000000000000000000000000000000000..7fdb3c546e7ad1784a4881067dedb42f2dd51992 --- /dev/null +++ b/app/common/svix.go @@ -0,0 +1,58 @@ +package common + +import ( + "crypto/tls" + "net/http" + "net/url" + + svix "github.com/svix/svix-webhooks/go" + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" + "go.opentelemetry.io/otel/metric" + semconv "go.opentelemetry.io/otel/semconv/v1.37.0" + "go.opentelemetry.io/otel/trace" + + "github.com/openmeterio/openmeter/app/config" +) + +func NewSvixAPIClient( + config config.SvixConfig, + meterProvider metric.MeterProvider, + tracerProvider trace.TracerProvider, +) (*svix.Svix, error) { + if !config.IsEnabled() { + return nil, nil + } + + opts := &svix.SvixOptions{ + Debug: config.Debug, + } + + if config.ServerURL != "" { + serverURL, err := url.Parse(config.ServerURL) + if err != nil { + return nil, err + } + + opts.ServerUrl = serverURL + } + + // See: https://github.com/svix/svix-webhooks/blob/main/go/internal/svix_http_client.go#L31 + // Disable HTTP/2.0 + tr := http.DefaultTransport.(*http.Transport).Clone() + tr.ForceAttemptHTTP2 = false + tr.TLSClientConfig = &tls.Config{ + MinVersion: tls.VersionTLS12, + } + tr.TLSNextProto = make(map[string]func(authority string, c *tls.Conn) http.RoundTripper) + + opts.HTTPClient = &http.Client{ + Transport: otelhttp.NewTransport( + tr, + otelhttp.WithMeterProvider(meterProvider), + otelhttp.WithTracerProvider(tracerProvider), + otelhttp.WithSpanOptions(trace.WithAttributes(semconv.PeerService("svix"))), + ), + } + + return svix.New(config.APIKey, opts) +} diff --git a/app/common/taxcode.go b/app/common/taxcode.go new file mode 100644 index 0000000000000000000000000000000000000000..3ea8005462bc1cb6f97d01d13cd60756120d1a7e --- /dev/null +++ b/app/common/taxcode.go @@ -0,0 +1,73 @@ +package common + +import ( + "log/slog" + "strings" + + "github.com/google/wire" + "github.com/samber/lo" + + "github.com/openmeterio/openmeter/app/config" + "github.com/openmeterio/openmeter/openmeter/app" + entdb "github.com/openmeterio/openmeter/openmeter/ent/db" + "github.com/openmeterio/openmeter/openmeter/taxcode" + taxcodeadapter "github.com/openmeterio/openmeter/openmeter/taxcode/adapter" + taxcodeservice "github.com/openmeterio/openmeter/openmeter/taxcode/service" +) + +var TaxCode = wire.NewSet( + NewTaxCodeAdapter, + NewTaxCodeService, +) + +var TaxCodeNamespaceHandler = wire.NewSet( + wire.FieldsOf(new(config.Configuration), "TaxCode"), + NewTaxCodeNamespaceHandler, +) + +func NewTaxCodeAdapter(logger *slog.Logger, db *entdb.Client) (taxcode.Repository, error) { + return taxcodeadapter.New(taxcodeadapter.Config{ + Client: db, + Logger: logger.With("subsystem", "taxcode"), + }) +} + +func NewTaxCodeService( + logger *slog.Logger, + adapter taxcode.Repository, +) (taxcode.Service, error) { + return taxcodeservice.New(taxcodeservice.Config{ + Adapter: adapter, + Logger: logger.With("subsystem", "taxcode"), + }) +} + +func NewTaxCodeNamespaceHandler( + logger *slog.Logger, + service taxcode.Service, + repository taxcode.Repository, + cfg config.TaxCodeConfiguration, +) (*taxcode.NamespaceHandler, error) { + seeds := lo.Map(cfg.Seeds, func(s config.TaxCodeSeed, _ int) taxcode.SeedEntry { + return taxcode.SeedEntry{ + Key: strings.TrimSpace(s.Key), + Name: strings.TrimSpace(s.Name), + Description: s.Description, + AppMappings: lo.Map(s.AppMappings, func(m config.TaxCodeAppMapping, _ int) taxcode.TaxCodeAppMapping { + return taxcode.TaxCodeAppMapping{ + AppType: app.AppType(strings.TrimSpace(m.AppType)), + TaxCode: strings.TrimSpace(m.TaxCode), + } + }), + DefaultInvoicing: s.DefaultInvoicing, + DefaultCreditGrant: s.DefaultCreditGrant, + } + }) + + return taxcode.NewNamespaceHandler(taxcode.NamespaceHandlerConfig{ + Logger: logger.With("subsystem", "taxcode", "component", "namespace-handler"), + Service: service, + Seeds: seeds, + TransactionManager: repository, + }) +} diff --git a/app/common/telemetry.go b/app/common/telemetry.go new file mode 100644 index 0000000000000000000000000000000000000000..7621a6dc79b96a5fa3fd65e3913c90be9892ae79 --- /dev/null +++ b/app/common/telemetry.go @@ -0,0 +1,459 @@ +package common + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "os" + "strings" + "time" + + health "github.com/AppsFlyer/go-sundheit" + healthhttp "github.com/AppsFlyer/go-sundheit/http" + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/go-slog/otelslog" + "github.com/google/uuid" + "github.com/google/wire" + "github.com/oklog/ulid/v2" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/collectors" + "github.com/prometheus/client_golang/prometheus/promhttp" + slogmulti "github.com/samber/slog-multi" + realotelslog "go.opentelemetry.io/contrib/bridges/otelslog" + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" + "go.opentelemetry.io/contrib/instrumentation/runtime" + "go.opentelemetry.io/otel/log" + "go.opentelemetry.io/otel/metric" + "go.opentelemetry.io/otel/propagation" + sdklog "go.opentelemetry.io/otel/sdk/log" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/resource" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + semconv "go.opentelemetry.io/otel/semconv/v1.27.0" + "go.opentelemetry.io/otel/trace" + + "github.com/openmeterio/openmeter/app/config" + "github.com/openmeterio/openmeter/openmeter/server" + "github.com/openmeterio/openmeter/pkg/contextx" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" + "github.com/openmeterio/openmeter/pkg/gosundheit" +) + +var TelemetryWithoutServer = wire.NewSet( + NewTelemetryResource, + + NewLoggerProvider, + wire.Bind(new(log.LoggerProvider), new(*sdklog.LoggerProvider)), + NewLogger, + + NewMeterProvider, + wire.Bind(new(metric.MeterProvider), new(*sdkmetric.MeterProvider)), + NewMeter, + NewTracerProvider, + wire.Bind(new(trace.TracerProvider), new(*sdktrace.TracerProvider)), + NewTracer, + + NewRuntimeMetricsCollector, +) + +var Telemetry = wire.NewSet( + NewTelemetryResource, + + NewLoggerProvider, + wire.Bind(new(log.LoggerProvider), new(*sdklog.LoggerProvider)), + NewLogger, + + NewMeterProvider, + wire.Bind(new(metric.MeterProvider), new(*sdkmetric.MeterProvider)), + NewMeter, + NewTracerProvider, + wire.Bind(new(trace.TracerProvider), new(*sdktrace.TracerProvider)), + NewTracer, + + NewHealthChecker, + + NewTelemetryHandler, + NewTelemetryServer, + + NewRuntimeMetricsCollector, +) + +// Set the default logger to JSON for messages emitted before the "real" logger is initialized. +// +// We use JSON as a best-effort to make the logs machine-readable. +func init() { + slog.SetDefault(slog.New(config.NewJSONHandler(os.Stderr, slog.LevelInfo))) +} + +const ( + DefaultShutdownTimeout = 5 * time.Second +) + +func NewTelemetryResource(metadata Metadata) *resource.Resource { + extraResources, _ := resource.New( + // TODO: use the globally available context here? + context.Background(), + resource.WithContainer(), + resource.WithAttributes( + semconv.ServiceName(metadata.ServiceName), + semconv.ServiceVersion(metadata.Version), + semconv.DeploymentEnvironmentName(metadata.Environment), + ), + ) + + res, _ := resource.Merge( + resource.Default(), + extraResources, + ) + + return res +} + +func NewLoggerProvider(ctx context.Context, conf config.LogTelemetryConfig, res *resource.Resource) (*sdklog.LoggerProvider, func(), error) { + loggerProvider, err := conf.NewLoggerProvider(ctx, res) + if err != nil { + return nil, nil, fmt.Errorf("failed to initialize OpenTelemetry Trace provider: %w", err) + } + + return loggerProvider, func() { + // Use dedicated context with timeout for shutdown as parent context might be canceled + // by the time the execution reaches this stage. + ctx, cancel := context.WithTimeout(context.Background(), DefaultShutdownTimeout) + defer cancel() + + if err := loggerProvider.ForceFlush(ctx); err != nil { + // no logger initialized at this point yet, so we are using the global logger + slog.Error("flushing logger provider", slog.Any("error", err)) + } + + if err := loggerProvider.Shutdown(ctx); err != nil { + // no logger initialized at this point yet, so we are using the global logger + slog.Error("shutting down logger provider", slog.Any("error", err)) + } + }, nil +} + +func NewLogger(conf config.LogTelemetryConfig, res *resource.Resource, loggerProvider log.LoggerProvider, metadata Metadata, additionalMiddlewares []slogmulti.Middleware) *slog.Logger { + baseMiddlewares := []slogmulti.Middleware{ + otelslog.ResourceMiddleware(res), + otelslog.NewHandler, + } + + baseMiddlewares = append(baseMiddlewares, additionalMiddlewares...) + + // Stdout logger + stdoutLogger := slogmulti. + Pipe(baseMiddlewares...). + Handler(conf.NewHandler(os.Stdout)) + + // OTel logger + // It already has the resource middleware applied by the loggerProvider + otelLogger := NewLevelHandler( + realotelslog.NewHandler(metadata.OpenTelemetryName, realotelslog.WithLoggerProvider(loggerProvider)), + conf.Level, + ) + + // Fanout logger to stdout and OTel logger + out := slogmulti.Fanout( + stdoutLogger, + otelLogger, + ) + + // Enrich log records + middlewares := slogmulti.Pipe( + contextx.NewLogHandler, + ) + + return slog.New(middlewares.Handler(out)) +} + +func TelemetryLoggerNoAdditionalMiddlewares() []slogmulti.Middleware { + return nil +} + +func NewMeterProvider(ctx context.Context, conf config.MetricsTelemetryConfig, res *resource.Resource, logger *slog.Logger) (*sdkmetric.MeterProvider, func(), error) { + meterProvider, err := conf.NewMeterProvider(ctx, res) + if err != nil { + return nil, nil, fmt.Errorf("failed to initialize OpenTelemetry Metrics provider: %w", err) + } + + return meterProvider, func() { + // Use dedicated context with timeout for shutdown as parent context might be canceled + // by the time the execution reaches this stage. + ctx, cancel := context.WithTimeout(context.Background(), DefaultShutdownTimeout) + defer cancel() + + if err := meterProvider.Shutdown(ctx); err != nil { + logger.Error("shutting down meter provider", slog.Any("error", err)) + } + }, nil +} + +func NewMeter(meterProvider metric.MeterProvider, metadata Metadata) metric.Meter { + return meterProvider.Meter(metadata.OpenTelemetryName) +} + +func NewTracerProvider(ctx context.Context, conf config.TraceTelemetryConfig, res *resource.Resource, logger *slog.Logger) (*sdktrace.TracerProvider, func(), error) { + tracerProvider, err := conf.NewTracerProvider(ctx, res) + if err != nil { + return nil, nil, fmt.Errorf("failed to initialize OpenTelemetry Trace provider: %w", err) + } + + // Apply the global per-operation child-span toggle once, at trace init. + httptransport.EnableOperationSpans(conf.OperationSpans) + + return tracerProvider, func() { + // Use dedicated context with timeout for shutdown as parent context might be canceled + // by the time the execution reaches this stage. + ctx, cancel := context.WithTimeout(context.Background(), DefaultShutdownTimeout) + defer cancel() + + if err := tracerProvider.Shutdown(ctx); err != nil { + logger.Error("shutting down tracer provider", slog.Any("error", err)) + } + }, nil +} + +func NewTracer(tracerProvider trace.TracerProvider, metadata Metadata) trace.Tracer { + return tracerProvider.Tracer(metadata.OpenTelemetryName) +} + +func NewDefaultTextMapPropagator() propagation.TextMapPropagator { + return propagation.TraceContext{} +} + +func NewHealthChecker(logger *slog.Logger) health.Health { + return health.New(health.WithCheckListeners(gosundheit.NewLogger(logger.With(slog.String("component", "healthcheck"))))) +} + +type TelemetryHandler http.Handler + +func NewTelemetryHandler( + metricsConf config.MetricsTelemetryConfig, + healthChecker health.Health, + logger *slog.Logger, +) TelemetryHandler { + if metricsConf.Exporters.Prometheus.DisableDefaultCollectors { + prometheus.Unregister(collectors.NewGoCollector()) + prometheus.Unregister(collectors.NewProcessCollector(collectors.ProcessCollectorOpts{})) + } + + router := chi.NewRouter() + router.Mount("/debug", middleware.Profiler()) + + if metricsConf.Exporters.Prometheus.Enabled { + router.Handle("/metrics", promhttp.Handler()) + } + + // Health + { + handler := healthhttp.HandleHealthJSON(healthChecker) + router.Handle("/healthz", handler) + + // Kubernetes style health checks + router.HandleFunc("/healthz/live", func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("ok")) + }) + router.Handle("/healthz/ready", handler) + } + + return router +} + +type TelemetryServer = *http.Server + +func NewTelemetryServer(conf config.TelemetryConfig, handler TelemetryHandler) (TelemetryServer, func()) { + server := &http.Server{ + Addr: conf.Address, + Handler: handler, + ReadHeaderTimeout: conf.ReadHeaderTimeout, + } + + return server, func() { server.Close() } +} + +type TelemetryMiddlewareHook server.MiddlewareHook + +func NewTelemetryRouterHook(meterProvider metric.MeterProvider, tracerProvider trace.TracerProvider) TelemetryMiddlewareHook { + return func(m server.MiddlewareManager) { + m.Use(func(h http.Handler) http.Handler { + return otelhttp.NewHandler( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h.ServeHTTP(w, r) + + routePattern := chi.RouteContext(r.Context()).RoutePattern() + + // Record the route template as http.route on the span and request + // metrics — the canonical low-cardinality routing dimension. The span + // *name* is set separately by WithSpanNameFormatter below: otelhttp + // fixes the name at tracer.Start and a post-start SetName here is a + // no-op in this setup, so we cannot rename the span from this point. + span := trace.SpanFromContext(r.Context()) + span.SetAttributes(semconv.URLPath(r.URL.Path), semconv.HTTPRoute(routePattern)) + + if labeler, ok := otelhttp.LabelerFromContext(r.Context()); ok { + labeler.Add(semconv.HTTPRoute(routePattern)) + } + }), + "", + otelhttp.WithMeterProvider(meterProvider), + otelhttp.WithTracerProvider(tracerProvider), + otelhttp.WithSpanNameFormatter(func(_ string, r *http.Request) string { + // Name the server span "METHOD /route/{template}" using the lowest- + // cardinality route identifier available. + // + // Two-pass note: otelhttp calls this formatter once when the span + // starts (before chi has finished routing) and, in v0.68, again after + // the handler only when r.Pattern is set. r.Pattern is the net/http + // ServeMux pattern; chi does not populate it, so under chi the name is + // set on the first (pre-routing) call only. We therefore resolve the + // route from the available signals, most specific first: + // 1. r.Pattern — net/http ServeMux pattern; chi sets it to the + // mount prefix ("/api/v3/*") for sub-routers + // 2. chi RoutePattern — matched chi template ("{param}" placeholders) + // 3. ULID-collapsed path — fallback when no resolved template is available + // The exact template is also recorded on the http.route attribute. + // + // A value containing "*" is an unresolved mount prefix (e.g. "/api/v3/*" + // when otelhttp runs inside a sub-router, before the leaf is matched — + // this surfaces via r.Pattern on otelhttp's post-handler pass). It is not + // a usable route name, so skip it (from any source) and fall back to the + // ULID-collapsed path. + route := r.Pattern + if route == "" { + if rctx := chi.RouteContext(r.Context()); rctx != nil { + route = rctx.RoutePattern() + } + } + if route == "" || strings.Contains(route, "*") { + route = lowCardinalityPath(r.URL.Path) + } + return r.Method + " " + route + }), + ) + }) + } +} + +const ( + // maxRouteSegmentLen: segments longer than this are treated as opaque identifiers + // (tokens, keys, random slugs — typical of unmatched/scanner traffic). Legitimate + // route segments are well under this (longest is ~"entitlement-access"). + maxRouteSegmentLen = 32 + // maxRouteSegments: paths deeper than this are truncated. Real routes are shallow; + // deeper paths are almost always scanner traversal. + maxRouteSegments = 12 +) + +// lowCardinalityPath turns a raw request path into a bounded span-name fragment. +// It masks high-cardinality segments (ULIDs, UUIDs, numeric ids, over-long opaque +// tokens) to ":id" and truncates pathologically deep paths, so unmatched/scanner +// traffic cannot blow up span-name cardinality. It is the name source both for v3 +// routes (whose chi pattern is the skipped "/api/v3/*" mount wildcard) and for +// unmatched requests. The exact route template, when one matched, is carried +// separately on the http.route attribute. +func lowCardinalityPath(path string) string { + segments := strings.Split(path, "/") + + truncated := false + if len(segments) > maxRouteSegments { + segments = segments[:maxRouteSegments] + truncated = true + } + + for i, seg := range segments { + if isHighCardinalitySegment(seg) { + segments[i] = ":id" + } + } + + out := strings.Join(segments, "/") + if truncated { + out += "/..." + } + return out +} + +// isHighCardinalitySegment reports whether a path segment looks like an identifier or +// opaque token rather than a fixed route word. +func isHighCardinalitySegment(seg string) bool { + if len(seg) > maxRouteSegmentLen { + return true + } + if isAllDigits(seg) { + return true + } + if _, err := ulid.ParseStrict(seg); err == nil { + return true + } + if _, err := uuid.Parse(seg); err == nil { + return true + } + return false +} + +func isAllDigits(s string) bool { + if s == "" { + return false + } + for _, r := range s { + if r < '0' || r > '9' { + return false + } + } + return true +} + +type RuntimeMetricsCollector struct{} + +func NewRuntimeMetricsCollector( + meterProvider metric.MeterProvider, + logger *slog.Logger, +) (RuntimeMetricsCollector, error) { + err := runtime.Start( + runtime.WithMinimumReadMemStatsInterval(time.Second), + runtime.WithMeterProvider(meterProvider), + ) + if err != nil { + return RuntimeMetricsCollector{}, fmt.Errorf("starting runtime metrics: %w", err) + } + + logger.Debug("started collecting runtime metrics") + return RuntimeMetricsCollector{}, nil +} + +// Compile-time check LevelHandler implements slog.Handler. +var _ slog.Handler = (*LevelHandler)(nil) + +// NewLevelHandler returns a new LevelHandler. +func NewLevelHandler(handler slog.Handler, level slog.Leveler) *LevelHandler { + return &LevelHandler{ + handler: handler, + level: level, + } +} + +// LevelHandler is a slog.Handler that filters log records based on the log level. +type LevelHandler struct { + handler slog.Handler + level slog.Leveler +} + +func (h *LevelHandler) Enabled(ctx context.Context, level slog.Level) bool { + // The higher the level, the more important or severe the event. + return level >= h.level.Level() && h.handler.Enabled(ctx, level) +} + +func (h *LevelHandler) WithGroup(name string) slog.Handler { + return NewLevelHandler(h.handler.WithGroup(name), h.level) +} + +func (h *LevelHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + return NewLevelHandler(h.handler.WithAttrs(attrs), h.level) +} + +func (h *LevelHandler) Handle(ctx context.Context, record slog.Record) error { + return h.handler.Handle(ctx, record) +} diff --git a/app/common/telemetry_test.go b/app/common/telemetry_test.go new file mode 100644 index 0000000000000000000000000000000000000000..91d76b1d5dbadbc71d4a35366e26bc3961685c5c --- /dev/null +++ b/app/common/telemetry_test.go @@ -0,0 +1,120 @@ +package common + +import ( + "context" + "log/slog" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestLowCardinalityPath(t *testing.T) { + tests := []struct { + name string + path string + want string + }{ + { + name: "fixed route is unchanged", + path: "/api/v3/openmeter/customers", + want: "/api/v3/openmeter/customers", + }, + { + name: "ULID segment masked", + path: "/api/v3/openmeter/customers/01ARZ3NDEKTSV4RRFFQ69G5FAV/billing", + want: "/api/v3/openmeter/customers/:id/billing", + }, + { + name: "UUID segment masked", + path: "/api/v3/openmeter/customers/550e8400-e29b-41d4-a716-446655440000", + want: "/api/v3/openmeter/customers/:id", + }, + { + name: "numeric segment masked", + path: "/api/v1/things/12345", + want: "/api/v1/things/:id", + }, + { + name: "over-long opaque token masked", + path: "/api/v1/things/" + strings.Repeat("x", 40), + want: "/api/v1/things/:id", + }, + { + name: "short legit segments preserved", + path: "/api/v3/openmeter/customers/01ARZ3NDEKTSV4RRFFQ69G5FAV/entitlement-access", + want: "/api/v3/openmeter/customers/:id/entitlement-access", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, lowCardinalityPath(tc.path)) + }) + } +} + +func TestLowCardinalityPath_TruncatesDeepPaths(t *testing.T) { + // A pathologically deep path (scanner traversal) is truncated and suffixed. + deep := "/" + strings.Repeat("a/", 30) + got := lowCardinalityPath(deep) + + assert.True(t, strings.HasSuffix(got, "/..."), "deep path should be truncated, got %q", got) + assert.LessOrEqual(t, strings.Count(got, "/"), maxRouteSegments+1) +} + +func TestLevelHandler(t *testing.T) { + mockHandler := &MockHandler{} + logger := slog.New(NewLevelHandler(mockHandler, slog.LevelInfo)) + + mockHandler.On("Enabled", mock.Anything, slog.LevelInfo).Return(true) + mockHandler.On("Enabled", mock.Anything, slog.LevelWarn).Return(true) + mockHandler.On("Enabled", mock.Anything, slog.LevelError).Return(true) + + logger.Debug("debug") + logger.Info("info") + logger.Warn("warn") + logger.Error("error") + + mockHandler.AssertExpectations(t) +} + +func TestLevelHandlerWith(t *testing.T) { + mockHandler := &MockHandler{} + logger := slog.New(NewLevelHandler(mockHandler, slog.LevelInfo)) + + logger = logger.With(slog.String("key", "value")) + + mockHandler.On("Enabled", mock.Anything, slog.LevelInfo).Return(true) + mockHandler.On("Enabled", mock.Anything, slog.LevelWarn).Return(true) + mockHandler.On("Enabled", mock.Anything, slog.LevelError).Return(true) + + logger.Debug("debug") + logger.Info("info") + logger.Warn("warn") + logger.Error("error") + + mockHandler.AssertExpectations(t) +} + +type MockHandler struct { + mock.Mock +} + +func (h *MockHandler) Enabled(ctx context.Context, level slog.Level) bool { + args := h.Called(ctx, level) + return args.Bool(0) +} + +func (h *MockHandler) WithGroup(name string) slog.Handler { + return h +} + +func (h *MockHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + return h +} + +func (h *MockHandler) Handle(ctx context.Context, record slog.Record) error { + return nil +} diff --git a/app/common/termination.go b/app/common/termination.go new file mode 100644 index 0000000000000000000000000000000000000000..96360a3bbbb59d155511cc316b85c4e6027f7e6b --- /dev/null +++ b/app/common/termination.go @@ -0,0 +1,173 @@ +package common + +import ( + "context" + "errors" + "fmt" + "log/slog" + "sync" + "time" + + health "github.com/AppsFlyer/go-sundheit" + "github.com/oklog/run" + + "github.com/openmeterio/openmeter/app/config" +) + +type TerminationState = string + +const ( + TerminationStateRunning TerminationState = "running" + TerminationStateTerminating TerminationState = "terminating" +) + +type status struct { + State TerminationState `json:"state"` + Reason error `json:"reason,omitempty"` + Timestamp time.Time `json:"timestamp,omitempty"` +} + +var _ health.Check = (*TerminationChecker)(nil) + +type TerminationChecker struct { + status + + mu sync.RWMutex + + propagationTimeout time.Duration +} + +func (s *TerminationChecker) setDetails(state TerminationState, reason error) { + s.mu.Lock() + defer s.mu.Unlock() + + s.status.State = state + s.status.Reason = reason + s.status.Timestamp = time.Now() +} + +func (s *TerminationChecker) Terminate(reason error) { + s.setDetails(TerminationStateTerminating, reason) +} + +func (s *TerminationChecker) IsRunning() bool { + s.mu.RLock() + defer s.mu.RUnlock() + + return s.status.State == TerminationStateRunning +} + +func (s *TerminationChecker) IsTerminating() bool { + s.mu.RLock() + defer s.mu.RUnlock() + + return s.status.State == TerminationStateTerminating +} + +func (s *TerminationChecker) Name() string { + return "termination.check" +} + +func (s *TerminationChecker) Execute(_ context.Context) (interface{}, error) { + details := struct { + State TerminationState `json:"state"` + Reason error `json:"reason,omitempty"` + Timestamp string `json:"timestamp"` + }{ + State: s.status.State, + Reason: s.status.Reason, + Timestamp: s.status.Timestamp.UTC().Format(time.RFC3339), + } + + if s.IsTerminating() { + return details, fmt.Errorf("termination was initiated [state=%s time=%s]: %w", details.State, details.Timestamp, details.Reason) + } + + return details, nil +} + +// WaitForPropagation blocks for +func (s *TerminationChecker) WaitForPropagation(ctx context.Context) error { + if s.IsRunning() { + return nil + } + + propagationDeadline := s.status.Timestamp.Add(s.propagationTimeout) + if time.Now().After(propagationDeadline) { + return nil + } + + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case now := <-ticker.C: + if now.After(propagationDeadline) { + return nil + } + } + } +} + +// NewTerminationChecker returns a new TerminationChecker and registers the TerminationChecker to +// the provided health checker. +func NewTerminationChecker(conf config.TerminationConfig, healthChecker health.Health) (*TerminationChecker, error) { + rc := &TerminationChecker{ + status: status{ + State: TerminationStateRunning, + Timestamp: time.Now(), + }, + propagationTimeout: conf.PropagationTimeout, + } + + err := healthChecker.RegisterCheck( + rc, + health.ExecutionPeriod(conf.CheckInterval), + ) + if err != nil { + return nil, err + } + + return rc, nil +} + +// NewTerminationCheckerActor returns actor functions (execute, interrupt) which can be passed to run.Add() to register it. +// Its state is set to TerminationStateStopping as soon as interrupt function is called. +func NewTerminationCheckerActor(r *TerminationChecker, logger *slog.Logger) (execute func() error, interrupt func(error), err error) { + if r == nil { + return nil, nil, errors.New("the TerminationChecker must not be nil") + } + + if logger == nil { + logger = slog.Default() + } + + c := make(chan struct{}) + + return func() error { + // Wait until interrupt is invoked and return after + <-c + + return nil + }, func(err error) { + logger.Debug("shutting down termination checker", "cause", err) + + r.Terminate(err) + + if errors.Is(err, run.ErrSignal) { + logger.Debug("waiting for propagation of termination...", "until", r.Timestamp.Add(r.propagationTimeout)) + + // Wait for propagation timeout + if err = r.WaitForPropagation(context.Background()); err != nil { + logger.Error("failed to wait for propagation", "error", err) + } + + logger.Debug("waiting for propagation of termination is done", "until", r.Timestamp.Add(r.propagationTimeout)) + } + + close(c) + }, nil +} diff --git a/app/common/watermill.go b/app/common/watermill.go new file mode 100644 index 0000000000000000000000000000000000000000..baf1208bca7f8744a868cd6ce6420ec110f405a4 --- /dev/null +++ b/app/common/watermill.go @@ -0,0 +1,98 @@ +package common + +import ( + "context" + "fmt" + "log/slog" + + "github.com/ThreeDotsLabs/watermill/message" + "github.com/google/wire" + "go.opentelemetry.io/otel/metric" + + "github.com/openmeterio/openmeter/app/config" + watermillkafka "github.com/openmeterio/openmeter/openmeter/watermill/driver/kafka" + "github.com/openmeterio/openmeter/openmeter/watermill/eventbus" + "github.com/openmeterio/openmeter/openmeter/watermill/router" + pkgkafka "github.com/openmeterio/openmeter/pkg/kafka" +) + +var Watermill = wire.NewSet( + WatermillNoPublisher, + + NewPublisher, +) + +// TODO: move this back to [Watermill] +// NOTE: this is also used by the sink-worker that requires control over how the publisher is closed +var WatermillNoPublisher = wire.NewSet( + NewBrokerConfiguration, + wire.Struct(new(watermillkafka.PublisherOptions), "*"), + + NewEventBusPublisher, +) + +var WatermillRouter = wire.NewSet( + wire.Struct(new(router.Options), "*"), +) + +var WatermillNoTopicProvisioning = wire.NewSet( + NewNoopKafkaTopicProvisioner, + NewEmptyProvisionTopics, +) + +func NewBrokerConfiguration( + kafkaConfig config.KafkaConfiguration, + appMetadata Metadata, + logger *slog.Logger, + meter metric.Meter, +) watermillkafka.BrokerOptions { + return watermillkafka.BrokerOptions{ + KafkaConfig: kafkaConfig, + ClientID: appMetadata.OpenTelemetryName, // TODO: use a better name or rename otel name + Logger: logger, + MetricMeter: meter, + } +} + +// NOTE: this is also used by the sink-worker that requires control over how the publisher is closed +func NewPublisher( + ctx context.Context, + options watermillkafka.PublisherOptions, + logger *slog.Logger, +) (message.Publisher, func(), error) { + publisher, err := watermillkafka.NewPublisher(ctx, options) + if err != nil { + return nil, nil, fmt.Errorf("failed to initialize event publisher: %w", err) + } + + return publisher, func() { + // TODO: isn't this logged by the publisher itself? + logger.Info("closing event publisher") + + if err = publisher.Close(); err != nil { + logger.ErrorContext(ctx, "failed to close event publisher", "error", err) + } + }, nil +} + +func NewEventBusPublisher( + publisher message.Publisher, + conf config.EventsConfiguration, + logger *slog.Logger, +) (eventbus.Publisher, error) { + eventBusPublisher, err := eventbus.New(eventbus.Options{ + Publisher: publisher, + TopicMapping: conf.EventBusTopicMapping(), + Logger: logger, + MarshalerTransformFunc: watermillkafka.AddPartitionKeyFromSubject, + }) + if err != nil { + return nil, fmt.Errorf("failed to initialize event bus publisher: %w", err) + } + + return eventBusPublisher, nil +} + +func NewEmptyProvisionTopics() []pkgkafka.TopicConfig { + return []pkgkafka.TopicConfig{} +} diff --git a/app/config/aggregation.go b/app/config/aggregation.go new file mode 100644 index 0000000000000000000000000000000000000000..da77ccbb737f10fef68000ace7e425723414db1b --- /dev/null +++ b/app/config/aggregation.go @@ -0,0 +1,228 @@ +package config + +import ( + "crypto/tls" + "errors" + "fmt" + "time" + + "github.com/ClickHouse/clickhouse-go/v2" + "github.com/spf13/viper" +) + +type AggregationConfiguration struct { + ClickHouse ClickHouseAggregationConfiguration + + EventsTableName string + + // Set true for ClickHouse first store the incoming inserts into an in-memory buffer + // before flushing them regularly to disk. + // See https://clickhouse.com/docs/en/cloud/bestpractices/asynchronous-inserts + AsyncInsert bool + // Set true if you want an insert statement to return with an acknowledgment immediately + // without waiting for the data got inserted into the buffer. + // Setting true can cause silent errors that you need to monitor separately. + AsyncInsertWait bool + + // See https://clickhouse.com/docs/en/operations/settings/settings + // For example, you can set the `max_insert_threads` setting to control the number of threads + // or the `parallel_view_processing` setting to enable pushing to attached views concurrently. + InsertQuerySettings map[string]string + + // MeterQuerySettings is the settings for the meter query + // For example, you can set the `enable_parallel_replicas` and `max_parallel_replicas` settings. + // See https://clickhouse.com/docs/en/operations/settings/settings + MeterQuerySettings map[string]string + + // EnablePrewhere is the setting to enable prewhere for the meter query. + EnablePrewhere bool + + // EnableDecimalPrecision enables high precision decimal calculations for meter queries. + // When enabled, values are calculated using Decimal128 instead of Float64, providing + // higher precision for financial calculations at the cost of some performance. + EnableDecimalPrecision bool +} + +// Validate validates the configuration. +func (c AggregationConfiguration) Validate() error { + if err := c.ClickHouse.Validate(); err != nil { + return fmt.Errorf("clickhouse: %w", err) + } + + if c.EventsTableName == "" { + return errors.New("events table is required") + } + + if c.AsyncInsertWait && !c.AsyncInsert { + return errors.New("async insert wait is set but async insert is not") + } + + return nil +} + +// ClickHouseAggregationConfiguration is the configuration for the ClickHouse aggregation engine +type ClickHouseAggregationConfiguration struct { + Address string + TLS bool + Username string + Password string + Database string + + // ClickHouse connection options + DialTimeout time.Duration + MaxOpenConns int + MaxIdleConns int + ConnMaxLifetime time.Duration + BlockBufferSize uint8 + + Tracing bool + PoolMetrics ClickhousePoolMetricsConfig + + Retry ClickhouseQueryRetryConfig +} + +// Validate validates the configuration. +func (c ClickHouseAggregationConfiguration) Validate() error { + var errs []error + + if c.Address == "" { + errs = append(errs, errors.New("address is required")) + } + + if c.DialTimeout <= 0 { + errs = append(errs, errors.New("dial timeout must be greater than 0")) + } + + if c.MaxOpenConns <= 0 { + errs = append(errs, errors.New("max open connections must be greater than 0")) + } + + if c.MaxIdleConns <= 0 { + errs = append(errs, errors.New("max idle connections must be greater than 0")) + } + + if c.ConnMaxLifetime <= 0 { + errs = append(errs, errors.New("connection max lifetime must be greater than 0")) + } + + if c.BlockBufferSize <= 0 { + errs = append(errs, errors.New("block buffer size must be greater than 0")) + } + + if err := c.Retry.Validate(); err != nil { + errs = append(errs, fmt.Errorf("retry: %w", err)) + } + + if err := c.PoolMetrics.Validate(); err != nil { + errs = append(errs, fmt.Errorf("pool metrics: %w", err)) + } + + return errors.Join(errs...) +} + +func (c ClickHouseAggregationConfiguration) GetClientOptions() *clickhouse.Options { + options := &clickhouse.Options{ + Addr: []string{c.Address}, + Auth: clickhouse.Auth{ + Database: c.Database, + Username: c.Username, + Password: c.Password, + }, + DialTimeout: c.DialTimeout, + MaxOpenConns: c.MaxOpenConns, + MaxIdleConns: c.MaxIdleConns, + ConnMaxLifetime: c.ConnMaxLifetime, + ConnOpenStrategy: clickhouse.ConnOpenInOrder, + BlockBufferSize: c.BlockBufferSize, + } + // This minimal TLS.Config is normally sufficient to connect to the secure native port (normally 9440) on a ClickHouse server. + // See: https://clickhouse.com/docs/en/integrations/go#using-tls + if c.TLS { + options.TLS = &tls.Config{ + MinVersion: tls.VersionTLS13, + } + } + + return options +} + +type ClickhouseQueryRetryConfig struct { + Enabled bool + MaxTries int + RetryWaitDuration time.Duration + MaxDelay time.Duration +} + +func (c ClickhouseQueryRetryConfig) Validate() error { + var errs []error + + if !c.Enabled { + return nil + } + + if c.MaxTries < 1 { + errs = append(errs, errors.New("max retries must be greater than or equal to 1")) + } + + if c.RetryWaitDuration <= 0 { + errs = append(errs, errors.New("retry wait duration must be greater than 0")) + } + + if c.MaxDelay < 0 { + errs = append(errs, errors.New("max delay must not be negative")) + } + + return errors.Join(errs...) +} + +type ClickhousePoolMetricsConfig struct { + Enabled bool + PollInterval time.Duration +} + +func (c ClickhousePoolMetricsConfig) Validate() error { + var errs []error + + if !c.Enabled { + return nil + } + + if c.PollInterval <= 0 { + errs = append(errs, errors.New("poll interval must be greater than 0")) + } + + return errors.Join(errs...) +} + +// ConfigureAggregation configures some defaults in the Viper instance. +func ConfigureAggregation(v *viper.Viper) { + v.SetDefault("aggregation.eventsTableName", "om_events") + v.SetDefault("aggregation.asyncInsert", false) + v.SetDefault("aggregation.asyncInsertWait", false) + + v.SetDefault("aggregation.clickhouse.address", "127.0.0.1:9000") + v.SetDefault("aggregation.clickhouse.tls", false) + v.SetDefault("aggregation.clickhouse.database", "openmeter") + v.SetDefault("aggregation.clickhouse.username", "default") + v.SetDefault("aggregation.clickhouse.password", "default") + v.SetDefault("aggregation.clickhouse.tracing", false) + // ClickHouse connection options + v.SetDefault("aggregation.clickhouse.dialTimeout", "10s") + v.SetDefault("aggregation.clickhouse.maxOpenConns", 5) + v.SetDefault("aggregation.clickhouse.maxIdleConns", 5) + v.SetDefault("aggregation.clickhouse.connMaxLifetime", "10m") + v.SetDefault("aggregation.clickhouse.blockBufferSize", 10) + + // Retry + v.SetDefault("aggregation.clickhouse.retry.enabled", false) + v.SetDefault("aggregation.clickhouse.retry.maxTries", 3) + v.SetDefault("aggregation.clickhouse.retry.retryWaitDuration", "20ms") + v.SetDefault("aggregation.clickhouse.retry.maxDelay", "5s") + + // Pool metrics + v.SetDefault("aggregation.clickhouse.poolMetrics.enabled", true) + v.SetDefault("aggregation.clickhouse.poolMetrics.pollInterval", "5s") + + // Decimal precision + v.SetDefault("aggregation.enableDecimalPrecision", false) +} diff --git a/app/config/apps.go b/app/config/apps.go new file mode 100644 index 0000000000000000000000000000000000000000..e7fea27661b0c90894aa180f780ad9cbd157d9f2 --- /dev/null +++ b/app/config/apps.go @@ -0,0 +1,48 @@ +package config + +import ( + "errors" + + "github.com/spf13/pflag" + "github.com/spf13/viper" +) + +type AppsConfiguration struct { + // BaseURL is the base URL for the Stripe webhook. + BaseURL string `yaml:"baseURL"` + + Stripe AppStripeConfiguration `yaml:"stripe"` +} + +func (c AppsConfiguration) Validate() error { + var errs []error + + if c.BaseURL == "" { + errs = append(errs, errors.New("base URL is required")) + } + + if err := c.Stripe.Validate(); err != nil { + errs = append(errs, err) + } + + return errors.Join(errs...) +} + +type AppStripeConfiguration struct { + DisableWebhookRegistration bool `yaml:"disableWebhookRegistration"` + WebhookURLPattern string `yaml:"webhookURLPattern" mapstructure:"webhookURLPattern"` +} + +func (c AppStripeConfiguration) Validate() error { + return nil +} + +func ConfigureApps(v *viper.Viper, flags *pflag.FlagSet) { + v.SetDefault("apps.baseURL", "https://example.com") + + flags.Bool("stripe-disable-webhook-registration", false, "Disable webhook registration for Stripe [for local development]") + flags.String("stripe-webhook-url-pattern", "", "Webhook URL pattern for Stripe [for local development]") + _ = v.BindPFlag("apps.stripe.disableWebhookRegistration", flags.Lookup("stripe-disable-webhook-registration")) + v.SetDefault("apps.stripe.disableWebhookRegistration", false) + v.SetDefault("apps.stripe.webhookURLPattern", "") +} diff --git a/app/config/balanceworker.go b/app/config/balanceworker.go new file mode 100644 index 0000000000000000000000000000000000000000..84d28cc62591845ec138eb909d8514a84cf5a3ea --- /dev/null +++ b/app/config/balanceworker.go @@ -0,0 +1,74 @@ +package config + +import ( + "errors" + + "github.com/spf13/viper" + + "github.com/openmeterio/openmeter/pkg/errorsx" +) + +type BalanceWorkerConfiguration struct { + ConsumerConfiguration `mapstructure:",squash"` + StateStorage BalanceWorkerStateStorageConfiguration +} + +func (c BalanceWorkerConfiguration) Validate() error { + var errs []error + + if err := c.ConsumerConfiguration.Validate(); err != nil { + errs = append(errs, errorsx.WithPrefix(err, "consumer")) + } + + if err := c.StateStorage.Validate(); err != nil { + errs = append(errs, errorsx.WithPrefix(err, "stateStorage")) + } + + return errors.Join(errs...) +} + +type BalanceWorkerStateStorageDriver string + +const ( + BalanceWorkerStateStorageDriverRedis BalanceWorkerStateStorageDriver = "redis" + BalanceWorkerStateStorageDriverInMemory BalanceWorkerStateStorageDriver = "in-memory" +) + +type rawBalanceWorkerStateStorageConfiguration struct { + Driver BalanceWorkerStateStorageDriver + Config map[string]any +} + +type BalanceWorkerStateStorageConfiguration struct { + HighWatermarkCache BalanceWorkerHighWatermarkCacheConfiguration +} + +func (c BalanceWorkerStateStorageConfiguration) Validate() error { + var errs []error + + if err := c.HighWatermarkCache.Validate(); err != nil { + errs = append(errs, errorsx.WithPrefix(err, "high watermark cache")) + } + + return errors.Join(errs...) +} + +type BalanceWorkerHighWatermarkCacheConfiguration struct { + LRUCacheSize int +} + +func (c BalanceWorkerHighWatermarkCacheConfiguration) Validate() error { + if c.LRUCacheSize <= 0 { + return errors.New("LRU cache size must be positive") + } + + return nil +} + +func ConfigureBalanceWorker(v *viper.Viper) { + ConfigureConsumer(v, "balanceWorker") + v.SetDefault("balanceWorker.dlq.topic", "om_sys.balance_worker_dlq") + v.SetDefault("balanceWorker.consumerGroupName", "om_balance_worker") + + v.SetDefault("balanceWorker.stateStorage.highWatermarkCache.lruCacheSize", 100_000) +} diff --git a/app/config/billing.go b/app/config/billing.go new file mode 100644 index 0000000000000000000000000000000000000000..3444a7bee0e097460e3efaf0a1ddfb1f4b091715 --- /dev/null +++ b/app/config/billing.go @@ -0,0 +1,65 @@ +package config + +import ( + "errors" + + "github.com/spf13/pflag" + "github.com/spf13/viper" + + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/pkg/models" +) + +type BillingConfiguration struct { + AdvancementStrategy billing.AdvancementStrategy + MaxParallelQuantitySnapshots int + Worker BillingWorkerConfiguration + FeatureSwitches BillingFeatureSwitchesConfiguration +} + +func (c BillingConfiguration) Validate() error { + var errs []error + if err := c.Worker.Validate(); err != nil { + errs = append(errs, err) + } + + if err := c.AdvancementStrategy.Validate(); err != nil { + errs = append(errs, err) + } + + if err := c.FeatureSwitches.Validate(); err != nil { + errs = append(errs, err) + } + + return errors.Join(errs...) +} + +type BillingFeatureSwitchesConfiguration struct { + NamespaceLockdown []string + // MaxLinesPerCollectedInvoice is the maximum number of lines that can be collected for a single invoice, 0 means no limit. + MaxLinesPerCollectedInvoice int + SubscriptionSyncForceAsyncAdvance bool +} + +func (c BillingFeatureSwitchesConfiguration) Validate() error { + var errs []error + + if c.MaxLinesPerCollectedInvoice < 0 { + errs = append(errs, errors.New("maxLinesPerCollectedInvoice must not be negative")) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +func ConfigureBilling(v *viper.Viper, flags *pflag.FlagSet) { + ConfigureBillingWorker(v) + + // Allow overriding the advancement strategy for local development purposes where we are + // likely to use the same configuration file for multiple services. + flags.String("billing-advancement-strategy", "foreground", "Advancement strategy for billing") + _ = v.BindPFlag("billing.advancementStrategy", flags.Lookup("billing-advancement-strategy")) + v.SetDefault("billing.advancementStrategy", billing.ForegroundAdvancementStrategy) + v.SetDefault("billing.maxParallelQuantitySnapshots", 4) + v.SetDefault("billing.featureSwitches.maxLinesPerCollectedInvoice", 0) + v.SetDefault("billing.featureSwitches.subscriptionSyncForceAsyncAdvance", true) +} diff --git a/app/config/billing_test.go b/app/config/billing_test.go new file mode 100644 index 0000000000000000000000000000000000000000..3dc76f084caea57b07a37d6e43c0c785b6c78ec5 --- /dev/null +++ b/app/config/billing_test.go @@ -0,0 +1,29 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestBillingFeatureSwitchesConfigurationValidate(t *testing.T) { + t.Run("zero max collected invoice lines is valid", func(t *testing.T) { + require.NoError(t, BillingFeatureSwitchesConfiguration{ + MaxLinesPerCollectedInvoice: 0, + }.Validate()) + }) + + t.Run("positive max collected invoice lines is valid", func(t *testing.T) { + require.NoError(t, BillingFeatureSwitchesConfiguration{ + MaxLinesPerCollectedInvoice: 10, + }.Validate()) + }) + + t.Run("negative max collected invoice lines is invalid", func(t *testing.T) { + err := BillingFeatureSwitchesConfiguration{ + MaxLinesPerCollectedInvoice: -1, + }.Validate() + + require.ErrorContains(t, err, "maxLinesPerCollectedInvoice must not be negative") + }) +} diff --git a/app/config/billingworker.go b/app/config/billingworker.go new file mode 100644 index 0000000000000000000000000000000000000000..5e74c8a9815b38e5aa255b6b8c99139942c09a44 --- /dev/null +++ b/app/config/billingworker.go @@ -0,0 +1,30 @@ +package config + +import ( + "errors" + + "github.com/spf13/viper" + + "github.com/openmeterio/openmeter/pkg/errorsx" +) + +type BillingWorkerConfiguration struct { + ConsumerConfiguration `mapstructure:",squash"` +} + +func (c BillingWorkerConfiguration) Validate() error { + var errs []error + + if err := c.ConsumerConfiguration.Validate(); err != nil { + errs = append(errs, errorsx.WithPrefix(err, "consumer")) + } + + return errors.Join(errs...) +} + +func ConfigureBillingWorker(v *viper.Viper) { + v.SetDefault("billing.worker.dlq.topic", "om_sys.billing_worker_dlq") + v.SetDefault("billing.worker.consumerGroupName", "om_billing_worker") + + ConfigureConsumer(v, "billing.worker") +} diff --git a/app/config/config.go b/app/config/config.go new file mode 100644 index 0000000000000000000000000000000000000000..35373323d5224718cad067a90d6fdba2c9cfd42b --- /dev/null +++ b/app/config/config.go @@ -0,0 +1,241 @@ +// Package config loads application configuration. +package config + +import ( + "errors" + "fmt" + "regexp" + "strings" + "time" + + "github.com/samber/lo" + "github.com/spf13/pflag" + "github.com/spf13/viper" + + "github.com/openmeterio/openmeter/openmeter/meter" + "github.com/openmeterio/openmeter/pkg/errorsx" + "github.com/openmeterio/openmeter/pkg/models" +) + +type ReservedEventTypePattern = string + +// Configuration holds any kind of Configuration that comes from the outside world and +// is necessary for running the application. +type Configuration struct { + Address string + Environment string + + Telemetry TelemetryConfig + + Termination TerminationConfig + + Server ServerConfig + + Aggregation AggregationConfiguration + Entitlements EntitlementsConfiguration + Customer CustomerConfiguration + Credits CreditsConfiguration + Dedupe DedupeConfiguration + Events EventsConfiguration + Ingest IngestConfiguration + Meters []*meter.Meter + ReservedEventTypes []ReservedEventTypePattern + Namespace NamespaceConfiguration + Portal PortalConfiguration + Postgres PostgresConfig + Sink SinkConfiguration + BalanceWorker BalanceWorkerConfiguration + Notification NotificationConfiguration + ProductCatalog ProductCatalogConfiguration + ProgressManager ProgressManagerConfiguration + Billing BillingConfiguration + Apps AppsConfiguration + Svix SvixConfig + TaxCode TaxCodeConfiguration + FeatureGate FeatureGateConfiguration + UnitConfig UnitConfigConfiguration +} + +// Validate validates the configuration. +func (c Configuration) Validate() error { + var errs []error + + if c.Address == "" { + errs = append(errs, errors.New("server address is required")) + } + + if err := c.Telemetry.Validate(); err != nil { + errs = append(errs, errorsx.WithPrefix(err, "telemetry")) + } + + if err := c.Namespace.Validate(); err != nil { + errs = append(errs, errorsx.WithPrefix(err, "namespace")) + } + + if err := c.Ingest.Validate(); err != nil { + errs = append(errs, errorsx.WithPrefix(err, "ingest")) + } + + if err := c.Aggregation.Validate(); err != nil { + errs = append(errs, errorsx.WithPrefix(err, "aggregation")) + } + + if err := c.Sink.Validate(); err != nil { + errs = append(errs, errorsx.WithPrefix(err, "sink")) + } + + if err := c.Dedupe.Validate(); err != nil { + errs = append(errs, errorsx.WithPrefix(err, "dedupe")) + } + + if err := c.Portal.Validate(); err != nil { + errs = append(errs, errorsx.WithPrefix(err, "portal")) + } + + if err := c.Entitlements.Validate(); err != nil { + errs = append(errs, errorsx.WithPrefix(err, "entitlements")) + } + + for idx, m := range c.Meters { + // Set managed resource + c.Meters[idx].ManagedResource = models.ManagedResource{ + NamespacedModel: models.NamespacedModel{ + Namespace: c.Namespace.Default, + }, + ManagedModel: models.ManagedModel{ + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }, + ID: m.Key, + // Meter used to not have a name, + // so we need to coalesce it with the key when it comes from the config + Name: lo.CoalesceOrEmpty(m.Name, m.Key), + Description: m.Description, + } + + if err := c.Meters[idx].Validate(); err != nil { + errs = append(errs, err) + } + } + + for _, pattern := range c.ReservedEventTypes { + if _, err := regexp.Compile(pattern); err != nil { + errs = append(errs, errorsx.WithPrefix( + fmt.Errorf("reserved event type pattern %q: invalid regular expression", pattern), + "reserved event types", + )) + } + } + + if err := c.BalanceWorker.Validate(); err != nil { + errs = append(errs, errorsx.WithPrefix(err, "balance worker")) + } + + if err := c.Notification.Validate(); err != nil { + errs = append(errs, errorsx.WithPrefix(err, "notification")) + } + + if err := c.Svix.Validate(); err != nil { + errs = append(errs, errorsx.WithPrefix(err, "svix")) + } + + if err := c.ProductCatalog.Validate(); err != nil { + errs = append(errs, errorsx.WithPrefix(err, "product catalog")) + } + + if err := c.Billing.Validate(); err != nil { + errs = append(errs, errorsx.WithPrefix(err, "billing")) + } + + if err := c.Credits.Validate(); err != nil { + errs = append(errs, errorsx.WithPrefix(err, "credits")) + } + + if err := c.Apps.Validate(); err != nil { + errs = append(errs, errorsx.WithPrefix(err, "apps")) + } + + if err := c.ProgressManager.Validate(); err != nil { + errs = append(errs, errorsx.WithPrefix(err, "progress manager")) + } + + if err := c.Termination.Validate(); err != nil { + errs = append(errs, errorsx.WithPrefix(err, "termination")) + } + + if err := c.Server.Validate(); err != nil { + errs = append(errs, errorsx.WithPrefix(err, "server")) + } + + if err := c.Customer.Validate(); err != nil { + errs = append(errs, errorsx.WithPrefix(err, "customer")) + } + + if err := c.Credits.Validate(); err != nil { + errs = append(errs, errorsx.WithPrefix(err, "credit")) + } + + if err := c.TaxCode.Validate(); err != nil { + errs = append(errs, errorsx.WithPrefix(err, "taxcode")) + } + + if c.FeatureGate.Enabled { + if err := c.FeatureGate.Flags.Validate(); err != nil { + errs = append(errs, errorsx.WithPrefix(err, "featuregate.flags")) + } + } + + if err := c.UnitConfig.Validate(); err != nil { + errs = append(errs, errorsx.WithPrefix(err, "unitConfig")) + } + + return errors.Join(errs...) +} + +func SetViperDefaults(v *viper.Viper, flags *pflag.FlagSet) { + // Viper settings + // TODO: remove this: it's not in use + v.AddConfigPath(".") + + // Environment variable settings + // TODO: replace this with constructor option + v.SetEnvKeyReplacer(strings.NewReplacer(".", "_", "-", "_")) + + v.AllowEmptyEnv(true) + v.AutomaticEnv() + + // Server configuration + flags.String("address", ":8888", "Server address") + _ = v.BindPFlag("address", flags.Lookup("address")) + v.SetDefault("address", ":8888") + + // Environment used for identifying the service environment + v.SetDefault("environment", "unknown") + + ConfigureTelemetry(v, flags) + + ConfigurePostgres(v, "postgres") + v.SetDefault("postgres.autoMigrate", "migration") + + ConfigureNamespace(v) + ConfigureIngest(v) + ConfigureAggregation(v) + ConfigureSink(v) + ConfigureDedupe(v) + ConfigurePortal(v) + ConfigureEvents(v) + ConfigureBalanceWorker(v) + ConfigureNotification(v) + ConfigureBilling(v, flags) + ConfigureProductCatalog(v) + ConfigureApps(v, flags) + ConfigureEntitlements(v, flags) + ConfigureTermination(v, "termination") + ConfigureServer(v, "server") + ConfigureProgressManager(v) + ConfigureCustomer(v, "customer") + ConfigureCredits(v, "credits") + ConfigureTaxCode(v) + ConfigureFeatureGate(v, "featureGate") + ConfigureUnitConfig(v, "unitConfig") +} diff --git a/app/config/config_test.go b/app/config/config_test.go new file mode 100644 index 0000000000000000000000000000000000000000..0f171f6db3353d306c801228f504cbfc54838d4c --- /dev/null +++ b/app/config/config_test.go @@ -0,0 +1,496 @@ +package config + +import ( + "errors" + "log/slog" + "os" + "testing" + "time" + + "github.com/samber/lo" + "github.com/spf13/pflag" + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/dedupe/redisdedupe" + "github.com/openmeterio/openmeter/openmeter/meter" + notificationwebhook "github.com/openmeterio/openmeter/openmeter/notification/webhook" + "github.com/openmeterio/openmeter/openmeter/notification/webhook/svix" + "github.com/openmeterio/openmeter/pkg/datetime" + "github.com/openmeterio/openmeter/pkg/featuregate" + pkgkafka "github.com/openmeterio/openmeter/pkg/kafka" + "github.com/openmeterio/openmeter/pkg/models" + "github.com/openmeterio/openmeter/pkg/pglockx" + "github.com/openmeterio/openmeter/pkg/redis" +) + +func TestComplete(t *testing.T) { + v, flags := viper.New(), pflag.NewFlagSet("OpenMeter", pflag.ExitOnError) + + // Messes with vscode defaults + val, set := os.LookupEnv("POSTGRES_HOST") + os.Unsetenv("POSTGRES_HOST") + defer func() { + if set { + os.Setenv("POSTGRES_HOST", val) + } + }() + + SetViperDefaults(v, flags) + + v.SetConfigFile("testdata/complete.yaml") + + err := v.ReadInConfig() + if err != nil && !errors.As(err, &viper.ConfigFileNotFoundError{}) { + t.Fatal(err) + } + + var actual Configuration + err = v.Unmarshal(&actual, viper.DecodeHook(DecodeHook())) + if err != nil { + t.Fatal(err) + } + + err = actual.Validate() + if err != nil { + t.Fatal(err) + } + + expected := Configuration{ + Postgres: PostgresConfig{ + AutoMigrate: AutoMigrateMigration, + }, + Address: "127.0.0.1:8888", + Apps: AppsConfiguration{ + BaseURL: "https://example.com", + }, + Environment: "local", + Telemetry: TelemetryConfig{ + Address: "127.0.0.1:10000", + ReadHeaderTimeout: 10 * time.Second, + Trace: TraceTelemetryConfig{ + Sampler: "always", + Exporters: ExportersTraceTelemetryConfig{ + OTLP: OTLPExportersTraceTelemetryConfig{ + Enabled: true, + OTLPExporterTelemetryConfig: OTLPExporterTelemetryConfig{ + Address: "127.0.0.1:4317", + }, + }, + }, + }, + Metrics: MetricsTelemetryConfig{ + Exporters: ExportersMetricsTelemetryConfig{ + Prometheus: PrometheusExportersMetricsTelemetryConfig{ + Enabled: true, + }, + OTLP: OTLPExportersMetricsTelemetryConfig{ + Enabled: true, + OTLPExporterTelemetryConfig: OTLPExporterTelemetryConfig{ + Address: "127.0.0.1:4317", + }, + }, + }, + }, + Log: LogTelemetryConfig{ + Format: "json", + Level: slog.LevelInfo, + }, + }, + Namespace: NamespaceConfiguration{ + Default: "default", + DisableManagement: false, + }, + Ingest: IngestConfiguration{ + Kafka: KafkaIngestConfiguration{ + KafkaConfiguration: KafkaConfiguration{ + Broker: "127.0.0.1:9092", + SecurityProtocol: "SASL_SSL", + SaslMechanisms: "PLAIN", + SaslUsername: "user", + SaslPassword: "pass", + + BrokerAddressFamily: pkgkafka.BrokerAddressFamilyAny, + TopicMetadataRefreshInterval: pkgkafka.TimeDurationMilliSeconds(time.Minute), + StatsInterval: pkgkafka.TimeDurationMilliSeconds(5 * time.Second), + SocketKeepAliveEnabled: true, + DebugContexts: pkgkafka.DebugContexts{ + "broker", + "topic", + "consumer", + }, + }, + Partitions: 1, + EventsTopicTemplate: "om_%s_events", + TopicProvisioner: TopicProvisionerConfig{ + Enabled: true, + CacheSize: 200, + CacheTTL: 15 * time.Minute, + ProtectedTopics: []string{ + "protected-topic-1", + "protected-topic-2", + }, + }, + }, + }, + Aggregation: AggregationConfiguration{ + ClickHouse: ClickHouseAggregationConfiguration{ + Address: "127.0.0.1:9440", + TLS: true, + Username: "default", + Password: "default", + Database: "openmeter", + DialTimeout: 10 * time.Second, + MaxOpenConns: 5, + MaxIdleConns: 5, + ConnMaxLifetime: 10 * time.Minute, + BlockBufferSize: 10, + Retry: ClickhouseQueryRetryConfig{ + Enabled: true, + MaxTries: 3, + RetryWaitDuration: 20 * time.Millisecond, + MaxDelay: 5 * time.Second, + }, + PoolMetrics: ClickhousePoolMetricsConfig{ + Enabled: true, + PollInterval: 5 * time.Second, + }, + }, + EventsTableName: "om_events", + AsyncInsert: false, + AsyncInsertWait: false, + }, + Entitlements: EntitlementsConfiguration{ + GracePeriod: datetime.ISODurationString("P1D"), + }, + Billing: BillingConfiguration{ + AdvancementStrategy: billing.ForegroundAdvancementStrategy, + MaxParallelQuantitySnapshots: 4, + FeatureSwitches: BillingFeatureSwitchesConfiguration{ + SubscriptionSyncForceAsyncAdvance: true, + }, + Worker: BillingWorkerConfiguration{ + ConsumerConfiguration: ConsumerConfiguration{ + ProcessingTimeout: 30 * time.Second, + Retry: RetryConfiguration{ + MaxRetries: 10, + InitialInterval: 10 * time.Millisecond, + MaxInterval: time.Second, + MaxElapsedTime: time.Minute, + }, + DLQ: DLQConfiguration{ + Enabled: true, + Topic: "om_sys.billing_worker_dlq", + AutoProvision: DLQAutoProvisionConfiguration{ + Enabled: true, + Partitions: 1, + Retention: 90 * 24 * time.Hour, + }, + }, + ConsumerGroupName: "om_billing_worker", + }, + }, + }, + Credits: CreditsConfiguration{ + Enabled: false, + EnableCreditThenInvoice: false, + }, + UnitConfig: UnitConfigConfiguration{ + Enabled: false, + }, + Sink: SinkConfiguration{ + GroupId: "openmeter-sink-worker", + MinCommitCount: 500, + MaxCommitWait: 30 * time.Second, + MaxPollTimeout: 100 * time.Millisecond, + NamespaceRefetch: 15 * time.Second, + FlushSuccessTimeout: 5 * time.Second, + DrainTimeout: 10 * time.Second, + NamespaceRefetchTimeout: 9 * time.Second, + NamespaceTopicRegexp: "^om_test_([A-Za-z0-9]+(?:_[A-Za-z0-9]+)*)_events$", + MeterRefetchInterval: 15 * time.Second, + Dedupe: DedupeConfiguration{ + Enabled: true, + DedupeDriverConfiguration: DedupeDriverRedisConfiguration{ + Config: redis.Config{ + Address: "127.0.0.1:6379", + Database: 0, + Username: "default", + Password: "pass", + + TLS: struct { + Enabled bool + InsecureSkipVerify bool + }{ + Enabled: true, + }, + }, + Expiration: 768 * time.Hour, + Mode: redisdedupe.DedupeModeRawKey, + }, + }, + IngestNotifications: IngestNotificationsConfiguration{ + MaxEventsInBatch: 50, + }, + Kafka: KafkaConfig{ + CommonConfigParams: pkgkafka.CommonConfigParams{ + Brokers: "127.0.0.1:9092", + SecurityProtocol: "SASL_SSL", + SaslMechanisms: "PLAIN", + SaslUsername: "user", + SaslPassword: "pass", + ClientID: "kafka-client-1", + StatsInterval: pkgkafka.TimeDurationMilliSeconds(5 * time.Second), + BrokerAddressFamily: pkgkafka.BrokerAddressFamilyAny, + TopicMetadataRefreshInterval: pkgkafka.TimeDurationMilliSeconds(time.Minute), + SocketKeepAliveEnabled: true, + DebugContexts: pkgkafka.DebugContexts{ + "broker", + "topic", + "consumer", + }, + }, + ConsumerConfigParams: pkgkafka.ConsumerConfigParams{ + ConsumerGroupID: "consumer-group", + ConsumerGroupInstanceID: "consumer-group-1", + SessionTimeout: pkgkafka.TimeDurationMilliSeconds(5 * time.Minute), + HeartbeatInterval: pkgkafka.TimeDurationMilliSeconds(3 * time.Second), + EnableAutoCommit: true, + EnableAutoOffsetStore: false, + AutoOffsetReset: "error", + PartitionAssignmentStrategy: pkgkafka.PartitionAssignmentStrategies{ + pkgkafka.PartitionAssignmentStrategyRange, + pkgkafka.PartitionAssignmentStrategyRoundRobin, + }, + }, + }, + Storage: StorageConfiguration{ + AsyncInsert: false, + AsyncInsertWait: false, + }, + }, + Dedupe: DedupeConfiguration{ + Enabled: true, + DedupeDriverConfiguration: DedupeDriverRedisConfiguration{ + Config: redis.Config{ + Address: "127.0.0.1:6379", + Database: 0, + Username: "default", + Password: "pass", + + TLS: struct { + Enabled bool + InsecureSkipVerify bool + }{ + Enabled: true, + }, + }, + Expiration: 768 * time.Hour, + Mode: redisdedupe.DedupeModeRawKey, + }, + }, + Portal: PortalConfiguration{ + Enabled: false, + CORS: PortalCORSConfiguration{ + Enabled: true, + }, + TokenExpiration: time.Hour, + }, + Meters: []*meter.Meter{ + { + ManagedResource: models.ManagedResource{ + ID: "m1", + NamespacedModel: models.NamespacedModel{ + Namespace: "default", + }, + ManagedModel: models.ManagedModel{ + CreatedAt: actual.Meters[0].ManagedResource.CreatedAt, + UpdatedAt: actual.Meters[0].ManagedResource.UpdatedAt, + }, + Name: "m1", + }, + Key: "m1", + Aggregation: "SUM", + EventType: "api-calls", + ValueProperty: lo.ToPtr("$.duration_ms"), + GroupBy: map[string]string{ + "method": "$.method", + "path": "$.path", + }, + }, + }, + Events: EventsConfiguration{ + SystemEvents: EventSubsystemConfiguration{ + Topic: "om_sys.api_events", + AutoProvision: AutoProvisionConfiguration{ + Enabled: true, + Partitions: 4, + }, + }, + IngestEvents: EventSubsystemConfiguration{ + Topic: "om_sys.ingest_events", + AutoProvision: AutoProvisionConfiguration{ + Enabled: true, + Partitions: 8, + }, + }, + BalanceWorkerEvents: EventSubsystemConfiguration{ + Topic: "om_sys.balance_worker_events", + AutoProvision: AutoProvisionConfiguration{ + Enabled: true, + Partitions: 4, + }, + }, + }, + BalanceWorker: BalanceWorkerConfiguration{ + ConsumerConfiguration: ConsumerConfiguration{ + ProcessingTimeout: 30 * time.Second, + Retry: RetryConfiguration{ + MaxRetries: 10, + InitialInterval: 10 * time.Millisecond, + MaxInterval: time.Second, + MaxElapsedTime: time.Minute, + }, + DLQ: DLQConfiguration{ + Enabled: true, + Topic: "om_sys.balance_worker_dlq", + AutoProvision: DLQAutoProvisionConfiguration{ + Enabled: true, + Partitions: 1, + Retention: 90 * 24 * time.Hour, + }, + }, + ConsumerGroupName: "om_balance_worker", + }, + StateStorage: BalanceWorkerStateStorageConfiguration{ + HighWatermarkCache: BalanceWorkerHighWatermarkCacheConfiguration{ + LRUCacheSize: 100_000, + }, + }, + }, + ProductCatalog: ProductCatalogConfiguration{ + Subscription: SubscriptionConfiguration{ + MultiSubscriptionNamespaces: []string{ + "multi-subscription", + }, + }, + }, + Notification: NotificationConfiguration{ + Consumer: ConsumerConfiguration{ + ProcessingTimeout: 30 * time.Second, + Retry: RetryConfiguration{ + MaxRetries: 10, + InitialInterval: 10 * time.Millisecond, + MaxInterval: time.Second, + MaxElapsedTime: time.Minute, + }, + DLQ: DLQConfiguration{ + Enabled: true, + Topic: "om_sys.notification_service_dlq", + AutoProvision: DLQAutoProvisionConfiguration{ + Enabled: true, + Partitions: 1, + Retention: 90 * 24 * time.Hour, + }, + }, + ConsumerGroupName: "om_notification_service", + }, + Webhook: WebhookConfiguration{ + EventTypeRegistrationTimeout: notificationwebhook.DefaultRegistrationTimeout, + SkipEventTypeRegistrationOnError: false, + }, + ReconcileInterval: time.Minute, + SendingTimeout: time.Hour, + PendingTimeout: 2 * time.Hour, + ReconcilerWorkers: 10, + Lock: pglockx.Config{ + LeaseTime: 7 * time.Minute, + HeartbeatInterval: 29 * time.Second, + Owner: "lock-owner", + }, + }, + Svix: svix.SvixConfig{ + APIKey: "test-svix-token", + ServerURL: "http://127.0.0.1:8071", + Debug: true, + }, + Termination: TerminationConfig{ + CheckInterval: 7 * time.Second, + GracefulShutdownTimeout: 43 * time.Second, + PropagationTimeout: 18 * time.Second, + }, + Server: ServerConfig{ + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 60 * time.Second, + ResponseValidation: ResponseValidationConfig{ + Mode: ResponseValidationModeUnstable, + }, + ClientIPMiddleware: ClientIPMiddlewareConfig{ + Source: ClientIPSourceXFF, + Header: "X-Real-IP", + TrustedIPPrefixes: []string{ + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + }, + TrustedProxies: 2, + }, + }, + ProgressManager: ProgressManagerConfiguration{ + Enabled: false, + Expiration: 5 * time.Minute, + Redis: redis.Config{ + Address: "127.0.0.1:6379", + Database: 0, + Username: "", + Password: "", + TLS: struct { + Enabled bool + InsecureSkipVerify bool + }{ + Enabled: false, + }, + }, + }, + Customer: CustomerConfiguration{ + EnableSubjectHook: true, + IgnoreErrors: true, + }, + ReservedEventTypes: []string{ + `^reserved\..*$`, + `^_\..*$`, + `^openmeter\..*$`, + }, + TaxCode: TaxCodeConfiguration{ + Seeds: []TaxCodeSeed{ + { + Key: "default", + Name: "Provider default", + DefaultInvoicing: true, + }, + { + Key: "nontaxable", + Name: "Nontaxable", + DefaultCreditGrant: true, + AppMappings: []TaxCodeAppMapping{ + { + AppType: "stripe", + TaxCode: "txcd_00000000", + }, + }, + }, + }, + }, + FeatureGate: FeatureGateConfiguration{ + Enabled: true, + Flags: featuregate.Flags{ + featuregate.FeatureFlag("om_ff_credits_enabled"): "credits", + }, + }, + } + + assert.Equal(t, expected, actual) +} diff --git a/app/config/credits.go b/app/config/credits.go new file mode 100644 index 0000000000000000000000000000000000000000..fbc18405fa1bff80184ab32185233efe5861f03f --- /dev/null +++ b/app/config/credits.go @@ -0,0 +1,27 @@ +package config + +import ( + "errors" + + "github.com/spf13/viper" +) + +type CreditsConfiguration struct { + Enabled bool `yaml:"enabled"` + EnableCreditThenInvoice bool `yaml:"enableCreditThenInvoice"` +} + +func (c CreditsConfiguration) Validate() error { + var errs []error + + // Add any validation logic for CreditsConfiguration here if needed. + + return errors.Join(errs...) +} + +func ConfigureCredits(v *viper.Viper, prefixes ...string) { + prefixer := NewViperKeyPrefixer(prefixes...) + + v.SetDefault(prefixer("enabled"), false) + v.SetDefault(prefixer("enableCreditThenInvoice"), false) +} diff --git a/app/config/customer.go b/app/config/customer.go new file mode 100644 index 0000000000000000000000000000000000000000..3b4f96f4eec85c617856db10b60a3da33a8dc262 --- /dev/null +++ b/app/config/customer.go @@ -0,0 +1,26 @@ +package config + +import ( + "github.com/spf13/viper" + + "github.com/openmeterio/openmeter/pkg/models" +) + +var _ models.Validator = (*CustomerConfiguration)(nil) + +type CustomerConfiguration struct { + EnableSubjectHook bool + IgnoreErrors bool +} + +func (c CustomerConfiguration) Validate() error { + return nil +} + +// ConfigureCustomer configures some defaults in the Viper instance. +func ConfigureCustomer(v *viper.Viper, prefixes ...string) { + prefixer := NewViperKeyPrefixer(prefixes...) + + v.SetDefault(prefixer("enableSubjectHook"), false) + v.SetDefault(prefixer("ignoreErrors"), false) +} diff --git a/app/config/dedupe.go b/app/config/dedupe.go new file mode 100644 index 0000000000000000000000000000000000000000..b984188cf228644c5fe7d028bc20e3805808687c --- /dev/null +++ b/app/config/dedupe.go @@ -0,0 +1,214 @@ +package config + +import ( + "errors" + "fmt" + "time" + + "github.com/mitchellh/mapstructure" + "github.com/spf13/viper" + + "github.com/openmeterio/openmeter/openmeter/dedupe" + "github.com/openmeterio/openmeter/openmeter/dedupe/memorydedupe" + "github.com/openmeterio/openmeter/openmeter/dedupe/redisdedupe" + "github.com/openmeterio/openmeter/pkg/errorsx" + "github.com/openmeterio/openmeter/pkg/redis" +) + +// Requires [mapstructurex.MapDecoderHookFunc] to be high up in the decode hook chain. +type DedupeConfiguration struct { + Enabled bool + + DedupeDriverConfiguration +} + +func (c DedupeConfiguration) NewDeduplicator() (dedupe.Deduplicator, error) { + if !c.Enabled { + return nil, errors.New("dedupe: disabled") + } + + if c.DedupeDriverConfiguration == nil { + return nil, errors.New("dedupe: missing driver configuration") + } + + return c.DedupeDriverConfiguration.NewDeduplicator() +} + +func (c DedupeConfiguration) Validate() error { + if !c.Enabled { + return nil + } + + if c.DedupeDriverConfiguration == nil { + return errors.New("missing driver configuration") + } + + if err := c.DedupeDriverConfiguration.Validate(); err != nil { + return errorsx.WithPrefix(err, fmt.Sprintf("driver(%s)", c.DriverName())) + } + + return nil +} + +type rawDedupeConfiguration struct { + Enabled bool + Driver string + Config map[string]any +} + +func (c *DedupeConfiguration) DecodeMap(v map[string]any) error { + var rawConfig rawDedupeConfiguration + + err := mapstructure.Decode(v, &rawConfig) + if err != nil { + return err + } + + c.Enabled = rawConfig.Enabled + + // Deduplication is disabled and not configured, so skip further decoding + if !c.Enabled && rawConfig.Driver == "" { + return nil + } + + switch rawConfig.Driver { + case "memory": + var driverConfig DedupeDriverMemoryConfiguration + + err := mapstructure.Decode(rawConfig.Config, &driverConfig) + if err != nil { + return fmt.Errorf("dedupe: decoding memory driver config: %w", err) + } + + c.DedupeDriverConfiguration = driverConfig + + case "redis": + var driverConfig DedupeDriverRedisConfiguration + + decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{ + Metadata: nil, + Result: &driverConfig, + WeaklyTypedInput: true, + DecodeHook: mapstructure.ComposeDecodeHookFunc( + mapstructure.StringToTimeDurationHookFunc(), + ), + }) + if err != nil { + return fmt.Errorf("dedupe: creating decoder: %w", err) + } + + err = decoder.Decode(rawConfig.Config) + if err != nil { + return fmt.Errorf("dedupe: decoding redis driver config: %w", err) + } + + c.DedupeDriverConfiguration = driverConfig + + default: + c.DedupeDriverConfiguration = unknownDedupeDriverConfiguration{ + name: rawConfig.Driver, + } + } + + return nil +} + +type DedupeDriverConfiguration interface { + DriverName() string + NewDeduplicator() (dedupe.Deduplicator, error) + Validate() error +} + +type unknownDedupeDriverConfiguration struct { + name string +} + +func (c unknownDedupeDriverConfiguration) DriverName() string { + return c.name +} + +func (c unknownDedupeDriverConfiguration) NewDeduplicator() (dedupe.Deduplicator, error) { + return nil, errors.New("dedupe: unknown driver") +} + +func (c unknownDedupeDriverConfiguration) Validate() error { + return errors.New("unknown driver") +} + +// Dedupe memory driver configuration +type DedupeDriverMemoryConfiguration struct { + Enabled bool + Size int +} + +func (DedupeDriverMemoryConfiguration) DriverName() string { + return "memory" +} + +func (c DedupeDriverMemoryConfiguration) NewDeduplicator() (dedupe.Deduplicator, error) { + return memorydedupe.NewDeduplicator(c.Size) +} + +func (c DedupeDriverMemoryConfiguration) Validate() error { + var errs []error + + if c.Size == 0 { + errs = append(errs, errors.New("size is required")) + } + + return errors.Join(errs...) +} + +// Dedupe redis driver configuration +type DedupeDriverRedisConfiguration struct { + redis.Config `mapstructure:",squash"` + + Expiration time.Duration + Mode redisdedupe.DedupeMode +} + +func (DedupeDriverRedisConfiguration) DriverName() string { + return "redis" +} + +func (c DedupeDriverRedisConfiguration) NewDeduplicator() (dedupe.Deduplicator, error) { + redisClient, err := c.NewClient() + if err != nil { + return nil, fmt.Errorf("failed to initialize redis client: %w", err) + } + + // TODO: register health check for redis + return redisdedupe.Deduplicator{ + Redis: redisClient, + Expiration: c.Expiration, + Mode: c.Mode, + }, nil +} + +func (c DedupeDriverRedisConfiguration) Validate() error { + var errs []error + + if err := c.Config.Validate(); err != nil { + errs = append(errs, errorsx.WithPrefix(err, "redis")) + } + + if err := c.Mode.Validate(); err != nil { + errs = append(errs, errorsx.WithPrefix(err, "mode")) + } + + return errors.Join(errs...) +} + +// ConfigureDedupe configures some defaults in the Viper instance. +func ConfigureDedupe(v *viper.Viper) { + v.SetDefault("dedupe.enabled", false) + v.SetDefault("dedupe.driver", "memory") + + // Memory driver + v.SetDefault("dedupe.config.size", 128) + + // Redis driver + redis.Configure(v, "dedupe.config") + v.SetDefault("sink.dedupe.config.mode", redisdedupe.DedupeModeRawKey) + v.SetDefault("dedupe.config.mode", redisdedupe.DedupeModeRawKey) +} diff --git a/app/config/entitlements.go b/app/config/entitlements.go new file mode 100644 index 0000000000000000000000000000000000000000..54d66c89c4d5c5b2f509f5bc274fad4437d01cbb --- /dev/null +++ b/app/config/entitlements.go @@ -0,0 +1,46 @@ +package config + +import ( + "errors" + "log/slog" + + "github.com/spf13/pflag" + "github.com/spf13/viper" + + "github.com/openmeterio/openmeter/pkg/datetime" +) + +type EntitlementsConfiguration struct { + // GracePeriod represents a period of time in the current usage period during which values will not be snapshotted. + // This in effect results in late events being included during this period. + GracePeriod datetime.ISODurationString +} + +// Validate validates the configuration. +func (c EntitlementsConfiguration) Validate() error { + if c.GracePeriod == "" { + return errors.New("gracePeriod is required") + } + + if p, err := c.GracePeriod.Parse(); err != nil { + return errors.New("gracePeriod is invalid") + } else if p.Sign() != 1 { + return errors.New("gracePeriod must be positive") + } + + return nil +} + +func (c *EntitlementsConfiguration) GetGracePeriod() datetime.ISODuration { + gracePeriod, err := c.GracePeriod.Parse() + if err != nil { + slog.Error("failed to parse grace period, using default of 1 day", "error", err) + return datetime.NewISODuration(0, 0, 0, 1, 0, 0, 0) + } + return gracePeriod +} + +func ConfigureEntitlements(v *viper.Viper, flags *pflag.FlagSet) { + // Let's set the default grace period to 1 day so late events can arrive up to a day late and be included in balance calculations + v.SetDefault("entitlements.gracePeriod", "P1D") +} diff --git a/app/config/events.go b/app/config/events.go new file mode 100644 index 0000000000000000000000000000000000000000..36393fdfc2c017e1a0e5e43901b3629daea29fe0 --- /dev/null +++ b/app/config/events.go @@ -0,0 +1,241 @@ +package config + +import ( + "errors" + "time" + + "github.com/samber/lo" + "github.com/spf13/viper" + + "github.com/openmeterio/openmeter/openmeter/watermill/eventbus" + "github.com/openmeterio/openmeter/pkg/errorsx" +) + +type EventsConfiguration struct { + SystemEvents EventSubsystemConfiguration + IngestEvents EventSubsystemConfiguration + BalanceWorkerEvents EventSubsystemConfiguration +} + +func (c EventsConfiguration) Validate() error { + var errs []error + + if err := c.SystemEvents.Validate(); err != nil { + errs = append(errs, errorsx.WithPrefix(err, "system events")) + } + + if err := c.IngestEvents.Validate(); err != nil { + errs = append(errs, errorsx.WithPrefix(err, "ingest events")) + } + + if err := c.BalanceWorkerEvents.Validate(); err != nil { + errs = append(errs, errorsx.WithPrefix(err, "balance worker events")) + } + + // Validate topic uniqueness + uniqueTopics := lo.Uniq([]string{c.SystemEvents.Topic, c.IngestEvents.Topic, c.BalanceWorkerEvents.Topic}) + if len(uniqueTopics) != 3 { + errs = append(errs, errors.New("topic names must be unique")) + } + + return errors.Join(errs...) +} + +func (c EventsConfiguration) EventBusTopicMapping() eventbus.TopicMapping { + return eventbus.TopicMapping{ + IngestEventsTopic: c.IngestEvents.Topic, + SystemEventsTopic: c.SystemEvents.Topic, + BalanceWorkerEventsTopic: c.BalanceWorkerEvents.Topic, + } +} + +type EventSubsystemConfiguration struct { + Topic string + + AutoProvision AutoProvisionConfiguration +} + +func (c EventSubsystemConfiguration) Validate() error { + var errs []error + + if c.Topic == "" { + errs = append(errs, errors.New("topic name is required")) + } + + if err := c.AutoProvision.Validate(); err != nil { + errs = append(errs, errorsx.WithPrefix(err, "auto provision")) + } + + return errors.Join(errs...) +} + +type AutoProvisionConfiguration struct { + Enabled bool + Partitions int + DLQRetention time.Duration +} + +func (c AutoProvisionConfiguration) Validate() error { + if !c.Enabled { + return nil + } + + var errs []error + + if c.Partitions < 1 { + errs = append(errs, errors.New("partitions must be greater than 0")) + } + + return errors.Join(errs...) +} + +type ConsumerConfiguration struct { + // ProcessingTimeout is the maximum time a message is allowed to be processed before it is considered failed. 0 disables the timeout. + ProcessingTimeout time.Duration + + // Retry specifies how many times a message should be retried before it is sent to the DLQ. + Retry RetryConfiguration + + // ConsumerGroupName is the name of the consumer group that the consumer belongs to. + ConsumerGroupName string + + // DLQ specifies the configuration for the Dead Letter Queue. + DLQ DLQConfiguration +} + +func (c ConsumerConfiguration) Validate() error { + var errs []error + + if c.ProcessingTimeout < 0 { + errs = append(errs, errors.New("processing timeout must be positive or 0")) + } + + if c.ConsumerGroupName == "" { + errs = append(errs, errors.New("consumer group name is required")) + } + + if err := c.Retry.Validate(); err != nil { + errs = append(errs, errorsx.WithPrefix(err, "retry")) + } + + if err := c.DLQ.Validate(); err != nil { + errs = append(errs, errorsx.WithPrefix(err, "dlq")) + } + + return errors.Join(errs...) +} + +type DLQConfiguration struct { + Enabled bool + Topic string + AutoProvision DLQAutoProvisionConfiguration +} + +func (c DLQConfiguration) Validate() error { + if !c.Enabled { + return nil + } + + var errs []error + + if c.Topic == "" { + errs = append(errs, errors.New("topic name is required")) + } + + if err := c.AutoProvision.Validate(); err != nil { + errs = append(errs, errorsx.WithPrefix(err, "auto provision")) + } + + return errors.Join(errs...) +} + +type DLQAutoProvisionConfiguration struct { + Enabled bool + Partitions int + Retention time.Duration +} + +func (c DLQAutoProvisionConfiguration) Validate() error { + if !c.Enabled { + return nil + } + + var errs []error + + if c.Partitions < 1 { + errs = append(errs, errors.New("partitions must be greater than 0")) + } + + if c.Retention <= 0 { + errs = append(errs, errors.New("retention must be greater than 0")) + } + + return errors.Join(errs...) +} + +type RetryConfiguration struct { + // MaxRetries is maximum number of times a retry will be attempted. Disabled if 0 + MaxRetries int + // InitialInterval is the first interval between retries. Subsequent intervals will be scaled by Multiplier. + InitialInterval time.Duration + // MaxInterval sets the limit for the exponential backoff of retries. The interval will not be increased beyond MaxInterval. + MaxInterval time.Duration + // MaxElapsedTime sets the time limit of how long retries will be attempted. Disabled if 0. + MaxElapsedTime time.Duration +} + +func (c RetryConfiguration) Validate() error { + var errs []error + + if c.MaxRetries < 0 { + errs = append(errs, errors.New("max retries must be positive or 0")) + } + + if c.MaxElapsedTime < 0 { + errs = append(errs, errors.New("max elapsed time must be positive or 0")) + } + + if c.InitialInterval <= 0 { + errs = append(errs, errors.New("initial interval must be greater than 0")) + } + + if c.MaxInterval <= 0 { + errs = append(errs, errors.New("max interval must be greater than 0")) + } + + if c.MaxElapsedTime > 0 && c.MaxRetries == 0 { + errs = append(errs, errors.New("max elapsed time is set but max retries is disabled, set max retries to enable retries")) + } + + return errors.Join(errs...) +} + +func ConfigureConsumer(v *viper.Viper, prefix string) { + v.SetDefault(prefix+".processingTimeout", 30*time.Second) + + v.SetDefault(prefix+".retry.maxRetries", 10) + v.SetDefault(prefix+".retry.initialInterval", 10*time.Millisecond) + v.SetDefault(prefix+".retry.maxInterval", time.Second) + v.SetDefault(prefix+".retry.maxElapsedTime", time.Minute) + + v.SetDefault(prefix+".dlq.enabled", true) + v.SetDefault(prefix+".dlq.autoProvision.enabled", true) + v.SetDefault(prefix+".dlq.autoProvision.partitions", 1) + v.SetDefault(prefix+".dlq.autoProvision.retention", 90*24*time.Hour) +} + +func ConfigureEvents(v *viper.Viper) { + // TODO: after the system events are fully implemented, we should enable them by default + v.SetDefault("events.enabled", false) + v.SetDefault("events.systemEvents.topic", "om_sys.api_events") + v.SetDefault("events.systemEvents.autoProvision.enabled", true) + v.SetDefault("events.systemEvents.autoProvision.partitions", 4) + + v.SetDefault("events.ingestEvents.topic", "om_sys.ingest_events") + v.SetDefault("events.ingestEvents.autoProvision.enabled", true) + v.SetDefault("events.ingestEvents.autoProvision.partitions", 8) + + v.SetDefault("events.balanceWorkerEvents.topic", "om_sys.balance_worker_events") + v.SetDefault("events.balanceWorkerEvents.autoProvision.enabled", true) + v.SetDefault("events.balanceWorkerEvents.autoProvision.partitions", 4) +} diff --git a/app/config/featuregate.go b/app/config/featuregate.go new file mode 100644 index 0000000000000000000000000000000000000000..5a54f546668212ff85204de1d0311f2767a12067 --- /dev/null +++ b/app/config/featuregate.go @@ -0,0 +1,26 @@ +package config + +import ( + "github.com/spf13/viper" + + "github.com/openmeterio/openmeter/pkg/featuregate" +) + +type FeatureGateConfiguration struct { + Enabled bool `yaml:"enabled"` + Flags featuregate.Flags `yaml:"flags"` +} + +func (c FeatureGateConfiguration) Validate() error { + if !c.Enabled { + return nil + } + return c.Flags.Validate() +} + +func ConfigureFeatureGate(v *viper.Viper, prefixes ...string) { + prefixer := NewViperKeyPrefixer(prefixes...) + + v.SetDefault(prefixer("enabled"), false) + v.SetDefault(prefixer("flags"), make(featuregate.Flags)) +} diff --git a/app/config/helpers.go b/app/config/helpers.go new file mode 100644 index 0000000000000000000000000000000000000000..5fce99a1e98f192bad3d67127b29d7775571a442 --- /dev/null +++ b/app/config/helpers.go @@ -0,0 +1,13 @@ +package config + +import "strings" + +// AddPrefix returns string with "." prepended to key. +// If returns key unmodified if prefix is empty or key already has the prefix added. +func AddPrefix(prefix, key string) string { + if prefix == "" || strings.HasPrefix(key, prefix+".") { + return key + } + + return prefix + "." + key +} diff --git a/app/config/ingest.go b/app/config/ingest.go new file mode 100644 index 0000000000000000000000000000000000000000..410d18432e4b928db2e895efe38eb5c3a41fd306 --- /dev/null +++ b/app/config/ingest.go @@ -0,0 +1,185 @@ +package config + +import ( + "errors" + "strings" + "time" + + "github.com/confluentinc/confluent-kafka-go/v2/kafka" + "github.com/spf13/viper" + + "github.com/openmeterio/openmeter/pkg/errorsx" + pkgkafka "github.com/openmeterio/openmeter/pkg/kafka" +) + +type IngestConfiguration struct { + Kafka KafkaIngestConfiguration +} + +// Validate validates the configuration. +func (c IngestConfiguration) Validate() error { + var errs []error + + if err := c.Kafka.Validate(); err != nil { + errs = append(errs, errorsx.WithPrefix(err, "kafka")) + } + + return errors.Join(errs...) +} + +type KafkaIngestConfiguration struct { + KafkaConfiguration `mapstructure:",squash"` + + TopicProvisioner TopicProvisionerConfig + + Partitions int + EventsTopicTemplate string + + // NamespaceDeletionEnabled defines whether deleting namespaces are allowed or not. + NamespaceDeletionEnabled bool +} + +// Validate validates the configuration. +func (c KafkaIngestConfiguration) Validate() error { + var errs []error + + if c.EventsTopicTemplate == "" { + errs = append(errs, errors.New("events topic template is required")) + } + + if err := c.KafkaConfiguration.Validate(); err != nil { + errs = append(errs, err) + } + + if err := c.TopicProvisioner.Validate(); err != nil { + errs = append(errs, err) + } + + return errors.Join(errs...) +} + +type KafkaConfiguration struct { + Broker string + SecurityProtocol string + SaslMechanisms string + SaslUsername string + SaslPassword string + + StatsInterval pkgkafka.TimeDurationMilliSeconds + + // BrokerAddressFamily defines the IP address family to be used for network communication with Kafka cluster + BrokerAddressFamily pkgkafka.BrokerAddressFamily + // SocketKeepAliveEnable defines if TCP socket keep-alive is enabled to prevent closing idle connections + // by Kafka brokers. + SocketKeepAliveEnabled bool + // TopicMetadataRefreshInterval defines how frequently the Kafka client needs to fetch metadata information + // (brokers, topic, partitions, etc) from the Kafka cluster. + // The 5 minutes default value is appropriate for mostly static Kafka clusters, but needs to be lowered + // in case of large clusters where changes are more frequent. + // This value must not be set to value lower than 10s. + TopicMetadataRefreshInterval pkgkafka.TimeDurationMilliSeconds + + // Enable contexts for extensive debugging of librdkafka. + // See: https://github.com/confluentinc/librdkafka/blob/master/INTRODUCTION.md#debug-contexts + DebugContexts pkgkafka.DebugContexts +} + +func (c KafkaConfiguration) Validate() error { + var errs []error + + if c.Broker == "" { + errs = append(errs, errors.New("broker is required")) + } + + if c.StatsInterval > 0 && c.StatsInterval.Duration() < 5*time.Second { + errs = append(errs, errors.New("StatsInterval must be >=5s")) + } + + if c.TopicMetadataRefreshInterval > 0 && c.TopicMetadataRefreshInterval.Duration() < 10*time.Second { + errs = append(errs, errors.New("topic metadata refresh interval must be >=10s")) + } + + return errors.Join(errs...) +} + +// CreateKafkaConfig creates a Kafka config map. +func (c KafkaConfiguration) CreateKafkaConfig() kafka.ConfigMap { + config := kafka.ConfigMap{ + "bootstrap.servers": c.Broker, + + // Required for logging + "go.logs.channel.enable": true, + } + + // This is needed when using localhost brokers on OSX, + // since the OSX resolver will return the IPv6 addresses first. + // See: https://github.com/openmeterio/openmeter/issues/321 + if c.BrokerAddressFamily != "" { + config["broker.address.family"] = c.BrokerAddressFamily + } else if strings.Contains(c.Broker, "localhost") || strings.Contains(c.Broker, "127.0.0.1") { + config["broker.address.family"] = pkgkafka.BrokerAddressFamilyIPv4 + } + + if c.SecurityProtocol != "" { + config["security.protocol"] = c.SecurityProtocol + } + + if c.SaslMechanisms != "" { + config["sasl.mechanism"] = c.SaslMechanisms + } + + if c.SaslUsername != "" { + config["sasl.username"] = c.SaslUsername + } + + if c.SaslPassword != "" { + config["sasl.password"] = c.SaslPassword + } + + if c.StatsInterval > 0 { + config["statistics.interval.ms"] = c.StatsInterval + } + + if c.SocketKeepAliveEnabled { + config["socket.keepalive.enable"] = c.SocketKeepAliveEnabled + } + + // The `topic.metadata.refresh.interval.ms` defines the frequency the Kafka client needs to retrieve metadata + // from Kafka cluster. While `metadata.max.age.ms` defines the interval after the metadata cache maintained + // on client side becomes invalid. Setting the former will automatically adjust the value of the latter to avoid + // misconfiguration where the entries in metadata cache are evicted prior metadata refresh. + if c.TopicMetadataRefreshInterval > 0 { + config["topic.metadata.refresh.interval.ms"] = c.TopicMetadataRefreshInterval + config["metadata.max.age.ms"] = 3 * c.TopicMetadataRefreshInterval + } + + if len(c.DebugContexts) > 0 { + config["debug"] = c.DebugContexts.String() + } + + return config +} + +func ConfigureIngestKafkaConfiguration(v *viper.Viper, prefixes ...string) { + prefixer := NewViperKeyPrefixer(prefixes...) + + v.SetDefault(prefixer("kafka.broker"), "127.0.0.1:29092") + v.SetDefault(prefixer("kafka.securityProtocol"), "") + v.SetDefault(prefixer("kafka.saslMechanisms"), "") + v.SetDefault(prefixer("kafka.saslUsername"), "") + v.SetDefault(prefixer("kafka.saslPassword"), "") + v.SetDefault(prefixer("kafka.statsInterval"), 15*time.Second) + v.SetDefault(prefixer("kafka.brokerAddressFamily"), "v4") + v.SetDefault(prefixer("kafka.socketKeepAliveEnabled"), true) + v.SetDefault(prefixer("kafka.topicMetadataRefreshInterval"), time.Minute) +} + +// Configure configures some defaults in the Viper instance. +func ConfigureIngest(v *viper.Viper) { + v.SetDefault("ingest.kafka.partitions", 1) + v.SetDefault("ingest.kafka.eventsTopicTemplate", "om_%s_events") + v.SetDefault("ingest.kafka.namespaceDeletionEnabled", false) + + ConfigureIngestKafkaConfiguration(v, "ingest") + ConfigureTopicProvisioner(v, "ingest", "kafka", "topicProvisioner") +} diff --git a/app/config/ingest_test.go b/app/config/ingest_test.go new file mode 100644 index 0000000000000000000000000000000000000000..9eba1505997c3a76227ce6d71a22d9acbd0ae06b --- /dev/null +++ b/app/config/ingest_test.go @@ -0,0 +1,72 @@ +package config + +import ( + "testing" + "time" + + "github.com/confluentinc/confluent-kafka-go/v2/kafka" + "github.com/stretchr/testify/assert" + + pkgkafka "github.com/openmeterio/openmeter/pkg/kafka" +) + +func TestKafkaIngestConfiguration(t *testing.T) { + tests := []struct { + Name string + + KafkaConfig KafkaConfiguration + ExpectedKafkaConfigMap kafka.ConfigMap + }{ + { + Name: "All", + KafkaConfig: KafkaConfiguration{ + Broker: "127.0.0.1:29092", + SecurityProtocol: "SASL_SSL", + SaslMechanisms: "PLAIN", + SaslUsername: "user", + SaslPassword: "pass", + StatsInterval: pkgkafka.TimeDurationMilliSeconds(10 * time.Second), + BrokerAddressFamily: "v6", + SocketKeepAliveEnabled: true, + TopicMetadataRefreshInterval: pkgkafka.TimeDurationMilliSeconds(time.Minute), + DebugContexts: pkgkafka.DebugContexts{ + "broker", + "topic", + "consumer", + }, + }, + ExpectedKafkaConfigMap: kafka.ConfigMap{ + "bootstrap.servers": "127.0.0.1:29092", + "broker.address.family": pkgkafka.BrokerAddressFamilyIPv6, + "go.logs.channel.enable": true, + "metadata.max.age.ms": pkgkafka.TimeDurationMilliSeconds(3 * time.Minute), + "sasl.mechanism": "PLAIN", + "sasl.password": "pass", + "sasl.username": "user", + "security.protocol": "SASL_SSL", + "socket.keepalive.enable": true, + "statistics.interval.ms": pkgkafka.TimeDurationMilliSeconds(10 * time.Second), + "topic.metadata.refresh.interval.ms": pkgkafka.TimeDurationMilliSeconds(time.Minute), + "debug": "broker,topic,consumer", + }, + }, + { + Name: "Basic", + KafkaConfig: KafkaConfiguration{ + Broker: "127.0.0.1:29092", + }, + ExpectedKafkaConfigMap: kafka.ConfigMap{ + "bootstrap.servers": "127.0.0.1:29092", + "broker.address.family": pkgkafka.BrokerAddressFamilyIPv4, + "go.logs.channel.enable": true, + }, + }, + } + + for _, test := range tests { + t.Run(test.Name, func(t *testing.T) { + config := test.KafkaConfig.CreateKafkaConfig() + assert.Equal(t, test.ExpectedKafkaConfigMap, config) + }) + } +} diff --git a/app/config/kafka.go b/app/config/kafka.go new file mode 100644 index 0000000000000000000000000000000000000000..41373e254eb09eaab2010fa5b1d97f0a771db0aa --- /dev/null +++ b/app/config/kafka.go @@ -0,0 +1,80 @@ +package config + +import ( + "errors" + + "github.com/spf13/viper" + + pkgkafka "github.com/openmeterio/openmeter/pkg/kafka" +) + +var _ pkgkafka.ConfigValidator = (*KafkaConfig)(nil) + +type KafkaConfig struct { + pkgkafka.CommonConfigParams `mapstructure:",squash"` + pkgkafka.ConsumerConfigParams `mapstructure:",squash"` + pkgkafka.ProducerConfigParams `mapstructure:",squash"` +} + +func (c KafkaConfig) AsProducerConfig() pkgkafka.ProducerConfig { + return pkgkafka.ProducerConfig{ + CommonConfigParams: c.CommonConfigParams, + ProducerConfigParams: c.ProducerConfigParams, + } +} + +func (c KafkaConfig) AsConsumerConfig() pkgkafka.ConsumerConfig { + return pkgkafka.ConsumerConfig{ + CommonConfigParams: c.CommonConfigParams, + ConsumerConfigParams: c.ConsumerConfigParams, + } +} + +func (c KafkaConfig) AsAdminConfig() pkgkafka.AdminConfig { + return pkgkafka.AdminConfig{ + CommonConfigParams: c.CommonConfigParams, + } +} + +func (c KafkaConfig) Validate() error { + validators := []pkgkafka.ConfigValidator{ + c.CommonConfigParams, + c.ConsumerConfigParams, + c.ProducerConfigParams, + } + + var errs []error + + for _, validator := range validators { + if err := validator.Validate(); err != nil { + errs = append(errs, err) + } + } + + return errors.Join(errs...) +} + +// ConfigureKafkaConfiguration sets defaults in the Viper instance. +func ConfigureKafkaConfiguration(v *viper.Viper, prefix string) { + // NOTE(chrisgacsal): make sure all the possible configuration parameters defaulted (even of the default is an empty string) + // otherwise Viper might not register/resolve them. + v.SetDefault(AddPrefix(prefix, "kafka.brokers"), "127.0.0.1:29092") + v.SetDefault(AddPrefix(prefix, "kafka.securityProtocol"), "") + v.SetDefault(AddPrefix(prefix, "kafka.saslMechanisms"), "") + v.SetDefault(AddPrefix(prefix, "kafka.saslUsername"), "") + v.SetDefault(AddPrefix(prefix, "kafka.saslPassword"), "") + v.SetDefault(AddPrefix(prefix, "kafka.statsInterval"), 0) + v.SetDefault(AddPrefix(prefix, "kafka.brokerAddressFamily"), nil) + v.SetDefault(AddPrefix(prefix, "kafka.topicMetadataRefreshInterval"), 0) + v.SetDefault(AddPrefix(prefix, "kafka.socketKeepAliveEnabled"), false) + v.SetDefault(AddPrefix(prefix, "kafka.debugContexts"), nil) + v.SetDefault(AddPrefix(prefix, "kafka.clientID"), "") + v.SetDefault(AddPrefix(prefix, "kafka.consumerGroupID"), "") + v.SetDefault(AddPrefix(prefix, "kafka.consumerGroupInstanceID"), "") + v.SetDefault(AddPrefix(prefix, "kafka.sessionTimeout"), "9s") + v.SetDefault(AddPrefix(prefix, "kafka.heartbeatInterval"), 0) + v.SetDefault(AddPrefix(prefix, "kafka.enableAutoCommit"), true) + v.SetDefault(AddPrefix(prefix, "kafka.enableAutoOffsetStore"), true) + v.SetDefault(AddPrefix(prefix, "kafka.autoOffsetReset"), "largest") + v.SetDefault(AddPrefix(prefix, "kafka.partitionAssignmentStrategy"), "cooperative-sticky") +} diff --git a/app/config/namespace.go b/app/config/namespace.go new file mode 100644 index 0000000000000000000000000000000000000000..efbc3b322092c30b1ca0d92a3ad1a9da229f6352 --- /dev/null +++ b/app/config/namespace.go @@ -0,0 +1,29 @@ +package config + +import ( + "errors" + + "github.com/spf13/viper" +) + +// Namespace configuration +type NamespaceConfiguration struct { + Default string + DisableManagement bool +} + +func (c NamespaceConfiguration) Validate() error { + var errs []error + + if c.Default == "" { + errs = append(errs, errors.New("default namespace is required")) + } + + return errors.Join(errs...) +} + +// ConfigureNamespace configures some defaults in the Viper instance. +func ConfigureNamespace(v *viper.Viper) { + v.SetDefault("namespace.default", "default") + v.SetDefault("namespace.disableManagement", false) +} diff --git a/app/config/notification.go b/app/config/notification.go new file mode 100644 index 0000000000000000000000000000000000000000..f698c444bb648f8953bf7862552eaaaf540f0947 --- /dev/null +++ b/app/config/notification.go @@ -0,0 +1,60 @@ +package config + +import ( + "errors" + "time" + + "github.com/spf13/viper" + + "github.com/openmeterio/openmeter/openmeter/notification" + "github.com/openmeterio/openmeter/openmeter/notification/webhook" + "github.com/openmeterio/openmeter/pkg/errorsx" + "github.com/openmeterio/openmeter/pkg/pglockx" +) + +type WebhookConfiguration struct { + // Timeout for registering event types in webhook provider + EventTypeRegistrationTimeout time.Duration + // Skip registering event types on unsuccessful attempt instead of returning with error + SkipEventTypeRegistrationOnError bool +} + +type NotificationConfiguration struct { + Consumer ConsumerConfiguration + + Webhook WebhookConfiguration + + // EventHandler configuration + ReconcileInterval time.Duration + SendingTimeout time.Duration + PendingTimeout time.Duration + + ReconcilerWorkers int + + Lock pglockx.Config +} + +func (c NotificationConfiguration) Validate() error { + var errs []error + + if err := c.Consumer.Validate(); err != nil { + errs = append(errs, errorsx.WithPrefix(err, "consumer")) + } + + return errors.Join(errs...) +} + +func ConfigureNotification(v *viper.Viper) { + ConfigureConsumer(v, "notification.consumer") + v.SetDefault("notification.consumer.dlq.topic", "om_sys.notification_service_dlq") + v.SetDefault("notification.consumer.consumerGroupName", "om_notification_service") + v.SetDefault("notification.webhook.eventTypeRegistrationTimeout", webhook.DefaultRegistrationTimeout) + v.SetDefault("notification.webhook.skipEventTypeRegistrationOnError", false) + v.SetDefault("notification.reconcileInterval", notification.DefaultReconcileInterval) + v.SetDefault("notification.sendingTimeout", notification.DefaultDeliveryStateSendingTimeout) + v.SetDefault("notification.pendingTimeout", notification.DefaultDeliveryStatePendingTimeout) + v.SetDefault("notification.reconcilerWorkers", notification.DefaultReconcilerWorkers) + v.SetDefault("notification.lock.leaseTime", pglockx.DefaultLeaseTime) + v.SetDefault("notification.lock.heartbeat", pglockx.DefaultHeartbeatInterval) + v.SetDefault("notification.lock.owner", "") +} diff --git a/app/config/portal.go b/app/config/portal.go new file mode 100644 index 0000000000000000000000000000000000000000..50adacf87b6e94b1ed49daf1fb1a41ec165d4a8c --- /dev/null +++ b/app/config/portal.go @@ -0,0 +1,46 @@ +package config + +import ( + "errors" + "time" + + "github.com/spf13/viper" +) + +type PortalCORSConfiguration struct { + Enabled bool `mapstructure:"enabled"` +} + +type PortalConfiguration struct { + Enabled bool `mapstructure:"enabled"` + CORS PortalCORSConfiguration `mapstructure:"cors"` + TokenSecret string `mapstructure:"tokenSecret"` + TokenExpiration time.Duration `mapstructure:"tokenExpiration"` +} + +// Validate validates the configuration. +func (c PortalConfiguration) Validate() error { + if !c.Enabled { + return nil + } + + var errs []error + + if c.TokenSecret == "" { + errs = append(errs, errors.New("token secret is required")) + } + + if c.TokenExpiration.Seconds() == 0 { + errs = append(errs, errors.New("token duration is required")) + } + + return errors.Join(errs...) +} + +// ConfigurePortal configures some defaults in the Viper instance. +func ConfigurePortal(v *viper.Viper) { + v.SetDefault("portal.enabled", false) + v.SetDefault("portal.cors.enabled", true) + v.SetDefault("portal.tokenSecret", "") + v.SetDefault("portal.tokenExpiration", "1h") +} diff --git a/app/config/postgres.go b/app/config/postgres.go new file mode 100644 index 0000000000000000000000000000000000000000..a7f110b582b4f6e39763cafeee19dac3acbaacf7 --- /dev/null +++ b/app/config/postgres.go @@ -0,0 +1,176 @@ +package config + +import ( + "errors" + "fmt" + "net/url" + "strconv" + + "github.com/samber/lo" + "github.com/spf13/viper" +) + +type PostgresConfig struct { + // PostgresConnectionParams is the PostgreSQL connection parameters, URL and PostgresConnectionParams are mutually exclusive. + PostgresConnectionParams `mapstructure:",squash"` + + // URL is the PostgreSQL database connection URL. + URL string `yaml:"url"` + + // AutoMigrate is a flag that indicates whether the database should be automatically migrated. + // Supported values are: + // - "false" to disable auto-migration at startup + // - "migration" to use the migrations directory + // - "migration-job" to wait for a separate migration job + AutoMigrate AutoMigrate `yaml:"autoMigrate"` +} + +// Validate validates the configuration. +func (c PostgresConfig) Validate() error { + var errs []error + if c.URL == "" && c.PostgresConnectionParams.IsEmpty() { + errs = append(errs, errors.New("database URL or connection params are required")) + } + + if c.URL != "" && !c.PostgresConnectionParams.IsEmpty() { + errs = append(errs, errors.New("database URL and connection params are mutually exclusive")) + } + + if err := c.AutoMigrate.Validate(); err != nil { + errs = append(errs, err) + } + + return errors.Join(errs...) +} + +func (c PostgresConfig) AsURL() string { + if c.URL != "" { + return c.URL + } + return c.PostgresConnectionParams.AsURL() +} + +func ConfigurePostgres(v *viper.Viper, prefix string) { + v.SetDefault(AddPrefix(prefix, "url"), "") + v.SetDefault(AddPrefix(prefix, "options.poolMaxConns"), 0) + v.SetDefault(AddPrefix(prefix, "options.applicationName"), "") + v.SetDefault(AddPrefix(prefix, "options.sslVerify"), "") + v.SetDefault(AddPrefix(prefix, "options.sslRootCert"), "") + v.SetDefault(AddPrefix(prefix, "host"), "") + v.SetDefault(AddPrefix(prefix, "port"), 0) + v.SetDefault(AddPrefix(prefix, "database"), "") + v.SetDefault(AddPrefix(prefix, "user"), "") + v.SetDefault(AddPrefix(prefix, "password"), "") +} + +type AutoMigrate string + +const ( + AutoMigrateMigration AutoMigrate = "migration" + AutoMigrateMigrationJob AutoMigrate = "migration-job" + AutoMigrateOff AutoMigrate = "false" +) + +func (a AutoMigrate) Enabled() bool { + // For all other values it's enabled + return a != "false" +} + +func (a AutoMigrate) Validate() error { + switch a { + case AutoMigrateMigration, AutoMigrateMigrationJob, AutoMigrateOff: + return nil + case "ent": + return errors.New("ent auto-migration is no longer supported; run 'openmeter-jobs migrate adopt-ent' once, then run the normal migration (see docs/database-migration.md)") + default: + return errors.New("invalid auto-migrate value") + } +} + +type PostgresConnectionParams struct { + Host string `yaml:"host"` + Port uint16 `yaml:"port"` + Database string `yaml:"database"` + User string `yaml:"user"` + Password string `yaml:"password"` + + Options PostgresConnectionOptions `yaml:"options"` +} + +func (c PostgresConnectionParams) Validate() error { + var errs []error + + if c.Host == "" { + errs = append(errs, errors.New("host is required")) + } + + if c.Database == "" { + errs = append(errs, errors.New("database is required")) + } + + if c.User == "" { + errs = append(errs, errors.New("user is required")) + } + + if err := c.Options.Validate(); err != nil { + errs = append(errs, err) + } + + return errors.Join(errs...) +} + +func (c PostgresConnectionParams) IsEmpty() bool { + return lo.IsEmpty(c) +} + +func (c PostgresConnectionParams) AsURL() string { + host := c.Host + if c.Port != 0 { + host = fmt.Sprintf("%s:%d", c.Host, c.Port) + } + + runtimeParams := make(url.Values) + + if c.Options.ApplicationName != "" { + runtimeParams.Set("application_name", c.Options.ApplicationName) + } + + if c.Options.PoolMaxConns != 0 { + runtimeParams.Set("pool_max_conns", strconv.Itoa(c.Options.PoolMaxConns)) + } + + if c.Options.SSLVerify != "" { + runtimeParams.Set("sslmode", c.Options.SSLVerify) + } + + if c.Options.SSLRootCert != "" { + runtimeParams.Set("sslrootcert", c.Options.SSLRootCert) + } + + url := url.URL{ + Scheme: "postgresql", + User: url.UserPassword(c.User, c.Password), + Host: host, + Path: c.Database, + RawQuery: runtimeParams.Encode(), + } + + return url.String() +} + +type PostgresConnectionOptions struct { + PoolMaxConns int `yaml:"poolMaxConns"` + ApplicationName string `yaml:"applicationName"` + SSLVerify string `yaml:"sslVerify"` + SSLRootCert string `yaml:"sslRootCert"` +} + +func (c PostgresConnectionOptions) Validate() error { + var errs []error + + if c.PoolMaxConns < 0 { + errs = append(errs, errors.New("poolMaxConns must be greater than 0")) + } + + return errors.Join(errs...) +} diff --git a/app/config/productcatalog.go b/app/config/productcatalog.go new file mode 100644 index 0000000000000000000000000000000000000000..69b583c19af23485dcc7301cb8855bde60b5ca2f --- /dev/null +++ b/app/config/productcatalog.go @@ -0,0 +1,18 @@ +package config + +import "github.com/spf13/viper" + +type ProductCatalogConfiguration struct { + Subscription SubscriptionConfiguration +} + +func (c ProductCatalogConfiguration) Validate() error { + return nil +} + +func ConfigureProductCatalog(v *viper.Viper) { +} + +type SubscriptionConfiguration struct { + MultiSubscriptionNamespaces []string +} diff --git a/app/config/progressmanager.go b/app/config/progressmanager.go new file mode 100644 index 0000000000000000000000000000000000000000..9ed5d055069f7d4df023a100f28294e736f3c5c7 --- /dev/null +++ b/app/config/progressmanager.go @@ -0,0 +1,45 @@ +package config + +import ( + "errors" + "time" + + "github.com/spf13/viper" + + "github.com/openmeterio/openmeter/pkg/errorsx" + "github.com/openmeterio/openmeter/pkg/redis" +) + +// ProgressManagerConfiguration stores the configuration parameters for the progress manager +type ProgressManagerConfiguration struct { + Enabled bool + KeyPrefix string + Expiration time.Duration + Redis redis.Config +} + +// Validate checks if the configuration is valid +func (c ProgressManagerConfiguration) Validate() error { + var errs []error + + if !c.Enabled { + return nil + } + + if c.Expiration <= 0 { + errs = append(errs, errors.New("expiration must be greater than 0")) + } + + if err := c.Redis.Validate(); err != nil { + errs = append(errs, errorsx.WithPrefix(err, "redis")) + } + + return errors.Join(errs...) +} + +// ConfigureProgressManager sets the default values for the progress manager configuration +func ConfigureProgressManager(v *viper.Viper) { + v.SetDefault("progressManager.expiration", "5m") + v.SetDefault("progressManager.keyPrefix", "") + redis.Configure(v, "progressManager.redis") +} diff --git a/app/config/server.go b/app/config/server.go new file mode 100644 index 0000000000000000000000000000000000000000..99b37cd5a6e61600d8aa62ff3b010df4468120cc --- /dev/null +++ b/app/config/server.go @@ -0,0 +1,178 @@ +package config + +import ( + "errors" + "fmt" + "net/http" + "net/netip" + "time" + + "github.com/samber/lo" + "github.com/spf13/viper" + + "github.com/openmeterio/openmeter/pkg/models" +) + +// ServerConfig holds HTTP server timeout configuration. +type ServerConfig struct { + ReadHeaderTimeout time.Duration + ReadTimeout time.Duration + WriteTimeout time.Duration + IdleTimeout time.Duration + + ResponseValidation ResponseValidationConfig + + ClientIPMiddleware ClientIPMiddlewareConfig +} + +// ResponseValidationConfig controls optional post-response OpenAPI validation on the v3 API. +type ResponseValidationConfig struct { + Mode ResponseValidationMode +} + +type ResponseValidationMode string + +const ( + // ResponseValidationModeOff disables response validation. This is the default. + ResponseValidationModeOff ResponseValidationMode = "off" + // ResponseValidationModeUnstable validates only routes marked x-unstable: true in the spec. + ResponseValidationModeUnstable ResponseValidationMode = "unstable" + // ResponseValidationModeAll validates every route in the v3 spec. + ResponseValidationModeAll ResponseValidationMode = "all" +) + +func (m ResponseValidationMode) Enabled() bool { + return m != "" && m != ResponseValidationModeOff +} + +func (m ResponseValidationMode) Validate() error { + switch m { + case "", ResponseValidationModeOff, ResponseValidationModeUnstable, ResponseValidationModeAll: + return nil + default: + return errors.New("invalid response validation mode (allowed: off, unstable, all)") + } +} + +type ClientIPSource string + +const ( + ClientIPSourceRemoteAddr ClientIPSource = "remote-address" + ClientIPSourceHeader ClientIPSource = "header" + ClientIPSourceXFF ClientIPSource = "x-forwarded-for" +) + +var _ models.Validator = (*ClientIPMiddlewareConfig)(nil) + +// ClientIPMiddlewareConfig configures the middleware that extracts the client IP address from the HTTP request. +// See: https://adam-p.ca/blog/2022/03/x-forwarded-for/ +type ClientIPMiddlewareConfig struct { + Source ClientIPSource + + // Header defines the header name in the HTTP request containing the real client IP address. + // Set this only if ClientIPSourceHeader is used as Source. + // Only use headers your proxy unconditionally overwrites on every request, + // e.g. "X-Real-IP" (Nginx ngx_http_realip_module), "CF-Connecting-IP" (Cloudflare), or "X-Client-IP" (Apache mod_remoteip). + // Pass-through headers like "True-Client-IP", "X-Azure-ClientIP", or "Fastly-Client-IP" are client-spoofable + // unless your edge strips the inbound value. + Header string + + // TrustedIPPrefixes lists IP prefixes for trusted proxies. + // Set this only if the ClientIPSourceXFF is used as Source. + TrustedIPPrefixes []string + + // TrustedProxies defines the number of trusted proxies. + // Set this only if the ClientIPSourceXFF is used as Source. + TrustedProxies int +} + +func (c ClientIPMiddlewareConfig) Validate() error { + switch c.Source { + case ClientIPSourceRemoteAddr: + return nil + case ClientIPSourceHeader: + if c.Header == "" { + return errors.New("missing client IP header") + } + + // chi's ClientIPFromHeader takes the LAST header value, which for the append-style + // X-Forwarded-For header is the nearest proxy hop, not the client. + if http.CanonicalHeaderKey(c.Header) == "X-Forwarded-For" { + return fmt.Errorf("X-Forwarded-For cannot be used as client IP header, use the %s source instead", ClientIPSourceXFF) + } + + return nil + case ClientIPSourceXFF: + if len(c.TrustedIPPrefixes) > 0 { + // Use the same parser as chi's ClientIPFromXFF (netip.MustParsePrefix), which is + // stricter than net.ParseCIDR; a mismatch would panic at middleware construction. + invalidPrefixes := lo.Filter(c.TrustedIPPrefixes, func(prefix string, _ int) bool { + _, err := netip.ParsePrefix(prefix) + + return err != nil + }) + + if len(invalidPrefixes) > 0 { + return fmt.Errorf("invalid trusted IP prefixes: %+v", invalidPrefixes) + } + + return nil + } + + // chi's ClientIPFromXFFTrustedProxies panics if the count is < 1. + if c.TrustedProxies < 1 { + return fmt.Errorf("either trusted IP prefixes or a positive number of trusted proxies must be set if real client IP source is set to %s", ClientIPSourceXFF) + } + + return nil + default: + return fmt.Errorf("invalid client IP source: %s", c.Source) + } +} + +func (c ServerConfig) Validate() error { + var errs []error + + if c.ReadHeaderTimeout < 0 { + errs = append(errs, errors.New("readHeaderTimeout must be non-negative")) + } + + if c.ReadTimeout < 0 { + errs = append(errs, errors.New("readTimeout must be non-negative")) + } + + if c.WriteTimeout < 0 { + errs = append(errs, errors.New("writeTimeout must be non-negative")) + } + + if c.IdleTimeout < 0 { + errs = append(errs, errors.New("idleTimeout must be non-negative")) + } + + if err := c.ResponseValidation.Mode.Validate(); err != nil { + errs = append(errs, err) + } + + if err := c.ClientIPMiddleware.Validate(); err != nil { + errs = append(errs, err) + } + + return errors.Join(errs...) +} + +// ConfigureServer sets defaults for HTTP server timeouts. +func ConfigureServer(v *viper.Viper, prefixes ...string) { + prefixer := NewViperKeyPrefixer(prefixes...) + + v.SetDefault(prefixer("readHeaderTimeout"), 10*time.Second) + v.SetDefault(prefixer("readTimeout"), 60*time.Second) + v.SetDefault(prefixer("writeTimeout"), 90*time.Second) + v.SetDefault(prefixer("idleTimeout"), 120*time.Second) + + v.SetDefault(prefixer("responseValidation.mode"), string(ResponseValidationModeOff)) + + v.SetDefault(prefixer("clientIPMiddleware.source"), ClientIPSourceRemoteAddr) + v.SetDefault(prefixer("clientIPMiddleware.header"), "") + v.SetDefault(prefixer("clientIPMiddleware.trustedIPPrefixes"), nil) + v.SetDefault(prefixer("clientIPMiddleware.trustedProxies"), 0) +} diff --git a/app/config/server_test.go b/app/config/server_test.go new file mode 100644 index 0000000000000000000000000000000000000000..0d2f7a6d9c2ebe8ae2ede8518b32096cf67b5197 --- /dev/null +++ b/app/config/server_test.go @@ -0,0 +1,91 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestClientIPMiddlewareConfigValidate(t *testing.T) { + tests := []struct { + name string + config ClientIPMiddlewareConfig + wantErr string + }{ + { + name: "empty source", + config: ClientIPMiddlewareConfig{}, + wantErr: "invalid client IP source", + }, + { + name: "unknown source", + config: ClientIPMiddlewareConfig{Source: "bogus"}, + wantErr: "invalid client IP source", + }, + { + name: "remote address source", + config: ClientIPMiddlewareConfig{Source: ClientIPSourceRemoteAddr}, + }, + { + name: "header source without header", + config: ClientIPMiddlewareConfig{Source: ClientIPSourceHeader}, + wantErr: "missing client IP header", + }, + { + name: "header source with overwrite-style header", + config: ClientIPMiddlewareConfig{Source: ClientIPSourceHeader, Header: "X-Real-IP"}, + }, + { + name: "header source with X-Forwarded-For", + config: ClientIPMiddlewareConfig{Source: ClientIPSourceHeader, Header: "X-Forwarded-For"}, + wantErr: "X-Forwarded-For cannot be used as client IP header", + }, + { + name: "header source with non-canonical X-Forwarded-For", + config: ClientIPMiddlewareConfig{Source: ClientIPSourceHeader, Header: "x-forwarded-for"}, + wantErr: "X-Forwarded-For cannot be used as client IP header", + }, + { + name: "xff source with valid prefixes", + config: ClientIPMiddlewareConfig{Source: ClientIPSourceXFF, TrustedIPPrefixes: []string{"10.0.0.0/8", "2600:9000::/28"}}, + }, + { + name: "xff source with invalid prefix", + config: ClientIPMiddlewareConfig{Source: ClientIPSourceXFF, TrustedIPPrefixes: []string{"not-a-cidr"}}, + wantErr: "invalid trusted IP prefixes", + }, + { + // net.ParseCIDR accepts this but chi's netip.MustParsePrefix panics on it. + name: "xff source with leading zero prefix bits", + config: ClientIPMiddlewareConfig{Source: ClientIPSourceXFF, TrustedIPPrefixes: []string{"10.0.0.0/08"}}, + wantErr: "invalid trusted IP prefixes", + }, + { + name: "xff source with trusted proxies", + config: ClientIPMiddlewareConfig{Source: ClientIPSourceXFF, TrustedProxies: 2}, + }, + { + name: "xff source with neither prefixes nor proxies", + config: ClientIPMiddlewareConfig{Source: ClientIPSourceXFF}, + wantErr: "either trusted IP prefixes or a positive number of trusted proxies", + }, + { + // chi's ClientIPFromXFFTrustedProxies panics if the count is < 1. + name: "xff source with negative trusted proxies", + config: ClientIPMiddlewareConfig{Source: ClientIPSourceXFF, TrustedProxies: -1}, + wantErr: "either trusted IP prefixes or a positive number of trusted proxies", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.config.Validate() + if tt.wantErr == "" { + require.NoError(t, err) + return + } + + require.ErrorContains(t, err, tt.wantErr) + }) + } +} diff --git a/app/config/sink.go b/app/config/sink.go new file mode 100644 index 0000000000000000000000000000000000000000..e14f74484fa390810f9933338413974b2b97d9bb --- /dev/null +++ b/app/config/sink.go @@ -0,0 +1,180 @@ +package config + +import ( + "errors" + "time" + + "github.com/spf13/viper" + + "github.com/openmeterio/openmeter/pkg/errorsx" +) + +type SinkConfiguration struct { + // FIXME(chrisgacsal): remove as it is deprecated by moving Kafka specific configuration to dedicated config params. + GroupId string + Dedupe DedupeConfiguration + MinCommitCount int + MaxCommitWait time.Duration + MaxPollTimeout time.Duration + NamespaceRefetch time.Duration + FlushSuccessTimeout time.Duration + DrainTimeout time.Duration + IngestNotifications IngestNotificationsConfiguration + // Kafka client/Consumer configuration + Kafka KafkaConfig + // TODO: remove, config moved to aggregation config + // Storage configuration + Storage StorageConfiguration + + // NamespaceRefetchTimeout is the timeout for updating namespaces and consumer subscription. + // It must be less than NamespaceRefetch interval. + NamespaceRefetchTimeout time.Duration + + // NamespaceTopicRegexp defines the regular expression to match/validate topic names the sink-worker needs to subscribe to. + NamespaceTopicRegexp string + + // MeterRefetchInterval is the interval to refetch meters from the database + MeterRefetchInterval time.Duration + + // LogDroppedEvents controls whether dropped events are logged + LogDroppedEvents bool +} + +func (c SinkConfiguration) Validate() error { + var errs []error + + if c.MinCommitCount < 1 { + errs = append(errs, errors.New("MinCommitCount must be greater than 0")) + } + + if c.MaxCommitWait == 0 { + errs = append(errs, errors.New("MaxCommitWait must be greater than 0")) + } + + if c.MaxPollTimeout == 0 { + errs = append(errs, errors.New("MaxPollTimeout must be greater than 0")) + } + + if c.NamespaceRefetch == 0 { + errs = append(errs, errors.New("NamespaceRefetch must be greater than 0")) + } + + if c.FlushSuccessTimeout == 0 { + errs = append(errs, errors.New("FlushSuccessTimeout must be greater than 0")) + } + + if c.DrainTimeout == 0 { + errs = append(errs, errors.New("DrainTimeout must be greater than 0")) + } + + if c.NamespaceRefetchTimeout != 0 && c.NamespaceRefetchTimeout > c.NamespaceRefetch { + errs = append(errs, errors.New("NamespaceRefetchTimeout must be less than or equal to NamespaceRefetch")) + } + + if c.NamespaceTopicRegexp == "" { + errs = append(errs, errors.New("NamespaceTopicRegexp must no be empty")) + } + + if err := c.IngestNotifications.Validate(); err != nil { + errs = append(errs, errorsx.WithPrefix(err, "ingest notifications")) + } + + if err := c.Kafka.Validate(); err != nil { + errs = append(errs, errorsx.WithPrefix(err, "kafka")) + } + + if c.MeterRefetchInterval <= 0 { + errs = append(errs, errors.New("MeterRefetchInterval must be greater than 0")) + } + + return errors.Join(errs...) +} + +type IngestNotificationsConfiguration struct { + MaxEventsInBatch int +} + +func (c IngestNotificationsConfiguration) Validate() error { + var errs []error + + if c.MaxEventsInBatch <= 0 { + errs = append(errs, errors.New("MaxEventsInBatch must be greater than 0")) + } + + if c.MaxEventsInBatch > 1000 { + errs = append(errs, errors.New("MaxEventsInBatch must not be greater than 1000")) + } + + return errors.Join(errs...) +} + +type StorageConfiguration struct { + // Set true for ClickHouse first store the incoming inserts into an in-memory buffer + // before flushing them regularly to disk. + // See https://clickhouse.com/docs/en/cloud/bestpractices/asynchronous-inserts + AsyncInsert bool + // Set true if you want an insert statement to return with an acknowledgment immediately + // without waiting for the data got inserted into the buffer. + // Setting true can cause silent errors that you need to monitor separately. + AsyncInsertWait bool + + // See https://clickhouse.com/docs/en/operations/settings/settings + // For example, you can set the `max_insert_threads` setting to control the number of threads + // or the `parallel_view_processing` setting to enable pushing to attached views concurrently. + QuerySettings map[string]string +} + +func (c StorageConfiguration) Validate() error { + if c.AsyncInsertWait && !c.AsyncInsert { + return errors.New("AsyncInsertWait is set but AsyncInsert is not") + } + + return nil +} + +// ConfigureSink setup Sink specific configuration defaults for provided *viper.Viper instance. +func ConfigureSink(v *viper.Viper) { + // Sink Dedupe + v.SetDefault("sink.dedupe.enabled", false) + v.SetDefault("sink.dedupe.driver", "memory") + + // Sink Dedupe Memory driver + v.SetDefault("sink.dedupe.config.size", 128) + + // Sink Dedupe Redis driver + v.SetDefault("sink.dedupe.config.address", "127.0.0.1:6379") + v.SetDefault("sink.dedupe.config.database", 0) + v.SetDefault("sink.dedupe.config.username", "") + v.SetDefault("sink.dedupe.config.password", "") + v.SetDefault("sink.dedupe.config.expiration", "24h") + v.SetDefault("sink.dedupe.config.sentinel.enabled", false) + v.SetDefault("sink.dedupe.config.sentinel.masterName", "") + v.SetDefault("sink.dedupe.config.tls.enabled", false) + v.SetDefault("sink.dedupe.config.tls.insecureSkipVerify", false) + + // Sink + // FIXME(chrisgacsal): remove as it is deprecated by moving Kafka specific configuration to dedicated config params. + v.SetDefault("sink.groupId", "openmeter-sink-worker") + v.SetDefault("sink.minCommitCount", 500) + v.SetDefault("sink.maxCommitWait", "2s") + v.SetDefault("sink.maxPollTimeout", "100ms") + v.SetDefault("sink.namespaceRefetch", "15s") + v.SetDefault("sink.flushSuccessTimeout", "5s") + v.SetDefault("sink.drainTimeout", "10s") + v.SetDefault("sink.ingestNotifications.maxEventsInBatch", 50) + v.SetDefault("sink.namespaceRefetchTimeout", "10s") + v.SetDefault("sink.namespaceTopicRegexp", "^om_([A-Za-z0-9]+(?:_[A-Za-z0-9]+)*)_events$") + v.SetDefault("sink.meterRefetchInterval", "15s") + v.SetDefault("sink.logDroppedEvents", false) + + // TODO: remove, config moved to aggregation config + // Sink Storage + v.SetDefault("sink.storage.asyncInsert", false) + v.SetDefault("sink.storage.asyncInsertWait", false) + + // Sink Kafka configuration + ConfigureKafkaConfiguration(v, "sink") + + // Override Kafka configuration defaults + v.SetDefault("sink.kafka.consumerGroupId", "openmeter-sink-worker") +} diff --git a/app/config/svix.go b/app/config/svix.go new file mode 100644 index 0000000000000000000000000000000000000000..7e71bdd90c86c7caf6d1c58f0e48b5678be6619e --- /dev/null +++ b/app/config/svix.go @@ -0,0 +1,7 @@ +package config + +import ( + webhooksvix "github.com/openmeterio/openmeter/openmeter/notification/webhook/svix" +) + +type SvixConfig = webhooksvix.SvixConfig diff --git a/app/config/taxcode.go b/app/config/taxcode.go new file mode 100644 index 0000000000000000000000000000000000000000..199c988712c1fc99f1bca27be1e4df6c7693d89f --- /dev/null +++ b/app/config/taxcode.go @@ -0,0 +1,117 @@ +package config + +import ( + "errors" + "fmt" + "strings" + + "github.com/spf13/viper" + + "github.com/openmeterio/openmeter/openmeter/taxcode" +) + +type TaxCodeConfiguration struct { + Seeds []TaxCodeSeed `mapstructure:"seeds"` +} + +// TaxCodeSeed defines a single tax code entry to provision for every namespace. +// A single seed may carry both DefaultInvoicing and DefaultCreditGrant; the two flags are +// independent and the exactly-one rule is enforced per flag. +type TaxCodeSeed struct { + Key string `mapstructure:"key"` + Name string `mapstructure:"name"` + Description *string `mapstructure:"description"` + AppMappings []TaxCodeAppMapping `mapstructure:"appMappings"` + DefaultInvoicing bool `mapstructure:"defaultInvoicing"` + DefaultCreditGrant bool `mapstructure:"defaultCreditGrant"` +} + +type TaxCodeAppMapping struct { + AppType string `mapstructure:"appType"` + TaxCode string `mapstructure:"taxCode"` +} + +func (c TaxCodeConfiguration) Validate() error { + var errs []error + + if len(c.Seeds) == 0 { + errs = append(errs, errors.New("seeds must not be empty")) + return errors.Join(errs...) + } + + keys := make(map[string]struct{}, len(c.Seeds)) + defaultInvoicingCount := 0 + defaultCreditGrantCount := 0 + + for i, seed := range c.Seeds { + trimmedKey := strings.TrimSpace(seed.Key) + if trimmedKey == "" { + errs = append(errs, fmt.Errorf("seed[%d]: key must not be empty", i)) + } + + if strings.TrimSpace(seed.Name) == "" { + errs = append(errs, fmt.Errorf("seed[%d]: name must not be empty", i)) + } + + if trimmedKey != "" { + if _, exists := keys[trimmedKey]; exists { + errs = append(errs, fmt.Errorf("seed[%d]: duplicate key %q", i, seed.Key)) + } + keys[trimmedKey] = struct{}{} + } + + if seed.DefaultInvoicing { + defaultInvoicingCount++ + } + + if seed.DefaultCreditGrant { + defaultCreditGrantCount++ + } + + for j, mapping := range seed.AppMappings { + trimmedAppType := strings.TrimSpace(mapping.AppType) + trimmedTaxCode := strings.TrimSpace(mapping.TaxCode) + + if trimmedAppType == "" { + errs = append(errs, fmt.Errorf("seed[%d].appMappings[%d]: appType must not be empty", i, j)) + } + + if trimmedTaxCode == "" { + errs = append(errs, fmt.Errorf("seed[%d].appMappings[%d]: taxCode must not be empty", i, j)) + } else if trimmedAppType == "stripe" && !taxcode.TaxCodeStripeRegexp.MatchString(trimmedTaxCode) { + errs = append(errs, fmt.Errorf("seed[%d].appMappings[%d]: taxCode %q is not a valid Stripe tax code (must match %s)", i, j, trimmedTaxCode, taxcode.TaxCodeStripeRegexp.String())) + } + } + } + + if defaultInvoicingCount != 1 { + errs = append(errs, fmt.Errorf("exactly one seed must have defaultInvoicing=true, got %d", defaultInvoicingCount)) + } + + if defaultCreditGrantCount != 1 { + errs = append(errs, fmt.Errorf("exactly one seed must have defaultCreditGrant=true, got %d", defaultCreditGrantCount)) + } + + return errors.Join(errs...) +} + +func ConfigureTaxCode(v *viper.Viper) { + v.SetDefault("taxcode.seeds", []map[string]any{ + { + "key": taxcode.ProviderDefaultTaxCodeKey, + "name": "Provider default", + "defaultInvoicing": true, + }, + { + "key": "nontaxable", + "name": "Nontaxable", + "defaultCreditGrant": true, + "appMappings": []map[string]any{ + { + "appType": "stripe", + "taxCode": "txcd_00000000", + }, + }, + }, + }) +} diff --git a/app/config/taxcode_test.go b/app/config/taxcode_test.go new file mode 100644 index 0000000000000000000000000000000000000000..cd03ab72847a15d1892bbc050045a16f0bad495b --- /dev/null +++ b/app/config/taxcode_test.go @@ -0,0 +1,103 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTaxCodeConfigurationValidate(t *testing.T) { + validBase := func() TaxCodeConfiguration { + return TaxCodeConfiguration{ + Seeds: []TaxCodeSeed{ + {Key: "default", Name: "Provider default", DefaultInvoicing: true}, + {Key: "nontaxable", Name: "Nontaxable", DefaultCreditGrant: true, AppMappings: []TaxCodeAppMapping{ + {AppType: "stripe", TaxCode: "txcd_00000000"}, + }}, + }, + } + } + + t.Run("Valid", func(t *testing.T) { + require.NoError(t, validBase().Validate()) + }) + + t.Run("EmptySeeds", func(t *testing.T) { + err := TaxCodeConfiguration{}.Validate() + assert.ErrorContains(t, err, "seeds must not be empty") + }) + + t.Run("EmptyKey", func(t *testing.T) { + cfg := validBase() + cfg.Seeds[0].Key = "" + err := cfg.Validate() + assert.ErrorContains(t, err, "key must not be empty") + }) + + t.Run("EmptyName", func(t *testing.T) { + cfg := validBase() + cfg.Seeds[0].Name = "" + err := cfg.Validate() + assert.ErrorContains(t, err, "name must not be empty") + }) + + t.Run("DuplicateKeys", func(t *testing.T) { + cfg := validBase() + cfg.Seeds[1].Key = cfg.Seeds[0].Key + err := cfg.Validate() + assert.ErrorContains(t, err, "duplicate key") + }) + + t.Run("NoDefaultInvoicing", func(t *testing.T) { + cfg := validBase() + cfg.Seeds[0].DefaultInvoicing = false + err := cfg.Validate() + assert.ErrorContains(t, err, "defaultInvoicing=true") + }) + + t.Run("MultipleDefaultInvoicing", func(t *testing.T) { + cfg := validBase() + cfg.Seeds[1].DefaultInvoicing = true + err := cfg.Validate() + assert.ErrorContains(t, err, "defaultInvoicing=true") + }) + + t.Run("NoDefaultCreditGrant", func(t *testing.T) { + cfg := validBase() + cfg.Seeds[1].DefaultCreditGrant = false + err := cfg.Validate() + assert.ErrorContains(t, err, "defaultCreditGrant=true") + }) + + t.Run("MultipleDefaultCreditGrant", func(t *testing.T) { + cfg := validBase() + cfg.Seeds[0].DefaultCreditGrant = true + err := cfg.Validate() + assert.ErrorContains(t, err, "defaultCreditGrant=true") + }) + + t.Run("SingleSeedCarriesBothFlags", func(t *testing.T) { + // A single seed may carry both flags; this is legal. + cfg := TaxCodeConfiguration{ + Seeds: []TaxCodeSeed{ + {Key: "all", Name: "All", DefaultInvoicing: true, DefaultCreditGrant: true}, + }, + } + require.NoError(t, cfg.Validate()) + }) + + t.Run("AppMappingEmptyAppType", func(t *testing.T) { + cfg := validBase() + cfg.Seeds[1].AppMappings[0].AppType = "" + err := cfg.Validate() + assert.ErrorContains(t, err, "appType must not be empty") + }) + + t.Run("AppMappingEmptyTaxCode", func(t *testing.T) { + cfg := validBase() + cfg.Seeds[1].AppMappings[0].TaxCode = "" + err := cfg.Validate() + assert.ErrorContains(t, err, "taxCode must not be empty") + }) +} diff --git a/app/config/telemetry.go b/app/config/telemetry.go new file mode 100644 index 0000000000000000000000000000000000000000..a4b79429c5224d58cf6f3cfd67dbda4360ae20ff --- /dev/null +++ b/app/config/telemetry.go @@ -0,0 +1,688 @@ +package config + +import ( + "context" + "errors" + "fmt" + "io" + "log/slog" + "os" + "runtime" + "slices" + "strconv" + "strings" + "time" + + "github.com/golang-cz/devslog" + "github.com/lmittmann/tint" + "github.com/spf13/pflag" + "github.com/spf13/viper" + "go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc" + "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" + "go.opentelemetry.io/otel/exporters/prometheus" + "go.opentelemetry.io/otel/exporters/stdout/stdoutlog" + "go.opentelemetry.io/otel/log" + sdklog "go.opentelemetry.io/otel/sdk/log" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/resource" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + + "github.com/openmeterio/openmeter/pkg/errorsx" +) + +type OTLPExporterTelemetryConfig struct { + Address string +} + +// Validate validates the configuration. +func (c OTLPExporterTelemetryConfig) Validate() error { + var errs []error + + if c.Address == "" { + errs = append(errs, errors.New("address is required")) + } + + return errors.Join(errs...) +} + +func (c OTLPExporterTelemetryConfig) DialExporter(ctx context.Context) (*grpc.ClientConn, error) { + conn, err := grpc.NewClient( + c.Address, + // Note the use of insecure transport here. TLS is recommended in production. + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + if err != nil { + return nil, fmt.Errorf("connecting to collector: %w", err) + } + + return conn, nil +} + +type TelemetryConfig struct { + // Telemetry HTTP server address + Address string + + ReadHeaderTimeout time.Duration + + Trace TraceTelemetryConfig + + Metrics MetricsTelemetryConfig + + Log LogTelemetryConfig +} + +// Validate validates the configuration. +func (c TelemetryConfig) Validate() error { + var errs []error + + if c.Address == "" { + errs = append(errs, errors.New("http server address is required")) + } + + if err := c.Trace.Validate(); err != nil { + errs = append(errs, errorsx.WithPrefix(err, "trace")) + } + + if err := c.Metrics.Validate(); err != nil { + errs = append(errs, errorsx.WithPrefix(err, "metrics")) + } + + if err := c.Log.Validate(); err != nil { + errs = append(errs, errorsx.WithPrefix(err, "log")) + } + + return errors.Join(errs...) +} + +type TraceTelemetryConfig struct { + Sampler string + Exporters ExportersTraceTelemetryConfig + + // OperationSpans enables an application-level child span per HTTP operation + // (named after the operation, e.g. "list-customers"), nested under the otelhttp + // server span. Off by default: it adds a span to every operation request across + // the API, so it is a deliberate, env-level opt-in given the extra span volume. + OperationSpans bool +} + +// Validate validates the configuration. +func (c TraceTelemetryConfig) Validate() error { + var errs []error + + if _, err := strconv.ParseFloat(c.Sampler, 64); err != nil && !slices.Contains([]string{"always", "never"}, c.Sampler) { + errs = append(errs, fmt.Errorf("sampler either needs to be always|never or a ration, got: %s", c.Sampler)) + } + + if err := c.Exporters.Validate(); err != nil { + errs = append(errs, errorsx.WithPrefix(err, "exporters")) + } + + return errors.Join(errs...) +} + +func (c TraceTelemetryConfig) GetSampler() sdktrace.Sampler { + switch c.Sampler { + case "always": + return sdktrace.AlwaysSample() + + case "never": + return sdktrace.NeverSample() + + default: + ratio, err := strconv.ParseFloat(c.Sampler, 64) + if err != nil { + panic(fmt.Errorf("trace: invalid ratio: %w", err)) + } + + return sdktrace.ParentBased(sdktrace.TraceIDRatioBased(ratio)) + } +} + +func (c TraceTelemetryConfig) NewTracerProvider(ctx context.Context, res *resource.Resource) (*sdktrace.TracerProvider, error) { + options := []sdktrace.TracerProviderOption{ + sdktrace.WithResource(res), + sdktrace.WithSampler(c.GetSampler()), + } + + if c.Exporters.OTLP.Enabled { + exporter, err := c.Exporters.OTLP.NewExporter(ctx) + if err != nil { + return nil, err + } + + options = append(options, sdktrace.WithBatcher(exporter)) + } + + return sdktrace.NewTracerProvider(options...), nil +} + +type ExportersTraceTelemetryConfig struct { + OTLP OTLPExportersTraceTelemetryConfig +} + +// Validate validates the configuration. +func (c ExportersTraceTelemetryConfig) Validate() error { + var errs []error + + if err := c.OTLP.Validate(); err != nil { + errs = append(errs, errorsx.WithPrefix(err, "otlp")) + } + + return errors.Join(errs...) +} + +type OTLPExportersTraceTelemetryConfig struct { + Enabled bool + + OTLPExporterTelemetryConfig `mapstructure:",squash"` +} + +// Validate validates the configuration. +func (c OTLPExportersTraceTelemetryConfig) Validate() error { + if !c.Enabled { + return nil + } + + return c.OTLPExporterTelemetryConfig.Validate() +} + +// NewExporter creates a new [sdktrace.SpanExporter]. +func (c OTLPExportersTraceTelemetryConfig) NewExporter(ctx context.Context) (sdktrace.SpanExporter, error) { + if !c.Enabled { + return nil, errors.New("telemetry: trace: exporter: otlp: disabled") + } + + // TODO: make this configurable + ctx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + + conn, err := c.DialExporter(ctx) + if err != nil { + return nil, fmt.Errorf("telemetry: trace: exporter: otlp: %w", err) + } + + exporter, err := otlptracegrpc.New(ctx, otlptracegrpc.WithGRPCConn(conn)) + if err != nil { + return nil, fmt.Errorf("telemetry: trace: exporter: otlp: initializing exporter: %w", err) + } + + return exporter, nil +} + +type MetricsTelemetryConfig struct { + Exporters ExportersMetricsTelemetryConfig +} + +// Validate validates the configuration. +func (c MetricsTelemetryConfig) Validate() error { + if err := c.Exporters.Validate(); err != nil { + return fmt.Errorf("exporter: %w", err) + } + + return nil +} + +func (c MetricsTelemetryConfig) NewMeterProvider(ctx context.Context, res *resource.Resource) (*sdkmetric.MeterProvider, error) { + options := []sdkmetric.Option{ + sdkmetric.WithResource(res), + } + + if c.Exporters.Prometheus.Enabled { + exporter, err := c.Exporters.Prometheus.NewExporter() + if err != nil { + return nil, err + } + + options = append(options, sdkmetric.WithReader(exporter)) + } + + if c.Exporters.OTLP.Enabled { + exporter, err := c.Exporters.OTLP.NewExporter(ctx) + if err != nil { + return nil, err + } + + options = append(options, sdkmetric.WithReader(exporter)) + } + + return sdkmetric.NewMeterProvider(options...), nil +} + +type ExportersMetricsTelemetryConfig struct { + Prometheus PrometheusExportersMetricsTelemetryConfig + OTLP OTLPExportersMetricsTelemetryConfig +} + +// Validate validates the configuration. +func (c ExportersMetricsTelemetryConfig) Validate() error { + var errs []error + + if err := c.Prometheus.Validate(); err != nil { + errs = append(errs, errorsx.WithPrefix(err, "prometheus")) + } + + if err := c.OTLP.Validate(); err != nil { + errs = append(errs, errorsx.WithPrefix(err, "otlp")) + } + + return errors.Join(errs...) +} + +type PrometheusExportersMetricsTelemetryConfig struct { + Enabled bool + + // DisableDefaultCollectors unregisters the default Prometheus Go and Process + // collectors so that OTel runtime metrics are the sole source of those signals. + // Defaults to false to preserve backward compatibility. + DisableDefaultCollectors bool +} + +// Validate validates the configuration. +func (c PrometheusExportersMetricsTelemetryConfig) Validate() error { + return nil +} + +// NewExporter creates a new [sdkmetric.Reader]. +func (c PrometheusExportersMetricsTelemetryConfig) NewExporter() (sdkmetric.Reader, error) { + if !c.Enabled { + return nil, errors.New("telemetry: metrics: exporter: prometheus: disabled") + } + + exporter, err := prometheus.New() + if err != nil { + return nil, fmt.Errorf("telemetry: metrics: exporter: prometheus: initializing exporter: %w", err) + } + + return exporter, nil +} + +type OTLPExportersMetricsTelemetryConfig struct { + Enabled bool + + OTLPExporterTelemetryConfig `mapstructure:",squash"` +} + +// Validate validates the configuration. +func (c OTLPExportersMetricsTelemetryConfig) Validate() error { + if !c.Enabled { + return nil + } + + return c.OTLPExporterTelemetryConfig.Validate() +} + +// NewExporter creates a new [sdkmetric.Reader]. +func (c OTLPExportersMetricsTelemetryConfig) NewExporter(ctx context.Context) (sdkmetric.Reader, error) { + if !c.Enabled { + return nil, errors.New("telemetry: metrics: exporter: otlp: disabled") + } + + // TODO: make this configurable + ctx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + + conn, err := c.DialExporter(ctx) + if err != nil { + return nil, fmt.Errorf("telemetry: metrics: exporter: otlp: %w", err) + } + + exporter, err := otlpmetricgrpc.New(ctx, otlpmetricgrpc.WithGRPCConn(conn)) + if err != nil { + return nil, fmt.Errorf("telemetry: metrics: exporter: otlp: initializing exporter: %w", err) + } + + return sdkmetric.NewPeriodicReader(exporter), nil +} + +type LogTelemetryConfig struct { + // Format specifies the output log format. + // Accepted values are: json, text + Format string + + // Level is the minimum log level that should appear on the output. + // + // Requires [mapstructure.TextUnmarshallerHookFunc] to be high up in the decode hook chain. + Level slog.Level + + Exporters ExportersLogTelemetryConfig +} + +// Validate validates the configuration. +func (c LogTelemetryConfig) Validate() error { + var errs []error + + if !slices.Contains([]string{"json", "text", "tint", "prettydev"}, c.Format) { + errs = append(errs, fmt.Errorf("invalid format: %q", c.Format)) + } + + if !slices.Contains([]slog.Level{slog.LevelDebug, slog.LevelInfo, slog.LevelWarn, slog.LevelError}, c.Level) { + errs = append(errs, fmt.Errorf("invalid level: %q", c.Level)) + } + + return errors.Join(errs...) +} + +// moduleRoot is the absolute path to the repository root, determined at init time. +// It is used to trim source file paths to be relative to the project root. +var moduleRoot string + +func init() { + // runtime.Caller(0) returns the path of this source file. + // Stripping its known suffix gives us the module root. + _, file, _, ok := runtime.Caller(0) + if ok { + moduleRoot = strings.TrimSuffix(file, "app/config/telemetry.go") + } +} + +// kongReplaceAttr renames and reformats slog attributes to match the Kong encoder config +// and DataDog standard attributes: +// - TimeKey ("time") → "ts" formatted as ISO8601 (RFC3339) +// - SourceKey ("source") → "caller" formatted as "file:line" +// - "error" → "error.message" (DataDog standard error attribute) +func kongReplaceAttr(groups []string, a slog.Attr) slog.Attr { + if len(groups) > 0 { + return a + } + switch a.Key { + case slog.TimeKey: + a.Key = "ts" + if t, ok := a.Value.Any().(time.Time); ok { + a.Value = slog.StringValue(t.Format(time.RFC3339)) + } + case slog.SourceKey: + a.Key = "caller" + if src, ok := a.Value.Any().(*slog.Source); ok && src != nil { + file := strings.TrimPrefix(src.File, moduleRoot) + a.Value = slog.StringValue(fmt.Sprintf("%s:%d", file, src.Line)) + } + case "error", "err": + a.Key = "error.message" + } + return a +} + +// NewJSONHandler returns a [slog.JSONHandler] pre-configured with the Kong/DataDog +// field schema (ts, caller, error.message). It is safe to call before full telemetry +// initialisation, e.g. from package init() for bootstrap logging. +func NewJSONHandler(w io.Writer, level slog.Leveler) *slog.JSONHandler { + return slog.NewJSONHandler(w, &slog.HandlerOptions{ + Level: level, + AddSource: true, + ReplaceAttr: kongReplaceAttr, + }) +} + +// NewHandler creates a new [slog.Handler]. +func (c LogTelemetryConfig) NewHandler(w io.Writer) slog.Handler { + switch c.Format { + case "json": + return NewJSONHandler(w, c.Level) + + case "text": + return slog.NewTextHandler(w, &slog.HandlerOptions{Level: c.Level}) + + case "tint": + return tint.NewHandler(os.Stdout, &tint.Options{Level: c.Level}) + case "prettydev": + return devslog.NewHandler(w, &devslog.Options{ + MaxSlicePrintSize: 4, + SortKeys: true, + TimeFormat: "[04:05]", + NewLineAfterLog: true, + DebugColor: devslog.Magenta, + HandlerOptions: &slog.HandlerOptions{Level: c.Level}, + }) + } + + return NewJSONHandler(w, c.Level) +} + +type ExportersLogTelemetryConfig struct { + OTLP OTLPExportersLogTelemetryConfig + Stdout StdoutExportersLogTelemetryConfig + File FileExportersLogTelemetryConfig +} + +// Validate validates the configuration. +func (c ExportersLogTelemetryConfig) Validate() error { + var errs []error + + if err := c.OTLP.Validate(); err != nil { + errs = append(errs, errorsx.WithPrefix(err, "otlp")) + } + + if err := c.File.Validate(); err != nil { + errs = append(errs, errorsx.WithPrefix(err, "file")) + } + + return errors.Join(errs...) +} + +type OTLPExportersLogTelemetryConfig struct { + Enabled bool + + OTLPExporterTelemetryConfig `mapstructure:",squash"` +} + +// Validate validates the configuration. +func (c OTLPExportersLogTelemetryConfig) Validate() error { + if !c.Enabled { + return nil + } + + return c.OTLPExporterTelemetryConfig.Validate() +} + +// NewExporter creates a new [sdklog.Exporter]. +func (c OTLPExportersLogTelemetryConfig) NewExporter(ctx context.Context) (sdklog.Exporter, error) { + if !c.Enabled { + return nil, errors.New("telemetry: log: exporter: otlp: disabled") + } + + // TODO: make this configurable + ctx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + + conn, err := c.DialExporter(ctx) + if err != nil { + return nil, fmt.Errorf("telemetry: log: exporter: otlp: %w", err) + } + + exporter, err := otlploggrpc.New(ctx, otlploggrpc.WithGRPCConn(conn)) + if err != nil { + return nil, fmt.Errorf("telemetry: log: exporter: otlp: initializing exporter: %w", err) + } + + return exporter, nil +} + +// StdoutExportersLogTelemetryConfig represents the configuration for the stdout log exporter. +// See https://pkg.go.dev/go.opentelemetry.io/otel/exporters/stdout/stdoutlog +type StdoutExportersLogTelemetryConfig struct { + Enabled bool + PrettyPrint bool +} + +// Validate validates the configuration. +func (c StdoutExportersLogTelemetryConfig) Validate() error { + if !c.Enabled { + return nil + } + + return nil +} + +// NewExporter creates a new [sdklog.Exporter]. +func (c StdoutExportersLogTelemetryConfig) NewExporter() (sdklog.Exporter, error) { + if !c.Enabled { + return nil, errors.New("telemetry: log: exporter: stdout: disabled") + } + + var opts []stdoutlog.Option + + if c.PrettyPrint { + opts = append(opts, stdoutlog.WithPrettyPrint()) + } + + return stdoutlog.New(opts...) +} + +// FileExportersLogTelemetryConfig represents the configuration for the file log exporter. +type FileExportersLogTelemetryConfig struct { + Enabled bool + FilePath string + PrettyPrint bool +} + +// Validate validates the configuration. +func (c FileExportersLogTelemetryConfig) Validate() error { + if !c.Enabled { + return nil + } + + if c.FilePath == "" { + return errors.New("file path is required") + } + + return nil +} + +// fileExporter wraps an slog.Handler as an OpenTelemetry exporter +type fileExporter struct { + handler slog.Handler + file *os.File +} + +func (e *fileExporter) Export(ctx context.Context, logs []sdklog.Record) error { + for _, record := range logs { + // Convert OTel severity to slog level + level := slog.LevelInfo + switch record.Severity() { + case log.SeverityTrace, log.SeverityDebug: + level = slog.LevelDebug + case log.SeverityInfo: + level = slog.LevelInfo + case log.SeverityWarn: + level = slog.LevelWarn + case log.SeverityError, log.SeverityFatal: + level = slog.LevelError + } + + attrs := make([]any, 0, record.AttributesLen()*2) + record.WalkAttributes(func(attr log.KeyValue) bool { + attrs = append(attrs, attr.Key, attr.Value.AsString()) + return true + }) + + rec := slog.NewRecord( + record.Timestamp(), + level, + record.Body().AsString(), + 0, + ) + + rec.Add(attrs...) + + // Let's add retries eventually + _ = e.handler.Handle(ctx, rec) + } + return nil +} + +func (e *fileExporter) ForceFlush(ctx context.Context) error { + return e.file.Sync() +} + +func (e *fileExporter) Shutdown(ctx context.Context) error { + return e.file.Close() +} + +// NewExporter creates a new [sdklog.Exporter]. +func (c FileExportersLogTelemetryConfig) NewExporter() (sdklog.Exporter, error) { + if !c.Enabled { + return nil, errors.New("telemetry: log: exporter: file: disabled") + } + + f, err := os.OpenFile(c.FilePath, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o644) + if err != nil { + return nil, fmt.Errorf("telemetry: log: exporter: file: opening file: %w", err) + } + + handler := slog.NewTextHandler(f, &slog.HandlerOptions{ + Level: slog.LevelDebug, + }) + + return &fileExporter{ + handler: handler, + file: f, + }, nil +} + +func (c LogTelemetryConfig) NewLoggerProvider(ctx context.Context, res *resource.Resource) (*sdklog.LoggerProvider, error) { + options := []sdklog.LoggerProviderOption{ + sdklog.WithResource(res), + } + + if c.Exporters.OTLP.Enabled { + exporter, err := c.Exporters.OTLP.NewExporter(ctx) + if err != nil { + return nil, fmt.Errorf("exporter: otlp: %w", err) + } + + options = append(options, sdklog.WithProcessor(sdklog.NewBatchProcessor(exporter))) + } + + if c.Exporters.Stdout.Enabled { + exporter, err := c.Exporters.Stdout.NewExporter() + if err != nil { + return nil, fmt.Errorf("exporter: stdout: %w", err) + } + + options = append(options, sdklog.WithProcessor(sdklog.NewBatchProcessor(exporter))) + } + + if c.Exporters.File.Enabled { + exporter, err := c.Exporters.File.NewExporter() + if err != nil { + return nil, fmt.Errorf("exporter: file: %w", err) + } + + // Use simple processor for immediate writes to file + options = append(options, sdklog.WithProcessor(sdklog.NewSimpleProcessor(exporter))) + } + + return sdklog.NewLoggerProvider(options...), nil +} + +// ConfigureTelemetry configures some defaults in the Viper instance. +func ConfigureTelemetry(v *viper.Viper, flags *pflag.FlagSet) { + flags.String("telemetry-address", ":10000", "Telemetry HTTP server address") + _ = v.BindPFlag("telemetry.address", flags.Lookup("telemetry-address")) + v.SetDefault("telemetry.address", ":10000") + + v.SetDefault("telemetry.trace.sampler", "never") + v.SetDefault("telemetry.trace.operationSpans", false) + v.SetDefault("telemetry.trace.exporters.otlp.enabled", false) + v.SetDefault("telemetry.trace.exporters.otlp.address", "") + + v.SetDefault("telemetry.metrics.exporters.prometheus.enabled", false) + v.SetDefault("telemetry.metrics.exporters.prometheus.disabledefaultcollectors", false) + v.SetDefault("telemetry.metrics.exporters.otlp.enabled", false) + v.SetDefault("telemetry.metrics.exporters.otlp.address", "") + + v.SetDefault("telemetry.log.format", "json") + v.SetDefault("telemetry.log.level", "info") + v.SetDefault("telemetry.log.exporters.otlp.enabled", false) + v.SetDefault("telemetry.log.exporters.otlp.address", "") + v.SetDefault("telemetry.log.exporters.stdout.enabled", false) + v.SetDefault("telemetry.log.exporters.file.enabled", false) + v.SetDefault("telemetry.log.exporters.file.filepath", "") + v.SetDefault("telemetry.log.exporters.file.prettyprint", false) + + v.SetDefault("telemetry.readiness.interval", 3*time.Second) + v.SetDefault("telemetry.readHeaderTimeout", 10*time.Second) +} diff --git a/app/config/termination.go b/app/config/termination.go new file mode 100644 index 0000000000000000000000000000000000000000..1db71857f105e5de137777b4be512a1bebdd9739 --- /dev/null +++ b/app/config/termination.go @@ -0,0 +1,46 @@ +package config + +import ( + "errors" + "time" + + "github.com/spf13/viper" +) + +type TerminationConfig struct { + // CheckInterval defines the time period used for updating the readiness check based on the termination status + CheckInterval time.Duration + + // GracefulShutdownTimeout defines the maximum time for the process to gracefully stop on receiving stop signal. + GracefulShutdownTimeout time.Duration + + // PropagationTimeout defines how long to block the termination process in order + // to allow the termination event to be propagated to other systems. e.g. reverse proxy. + // Its value should be set higher than the failure threshold for readiness probe. + // In Kubernetes it should be: readiness.periodSeconds * (readiness.failureThreshold + 1) + CheckInterval + // PropagationTimeout must always less than GracefulShutdownTimeout. + PropagationTimeout time.Duration +} + +func (c TerminationConfig) Validate() error { + var errs []error + + if c.CheckInterval < time.Second { + errs = append(errs, errors.New("checkInterval must be greater than or equal to 1s")) + } + + if c.PropagationTimeout > c.GracefulShutdownTimeout { + errs = append(errs, errors.New("propagationTimeout must be less or equal to gracefulShutdownTimeout")) + } + + return errors.Join(errs...) +} + +// ConfigureTermination configures some defaults in the Viper instance. +func ConfigureTermination(v *viper.Viper, prefixes ...string) { + prefixer := NewViperKeyPrefixer(prefixes...) + + v.SetDefault(prefixer("checkInterval"), time.Second) + v.SetDefault(prefixer("gracefulShutdownTimeout"), 30*time.Second) + v.SetDefault(prefixer("propagationTimeout"), 3*time.Second) +} diff --git a/app/config/testdata/complete.yaml b/app/config/testdata/complete.yaml new file mode 100644 index 0000000000000000000000000000000000000000..00d90341ee5fb26f032f41ccabddf0df398b6982 --- /dev/null +++ b/app/config/testdata/complete.yaml @@ -0,0 +1,202 @@ +address: 127.0.0.1:8888 + +environment: local + +termination: + checkInterval: 7s + gracefulShutdownTimeout: 43s + propagationTimeout: 18s + +telemetry: + address: 127.0.0.1:10000 + + trace: + sampler: always + exporters: + otlp: + enabled: true + address: 127.0.0.1:4317 + + metrics: + exporters: + prometheus: + enabled: true + otlp: + enabled: true + address: 127.0.0.1:4317 + + log: + format: json + level: info + +namespace: + default: default + +ingest: + kafka: + broker: 127.0.0.1:9092 + securityProtocol: SASL_SSL + saslMechanisms: PLAIN + saslUsername: user + saslPassword: pass + partitions: 1 + statsInterval: 5s + brokerAddressFamily: any + socketKeepAliveEnabled: true + topicMetadataRefreshInterval: 1m + debugContexts: + - broker + - topic + - consumer + topicProvisioner: + cacheSize: 200 + cacheTTL: 15m + protectedTopics: + - "protected-topic-1" + - "protected-topic-2" + +aggregation: + clickhouse: + address: 127.0.0.1:9440 + tls: true + username: default + password: default + database: openmeter + + retry: + enabled: true + +sink: + groupId: openmeter-sink-worker + minCommitCount: 500 + maxCommitWait: 30s + namespaceRefetch: 15s + namespaceRefetchTimeout: 9s + namespaceTopicRegexp: "^om_test_([A-Za-z0-9]+(?:_[A-Za-z0-9]+)*)_events$" + dedupe: + enabled: true + driver: redis + config: + expiration: 768h # 32d + database: 0 + address: 127.0.0.1:6379 + username: default + password: pass + tls: + enabled: true + kafka: + brokers: 127.0.0.1:9092 + securityProtocol: SASL_SSL + saslMechanisms: PLAIN + saslUsername: user + saslPassword: pass + statsInterval: 5s + brokerAddressFamily: any + socketKeepAliveEnabled: true + topicMetadataRefreshInterval: 1m + debugContexts: + - broker + - topic + - consumer + clientID: kafka-client-1 + consumerGroupID: consumer-group + consumerGroupInstanceID: consumer-group-1 + sessionTimeout: 5m + heartbeatInterval: 3s + enableAutoCommit: true + enableAutoOffsetStore: false + autoOffsetReset: "error" + partitionAssignmentStrategy: + - "range" + - "roundrobin" + +customer: + enableSubjectHook: true + ignoreErrors: true + +dedupe: + enabled: true + driver: redis + config: + expiration: 768h # 32d + database: 0 + address: 127.0.0.1:6379 + username: default + password: pass + tls: + enabled: true + +balanceWorker: + stateStorage: + driver: redis + config: + expiration: 23h + address: 127.0.0.1:6379 + +meters: + - slug: m1 + eventType: api-calls + valueProperty: $.duration_ms + aggregation: SUM + groupBy: + method: $.method + path: $.path + +reservedEventTypes: + - ^reserved\..*$ + - ^_\..*$ + - ^openmeter\..*$ + +notification: + enabled: true + reconcileInterval: 1m + sendingTimeout: 1h + pendingTimeout: 2h + reconcilerWorkers: 10 + lock: + owner: lock-owner + leaseTime: 7m + heartbeatInterval: 29s + +svix: + apiKey: test-svix-token + serverURL: http://127.0.0.1:8071 + debug: true + +productCatalog: + subscription: + multiSubscriptionNamespaces: + - "multi-subscription" + +taxcode: + seeds: + - key: default + name: Provider default + defaultInvoicing: true + - key: nontaxable + name: Nontaxable + defaultCreditGrant: true + appMappings: + - appType: stripe + taxCode: txcd_00000000 +featureGate: + enabled: true + flags: + om_ff_credits_enabled: "credits" + +server: + readHeaderTimeout: 10s + readTimeout: 30s + writeTimeout: 30s + idleTimeout: 60s + responseValidation: + mode: "unstable" # "off" | "unstable" | "all" + clientIPMiddleware: + source: "x-forwarded-for" # "remote-address" | "header" | "x-forwarded-for" + header: "X-Real-IP" + trustedIPPrefixes: [ + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + ] + trustedProxies: 2 diff --git a/app/config/topicprovisioner.go b/app/config/topicprovisioner.go new file mode 100644 index 0000000000000000000000000000000000000000..6aefc4cf6cf581457e16ee564a6cc267ac637eaa --- /dev/null +++ b/app/config/topicprovisioner.go @@ -0,0 +1,53 @@ +package config + +import ( + "errors" + "fmt" + "time" + + "github.com/spf13/viper" +) + +// TopicProvisionerConfig stores the configuration for TopicProvisioner +type TopicProvisionerConfig struct { + // The maximum number of entries stored in topic cache at a time which after the least recently used is evicted. + // Setting size to 0 makes it unlimited + CacheSize int + + // The maximum time an entries is kept in cache before being evicted + CacheTTL time.Duration + + // ProtectedTopics defines a list of topics which are protected from deletion. + ProtectedTopics []string + + // Enabled defines whether topic provisioning is enabled or not. + Enabled bool +} + +func (c TopicProvisionerConfig) Validate() error { + var errs []error + + if !c.Enabled { + return nil + } + + if c.CacheSize < 0 { + errs = append(errs, fmt.Errorf("invalid cache size: %d", c.CacheSize)) + } + + if c.CacheTTL < 0 { + errs = append(errs, fmt.Errorf("invalid cache ttl: %d", c.CacheTTL)) + } + + return errors.Join(errs...) +} + +// ConfigureTopicProvisioner configures some defaults in the Viper instance. +func ConfigureTopicProvisioner(v *viper.Viper, prefixes ...string) { + prefixer := NewViperKeyPrefixer(prefixes...) + + v.SetDefault(prefixer("cacheSize"), 250) + v.SetDefault(prefixer("cacheTTL"), "5m") + v.SetDefault(prefixer("protectedTopics"), nil) + v.SetDefault(prefixer("enabled"), true) +} diff --git a/app/config/unit_config.go b/app/config/unit_config.go new file mode 100644 index 0000000000000000000000000000000000000000..4ab4bc1e270a26f25974bcd7d179ecd1a1e7e54b --- /dev/null +++ b/app/config/unit_config.go @@ -0,0 +1,20 @@ +package config + +import ( + "github.com/spf13/viper" +) + +type UnitConfigConfiguration struct { + Enabled bool `yaml:"enabled"` +} + +func (c UnitConfigConfiguration) Validate() error { + // Add any validation logic for UnitConfigConfiguration here if needed. + return nil +} + +func ConfigureUnitConfig(v *viper.Viper, prefixes ...string) { + prefixer := NewViperKeyPrefixer(prefixes...) + + v.SetDefault(prefixer("enabled"), false) +} diff --git a/app/config/viper.go b/app/config/viper.go new file mode 100644 index 0000000000000000000000000000000000000000..e8c4f9f60e5b8b0f5d42eab4f838aa0cc8ec686e --- /dev/null +++ b/app/config/viper.go @@ -0,0 +1,48 @@ +package config + +import ( + "github.com/go-viper/mapstructure/v2" + "github.com/sagikazarmark/mapstructurex" +) + +func DecodeHook() mapstructure.DecodeHookFunc { + return mapstructure.ComposeDecodeHookFunc( + mapstructurex.MapDecoderHookFunc(), + mapstructure.TextUnmarshallerHookFunc(), + mapstructure.StringToTimeDurationHookFunc(), + mapstructure.StringToSliceHookFunc(","), + ) +} + +// ViperKeyPrefixer is a helper to prepend prefix to a key name. +type ViperKeyPrefixer func(s string) string + +const delimiter = "." + +// NewViperKeyPrefixer returns a new ViperKeyPrefixer which prepends a dot delimited prefix calculated by concatenating provided +// prefixes in the order they appear in prefixes list. +// +// prefixer := NewViperKeyPrefixer("a", "b") +// s := prefixer("c") +// fmt.Println(s) // -> "a.b.c" +func NewViperKeyPrefixer(prefixes ...string) ViperKeyPrefixer { + var prefix string + + for _, p := range prefixes { + if p == "" { + continue + } + + if prefix == "" { + prefix = p + } else { + prefix += delimiter + p + } + } + + if prefix == "" { + return func(s string) string { return s } + } else { + return func(s string) string { return prefix + delimiter + s } + } +} diff --git a/assets/logo.png b/assets/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..69dbc7871696d3fb9d1b6ca5a43166bac2130fcc Binary files /dev/null and b/assets/logo.png differ diff --git a/atlas.hcl b/atlas.hcl new file mode 100644 index 0000000000000000000000000000000000000000..4b5bde4c6fae8aff8f04d0d49aefc9e5b8b36f1c --- /dev/null +++ b/atlas.hcl @@ -0,0 +1,78 @@ +env "local" { + src = "${local.schema_src}" + + migration { + dir = "${local.migrations_dir}" + format = "${local.migrations_format}" + exclude = "${local.migration_exlude}" + } + + format { + migrate { + diff = "{{ sql . \" \" }}" + } + } + + url = "${local.local_url}" + + // Define the URL of the Dev Database for this environment + // See: https://atlasgo.io/concepts/dev-database + dev = "docker://postgres/15/dev?search_path=public" + + lint { + // Lint the effects of the 100 latest migration files + latest = 100 + } +} + +env "ci" { + src = "${local.schema_src}" + + migration { + dir = "${local.migrations_dir}" + format = "${local.migrations_format}" + exclude = "${local.migration_exlude}" + } + + format { + migrate { + diff = "{{ sql . \" \" }}" + } + } + + dev = "${local.ci_url}" +} + +locals { + // Define the directory where the schema definition resides. + schema_src = "ent://openmeter/ent/schema" + // Define the initial migration timestamp + init_migration_ts = "20240826120919" + // Define the directory where the migrations are stored. + migrations_dir = "file://tools/migrate/migrations" + // We use golang-migrate + migrations_format = "golang-migrate" + // Define common connection URLs + local_url = "postgres://postgres:postgres@localhost:5432/postgres?search_path=public&sslmode=disable" + ci_url = "postgres://postgres:postgres@postgres:5432/postgres?search_path=public&sslmode=disable" + + migration_exlude = ["distributed_locks[type=table]"] +} + +lint { + non_linear { + error = true + } + + destructive { + error = false + } + + data_depend { + error = true + } + + incompatible { + error = true + } +} diff --git a/benthos-collector.Dockerfile b/benthos-collector.Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..288d8514f28f273efa8a80fa150e725611cbd038 --- /dev/null +++ b/benthos-collector.Dockerfile @@ -0,0 +1,54 @@ +FROM --platform=$BUILDPLATFORM golang:1.26.4-alpine3.23@sha256:18b460dd17542c2ba43299a633cf6ebfc1115101509531471d7cfce1019af083 AS builder + +RUN apk add --update --no-cache ca-certificates make git curl + +ARG TARGETPLATFORM + +WORKDIR /src + +ARG GOPROXY + +ENV CGO_ENABLED=0 + +ENV GOCACHE=/go/cache +ENV GOMODCACHE=/go/pkg/mod + +COPY --link go.mod go.sum ./ + +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/go/cache \ + go mod download -x + +COPY --link collector/go.mod collector/go.sum ./collector/ + +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/go/cache \ + go mod download -C ./collector -x + +ARG VERSION + +COPY --link . . + +RUN chmod +x entrypoint.sh + +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/go/cache \ + go build -C ./collector -ldflags "-X main.version=${VERSION}" -o /usr/local/bin/benthos ./cmd + +FROM alpine:3.24.1@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b + +RUN apk add --update --no-cache ca-certificates tzdata bash + +SHELL ["/bin/bash", "-c"] + +# This is so we can reuse presets in development +WORKDIR /etc/benthos + +COPY cloudevents.spec.json /etc/benthos/ + +COPY collector/benthos/presets /etc/benthos/presets + +COPY --link --from=builder /usr/local/bin/benthos /usr/local/bin/ +COPY --link --from=builder /src/entrypoint.sh /entrypoint.sh + +ENTRYPOINT ["/entrypoint.sh", "/usr/local/bin/benthos"] diff --git a/cloudevents.spec.json b/cloudevents.spec.json new file mode 100644 index 0000000000000000000000000000000000000000..2fef2f143f1b5d1f30894167f991c462b37f9c56 --- /dev/null +++ b/cloudevents.spec.json @@ -0,0 +1,123 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "CloudEvents Specification JSON Schema", + "type": "object", + "properties": { + "id": { + "description": "Identifies the event.", + "$ref": "#/definitions/iddef", + "examples": [ + "A234-1234-1234" + ] + }, + "source": { + "description": "Identifies the context in which an event happened.", + "$ref": "#/definitions/sourcedef", + "examples" : [ + "https://github.com/cloudevents", + "mailto:cncf-wg-serverless@lists.cncf.io", + "urn:uuid:6e8bc430-9c3a-11d9-9669-0800200c9a66", + "cloudevents/spec/pull/123", + "/sensors/tn-1234567/alerts", + "1-555-123-4567" + ] + }, + "specversion": { + "description": "The version of the CloudEvents specification which the event uses.", + "$ref": "#/definitions/specversiondef", + "examples": [ + "1.0" + ] + }, + "type": { + "description": "Describes the type of event related to the originating occurrence.", + "$ref": "#/definitions/typedef", + "examples" : [ + "com.github.pull_request.opened", + "com.example.object.deleted.v2" + ] + }, + "datacontenttype": { + "description": "Content type of the data value. Must adhere to RFC 2046 format.", + "$ref": "#/definitions/datacontenttypedef", + "examples": [ + "application/json" + ] + }, + "dataschema": { + "description": "Identifies the schema that data adheres to.", + "$ref": "#/definitions/dataschemadef" + }, + "subject": { + "description": "Describes the subject of the event in the context of the event producer (identified by source).", + "$ref": "#/definitions/subjectdef", + "examples": [ + "mynewfile.jpg" + ] + }, + "time": { + "description": "Timestamp of when the occurrence happened. Must adhere to RFC 3339.", + "$ref": "#/definitions/timedef", + "examples": [ + "2018-04-05T17:31:00Z" + ] + }, + "data": { + "description": "The event payload.", + "$ref": "#/definitions/datadef" + }, + "data_base64": { + "description": "Base64 encoded event payload. Must adhere to RFC4648.", + "$ref": "#/definitions/data_base64def", + "examples": [ + "Zm9vYg==" + ] + } + }, + "required": ["id", "source", "specversion", "type", "subject"], + "definitions": { + "iddef": { + "type": "string", + "minLength": 1 + }, + "sourcedef": { + "type": "string", + "format": "uri-reference", + "minLength": 1 + }, + "specversiondef": { + "type": "string", + "minLength": 1 + }, + "typedef": { + "type": "string", + "minLength": 1 + }, + "datacontenttypedef": { + "type": ["string", "null"], + "minLength": 1 + }, + "dataschemadef": { + "type": ["string", "null"], + "format": "uri", + "minLength": 1 + }, + "subjectdef": { + "type": ["string", "null"], + "minLength": 1 + }, + "timedef": { + "type": ["string", "null"], + "format": "date-time", + "minLength": 1 + }, + "datadef": { + "type": ["object", "null"], + "additionalProperties": true + }, + "data_base64def": { + "type": ["string", "null"], + "contentEncoding": "base64" + } + } +} diff --git a/cmd/balance-worker/.air.toml b/cmd/balance-worker/.air.toml new file mode 100644 index 0000000000000000000000000000000000000000..8f99b631217e367f83a5909bfba40e7591c10e48 --- /dev/null +++ b/cmd/balance-worker/.air.toml @@ -0,0 +1,44 @@ +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] + args_bin = ["--config", "./config.yaml", "--telemetry-address", ":10002"] + bin = "./tmp/openmeter-balance-worker" + cmd = "go build -gcflags=\"all=-N -l\" -tags dynamic -o ./tmp/openmeter-balance-worker ./cmd/balance-worker" + delay = 0 + exclude_dir = ["assets", "ci", "deploy", "docs", "examples", "testdata", "quickstart", "tmp", "vendor", "api/client", "node_modules"] + exclude_file = [] + exclude_regex = ["_test.go"] + exclude_unchanged = false + follow_symlink = false + full_bin = "" + include_dir = [] + include_ext = ["go", "tpl", "tmpl", "html", "yml", "yaml", "sql", "json"] + include_file = [] + kill_delay = "0s" + log = "build-errors.log" + poll = false + poll_interval = 0 + rerun = false + rerun_delay = 500 + send_interrupt = true + stop_on_error = true + +[color] + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + main_only = false + time = false + +[misc] + clean_on_exit = false + +[screen] + clear_on_rebuild = false + keep_scroll = true diff --git a/cmd/balance-worker/main.go b/cmd/balance-worker/main.go new file mode 100644 index 0000000000000000000000000000000000000000..5c768dbe82e14ee1c661e8e380ae52fa774b6e49 --- /dev/null +++ b/cmd/balance-worker/main.go @@ -0,0 +1,88 @@ +package main + +import ( + "context" + "errors" + "fmt" + "log/slog" + "os" + + "github.com/spf13/pflag" + "github.com/spf13/viper" + + "github.com/openmeterio/openmeter/app/config" + "github.com/openmeterio/openmeter/pkg/log" +) + +func main() { + defer log.PanicLogger(log.WithExit) + + v, flags := viper.NewWithOptions(viper.WithDecodeHook(config.DecodeHook())), pflag.NewFlagSet("OpenMeter", pflag.ExitOnError) + ctx := context.Background() + + config.SetViperDefaults(v, flags) + + flags.String("config", "", "Configuration file") + flags.Bool("version", false, "Show version information") + flags.Bool("validate", false, "Validate configuration and exit") + + _ = flags.Parse(os.Args[1:]) + + if v, _ := flags.GetBool("version"); v { + fmt.Printf("%s version %s (%s) built on %s\n", "Open Meter", version, revision, revisionDate) + + os.Exit(0) + } + + if c, _ := flags.GetString("config"); c != "" { + v.SetConfigFile(c) + } + + err := v.ReadInConfig() + if err != nil && !errors.As(err, &viper.ConfigFileNotFoundError{}) { + panic(err) + } + + var conf config.Configuration + err = v.Unmarshal(&conf) + if err != nil { + panic(err) + } + + err = conf.Validate() + if err != nil { + println("configuration error:") + println(err.Error()) + os.Exit(1) + } + + if v, _ := flags.GetBool("validate"); v { + os.Exit(0) + } + + app, cleanup, err := initializeApplication(ctx, conf) + if err != nil { + slog.Error("failed to initialize application", "error", err) + + // Call cleanup function is may not set yet + if cleanup != nil { + cleanup() + } + + os.Exit(1) + } + defer cleanup() + + app.SetGlobals() + + logger := app.Logger + + // Validate service prerequisites + + if err := app.Migrate(ctx); err != nil { + logger.Error("failed to initialize database", "error", err) + os.Exit(1) + } + + app.Run() +} diff --git a/cmd/balance-worker/version.go b/cmd/balance-worker/version.go new file mode 100644 index 0000000000000000000000000000000000000000..24582ea13df9a8b2e85220e2ba9c690275f0faa4 --- /dev/null +++ b/cmd/balance-worker/version.go @@ -0,0 +1,34 @@ +package main + +import "runtime/debug" + +// Provisioned by ldflags. +var version string + +//nolint:gochecknoglobals +var ( + revision string + revisionDate string +) + +//nolint:gochecknoinits,goconst +func init() { + if version == "" { + version = "unknown" + } + + buildInfo, _ := debug.ReadBuildInfo() + + revision = "unknown" + revisionDate = "unknown" + + for _, setting := range buildInfo.Settings { + if setting.Key == "vcs.revision" { + revision = setting.Value + } + + if setting.Key == "vcs.time" { + revisionDate = setting.Value + } + } +} diff --git a/cmd/balance-worker/wire.go b/cmd/balance-worker/wire.go new file mode 100644 index 0000000000000000000000000000000000000000..725f5090c3ed8222be61ad9e1ee872c81204b1d6 --- /dev/null +++ b/cmd/balance-worker/wire.go @@ -0,0 +1,61 @@ +//go:build wireinject +// +build wireinject + +package main + +import ( + "context" + "log/slog" + + "github.com/google/wire" + + "github.com/openmeterio/openmeter/app/common" + "github.com/openmeterio/openmeter/app/config" +) + +type Application struct { + common.GlobalInitializer + common.Migrator + common.Runner + + Logger *slog.Logger + RuntimeMetricsCollector common.RuntimeMetricsCollector +} + +func initializeApplication(ctx context.Context, conf config.Configuration) (Application, func(), error) { + wire.Build( + metadata, + + wire.FieldsOf(new(config.Configuration), "BalanceWorker"), + wire.FieldsOf(new(config.BalanceWorkerConfiguration), "ConsumerConfiguration"), + + common.BalanceWorker, + common.BalanceWorkerAdapter, + common.ClickHouse, + common.Config, + common.Customer, + common.Database, + common.Feature, + common.Framework, + common.Meter, + common.Namespace, + common.NewDefaultTextMapPropagator, + common.NewKafkaTopicProvisioner, + common.Notification, + common.ProgressManager, + common.Streaming, + common.Subject, + common.NewSvixAPIClient, + common.Telemetry, + common.TelemetryLoggerNoAdditionalMiddlewares, + common.Watermill, + common.WatermillRouter, + common.Lockr, + wire.Struct(new(Application), "*"), + ) + return Application{}, nil, nil +} + +func metadata(conf config.Configuration) common.Metadata { + return common.NewMetadata(conf, version, "balance-worker") +} diff --git a/cmd/balance-worker/wire_gen.go b/cmd/balance-worker/wire_gen.go new file mode 100644 index 0000000000000000000000000000000000000000..016941505588a925aedc34002ced0f9350999885 --- /dev/null +++ b/cmd/balance-worker/wire_gen.go @@ -0,0 +1,356 @@ +// Code generated by Wire. DO NOT EDIT. + +//go:generate go run -mod=mod github.com/google/wire/cmd/wire +//go:build !wireinject +// +build !wireinject + +package main + +import ( + "context" + "github.com/openmeterio/openmeter/app/common" + "github.com/openmeterio/openmeter/app/config" + "github.com/openmeterio/openmeter/openmeter/watermill/driver/kafka" + "github.com/openmeterio/openmeter/openmeter/watermill/router" + "log/slog" +) + +// Injectors from wire.go: + +func initializeApplication(ctx context.Context, conf config.Configuration) (Application, func(), error) { + telemetryConfig := conf.Telemetry + logTelemetryConfig := telemetryConfig.Log + commonMetadata := metadata(conf) + resource := common.NewTelemetryResource(commonMetadata) + loggerProvider, cleanup, err := common.NewLoggerProvider(ctx, logTelemetryConfig, resource) + if err != nil { + return Application{}, nil, err + } + v := common.TelemetryLoggerNoAdditionalMiddlewares() + logger := common.NewLogger(logTelemetryConfig, resource, loggerProvider, commonMetadata, v) + metricsTelemetryConfig := telemetryConfig.Metrics + meterProvider, cleanup2, err := common.NewMeterProvider(ctx, metricsTelemetryConfig, resource, logger) + if err != nil { + cleanup() + return Application{}, nil, err + } + traceTelemetryConfig := telemetryConfig.Trace + tracerProvider, cleanup3, err := common.NewTracerProvider(ctx, traceTelemetryConfig, resource, logger) + if err != nil { + cleanup2() + cleanup() + return Application{}, nil, err + } + textMapPropagator := common.NewDefaultTextMapPropagator() + globalInitializer := common.GlobalInitializer{ + Logger: logger, + MeterProvider: meterProvider, + TracerProvider: tracerProvider, + TextMapPropagator: textMapPropagator, + } + postgresConfig := conf.Postgres + meter := common.NewMeter(meterProvider, commonMetadata) + driver, cleanup4, err := common.NewPostgresDriver(ctx, postgresConfig, meterProvider, meter, tracerProvider, logger) + if err != nil { + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + db := common.NewDB(driver) + entPostgresDriver, cleanup5 := common.NewEntPostgresDriver(db, logger) + client := common.NewEntClient(entPostgresDriver) + migrator := common.Migrator{ + Config: postgresConfig, + Client: client, + Logger: logger, + } + eventsConfiguration := conf.Events + balanceWorkerConfiguration := conf.BalanceWorker + ingestConfiguration := conf.Ingest + kafkaIngestConfiguration := ingestConfiguration.Kafka + kafkaConfiguration := kafkaIngestConfiguration.KafkaConfiguration + brokerOptions := common.NewBrokerConfiguration(kafkaConfiguration, commonMetadata, logger, meter) + subscriber, err := common.BalanceWorkerSubscriber(balanceWorkerConfiguration, brokerOptions) + if err != nil { + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + v2 := common.BalanceWorkerProvisionTopics(balanceWorkerConfiguration, eventsConfiguration) + topicProvisionerConfig := kafkaIngestConfiguration.TopicProvisioner + topicProvisioner, err := common.NewKafkaTopicProvisioner(kafkaConfiguration, topicProvisionerConfig, logger, meter) + if err != nil { + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + publisherOptions := kafka.PublisherOptions{ + Broker: brokerOptions, + ProvisionTopics: v2, + TopicProvisioner: topicProvisioner, + } + publisher, cleanup6, err := common.NewPublisher(ctx, publisherOptions, logger) + if err != nil { + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + tracer := common.NewTracer(tracerProvider, commonMetadata) + consumerConfiguration := balanceWorkerConfiguration.ConsumerConfiguration + options := router.Options{ + Subscriber: subscriber, + Publisher: publisher, + Logger: logger, + MetricMeter: meter, + Tracer: tracer, + Config: consumerConfiguration, + } + eventbusPublisher, err := common.NewEventBusPublisher(publisher, eventsConfiguration, logger) + if err != nil { + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + entitlementsConfiguration := conf.Entitlements + aggregationConfiguration := conf.Aggregation + clickHouseAggregationConfiguration := aggregationConfiguration.ClickHouse + v3, cleanup7, err := common.NewClickHouse(ctx, clickHouseAggregationConfiguration, tracer, meter, logger) + if err != nil { + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + progressManagerConfiguration := conf.ProgressManager + service, err := common.NewProgressManager(logger, progressManagerConfiguration) + if err != nil { + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + namespaceConfiguration := conf.Namespace + manager, err := common.NewNamespaceManager(namespaceConfiguration) + if err != nil { + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + connector, err := common.NewStreamingConnector(ctx, aggregationConfiguration, v3, logger, service, manager) + if err != nil { + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + adapter, err := common.NewMeterAdapter(logger, client) + if err != nil { + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + meterService := common.NewMeterService(adapter) + locker, err := common.NewLocker(logger) + if err != nil { + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + customerService, err := common.NewCustomerService(logger, client, eventbusPublisher) + if err != nil { + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + entitlement, err := common.NewEntitlementRegistry(logger, client, tracer, entitlementsConfiguration, connector, meterService, eventbusPublisher, locker, customerService) + if err != nil { + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + balanceWorkerEntitlementRepo := common.NewBalanceWorkerEntitlementRepo(client) + repository, err := common.NewNotificationAdapter(logger, client) + if err != nil { + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + notificationConfiguration := conf.Notification + webhookConfiguration := notificationConfiguration.Webhook + v4 := conf.Svix + svix, err := common.NewSvixAPIClient(v4, meterProvider, tracerProvider) + if err != nil { + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + handler, err := common.NewNotificationWebhookHandler(logger, tracer, webhookConfiguration, svix) + if err != nil { + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + featureConnector := common.NewFeatureConnector(logger, client, meterService, eventbusPublisher) + notificationService, err := common.NewNotificationService(logger, repository, handler, featureConnector) + if err != nil { + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + subjectAdapter, err := common.NewSubjectAdapter(client) + if err != nil { + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + subjectService, err := common.NewSubjectService(subjectAdapter) + if err != nil { + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + workerOptions := common.NewBalanceWorkerOptions(eventsConfiguration, options, eventbusPublisher, entitlement, balanceWorkerEntitlementRepo, notificationService, subjectService, customerService, logger, balanceWorkerConfiguration) + worker, err := common.NewBalanceWorker(workerOptions) + if err != nil { + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + health := common.NewHealthChecker(logger) + telemetryHandler := common.NewTelemetryHandler(metricsTelemetryConfig, health, logger) + v5, cleanup8 := common.NewTelemetryServer(telemetryConfig, telemetryHandler) + group := common.BalanceWorkerGroup(ctx, worker, v5) + runner := common.Runner{ + Group: group, + Logger: logger, + } + runtimeMetricsCollector, err := common.NewRuntimeMetricsCollector(meterProvider, logger) + if err != nil { + cleanup8() + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + application := Application{ + GlobalInitializer: globalInitializer, + Migrator: migrator, + Runner: runner, + Logger: logger, + RuntimeMetricsCollector: runtimeMetricsCollector, + } + return application, func() { + cleanup8() + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + }, nil +} + +// wire.go: + +type Application struct { + common.GlobalInitializer + common.Migrator + common.Runner + + Logger *slog.Logger + RuntimeMetricsCollector common.RuntimeMetricsCollector +} + +func metadata(conf config.Configuration) common.Metadata { + return common.NewMetadata(conf, version, "balance-worker") +} diff --git a/cmd/billing-worker/.air.toml b/cmd/billing-worker/.air.toml new file mode 100644 index 0000000000000000000000000000000000000000..c5b25edbb46159d741183fee97be9c3c4c332158 --- /dev/null +++ b/cmd/billing-worker/.air.toml @@ -0,0 +1,44 @@ +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] + args_bin = ["--config", "./config.yaml", "--telemetry-address", ":10004"] + bin = "./tmp/openmeter-billing-worker" + cmd = "go build -gcflags=\"all=-N -l\" -tags dynamic -o ./tmp/openmeter-billing-worker ./cmd/billing-worker" + delay = 0 + exclude_dir = ["assets", "ci", "deploy", "docs", "examples", "testdata", "quickstart", "tmp", "vendor", "api/client", "node_modules"] + exclude_file = [] + exclude_regex = ["_test.go"] + exclude_unchanged = false + follow_symlink = false + full_bin = "" + include_dir = [] + include_ext = ["go", "tpl", "tmpl", "html", "yml", "yaml", "sql", "json"] + include_file = [] + kill_delay = "0s" + log = "build-errors.log" + poll = false + poll_interval = 0 + rerun = false + rerun_delay = 500 + send_interrupt = true + stop_on_error = true + +[color] + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + main_only = false + time = false + +[misc] + clean_on_exit = false + +[screen] + clear_on_rebuild = false + keep_scroll = true diff --git a/cmd/billing-worker/main.go b/cmd/billing-worker/main.go new file mode 100644 index 0000000000000000000000000000000000000000..ac06d56a9f735879d7dfbff1c3be638d2b2bd1da --- /dev/null +++ b/cmd/billing-worker/main.go @@ -0,0 +1,100 @@ +package main + +import ( + "context" + "errors" + "fmt" + "log/slog" + "os" + + "github.com/spf13/pflag" + "github.com/spf13/viper" + + "github.com/openmeterio/openmeter/app/config" + "github.com/openmeterio/openmeter/pkg/log" +) + +func main() { + defer log.PanicLogger(log.WithExit) + + v, flags := viper.NewWithOptions(viper.WithDecodeHook(config.DecodeHook())), pflag.NewFlagSet("OpenMeter", pflag.ExitOnError) + ctx := context.Background() + + config.SetViperDefaults(v, flags) + + flags.String("config", "", "Configuration file") + flags.Bool("version", false, "Show version information") + flags.Bool("validate", false, "Validate configuration and exit") + + _ = flags.Parse(os.Args[1:]) + + if v, _ := flags.GetBool("version"); v { + fmt.Printf("%s version %s (%s) built on %s\n", "Open Meter", version, revision, revisionDate) + + os.Exit(0) + } + + if c, _ := flags.GetString("config"); c != "" { + v.SetConfigFile(c) + } + + err := v.ReadInConfig() + if err != nil && !errors.As(err, &viper.ConfigFileNotFoundError{}) { + panic(err) + } + + var conf config.Configuration + err = v.Unmarshal(&conf) + if err != nil { + panic(err) + } + + err = conf.Validate() + if err != nil { + println("configuration error:") + println(err.Error()) + os.Exit(1) + } + + if v, _ := flags.GetBool("validate"); v { + os.Exit(0) + } + + app, cleanup, err := initializeApplication(ctx, conf) + if err != nil { + slog.Error("failed to initialize application", "error", err) + + // Call cleanup function is may not set yet + if cleanup != nil { + cleanup() + } + + os.Exit(1) + } + defer cleanup() + + app.SetGlobals() + + logger := app.Logger + + // Migrate database + if err := app.Migrate(ctx); err != nil { + logger.Error("failed to initialize database", "error", err) + os.Exit(1) + } + + _, err = app.LedgerAccountResolver.EnsureBusinessAccounts(ctx, app.NamespaceManager.GetDefaultNamespace()) + if err != nil { + logger.Error("failed to provision ledger business accounts", "error", err) + os.Exit(1) + } + + // Provision sandbox app + err = app.AppRegistry.SandboxProvisioner(ctx, app.NamespaceManager.GetDefaultNamespace()) + if err != nil { + logger.Error("failed to provision sandbox app", "error", err) + os.Exit(1) + } + + app.Run() +} diff --git a/cmd/billing-worker/version.go b/cmd/billing-worker/version.go new file mode 100644 index 0000000000000000000000000000000000000000..24582ea13df9a8b2e85220e2ba9c690275f0faa4 --- /dev/null +++ b/cmd/billing-worker/version.go @@ -0,0 +1,34 @@ +package main + +import "runtime/debug" + +// Provisioned by ldflags. +var version string + +//nolint:gochecknoglobals +var ( + revision string + revisionDate string +) + +//nolint:gochecknoinits,goconst +func init() { + if version == "" { + version = "unknown" + } + + buildInfo, _ := debug.ReadBuildInfo() + + revision = "unknown" + revisionDate = "unknown" + + for _, setting := range buildInfo.Settings { + if setting.Key == "vcs.revision" { + revision = setting.Value + } + + if setting.Key == "vcs.time" { + revisionDate = setting.Value + } + } +} diff --git a/cmd/billing-worker/wire.go b/cmd/billing-worker/wire.go new file mode 100644 index 0000000000000000000000000000000000000000..022e66cd32322fd4714e7dd6e4a7956ae50fc73f --- /dev/null +++ b/cmd/billing-worker/wire.go @@ -0,0 +1,64 @@ +//go:build wireinject +// +build wireinject + +package main + +import ( + "context" + "log/slog" + + "github.com/google/wire" + + "github.com/openmeterio/openmeter/app/common" + "github.com/openmeterio/openmeter/app/config" + "github.com/openmeterio/openmeter/openmeter/ledger" + "github.com/openmeterio/openmeter/openmeter/meter" + "github.com/openmeterio/openmeter/openmeter/namespace" + "github.com/openmeterio/openmeter/openmeter/streaming" +) + +type Application struct { + common.GlobalInitializer + common.Migrator + common.Runner + + AppRegistry common.AppRegistry + LedgerAccountResolver ledger.AccountResolver + Logger *slog.Logger + Meter meter.Service + RuntimeMetricsCollector common.RuntimeMetricsCollector + NamespaceManager *namespace.Manager + Streaming streaming.Connector +} + +func initializeApplication(ctx context.Context, conf config.Configuration) (Application, func(), error) { + wire.Build( + wire.FieldsOf(new(config.BillingWorkerConfiguration), "ConsumerConfiguration"), + wire.FieldsOf(new(config.BillingConfiguration), "Worker"), + + metadata, + common.BillingWorker, + common.ClickHouse, + common.Config, + common.Database, + common.Framework, + common.Meter, + common.Namespace, + common.NewDefaultTextMapPropagator, + common.NewKafkaTopicProvisioner, + common.ProgressManager, + common.Streaming, + common.TaxCode, + common.Telemetry, + common.TelemetryLoggerNoAdditionalMiddlewares, + common.Watermill, + common.WatermillRouter, + wire.Struct(new(Application), "*"), + common.FeatureGateChecker, + ) + return Application{}, nil, nil +} + +func metadata(conf config.Configuration) common.Metadata { + return common.NewMetadata(conf, version, "billing-worker") +} diff --git a/cmd/billing-worker/wire_gen.go b/cmd/billing-worker/wire_gen.go new file mode 100644 index 0000000000000000000000000000000000000000..091a7d875208c4b4b6d651f06b35e3bd5b7b28b0 --- /dev/null +++ b/cmd/billing-worker/wire_gen.go @@ -0,0 +1,558 @@ +// Code generated by Wire. DO NOT EDIT. + +//go:generate go run -mod=mod github.com/google/wire/cmd/wire +//go:build !wireinject +// +build !wireinject + +package main + +import ( + "context" + "github.com/openmeterio/openmeter/app/common" + "github.com/openmeterio/openmeter/app/config" + "github.com/openmeterio/openmeter/openmeter/ledger" + "github.com/openmeterio/openmeter/openmeter/meter" + "github.com/openmeterio/openmeter/openmeter/namespace" + "github.com/openmeterio/openmeter/openmeter/productcatalog/featureresolver" + "github.com/openmeterio/openmeter/openmeter/streaming" + "github.com/openmeterio/openmeter/openmeter/watermill/driver/kafka" + "github.com/openmeterio/openmeter/openmeter/watermill/router" + "github.com/openmeterio/openmeter/pkg/featuregate" + "github.com/openmeterio/openmeter/pkg/ffx" + "log/slog" +) + +// Injectors from wire.go: + +func initializeApplication(ctx context.Context, conf config.Configuration) (Application, func(), error) { + telemetryConfig := conf.Telemetry + logTelemetryConfig := telemetryConfig.Log + commonMetadata := metadata(conf) + resource := common.NewTelemetryResource(commonMetadata) + loggerProvider, cleanup, err := common.NewLoggerProvider(ctx, logTelemetryConfig, resource) + if err != nil { + return Application{}, nil, err + } + v := common.TelemetryLoggerNoAdditionalMiddlewares() + logger := common.NewLogger(logTelemetryConfig, resource, loggerProvider, commonMetadata, v) + metricsTelemetryConfig := telemetryConfig.Metrics + meterProvider, cleanup2, err := common.NewMeterProvider(ctx, metricsTelemetryConfig, resource, logger) + if err != nil { + cleanup() + return Application{}, nil, err + } + traceTelemetryConfig := telemetryConfig.Trace + tracerProvider, cleanup3, err := common.NewTracerProvider(ctx, traceTelemetryConfig, resource, logger) + if err != nil { + cleanup2() + cleanup() + return Application{}, nil, err + } + textMapPropagator := common.NewDefaultTextMapPropagator() + globalInitializer := common.GlobalInitializer{ + Logger: logger, + MeterProvider: meterProvider, + TracerProvider: tracerProvider, + TextMapPropagator: textMapPropagator, + } + postgresConfig := conf.Postgres + meter := common.NewMeter(meterProvider, commonMetadata) + driver, cleanup4, err := common.NewPostgresDriver(ctx, postgresConfig, meterProvider, meter, tracerProvider, logger) + if err != nil { + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + db := common.NewDB(driver) + entPostgresDriver, cleanup5 := common.NewEntPostgresDriver(db, logger) + client := common.NewEntClient(entPostgresDriver) + migrator := common.Migrator{ + Config: postgresConfig, + Client: client, + Logger: logger, + } + eventsConfiguration := conf.Events + billingConfiguration := conf.Billing + ingestConfiguration := conf.Ingest + kafkaIngestConfiguration := ingestConfiguration.Kafka + kafkaConfiguration := kafkaIngestConfiguration.KafkaConfiguration + brokerOptions := common.NewBrokerConfiguration(kafkaConfiguration, commonMetadata, logger, meter) + subscriber, err := common.BillingWorkerSubscriber(billingConfiguration, brokerOptions) + if err != nil { + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + v2 := common.BillingWorkerProvisionTopics(billingConfiguration) + topicProvisionerConfig := kafkaIngestConfiguration.TopicProvisioner + topicProvisioner, err := common.NewKafkaTopicProvisioner(kafkaConfiguration, topicProvisionerConfig, logger, meter) + if err != nil { + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + publisherOptions := kafka.PublisherOptions{ + Broker: brokerOptions, + ProvisionTopics: v2, + TopicProvisioner: topicProvisioner, + } + publisher, cleanup6, err := common.NewPublisher(ctx, publisherOptions, logger) + if err != nil { + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + tracer := common.NewTracer(tracerProvider, commonMetadata) + billingWorkerConfiguration := billingConfiguration.Worker + consumerConfiguration := billingWorkerConfiguration.ConsumerConfiguration + options := router.Options{ + Subscriber: subscriber, + Publisher: publisher, + Logger: logger, + MetricMeter: meter, + Tracer: tracer, + Config: consumerConfiguration, + } + eventbusPublisher, err := common.NewEventBusPublisher(publisher, eventsConfiguration, logger) + if err != nil { + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + service, err := common.NewAppService(logger, client, eventbusPublisher) + if err != nil { + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + adapter, err := common.BillingAdapter(logger, client) + if err != nil { + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + unitConfigConfiguration := conf.UnitConfig + ratingService := common.NewBillingRatingService(unitConfigConfiguration) + customerService, err := common.NewCustomerService(logger, client, eventbusPublisher) + if err != nil { + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + adapterAdapter, err := common.NewMeterAdapter(logger, client) + if err != nil { + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + meterService := common.NewMeterService(adapterAdapter) + featureConnector := common.NewFeatureConnector(logger, client, meterService, eventbusPublisher) + aggregationConfiguration := conf.Aggregation + clickHouseAggregationConfiguration := aggregationConfiguration.ClickHouse + v3, cleanup7, err := common.NewClickHouse(ctx, clickHouseAggregationConfiguration, tracer, meter, logger) + if err != nil { + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + progressManagerConfiguration := conf.ProgressManager + progressmanagerService, err := common.NewProgressManager(logger, progressManagerConfiguration) + if err != nil { + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + namespaceConfiguration := conf.Namespace + manager, err := common.NewNamespaceManager(namespaceConfiguration) + if err != nil { + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + connector, err := common.NewStreamingConnector(ctx, aggregationConfiguration, v3, logger, progressmanagerService, manager) + if err != nil { + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + entitlementsConfiguration := conf.Entitlements + locker, err := common.NewLocker(logger) + if err != nil { + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + entitlement, err := common.NewEntitlementRegistry(logger, client, tracer, entitlementsConfiguration, connector, meterService, eventbusPublisher, locker, customerService) + if err != nil { + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + featureResolver, err := featureresolver.New(featureConnector) + if err != nil { + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + repository, err := common.NewTaxCodeAdapter(logger, client) + if err != nil { + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + taxcodeService, err := common.NewTaxCodeService(logger, repository) + if err != nil { + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + planService, err := common.NewPlanService(logger, client, featureResolver, taxcodeService, eventbusPublisher) + if err != nil { + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + addonService, err := common.NewAddonService(logger, client, featureResolver, taxcodeService, eventbusPublisher) + if err != nil { + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + planaddonService, err := common.NewPlanAddonService(logger, client, planService, addonService, eventbusPublisher) + if err != nil { + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + ffxService := ffx.NewContextService() + subscriptionServiceWithWorkflow, err := common.NewSubscriptionServices(logger, client, featureConnector, entitlement, customerService, planService, planaddonService, addonService, eventbusPublisher, locker, ffxService, taxcodeService) + if err != nil { + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + billingFeatureSwitchesConfiguration := billingConfiguration.FeatureSwitches + creditsConfiguration := conf.Credits + repo := common.NewLedgerHistoricalRepo(client) + accountRepo := common.NewLedgerAccountRepo(client) + accountService := common.NewLedgerAccountService(creditsConfiguration, accountRepo, locker) + accountCatalog := common.NewLedgerAccountCatalog(accountService) + accountLocker := common.NewLedgerAccountLocker(accountService) + routingValidator := common.NewLedgerRoutingValidator() + ledgerReadWriter := common.NewLedgerHistoricalLedger(creditsConfiguration, repo, accountCatalog, accountLocker, routingValidator) + ledger := common.NewLedgerService(ledgerReadWriter) + balanceQuerier := common.NewLedgerBalanceQuerier(ledgerReadWriter) + customerAccountRepo := common.NewLedgerResolversRepo(client) + customerLedgerProvisioner := common.NewLedgerResolversService(creditsConfiguration, accountService, customerAccountRepo, locker) + accountResolver := common.NewLedgerAccountResolver(customerLedgerProvisioner) + breakageService, err := common.NewLedgerBreakageService(creditsConfiguration, client, balanceQuerier, accountResolver, accountService) + if err != nil { + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + gate := featuregate.NewNoop() + featureGateConfiguration := conf.FeatureGate + featureGateChecker := common.NewFeatureGateChecker(gate, featureGateConfiguration, creditsConfiguration) + billingRegistry, err := common.NewBillingRegistry(logger, service, adapter, ratingService, customerService, featureConnector, meterService, meter, connector, eventbusPublisher, billingConfiguration, subscriptionServiceWithWorkflow, client, billingFeatureSwitchesConfiguration, creditsConfiguration, tracer, taxcodeService, locker, ledger, balanceQuerier, accountResolver, accountService, breakageService, featureGateChecker) + if err != nil { + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + subscriptionsyncAdapter, err := common.NewBillingSubscriptionSyncAdapter(client) + if err != nil { + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + subscriptionsyncService, err := common.NewBillingSubscriptionSyncService(logger, subscriptionServiceWithWorkflow, billingRegistry, subscriptionsyncAdapter, tracer, creditsConfiguration, billingFeatureSwitchesConfiguration, featureGateChecker) + if err != nil { + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + invoiceCollector, err := common.NewBillingCollector(logger, billingRegistry, billingFeatureSwitchesConfiguration) + if err != nil { + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + workerOptions := common.NewBillingWorkerOptions(eventsConfiguration, options, eventbusPublisher, billingRegistry, subscriptionServiceWithWorkflow, subscriptionsyncService, invoiceCollector, billingFeatureSwitchesConfiguration, logger) + worker, err := common.NewBillingWorker(workerOptions) + if err != nil { + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + invoicemetricsAdapter, err := common.NewBillingWorkerInvoiceMetricsAdapter(client, adapter) + if err != nil { + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + invoicemetricsService, err := common.NewBillingWorkerInvoiceMetricsService(invoicemetricsAdapter, meter, logger, billingFeatureSwitchesConfiguration) + if err != nil { + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + health := common.NewHealthChecker(logger) + telemetryHandler := common.NewTelemetryHandler(metricsTelemetryConfig, health, logger) + v4, cleanup8 := common.NewTelemetryServer(telemetryConfig, telemetryHandler) + group := common.BillingWorkerGroup(ctx, worker, invoicemetricsService, v4) + runner := common.Runner{ + Group: group, + Logger: logger, + } + appsConfiguration := conf.Apps + factory, err := common.NewAppSandboxFactory(appsConfiguration, service, billingRegistry) + if err != nil { + cleanup8() + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + appSandboxProvisioner, err := common.NewAppSandboxProvisioner(ctx, logger, appsConfiguration, service, manager, billingRegistry, factory) + if err != nil { + cleanup8() + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + secretserviceService, err := common.NewUnsafeSecretService(logger, client) + if err != nil { + cleanup8() + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + appstripeService, err := common.NewAppStripeService(logger, client, appsConfiguration, service, customerService, secretserviceService, billingRegistry, eventbusPublisher) + if err != nil { + cleanup8() + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + appcustominvoicingService, err := common.NewAppCustomInvoicingService(logger, client, appsConfiguration, service, customerService, secretserviceService, billingRegistry, eventbusPublisher) + if err != nil { + cleanup8() + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + appRegistry := common.NewAppRegistry(service, appSandboxProvisioner, appstripeService, appcustominvoicingService) + runtimeMetricsCollector, err := common.NewRuntimeMetricsCollector(meterProvider, logger) + if err != nil { + cleanup8() + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + application := Application{ + GlobalInitializer: globalInitializer, + Migrator: migrator, + Runner: runner, + AppRegistry: appRegistry, + LedgerAccountResolver: accountResolver, + Logger: logger, + Meter: meterService, + RuntimeMetricsCollector: runtimeMetricsCollector, + NamespaceManager: manager, + Streaming: connector, + } + return application, func() { + cleanup8() + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + }, nil +} + +// wire.go: + +type Application struct { + common.GlobalInitializer + common.Migrator + common.Runner + + AppRegistry common.AppRegistry + LedgerAccountResolver ledger.AccountResolver + Logger *slog.Logger + Meter meter.Service + RuntimeMetricsCollector common.RuntimeMetricsCollector + NamespaceManager *namespace.Manager + Streaming streaming.Connector +} + +func metadata(conf config.Configuration) common.Metadata { + return common.NewMetadata(conf, version, "billing-worker") +} diff --git a/cmd/jobs/billing/advance/advance.go b/cmd/jobs/billing/advance/advance.go new file mode 100644 index 0000000000000000000000000000000000000000..5e1b74ec12f502fca2d6f4afc0e388313f0c507b --- /dev/null +++ b/cmd/jobs/billing/advance/advance.go @@ -0,0 +1,99 @@ +package advance + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/openmeterio/openmeter/cmd/jobs/internal" + "github.com/openmeterio/openmeter/openmeter/billing" +) + +var namespace string + +var Cmd = &cobra.Command{ + Use: "advance", + Short: "Invoice advance operations", +} + +func init() { + Cmd.AddCommand(ListCmd()) + Cmd.AddCommand(InvoiceCmd()) + Cmd.AddCommand(AllCmd()) + + Cmd.PersistentFlags().StringVar(&namespace, "namespace", "", "namespace the operation should be performed") +} + +var ListCmd = func() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List invoices which can be advanced", + RunE: func(cmd *cobra.Command, args []string) error { + var ns []string + if namespace != "" { + ns = append(ns, namespace) + } + + invoices, err := internal.App.BillingAutoAdvancer.ListInvoicesToAdvance(cmd.Context(), ns, nil) + if err != nil { + return err + } + + for _, invoice := range invoices { + fmt.Printf("Namespace: %s ID: %s DraftUntil: %s Status: %s\n", invoice.Namespace, invoice.ID, invoice.DraftUntil, invoice.Status) + } + + return nil + }, + } + + return cmd +} + +var InvoiceCmd = func() *cobra.Command { + cmd := &cobra.Command{ + Use: "invoice [INVOICE_ID]", + Short: "Advance invoice(s)", + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if namespace == "" { + return fmt.Errorf("invoice namespace is required") + } + + for _, invoiceID := range args { + _, err := internal.App.BillingAutoAdvancer.AdvanceInvoice(cmd.Context(), billing.InvoiceID{ + Namespace: namespace, + ID: invoiceID, + }) + if err != nil { + return fmt.Errorf("failed to advance invoice %s: %w", invoiceID, err) + } + } + + return nil + }, + } + + return cmd +} + +var batchSize int + +var AllCmd = func() *cobra.Command { + cmd := &cobra.Command{ + Use: "all", + Short: "Advance all eligible invoices", + RunE: func(cmd *cobra.Command, args []string) error { + var ns []string + if namespace != "" { + ns = append(ns, namespace) + } + + return internal.App.BillingAutoAdvancer.All(cmd.Context(), ns, batchSize) + }, + } + + cmd.PersistentFlags().IntVar(&batchSize, "batch", 0, "operation batch size") + + return cmd +} diff --git a/cmd/jobs/billing/advancecharges/advancecharges.go b/cmd/jobs/billing/advancecharges/advancecharges.go new file mode 100644 index 0000000000000000000000000000000000000000..ccea5f05e6ab3dd56b87123250f9dd95111ee49f --- /dev/null +++ b/cmd/jobs/billing/advancecharges/advancecharges.go @@ -0,0 +1,100 @@ +package advancecharges + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/openmeterio/openmeter/cmd/jobs/internal" + "github.com/openmeterio/openmeter/openmeter/customer" +) + +var namespace string + +var Cmd = &cobra.Command{ + Use: "advance-charges", + Short: "Charge advance operations", +} + +func init() { + Cmd.AddCommand(ListCmd()) + Cmd.AddCommand(CustomerCmd()) + Cmd.AddCommand(AllCmd()) + + Cmd.PersistentFlags().StringVar(&namespace, "namespace", "", "namespace the operation should be performed") +} + +var ListCmd = func() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List customers which have charges to advance", + RunE: func(cmd *cobra.Command, args []string) error { + if internal.App.ChargesAutoAdvancer == nil { + return fmt.Errorf("charges are not enabled") + } + + var ns []string + if namespace != "" { + ns = append(ns, namespace) + } + + customers, err := internal.App.ChargesAutoAdvancer.ListCustomersToAdvance(cmd.Context(), ns) + if err != nil { + return err + } + + for _, c := range customers { + fmt.Printf("Namespace: %s CustomerID: %s\n", c.Namespace, c.ID) + } + + return nil + }, + } + + return cmd +} + +var CustomerCmd = func() *cobra.Command { + cmd := &cobra.Command{ + Use: "customer [CUSTOMER_ID]", + Short: "Advance charges for a customer", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if internal.App.ChargesAutoAdvancer == nil { + return fmt.Errorf("charges are not enabled") + } + + if namespace == "" { + return fmt.Errorf("namespace is required") + } + + return internal.App.ChargesAutoAdvancer.AdvanceCharges(cmd.Context(), customer.CustomerID{ + Namespace: namespace, + ID: args[0], + }) + }, + } + + return cmd +} + +var AllCmd = func() *cobra.Command { + cmd := &cobra.Command{ + Use: "all", + Short: "Advance all eligible charges", + RunE: func(cmd *cobra.Command, args []string) error { + if internal.App.ChargesAutoAdvancer == nil { + return fmt.Errorf("charges are not enabled") + } + + var ns []string + if namespace != "" { + ns = append(ns, namespace) + } + + return internal.App.ChargesAutoAdvancer.All(cmd.Context(), ns) + }, + } + + return cmd +} diff --git a/cmd/jobs/billing/billing.go b/cmd/jobs/billing/billing.go new file mode 100644 index 0000000000000000000000000000000000000000..b3b327e46f998161a8db245280ed6171086aa13f --- /dev/null +++ b/cmd/jobs/billing/billing.go @@ -0,0 +1,22 @@ +package billing + +import ( + "github.com/spf13/cobra" + + "github.com/openmeterio/openmeter/cmd/jobs/billing/advance" + "github.com/openmeterio/openmeter/cmd/jobs/billing/advancecharges" + "github.com/openmeterio/openmeter/cmd/jobs/billing/collect" + "github.com/openmeterio/openmeter/cmd/jobs/billing/subscriptionsync" +) + +var Cmd = &cobra.Command{ + Use: "billing", + Short: "Billing operations", +} + +func init() { + Cmd.AddCommand(advance.Cmd) + Cmd.AddCommand(advancecharges.Cmd) + Cmd.AddCommand(collect.Cmd) + Cmd.AddCommand(subscriptionsync.Cmd) +} diff --git a/cmd/jobs/billing/collect/collect.go b/cmd/jobs/billing/collect/collect.go new file mode 100644 index 0000000000000000000000000000000000000000..fc3e738d7581a665d1cc11f87c49d2bad55d366d --- /dev/null +++ b/cmd/jobs/billing/collect/collect.go @@ -0,0 +1,106 @@ +package collect + +import ( + "fmt" + "time" + + "github.com/spf13/cobra" + + "github.com/openmeterio/openmeter/cmd/jobs/internal" + billingworkercollect "github.com/openmeterio/openmeter/openmeter/billing/worker/collect" + "github.com/openmeterio/openmeter/openmeter/customer" +) + +var ( + namespaces []string + customerIDs []string + invoiceIDs []string +) + +var Cmd = &cobra.Command{ + Use: "collect", + Short: "Invoice collection operations", +} + +func init() { + Cmd.AddCommand(ListCmd()) + Cmd.AddCommand(InvoiceCmd()) + Cmd.AddCommand(AllCmd()) +} + +var ListCmd = func() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List gathering invoices which can be collected", + RunE: func(cmd *cobra.Command, args []string) error { + invoices, err := internal.App.BillingCollector.ListCollectableInvoices(cmd.Context(), + billingworkercollect.ListCollectableInvoicesInput{ + Namespaces: namespaces, + InvoiceIDs: invoiceIDs, + Customers: customerIDs, + CollectionAt: time.Now(), + }) + if err != nil { + return err + } + + for _, invoice := range invoices { + fmt.Printf("Namespace: %s ID: %s CollectAt: %s\n", invoice.Namespace, invoice.ID, invoice.NextCollectionAt) + } + + return nil + }, + } + + cmd.PersistentFlags().StringSliceVar(&namespaces, "n", nil, "filter by namespaces") + cmd.PersistentFlags().StringSliceVar(&customerIDs, "c", nil, "filter by customer ids") + cmd.PersistentFlags().StringSliceVar(&invoiceIDs, "i", nil, "filter by invoice ids") + + return cmd +} + +var InvoiceCmd = func() *cobra.Command { + cmd := &cobra.Command{ + Use: "invoice [CUSTOMER_ID]", + Short: "Create new invoice(s) for customer(s)", + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + for _, customerID := range args { + _, err := internal.App.BillingCollector.CollectCustomerInvoice(cmd.Context(), + billingworkercollect.CollectCustomerInvoiceInput{ + CustomerID: customer.CustomerID{ + Namespace: "default", + ID: customerID, + }, + AsOf: time.Now(), + }, + ) + if err != nil { + return fmt.Errorf("failed to invoice customer %s: %w", customerID, err) + } + } + + return nil + }, + } + + return cmd +} + +var batchSize int + +var AllCmd = func() *cobra.Command { + cmd := &cobra.Command{ + Use: "all", + Short: "Advance all eligible invoices", + RunE: func(cmd *cobra.Command, args []string) error { + return internal.App.BillingCollector.All(cmd.Context(), namespaces, customerIDs, batchSize) + }, + } + + cmd.PersistentFlags().StringSliceVar(&namespaces, "n", nil, "filter by namespaces") + cmd.PersistentFlags().StringSliceVar(&customerIDs, "c", nil, "filter by customer ids") + cmd.PersistentFlags().IntVar(&batchSize, "batch", 0, "operation batch size") + + return cmd +} diff --git a/cmd/jobs/billing/subscriptionsync/sync.go b/cmd/jobs/billing/subscriptionsync/sync.go new file mode 100644 index 0000000000000000000000000000000000000000..ebb269bf50726547461fb051ca517cc58a88b00f --- /dev/null +++ b/cmd/jobs/billing/subscriptionsync/sync.go @@ -0,0 +1,95 @@ +package subscriptionsync + +import ( + "fmt" + "time" + + "github.com/spf13/cobra" + + "github.com/openmeterio/openmeter/cmd/jobs/internal" + "github.com/openmeterio/openmeter/openmeter/billing/worker/subscriptionsync/reconciler" +) + +const ( + defaultLookback = 24 * time.Hour +) + +var ( + namespaces []string + customerIDs []string + lookback time.Duration +) + +var Cmd = &cobra.Command{ + Use: "subscriptionsync", + Short: "Subscription sync operations", +} + +func init() { + Cmd.AddCommand(ListCmd()) + Cmd.AddCommand(AllCmd()) +} + +var ListCmd = func() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List subscriptions which can be synced", + RunE: func(cmd *cobra.Command, args []string) error { + subs, err := internal.App.BillingSubscriptionReconciler.ListSubscriptions(cmd.Context(), reconciler.ReconcilerListSubscriptionsInput{ + Namespaces: namespaces, + Customers: customerIDs, + Lookback: lookback, + }) + if err != nil { + return err + } + + for _, sub := range subs { + activeTo := "" + if sub.ActiveTo != nil { + activeTo = fmt.Sprintf(" ActiveTo: %s", sub.ActiveTo.Format(time.RFC3339)) + } + + fmt.Printf("Namespace: %s ID: %s CustomerID: %s ActiveFrom: %s%s\n", + sub.Namespace, + sub.ID, + sub.CustomerId, + sub.ActiveFrom.Format(time.RFC3339), + activeTo) + } + + return nil + }, + } + + cmd.PersistentFlags().StringSliceVar(&namespaces, "n", nil, "filter by namespaces") + cmd.PersistentFlags().StringSliceVar(&customerIDs, "c", nil, "filter by customer ids") + cmd.PersistentFlags().DurationVar(&lookback, "l", defaultLookback, "lookback period") + + return cmd +} + +var AllCmd = func() *cobra.Command { + var force bool + + cmd := &cobra.Command{ + Use: "all", + Short: "Sync all subscriptions", + RunE: func(cmd *cobra.Command, args []string) error { + return internal.App.BillingSubscriptionReconciler.All(cmd.Context(), reconciler.ReconcilerAllInput{ + ReconcilerListSubscriptionsInput: reconciler.ReconcilerListSubscriptionsInput{ + Namespaces: namespaces, + Customers: customerIDs, + Lookback: lookback, + }, + Force: force, + }) + }, + } + + cmd.PersistentFlags().StringSliceVar(&namespaces, "n", nil, "filter by namespaces") + cmd.PersistentFlags().StringSliceVar(&customerIDs, "c", nil, "filter by customer ids") + cmd.PersistentFlags().DurationVar(&lookback, "l", defaultLookback, "lookback period") + cmd.PersistentFlags().BoolVar(&force, "f", false, "force reconciliation (even if the sync state would not necessarily require it)") + return cmd +} diff --git a/cmd/jobs/entitlement/recalculatesnapshots.go b/cmd/jobs/entitlement/recalculatesnapshots.go new file mode 100644 index 0000000000000000000000000000000000000000..8e22ac16a753265ceaa4b82743b5f50134bcf717 --- /dev/null +++ b/cmd/jobs/entitlement/recalculatesnapshots.go @@ -0,0 +1,34 @@ +package entitlement + +import ( + "time" + + "github.com/spf13/cobra" + + "github.com/openmeterio/openmeter/cmd/jobs/internal" + "github.com/openmeterio/openmeter/openmeter/entitlement/balanceworker" +) + +func NewRecalculateBalanceSnapshotsCommand() *cobra.Command { + return &cobra.Command{ + Use: "recalculate-balance-snapshots", + Short: "Recalculate balance snapshots and send the resulting events into the eventbus", + RunE: func(cmd *cobra.Command, args []string) error { + recalculator, err := balanceworker.NewRecalculator(balanceworker.RecalculatorOptions{ + Entitlement: internal.App.EntitlementRegistry, + EventBus: internal.App.EventPublisher, + MetricMeter: internal.App.Meter, + NotificationService: internal.App.NotificationService, + HighWatermarkCacheSize: 100_000, + Logger: internal.App.Logger, + Customer: internal.App.Customer, + Subject: internal.App.Subject, + }) + if err != nil { + return err + } + + return recalculator.Recalculate(cmd.Context(), "default", time.Now()) + }, + } +} diff --git a/cmd/jobs/entitlement/root.go b/cmd/jobs/entitlement/root.go new file mode 100644 index 0000000000000000000000000000000000000000..c930e9fdd8ad2d3c2120f2dbd1672987cf58496a --- /dev/null +++ b/cmd/jobs/entitlement/root.go @@ -0,0 +1,14 @@ +package entitlement + +import "github.com/spf13/cobra" + +func RootCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "entitlement", + Short: "Entitlement related jobs", + } + + cmd.AddCommand(NewRecalculateBalanceSnapshotsCommand()) + + return cmd +} diff --git a/cmd/jobs/internal/app.go b/cmd/jobs/internal/app.go new file mode 100644 index 0000000000000000000000000000000000000000..40e987ade774479867b5c6aeef6a2eb194fe7665 --- /dev/null +++ b/cmd/jobs/internal/app.go @@ -0,0 +1,18 @@ +package internal + +import "context" + +func InitializeApplication(ctx context.Context, configFile string) error { + var err error + + if err = loadConfig(configFile); err != nil { + return err + } + + App, AppShutdown, err = initializeApplication(ctx, Config) + if err != nil { + return err + } + + return nil +} diff --git a/cmd/jobs/internal/config.go b/cmd/jobs/internal/config.go new file mode 100644 index 0000000000000000000000000000000000000000..247f95c4fec1633a6a3ae8599f60b6189be71975 --- /dev/null +++ b/cmd/jobs/internal/config.go @@ -0,0 +1,31 @@ +package internal + +import ( + "errors" + + "github.com/spf13/pflag" + "github.com/spf13/viper" + + "github.com/openmeterio/openmeter/app/config" +) + +func loadConfig(configFile string) error { + v, flags := viper.NewWithOptions(viper.WithDecodeHook(config.DecodeHook())), pflag.NewFlagSet("OpenMeter", pflag.ExitOnError) + + config.SetViperDefaults(v, flags) + if configFile != "" { + v.SetConfigFile(configFile) + } + + err := v.ReadInConfig() + if err != nil && !errors.As(err, &viper.ConfigFileNotFoundError{}) { + return err + } + + err = v.Unmarshal(&Config) + if err != nil { + return err + } + + return Config.Validate() +} diff --git a/cmd/jobs/internal/globals.go b/cmd/jobs/internal/globals.go new file mode 100644 index 0000000000000000000000000000000000000000..ada89d9a5680406bdd29a650037f2912a99add01 --- /dev/null +++ b/cmd/jobs/internal/globals.go @@ -0,0 +1,11 @@ +package internal + +import "github.com/openmeterio/openmeter/app/config" + +//nolint:gochecknoglobals +var ( + App Application + AppShutdown func() + Config config.Configuration + ConfigFile string +) diff --git a/cmd/jobs/internal/version.go b/cmd/jobs/internal/version.go new file mode 100644 index 0000000000000000000000000000000000000000..81990a2a99f12cd0e5e0b1a4f910d9916cea8676 --- /dev/null +++ b/cmd/jobs/internal/version.go @@ -0,0 +1,38 @@ +package internal + +import ( + "runtime/debug" +) + +//nolint:gochecknoglobals +var ( + version string + revision string + revisionDate string +) + +//nolint:gochecknoinits,goconst +func init() { + if version == "" { + version = "unknown" + } + + buildInfo, _ := debug.ReadBuildInfo() + + revision = "unknown" + revisionDate = "unknown" + + for _, setting := range buildInfo.Settings { + if setting.Key == "vcs.revision" { + revision = setting.Value + } + + if setting.Key == "vcs.time" { + revisionDate = setting.Value + } + } +} + +func Version() (string, string, string) { + return version, revision, revisionDate +} diff --git a/cmd/jobs/internal/wire.go b/cmd/jobs/internal/wire.go new file mode 100644 index 0000000000000000000000000000000000000000..0ed8d4699da2e9add3b3de1b2474e735d8c0af81 --- /dev/null +++ b/cmd/jobs/internal/wire.go @@ -0,0 +1,118 @@ +//go:build wireinject +// +build wireinject + +package internal + +import ( + "context" + "log/slog" + + "github.com/confluentinc/confluent-kafka-go/v2/kafka" + "github.com/google/wire" + "go.opentelemetry.io/otel/metric" + + "github.com/openmeterio/openmeter/app/common" + "github.com/openmeterio/openmeter/app/config" + "github.com/openmeterio/openmeter/openmeter/app" + appstripe "github.com/openmeterio/openmeter/openmeter/app/stripe" + chargesworkeradvance "github.com/openmeterio/openmeter/openmeter/billing/charges/worker/advance" + billingworkerautoadvance "github.com/openmeterio/openmeter/openmeter/billing/worker/advance" + billingworkercollect "github.com/openmeterio/openmeter/openmeter/billing/worker/collect" + billingworkersubscriptionreconciler "github.com/openmeterio/openmeter/openmeter/billing/worker/subscriptionsync/reconciler" + "github.com/openmeterio/openmeter/openmeter/customer" + "github.com/openmeterio/openmeter/openmeter/ent/db" + llmcostsync "github.com/openmeterio/openmeter/openmeter/llmcost/sync" + "github.com/openmeterio/openmeter/openmeter/meter" + "github.com/openmeterio/openmeter/openmeter/namespace" + "github.com/openmeterio/openmeter/openmeter/notification" + "github.com/openmeterio/openmeter/openmeter/productcatalog/feature" + "github.com/openmeterio/openmeter/openmeter/productcatalog/plan" + "github.com/openmeterio/openmeter/openmeter/registry" + "github.com/openmeterio/openmeter/openmeter/secret" + "github.com/openmeterio/openmeter/openmeter/streaming" + "github.com/openmeterio/openmeter/openmeter/subject" + "github.com/openmeterio/openmeter/openmeter/watermill/eventbus" + kafkametrics "github.com/openmeterio/openmeter/pkg/kafka/metrics" +) + +type Application struct { + common.GlobalInitializer + common.Migrator + + App app.Service + AppStripe appstripe.Service + AppSandboxProvisioner common.AppSandboxProvisioner + Customer customer.Service + BillingRegistry common.BillingRegistry + BillingAutoAdvancer *billingworkerautoadvance.AutoAdvancer + ChargesAutoAdvancer *chargesworkeradvance.AutoAdvancer + BillingCollector *billingworkercollect.InvoiceCollector + BillingSubscriptionReconciler *billingworkersubscriptionreconciler.Reconciler + EntClient *db.Client + EventPublisher eventbus.Publisher + EntitlementRegistry *registry.Entitlement + FeatureConnector feature.FeatureConnector + KafkaProducer *kafka.Producer + KafkaMetrics *kafkametrics.Metrics + Logger *slog.Logger + MeterService meter.Service + NamespaceManager *namespace.Manager + Meter metric.Meter + NotificationService notification.Service + Plan plan.Service + Secret secret.Service + Subscription common.SubscriptionServiceWithWorkflow + Subject subject.Service + StreamingConnector streaming.Connector + LLMCostSyncJob *llmcostsync.SyncJob +} + +func initializeApplication(ctx context.Context, conf config.Configuration) (Application, func(), error) { + wire.Build( + metadata, + common.App, + common.Billing, + common.ClickHouse, + common.Config, + common.Customer, + common.Subject, + common.Database, + common.Entitlement, + common.Framework, + common.Kafka, + common.LLMCost, + common.Meter, + common.FFX, + common.Namespace, + common.NewBillingAutoAdvancer, + common.NewChargesAutoAdvancer, + common.NewBillingCollector, + common.NewBillingSubscriptionSyncAdapter, + common.NewBillingSubscriptionSyncService, + common.NewBillingSubscriptionReconciler, + common.NewDefaultTextMapPropagator, + common.NewServerPublisher, + common.Notification, + common.Streaming, + common.TaxCode, + common.ProductCatalog, + common.ProgressManager, + common.Subscription, + common.LedgerStack, + common.Lockr, + common.Secret, + common.ServerProvisionTopics, + common.NewSvixAPIClient, + common.TelemetryWithoutServer, + common.TelemetryLoggerNoAdditionalMiddlewares, + common.WatermillNoPublisher, + wire.Struct(new(Application), "*"), + common.FeatureGateChecker, + ) + + return Application{}, nil, nil +} + +func metadata(conf config.Configuration) common.Metadata { + return common.NewMetadata(conf, version, "jobs") +} diff --git a/cmd/jobs/internal/wire_gen.go b/cmd/jobs/internal/wire_gen.go new file mode 100644 index 0000000000000000000000000000000000000000..3f95f5b1568ceac741ba3a8667db12ef348fc482 --- /dev/null +++ b/cmd/jobs/internal/wire_gen.go @@ -0,0 +1,654 @@ +// Code generated by Wire. DO NOT EDIT. + +//go:generate go run -mod=mod github.com/google/wire/cmd/wire +//go:build !wireinject +// +build !wireinject + +package internal + +import ( + "context" + kafka2 "github.com/confluentinc/confluent-kafka-go/v2/kafka" + "github.com/openmeterio/openmeter/app/common" + "github.com/openmeterio/openmeter/app/config" + "github.com/openmeterio/openmeter/openmeter/app" + "github.com/openmeterio/openmeter/openmeter/app/stripe" + "github.com/openmeterio/openmeter/openmeter/billing/charges/worker/advance" + "github.com/openmeterio/openmeter/openmeter/billing/worker/advance" + "github.com/openmeterio/openmeter/openmeter/billing/worker/collect" + "github.com/openmeterio/openmeter/openmeter/billing/worker/subscriptionsync/reconciler" + "github.com/openmeterio/openmeter/openmeter/customer" + "github.com/openmeterio/openmeter/openmeter/ent/db" + "github.com/openmeterio/openmeter/openmeter/llmcost/sync" + "github.com/openmeterio/openmeter/openmeter/meter" + "github.com/openmeterio/openmeter/openmeter/namespace" + "github.com/openmeterio/openmeter/openmeter/notification" + "github.com/openmeterio/openmeter/openmeter/productcatalog/feature" + "github.com/openmeterio/openmeter/openmeter/productcatalog/featureresolver" + "github.com/openmeterio/openmeter/openmeter/productcatalog/plan" + "github.com/openmeterio/openmeter/openmeter/registry" + "github.com/openmeterio/openmeter/openmeter/secret" + "github.com/openmeterio/openmeter/openmeter/streaming" + "github.com/openmeterio/openmeter/openmeter/subject" + "github.com/openmeterio/openmeter/openmeter/watermill/driver/kafka" + "github.com/openmeterio/openmeter/openmeter/watermill/eventbus" + "github.com/openmeterio/openmeter/pkg/featuregate" + "github.com/openmeterio/openmeter/pkg/ffx" + "github.com/openmeterio/openmeter/pkg/kafka/metrics" + "go.opentelemetry.io/otel/metric" + "log/slog" +) + +// Injectors from wire.go: + +func initializeApplication(ctx context.Context, conf config.Configuration) (Application, func(), error) { + telemetryConfig := conf.Telemetry + logTelemetryConfig := telemetryConfig.Log + commonMetadata := metadata(conf) + resource := common.NewTelemetryResource(commonMetadata) + loggerProvider, cleanup, err := common.NewLoggerProvider(ctx, logTelemetryConfig, resource) + if err != nil { + return Application{}, nil, err + } + v := common.TelemetryLoggerNoAdditionalMiddlewares() + logger := common.NewLogger(logTelemetryConfig, resource, loggerProvider, commonMetadata, v) + metricsTelemetryConfig := telemetryConfig.Metrics + meterProvider, cleanup2, err := common.NewMeterProvider(ctx, metricsTelemetryConfig, resource, logger) + if err != nil { + cleanup() + return Application{}, nil, err + } + traceTelemetryConfig := telemetryConfig.Trace + tracerProvider, cleanup3, err := common.NewTracerProvider(ctx, traceTelemetryConfig, resource, logger) + if err != nil { + cleanup2() + cleanup() + return Application{}, nil, err + } + textMapPropagator := common.NewDefaultTextMapPropagator() + globalInitializer := common.GlobalInitializer{ + Logger: logger, + MeterProvider: meterProvider, + TracerProvider: tracerProvider, + TextMapPropagator: textMapPropagator, + } + postgresConfig := conf.Postgres + meter := common.NewMeter(meterProvider, commonMetadata) + driver, cleanup4, err := common.NewPostgresDriver(ctx, postgresConfig, meterProvider, meter, tracerProvider, logger) + if err != nil { + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + db := common.NewDB(driver) + entPostgresDriver, cleanup5 := common.NewEntPostgresDriver(db, logger) + client := common.NewEntClient(entPostgresDriver) + migrator := common.Migrator{ + Config: postgresConfig, + Client: client, + Logger: logger, + } + ingestConfiguration := conf.Ingest + kafkaIngestConfiguration := ingestConfiguration.Kafka + kafkaConfiguration := kafkaIngestConfiguration.KafkaConfiguration + brokerOptions := common.NewBrokerConfiguration(kafkaConfiguration, commonMetadata, logger, meter) + eventsConfiguration := conf.Events + v2 := common.ServerProvisionTopics(eventsConfiguration) + topicProvisionerConfig := kafkaIngestConfiguration.TopicProvisioner + topicProvisioner, err := common.NewKafkaTopicProvisioner(kafkaConfiguration, topicProvisionerConfig, logger, meter) + if err != nil { + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + publisherOptions := kafka.PublisherOptions{ + Broker: brokerOptions, + ProvisionTopics: v2, + TopicProvisioner: topicProvisioner, + } + publisher, cleanup6, err := common.NewServerPublisher(ctx, publisherOptions, logger) + if err != nil { + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + eventbusPublisher, err := common.NewEventBusPublisher(publisher, eventsConfiguration, logger) + if err != nil { + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + service, err := common.NewAppService(logger, client, eventbusPublisher) + if err != nil { + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + appsConfiguration := conf.Apps + customerService, err := common.NewCustomerService(logger, client, eventbusPublisher) + if err != nil { + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + secretserviceService, err := common.NewUnsafeSecretService(logger, client) + if err != nil { + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + adapter, err := common.BillingAdapter(logger, client) + if err != nil { + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + unitConfigConfiguration := conf.UnitConfig + ratingService := common.NewBillingRatingService(unitConfigConfiguration) + adapterAdapter, err := common.NewMeterAdapter(logger, client) + if err != nil { + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + meterService := common.NewMeterService(adapterAdapter) + featureConnector := common.NewFeatureConnector(logger, client, meterService, eventbusPublisher) + aggregationConfiguration := conf.Aggregation + clickHouseAggregationConfiguration := aggregationConfiguration.ClickHouse + tracer := common.NewTracer(tracerProvider, commonMetadata) + v3, cleanup7, err := common.NewClickHouse(ctx, clickHouseAggregationConfiguration, tracer, meter, logger) + if err != nil { + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + progressManagerConfiguration := conf.ProgressManager + progressmanagerService, err := common.NewProgressManager(logger, progressManagerConfiguration) + if err != nil { + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + namespaceConfiguration := conf.Namespace + manager, err := common.NewNamespaceManager(namespaceConfiguration) + if err != nil { + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + connector, err := common.NewStreamingConnector(ctx, aggregationConfiguration, v3, logger, progressmanagerService, manager) + if err != nil { + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + billingConfiguration := conf.Billing + entitlementsConfiguration := conf.Entitlements + locker, err := common.NewLocker(logger) + if err != nil { + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + entitlement, err := common.NewEntitlementRegistry(logger, client, tracer, entitlementsConfiguration, connector, meterService, eventbusPublisher, locker, customerService) + if err != nil { + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + featureResolver, err := featureresolver.New(featureConnector) + if err != nil { + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + repository, err := common.NewTaxCodeAdapter(logger, client) + if err != nil { + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + taxcodeService, err := common.NewTaxCodeService(logger, repository) + if err != nil { + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + planService, err := common.NewPlanService(logger, client, featureResolver, taxcodeService, eventbusPublisher) + if err != nil { + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + addonService, err := common.NewAddonService(logger, client, featureResolver, taxcodeService, eventbusPublisher) + if err != nil { + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + planaddonService, err := common.NewPlanAddonService(logger, client, planService, addonService, eventbusPublisher) + if err != nil { + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + ffxService := ffx.NewContextService() + subscriptionServiceWithWorkflow, err := common.NewSubscriptionServices(logger, client, featureConnector, entitlement, customerService, planService, planaddonService, addonService, eventbusPublisher, locker, ffxService, taxcodeService) + if err != nil { + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + billingFeatureSwitchesConfiguration := billingConfiguration.FeatureSwitches + creditsConfiguration := conf.Credits + repo := common.NewLedgerHistoricalRepo(client) + accountRepo := common.NewLedgerAccountRepo(client) + accountService := common.NewLedgerAccountService(creditsConfiguration, accountRepo, locker) + accountCatalog := common.NewLedgerAccountCatalog(accountService) + accountLocker := common.NewLedgerAccountLocker(accountService) + routingValidator := common.NewLedgerRoutingValidator() + ledgerReadWriter := common.NewLedgerHistoricalLedger(creditsConfiguration, repo, accountCatalog, accountLocker, routingValidator) + ledger := common.NewLedgerService(ledgerReadWriter) + balanceQuerier := common.NewLedgerBalanceQuerier(ledgerReadWriter) + customerAccountRepo := common.NewLedgerResolversRepo(client) + customerLedgerProvisioner := common.NewLedgerResolversService(creditsConfiguration, accountService, customerAccountRepo, locker) + accountResolver := common.NewLedgerAccountResolver(customerLedgerProvisioner) + breakageService, err := common.NewLedgerBreakageService(creditsConfiguration, client, balanceQuerier, accountResolver, accountService) + if err != nil { + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + gate := featuregate.NewNoop() + featureGateConfiguration := conf.FeatureGate + featureGateChecker := common.NewFeatureGateChecker(gate, featureGateConfiguration, creditsConfiguration) + billingRegistry, err := common.NewBillingRegistry(logger, service, adapter, ratingService, customerService, featureConnector, meterService, meter, connector, eventbusPublisher, billingConfiguration, subscriptionServiceWithWorkflow, client, billingFeatureSwitchesConfiguration, creditsConfiguration, tracer, taxcodeService, locker, ledger, balanceQuerier, accountResolver, accountService, breakageService, featureGateChecker) + if err != nil { + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + appstripeService, err := common.NewAppStripeService(logger, client, appsConfiguration, service, customerService, secretserviceService, billingRegistry, eventbusPublisher) + if err != nil { + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + factory, err := common.NewAppSandboxFactory(appsConfiguration, service, billingRegistry) + if err != nil { + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + appSandboxProvisioner, err := common.NewAppSandboxProvisioner(ctx, logger, appsConfiguration, service, manager, billingRegistry, factory) + if err != nil { + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + autoAdvancer, err := common.NewBillingAutoAdvancer(logger, billingRegistry) + if err != nil { + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + chargesworkeradvanceAutoAdvancer, err := common.NewChargesAutoAdvancer(logger, billingRegistry) + if err != nil { + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + invoiceCollector, err := common.NewBillingCollector(logger, billingRegistry, billingFeatureSwitchesConfiguration) + if err != nil { + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + subscriptionsyncAdapter, err := common.NewBillingSubscriptionSyncAdapter(client) + if err != nil { + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + subscriptionsyncService, err := common.NewBillingSubscriptionSyncService(logger, subscriptionServiceWithWorkflow, billingRegistry, subscriptionsyncAdapter, tracer, creditsConfiguration, billingFeatureSwitchesConfiguration, featureGateChecker) + if err != nil { + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + reconciler, err := common.NewBillingSubscriptionReconciler(logger, subscriptionServiceWithWorkflow, subscriptionsyncService, customerService) + if err != nil { + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + producer, err := common.NewKafkaProducer(kafkaIngestConfiguration, logger, commonMetadata) + if err != nil { + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + metrics, err := common.NewKafkaMetrics(meter) + if err != nil { + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + notificationRepository, err := common.NewNotificationAdapter(logger, client) + if err != nil { + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + notificationConfiguration := conf.Notification + webhookConfiguration := notificationConfiguration.Webhook + v4 := conf.Svix + svix, err := common.NewSvixAPIClient(v4, meterProvider, tracerProvider) + if err != nil { + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + handler, err := common.NewNotificationWebhookHandler(logger, tracer, webhookConfiguration, svix) + if err != nil { + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + notificationService, err := common.NewNotificationService(logger, notificationRepository, handler, featureConnector) + if err != nil { + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + subjectAdapter, err := common.NewSubjectAdapter(client) + if err != nil { + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + subjectService, err := common.NewSubjectService(subjectAdapter) + if err != nil { + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + syncJob, err := common.NewLLMCostSyncJob(logger, client) + if err != nil { + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + return Application{}, nil, err + } + application := Application{ + GlobalInitializer: globalInitializer, + Migrator: migrator, + App: service, + AppStripe: appstripeService, + AppSandboxProvisioner: appSandboxProvisioner, + Customer: customerService, + BillingRegistry: billingRegistry, + BillingAutoAdvancer: autoAdvancer, + ChargesAutoAdvancer: chargesworkeradvanceAutoAdvancer, + BillingCollector: invoiceCollector, + BillingSubscriptionReconciler: reconciler, + EntClient: client, + EventPublisher: eventbusPublisher, + EntitlementRegistry: entitlement, + FeatureConnector: featureConnector, + KafkaProducer: producer, + KafkaMetrics: metrics, + Logger: logger, + MeterService: meterService, + NamespaceManager: manager, + Meter: meter, + NotificationService: notificationService, + Plan: planService, + Secret: secretserviceService, + Subscription: subscriptionServiceWithWorkflow, + Subject: subjectService, + StreamingConnector: connector, + LLMCostSyncJob: syncJob, + } + return application, func() { + cleanup7() + cleanup6() + cleanup5() + cleanup4() + cleanup3() + cleanup2() + cleanup() + }, nil +} + +// wire.go: + +type Application struct { + common.GlobalInitializer + common.Migrator + + App app.Service + AppStripe appstripe.Service + AppSandboxProvisioner common.AppSandboxProvisioner + Customer customer.Service + BillingRegistry common.BillingRegistry + BillingAutoAdvancer *billingworkeradvance.AutoAdvancer + ChargesAutoAdvancer *chargesworkeradvance.AutoAdvancer + BillingCollector *billingworkercollect.InvoiceCollector + BillingSubscriptionReconciler *reconciler.Reconciler + EntClient *db.Client + EventPublisher eventbus.Publisher + EntitlementRegistry *registry.Entitlement + FeatureConnector feature.FeatureConnector + KafkaProducer *kafka2.Producer + KafkaMetrics *metrics.Metrics + Logger *slog.Logger + MeterService meter.Service + NamespaceManager *namespace.Manager + Meter metric.Meter + NotificationService notification.Service + Plan plan.Service + Secret secret.Service + Subscription common.SubscriptionServiceWithWorkflow + Subject subject.Service + StreamingConnector streaming.Connector + LLMCostSyncJob *sync.SyncJob +} + +func metadata(conf config.Configuration) common.Metadata { + return common.NewMetadata(conf, version, "jobs") +} diff --git a/cmd/jobs/ledger/backfillaccounts/backfillaccounts.go b/cmd/jobs/ledger/backfillaccounts/backfillaccounts.go new file mode 100644 index 0000000000000000000000000000000000000000..f89f0102896cbccb017b27f1a1cf61bc983de86e --- /dev/null +++ b/cmd/jobs/ledger/backfillaccounts/backfillaccounts.go @@ -0,0 +1,125 @@ +package backfillaccounts + +import ( + "fmt" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/openmeterio/openmeter/cmd/jobs/internal" + ledgerbackfillservice "github.com/openmeterio/openmeter/cmd/jobs/ledger/service" + accountadapter "github.com/openmeterio/openmeter/openmeter/ledger/account/adapter" + accountservice "github.com/openmeterio/openmeter/openmeter/ledger/account/service" + "github.com/openmeterio/openmeter/openmeter/ledger/resolvers" + resolversadapter "github.com/openmeterio/openmeter/openmeter/ledger/resolvers/adapter" + "github.com/openmeterio/openmeter/pkg/framework/lockr" +) + +var ( + createdBefore string + customerPageSize int + dryRun bool + continueOnError bool + includeDeleted bool +) + +var Cmd = &cobra.Command{ + Use: "backfill-accounts", + Short: "Backfill customer and business ledger accounts", + RunE: func(cmd *cobra.Command, args []string) error { + service, err := newService() + if err != nil { + return err + } + + targetNamespace := internal.App.NamespaceManager.GetDefaultNamespace() + + var createdBeforeTime *time.Time + if strings.TrimSpace(createdBefore) != "" { + parsed, parseErr := time.Parse(time.RFC3339, createdBefore) + if parseErr != nil { + return fmt.Errorf("invalid created-before value %q: %w", createdBefore, parseErr) + } + + parsed = parsed.UTC() + createdBeforeTime = &parsed + } + + output, err := service.Run(cmd.Context(), ledgerbackfillservice.RunInput{ + Namespace: targetNamespace, + DryRun: dryRun, + ContinueOnError: continueOnError, + IncludeDeletedCustomers: includeDeleted, + CustomerPageSize: customerPageSize, + CreatedBefore: createdBeforeTime, + }) + if err != nil { + printSummary(output) + return err + } + + printSummary(output) + + if output.Result.FailureCount > 0 { + return fmt.Errorf("backfill completed with %d failures", output.Result.FailureCount) + } + + return nil + }, +} + +func init() { + Cmd.Flags().StringVar(&createdBefore, "created-before", "", "process only customers created before this RFC3339 timestamp") + Cmd.Flags().IntVar(&customerPageSize, "customer-page-size", ledgerbackfillservice.DefaultCustomerPageSize, "number of customers to process per page") + Cmd.Flags().BoolVar(&dryRun, "dry-run", false, "calculate what would be provisioned without writing") + Cmd.Flags().BoolVar(&continueOnError, "continue-on-error", false, "continue after per-customer or per-namespace failures") + Cmd.Flags().BoolVar(&includeDeleted, "include-deleted", false, "include soft-deleted customers") +} + +func newService() (*ledgerbackfillservice.Service, error) { + locker, err := lockr.NewLocker(&lockr.LockerConfig{Logger: internal.App.Logger}) + if err != nil { + return nil, fmt.Errorf("create locker: %w", err) + } + + // We intentionally build the concrete resolver stack here because the public + // wired account-resolver surface is narrowed and doesn't expose CreateCustomerAccounts. + accountRepo := accountadapter.NewRepo(internal.App.EntClient) + accountSvc := accountservice.New(accountRepo, locker) + + resolverRepo := resolversadapter.NewRepo(internal.App.EntClient) + accountResolver := resolvers.NewAccountResolver(resolvers.AccountResolverConfig{ + AccountService: accountSvc, + Repo: resolverRepo, + Locker: locker, + }) + + service, err := ledgerbackfillservice.NewService(ledgerbackfillservice.Config{ + CustomerLister: ledgerbackfillservice.NewEntCustomerLister(internal.App.EntClient), + AccountProvisioner: accountResolver, + Logger: internal.App.Logger, + }) + if err != nil { + return nil, fmt.Errorf("create backfill service: %w", err) + } + + return service, nil +} + +func printSummary(out ledgerbackfillservice.RunOutput) { + ns := out.Result + fmt.Printf( + "namespace=%s business(already=%d would=%d provisioned=%d) customers(scanned=%d skipped_recent=%d already=%d would=%d provisioned=%d failures=%d)\n", + ns.Namespace, + ns.BusinessAlreadyProvisioned, + ns.BusinessWouldProvision, + ns.BusinessProvisioned, + ns.CustomersScanned, + ns.CustomersSkippedRecent, + ns.CustomersAlreadyProvisioned, + ns.CustomersWouldProvision, + ns.CustomersProvisioned, + ns.FailureCount, + ) +} diff --git a/cmd/jobs/ledger/ledger.go b/cmd/jobs/ledger/ledger.go new file mode 100644 index 0000000000000000000000000000000000000000..0608ffc87faf76c4c4c6c4979197cf3e697ee9d5 --- /dev/null +++ b/cmd/jobs/ledger/ledger.go @@ -0,0 +1,16 @@ +package ledger + +import ( + "github.com/spf13/cobra" + + "github.com/openmeterio/openmeter/cmd/jobs/ledger/backfillaccounts" +) + +var Cmd = &cobra.Command{ + Use: "ledger", + Short: "Ledger operations", +} + +func init() { + Cmd.AddCommand(backfillaccounts.Cmd) +} diff --git a/cmd/jobs/ledger/service/customer_lister_ent.go b/cmd/jobs/ledger/service/customer_lister_ent.go new file mode 100644 index 0000000000000000000000000000000000000000..3a66a3928327f11b69137089f72f24df131c5c07 --- /dev/null +++ b/cmd/jobs/ledger/service/customer_lister_ent.go @@ -0,0 +1,64 @@ +package service + +import ( + "context" + "fmt" + + entdb "github.com/openmeterio/openmeter/openmeter/ent/db" + customerdb "github.com/openmeterio/openmeter/openmeter/ent/db/customer" + "github.com/openmeterio/openmeter/pkg/clock" +) + +type EntCustomerLister struct { + entClient *entdb.Client +} + +func NewEntCustomerLister(entClient *entdb.Client) *EntCustomerLister { + return &EntCustomerLister{ + entClient: entClient, + } +} + +func (l *EntCustomerLister) ListCustomers(ctx context.Context, input ListCustomersInput) (ListCustomersResult, error) { + if err := input.Validate(); err != nil { + return ListCustomersResult{}, fmt.Errorf("invalid list customers input: %w", err) + } + + query := l.entClient.Customer.Query(). + Where(customerdb.Namespace(input.Namespace)). + Limit(input.PageSize) + + if !input.IncludeDeleted { + now := clock.Now().UTC() + query = query.Where(customerdb.Or( + customerdb.DeletedAtIsNil(), + customerdb.DeletedAtGTE(now), + )) + } + + if input.CreatedBefore != nil { + query = query.Where(customerdb.CreatedAtLT(input.CreatedBefore.UTC())) + } + + paged, err := query.Cursor(ctx, input.Cursor) + if err != nil { + return ListCustomersResult{}, fmt.Errorf("query customers: %w", err) + } + + items := make([]CustomerListItem, 0, len(paged.Items)) + for _, item := range paged.Items { + if item == nil { + continue + } + + items = append(items, CustomerListItem{ + ID: item.ID, + CreatedAt: item.CreatedAt.UTC(), + }) + } + + return ListCustomersResult{ + Items: items, + NextCursor: paged.NextCursor, + }, nil +} diff --git a/cmd/jobs/ledger/service/service.go b/cmd/jobs/ledger/service/service.go new file mode 100644 index 0000000000000000000000000000000000000000..6f963fb84d61d91b05210c2a16fc340abfbe93bd --- /dev/null +++ b/cmd/jobs/ledger/service/service.go @@ -0,0 +1,334 @@ +package service + +import ( + "context" + "fmt" + "log/slog" + "time" + + "github.com/openmeterio/openmeter/openmeter/customer" + "github.com/openmeterio/openmeter/openmeter/ledger" + "github.com/openmeterio/openmeter/pkg/models" + paginationv2 "github.com/openmeterio/openmeter/pkg/pagination/v2" +) + +const DefaultCustomerPageSize = 100 + +type customerLister interface { + ListCustomers(ctx context.Context, input ListCustomersInput) (ListCustomersResult, error) +} + +type ListCustomersInput struct { + Namespace string + IncludeDeleted bool + CreatedBefore *time.Time + PageSize int + Cursor *paginationv2.Cursor +} + +func (i ListCustomersInput) Validate() error { + if i.Namespace == "" { + return fmt.Errorf("namespace is required") + } + + if i.PageSize <= 0 { + return fmt.Errorf("page size must be greater than zero") + } + + return nil +} + +type CustomerListItem struct { + ID string + CreatedAt time.Time +} + +type ListCustomersResult struct { + Items []CustomerListItem + NextCursor *paginationv2.Cursor +} + +type accountProvisioner interface { + CreateCustomerAccounts(ctx context.Context, customerID customer.CustomerID) (ledger.CustomerAccounts, error) + GetCustomerAccounts(ctx context.Context, customerID customer.CustomerID) (ledger.CustomerAccounts, error) + EnsureBusinessAccounts(ctx context.Context, namespace string) (ledger.BusinessAccounts, error) + GetBusinessAccounts(ctx context.Context, namespace string) (ledger.BusinessAccounts, error) +} + +type Config struct { + CustomerLister customerLister + AccountProvisioner accountProvisioner + Logger *slog.Logger +} + +func (c Config) Validate() error { + if c.CustomerLister == nil { + return fmt.Errorf("customer lister is required") + } + + if c.AccountProvisioner == nil { + return fmt.Errorf("account provisioner is required") + } + + return nil +} + +type Service struct { + customerLister customerLister + accountProvisioner accountProvisioner + logger *slog.Logger +} + +func NewService(cfg Config) (*Service, error) { + if err := cfg.Validate(); err != nil { + return nil, fmt.Errorf("invalid backfill config: %w", err) + } + + return &Service{ + customerLister: cfg.CustomerLister, + accountProvisioner: cfg.AccountProvisioner, + logger: cfg.Logger, + }, nil +} + +type RunInput struct { + Namespace string + + DryRun bool + ContinueOnError bool + + IncludeDeletedCustomers bool + CustomerPageSize int + CreatedBefore *time.Time +} + +func (i RunInput) Validate() error { + if i.Namespace == "" { + return fmt.Errorf("namespace is required") + } + + if i.CustomerPageSize < 0 { + return fmt.Errorf("customer page size cannot be negative") + } + + return nil +} + +func (i RunInput) normalized() RunInput { + out := i + + if out.CustomerPageSize == 0 { + out.CustomerPageSize = DefaultCustomerPageSize + } + + if out.CreatedBefore != nil { + t := out.CreatedBefore.UTC() + out.CreatedBefore = &t + } + + return out +} + +type RunOutput struct { + Result NamespaceResult +} + +type NamespaceResult struct { + Namespace string + + BusinessAlreadyProvisioned int + BusinessWouldProvision int + BusinessProvisioned int + + CustomersScanned int + CustomersSkippedRecent int + CustomersAlreadyProvisioned int + CustomersWouldProvision int + CustomersProvisioned int + + FailureCount int +} + +func (s *Service) Run(ctx context.Context, in RunInput) (RunOutput, error) { + if err := in.Validate(); err != nil { + return RunOutput{}, fmt.Errorf("invalid backfill input: %w", err) + } + + input := in.normalized() + result, err := s.runNamespace(ctx, input, input.Namespace) + output := RunOutput{Result: result} + + if err != nil { + if input.ContinueOnError { + return output, nil + } + + return output, err + } + + return output, nil +} + +func (s *Service) runNamespace(ctx context.Context, input RunInput, namespace string) (NamespaceResult, error) { + result := NamespaceResult{Namespace: namespace} + + if err := s.ensureBusinessAccounts(ctx, input, &result); err != nil { + if !input.ContinueOnError { + return result, err + } + } + + var cursor *paginationv2.Cursor + completed := false + + for iter := 0; iter < paginationv2.MAX_SAFE_ITER; iter++ { + res, err := s.customerLister.ListCustomers(ctx, ListCustomersInput{ + Namespace: namespace, + IncludeDeleted: input.IncludeDeletedCustomers, + CreatedBefore: input.CreatedBefore, + PageSize: input.CustomerPageSize, + Cursor: cursor, + }) + if err != nil { + failure := s.recordFailure(&result, "list_customers", "", err) + if input.ContinueOnError { + return result, nil + } + + return result, failure + } + + if len(res.Items) == 0 { + completed = true + break + } + + for _, item := range res.Items { + result.CustomersScanned++ + + if err := s.ensureCustomerAccounts(ctx, input, &result, customer.CustomerID{Namespace: namespace, ID: item.ID}); err != nil { + if !input.ContinueOnError { + return result, err + } + } + } + + if res.NextCursor == nil { + completed = true + break + } + + cursor = res.NextCursor + } + + if !completed { + failure := s.recordFailure(&result, "paginate_customers", "", fmt.Errorf("max safe iter reached: %d", paginationv2.MAX_SAFE_ITER)) + if input.ContinueOnError { + return result, nil + } + + return result, failure + } + + return result, nil +} + +func (s *Service) ensureBusinessAccounts(ctx context.Context, input RunInput, result *NamespaceResult) error { + _, err := s.accountProvisioner.GetBusinessAccounts(ctx, result.Namespace) + if err == nil { + result.BusinessAlreadyProvisioned++ + return nil + } + + if !hasValidationIssueCode(err, ledger.ErrCodeBusinessAccountMissing) { + failure := s.recordFailure(result, "get_business_accounts", "", err) + return failure + } + + if input.DryRun { + result.BusinessWouldProvision++ + return nil + } + + _, err = s.accountProvisioner.EnsureBusinessAccounts(ctx, result.Namespace) + if err != nil { + failure := s.recordFailure(result, "ensure_business_accounts", "", err) + return failure + } + + result.BusinessProvisioned++ + + return nil +} + +func (s *Service) ensureCustomerAccounts(ctx context.Context, input RunInput, result *NamespaceResult, customerID customer.CustomerID) error { + _, err := s.accountProvisioner.GetCustomerAccounts(ctx, customerID) + if err == nil { + result.CustomersAlreadyProvisioned++ + return nil + } + + if !hasValidationIssueCode(err, ledger.ErrCodeCustomerAccountMissing) { + failure := s.recordFailure(result, "get_customer_accounts", customerID.ID, err) + return failure + } + + if input.DryRun { + result.CustomersWouldProvision++ + return nil + } + + _, err = s.accountProvisioner.CreateCustomerAccounts(ctx, customerID) + if err != nil { + failure := s.recordFailure(result, "create_customer_accounts", customerID.ID, err) + return failure + } + + result.CustomersProvisioned++ + + return nil +} + +func hasValidationIssueCode(err error, code models.ErrorCode) bool { + issues, issueErr := models.AsValidationIssues(err) + if issueErr != nil { + return false + } + + for _, issue := range issues { + if issue.Code() == code { + return true + } + } + + return false +} + +func (s *Service) recordFailure(result *NamespaceResult, stage string, customerID string, err error) error { + result.FailureCount++ + + if s.logger != nil { + if customerID == "" { + s.logger.Warn( + "ledger account backfill step failed", + "namespace", result.Namespace, + "stage", stage, + "error", err, + ) + } else { + s.logger.Warn( + "ledger account backfill step failed", + "namespace", result.Namespace, + "customer_id", customerID, + "stage", stage, + "error", err, + ) + } + } + + if customerID == "" { + return fmt.Errorf("namespace=%s stage=%s: %w", result.Namespace, stage, err) + } + + return fmt.Errorf("namespace=%s customer_id=%s stage=%s: %w", result.Namespace, customerID, stage, err) +} diff --git a/cmd/jobs/ledger/service/service_test.go b/cmd/jobs/ledger/service/service_test.go new file mode 100644 index 0000000000000000000000000000000000000000..0e6f178c2c89891de6ba48c3c5067a1ccd07d2bc --- /dev/null +++ b/cmd/jobs/ledger/service/service_test.go @@ -0,0 +1,333 @@ +package service + +import ( + "context" + "errors" + "fmt" + "slices" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/openmeterio/openmeter/openmeter/customer" + "github.com/openmeterio/openmeter/openmeter/ledger" + "github.com/openmeterio/openmeter/pkg/models" + paginationv2 "github.com/openmeterio/openmeter/pkg/pagination/v2" +) + +func TestRunDryRun(t *testing.T) { + now := time.Date(2026, time.January, 2, 12, 0, 0, 0, time.UTC) + provisioner := newFakeAccountProvisioner() + provisioner.missingBusiness["ns-a"] = true + provisioner.missingCustomer["ns-a/customer-1"] = true + provisioner.missingCustomer["ns-a/customer-2"] = true + + svc, err := NewService(Config{ + CustomerLister: fakeCustomerLister{ + customersByNamespace: map[string][]customer.Customer{ + "ns-a": { + newCustomer("ns-a", "customer-1", now.Add(-2*time.Hour)), + newCustomer("ns-a", "customer-2", now.Add(-1*time.Hour)), + }, + }, + }, + AccountProvisioner: provisioner, + }) + require.NoError(t, err) + + out, err := svc.Run(t.Context(), RunInput{ + Namespace: "ns-a", + DryRun: true, + }) + require.NoError(t, err) + + ns := out.Result + require.Equal(t, 1, ns.BusinessWouldProvision) + require.Equal(t, 2, ns.CustomersWouldProvision) + require.Equal(t, 2, ns.CustomersScanned) + require.Zero(t, ns.CustomersProvisioned) + require.Zero(t, ns.BusinessProvisioned) + + require.Equal(t, 0, ns.FailureCount) +} + +func TestRunCursorsThroughMultiplePages(t *testing.T) { + now := time.Date(2026, time.January, 2, 12, 0, 0, 0, time.UTC) + + provisioner := newFakeAccountProvisioner() + provisioner.missingBusiness["ns-a"] = true + provisioner.missingCustomer["ns-a/customer-1"] = true + provisioner.missingCustomer["ns-a/customer-2"] = true + provisioner.missingCustomer["ns-a/customer-3"] = true + provisioner.missingCustomer["ns-a/customer-4"] = true + provisioner.missingCustomer["ns-a/customer-5"] = true + + svc, err := NewService(Config{ + CustomerLister: fakeCustomerLister{ + customersByNamespace: map[string][]customer.Customer{ + "ns-a": { + newCustomer("ns-a", "customer-1", now.Add(-5*time.Hour)), + newCustomer("ns-a", "customer-2", now.Add(-4*time.Hour)), + newCustomer("ns-a", "customer-3", now.Add(-3*time.Hour)), + newCustomer("ns-a", "customer-4", now.Add(-2*time.Hour)), + newCustomer("ns-a", "customer-5", now.Add(-1*time.Hour)), + }, + }, + }, + AccountProvisioner: provisioner, + }) + require.NoError(t, err) + + out, err := svc.Run(t.Context(), RunInput{ + Namespace: "ns-a", + DryRun: true, + CustomerPageSize: 2, + }) + require.NoError(t, err) + + ns := out.Result + require.Equal(t, 5, ns.CustomersScanned) + require.Equal(t, 5, ns.CustomersWouldProvision) + require.Equal(t, 1, ns.BusinessWouldProvision) + require.Equal(t, 0, ns.FailureCount) +} + +func TestRunProvisionWithCreatedBeforeCutoff(t *testing.T) { + now := time.Date(2026, time.January, 2, 12, 0, 0, 0, time.UTC) + cutoff := now.Add(-30 * time.Minute) + + provisioner := newFakeAccountProvisioner() + provisioner.missingBusiness["ns-a"] = true + provisioner.missingCustomer["ns-a/customer-old"] = true + provisioner.missingCustomer["ns-a/customer-recent"] = true + + svc, err := NewService(Config{ + CustomerLister: fakeCustomerLister{ + customersByNamespace: map[string][]customer.Customer{ + "ns-a": { + newCustomer("ns-a", "customer-old", now.Add(-2*time.Hour)), + newCustomer("ns-a", "customer-recent", now.Add(-10*time.Minute)), + }, + }, + }, + AccountProvisioner: provisioner, + }) + require.NoError(t, err) + + out, err := svc.Run(t.Context(), RunInput{ + Namespace: "ns-a", + CreatedBefore: &cutoff, + }) + require.NoError(t, err) + + require.Equal(t, []customer.CustomerID{{Namespace: "ns-a", ID: "customer-old"}}, provisioner.createdCustomers) + require.Equal(t, []string{"ns-a"}, provisioner.ensuredBusiness) + + ns := out.Result + require.Equal(t, 1, ns.CustomersScanned) + require.Equal(t, 0, ns.CustomersSkippedRecent) + require.Equal(t, 1, ns.CustomersProvisioned) + require.Zero(t, ns.CustomersWouldProvision) + require.Equal(t, 1, ns.BusinessProvisioned) + require.Equal(t, 0, ns.FailureCount) +} + +func TestRunContinueOnError(t *testing.T) { + provisioner := newFakeAccountProvisioner() + provisioner.missingBusiness["ns-a"] = true + provisioner.customerGetErrors["ns-a/bad"] = errors.New("db unavailable") + provisioner.missingCustomer["ns-a/good"] = true + + svc, err := NewService(Config{ + CustomerLister: fakeCustomerLister{ + customersByNamespace: map[string][]customer.Customer{ + "ns-a": { + newCustomer("ns-a", "bad", time.Date(2026, 1, 2, 10, 0, 0, 0, time.UTC)), + newCustomer("ns-a", "good", time.Date(2026, 1, 2, 10, 1, 0, 0, time.UTC)), + }, + }, + }, + AccountProvisioner: provisioner, + }) + require.NoError(t, err) + + out, err := svc.Run(t.Context(), RunInput{ + Namespace: "ns-a", + ContinueOnError: true, + }) + require.NoError(t, err) + + ns := out.Result + require.Equal(t, 1, ns.CustomersProvisioned) + require.Equal(t, 1, ns.FailureCount) +} + +type fakeCustomerLister struct { + customersByNamespace map[string][]customer.Customer +} + +func (f fakeCustomerLister) ListCustomers(_ context.Context, input ListCustomersInput) (ListCustomersResult, error) { + if err := input.Validate(); err != nil { + return ListCustomersResult{}, err + } + + items := f.customersByNamespace[input.Namespace] + filtered := make([]customer.Customer, 0, len(items)) + for _, item := range items { + if input.CreatedBefore != nil && !item.CreatedAt.Before(*input.CreatedBefore) { + continue + } + filtered = append(filtered, item) + } + + slices.SortFunc(filtered, func(a customer.Customer, b customer.Customer) int { + if a.CreatedAt.Before(b.CreatedAt) { + return -1 + } + if a.CreatedAt.After(b.CreatedAt) { + return 1 + } + return stringsCompare(a.ID, b.ID) + }) + + start := 0 + if input.Cursor != nil { + for idx, item := range filtered { + if item.CreatedAt.After(input.Cursor.Time) { + start = idx + break + } + if item.CreatedAt.Equal(input.Cursor.Time) && stringsCompare(item.ID, input.Cursor.ID) > 0 { + start = idx + break + } + start = idx + 1 + } + } + + if start >= len(filtered) { + return ListCustomersResult{ + Items: []CustomerListItem{}, + NextCursor: nil, + }, nil + } + + end := start + input.PageSize + if end > len(filtered) { + end = len(filtered) + } + + paged := make([]CustomerListItem, 0, end-start) + for _, item := range filtered[start:end] { + paged = append(paged, CustomerListItem{ + ID: item.ID, + CreatedAt: item.CreatedAt, + }) + } + + var nextCursor *paginationv2.Cursor + if len(paged) > 0 { + last := paged[len(paged)-1] + nextCursor = &paginationv2.Cursor{ + Time: last.CreatedAt, + ID: last.ID, + } + } + + return ListCustomersResult{ + Items: paged, + NextCursor: nextCursor, + }, nil +} + +func stringsCompare(a string, b string) int { + if a < b { + return -1 + } + if a > b { + return 1 + } + + return 0 +} + +type fakeAccountProvisioner struct { + missingBusiness map[string]bool + missingCustomer map[string]bool + + customerGetErrors map[string]error + + ensuredBusiness []string + createdCustomers []customer.CustomerID +} + +func newFakeAccountProvisioner() *fakeAccountProvisioner { + return &fakeAccountProvisioner{ + missingBusiness: make(map[string]bool), + missingCustomer: make(map[string]bool), + customerGetErrors: map[string]error{}, + } +} + +func (f *fakeAccountProvisioner) customerKey(id customer.CustomerID) string { + return fmt.Sprintf("%s/%s", id.Namespace, id.ID) +} + +func (f *fakeAccountProvisioner) GetCustomerAccounts(_ context.Context, customerID customer.CustomerID) (ledger.CustomerAccounts, error) { + key := f.customerKey(customerID) + + if err, ok := f.customerGetErrors[key]; ok { + return ledger.CustomerAccounts{}, err + } + + if f.missingCustomer[key] { + return ledger.CustomerAccounts{}, ledger.ErrCustomerAccountMissing.WithAttrs(models.Attributes{ + "namespace": customerID.Namespace, + "customer_id": customerID.ID, + }) + } + + return ledger.CustomerAccounts{}, nil +} + +func (f *fakeAccountProvisioner) CreateCustomerAccounts(_ context.Context, customerID customer.CustomerID) (ledger.CustomerAccounts, error) { + key := f.customerKey(customerID) + f.createdCustomers = append(f.createdCustomers, customerID) + f.missingCustomer[key] = false + + return ledger.CustomerAccounts{}, nil +} + +func (f *fakeAccountProvisioner) GetBusinessAccounts(_ context.Context, namespace string) (ledger.BusinessAccounts, error) { + if f.missingBusiness[namespace] { + return ledger.BusinessAccounts{}, ledger.ErrBusinessAccountMissing.WithAttrs(models.Attributes{ + "namespace": namespace, + }) + } + + return ledger.BusinessAccounts{}, nil +} + +func (f *fakeAccountProvisioner) EnsureBusinessAccounts(_ context.Context, namespace string) (ledger.BusinessAccounts, error) { + f.ensuredBusiness = append(f.ensuredBusiness, namespace) + f.missingBusiness[namespace] = false + + return ledger.BusinessAccounts{}, nil +} + +func newCustomer(namespace string, id string, createdAt time.Time) customer.Customer { + return customer.Customer{ + ManagedResource: models.NewManagedResource(models.ManagedResourceInput{ + ID: id, + Namespace: namespace, + Name: id, + CreatedAt: createdAt, + UpdatedAt: createdAt, + }), + Key: nil, + UsageAttribution: &customer.CustomerUsageAttribution{ + SubjectKeys: []string{"sub-" + id}, + }, + } +} diff --git a/cmd/jobs/llmcost/llmcost.go b/cmd/jobs/llmcost/llmcost.go new file mode 100644 index 0000000000000000000000000000000000000000..4cce6764af7349aca7d9a8dc0d7134fe74f353b4 --- /dev/null +++ b/cmd/jobs/llmcost/llmcost.go @@ -0,0 +1,26 @@ +package llmcost + +import ( + "github.com/spf13/cobra" + + "github.com/openmeterio/openmeter/cmd/jobs/internal" +) + +var Cmd = &cobra.Command{ + Use: "llm-cost", + Short: "LLM cost database operations", +} + +func init() { + Cmd.AddCommand(syncCmd()) +} + +func syncCmd() *cobra.Command { + return &cobra.Command{ + Use: "sync", + Short: "Sync LLM cost prices from external sources", + RunE: func(cmd *cobra.Command, args []string) error { + return internal.App.LLMCostSyncJob.Run(cmd.Context()) + }, + } +} diff --git a/cmd/jobs/main.go b/cmd/jobs/main.go new file mode 100644 index 0000000000000000000000000000000000000000..b2588f1edded44ab8e20d1590138fe69ad2c33f6 --- /dev/null +++ b/cmd/jobs/main.go @@ -0,0 +1,76 @@ +package main + +import ( + "context" + "errors" + "log/slog" + "os" + "os/signal" + "syscall" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/openmeterio/openmeter/cmd/jobs/billing" + "github.com/openmeterio/openmeter/cmd/jobs/entitlement" + "github.com/openmeterio/openmeter/cmd/jobs/internal" + "github.com/openmeterio/openmeter/cmd/jobs/ledger" + "github.com/openmeterio/openmeter/cmd/jobs/llmcost" + "github.com/openmeterio/openmeter/cmd/jobs/migrate" + "github.com/openmeterio/openmeter/cmd/jobs/quickstart" + "github.com/openmeterio/openmeter/pkg/log" +) + +func main() { + var configFileName string + + defer log.PanicLogger(log.WithExit) + + // Create os.Signal aware context.Context which will trigger context cancellation + // upon receiving any of the listed signals. + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGHUP, syscall.SIGTERM) + defer cancel() + + rootCmd := cobra.Command{ + Use: "jobs", + SilenceUsage: true, + SilenceErrors: true, + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + err := internal.InitializeApplication(ctx, configFileName) + if err != nil { + slog.Error("failed to initialize application", "error", err) + + // Call cleanup function is may not set yet + if internal.AppShutdown != nil { + internal.AppShutdown() + } + + return errors.New("failed to initialize application") + } + + return nil + }, + } + + rootCmd.PersistentFlags().StringVarP(&configFileName, "config", "", "config.yaml", "config file (default is config.yaml)") + _ = viper.BindPFlag("config", rootCmd.PersistentFlags().Lookup("config")) + + rootCmd.AddCommand(versionCommand()) + rootCmd.AddCommand(entitlement.RootCommand()) + rootCmd.AddCommand(billing.Cmd) + rootCmd.AddCommand(ledger.Cmd) + rootCmd.AddCommand(llmcost.Cmd) + rootCmd.AddCommand(quickstart.Cmd) + rootCmd.AddCommand(migrate.RootCommand()) + + defer func() { + if internal.AppShutdown != nil { + internal.AppShutdown() + } + }() + + if err := rootCmd.ExecuteContext(ctx); err != nil { + slog.Error("failed to execute command", "error", err) + os.Exit(1) + } +} diff --git a/cmd/jobs/migrate/migrate.go b/cmd/jobs/migrate/migrate.go new file mode 100644 index 0000000000000000000000000000000000000000..01a5b9d0fce2332bd6d61af0b60003990d364753 --- /dev/null +++ b/cmd/jobs/migrate/migrate.go @@ -0,0 +1,64 @@ +package migrate + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/openmeterio/openmeter/app/config" + "github.com/openmeterio/openmeter/cmd/jobs/internal" +) + +func RootCommand() *cobra.Command { + var migrationMode string + + cmd := &cobra.Command{ + Use: "migrate", + Short: "Database migration operations", + RunE: func(cmd *cobra.Command, args []string) error { + migrationMode := config.AutoMigrate(migrationMode) + if migrationMode == "ent" { + return fmt.Errorf("ent migration is no longer supported; run 'openmeter-jobs migrate adopt-ent' once, then set migration mode to 'migration'") + } + if !migrationMode.Enabled() { + return fmt.Errorf("migration mode is disabled") + } + + if migrationMode == config.AutoMigrateMigrationJob { + return fmt.Errorf("migration mode cannot be job for this command") + } + + internal.App.Migrator.Config.AutoMigrate = migrationMode + err := internal.App.Migrator.Migrate(cmd.Context()) + if err != nil { + return fmt.Errorf("failed to migrate database: %w", err) + } + + internal.App.Logger.Info("database migrated successfully") + + return nil + }, + } + + cmd.Flags().StringVarP(&migrationMode, "mode", "m", string(config.AutoMigrateMigration), "Migration mode allowed values: migration") + cmd.AddCommand(adoptEntCommand()) + + return cmd +} + +func adoptEntCommand() *cobra.Command { + return &cobra.Command{ + Use: "adopt-ent", + Short: "Adopt a database previously managed by Ent into versioned migrations", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if err := internal.App.Migrator.AdoptLegacyEnt(cmd.Context()); err != nil { + return fmt.Errorf("failed to adopt legacy Ent database: %w", err) + } + + internal.App.Logger.Info("legacy Ent database adopted successfully; run the normal migration command to upgrade to the target version") + + return nil + }, + } +} diff --git a/cmd/jobs/quickstart/cronjobs.go b/cmd/jobs/quickstart/cronjobs.go new file mode 100644 index 0000000000000000000000000000000000000000..e0dd0a46062f6912a1e0dd846bf19dc386aee79d --- /dev/null +++ b/cmd/jobs/quickstart/cronjobs.go @@ -0,0 +1,116 @@ +package quickstart + +import ( + "log/slog" + "os" + "time" + + "github.com/go-co-op/gocron/v2" + "github.com/spf13/cobra" + + "github.com/openmeterio/openmeter/cmd/jobs/internal" + reconciler "github.com/openmeterio/openmeter/openmeter/billing/worker/subscriptionsync/reconciler" +) + +var Cron = &cobra.Command{ + Use: "cron", + Short: "Schedule the required cron jobs in the background.", + RunE: func(cmd *cobra.Command, args []string) error { + s, err := gocron.NewScheduler( + gocron.WithLogger(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelInfo, + }))), + ) + if err != nil { + return err + } + + namespaces := []string{"default"} + batchSize := 10 + + // Sync subscriptions every hour + _, err = s.NewJob( + gocron.DurationJob(time.Hour), + gocron.NewTask(func() { + slog.Info("Syncing subscriptions") + + err := internal.App.BillingSubscriptionReconciler.All(cmd.Context(), reconciler.ReconcilerAllInput{ + ReconcilerListSubscriptionsInput: reconciler.ReconcilerListSubscriptionsInput{ + Namespaces: namespaces, + Lookback: time.Hour, + }, + Force: false, + }) + if err != nil { + slog.Error("Error syncing subscriptions", "error", err) + } + }), + ) + if err != nil { + return err + } + + // Collect invoices every minute + _, err = s.NewJob( + gocron.DurationJob(time.Minute), + gocron.NewTask(func() { + slog.Info("Collecting invoices") + + err := internal.App.BillingCollector.All(cmd.Context(), namespaces, nil, batchSize) + if err != nil { + slog.Error("Error collecting invoices", "error", err) + } + }), + ) + if err != nil { + return err + } + + // Advance invoices every minute + _, err = s.NewJob( + gocron.DurationJob(time.Minute), + gocron.NewTask(func() { + slog.Info("Advancing invoices") + + err := internal.App.BillingAutoAdvancer.All(cmd.Context(), namespaces, batchSize) + if err != nil { + slog.Error("Error advancing invoices", "error", err) + } + }), + ) + if err != nil { + return err + } + + // Advance charges every minute (if charges are enabled) + if internal.App.ChargesAutoAdvancer != nil { + _, err = s.NewJob( + gocron.DurationJob(time.Minute), + gocron.NewTask(func() { + slog.Info("Advancing charges") + + err := internal.App.ChargesAutoAdvancer.All(cmd.Context(), namespaces) + if err != nil { + slog.Error("Error advancing charges", "error", err) + } + }), + ) + if err != nil { + return err + } + } + + s.Start() + + <-cmd.Context().Done() + if err := s.Shutdown(); err != nil { + return err + } + + return nil + }, +} + +func init() { + Cmd.AddCommand(Cron) +} diff --git a/cmd/jobs/quickstart/quickstart.go b/cmd/jobs/quickstart/quickstart.go new file mode 100644 index 0000000000000000000000000000000000000000..a29f95caa87a3225c62aecc040cf95a2beaf384f --- /dev/null +++ b/cmd/jobs/quickstart/quickstart.go @@ -0,0 +1,17 @@ +package quickstart + +import ( + "github.com/spf13/cobra" + + "github.com/openmeterio/openmeter/cmd/jobs/billing/subscriptionsync" +) + +var Cmd = &cobra.Command{ + Use: "quickstart", + Short: "Quickstart operations", + Long: "Helpers for docker-compose based quickstart setup. Should not be used in production systems.", +} + +func init() { + Cmd.AddCommand(subscriptionsync.Cmd) +} diff --git a/cmd/jobs/version.go b/cmd/jobs/version.go new file mode 100644 index 0000000000000000000000000000000000000000..f8ec374a40906b9c4b5e4002fad2b264dbd10fde --- /dev/null +++ b/cmd/jobs/version.go @@ -0,0 +1,18 @@ +package main + +import ( + "github.com/spf13/cobra" + + "github.com/openmeterio/openmeter/cmd/jobs/internal" +) + +func versionCommand() *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "Print the version", + Run: func(cmd *cobra.Command, args []string) { + version, revision, revisionDate := internal.Version() + cmd.Printf("%s version %s (%s) built on %s\n", "Open Meter", version, revision, revisionDate) + }, + } +} diff --git a/cmd/notification-service/.air.toml b/cmd/notification-service/.air.toml new file mode 100644 index 0000000000000000000000000000000000000000..03da48e20738ad31614273a0812a2553382bb68f --- /dev/null +++ b/cmd/notification-service/.air.toml @@ -0,0 +1,44 @@ +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] + args_bin = ["--config", "./config.yaml", "--telemetry-address", ":10003"] + bin = "./tmp/openmeter-notification-service" + cmd = "go build -gcflags=\"all=-N -l\" -tags dynamic -o ./tmp/openmeter-notification-service ./cmd/notification-service" + delay = 0 + exclude_dir = ["assets", "ci", "deploy", "docs", "examples", "testdata", "quickstart", "tmp", "vendor", "api/client", "node_modules"] + exclude_file = [] + exclude_regex = ["_test.go"] + exclude_unchanged = false + follow_symlink = false + full_bin = "" + include_dir = [] + include_ext = ["go", "tpl", "tmpl", "html", "yml", "yaml", "sql", "json"] + include_file = [] + kill_delay = "0s" + log = "build-errors.log" + poll = false + poll_interval = 0 + rerun = false + rerun_delay = 500 + send_interrupt = true + stop_on_error = true + +[color] + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + main_only = false + time = false + +[misc] + clean_on_exit = false + +[screen] + clear_on_rebuild = false + keep_scroll = true