diff --git a/apiconverter/cursor.go b/apiconverter/cursor.go new file mode 100644 index 0000000000000000000000000000000000000000..72c58d573ad52918a93216e9604e0614aaa249d1 --- /dev/null +++ b/apiconverter/cursor.go @@ -0,0 +1,18 @@ +package apiconverter + +import ( + "github.com/openmeterio/openmeter/api" + "github.com/openmeterio/openmeter/pkg/pagination/v2" +) + +func ConvertCursor(s api.CursorPaginationCursor) (*pagination.Cursor, error) { + return pagination.DecodeCursor(s) +} + +func ConvertCursorPtr(s *api.CursorPaginationCursor) (*pagination.Cursor, error) { + if s == nil { + return nil, nil + } + + return ConvertCursor(*s) +} diff --git a/apiconverter/filter.gen.go b/apiconverter/filter.gen.go new file mode 100644 index 0000000000000000000000000000000000000000..4254901269726f25e530bbf9789bfc16d3d114b8 --- /dev/null +++ b/apiconverter/filter.gen.go @@ -0,0 +1,266 @@ +// Code generated by github.com/jmattheis/goverter, DO NOT EDIT. +//go:build !goverter + +package apiconverter + +import ( + api "github.com/openmeterio/openmeter/api" + filter "github.com/openmeterio/openmeter/pkg/filter" +) + +func init() { + ConvertBoolean = func(source api.FilterBoolean) filter.FilterBoolean { + var filterFilterBoolean filter.FilterBoolean + filterFilterBoolean.Eq = source.Eq + return filterFilterBoolean + } + ConvertBooleanPtr = func(source *api.FilterBoolean) *filter.FilterBoolean { + var pFilterFilterBoolean *filter.FilterBoolean + if source != nil { + filterFilterBoolean := ConvertBoolean((*source)) + pFilterFilterBoolean = &filterFilterBoolean + } + return pFilterFilterBoolean + } + ConvertFloat = func(source api.FilterFloat) filter.FilterFloat { + var filterFilterFloat filter.FilterFloat + filterFilterFloat.Eq = source.Eq + filterFilterFloat.Ne = source.Ne + filterFilterFloat.Gt = source.Gt + filterFilterFloat.Gte = source.Gte + filterFilterFloat.Lt = source.Lt + filterFilterFloat.Lte = source.Lte + if source.And != nil { + var filterFilterFloatList []filter.FilterFloat + if (*source.And) != nil { + filterFilterFloatList = make([]filter.FilterFloat, len((*source.And))) + for i := 0; i < len((*source.And)); i++ { + filterFilterFloatList[i] = ConvertFloat((*source.And)[i]) + } + } + filterFilterFloat.And = &filterFilterFloatList + } + if source.Or != nil { + var filterFilterFloatList2 []filter.FilterFloat + if (*source.Or) != nil { + filterFilterFloatList2 = make([]filter.FilterFloat, len((*source.Or))) + for j := 0; j < len((*source.Or)); j++ { + filterFilterFloatList2[j] = ConvertFloat((*source.Or)[j]) + } + } + filterFilterFloat.Or = &filterFilterFloatList2 + } + return filterFilterFloat + } + ConvertFloatPtr = func(source *api.FilterFloat) *filter.FilterFloat { + var pFilterFilterFloat *filter.FilterFloat + if source != nil { + filterFilterFloat := ConvertFloat((*source)) + pFilterFilterFloat = &filterFilterFloat + } + return pFilterFilterFloat + } + ConvertIDExact = func(source api.FilterIDExact) filter.FilterString { + var filterFilterString filter.FilterString + filterFilterString.In = source.In + return filterFilterString + } + ConvertIDExactPtr = func(source *api.FilterIDExact) *filter.FilterString { + var pFilterFilterString *filter.FilterString + if source != nil { + filterFilterString := ConvertIDExact((*source)) + pFilterFilterString = &filterFilterString + } + return pFilterFilterString + } + ConvertInt = func(source api.FilterInteger) filter.FilterInteger { + var filterFilterInteger filter.FilterInteger + filterFilterInteger.Eq = source.Eq + filterFilterInteger.Ne = source.Ne + filterFilterInteger.Gt = source.Gt + filterFilterInteger.Gte = source.Gte + filterFilterInteger.Lt = source.Lt + filterFilterInteger.Lte = source.Lte + if source.And != nil { + var filterFilterIntegerList []filter.FilterInteger + if (*source.And) != nil { + filterFilterIntegerList = make([]filter.FilterInteger, len((*source.And))) + for i := 0; i < len((*source.And)); i++ { + filterFilterIntegerList[i] = ConvertInt((*source.And)[i]) + } + } + filterFilterInteger.And = &filterFilterIntegerList + } + if source.Or != nil { + var filterFilterIntegerList2 []filter.FilterInteger + if (*source.Or) != nil { + filterFilterIntegerList2 = make([]filter.FilterInteger, len((*source.Or))) + for j := 0; j < len((*source.Or)); j++ { + filterFilterIntegerList2[j] = ConvertInt((*source.Or)[j]) + } + } + filterFilterInteger.Or = &filterFilterIntegerList2 + } + return filterFilterInteger + } + ConvertIntPtr = func(source *api.FilterInteger) *filter.FilterInteger { + var pFilterFilterInteger *filter.FilterInteger + if source != nil { + filterFilterInteger := ConvertInt((*source)) + pFilterFilterInteger = &filterFilterInteger + } + return pFilterFilterInteger + } + ConvertString = func(source api.FilterString) filter.FilterString { + var filterFilterString filter.FilterString + filterFilterString.Eq = source.Eq + filterFilterString.Ne = source.Ne + filterFilterString.In = source.In + filterFilterString.Nin = source.Nin + filterFilterString.Like = source.Like + filterFilterString.Nlike = source.Nlike + filterFilterString.Ilike = source.Ilike + filterFilterString.Nilike = source.Nilike + filterFilterString.Gt = source.Gt + filterFilterString.Gte = source.Gte + filterFilterString.Lt = source.Lt + filterFilterString.Lte = source.Lte + if source.And != nil { + var filterFilterStringList []filter.FilterString + if (*source.And) != nil { + filterFilterStringList = make([]filter.FilterString, len((*source.And))) + for i := 0; i < len((*source.And)); i++ { + filterFilterStringList[i] = ConvertString((*source.And)[i]) + } + } + filterFilterString.And = &filterFilterStringList + } + if source.Or != nil { + var filterFilterStringList2 []filter.FilterString + if (*source.Or) != nil { + filterFilterStringList2 = make([]filter.FilterString, len((*source.Or))) + for j := 0; j < len((*source.Or)); j++ { + filterFilterStringList2[j] = ConvertString((*source.Or)[j]) + } + } + filterFilterString.Or = &filterFilterStringList2 + } + return filterFilterString + } + ConvertStringMap = func(source map[string]api.FilterString) map[string]filter.FilterString { + var mapStringFilterFilterString map[string]filter.FilterString + if source != nil { + mapStringFilterFilterString = make(map[string]filter.FilterString, len(source)) + for key, value := range source { + mapStringFilterFilterString[key] = ConvertString(value) + } + } + return mapStringFilterFilterString + } + ConvertStringMapPtr = func(source *map[string]api.FilterString) *map[string]filter.FilterString { + var pMapStringFilterFilterString *map[string]filter.FilterString + if source != nil { + mapStringFilterFilterString := ConvertStringMap((*source)) + pMapStringFilterFilterString = &mapStringFilterFilterString + } + return pMapStringFilterFilterString + } + ConvertStringMapToAPIPtr = func(source map[string]filter.FilterString) map[string]api.FilterString { + var mapStringApiFilterString map[string]api.FilterString + if source != nil { + mapStringApiFilterString = make(map[string]api.FilterString, len(source)) + for key, value := range source { + mapStringApiFilterString[key] = filterFilterStringToApiFilterString(value) + } + } + return mapStringApiFilterString + } + ConvertStringPtr = func(source *api.FilterString) *filter.FilterString { + var pFilterFilterString *filter.FilterString + if source != nil { + filterFilterString := ConvertString((*source)) + pFilterFilterString = &filterFilterString + } + return pFilterFilterString + } + ConvertStringToAPI = func(source *filter.FilterString) *api.FilterString { + var pApiFilterString *api.FilterString + if source != nil { + apiFilterString := filterFilterStringToApiFilterString((*source)) + pApiFilterString = &apiFilterString + } + return pApiFilterString + } + ConvertTime = func(source api.FilterTime) filter.FilterTime { + var filterFilterTime filter.FilterTime + filterFilterTime.Gt = source.Gt + filterFilterTime.Gte = source.Gte + filterFilterTime.Lt = source.Lt + filterFilterTime.Lte = source.Lte + if source.And != nil { + var filterFilterTimeList []filter.FilterTime + if (*source.And) != nil { + filterFilterTimeList = make([]filter.FilterTime, len((*source.And))) + for i := 0; i < len((*source.And)); i++ { + filterFilterTimeList[i] = ConvertTime((*source.And)[i]) + } + } + filterFilterTime.And = &filterFilterTimeList + } + if source.Or != nil { + var filterFilterTimeList2 []filter.FilterTime + if (*source.Or) != nil { + filterFilterTimeList2 = make([]filter.FilterTime, len((*source.Or))) + for j := 0; j < len((*source.Or)); j++ { + filterFilterTimeList2[j] = ConvertTime((*source.Or)[j]) + } + } + filterFilterTime.Or = &filterFilterTimeList2 + } + return filterFilterTime + } + ConvertTimePtr = func(source *api.FilterTime) *filter.FilterTime { + var pFilterFilterTime *filter.FilterTime + if source != nil { + filterFilterTime := ConvertTime((*source)) + pFilterFilterTime = &filterFilterTime + } + return pFilterFilterTime + } +} +func filterFilterStringToApiFilterString(source filter.FilterString) api.FilterString { + var apiFilterString api.FilterString + if source.And != nil { + var apiFilterStringList []api.FilterString + if (*source.And) != nil { + apiFilterStringList = make([]api.FilterString, len((*source.And))) + for i := 0; i < len((*source.And)); i++ { + apiFilterStringList[i] = filterFilterStringToApiFilterString((*source.And)[i]) + } + } + apiFilterString.And = &apiFilterStringList + } + apiFilterString.Eq = source.Eq + apiFilterString.Gt = source.Gt + apiFilterString.Gte = source.Gte + apiFilterString.Ilike = source.Ilike + apiFilterString.In = source.In + apiFilterString.Like = source.Like + apiFilterString.Lt = source.Lt + apiFilterString.Lte = source.Lte + apiFilterString.Ne = source.Ne + apiFilterString.Nilike = source.Nilike + apiFilterString.Nin = source.Nin + apiFilterString.Nlike = source.Nlike + if source.Or != nil { + var apiFilterStringList2 []api.FilterString + if (*source.Or) != nil { + apiFilterStringList2 = make([]api.FilterString, len((*source.Or))) + for j := 0; j < len((*source.Or)); j++ { + apiFilterStringList2[j] = filterFilterStringToApiFilterString((*source.Or)[j]) + } + } + apiFilterString.Or = &apiFilterStringList2 + } + return apiFilterString +} diff --git a/apiconverter/filter.go b/apiconverter/filter.go new file mode 100644 index 0000000000000000000000000000000000000000..a66ca82267ed535fc5db9f44506aa0245f3627f6 --- /dev/null +++ b/apiconverter/filter.go @@ -0,0 +1,43 @@ +//go:generate go tool github.com/jmattheis/goverter/cmd/goverter gen ./ +package apiconverter + +import ( + "github.com/openmeterio/openmeter/api" + "github.com/openmeterio/openmeter/pkg/filter" +) + +// goverter:variables +// goverter:skipCopySameType +// goverter:output:file ./filter.gen.go +// +// The `goverter:ignore` directives below silence field-mismatch errors for +// fields that exist on the internal filter types but not on the v1 API types. +// If the v1 API (api/api.gen.go) is ever extended to expose those operators, +// remove the corresponding ignore entry so the generated converter copies +// them through — otherwise they will be silently dropped at the boundary. +var ( + // Exists/Contains/Ncontains are internal-only; the v1 FilterString has no + // equivalent fields. Remove entries from the ignore list when v1 grows them. + // goverter:ignore Exists Contains Ncontains + ConvertString func(api.FilterString) filter.FilterString + ConvertStringPtr func(*api.FilterString) *filter.FilterString + ConvertStringMap func(map[string]api.FilterString) map[string]filter.FilterString + ConvertStringMapPtr func(*map[string]api.FilterString) *map[string]filter.FilterString + // goverter:ignoreMissing + ConvertIDExact func(api.FilterIDExact) filter.FilterString + ConvertIDExactPtr func(*api.FilterIDExact) *filter.FilterString + ConvertInt func(api.FilterInteger) filter.FilterInteger + ConvertIntPtr func(*api.FilterInteger) *filter.FilterInteger + ConvertFloat func(api.FilterFloat) filter.FilterFloat + ConvertFloatPtr func(*api.FilterFloat) *filter.FilterFloat + // FilterTime.Eq is new on the internal type; v1 api.FilterTime does not + // expose it. Remove this ignore when v1 grows an Eq field. + // goverter:ignore Eq + // goverter:ignore Exists + ConvertTime func(api.FilterTime) filter.FilterTime + ConvertTimePtr func(*api.FilterTime) *filter.FilterTime + ConvertBoolean func(api.FilterBoolean) filter.FilterBoolean + ConvertBooleanPtr func(*api.FilterBoolean) *filter.FilterBoolean + ConvertStringToAPI func(*filter.FilterString) *api.FilterString + ConvertStringMapToAPIPtr func(map[string]filter.FilterString) map[string]api.FilterString +) diff --git a/app/adapter.go b/app/adapter.go new file mode 100644 index 0000000000000000000000000000000000000000..2a737129556e8a06ace93a66d78a11bf0417593a --- /dev/null +++ b/app/adapter.go @@ -0,0 +1,37 @@ +package app + +import ( + "context" + + "github.com/openmeterio/openmeter/pkg/framework/entutils" + "github.com/openmeterio/openmeter/pkg/pagination" +) + +type Adapter interface { + AppAdapter + + entutils.TxCreator +} +type AppAdapter interface { + // Marketplace + RegisterMarketplaceListing(input RegisterMarketplaceListingInput) error + GetMarketplaceListing(ctx context.Context, input MarketplaceGetInput) (RegistryItem, error) + ListMarketplaceListings(ctx context.Context, input MarketplaceListInput) (pagination.Result[RegistryItem], error) + InstallMarketplaceListingWithAPIKey(ctx context.Context, input InstallAppWithAPIKeyInput) (App, error) + InstallMarketplaceListing(ctx context.Context, input InstallAppInput) (App, error) + GetMarketplaceListingOauth2InstallURL(ctx context.Context, input GetOauth2InstallURLInput) (GetOauth2InstallURLOutput, error) + AuthorizeMarketplaceListingOauth2Install(ctx context.Context, input AuthorizeOauth2InstallInput) error + + // Installed app + CreateApp(ctx context.Context, input CreateAppInput) (AppBase, error) + GetApp(ctx context.Context, input GetAppInput) (App, error) + UpdateApp(ctx context.Context, input UpdateAppInput) (App, error) + ListApps(ctx context.Context, input ListAppInput) (pagination.Result[App], error) + UninstallApp(ctx context.Context, input UninstallAppInput) (*AppBase, error) + UpdateAppStatus(ctx context.Context, input UpdateAppStatusInput) error + + // Customer data + ListCustomerData(ctx context.Context, input ListCustomerInput) (pagination.Result[CustomerApp], error) + EnsureCustomer(ctx context.Context, input EnsureCustomerInput) error + DeleteCustomer(ctx context.Context, input DeleteCustomerInput) error +} diff --git a/app/adapter/adapter.go b/app/adapter/adapter.go new file mode 100644 index 0000000000000000000000000000000000000000..3227d6f701174f37c667b76223adcd14125006df --- /dev/null +++ b/app/adapter/adapter.go @@ -0,0 +1,68 @@ +package appadapter + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/openmeterio/openmeter/openmeter/app" + entdb "github.com/openmeterio/openmeter/openmeter/ent/db" + "github.com/openmeterio/openmeter/pkg/framework/entutils" + "github.com/openmeterio/openmeter/pkg/framework/transaction" +) + +type Config struct { + Client *entdb.Client +} + +func (c Config) Validate() error { + if c.Client == nil { + return errors.New("ent client is required") + } + + return nil +} + +func New(config Config) (app.Adapter, error) { + if err := config.Validate(); err != nil { + return nil, err + } + + adapter := &adapter{ + db: config.Client, + registry: map[app.AppType]app.RegistryItem{}, + } + + return adapter, nil +} + +var _ app.Adapter = (*adapter)(nil) + +type adapter struct { + db *entdb.Client + registry map[app.AppType]app.RegistryItem +} + +// Tx implements entutils.TxCreator interface +func (a *adapter) Tx(ctx context.Context) (context.Context, transaction.Driver, error) { + txCtx, rawConfig, eDriver, err := a.db.HijackTx(ctx, &sql.TxOptions{ + ReadOnly: false, + }) + if err != nil { + return nil, nil, fmt.Errorf("failed to hijack transaction: %w", err) + } + return txCtx, entutils.NewTxDriver(eDriver, rawConfig), nil +} + +func (a *adapter) WithTx(ctx context.Context, tx *entutils.TxDriver) *adapter { + txClient := entdb.NewTxClientFromRawConfig(ctx, *tx.GetConfig()) + return &adapter{ + db: txClient.Client(), + registry: a.registry, + } +} + +func (a *adapter) Self() *adapter { + return a +} diff --git a/app/adapter/app.go b/app/adapter/app.go new file mode 100644 index 0000000000000000000000000000000000000000..9e2ce9743008d145fb975af51018b84b735009b3 --- /dev/null +++ b/app/adapter/app.go @@ -0,0 +1,283 @@ +package appadapter + +import ( + "context" + "fmt" + "time" + + "github.com/samber/lo" + + "github.com/openmeterio/openmeter/openmeter/app" + "github.com/openmeterio/openmeter/openmeter/ent/db" + appdb "github.com/openmeterio/openmeter/openmeter/ent/db/app" + appcustomerdb "github.com/openmeterio/openmeter/openmeter/ent/db/appcustomer" + "github.com/openmeterio/openmeter/pkg/framework/entutils" + "github.com/openmeterio/openmeter/pkg/framework/transaction" + "github.com/openmeterio/openmeter/pkg/models" + "github.com/openmeterio/openmeter/pkg/pagination" +) + +var _ app.AppAdapter = (*adapter)(nil) + +// CreateApp creates an app +func (a *adapter) CreateApp(ctx context.Context, input app.CreateAppInput) (app.AppBase, error) { + return transaction.Run(ctx, a, func(ctx context.Context) (app.AppBase, error) { + return entutils.TransactingRepo( + ctx, + a, + func(ctx context.Context, repo *adapter) (app.AppBase, error) { + appCreateQuery := repo.db.App.Create(). + SetNamespace(input.Namespace). + SetName(input.Name). + SetDescription(input.Description). + SetType(input.Type). + SetStatus(app.AppStatusReady) + + // Set ID if provided by the input + if input.ID != nil { + appCreateQuery = appCreateQuery.SetID(input.ID.ID) + } + + dbApp, err := appCreateQuery.Save(ctx) + if err != nil { + return app.AppBase{}, fmt.Errorf("failed to create app: %w", err) + } + + // Get registry item + registryItem, err := repo.GetMarketplaceListing(ctx, app.MarketplaceGetInput{ + Type: dbApp.Type, + }) + if err != nil { + return app.AppBase{}, fmt.Errorf("failed to get listing for app %s: %w", dbApp.ID, err) + } + + // Map app base from db + return mapAppBaseFromDB(dbApp, registryItem), nil + }) + }) +} + +// UpdateAppStatus updates an app status +func (a *adapter) UpdateAppStatus(ctx context.Context, input app.UpdateAppStatusInput) error { + _, err := a.db.App.Update(). + Where(appdb.Namespace(input.ID.Namespace)). + Where(appdb.ID(input.ID.ID)). + SetStatus(input.Status). + Save(ctx) + if err != nil { + return fmt.Errorf("failed to update app status: %w", err) + } + + return nil +} + +// ListApps lists apps +func (a *adapter) ListApps(ctx context.Context, params app.ListAppInput) (pagination.Result[app.App], error) { + return entutils.TransactingRepo( + ctx, + a, + func(ctx context.Context, repo *adapter) (pagination.Result[app.App], error) { + query := repo.db.App. + Query(). + Where(appdb.Namespace(params.Namespace)) + + if params.Type != nil { + query = query.Where(appdb.Type(*params.Type)) + } + + // Do not return deleted apps by default + if !params.IncludeDeleted { + query = query.Where(appdb.DeletedAtIsNil()) + } + + // Only list apps that has customer data for the given customer + if params.CustomerID != nil { + query = query.Where(appdb.HasCustomerAppsWith( + appcustomerdb.CustomerID(params.CustomerID.ID), + appcustomerdb.DeletedAtIsNil(), + )) + } + + // Only list apps that has the given app IDs + if len(params.AppIDs) > 0 { + appIDs := lo.Map(params.AppIDs, func(appID app.AppID, _ int) string { + return appID.ID + }) + + query = query.Where(appdb.IDIn(appIDs...)) + } + + response := pagination.Result[app.App]{ + Page: params.Page, + } + + paged, err := query.Paginate(ctx, params.Page) + if err != nil { + return response, err + } + + result := make([]app.App, 0, len(paged.Items)) + for _, dbApp := range paged.Items { + registryItem, err := repo.GetMarketplaceListing(ctx, app.MarketplaceGetInput{ + Type: dbApp.Type, + }) + if err != nil { + return response, fmt.Errorf("failed to get listing for app %s: %w", dbApp.ID, err) + } + + app, err := mapAppFromDB(ctx, dbApp, registryItem) + if err != nil { + return response, fmt.Errorf("failed to map app %s from db: %w", dbApp.ID, err) + } + + result = append(result, app) + } + + response.TotalCount = paged.TotalCount + response.Items = result + + return response, nil + }, + ) +} + +// GetApp gets an app +func (a *adapter) GetApp(ctx context.Context, input app.GetAppInput) (app.App, error) { + return entutils.TransactingRepo( + ctx, + a, + func(ctx context.Context, repo *adapter) (app.App, error) { + dbApp, err := repo.db.App.Query(). + Where(appdb.Namespace(input.Namespace)). + Where(appdb.ID(input.ID)). + First(ctx) + if err != nil { + if db.IsNotFound(err) { + return nil, app.NewAppNotFoundError(input) + } + + return nil, err + } + + // Get registry item + registryItem, err := repo.GetMarketplaceListing(ctx, app.MarketplaceGetInput{ + Type: dbApp.Type, + }) + if err != nil { + return nil, fmt.Errorf("failed to get listing for app %s: %w", dbApp.ID, err) + } + + // Map app from db + app, err := mapAppFromDB(ctx, dbApp, registryItem) + if err != nil { + return nil, fmt.Errorf("failed to map app from db: %w", err) + } + + return app, nil + }, + ) +} + +// UpdateApp updates an app +func (a *adapter) UpdateApp(ctx context.Context, input app.UpdateAppInput) (app.App, error) { + return transaction.Run(ctx, a, func(ctx context.Context) (app.App, error) { + return entutils.TransactingRepo( + ctx, + a, + func(ctx context.Context, repo *adapter) (app.App, error) { + // Update the app + _, err := repo.db.App.Update(). + Where(appdb.Namespace(input.AppID.Namespace)). + Where(appdb.ID(input.AppID.ID)). + SetName(input.Name). + SetOrClearDescription(input.Description). + SetOrClearMetadata(input.Metadata). + Save(ctx) + if err != nil { + return nil, fmt.Errorf("failed to update the app with id %s: %w", input.AppID.ID, err) + } + + // Get the updated app + app, err := a.GetApp(ctx, input.AppID) + if err != nil { + return nil, fmt.Errorf("failed to get updated app: %s: %w", input.AppID.ID, err) + } + + return app, nil + }) + }) +} + +// UninstallApp uninstalls an app +func (a *adapter) UninstallApp(ctx context.Context, input app.UninstallAppInput) (*app.AppBase, error) { + return transaction.Run(ctx, a, func(ctx context.Context) (*app.AppBase, error) { + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, repo *adapter) (*app.AppBase, error) { + installedApp, err := repo.GetApp(ctx, input) + if err != nil { + return nil, fmt.Errorf("failed to get app: %w", err) + } + + // Get app factory through registry + registryItem, err := repo.GetMarketplaceListing(ctx, app.MarketplaceGetInput{ + Type: installedApp.GetType(), + }) + if err != nil { + return nil, fmt.Errorf("failed to get listing for app: %w", err) + } + + // Uninstall app through factory + err = registryItem.Factory.UninstallApp(ctx, installedApp.GetID()) + if err != nil { + return nil, fmt.Errorf("failed to uninstall app: %w", err) + } + + deletedAt := time.Now() + + // Delete app from database + _, err = repo.db.App.Update(). + Where(appdb.Namespace(input.Namespace)). + Where(appdb.ID(input.ID)). + SetDeletedAt(time.Now()). + Save(ctx) + if err != nil { + return nil, fmt.Errorf("failed to delete app from database: %w", err) + } + + appBase := installedApp.GetAppBase() + appBase.DeletedAt = &deletedAt + + return &appBase, nil + }) + }) +} + +// mapAppBaseFromDB maps an app base from the database +func mapAppBaseFromDB(dbApp *db.App, registryItem app.RegistryItem) app.AppBase { + return app.AppBase{ + ManagedResource: models.NewManagedResource(models.ManagedResourceInput{ + ID: dbApp.ID, + Namespace: dbApp.Namespace, + CreatedAt: dbApp.CreatedAt, + UpdatedAt: dbApp.UpdatedAt, + DeletedAt: dbApp.DeletedAt, + Name: dbApp.Name, + Description: dbApp.Description, + }), + Type: dbApp.Type, + Status: dbApp.Status, + Listing: registryItem.Listing, + Metadata: dbApp.Metadata, + } +} + +// mapAppFromDB maps an app from the database +func mapAppFromDB(ctx context.Context, dbApp *db.App, registryItem app.RegistryItem) (app.App, error) { + appBase := mapAppBaseFromDB(dbApp, registryItem) + + app, err := registryItem.Factory.NewApp(ctx, appBase) + if err != nil { + return app, fmt.Errorf("failed to create app with %s factory: %w", appBase.Type, err) + } + + return app, nil +} diff --git a/app/adapter/customer.go b/app/adapter/customer.go new file mode 100644 index 0000000000000000000000000000000000000000..573c825d0aa01805758f23f03f6b6812c4efa479 --- /dev/null +++ b/app/adapter/customer.go @@ -0,0 +1,178 @@ +package appadapter + +import ( + "context" + "fmt" + "time" + + "entgo.io/ent/dialect/sql" + + "github.com/openmeterio/openmeter/openmeter/app" + "github.com/openmeterio/openmeter/openmeter/ent/db" + appcustomerdb "github.com/openmeterio/openmeter/openmeter/ent/db/appcustomer" + "github.com/openmeterio/openmeter/pkg/framework/entutils" + "github.com/openmeterio/openmeter/pkg/framework/transaction" + "github.com/openmeterio/openmeter/pkg/models" + "github.com/openmeterio/openmeter/pkg/pagination" +) + +var _ app.AppAdapter = (*adapter)(nil) + +// ListCustomerData lists app customer data +func (a *adapter) ListCustomerData(ctx context.Context, input app.ListCustomerInput) (pagination.Result[app.CustomerApp], error) { + if err := input.Validate(); err != nil { + return pagination.Result[app.CustomerApp]{}, models.NewGenericValidationError( + fmt.Errorf("error listing customer data: %w", err), + ) + } + + listInput := app.ListAppInput{ + Page: input.Page, + Namespace: input.CustomerID.Namespace, + CustomerID: &input.CustomerID, + Type: input.Type, + } + + if input.AppID != nil { + listInput.AppIDs = []app.AppID{*input.AppID} + } + + apps, err := a.ListApps(ctx, listInput) + if err != nil { + return pagination.Result[app.CustomerApp]{}, fmt.Errorf("failed to list apps: %w", err) + } + + response := pagination.Result[app.CustomerApp]{ + Page: input.Page, + TotalCount: apps.TotalCount, + Items: make([]app.CustomerApp, 0, len(apps.Items)), + } + + for _, customerApp := range apps.Items { + customerData, err := customerApp.GetCustomerData(ctx, app.GetAppInstanceCustomerDataInput{ + CustomerID: input.CustomerID, + }) + if err != nil { + return pagination.Result[app.CustomerApp]{}, fmt.Errorf("failed to get customer data for app %s: %w", customerApp.GetID().ID, err) + } + + response.Items = append(response.Items, app.CustomerApp{ + App: customerApp, + CustomerData: customerData, + }) + } + + return response, nil +} + +// EnsureCustomer upserts app customer relationship: +// If the app or customer does not exist, an error is returned +// If the app customer relationship already exists, nothing is done +// If the app customer relationship is deleted, it is restored +func (a *adapter) EnsureCustomer(ctx context.Context, input app.EnsureCustomerInput) error { + return transaction.RunWithNoValue(ctx, a, func(ctx context.Context) error { + if err := input.Validate(); err != nil { + return models.NewGenericValidationError( + err, + ) + } + + _, err := entutils.TransactingRepo( + ctx, + a, + func(ctx context.Context, repo *adapter) (any, error) { + // Upsert customer data for the app + err := repo.db.AppCustomer. + Create(). + SetNamespace(input.AppID.Namespace). + SetAppID(input.AppID.ID). + SetCustomerID(input.CustomerID.ID). + SetNillableDeletedAt(nil). + // Upsert + OnConflict( + sql.ConflictColumns( + appcustomerdb.FieldNamespace, + appcustomerdb.FieldAppID, + appcustomerdb.FieldCustomerID, + ), + sql.ConflictWhere(sql.IsNull(appcustomerdb.FieldDeletedAt)), + ). + UpdateDeletedAt(). + Exec(ctx) + if err != nil { + // TODO: differentiate between app or customer not found + // When the constraint error is returned, it means that the app or customer does not exist. + if db.IsConstraintError(err) { + return nil, app.NewAppNotFoundError(input.AppID) + } + + // TODO (pmarton): This is a workaround for the issue where DoNothing() returns an error when no rows are affected. + // See: https://github.com/ent/ent/issues/1821 + if err.Error() == "sql: no rows in result set" { + return nil, nil + } + + return nil, fmt.Errorf("failed to upsert app customer: %w", err) + } + + return nil, nil + }, + ) + + return err + }) +} + +// DeleteCustomer deletes app customer +func (a *adapter) DeleteCustomer(ctx context.Context, input app.DeleteCustomerInput) error { + return transaction.RunWithNoValue(ctx, a, func(ctx context.Context) error { + if err := input.Validate(); err != nil { + return models.NewGenericValidationError( + fmt.Errorf("error delete customer: %w", err), + ) + } + + // Determine namespace + var namespace string + + if input.AppID != nil { + namespace = input.AppID.Namespace + } + + if input.CustomerID != nil { + namespace = input.CustomerID.Namespace + } + + if namespace == "" { + return models.NewGenericValidationError( + fmt.Errorf("error delete customer: namespace is empty"), + ) + } + + _, err := entutils.TransactingRepo(ctx, a, func(ctx context.Context, repo *adapter) (any, error) { + // Delete app customer + query := repo.db.AppCustomer. + Update(). + SetDeletedAt(time.Now()). + Where( + appcustomerdb.Namespace(namespace), + ) + + if input.AppID != nil { + query = query.Where(appcustomerdb.AppID(input.AppID.ID)) + } + + if input.CustomerID != nil { + query = query.Where(appcustomerdb.CustomerID(input.CustomerID.ID)) + } + + _, err := query.Save(ctx) + if err != nil { + return nil, fmt.Errorf("failed to delete app customer: %w", err) + } + + return nil, nil + }) + return err + }) +} diff --git a/app/adapter/marketplace.go b/app/adapter/marketplace.go new file mode 100644 index 0000000000000000000000000000000000000000..46bc92620e582190af17d7fef3b902c14cae4a2b --- /dev/null +++ b/app/adapter/marketplace.go @@ -0,0 +1,132 @@ +package appadapter + +import ( + "context" + "fmt" + + "github.com/samber/lo" + + "github.com/openmeterio/openmeter/openmeter/app" + "github.com/openmeterio/openmeter/pkg/framework/transaction" + "github.com/openmeterio/openmeter/pkg/models" + "github.com/openmeterio/openmeter/pkg/pagination" +) + +// ListMarketplaceListings lists marketplace listings +func (a adapter) ListMarketplaceListings(ctx context.Context, input app.MarketplaceListInput) (pagination.Result[app.RegistryItem], error) { + items := lo.Values(a.registry) + items = lo.Subset(items, (input.PageNumber-1)*input.PageSize, uint(input.PageSize)) + + response := pagination.Result[app.RegistryItem]{ + Page: input.Page, + Items: items, + TotalCount: len(a.registry), + } + + return response, nil +} + +// GetMarketplaceListing gets a marketplace listing +func (a adapter) GetMarketplaceListing(ctx context.Context, input app.MarketplaceGetInput) (app.RegistryItem, error) { + if _, ok := a.registry[input.Type]; !ok { + return app.RegistryItem{}, models.NewGenericNotFoundError( + fmt.Errorf("listing with type not found: %s", input.Type), + ) + } + + return a.registry[input.Type], nil +} + +// InstallMarketplaceListingWithAPIKey installs an app with an API key +func (a *adapter) InstallMarketplaceListingWithAPIKey(ctx context.Context, input app.InstallAppWithAPIKeyInput) (app.App, error) { + return transaction.Run(ctx, a, func(ctx context.Context) (app.App, error) { + // Get registry item + registryItem, err := a.GetMarketplaceListing(ctx, app.MarketplaceGetInput{ + Type: input.Type, + }) + if err != nil { + return nil, fmt.Errorf("failed to get listing for app %s: %w", input.Type, err) + } + + name, ok := lo.Coalesce(input.Name, registryItem.Listing.Name) + if !ok { + return nil, fmt.Errorf("name is required, listing doesn't have a name either") + } + + installer, ok := registryItem.Factory.(app.AppFactoryInstallWithAPIKey) + if !ok { + return nil, models.NewGenericValidationError(fmt.Errorf("app does not support this installation method. Supported methods: %v", registryItem.Listing.InstallMethods)) + } + + // Install app + app, err := installer.InstallAppWithAPIKey(ctx, app.AppFactoryInstallAppWithAPIKeyInput{ + Namespace: input.Namespace, + APIKey: input.APIKey, + Name: name, + }) + if err != nil { + return nil, fmt.Errorf("failed to install app: %w", err) + } + + return app, nil + }) +} + +// InstallMarketplaceListing installs an app +func (a *adapter) InstallMarketplaceListing(ctx context.Context, input app.InstallAppInput) (app.App, error) { + return transaction.Run(ctx, a, func(ctx context.Context) (app.App, error) { + // Get registry item + registryItem, err := a.GetMarketplaceListing(ctx, app.MarketplaceGetInput{ + Type: input.Type, + }) + if err != nil { + return nil, fmt.Errorf("failed to get listing for app %s: %w", input.Type, err) + } + + name, ok := lo.Coalesce(input.Name, registryItem.Listing.Name) + if !ok { + return nil, fmt.Errorf("name is required, listing doesn't have a name either") + } + + installer, ok := registryItem.Factory.(app.AppFactoryInstall) + if !ok { + return nil, models.NewGenericValidationError(fmt.Errorf("app does not support this installation method. Supported methods: %v", registryItem.Listing.InstallMethods)) + } + + // Install app + app, err := installer.InstallApp(ctx, app.AppFactoryInstallAppInput{ + Namespace: input.Namespace, + Name: name, + }) + if err != nil { + return nil, fmt.Errorf("failed to install app: %w", err) + } + + return app, nil + }) +} + +// GetMarketplaceListingOauth2InstallURL gets an OAuth2 install URL +func (a adapter) GetMarketplaceListingOauth2InstallURL(ctx context.Context, input app.GetOauth2InstallURLInput) (app.GetOauth2InstallURLOutput, error) { + return app.GetOauth2InstallURLOutput{}, fmt.Errorf("not implemented") +} + +// AuthorizeOauth2Install authorizes an OAuth2 install +func (a adapter) AuthorizeMarketplaceListingOauth2Install(ctx context.Context, input app.AuthorizeOauth2InstallInput) error { + return fmt.Errorf("not implemented") +} + +// RegisterMarketplaceListing registers an app type +func (a adapter) RegisterMarketplaceListing(input app.RegisterMarketplaceListingInput) error { + if _, ok := a.registry[input.Listing.Type]; ok { + return fmt.Errorf("marketplace listing with key %s already exists", input.Listing.Type) + } + + if err := input.Listing.Validate(); err != nil { + return fmt.Errorf("marketplace listing with key %s is invalid: %w", input.Listing.Type, err) + } + + a.registry[input.Listing.Type] = input + + return nil +} diff --git a/app/app.go b/app/app.go new file mode 100644 index 0000000000000000000000000000000000000000..876f5bc38dc6ebbdea8dde03313cf56bc727b970 --- /dev/null +++ b/app/app.go @@ -0,0 +1,218 @@ +package app + +import ( + "context" + "errors" + "fmt" + + "github.com/openmeterio/openmeter/openmeter/customer" + "github.com/openmeterio/openmeter/pkg/models" + "github.com/openmeterio/openmeter/pkg/pagination" +) + +// App represents an installed app +type App interface { + GetAppBase() AppBase + GetID() AppID + GetType() AppType + GetName() string + GetDescription() *string + GetStatus() AppStatus + GetMetadata() models.Metadata + GetListing() MarketplaceListing + + GetEventAppData() (EventAppData, error) + + UpdateAppConfig(ctx context.Context, input AppConfigUpdate) error + + // ValidateCapabilities validates if the app can run for the given capabilities + ValidateCapabilities(capabilities ...CapabilityType) error + + // Customer data + GetCustomerData(ctx context.Context, input GetAppInstanceCustomerDataInput) (CustomerData, error) + UpsertCustomerData(ctx context.Context, input UpsertAppInstanceCustomerDataInput) error + DeleteCustomerData(ctx context.Context, input DeleteAppInstanceCustomerDataInput) error +} + +type GetAppInstanceCustomerDataInput struct { + CustomerID customer.CustomerID +} + +func (i GetAppInstanceCustomerDataInput) Validate() error { + if err := i.CustomerID.Validate(); err != nil { + return err + } + + return nil +} + +type UpsertAppInstanceCustomerDataInput struct { + CustomerID customer.CustomerID + Data CustomerData +} + +func (i UpsertAppInstanceCustomerDataInput) Validate() error { + if err := i.CustomerID.Validate(); err != nil { + return err + } + + if err := i.Data.Validate(); err != nil { + return err + } + + return nil +} + +type DeleteAppInstanceCustomerDataInput struct { + CustomerID customer.CustomerID +} + +func (i DeleteAppInstanceCustomerDataInput) Validate() error { + if err := i.CustomerID.Validate(); err != nil { + return err + } + + return nil +} + +// GetAppInput is the input for getting an installed app +type GetAppInput = AppID + +type AppConfigUpdate interface { + models.Validator +} + +// UpdateAppInput is the input for setting an app as default for a type +type UpdateAppInput struct { + AppID AppID + Name string + Description *string + Default bool + Metadata *map[string]string + AppConfigUpdate AppConfigUpdate +} + +func (i UpdateAppInput) Validate() error { + if err := i.AppID.Validate(); err != nil { + return fmt.Errorf("error validating app ID: %w", err) + } + + // Required fields + if i.Name == "" { + return errors.New("name is required") + } + + if i.Metadata != nil { + for k, v := range *i.Metadata { + if k == "" { + return errors.New("metadata key is required") + } + + if v == "" { + return errors.New("metadata value is required") + } + } + } + + if i.AppConfigUpdate != nil { + if err := i.AppConfigUpdate.Validate(); err != nil { + return fmt.Errorf("error validating app entity update: %w", err) + } + } + + return nil +} + +// CreateAppInput is the input for creating an app +type CreateAppInput struct { + // AppID is optional. If not provided, a new AppID will be generated by the database + ID *AppID + Namespace string + Name string + Description string + Type AppType +} + +func (i CreateAppInput) Validate() error { + if i.Namespace == "" { + return errors.New("namespace is required") + } + + if i.Name == "" { + return errors.New("name is required") + } + + return nil +} + +// ListAppInput is the input for listing installed apps +type ListAppInput struct { + Namespace string + pagination.Page + + AppIDs []AppID + Type *AppType + IncludeDeleted bool + // Only list apps that has data for the given customer + CustomerID *customer.CustomerID +} + +func (i ListAppInput) Validate() error { + var errs []error + + if i.Namespace == "" { + errs = append(errs, models.NewGenericValidationError( + errors.New("namespace is required"), + )) + } + + if i.CustomerID != nil { + if err := i.CustomerID.Validate(); err != nil { + errs = append(errs, models.NewGenericValidationError( + fmt.Errorf("error validating customer id: %w", err), + )) + } + + if i.CustomerID.Namespace != i.Namespace { + errs = append(errs, models.NewGenericValidationError( + fmt.Errorf("customer id namespace %s does not match app namespace %s", i.CustomerID.Namespace, i.Namespace), + )) + } + } + + if len(i.AppIDs) > 0 { + for _, appID := range i.AppIDs { + if appID.Namespace != i.Namespace { + errs = append(errs, models.NewGenericValidationError( + fmt.Errorf("app id namespace %s does not match app namespace %s", appID.Namespace, i.Namespace), + )) + } + + if err := appID.Validate(); err != nil { + errs = append(errs, models.NewGenericValidationError( + fmt.Errorf("error validating app id: %w", err), + )) + } + } + } + + return errors.Join(errs...) +} + +// UpdateAppStatusInput is the input for updating an app status +type UpdateAppStatusInput struct { + ID AppID + Status AppStatus +} + +func (i UpdateAppStatusInput) Validate() error { + if err := i.ID.Validate(); err != nil { + return err + } + + if i.Status == "" { + return errors.New("status is required") + } + + return nil +} diff --git a/app/appbase.go b/app/appbase.go new file mode 100644 index 0000000000000000000000000000000000000000..329d93e59c2c22d34a926189e8fd1e2ccd361894 --- /dev/null +++ b/app/appbase.go @@ -0,0 +1,161 @@ +package app + +import ( + "errors" + "fmt" + + "github.com/openmeterio/openmeter/pkg/models" +) + +// AppType represents the type of an app +type AppType string + +const ( + AppTypeStripe AppType = "stripe" + AppTypeSandbox AppType = "sandbox" + AppTypeCustomInvoicing AppType = "custom_invoicing" +) + +func (t AppType) Validate() error { + switch t { + case AppTypeStripe, AppTypeSandbox, AppTypeCustomInvoicing: + return nil + default: + return models.NewGenericValidationError(fmt.Errorf("invalid app type: %s", t)) + } +} + +// AppStatus represents the status of an app +type AppStatus string + +const ( + AppStatusReady AppStatus = "ready" + AppStatusUnauthorized AppStatus = "unauthorized" +) + +type CapabilityType string + +const ( + CapabilityTypeReportUsage CapabilityType = "reportUsage" + CapabilityTypeReportEvents CapabilityType = "reportEvents" + CapabilityTypeCalculateTax CapabilityType = "calculateTax" + CapabilityTypeInvoiceCustomers CapabilityType = "invoiceCustomers" + CapabilityTypeCollectPayments CapabilityType = "collectPayments" +) + +// AppBase represents an abstract with the base fields of an app +type AppBase struct { + models.ManagedResource + + Type AppType `json:"type"` + Status AppStatus `json:"status"` + Listing MarketplaceListing `json:"listing"` + Metadata models.Metadata `json:"metadata,omitempty"` +} + +func (a AppBase) GetAppBase() AppBase { + return a +} + +func (a AppBase) GetID() AppID { + return AppID{ + Namespace: a.Namespace, + ID: a.ID, + } +} + +func (a AppBase) GetType() AppType { + return a.Type +} + +func (a AppBase) GetName() string { + return a.Name +} + +func (a AppBase) GetDescription() *string { + return a.Description +} + +func (a AppBase) GetStatus() AppStatus { + return a.Status +} + +func (a AppBase) GetListing() MarketplaceListing { + return a.Listing +} + +func (a AppBase) GetMetadata() models.Metadata { + return a.Metadata +} + +// ValidateCapabilities validates if the app can run for the given capabilities +func (a AppBase) ValidateCapabilities(capabilities ...CapabilityType) error { + for _, capability := range capabilities { + found := false + + for _, c := range a.Listing.Capabilities { + if c.Type == capability { + found = true + break + } + } + + if !found { + return fmt.Errorf("capability %s is not supported by %s app type", capability, a.Type) + } + } + + return nil +} + +// ValidateCustomer validates if the app can run for the given customer +// func (a AppBase) ValidateCustomer(c customerentity.Customer, capabilities []CapabilityType) error { +// return fmt.Errorf("each app must implement its own ValidateCustomer method") +// } + +// Validate validates the app base +func (a AppBase) Validate() error { + if err := a.ManagedResource.Validate(); err != nil { + return fmt.Errorf("error validating managed resource: %w", err) + } + + if a.ID == "" { + return errors.New("id is required") + } + + if a.Namespace == "" { + return errors.New("namespace is required") + } + + if a.Name == "" { + return errors.New("name is required") + } + + if a.Status == "" { + return errors.New("status is required") + } + + if err := a.Listing.Validate(); err != nil { + return fmt.Errorf("error validating listing: %w", err) + } + + return nil +} + +// AppID represents the unique identifier for an installed app +type AppID struct { + Namespace string + ID string +} + +func (i AppID) Validate() error { + if i.Namespace == "" { + return errors.New("namespace is required") + } + + if i.ID == "" { + return errors.New("id is required") + } + + return nil +} diff --git a/app/customer.go b/app/customer.go new file mode 100644 index 0000000000000000000000000000000000000000..2b026f1a8f251f48365a430b8ada9d590bb32d1f --- /dev/null +++ b/app/customer.go @@ -0,0 +1,10 @@ +package app + +type CustomerData interface { + Validate() error +} + +type CustomerApp struct { + App App + CustomerData CustomerData +} diff --git a/app/custominvoicing/adapter.go b/app/custominvoicing/adapter.go new file mode 100644 index 0000000000000000000000000000000000000000..d233291c571f041ba89ad5dfdaf02112c6d6916c --- /dev/null +++ b/app/custominvoicing/adapter.go @@ -0,0 +1,27 @@ +package appcustominvoicing + +import ( + "context" + + "github.com/openmeterio/openmeter/openmeter/app" + "github.com/openmeterio/openmeter/pkg/framework/entutils" +) + +type Adapter interface { + CustomerDataAdapter + AppConfigAdapter + + entutils.TxCreator +} + +type CustomerDataAdapter interface { + GetCustomerData(ctx context.Context, input GetAppCustomerDataInput) (CustomerData, error) + UpsertCustomerData(ctx context.Context, input UpsertCustomerDataInput) error + DeleteCustomerData(ctx context.Context, input DeleteAppCustomerDataInput) error +} + +type AppConfigAdapter interface { + GetAppConfiguration(ctx context.Context, input app.AppID) (Configuration, error) + UpsertAppConfiguration(ctx context.Context, input UpsertAppConfigurationInput) error + DeleteAppConfiguration(ctx context.Context, input app.AppID) error +} diff --git a/app/custominvoicing/adapter/adapter.go b/app/custominvoicing/adapter/adapter.go new file mode 100644 index 0000000000000000000000000000000000000000..06540f98bd72c308773d370366184f85c90bbebb --- /dev/null +++ b/app/custominvoicing/adapter/adapter.go @@ -0,0 +1,72 @@ +package adapter + +import ( + "context" + "database/sql" + "errors" + "fmt" + "log/slog" + + appcustominvoicing "github.com/openmeterio/openmeter/openmeter/app/custominvoicing" + entdb "github.com/openmeterio/openmeter/openmeter/ent/db" + "github.com/openmeterio/openmeter/pkg/framework/entutils" + "github.com/openmeterio/openmeter/pkg/framework/transaction" +) + +type Config struct { + Client *entdb.Client + Logger *slog.Logger +} + +func (c Config) Validate() error { + if c.Client == nil { + return errors.New("ent client is required") + } + + if c.Logger == nil { + return errors.New("logger is required") + } + + return nil +} + +func New(config Config) (appcustominvoicing.Adapter, error) { + if err := config.Validate(); err != nil { + return nil, err + } + + return &adapter{ + db: config.Client, + logger: config.Logger, + }, nil +} + +var _ appcustominvoicing.Adapter = (*adapter)(nil) + +type adapter struct { + db *entdb.Client + logger *slog.Logger +} + +func (a *adapter) Tx(ctx context.Context) (context.Context, transaction.Driver, error) { + txCtx, rawConfig, eDriver, err := a.db.HijackTx(ctx, &sql.TxOptions{ + ReadOnly: false, + }) + if err != nil { + return nil, nil, fmt.Errorf("failed to hijack transaction: %w", err) + } + return txCtx, entutils.NewTxDriver(eDriver, rawConfig), nil +} + +func (a *adapter) WithTx(ctx context.Context, tx *entutils.TxDriver) *adapter { + txDb := entdb.NewTxClientFromRawConfig(ctx, *tx.GetConfig()) + + return &adapter{ + db: txDb.Client(), + logger: a.logger, + } +} + +func (a *adapter) Self() *adapter { + return a +} diff --git a/app/custominvoicing/adapter/appconfig.go b/app/custominvoicing/adapter/appconfig.go new file mode 100644 index 0000000000000000000000000000000000000000..d0e5be39f4ff292b5eced56f08d5edcbf59c7d47 --- /dev/null +++ b/app/custominvoicing/adapter/appconfig.go @@ -0,0 +1,70 @@ +package adapter + +import ( + "context" + "time" + + "github.com/openmeterio/openmeter/openmeter/app" + custominvoicing "github.com/openmeterio/openmeter/openmeter/app/custominvoicing" + "github.com/openmeterio/openmeter/openmeter/ent/db" + "github.com/openmeterio/openmeter/openmeter/ent/db/appcustominvoicing" + "github.com/openmeterio/openmeter/pkg/framework/entutils" +) + +var _ custominvoicing.AppConfigAdapter = (*adapter)(nil) + +func (a *adapter) GetAppConfiguration(ctx context.Context, input app.AppID) (custominvoicing.Configuration, error) { + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) (custominvoicing.Configuration, error) { + appConfig, err := tx.db.AppCustomInvoicing.Query(). + Where( + appcustominvoicing.ID(input.ID), + appcustominvoicing.Namespace(input.Namespace), + appcustominvoicing.DeletedAtIsNil(), + ). + First(ctx) + if err != nil { + if db.IsNotFound(err) { + return custominvoicing.Configuration{}, nil + } + + return custominvoicing.Configuration{}, err + } + + return mapDBToAppConfiguration(appConfig), nil + }) +} + +func (a *adapter) UpsertAppConfiguration(ctx context.Context, input custominvoicing.UpsertAppConfigurationInput) error { + return entutils.TransactingRepoWithNoValue(ctx, a, func(ctx context.Context, tx *adapter) error { + return tx.db.AppCustomInvoicing.Create(). + SetID(input.AppID.ID). + SetNamespace(input.AppID.Namespace). + SetEnableDraftSyncHook(input.Configuration.EnableDraftSyncHook). + SetEnableIssuingSyncHook(input.Configuration.EnableIssuingSyncHook). + + // Upsert + OnConflictColumns(appcustominvoicing.FieldID, appcustominvoicing.FieldNamespace). + UpdateNewValues(). + Exec(ctx) + }) +} + +func (a *adapter) DeleteAppConfiguration(ctx context.Context, input app.AppID) error { + return entutils.TransactingRepoWithNoValue(ctx, a, func(ctx context.Context, tx *adapter) error { + return tx.db.AppCustomInvoicing.Update(). + Where( + appcustominvoicing.ID(input.ID), + appcustominvoicing.Namespace(input.Namespace), + appcustominvoicing.DeletedAtIsNil(), + ). + SetDeletedAt(time.Now()). + Exec(ctx) + }) +} + +func mapDBToAppConfiguration(appConfig *db.AppCustomInvoicing) custominvoicing.Configuration { + return custominvoicing.Configuration{ + EnableDraftSyncHook: appConfig.EnableDraftSyncHook, + EnableIssuingSyncHook: appConfig.EnableIssuingSyncHook, + } +} diff --git a/app/custominvoicing/adapter/customerdata.go b/app/custominvoicing/adapter/customerdata.go new file mode 100644 index 0000000000000000000000000000000000000000..d653c37f7b0e2326d51647101e8291dd534f94f2 --- /dev/null +++ b/app/custominvoicing/adapter/customerdata.go @@ -0,0 +1,89 @@ +package adapter + +import ( + "context" + "time" + + "entgo.io/ent/dialect/sql" + + appcustominvoicing "github.com/openmeterio/openmeter/openmeter/app/custominvoicing" + "github.com/openmeterio/openmeter/openmeter/ent/db" + "github.com/openmeterio/openmeter/openmeter/ent/db/appcustominvoicingcustomer" + "github.com/openmeterio/openmeter/pkg/framework/entutils" +) + +func (a *adapter) GetCustomerData(ctx context.Context, input appcustominvoicing.GetAppCustomerDataInput) (appcustominvoicing.CustomerData, error) { + if err := input.Validate(); err != nil { + return appcustominvoicing.CustomerData{}, err + } + + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) (appcustominvoicing.CustomerData, error) { + line, err := tx.db.AppCustomInvoicingCustomer.Query(). + Where( + appcustominvoicingcustomer.CustomerID(input.CustomerID), + appcustominvoicingcustomer.Namespace(input.Namespace), + appcustominvoicingcustomer.AppID(input.AppID), + appcustominvoicingcustomer.DeletedAtIsNil(), + ). + First(ctx) + if err != nil { + if db.IsNotFound(err) { + return appcustominvoicing.CustomerData{}, nil + } + + return appcustominvoicing.CustomerData{}, err + } + + return mapDBToCustomerData(line), nil + }) +} + +func (a *adapter) UpsertCustomerData(ctx context.Context, input appcustominvoicing.UpsertCustomerDataInput) error { + if err := input.Validate(); err != nil { + return err + } + + return entutils.TransactingRepoWithNoValue(ctx, a, func(ctx context.Context, tx *adapter) error { + return tx.db.AppCustomInvoicingCustomer.Create(). + SetMetadata(input.Data.Metadata). + SetCustomerID(input.CustomerDataID.CustomerID). + SetNamespace(input.CustomerDataID.Namespace). + SetAppID(input.CustomerDataID.AppID). + // Upsert + OnConflict( + sql.ConflictColumns( + appcustominvoicingcustomer.FieldCustomerID, + appcustominvoicingcustomer.FieldNamespace, + appcustominvoicingcustomer.FieldAppID, + ), + sql.ConflictWhere(sql.IsNull(appcustominvoicingcustomer.FieldDeletedAt)), + ). + UpdateMetadata(). + UpdateDeletedAt(). + Exec(ctx) + }) +} + +func (a *adapter) DeleteCustomerData(ctx context.Context, input appcustominvoicing.DeleteAppCustomerDataInput) error { + if err := input.Validate(); err != nil { + return err + } + + return entutils.TransactingRepoWithNoValue(ctx, a, func(ctx context.Context, tx *adapter) error { + return tx.db.AppCustomInvoicingCustomer.Update(). + SetDeletedAt(time.Now()). + Where( + appcustominvoicingcustomer.CustomerID(input.CustomerID), + appcustominvoicingcustomer.Namespace(input.Namespace), + appcustominvoicingcustomer.AppID(input.AppID), + appcustominvoicingcustomer.DeletedAtIsNil(), + ). + Exec(ctx) + }) +} + +func mapDBToCustomerData(line *db.AppCustomInvoicingCustomer) appcustominvoicing.CustomerData { + return appcustominvoicing.CustomerData{ + Metadata: line.Metadata, + } +} diff --git a/app/custominvoicing/app.go b/app/custominvoicing/app.go new file mode 100644 index 0000000000000000000000000000000000000000..1614343f6361c701bf60dda47fe1740870975350 --- /dev/null +++ b/app/custominvoicing/app.go @@ -0,0 +1,170 @@ +package appcustominvoicing + +import ( + "context" + "fmt" + + "github.com/openmeterio/openmeter/openmeter/app" + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/billing/sequence" + "github.com/openmeterio/openmeter/openmeter/customer" + customerapp "github.com/openmeterio/openmeter/openmeter/customer/app" +) + +var ( + _ customerapp.App = (*App)(nil) + _ billing.InvoicingApp = (*App)(nil) + _ billing.InvoicingAppAsyncSyncer = (*App)(nil) +) + +var DefaultInvoiceSequenceNumber = sequence.Definition{ + Prefix: "INV", + SuffixTemplate: "{{.CustomerPrefix}}-{{.NextSequenceNumber}}", + Scope: "invoices/custom-invoicing", + CommitMode: sequence.CommitModeWithCaller, +} + +type Configuration struct { + EnableDraftSyncHook bool `json:"enable_draft_sync_hook"` + EnableIssuingSyncHook bool `json:"enable_issuing_sync_hook"` +} + +const ( + MetadataKeyDraftSyncedAt = "openmeter.io/custominvoicing/draft-synced-at" + MetadataKeyFinalizedAt = "openmeter.io/custominvoicing/finalized-at" +) + +func (c Configuration) Validate() error { + return nil +} + +type Meta struct { + app.AppBase + Configuration +} + +var _ app.EventAppParser = (*Meta)(nil) + +func (m *Meta) FromEventAppData(event app.EventApp) error { + m.AppBase = event.AppBase + + if err := event.AppData.ParseInto(&m.Configuration); err != nil { + return fmt.Errorf("error parsing app data: %w", err) + } + + return nil +} + +type App struct { + Meta + + customInvoicingService Service + sequenceService sequence.Service +} + +func (a App) ValidateCustomer(ctx context.Context, customer *customer.Customer, capabilities []app.CapabilityType) error { + return nil +} + +func (a App) UpdateAppConfig(ctx context.Context, input app.AppConfigUpdate) error { + cfg, ok := input.(Configuration) + if !ok { + return fmt.Errorf("invalid configuration") + } + + if err := cfg.Validate(); err != nil { + return err + } + + return a.customInvoicingService.UpsertAppConfiguration(ctx, UpsertAppConfigurationInput{ + AppID: a.GetID(), + Configuration: cfg, + }) +} + +func (a App) GetEventAppData() (app.EventAppData, error) { + return app.NewEventAppData(a.Configuration) +} + +// InvoicingApp +// These are no-ops as whatever is meaningful, is handled via the http driver of the custominvoicing app. + +// ValidateStandardInvoice is a no-op as any validation issues are published via the draft.syncing and finalizations syncing +// flow. +func (a App) ValidateStandardInvoice(ctx context.Context, invoice billing.StandardInvoice) error { + return nil +} + +func (a App) UpsertStandardInvoice(ctx context.Context, invoice billing.StandardInvoice) (*billing.UpsertStandardInvoiceResult, error) { + return nil, nil +} + +func (a App) FinalizeStandardInvoice(ctx context.Context, invoice billing.StandardInvoice) (*billing.FinalizeStandardInvoiceResult, error) { + canAdvance, err := a.CanIssuingSyncAdvance(invoice) + if err != nil { + return nil, err + } + + res := billing.NewFinalizeStandardInvoiceResult() + + // If we are done with the hook work, let's make sure that the invoice has a non-draft invoice number + if canAdvance { + // If the invoice still has a draft invoice number, let's generate a non-draft one + if sequence.DraftInvoiceSequenceNumber.PrefixMatches(invoice.Number) { + invoiceNumber, err := a.sequenceService.GenerateInvoiceSequenceNumber(ctx, + sequence.GenerationInput{ + Namespace: invoice.Namespace, + CustomerName: invoice.Customer.Name, + Currency: invoice.Currency, + }, + DefaultInvoiceSequenceNumber, + ) + if err != nil { + return nil, fmt.Errorf("generating invoice number: %w", err) + } + + res.SetInvoiceNumber(invoiceNumber) + } + } + + return res, nil +} + +// DeleteStandardInvoice is a no-op as this should happen via the notifications webhook +func (a App) DeleteStandardInvoice(ctx context.Context, invoice billing.StandardInvoice) error { + return nil +} + +// InvoicingAppAsyncSyncer + +func (a App) CanDraftSyncAdvance(invoice billing.StandardInvoice) (bool, error) { + if !a.Configuration.EnableDraftSyncHook { + return true, nil + } + + if invoice.Metadata == nil { + return false, nil + } + + if _, ok := invoice.Metadata[MetadataKeyDraftSyncedAt]; ok { + return true, nil + } + + return false, nil +} + +func (a App) CanIssuingSyncAdvance(invoice billing.StandardInvoice) (bool, error) { + if !a.Configuration.EnableIssuingSyncHook { + return true, nil + } + + if invoice.Metadata == nil { + return false, nil + } + + if _, ok := invoice.Metadata[MetadataKeyFinalizedAt]; ok { + return true, nil + } + + return false, nil +} diff --git a/app/custominvoicing/customerdata.go b/app/custominvoicing/customerdata.go new file mode 100644 index 0000000000000000000000000000000000000000..d2e7a1ff521a2a76169e7a57c859d5f32395044f --- /dev/null +++ b/app/custominvoicing/customerdata.go @@ -0,0 +1,100 @@ +package appcustominvoicing + +import ( + "context" + "errors" + "fmt" + + "github.com/openmeterio/openmeter/openmeter/app" + "github.com/openmeterio/openmeter/pkg/models" +) + +var _ app.CustomerData = (*CustomerData)(nil) + +type CustomerData struct { + Metadata models.Metadata `json:"metadata,omitempty"` +} + +func (c CustomerData) Validate() error { + return nil +} + +// Customer Specific App Data Handling + +func (a App) GetCustomerData(ctx context.Context, input app.GetAppInstanceCustomerDataInput) (app.CustomerData, error) { + return a.customInvoicingService.GetCustomerData(ctx, GetAppCustomerDataInput{ + Namespace: a.Namespace, + AppID: a.ID, + CustomerID: input.CustomerID.ID, + }) +} + +func (a App) UpsertCustomerData(ctx context.Context, input app.UpsertAppInstanceCustomerDataInput) error { + data, ok := input.Data.(CustomerData) + if !ok { + return fmt.Errorf("invalid customer data: %v", input.Data) + } + + return a.customInvoicingService.UpsertCustomerData(ctx, UpsertCustomerDataInput{ + CustomerDataID: CustomerDataID{ + Namespace: a.Namespace, + AppID: a.ID, + CustomerID: input.CustomerID.ID, + }, + Data: data, + }) +} + +func (a App) DeleteCustomerData(ctx context.Context, input app.DeleteAppInstanceCustomerDataInput) error { + return a.customInvoicingService.DeleteCustomerData(ctx, DeleteAppCustomerDataInput{ + Namespace: a.Namespace, + AppID: a.ID, + CustomerID: input.CustomerID.ID, + }) +} + +// Service types + +type UpsertCustomerDataInput struct { + CustomerDataID + Data CustomerData +} + +func (i UpsertCustomerDataInput) Validate() error { + if err := i.CustomerDataID.Validate(); err != nil { + return err + } + + if err := i.Data.Validate(); err != nil { + return err + } + + return nil +} + +type CustomerDataID struct { + Namespace string + AppID string + CustomerID string +} + +func (i CustomerDataID) Validate() error { + if i.Namespace == "" { + return errors.New("namespace is required") + } + + if i.CustomerID == "" { + return errors.New("customer id is required") + } + + if i.AppID == "" { + return errors.New("app id is required") + } + + return nil +} + +type ( + GetAppCustomerDataInput = CustomerDataID + DeleteAppCustomerDataInput = CustomerDataID +) diff --git a/app/custominvoicing/factory.go b/app/custominvoicing/factory.go new file mode 100644 index 0000000000000000000000000000000000000000..c03e86257035a26242d376c8aad96cc9ea5362db --- /dev/null +++ b/app/custominvoicing/factory.go @@ -0,0 +1,159 @@ +package appcustominvoicing + +import ( + "context" + "fmt" + + "github.com/openmeterio/openmeter/openmeter/app" + "github.com/openmeterio/openmeter/openmeter/billing/sequence" +) + +var ( + MarketplaceListing = app.MarketplaceListing{ + Type: app.AppTypeCustomInvoicing, + Name: "Custom Invoicing", + Description: "Custom Invoicing can be used to interface with third party invoicing and payment systems", + Capabilities: []app.Capability{ + CollectPaymentCapability, + CalculateTaxCapability, + InvoiceCustomerCapability, + }, + InstallMethods: []app.InstallMethod{ + app.InstallMethodNoCredentials, + }, + } + + CollectPaymentCapability = app.Capability{ + Type: app.CapabilityTypeCollectPayments, + Key: "custom_invoicing_collect_payment", + Name: "Payment", + Description: "Process payments", + } + + CalculateTaxCapability = app.Capability{ + Type: app.CapabilityTypeCalculateTax, + Key: "custom_invoicing_calculate_tax", + Name: "Calculate Tax", + Description: "Calculate tax for a payment", + } + + InvoiceCustomerCapability = app.Capability{ + Type: app.CapabilityTypeInvoiceCustomers, + Key: "custom_invoicing_invoice_customer", + Name: "Invoice Customer", + Description: "Invoice a customer", + } +) + +type Factory struct { + appService app.Service + customInvoicingService Service + sequenceService sequence.Service +} + +type FactoryConfig struct { + AppService app.Service + CustomInvoicingService Service + SequenceService sequence.Service +} + +func (c FactoryConfig) Validate() error { + if c.AppService == nil { + return fmt.Errorf("app service is required") + } + + if c.CustomInvoicingService == nil { + return fmt.Errorf("custom invoicing service is required") + } + + if c.SequenceService == nil { + return fmt.Errorf("sequence service is required") + } + + return nil +} + +func NewFactory(config FactoryConfig) (*Factory, error) { + if err := config.Validate(); err != nil { + return nil, fmt.Errorf("failed to validate config: %w", err) + } + + fact := &Factory{ + appService: config.AppService, + customInvoicingService: config.CustomInvoicingService, + sequenceService: config.SequenceService, + } + + err := config.AppService.RegisterMarketplaceListing(app.RegistryItem{ + Listing: MarketplaceListing, + Factory: fact, + }) + if err != nil { + return nil, fmt.Errorf("failed to register marketplace listing: %w", err) + } + + return fact, nil +} + +// Factory +func (f *Factory) NewApp(ctx context.Context, appBase app.AppBase) (app.App, error) { + cfg, err := f.customInvoicingService.GetAppConfiguration(ctx, appBase.GetID()) + if err != nil { + return nil, fmt.Errorf("failed to get app config: %w", err) + } + + return App{ + Meta: Meta{ + AppBase: appBase, + Configuration: cfg, + }, + customInvoicingService: f.customInvoicingService, + sequenceService: f.sequenceService, + }, nil +} + +func (f *Factory) InstallApp(ctx context.Context, input app.AppFactoryInstallAppInput) (app.App, error) { + if err := input.Validate(); err != nil { + return nil, fmt.Errorf("invalid input: %w", err) + } + + newApp, err := f.customInvoicingService.CreateApp(ctx, CreateAppInput{ + Namespace: input.Namespace, + Name: input.Name, + }) + if err != nil { + return nil, fmt.Errorf("failed to create app: %w", err) + } + + return f.NewApp(ctx, newApp.GetAppBase()) +} + +func (f *Factory) UninstallApp(ctx context.Context, input app.UninstallAppInput) error { + return f.customInvoicingService.DeleteApp(ctx, input) +} + +// Service types + +type CreateAppInput struct { + Namespace string + Name string + + Config Configuration +} + +func (i CreateAppInput) Validate() error { + if i.Namespace == "" { + return fmt.Errorf("namespace is required") + } + + if i.Name == "" { + return fmt.Errorf("name is required") + } + + return nil +} + +type UpsertAppConfigurationInput struct { + AppID app.AppID + Configuration Configuration +} diff --git a/app/custominvoicing/httpdriver/custominvoicing.go b/app/custominvoicing/httpdriver/custominvoicing.go new file mode 100644 index 0000000000000000000000000000000000000000..b7003549892f739a160fe5934bdc6f10016a543d --- /dev/null +++ b/app/custominvoicing/httpdriver/custominvoicing.go @@ -0,0 +1,172 @@ +package httpdriver + +import ( + "context" + "fmt" + "net/http" + + "github.com/openmeterio/openmeter/api" + appcustominvoicing "github.com/openmeterio/openmeter/openmeter/app/custominvoicing" + "github.com/openmeterio/openmeter/openmeter/billing" + billinghttpdriver "github.com/openmeterio/openmeter/openmeter/billing/httpdriver" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" +) + +type ( + DraftSyncronizedRequest = appcustominvoicing.SyncDraftInvoiceInput + DraftSyncronizedResponse = api.Invoice + DraftSyncronizedParams = struct { + InvoiceID string `json:"invoiceId"` + } + DraftSyncronizedHandler httptransport.HandlerWithArgs[DraftSyncronizedRequest, DraftSyncronizedResponse, DraftSyncronizedParams] +) + +func (h *handler) DraftSyncronized() DraftSyncronizedHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, params DraftSyncronizedParams) (DraftSyncronizedRequest, error) { + namespace, err := h.resolveNamespace(ctx) + if err != nil { + return DraftSyncronizedRequest{}, fmt.Errorf("failed to resolve namespace: %w", err) + } + + var body api.CustomInvoicingDraftSynchronizedRequest + if err := commonhttp.JSONRequestBodyDecoder(r, &body); err != nil { + return DraftSyncronizedRequest{}, fmt.Errorf("failed to decode draft synchronized request: %w", err) + } + + return DraftSyncronizedRequest{ + InvoiceID: billing.InvoiceID{ + ID: params.InvoiceID, + Namespace: namespace, + }, + UpsertInvoiceResults: mapUpsertStandardInvoiceResultFromAPI(body.Invoicing), + }, nil + }, + func(ctx context.Context, request DraftSyncronizedRequest) (DraftSyncronizedResponse, error) { + if err := request.Validate(); err != nil { + return DraftSyncronizedResponse{}, err + } + + invoice, err := h.service.SyncDraftInvoice(ctx, request) + if err != nil { + return DraftSyncronizedResponse{}, err + } + + return billinghttpdriver.MapStandardInvoiceToAPI(invoice) + }, + commonhttp.JSONResponseEncoderWithStatus[DraftSyncronizedResponse](http.StatusOK), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("DraftSyncronized"), + httptransport.WithErrorEncoder(errorEncoder()), + )..., + ) +} + +type ( + IssuingSyncronizedRequest = appcustominvoicing.SyncIssuingInvoiceInput + IssuingSyncronizedResponse = api.Invoice + IssuingSyncronizedParams = struct { + InvoiceID string `json:"invoiceId"` + } + IssuingSyncronizedHandler httptransport.HandlerWithArgs[IssuingSyncronizedRequest, IssuingSyncronizedResponse, IssuingSyncronizedParams] +) + +func (h *handler) IssuingSyncronized() IssuingSyncronizedHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, params IssuingSyncronizedParams) (IssuingSyncronizedRequest, error) { + namespace, err := h.resolveNamespace(ctx) + if err != nil { + return IssuingSyncronizedRequest{}, fmt.Errorf("failed to resolve namespace: %w", err) + } + + var body api.CustomInvoicingFinalizedRequest + if err := commonhttp.JSONRequestBodyDecoder(r, &body); err != nil { + return IssuingSyncronizedRequest{}, fmt.Errorf("failed to decode issuing synchronized request: %w", err) + } + + return IssuingSyncronizedRequest{ + InvoiceID: billing.InvoiceID{ + ID: params.InvoiceID, + Namespace: namespace, + }, + FinalizeInvoiceResult: mapFinalizeStandardInvoiceResultFromAPI(body), + }, nil + }, + func(ctx context.Context, request IssuingSyncronizedRequest) (IssuingSyncronizedResponse, error) { + if err := request.Validate(); err != nil { + return IssuingSyncronizedResponse{}, err + } + + invoice, err := h.service.SyncIssuingInvoice(ctx, request) + if err != nil { + return IssuingSyncronizedResponse{}, err + } + + return billinghttpdriver.MapStandardInvoiceToAPI(invoice) + }, + commonhttp.JSONResponseEncoderWithStatus[IssuingSyncronizedResponse](http.StatusOK), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("IssuingSyncronized"), + httptransport.WithErrorEncoder(errorEncoder()), + )..., + ) +} + +type ( + UpdatePaymentStatusRequest = appcustominvoicing.HandlePaymentTriggerInput + UpdatePaymentStatusResponse = api.Invoice + UpdatePaymentStatusParams = struct { + InvoiceID string `json:"invoiceId"` + } + UpdatePaymentStatusHandler httptransport.HandlerWithArgs[UpdatePaymentStatusRequest, UpdatePaymentStatusResponse, UpdatePaymentStatusParams] +) + +func (h *handler) UpdatePaymentStatus() UpdatePaymentStatusHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, params UpdatePaymentStatusParams) (UpdatePaymentStatusRequest, error) { + namespace, err := h.resolveNamespace(ctx) + if err != nil { + return UpdatePaymentStatusRequest{}, fmt.Errorf("failed to resolve namespace: %w", err) + } + + var body api.CustomInvoicingUpdatePaymentStatusRequest + if err := commonhttp.JSONRequestBodyDecoder(r, &body); err != nil { + return UpdatePaymentStatusRequest{}, fmt.Errorf("failed to decode handle payment trigger request: %w", err) + } + + trigger, err := mapPaymentTriggerFromAPI(body.Trigger) + if err != nil { + return UpdatePaymentStatusRequest{}, fmt.Errorf("failed to map payment trigger: %w", err) + } + + return UpdatePaymentStatusRequest{ + InvoiceID: billing.InvoiceID{ + ID: params.InvoiceID, + Namespace: namespace, + }, + Trigger: trigger, + }, nil + }, + func(ctx context.Context, request UpdatePaymentStatusRequest) (UpdatePaymentStatusResponse, error) { + if err := request.Validate(); err != nil { + return UpdatePaymentStatusResponse{}, err + } + + invoice, err := h.service.HandlePaymentTrigger(ctx, request) + if err != nil { + return UpdatePaymentStatusResponse{}, err + } + + return billinghttpdriver.MapStandardInvoiceToAPI(invoice) + }, + commonhttp.JSONResponseEncoderWithStatus[UpdatePaymentStatusResponse](http.StatusOK), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("UpdatePaymentStatus"), + httptransport.WithErrorEncoder(errorEncoder()), + )..., + ) +} diff --git a/app/custominvoicing/httpdriver/errors.go b/app/custominvoicing/httpdriver/errors.go new file mode 100644 index 0000000000000000000000000000000000000000..33e55460a805fd0c985c88f668c0dcc65d3fe291 --- /dev/null +++ b/app/custominvoicing/httpdriver/errors.go @@ -0,0 +1,21 @@ +package httpdriver + +import ( + "context" + "net/http" + + "github.com/openmeterio/openmeter/openmeter/billing" + "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[billing.NotFoundError](ctx, http.StatusNotFound, err, w, billing.EncodeValidationIssues) || + commonhttp.HandleErrorIfTypeMatches[billing.ValidationError](ctx, http.StatusBadRequest, err, w, billing.EncodeValidationIssues) || + commonhttp.HandleErrorIfTypeMatches[billing.UpdateAfterDeleteError](ctx, http.StatusConflict, err, w, billing.EncodeValidationIssues) || + commonhttp.HandleErrorIfTypeMatches[billing.ValidationIssue](ctx, http.StatusBadRequest, err, w, billing.EncodeValidationIssues) || + // dependency: apps + commonhttp.HandleErrorIfTypeMatches[billing.AppError](ctx, http.StatusBadRequest, err, w) + } +} diff --git a/app/custominvoicing/httpdriver/handler.go b/app/custominvoicing/httpdriver/handler.go new file mode 100644 index 0000000000000000000000000000000000000000..f5fa5733e799a167be821196b201b65834a7488b --- /dev/null +++ b/app/custominvoicing/httpdriver/handler.go @@ -0,0 +1,52 @@ +package httpdriver + +import ( + "context" + "errors" + "net/http" + + appcustominvoicing "github.com/openmeterio/openmeter/openmeter/app/custominvoicing" + "github.com/openmeterio/openmeter/openmeter/namespace/namespacedriver" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" +) + +type Handler interface { + AppHandler +} + +type AppHandler interface { + DraftSyncronized() DraftSyncronizedHandler + IssuingSyncronized() IssuingSyncronizedHandler + UpdatePaymentStatus() UpdatePaymentStatusHandler +} + +var _ Handler = (*handler)(nil) + +type handler struct { + service appcustominvoicing.SyncService + + namespaceDecoder namespacedriver.NamespaceDecoder + options []httptransport.HandlerOption +} + +func (h *handler) resolveNamespace(ctx context.Context) (string, error) { + ns, ok := h.namespaceDecoder.GetNamespace(ctx) + if !ok { + return "", commonhttp.NewHTTPError(http.StatusInternalServerError, errors.New("internal server error")) + } + + return ns, nil +} + +func New( + service appcustominvoicing.SyncService, + namespaceDecoder namespacedriver.NamespaceDecoder, + options ...httptransport.HandlerOption, +) Handler { + return &handler{ + service: service, + namespaceDecoder: namespaceDecoder, + options: options, + } +} diff --git a/app/custominvoicing/httpdriver/mapper.go b/app/custominvoicing/httpdriver/mapper.go new file mode 100644 index 0000000000000000000000000000000000000000..7039bc92c207473333f5eb57a2e0c5d16e4c5671 --- /dev/null +++ b/app/custominvoicing/httpdriver/mapper.go @@ -0,0 +1,85 @@ +package httpdriver + +import ( + "fmt" + + "github.com/openmeterio/openmeter/api" + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/pkg/models" +) + +func mapUpsertStandardInvoiceResultFromAPI(in *api.CustomInvoicingSyncResult) *billing.UpsertStandardInvoiceResult { + if in == nil { + return nil + } + + res := billing.NewUpsertStandardInvoiceResult() + + if in.InvoiceNumber != nil { + res.SetInvoiceNumber(*in.InvoiceNumber) + } + + if in.ExternalId != nil { + res.SetExternalID(*in.ExternalId) + } + + if in.LineExternalIds != nil { + for _, line := range *in.LineExternalIds { + res.AddLineExternalID(line.LineId, line.ExternalId) + } + } + + if in.LineDiscountExternalIds != nil { + for _, lineDiscount := range *in.LineDiscountExternalIds { + res.AddLineDiscountExternalID(lineDiscount.LineDiscountId, lineDiscount.ExternalId) + } + } + + return res +} + +func mapFinalizeStandardInvoiceResultFromAPI(in api.CustomInvoicingFinalizedRequest) *billing.FinalizeStandardInvoiceResult { + res := billing.NewFinalizeStandardInvoiceResult() + + if in.Invoicing != nil { + if in.Invoicing.InvoiceNumber != nil { + res.SetInvoiceNumber(*in.Invoicing.InvoiceNumber) + } + + if in.Invoicing.SentToCustomerAt != nil { + res.SetSentToCustomerAt(*in.Invoicing.SentToCustomerAt) + } + } + + if in.Payment != nil { + if in.Payment.ExternalId != nil { + res.SetPaymentExternalID(*in.Payment.ExternalId) + } + } + return res +} + +func mapPaymentTriggerFromAPI(in api.CustomInvoicingPaymentTrigger) (billing.InvoiceTrigger, error) { + if in == "" { + return "", models.NewGenericValidationError(fmt.Errorf("payment trigger is required")) + } + + // Map API trigger names to internal state machine triggers + switch in { + case api.CustomInvoicingPaymentTriggerPaid: + return billing.TriggerPaid, nil + case api.CustomInvoicingPaymentTriggerPaymentFailed: + // Note: API uses "payment_failed" but internal trigger is "failed" + return billing.TriggerFailed, nil + case api.CustomInvoicingPaymentTriggerPaymentUncollectible: + return billing.TriggerPaymentUncollectible, nil + case api.CustomInvoicingPaymentTriggerPaymentOverdue: + return billing.TriggerPaymentOverdue, nil + case api.CustomInvoicingPaymentTriggerActionRequired: + return billing.TriggerActionRequired, nil + case api.CustomInvoicingPaymentTriggerVoid: + return billing.TriggerVoid, nil + default: + return "", models.NewGenericValidationError(fmt.Errorf("unknown payment trigger: %s", in)) + } +} diff --git a/app/custominvoicing/service.go b/app/custominvoicing/service.go new file mode 100644 index 0000000000000000000000000000000000000000..797275d884ae7937327f9a0e26bd9ed954ad8e7c --- /dev/null +++ b/app/custominvoicing/service.go @@ -0,0 +1,34 @@ +package appcustominvoicing + +import ( + "context" + + "github.com/openmeterio/openmeter/openmeter/app" + "github.com/openmeterio/openmeter/openmeter/billing" +) + +type Service interface { + CustomerDataService + FactoryService + SyncService +} + +type CustomerDataService interface { + GetCustomerData(ctx context.Context, input GetAppCustomerDataInput) (CustomerData, error) + UpsertCustomerData(ctx context.Context, input UpsertCustomerDataInput) error + DeleteCustomerData(ctx context.Context, input DeleteAppCustomerDataInput) error +} + +type FactoryService interface { + CreateApp(ctx context.Context, input CreateAppInput) (app.AppBase, error) + DeleteApp(ctx context.Context, input app.UninstallAppInput) error + UpsertAppConfiguration(ctx context.Context, input UpsertAppConfigurationInput) error + GetAppConfiguration(ctx context.Context, appID app.AppID) (Configuration, error) +} + +type SyncService interface { + SyncDraftInvoice(ctx context.Context, input SyncDraftInvoiceInput) (billing.StandardInvoice, error) + SyncIssuingInvoice(ctx context.Context, input SyncIssuingInvoiceInput) (billing.StandardInvoice, error) + + HandlePaymentTrigger(ctx context.Context, input HandlePaymentTriggerInput) (billing.StandardInvoice, error) +} diff --git a/app/custominvoicing/service/customerdata.go b/app/custominvoicing/service/customerdata.go new file mode 100644 index 0000000000000000000000000000000000000000..372474c3679c8c9bc5304cbcc05a2a0d5e5516c7 --- /dev/null +++ b/app/custominvoicing/service/customerdata.go @@ -0,0 +1,26 @@ +package service + +import ( + "context" + + appcustominvoicing "github.com/openmeterio/openmeter/openmeter/app/custominvoicing" + "github.com/openmeterio/openmeter/pkg/framework/transaction" +) + +func (s *Service) GetCustomerData(ctx context.Context, input appcustominvoicing.GetAppCustomerDataInput) (appcustominvoicing.CustomerData, error) { + return transaction.Run(ctx, s.adapter, func(ctx context.Context) (appcustominvoicing.CustomerData, error) { + return s.adapter.GetCustomerData(ctx, input) + }) +} + +func (s *Service) UpsertCustomerData(ctx context.Context, input appcustominvoicing.UpsertCustomerDataInput) error { + return transaction.RunWithNoValue(ctx, s.adapter, func(ctx context.Context) error { + return s.adapter.UpsertCustomerData(ctx, input) + }) +} + +func (s *Service) DeleteCustomerData(ctx context.Context, input appcustominvoicing.DeleteAppCustomerDataInput) error { + return transaction.RunWithNoValue(ctx, s.adapter, func(ctx context.Context) error { + return s.adapter.DeleteCustomerData(ctx, input) + }) +} diff --git a/app/custominvoicing/service/factory.go b/app/custominvoicing/service/factory.go new file mode 100644 index 0000000000000000000000000000000000000000..df12d3f0a26669a023bc0340b70db8cb3c48980a --- /dev/null +++ b/app/custominvoicing/service/factory.go @@ -0,0 +1,57 @@ +package service + +import ( + "context" + "fmt" + + "github.com/openmeterio/openmeter/openmeter/app" + appcustominvoicing "github.com/openmeterio/openmeter/openmeter/app/custominvoicing" + "github.com/openmeterio/openmeter/pkg/framework/transaction" +) + +var _ appcustominvoicing.FactoryService = (*Service)(nil) + +func (s *Service) CreateApp(ctx context.Context, input appcustominvoicing.CreateAppInput) (app.AppBase, error) { + if err := input.Validate(); err != nil { + return app.AppBase{}, fmt.Errorf("invalid input: %w", err) + } + + return transaction.Run(ctx, s.adapter, func(ctx context.Context) (app.AppBase, error) { + // Let's create the app first + appBase, err := s.appService.CreateApp(ctx, app.CreateAppInput{ + Namespace: input.Namespace, + Name: input.Name, + Type: app.AppTypeCustomInvoicing, + }) + if err != nil { + return app.AppBase{}, fmt.Errorf("failed to create app: %w", err) + } + + // Let's create the app settings entity + err = s.adapter.UpsertAppConfiguration(ctx, appcustominvoicing.UpsertAppConfigurationInput{ + AppID: app.AppID{ID: appBase.ID, Namespace: appBase.Namespace}, + Configuration: input.Config, + }) + if err != nil { + return app.AppBase{}, fmt.Errorf("failed to create app settings: %w", err) + } + + return appBase, nil + }) +} + +func (s *Service) DeleteApp(ctx context.Context, input app.UninstallAppInput) error { + return transaction.RunWithNoValue(ctx, s.adapter, func(ctx context.Context) error { + return s.adapter.DeleteAppConfiguration(ctx, input) + }) +} + +func (s *Service) UpsertAppConfiguration(ctx context.Context, input appcustominvoicing.UpsertAppConfigurationInput) error { + return transaction.RunWithNoValue(ctx, s.adapter, func(ctx context.Context) error { + return s.adapter.UpsertAppConfiguration(ctx, input) + }) +} + +func (s *Service) GetAppConfiguration(ctx context.Context, appID app.AppID) (appcustominvoicing.Configuration, error) { + return s.adapter.GetAppConfiguration(ctx, appID) +} diff --git a/app/custominvoicing/service/service.go b/app/custominvoicing/service/service.go new file mode 100644 index 0000000000000000000000000000000000000000..7a293ae276cf586e2201c8bd22f22161aa04112d --- /dev/null +++ b/app/custominvoicing/service/service.go @@ -0,0 +1,62 @@ +package service + +import ( + "errors" + "log/slog" + + "github.com/openmeterio/openmeter/openmeter/app" + appcustominvoicing "github.com/openmeterio/openmeter/openmeter/app/custominvoicing" + "github.com/openmeterio/openmeter/openmeter/billing" +) + +var _ appcustominvoicing.Service = (*Service)(nil) + +type Service struct { + adapter appcustominvoicing.Adapter + logger *slog.Logger + + // dependencies + appService app.Service + billingService billing.Service +} + +type Config struct { + Adapter appcustominvoicing.Adapter + Logger *slog.Logger + + AppService app.Service + BillingService billing.Service +} + +func (c Config) Validate() error { + if c.Adapter == nil { + return errors.New("adapter cannot be nil") + } + + if c.Logger == nil { + return errors.New("logger cannot be nil") + } + + if c.AppService == nil { + return errors.New("app service cannot be nil") + } + + if c.BillingService == nil { + return errors.New("billing service cannot be nil") + } + + return nil +} + +func New(config Config) (*Service, error) { + if err := config.Validate(); err != nil { + return nil, err + } + + return &Service{ + adapter: config.Adapter, + logger: config.Logger, + appService: config.AppService, + billingService: config.BillingService, + }, nil +} diff --git a/app/custominvoicing/service/sync.go b/app/custominvoicing/service/sync.go new file mode 100644 index 0000000000000000000000000000000000000000..8e88ba045cb06e286eab2bda82ee979fbf5280df --- /dev/null +++ b/app/custominvoicing/service/sync.go @@ -0,0 +1,117 @@ +package service + +import ( + "context" + "fmt" + "time" + + "github.com/samber/lo" + + "github.com/openmeterio/openmeter/openmeter/app" + appcustominvoicing "github.com/openmeterio/openmeter/openmeter/app/custominvoicing" + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/pkg/clock" + "github.com/openmeterio/openmeter/pkg/framework/transaction" + "github.com/openmeterio/openmeter/pkg/models" +) + +var _ appcustominvoicing.SyncService = (*Service)(nil) + +func (s *Service) SyncDraftInvoice(ctx context.Context, input appcustominvoicing.SyncDraftInvoiceInput) (billing.StandardInvoice, error) { + if err := input.Validate(); err != nil { + return billing.StandardInvoice{}, err + } + + return s.billingService.SyncDraftInvoice(ctx, billing.SyncDraftStandardInvoiceInput{ + InvoiceID: input.InvoiceID, + UpsertInvoiceResults: input.UpsertInvoiceResults, + AdditionalMetadata: map[string]string{ + appcustominvoicing.MetadataKeyDraftSyncedAt: clock.Now().Format(time.RFC3339), + }, + InvoiceValidator: s.ValidateInvoiceApp, + }) +} + +func (s *Service) SyncIssuingInvoice(ctx context.Context, input appcustominvoicing.SyncIssuingInvoiceInput) (billing.StandardInvoice, error) { + if err := input.Validate(); err != nil { + return billing.StandardInvoice{}, err + } + + return s.billingService.SyncIssuingInvoice(ctx, billing.SyncIssuingStandardInvoiceInput{ + InvoiceID: input.InvoiceID, + FinalizeInvoiceResult: input.FinalizeInvoiceResult, + AdditionalMetadata: map[string]string{ + appcustominvoicing.MetadataKeyFinalizedAt: clock.Now().Format(time.RFC3339), + }, + InvoiceValidator: s.ValidateInvoiceApp, + }) +} + +func (s *Service) ValidateInvoiceApp(invoice billing.StandardInvoice) error { + if invoice.Workflow.Apps == nil { + return models.NewGenericValidationError(fmt.Errorf("standard invoice %s has no apps", invoice.ID)) + } + + if invoice.Workflow.Apps.Invoicing == nil { + return models.NewGenericValidationError(fmt.Errorf("invoice %s has no invoicing app", invoice.ID)) + } + + if invoice.Workflow.Apps.Invoicing.GetType() != app.AppTypeCustomInvoicing { + return models.NewGenericValidationError(fmt.Errorf("invoice %s is not managed by the custom invoicing app", invoice.ID)) + } + + return nil +} + +func (s *Service) HandlePaymentTrigger(ctx context.Context, input appcustominvoicing.HandlePaymentTriggerInput) (billing.StandardInvoice, error) { + if err := input.Validate(); err != nil { + return billing.StandardInvoice{}, err + } + + return transaction.Run(ctx, s.adapter, func(ctx context.Context) (billing.StandardInvoice, error) { + invoice, err := s.billingService.GetStandardInvoiceById(ctx, billing.GetStandardInvoiceByIdInput{ + Invoice: input.InvoiceID, + }) + if err != nil { + return billing.StandardInvoice{}, err + } + + if err := s.ValidateInvoiceApp(invoice); err != nil { + return billing.StandardInvoice{}, err + } + + err = s.billingService.TriggerInvoice(ctx, billing.InvoiceTriggerServiceInput{ + InvoiceTriggerInput: billing.InvoiceTriggerInput{ + Invoice: input.InvoiceID, + Trigger: input.Trigger, + }, + AppType: app.AppTypeCustomInvoicing, + Capability: app.CapabilityTypeCollectPayments, + }) + if err != nil { + return billing.StandardInvoice{}, err + } + + invoice, err = s.billingService.GetStandardInvoiceById(ctx, billing.GetStandardInvoiceByIdInput{ + Invoice: input.InvoiceID, + }) + if err != nil { + return billing.StandardInvoice{}, err + } + + if len(invoice.ValidationIssues) > 0 { + criticalIssues := lo.Filter(invoice.ValidationIssues, func(issue billing.ValidationIssue, _ int) bool { + return issue.Severity == billing.ValidationIssueSeverityCritical + }) + + if len(criticalIssues) > 0 { + // Warning: This causes a rollback of the transaction + return billing.StandardInvoice{}, billing.ValidationError{ + Err: criticalIssues.AsError(), + } + } + } + + return invoice, nil + }) +} diff --git a/app/custominvoicing/sync.go b/app/custominvoicing/sync.go new file mode 100644 index 0000000000000000000000000000000000000000..e3a7f8a498668868a7aeb64e054ee94a8bd5acb9 --- /dev/null +++ b/app/custominvoicing/sync.go @@ -0,0 +1,66 @@ +package appcustominvoicing + +import ( + "errors" + "fmt" + + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/pkg/models" +) + +type SyncDraftInvoiceInput struct { + InvoiceID billing.InvoiceID + UpsertInvoiceResults *billing.UpsertStandardInvoiceResult +} + +func (i *SyncDraftInvoiceInput) Validate() error { + var errs []error + + if err := i.InvoiceID.Validate(); err != nil { + errs = append(errs, err) + } + + if i.UpsertInvoiceResults == nil { + errs = append(errs, fmt.Errorf("upsert invoice results are required")) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +type SyncIssuingInvoiceInput struct { + InvoiceID billing.InvoiceID + FinalizeInvoiceResult *billing.FinalizeStandardInvoiceResult +} + +func (i *SyncIssuingInvoiceInput) Validate() error { + var errs []error + + if err := i.InvoiceID.Validate(); err != nil { + errs = append(errs, err) + } + + if i.FinalizeInvoiceResult == nil { + errs = append(errs, fmt.Errorf("finalize invoice result is required")) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +type HandlePaymentTriggerInput struct { + InvoiceID billing.InvoiceID + Trigger billing.InvoiceTrigger +} + +func (i *HandlePaymentTriggerInput) Validate() error { + var errs []error + + if err := i.InvoiceID.Validate(); err != nil { + errs = append(errs, err) + } + + if i.Trigger == "" { + errs = append(errs, fmt.Errorf("trigger is required")) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} diff --git a/app/defaults.go b/app/defaults.go new file mode 100644 index 0000000000000000000000000000000000000000..89b65ec851fd1eb5da9494ded380c23b64a3168e --- /dev/null +++ b/app/defaults.go @@ -0,0 +1,6 @@ +package app + +const ( + DefaultPageNumber = 1 + DefaultPageSize = 100 +) diff --git a/app/errors.go b/app/errors.go new file mode 100644 index 0000000000000000000000000000000000000000..29b68fe6ad54e40fd0668b8e2ff1d9c05f6c45dc --- /dev/null +++ b/app/errors.go @@ -0,0 +1,226 @@ +package app + +import ( + "errors" + "fmt" + + "github.com/openmeterio/openmeter/openmeter/customer" + "github.com/openmeterio/openmeter/pkg/models" +) + +// AppNotFoundError +func NewAppNotFoundError(appID AppID) *AppNotFoundError { + return &AppNotFoundError{ + err: models.NewGenericNotFoundError( + fmt.Errorf("app with id %s not found in %s namespace", appID.ID, appID.Namespace), + ), + } +} + +var _ models.GenericError = AppNotFoundError{} + +type AppNotFoundError struct { + err error +} + +func (e AppNotFoundError) Error() string { + return e.err.Error() +} + +func (e AppNotFoundError) Unwrap() error { + return e.err +} + +// IsAppNotFoundError returns true if the error is a AppNotFoundError. +func IsAppNotFoundError(err error) bool { + if err == nil { + return false + } + + var e *AppNotFoundError + + return errors.As(err, &e) +} + +// AppDefaultNotFoundError +func NewAppDefaultNotFoundError(appType AppType, namespace string) *AppDefaultNotFoundError { + return &AppDefaultNotFoundError{ + err: models.NewGenericNotFoundError( + fmt.Errorf("there is no default app for %s type in %s namespace", appType, namespace), + ), + } +} + +var _ models.GenericError = AppDefaultNotFoundError{} + +type AppDefaultNotFoundError struct { + err error +} + +func (e AppDefaultNotFoundError) Error() string { + return e.err.Error() +} + +func (e AppDefaultNotFoundError) Unwrap() error { + return e.err +} + +func IsAppDefaultNotFoundError(err error) bool { + if err == nil { + return false + } + + var e *AppDefaultNotFoundError + + return errors.As(err, &e) +} + +// AppProviderAuthenticationError +func NewAppProviderAuthenticationError(appID *AppID, namespace string, providerError error) *AppProviderAuthenticationError { + var err error + + if appID == nil { + err = fmt.Errorf("provider authentication error for app in %s namespace: %w", namespace, providerError) + } else { + err = fmt.Errorf("provider authentication error for app %s: %w", appID.ID, providerError) + } + + return &AppProviderAuthenticationError{ + err: models.NewGenericUnauthorizedError(err), + } +} + +var _ models.GenericError = (*AppProviderAuthenticationError)(nil) + +type AppProviderAuthenticationError struct { + err error +} + +func (e AppProviderAuthenticationError) Error() string { + return e.err.Error() +} + +func (e AppProviderAuthenticationError) Unwrap() error { + return e.err +} + +func IsAppProviderAuthenticationError(err error) bool { + if err == nil { + return false + } + + var e *AppProviderAuthenticationError + + return errors.As(err, &e) +} + +// AppProviderError +func NewAppProviderError(appID *AppID, namespace string, providerError error) *AppProviderError { + var err error + + if appID == nil { + err = fmt.Errorf("provider error for app in %s namespace: %w", namespace, providerError) + } else { + err = fmt.Errorf("provider error for app %s: %w", appID.ID, providerError) + } + + return &AppProviderError{ + err: models.NewGenericPreConditionFailedError(err), + } +} + +var _ models.GenericError = (*AppProviderError)(nil) + +type AppProviderError struct { + err error +} + +func (e AppProviderError) Error() string { + return e.err.Error() +} + +func (e AppProviderError) Unwrap() error { + return e.err +} + +func IsAppProviderError(err error) bool { + if err == nil { + return false + } + + var e *AppProviderError + + return errors.As(err, &e) +} + +// AppProviderPreConditionError +var _ models.GenericError = (*AppProviderPreConditionError)(nil) + +func NewAppProviderPreConditionError(appID AppID, condition string) *AppProviderPreConditionError { + return &AppProviderPreConditionError{ + err: models.NewGenericPreConditionFailedError( + fmt.Errorf("app does not meet condition for %s: %s", appID.ID, condition), + ), + } +} + +type AppProviderPreConditionError struct { + err error +} + +func (e AppProviderPreConditionError) Error() string { + return e.err.Error() +} + +func (e AppProviderPreConditionError) Unwrap() error { + return e.err +} + +func IsAppProviderPreConditionError(err error) bool { + if err == nil { + return false + } + + var e *AppProviderPreConditionError + + return errors.As(err, &e) +} + +// AppCustomerPreConditionError +func NewAppCustomerPreConditionError(appID AppID, appType AppType, customerID *customer.CustomerID, condition string) *AppCustomerPreConditionError { + var err error + + if customerID == nil { + err = fmt.Errorf("customer does not meet condition for %s app type with id %s in namespace %s: %s", appType, appID.ID, appID.Namespace, condition) + } else { + err = fmt.Errorf("customer with id %s does not meet condition %s for %s app type with id %s in namespace %s", customerID.ID, condition, appType, appID.ID, appID.Namespace) + } + + return &AppCustomerPreConditionError{ + err: models.NewGenericPreConditionFailedError(err), + } +} + +var _ models.GenericError = (*AppCustomerPreConditionError)(nil) + +type AppCustomerPreConditionError struct { + err error +} + +func (e AppCustomerPreConditionError) Error() string { + return e.err.Error() +} + +func (e AppCustomerPreConditionError) Unwrap() error { + return e.err +} + +func IsAppCustomerPreConditionError(err error) bool { + if err == nil { + return false + } + + var e *AppCustomerPreConditionError + + return errors.As(err, &e) +} diff --git a/app/event.go b/app/event.go new file mode 100644 index 0000000000000000000000000000000000000000..6134a04995db27ae3e80543160154f9153dcb8b1 --- /dev/null +++ b/app/event.go @@ -0,0 +1,214 @@ +package app + +import ( + "context" + "encoding/json" + "fmt" + "reflect" + + "github.com/oklog/ulid/v2" + + "github.com/openmeterio/openmeter/openmeter/event/metadata" + "github.com/openmeterio/openmeter/openmeter/session" +) + +// EventAppParser should be implemented by the app's meta contents to be parsable from an EventApp +type EventAppParser interface { + FromEventAppData(EventApp) error +} + +type EventAppData map[string]any + +// NewEventAppData creates a new EventAppData from a given value +// TODO[later]: we need to refactor apps to be able to handle serialization more gracefully, e.g. having a proper +// union type for app instead of the interface +func NewEventAppData(v any) (EventAppData, error) { + jsonBytes, err := json.Marshal(v) + if err != nil { + return nil, err + } + + var data EventAppData + if err := json.Unmarshal(jsonBytes, &data); err != nil { + return nil, err + } + + return data, nil +} + +// ParseInto parses the EventAppData into a given value, the value must be a pointer +func (e EventAppData) ParseInto(v any) error { + if rv := reflect.ValueOf(v); rv.Kind() != reflect.Pointer || rv.IsNil() { + return fmt.Errorf("target must be a non-nil pointer") + } + + jsonBytes, err := json.Marshal(e) + if err != nil { + return err + } + + if err := json.Unmarshal(jsonBytes, v); err != nil { + return err + } + + return nil +} + +type EventApp struct { + AppBase + AppData EventAppData `json:"appData"` +} + +func NewEventApp(app App) (EventApp, error) { + appBase := app.GetAppBase() + + appData, err := app.GetEventAppData() + if err != nil { + return EventApp{}, err + } + + return EventApp{ + AppBase: appBase, + AppData: appData, + }, nil +} + +const ( + AppEventSubsystem metadata.EventSubsystem = "app" + AppCreateEventName metadata.EventName = "app.created" + AppUpdateEventName metadata.EventName = "app.updated" + AppDeleteEventName metadata.EventName = "app.deleted" +) + +// NewAppCreateEvent creates a new app create event +// TODO[later]: We should use eventApp instead of AppBase, but the creation flow is somewhat tricky to change as the flow +// is that the app calls the AppCreate without having the configuration presisted. +func NewAppCreateEvent(ctx context.Context, appBase AppBase) AppCreateEvent { + return AppCreateEvent{ + AppBase: appBase, + UserID: session.GetSessionUserID(ctx), + } +} + +// AppCreateEvent is an event that is emitted when an app is created +type AppCreateEvent struct { + AppBase + UserID *string `json:"userId,omitempty"` +} + +func (e AppCreateEvent) EventName() string { + return metadata.GetEventName(metadata.EventType{ + Subsystem: AppEventSubsystem, + Name: AppCreateEventName, + Version: "v1", + }) +} + +func (e AppCreateEvent) EventMetadata() metadata.EventMetadata { + resourcePath := metadata.ComposeResourcePath(e.AppBase.Namespace, metadata.EntityApp, e.AppBase.ID) + + return metadata.EventMetadata{ + ID: ulid.Make().String(), + Source: resourcePath, + Subject: resourcePath, + Time: e.AppBase.CreatedAt, + } +} + +func (e AppCreateEvent) Validate() error { + if e.AppBase.ID == "" { + return fmt.Errorf("app base is required") + } + return nil +} + +// NewAppUpdateEvent creates a new app update event +func NewAppUpdateEvent(ctx context.Context, app App) (AppUpdateEvent, error) { + eventApp, err := NewEventApp(app) + if err != nil { + return AppUpdateEvent{}, err + } + + return AppUpdateEvent{ + EventApp: eventApp, + UserID: session.GetSessionUserID(ctx), + }, nil +} + +// AppUpdateEvent is an event that is emitted when an app is updated +type AppUpdateEvent struct { + EventApp + UserID *string `json:"userId,omitempty"` +} + +func (e AppUpdateEvent) EventName() string { + return metadata.GetEventName(metadata.EventType{ + Subsystem: AppEventSubsystem, + Name: AppUpdateEventName, + Version: "v2", + }) +} + +func (e AppUpdateEvent) EventMetadata() metadata.EventMetadata { + appBase := e.AppBase.GetAppBase() + resourcePath := metadata.ComposeResourcePath(appBase.Namespace, metadata.EntityApp, appBase.ID) + + return metadata.EventMetadata{ + ID: ulid.Make().String(), + Source: resourcePath, + Subject: resourcePath, + Time: appBase.UpdatedAt, + } +} + +func (e AppUpdateEvent) Validate() error { + if e.AppBase.ID == "" { + return fmt.Errorf("app base is required") + } + + return nil +} + +// NewAppDeleteEvent creates a new app delete event +func NewAppDeleteEvent(ctx context.Context, app AppBase, appData EventAppData) AppDeleteEvent { + return AppDeleteEvent{ + EventApp: EventApp{ + AppBase: app, + AppData: appData, + }, + UserID: session.GetSessionUserID(ctx), + } +} + +// AppDeleteEvent is an event that is emitted when an app is deleted +type AppDeleteEvent struct { + EventApp + UserID *string `json:"userId,omitempty"` +} + +func (e AppDeleteEvent) EventName() string { + return metadata.GetEventName(metadata.EventType{ + Subsystem: AppEventSubsystem, + Name: AppDeleteEventName, + Version: "v2", + }) +} + +func (e AppDeleteEvent) EventMetadata() metadata.EventMetadata { + resourcePath := metadata.ComposeResourcePath(e.AppBase.Namespace, metadata.EntityApp, e.AppBase.ID) + + return metadata.EventMetadata{ + ID: ulid.Make().String(), + Source: resourcePath, + Subject: resourcePath, + Time: *e.AppBase.DeletedAt, + } +} + +func (e AppDeleteEvent) Validate() error { + if e.AppBase.ID == "" { + return fmt.Errorf("app base is required") + } + + return nil +} diff --git a/app/events.go b/app/events.go new file mode 100644 index 0000000000000000000000000000000000000000..ecb073c20998eb61c4e342231850542dee047549 --- /dev/null +++ b/app/events.go @@ -0,0 +1,70 @@ +package app + +import ( + "fmt" + + "github.com/openmeterio/openmeter/openmeter/customer" + "github.com/openmeterio/openmeter/openmeter/event/metadata" + "github.com/openmeterio/openmeter/openmeter/watermill/marshaler" +) + +const ( + EventSubsystemAppCustomer = "app_customer" +) + +type PaymentSetupAppData interface { + Validate() error +} + +type CustomerPaymentSetupResult struct { + Metadata map[string]string `json:"metadata,omitempty"` + // Add additional fields here as needed. Keep in mind that this event is app neutral, so please create abstractions on top of app specific data if needed. + // The consumer can always query the specific app data. (If this does not cut it on the long run, we need to have per app event types, which is an overkill) +} + +func (r CustomerPaymentSetupResult) Validate() error { + return nil +} + +type CustomerPaymentSetupSucceededEvent struct { + App AppBase `json:"app"` + Customer customer.CustomerID `json:"customer"` + Result CustomerPaymentSetupResult `json:"result"` +} + +var ( + _ marshaler.Event = CustomerPaymentSetupSucceededEvent{} + + appCustomerDefaultPaymentMethodChangedEventName = metadata.GetEventName(metadata.EventType{ + Subsystem: EventSubsystemAppCustomer, + Name: "payment_setup_succeeded", + Version: "v2", + }) +) + +func (e CustomerPaymentSetupSucceededEvent) Validate() error { + if err := e.App.Validate(); err != nil { + return fmt.Errorf("app: %w", err) + } + + if err := e.Customer.Validate(); err != nil { + return fmt.Errorf("customer: %w", err) + } + + if err := e.Result.Validate(); err != nil { + return fmt.Errorf("result: %w", err) + } + + return nil +} + +func (e CustomerPaymentSetupSucceededEvent) EventName() string { + return appCustomerDefaultPaymentMethodChangedEventName +} + +func (e CustomerPaymentSetupSucceededEvent) EventMetadata() metadata.EventMetadata { + return metadata.EventMetadata{ + Source: metadata.ComposeResourcePath(e.App.Namespace, metadata.EntityApp, e.App.ID), + Subject: metadata.ComposeResourcePath(e.Customer.Namespace, metadata.EntityCustomer, e.Customer.ID), + } +} diff --git a/app/httpdriver/app.go b/app/httpdriver/app.go new file mode 100644 index 0000000000000000000000000000000000000000..bc5f724ba49bf721d9bbe16772dfd7a78c0f9471 --- /dev/null +++ b/app/httpdriver/app.go @@ -0,0 +1,261 @@ +package httpdriver + +import ( + "context" + "fmt" + "net/http" + + "github.com/samber/lo" + + "github.com/openmeterio/openmeter/api" + "github.com/openmeterio/openmeter/openmeter/app" + appcustominvoicing "github.com/openmeterio/openmeter/openmeter/app/custominvoicing" + appsandbox "github.com/openmeterio/openmeter/openmeter/app/sandbox" + appstripe "github.com/openmeterio/openmeter/openmeter/app/stripe" + "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" +) + +// ListAppsHandler is a handler for listing apps +type ( + ListAppsRequest = app.ListAppInput + ListAppsResponse = api.AppPaginatedResponse + ListAppsParams = api.ListAppsParams + ListAppsHandler httptransport.HandlerWithArgs[ListAppsRequest, ListAppsResponse, ListAppsParams] +) + +// ListApps returns a handler for listing apps +func (h *handler) ListApps() ListAppsHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, params ListAppsParams) (ListAppsRequest, error) { + // Resolve namespace + namespace, err := h.resolveNamespace(ctx) + if err != nil { + return ListAppsRequest{}, fmt.Errorf("failed to resolve namespace: %w", err) + } + + return ListAppsRequest{ + Namespace: namespace, + Page: pagination.Page{ + PageSize: lo.FromPtrOr(params.PageSize, app.DefaultPageSize), + PageNumber: lo.FromPtrOr(params.Page, app.DefaultPageNumber), + }, + }, nil + }, + func(ctx context.Context, request ListAppsRequest) (ListAppsResponse, error) { + result, err := h.service.ListApps(ctx, request) + if err != nil { + return ListAppsResponse{}, fmt.Errorf("failed to list apps: %w", err) + } + + items := make([]api.App, 0, len(result.Items)) + for _, item := range result.Items { + app, err := MapAppToAPI(item) + if err != nil { + return ListAppsResponse{}, fmt.Errorf("failed to map app to api: %w", err) + } + + items = append(items, app) + } + + return ListAppsResponse{ + Page: result.Page.PageNumber, + PageSize: result.Page.PageSize, + TotalCount: result.TotalCount, + Items: items, + }, nil + }, + commonhttp.JSONResponseEncoderWithStatus[ListAppsResponse](http.StatusOK), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("listApps"), + )..., + ) +} + +// GetAppHandler is a handler to get an app by id +type ( + GetAppRequest = app.GetAppInput + GetAppResponse = api.App + GetAppHandler httptransport.HandlerWithArgs[GetAppRequest, GetAppResponse, string] +) + +// GetApp returns an app handler +func (h *handler) GetApp() GetAppHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, appId string) (GetAppRequest, error) { + // Resolve namespace + namespace, err := h.resolveNamespace(ctx) + if err != nil { + return GetAppRequest{}, fmt.Errorf("failed to resolve namespace: %w", err) + } + + return GetAppRequest{ + Namespace: namespace, + ID: appId, + }, nil + }, + func(ctx context.Context, request GetAppRequest) (GetAppResponse, error) { + app, err := h.service.GetApp(ctx, request) + if err != nil { + return GetAppResponse{}, fmt.Errorf("failed to get app: %w", err) + } + + return MapAppToAPI(app) + }, + commonhttp.JSONResponseEncoderWithStatus[GetAppResponse](http.StatusOK), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("getApp"), + )..., + ) +} + +// UpdateAppHandler is a handler to update an app +type ( + UpdateAppRequest = app.UpdateAppInput + UpdateAppResponse = api.App + UpdateAppHandler httptransport.HandlerWithArgs[UpdateAppRequest, UpdateAppResponse, string] +) + +// UpdateApp returns an app handler +func (h *handler) UpdateApp() UpdateAppHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, appId string) (UpdateAppRequest, error) { + // Resolve namespace + namespace, err := h.resolveNamespace(ctx) + if err != nil { + return UpdateAppRequest{}, fmt.Errorf("failed to resolve namespace: %w", err) + } + + var body api.UpdateAppJSONRequestBody + if err := commonhttp.JSONRequestBodyDecoder(r, &body); err != nil { + return UpdateAppRequest{}, fmt.Errorf("field to decode upsert customer data request: %w", err) + } + + updateType, err := body.Discriminator() + if err != nil { + return UpdateAppRequest{}, models.NewGenericValidationError(fmt.Errorf("failed to get update type: %w", err)) + } + + switch updateType { + case string(app.AppTypeStripe): + payload, err := body.AsStripeAppReplaceUpdate() + if err != nil { + return UpdateAppRequest{}, fmt.Errorf("failed to get stripe app replace update: %w", err) + } + + return UpdateAppRequest{ + AppID: app.AppID{ + ID: appId, + Namespace: namespace, + }, + Name: payload.Name, + Description: payload.Description, + Metadata: payload.Metadata, + AppConfigUpdate: appstripe.Configuration{ + SecretAPIKey: payload.SecretAPIKey, + }, + }, nil + + case string(app.AppTypeSandbox): + payload, err := body.AsSandboxAppReplaceUpdate() + if err != nil { + return UpdateAppRequest{}, fmt.Errorf("failed to get sandbox app replace update: %w", err) + } + + return UpdateAppRequest{ + AppID: app.AppID{ + ID: appId, + Namespace: namespace, + }, + Name: payload.Name, + Description: payload.Description, + Metadata: payload.Metadata, + AppConfigUpdate: appsandbox.Configuration{}, + }, nil + case string(app.AppTypeCustomInvoicing): + payload, err := body.AsCustomInvoicingAppReplaceUpdate() + if err != nil { + return UpdateAppRequest{}, fmt.Errorf("failed to get custom invoicing app replace update: %w", err) + } + + return UpdateAppRequest{ + AppID: app.AppID{ + ID: appId, + Namespace: namespace, + }, + Name: payload.Name, + Description: payload.Description, + Metadata: payload.Metadata, + AppConfigUpdate: appcustominvoicing.Configuration{ + EnableDraftSyncHook: payload.EnableDraftSyncHook, + EnableIssuingSyncHook: payload.EnableIssuingSyncHook, + }, + }, nil + default: + return UpdateAppRequest{}, models.NewGenericValidationError(fmt.Errorf("invalid app type: %s", updateType)) + } + }, + func(ctx context.Context, request UpdateAppRequest) (UpdateAppResponse, error) { + app, err := h.service.UpdateApp(ctx, request) + if err != nil { + return UpdateAppResponse{}, fmt.Errorf("failed to update app: %w", err) + } + + return MapAppToAPI(app) + }, + commonhttp.JSONResponseEncoderWithStatus[UpdateAppResponse](http.StatusOK), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("updateApp"), + )..., + ) +} + +// UninstallAppHandler is a handler to uninstalls an app by id +type ( + UninstallAppRequest = app.UninstallAppInput + UninstallAppResponse = interface{} + UninstallAppHandler httptransport.HandlerWithArgs[UninstallAppRequest, UninstallAppResponse, string] +) + +// UninstallApp uninstalls an app +func (h *handler) UninstallApp() UninstallAppHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, appId string) (UninstallAppRequest, error) { + // Resolve namespace + namespace, err := h.resolveNamespace(ctx) + if err != nil { + return UninstallAppRequest{}, fmt.Errorf("failed to resolve namespace: %w", err) + } + + return UninstallAppRequest{ + Namespace: namespace, + ID: appId, + }, nil + }, + func(ctx context.Context, request UninstallAppRequest) (UninstallAppResponse, error) { + // Check if the app is not used by any billing profile + + if err := h.billingService.IsAppUsed(ctx, request); err != nil { + return nil, err + } + + // Uninstall app + err := h.service.UninstallApp(ctx, request) + if err != nil { + return nil, fmt.Errorf("failed to uninstall app: %w", err) + } + + return nil, nil + }, + commonhttp.EmptyResponseEncoder[UninstallAppResponse](http.StatusNoContent), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("uninstallApp"), + )..., + ) +} diff --git a/app/httpdriver/customer.go b/app/httpdriver/customer.go new file mode 100644 index 0000000000000000000000000000000000000000..5a29b9c68f4345be84cfd64dee7354a853259a62 --- /dev/null +++ b/app/httpdriver/customer.go @@ -0,0 +1,421 @@ +package httpdriver + +import ( + "context" + "fmt" + "net/http" + + "github.com/samber/lo" + + "github.com/openmeterio/openmeter/api" + "github.com/openmeterio/openmeter/openmeter/app" + appcustominvoicing "github.com/openmeterio/openmeter/openmeter/app/custominvoicing" + appsandbox "github.com/openmeterio/openmeter/openmeter/app/sandbox" + appstripe "github.com/openmeterio/openmeter/openmeter/app/stripe" + "github.com/openmeterio/openmeter/openmeter/billing" + "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/models" + "github.com/openmeterio/openmeter/pkg/pagination" +) + +type ( + ListCustomerDataRequest = app.ListCustomerInput + ListCustomerDataResponse = api.CustomerAppDataPaginatedResponse + ListCustomerDataHandler httptransport.HandlerWithArgs[ListCustomerDataRequest, ListCustomerDataResponse, ListCustomerDataParams] +) + +type ListCustomerDataParams struct { + api.ListCustomerAppDataParams + CustomerIdOrKey string +} + +// ListCustomerData returns a handler for listing customers app data. +func (h *handler) ListCustomerData() ListCustomerDataHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, params ListCustomerDataParams) (ListCustomerDataRequest, error) { + ns, err := h.resolveNamespace(ctx) + if err != nil { + return ListCustomerDataRequest{}, err + } + + // Get the customer + cus, err := h.customerService.GetCustomer(ctx, customer.GetCustomerInput{ + CustomerIDOrKey: &customer.CustomerIDOrKey{ + IDOrKey: params.CustomerIdOrKey, + Namespace: ns, + }, + }) + if err != nil { + return ListCustomerDataRequest{}, err + } + + if cus != nil && cus.IsDeleted() { + return ListCustomerDataRequest{}, + models.NewGenericPreConditionFailedError( + fmt.Errorf("customer is deleted [namespace=%s customer.id=%s]", cus.Namespace, cus.ID), + ) + } + + req := ListCustomerDataRequest{ + CustomerID: cus.GetID(), + + // Pagination + Page: pagination.Page{ + PageSize: lo.FromPtrOr(params.PageSize, customer.DefaultPageSize), + PageNumber: lo.FromPtrOr(params.Page, customer.DefaultPageNumber), + }, + } + + if params.Type != nil { + req.Type = lo.ToPtr(app.AppType(*params.Type)) + } + + return req, nil + }, + func(ctx context.Context, request ListCustomerDataRequest) (ListCustomerDataResponse, error) { + resp, err := h.service.ListCustomerData(ctx, request) + if err != nil { + return ListCustomerDataResponse{}, fmt.Errorf("failed to list customers: %w", err) + } + + items := make([]api.CustomerAppData, 0, len(resp.Items)) + + for _, customerApp := range resp.Items { + item, err := h.toAPICustomerAppData(customerApp) + if err != nil { + return ListCustomerDataResponse{}, fmt.Errorf("failed to cast app customer data: %w", err) + } + + items = append(items, item) + } + + return ListCustomerDataResponse{ + Items: items, + Page: resp.Page.PageNumber, + PageSize: resp.Page.PageSize, + TotalCount: resp.TotalCount, + }, nil + }, + commonhttp.JSONResponseEncoderWithStatus[ListCustomerDataResponse](http.StatusOK), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("listCustomerData"), + )..., + ) +} + +type UpsertCustomerDataRequest struct { + CustomerId customer.CustomerID + Data []api.CustomerAppData +} + +type UpsertCustomerDataParams struct { + CustomerIdOrKey string +} + +type ( + UpsertCustomerDataResponse = interface{} + UpsertCustomerDataHandler httptransport.HandlerWithArgs[UpsertCustomerDataRequest, UpsertCustomerDataResponse, UpsertCustomerDataParams] +) + +// UpsertCustomerData returns a new httptransport.Handler for creating a customer. +func (h *handler) UpsertCustomerData() UpsertCustomerDataHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, params UpsertCustomerDataParams) (UpsertCustomerDataRequest, error) { + ns, err := h.resolveNamespace(ctx) + if err != nil { + return UpsertCustomerDataRequest{}, err + } + + // Get the customer and ensure we do not update customer data if the customer is already deleted + cus, err := h.customerService.GetCustomer(ctx, customer.GetCustomerInput{ + CustomerIDOrKey: &customer.CustomerIDOrKey{ + IDOrKey: params.CustomerIdOrKey, + Namespace: ns, + }, + }) + if err != nil { + return UpsertCustomerDataRequest{}, err + } + + if cus != nil && cus.IsDeleted() { + return UpsertCustomerDataRequest{}, + models.NewGenericPreConditionFailedError( + fmt.Errorf("customer is deleted [namespace=%s customer.id=%s]", cus.Namespace, cus.ID), + ) + } + + var body []api.CustomerAppData + + if err := commonhttp.JSONRequestBodyDecoder(r, &body); err != nil { + return UpsertCustomerDataRequest{}, + fmt.Errorf("field to decode upsert customer data request: %w", err) + } + + return UpsertCustomerDataRequest{ + CustomerId: cus.GetID(), + Data: body, + }, nil + }, + func(ctx context.Context, req UpsertCustomerDataRequest) (UpsertCustomerDataResponse, error) { + for _, apiCustomerData := range req.Data { + customerApp, customerData, err := h.toCustomerData(ctx, req.CustomerId, apiCustomerData) + if err != nil { + return nil, err + } + + err = customerApp.UpsertCustomerData(ctx, app.UpsertAppInstanceCustomerDataInput{ + CustomerID: req.CustomerId, + Data: customerData, + }) + if err != nil { + return nil, err + } + } + + return nil, nil + }, + commonhttp.EmptyResponseEncoder[UpsertCustomerDataResponse](http.StatusOK), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("upsertCustomerData"), + )..., + ) +} + +type DeleteCustomerDataParams struct { + CustomerIdOrKey string + AppId string +} + +type DeleteCustomerDataRequest struct { + AppID app.AppID + CustomerID customer.CustomerID +} + +type ( + DeleteCustomerDataResponse = interface{} + DeleteCustomerDataHandler httptransport.HandlerWithArgs[DeleteCustomerDataRequest, DeleteCustomerDataResponse, DeleteCustomerDataParams] +) + +// DeleteCustomerData returns a handler for deleting a customer data. +func (h *handler) DeleteCustomerData() DeleteCustomerDataHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, params DeleteCustomerDataParams) (DeleteCustomerDataRequest, error) { + ns, err := h.resolveNamespace(ctx) + if err != nil { + return DeleteCustomerDataRequest{}, err + } + + // Get the customer + cus, err := h.customerService.GetCustomer(ctx, customer.GetCustomerInput{ + CustomerIDOrKey: &customer.CustomerIDOrKey{ + IDOrKey: params.CustomerIdOrKey, + Namespace: ns, + }, + }) + if err != nil { + return DeleteCustomerDataRequest{}, err + } + + if cus != nil && cus.IsDeleted() { + return DeleteCustomerDataRequest{}, + models.NewGenericPreConditionFailedError( + fmt.Errorf("customer is deleted [namespace=%s customer.id=%s]", cus.Namespace, cus.ID), + ) + } + + return DeleteCustomerDataRequest{ + CustomerID: cus.GetID(), + AppID: app.AppID{ + Namespace: ns, + ID: params.AppId, + }, + }, nil + }, + func(ctx context.Context, request DeleteCustomerDataRequest) (DeleteCustomerDataResponse, error) { + // Get app + existingApp, err := h.service.GetApp(ctx, request.AppID) + if err != nil { + return nil, err + } + + // Delete customer data + err = existingApp.DeleteCustomerData(ctx, app.DeleteAppInstanceCustomerDataInput{ + CustomerID: request.CustomerID, + }) + if err != nil { + return nil, err + } + + return nil, nil + }, + commonhttp.EmptyResponseEncoder[DeleteCustomerDataResponse](http.StatusNoContent), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("deleteCustomerData"), + )..., + ) +} + +// toCustomerData converts an API CustomerAppData to a CustomerData model +func (h *handler) toCustomerData(ctx context.Context, customerID customer.CustomerID, apiApp api.CustomerAppData) (app.App, app.CustomerData, error) { + // Get app type + appType, err := apiApp.Discriminator() + if err != nil { + return nil, nil, fmt.Errorf("error getting app type: %w", err) + } + + switch appType { + // Sandbox app + case string(app.AppTypeSandbox): + // Parse as sandbox app + apiSandboxCustomerData, err := apiApp.AsSandboxCustomerAppData() + if err != nil { + return nil, nil, fmt.Errorf("error converting to stripe app: %w", err) + } + + // Resolve app + resolvedApp, err := h.resolveCustomerApp(ctx, customerID, app.AppTypeSandbox, apiSandboxCustomerData.Id) + if err != nil { + return nil, nil, fmt.Errorf("error resolving sandbox app: %w", err) + } + + // Create customer data + sandboxCustomerData := appsandbox.CustomerData{} + + return resolvedApp, sandboxCustomerData, nil + + // Stripe app + case string(app.AppTypeStripe): + // Parse as stripe app + apiStripeCustomerData, err := apiApp.AsStripeCustomerAppData() + if err != nil { + return nil, nil, fmt.Errorf("error converting to stripe app: %w", err) + } + + // Resolve app + resolvedApp, err := h.resolveCustomerApp(ctx, customerID, app.AppTypeStripe, apiStripeCustomerData.Id) + if err != nil { + return nil, nil, fmt.Errorf("error resolving stripe app: %w", err) + } + + // Create customer data + stripeCustomerData := fromAPIAppStripeCustomerData(apiStripeCustomerData) + + return resolvedApp, stripeCustomerData, nil + case string(app.AppTypeCustomInvoicing): + // Parse as custom invoicing app + apiCustomInvoicingCustomerData, err := apiApp.AsCustomInvoicingCustomerAppData() + if err != nil { + return nil, nil, fmt.Errorf("error converting to custom invoicing app: %w", err) + } + + // Resolve app + resolvedApp, err := h.resolveCustomerApp(ctx, customerID, app.AppTypeCustomInvoicing, apiCustomInvoicingCustomerData.Id) + if err != nil { + return nil, nil, fmt.Errorf("error resolving custom invoicing app: %w", err) + } + + // Create customer data + customInvoicingCustomerData := appcustominvoicing.CustomerData{ + Metadata: lo.FromPtrOr(apiCustomInvoicingCustomerData.Metadata, map[string]string{}), + } + + return resolvedApp, customInvoicingCustomerData, nil + } + + return nil, nil, fmt.Errorf("unsupported app type: %s", appType) +} + +// resolveCustomerApp resolves a customer app based on the app type or app ID. +func (h *handler) resolveCustomerApp(ctx context.Context, customerID customer.CustomerID, appType app.AppType, appID *string) (app.App, error) { + var resolvedApp app.App + var err error + + // Get app ID from API data or get default app for billing profile + if appID != nil { + return h.service.GetApp(ctx, app.GetAppInput{ + Namespace: customerID.Namespace, + ID: *appID, + }) + } + + // Get the customer app by type + resolvedApp, err = h.billingService.GetCustomerApp(ctx, billing.GetCustomerAppInput{ + CustomerID: customerID, + AppType: appType, + }) + if err != nil { + return nil, fmt.Errorf("error getting customer app: %w", err) + } + + return resolvedApp, nil +} + +// toAPICustomerAppData converts a CustomerApp to an API CustomerAppData +func (h *handler) toAPICustomerAppData(a app.CustomerApp) (api.CustomerAppData, error) { + apiCustomerAppData := api.CustomerAppData{} + appId := a.App.GetID().ID + + switch customerAppData := a.CustomerData.(type) { + case appstripe.CustomerData: + stripeApp, ok := a.App.(appstripe.App) + if !ok { + return apiCustomerAppData, fmt.Errorf("error casting app to stripe app") + } + + // Convert to API stripe customer app data + apiStripeCustomerAppData := ToAPIStripeCustomerAppData(customerAppData, stripeApp) + + // Convert to API customer app data + err := apiCustomerAppData.FromStripeCustomerAppData(apiStripeCustomerAppData) + if err != nil { + return apiCustomerAppData, fmt.Errorf("error converting to stripe customer app: %w", err) + } + + case appsandbox.CustomerData: + sandboxApp, ok := a.App.(appsandbox.App) + if !ok { + return apiCustomerAppData, fmt.Errorf("error casting app to sandbox app") + } + + apiApp := mapSandboxAppToAPI(sandboxApp.Meta) + + apiSandboxCustomerAppData := api.SandboxCustomerAppData{ + Id: &appId, + Type: api.SandboxCustomerAppDataTypeSandbox, + App: &apiApp, + } + + err := apiCustomerAppData.FromSandboxCustomerAppData(apiSandboxCustomerAppData) + if err != nil { + return apiCustomerAppData, fmt.Errorf("error converting to sandbox customer app: %w", err) + } + + case appcustominvoicing.CustomerData: + customInvoicingApp, ok := a.App.(appcustominvoicing.App) + if !ok { + return apiCustomerAppData, fmt.Errorf("error casting app to custom invoicing app") + } + + apiApp := mapCustomInvoicingAppToAPI(customInvoicingApp.Meta) + + apiCustomInvoicingCustomerAppData := api.CustomInvoicingCustomerAppData{ + Id: &appId, + Type: api.CustomInvoicingCustomerAppDataTypeCustomInvoicing, + App: &apiApp, + } + + err := apiCustomerAppData.FromCustomInvoicingCustomerAppData(apiCustomInvoicingCustomerAppData) + if err != nil { + return apiCustomerAppData, fmt.Errorf("error converting to custom invoicing customer app: %w", err) + } + default: + return apiCustomerAppData, fmt.Errorf("unsupported customer data for app: %s", appId) + } + + return apiCustomerAppData, nil +} diff --git a/app/httpdriver/handler.go b/app/httpdriver/handler.go new file mode 100644 index 0000000000000000000000000000000000000000..f7a6a05594538a79fc50d434700c4f1b677c1f83 --- /dev/null +++ b/app/httpdriver/handler.go @@ -0,0 +1,80 @@ +package httpdriver + +import ( + "context" + "errors" + "log/slog" + "net/http" + + "github.com/openmeterio/openmeter/openmeter/app" + stripeapp "github.com/openmeterio/openmeter/openmeter/app/stripe" + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/customer" + "github.com/openmeterio/openmeter/openmeter/namespace/namespacedriver" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" +) + +type Handler interface { + AppHandler +} + +type AppHandler interface { + // App handlers + ListApps() ListAppsHandler + GetApp() GetAppHandler + UninstallApp() UninstallAppHandler + UpdateApp() UpdateAppHandler + + // Customer Data handlers + ListCustomerData() ListCustomerDataHandler + UpsertCustomerData() UpsertCustomerDataHandler + DeleteCustomerData() DeleteCustomerDataHandler + + // Marketplace handlers + ListMarketplaceListings() ListMarketplaceListingsHandler + GetMarketplaceListing() GetMarketplaceListingHandler + MarketplaceAppAPIKeyInstall() MarketplaceAppAPIKeyInstallHandler + MarketplaceAppInstall() MarketplaceAppInstallHandler +} + +var _ Handler = (*handler)(nil) + +type handler struct { + service app.Service + + stripeAppService stripeapp.Service + billingService billing.Service + customerService customer.Service + namespaceDecoder namespacedriver.NamespaceDecoder + options []httptransport.HandlerOption +} + +func (h *handler) resolveNamespace(ctx context.Context) (string, error) { + ns, ok := h.namespaceDecoder.GetNamespace(ctx) + if !ok { + return "", commonhttp.NewHTTPError(http.StatusInternalServerError, errors.New("internal server error")) + } + + return ns, nil +} + +func New( + logger *slog.Logger, + namespaceDecoder namespacedriver.NamespaceDecoder, + appService app.Service, + appStripeService stripeapp.Service, + billingService billing.Service, + customerService customer.Service, + + options ...httptransport.HandlerOption, +) Handler { + return &handler{ + service: appService, + namespaceDecoder: namespaceDecoder, + stripeAppService: appStripeService, + billingService: billingService, + customerService: customerService, + options: options, + } +} diff --git a/app/httpdriver/mapper.go b/app/httpdriver/mapper.go new file mode 100644 index 0000000000000000000000000000000000000000..c940f8dbad07a5dab5443cd273407acf97453852 --- /dev/null +++ b/app/httpdriver/mapper.go @@ -0,0 +1,177 @@ +package httpdriver + +import ( + "errors" + "fmt" + + "github.com/samber/lo" + + "github.com/openmeterio/openmeter/api" + "github.com/openmeterio/openmeter/openmeter/app" + appcustominvoicing "github.com/openmeterio/openmeter/openmeter/app/custominvoicing" + appsandbox "github.com/openmeterio/openmeter/openmeter/app/sandbox" + appstripe "github.com/openmeterio/openmeter/openmeter/app/stripe" +) + +// MapAppToAPI maps an app to an API app +func MapAppToAPI(item app.App) (api.App, error) { + if item == nil { + return api.App{}, errors.New("invalid app: nil") + } + + switch item.GetType() { + case app.AppTypeStripe: + stripeApp := item.(appstripe.App) + + app := api.App{} + if err := app.FromStripeApp(mapStripeAppToAPI(stripeApp.Meta)); err != nil { + return app, err + } + + return app, nil + case app.AppTypeSandbox: + sandboxApp := item.(appsandbox.App) + + app := api.App{} + if err := app.FromSandboxApp(mapSandboxAppToAPI(sandboxApp.Meta)); err != nil { + return app, err + } + + return app, nil + case app.AppTypeCustomInvoicing: + customInvoicingApp := item.(appcustominvoicing.App) + + app := api.App{} + if err := app.FromCustomInvoicingApp(mapCustomInvoicingAppToAPI(customInvoicingApp.Meta)); err != nil { + return app, err + } + + return app, nil + default: + return api.App{}, fmt.Errorf("unsupported app type: %s", item.GetType()) + } +} + +func mapSandboxAppToAPI(app appsandbox.Meta) api.SandboxApp { + return api.SandboxApp{ + Id: app.GetID().ID, + Type: api.SandboxAppTypeSandbox, + Name: app.GetName(), + Status: api.AppStatus(app.GetStatus()), + Listing: mapMarketplaceListing(app.GetListing()), + CreatedAt: app.CreatedAt, + UpdatedAt: app.UpdatedAt, + DeletedAt: app.DeletedAt, + } +} + +func mapStripeAppToAPI( + stripeApp appstripe.Meta, +) api.StripeApp { + apiStripeApp := api.StripeApp{ + Id: stripeApp.GetID().ID, + Type: api.StripeAppType(stripeApp.GetType()), + Name: stripeApp.Name, + Status: api.AppStatus(stripeApp.GetStatus()), + Listing: mapMarketplaceListing(stripeApp.GetListing()), + MaskedAPIKey: stripeApp.MaskedAPIKey, + CreatedAt: stripeApp.CreatedAt, + UpdatedAt: stripeApp.UpdatedAt, + DeletedAt: stripeApp.DeletedAt, + StripeAccountId: stripeApp.StripeAccountID, + Livemode: stripeApp.Livemode, + } + + apiStripeApp.Description = stripeApp.GetDescription() + + if stripeApp.GetMetadata() != nil { + apiStripeApp.Metadata = lo.ToPtr(api.Metadata(stripeApp.GetMetadata())) + } + + return apiStripeApp +} + +func mapCustomInvoicingAppToAPI(app appcustominvoicing.Meta) api.CustomInvoicingApp { + return api.CustomInvoicingApp{ + Id: app.GetID().ID, + Type: api.CustomInvoicingAppTypeCustomInvoicing, + Name: app.GetName(), + Status: api.AppStatus(app.GetStatus()), + Listing: mapMarketplaceListing(app.GetListing()), + Metadata: lo.EmptyableToPtr(api.Metadata(app.GetMetadata())), + Description: app.GetDescription(), + CreatedAt: app.CreatedAt, + UpdatedAt: app.UpdatedAt, + DeletedAt: app.DeletedAt, + + EnableDraftSyncHook: app.Configuration.EnableDraftSyncHook, + EnableIssuingSyncHook: app.Configuration.EnableIssuingSyncHook, + } +} + +func MapEventAppToAPI(event app.EventApp) (api.App, error) { + switch event.GetType() { + case app.AppTypeStripe: + target := appstripe.App{} + if err := target.FromEventAppData(event); err != nil { + return api.App{}, err + } + + app := api.App{} + if err := app.FromStripeApp(mapStripeAppToAPI(target.Meta)); err != nil { + return api.App{}, err + } + + return app, nil + case app.AppTypeSandbox: + target := appsandbox.Meta{} + if err := target.FromEventAppData(event); err != nil { + return api.App{}, err + } + + app := api.App{} + if err := app.FromSandboxApp(mapSandboxAppToAPI(target)); err != nil { + return api.App{}, err + } + + return app, nil + case app.AppTypeCustomInvoicing: + target := appcustominvoicing.App{} + if err := target.FromEventAppData(event); err != nil { + return api.App{}, err + } + + app := api.App{} + if err := app.FromCustomInvoicingApp(mapCustomInvoicingAppToAPI(target.Meta)); err != nil { + return api.App{}, err + } + + return app, nil + default: + return api.App{}, fmt.Errorf("unsupported app type: %s", event.GetType()) + } +} + +// fromAPIAppStripeCustomerData maps an API stripe customer data to an app stripe customer data +func fromAPIAppStripeCustomerData(apiStripeCustomerData api.StripeCustomerAppData) appstripe.CustomerData { + return appstripe.CustomerData{ + StripeCustomerID: apiStripeCustomerData.StripeCustomerId, + StripeDefaultPaymentMethodID: apiStripeCustomerData.StripeDefaultPaymentMethodId, + } +} + +// customerAppToAPI converts a CustomerApp to an API CustomerAppData +func ToAPIStripeCustomerAppData( + customerAppData appstripe.CustomerData, + stripeApp appstripe.App, +) api.StripeCustomerAppData { + apiStripeCustomerAppData := api.StripeCustomerAppData{ + Id: lo.ToPtr(stripeApp.GetID().ID), + Type: api.StripeCustomerAppDataTypeStripe, + App: lo.ToPtr(mapStripeAppToAPI(stripeApp.Meta)), + StripeCustomerId: customerAppData.StripeCustomerID, + StripeDefaultPaymentMethodId: customerAppData.StripeDefaultPaymentMethodID, + } + + return apiStripeCustomerAppData +} diff --git a/app/httpdriver/marketplace.go b/app/httpdriver/marketplace.go new file mode 100644 index 0000000000000000000000000000000000000000..f80921655b61ae17a935509bc4bd9cb67c3d4d00 --- /dev/null +++ b/app/httpdriver/marketplace.go @@ -0,0 +1,245 @@ +package httpdriver + +import ( + "context" + "fmt" + "net/http" + + "github.com/samber/lo" + + "github.com/openmeterio/openmeter/api" + "github.com/openmeterio/openmeter/openmeter/app" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" + "github.com/openmeterio/openmeter/pkg/pagination" +) + +// ListMarketplaceListingsHandler is a handler for listing marketplace listings +type ( + ListMarketplaceListingsRequest = app.MarketplaceListInput + ListMarketplaceListingsResponse = api.MarketplaceListingPaginatedResponse + ListMarketplaceListingsParams = api.ListMarketplaceListingsParams + ListMarketplaceListingsHandler httptransport.HandlerWithArgs[ListMarketplaceListingsRequest, ListMarketplaceListingsResponse, ListMarketplaceListingsParams] +) + +// ListMarketplaceListings returns a handler for listing marketplace listings +func (h *handler) ListMarketplaceListings() ListMarketplaceListingsHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, params ListMarketplaceListingsParams) (ListMarketplaceListingsRequest, error) { + return ListMarketplaceListingsRequest{ + Page: pagination.Page{ + PageSize: lo.FromPtrOr(params.PageSize, app.DefaultPageSize), + PageNumber: lo.FromPtrOr(params.Page, app.DefaultPageNumber), + }, + }, nil + }, + func(ctx context.Context, request ListMarketplaceListingsRequest) (ListMarketplaceListingsResponse, error) { + result, err := h.service.ListMarketplaceListings(ctx, request) + if err != nil { + return ListMarketplaceListingsResponse{}, fmt.Errorf("failed to list marketplace listings: %w", err) + } + + return ListMarketplaceListingsResponse{ + Page: result.Page.PageNumber, + PageSize: result.Page.PageSize, + TotalCount: result.TotalCount, + Items: lo.Map(result.Items, func(item app.RegistryItem, _ int) api.MarketplaceListing { + return mapMarketplaceListing(item.Listing) + }), + }, nil + }, + commonhttp.JSONResponseEncoderWithStatus[ListMarketplaceListingsResponse](http.StatusOK), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("listMarketplaceListings"), + )..., + ) +} + +// GetMarketplaceListingHandler is a handler to get a marketplace listing +type ( + GetMarketplaceListingRequest = app.MarketplaceGetInput + GetMarketplaceListingResponse = api.MarketplaceListing + GetMarketplaceListingHandler httptransport.HandlerWithArgs[GetMarketplaceListingRequest, GetMarketplaceListingResponse, api.AppType] +) + +// GetMarketplaceListing returns a handler for listing marketplace listings +func (h *handler) GetMarketplaceListing() GetMarketplaceListingHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, appType api.AppType) (GetMarketplaceListingRequest, error) { + return GetMarketplaceListingRequest{ + Type: app.AppType(appType), + }, nil + }, + func(ctx context.Context, request GetMarketplaceListingRequest) (GetMarketplaceListingResponse, error) { + result, err := h.service.GetMarketplaceListing(ctx, request) + if err != nil { + return GetMarketplaceListingResponse{}, fmt.Errorf("failed to get marketplace listing: %w", err) + } + + return mapMarketplaceListing(result.Listing), nil + }, + commonhttp.JSONResponseEncoderWithStatus[GetMarketplaceListingResponse](http.StatusOK), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("getMarketplaceListing"), + )..., + ) +} + +type ( + MarketplaceAppAPIKeyInstallResponse = api.MarketplaceInstallResponse + MarketplaceAppAPIKeyInstallHandler httptransport.HandlerWithArgs[MarketplaceAppAPIKeyInstallRequest, MarketplaceAppAPIKeyInstallResponse, api.AppType] +) + +type MarketplaceAppAPIKeyInstallRequest struct { + app.InstallAppV3Input + CreateBillingProfile bool +} + +// MarketplaceAppAPIKeyInstall returns a handler for installing an app type with an API key +func (h *handler) MarketplaceAppAPIKeyInstall() MarketplaceAppAPIKeyInstallHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, appType api.AppType) (MarketplaceAppAPIKeyInstallRequest, error) { + body := api.MarketplaceAppAPIKeyInstallJSONBody{} + if err := commonhttp.JSONRequestBodyDecoder(r, &body); err != nil { + return MarketplaceAppAPIKeyInstallRequest{}, fmt.Errorf("field to decode marketplace app install request: %w", err) + } + + // Resolve namespace + namespace, err := h.resolveNamespace(ctx) + if err != nil { + return MarketplaceAppAPIKeyInstallRequest{}, fmt.Errorf("failed to resolve namespace: %w", err) + } + + req := MarketplaceAppAPIKeyInstallRequest{ + InstallAppV3Input: app.InstallAppV3Input{ + MarketplaceListingID: app.MarketplaceListingID{Type: app.AppType(appType)}, + Namespace: namespace, + Name: lo.FromPtr(body.Name), + APIKey: lo.ToPtr(body.ApiKey), + }, + CreateBillingProfile: lo.FromPtrOr(body.CreateBillingProfile, true), + } + + return req, nil + }, + func(ctx context.Context, request MarketplaceAppAPIKeyInstallRequest) (MarketplaceAppAPIKeyInstallResponse, error) { + resp := MarketplaceAppAPIKeyInstallResponse{ + DefaultForCapabilityTypes: []api.AppCapabilityType{}, + } + + // Install app + installedApp, err := h.service.InstallApp(ctx, request.InstallAppV3Input) + if err != nil { + return resp, err + } + + // Map app to API + apiApp, err := MapAppToAPI(installedApp.App) + if err != nil { + return resp, fmt.Errorf("failed to map app to API: %w", err) + } + + resp.App = apiApp + resp.DefaultForCapabilityTypes = lo.Map(installedApp.DefaultCapabilies, func(c app.CapabilityType, _ int) api.AppCapabilityType { + return api.AppCapabilityType(c) + }) + + return resp, nil + }, + commonhttp.JSONResponseEncoderWithStatus[MarketplaceAppAPIKeyInstallResponse](http.StatusOK), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("marketplaceAppAPIKeyInstall"), + )..., + ) +} + +type ( + MarketplaceAppInstallResponse = api.MarketplaceInstallResponse + MarketplaceAppInstallHandler httptransport.HandlerWithArgs[MarketplaceAppInstallRequest, MarketplaceAppInstallResponse, api.AppType] +) + +type MarketplaceAppInstallRequest struct { + app.InstallAppV3Input + CreateBillingProfile bool +} + +// MarketplaceAppInstall returns a handler for installing an app type +func (h *handler) MarketplaceAppInstall() MarketplaceAppInstallHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, appType api.AppType) (MarketplaceAppInstallRequest, error) { + body := api.MarketplaceInstallRequestPayload{} + if err := commonhttp.JSONRequestBodyDecoder(r, &body); err != nil { + return MarketplaceAppInstallRequest{}, fmt.Errorf("field to decode marketplace app install request: %w", err) + } + + // Resolve namespace + namespace, err := h.resolveNamespace(ctx) + if err != nil { + return MarketplaceAppInstallRequest{}, fmt.Errorf("failed to resolve namespace: %w", err) + } + + req := MarketplaceAppInstallRequest{ + InstallAppV3Input: app.InstallAppV3Input{ + MarketplaceListingID: app.MarketplaceListingID{Type: app.AppType(appType)}, + Namespace: namespace, + Name: lo.FromPtr(body.Name), + }, + CreateBillingProfile: lo.FromPtrOr(body.CreateBillingProfile, true), + } + + return req, nil + }, + func(ctx context.Context, request MarketplaceAppInstallRequest) (MarketplaceAppInstallResponse, error) { + resp := MarketplaceAppInstallResponse{ + DefaultForCapabilityTypes: []api.AppCapabilityType{}, + } + + // Install app + installedApp, err := h.service.InstallApp(ctx, request.InstallAppV3Input) + if err != nil { + return resp, err + } + + // Map app to API + apiApp, err := MapAppToAPI(installedApp.App) + if err != nil { + return resp, fmt.Errorf("failed to map app to API: %w", err) + } + + resp.App = apiApp + resp.DefaultForCapabilityTypes = lo.Map(installedApp.DefaultCapabilies, func(c app.CapabilityType, _ int) api.AppCapabilityType { + return api.AppCapabilityType(c) + }) + + return resp, nil + }, + commonhttp.JSONResponseEncoderWithStatus[MarketplaceAppInstallResponse](http.StatusOK), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("marketplaceAppInstall"), + )..., + ) +} + +// Map marketplace listing to API +func mapMarketplaceListing(listing app.MarketplaceListing) api.MarketplaceListing { + return api.MarketplaceListing{ + Type: api.AppType(listing.Type), + Name: listing.Name, + Description: listing.Description, + Capabilities: lo.Map(listing.Capabilities, func(v app.Capability, _ int) api.AppCapability { + return api.AppCapability{ + Type: api.AppCapabilityType(v.Type), + Key: v.Key, + Name: v.Name, + Description: v.Description, + } + }), + InstallMethods: lo.Map(listing.InstallMethods, func(v app.InstallMethod, _ int) api.InstallMethod { + return api.InstallMethod(v) + }), + } +} diff --git a/app/input.go b/app/input.go new file mode 100644 index 0000000000000000000000000000000000000000..f6a25e257720c4fa68c6387a97e11a3d15502a45 --- /dev/null +++ b/app/input.go @@ -0,0 +1,91 @@ +package app + +import ( + "errors" + "fmt" + + "github.com/openmeterio/openmeter/openmeter/customer" + "github.com/openmeterio/openmeter/pkg/models" + "github.com/openmeterio/openmeter/pkg/pagination" +) + +type ListCustomerInput struct { + pagination.Page + AppID *AppID + CustomerID customer.CustomerID + Type *AppType +} + +func (a ListCustomerInput) Validate() error { + var errs []error + + if err := a.CustomerID.Validate(); err != nil { + errs = append(errs, err) + } + + if a.AppID != nil { + if err := a.AppID.Validate(); err != nil { + errs = append(errs, err) + } + } + + if a.Type != nil { + if *a.Type == "" { + errs = append(errs, models.NewGenericValidationError( + fmt.Errorf("app type cannot be empty"), + )) + } + } + + return errors.Join(errs...) +} + +type EnsureCustomerInput struct { + AppID AppID + CustomerID customer.CustomerID +} + +func (a EnsureCustomerInput) Validate() error { + if err := a.AppID.Validate(); err != nil { + return err + } + + if err := a.CustomerID.Validate(); err != nil { + return err + } + + if a.AppID.Namespace != a.CustomerID.Namespace { + return fmt.Errorf("app ID namespace %s does not match customer ID namespace %s", a.AppID.Namespace, a.CustomerID.Namespace) + } + + return nil +} + +type DeleteCustomerInput struct { + AppID *AppID + CustomerID *customer.CustomerID +} + +func (a DeleteCustomerInput) Validate() error { + if a.AppID == nil && a.CustomerID == nil { + return fmt.Errorf("app ID and customer ID cannot be nil") + } + + if a.AppID != nil { + if err := a.AppID.Validate(); err != nil { + return err + } + } + + if a.CustomerID != nil { + if err := a.CustomerID.Validate(); err != nil { + return err + } + } + + if a.AppID != nil && a.CustomerID != nil && a.AppID.Namespace != a.CustomerID.Namespace { + return errors.New("app and customer must be in the same namespace") + } + + return nil +} diff --git a/app/marketplace.go b/app/marketplace.go new file mode 100644 index 0000000000000000000000000000000000000000..ce90d8383eff77f2ef4448e992c7cd06eff0f835 --- /dev/null +++ b/app/marketplace.go @@ -0,0 +1,230 @@ +package app + +import ( + "context" + "errors" + "fmt" + "slices" + + "github.com/openmeterio/openmeter/pkg/models" + "github.com/openmeterio/openmeter/pkg/pagination" +) + +type InstallMethod string + +const ( + InstallMethodOAuth2 InstallMethod = "with_oauth2" + InstallMethodAPIKey InstallMethod = "with_api_key" + InstallMethodNoCredentials InstallMethod = "no_credentials_required" +) + +func (i InstallMethod) Validate() error { + if i == "" { + return errors.New("install method is required") + } + + if !slices.Contains([]InstallMethod{ + InstallMethodOAuth2, + InstallMethodAPIKey, + InstallMethodNoCredentials, + }, i) { + return fmt.Errorf("invalid install method: %s", i) + } + + return nil +} + +type MarketplaceListing struct { + Type AppType `json:"type"` + Name string `json:"name"` + Description string `json:"description"` + Capabilities []Capability `json:"capabilities"` + InstallMethods []InstallMethod `json:"installMethods"` +} + +func (p MarketplaceListing) Validate() error { + if p.Type == "" { + return errors.New("type is required") + } + + if p.Name == "" { + return errors.New("name is required") + } + + if p.Description == "" { + return errors.New("description is required") + } + + for i, capability := range p.Capabilities { + if err := capability.Validate(); err != nil { + return fmt.Errorf("error validating capability at position %d: %w", i, err) + } + } + + for i, installMethod := range p.InstallMethods { + if err := installMethod.Validate(); err != nil { + return fmt.Errorf("error validating install method at position %d: %w", i, err) + } + } + + return nil +} + +type Capability struct { + Type CapabilityType `json:"type"` + Key string `json:"key"` + Name string `json:"name"` + Description string `json:"description"` +} + +func (c Capability) Validate() error { + if c.Key == "" { + return errors.New("key is required") + } + + if c.Name == "" { + return errors.New("name is required") + } + + if c.Description == "" { + return errors.New("description is required") + } + + return nil +} + +type MarketplaceListingID struct { + Type AppType +} + +func (i MarketplaceListingID) Validate() error { + if i.Type == "" { + return errors.New("type is required") + } + + return nil +} + +type RegisterMarketplaceListingInput = RegistryItem + +type MarketplaceGetInput = MarketplaceListingID + +type MarketplaceListInput struct { + pagination.Page +} + +func (i MarketplaceListInput) Validate() error { + if err := i.Page.Validate(); err != nil { + return fmt.Errorf("error validating page: %w", err) + } + + return nil +} + +type InstallAppWithAPIKeyInput struct { + InstallAppInput + + APIKey string +} + +func (i InstallAppWithAPIKeyInput) Validate() error { + if err := i.InstallAppInput.Validate(); err != nil { + return fmt.Errorf("error validating install app input: %w", err) + } + + if i.APIKey == "" { + return errors.New("api key is required") + } + + return nil +} + +type InstallAppV3Input struct { + MarketplaceListingID + + Namespace string + Name string + APIKey *string + CreateDefaultBillingProfile bool + + CreateDefaultBillingProfileFn func(ctx context.Context, installedApp App) ([]CapabilityType, error) +} + +func (i InstallAppV3Input) Validate() error { + var errs []error + if err := i.MarketplaceListingID.Validate(); err != nil { + errs = append(errs, fmt.Errorf("error validating marketplace listing id: %w", err)) + } + + if i.Namespace == "" { + errs = append(errs, errors.New("namespace is required")) + } + + if i.APIKey != nil && *i.APIKey == "" { + errs = append(errs, errors.New("api key is required")) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +type InstallAppV3Output struct { + App App + DefaultCapabilies []CapabilityType +} + +type InstallAppInput struct { + MarketplaceListingID + + Namespace string + Name string +} + +func (i InstallAppInput) Validate() error { + if err := i.MarketplaceListingID.Validate(); err != nil { + return models.NewGenericValidationError( + fmt.Errorf("error validating marketplace listing id: %w", err), + ) + } + + if i.Namespace == "" { + return errors.New("namespace is required") + } + + return nil +} + +type GetOauth2InstallURLInput = MarketplaceListingID + +type GetOauth2InstallURLOutput struct { + URL string +} + +type AuthorizeOauth2InstallInput struct { + MarketplaceListingID + + Code string + // Success response fields + State string + // Error response fields + Error string + ErrorDescription string + ErrorURI string +} + +func (i AuthorizeOauth2InstallInput) Validate() error { + if err := i.MarketplaceListingID.Validate(); err != nil { + return models.NewGenericValidationError( + fmt.Errorf("error validating marketplace listing id: %w", err), + ) + } + + if i.State == "" { + return errors.New("state is required") + } + + if i.Error != "" && i.Code != "" { + return errors.New("code and error cannot be set at the same time") + } + + return nil +} diff --git a/app/registry.go b/app/registry.go new file mode 100644 index 0000000000000000000000000000000000000000..a74e954cea8c85ccb0f70ab53d52bd545074e6ef --- /dev/null +++ b/app/registry.go @@ -0,0 +1,78 @@ +package app + +import ( + "context" + "errors" + "fmt" +) + +type AppFactory interface { + NewApp(context.Context, AppBase) (App, error) + UninstallApp(ctx context.Context, input UninstallAppInput) error +} + +type AppFactoryInstallWithAPIKey interface { + InstallAppWithAPIKey(ctx context.Context, input AppFactoryInstallAppWithAPIKeyInput) (App, error) +} + +type UninstallAppInput = AppID + +type AppFactoryInstallAppWithAPIKeyInput struct { + Namespace string + APIKey string + Name string +} + +func (i AppFactoryInstallAppWithAPIKeyInput) Validate() error { + if i.Namespace == "" { + return errors.New("namespace is required") + } + + if i.APIKey == "" { + return errors.New("api key is required") + } + + if i.Name == "" { + return errors.New("name is required") + } + + return nil +} + +type AppFactoryInstall interface { + InstallApp(ctx context.Context, input AppFactoryInstallAppInput) (App, error) +} + +type AppFactoryInstallAppInput struct { + Namespace string + Name string +} + +func (i AppFactoryInstallAppInput) Validate() error { + if i.Namespace == "" { + return errors.New("namespace is required") + } + + if i.Name == "" { + return errors.New("name is required") + } + + return nil +} + +type RegistryItem struct { + Listing MarketplaceListing + Factory AppFactory +} + +func (r RegistryItem) Validate() error { + if err := r.Listing.Validate(); err != nil { + return fmt.Errorf("error validating registry item: %w", err) + } + + if r.Factory == nil { + return errors.New("factory is required") + } + + return nil +} diff --git a/app/sandbox/app.go b/app/sandbox/app.go new file mode 100644 index 0000000000000000000000000000000000000000..28e3b947429ea0ed4a178b901ceb150948c03e3f --- /dev/null +++ b/app/sandbox/app.go @@ -0,0 +1,255 @@ +package appsandbox + +import ( + "context" + "fmt" + + "github.com/samber/lo" + + "github.com/openmeterio/openmeter/openmeter/app" + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/billing/sequence" + "github.com/openmeterio/openmeter/openmeter/customer" + customerapp "github.com/openmeterio/openmeter/openmeter/customer/app" + "github.com/openmeterio/openmeter/pkg/clock" + "github.com/openmeterio/openmeter/pkg/models" +) + +const ( + TargetPaymentStatusMetadataKey = "openmeter.io/sandbox/target-payment-status" + + TargetPaymentStatusPaid = "paid" + TargetPaymentStatusFailed = "failed" + TargetPaymentStatusUncollectible = "uncollectible" + TargetPaymentStatusActionRequired = "action_required" +) + +var ( + _ customerapp.App = (*App)(nil) + _ billing.InvoicingApp = (*App)(nil) + _ billing.InvoicingAppPostAdvanceHook = (*App)(nil) + _ app.CustomerData = (*CustomerData)(nil) + + InvoiceSequenceNumber = sequence.Definition{ + Prefix: "OM-SANDBOX", + SuffixTemplate: "{{.CustomerPrefix}}-{{.NextSequenceNumber}}", + Scope: "invoices/app/sandbox", + CommitMode: sequence.CommitModeWithCaller, + } +) + +type Meta struct { + app.AppBase +} + +var _ app.EventAppParser = (*Meta)(nil) + +func (m *Meta) FromEventAppData(event app.EventApp) error { + m.AppBase = event.AppBase + + return nil +} + +type App struct { + Meta + + sequenceService sequence.Service +} + +func (a App) ValidateCustomer(ctx context.Context, customer *customer.Customer, capabilities []app.CapabilityType) error { + if err := a.ValidateCapabilities(capabilities...); err != nil { + return fmt.Errorf("error validating capabilities: %w", err) + } + + return nil +} + +func (a App) GetCustomerData(ctx context.Context, input app.GetAppInstanceCustomerDataInput) (app.CustomerData, error) { + return CustomerData{}, nil +} + +func (a App) UpsertCustomerData(ctx context.Context, input app.UpsertAppInstanceCustomerDataInput) error { + return nil +} + +func (a App) DeleteCustomerData(ctx context.Context, input app.DeleteAppInstanceCustomerDataInput) error { + return nil +} + +func (a App) ValidateStandardInvoice(ctx context.Context, invoice billing.StandardInvoice) error { + return nil +} + +func (a App) UpdateAppConfig(ctx context.Context, input app.AppConfigUpdate) error { + return nil +} + +func (a App) UpsertStandardInvoice(ctx context.Context, invoice billing.StandardInvoice) (*billing.UpsertStandardInvoiceResult, error) { + return billing.NewUpsertStandardInvoiceResult(), nil +} + +func (a App) FinalizeStandardInvoice(ctx context.Context, invoice billing.StandardInvoice) (*billing.FinalizeStandardInvoiceResult, error) { + invoiceNumber, err := a.sequenceService.GenerateInvoiceSequenceNumber( + ctx, + sequence.GenerationInput{ + Namespace: invoice.Namespace, + CustomerName: invoice.Customer.Name, + Currency: invoice.Currency, + }, + InvoiceSequenceNumber, + ) + if err != nil { + return nil, fmt.Errorf("failed to generate invoice sequence number: %w", err) + } + + return billing.NewFinalizeStandardInvoiceResult(). + SetInvoiceNumber(invoiceNumber). + SetSentToCustomerAt(clock.Now()), nil +} + +func (a App) DeleteStandardInvoice(ctx context.Context, invoice billing.StandardInvoice) error { + return nil +} + +func (a App) PostAdvanceStandardInvoiceHook(ctx context.Context, invoice billing.StandardInvoice) (*billing.PostAdvanceHookResult, error) { + if invoice.Status != billing.StandardInvoiceStatusPaymentProcessingPending { + return nil, nil + } + + targetStatus := TargetPaymentStatusPaid + + // Allow overriding via metadata for testing (unit, customer) purposes + override, ok := invoice.Metadata[TargetPaymentStatusMetadataKey] + if ok && override != "" { + targetStatus = override + } + + out := billing.NewPostAdvanceHookResult() + // Let's simulate the payment status by invoking the right trigger + switch targetStatus { + case TargetPaymentStatusFailed: + return out.InvokeTrigger(billing.InvoiceTriggerInput{ + Invoice: invoice.GetInvoiceID(), + Trigger: billing.TriggerFailed, + ValidationErrors: &billing.InvoiceTriggerValidationInput{ + Operation: billing.StandardInvoiceOpInitiatePayment, + Errors: []error{ErrSimulatedPaymentFailure}, + }, + }), nil + case TargetPaymentStatusUncollectible: + return out.InvokeTrigger(billing.InvoiceTriggerInput{ + Invoice: invoice.GetInvoiceID(), + Trigger: billing.TriggerPaymentUncollectible, + }), nil + case TargetPaymentStatusActionRequired: + return out.InvokeTrigger(billing.InvoiceTriggerInput{ + Invoice: invoice.GetInvoiceID(), + Trigger: billing.TriggerActionRequired, + }), nil + case TargetPaymentStatusPaid: + fallthrough + default: + return out.InvokeTrigger(billing.InvoiceTriggerInput{ + Invoice: invoice.GetInvoiceID(), + Trigger: billing.TriggerPaid, + }), nil + } +} + +func (a App) GetEventAppData() (app.EventAppData, error) { + return app.EventAppData{}, nil +} + +type CustomerData struct{} + +func (c CustomerData) Validate() error { + return nil +} + +type Factory struct { + appService app.Service + sequenceService sequence.Service +} + +type Config struct { + AppService app.Service + SequenceService sequence.Service +} + +func (c Config) Validate() error { + if c.AppService == nil { + return fmt.Errorf("app service is required") + } + + if c.SequenceService == nil { + return fmt.Errorf("sequence service is required") + } + + return nil +} + +func NewFactory(config Config) (*Factory, error) { + if err := config.Validate(); err != nil { + return nil, fmt.Errorf("failed to validate config: %w", err) + } + + fact := &Factory{ + appService: config.AppService, + sequenceService: config.SequenceService, + } + + err := config.AppService.RegisterMarketplaceListing(app.RegistryItem{ + Listing: MarketplaceListing, + Factory: fact, + }) + if err != nil { + return nil, fmt.Errorf("failed to register marketplace listing: %w", err) + } + + return fact, nil +} + +// Factory +func (a *Factory) NewApp(_ context.Context, appBase app.AppBase) (app.App, error) { + return App{ + Meta: Meta{ + AppBase: appBase, + }, + sequenceService: a.sequenceService, + }, nil +} + +func (a *Factory) InstallApp(ctx context.Context, input app.AppFactoryInstallAppInput) (app.App, error) { + if err := input.Validate(); err != nil { + return nil, fmt.Errorf("invalid input: %w", err) + } + + // Sandbox is a singleton per namespace — only one instance makes sense since all + // instances are functionally identical (no credentials, no external state). + existing, err := a.appService.ListApps(ctx, app.ListAppInput{ + Namespace: input.Namespace, + Type: lo.ToPtr(app.AppTypeSandbox), + }) + if err != nil { + return nil, fmt.Errorf("failed to list sandbox apps: %w", err) + } + + if existing.TotalCount > 0 { + return nil, models.NewGenericConflictError(fmt.Errorf("sandbox app: %s already exists", existing.Items[0].GetName())) + } + + appBase, err := a.appService.CreateApp(ctx, app.CreateAppInput{ + Namespace: input.Namespace, + Name: input.Name, + Type: app.AppTypeSandbox, + }) + if err != nil { + return nil, fmt.Errorf("failed to create app: %w", err) + } + + return a.NewApp(ctx, appBase.GetAppBase()) +} + +func (a *Factory) UninstallApp(ctx context.Context, input app.UninstallAppInput) error { + return nil +} diff --git a/app/sandbox/config.go b/app/sandbox/config.go new file mode 100644 index 0000000000000000000000000000000000000000..82bdd4a4dde9aaaff2b5fdd2375fe3e390b42673 --- /dev/null +++ b/app/sandbox/config.go @@ -0,0 +1,7 @@ +package appsandbox + +type Configuration struct{} + +func (c Configuration) Validate() error { + return nil +} diff --git a/app/sandbox/errors.go b/app/sandbox/errors.go new file mode 100644 index 0000000000000000000000000000000000000000..152dd6d1e2b4b61a1918d1d0fe7b3bd5f078534a --- /dev/null +++ b/app/sandbox/errors.go @@ -0,0 +1,5 @@ +package appsandbox + +import "github.com/openmeterio/openmeter/openmeter/billing" + +var ErrSimulatedPaymentFailure = billing.NewValidationError("simulated_payment_failure", "simulated payment failure") diff --git a/app/sandbox/helpers.go b/app/sandbox/helpers.go new file mode 100644 index 0000000000000000000000000000000000000000..d59a33707b3f35987ac13bcbf12c3b0613a39c08 --- /dev/null +++ b/app/sandbox/helpers.go @@ -0,0 +1,70 @@ +package appsandbox + +import ( + "context" + "errors" + "fmt" + + "github.com/samber/lo" + + "github.com/openmeterio/openmeter/openmeter/app" + "github.com/openmeterio/openmeter/pkg/models" +) + +type AutoProvisionInput struct { + Namespace string + AppService app.Service +} + +func (a AutoProvisionInput) Validate() error { + if a.Namespace == "" { + return errors.New("namespace is required") + } + + if a.AppService == nil { + return errors.New("app service is required") + } + + return nil +} + +// AutoProvision creates a new default sandbox app if it doesn't exist, otherwise returns the existing one. +// +// We install the sandbox app by default in the system, so that the user can start trying out the system +// right away. +func AutoProvision(ctx context.Context, input AutoProvisionInput) (app.App, error) { + if err := input.Validate(); err != nil { + return nil, models.NewGenericValidationError(err) + } + + // Get the sandbox app list + sandboxAppList, err := input.AppService.ListApps(ctx, app.ListAppInput{ + Namespace: input.Namespace, + Type: lo.ToPtr(app.AppTypeSandbox), + }) + if err != nil { + return nil, fmt.Errorf("cannot list apps: %w", err) + } + + // If there is no sandbox app, we need to provision a new one + if sandboxAppList.TotalCount == 0 { + // Let's provision the new app + appBase, err := input.AppService.CreateApp(ctx, app.CreateAppInput{ + Namespace: input.Namespace, + Name: "Sandbox", + Description: "OpenMeter Sandbox App to be used for testing purposes.", + Type: app.AppTypeSandbox, + }) + if err != nil { + return nil, fmt.Errorf("cannot create sandbox app: %w", err) + } + + return input.AppService.GetApp(ctx, app.GetAppInput{ + Namespace: input.Namespace, + ID: appBase.GetID().ID, + }) + } + + // Otherwise, we return the first one + return sandboxAppList.Items[0], nil +} diff --git a/app/sandbox/marketplace.go b/app/sandbox/marketplace.go new file mode 100644 index 0000000000000000000000000000000000000000..ccb7933ad89518e40830c4ab76f01647af4bbe7b --- /dev/null +++ b/app/sandbox/marketplace.go @@ -0,0 +1,42 @@ +package appsandbox + +import ( + "github.com/openmeterio/openmeter/openmeter/app" +) + +var ( + MarketplaceListing = app.MarketplaceListing{ + Type: app.AppTypeSandbox, + Name: "Sandbox", + Description: "Sandbox can be used to test OpenMeter without external connections.", + Capabilities: []app.Capability{ + CollectPaymentCapability, + CalculateTaxCapability, + InvoiceCustomerCapability, + }, + InstallMethods: []app.InstallMethod{ + app.InstallMethodNoCredentials, + }, + } + + CollectPaymentCapability = app.Capability{ + Type: app.CapabilityTypeCollectPayments, + Key: "sandbox_collect_payment", + Name: "Payment", + Description: "Process payments", + } + + CalculateTaxCapability = app.Capability{ + Type: app.CapabilityTypeCalculateTax, + Key: "sandbox_calculate_tax", + Name: "Calculate Tax", + Description: "Calculate tax for a payment", + } + + InvoiceCustomerCapability = app.Capability{ + Type: app.CapabilityTypeInvoiceCustomers, + Key: "sandbox_invoice_customer", + Name: "Invoice Customer", + Description: "Invoice a customer", + } +) diff --git a/app/sandbox/mock.go b/app/sandbox/mock.go new file mode 100644 index 0000000000000000000000000000000000000000..34a1a2839deb63ed213e160288b6af2f05ec1334 --- /dev/null +++ b/app/sandbox/mock.go @@ -0,0 +1,284 @@ +package appsandbox + +import ( + "context" + "fmt" + "testing" + + "github.com/samber/mo" + + "github.com/openmeterio/openmeter/openmeter/app" + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/customer" + customerapp "github.com/openmeterio/openmeter/openmeter/customer/app" +) + +type AppFactory interface { + NewApp(ctx context.Context, appBase app.AppBase) (app.App, error) +} + +type InvoiceUpsertCallback func(billing.StandardInvoice) (*billing.UpsertStandardInvoiceResult, error) + +type MockApp struct { + validateCustomerResponse mo.Option[error] + validateCustomerCalled bool + + validateInvoiceResponse mo.Option[error] + validateInvoiceResponseCalled bool + + upsertInvoiceCallback mo.Option[InvoiceUpsertCallback] + upsertInvoiceCalled bool + + finalizeInvoiceResponse mo.Option[*billing.FinalizeStandardInvoiceResult] + finalizeInvoiceCalled bool + + deleteInvoiceResponse mo.Option[error] + deleteInvoiceCalled bool +} + +func NewMockApp(_ *testing.T) *MockApp { + return &MockApp{} +} + +func (m *MockApp) GetCustomerData(ctx context.Context, input app.GetAppInstanceCustomerDataInput) (app.CustomerData, error) { + return nil, nil +} + +func (m *MockApp) UpsertCustomerData(ctx context.Context, input app.UpsertAppInstanceCustomerDataInput) error { + return nil +} + +func (m *MockApp) DeleteCustomerData(ctx context.Context, input app.DeleteAppInstanceCustomerDataInput) error { + return nil +} + +func (m *MockApp) UpdateAppConfig(ctx context.Context, input app.AppConfigUpdate) error { + return nil +} + +func (m *MockApp) ValidateCustomer(appID string, customer *customer.Customer, capabilities []app.CapabilityType) error { + m.validateCustomerCalled = true + return m.validateCustomerResponse.MustGet() +} + +func (m *MockApp) OnValidateCustomer(err error) { + m.validateCustomerResponse = mo.Some(err) +} + +// InvoicingApp + +func (m *MockApp) ValidateStandardInvoice(appID string, invoice billing.StandardInvoice) error { + m.validateInvoiceResponseCalled = true + return m.validateInvoiceResponse.MustGet() +} + +func (m *MockApp) OnValidateStandardInvoice(err error) { + m.validateInvoiceResponse = mo.Some(err) +} + +func (m *MockApp) UpsertStandardInvoice(ctx context.Context, invoice billing.StandardInvoice) (*billing.UpsertStandardInvoiceResult, error) { + m.upsertInvoiceCalled = true + + if m.upsertInvoiceCallback.IsPresent() && m.upsertInvoiceCallback.MustGet() != nil { + return m.upsertInvoiceCallback.MustGet()(invoice) + } + + return billing.NewUpsertStandardInvoiceResult(), nil +} + +func (m *MockApp) OnUpsertStandardInvoice(cb InvoiceUpsertCallback) { + m.upsertInvoiceCallback = mo.Some(cb) +} + +func (m *MockApp) FinalizeStandardInvoice(ctx context.Context, invoice billing.StandardInvoice) (*billing.FinalizeStandardInvoiceResult, error) { + m.finalizeInvoiceCalled = true + return m.finalizeInvoiceResponse.MustGet(), nil +} + +func (m *MockApp) OnFinalizeStandardInvoice(result *billing.FinalizeStandardInvoiceResult) { + m.finalizeInvoiceResponse = mo.Some(result) +} + +func (m *MockApp) DeleteStandardInvoice(ctx context.Context, invoice billing.StandardInvoice) error { + m.deleteInvoiceCalled = true + return m.deleteInvoiceResponse.MustGet() +} + +func (m *MockApp) OnDeleteStandardInvoice(err error) { + m.deleteInvoiceResponse = mo.Some(err) +} + +func (m *MockApp) Reset(t *testing.T) { + t.Helper() + + m.AssertExpectations(t) + + m.validateCustomerResponse = mo.None[error]() + m.validateCustomerCalled = false + + m.validateInvoiceResponse = mo.None[error]() + m.validateInvoiceResponseCalled = false + + m.upsertInvoiceCallback = mo.None[InvoiceUpsertCallback]() + m.upsertInvoiceCalled = false + + m.finalizeInvoiceResponse = mo.None[*billing.FinalizeStandardInvoiceResult]() + m.finalizeInvoiceCalled = false + + m.deleteInvoiceResponse = mo.None[error]() + m.deleteInvoiceCalled = false +} + +func (m *MockApp) AssertExpectations(t *testing.T) { + t.Helper() + + if m.validateCustomerResponse.IsPresent() && !m.validateCustomerCalled { + t.Errorf("expected ValidateCustomer to be called") + } + + if m.validateInvoiceResponse.IsPresent() && !m.validateInvoiceResponseCalled { + t.Errorf("expected ValidateInvoice to be called") + } + + if m.upsertInvoiceCallback.IsPresent() && !m.upsertInvoiceCalled { + t.Errorf("expected UpsertInvoice to be called") + } + + if m.finalizeInvoiceResponse.IsPresent() && !m.finalizeInvoiceCalled { + t.Errorf("expected FinalizeInvoice to be called") + } + + if m.deleteInvoiceResponse.IsPresent() && !m.deleteInvoiceCalled { + t.Errorf("expected DeleteInvoice to be called") + } +} + +func (m *MockApp) NewApp(_ context.Context, app app.AppBase) (app.App, error) { + return &mockAppInstance{ + AppBase: app, + parent: m, + }, nil +} + +type mockAppInstance struct { + app.AppBase + + parent *MockApp +} + +var ( + _ billing.InvoicingApp = (*mockAppInstance)(nil) + _ customerapp.App = (*mockAppInstance)(nil) +) + +func (m *mockAppInstance) GetCustomerData(ctx context.Context, input app.GetAppInstanceCustomerDataInput) (app.CustomerData, error) { + return m.parent.GetCustomerData(ctx, input) +} + +func (m *mockAppInstance) UpsertCustomerData(ctx context.Context, input app.UpsertAppInstanceCustomerDataInput) error { + return m.parent.UpsertCustomerData(ctx, input) +} + +func (m *mockAppInstance) DeleteCustomerData(ctx context.Context, input app.DeleteAppInstanceCustomerDataInput) error { + return m.parent.DeleteCustomerData(ctx, input) +} + +func (m *mockAppInstance) UpdateAppConfig(ctx context.Context, input app.AppConfigUpdate) error { + return m.parent.UpdateAppConfig(ctx, input) +} + +func (m *mockAppInstance) ValidateCustomer(ctx context.Context, customer *customer.Customer, capabilities []app.CapabilityType) error { + return m.parent.ValidateCustomer(m.GetID().ID, customer, capabilities) +} + +func (m *mockAppInstance) ValidateStandardInvoice(ctx context.Context, invoice billing.StandardInvoice) error { + return m.parent.ValidateStandardInvoice(m.GetID().ID, invoice) +} + +func (m *mockAppInstance) UpsertStandardInvoice(ctx context.Context, invoice billing.StandardInvoice) (*billing.UpsertStandardInvoiceResult, error) { + return m.parent.UpsertStandardInvoice(ctx, invoice) +} + +func (m *mockAppInstance) FinalizeStandardInvoice(ctx context.Context, invoice billing.StandardInvoice) (*billing.FinalizeStandardInvoiceResult, error) { + return m.parent.FinalizeStandardInvoice(ctx, invoice) +} + +func (m *mockAppInstance) DeleteStandardInvoice(ctx context.Context, invoice billing.StandardInvoice) error { + return m.parent.DeleteStandardInvoice(ctx, invoice) +} + +func (m *mockAppInstance) GetEventAppData() (app.EventAppData, error) { + return app.EventAppData{}, nil +} + +type MockableFactory struct { + *Factory + + overrideFactory AppFactory +} + +type mockConfig struct { + OverrideType app.AppType +} + +type mockConfigOption func(*mockConfig) + +func MockWithAppType(t app.AppType) mockConfigOption { + return func(c *mockConfig) { + c.OverrideType = t + } +} + +func NewMockableFactory(_ *testing.T, config Config, opts ...mockConfigOption) (*MockableFactory, error) { + if err := config.Validate(); err != nil { + return nil, fmt.Errorf("failed to validate config: %w", err) + } + + fact := &MockableFactory{ + Factory: &Factory{ + appService: config.AppService, + sequenceService: config.SequenceService, + }, + } + + mockConfig := &mockConfig{} + for _, opt := range opts { + opt(mockConfig) + } + + listing := MarketplaceListing + + if mockConfig.OverrideType != "" { + listing.Type = mockConfig.OverrideType + } + + err := config.AppService.RegisterMarketplaceListing(app.RegistryItem{ + Listing: listing, + Factory: fact, + }) + if err != nil { + return nil, fmt.Errorf("failed to register marketplace listing: %w", err) + } + + return fact, nil +} + +func (m *MockableFactory) NewApp(ctx context.Context, appBase app.AppBase) (app.App, error) { + if m.overrideFactory != nil { + return m.overrideFactory.NewApp(ctx, appBase) + } + + return m.Factory.NewApp(ctx, appBase) +} + +func (m *MockableFactory) EnableMock(t *testing.T) *MockApp { + mock := NewMockApp(t) + + m.overrideFactory = mock + + return mock +} + +func (m *MockableFactory) DisableMock() { + m.overrideFactory = nil +} diff --git a/app/service.go b/app/service.go new file mode 100644 index 0000000000000000000000000000000000000000..6c5387e8d284210fd139580a136e8f741c3b3363 --- /dev/null +++ b/app/service.go @@ -0,0 +1,34 @@ +package app + +import ( + "context" + + "github.com/openmeterio/openmeter/pkg/pagination" +) + +type Service interface { + AppService +} + +type AppService interface { + // Marketplace + RegisterMarketplaceListing(input RegisterMarketplaceListingInput) error + GetMarketplaceListing(ctx context.Context, input MarketplaceGetInput) (RegistryItem, error) + ListMarketplaceListings(ctx context.Context, input MarketplaceListInput) (pagination.Result[RegistryItem], error) + InstallApp(ctx context.Context, input InstallAppV3Input) (InstallAppV3Output, error) + GetMarketplaceListingOauth2InstallURL(ctx context.Context, input GetOauth2InstallURLInput) (GetOauth2InstallURLOutput, error) + AuthorizeMarketplaceListingOauth2Install(ctx context.Context, input AuthorizeOauth2InstallInput) error + + // Installed app + CreateApp(ctx context.Context, input CreateAppInput) (AppBase, error) + GetApp(ctx context.Context, input GetAppInput) (App, error) + UpdateAppStatus(ctx context.Context, input UpdateAppStatusInput) error + UpdateApp(ctx context.Context, input UpdateAppInput) (App, error) + ListApps(ctx context.Context, input ListAppInput) (pagination.Result[App], error) + UninstallApp(ctx context.Context, input UninstallAppInput) error + + // Customer data + ListCustomerData(ctx context.Context, input ListCustomerInput) (pagination.Result[CustomerApp], error) + EnsureCustomer(ctx context.Context, input EnsureCustomerInput) error + DeleteCustomer(ctx context.Context, input DeleteCustomerInput) error +} diff --git a/app/service/app.go b/app/service/app.go new file mode 100644 index 0000000000000000000000000000000000000000..9a96005e6a9db23e373725230a0daee71e209083 --- /dev/null +++ b/app/service/app.go @@ -0,0 +1,151 @@ +package appservice + +import ( + "context" + + "github.com/openmeterio/openmeter/openmeter/app" + "github.com/openmeterio/openmeter/pkg/framework/transaction" + "github.com/openmeterio/openmeter/pkg/models" + "github.com/openmeterio/openmeter/pkg/pagination" +) + +var _ app.AppService = (*Service)(nil) + +func (s *Service) CreateApp(ctx context.Context, input app.CreateAppInput) (app.AppBase, error) { + // Validate the input + if err := input.Validate(); err != nil { + return app.AppBase{}, models.NewGenericValidationError(err) + } + + // Create the app + appBase, err := s.adapter.CreateApp(ctx, input) + if err != nil { + return app.AppBase{}, err + } + + // Emit the app created event + event := app.NewAppCreateEvent(ctx, appBase) + if err := s.publisher.Publish(ctx, event); err != nil { + return app.AppBase{}, err + } + + return appBase, nil +} + +func (s *Service) GetApp(ctx context.Context, input app.GetAppInput) (app.App, error) { + if err := input.Validate(); err != nil { + return nil, models.NewGenericValidationError(err) + } + + return s.adapter.GetApp(ctx, input) +} + +func (s *Service) UpdateApp(ctx context.Context, input app.UpdateAppInput) (app.App, error) { + // Validate the input + if err := input.Validate(); err != nil { + return nil, models.NewGenericValidationError(err) + } + + return transaction.Run(ctx, s.adapter, func(ctx context.Context) (app.App, error) { + // Update the app + updatedApp, err := s.adapter.UpdateApp(ctx, input) + if err != nil { + return nil, err + } + + // Update the app specific entity + if input.AppConfigUpdate != nil { + err := updatedApp.UpdateAppConfig(ctx, input.AppConfigUpdate) + if err != nil { + return nil, err + } + + updatedApp, err = s.adapter.GetApp(ctx, input.AppID) + if err != nil { + return nil, err + } + } + + // Emit the app updated event + event, err := app.NewAppUpdateEvent(ctx, updatedApp) + if err != nil { + return nil, err + } + + if err := s.publisher.Publish(ctx, event); err != nil { + return nil, err + } + + return updatedApp, nil + }) +} + +func (s *Service) ListApps(ctx context.Context, input app.ListAppInput) (pagination.Result[app.App], error) { + if err := input.Validate(); err != nil { + return pagination.Result[app.App]{}, models.NewGenericValidationError(err) + } + + return s.adapter.ListApps(ctx, input) +} + +func (s *Service) UninstallApp(ctx context.Context, input app.UninstallAppInput) error { + // Validate the input + if err := input.Validate(); err != nil { + return models.NewGenericValidationError(err) + } + + // Existing app + existingApp, err := s.adapter.GetApp(ctx, input) + if err != nil { + return err + } + + // Delete the app + appBase, err := s.adapter.UninstallApp(ctx, input) + if err != nil { + return err + } + + // Emit the app deleted event + eventAppData, err := existingApp.GetEventAppData() + if err != nil { + return err + } + + event := app.NewAppDeleteEvent(ctx, *appBase, eventAppData) + if err := s.publisher.Publish(ctx, event); err != nil { + return err + } + + return nil +} + +func (s *Service) UpdateAppStatus(ctx context.Context, input app.UpdateAppStatusInput) error { + // Validate the input + if err := input.Validate(); err != nil { + return models.NewGenericValidationError(err) + } + + // Update the app status + if err := s.adapter.UpdateAppStatus(ctx, input); err != nil { + return err + } + + // Get the app after status update to include in the event + updatedApp, err := s.adapter.GetApp(ctx, input.ID) + if err != nil { + return err + } + + // Emit the app updated event + event, err := app.NewAppUpdateEvent(ctx, updatedApp) + if err != nil { + return err + } + + if err := s.publisher.Publish(ctx, event); err != nil { + return err + } + + return nil +} diff --git a/app/service/customer.go b/app/service/customer.go new file mode 100644 index 0000000000000000000000000000000000000000..535d5cace6372c33212a1d0243cde1d77a714702 --- /dev/null +++ b/app/service/customer.go @@ -0,0 +1,22 @@ +package appservice + +import ( + "context" + + "github.com/openmeterio/openmeter/openmeter/app" + "github.com/openmeterio/openmeter/pkg/pagination" +) + +var _ app.AppService = (*Service)(nil) + +func (s *Service) ListCustomerData(ctx context.Context, input app.ListCustomerInput) (pagination.Result[app.CustomerApp], error) { + return s.adapter.ListCustomerData(ctx, input) +} + +func (s *Service) EnsureCustomer(ctx context.Context, input app.EnsureCustomerInput) error { + return s.adapter.EnsureCustomer(ctx, input) +} + +func (s *Service) DeleteCustomer(ctx context.Context, input app.DeleteCustomerInput) error { + return s.adapter.DeleteCustomer(ctx, input) +} diff --git a/app/service/marketplace.go b/app/service/marketplace.go new file mode 100644 index 0000000000000000000000000000000000000000..92e4ad4f4921a8c98adc51a819fb82e64d4ab7ad --- /dev/null +++ b/app/service/marketplace.go @@ -0,0 +1,105 @@ +package appservice + +import ( + "context" + "errors" + "fmt" + + "github.com/openmeterio/openmeter/openmeter/app" + "github.com/openmeterio/openmeter/pkg/framework/transaction" + "github.com/openmeterio/openmeter/pkg/models" + "github.com/openmeterio/openmeter/pkg/pagination" +) + +var _ app.AppService = (*Service)(nil) + +func (s *Service) RegisterMarketplaceListing(input app.RegisterMarketplaceListingInput) error { + if err := input.Validate(); err != nil { + return models.NewGenericValidationError(err) + } + + return s.adapter.RegisterMarketplaceListing(input) +} + +func (s *Service) GetMarketplaceListing(ctx context.Context, input app.MarketplaceGetInput) (app.RegistryItem, error) { + if err := input.Validate(); err != nil { + return app.RegistryItem{}, models.NewGenericValidationError(err) + } + + return s.adapter.GetMarketplaceListing(ctx, input) +} + +func (s *Service) ListMarketplaceListings(ctx context.Context, input app.MarketplaceListInput) (pagination.Result[app.RegistryItem], error) { + if err := input.Validate(); err != nil { + return pagination.Result[app.RegistryItem]{}, models.NewGenericValidationError(err) + } + + return s.adapter.ListMarketplaceListings(ctx, input) +} + +func (s *Service) InstallApp(ctx context.Context, input app.InstallAppV3Input) (app.InstallAppV3Output, error) { + if err := input.Validate(); err != nil { + return app.InstallAppV3Output{}, models.NewGenericValidationError(err) + } + + return transaction.Run(ctx, s.adapter, func(ctx context.Context) (app.InstallAppV3Output, error) { + var installedApp app.App + var err error + if input.APIKey != nil { + installedApp, err = s.adapter.InstallMarketplaceListingWithAPIKey(ctx, app.InstallAppWithAPIKeyInput{ + InstallAppInput: app.InstallAppInput{ + MarketplaceListingID: app.MarketplaceListingID{ + Type: input.Type, + }, + Namespace: input.Namespace, + Name: input.Name, + }, + APIKey: *input.APIKey, + }) + } else { + installedApp, err = s.adapter.InstallMarketplaceListing(ctx, app.InstallAppInput{ + MarketplaceListingID: input.MarketplaceListingID, + Namespace: input.Namespace, + Name: input.Name, + }) + } + + if err != nil { + return app.InstallAppV3Output{}, err + } + + out := app.InstallAppV3Output{ + App: installedApp, + } + + if input.CreateDefaultBillingProfile { + if input.CreateDefaultBillingProfileFn == nil { + return app.InstallAppV3Output{}, errors.New("create default billing profile function is required when CreateDefaultBillingProfile is true") + } + defaultForCapabilityTypes, err := input.CreateDefaultBillingProfileFn(ctx, installedApp) + if err != nil { + return app.InstallAppV3Output{}, fmt.Errorf("create billing profile: %w", err) + } + + out.DefaultCapabilies = defaultForCapabilityTypes + } + + return out, nil + }) +} + +func (s *Service) GetMarketplaceListingOauth2InstallURL(ctx context.Context, input app.GetOauth2InstallURLInput) (app.GetOauth2InstallURLOutput, error) { + if err := input.Validate(); err != nil { + return app.GetOauth2InstallURLOutput{}, models.NewGenericValidationError(err) + } + + return s.adapter.GetMarketplaceListingOauth2InstallURL(ctx, input) +} + +func (s *Service) AuthorizeMarketplaceListingOauth2Install(ctx context.Context, input app.AuthorizeOauth2InstallInput) error { + if err := input.Validate(); err != nil { + return models.NewGenericValidationError(err) + } + + return s.adapter.AuthorizeMarketplaceListingOauth2Install(ctx, input) +} diff --git a/app/service/service.go b/app/service/service.go new file mode 100644 index 0000000000000000000000000000000000000000..5dd4bab6b3684cb8764e007c0324de5366b4d958 --- /dev/null +++ b/app/service/service.go @@ -0,0 +1,43 @@ +package appservice + +import ( + "errors" + + "github.com/openmeterio/openmeter/openmeter/app" + "github.com/openmeterio/openmeter/openmeter/watermill/eventbus" +) + +var _ app.Service = (*Service)(nil) + +type Service struct { + adapter app.Adapter + publisher eventbus.Publisher +} + +type Config struct { + Adapter app.Adapter + Publisher eventbus.Publisher +} + +func (c Config) Validate() error { + if c.Adapter == nil { + return errors.New("adapter cannot be null") + } + + if c.Publisher == nil { + return errors.New("publisher cannot be null") + } + + return nil +} + +func New(config Config) (*Service, error) { + if err := config.Validate(); err != nil { + return nil, err + } + + return &Service{ + adapter: config.Adapter, + publisher: config.Publisher, + }, nil +} diff --git a/app/stripe/adapter.go b/app/stripe/adapter.go new file mode 100644 index 0000000000000000000000000000000000000000..b19240eaaee709efa44898f38d65abe380cae01d --- /dev/null +++ b/app/stripe/adapter.go @@ -0,0 +1,40 @@ +package appstripe + +import ( + "context" + + "github.com/stripe/stripe-go/v80" + + "github.com/openmeterio/openmeter/openmeter/app/stripe/client" + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/pkg/framework/entutils" +) + +type Adapter interface { + AppStripeAdapter + + entutils.TxCreator +} + +type AppStripeAdapter interface { + GetStripeClientFactory() client.StripeClientFactory + GetStripeAppClientFactory() client.StripeAppClientFactory + + UpdateAPIKey(ctx context.Context, input UpdateAPIKeyAdapterInput) error + CreateCheckoutSession(ctx context.Context, input CreateCheckoutSessionInput) (CreateCheckoutSessionOutput, error) + GetWebhookSecret(ctx context.Context, input GetWebhookSecretInput) (GetWebhookSecretOutput, error) + // App + CreateStripeApp(ctx context.Context, input CreateAppStripeInput) (AppBase, error) + GetStripeAppData(ctx context.Context, input GetStripeAppDataInput) (AppData, error) + DeleteStripeAppData(ctx context.Context, input DeleteStripeAppDataInput) error + // Billing + GetSupplierContact(ctx context.Context, input GetSupplierContactInput) (billing.SupplierContact, error) + GetStripeInvoice(ctx context.Context, input GetStripeInvoiceInput) (*stripe.Invoice, error) + // Customer + GetStripeCustomerData(ctx context.Context, input GetStripeCustomerDataInput) (CustomerData, error) + UpsertStripeCustomerData(ctx context.Context, input UpsertStripeCustomerDataInput) error + DeleteStripeCustomerData(ctx context.Context, input DeleteStripeCustomerDataInput) error + SetCustomerDefaultPaymentMethod(ctx context.Context, input SetCustomerDefaultPaymentMethodInput) (SetCustomerDefaultPaymentMethodOutput, error) + // Portal + CreatePortalSession(ctx context.Context, input CreateStripePortalSessionInput) (StripePortalSession, error) +} diff --git a/app/stripe/adapter/adapter.go b/app/stripe/adapter/adapter.go new file mode 100644 index 0000000000000000000000000000000000000000..f9987f8f218db9a2a9d978076bb5a5e49a468f2e --- /dev/null +++ b/app/stripe/adapter/adapter.go @@ -0,0 +1,124 @@ +package appstripeadapter + +import ( + "context" + "database/sql" + "errors" + "fmt" + "log/slog" + + "github.com/openmeterio/openmeter/openmeter/app" + appstripe "github.com/openmeterio/openmeter/openmeter/app/stripe" + stripeclient "github.com/openmeterio/openmeter/openmeter/app/stripe/client" + "github.com/openmeterio/openmeter/openmeter/customer" + entdb "github.com/openmeterio/openmeter/openmeter/ent/db" + "github.com/openmeterio/openmeter/openmeter/secret" + "github.com/openmeterio/openmeter/pkg/framework/entutils" + "github.com/openmeterio/openmeter/pkg/framework/transaction" +) + +type Config struct { + Client *entdb.Client + AppService app.Service + CustomerService customer.Service + SecretService secret.Service + StripeClientFactory stripeclient.StripeClientFactory + StripeAppClientFactory stripeclient.StripeAppClientFactory + Logger *slog.Logger +} + +func (c Config) Validate() error { + if c.Client == nil { + return errors.New("ent client is required") + } + + if c.AppService == nil { + return errors.New("app service is required") + } + + if c.CustomerService == nil { + return errors.New("customer service is required") + } + + if c.SecretService == nil { + return errors.New("secret service is required") + } + + if c.Logger == nil { + return errors.New("logger is required") + } + + return nil +} + +func New(config Config) (appstripe.Adapter, error) { + if err := config.Validate(); err != nil { + return nil, fmt.Errorf("failed to validate config: %w", err) + } + + // Create stripe client factory + stripeClientFactory := config.StripeClientFactory + if stripeClientFactory == nil { + stripeClientFactory = stripeclient.NewStripeClient + } + + // Create stripe app client factory + stripeAppClientFactory := config.StripeAppClientFactory + if stripeAppClientFactory == nil { + stripeAppClientFactory = stripeclient.NewStripeAppClient + } + + // Create app stripe adapter + adapter := &adapter{ + db: config.Client, + logger: config.Logger, + appService: config.AppService, + customerService: config.CustomerService, + secretService: config.SecretService, + stripeClientFactory: stripeClientFactory, + stripeAppClientFactory: stripeAppClientFactory, + } + + return adapter, nil +} + +var _ appstripe.Adapter = (*adapter)(nil) + +type adapter struct { + db *entdb.Client + + logger *slog.Logger + + appService app.Service + customerService customer.Service + secretService secret.Service + stripeAppClientFactory stripeclient.StripeAppClientFactory + stripeClientFactory stripeclient.StripeClientFactory +} + +// Tx implements entutils.TxCreator interface +func (a *adapter) Tx(ctx context.Context) (context.Context, transaction.Driver, error) { + txCtx, rawConfig, eDriver, err := a.db.HijackTx(ctx, &sql.TxOptions{ + ReadOnly: false, + }) + if err != nil { + return nil, nil, fmt.Errorf("failed to hijack transaction: %w", err) + } + return txCtx, entutils.NewTxDriver(eDriver, rawConfig), nil +} + +func (a *adapter) WithTx(ctx context.Context, tx *entutils.TxDriver) *adapter { + txClient := entdb.NewTxClientFromRawConfig(ctx, *tx.GetConfig()) + return &adapter{ + db: txClient.Client(), + appService: a.appService, + customerService: a.customerService, + secretService: a.secretService, + stripeClientFactory: a.stripeClientFactory, + stripeAppClientFactory: a.stripeAppClientFactory, + } +} + +func (a *adapter) Self() *adapter { + return a +} diff --git a/app/stripe/adapter/customer.go b/app/stripe/adapter/customer.go new file mode 100644 index 0000000000000000000000000000000000000000..0334365964dfc7eec40507d7f81f96eb19faa848 --- /dev/null +++ b/app/stripe/adapter/customer.go @@ -0,0 +1,290 @@ +package appstripeadapter + +import ( + "context" + "fmt" + + "entgo.io/ent/dialect/sql" + + "github.com/openmeterio/openmeter/openmeter/app" + appstripe "github.com/openmeterio/openmeter/openmeter/app/stripe" + stripeclient "github.com/openmeterio/openmeter/openmeter/app/stripe/client" + entdb "github.com/openmeterio/openmeter/openmeter/ent/db" + appstripecustomerdb "github.com/openmeterio/openmeter/openmeter/ent/db/appstripecustomer" + "github.com/openmeterio/openmeter/pkg/framework/entutils" + "github.com/openmeterio/openmeter/pkg/models" +) + +// GetStripeCustomerData gets stripe customer data +func (a *adapter) GetStripeCustomerData(ctx context.Context, input appstripe.GetStripeCustomerDataInput) (appstripe.CustomerData, error) { + if err := input.Validate(); err != nil { + return appstripe.CustomerData{}, models.NewGenericValidationError( + fmt.Errorf("error getting stripe customer data: %w", err), + ) + } + + stripeCustomerDBEntity, err := a.db.AppStripeCustomer. + Query(). + Where(appstripecustomerdb.Namespace(input.AppID.Namespace)). + Where(appstripecustomerdb.AppID(input.AppID.ID)). + Where(appstripecustomerdb.CustomerID(input.CustomerID.ID)). + Only(ctx) + if err != nil { + if entdb.IsNotFound(err) { + return appstripe.CustomerData{}, app.NewAppCustomerPreConditionError( + input.AppID, + app.AppTypeStripe, + &input.CustomerID, + "customer has no data for stripe app", + ) + } + + return appstripe.CustomerData{}, fmt.Errorf("error getting stripe customer data: %w", err) + } + + customerData := appstripe.CustomerData{ + StripeCustomerID: stripeCustomerDBEntity.StripeCustomerID, + StripeDefaultPaymentMethodID: stripeCustomerDBEntity.StripeDefaultPaymentMethodID, + } + + if err := customerData.Validate(); err != nil { + return appstripe.CustomerData{}, fmt.Errorf("error validating stripe customer data: %w", err) + } + + return customerData, nil +} + +// UpsertStripeCustomerData upserts stripe customer data +func (a *adapter) UpsertStripeCustomerData(ctx context.Context, input appstripe.UpsertStripeCustomerDataInput) error { + if err := input.Validate(); err != nil { + return models.NewGenericValidationError( + fmt.Errorf("error upsert stripe customer data: %w", err), + ) + } + + // Get the stripe app client + stripeAppData, stripeAppClient, err := a.getStripeAppClient(ctx, input.AppID, "upsertStripeCustomerData", "customer_id", input.CustomerID.ID, "stripe_customer_id", input.StripeCustomerID) + if err != nil { + return fmt.Errorf("failed to get stripe app client: %w", err) + } + + // Check if the Stripe customer exists in the stripe account + _, err = stripeAppClient.GetCustomer(ctx, input.StripeCustomerID) + if err != nil { + if stripeclient.IsStripeCustomerNotFoundError(err) { + return app.NewAppCustomerPreConditionError( + input.AppID, + app.AppTypeStripe, + &input.CustomerID, + fmt.Sprintf("stripe customer %s not found in stripe account: %s", input.StripeCustomerID, stripeAppData.StripeAccountID), + ) + } + + return fmt.Errorf("failed to get stripe customer: %w", err) + } + + // Check if the Stripe payment method exists in the stripe account + if input.StripeDefaultPaymentMethodID != nil { + paymentMethod, err := stripeAppClient.GetPaymentMethod(ctx, *input.StripeDefaultPaymentMethodID) + if err != nil { + if stripeclient.IsStripePaymentMethodNotFoundError(err) { + return app.NewAppProviderPreConditionError( + input.AppID, + fmt.Sprintf("stripe payment method %s not found in stripe account: %s", *input.StripeDefaultPaymentMethodID, stripeAppData.StripeAccountID), + ) + } + + return fmt.Errorf("failed to get stripe payment method: %w", err) + } + + // Check if the payment method belongs to the customer + if paymentMethod.StripeCustomerID == nil || *paymentMethod.StripeCustomerID != input.StripeCustomerID { + return app.NewAppProviderPreConditionError( + input.AppID, + fmt.Sprintf( + "stripe payment method %s does not belong to stripe customer %s in stripe account: %s", + *input.StripeDefaultPaymentMethodID, + input.StripeCustomerID, + stripeAppData.StripeAccountID, + ), + ) + } + } + + // Start transaction + _, err = entutils.TransactingRepo(ctx, a, func(ctx context.Context, repo *adapter) (any, error) { + // Make sure the customer has an app relationship + err := repo.appService.EnsureCustomer(ctx, app.EnsureCustomerInput{ + AppID: input.AppID, + CustomerID: input.CustomerID, + }) + if err != nil { + return nil, fmt.Errorf("failed to ensure customer: %w", err) + } + + // Upsert stripe customer data + err = repo.db.AppStripeCustomer. + Create(). + SetNamespace(input.AppID.Namespace). + SetStripeAppID(input.AppID.ID). + SetCustomerID(input.CustomerID.ID). + SetStripeCustomerID(input.StripeCustomerID). + SetNillableStripeDefaultPaymentMethodID(input.StripeDefaultPaymentMethodID). + // Upsert + OnConflict( + sql.ConflictColumns( + appstripecustomerdb.FieldNamespace, + appstripecustomerdb.FieldAppID, + appstripecustomerdb.FieldCustomerID, + ), + sql.ConflictWhere(sql.IsNull(appstripecustomerdb.FieldDeletedAt)), + ). + UpdateStripeCustomerID(). + UpdateStripeDefaultPaymentMethodID(). + Exec(ctx) + if err != nil { + if entdb.IsConstraintError(err) { + a.logger.WarnContext(ctx, "failed to upsert app stripe customer data", + "error", err, + "app_id", input.AppID.ID, + "customer_id", input.CustomerID.ID, + "stripe_customer_id", input.StripeCustomerID, + ) + + return nil, app.NewAppCustomerPreConditionError( + input.AppID, + app.AppTypeStripe, + &input.CustomerID, + "unique stripe customer id", + ) + } + + return nil, fmt.Errorf("failed to upsert app stripe customer data: %w", err) + } + + return nil, nil + }) + + return err +} + +// DeleteStripeCustomerData deletes stripe customer data +func (a *adapter) DeleteStripeCustomerData(ctx context.Context, input appstripe.DeleteStripeCustomerDataInput) error { + if err := input.Validate(); err != nil { + return models.NewGenericValidationError( + fmt.Errorf("error delete stripe customer data: %w", err), + ) + } + + // Determine namespace + var namespace string + + if input.AppID != nil { + namespace = input.AppID.Namespace + } + + if input.CustomerID != nil { + namespace = input.CustomerID.Namespace + } + + if namespace == "" { + return models.NewGenericValidationError( + fmt.Errorf("error delete stripe customer data: namespace is empty"), + ) + } + + // Start transaction + _, err := entutils.TransactingRepo(ctx, a, func(ctx context.Context, repo *adapter) (any, error) { + // Delete stripe app customer data + query := repo.db.AppStripeCustomer. + Delete(). + Where( + appstripecustomerdb.Namespace(namespace), + ) + + if input.CustomerID != nil { + query = query.Where(appstripecustomerdb.CustomerID(input.CustomerID.ID)) + } + + if input.AppID != nil { + query = query.Where(appstripecustomerdb.AppID(input.AppID.ID)) + } + + _, err := query.Exec(ctx) + if err != nil { + return nil, fmt.Errorf("failed to delete app stripe customer data: %w", err) + } + + // Delete app customer relationship + err = repo.appService.DeleteCustomer(ctx, app.DeleteCustomerInput{ + AppID: input.AppID, + CustomerID: input.CustomerID, + }) + if err != nil { + return nil, fmt.Errorf("failed to delete customer relationship: %w", err) + } + + return nil, nil + }) + return err +} + +// createStripeCustomer creates a new stripe customer +func (a *adapter) createStripeCustomer(ctx context.Context, input appstripe.CreateStripeCustomerInput) (appstripe.CreateStripeCustomerOutput, error) { + // Get the stripe app + stripeAppData, err := a.GetStripeAppData(ctx, appstripe.GetStripeAppDataInput{ + AppID: input.AppID, + }) + if err != nil { + return appstripe.CreateStripeCustomerOutput{}, fmt.Errorf("failed to get stripe app data: %w", err) + } + + // Get Stripe API Key + apiKeySecret, err := a.secretService.GetAppSecret(ctx, stripeAppData.APIKey) + if err != nil { + return appstripe.CreateStripeCustomerOutput{}, fmt.Errorf("failed to get stripe api key secret: %w", err) + } + + // Stripe Client + stripeClient, err := a.stripeAppClientFactory(stripeclient.StripeAppClientConfig{ + AppID: input.AppID, + AppService: a.appService, + APIKey: apiKeySecret.Value, + Logger: a.logger.With("operation", "createStripeCustomer", "app_id", input.AppID.ID, "customer_id", input.CustomerID.ID), + }) + if err != nil { + return appstripe.CreateStripeCustomerOutput{}, fmt.Errorf("failed to create stripe client: %w", err) + } + + // Create stripe customer + stripeCustomer, err := stripeClient.CreateCustomer(ctx, stripeclient.CreateStripeCustomerInput{ + AppID: input.AppID, + CustomerID: input.CustomerID, + Name: input.Name, + Email: input.Email, + }) + if err != nil { + return appstripe.CreateStripeCustomerOutput{}, fmt.Errorf("failed to create stripe customer: %w", err) + } + + // Upsert stripe customer data + err = a.UpsertStripeCustomerData(ctx, appstripe.UpsertStripeCustomerDataInput{ + AppID: input.AppID, + CustomerID: input.CustomerID, + StripeCustomerID: stripeCustomer.StripeCustomerID, + }) + if err != nil { + return appstripe.CreateStripeCustomerOutput{}, fmt.Errorf("failed to upsert stripe customer data: %w", err) + } + + // Output + out := appstripe.CreateStripeCustomerOutput{ + StripeCustomerID: stripeCustomer.StripeCustomerID, + } + + if err := out.Validate(); err != nil { + return appstripe.CreateStripeCustomerOutput{}, fmt.Errorf("failed to validate create stripe customer output: %w", err) + } + + return out, nil +} diff --git a/app/stripe/adapter/stripe.go b/app/stripe/adapter/stripe.go new file mode 100644 index 0000000000000000000000000000000000000000..c6f556148a772a96994c7b44f56fdaa3d682ac0a --- /dev/null +++ b/app/stripe/adapter/stripe.go @@ -0,0 +1,682 @@ +package appstripeadapter + +import ( + "context" + "errors" + "fmt" + + "github.com/samber/lo" + "github.com/stripe/stripe-go/v80" + + "github.com/openmeterio/openmeter/openmeter/app" + appstripe "github.com/openmeterio/openmeter/openmeter/app/stripe" + stripeclient "github.com/openmeterio/openmeter/openmeter/app/stripe/client" + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/customer" + entdb "github.com/openmeterio/openmeter/openmeter/ent/db" + appstripedb "github.com/openmeterio/openmeter/openmeter/ent/db/appstripe" + appstripecustomerdb "github.com/openmeterio/openmeter/openmeter/ent/db/appstripecustomer" + secretentity "github.com/openmeterio/openmeter/openmeter/secret/entity" + "github.com/openmeterio/openmeter/pkg/framework/entutils" + "github.com/openmeterio/openmeter/pkg/models" +) + +var _ appstripe.AppStripeAdapter = (*adapter)(nil) + +// GetStripeClientFactory gets the stripe client factory +func (a adapter) GetStripeClientFactory() stripeclient.StripeClientFactory { + return a.stripeClientFactory +} + +// GetStripeAppClientFactory gets the stripe client factory +func (a adapter) GetStripeAppClientFactory() stripeclient.StripeAppClientFactory { + return a.stripeAppClientFactory +} + +// CreateApp creates a new app +func (a *adapter) CreateStripeApp(ctx context.Context, input appstripe.CreateAppStripeInput) (appstripe.AppBase, error) { + if err := input.Validate(); err != nil { + return appstripe.AppBase{}, models.NewGenericValidationError( + fmt.Errorf("error create stripe app: %w", err), + ) + } + + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, repo *adapter) (appstripe.AppBase, error) { + // Create the base app + appBase, err := repo.appService.CreateApp(ctx, input.CreateAppInput) + if err != nil { + return appstripe.AppBase{}, fmt.Errorf("failed to create app: %w", err) + } + + // Create the stripe app in the database + appStripeCreateQuery := repo.db.AppStripe.Create(). + SetID(appBase.GetID().ID). + SetNamespace(input.Namespace). + SetStripeAccountID(input.StripeAccountID). + SetStripeLivemode(input.Livemode). + SetAPIKey(input.APIKey.ID). + SetStripeWebhookID(input.StripeWebhookID). + SetWebhookSecret(input.WebhookSecret.ID). + SetMaskedAPIKey(input.MaskedAPIKey) + + dbApp, err := appStripeCreateQuery.Save(ctx) + if err != nil { + if entdb.IsConstraintError(err) { + return appstripe.AppBase{}, models.NewGenericConflictError( + fmt.Errorf("stripe app already exists with stripe account id: %s in namespace %s", input.StripeAccountID, appBase.GetID().Namespace), + ) + } + + return appstripe.AppBase{}, fmt.Errorf("failed to create stripe app: %w", err) + } + + // Map the database stripe app to an app entity + appData := mapAppStripeData(appBase.GetID(), dbApp) + + return appstripe.AppBase{ + AppBase: appBase, + AppData: appData, + }, nil + }) +} + +// UpdateAPIKey replaces the API key +func (a *adapter) UpdateAPIKey(ctx context.Context, input appstripe.UpdateAPIKeyAdapterInput) error { + // Validate the input + if err := input.Validate(); err != nil { + return models.NewGenericValidationError( + fmt.Errorf("error replace api key: %w", err), + ) + } + + // Get the stripe app data + appData, err := a.GetStripeAppData(ctx, appstripe.GetStripeAppDataInput{ + AppID: input.AppID, + }) + if err != nil { + return fmt.Errorf("failed to get stripe app data: %w", err) + } + + // Validate the new API key + stripeClient, err := a.stripeAppClientFactory(stripeclient.StripeAppClientConfig{ + AppID: input.AppID, + AppService: a.appService, + APIKey: input.APIKey, + Logger: a.logger.With("operation", "validateStripeAPIKey", "app_id", input.AppID.ID), + }) + if err != nil { + return fmt.Errorf("failed to create stripe client: %w", err) + } + + // Check if new API Key in the same live or test mode as the app + livemode := stripeclient.IsAPIKeyLiveMode(input.APIKey) + if livemode != appData.Livemode { + var err error + + if livemode { + err = errors.New("new stripe api key is in live mode but the app is in test mode") + } else { + err = errors.New("new stripe api key is in test mode but the app is in live mode") + } + + return models.NewGenericValidationError( + err, + ) + } + + // Check if it belongs to the same stripe account + stripeAccount, err := stripeClient.GetAccount(ctx) + if err != nil { + return fmt.Errorf("failed to validate stripe api key: %w", err) + } + + // Check if the stripe account id matches with the stored one + if stripeAccount.StripeAccountID != appData.StripeAccountID { + return models.NewGenericValidationError( + fmt.Errorf("stripe account id mismatch: %s != %s", stripeAccount.StripeAccountID, appData.StripeAccountID), + ) + } + + // Update the API key + newApiKeySecretID, err := a.secretService.UpdateAppSecret(ctx, secretentity.UpdateAppSecretInput{ + AppID: input.AppID, + SecretID: appData.APIKey, + Key: appstripe.APIKeySecretKey, + Value: input.APIKey, + }) + if err != nil { + return fmt.Errorf("failed to update api key app secret: %w", err) + } + + return entutils.TransactingRepoWithNoValue(ctx, a, func(ctx context.Context, repo *adapter) error { + // Update the API key in the database if it has changed + // Some secrets stores don't update the id when updating the value + if appData.APIKey.ID != newApiKeySecretID.ID { + err = repo.db.AppStripe.Update(). + Where(appstripedb.Namespace(input.AppID.Namespace)). + Where(appstripedb.ID(input.AppID.ID)). + SetAPIKey(newApiKeySecretID.ID). + SetMaskedAPIKey(input.MaskedAPIKey). + Exec(ctx) + if err != nil { + return fmt.Errorf("failed to update api key: %w", err) + } + } + + // Update the app status to ready + status := app.AppStatusReady + + err = a.appService.UpdateAppStatus(ctx, app.UpdateAppStatusInput{ + ID: input.AppID, + Status: status, + }) + if err != nil { + return fmt.Errorf("failed to update app status to %s for %s: %w", input.AppID.ID, status, err) + } + + return nil + }) +} + +// GetStripeAppData gets stripe customer data +func (a *adapter) GetStripeAppData(ctx context.Context, input appstripe.GetStripeAppDataInput) (appstripe.AppData, error) { + if err := input.Validate(); err != nil { + return appstripe.AppData{}, models.NewGenericValidationError( + fmt.Errorf("error getting stripe customer data: %w", err), + ) + } + + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, repo *adapter) (appstripe.AppData, error) { + dbApp, err := repo.db.AppStripe. + Query(). + Where(appstripedb.Namespace(input.AppID.Namespace)). + Where(appstripedb.ID(input.AppID.ID)). + Only(ctx) + if err != nil { + if entdb.IsNotFound(err) { + return appstripe.AppData{}, app.NewAppNotFoundError(input.AppID) + } + + return appstripe.AppData{}, fmt.Errorf("error getting stripe customer data: %w", err) + } + + // Map the database stripe app to an app entity + appData := mapAppStripeData(input.AppID, dbApp) + if err := appData.Validate(); err != nil { + return appstripe.AppData{}, models.NewGenericValidationError(fmt.Errorf("error validating stripe app data: %w", err)) + } + + return appData, nil + }) +} + +// DeleteStripeAppData deletes the stripe app data +func (a *adapter) DeleteStripeAppData(ctx context.Context, input appstripe.DeleteStripeAppDataInput) error { + if err := input.Validate(); err != nil { + return models.NewGenericValidationError( + fmt.Errorf("error delete stripe app: %w", err), + ) + } + + return entutils.TransactingRepoWithNoValue(ctx, a, func(ctx context.Context, repo *adapter) error { + // Delete the stripe app data + _, err := repo.db.AppStripe. + Delete(). + Where(appstripedb.Namespace(input.AppID.Namespace)). + Where(appstripedb.ID(input.AppID.ID)). + Exec(ctx) + if err != nil { + if entdb.IsNotFound(err) { + return app.NewAppNotFoundError(input.AppID) + } + + return fmt.Errorf("failed to delete stripe app: %w", err) + } + + return nil + }) +} + +// GetWebhookSecret gets the webhook secret +func (a *adapter) GetWebhookSecret(ctx context.Context, input appstripe.GetWebhookSecretInput) (appstripe.GetWebhookSecretOutput, error) { + if err := input.Validate(); err != nil { + return secretentity.Secret{}, models.NewGenericValidationError( + fmt.Errorf("error get webhook secret: %w", err), + ) + } + + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, repo *adapter) (appstripe.GetWebhookSecretOutput, error) { + // Get the stripe app + stripeApp, err := repo.db.AppStripe. + Query(). + // We intentionally do not filter by namespace as the webhook payload is signed with the secret + Where(appstripedb.ID(input.AppID)). + Only(ctx) + if err != nil { + if entdb.IsNotFound(err) { + // We don't know the namespace from the app id for webhook requests + return secretentity.Secret{}, app.NewAppNotFoundError(app.AppID{ + Namespace: "", + ID: input.AppID, + }) + } + + return secretentity.Secret{}, fmt.Errorf("failed to get stripe app: %w", err) + } + + // Get the webhook secret + appID := app.AppID{ + Namespace: stripeApp.Namespace, + ID: stripeApp.ID, + } + + secret, err := a.secretService.GetAppSecret(ctx, secretentity.NewSecretID(appID, stripeApp.WebhookSecret, appstripe.WebhookSecretKey)) + if err != nil { + return secretentity.Secret{}, fmt.Errorf("failed to get webhook secret: %w", err) + } + + return secret, nil + }) +} + +// SetCustomerDefaultPaymentMethod sets the default payment method for a customer +func (a *adapter) SetCustomerDefaultPaymentMethod(ctx context.Context, input appstripe.SetCustomerDefaultPaymentMethodInput) (appstripe.SetCustomerDefaultPaymentMethodOutput, error) { + if err := input.Validate(); err != nil { + return appstripe.SetCustomerDefaultPaymentMethodOutput{}, models.NewGenericValidationError( + fmt.Errorf("error set customer default payment method: %w", err), + ) + } + + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, repo *adapter) (appstripe.SetCustomerDefaultPaymentMethodOutput, error) { + // Get the stripe app customer + appCustomer, err := repo.db.AppStripeCustomer. + Query(). + Where( + appstripecustomerdb.Namespace(input.AppID.Namespace), + appstripecustomerdb.AppID(input.AppID.ID), + appstripecustomerdb.StripeCustomerID(input.StripeCustomerID), + ). + Only(ctx) + if err != nil { + if entdb.IsNotFound(err) { + return appstripe.SetCustomerDefaultPaymentMethodOutput{}, app.NewAppCustomerPreConditionError( + input.AppID, + app.AppTypeStripe, + nil, + fmt.Sprintf("stripe customer has no data for stripe app: %s", input.StripeCustomerID), + ) + } + } + + customerID := customer.CustomerID{ + Namespace: input.AppID.Namespace, + ID: appCustomer.CustomerID, + } + + // Check if the stripe customer id matches with the input + if appCustomer.StripeCustomerID != input.StripeCustomerID { + return appstripe.SetCustomerDefaultPaymentMethodOutput{}, app.NewAppCustomerPreConditionError( + input.AppID, + app.AppTypeStripe, + &customerID, + "customer stripe customer id mismatch", + ) + } + + _, err = repo.db.AppStripeCustomer. + Update(). + Where( + appstripecustomerdb.Namespace(input.AppID.Namespace), + appstripecustomerdb.AppID(input.AppID.ID), + appstripecustomerdb.CustomerID(customerID.ID), + ). + SetStripeDefaultPaymentMethodID(input.PaymentMethodID). + Save(ctx) + if err != nil { + return appstripe.SetCustomerDefaultPaymentMethodOutput{}, fmt.Errorf("failed to set customer default payment method: %w", err) + } + + return appstripe.SetCustomerDefaultPaymentMethodOutput{ + CustomerID: customerID, + }, nil + }) +} + +// CreateCheckoutSession creates a new checkout session +func (a *adapter) CreateCheckoutSession(ctx context.Context, input appstripe.CreateCheckoutSessionInput) (appstripe.CreateCheckoutSessionOutput, error) { + if err := input.Validate(); err != nil { + return appstripe.CreateCheckoutSessionOutput{}, models.NewGenericValidationError( + fmt.Errorf("error create checkout session: %w", err), + ) + } + + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, repo *adapter) (appstripe.CreateCheckoutSessionOutput, error) { + // Get the stripe app + stripeApp, err := repo.db.AppStripe. + Query(). + Where(appstripedb.ID(input.AppID.ID)). + Where(appstripedb.Namespace(input.AppID.Namespace)). + Only(ctx) + if err != nil { + if entdb.IsNotFound(err) { + return appstripe.CreateCheckoutSessionOutput{}, app.NewAppNotFoundError(input.AppID) + } + + return appstripe.CreateCheckoutSessionOutput{}, fmt.Errorf("failed to get stripe app: %w", err) + } + + // Get or create customer + var targetCustomer *customer.Customer + + if input.CustomerID != nil { + targetCustomer, err = repo.customerService.GetCustomer(ctx, customer.GetCustomerInput{ + CustomerID: input.CustomerID, + }) + if err != nil { + return appstripe.CreateCheckoutSessionOutput{}, fmt.Errorf("failed to get customer: %w", err) + } + + if targetCustomer != nil && targetCustomer.IsDeleted() { + return appstripe.CreateCheckoutSessionOutput{}, + models.NewGenericPreConditionFailedError( + fmt.Errorf("customer is deleted [namespace=%s customer.id=%s]", targetCustomer.Namespace, targetCustomer.ID), + ) + } + } + + // Create a customer if create input is provided + if input.CreateCustomerInput != nil { + targetCustomer, err = repo.customerService.CreateCustomer(ctx, *input.CreateCustomerInput) + if err != nil { + return appstripe.CreateCheckoutSessionOutput{}, fmt.Errorf("failed to create customer: %w", err) + } + } + + customerID := targetCustomer.GetID() + + // Get the stripe app customer + var stripeCustomerId string + { + stripeAppCustomer, err := repo.db.AppStripeCustomer. + Query(). + Where(appstripecustomerdb.AppID(input.AppID.ID)). + Where(appstripecustomerdb.Namespace(input.AppID.Namespace)). + Where(appstripecustomerdb.CustomerID(customerID.ID)). + Only(ctx) + if err != nil { + if entdb.IsNotFound(err) { + // If Stripe Customer ID is provided we need to upsert it + if input.StripeCustomerID != nil { + err = a.UpsertStripeCustomerData(ctx, appstripe.UpsertStripeCustomerDataInput{ + AppID: input.AppID, + CustomerID: customerID, + StripeCustomerID: *input.StripeCustomerID, + }) + if err != nil { + return appstripe.CreateCheckoutSessionOutput{}, fmt.Errorf("failed to upsert stripe customer data: %w", err) + } + + stripeCustomerId = *input.StripeCustomerID + } else { + // Otherwise we create a new Stripe Customer + params := appstripe.CreateStripeCustomerInput{ + AppID: input.AppID, + CustomerID: customerID, + Name: &targetCustomer.Name, + Email: targetCustomer.PrimaryEmail, + } + + out, err := a.createStripeCustomer(ctx, params) + if err != nil { + return appstripe.CreateCheckoutSessionOutput{}, fmt.Errorf("failed to create stripe customer: %w", err) + } + + stripeCustomerId = out.StripeCustomerID + } + } else { + return appstripe.CreateCheckoutSessionOutput{}, fmt.Errorf("failed to get stripe app customer: %w", err) + } + } + + // If the stripe app customer exists we check if the Stripe Customer ID matches with the input + if stripeAppCustomer != nil { + if input.StripeCustomerID != nil && *input.StripeCustomerID != stripeAppCustomer.StripeCustomerID { + return appstripe.CreateCheckoutSessionOutput{}, fmt.Errorf("stripe customer id mismatch the one stored for customer: %s != %s", *input.StripeCustomerID, stripeAppCustomer.StripeCustomerID) + } + + stripeCustomerId = stripeAppCustomer.StripeCustomerID + } + } + + // We set the Stripe Customer ID + input.StripeCustomerID = &stripeCustomerId + + // Get Stripe API Key + apiKeySecret, err := repo.secretService.GetAppSecret(ctx, secretentity.NewSecretID(input.AppID, stripeApp.APIKey, appstripe.APIKeySecretKey)) + if err != nil { + return appstripe.CreateCheckoutSessionOutput{}, fmt.Errorf("failed to get stripe api key secret: %w", err) + } + + // Stripe Client + stripeClient, err := repo.stripeAppClientFactory(stripeclient.StripeAppClientConfig{ + AppID: input.AppID, + AppService: repo.appService, + APIKey: apiKeySecret.Value, + Logger: a.logger.With("operation", "createCheckoutSession", "app_id", input.AppID.ID, "customer_id", customerID.ID), + }) + if err != nil { + return appstripe.CreateCheckoutSessionOutput{}, fmt.Errorf("failed to create stripe client: %w", err) + } + + // Set the currency if customer has one and it is not provided + if input.Options.Currency == nil && targetCustomer.Currency != nil { + input.Options.Currency = stripeclient.CurrencyPtr(targetCustomer.Currency) + } + + // Create the checkout session + checkoutSession, err := stripeClient.CreateCheckoutSession(ctx, stripeclient.CreateCheckoutSessionInput{ + StripeCustomerID: stripeCustomerId, + AppID: input.AppID, + CustomerID: customerID, + Options: input.Options, + }) + if err != nil { + return appstripe.CreateCheckoutSessionOutput{}, fmt.Errorf("failed to create checkout session: %w", err) + } + + if err := checkoutSession.Validate(); err != nil { + return appstripe.CreateCheckoutSessionOutput{}, fmt.Errorf("failed to validate checkout session: %w", err) + } + + return appstripe.CreateCheckoutSessionOutput{ + AppID: input.AppID, + CustomerID: customerID, + StripeCustomerID: stripeCustomerId, + StripeCheckoutSession: checkoutSession, + }, nil + }) +} + +// GetSupplierContact returns a supplier contact for the app +func (a adapter) GetSupplierContact(ctx context.Context, input appstripe.GetSupplierContactInput) (billing.SupplierContact, error) { + // Validate input + if err := input.Validate(); err != nil { + return billing.SupplierContact{}, models.NewGenericValidationError( + fmt.Errorf("error validate input: %w", err), + ) + } + + // Get stripe app data + stripeAppData, err := a.GetStripeAppData(ctx, appstripe.GetStripeAppDataInput(input)) + if err != nil { + return billing.SupplierContact{}, fmt.Errorf("failed to get stripe app data: %w", err) + } + + // Test mode Stripe accounts do not have supplier contact information + if !stripeAppData.Livemode { + return billing.SupplierContact{ + // TODO: use organization name + Name: "Stripe Test Account", + Address: models.Address{ + Country: lo.ToPtr(models.CountryCode("US")), + }, + }, nil + } + + // Get Stripe App client + _, stripeAppClient, err := a.getStripeAppClient(ctx, input.AppID, "getSupplierContact", "app_id", input.AppID.ID) + if err != nil { + return billing.SupplierContact{}, fmt.Errorf("failed to get stripe app client: %w", err) + } + + // Get Stripe Account + stripeAccount, err := stripeAppClient.GetAccount(ctx) + if err != nil { + return billing.SupplierContact{}, fmt.Errorf("failed to get stripe account: %w", err) + } + + if stripeAccount.BusinessProfile == nil || stripeAccount.BusinessProfile.Name == "" { + return billing.SupplierContact{}, app.NewAppProviderPreConditionError( + input.AppID, + fmt.Sprintf("stripe account is missing business profile name: %s", stripeAccount.StripeAccountID), + ) + } + + if stripeAccount.Country == "" { + return billing.SupplierContact{}, app.NewAppProviderPreConditionError( + input.AppID, + fmt.Sprintf("stripe account country is empty: %s", stripeAccount.StripeAccountID), + ) + } + + supplierContact := billing.SupplierContact{ + Name: stripeAccount.BusinessProfile.Name, + Address: models.Address{ + Country: &stripeAccount.Country, + }, + } + + return supplierContact, nil +} + +func (a adapter) GetStripeInvoice(ctx context.Context, input appstripe.GetStripeInvoiceInput) (*stripe.Invoice, error) { + // Validate input + if err := input.Validate(); err != nil { + return nil, models.NewGenericValidationError( + fmt.Errorf("error validate input: %w", err), + ) + } + + // Get Stripe App client + _, stripeAppClient, err := a.getStripeAppClient(ctx, input.AppID, "getStripeInvoice", "app_id", input.AppID.ID, "stripe_invoice_id", input.StripeInvoiceID) + if err != nil { + return nil, fmt.Errorf("failed to get stripe app client: %w", err) + } + + // Get the invoice + return stripeAppClient.GetInvoice(ctx, stripeclient.GetInvoiceInput{ + StripeInvoiceID: input.StripeInvoiceID, + }) +} + +// CreatePortalSession creates a portal session for a customer. +func (a adapter) CreatePortalSession(ctx context.Context, input appstripe.CreateStripePortalSessionInput) (appstripe.StripePortalSession, error) { + // Validate input + if err := input.Validate(); err != nil { + return appstripe.StripePortalSession{}, models.NewGenericValidationError( + fmt.Errorf("error validate input: %w", err), + ) + } + + // Get Stripe App client + _, stripeAppClient, err := a.getStripeAppClient(ctx, input.AppID, "createPortalSession", "app_id", input.AppID.ID) + if err != nil { + return appstripe.StripePortalSession{}, fmt.Errorf("failed to get stripe app client: %w", err) + } + + // Get the stripe app data + stripeCustomerData, err := a.GetStripeCustomerData(ctx, appstripe.GetStripeCustomerDataInput{ + AppID: input.AppID, + CustomerID: input.CustomerID, + }) + if err != nil { + return appstripe.StripePortalSession{}, fmt.Errorf("failed to get stripe customer data: %w", err) + } + + if stripeCustomerData.StripeCustomerID == "" { + return appstripe.StripePortalSession{}, app.NewAppCustomerPreConditionError( + input.AppID, + app.AppTypeStripe, + &input.CustomerID, + "stripe customer id is empty", + ) + } + + // Create the portal session + portalSession, err := stripeAppClient.CreatePortalSession(ctx, stripeclient.CreatePortalSessionInput{ + StripeCustomerID: stripeCustomerData.StripeCustomerID, + ConfigurationID: input.ConfigurationID, + ReturnURL: input.ReturnURL, + }) + if err != nil { + return appstripe.StripePortalSession{}, fmt.Errorf("failed to create portal session: %w", err) + } + + return appstripe.StripePortalSession{ + ID: portalSession.ID, + Configuration: portalSession.Configuration, + StripeCustomerID: stripeCustomerData.StripeCustomerID, + Livemode: portalSession.Livemode, + Locale: portalSession.Locale, + ReturnURL: portalSession.ReturnURL, + URL: portalSession.URL, + CreatedAt: portalSession.CreatedAt, + }, nil +} + +// getStripeAppClient returns a Stripe App Client based on App ID +func (a adapter) getStripeAppClient(ctx context.Context, appID app.AppID, logOperation string, logFields ...any) (appstripe.AppData, stripeclient.StripeAppClient, error) { + // Validate app id + if err := appID.Validate(); err != nil { + return appstripe.AppData{}, nil, fmt.Errorf("app id: %w", err) + } + + // Get the stripe app data + stripeAppData, err := a.GetStripeAppData(ctx, appstripe.GetStripeAppDataInput{ + AppID: appID, + }) + if err != nil { + return stripeAppData, nil, fmt.Errorf("failed to get stripe app data: %w", err) + } + + // Get Stripe API Key + apiKeySecret, err := a.secretService.GetAppSecret(ctx, stripeAppData.APIKey) + if err != nil { + return stripeAppData, nil, fmt.Errorf("failed to get stripe api key secret: %w", err) + } + + // Stripe Client + stripeClient, err := a.stripeAppClientFactory(stripeclient.StripeAppClientConfig{ + AppID: appID, + AppService: a.appService, + APIKey: apiKeySecret.Value, + Logger: a.logger.With("operation", logOperation).With(logFields...), + }) + if err != nil { + return stripeAppData, nil, fmt.Errorf("failed to create stripe client: %w", err) + } + + return stripeAppData, stripeClient, nil +} + +// mapAppStripeData maps stripe app data from the database +func mapAppStripeData(appID app.AppID, dbApp *entdb.AppStripe) appstripe.AppData { + return appstripe.AppData{ + StripeAccountID: dbApp.StripeAccountID, + Livemode: dbApp.StripeLivemode, + APIKey: secretentity.NewSecretID(appID, dbApp.APIKey, appstripe.APIKeySecretKey), + MaskedAPIKey: dbApp.MaskedAPIKey, + StripeWebhookID: dbApp.StripeWebhookID, + WebhookSecret: secretentity.NewSecretID(appID, dbApp.WebhookSecret, appstripe.WebhookSecretKey), + } +} diff --git a/app/stripe/app.go b/app/stripe/app.go new file mode 100644 index 0000000000000000000000000000000000000000..d5ca991ff93c3f3606e6a23436fa4f9e476f8a74 --- /dev/null +++ b/app/stripe/app.go @@ -0,0 +1,90 @@ +package appstripe + +import ( + "errors" + "fmt" + "log/slog" + + "github.com/openmeterio/openmeter/openmeter/app" + stripeclient "github.com/openmeterio/openmeter/openmeter/app/stripe/client" + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/secret" +) + +type Meta struct { + app.AppBase + AppData +} + +var _ app.EventAppParser = (*Meta)(nil) + +func (m *Meta) FromEventAppData(event app.EventApp) error { + m.AppBase = event.AppBase + + if err := event.AppData.ParseInto(&m.AppData); err != nil { + return fmt.Errorf("error parsing app data: %w", err) + } + + return nil +} + +// App represents an installed Stripe app +type App struct { + Meta + + Logger *slog.Logger `json:"-"` + + AppService app.Service `json:"-"` + BillingService billing.Service `json:"-"` + StripeAppClientFactory stripeclient.StripeAppClientFactory `json:"-"` + StripeAppService Service `json:"-"` + SecretService secret.Service `json:"-"` +} + +func (a App) Validate() error { + if err := a.AppBase.Validate(); err != nil { + return fmt.Errorf("error validating app: %w", err) + } + + if err := a.AppData.Validate(); err != nil { + return fmt.Errorf("error validating stripe app data: %w", err) + } + + if a.Type != app.AppTypeStripe { + return errors.New("app type must be stripe") + } + + if err := a.AppData.Validate(); err != nil { + return fmt.Errorf("error validating stripe app data: %w", err) + } + + if a.BillingService == nil { + return errors.New("billing service is required") + } + + if a.StripeAppClientFactory == nil { + return errors.New("stripe client factory is required") + } + + if a.AppService == nil { + return errors.New("app service is required") + } + + if a.StripeAppService == nil { + return errors.New("stripe app service is required") + } + + if a.SecretService == nil { + return errors.New("secret service is required") + } + + if a.Logger == nil { + return errors.New("logger is required") + } + + return nil +} + +func (a App) GetEventAppData() (app.EventAppData, error) { + return app.NewEventAppData(a.AppData) +} diff --git a/app/stripe/appcustomer.go b/app/stripe/appcustomer.go new file mode 100644 index 0000000000000000000000000000000000000000..ab1ef43c64936b1b2b6a84a90f2bea4d52ab1e90 --- /dev/null +++ b/app/stripe/appcustomer.go @@ -0,0 +1,237 @@ +package appstripe + +import ( + "context" + "fmt" + "slices" + + "github.com/openmeterio/openmeter/openmeter/app" + stripeclient "github.com/openmeterio/openmeter/openmeter/app/stripe/client" + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/customer" + customerapp "github.com/openmeterio/openmeter/openmeter/customer/app" + "github.com/openmeterio/openmeter/pkg/models" +) + +var _ customerapp.App = (*App)(nil) + +// ValidateCustomer validates if the app can run for the given customer +func (a App) ValidateCustomer(ctx context.Context, customer *customer.Customer, capabilities []app.CapabilityType) error { + return a.ValidateCustomerByID(ctx, customer.GetID(), capabilities) +} + +// ValidateCustomerByID validates if the app can run for the given customer ID +func (a App) ValidateCustomerByID(ctx context.Context, customerID customer.CustomerID, capabilities []app.CapabilityType) error { + // Validate if the app supports the given capabilities + if err := a.ValidateCapabilities(capabilities...); err != nil { + return fmt.Errorf("error validating capabilities: %w", err) + } + + // Get Stripe Customer + stripeCustomerData, err := a.StripeAppService.GetStripeCustomerData(ctx, GetStripeCustomerDataInput{ + AppID: a.GetID(), + CustomerID: customerID, + }) + if err != nil { + return fmt.Errorf("failed to get stripe customer data: %w", err) + } + + // Stripe Client + stripeAppData, stripeClient, err := a.getStripeClient(ctx, "validateCustomer", "customer_id", customerID.ID) + if err != nil { + return fmt.Errorf("failed to get stripe client: %w", err) + } + + // Check if the customer exists in Stripe + stripeCustomer, err := stripeClient.GetCustomer(ctx, stripeCustomerData.StripeCustomerID) + if err != nil { + if stripeclient.IsStripeCustomerNotFoundError(err) { + return app.NewAppCustomerPreConditionError( + a.GetID(), + a.GetType(), + &customerID, + fmt.Sprintf("stripe customer not found in stripe account [stripe.customer_id=%s stripe.account_id=%s]", stripeCustomerData.StripeCustomerID, stripeAppData.StripeAccountID), + ) + } + + return err + } + + // Get customer billing profile + customerBillingProfile, err := a.BillingService.GetCustomerOverride(ctx, billing.GetCustomerOverrideInput{ + Customer: customerID, + }) + if err != nil { + return fmt.Errorf("failed to get customer override: %w", err) + } + + collectionMethod := customerBillingProfile.MergedProfile.WorkflowConfig.Payment.CollectionMethod + + // Validate customer for payment capabilitie + if slices.Contains(capabilities, app.CapabilityTypeCollectPayments) { + switch collectionMethod { + // With auto charge collection method requires the customer to have a payment method and a billing address + case billing.CollectionMethodChargeAutomatically: + var paymentMethod stripeclient.StripePaymentMethod + + // Check if the customer has a default payment method in OpenMeter + // If not try to use the Stripe Customer's default payment method + if stripeCustomerData.StripeDefaultPaymentMethodID != nil { + // Get the default payment method + paymentMethod, err = stripeClient.GetPaymentMethod(ctx, *stripeCustomerData.StripeDefaultPaymentMethodID) + if err != nil { + if _, ok := err.(stripeclient.StripePaymentMethodNotFoundError); ok { + return app.NewAppCustomerPreConditionError( + a.GetID(), + a.GetType(), + &customerID, + fmt.Sprintf("default payment method %s not found in stripe account %s", *stripeCustomerData.StripeDefaultPaymentMethodID, stripeAppData.StripeAccountID), + ) + } + + return fmt.Errorf("failed to get default payment method: %w", err) + } + } else { + // Check if the customer has a default payment method + if stripeCustomer.DefaultPaymentMethod == nil { + return app.NewAppCustomerPreConditionError( + a.GetID(), + a.GetType(), + &customerID, + "stripe customer must have a default payment method", + ) + } + + paymentMethod = *stripeCustomer.DefaultPaymentMethod + } + + // Payment method must have a billing address + // Billing address is required for tax calculation and invoice creation + if paymentMethod.BillingAddress == nil { + return app.NewAppCustomerPreConditionError( + a.GetID(), + a.GetType(), + &customerID, + "stripe customer default payment method must have a billing address", + ) + } + case billing.CollectionMethodSendInvoice: + // With send invoice collection method, the customer must have an email address + // Although OpenMeter customer has an optional email address field, Stripe requires an email address on the Stripe Customer for invoice creation. + // The OpenMeter customer email will be ignored for Stripe invoices. + if stripeCustomer.Email == nil { + return app.NewAppCustomerPreConditionError( + a.GetID(), + a.GetType(), + &customerID, + fmt.Sprintf("stripe customer missing email: in order to create invoices that are sent to the stripe customer, the stripe customer %s must have a valid email", stripeCustomerData.StripeCustomerID), + ) + } + + default: + return fmt.Errorf("unsupported collection method: %s", collectionMethod) + } + } + + // Validate tax settings if the app supports tax calculation and tax is enabled + if slices.Contains(capabilities, app.CapabilityTypeCalculateTax) && customerBillingProfile.MergedProfile.WorkflowConfig.Tax.Enabled { + // If tax is enforced, we need to ensure that the customer has a tax location + if customerBillingProfile.MergedProfile.WorkflowConfig.Tax.Enforced { + switch stripeCustomer.Tax.AutomaticTax { + case stripeclient.StripeCustomerAutomaticTaxNotCollecting: + // Automatic tax is not supported + return app.NewAppCustomerPreConditionError( + a.GetID(), + a.GetType(), + &customerID, + fmt.Sprintf("stripe tax: customer %s is not collecting tax", stripeCustomerData.StripeCustomerID), + ) + + case stripeclient.StripeCustomerAutomaticTaxFailed: + // Automatic tax failed + return app.NewAppCustomerPreConditionError( + a.GetID(), + a.GetType(), + &customerID, + fmt.Sprintf("stripe tax: there was an error determining the customer %s location, retry later", stripeCustomerData.StripeCustomerID), + ) + case stripeclient.StripeCustomerAutomaticTaxUnrecognizedLocation: + // Automatic tax failed because the location could not be determined + return app.NewAppCustomerPreConditionError( + a.GetID(), + a.GetType(), + &customerID, + fmt.Sprintf("stripe tax: the customer %s location couldn't be determined", stripeCustomerData.StripeCustomerID), + ) + } + } + } + + return nil +} + +// GetCustomerData gets the customer data for the app +func (a App) GetCustomerData(ctx context.Context, input app.GetAppInstanceCustomerDataInput) (app.CustomerData, error) { + if err := input.Validate(); err != nil { + return nil, models.NewGenericValidationError( + err, + ) + } + + customerData, err := a.StripeAppService.GetStripeCustomerData(ctx, GetStripeCustomerDataInput{ + AppID: a.GetID(), + CustomerID: input.CustomerID, + }) + if err != nil { + return customerData, fmt.Errorf("failed to get stripe customer data: %w", err) + } + + return customerData, nil +} + +// UpsertCustomerData upserts the customer data for the app +func (a App) UpsertCustomerData(ctx context.Context, input app.UpsertAppInstanceCustomerDataInput) error { + if err := input.Validate(); err != nil { + return models.NewGenericValidationError( + err, + ) + } + + stripeCustomerData, ok := input.Data.(CustomerData) + if !ok { + return fmt.Errorf("error casting stripe customer data") + } + + // Upsert stripe customer data + if err := a.StripeAppService.UpsertStripeCustomerData(ctx, UpsertStripeCustomerDataInput{ + AppID: a.GetID(), + CustomerID: input.CustomerID, + StripeCustomerID: stripeCustomerData.StripeCustomerID, + StripeDefaultPaymentMethodID: stripeCustomerData.StripeDefaultPaymentMethodID, + }); err != nil { + return fmt.Errorf("failed to upsert stripe customer data: %w", err) + } + + return nil +} + +// DeleteCustomerData deletes the customer data for the app +func (a App) DeleteCustomerData(ctx context.Context, input app.DeleteAppInstanceCustomerDataInput) error { + if err := input.Validate(); err != nil { + return models.NewGenericValidationError( + err, + ) + } + + appId := a.GetID() + + // Delete stripe customer data + if err := a.StripeAppService.DeleteStripeCustomerData(ctx, DeleteStripeCustomerDataInput{ + AppID: &appId, + CustomerID: &input.CustomerID, + }); err != nil { + return fmt.Errorf("failed to delete stripe customer data: %w", err) + } + + return nil +} diff --git a/app/stripe/appinvoice.go b/app/stripe/appinvoice.go new file mode 100644 index 0000000000000000000000000000000000000000..0726eeef320aca3952bf737187697e157ed22824 --- /dev/null +++ b/app/stripe/appinvoice.go @@ -0,0 +1,735 @@ +package appstripe + +import ( + "context" + "fmt" + "math" + "sort" + "time" + + "github.com/alpacahq/alpacadecimal" + "github.com/samber/lo" + "github.com/stripe/stripe-go/v80" + + "github.com/openmeterio/openmeter/openmeter/app" + stripeclient "github.com/openmeterio/openmeter/openmeter/app/stripe/client" + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/customer" + "github.com/openmeterio/openmeter/openmeter/productcatalog" +) + +const ( + invoiceLineMetadataID = "om_line_id" + invoiceLineMetadataType = "om_line_type" + invoiceLineMetadataTypeLine = "line" + invoiceLineMetadataTypeDiscount = "discount" + invoiceLineMetadataTypeCredit = "credit" +) + +var _ billing.InvoicingApp = (*App)(nil) + +// ValidateStandardInvoice validates the invoice for the app +func (a App) ValidateStandardInvoice(ctx context.Context, invoice billing.StandardInvoice) error { + customerID := customer.CustomerID{ + Namespace: invoice.Namespace, + ID: invoice.Customer.CustomerID, + } + + // Check if the customer can be invoiced with Stripe. + // We check this at app customer create but we need to ensure that OpenMeter is + // still in sync with Stripe, for example that the customer wasn't deleted in Stripe. + err := a.ValidateCustomerByID(ctx, customerID, []app.CapabilityType{ + // For now now we only support Stripe with automatic tax calculation and payment collection. + app.CapabilityTypeCalculateTax, + app.CapabilityTypeInvoiceCustomers, + app.CapabilityTypeCollectPayments, + }) + if err != nil { + return fmt.Errorf("validate customer: %w", err) + } + + // Check if the invoice has any capabilities that are not supported by Stripe. + // Today all capabilities are supported. + + return nil +} + +// UpsertStandardInvoice upserts the invoice for the app +// Upsert is idempotent and can be used to create or update an invoice. +// In case of failure the upsert should be retried. +// +// TODO: should we split invoice create and lines adds to make retries more robust? +// Currently if the create fails between the create and add lines we can end up with +// an invoice without lines. +func (a App) UpsertStandardInvoice(ctx context.Context, invoice billing.StandardInvoice) (*billing.UpsertStandardInvoiceResult, error) { + // Create the invoice in Stripe. + if invoice.ExternalIDs.Invoicing == "" { + return a.createInvoice(ctx, invoice) + } + + // Update the invoice in Stripe. + return a.updateInvoice(ctx, invoice) +} + +// DeleteStandardInvoice deletes the invoice for the app +func (a App) DeleteStandardInvoice(ctx context.Context, invoice billing.StandardInvoice) error { + // Get the Stripe client + _, stripeClient, err := a.getStripeClient(ctx, "deleteInvoice", "invoice_id", invoice.ID, "stripe_invoice_id", invoice.ExternalIDs.GetInvoicingOrEmpty()) + if err != nil { + return fmt.Errorf("failed to get stripe client: %w", err) + } + + // Delete the invoice in Stripe + return stripeClient.DeleteInvoice(ctx, stripeclient.DeleteInvoiceInput{ + StripeInvoiceID: invoice.ExternalIDs.Invoicing, + }) +} + +// FinalizeStandardInvoice finalizes the invoice for the app +func (a App) FinalizeStandardInvoice(ctx context.Context, invoice billing.StandardInvoice) (*billing.FinalizeStandardInvoiceResult, error) { + // Get the Stripe client + _, stripeClient, err := a.getStripeClient(ctx, "finalizeInvoice", "invoice_id", invoice.ID, "stripe_invoice_id", invoice.ExternalIDs.GetInvoicingOrEmpty()) + if err != nil { + return nil, fmt.Errorf("failed to get stripe client: %w", err) + } + + // Finalize the invoice in Stripe + stripeInvoice, err := stripeClient.FinalizeInvoice(ctx, stripeclient.FinalizeInvoiceInput{ + StripeInvoiceID: invoice.ExternalIDs.Invoicing, + + // Controls whether Stripe performs automatic collection of the invoice. + // If false, the invoice’s state doesn’t automatically advance without an explicit action. + // https://docs.stripe.com/api/invoices/finalize#finalize_invoice-auto_advance + AutoAdvance: true, + }) + if err != nil { + // If customer tax location is invalid but tax is not enforced, + // we can finalize the invoice without tax calculation. + if stripeclient.IsStripeInvoiceCustomerTaxLocationInvalidError(err) { + if invoice.Workflow.Config.Tax.Enforced { + return nil, fmt.Errorf("tax enforced but stripe tax returns error: %w", err) + } + + // We can finalize the invoice without tax calculation. + _, err = stripeClient.UpdateInvoice(ctx, stripeclient.UpdateInvoiceInput{ + // Disable tax calculation + AutomaticTaxEnabled: false, + StripeInvoiceID: invoice.ExternalIDs.Invoicing, + }) + if err != nil { + return nil, fmt.Errorf("failed to update invoice in stripe to disable tax calculation: %w", err) + } + + // Finalize the invoice again + return a.FinalizeStandardInvoice(ctx, invoice) + } + + return nil, fmt.Errorf("failed to finalize invoice in stripe: %w", err) + } + + // Result + result := billing.NewFinalizeStandardInvoiceResult() + + // Stripe is the source of truth for invoice number + // We set it on result to save it + result.SetInvoiceNumber(stripeInvoice.Number) + + // The PaymentIntent is generated when the invoice is finalized, + // and can then be used to pay the invoice. + // https://docs.stripe.com/api/invoices/object#invoice_object-payment_intent + if stripeInvoice.PaymentIntent != nil { + result.SetPaymentExternalID(stripeInvoice.PaymentIntent.ID) + } + + return result, nil +} + +// createInvoice creates the invoice for the app +func (a App) createInvoice(ctx context.Context, invoice billing.StandardInvoice) (*billing.UpsertStandardInvoiceResult, error) { + // Get the currency calculator + calculator, err := NewStripeCalculator(invoice.Currency) + if err != nil { + return nil, fmt.Errorf("failed to get currency calculator: %w", err) + } + + customerID := customer.CustomerID{ + Namespace: invoice.Namespace, + ID: invoice.Customer.CustomerID, + } + + // Get the Stripe client + _, stripeClient, err := a.getStripeClient(ctx, "createInvoice", "customer_id", customerID.ID) + if err != nil { + return nil, fmt.Errorf("failed to get stripe client: %w", err) + } + + // Get stripe customer data + stripeCustomerData, err := a.StripeAppService.GetStripeCustomerData(ctx, GetStripeCustomerDataInput{ + AppID: a.GetID(), + CustomerID: customerID, + }) + if err != nil { + return nil, fmt.Errorf("failed to get stripe customer data: %w", err) + } + + // Create the invoice in Stripe + createInvoiceParams := stripeclient.CreateInvoiceInput{ + AppID: a.GetID(), + CustomerID: customerID, + InvoiceID: invoice.ID, + AutomaticTaxEnabled: invoice.Workflow.Config.Tax.Enabled, + CollectionMethod: invoice.Workflow.Config.Payment.CollectionMethod, + Currency: invoice.Currency, + StripeCustomerID: stripeCustomerData.StripeCustomerID, + StripeDefaultPaymentMethodID: stripeCustomerData.StripeDefaultPaymentMethodID, + } + + // Set the days until due if the invoice is sent + if invoice.Workflow.Config.Payment.CollectionMethod == billing.CollectionMethodSendInvoice { + daysUntilDue, _, ok := invoice.Workflow.Config.Invoicing.DueAfter.DaysDecimal().Int64(0) + if !ok { + return nil, fmt.Errorf("failed to get days until due") + } + + // This is a workaround to handle the case when someone defines the due in months like P1M instead of days like P30D. + // With P1M the library will truncate the period to 0 days, which is not what we want. + // Defining due in days like P30D is preferred over P1M. + if daysUntilDue == 0 { + futureDueAt, _ := invoice.Workflow.Config.Invoicing.DueAfter.AddTo(time.Now()) + daysUntilDue = int64(math.Round(time.Until(futureDueAt).Hours() / 24)) + } + + createInvoiceParams.DaysUntilDue = lo.ToPtr(daysUntilDue) + } + + stripeInvoice, err := stripeClient.CreateInvoice(ctx, createInvoiceParams) + if err != nil { + return nil, fmt.Errorf("failed to create invoice in stripe: %w", err) + } + + // Return the result + result := billing.NewUpsertStandardInvoiceResult() + result.SetExternalID(stripeInvoice.ID) + + // Stripe is the source of truth for invoice number + // We set it on result to save it + result.SetInvoiceNumber(stripeInvoice.Number) + + // Add lines to the Stripe invoice + var stripeLineAdd []*stripe.InvoiceItemParams + + leafLines := invoice.GetLeafLinesWithResolvedTaxConfig() + + // Iterate over the leaf lines + for _, line := range leafLines { + // Add discounts for line if any + for _, discount := range line.AmountDiscounts { + stripeLineAdd = append(stripeLineAdd, getDiscountStripeAddInvoiceItemParams(calculator, line, discount, stripeCustomerData.StripeCustomerID)) + } + + // Add applied credits for line if any + for _, credit := range line.CreditsApplied { + if credit.CreditRealizationID == "" { + return nil, fmt.Errorf("credit realization ID is required") + } + + stripeLineAdd = append(stripeLineAdd, getCreditStripeAddInvoiceItemParams(calculator, line, credit, stripeCustomerData.StripeCustomerID)) + } + + // Add line + stripeLineAdd = append(stripeLineAdd, getStripeAddInvoiceItemParams(line, calculator, stripeCustomerData.StripeCustomerID)) + } + + // Sort the Stripe line items for deterministic order + // TODO: use invoice summaries to group lines when Stripe supports it + sortInvoiceLines(stripeLineAdd) + + newLines := []stripeclient.StripeInvoiceItemWithLineID{} + + // It is valid to have an invoice with no lines: this signifies that the customer has no outstanding + // charges. + if len(stripeLineAdd) > 0 { + // Add Stripe line items to the Stripe invoice + newLines, err = stripeClient.AddInvoiceLines(ctx, stripeclient.AddInvoiceLinesInput{ + StripeInvoiceID: stripeInvoice.ID, + Lines: stripeLineAdd, + }) + if err != nil { + return nil, fmt.Errorf("failed to add line items to invoice in stripe: %w", err) + } + } + + // Add external line IDs + err = addResultExternalIDs(newLines, result) + if err != nil { + return nil, fmt.Errorf("failed to add external line IDs to result: %w", err) + } + + return result, nil +} + +// updateInvoice update the invoice for the app +func (a App) updateInvoice(ctx context.Context, invoice billing.StandardInvoice) (*billing.UpsertStandardInvoiceResult, error) { + // Get the currency calculator + calculator, err := NewStripeCalculator(invoice.Currency) + if err != nil { + return nil, fmt.Errorf("failed to get currency calculator: %w", err) + } + + // Get the Stripe client + _, stripeClient, err := a.getStripeClient(ctx, "updateInvoice", "invoice_id", invoice.ID, "stripe_invoice_id", invoice.ExternalIDs.GetInvoicingOrEmpty()) + if err != nil { + return nil, fmt.Errorf("failed to get stripe client: %w", err) + } + + // Get stripe customer data + stripeCustomerData, err := a.StripeAppService.GetStripeCustomerData(ctx, GetStripeCustomerDataInput{ + AppID: a.GetID(), + CustomerID: customer.CustomerID{ + Namespace: invoice.Namespace, + ID: invoice.Customer.CustomerID, + }, + }) + if err != nil { + return nil, fmt.Errorf("failed to get stripe customer data: %w", err) + } + + // Update the invoice in Stripe + stripeInvoice, err := stripeClient.UpdateInvoice(ctx, stripeclient.UpdateInvoiceInput{ + AutomaticTaxEnabled: invoice.Workflow.Config.Tax.Enabled, + StripeInvoiceID: invoice.ExternalIDs.Invoicing, + }) + if err != nil { + return nil, fmt.Errorf("failed to update invoice in stripe: %w", err) + } + + // The result + result := billing.NewUpsertStandardInvoiceResult() + result.SetExternalID(stripeInvoice.ID) + + // Stripe is the source of truth for invoice number + // We set it on result to save it + result.SetInvoiceNumber(stripeInvoice.Number) + + // Collect the existing line items + // We use this to determine which line items to remove. + // Existing lines that were not updated are removed. + stripeLinesToRemove := make(map[string]bool) + + var ( + stripeLineAdd []*stripe.InvoiceItemParams + stripeLinesUpdate []*stripeclient.StripeInvoiceItemWithID + stripeLinesRemove []string + ) + + // Get the existing line items from Stripe to build the maps + stripeInvoiceLineItems, err := stripeClient.ListInvoiceLineItems(ctx, stripeInvoice.ID) + if err != nil { + return nil, fmt.Errorf("failed to get existing line items from stripe: %w", err) + } + + stripeLinesByID := make(map[string]*stripe.InvoiceLineItem) + stripeCreditLinesByCreditRealizationID := make(map[string]*stripe.InvoiceLineItem) + + for _, stripeLine := range stripeInvoiceLineItems { + // We set all to true and the code later clears the ones that we keep + stripeLinesToRemove[stripeLine.ID] = true + + stripeLinesByID[stripeLine.ID] = stripeLine + // This allows looking up by stripe invoice item ID too (in case we ran into any inconsistencies going forward) + if stripeLine.InvoiceItem != nil { + stripeLinesByID[stripeLine.InvoiceItem.ID] = stripeLine + } + + if stripeLine.Metadata[invoiceLineMetadataType] == invoiceLineMetadataTypeCredit { + if creditRealizationID := stripeLine.Metadata[invoiceLineMetadataID]; creditRealizationID != "" { + stripeCreditLinesByCreditRealizationID[creditRealizationID] = stripeLine + } + } + } + + // Iterate over the leaf lines + for _, line := range invoice.GetLeafLinesWithResolvedTaxConfig() { + amountDiscountsById, err := line.AmountDiscounts.GetByID() + if err != nil { + return nil, fmt.Errorf("failed to get amount discounts by ID: %w", err) + } + + // Add discounts for line if any + for _, discount := range amountDiscountsById { + // Update discount line item if it already has an external ID + if discount.ExternalIDs.Invoicing != "" { + // Get the Stripe line item for the discount + stripeLine, ok := stripeLinesByID[discount.ExternalIDs.Invoicing] + if !ok { + return nil, fmt.Errorf("discount not found in stripe lines: %s", discount.ExternalIDs.Invoicing) + } + + // Exclude line from the remove list as it is updated + delete(stripeLinesToRemove, stripeLine.ID) + + result.AddLineDiscountExternalID(discount.ID, line.ExternalIDs.Invoicing) + + stripeLinesUpdate = append(stripeLinesUpdate, getDiscountStripeUpdateInvoiceItemParams(calculator, line, discount, stripeLine)) + } else { + // Add the discount line item if it doesn't have an external ID yet + stripeLineAdd = append(stripeLineAdd, getDiscountStripeAddInvoiceItemParams(calculator, line, discount, stripeCustomerData.StripeCustomerID)) + } + } + + // Add or update applied credit line items. + for _, credit := range line.CreditsApplied { + if credit.CreditRealizationID == "" { + return nil, fmt.Errorf("credit realization ID is required") + } + + if stripeLine, ok := stripeCreditLinesByCreditRealizationID[credit.CreditRealizationID]; ok { + delete(stripeLinesToRemove, stripeLine.ID) + + stripeLinesUpdate = append(stripeLinesUpdate, getCreditStripeUpdateInvoiceItemParams(calculator, line, credit, stripeLine)) + } else { + stripeLineAdd = append(stripeLineAdd, getCreditStripeAddInvoiceItemParams(calculator, line, credit, stripeCustomerData.StripeCustomerID)) + } + } + + // Update line item if it already has an external ID + if line.ExternalIDs.Invoicing != "" { + // Get the Stripe line item for the line + stripeLine, ok := stripeLinesByID[line.ExternalIDs.Invoicing] + if !ok { + return nil, fmt.Errorf("line not found in stripe lines: %s", line.ExternalIDs.Invoicing) + } + + // Exclude line from the remove list as it is updated + delete(stripeLinesToRemove, stripeLine.ID) + + // Add external line ID to the result + result.AddLineExternalID(line.ID, stripeLine.ID) + + // Get stripe update line params + stripeLinesUpdate = append(stripeLinesUpdate, getStripeUpdateInvoiceItemParams(calculator, line, stripeLine)) + } else { + // Add the line item if it doesn't have an external ID yet + stripeLineAdd = append(stripeLineAdd, getStripeAddInvoiceItemParams(line, calculator, stripeCustomerData.StripeCustomerID)) + } + } + + // Add Stripe lines to the Stripe invoice + if len(stripeLineAdd) > 0 { + // Sort the line items by description + sortInvoiceLines(stripeLineAdd) + + // Add Stripe line items to the Stripe invoice + newInvoiceItems, err := stripeClient.AddInvoiceLines(ctx, stripeclient.AddInvoiceLinesInput{ + StripeInvoiceID: stripeInvoice.ID, + Lines: stripeLineAdd, + }) + if err != nil { + return nil, fmt.Errorf("failed to add line items to invoice in stripe: %w", err) + } + + err = addResultExternalIDs(newInvoiceItems, result) + if err != nil { + return nil, fmt.Errorf("failed to add external line IDs to result: %w", err) + } + } + + // Update Stripe lines on the Stripe invoice + if len(stripeLinesUpdate) > 0 { + // Sort the line items by description + sortInvoiceLines(stripeLinesUpdate) + + _, err = stripeClient.UpdateInvoiceLines(ctx, stripeclient.UpdateInvoiceLinesInput{ + StripeInvoiceID: stripeInvoice.ID, + Lines: stripeLinesUpdate, + }) + if err != nil { + return nil, fmt.Errorf("failed to update line items in invoice in stripe: %w", err) + } + } + + // Remove Stripe lines from the Stripe invoice + stripeLinesRemove = append(stripeLinesRemove, lo.Keys(stripeLinesToRemove)...) + + if len(stripeLinesRemove) > 0 { + err = stripeClient.RemoveInvoiceLines(ctx, stripeclient.RemoveInvoiceLinesInput{ + StripeInvoiceID: stripeInvoice.ID, + Lines: stripeLinesRemove, + }) + if err != nil { + return nil, fmt.Errorf("failed to remove line items from invoice in stripe: %w", err) + } + } + + return result, nil +} + +type StripeInvoiceLineOperationParams interface { + stripe.InvoiceItemParams | stripeclient.StripeInvoiceItemWithID +} + +// sortInvoiceLines sorts the lines by description +func sortInvoiceLines[K StripeInvoiceLineOperationParams](stripeLineAdd []*K) { + sort.Slice(stripeLineAdd, func(i, j int) bool { + var ( + descA *string + descB *string + ) + + // Go generics can't handle two structs with common fields + // We need to switch on the type + switch params := any(stripeLineAdd).(type) { + case []*stripe.InvoiceAddLinesLineParams: + descA = params[i].Description + descB = params[j].Description + + case []*stripe.InvoiceUpdateLinesLineParams: + descA = params[i].Description + descB = params[j].Description + } + + a := lo.FromPtr(descA) + b := lo.FromPtr(descB) + + return a < b + }) +} + +// getDiscountStripeUpdateInvoiceItemParams returns the Stripe line item for a discount +func getDiscountStripeUpdateInvoiceItemParams( + calculator StripeCalculator, + line billing.DetailedLineWithResolvedTaxConfig, + discount billing.AmountLineDiscountManaged, + stripeLine *stripe.InvoiceLineItem, +) *stripeclient.StripeInvoiceItemWithID { + return &stripeclient.StripeInvoiceItemWithID{ + ID: stripeLine.ID, + InvoiceItemParams: getDiscountStripeInvoiceItemParams(calculator, line, discount), + } +} + +// getDiscountStripeInvoiceItemParams returns the Stripe line item for a discount +func getDiscountStripeInvoiceItemParams(calculator StripeCalculator, line billing.DetailedLineWithResolvedTaxConfig, discount billing.AmountLineDiscountManaged) *stripe.InvoiceItemParams { + name := getDiscountLineName(line.DetailedLine, discount) + period := getPeriod(line.DetailedLine) + + addParams := &stripe.InvoiceItemParams{ + Description: lo.ToPtr(name), + Amount: lo.ToPtr(-calculator.RoundToAmount(discount.Amount.Add(discount.RoundingAmount))), + Period: period, + Metadata: map[string]string{ + invoiceLineMetadataID: discount.ID, + invoiceLineMetadataType: invoiceLineMetadataTypeDiscount, + }, + } + + return applyTaxSettingsToInvoiceItem(addParams, line.TaxConfig) +} + +func getDiscountStripeAddInvoiceItemParams(calculator StripeCalculator, line billing.DetailedLineWithResolvedTaxConfig, discount billing.AmountLineDiscountManaged, stripeCustomerID string) *stripe.InvoiceItemParams { + params := getDiscountStripeInvoiceItemParams(calculator, line, discount) + // Customer is required for adds + params.Customer = stripe.String(stripeCustomerID) + return params +} + +// getCreditStripeUpdateInvoiceItemParams returns the Stripe line item for an applied credit. +func getCreditStripeUpdateInvoiceItemParams( + calculator StripeCalculator, + line billing.DetailedLineWithResolvedTaxConfig, + credit billing.CreditApplied, + stripeLine *stripe.InvoiceLineItem, +) *stripeclient.StripeInvoiceItemWithID { + return &stripeclient.StripeInvoiceItemWithID{ + ID: stripeLine.ID, + InvoiceItemParams: getCreditStripeInvoiceItemParams(calculator, line, credit), + } +} + +// getCreditStripeInvoiceItemParams returns the Stripe line item for an applied credit. +func getCreditStripeInvoiceItemParams(calculator StripeCalculator, line billing.DetailedLineWithResolvedTaxConfig, credit billing.CreditApplied) *stripe.InvoiceItemParams { + name := getCreditLineName(line.DetailedLine, credit) + period := getPeriod(line.DetailedLine) + + addParams := &stripe.InvoiceItemParams{ + Description: lo.ToPtr(name), + Amount: lo.ToPtr(-calculator.RoundToAmount(credit.Amount)), + Period: period, + Metadata: map[string]string{ + invoiceLineMetadataID: credit.CreditRealizationID, + invoiceLineMetadataType: invoiceLineMetadataTypeCredit, + }, + } + + return applyTaxSettingsToInvoiceItem(addParams, line.TaxConfig) +} + +func getCreditStripeAddInvoiceItemParams(calculator StripeCalculator, line billing.DetailedLineWithResolvedTaxConfig, credit billing.CreditApplied, stripeCustomerID string) *stripe.InvoiceItemParams { + params := getCreditStripeInvoiceItemParams(calculator, line, credit) + // Customer is required for adds + params.Customer = stripe.String(stripeCustomerID) + return params +} + +func applyTaxSettingsToInvoiceItem(add *stripe.InvoiceItemParams, taxConfig *billing.TaxConfig) *stripe.InvoiceItemParams { + if taxConfig != nil && !lo.IsEmpty(taxConfig) { + if taxConfig.Behavior != nil { + add.TaxBehavior = getStripeTaxBehavior(taxConfig.Behavior) + } + + if taxConfig.Stripe != nil { + add.TaxCode = stripe.String(taxConfig.Stripe.Code) + } + } + + return add +} + +// getStripeUpdateInvoiceItemParams returns the Stripe update line params +func getStripeUpdateInvoiceItemParams( + calculator StripeCalculator, + line billing.DetailedLineWithResolvedTaxConfig, + stripeLine *stripe.InvoiceLineItem, +) *stripeclient.StripeInvoiceItemWithID { + return &stripeclient.StripeInvoiceItemWithID{ + ID: stripeLine.ID, + InvoiceItemParams: getStripeInvoiceItemParams(line, calculator), + } +} + +// getStripeAddLinesLineParams returns the Stripe line item +func getStripeInvoiceItemParams(line billing.DetailedLineWithResolvedTaxConfig, calculator StripeCalculator) *stripe.InvoiceItemParams { + description := getLineName(line.DetailedLine) + period := getPeriod(line.DetailedLine) + amount := line.Totals.Amount + + // Handle usage based commitments like minimum spend + if amount.IsZero() { + // ChargesTotal is the amount of value of the line that are due to additional charges. + // If the line is a commitment we use the total charges. + amount = line.Totals.ChargesTotal + } + + // If the line has a quantity we add the quantity and per unit amount to the description + if line.Quantity.GreaterThan(alpacadecimal.NewFromInt(1)) || line.Quantity.IsNegative() { + description = fmt.Sprintf( + "%s (%s x %s)", + description, + calculator.FormatQuantity(line.Quantity), + calculator.FormatAmount(line.PerUnitAmount), + ) + } + + // Otherwise we add the calculated total with with quantity one + addParams := &stripe.InvoiceItemParams{ + Description: lo.ToPtr(description), + Amount: lo.ToPtr(calculator.RoundToAmount(amount)), + Period: period, + Metadata: map[string]string{ + invoiceLineMetadataID: line.ID, + invoiceLineMetadataType: invoiceLineMetadataTypeLine, + }, + } + + return applyTaxSettingsToInvoiceItem(addParams, line.TaxConfig) +} + +// getStripeAddInvoiceItemParams returns the Stripe line item +func getStripeAddInvoiceItemParams(line billing.DetailedLineWithResolvedTaxConfig, calculator StripeCalculator, stripeCustomerID string) *stripe.InvoiceItemParams { + params := getStripeInvoiceItemParams(line, calculator) + params.Customer = stripe.String(stripeCustomerID) + return params +} + +// getPeriod returns the period +func getPeriod(line billing.DetailedLine) *stripe.InvoiceItemPeriodParams { + return &stripe.InvoiceItemPeriodParams{ + Start: lo.ToPtr(line.ServicePeriod.From.Unix()), + End: lo.ToPtr(line.ServicePeriod.To.Unix()), + } +} + +// getDiscountLineName returns the line name +func getDiscountLineName(line billing.DetailedLine, discount billing.AmountLineDiscountManaged) string { + name := line.Name + if discount.Description != nil { + name = fmt.Sprintf("%s (%s)", name, *discount.Description) + } + + return name +} + +// getCreditLineName returns the applied-credit line name. +func getCreditLineName(line billing.DetailedLine, credit billing.CreditApplied) string { + name := fmt.Sprintf("credits applied for %s", getLineName(line)) + if credit.Description != "" { + name = fmt.Sprintf("%s (%s)", name, credit.Description) + } + + return name +} + +// getLineName returns the line name +func getLineName(line billing.DetailedLine) string { + name := line.Name + if line.Description != nil { + name = fmt.Sprintf("%s (%s)", name, *line.Description) + } + + return name +} + +// getStripeTaxBehavior returns the Stripe tax behavior from a TaxBehavior +func getStripeTaxBehavior(tb *productcatalog.TaxBehavior) *string { + if tb == nil { + return nil + } + + switch *tb { + case productcatalog.InclusiveTaxBehavior: + return lo.ToPtr(string(stripe.PriceCurrencyOptionsTaxBehaviorInclusive)) + case productcatalog.ExclusiveTaxBehavior: + return lo.ToPtr(string(stripe.PriceCurrencyOptionsTaxBehaviorExclusive)) + default: + return nil + } +} + +// addResultExternalIDs adds the Stripe line item IDs to the result external IDs +func addResultExternalIDs( + newLines []stripeclient.StripeInvoiceItemWithLineID, + result *billing.UpsertStandardInvoiceResult, +) error { + // Check if we have the same number of params and new lines + + for idx, stripeLine := range newLines { + // Get the line ID from the param metadata + // We always read it from params as it's our source of truth + id, ok := newLines[idx].Metadata[invoiceLineMetadataID] + if !ok { + return fmt.Errorf("line ID not found in stripe line metadata") + } + + // Get the line type from the param metadata + // We always read it from params as it's our source of truth + lineType, ok := newLines[idx].Metadata[invoiceLineMetadataType] + if !ok { + return fmt.Errorf("line type not found in stripe line metadata") + } + + // Add line discount external ID + if lineType == invoiceLineMetadataTypeDiscount { + result.AddLineDiscountExternalID(id, stripeLine.LineID) + continue + } + + if lineType == invoiceLineMetadataTypeCredit { + continue + } + + // Add line external ID + result.AddLineExternalID(id, stripeLine.LineID) + } + + return nil +} diff --git a/app/stripe/calculator.go b/app/stripe/calculator.go new file mode 100644 index 0000000000000000000000000000000000000000..5a13bc5747665a65e42d91b0eacfce57d3d7dc62 --- /dev/null +++ b/app/stripe/calculator.go @@ -0,0 +1,69 @@ +package appstripe + +import ( + "fmt" + + "github.com/alpacahq/alpacadecimal" + "github.com/invopop/gobl/num" + "golang.org/x/text/language" + "golang.org/x/text/message" + + "github.com/openmeterio/openmeter/pkg/currencyx" +) + +// NewStripeCalculator creates a new StripeCalculator. +func NewStripeCalculator(currencyCode currencyx.Code) (StripeCalculator, error) { + currency, err := currencyx.NewCurrencyBuilder(currencyx.CurrencyTypeFiat). + WithCode(currencyCode). + Build() + if err != nil { + return StripeCalculator{}, fmt.Errorf("failed to get stripe calculator: %w", err) + } + + return StripeCalculator{ + currency: currency, + printer: message.NewPrinter(language.English), + multiplier: alpacadecimal.NewFromInt(10).Pow(alpacadecimal.NewFromInt(int64(currency.Details().Precision))), + }, nil +} + +// StripeCalculator provides a currency calculator object. +type StripeCalculator struct { + currency currencyx.Currency + printer *message.Printer + multiplier alpacadecimal.Decimal +} + +// RoundToAmount rounds the amount to the precision of the Stripe currency in Stripe amount. +func (c StripeCalculator) RoundToAmount(amount alpacadecimal.Decimal) int64 { + return amount.Mul(c.multiplier).Round(0).IntPart() +} + +// FormatAmount formats the amount +func (c StripeCalculator) FormatAmount(amount alpacadecimal.Decimal) string { + def := c.currency.Definition() + + if amount.IsInteger() { + return def.FormatAmount(num.MakeAmount(amount.IntPart(), 0)) + } + + am, _ := amount.Float64() + + return def.FormatAmount(num.AmountFromFloat64(am, uint32(amount.NumDigits()))) +} + +// FormatQuantity formats the quantity to two decimal places. +// This should be only used to display the quantity not for calculations. +func (c StripeCalculator) FormatQuantity(quantity alpacadecimal.Decimal) string { + if quantity.IsInteger() { + return c.printer.Sprintf("%d", quantity.IntPart()) + } + + f, _ := quantity.Float64() + return c.printer.Sprintf("%.2f", f) +} + +// IsInteger checks if the amount is an integer in the Stripe currency. +func (c StripeCalculator) IsInteger(amount alpacadecimal.Decimal) bool { + return amount.Mul(c.multiplier).IsInteger() +} diff --git a/app/stripe/client/appclient.go b/app/stripe/client/appclient.go new file mode 100644 index 0000000000000000000000000000000000000000..98a002d152c4b61154857f010e638829282f3b85 --- /dev/null +++ b/app/stripe/client/appclient.go @@ -0,0 +1,263 @@ +package client + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + + "github.com/samber/lo" + "github.com/stripe/stripe-go/v80" + "github.com/stripe/stripe-go/v80/client" + + app "github.com/openmeterio/openmeter/openmeter/app" + "github.com/openmeterio/openmeter/pkg/models" +) + +const ( + StripeMetadataNamespace = "om_namespace" + StripeMetadataAppID = "om_app_id" + StripeMetadataCustomerID = "om_customer_id" + StripeMetadataInvoiceID = "om_invoice_id" +) + +// SetupIntentReservedMetadataKeys are the keys that are reserved for internal use by OpenMeter +// specifying these keys in the metadata will result in a validation error +var SetupIntentReservedMetadataKeys = []string{ + StripeMetadataNamespace, + StripeMetadataAppID, + StripeMetadataCustomerID, +} + +const ( + // Stripe Webhook event types + + // Occurs when an SetupIntent has successfully setup a payment method. + WebhookEventTypeSetupIntentSucceeded = "setup_intent.succeeded" + // Occurs when an SetupIntent has failed to set up a payment method. + WebhookEventTypeSetupIntentFailed = "setup_intent.setup_failed" + // Occurs when a SetupIntent is in requires_action state. + WebhookEventTypeSetupIntentRequiresAction = "setup_intent.requires_action" + + // Occurs whenever a draft invoice cannot be finalized + WebhookEventTypeInvoiceFinalizationFailed = "invoice.finalization_failed" + // Occurs whenever an invoice is marked uncollectible + WebhookEventTypeInvoiceMarkedUncollectible = "invoice.marked_uncollectible" + // Occurs X number of days after an invoice becomes due—where X is determined by Automations + WebhookEventTypeInvoiceOverdue = "invoice.overdue" + // Occurs whenever an invoice payment attempt succeeds or an invoice is marked as paid out-of-band. + WebhookEventTypeInvoicePaid = "invoice.paid" + // Occurs whenever an invoice payment attempt requires further user action to complete. + WebhookEventTypeInvoicePaymentActionRequired = "invoice.payment_action_required" + // Occurs whenever an invoice payment attempt fails, due either to a declined payment or to the lack of a stored payment method. + WebhookEventTypeInvoicePaymentFailed = "invoice.payment_failed" + // Occurs whenever an invoice payment attempt succeeds. + WebhookEventTypeInvoicePaymentSucceeded = "invoice.payment_succeeded" + // Occurs whenever an invoice email is sent out. + WebhookEventTypeInvoiceSent = "invoice.sent" + // Occurs whenever an invoice is voided. + WebhookEventTypeInvoiceVoided = "invoice.voided" +) + +// StripeAppClient is a client for the stripe API for an installed app. +// It is useful to call the Stripe API after the app is installed. +type StripeAppClient interface { + DeleteWebhook(ctx context.Context, input DeleteWebhookInput) error + GetAccount(ctx context.Context) (StripeAccount, error) + GetCustomer(ctx context.Context, stripeCustomerID string) (StripeCustomer, error) + CreateCustomer(ctx context.Context, input CreateStripeCustomerInput) (StripeCustomer, error) + CreateCheckoutSession(ctx context.Context, input CreateCheckoutSessionInput) (StripeCheckoutSession, error) + GetPaymentMethod(ctx context.Context, stripePaymentMethodID string) (StripePaymentMethod, error) + CreatePortalSession(ctx context.Context, input CreatePortalSessionInput) (PortalSession, error) + // Invoice + GetInvoice(ctx context.Context, input GetInvoiceInput) (*stripe.Invoice, error) + CreateInvoice(ctx context.Context, input CreateInvoiceInput) (*stripe.Invoice, error) + UpdateInvoice(ctx context.Context, input UpdateInvoiceInput) (*stripe.Invoice, error) + DeleteInvoice(ctx context.Context, input DeleteInvoiceInput) error + FinalizeInvoice(ctx context.Context, input FinalizeInvoiceInput) (*stripe.Invoice, error) + // Invoice Line + ListInvoiceLineItems(ctx context.Context, stripeInvoiceID string) ([]*stripe.InvoiceLineItem, error) + AddInvoiceLines(ctx context.Context, input AddInvoiceLinesInput) ([]StripeInvoiceItemWithLineID, error) + UpdateInvoiceLines(ctx context.Context, input UpdateInvoiceLinesInput) ([]*stripe.InvoiceItem, error) + RemoveInvoiceLines(ctx context.Context, input RemoveInvoiceLinesInput) error +} + +// StripeAppClientFactory is a factory for creating a StripeAppClient for an installed app. +type StripeAppClientFactory = func(config StripeAppClientConfig) (StripeAppClient, error) + +type StripeAppClientConfig struct { + AppService app.Service + AppID app.AppID + APIKey string + Logger *slog.Logger +} + +func (c *StripeAppClientConfig) Validate() error { + if c.AppService == nil { + return fmt.Errorf("app stripe servive is required") + } + + if err := c.AppID.Validate(); err != nil { + return fmt.Errorf("app id is required") + } + + if c.APIKey == "" { + return fmt.Errorf("api key is required") + } + + if c.Logger == nil { + return fmt.Errorf("logger is required") + } + + return nil +} + +type stripeAppClient struct { + appService app.Service + appID app.AppID + client *client.API +} + +func NewStripeAppClient(config StripeAppClientConfig) (StripeAppClient, error) { + if err := config.Validate(); err != nil { + return nil, err + } + + backend := stripe.GetBackendWithConfig(stripe.APIBackend, &stripe.BackendConfig{ + LeveledLogger: leveledLogger{ + logger: config.Logger, + }, + }) + client := &client.API{} + client.Init(config.APIKey, &stripe.Backends{ + API: backend, + Connect: backend, + Uploads: backend, + }) + + return &stripeAppClient{ + appService: config.AppService, + appID: config.AppID, + client: client, + }, nil +} + +// DeleteWebhook setups a stripe webhook to handle setup intents and save the payment method +func (c *stripeAppClient) DeleteWebhook(ctx context.Context, input DeleteWebhookInput) error { + _, err := c.client.WebhookEndpoints.Del(input.StripeWebhookID, nil) + if err != nil { + if stripeErr, ok := err.(*stripe.Error); ok { + // Ignore error if user already removed the webhook + if stripeErr.HTTPStatusCode == http.StatusNotFound { + return nil + } + + // Ignore error if user already revoked access + if stripeErr.HTTPStatusCode == http.StatusUnauthorized { + return nil + } + } + + return c.providerError(err) + } + return nil +} + +// GetAccount returns the authorized stripe account +func (c *stripeAppClient) GetAccount(ctx context.Context) (StripeAccount, error) { + stripeAccount, err := c.client.Accounts.Get() + if err != nil { + return StripeAccount{}, c.providerError(err) + } + + return StripeAccount{ + StripeAccountID: stripeAccount.ID, + }, nil +} + +// GetPaymentMethod returns the stripe payment method by stripe payment method ID +func (c *stripeAppClient) GetPaymentMethod(ctx context.Context, stripePaymentMethodID string) (StripePaymentMethod, error) { + stripePaymentMethod, err := c.client.PaymentMethods.Get(stripePaymentMethodID, nil) + if err != nil { + // Stripe customer not found error + if stripeErr, ok := err.(*stripe.Error); ok && stripeErr.Code == stripe.ErrorCodeResourceMissing { + if stripeErr.HTTPStatusCode == http.StatusUnauthorized { + return StripePaymentMethod{}, NewStripePaymentMethodNotFoundError(stripePaymentMethodID) + } + } + + return StripePaymentMethod{}, c.providerError(err) + } + + return toStripePaymentMethod(stripePaymentMethod), nil +} + +// StripePaymentMethod converts a Stripe API payment method to a StripePaymentMethod +func toStripePaymentMethod(stripePaymentMethod *stripe.PaymentMethod) StripePaymentMethod { + paymentMethod := StripePaymentMethod{ + ID: stripePaymentMethod.ID, + } + + if stripePaymentMethod.Customer != nil { + paymentMethod.StripeCustomerID = &stripePaymentMethod.Customer.ID + } + + if stripePaymentMethod.BillingDetails != nil && stripePaymentMethod.BillingDetails.Address != nil { + address := *stripePaymentMethod.BillingDetails.Address + + paymentMethod.Name = stripePaymentMethod.BillingDetails.Name + paymentMethod.Email = stripePaymentMethod.BillingDetails.Email + + paymentMethod.BillingAddress = &models.Address{ + Country: lo.ToPtr(models.CountryCode(address.Country)), + City: lo.ToPtr(address.City), + State: lo.ToPtr(address.State), + PostalCode: lo.ToPtr(address.PostalCode), + Line1: lo.ToPtr(address.Line1), + Line2: lo.ToPtr(address.Line2), + PhoneNumber: lo.ToPtr(stripePaymentMethod.BillingDetails.Phone), + } + } + + return paymentMethod +} + +// providerError returns a typed error for stripe provider errors +func (c *stripeAppClient) providerError(err error) error { + if stripeErr, ok := err.(*stripe.Error); ok { + switch stripeErr.HTTPStatusCode { + // Let's reflect back invalid request errors to the client. + case http.StatusBadRequest: + return models.NewGenericValidationError( + fmt.Errorf("stripe error: %s, request log url: %s", stripeErr.Msg, stripeErr.RequestLogURL), + ) + // Let's reflect back unauthorized errors to the client. + // We also update the app status to unauthorized. + case http.StatusUnauthorized: + status := app.AppStatusUnauthorized + + err = c.appService.UpdateAppStatus(context.Background(), app.UpdateAppStatusInput{ + ID: c.appID, + Status: status, + }) + if err != nil { + return fmt.Errorf("failed to update app status to %s for app %s: %w", c.appID.ID, status, err) + } + + return app.NewAppProviderAuthenticationError( + &c.appID, + c.appID.Namespace, + errors.New(stripeErr.Msg), + ) + default: + return app.NewAppProviderError( + &c.appID, + c.appID.Namespace, + errors.New(stripeErr.Msg), + ) + } + } + + return err +} diff --git a/app/stripe/client/checkout.go b/app/stripe/client/checkout.go new file mode 100644 index 0000000000000000000000000000000000000000..d0c1e5ec2354b24ac1177597c28fb996cfb51fde --- /dev/null +++ b/app/stripe/client/checkout.go @@ -0,0 +1,323 @@ +package client + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/samber/lo" + "github.com/stripe/stripe-go/v80" + + "github.com/openmeterio/openmeter/api" + app "github.com/openmeterio/openmeter/openmeter/app" + "github.com/openmeterio/openmeter/openmeter/customer" + "github.com/openmeterio/openmeter/pkg/currencyx" + "github.com/openmeterio/openmeter/pkg/models" +) + +// CreateCheckoutSession creates a checkout session +func (c *stripeAppClient) CreateCheckoutSession(ctx context.Context, input CreateCheckoutSessionInput) (StripeCheckoutSession, error) { + if err := input.Validate(); err != nil { + return StripeCheckoutSession{}, models.NewGenericValidationError(err) + } + + metadata := lo.FromPtr(input.Options.Metadata) + if metadata == nil { + metadata = map[string]string{} + } + + metadata[StripeMetadataNamespace] = input.AppID.Namespace + metadata[StripeMetadataAppID] = input.AppID.ID + metadata[StripeMetadataCustomerID] = input.CustomerID.ID + + // Create checkout session + params := &stripe.CheckoutSessionParams{ + Customer: lo.ToPtr(input.StripeCustomerID), + Mode: lo.ToPtr(string(stripe.CheckoutSessionModeSetup)), + SetupIntentData: &stripe.CheckoutSessionSetupIntentDataParams{ + Metadata: metadata, + }, + } + + if input.Options.BillingAddressCollection != nil { + params.BillingAddressCollection = lo.ToPtr(string(*input.Options.BillingAddressCollection)) + } + + if input.Options.CancelURL != nil { + params.CancelURL = input.Options.CancelURL + } + + if input.Options.ClientReferenceID != nil { + params.ClientReferenceID = input.Options.ClientReferenceID + } + + if input.Options.CustomerUpdate != nil { + params.CustomerUpdate = &stripe.CheckoutSessionCustomerUpdateParams{} + + if input.Options.CustomerUpdate.Address != nil { + params.CustomerUpdate.Address = lo.ToPtr(string(*input.Options.CustomerUpdate.Address)) + } + + if input.Options.CustomerUpdate.Name != nil { + params.CustomerUpdate.Name = lo.ToPtr(string(*input.Options.CustomerUpdate.Name)) + } + + if input.Options.CustomerUpdate.Shipping != nil { + params.CustomerUpdate.Shipping = lo.ToPtr(string(*input.Options.CustomerUpdate.Shipping)) + } + } + + if input.Options.Currency != nil { + params.Currency = input.Options.Currency + } + + if input.Options.ConsentCollection != nil { + params.ConsentCollection = &stripe.CheckoutSessionConsentCollectionParams{} + + if input.Options.ConsentCollection.PaymentMethodReuseAgreement != nil { + params.ConsentCollection.PaymentMethodReuseAgreement = &stripe.CheckoutSessionConsentCollectionPaymentMethodReuseAgreementParams{} + + if input.Options.ConsentCollection.PaymentMethodReuseAgreement.Position != nil { + params.ConsentCollection.PaymentMethodReuseAgreement.Position = lo.ToPtr(string(*input.Options.ConsentCollection.PaymentMethodReuseAgreement.Position)) + } + } + + if input.Options.ConsentCollection.Promotions != nil { + params.ConsentCollection.Promotions = lo.ToPtr(string(*input.Options.ConsentCollection.Promotions)) + } + + if input.Options.ConsentCollection.TermsOfService != nil { + params.ConsentCollection.TermsOfService = lo.ToPtr(string(*input.Options.ConsentCollection.TermsOfService)) + } + } + + if input.Options.CustomText != nil { + params.CustomText = &stripe.CheckoutSessionCustomTextParams{} + + if input.Options.CustomText.AfterSubmit != nil { + params.CustomText.AfterSubmit = &stripe.CheckoutSessionCustomTextAfterSubmitParams{} + + if input.Options.CustomText.AfterSubmit.Message != nil { + params.CustomText.AfterSubmit.Message = input.Options.CustomText.AfterSubmit.Message + } + } + + if input.Options.CustomText.ShippingAddress != nil { + params.CustomText.ShippingAddress = &stripe.CheckoutSessionCustomTextShippingAddressParams{} + + if input.Options.CustomText.ShippingAddress.Message != nil { + params.CustomText.ShippingAddress.Message = input.Options.CustomText.ShippingAddress.Message + } + } + + if input.Options.CustomText.Submit != nil { + params.CustomText.Submit = &stripe.CheckoutSessionCustomTextSubmitParams{} + + if input.Options.CustomText.Submit.Message != nil { + params.CustomText.Submit.Message = input.Options.CustomText.Submit.Message + } + } + + if input.Options.CustomText.TermsOfServiceAcceptance != nil { + params.CustomText.TermsOfServiceAcceptance = &stripe.CheckoutSessionCustomTextTermsOfServiceAcceptanceParams{} + + if input.Options.CustomText.TermsOfServiceAcceptance.Message != nil { + params.CustomText.TermsOfServiceAcceptance.Message = input.Options.CustomText.TermsOfServiceAcceptance.Message + } + } + } + + if input.Options.ExpiresAt != nil { + params.ExpiresAt = input.Options.ExpiresAt + } + + if input.Options.Locale != nil { + params.Locale = input.Options.Locale + } + + if input.Options.ReturnURL != nil { + params.ReturnURL = input.Options.ReturnURL + } + + if input.Options.SuccessURL != nil { + params.SuccessURL = input.Options.SuccessURL + } + + if input.Options.UiMode != nil { + params.UIMode = lo.ToPtr(string(*input.Options.UiMode)) + } + + if input.Options.PaymentMethodTypes != nil { + params.PaymentMethodTypes = lo.Map( + *input.Options.PaymentMethodTypes, + func(paymentMethodType string, _ int) *string { + return &paymentMethodType + }, + ) + } + + if input.Options.RedirectOnCompletion != nil { + params.RedirectOnCompletion = lo.ToPtr(string(*input.Options.RedirectOnCompletion)) + } + + if input.Options.TaxIdCollection != nil { + params.TaxIDCollection = &stripe.CheckoutSessionTaxIDCollectionParams{ + Enabled: &input.Options.TaxIdCollection.Enabled, + } + + if input.Options.TaxIdCollection.Required != nil { + params.TaxIDCollection.Required = lo.ToPtr(string(*input.Options.TaxIdCollection.Required)) + } + } + + // Create checkout session + session, err := c.client.CheckoutSessions.New(params) + if err != nil { + return StripeCheckoutSession{}, c.providerError(err) + } + + // Create output + if session.SetupIntent == nil { + return StripeCheckoutSession{}, errors.New("setup intent is required") + } + + stripeCheckoutSession := StripeCheckoutSession{ + Mode: session.Mode, + SessionID: session.ID, + SetupIntentID: session.SetupIntent.ID, + CreatedAt: time.Unix(session.Created, 0), + } + + if session.CancelURL != "" { + stripeCheckoutSession.CancelURL = &session.CancelURL + } + + if session.ClientSecret != "" { + stripeCheckoutSession.ClientSecret = &session.ClientSecret + } + + if session.ClientReferenceID != "" { + stripeCheckoutSession.ClientReferenceID = &session.ClientReferenceID + } + + if session.Currency != "" { + stripeCheckoutSession.Currency = lo.ToPtr(FromStripeCurrency(session.Currency)) + } + + if session.CustomerEmail != "" { + stripeCheckoutSession.CustomerEmail = &session.CustomerEmail + } + + if session.Metadata != nil { + stripeCheckoutSession.Metadata = &session.Metadata + } + + if session.ReturnURL != "" { + stripeCheckoutSession.ReturnURL = &session.ReturnURL + } + + if session.SuccessURL != "" { + stripeCheckoutSession.SuccessURL = &session.SuccessURL + } + + if session.Status != "" { + stripeCheckoutSession.Status = &session.Status + } + + if session.URL != "" { + stripeCheckoutSession.URL = &session.URL + } + + if session.ExpiresAt != 0 { + stripeCheckoutSession.ExpiresAt = lo.ToPtr(time.Unix(session.ExpiresAt, 0)) + } + + return stripeCheckoutSession, nil +} + +type StripeCheckoutSession struct { + CancelURL *string + ClientSecret *string + ClientReferenceID *string + CustomerEmail *string + Currency *currencyx.Code + CreatedAt time.Time + ExpiresAt *time.Time + Mode stripe.CheckoutSessionMode + Metadata *map[string]string + ReturnURL *string + SessionID string + SetupIntentID string + Status *stripe.CheckoutSessionStatus + SuccessURL *string + URL *string + + // We don't add payment intent and status here because we always use setup mode +} + +func (o StripeCheckoutSession) Validate() error { + if o.SessionID == "" { + return errors.New("session id is required") + } + + if o.SetupIntentID == "" { + return errors.New("setup intent id is required") + } + + if o.Mode != stripe.CheckoutSessionModeSetup { + return errors.New("mode must be setup") + } + + return nil +} + +type CreateCheckoutSessionInput struct { + AppID app.AppID + CustomerID customer.CustomerID + StripeCustomerID string + Options api.CreateStripeCheckoutSessionRequestOptions +} + +func (i CreateCheckoutSessionInput) Validate() error { + if err := i.AppID.Validate(); err != nil { + return fmt.Errorf("error validating app id: %w", err) + } + + if err := i.CustomerID.Validate(); err != nil { + return fmt.Errorf("error validating customer id: %w", err) + } + + if i.AppID.Namespace != i.CustomerID.Namespace { + return errors.New("app and customer must be in the same namespace") + } + + if i.StripeCustomerID != "" && !strings.HasPrefix(i.StripeCustomerID, "cus_") { + return errors.New("stripe customer id must start with cus_") + } + + if i.Options.UiMode != nil { + switch *i.Options.UiMode { + case api.CheckoutSessionUIModeEmbedded: + if i.Options.CancelURL != nil { + return errors.New("cancel url is not allowed for embedded ui mode") + } + case api.CheckoutSessionUIModeHosted: + if i.Options.SuccessURL == nil { + return errors.New("success url is required for hosted ui mode") + } + } + } + + // Let's validate metadata for reserved keys + metadata := lo.FromPtr(i.Options.Metadata) + if metadata != nil { + for _, reservedKey := range SetupIntentReservedMetadataKeys { + if _, ok := metadata[reservedKey]; ok { + return fmt.Errorf("metadata key %s is reserved", reservedKey) + } + } + } + return nil +} diff --git a/app/stripe/client/client.go b/app/stripe/client/client.go new file mode 100644 index 0000000000000000000000000000000000000000..7086468b3127f2c16d60621165870c0d526bc9e0 --- /dev/null +++ b/app/stripe/client/client.go @@ -0,0 +1,185 @@ +package client + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "strings" + + "github.com/samber/lo" + "github.com/stripe/stripe-go/v80" + "github.com/stripe/stripe-go/v80/client" + + app "github.com/openmeterio/openmeter/openmeter/app" + "github.com/openmeterio/openmeter/pkg/currencyx" + "github.com/openmeterio/openmeter/pkg/models" +) + +// StripeClient is a client for the stripe API without an installed app +// It is useful to call the Stripe API before the app is installed, +// for example during the app installation process. +type StripeClient interface { + GetAccount(ctx context.Context) (StripeAccount, error) + SetupWebhook(ctx context.Context, input SetupWebhookInput) (StripeWebhookEndpoint, error) +} + +// StripeClientFactory is a factory function to create a StripeClient. +type StripeClientFactory = func(config StripeClientConfig) (StripeClient, error) + +type StripeClientConfig struct { + Namespace string + APIKey string + Logger *slog.Logger +} + +func (c *StripeClientConfig) Validate() error { + if c.Namespace == "" { + return fmt.Errorf("namespace is required") + } + + if c.APIKey == "" { + return fmt.Errorf("api key is required") + } + + if c.Logger == nil { + return fmt.Errorf("logger is required") + } + + return nil +} + +type stripeClient struct { + client *client.API + namespace string +} + +func NewStripeClient(config StripeClientConfig) (StripeClient, error) { + if err := config.Validate(); err != nil { + return nil, err + } + + backend := stripe.GetBackendWithConfig(stripe.APIBackend, &stripe.BackendConfig{ + LeveledLogger: leveledLogger{ + logger: config.Logger, + }, + }) + client := &client.API{} + client.Init(config.APIKey, &stripe.Backends{ + API: backend, + Connect: backend, + Uploads: backend, + }) + + return &stripeClient{ + client: client, + namespace: config.Namespace, + }, nil +} + +// SetupWebhook setups a stripe webhook to handle setup intents and save the payment method +func (c *stripeClient) SetupWebhook(ctx context.Context, input SetupWebhookInput) (StripeWebhookEndpoint, error) { + if err := input.Validate(); err != nil { + return StripeWebhookEndpoint{}, fmt.Errorf("invalid input: %w", err) + } + + params := &stripe.WebhookEndpointParams{ + EnabledEvents: []*string{ + // Setup intents + lo.ToPtr(WebhookEventTypeSetupIntentSucceeded), + lo.ToPtr(WebhookEventTypeSetupIntentFailed), + lo.ToPtr(WebhookEventTypeSetupIntentRequiresAction), + + // Invoices + lo.ToPtr(WebhookEventTypeInvoiceFinalizationFailed), + lo.ToPtr(WebhookEventTypeInvoiceMarkedUncollectible), + lo.ToPtr(WebhookEventTypeInvoiceOverdue), + lo.ToPtr(WebhookEventTypeInvoicePaid), + lo.ToPtr(WebhookEventTypeInvoicePaymentActionRequired), + lo.ToPtr(WebhookEventTypeInvoicePaymentFailed), + lo.ToPtr(WebhookEventTypeInvoicePaymentSucceeded), + lo.ToPtr(WebhookEventTypeInvoiceSent), + lo.ToPtr(WebhookEventTypeInvoiceVoided), + }, + URL: lo.ToPtr(input.WebhookURL), + Description: lo.ToPtr("OpenMeter Stripe Webhook, do not delete or modify manually"), + Metadata: map[string]string{ + StripeMetadataNamespace: input.AppID.Namespace, + StripeMetadataAppID: input.AppID.ID, + }, + // We set the API version to a specific date to ensure that + // the webhook is compatible with the Stripe client's API version. + // https://docs.stripe.com/sdks/set-version + APIVersion: lo.ToPtr(stripe.APIVersion), + } + result, err := c.client.WebhookEndpoints.New(params) + if err != nil { + return StripeWebhookEndpoint{}, c.providerError(err) + } + + out := StripeWebhookEndpoint{ + EndpointID: result.ID, + Secret: result.Secret, + } + + return out, nil +} + +// GetAccount returns the authorized stripe account +func (c *stripeClient) GetAccount(ctx context.Context) (StripeAccount, error) { + stripeAccount, err := c.client.Accounts.Get() + if err != nil { + return StripeAccount{}, c.providerError(err) + } + + return StripeAccount{ + StripeAccountID: stripeAccount.ID, + Country: models.CountryCode(stripeAccount.Country), + BusinessProfile: stripeAccount.BusinessProfile, + }, nil +} + +// providerError returns a typed error for stripe provider errors +func (c *stripeClient) providerError(err error) error { + if stripeErr, ok := err.(*stripe.Error); ok { + switch stripeErr.HTTPStatusCode { + // Let's reflect back invalid request errors to the client. + case http.StatusBadRequest: + return models.NewGenericValidationError( + fmt.Errorf("stripe error: %s, request log url: %s", stripeErr.Msg, stripeErr.RequestLogURL), + ) + // Let's reflect back unauthorized errors to the client. + case http.StatusUnauthorized: + return app.NewAppProviderAuthenticationError( + nil, + c.namespace, + errors.New(stripeErr.Msg), + ) + default: + return app.NewAppProviderError( + nil, + c.namespace, + errors.New(stripeErr.Msg), + ) + } + } + + return err +} + +// Stripe uses lowercase three-letter ISO codes for currency codes. +// See: https://docs.stripe.com/currencies +func Currency(c currencyx.Code) string { + return strings.ToLower(string(c)) +} + +// CurrencyPtr is a helper function for pointer currency codes. +func CurrencyPtr(c *currencyx.Code) *string { + return lo.ToPtr(Currency(*c)) +} + +// FromStripeCurrency converts a stripe currency code to a currencyx code. +func FromStripeCurrency(c stripe.Currency) currencyx.Code { + return currencyx.Code(strings.ToUpper(string(c))) +} diff --git a/app/stripe/client/customer.go b/app/stripe/client/customer.go new file mode 100644 index 0000000000000000000000000000000000000000..6a5f23b8284233094cdc322b92fc01b14efa9e21 --- /dev/null +++ b/app/stripe/client/customer.go @@ -0,0 +1,80 @@ +package client + +import ( + "context" + + "github.com/samber/lo" + "github.com/stripe/stripe-go/v80" +) + +// GetCustomer returns the stripe customer by stripe customer ID +func (c *stripeAppClient) GetCustomer(ctx context.Context, stripeCustomerID string) (StripeCustomer, error) { + stripeCustomer, err := c.client.Customers.Get(stripeCustomerID, &stripe.CustomerParams{ + Expand: []*string{ + lo.ToPtr("invoice_settings.default_payment_method"), + lo.ToPtr("tax"), + }, + }) + if err != nil { + // Stripe customer not found error + if stripeErr, ok := err.(*stripe.Error); ok && stripeErr.Code == stripe.ErrorCodeResourceMissing { + return StripeCustomer{}, NewStripeCustomerNotFoundError(stripeCustomerID) + } + + return StripeCustomer{}, c.providerError(err) + } + + customer := StripeCustomer{ + StripeCustomerID: stripeCustomer.ID, + } + + if stripeCustomer.Email != "" { + customer.Email = &stripeCustomer.Email + } + + if stripeCustomer.Currency != "" { + customer.Currency = lo.ToPtr(string(stripeCustomer.Currency)) + } + + if stripeCustomer.InvoiceSettings != nil { + invoiceSettings := *stripeCustomer.InvoiceSettings + + if stripeCustomer.InvoiceSettings.DefaultPaymentMethod != nil { + customer.DefaultPaymentMethod = lo.ToPtr(toStripePaymentMethod(invoiceSettings.DefaultPaymentMethod)) + } + } + + if stripeCustomer.Tax != nil { + customer.Tax = &StripeCustomerTax{ + AutomaticTax: StripeCustomerAutomaticTax(stripeCustomer.Tax.AutomaticTax), + } + } + + return customer, nil +} + +// CreateCustomer creates a stripe customer +func (c *stripeAppClient) CreateCustomer(ctx context.Context, input CreateStripeCustomerInput) (StripeCustomer, error) { + if err := input.Validate(); err != nil { + return StripeCustomer{}, err + } + + // Create customer + stripeCustomer, err := c.client.Customers.New(&stripe.CustomerParams{ + Name: input.Name, + Email: input.Email, + Metadata: map[string]string{ + StripeMetadataNamespace: input.AppID.Namespace, + StripeMetadataCustomerID: input.CustomerID.ID, + }, + }) + if err != nil { + return StripeCustomer{}, c.providerError(err) + } + + out := StripeCustomer{ + StripeCustomerID: stripeCustomer.ID, + } + + return out, nil +} diff --git a/app/stripe/client/errors.go b/app/stripe/client/errors.go new file mode 100644 index 0000000000000000000000000000000000000000..5fe7bde3a3c3d32eedd92ef42be2d39e539acbfb --- /dev/null +++ b/app/stripe/client/errors.go @@ -0,0 +1,101 @@ +package client + +import ( + "errors" + "fmt" + + "github.com/openmeterio/openmeter/pkg/models" +) + +// StripeCustomerNotFoundError +var _ models.GenericError = StripeCustomerNotFoundError{} + +func NewStripeCustomerNotFoundError(stripeCustomerID string) *StripeCustomerNotFoundError { + return &StripeCustomerNotFoundError{ + err: fmt.Errorf("stripe customer %s not found", stripeCustomerID), + } +} + +func IsStripeCustomerNotFoundError(err error) bool { + if err == nil { + return false + } + + var e *StripeCustomerNotFoundError + + return errors.As(err, &e) +} + +type StripeCustomerNotFoundError struct { + err error +} + +func (e StripeCustomerNotFoundError) Error() string { + return e.err.Error() +} + +func (e StripeCustomerNotFoundError) Unwrap() error { + return e.err +} + +// StripePaymentMethodNotFoundError +var _ models.GenericError = StripePaymentMethodNotFoundError{} + +func NewStripePaymentMethodNotFoundError(stripePaymentMethodID string) *StripePaymentMethodNotFoundError { + return &StripePaymentMethodNotFoundError{ + err: fmt.Errorf("stripe payment method %s not found", stripePaymentMethodID), + } +} + +func IsStripePaymentMethodNotFoundError(err error) bool { + if err == nil { + return false + } + + var e *StripePaymentMethodNotFoundError + + return errors.As(err, &e) +} + +type StripePaymentMethodNotFoundError struct { + err error +} + +func (e StripePaymentMethodNotFoundError) Error() string { + return e.err.Error() +} + +func (e StripePaymentMethodNotFoundError) Unwrap() error { + return e.err +} + +// StripeInvoiceCustomerTaxLocationInvalid +var _ models.GenericError = StripeInvoiceCustomerTaxLocationInvalidError{} + +func NewStripeInvoiceCustomerTaxLocationInvalidError(stripeInvoiceID string, stripeMessage string) *StripeInvoiceCustomerTaxLocationInvalidError { + return &StripeInvoiceCustomerTaxLocationInvalidError{ + err: fmt.Errorf("stripe invoice %s customer tax location invalid: %s", stripeInvoiceID, stripeMessage), + } +} + +func IsStripeInvoiceCustomerTaxLocationInvalidError(err error) bool { + if err == nil { + return false + } + + var e *StripeInvoiceCustomerTaxLocationInvalidError + + return errors.As(err, &e) +} + +type StripeInvoiceCustomerTaxLocationInvalidError struct { + err error +} + +func (e StripeInvoiceCustomerTaxLocationInvalidError) Error() string { + return e.err.Error() +} + +func (e StripeInvoiceCustomerTaxLocationInvalidError) Unwrap() error { + return e.err +} diff --git a/app/stripe/client/invoice.go b/app/stripe/client/invoice.go new file mode 100644 index 0000000000000000000000000000000000000000..fa4b7584e6594056364c8767178af120e80bcda7 --- /dev/null +++ b/app/stripe/client/invoice.go @@ -0,0 +1,257 @@ +package client + +import ( + "context" + "errors" + "fmt" + + "github.com/samber/lo" + "github.com/stripe/stripe-go/v80" + + app "github.com/openmeterio/openmeter/openmeter/app" + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/customer" + "github.com/openmeterio/openmeter/pkg/currencyx" + "github.com/openmeterio/openmeter/pkg/models" +) + +// CreateInvoice creates a new invoice for a customer in Stripe. +func (c *stripeAppClient) CreateInvoice(ctx context.Context, input CreateInvoiceInput) (*stripe.Invoice, error) { + if err := input.Validate(); err != nil { + return nil, fmt.Errorf("stripe create invoice: invalid input: %w", err) + } + + params := &stripe.InvoiceParams{ + Currency: lo.ToPtr(string(input.Currency)), + Customer: lo.ToPtr(input.StripeCustomerID), + // FinalizeInvoice will advance the invoice + AutoAdvance: lo.ToPtr(false), + // If not set, defaults to the default payment method in the customer’s invoice settings. + DefaultPaymentMethod: input.StripeDefaultPaymentMethodID, + DaysUntilDue: input.DaysUntilDue, + StatementDescriptor: input.StatementDescriptor, + // Tax settings + AutomaticTax: &stripe.InvoiceAutomaticTaxParams{ + Enabled: lo.ToPtr(input.AutomaticTaxEnabled), + }, + Metadata: map[string]string{ + StripeMetadataNamespace: input.AppID.Namespace, + StripeMetadataAppID: input.AppID.ID, + StripeMetadataCustomerID: input.CustomerID.ID, + StripeMetadataInvoiceID: input.InvoiceID, + }, + } + + // When charging automatically, Stripe will attempt to pay this invoice using the default source attached to the customer. + // When sending an invoice, Stripe will email this invoice to the customer with payment instructions. + switch input.CollectionMethod { + case billing.CollectionMethodChargeAutomatically: + params.CollectionMethod = lo.ToPtr(string(stripe.InvoiceCollectionMethodChargeAutomatically)) + case billing.CollectionMethodSendInvoice: + params.CollectionMethod = lo.ToPtr(string(stripe.InvoiceCollectionMethodSendInvoice)) + default: + return nil, fmt.Errorf("stripe create invoice: invalid collection method: %s", input.CollectionMethod) + } + + // See: https://docs.stripe.com/api/idempotent_requests + // Stripe’s idempotency works by saving the resulting status code and body of the first request made for any given idempotency key, + // regardless of whether it succeeds or fails. Subsequent requests with the same key return the same result, including 500 errors. + params.SetIdempotencyKey(fmt.Sprintf("invoice-create-%s", input.InvoiceID)) + + invoice, err := c.client.Invoices.New(params) + if err != nil { + return nil, c.providerError(err) + } + + return invoice, nil +} + +// UpdateInvoice updates a Stripe invoice Stripe. +func (c *stripeAppClient) UpdateInvoice(ctx context.Context, input UpdateInvoiceInput) (*stripe.Invoice, error) { + if err := input.Validate(); err != nil { + return nil, fmt.Errorf("stripe update invoice: invalid input: %w", err) + } + + params := &stripe.InvoiceParams{ + AutomaticTax: &stripe.InvoiceAutomaticTaxParams{ + Enabled: lo.ToPtr(input.AutomaticTaxEnabled), + }, + StatementDescriptor: input.StatementDescriptor, + } + + invoice, err := c.client.Invoices.Update(input.StripeInvoiceID, params) + if err != nil { + return nil, c.providerError(err) + } + + return invoice, nil +} + +// DeleteInvoice deletes a Stripe invoice. +// Stripe only allows deleting invoices in draft state. +func (c *stripeAppClient) DeleteInvoice(ctx context.Context, input DeleteInvoiceInput) error { + if err := input.Validate(); err != nil { + return fmt.Errorf("stripe delete invoice: invalid input: %w", err) + } + + _, err := c.client.Invoices.Del(input.StripeInvoiceID, nil) + if err != nil { + return c.providerError(err) + } + + return nil +} + +// FinalizeInvoice finalizes a Stripe invoice. +func (c *stripeAppClient) FinalizeInvoice(ctx context.Context, input FinalizeInvoiceInput) (*stripe.Invoice, error) { + if err := input.Validate(); err != nil { + return nil, fmt.Errorf("stripe finalize invoice: invalid input: %w", err) + } + + invoice, err := c.client.Invoices.FinalizeInvoice(input.StripeInvoiceID, &stripe.InvoiceFinalizeInvoiceParams{ + AutoAdvance: lo.ToPtr(input.AutoAdvance), + }) + if err != nil { + // Stripe customer tax location invalid error + if stripeErr, ok := err.(*stripe.Error); ok && stripeErr.Code == stripe.ErrorCodeCustomerTaxLocationInvalid { + return nil, NewStripeInvoiceCustomerTaxLocationInvalidError(input.StripeInvoiceID, stripeErr.Msg) + } + + return nil, c.providerError(err) + } + + return invoice, nil +} + +// GetInvoice gets an invoice from Stripe. +func (c *stripeAppClient) GetInvoice(ctx context.Context, input GetInvoiceInput) (*stripe.Invoice, error) { + if err := input.Validate(); err != nil { + return nil, fmt.Errorf("stripe get invoice: invalid input: %w", err) + } + + invoice, err := c.client.Invoices.Get(input.StripeInvoiceID, nil) + if err != nil { + return nil, c.providerError(err) + } + + return invoice, nil +} + +// CreateInvoiceInput is the input for creating a new invoice in Stripe. +type CreateInvoiceInput struct { + AppID app.AppID + CustomerID customer.CustomerID + InvoiceID string + AutomaticTaxEnabled bool + CollectionMethod billing.CollectionMethod + Currency currencyx.Code + DaysUntilDue *int64 + StatementDescriptor *string + StripeCustomerID string + StripeDefaultPaymentMethodID *string +} + +func (i CreateInvoiceInput) Validate() error { + var errs []error + + if err := i.AppID.Validate(); err != nil { + errs = append(errs, fmt.Errorf("invalid app id: %w", err)) + } + + if err := i.CustomerID.Validate(); err != nil { + errs = append(errs, fmt.Errorf("invalid customer id: %w", err)) + } + + if i.InvoiceID == "" { + errs = append(errs, errors.New("invoice id is required")) + } + + if i.CollectionMethod == "" { + errs = append(errs, errors.New("collection method is required")) + } + + if i.Currency == "" { + errs = append(errs, errors.New("currency is required")) + } + + if i.CollectionMethod == billing.CollectionMethodChargeAutomatically && i.DaysUntilDue != nil { + errs = append(errs, errors.New("days until due cannot be set when charging automatically")) + } + + if i.CollectionMethod == billing.CollectionMethodSendInvoice && i.DaysUntilDue == nil { + errs = append(errs, errors.New("days until due is required when sending an invoice")) + } + + if i.StripeCustomerID == "" { + errs = append(errs, errors.New("stripe customer id is required")) + } + + if i.StatementDescriptor != nil && *i.StatementDescriptor == "" { + errs = append(errs, errors.New("statement descriptor cannot be empty")) + } + + if len(errs) > 0 { + return models.NewGenericValidationError(errors.Join(errs...)) + } + + return nil +} + +// UpdateInvoiceInput is the input for updating an invoice in Stripe. +type UpdateInvoiceInput struct { + AutomaticTaxEnabled bool + StripeInvoiceID string + StatementDescriptor *string +} + +func (i UpdateInvoiceInput) Validate() error { + if i.StripeInvoiceID == "" { + return errors.New("stripe invoice id is required") + } + + if i.StatementDescriptor != nil && *i.StatementDescriptor == "" { + return errors.New("statement descriptor cannot be empty") + } + + return nil +} + +// DeleteInvoiceInput is the input for deleting an invoice in Stripe. +type DeleteInvoiceInput struct { + StripeInvoiceID string +} + +func (i DeleteInvoiceInput) Validate() error { + if i.StripeInvoiceID == "" { + return errors.New("stripe invoice id is required") + } + + return nil +} + +// FinalizeInvoiceInput is the input for finalizing an invoice in Stripe. +type FinalizeInvoiceInput struct { + StripeInvoiceID string + AutoAdvance bool +} + +func (i FinalizeInvoiceInput) Validate() error { + if i.StripeInvoiceID == "" { + return errors.New("stripe invoice id is required") + } + + return nil +} + +// GetInvoice gets an invoice from Stripe. +type GetInvoiceInput struct { + StripeInvoiceID string +} + +func (i GetInvoiceInput) Validate() error { + if i.StripeInvoiceID == "" { + return errors.New("stripe invoice id is required") + } + + return nil +} diff --git a/app/stripe/client/invoice_line.go b/app/stripe/client/invoice_line.go new file mode 100644 index 0000000000000000000000000000000000000000..7419041f8640060a34d7bb490ae4eb92a27d1fa3 --- /dev/null +++ b/app/stripe/client/invoice_line.go @@ -0,0 +1,177 @@ +package client + +import ( + "context" + "errors" + "fmt" + + "github.com/samber/lo" + "github.com/stripe/stripe-go/v80" + + "github.com/openmeterio/openmeter/pkg/slicesx" +) + +// ListInvoiceLineItems lists the invoice line items for a given Stripe invoice. +func (c *stripeAppClient) ListInvoiceLineItems(ctx context.Context, stripeInvoiceID string) ([]*stripe.InvoiceLineItem, error) { + if stripeInvoiceID == "" { + return nil, errors.New("stripe get invoice line items: invoice id is required") + } + + invoiceLineItems := []*stripe.InvoiceLineItem{} + + // Stripe SDK paginates automatically by default, so we don't need to handle pagination here. + invoiceLineItemsIterator := c.client.Invoices.ListLines(&stripe.InvoiceListLinesParams{ + Invoice: &stripeInvoiceID, + }) + + // Map the invoice item IDs to the line IDs + for invoiceLineItemsIterator.Next() { + invoiceLine := invoiceLineItemsIterator.InvoiceLineItem() + if invoiceLine != nil && invoiceLine.InvoiceItem != nil { + invoiceLineItems = append(invoiceLineItems, invoiceLine) + } + } + + if invoiceLineItemsIterator.Err() != nil { + return nil, fmt.Errorf("stripe get invoice line items: %w", invoiceLineItemsIterator.Err()) + } + + return invoiceLineItems, nil +} + +// AddInvoiceLines is the input for adding invoice lines to a Stripe invoice. +func (c *stripeAppClient) AddInvoiceLines(ctx context.Context, input AddInvoiceLinesInput) ([]StripeInvoiceItemWithLineID, error) { + if err := input.Validate(); err != nil { + return nil, fmt.Errorf("stripe add invoice lines: invalid input: %w", err) + } + + // Add the invoice lines to the Stripe invoice, one by one. + createdInvoiceItems, err := slicesx.MapWithErr(input.Lines, func(i *stripe.InvoiceItemParams) (*stripe.InvoiceItem, error) { + i.Invoice = stripe.String(input.StripeInvoiceID) + return c.client.InvoiceItems.New(i) + }) + if err != nil { + return nil, fmt.Errorf("stripe add invoice lines: %w", err) + } + + if len(createdInvoiceItems) == 0 { + return nil, nil + } + + // Creating an invoice item in Stripe does not return it's Stripe Invoice Line Item ID, + // so we need to list the invoice line items to get the line IDs. + invoiceLineItems, err := c.ListInvoiceLineItems(ctx, input.StripeInvoiceID) + if err != nil { + return nil, fmt.Errorf("stripe add invoice lines: get invoice line items: %w", err) + } + + // We know the invoice item ID from the creation above so we key line items by that + invoiceLineItemByInvoiceItemID := lo.KeyBy(invoiceLineItems, func(i *stripe.InvoiceLineItem) string { + return i.InvoiceItem.ID + }) + + // Lookup the line IDs for the invoice items + createdLines := make([]StripeInvoiceItemWithLineID, 0, len(createdInvoiceItems)) + for _, createdInvoiceItem := range createdInvoiceItems { + invoiceLineItem, found := invoiceLineItemByInvoiceItemID[createdInvoiceItem.ID] + if !found { + return nil, fmt.Errorf("stripe add invoice lines: line not found: %s", createdInvoiceItem.ID) + } + + createdLines = append(createdLines, StripeInvoiceItemWithLineID{ + InvoiceItem: createdInvoiceItem, + LineID: invoiceLineItem.ID, + }) + } + + return createdLines, nil +} + +// UpdateInvoiceLines is the input for updating invoice lines on a Stripe invoice. +func (c *stripeAppClient) UpdateInvoiceLines(ctx context.Context, input UpdateInvoiceLinesInput) ([]*stripe.InvoiceItem, error) { + if err := input.Validate(); err != nil { + return nil, fmt.Errorf("stripe update invoice lines: invalid input: %w", err) + } + + return slicesx.MapWithErr(input.Lines, func(i *StripeInvoiceItemWithID) (*stripe.InvoiceItem, error) { + return c.client.InvoiceItems.Update(i.ID, i.InvoiceItemParams) + }) +} + +// RemoveInvoiceLines is the input for removing invoice lines from a Stripe invoice. +func (c *stripeAppClient) RemoveInvoiceLines(ctx context.Context, input RemoveInvoiceLinesInput) error { + if err := input.Validate(); err != nil { + return fmt.Errorf("stripe update invoice lines: invalid input: %w", err) + } + + return errors.Join(lo.Map(input.Lines, func(id string, _ int) error { + _, err := c.client.InvoiceItems.Del(id, nil) + return err + })...) +} + +// AddInvoiceLinesInput is the input for adding lines to an invoice in Stripe. +type AddInvoiceLinesInput struct { + StripeInvoiceID string + Lines []*stripe.InvoiceItemParams +} + +type StripeInvoiceItemWithLineID struct { + *stripe.InvoiceItem + + LineID string +} + +func (i AddInvoiceLinesInput) Validate() error { + if i.StripeInvoiceID == "" { + return errors.New("stripe invoice id is required") + } + + if len(i.Lines) == 0 { + return errors.New("at least one line is required") + } + + return nil +} + +type StripeInvoiceItemWithID struct { + *stripe.InvoiceItemParams + + ID string +} + +// UpdateInvoiceLinesInput is the input for updating lines on an invoice in Stripe. +type UpdateInvoiceLinesInput struct { + StripeInvoiceID string + Lines []*StripeInvoiceItemWithID +} + +func (i UpdateInvoiceLinesInput) Validate() error { + if i.StripeInvoiceID == "" { + return errors.New("stripe invoice id is required") + } + + if len(i.Lines) == 0 { + return errors.New("at least one line is required") + } + + return nil +} + +// RemoveInvoiceLinesInput is the input for deleting lines on an invoice in Stripe. +type RemoveInvoiceLinesInput struct { + StripeInvoiceID string + Lines []string +} + +func (i RemoveInvoiceLinesInput) Validate() error { + if i.StripeInvoiceID == "" { + return errors.New("stripe invoice id is required") + } + + if len(i.Lines) == 0 { + return errors.New("at least one line is required") + } + + return nil +} diff --git a/app/stripe/client/logger.go b/app/stripe/client/logger.go new file mode 100644 index 0000000000000000000000000000000000000000..7528f18af8c2125be0abe4d1f7cd71afa4684280 --- /dev/null +++ b/app/stripe/client/logger.go @@ -0,0 +1,32 @@ +package client + +import ( + "fmt" + "log/slog" + + "github.com/stripe/stripe-go/v80" +) + +// leveledLogger is a logger that implements the stripe LeveledLogger interface +var _ stripe.LeveledLoggerInterface = (*leveledLogger)(nil) + +type leveledLogger struct { + logger *slog.Logger +} + +func (l leveledLogger) Debugf(format string, args ...interface{}) { + l.logger.Debug(fmt.Sprintf(format, args...), "source", "stripe-go") +} + +func (l leveledLogger) Infof(format string, args ...interface{}) { + l.logger.Info(fmt.Sprintf(format, args...), "source", "stripe-go") +} + +func (l leveledLogger) Warnf(format string, args ...interface{}) { + l.logger.Warn(fmt.Sprintf(format, args...), "source", "stripe-go") +} + +func (l leveledLogger) Errorf(format string, args ...interface{}) { + // We don't want to pollute the logs with errors from the Stripe API as we are handling them in the application + l.logger.Warn(fmt.Sprintf(format, args...), "source", "stripe-go") +} diff --git a/app/stripe/client/portal.go b/app/stripe/client/portal.go new file mode 100644 index 0000000000000000000000000000000000000000..278e6e0e5bac1150c705ffe3e0e59f31baa94727 --- /dev/null +++ b/app/stripe/client/portal.go @@ -0,0 +1,103 @@ +package client + +import ( + "context" + "errors" + "time" + + "github.com/samber/lo" + "github.com/stripe/stripe-go/v80" + + "github.com/openmeterio/openmeter/pkg/models" +) + +// CreatePortalSessionInput is the input for creating a customer portal session. +type CreatePortalSessionInput struct { + StripeCustomerID string + ConfigurationID *string + ReturnURL *string + Locale *string +} + +// Validate validates the input for creating a customer portal session. +func (i CreatePortalSessionInput) Validate() error { + var errs []error + + if i.StripeCustomerID == "" { + errs = append(errs, models.NewGenericValidationError(errors.New("stripe customer id is required"))) + } + + if i.ReturnURL != nil && *i.ReturnURL == "" { + errs = append(errs, models.NewGenericValidationError(errors.New("return url cannot be empty if provided"))) + } + + if i.Locale != nil && *i.Locale == "" { + errs = append(errs, models.NewGenericValidationError(errors.New("locale cannot be empty if provided"))) + } + + return errors.Join(errs...) +} + +// PortalSession is the response from the Stripe API for a customer portal session. +type PortalSession struct { + // The ID of the customer portal session. + // See: https://docs.stripe.com/api/customer_portal/sessions/object#portal_session_object-id + ID string + + // Configuration Configuration used to customize the customer portal. + // See: https://docs.stripe.com/api/customer_portal/sessions/object#portal_session_object-configuration + Configuration *stripe.BillingPortalConfiguration + CreatedAt time.Time + StripeCustomerID string + + // Livemode Livemode. + Livemode bool + + // Locale Status. + // 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 + Locale string + + // ReturnUrl Return URL. + // See: https://docs.stripe.com/api/customer_portal/sessions/object#portal_session_object-return_url + ReturnURL string + + // The URL to redirect the customer to after they have completed + // their requested actions. + URL string +} + +// CreatePortalSession creates a customer portal session. +func (c *stripeAppClient) CreatePortalSession(ctx context.Context, input CreatePortalSessionInput) (PortalSession, error) { + if err := input.Validate(); err != nil { + return PortalSession{}, err + } + + portalSession, err := c.client.BillingPortalSessions.New(&stripe.BillingPortalSessionParams{ + Customer: lo.ToPtr(input.StripeCustomerID), + Configuration: input.ConfigurationID, + ReturnURL: input.ReturnURL, + Locale: input.Locale, + }) + if err != nil { + // Stripe customer not found error + if stripeErr, ok := err.(*stripe.Error); ok && stripeErr.Code == stripe.ErrorCodeResourceMissing { + return PortalSession{}, NewStripeCustomerNotFoundError(input.StripeCustomerID) + } + + return PortalSession{}, c.providerError(err) + } + + stripePortalSession := PortalSession{ + ID: portalSession.ID, + Configuration: portalSession.Configuration, + StripeCustomerID: portalSession.Customer, + Livemode: portalSession.Livemode, + Locale: portalSession.Locale, + ReturnURL: portalSession.ReturnURL, + URL: portalSession.URL, + CreatedAt: time.Unix(portalSession.Created, 0), + } + + return stripePortalSession, nil +} diff --git a/app/stripe/client/stripe.go b/app/stripe/client/stripe.go new file mode 100644 index 0000000000000000000000000000000000000000..2a2cdf47e2999a43cbc057ad3f268c41f0504559 --- /dev/null +++ b/app/stripe/client/stripe.go @@ -0,0 +1,142 @@ +package client + +import ( + "errors" + "fmt" + "strings" + + "github.com/stripe/stripe-go/v80" + + "github.com/openmeterio/openmeter/openmeter/app" + "github.com/openmeterio/openmeter/openmeter/customer" + "github.com/openmeterio/openmeter/pkg/models" +) + +type StripeWebhookEndpoint struct { + EndpointID string + Secret string +} + +type StripeAccount struct { + StripeAccountID string + BusinessProfile *stripe.AccountBusinessProfile + Country models.CountryCode +} + +type StripeCustomer struct { + StripeCustomerID string + Name *string + Currency *string + Email *string + // ID of a payment method that’s attached to the customer, + // to be used as the customer’s default payment method for invoices. + DefaultPaymentMethod *StripePaymentMethod + Tax *StripeCustomerTax +} + +type StripeCustomerTax struct { + AutomaticTax StripeCustomerAutomaticTax +} + +// https://docs.stripe.com/api/customers/object#customer_object-tax-automatic_tax +type StripeCustomerAutomaticTax string + +const ( + // There was an error determining the customer’s location. This is usually caused by a temporary issue. Retrieve the customer to try again. + StripeCustomerAutomaticTaxFailed StripeCustomerAutomaticTax = "failed" + // The customer is located in a country or state where you’re not registered to collect tax. Also returned when automatic tax calculation is not supported in the customer’s location. + StripeCustomerAutomaticTaxNotCollecting StripeCustomerAutomaticTax = "not_collecting" + // The customer is located in a country or state where you’re collecting tax + StripeCustomerAutomaticTaxSupported StripeCustomerAutomaticTax = "supported" + // The customer’s location couldn’t be determined. Make sure the provided address information is valid and supported in the customer’s country. + StripeCustomerAutomaticTaxUnrecognizedLocation StripeCustomerAutomaticTax = "unrecognized_location" +) + +type StripePaymentMethod struct { + ID string + StripeCustomerID *string + Name string + Email string + BillingAddress *models.Address +} + +type SetupWebhookInput struct { + AppID app.AppID + WebhookURL string +} + +func (i SetupWebhookInput) Validate() error { + if err := i.AppID.Validate(); err != nil { + return fmt.Errorf("error validating app id: %w", err) + } + + if i.WebhookURL == "" { + return errors.New("webhook url is required") + } + + return nil +} + +type DeleteWebhookInput struct { + AppID app.AppID + StripeWebhookID string +} + +func (i DeleteWebhookInput) Validate() error { + if err := i.AppID.Validate(); err != nil { + return fmt.Errorf("error validating app id: %w", err) + } + + if i.StripeWebhookID == "" { + return errors.New("stripe webhook id is required") + } + + return nil +} + +type CreateStripeCustomerInput struct { + AppID app.AppID + CustomerID customer.CustomerID + + Name *string + Email *string +} + +func (i CreateStripeCustomerInput) Validate() error { + if err := i.AppID.Validate(); err != nil { + return fmt.Errorf("error validating app id: %w", err) + } + + if err := i.CustomerID.Validate(); err != nil { + return fmt.Errorf("error validating customer id: %w", err) + } + + if i.AppID.Namespace != i.CustomerID.Namespace { + return errors.New("app and customer must be in the same namespace") + } + + if i.Name != nil && *i.Name == "" { + return errors.New("name cannot be empty if provided") + } + + if i.Email != nil && *i.Email == "" { + return errors.New("email cannot be empty if provided") + } + + return nil +} + +// IsAPIKeyLiveMode checks if the API key is a live mode key +func IsAPIKeyLiveMode(apiKey string) bool { + // Root keys start with "sk_" + if strings.HasPrefix(apiKey, "sk_test") { + return false + } + + // Restricted keys start with "rk_" + if strings.HasPrefix(apiKey, "rk_test") { + return false + } + + return true +} diff --git a/app/stripe/clientapp.go b/app/stripe/clientapp.go new file mode 100644 index 0000000000000000000000000000000000000000..fb8d97072804884c84121a7acdde49cb432a8d7e --- /dev/null +++ b/app/stripe/clientapp.go @@ -0,0 +1,39 @@ +package appstripe + +import ( + "context" + "fmt" + + stripeclient "github.com/openmeterio/openmeter/openmeter/app/stripe/client" + secretentity "github.com/openmeterio/openmeter/openmeter/secret/entity" +) + +// getStripeClient gets the Stripe client for the app +func (a App) getStripeClient(ctx context.Context, logOperation string, logFields ...any) (AppData, stripeclient.StripeAppClient, error) { + // Get Stripe App + stripeAppData, err := a.StripeAppService.GetStripeAppData(ctx, GetStripeAppDataInput{ + AppID: a.GetID(), + }) + if err != nil { + return AppData{}, nil, fmt.Errorf("failed to get stripe app data: %w", err) + } + + // Get Stripe API Key + apiKeySecret, err := a.SecretService.GetAppSecret(ctx, secretentity.NewSecretID(a.GetID(), stripeAppData.APIKey.ID, APIKeySecretKey)) + if err != nil { + return AppData{}, nil, fmt.Errorf("failed to get stripe api key secret: %w", err) + } + + // Stripe Client + stripeClient, err := a.StripeAppClientFactory(stripeclient.StripeAppClientConfig{ + AppID: a.GetID(), + AppService: a.AppService, + APIKey: apiKeySecret.Value, + Logger: a.Logger.With("operation", logOperation).With(logFields...), + }) + if err != nil { + return AppData{}, nil, fmt.Errorf("failed to create stripe client: %w", err) + } + + return stripeAppData, stripeClient, nil +} diff --git a/app/stripe/config.go b/app/stripe/config.go new file mode 100644 index 0000000000000000000000000000000000000000..9b8fc9a42bc0beb970b38b337e018cba4117fc06 --- /dev/null +++ b/app/stripe/config.go @@ -0,0 +1,40 @@ +package appstripe + +import ( + "context" + "errors" + + "github.com/openmeterio/openmeter/openmeter/app" +) + +type Configuration struct { + SecretAPIKey *string +} + +func (c Configuration) Validate() error { + if c.SecretAPIKey != nil && *c.SecretAPIKey == "" { + return errors.New("secretAPIKey cannot be empty") + } + + return nil +} + +func (a App) UpdateAppConfig(ctx context.Context, input app.AppConfigUpdate) error { + configUpdate, ok := input.(Configuration) + if !ok { + return errors.New("invalid config update") + } + + if err := configUpdate.Validate(); err != nil { + return err + } + + if configUpdate.SecretAPIKey != nil { + return a.StripeAppService.UpdateAPIKey(ctx, UpdateAPIKeyInput{ + AppID: a.GetID(), + APIKey: *configUpdate.SecretAPIKey, + }) + } + + return nil +} diff --git a/app/stripe/customerdata.go b/app/stripe/customerdata.go new file mode 100644 index 0000000000000000000000000000000000000000..56e06a9b9f0774edc38e9cb6ab89e6a952ca62f6 --- /dev/null +++ b/app/stripe/customerdata.go @@ -0,0 +1,26 @@ +package appstripe + +import ( + "errors" + + "github.com/openmeterio/openmeter/openmeter/app" +) + +var _ app.CustomerData = (*CustomerData)(nil) + +type CustomerData struct { + StripeCustomerID string + StripeDefaultPaymentMethodID *string +} + +func (d CustomerData) Validate() error { + if d.StripeCustomerID == "" { + return errors.New("stripe customer id is required") + } + + if d.StripeDefaultPaymentMethodID != nil && *d.StripeDefaultPaymentMethodID == "" { + return errors.New("stripe default payment method id cannot be empty if provided") + } + + return nil +} diff --git a/app/stripe/event.go b/app/stripe/event.go new file mode 100644 index 0000000000000000000000000000000000000000..68a1c2a7d817b39757faafdb1d4562f5796d5430 --- /dev/null +++ b/app/stripe/event.go @@ -0,0 +1,79 @@ +package appstripe + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/oklog/ulid/v2" + + "github.com/openmeterio/openmeter/openmeter/event/metadata" + "github.com/openmeterio/openmeter/openmeter/session" +) + +const ( + AppEventSubsystem metadata.EventSubsystem = "app.stripe" + AppCheckoutSessionEventName metadata.EventName = "app.stripe.checkout_session.created" +) + +// NewAppCheckoutSessionEvent creates a new checkout session event +func NewAppCheckoutSessionEvent(ctx context.Context, namespace string, sessionID string, appID string, customerID string) AppCheckoutSessionEvent { + return AppCheckoutSessionEvent{ + Namespace: namespace, + SessionID: sessionID, + AppID: appID, + CustomerID: customerID, + UserID: session.GetSessionUserID(ctx), + } +} + +// AppCheckoutSessionEvent is an event that is emitted when a checkout session is created +type AppCheckoutSessionEvent struct { + SessionID string `json:"sessionId"` + Namespace string `json:"namespace"` + AppID string `json:"appId"` + CustomerID string `json:"customerId"` + UserID *string `json:"userId,omitempty"` +} + +func (e AppCheckoutSessionEvent) EventName() string { + return metadata.GetEventName(metadata.EventType{ + Subsystem: AppEventSubsystem, + Name: AppCheckoutSessionEventName, + Version: "v1", + }) +} + +func (e AppCheckoutSessionEvent) EventMetadata() metadata.EventMetadata { + resourcePath := metadata.ComposeResourcePath(e.Namespace, metadata.EntityApp, "stripe", "checkoutSession", e.SessionID) + + return metadata.EventMetadata{ + ID: ulid.Make().String(), + Source: resourcePath, + Subject: resourcePath, + Time: time.Now(), + } +} + +func (e AppCheckoutSessionEvent) Validate() error { + var errs []error + + if e.Namespace == "" { + errs = append(errs, fmt.Errorf("namespace is required")) + } + + if e.AppID == "" { + errs = append(errs, fmt.Errorf("app id is required")) + } + + if e.CustomerID == "" { + errs = append(errs, fmt.Errorf("customer id is required")) + } + + if e.SessionID == "" { + errs = append(errs, fmt.Errorf("session id is required")) + } + + return errors.Join(errs...) +} diff --git a/app/stripe/httpdriver/apikey.go b/app/stripe/httpdriver/apikey.go new file mode 100644 index 0000000000000000000000000000000000000000..06ca4dd7dbe8b9873d86d8798ea65625adb25b8a --- /dev/null +++ b/app/stripe/httpdriver/apikey.go @@ -0,0 +1,56 @@ +package httpdriver + +import ( + "context" + "fmt" + "net/http" + + "github.com/openmeterio/openmeter/api" + "github.com/openmeterio/openmeter/openmeter/app" + appstripe "github.com/openmeterio/openmeter/openmeter/app/stripe" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" +) + +type ( + UpdateStripeAPIKeyRequest = appstripe.UpdateAPIKeyInput + UpdateStripeAPIKeyResponse = struct{} + UpdateStripeAPIKeyHandler httptransport.HandlerWithArgs[UpdateStripeAPIKeyRequest, UpdateStripeAPIKeyResponse, string] +) + +// UpdateStripeAPIKeyHandler returns a handler for replacing stripe API key +func (h *handler) UpdateStripeAPIKey() UpdateStripeAPIKeyHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, appID string) (UpdateStripeAPIKeyRequest, error) { + body := api.UpdateStripeAPIKeyJSONRequestBody{} + if err := commonhttp.JSONRequestBodyDecoder(r, &body); err != nil { + return UpdateStripeAPIKeyRequest{}, fmt.Errorf("field to decode replace stripe api key request: %w", err) + } + + namespace, err := h.resolveNamespace(ctx) + if err != nil { + return UpdateStripeAPIKeyRequest{}, fmt.Errorf("failed to resolve namespace: %w", err) + } + + req := UpdateStripeAPIKeyRequest{ + AppID: app.AppID{Namespace: namespace, ID: appID}, + APIKey: body.SecretAPIKey, + } + + return req, nil + }, + func(ctx context.Context, request UpdateStripeAPIKeyRequest) (UpdateStripeAPIKeyResponse, error) { + err := h.service.UpdateAPIKey(ctx, request) + if err != nil { + return UpdateStripeAPIKeyResponse{}, fmt.Errorf("failed to replace stripe api key: %w", err) + } + + return UpdateStripeAPIKeyResponse{}, nil + }, + commonhttp.EmptyResponseEncoder[UpdateStripeAPIKeyResponse](http.StatusNoContent), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("replaceStripeAPIKey"), + )..., + ) +} diff --git a/app/stripe/httpdriver/checkout_session.go b/app/stripe/httpdriver/checkout_session.go new file mode 100644 index 0000000000000000000000000000000000000000..a47557680e71096aae2da794b7494dcf82e991c7 --- /dev/null +++ b/app/stripe/httpdriver/checkout_session.go @@ -0,0 +1,161 @@ +package httpdriver + +import ( + "context" + "fmt" + "net/http" + + "github.com/samber/lo" + + "github.com/openmeterio/openmeter/api" + "github.com/openmeterio/openmeter/openmeter/app" + appstripe "github.com/openmeterio/openmeter/openmeter/app/stripe" + "github.com/openmeterio/openmeter/openmeter/customer" + customerhttpdriver "github.com/openmeterio/openmeter/openmeter/customer/httpdriver" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" + "github.com/openmeterio/openmeter/pkg/models" +) + +type ( + CreateAppStripeCheckoutSessionRequest = appstripe.CreateCheckoutSessionInput + CreateAppStripeCheckoutSessionResponse = api.CreateStripeCheckoutSessionResult + CreateAppStripeCheckoutSessionHandler httptransport.Handler[CreateAppStripeCheckoutSessionRequest, CreateAppStripeCheckoutSessionResponse] +) + +// CreateAppStripeCheckoutSession returns a handler for creating a checkout session. +func (h *handler) CreateAppStripeCheckoutSession() CreateAppStripeCheckoutSessionHandler { + return httptransport.NewHandler( + func(ctx context.Context, r *http.Request) (CreateAppStripeCheckoutSessionRequest, error) { + body := api.CreateStripeCheckoutSessionRequest{} + if err := commonhttp.JSONRequestBodyDecoder(r, &body); err != nil { + return CreateAppStripeCheckoutSessionRequest{}, fmt.Errorf("field to decode create app stripe checkout session request: %w", err) + } + + namespace, err := h.resolveNamespace(ctx) + if err != nil { + return CreateAppStripeCheckoutSessionRequest{}, fmt.Errorf("failed to resolve namespace: %w", err) + } + + var createCustomerInput *customer.CreateCustomerInput + var customerId *customer.CustomerID + var customerKey *string + + // Try to parse as customer create first + maybeCustomerCreate, asCustomerCreateErr := body.Customer.AsCustomerCreate() + if asCustomerCreateErr == nil && maybeCustomerCreate.Name != "" { + createCustomerInput = &customer.CreateCustomerInput{ + Namespace: namespace, + CustomerMutate: customerhttpdriver.MapCustomerCreate(maybeCustomerCreate), + } + } + + // Try to parse as customer ID second + if createCustomerInput == nil { + apiCustomerId, asCustomerIdErr := body.Customer.AsCustomerId() + if asCustomerIdErr == nil && apiCustomerId.Id != "" { + customerId = &customer.CustomerID{ + Namespace: namespace, + ID: apiCustomerId.Id, + } + } + } + + // Try to parse as customer key third + if createCustomerInput == nil && customerId == nil { + maybeCustomerKey, asCustomerKeyErr := body.Customer.AsCustomerKey() + + if asCustomerKeyErr == nil && maybeCustomerKey.Key != "" { + customerKey = &maybeCustomerKey.Key + } + } + + // One of the three must be provided + if createCustomerInput == nil && customerId == nil && customerKey == nil { + return CreateAppStripeCheckoutSessionRequest{}, fmt.Errorf("customer is required") + } + + // Resolve customer ID from key + if customerKey != nil { + cus, err := h.customerService.GetCustomer(ctx, customer.GetCustomerInput{ + CustomerKey: lo.ToPtr( + customer.CustomerKey{ + Namespace: namespace, + Key: *customerKey, + }, + ), + }) + if err != nil { + return CreateAppStripeCheckoutSessionRequest{}, fmt.Errorf("failed to get customer by key: %w", err) + } + + if cus != nil && cus.IsDeleted() { + return CreateAppStripeCheckoutSessionRequest{}, + models.NewGenericPreConditionFailedError( + fmt.Errorf("customer is deleted [namespace=%s customer.id=%s]", cus.Namespace, cus.ID), + ) + } + + customerId = lo.ToPtr(cus.GetID()) + } + + // Create request + req := CreateAppStripeCheckoutSessionRequest{ + Namespace: namespace, + CustomerID: customerId, + CreateCustomerInput: createCustomerInput, + StripeCustomerID: body.StripeCustomerId, + Options: body.Options, + } + + // Resolve app ID from request or from billing profile + if body.AppId != nil { + req.AppID = app.AppID{Namespace: namespace, ID: *body.AppId} + } else { + appId, err := h.billingService.ResolveStripeAppIDFromBillingProfile(ctx, namespace, customerId) + if err != nil { + return CreateAppStripeCheckoutSessionRequest{}, fmt.Errorf("failed to resolve app id from billing profile: %w", err) + } + + req.AppID = appId + } + + return req, nil + }, + func(ctx context.Context, request CreateAppStripeCheckoutSessionRequest) (CreateAppStripeCheckoutSessionResponse, error) { + out, err := h.service.CreateCheckoutSession(ctx, request) + if err != nil { + return CreateAppStripeCheckoutSessionResponse{}, fmt.Errorf("failed to create app stripe checkout session: %w", err) + } + + response := CreateAppStripeCheckoutSessionResponse{ + CancelURL: out.CancelURL, + CustomerId: out.CustomerID.ID, + Mode: api.StripeCheckoutSessionMode(out.Mode), + ReturnURL: out.ReturnURL, + SessionId: out.SessionID, + SetupIntentId: out.SetupIntentID, + StripeCustomerId: out.StripeCustomerID, + SuccessURL: out.SuccessURL, + Url: out.URL, + + // Add new fields from the CreateCheckoutSessionOutput + ClientSecret: out.ClientSecret, + ClientReferenceId: out.ClientReferenceID, + CustomerEmail: out.CustomerEmail, + Currency: (*api.CurrencyCode)(out.Currency), + CreatedAt: out.CreatedAt, + Metadata: out.Metadata, + Status: (*string)(out.Status), + ExpiresAt: out.ExpiresAt, + } + + return response, nil + }, + commonhttp.JSONResponseEncoderWithStatus[CreateAppStripeCheckoutSessionResponse](http.StatusCreated), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("createAppStripeCheckoutSession"), + )..., + ) +} diff --git a/app/stripe/httpdriver/const.go b/app/stripe/httpdriver/const.go new file mode 100644 index 0000000000000000000000000000000000000000..d6c3eb8d829fa7cb0f2bc97106e816a5a8295ee0 --- /dev/null +++ b/app/stripe/httpdriver/const.go @@ -0,0 +1,9 @@ +package httpdriver + +type StripeLogAttributeName string + +const ( + StripeEventIDAttributeName StripeLogAttributeName = "stripe_event_id" + StripeEventTypeAttributeName StripeLogAttributeName = "stripe_event_type" + AppIDAttributeName StripeLogAttributeName = "app_id" +) diff --git a/app/stripe/httpdriver/customer.go b/app/stripe/httpdriver/customer.go new file mode 100644 index 0000000000000000000000000000000000000000..80547168926b75760b18b3c5443bcb4d883befbc --- /dev/null +++ b/app/stripe/httpdriver/customer.go @@ -0,0 +1,284 @@ +package httpdriver + +import ( + "context" + "fmt" + "net/http" + + "github.com/openmeterio/openmeter/api" + "github.com/openmeterio/openmeter/openmeter/app" + apphttphandler "github.com/openmeterio/openmeter/openmeter/app/httpdriver" + appstripe "github.com/openmeterio/openmeter/openmeter/app/stripe" + "github.com/openmeterio/openmeter/openmeter/billing" + "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/models" +) + +type ( + GetCustomerStripeAppDataResponse = api.StripeCustomerAppData + GetCustomerStripeAppDataHandler httptransport.HandlerWithArgs[GetCustomerStripeAppDataRequest, GetCustomerStripeAppDataResponse, GetCustomerStripeAppDataParams] +) + +type GetCustomerStripeAppDataRequest struct { + CustomerID customer.CustomerID +} + +type GetCustomerStripeAppDataParams struct { + CustomerIdOrKey string +} + +// GetCustomerStripeAppData returns a handler for listing customers app data. +func (h *handler) GetCustomerStripeAppData() GetCustomerStripeAppDataHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, params GetCustomerStripeAppDataParams) (GetCustomerStripeAppDataRequest, error) { + // Resolve the namespace + ns, err := h.resolveNamespace(ctx) + if err != nil { + return GetCustomerStripeAppDataRequest{}, err + } + + // Resolve the customer by id or key + cus, err := h.customerService.GetCustomer(ctx, customer.GetCustomerInput{ + CustomerIDOrKey: &customer.CustomerIDOrKey{ + IDOrKey: params.CustomerIdOrKey, + Namespace: ns, + }, + }) + if err != nil { + return GetCustomerStripeAppDataRequest{}, err + } + + if cus != nil && cus.IsDeleted() { + return GetCustomerStripeAppDataRequest{}, + models.NewGenericPreConditionFailedError( + fmt.Errorf("customer is deleted [namespace=%s customer.id=%s]", cus.Namespace, cus.ID), + ) + } + + // Construct the request + req := GetCustomerStripeAppDataRequest{ + CustomerID: cus.GetID(), + } + + return req, nil + }, + func(ctx context.Context, request GetCustomerStripeAppDataRequest) (GetCustomerStripeAppDataResponse, error) { + return h.getAPIStripeCustomerAppData(ctx, request.CustomerID) + }, + commonhttp.JSONResponseEncoderWithStatus[GetCustomerStripeAppDataResponse](http.StatusOK), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("getCustomerStripeAppData"), + )..., + ) +} + +type UpsertCustomerStripeAppDataRequest struct { + CustomerId customer.CustomerID + Data api.StripeCustomerAppDataBase +} + +type UpsertCustomerStripeAppDataParams struct { + CustomerIdOrKey string +} + +type ( + UpsertCustomerStripeAppDataResponse = api.StripeCustomerAppData + UpsertCustomerStripeAppDataHandler httptransport.HandlerWithArgs[UpsertCustomerStripeAppDataRequest, UpsertCustomerStripeAppDataResponse, UpsertCustomerStripeAppDataParams] +) + +// UpsertCustomerStripeAppData returns a new httptransport.Handler for creating a customer. +func (h *handler) UpsertCustomerStripeAppData() UpsertCustomerStripeAppDataHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, params UpsertCustomerStripeAppDataParams) (UpsertCustomerStripeAppDataRequest, error) { + // Parse the request body + body := api.StripeCustomerAppDataBase{} + if err := commonhttp.JSONRequestBodyDecoder(r, &body); err != nil { + return UpsertCustomerStripeAppDataRequest{}, fmt.Errorf("field to decode upsert customer data request: %w", err) + } + + // Resolve the namespace + ns, err := h.resolveNamespace(ctx) + if err != nil { + return UpsertCustomerStripeAppDataRequest{}, err + } + + // Resolve the customer by id or key + cus, err := h.customerService.GetCustomer(ctx, customer.GetCustomerInput{ + CustomerIDOrKey: &customer.CustomerIDOrKey{ + IDOrKey: params.CustomerIdOrKey, + Namespace: ns, + }, + }) + if err != nil { + return UpsertCustomerStripeAppDataRequest{}, err + } + + if cus != nil && cus.IsDeleted() { + return UpsertCustomerStripeAppDataRequest{}, + models.NewGenericPreConditionFailedError( + fmt.Errorf("customer is deleted [namespace=%s customer.id=%s]", cus.Namespace, cus.ID), + ) + } + + return UpsertCustomerStripeAppDataRequest{ + CustomerId: cus.GetID(), + Data: body, + }, nil + }, + func(ctx context.Context, req UpsertCustomerStripeAppDataRequest) (UpsertCustomerStripeAppDataResponse, error) { + // Resolve the customer app by billing profile + stripeApp, err := h.billingService.GetCustomerApp(ctx, billing.GetCustomerAppInput{ + CustomerID: req.CustomerId, + AppType: app.AppTypeStripe, + }) + if err != nil { + return api.StripeCustomerAppData{}, err + } + + // Upsert the customer data + err = stripeApp.UpsertCustomerData(ctx, app.UpsertAppInstanceCustomerDataInput{ + CustomerID: req.CustomerId, + Data: fromAPIAppStripeCustomerDataBase(req.Data), + }) + if err != nil { + return api.StripeCustomerAppData{}, err + } + + return h.getAPIStripeCustomerAppData(ctx, req.CustomerId) + }, + commonhttp.JSONResponseEncoderWithStatus[UpsertCustomerStripeAppDataResponse](http.StatusOK), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("upsertCustomerStripeAppData"), + )..., + ) +} + +type ( + CreateStripeCustomerPortalSessionResponse = api.StripeCustomerPortalSession + CreateStripeCustomerPortalSessionHandler httptransport.HandlerWithArgs[CreateStripeCustomerPortalSessionRequest, CreateStripeCustomerPortalSessionResponse, CreateStripeCustomerPortalSessionParams] +) + +type CreateStripeCustomerPortalSessionRequest struct { + customerId customer.CustomerID + params api.CreateStripeCustomerPortalSessionParams +} + +type CreateStripeCustomerPortalSessionParams struct { + CustomerIdOrKey string +} + +// CreateStripeCustomerPortalSession returns a handler for creating a checkout session. +func (h *handler) CreateStripeCustomerPortalSession() CreateStripeCustomerPortalSessionHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, params CreateStripeCustomerPortalSessionParams) (CreateStripeCustomerPortalSessionRequest, error) { + // Parse request body + body := api.CreateStripeCustomerPortalSessionParams{} + if err := commonhttp.JSONRequestBodyDecoder(r, &body); err != nil { + return CreateStripeCustomerPortalSessionRequest{}, fmt.Errorf("field to decode create app stripe checkout session request: %w", err) + } + + // Resolve namespace + namespace, err := h.resolveNamespace(ctx) + if err != nil { + return CreateStripeCustomerPortalSessionRequest{}, fmt.Errorf("failed to resolve namespace: %w", err) + } + + // Get the customer + cus, err := h.customerService.GetCustomer(ctx, customer.GetCustomerInput{ + CustomerIDOrKey: &customer.CustomerIDOrKey{ + IDOrKey: params.CustomerIdOrKey, + Namespace: namespace, + }, + }) + if err != nil { + return CreateStripeCustomerPortalSessionRequest{}, err + } + + if cus != nil && cus.IsDeleted() { + return CreateStripeCustomerPortalSessionRequest{}, + models.NewGenericPreConditionFailedError( + fmt.Errorf("customer is deleted [namespace=%s customer.id=%s]", cus.Namespace, cus.ID), + ) + } + + // Create request + req := CreateStripeCustomerPortalSessionRequest{ + customerId: cus.GetID(), + params: body, + } + + return req, nil + }, + func(ctx context.Context, request CreateStripeCustomerPortalSessionRequest) (CreateStripeCustomerPortalSessionResponse, error) { + // Resolve the customer app by billing profile + genericApp, err := h.billingService.GetCustomerApp(ctx, billing.GetCustomerAppInput{ + CustomerID: request.customerId, + AppType: app.AppTypeStripe, + }) + if err != nil { + return CreateStripeCustomerPortalSessionResponse{}, err + } + + // Enforce stripe apptype, see app type filter above + stripeApp, ok := genericApp.(appstripe.App) + if !ok { + return CreateStripeCustomerPortalSessionResponse{}, fmt.Errorf("customer app is not a stripe app") + } + + // Create the portal session + portalSession, err := h.service.CreatePortalSession(ctx, appstripe.CreateStripePortalSessionInput{ + AppID: stripeApp.GetID(), + CustomerID: request.customerId, + ConfigurationID: request.params.ConfigurationId, + ReturnURL: request.params.ReturnUrl, + Locale: request.params.Locale, + }) + if err != nil { + return CreateStripeCustomerPortalSessionResponse{}, fmt.Errorf("failed to create portal session: %w", err) + } + + return toAPIStripePortalSession(portalSession), nil + }, + commonhttp.JSONResponseEncoderWithStatus[CreateStripeCustomerPortalSessionResponse](http.StatusCreated), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("createStripeCustomerPortalSession"), + )..., + ) +} + +// getAPIStripeCustomerAppData returns the stripe customer app data for the given customer id. +func (h *handler) getAPIStripeCustomerAppData(ctx context.Context, customerID customer.CustomerID) (api.StripeCustomerAppData, error) { + // Resolve the customer app by billing profile + genericApp, err := h.billingService.GetCustomerApp(ctx, billing.GetCustomerAppInput{ + CustomerID: customerID, + AppType: app.AppTypeStripe, + }) + if err != nil { + return GetCustomerStripeAppDataResponse{}, err + } + + // Enforce stripe apptype, see app type filter above + stripeApp, ok := genericApp.(appstripe.App) + if !ok { + return GetCustomerStripeAppDataResponse{}, fmt.Errorf("customer app is not a stripe app") + } + + // List customer data for the specific stripe app + customerData, err := h.service.GetStripeCustomerData(ctx, appstripe.GetStripeCustomerDataInput{ + AppID: stripeApp.GetID(), + CustomerID: customerID, + }) + if err != nil { + return GetCustomerStripeAppDataResponse{}, fmt.Errorf("failed to get customer stripe app data: %w", err) + } + + // Convert to API stripe customer app data + apiStripeCustomerAppData := apphttphandler.ToAPIStripeCustomerAppData(customerData, stripeApp) + + return apiStripeCustomerAppData, nil +} diff --git a/app/stripe/httpdriver/handler.go b/app/stripe/httpdriver/handler.go new file mode 100644 index 0000000000000000000000000000000000000000..c8294cf326d48196a2a6dca773e5bc81cbefd20a --- /dev/null +++ b/app/stripe/httpdriver/handler.go @@ -0,0 +1,66 @@ +package httpdriver + +import ( + "context" + "errors" + "net/http" + + appstripe "github.com/openmeterio/openmeter/openmeter/app/stripe" + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/customer" + "github.com/openmeterio/openmeter/openmeter/namespace/namespacedriver" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" +) + +type Handler interface { + AppStripeHandler +} + +type AppStripeHandler interface { + AppStripeWebhook() AppStripeWebhookHandler + UpdateStripeAPIKey() UpdateStripeAPIKeyHandler + CreateAppStripeCheckoutSession() CreateAppStripeCheckoutSessionHandler + + // Customer Stripe Data handlers + GetCustomerStripeAppData() GetCustomerStripeAppDataHandler + UpsertCustomerStripeAppData() UpsertCustomerStripeAppDataHandler + + // Customer Stripe Portal handlers + CreateStripeCustomerPortalSession() CreateStripeCustomerPortalSessionHandler +} + +var _ Handler = (*handler)(nil) + +type handler struct { + service appstripe.Service + billingService billing.Service + customerService customer.Service + namespaceDecoder namespacedriver.NamespaceDecoder + options []httptransport.HandlerOption +} + +func (h *handler) resolveNamespace(ctx context.Context) (string, error) { + ns, ok := h.namespaceDecoder.GetNamespace(ctx) + if !ok { + return "", commonhttp.NewHTTPError(http.StatusInternalServerError, errors.New("internal server error")) + } + + return ns, nil +} + +func New( + namespaceDecoder namespacedriver.NamespaceDecoder, + service appstripe.Service, + billingService billing.Service, + customerService customer.Service, + options ...httptransport.HandlerOption, +) Handler { + return &handler{ + service: service, + billingService: billingService, + customerService: customerService, + namespaceDecoder: namespaceDecoder, + options: options, + } +} diff --git a/app/stripe/httpdriver/mapping.go b/app/stripe/httpdriver/mapping.go new file mode 100644 index 0000000000000000000000000000000000000000..7cb39d322ca073d60dd8f432838dba67439944f0 --- /dev/null +++ b/app/stripe/httpdriver/mapping.go @@ -0,0 +1,33 @@ +package httpdriver + +import ( + "github.com/openmeterio/openmeter/api" + appstripe "github.com/openmeterio/openmeter/openmeter/app/stripe" +) + +// toAPIStripePortalSession maps a StripePortalSession to an API StripePortalSession +func toAPIStripePortalSession(portalSession appstripe.StripePortalSession) api.StripeCustomerPortalSession { + apiPortalSession := api.StripeCustomerPortalSession{ + Id: portalSession.ID, + StripeCustomerId: portalSession.StripeCustomerID, + ReturnUrl: portalSession.ReturnURL, + Url: portalSession.URL, + CreatedAt: portalSession.CreatedAt, + Livemode: portalSession.Livemode, + Locale: portalSession.Locale, + } + + if portalSession.Configuration != nil { + apiPortalSession.ConfigurationId = portalSession.Configuration.ID + } + + return apiPortalSession +} + +// fromAPIAppStripeCustomerDataBase maps an API stripe customer data base to an app stripe customer data +func fromAPIAppStripeCustomerDataBase(apiStripeCustomerData api.StripeCustomerAppDataBase) appstripe.CustomerData { + return appstripe.CustomerData{ + StripeCustomerID: apiStripeCustomerData.StripeCustomerId, + StripeDefaultPaymentMethodID: apiStripeCustomerData.StripeDefaultPaymentMethodId, + } +} diff --git a/app/stripe/httpdriver/webhook.go b/app/stripe/httpdriver/webhook.go new file mode 100644 index 0000000000000000000000000000000000000000..b01995755290a9661a880ddd15e89d9f8276ea49 --- /dev/null +++ b/app/stripe/httpdriver/webhook.go @@ -0,0 +1,448 @@ +package httpdriver + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/samber/lo" + "github.com/stripe/stripe-go/v80" + "github.com/stripe/stripe-go/v80/webhook" + + "github.com/openmeterio/openmeter/api" + "github.com/openmeterio/openmeter/openmeter/app" + appstripe "github.com/openmeterio/openmeter/openmeter/app/stripe" + stripeclient "github.com/openmeterio/openmeter/openmeter/app/stripe/client" + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" + "github.com/openmeterio/openmeter/pkg/models" +) + +type AppStripeWebhookParams struct { + AppID string + Payload []byte +} + +type AppStripeWebhookRequest struct { + AppID app.AppID + Event stripe.Event +} + +type ( + AppStripeWebhookResponse = api.StripeWebhookResponse + AppStripeWebhookHandler httptransport.HandlerWithArgs[AppStripeWebhookRequest, AppStripeWebhookResponse, AppStripeWebhookParams] +) + +// AppStripeWebhook returns a new httptransport.Handler for creating a customer. +func (h *handler) AppStripeWebhook() AppStripeWebhookHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, params AppStripeWebhookParams) (AppStripeWebhookRequest, error) { + // Note that the webhook handler has no namespace resolver + // We only know the namespace from the app id. Which we trust because + // we validate the payload signature with the app's webhook secret. + + // Get the webhook secret for the app + secret, err := h.service.GetWebhookSecret(ctx, appstripe.GetWebhookSecretInput{ + AppID: params.AppID, + }) + if err != nil { + return AppStripeWebhookRequest{}, err + } + + // Validate the webhook event + event, err := webhook.ConstructEventWithTolerance(params.Payload, r.Header.Get("Stripe-Signature"), secret.Value, time.Hour*10000) + if err != nil { + return AppStripeWebhookRequest{}, models.NewGenericValidationError( + fmt.Errorf("failed to construct webhook event: %w", err), + ) + } + + appID := app.AppID{ + Namespace: secret.SecretID.Namespace, + ID: params.AppID, + } + + req := AppStripeWebhookRequest{ + AppID: appID, + Event: event, + } + + return req, nil + }, + func(ctx context.Context, request AppStripeWebhookRequest) (AppStripeWebhookResponse, error) { + ctx = context.WithValue(ctx, StripeEventIDAttributeName, request.Event.ID) + ctx = context.WithValue(ctx, StripeEventTypeAttributeName, request.Event.Type) + ctx = context.WithValue(ctx, AppIDAttributeName, request.AppID) + + // Handle the webhook event based on the event type + switch request.Event.Type { + case stripeclient.WebhookEventTypeSetupIntentSucceeded: + // Unmarshal to payment intent object + var paymentIntent stripe.PaymentIntent + + err := json.Unmarshal(request.Event.Data.Raw, &paymentIntent) + if err != nil { + return AppStripeWebhookResponse{}, models.NewGenericValidationError( + fmt.Errorf("failed to unmarshal payment intent for app: %s in event: %s: %w", request.AppID.ID, request.Event.ID, err), + ) + } + + // Validate the payment intent metadata + metadataAppId, hasMetadataAppId := paymentIntent.Metadata[stripeclient.StripeMetadataAppID] + + // If the event has not app metadata it's not initiated by an OpenMeter app and we ignore it. + // This can be the case when someone manually creates a payment intent. + if !hasMetadataAppId { + return AppStripeWebhookResponse{ + Message: lo.ToPtr("ignoring event as it was not initiated by the openmeter app"), + }, nil + } + + // When the OpenMeter app id is set it cannot be empty + if metadataAppId == "" { + return AppStripeWebhookResponse{}, models.NewGenericValidationError( + fmt.Errorf("appid metadata cannot be empty if provided for app: %s in event: %s", request.AppID.ID, request.Event.ID), + ) + } + + // If someone installs the same Stripe account in multiple apps, we need to ignore the event from other apps + if metadataAppId != request.AppID.ID { + // Ignore the event from other apps + return AppStripeWebhookResponse{ + Message: lo.ToPtr("ignoring event as it was initiated by a different openmeter app"), + }, nil + } + + // Validate the namespace + // At this point we know that the event is for this specific app so require the namespace. + metadataNamespace, hasMetadataNamespace := paymentIntent.Metadata[stripeclient.StripeMetadataNamespace] + if !hasMetadataNamespace { + return AppStripeWebhookResponse{}, models.NewGenericValidationError( + fmt.Errorf("namespace metadata is required for app: %s in event: %s", request.AppID.ID, request.Event.ID), + ) + } + + // When the namespace is set it cannot be empty + if metadataNamespace == "" { + return AppStripeWebhookResponse{}, models.NewGenericValidationError( + fmt.Errorf("namespace metadata cannot be empty if provided for app: %s in event: %s", request.AppID.ID, request.Event.ID), + ) + } + + // As we already checked that this event is for this specific app we validate the namespace + if metadataNamespace != request.AppID.Namespace { + return AppStripeWebhookResponse{}, models.NewGenericValidationError( + fmt.Errorf("namespace mismatch: in request %s, in payment intent metadata %s in event: %s", request.AppID.Namespace, metadataNamespace, request.Event.ID), + ) + } + + // Validate the payment intent object + if paymentIntent.Customer == nil { + return AppStripeWebhookResponse{}, models.NewGenericValidationError( + fmt.Errorf("payment intent customer is required for app: %s in event: %s", request.AppID.ID, request.Event.ID), + ) + } + + if paymentIntent.PaymentMethod == nil { + return AppStripeWebhookResponse{}, models.NewGenericValidationError( + fmt.Errorf("payment intent payment method is required for app %s in event: %s", request.AppID.ID, request.Event.ID), + ) + } + + // Set the default payment method for the customer + out, err := h.service.HandleSetupIntentSucceeded(ctx, + appstripe.HandleSetupIntentSucceededInput{ + SetCustomerDefaultPaymentMethodInput: appstripe.SetCustomerDefaultPaymentMethodInput{ + AppID: request.AppID, + StripeCustomerID: paymentIntent.Customer.ID, + PaymentMethodID: paymentIntent.PaymentMethod.ID, + }, + PaymentIntentMetadata: paymentIntent.Metadata, + }) + if err != nil { + return AppStripeWebhookResponse{}, err + } + + // In the response, we return what resources took action + return AppStripeWebhookResponse{ + NamespaceId: request.AppID.Namespace, + AppId: request.AppID.ID, + CustomerId: &out.CustomerID.ID, + Message: lo.ToPtr("customer default payment method set"), + }, nil + + case stripeclient.WebhookEventTypeSetupIntentFailed: + return AppStripeWebhookResponse{ + NamespaceId: request.AppID.Namespace, + AppId: request.AppID.ID, + }, nil + case stripeclient.WebhookEventTypeSetupIntentRequiresAction: + return AppStripeWebhookResponse{ + NamespaceId: request.AppID.Namespace, + AppId: request.AppID.ID, + }, nil + + // Invoice events + case stripeclient.WebhookEventTypeInvoiceFinalizationFailed: + invoice, err := unmarshalInvoiceEvent(request.Event.Data.Raw) + if err != nil { + return AppStripeWebhookResponse{}, err + } + + err = h.service.HandleInvoiceStateTransition(ctx, appstripe.HandleInvoiceStateTransitionInput{ + AppID: request.AppID, + Invoice: invoice, + Trigger: billing.TriggerFailed, + TargetStatuses: []billing.StandardInvoiceStatus{ + billing.StandardInvoiceStatusIssuingSyncFailed, + billing.StandardInvoiceStatusPaymentProcessingFailed, + }, + IgnoreInvoiceInStatus: []billing.StandardInvoiceStatusMatcher{ + billing.StandardInvoiceStatusCategoryPaymentProcessing, + billing.StandardInvoiceStatusCategoryPaid, + billing.StandardInvoiceStatusCategoryUncollectible, + }, + ShouldTriggerOnEvent: func(stripeInvoice *stripe.Invoice) (bool, error) { + return stripeInvoice.LastFinalizationError != nil, nil + }, + GetValidationErrors: func(stripeInvoice *stripe.Invoice) (*appstripe.ValidationErrorsInput, error) { + return &appstripe.ValidationErrorsInput{ + Op: billing.StandardInvoiceOpFinalize, + Errors: []*stripe.Error{stripeInvoice.LastFinalizationError}, + }, nil + }, + }) + if err != nil { + return AppStripeWebhookResponse{}, err + } + + return AppStripeWebhookResponse{ + NamespaceId: request.AppID.Namespace, + AppId: request.AppID.ID, + }, nil + + case stripeclient.WebhookEventTypeInvoiceSent: + invoice, err := unmarshalInvoiceEvent(request.Event.Data.Raw) + if err != nil { + return AppStripeWebhookResponse{}, err + } + + err = h.service.HandleInvoiceSentEvent(ctx, appstripe.HandleInvoiceSentEventInput{ + AppID: request.AppID, + Invoice: invoice, + SentAt: request.Event.Created, + }) + if err != nil { + return AppStripeWebhookResponse{}, err + } + + return AppStripeWebhookResponse{ + NamespaceId: request.AppID.Namespace, + AppId: request.AppID.ID, + }, nil + + case stripeclient.WebhookEventTypeInvoiceVoided: + invoice, err := unmarshalInvoiceEvent(request.Event.Data.Raw) + if err != nil { + return AppStripeWebhookResponse{}, err + } + + err = h.service.HandleInvoiceStateTransition(ctx, appstripe.HandleInvoiceStateTransitionInput{ + AppID: request.AppID, + Invoice: invoice, + Trigger: billing.TriggerVoid, + TargetStatuses: []billing.StandardInvoiceStatus{billing.StandardInvoiceStatusVoided}, + IgnoreInvoiceInStatus: []billing.StandardInvoiceStatusMatcher{ + billing.StandardInvoiceStatusCategoryPaid, + }, + ShouldTriggerOnEvent: func(stripeInvoice *stripe.Invoice) (bool, error) { + // Let's only invoke the state transition if the upstream invoice is voided + return stripeInvoice.Status == stripe.InvoiceStatusVoid, nil + }, + }) + if err != nil { + return AppStripeWebhookResponse{}, err + } + + return AppStripeWebhookResponse{ + NamespaceId: request.AppID.Namespace, + AppId: request.AppID.ID, + }, nil + + case stripeclient.WebhookEventTypeInvoiceMarkedUncollectible: + invoice, err := unmarshalInvoiceEvent(request.Event.Data.Raw) + if err != nil { + return AppStripeWebhookResponse{}, err + } + + err = h.service.HandleInvoiceStateTransition(ctx, appstripe.HandleInvoiceStateTransitionInput{ + AppID: request.AppID, + Invoice: invoice, + Trigger: billing.TriggerPaymentUncollectible, + TargetStatuses: []billing.StandardInvoiceStatus{billing.StandardInvoiceStatusUncollectible}, + ShouldTriggerOnEvent: func(stripeInvoice *stripe.Invoice) (bool, error) { + // Let's only invoke the state transition if the upstream invoice is uncollectible + return stripeInvoice.Status == stripe.InvoiceStatusUncollectible, nil + }, + }) + if err != nil { + return AppStripeWebhookResponse{}, err + } + + return AppStripeWebhookResponse{ + NamespaceId: request.AppID.Namespace, + AppId: request.AppID.ID, + }, nil + case stripeclient.WebhookEventTypeInvoiceOverdue: + invoice, err := unmarshalInvoiceEvent(request.Event.Data.Raw) + if err != nil { + return AppStripeWebhookResponse{}, err + } + + err = h.service.HandleInvoiceStateTransition(ctx, appstripe.HandleInvoiceStateTransitionInput{ + AppID: request.AppID, + Invoice: invoice, + Trigger: billing.TriggerPaymentOverdue, + TargetStatuses: []billing.StandardInvoiceStatus{billing.StandardInvoiceStatusOverdue}, + IgnoreInvoiceInStatus: []billing.StandardInvoiceStatusMatcher{ + billing.StandardInvoiceStatusCategoryUncollectible, + }, + ShouldTriggerOnEvent: func(stripeInvoice *stripe.Invoice) (bool, error) { + // Let's only invoke the state transition if the upstream invoice is still open + return stripeInvoice.Status == stripe.InvoiceStatusOpen, nil + }, + }) + if err != nil { + return AppStripeWebhookResponse{}, err + } + + return AppStripeWebhookResponse{ + NamespaceId: request.AppID.Namespace, + AppId: request.AppID.ID, + }, nil + case stripeclient.WebhookEventTypeInvoicePaid: + invoice, err := unmarshalInvoiceEvent(request.Event.Data.Raw) + if err != nil { + return AppStripeWebhookResponse{}, err + } + + err = h.service.HandleInvoiceStateTransition(ctx, appstripe.HandleInvoiceStateTransitionInput{ + AppID: request.AppID, + Invoice: invoice, + Trigger: billing.TriggerPaid, + TargetStatuses: []billing.StandardInvoiceStatus{billing.StandardInvoiceStatusPaid}, + ShouldTriggerOnEvent: func(stripeInvoice *stripe.Invoice) (bool, error) { + // Let's only invoke the state transition if the upstream invoice is paid + return stripeInvoice.Status == stripe.InvoiceStatusPaid, nil + }, + }) + if err != nil { + return AppStripeWebhookResponse{}, err + } + + return AppStripeWebhookResponse{ + NamespaceId: request.AppID.Namespace, + AppId: request.AppID.ID, + }, nil + case stripeclient.WebhookEventTypeInvoicePaymentActionRequired: + invoice, err := unmarshalInvoiceEvent(request.Event.Data.Raw) + if err != nil { + return AppStripeWebhookResponse{}, err + } + + err = h.service.HandleInvoiceStateTransition(ctx, appstripe.HandleInvoiceStateTransitionInput{ + AppID: request.AppID, + Invoice: invoice, + Trigger: billing.TriggerActionRequired, + TargetStatuses: []billing.StandardInvoiceStatus{billing.StandardInvoiceStatusPaymentProcessingActionRequired}, + IgnoreInvoiceInStatus: []billing.StandardInvoiceStatusMatcher{ + billing.StandardInvoiceStatusCategoryPaid, + billing.StandardInvoiceStatusCategoryUncollectible, + }, + + ShouldTriggerOnEvent: func(stripeInvoice *stripe.Invoice) (bool, error) { + // Let's only invoke the state transition if the upstream invoice is still open + return stripeInvoice.Status == stripe.InvoiceStatusOpen, nil + }, + }) + if err != nil { + return AppStripeWebhookResponse{}, err + } + + return AppStripeWebhookResponse{ + NamespaceId: request.AppID.Namespace, + AppId: request.AppID.ID, + }, nil + case stripeclient.WebhookEventTypeInvoicePaymentFailed: + invoice, err := unmarshalInvoiceEvent(request.Event.Data.Raw) + if err != nil { + return AppStripeWebhookResponse{}, err + } + + err = h.service.HandleInvoiceStateTransition(ctx, appstripe.HandleInvoiceStateTransitionInput{ + AppID: request.AppID, + Invoice: invoice, + Trigger: billing.TriggerFailed, + + TargetStatuses: []billing.StandardInvoiceStatus{ + billing.StandardInvoiceStatusPaymentProcessingFailed, + }, + IgnoreInvoiceInStatus: []billing.StandardInvoiceStatusMatcher{ + billing.StandardInvoiceStatusCategoryPaid, + billing.StandardInvoiceStatusCategoryUncollectible, + }, + + ShouldTriggerOnEvent: func(stripeInvoice *stripe.Invoice) (bool, error) { + // Let's only invoke the state transition if the upstream invoice is still open + return stripeInvoice.Status == stripe.InvoiceStatusOpen, nil + }, + }) + if err != nil { + return AppStripeWebhookResponse{}, err + } + + return AppStripeWebhookResponse{ + NamespaceId: request.AppID.Namespace, + AppId: request.AppID.ID, + }, nil + case stripeclient.WebhookEventTypeInvoicePaymentSucceeded: + // We ignore this event for now, as we handle the invoice.paid event instead + + // Details: https://docs.stripe.com/invoicing/integration + + // Successful invoice payments trigger both an invoice.paid and invoice.payment_succeeded event. Both event + // types contain the same invoice data, so it’s only necessary to listen to one of them to be notified of successful + // invoice payments. The difference is that invoice.payment_succeeded events are sent for successful invoice payments, + // but aren’t sent when you mark an invoice as paid_out_of_band. invoice.paid events, on the other hand, are triggered for + // both successful payments and out of band payments. Because invoice.paid covers both scenarios, we typically recommend + // listening to invoice.paid rather than invoice.payment_succeeded. + return AppStripeWebhookResponse{ + NamespaceId: request.AppID.Namespace, + AppId: request.AppID.ID, + }, nil + } + + return AppStripeWebhookResponse{}, models.NewGenericValidationError( + fmt.Errorf("unsupported event type: %s", request.Event.Type), + ) + }, + commonhttp.JSONResponseEncoderWithStatus[AppStripeWebhookResponse](http.StatusCreated), + httptransport.AppendOptions( + h.options, + httptransport.WithOperationName("appStripeWebhook"), + )..., + ) +} + +func unmarshalInvoiceEvent(data []byte) (stripe.Invoice, error) { + var invoice stripe.Invoice + if err := json.Unmarshal(data, &invoice); err != nil { + return stripe.Invoice{}, models.NewGenericValidationError( + fmt.Errorf("failed to unmarshal invoice: %w", err), + ) + } + return invoice, nil +} diff --git a/app/stripe/marketplace.go b/app/stripe/marketplace.go new file mode 100644 index 0000000000000000000000000000000000000000..4033c6d4606ab6ed85c27b0ba06f2d276d6ab2e8 --- /dev/null +++ b/app/stripe/marketplace.go @@ -0,0 +1,42 @@ +package appstripe + +import ( + "github.com/openmeterio/openmeter/openmeter/app" +) + +var ( + StripeMarketplaceListing = app.MarketplaceListing{ + Type: app.AppTypeStripe, + Name: "Stripe", + Description: "Send invoices, calculate tax and collect payments.", + Capabilities: []app.Capability{ + StripeCollectPaymentCapability, + StripeCalculateTaxCapability, + StripeInvoiceCustomerCapability, + }, + InstallMethods: []app.InstallMethod{ + app.InstallMethodAPIKey, + }, + } + + StripeCollectPaymentCapability = app.Capability{ + Type: app.CapabilityTypeCollectPayments, + Key: "stripe_collect_payment", + Name: "Payment", + Description: "Process payments", + } + + StripeCalculateTaxCapability = app.Capability{ + Type: app.CapabilityTypeCalculateTax, + Key: "stripe_calculate_tax", + Name: "Calculate Tax", + Description: "Calculate tax for a payment", + } + + StripeInvoiceCustomerCapability = app.Capability{ + Type: app.CapabilityTypeInvoiceCustomers, + Key: "stripe_invoice_customer", + Name: "Invoice Customer", + Description: "Invoice a customer", + } +) diff --git a/app/stripe/portal.go b/app/stripe/portal.go new file mode 100644 index 0000000000000000000000000000000000000000..436673e38b1b1f412a691aed437bc35ff77b8b21 --- /dev/null +++ b/app/stripe/portal.go @@ -0,0 +1,74 @@ +package appstripe + +import ( + "errors" + "fmt" + "time" + + "github.com/stripe/stripe-go/v80" + + "github.com/openmeterio/openmeter/openmeter/app" + "github.com/openmeterio/openmeter/openmeter/customer" + "github.com/openmeterio/openmeter/pkg/models" +) + +// CreateStripePortalSessionInput is the input for creating a stripe customer portal session. +type CreateStripePortalSessionInput struct { + AppID app.AppID + CustomerID customer.CustomerID + Locale *string + ConfigurationID *string + ReturnURL *string +} + +// Validate validates the input for creating a stripe customer portal session. +func (i CreateStripePortalSessionInput) Validate() error { + var errs []error + + if err := i.AppID.Validate(); err != nil { + errs = append(errs, models.NewGenericValidationError(fmt.Errorf("app id is required: %w", err))) + } + + if err := i.CustomerID.Validate(); err != nil { + errs = append(errs, models.NewGenericValidationError(errors.New("customer id is required"))) + } + + if i.ReturnURL != nil && *i.ReturnURL == "" { + errs = append(errs, models.NewGenericValidationError(errors.New("return url cannot be empty if provided"))) + } + + if i.Locale != nil && *i.Locale == "" { + errs = append(errs, models.NewGenericValidationError(errors.New("locale cannot be empty if provided"))) + } + + return errors.Join(errs...) +} + +// StripePortalSession is the response from the Stripe API for a customer portal session. +type StripePortalSession struct { + // The ID of the customer portal session. + // See: https://docs.stripe.com/api/customer_portal/sessions/object#portal_session_object-id + ID string + + // Configuration Configuration used to customize the customer portal. + // See: https://docs.stripe.com/api/customer_portal/sessions/object#portal_session_object-configuration + Configuration *stripe.BillingPortalConfiguration + CreatedAt time.Time + StripeCustomerID string + + // Livemode Livemode. + Livemode bool + + // Locale Status. + // 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 + Locale string + + // ReturnUrl Return URL. + // See: https://docs.stripe.com/api/customer_portal/sessions/object#portal_session_object-return_url + ReturnURL string + + // The URL to redirect the customer to after they have completed + // their requested actions. + URL string +} diff --git a/app/stripe/service.go b/app/stripe/service.go new file mode 100644 index 0000000000000000000000000000000000000000..0ed557b765232c11b52ae4e39528a09756015a07 --- /dev/null +++ b/app/stripe/service.go @@ -0,0 +1,50 @@ +package appstripe + +import ( + "context" + + "github.com/openmeterio/openmeter/openmeter/app" + "github.com/openmeterio/openmeter/openmeter/billing" +) + +type Service interface { + AppFactoryService + StripeAppService + CustomerService + BillingService +} + +// AppFactoryService contains methods to interface with app subsystem +type AppFactoryService interface { + // App Factory methods + NewApp(ctx context.Context, appBase app.AppBase) (app.App, error) + InstallAppWithAPIKey(ctx context.Context, input app.AppFactoryInstallAppWithAPIKeyInput) (app.App, error) + UninstallApp(ctx context.Context, input app.UninstallAppInput) error +} + +// StripeAppService contains methods for managing stripe app +type StripeAppService interface { + UpdateAPIKey(ctx context.Context, input UpdateAPIKeyInput) error + GetStripeAppData(ctx context.Context, input GetStripeAppDataInput) (AppData, error) + GetWebhookSecret(ctx context.Context, input GetWebhookSecretInput) (GetWebhookSecretOutput, error) +} + +// CustomerService contains methods for managing customer data +type CustomerService interface { + GetStripeCustomerData(ctx context.Context, input GetStripeCustomerDataInput) (CustomerData, error) + UpsertStripeCustomerData(ctx context.Context, input UpsertStripeCustomerDataInput) error + DeleteStripeCustomerData(ctx context.Context, input DeleteStripeCustomerDataInput) error + HandleSetupIntentSucceeded(ctx context.Context, input HandleSetupIntentSucceededInput) (HandleSetupIntentSucceededOutput, error) + + CreateCheckoutSession(ctx context.Context, input CreateCheckoutSessionInput) (CreateCheckoutSessionOutput, error) + CreatePortalSession(ctx context.Context, input CreateStripePortalSessionInput) (StripePortalSession, error) +} + +// BillingService contains methods for managing billing subsystem (invoices) +type BillingService interface { + GetSupplierContact(ctx context.Context, input GetSupplierContactInput) (billing.SupplierContact, error) + + // Invoice webhook handlers + HandleInvoiceStateTransition(ctx context.Context, input HandleInvoiceStateTransitionInput) error + HandleInvoiceSentEvent(ctx context.Context, input HandleInvoiceSentEventInput) error +} diff --git a/app/stripe/service/app.go b/app/stripe/service/app.go new file mode 100644 index 0000000000000000000000000000000000000000..e653e96d784c0b723f6b4ad32de0a4e47f84c727 --- /dev/null +++ b/app/stripe/service/app.go @@ -0,0 +1,111 @@ +package appservice + +import ( + "context" + "fmt" + + "github.com/samber/lo" + + "github.com/openmeterio/openmeter/openmeter/app" + appstripe "github.com/openmeterio/openmeter/openmeter/app/stripe" + stripeclient "github.com/openmeterio/openmeter/openmeter/app/stripe/client" + "github.com/openmeterio/openmeter/pkg/framework/transaction" +) + +var _ appstripe.Service = (*Service)(nil) + +func (s *Service) GetWebhookSecret(ctx context.Context, input appstripe.GetWebhookSecretInput) (appstripe.GetWebhookSecretOutput, error) { + return transaction.Run(ctx, s.adapter, func(ctx context.Context) (appstripe.GetWebhookSecretOutput, error) { + return s.adapter.GetWebhookSecret(ctx, input) + }) +} + +func (s *Service) UpdateAPIKey(ctx context.Context, input appstripe.UpdateAPIKeyInput) error { + return transaction.RunWithNoValue(ctx, s.adapter, func(ctx context.Context) error { + return s.adapter.UpdateAPIKey(ctx, appstripe.UpdateAPIKeyAdapterInput{ + UpdateAPIKeyInput: input, + MaskedAPIKey: s.generateMaskedSecretAPIKey(input.APIKey), + }) + }) +} + +func (s *Service) CreateCheckoutSession(ctx context.Context, input appstripe.CreateCheckoutSessionInput) (appstripe.CreateCheckoutSessionOutput, error) { + return transaction.Run(ctx, s.adapter, func(ctx context.Context) (appstripe.CreateCheckoutSessionOutput, error) { + // Create the checkout session + output, err := s.adapter.CreateCheckoutSession(ctx, input) + if err != nil { + return appstripe.CreateCheckoutSessionOutput{}, err + } + + // Emit the checkout session created event + event := appstripe.NewAppCheckoutSessionEvent(ctx, input.Namespace, output.SessionID, output.AppID.ID, output.CustomerID.ID) + if err := s.publisher.Publish(ctx, event); err != nil { + return appstripe.CreateCheckoutSessionOutput{}, fmt.Errorf("failed to publish event: %w", err) + } + + return output, nil + }) +} + +func (s *Service) GetStripeAppData(ctx context.Context, input appstripe.GetStripeAppDataInput) (appstripe.AppData, error) { + return transaction.Run(ctx, s.adapter, func(ctx context.Context) (appstripe.AppData, error) { + return s.adapter.GetStripeAppData(ctx, input) + }) +} + +func (s *Service) GetStripeCustomerData(ctx context.Context, input appstripe.GetStripeCustomerDataInput) (appstripe.CustomerData, error) { + return transaction.Run(ctx, s.adapter, func(ctx context.Context) (appstripe.CustomerData, error) { + return s.adapter.GetStripeCustomerData(ctx, input) + }) +} + +func (s *Service) UpsertStripeCustomerData(ctx context.Context, input appstripe.UpsertStripeCustomerDataInput) error { + return transaction.RunWithNoValue(ctx, s.adapter, func(ctx context.Context) error { + return s.adapter.UpsertStripeCustomerData(ctx, input) + }) +} + +func (s *Service) DeleteStripeCustomerData(ctx context.Context, input appstripe.DeleteStripeCustomerDataInput) error { + return transaction.RunWithNoValue(ctx, s.adapter, func(ctx context.Context) error { + return s.adapter.DeleteStripeCustomerData(ctx, input) + }) +} + +func (s *Service) HandleSetupIntentSucceeded(ctx context.Context, input appstripe.HandleSetupIntentSucceededInput) (appstripe.HandleSetupIntentSucceededOutput, error) { + return transaction.Run(ctx, s.adapter, func(ctx context.Context) (appstripe.HandleSetupIntentSucceededOutput, error) { + def := appstripe.HandleSetupIntentSucceededOutput{} + + res, err := s.adapter.SetCustomerDefaultPaymentMethod(ctx, input.SetCustomerDefaultPaymentMethodInput) + if err != nil { + return def, fmt.Errorf("failed to set customer default payment method: %w", err) + } + + handlingApp, err := s.appService.GetApp(ctx, input.AppID) + if err != nil { + return def, fmt.Errorf("failed to get app: %w", err) + } + + event := app.CustomerPaymentSetupSucceededEvent{ + App: handlingApp.GetAppBase(), + Customer: res.CustomerID, + Result: app.CustomerPaymentSetupResult{ + Metadata: lo.OmitByKeys(input.PaymentIntentMetadata, stripeclient.SetupIntentReservedMetadataKeys), + }, + } + + if err := s.publisher.Publish(ctx, event); err != nil { + return def, fmt.Errorf("failed to publish event: %w", err) + } + + return appstripe.HandleSetupIntentSucceededOutput(res), nil + }) +} + +// CreatePortalSession creates a portal session for a customer. +func (s *Service) CreatePortalSession(ctx context.Context, input appstripe.CreateStripePortalSessionInput) (appstripe.StripePortalSession, error) { + return s.adapter.CreatePortalSession(ctx, input) +} + +func (s *Service) generateMaskedSecretAPIKey(secretAPIKey string) string { + return fmt.Sprintf("%s***%s", secretAPIKey[:8], secretAPIKey[len(secretAPIKey)-3:]) +} diff --git a/app/stripe/service/billing.go b/app/stripe/service/billing.go new file mode 100644 index 0000000000000000000000000000000000000000..8ccc2eb062c7125b3e76cc46f348d840326d0553 --- /dev/null +++ b/app/stripe/service/billing.go @@ -0,0 +1,187 @@ +package appservice + +import ( + "context" + "fmt" + "log/slog" + "slices" + "time" + + "github.com/samber/lo" + "github.com/samber/mo" + "github.com/stripe/stripe-go/v80" + + "github.com/openmeterio/openmeter/openmeter/app" + appstripe "github.com/openmeterio/openmeter/openmeter/app/stripe" + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/pkg/pagination" +) + +var _ appstripe.BillingService = (*Service)(nil) + +func (s *Service) GetSupplierContact(ctx context.Context, input appstripe.GetSupplierContactInput) (billing.SupplierContact, error) { + return s.adapter.GetSupplierContact(ctx, input) +} + +// Invoice webhook handlers +func (s *Service) HandleInvoiceStateTransition(ctx context.Context, input appstripe.HandleInvoiceStateTransitionInput) error { + if err := input.Validate(); err != nil { + return err + } + + invoice, err := s.getInvoiceByStripeID(ctx, input.AppID, input.Invoice.ID) + if err != nil { + return err + } + + if invoice == nil { + return nil + } + + logger := s.logger.With( + slog.String(StripeInvoiceIDAttributeName, input.Invoice.ID), + slog.String(InvoiceIDAttributeName, invoice.ID), + slog.String(InvoiceStatusAttributeName, string(invoice.Status)), + ) + + if slices.Contains(input.TargetStatuses, invoice.Status) { + // No need to handle the event, the invoice is already in the target state + logger.InfoContext(ctx, "invoice is already in the target state, ignoring state event") + return nil + } + + if invoice.Status.Matches(input.IgnoreInvoiceInStatus...) { + // No need to handle the event, the invoice is in a state that should be ignored + logger.InfoContext(ctx, "invoice is in a state that should be ignored, ignoring state event") + return nil + } + + var stripeInvoice *stripe.Invoice + if input.ShouldTriggerOnEvent != nil || input.GetValidationErrors != nil { + // Let's rule out any late events by validating the invoice status + stripeInvoice, err = s.adapter.GetStripeInvoice(ctx, appstripe.GetStripeInvoiceInput{ + AppID: input.AppID, + StripeInvoiceID: input.Invoice.ID, + }) + if err != nil { + logger.ErrorContext(ctx, "failed to get stripe invoice", "error", err) + return err + } + } + + if input.ShouldTriggerOnEvent != nil { + shouldTrigger, err := input.ShouldTriggerOnEvent(stripeInvoice) + if err != nil { + logger.ErrorContext(ctx, "failed to determine if event should trigger", "error", err) + } + + if !shouldTrigger { + logger.InfoContext(ctx, "event should not trigger invoice state transition, ignoring state event") + return nil + } + } + + var validationErrors *billing.InvoiceTriggerValidationInput + if input.GetValidationErrors != nil { + stripeValidationErrors, err := input.GetValidationErrors(stripeInvoice) + if err != nil { + logger.ErrorContext(ctx, "failed to get validation errors", slog.Any("error", err)) + return err + } + + if stripeValidationErrors != nil { + validationErrors = &billing.InvoiceTriggerValidationInput{ + Operation: billing.StandardInvoiceOpInitiatePayment, + Errors: lo.Map(stripeValidationErrors.Errors, func(stripeErr *stripe.Error, _ int) error { + return stripeErrorToValidationError(stripeErr) + }), + } + } + } + + err = s.billingService.TriggerInvoice(ctx, billing.InvoiceTriggerServiceInput{ + InvoiceTriggerInput: billing.InvoiceTriggerInput{ + Invoice: invoice.GetInvoiceID(), + Trigger: input.Trigger, + ValidationErrors: validationErrors, + }, + AppType: app.AppTypeStripe, + Capability: app.CapabilityTypeCollectPayments, + }) + if err != nil { + logger.ErrorContext(ctx, "failed to trigger invoice failed trigger") + return err + } + + logger.InfoContext(ctx, "invoice state transition handled successfully", "trigger", input.Trigger) + + return nil +} + +func (s *Service) HandleInvoiceSentEvent(ctx context.Context, input appstripe.HandleInvoiceSentEventInput) error { + if err := input.Validate(); err != nil { + return err + } + + invoice, err := s.getInvoiceByStripeID(ctx, input.AppID, input.Invoice.ID) + if err != nil { + return err + } + + if invoice == nil { + return nil + } + + return s.billingService.UpdateInvoiceFields(ctx, billing.UpdateInvoiceFieldsInput{ + Invoice: invoice.GetInvoiceID(), + SentToCustomerAt: mo.Some(lo.ToPtr(time.Unix(input.SentAt, 0))), + }) +} + +func stripeErrorToValidationError(stripeErr *stripe.Error) error { + if stripeErr == nil { + return nil + } + + return billing.NewValidationError(string(stripeErr.Code), stripeErr.Msg) +} + +// getInvoiceByStripeID retrieves an invoice by its stripe ID, it returns nil if the invoice is not found (thus not managed by the app) +func (s *Service) getInvoiceByStripeID(ctx context.Context, appID app.AppID, stripeInvoiceID string) (*billing.StandardInvoice, error) { + invoices, err := s.billingService.ListStandardInvoices(ctx, billing.ListStandardInvoicesInput{ + Namespaces: []string{appID.Namespace}, + ExternalIDs: &billing.ListInvoicesExternalIDFilter{ + Type: billing.InvoicingExternalIDType, + IDs: []string{stripeInvoiceID}, + }, + IncludeDeleted: true, + Page: pagination.Page{ + PageNumber: 1, + PageSize: 5, + }, + }) + if err != nil { + return nil, err + } + + if len(invoices.Items) == 0 { + // Invoice is not found, log a warning + s.logger.WarnContext(ctx, "stripe invoice not found in local database, assuming non-managed invoice") + return nil, nil + } + + if len(invoices.Items) > 1 { + // This should never happen, log an error + s.logger.ErrorContext(ctx, "multiple invoices found for the same external ID") + return nil, fmt.Errorf("multiple invoices found for the same external ID: %s", stripeInvoiceID) + } + + invoice := invoices.Items[0] + if invoice.Workflow.AppReferences.Invoicing.ID != appID.ID { + // Invoice is not managed by the app, log an error, should not happen, but if it happens we need to investigate + s.logger.ErrorContext(ctx, "stripe invoice not managed by the app", "invoice_id", invoice.ID, "app_id", appID.ID) + return nil, fmt.Errorf("stripe invoice not managed by the app: %s", invoice.ID) + } + + return &invoice, nil +} diff --git a/app/stripe/service/const.go b/app/stripe/service/const.go new file mode 100644 index 0000000000000000000000000000000000000000..fe8c23f7e252f712e680490cbd016383a795fbbb --- /dev/null +++ b/app/stripe/service/const.go @@ -0,0 +1,7 @@ +package appservice + +const ( + StripeInvoiceIDAttributeName = "invoice.stripe_invoice_id" + InvoiceIDAttributeName = "invoice.id" + InvoiceStatusAttributeName = "invoice.status" +) diff --git a/app/stripe/service/factory.go b/app/stripe/service/factory.go new file mode 100644 index 0000000000000000000000000000000000000000..5a6d8d399f9b41169680572b151d1dd5445c19d7 --- /dev/null +++ b/app/stripe/service/factory.go @@ -0,0 +1,238 @@ +package appservice + +import ( + "context" + "errors" + "fmt" + + "github.com/oklog/ulid/v2" + + "github.com/openmeterio/openmeter/openmeter/app" + appstripe "github.com/openmeterio/openmeter/openmeter/app/stripe" + stripeclient "github.com/openmeterio/openmeter/openmeter/app/stripe/client" + secretentity "github.com/openmeterio/openmeter/openmeter/secret/entity" +) + +// This file implements the app.AppFactory interface +var _ app.AppFactory = (*Service)(nil) + +// NewApp implement the app.AppFactory interface and returns a Stripe App by extending the AppBase +func (s *Service) NewApp(ctx context.Context, appBase app.AppBase) (app.App, error) { + stripeApp, err := s.adapter.GetStripeAppData(ctx, appstripe.GetStripeAppDataInput{AppID: appBase.GetID()}) + if err != nil { + return nil, fmt.Errorf("failed to get stripe app data: %w", err) + } + + app, err := s.newApp(appBase, stripeApp) + if err != nil { + return nil, fmt.Errorf("failed to map stripe app from db: %w", err) + } + + return app, nil +} + +// NewApp implement the app.AppFactory interface and installs a Stripe App type +func (s *Service) InstallAppWithAPIKey(ctx context.Context, input app.AppFactoryInstallAppWithAPIKeyInput) (app.App, error) { + // Validate input + if err := input.Validate(); err != nil { + return nil, fmt.Errorf("invalid input: %w", err) + } + + // Check if the Stripe API key is a test key + livemode := stripeclient.IsAPIKeyLiveMode(input.APIKey) + + // Get stripe client + stripeClient, err := s.adapter.GetStripeClientFactory()(stripeclient.StripeClientConfig{ + Namespace: input.Namespace, + APIKey: input.APIKey, + Logger: s.logger.With("operation", "installAppWithAPIKey", "namespace", input.Namespace, "app_name", input.Name), + }) + if err != nil { + return nil, fmt.Errorf("failed to create stripe client: %w", err) + } + + // Retrieve stripe account + stripeAccount, err := stripeClient.GetAccount(ctx) + if err != nil { + return nil, err + } + + // We generate the app ID here because we need it to setup the webhook and create the secrets + appID := app.AppID{Namespace: input.Namespace, ID: ulid.Make().String()} + + // TODO: secret creation, webhook setup and app creation should be done in a transaction + // This is challenging because we need to coordinate between three remote services (secret, stripe, db) + + // Create API Key secret + apiKeySecretID, err := s.secretService.CreateAppSecret(ctx, secretentity.CreateAppSecretInput{ + AppID: appID, + Key: appstripe.APIKeySecretKey, + Value: input.APIKey, + }) + if err != nil { + return nil, fmt.Errorf("failed to create secret: %w", err) + } + + // Get webhook URL + webhookURL, err := s.webhookURLGenerator.GetWebhookURL(ctx, appID) + if err != nil { + return nil, fmt.Errorf("failed to get webhook url: %w", err) + } + + // Setup webhook + var stripeWebhookEndpoint stripeclient.StripeWebhookEndpoint + if !s.disableWebhookRegistration { + stripeWebhookEndpoint, err = stripeClient.SetupWebhook(ctx, stripeclient.SetupWebhookInput{ + AppID: appID, + WebhookURL: webhookURL, + }) + if err != nil { + return nil, fmt.Errorf("failed to setup webhook: %w", err) + } + } else { + // Let's generate a fake secret for development purposes + stripeWebhookEndpoint = stripeclient.StripeWebhookEndpoint{ + EndpointID: "endpoint-registration-disabled", + Secret: "fake-secret", + } + } + + // Create webhook secret + webhookSecretID, err := s.secretService.CreateAppSecret(ctx, secretentity.CreateAppSecretInput{ + AppID: appID, + Key: appstripe.WebhookSecretKey, + Value: stripeWebhookEndpoint.Secret, + }) + if err != nil { + return nil, fmt.Errorf("failed to create secret: %w", err) + } + + // Create stripe app + createStripeAppInput := appstripe.CreateAppStripeInput{ + CreateAppInput: app.CreateAppInput{ + ID: &appID, + Namespace: input.Namespace, + Name: input.Name, + Description: fmt.Sprintf("Stripe account %s", stripeAccount.StripeAccountID), + Type: app.AppTypeStripe, + }, + + StripeAccountID: stripeAccount.StripeAccountID, + Livemode: livemode, + APIKey: apiKeySecretID, + MaskedAPIKey: s.generateMaskedSecretAPIKey(input.APIKey), + StripeWebhookID: stripeWebhookEndpoint.EndpointID, + WebhookSecret: webhookSecretID, + } + + if err := createStripeAppInput.Validate(); err != nil { + return nil, fmt.Errorf("invalid create stripe app input: %w", err) + } + + stripeApp, err := s.adapter.CreateStripeApp(ctx, createStripeAppInput) + if err != nil { + return nil, fmt.Errorf("failed to create app: %w", err) + } + + app, err := s.newApp(stripeApp.AppBase, stripeApp.AppData) + if err != nil { + return nil, fmt.Errorf("failed to factor stripe app: %w", err) + } + + return app, nil +} + +// UninstallApp uninstalls an app by id +func (s *Service) UninstallApp(ctx context.Context, input app.UninstallAppInput) error { + // Get Stripe App + stripeApp, err := s.adapter.GetStripeAppData(ctx, appstripe.GetStripeAppDataInput{ + AppID: input, + }) + if err != nil { + return fmt.Errorf("failed to get stripe app: %w", err) + } + + // Delete stripe customer data + err = s.adapter.DeleteStripeCustomerData(ctx, appstripe.DeleteStripeCustomerDataInput{ + AppID: &input, + }) + if err != nil { + return fmt.Errorf("failed to delete stripe customer data: %w", err) + } + + // Delete stripe app data + err = s.adapter.DeleteStripeAppData(ctx, appstripe.DeleteStripeAppDataInput{ + AppID: input, + }) + if err != nil { + return fmt.Errorf("failed to delete app: %w", err) + } + + // Get Stripe API Key + apiKeySecret, err := s.secretService.GetAppSecret(ctx, stripeApp.APIKey) + + // If the secret is not found, we continue with the uninstallation + var secretNotFoundError *secretentity.SecretNotFoundError + + if err != nil && !errors.As(err, &secretNotFoundError) { + return fmt.Errorf("failed to get stripe api key secret: %w", err) + } + + // Try to delete the webhook, it may fail if the token is invalid + if err == nil { + // Create Stripe Client + stripeClient, err := s.adapter.GetStripeAppClientFactory()(stripeclient.StripeAppClientConfig{ + AppID: input, + AppService: s.appService, + APIKey: apiKeySecret.Value, + Logger: s.logger.With("operation", "uninstalApp", "app_id", input.ID), + }) + if err != nil { + return fmt.Errorf("failed to create stripe client") + } + + // Delete Webhook + err = stripeClient.DeleteWebhook(ctx, stripeclient.DeleteWebhookInput{ + AppID: input, + StripeWebhookID: stripeApp.StripeWebhookID, + }) + + // If the error is not an authentication error, we return it + if app.IsAppProviderAuthenticationError(err) { + return fmt.Errorf("failed to delete stripe webhook") + } + } + + // Delete secrets + if err := s.secretService.DeleteAppSecret(ctx, stripeApp.APIKey); err != nil && !errors.As(err, &secretNotFoundError) { + return fmt.Errorf("failed to delete stripe api key secret") + } + + if err := s.secretService.DeleteAppSecret(ctx, stripeApp.WebhookSecret); err != nil && !errors.As(err, &secretNotFoundError) { + return fmt.Errorf("failed to delete stripe webhook secret") + } + + return nil +} + +// newApp combines the app base and stripe app data to create a new app +func (s *Service) newApp(appBase app.AppBase, stripeApp appstripe.AppData) (appstripe.App, error) { + app := appstripe.App{ + Meta: appstripe.Meta{ + AppBase: appBase, + AppData: stripeApp, + }, + AppService: s.appService, + BillingService: s.billingService, + StripeAppService: s, + SecretService: s.secretService, + StripeAppClientFactory: s.adapter.GetStripeAppClientFactory(), + Logger: s.logger, + } + + if err := app.Validate(); err != nil { + return appstripe.App{}, fmt.Errorf("failed to map stripe app from db: %w", err) + } + + return app, nil +} diff --git a/app/stripe/service/service.go b/app/stripe/service/service.go new file mode 100644 index 0000000000000000000000000000000000000000..c1caa9c49d20d1ffab5e180129fc1b7cda8b9b48 --- /dev/null +++ b/app/stripe/service/service.go @@ -0,0 +1,97 @@ +package appservice + +import ( + "errors" + "fmt" + "log/slog" + + "github.com/openmeterio/openmeter/openmeter/app" + appstripe "github.com/openmeterio/openmeter/openmeter/app/stripe" + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/secret" + "github.com/openmeterio/openmeter/openmeter/watermill/eventbus" +) + +var _ appstripe.Service = (*Service)(nil) + +type Service struct { + adapter appstripe.Adapter + appService app.Service + secretService secret.Service + billingService billing.Service + logger *slog.Logger + publisher eventbus.Publisher + disableWebhookRegistration bool + webhookURLGenerator app.WebhookURLGenerator +} + +type Config struct { + Adapter appstripe.Adapter + AppService app.Service + SecretService secret.Service + BillingService billing.Service + Logger *slog.Logger + DisableWebhookRegistration bool + Publisher eventbus.Publisher + WebhookURLGenerator app.WebhookURLGenerator +} + +func (c Config) Validate() error { + if c.Adapter == nil { + return errors.New("adapter cannot be null") + } + + if c.AppService == nil { + return errors.New("app service cannot be null") + } + + if c.SecretService == nil { + return errors.New("secret service cannot be null") + } + + if c.BillingService == nil { + return errors.New("billing service cannot be null") + } + + if c.Logger == nil { + return errors.New("logger cannot be null") + } + + if c.Publisher == nil { + return errors.New("publisher cannot be null") + } + + if c.WebhookURLGenerator == nil { + return errors.New("webhook url generator cannot be null") + } + + return nil +} + +func New(config Config) (*Service, error) { + if err := config.Validate(); err != nil { + return nil, err + } + + service := &Service{ + adapter: config.Adapter, + appService: config.AppService, + secretService: config.SecretService, + billingService: config.BillingService, + logger: config.Logger, + disableWebhookRegistration: config.DisableWebhookRegistration, + publisher: config.Publisher, + webhookURLGenerator: config.WebhookURLGenerator, + } + + // Register stripe app in marketplace + err := config.AppService.RegisterMarketplaceListing(app.RegistryItem{ + Listing: appstripe.StripeMarketplaceListing, + Factory: service, + }) + if err != nil { + return service, fmt.Errorf("failed to register stripe app to marketplace: %w", err) + } + + return service, nil +} diff --git a/app/stripe/service/webhook.go b/app/stripe/service/webhook.go new file mode 100644 index 0000000000000000000000000000000000000000..867746f4bcc15aa8d11ad6acf8fe4f4aca0c9a60 --- /dev/null +++ b/app/stripe/service/webhook.go @@ -0,0 +1,63 @@ +package appservice + +import ( + "context" + "errors" + "fmt" + "net/url" + "strings" + + "github.com/openmeterio/openmeter/openmeter/app" +) + +var _ app.WebhookURLGenerator = (*baseURLWebhookURLGenerator)(nil) + +type baseURLWebhookURLGenerator struct { + baseURL string +} + +func NewBaseURLWebhookURLGenerator(baseURL string) (app.WebhookURLGenerator, error) { + if baseURL == "" { + return nil, errors.New("base url is required") + } + + return &baseURLWebhookURLGenerator{ + baseURL: baseURL, + }, nil +} + +func (g *baseURLWebhookURLGenerator) GetWebhookURL(ctx context.Context, appID app.AppID) (string, error) { + if err := appID.Validate(); err != nil { + return "", fmt.Errorf("error validating app id: %w", err) + } + + return url.JoinPath(g.baseURL, "/api/v1/apps/", appID.ID, "/stripe/webhook") +} + +var _ app.WebhookURLGenerator = (*patternWebhookURLGenerator)(nil) + +type patternWebhookURLGenerator struct { + pattern string +} + +func NewPatternWebhookURLGenerator(pattern string) (app.WebhookURLGenerator, error) { + if pattern == "" { + return nil, errors.New("pattern is required") + } + + if !strings.Contains(pattern, "%s") { + return nil, errors.New("pattern must contain %s") + } + + return &patternWebhookURLGenerator{ + pattern: pattern, + }, nil +} + +func (g *patternWebhookURLGenerator) GetWebhookURL(ctx context.Context, appID app.AppID) (string, error) { + if appID.ID == "" { + return "", errors.New("app id is required") + } + + return fmt.Sprintf(g.pattern, appID.ID), nil +} diff --git a/app/stripe/types.go b/app/stripe/types.go new file mode 100644 index 0000000000000000000000000000000000000000..37fc2536a0dc6e6d12a2c9f44a9f15b558bcdcec --- /dev/null +++ b/app/stripe/types.go @@ -0,0 +1,568 @@ +package appstripe + +import ( + "errors" + "fmt" + "strings" + + "github.com/stripe/stripe-go/v80" + + "github.com/openmeterio/openmeter/api" + "github.com/openmeterio/openmeter/openmeter/app" + "github.com/openmeterio/openmeter/openmeter/app/stripe/client" + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/customer" + secretentity "github.com/openmeterio/openmeter/openmeter/secret/entity" +) + +const ( + APIKeySecretKey = "stripe_api_key" + WebhookSecretKey = "stripe_webhook_secret" +) + +type CreateAppStripeInput struct { + app.CreateAppInput + + StripeAccountID string + Livemode bool + APIKey secretentity.SecretID + MaskedAPIKey string + StripeWebhookID string + WebhookSecret secretentity.SecretID +} + +func (i CreateAppStripeInput) Validate() error { + if i.CreateAppInput.Type != app.AppTypeStripe { + return errors.New("app type must be stripe") + } + + if err := i.ID.Validate(); err != nil { + return errors.New("id cannot be empty if provided") + } + + if err := i.CreateAppInput.Validate(); err != nil { + return fmt.Errorf("error validating create app input: %w", err) + } + + if i.StripeAccountID == "" { + return errors.New("stripe account id is required") + } + + if err := i.APIKey.Validate(); err != nil { + return fmt.Errorf("error validating api key: %w", err) + } + + if i.MaskedAPIKey == "" { + return errors.New("masked api key is required") + } + + if i.ID != nil && i.APIKey.Namespace != i.ID.Namespace { + return errors.New("api key must be in the same namespace as the app") + } + + if err := i.WebhookSecret.Validate(); err != nil { + return fmt.Errorf("error validating webhook secret: %w", err) + } + + if i.StripeWebhookID == "" { + return errors.New("stripe webhook id is required") + } + + if i.ID != nil && i.WebhookSecret.Namespace != i.ID.Namespace { + return errors.New("webhook secret must be in the same namespace as the app") + } + + return nil +} + +type GetStripeAppDataInput struct { + AppID app.AppID +} + +func (i GetStripeAppDataInput) Validate() error { + if err := i.AppID.Validate(); err != nil { + return fmt.Errorf("error validating app id: %w", err) + } + + return nil +} + +type DeleteStripeAppDataInput struct { + AppID app.AppID +} + +func (i DeleteStripeAppDataInput) Validate() error { + if err := i.AppID.Validate(); err != nil { + return fmt.Errorf("error validating app id: %w", err) + } + + return nil +} + +type GetStripeCustomerDataInput struct { + AppID app.AppID + CustomerID customer.CustomerID +} + +func (i GetStripeCustomerDataInput) Validate() error { + if err := i.AppID.Validate(); err != nil { + return fmt.Errorf("error validating app id: %w", err) + } + + if err := i.CustomerID.Validate(); err != nil { + return fmt.Errorf("error validating customer id: %w", err) + } + + if i.AppID.Namespace != i.CustomerID.Namespace { + return errors.New("app and customer must be in the same namespace") + } + + return nil +} + +type CreateStripeCustomerInput struct { + AppID app.AppID + CustomerID customer.CustomerID + + Name *string + Email *string +} + +func (i CreateStripeCustomerInput) Validate() error { + if err := i.AppID.Validate(); err != nil { + return fmt.Errorf("error validating app id: %w", err) + } + + if err := i.CustomerID.Validate(); err != nil { + return fmt.Errorf("error validating customer id: %w", err) + } + + if i.AppID.Namespace != i.CustomerID.Namespace { + return errors.New("app and customer must be in the same namespace") + } + + if i.Name != nil && *i.Name == "" { + return errors.New("name cannot be empty if provided") + } + + if i.Email != nil && *i.Email == "" { + return errors.New("email cannot be empty if provided") + } + + return nil +} + +type CreateStripeCustomerOutput struct { + StripeCustomerID string +} + +func (o CreateStripeCustomerOutput) Validate() error { + if o.StripeCustomerID == "" { + return errors.New("stripe customer id is required") + } + + return nil +} + +type UpsertStripeCustomerDataInput struct { + AppID app.AppID + CustomerID customer.CustomerID + StripeCustomerID string + StripeDefaultPaymentMethodID *string +} + +func (i UpsertStripeCustomerDataInput) Validate() error { + if err := i.AppID.Validate(); err != nil { + return fmt.Errorf("error validating app id: %w", err) + } + + if err := i.CustomerID.Validate(); err != nil { + return fmt.Errorf("error validating customer id: %w", err) + } + + if i.AppID.Namespace != i.CustomerID.Namespace { + return errors.New("app and customer must be in the same namespace") + } + + if i.StripeCustomerID == "" { + return errors.New("stripe customer id is required") + } + + if i.StripeDefaultPaymentMethodID != nil && !strings.HasPrefix(*i.StripeDefaultPaymentMethodID, "pm_") { + return errors.New("stripe default payment method must start with pm_") + } + + return nil +} + +type DeleteStripeCustomerDataInput struct { + AppID *app.AppID + CustomerID *customer.CustomerID +} + +func (i DeleteStripeCustomerDataInput) Validate() error { + if i.AppID == nil && i.CustomerID == nil { + return errors.New("app id or customer id is required") + } + + if i.CustomerID != nil { + if i.CustomerID.ID == "" { + return errors.New("customer id is required") + } + + if i.CustomerID.Namespace == "" { + return errors.New("customer namespace is required") + } + } + + if i.AppID != nil { + if i.AppID.ID == "" { + return errors.New("app id is required") + } + + if i.AppID.Namespace == "" { + return errors.New("app namespace is required") + } + } + + if i.AppID != nil && i.CustomerID != nil && i.AppID.Namespace != i.CustomerID.Namespace { + return errors.New("app and customer must be in the same namespace") + } + + return nil +} + +type GetAppInput = app.AppID + +type GetWebhookSecretInput struct { + AppID string +} + +func (i GetWebhookSecretInput) Validate() error { + if i.AppID == "" { + return errors.New("app id is required") + } + + return nil +} + +type GetWebhookSecretOutput = secretentity.Secret + +type UpdateAPIKeyInput struct { + AppID app.AppID + APIKey string +} + +func (i UpdateAPIKeyInput) Validate() error { + if err := i.AppID.Validate(); err != nil { + return fmt.Errorf("error validating app id: %w", err) + } + + if i.APIKey == "" { + return errors.New("api key is required") + } + + return nil +} + +type UpdateAPIKeyAdapterInput struct { + UpdateAPIKeyInput + + MaskedAPIKey string +} + +func (i UpdateAPIKeyAdapterInput) Validate() error { + if err := i.UpdateAPIKeyInput.Validate(); err != nil { + return fmt.Errorf("error validating update api key input: %w", err) + } + + if i.MaskedAPIKey == "" { + return errors.New("masked api key is required") + } + + return nil +} + +type CreateCheckoutSessionInput struct { + Namespace string + AppID app.AppID + CreateCustomerInput *customer.CreateCustomerInput + CustomerID *customer.CustomerID + StripeCustomerID *string + Options api.CreateStripeCheckoutSessionRequestOptions +} + +func (i CreateCheckoutSessionInput) Validate() error { + if i.Namespace == "" { + return errors.New("namespace is required") + } + + if err := i.AppID.Validate(); err != nil { + return fmt.Errorf("error validating app id: %w", err) + } + + if i.AppID.Namespace != i.Namespace { + return errors.New("app id and namespace must be in the same namespace") + } + + // Least one of customer, customer id or customer key is required + if i.CreateCustomerInput == nil && i.CustomerID == nil { + return errors.New("create customer input or customer id or customer key is required") + } + + // Mutually exclusive + if i.CreateCustomerInput != nil { + if err := i.CreateCustomerInput.Validate(); err != nil { + return fmt.Errorf("error validating create customer input: %w", err) + } + + if i.CustomerID != nil { + return errors.New("create customer input and customer id cannot be provided at the same time") + } + } + + if i.CustomerID != nil { + if err := i.CustomerID.Validate(); err != nil { + return fmt.Errorf("error validating customer id: %w", err) + } + + if i.Namespace != i.CustomerID.Namespace { + return errors.New("app and customer must be in the same namespace") + } + + if i.CreateCustomerInput != nil { + return errors.New("customer id and create customer input cannot be provided at the same time") + } + } + + if i.StripeCustomerID != nil && !strings.HasPrefix(*i.StripeCustomerID, "cus_") { + return errors.New("stripe customer id must start with cus_") + } + + if i.Options.UiMode != nil { + switch *i.Options.UiMode { + case api.CheckoutSessionUIModeEmbedded: + if i.Options.CancelURL != nil { + return errors.New("cancel url is not allowed for embedded ui mode") + } + case api.CheckoutSessionUIModeHosted: + if i.Options.SuccessURL == nil { + return errors.New("success url is required for hosted ui mode") + } + } + } + + return nil +} + +type CreateCheckoutSessionOutput struct { + AppID app.AppID + CustomerID customer.CustomerID + StripeCustomerID string + + client.StripeCheckoutSession +} + +func (o CreateCheckoutSessionOutput) Validate() error { + var errs []error + + if err := o.AppID.Validate(); err != nil { + errs = append(errs, fmt.Errorf("error validating app id: %w", err)) + } + + if err := o.CustomerID.Validate(); err != nil { + errs = append(errs, fmt.Errorf("error validating customer id: %w", err)) + } + + if o.StripeCustomerID == "" { + errs = append(errs, errors.New("stripe customer id is required")) + } + + if err := o.StripeCheckoutSession.Validate(); err != nil { + errs = append(errs, fmt.Errorf("error validating stripe checkout session: %w", err)) + } + + return errors.Join(errs...) +} + +type AppBase struct { + app.AppBase + AppData +} + +// AppData represents the Stripe associated data for the app +type AppData struct { + StripeAccountID string `json:"stripeAccountId"` + Livemode bool `json:"livemode"` + APIKey secretentity.SecretID `json:"-"` + MaskedAPIKey string `json:"maskedApiKey"` + StripeWebhookID string `json:"stripeWebhookId"` + WebhookSecret secretentity.SecretID `json:"-"` +} + +func (d AppData) Validate() error { + if d.StripeAccountID == "" { + return errors.New("stripe account id is required") + } + + if err := d.APIKey.Validate(); err != nil { + return fmt.Errorf("error validating api key: %w", err) + } + + if d.StripeWebhookID == "" { + return errors.New("stripe webhook id is required") + } + + if err := d.WebhookSecret.Validate(); err != nil { + return fmt.Errorf("error validating webhook secret: %w", err) + } + + return nil +} + +type SetCustomerDefaultPaymentMethodInput struct { + AppID app.AppID + StripeCustomerID string + PaymentMethodID string +} + +type SetCustomerDefaultPaymentMethodOutput struct { + CustomerID customer.CustomerID +} + +func (i SetCustomerDefaultPaymentMethodInput) Validate() error { + if err := i.AppID.Validate(); err != nil { + return fmt.Errorf("app id: %w", err) + } + + if i.StripeCustomerID == "" { + return errors.New("stripe customer id is required") + } + + if i.PaymentMethodID == "" { + return errors.New("payment method id is required") + } + + return nil +} + +type HandleSetupIntentSucceededInput struct { + SetCustomerDefaultPaymentMethodInput + + PaymentIntentMetadata map[string]string +} + +func (i HandleSetupIntentSucceededInput) Validate() error { + if err := i.SetCustomerDefaultPaymentMethodInput.Validate(); err != nil { + return fmt.Errorf("error validating set customer default payment method adapter input: %w", err) + } + + return nil +} + +type HandleSetupIntentSucceededOutput struct { + CustomerID customer.CustomerID +} + +// GetSupplierContactInput to get the default supplier +type GetSupplierContactInput struct { + AppID app.AppID +} + +func (i GetSupplierContactInput) Validate() error { + if err := i.AppID.Validate(); err != nil { + return fmt.Errorf("error validating app id: %w", err) + } + + return nil +} + +type ValidationErrorsInput struct { + Op billing.StandardInvoiceOperation + Errors []*stripe.Error +} + +type HandleInvoiceStateTransitionInput struct { + AppID app.AppID + Invoice stripe.Invoice + + // Trigger setup + + // Trigger is the state machine trigger that will be used to transition the invoice + Trigger billing.InvoiceTrigger + // TargetStatus specifies the expected status of the invoice after the transition, needed to filter + // for duplicate events as the state machine doesn't allow transition into the same state + TargetStatuses []billing.StandardInvoiceStatus + + // Event filtering + + // IgnoreInvoiceInStatus is a list of invoice statuses. If the invoice is in this status we ignore the event + // this allows to filter for out of order events. + IgnoreInvoiceInStatus []billing.StandardInvoiceStatusMatcher + // ShouldTriggerOnEvent gets the *current* stripe invoice and returns true if the state machine should be triggered + // useful for filtering late events based on the current state (optional) + ShouldTriggerOnEvent func(*stripe.Invoice) (bool, error) + + // Validation errors + // GetValidationErrors is invoked with the current stripe invoice and returns the validation errors if any + GetValidationErrors func(*stripe.Invoice) (*ValidationErrorsInput, error) +} + +func (i HandleInvoiceStateTransitionInput) Validate() error { + if err := i.AppID.Validate(); err != nil { + return fmt.Errorf("error validating app id: %w", err) + } + + if i.Invoice.ID == "" { + return errors.New("invoice id is required") + } + + if i.Trigger == nil { + return errors.New("trigger is required") + } + + if len(i.TargetStatuses) == 0 { + return errors.New("target statuses are required") + } + + return nil +} + +type HandleInvoiceSentEventInput struct { + AppID app.AppID + Invoice stripe.Invoice + SentAt int64 +} + +func (i HandleInvoiceSentEventInput) Validate() error { + if err := i.AppID.Validate(); err != nil { + return fmt.Errorf("error validating app id: %w", err) + } + + if i.Invoice.ID == "" { + return errors.New("invoice id is required") + } + + if i.SentAt == 0 { + return errors.New("sent at is required") + } + + return nil +} + +type GetStripeInvoiceInput struct { + AppID app.AppID + StripeInvoiceID string +} + +func (i GetStripeInvoiceInput) Validate() error { + if err := i.AppID.Validate(); err != nil { + return fmt.Errorf("error validating app id: %w", err) + } + + if i.StripeInvoiceID == "" { + return errors.New("stripe invoice id is required") + } + + return nil +} diff --git a/app/webhook.go b/app/webhook.go new file mode 100644 index 0000000000000000000000000000000000000000..1c33c11267757cfc62918fde9f8f62952fbe5209 --- /dev/null +++ b/app/webhook.go @@ -0,0 +1,9 @@ +package app + +import ( + "context" +) + +type WebhookURLGenerator interface { + GetWebhookURL(ctx context.Context, appID AppID) (string, error) +} diff --git a/billing/README.md b/billing/README.md new file mode 100644 index 0000000000000000000000000000000000000000..e11c1a37e552e3e0e08968e578eab5945469397b --- /dev/null +++ b/billing/README.md @@ -0,0 +1,191 @@ +# Billing + +This package contains the implementation for the billing stack (invoicing, tax and payments). + +The package has the following main entities: + +## BillingProfile + +Captures all the billing details, two main information is stored inside: +- The [billing workflow](./entity/customeroverride.go) (when to invoice, due periods etc) +- References to the apps responsible for tax, invoicing and payments (Sandbox or Stripe for now) + +Only one default billing profile can exist per namespace. + +## CustomerOverride + +Contains customer specific overrides for billing pruposes. It can reference a billing profile other than the default (e.g. when different apps or lifecycle should be used) and allows to override the billing workflow. + +## Invoice + +Invoices are used to store the data required by tax, invoicing and payment app's master copy at OpenMeter side. + +Upon creation all the data required to generate invoices are snapshotted into the invoice entity, so that no updates to entities like Customer, BillingProfile, CustomerOverride change an invoice retrospectively. + +### Gathering invoices + +There are two general kinds of invoices (Invoice.Status) `gathering` invoices are used to collect upcoming lines that are to be added to future invoices. `gathering` invocie's state never changes: when upcoming line items become due, they are just assigned to a new invoice, so that we clone the data required afresh. + +Each customer can have one `gathering` issue per currency. +> For example, if the customer has upcoming charges in USD and HUF, then there will be one `gathering` invoice for HUF and one for USD. + +If there are no upcoming items, the `gathering` invoices are (soft) deleted. + +### Collection + +TODO: document when implemented + +### Invoices + +The invoices are governed by the [invoice state machine](./service/invoicestate.go). + +Invoices are composed of [lines](./entity/invoiceline.go). Each invoice can only have lines from the same currency. + +The lines can be of different types: +- Fee: one time charge +- UsageBased: usage-based charge (can be used to charge additional usage-based prices without the product catalog features) + +Each line has a `period` (`start`, `end`) and an `invoiceAt` property. The period specifies which period of time the line is referring to (in case of usage-based pricing, the underlying meter will be queried for this time-period). `invoiceAt` specifies the time when it is expected to create an invoice that contains this line. The invoice's collection settings can defer this. + +Invoices are always created by collecting one or more line from the `gathering` invoices. The `/v1/api/billing/invoices/lines` endpoint can be used to create new future line items. A new invoice can be created any time. In such case, the `gathering` items to be invoiced (`invoiceAt`) are already added to the invoice. Any usage-based line, that we can bill early is also added to the invoice for the period between the `period.start` of the line and the time of invoice creation. + +### Line splitting + +To achieve the behavior described above, we are using line splitting. By default we would have one line per billing period that would eventually be part of an invoice: + +``` + period.start period.end +Line1 [status=valid] |--------------------------------------------------------| +``` + +When the usage-based line can be billed mid-period, we `split` the line into two: + +``` + period.start asOf period.end +Line1 [status=split] |--------------------------------------------------------| +SplitLine1 [status=valid] |------------------| +SplitLine2 [status=valid] |-------------------------------------| +``` + +As visible: +- Line1's status changes from `valid` to `split`: it will be ignored in any calculation, it becomes a grouping line between invoices +- SplitLine1 is created with a period between `period.start` and `asof` (time of invoicing): it will be addedd to the freshly created invoice +- SplitLine2 is created with a period between `asof` and `period.end`: it will be pushed to the gathering invoice + +When creating a new invoice between `asof` and `period.end` the same logic continues, but without marking SplitLine2 `split`, instead the new line is added to the original line's parent line: + +``` + period.start asOf1 asof2 period.end +Line1 [status=split] |--------------------------------------------------------| +SplitLine1 [status=valid] |------------------| +SplitLine2 [status=valid] |---------------| +SplitLine3 [status=valid] |---------------------| +``` + +This flattening approach allows us not to have to recursively traverse lines in the database. + +### Usage-based quantity + +When a line is created for an invoice, the quantity of the underlying meter is captured into the line's qty field. This information is never updated, so late events will have to create new invoice lines when needed. + +### Detailed Lines + +Each (`valid`) line can have one or more detailed lines (children). These lines represent the actual sub-charges that are caused by the parent line. + +Example: +> If a line has: +> - Usage of 200 units +> - Tiered pricing: +> - Tier1: 1 - 50 units cost flat $300 +> - Tier2: 51 - 100 units cost flat $400 +> - Tier3: 100 - 150 units cost flat $400 + $1/unit +> - Tier4: more than 150 units cost $15/unit + +This would yield the following lines: + +- Line with quantity=200 + - Line quantity=1 per_unit_amount=300 total=300 (Tier1) + - Line quantity=1 per_unit_amount=400 total=400 (Tier2) + - Line quantity=1 per_unit_amount=400 total=400 (Tier3, flat component) + - Line quantity=50 per_unit_amount=1 total=50 (Tier3, per unit price) + - Line quantity=50 per_unit_amount=15 total=759 (Tier4) + +Apps can choose to synchronize the original line (if the upstream system understands our pricing model) or can use the sublines to synchronize individual lines without having to understand billing details. + +### Detailed Lines vs Splitting + +When we are dealing with a split line, the calculation of the quantity is by taking the meter's quantity for the whole line period ([`parent.period.start`, `splitline.period.end`]) and the amount before the period (`parent.period.start`, `splitline.period.start`). + +When subtracting the two we get the delta for the period (this gets the delta for all supported meter types except Min and Avg). + +We execute the pricing logic (e.g. tiered pricing) for the line qty, while considering the before usage, as it reflects the already billed for items. + +Corner cases: +- Graduating tiered prices cannot be billed mid-billing period (always arrears, as the calculation cannot be split into multiple items) +- Min, Avg meters are always billed arrears as we cannot calculate the delta. + +### Detailed line persisting + +In order for the calculation logic, to not to have to deal with the contents of the database, it is (mostly) the adapter layer's responsibility to understand what have changed and persist only that data to the database. + +In practice the high level rules are the following (see [adapter/invoicelinediff_test.go](./adapter/invoicelinediff_test.go) for examples): +- If an entity has an ID then it will be updated +- If an entity has changed compared to the database fetch, it will be updated +- If a child line, discount gets removed, it will be removed from the database (in case of lines with all sub-entities) +- If an entity doesn't have an ID a new entity will be generated by the database + +For idempotent entity sources (detailed lines and discounts for now), we have also added a field called `ChildUniqueReferenceID` which can be used to detect entities serving the same purpose. + +#### ChildUniqueReferenceID example + +Let's say we have an usage-based line whose detailed lines are persisted to the database, but then we would want to change the quantity of the line. + +First we load the existing detailed lines from the database, and save the database versions of the entities in memory. + +We execute the calculation for the new quantity that yields new detailed lines without database IDs. + +The entity's `ChildrenWithIDReuse` call can be used to facilitate the line reuse by assigning the known IDs to the yielded lines where the `ChildUniqueReferenceID` is set. + +Then the adapter layer will use those IDs to make decisions if they want to persist or recreate the records. + +We could do the same logic in the adapter layer, but this approach makes it more flexible on the calculation layer if we want to generate new lines or not. If this becomes a burden we can do the same matching logic as part of the upsert logic in adapter. + +## Lineengine Charges Integration Plan + +Mutable invoice edits route created, updated, and deleted invoice lines through the line engine that owns the line. For charge-backed manual line creation, billing and charges have a circular dependency: + +- the invoice line must reference the charge via `ChargeID` +- the charge realization must reference the invoice line via `LineID` and `InvoiceID` + +Billing owns invoice line identity and persistence. Charges owns charge creation, charge state transitions, realization runs, credit allocation, and mapping realization output back to invoice lines. + +The intended charge-backed manual create flow is: + +1. A mutable invoice is loaded and edited through `UpdateStandardInvoice`. +2. The edit diff routes a newly created line to the create line router. +3. Billing preallocates the created invoice line ID and inserts a provisional invoice line inside the same invoice manipulation transaction. +4. Billing refreshes or updates the in-memory line DB snapshot so the final invoice update treats the provisional line as an existing row to update, not as another create. +5. Billing invokes `LineEngine.OnMutableInvoiceLinesEditedViaAPI` with created lines that already have `LineID`, `InvoiceID`, and `Engine` populated. Charge-backed created lines do not have `ChargeID` yet. +6. The charge line engine creates the manually managed charge from the created line payload. +7. The charge line engine moves the new charge through an attach-to-existing-invoice-line state-machine transition. The exact trigger/state name can change, but the business meaning is that the charge attaches to a billing-owned invoice line that already exists. +8. The charge state machine creates the realization run with the provided `LineID` and `InvoiceID`, persists charge state, allocates/corrects credits as needed, and maps the realization result back onto the invoice line. +9. The charge line engine returns the realized invoice line with `ChargeID`, totals, detailed lines, and other charge-owned fields populated. +10. Billing replaces the provisional line in the invoice aggregate with the returned line and persists it as an update to the provisional row. + +All steps above must stay within the invoice manipulation transaction. If charge creation or realization fails, the provisional invoice line must roll back with the rest of the invoice edit. + +Callback expectations for this flow: + +- created lines passed to charge line engines must already have stable invoice line IDs +- charge engines must return created lines with the same line IDs as the created input lines +- updated lines are matched by existing line ID +- deleted lines are side-effect/validation inputs and are not returned in the API invoice line edit result +- billing validates callback output identity before replacing invoice lines + +## Subscription adapter + +The subscription adapter is responsible for feeding the billing with line items during the subscription's lifecycle. The generation of items is event-driven, new items are yielded when: +- A subscription is created +- A new invoice is created +- A subscription is modified +- Upgrade/Downgrade is handled as a subscription create/cancel diff --git a/billing/adapter.go b/billing/adapter.go new file mode 100644 index 0000000000000000000000000000000000000000..6b3c63a456375536b64ef7c45da7b941f6586d0a --- /dev/null +++ b/billing/adapter.go @@ -0,0 +1,110 @@ +package billing + +import ( + "context" + + "github.com/openmeterio/openmeter/openmeter/app" + "github.com/openmeterio/openmeter/openmeter/customer" + "github.com/openmeterio/openmeter/pkg/framework/entutils" + "github.com/openmeterio/openmeter/pkg/pagination" +) + +type Adapter interface { + ProfileAdapter + CustomerOverrideAdapter + InvoiceLineAdapter + InvoiceSplitLineGroupAdapter + InvoiceAdapter + GatheringInvoiceAdapter + StandardInvoiceAdapter + InvoiceAppAdapter + CustomerSynchronizationAdapter + SchemaLevelAdapter + + entutils.TxCreator +} + +type ProfileAdapter interface { + CreateProfile(ctx context.Context, input CreateProfileInput) (*BaseProfile, error) + ListProfiles(ctx context.Context, input ListProfilesInput) (pagination.Result[BaseProfile], error) + GetProfile(ctx context.Context, input GetProfileInput) (*AdapterGetProfileResponse, error) + GetDefaultProfile(ctx context.Context, input GetDefaultProfileInput) (*AdapterGetProfileResponse, error) + DeleteProfile(ctx context.Context, input DeleteProfileInput) error + UpdateProfile(ctx context.Context, input UpdateProfileAdapterInput) (*BaseProfile, error) + + IsAppUsed(ctx context.Context, appID app.AppID) error + + GetUnpinnedCustomerIDsWithPaidSubscription(ctx context.Context, input GetUnpinnedCustomerIDsWithPaidSubscriptionInput) ([]customer.CustomerID, error) +} + +type CustomerOverrideAdapter interface { + CreateCustomerOverride(ctx context.Context, input UpdateCustomerOverrideAdapterInput) (*CustomerOverride, error) + GetCustomerOverride(ctx context.Context, input GetCustomerOverrideAdapterInput) (*CustomerOverride, error) + UpdateCustomerOverride(ctx context.Context, input UpdateCustomerOverrideAdapterInput) (*CustomerOverride, error) + DeleteCustomerOverride(ctx context.Context, input DeleteCustomerOverrideInput) error + ListCustomerOverrides(ctx context.Context, input ListCustomerOverridesInput) (ListCustomerOverridesAdapterResult, error) + + BulkAssignCustomersToProfile(ctx context.Context, input BulkAssignCustomersToProfileInput) error + + GetCustomerOverrideReferencingProfile(ctx context.Context, input HasCustomerOverrideReferencingProfileAdapterInput) ([]customer.CustomerID, error) +} + +type CustomerSynchronizationAdapter interface { + // UpsertCustomerOverride upserts a customer override ignoring the transactional context, the override + // will be empty. + UpsertCustomerLock(ctx context.Context, input UpsertCustomerLockAdapterInput) error + LockCustomerForUpdate(ctx context.Context, input LockCustomerForUpdateAdapterInput) error +} + +type InvoiceLineAdapter interface { + UpsertInvoiceLines(ctx context.Context, input UpsertInvoiceLinesAdapterInput) ([]*StandardLine, error) + ListInvoiceLines(ctx context.Context, input ListInvoiceLinesAdapterInput) ([]*StandardLine, error) + GetLinesForSubscription(ctx context.Context, input GetLinesForSubscriptionInput) ([]LineOrHierarchy, error) +} + +type InvoiceAdapter interface { + CreateInvoice(ctx context.Context, input CreateInvoiceAdapterInput) (CreateInvoiceAdapterRespone, error) + DeleteGatheringInvoices(ctx context.Context, input DeleteGatheringInvoicesInput) error + ListInvoices(ctx context.Context, input ListInvoicesAdapterInput) (ListInvoicesResponse, error) + AssociatedLineCounts(ctx context.Context, input AssociatedLineCountsAdapterInput) (AssociatedLineCountsAdapterResponse, error) + + GetInvoiceOwnership(ctx context.Context, input GetInvoiceOwnershipAdapterInput) (GetOwnershipAdapterResponse, error) + + GetInvoiceType(ctx context.Context, input GetInvoiceTypeAdapterInput) (InvoiceType, error) +} + +type StandardInvoiceAdapter interface { + GetStandardInvoiceById(ctx context.Context, input GetStandardInvoiceByIdInput) (StandardInvoice, error) + UpdateStandardInvoice(ctx context.Context, input UpdateStandardInvoiceAdapterInput) (StandardInvoice, error) + ListStandardInvoicesPendingAdvancement(ctx context.Context, input ListStandardInvoicesPendingAdvancementInput) ([]StandardInvoice, error) + CountStandardInvoicesPendingAdvancement(ctx context.Context, input CountStandardInvoicesPendingAdvancementInput) (int64, error) +} + +type GatheringInvoiceAdapter interface { + CreateGatheringInvoice(ctx context.Context, input CreateGatheringInvoiceAdapterInput) (GatheringInvoice, error) + UpdateGatheringInvoice(ctx context.Context, input UpdateGatheringInvoiceAdapterInput) error + DeleteGatheringInvoice(ctx context.Context, input DeleteGatheringInvoiceAdapterInput) error + GetGatheringInvoiceById(ctx context.Context, input GetGatheringInvoiceByIdInput) (GatheringInvoice, error) + ListGatheringInvoices(ctx context.Context, input ListGatheringInvoicesInput) (pagination.Result[GatheringInvoice], error) + + HardDeleteGatheringInvoiceLines(ctx context.Context, invoiceID InvoiceID, lineIDs []string) error +} + +type InvoiceSplitLineGroupAdapter interface { + CreateSplitLineGroup(ctx context.Context, input CreateSplitLineGroupAdapterInput) (SplitLineGroup, error) + UpdateSplitLineGroup(ctx context.Context, input UpdateSplitLineGroupInput) (SplitLineGroup, error) + DeleteSplitLineGroup(ctx context.Context, input DeleteSplitLineGroupInput) error + GetSplitLineGroup(ctx context.Context, input GetSplitLineGroupInput) (SplitLineHierarchy, error) + GetSplitLineGroupHeaders(ctx context.Context, input GetSplitLineGroupHeadersInput) (SplitLineGroupHeaders, error) +} + +type InvoiceAppAdapter interface { + UpdateInvoiceFields(ctx context.Context, input UpdateInvoiceFieldsInput) error +} + +type SchemaLevelAdapter interface { + // GetInvoiceDefaultSchemaLevel returns the current default schema level for invoices. + GetInvoiceDefaultSchemaLevel(ctx context.Context) (int, error) + // SetInvoiceDefaultSchemaLevel sets the current default schema level for invoices. + SetInvoiceDefaultSchemaLevel(ctx context.Context, level int) error +} diff --git a/billing/adapter/adapter.go b/billing/adapter/adapter.go new file mode 100644 index 0000000000000000000000000000000000000000..2ae3caacd66b0a35256fccc816b7336eb82e4839 --- /dev/null +++ b/billing/adapter/adapter.go @@ -0,0 +1,72 @@ +package billingadapter + +import ( + "context" + "database/sql" + "errors" + "fmt" + "log/slog" + + "github.com/openmeterio/openmeter/openmeter/billing" + entdb "github.com/openmeterio/openmeter/openmeter/ent/db" + "github.com/openmeterio/openmeter/pkg/framework/entutils" + "github.com/openmeterio/openmeter/pkg/framework/transaction" +) + +type Config struct { + Client *entdb.Client + Logger *slog.Logger +} + +func (c Config) Validate() error { + if c.Client == nil { + return errors.New("ent client is required") + } + + if c.Logger == nil { + return errors.New("logger is required") + } + + return nil +} + +func New(config Config) (billing.Adapter, error) { + if err := config.Validate(); err != nil { + return nil, err + } + + return &adapter{ + db: config.Client, + logger: config.Logger, + }, nil +} + +var _ billing.Adapter = (*adapter)(nil) + +type adapter struct { + db *entdb.Client + logger *slog.Logger +} + +func (a *adapter) Tx(ctx context.Context) (context.Context, transaction.Driver, error) { + txCtx, rawConfig, eDriver, err := a.db.HijackTx(ctx, &sql.TxOptions{ + ReadOnly: false, + }) + if err != nil { + return nil, nil, fmt.Errorf("failed to hijack transaction: %w", err) + } + return txCtx, entutils.NewTxDriver(eDriver, rawConfig), nil +} + +func (a *adapter) WithTx(ctx context.Context, tx *entutils.TxDriver) *adapter { + txDb := entdb.NewTxClientFromRawConfig(ctx, *tx.GetConfig()) + + return &adapter{ + db: txDb.Client(), + logger: a.logger, + } +} + +func (a *adapter) Self() *adapter { + return a +} diff --git a/billing/adapter/customeroverride.go b/billing/adapter/customeroverride.go new file mode 100644 index 0000000000000000000000000000000000000000..98e849361ede7794c1a9d76633b4ff46a9bf4017 --- /dev/null +++ b/billing/adapter/customeroverride.go @@ -0,0 +1,457 @@ +package billingadapter + +import ( + "context" + "fmt" + "slices" + + "entgo.io/ent/dialect/sql" + "github.com/samber/lo" + + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/customer" + "github.com/openmeterio/openmeter/openmeter/ent/db" + "github.com/openmeterio/openmeter/openmeter/ent/db/billingcustomeroverride" + "github.com/openmeterio/openmeter/openmeter/ent/db/billingprofile" + dbcustomer "github.com/openmeterio/openmeter/openmeter/ent/db/customer" + "github.com/openmeterio/openmeter/openmeter/ent/db/predicate" + "github.com/openmeterio/openmeter/openmeter/productcatalog" + taxcodeadapter "github.com/openmeterio/openmeter/openmeter/taxcode/adapter" + "github.com/openmeterio/openmeter/pkg/clock" + "github.com/openmeterio/openmeter/pkg/framework/entutils" + "github.com/openmeterio/openmeter/pkg/pagination" + "github.com/openmeterio/openmeter/pkg/sortx" +) + +// defaultBulkAssignCustomersToProfileBatchSize is the maximum number of customers that can be assigned to a profile in a single +// upsert operation. This is based on the maximum number of parameters PostgreSQL can handle in a single upsert operation (64k). +// +// This is a pessimistic approximation, as entgo might not try to insert the null columns, but still as of the writing we are still +// inserting in 4k batches, which is more than enough. +var defaultBulkAssignCustomersToProfileBatchSize int = (65535 / len(billingcustomeroverride.Columns)) - 1 + +var _ billing.CustomerOverrideAdapter = (*adapter)(nil) + +func (a *adapter) CreateCustomerOverride(ctx context.Context, input billing.CreateCustomerOverrideAdapterInput) (*billing.CustomerOverride, error) { + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) (*billing.CustomerOverride, error) { + createCmd := tx.db.BillingCustomerOverride.Create(). + SetNamespace(input.Namespace). + SetCustomerID(input.CustomerID). + SetNillableBillingProfileID(lo.EmptyableToPtr(input.ProfileID)). + SetNillableCollectionAlignment(input.Collection.Alignment). + SetAnchoredAlignmentDetail(input.Collection.AnchoredAlignmentDetail). + SetNillableLineCollectionPeriod(input.Collection.Interval.ISOStringPtrOrNil()). + SetNillableInvoiceAutoAdvance(input.Invoicing.AutoAdvance). + SetNillableInvoiceDraftPeriod(input.Invoicing.DraftPeriod.ISOStringPtrOrNil()). + SetNillableInvoiceDueAfter(input.Invoicing.DueAfter.ISOStringPtrOrNil()). + SetNillableInvoiceCollectionMethod(input.Payment.CollectionMethod). + SetNillableInvoiceProgressiveBilling(input.Invoicing.ProgressiveBilling). + SetNillableInvoiceDefaultTaxConfig(input.Invoicing.DefaultTaxConfig) + + if cfg := input.Invoicing.DefaultTaxConfig; cfg != nil { + createCmd = createCmd.SetNillableTaxCodeID(cfg.TaxCodeID).SetNillableTaxBehavior(cfg.Behavior) + } + + _, err := createCmd.Save(ctx) + if err != nil { + return nil, err + } + + // Let's fetch the override with edges + return tx.GetCustomerOverride(ctx, billing.GetCustomerOverrideAdapterInput{ + Customer: customer.CustomerID{ + Namespace: input.Namespace, + ID: input.CustomerID, + }, + }) + }) +} + +func (a *adapter) UpdateCustomerOverride(ctx context.Context, input billing.UpdateCustomerOverrideAdapterInput) (*billing.CustomerOverride, error) { + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) (*billing.CustomerOverride, error) { + if input.ProfileID == "" { + // Let's resolve the default profile + defaultProfile, err := tx.GetDefaultProfile(ctx, billing.GetDefaultProfileInput{ + Namespace: input.Namespace, + }) + if err != nil { + return nil, billing.NotFoundError{ + Entity: billing.EntityDefaultProfile, + Err: billing.ErrDefaultProfileNotFound, + } + } + + input.ProfileID = defaultProfile.ID + } + + update := tx.db.BillingCustomerOverride.Update(). + Where(billingcustomeroverride.CustomerID(input.CustomerID)). + SetOrClearBillingProfileID(lo.EmptyableToPtr(input.ProfileID)). + SetOrClearCollectionAlignment(input.Collection.Alignment). + SetOrClearLineCollectionPeriod(input.Collection.Interval.ISOStringPtrOrNil()). + SetOrClearInvoiceAutoAdvance(input.Invoicing.AutoAdvance). + SetOrClearInvoiceDraftPeriod(input.Invoicing.DraftPeriod.ISOStringPtrOrNil()). + SetOrClearInvoiceDueAfter(input.Invoicing.DueAfter.ISOStringPtrOrNil()). + SetOrClearInvoiceCollectionMethod(input.Payment.CollectionMethod). + SetOrClearInvoiceProgressiveBilling(input.Invoicing.ProgressiveBilling). + SetOrClearInvoiceDefaultTaxConfig(input.Invoicing.DefaultTaxConfig). + ClearDeletedAt() + + if cfg := input.Invoicing.DefaultTaxConfig; cfg != nil { + update = update.SetOrClearTaxCodeID(cfg.TaxCodeID).SetOrClearTaxBehavior(cfg.Behavior) + } else { + update = update.ClearTaxCodeID().ClearTaxBehavior() + } + + linesAffected, err := update.Save(ctx) + if err != nil { + return nil, err + } + + if linesAffected == 0 { + return nil, billing.NotFoundError{ + ID: input.CustomerID, + Entity: billing.EntityCustomerOverride, + Err: billing.ErrCustomerOverrideNotFound, + } + } + + return tx.GetCustomerOverride(ctx, billing.GetCustomerOverrideAdapterInput{ + Customer: customer.CustomerID{ + Namespace: input.Namespace, + ID: input.CustomerID, + }, + }) + }) +} + +func (a *adapter) GetCustomerOverride(ctx context.Context, input billing.GetCustomerOverrideAdapterInput) (*billing.CustomerOverride, error) { + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) (*billing.CustomerOverride, error) { + query := tx.db.BillingCustomerOverride.Query(). + Where(billingcustomeroverride.Namespace(input.Customer.Namespace)). + Where(billingcustomeroverride.CustomerID(input.Customer.ID)). + WithTaxCode(). + WithBillingProfile(func(bpq *db.BillingProfileQuery) { + bpq.WithWorkflowConfig(workflowConfigWithTaxCode) + }) + + if !input.IncludeDeleted { + query = query.Where(billingcustomeroverride.DeletedAtIsNil()) + } + + dbCustomerOverride, err := query.First(ctx) + if err != nil { + if db.IsNotFound(err) { + return nil, nil + } + + return nil, err + } + + if dbCustomerOverride.BillingProfileID == nil { + // Let's fetch the default billing profile + dbDefaultProfile, err := tx.db.BillingProfile.Query(). + Where(billingprofile.Namespace(input.Customer.Namespace)). + Where(billingprofile.Default(true)). + Where(billingprofile.DeletedAtIsNil()). + WithWorkflowConfig(workflowConfigWithTaxCode). + Only(ctx) + if err != nil { + if !db.IsNotFound(err) { + return nil, err + } + } + + dbCustomerOverride.Edges.BillingProfile = dbDefaultProfile + } + + return mapCustomerOverrideFromDB(dbCustomerOverride) + }) +} + +func (a *adapter) ListCustomerOverrides(ctx context.Context, input billing.ListCustomerOverridesInput) (billing.ListCustomerOverridesAdapterResult, error) { + // Warning: We need to use the customer db parts as for the UI (and for a good API) we need to + // be able to filter based on customer fields too. + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) (billing.ListCustomerOverridesAdapterResult, error) { + query := tx.db.Customer.Query(). + Where(dbcustomer.NamespaceEQ(input.Namespace)). + Where(dbcustomer.DeletedAtIsNil()) + + // Customer field filters + if len(input.CustomerIDs) > 0 { + query = query.Where(dbcustomer.IDIn(input.CustomerIDs...)) + } + + if input.CustomerName != "" { + query = query.Where(dbcustomer.NameContainsFold(input.CustomerName)) + } + + if input.CustomerKey != "" { + query = query.Where(dbcustomer.KeyEQ(input.CustomerKey)) + } + + if input.CustomerPrimaryEmail != "" { + query = query.Where(dbcustomer.PrimaryEmailContainsFold(input.CustomerPrimaryEmail)) + } + + order := entutils.GetOrdering(sortx.OrderDefault) + if !input.Order.IsDefaultValue() { + order = entutils.GetOrdering(input.Order) + } + + switch input.OrderBy { + case billing.CustomerOverrideOrderByCustomerID: + query = query.Order(dbcustomer.ByID(order...)) + case billing.CustomerOverrideOrderByCustomerName: + query = query.Order(dbcustomer.ByName(order...)) + case billing.CustomerOverrideOrderByCustomerKey: + query = query.Order(dbcustomer.ByKey(order...)) + case billing.CustomerOverrideOrderByCustomerPrimaryEmail: + query = query.Order(dbcustomer.ByPrimaryEmail(order...)) + case billing.CustomerOverrideOrderByCustomerCreatedAt: + query = query.Order(dbcustomer.ByCreatedAt(order...)) + default: + query = query.Order(dbcustomer.ByID(order...)) + } + + // Customer override filtering + customerOverrideFilters := []predicate.BillingCustomerOverride{ + billingcustomeroverride.DeletedAtIsNil(), + billingcustomeroverride.NamespaceEQ(input.Namespace), + } + + if len(input.BillingProfiles) > 0 { + customerOverrideFilters = append(customerOverrideFilters, billingcustomeroverride.BillingProfileIDIn(input.BillingProfiles...)) + } + + // If we are filtering by customers without pinned profiles, we need to include all customers + if input.CustomersWithoutPinnedProfile { + input.IncludeAllCustomers = true + } + + if !input.IncludeAllCustomers { + query = query.Where(dbcustomer.HasBillingCustomerOverrideWith(customerOverrideFilters...)) + } else if input.CustomersWithoutPinnedProfile { + query = query.Where(dbcustomer.Not(dbcustomer.HasBillingCustomerOverrideWith(customerOverrideFilters...))) + } else { + // We need to understand if the default profile is being queried for or not + + shouldIncludeDefaultProfile := false + if len(input.BillingProfiles) == 0 { + shouldIncludeDefaultProfile = true + } else { + // Let's see if we are interested in the default profile + defaultProfile, err := tx.GetDefaultProfile(ctx, billing.GetDefaultProfileInput{ + Namespace: input.Namespace, + }) + if err != nil { + return billing.ListCustomerOverridesAdapterResult{}, err + } + + shouldIncludeDefaultProfile = slices.Contains(input.BillingProfiles, defaultProfile.ID) + } + + if shouldIncludeDefaultProfile { + query = query.Where( + dbcustomer.Or( + dbcustomer.HasBillingCustomerOverrideWith(customerOverrideFilters...), + dbcustomer.Not(dbcustomer.HasBillingCustomerOverride()), + ), + ) + } else { + query = query.Where(dbcustomer.HasBillingCustomerOverrideWith(customerOverrideFilters...)) + } + } + + query = query.WithBillingCustomerOverride(func(overrideQuery *db.BillingCustomerOverrideQuery) { + overrideQuery = overrideQuery.Where(billingcustomeroverride.NamespaceEQ(input.Namespace)). + Where(billingcustomeroverride.DeletedAtIsNil()). + WithTaxCode() + + overrideQuery.WithBillingProfile(func(profileQuery *db.BillingProfileQuery) { + profileQuery.WithWorkflowConfig(workflowConfigWithTaxCode) + }) + }) + + res, err := query.Paginate(ctx, input.Page) + if err != nil { + return billing.ListCustomerOverridesAdapterResult{}, err + } + + return pagination.MapResultErr(res, func(dbCustomer *db.Customer) (billing.CustomerOverrideWithCustomerID, error) { + if dbCustomer.Edges.BillingCustomerOverride == nil { + return billing.CustomerOverrideWithCustomerID{ + CustomerID: customer.CustomerID{ + Namespace: dbCustomer.Namespace, + ID: dbCustomer.ID, + }, + }, nil + } + + override, err := mapCustomerOverrideFromDB(dbCustomer.Edges.BillingCustomerOverride) + if err != nil { + return billing.CustomerOverrideWithCustomerID{}, err + } + + return billing.CustomerOverrideWithCustomerID{ + CustomerOverride: override, + CustomerID: customer.CustomerID{ + Namespace: dbCustomer.Namespace, + ID: dbCustomer.ID, + }, + }, nil + }) + }) +} + +func (a *adapter) DeleteCustomerOverride(ctx context.Context, input billing.DeleteCustomerOverrideInput) error { + return entutils.TransactingRepoWithNoValue(ctx, a, func(ctx context.Context, tx *adapter) error { + rowsAffected, err := tx.db.BillingCustomerOverride.Update(). + Where(billingcustomeroverride.CustomerID(input.Customer.ID)). + Where(billingcustomeroverride.Namespace(input.Customer.Namespace)). + Where(billingcustomeroverride.DeletedAtIsNil()). + SetDeletedAt(clock.Now()). + Save(ctx) + if err != nil { + if db.IsNotFound(err) { + return billing.NotFoundError{ + ID: input.Customer.ID, + Entity: billing.EntityCustomerOverride, + Err: billing.ErrCustomerOverrideNotFound, + } + } + + return err + } + + if rowsAffected == 0 { + return billing.NotFoundError{ + ID: input.Customer.ID, + Entity: billing.EntityCustomerOverride, + Err: billing.ErrCustomerOverrideNotFound, + } + } + + return nil + }) +} + +func (a *adapter) GetCustomerOverrideReferencingProfile(ctx context.Context, input billing.HasCustomerOverrideReferencingProfileAdapterInput) ([]customer.CustomerID, error) { + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) ([]customer.CustomerID, error) { + dbCustomerOverrides, err := tx.db.BillingCustomerOverride.Query(). + Where(billingcustomeroverride.Namespace(input.Namespace)). + Where(billingcustomeroverride.BillingProfileID(input.ID)). + Where(billingcustomeroverride.DeletedAtIsNil()). + Select(billingcustomeroverride.FieldCustomerID). + All(ctx) + if err != nil { + return nil, err + } + + var customerIDs []customer.CustomerID + for _, dbCustomerOverride := range dbCustomerOverrides { + customerIDs = append(customerIDs, customer.CustomerID{ + Namespace: input.Namespace, + ID: dbCustomerOverride.CustomerID, + }) + } + + return customerIDs, nil + }) +} + +func (a *adapter) BulkAssignCustomersToProfile(ctx context.Context, input billing.BulkAssignCustomersToProfileInput) error { + return entutils.TransactingRepoWithNoValue(ctx, a, func(ctx context.Context, tx *adapter) error { + creates := make([]*db.BillingCustomerOverrideCreate, len(input.CustomerIDs)) + for i, customerID := range input.CustomerIDs { + creates[i] = tx.db.BillingCustomerOverride.Create(). + SetNamespace(input.ProfileID.Namespace). + SetCustomerID(customerID.ID). + SetBillingProfileID(input.ProfileID.ID) + } + + for _, createChunk := range lo.Chunk(creates, defaultBulkAssignCustomersToProfileBatchSize) { + err := tx.db.BillingCustomerOverride. + CreateBulk(createChunk...). + OnConflict( + sql.ConflictColumns(billingcustomeroverride.FieldNamespace, billingcustomeroverride.FieldCustomerID), + ). + UpdateBillingProfileID(). + Exec(ctx) + if err != nil { + return err + } + } + + return nil + }) +} + +func mapCustomerOverrideFromDB(dbOverride *db.BillingCustomerOverride) (*billing.CustomerOverride, error) { + collectionInterval, err := dbOverride.LineCollectionPeriod.ParsePtrOrNil() + if err != nil { + return nil, fmt.Errorf("cannot parse collection.interval: %w", err) + } + + draftPeriod, err := dbOverride.InvoiceDraftPeriod.ParsePtrOrNil() + if err != nil { + return nil, fmt.Errorf("cannot parse invoicing.draftPeriod: %w", err) + } + + dueAfter, err := dbOverride.InvoiceDueAfter.ParsePtrOrNil() + if err != nil { + return nil, fmt.Errorf("cannot parse invoicing.dueAfter: %w", err) + } + + baseProfile, err := mapProfileFromDB(dbOverride.Edges.BillingProfile) + if err != nil { + return nil, fmt.Errorf("cannot map profile: %w", err) + } + + var profile *billing.Profile + if baseProfile != nil { + profile = &billing.Profile{ + BaseProfile: baseProfile.BaseProfile, + } + } + + invoicingOverride := billing.InvoicingOverrideConfig{ + AutoAdvance: dbOverride.InvoiceAutoAdvance, + DraftPeriod: draftPeriod, + DueAfter: dueAfter, + ProgressiveBilling: dbOverride.InvoiceProgressiveBilling, + DefaultTaxConfig: lo.EmptyableToPtr(dbOverride.InvoiceDefaultTaxConfig), + } + + if taxCodeRow, err := dbOverride.Edges.TaxCodeOrErr(); err == nil { + tc, err := taxcodeadapter.MapTaxCodeFromEntity(taxCodeRow) + if err != nil { + return nil, fmt.Errorf("mapping tax code for customer override: %w", err) + } + + invoicingOverride.DefaultTaxConfig = productcatalog.BackfillTaxConfig(invoicingOverride.DefaultTaxConfig, dbOverride.TaxBehavior, &tc) + } + + return &billing.CustomerOverride{ + ID: dbOverride.ID, + Namespace: dbOverride.Namespace, + + CreatedAt: dbOverride.CreatedAt, + UpdatedAt: dbOverride.UpdatedAt, + + CustomerID: dbOverride.CustomerID, + Collection: billing.CollectionOverrideConfig{ + Alignment: dbOverride.CollectionAlignment, + AnchoredAlignmentDetail: dbOverride.AnchoredAlignmentDetail, + Interval: collectionInterval, + }, + + Invoicing: invoicingOverride, + + Payment: billing.PaymentOverrideConfig{ + CollectionMethod: dbOverride.InvoiceCollectionMethod, + }, + + Profile: profile, + }, nil +} diff --git a/billing/adapter/gatheringinvoice.go b/billing/adapter/gatheringinvoice.go new file mode 100644 index 0000000000000000000000000000000000000000..5ed96ba0ffeff4b41036225f020f1fa2ef465d00 --- /dev/null +++ b/billing/adapter/gatheringinvoice.go @@ -0,0 +1,458 @@ +package billingadapter + +import ( + "context" + "fmt" + "time" + + "github.com/alpacahq/alpacadecimal" + "github.com/samber/lo" + + "github.com/openmeterio/openmeter/api" + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/ent/db" + "github.com/openmeterio/openmeter/openmeter/ent/db/billinginvoice" + "github.com/openmeterio/openmeter/openmeter/ent/db/billinginvoiceline" + "github.com/openmeterio/openmeter/pkg/clock" + "github.com/openmeterio/openmeter/pkg/convert" + "github.com/openmeterio/openmeter/pkg/filter" + "github.com/openmeterio/openmeter/pkg/framework/entutils" + "github.com/openmeterio/openmeter/pkg/models" + "github.com/openmeterio/openmeter/pkg/pagination" + "github.com/openmeterio/openmeter/pkg/sortx" + "github.com/openmeterio/openmeter/pkg/timeutil" +) + +var _ billing.GatheringInvoiceAdapter = (*adapter)(nil) + +func (a *adapter) CreateGatheringInvoice(ctx context.Context, input billing.CreateGatheringInvoiceAdapterInput) (billing.GatheringInvoice, error) { + if err := input.Validate(); err != nil { + return billing.GatheringInvoice{}, err + } + + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) (billing.GatheringInvoice, error) { + customer := input.Customer + supplier := input.MergedProfile.Supplier + + // Clone the workflow config + clonedWorkflowConfig, err := tx.createWorkflowConfig(ctx, input.Namespace, input.MergedProfile.WorkflowConfig) + if err != nil { + return billing.GatheringInvoice{}, fmt.Errorf("clone workflow config: %w", err) + } + + currentSchemaLevel, err := tx.GetInvoiceDefaultSchemaLevel(ctx) + if err != nil { + return billing.GatheringInvoice{}, fmt.Errorf("get invoice write schema level: %w", err) + } + + createMut := tx.db.BillingInvoice.Create(). + SetNamespace(input.Namespace). + SetMetadata(input.Metadata). + SetCurrency(input.Currency). + SetStatus(billing.StandardInvoiceStatusGathering). + SetSourceBillingProfileID(input.MergedProfile.ID). + SetType(billing.InvoiceTypeStandard). // TODO: Migrate to GatheringInvoiceType once we have the type in the database + SetNumber(input.Number). + SetNillableDescription(input.Description). + SetNillableCollectionAt(input.NextCollectionAt). + SetSchemaLevel(currentSchemaLevel). + // Customer snapshot about usage attribution fields + SetCustomerID(input.Customer.ID). + // TODO: Remove all below this line once we have separate tables for gathering invoices + SetBillingWorkflowConfigID(clonedWorkflowConfig.ID). + SetTaxAppID(input.MergedProfile.Apps.Tax.GetID().ID). + SetInvoicingAppID(input.MergedProfile.Apps.Invoicing.GetID().ID). + SetPaymentAppID(input.MergedProfile.Apps.Payment.GetID().ID). + // Totals + SetAmount(alpacadecimal.Zero). + SetChargesTotal(alpacadecimal.Zero). + SetCreditsTotal(alpacadecimal.Zero). + SetDiscountsTotal(alpacadecimal.Zero). + SetTaxesTotal(alpacadecimal.Zero). + SetTaxesExclusiveTotal(alpacadecimal.Zero). + SetTaxesInclusiveTotal(alpacadecimal.Zero). + SetTotal(alpacadecimal.Zero). + // Supplier contacts + SetSupplierName(supplier.Name) + + // Customer usage attribution + if usageAttr := mapCustomerUsageAttributionToDB(input.Customer); usageAttr != nil { + createMut = createMut.SetCustomerUsageAttribution(usageAttr) + } + createMut = createMut. + SetCustomerName(customer.Name) + + newInvoice, err := createMut.Save(ctx) + if err != nil { + return billing.GatheringInvoice{}, err + } + + // Let's add required edges for mapping + newInvoice.Edges.BillingWorkflowConfig = clonedWorkflowConfig + + return tx.mapGatheringInvoiceFromDB(ctx, newInvoice, billing.GatheringInvoiceExpands{}) + }) +} + +func (a *adapter) UpdateGatheringInvoice(ctx context.Context, in billing.GatheringInvoice) error { + if err := in.Validate(); err != nil { + return fmt.Errorf("validating gathering invoice: %w", err) + } + + return entutils.TransactingRepoWithNoValue(ctx, a, func(ctx context.Context, tx *adapter) error { + existingInvoice, err := tx.db.BillingInvoice.Query(). + Where(billinginvoice.ID(in.ID)). + Where(billinginvoice.Namespace(in.Namespace)). + Only(ctx) + if err != nil { + return err + } + + if err := tx.validateUpdateGatheringInvoiceRequest(in, existingInvoice); err != nil { + return err + } + + updateQuery := tx.db.BillingInvoice.UpdateOneID(in.ID). + Where(billinginvoice.Namespace(in.Namespace)). + SetMetadata(in.Metadata). + // Currency is immutable + SetStatus(billing.StandardInvoiceStatusGathering). + ClearStatusDetailsCache(). + // Type is immutable + SetNumber(in.Number). + SetOrClearDescription(in.Description). + ClearDueAt(). + ClearPaymentProcessingEnteredAt(). + ClearDraftUntil(). + ClearIssuedAt(). + SetOrClearDeletedAt(convert.SafeToUTC(in.DeletedAt)). + ClearSentToCustomerAt(). + ClearQuantitySnapshotedAt(). + // Totals + SetAmount(alpacadecimal.Zero). + SetChargesTotal(alpacadecimal.Zero). + SetCreditsTotal(alpacadecimal.Zero). + SetDiscountsTotal(alpacadecimal.Zero). + SetTaxesTotal(alpacadecimal.Zero). + SetTaxesExclusiveTotal(alpacadecimal.Zero). + SetTaxesInclusiveTotal(alpacadecimal.Zero). + SetTotal(alpacadecimal.Zero). + SetOrClearCollectionAt(convert.SafeToUTC(in.NextCollectionAt)) + + // Clear period when the invoice is soft-deleted + if in.DeletedAt != nil { + updateQuery = updateQuery. + ClearPeriodStart(). + ClearPeriodEnd() + } else { + updateQuery = updateQuery. + SetPeriodStart(in.ServicePeriod.From.In(time.UTC)). + SetPeriodEnd(in.ServicePeriod.To.In(time.UTC)) + } + + // Supplier + updateQuery = updateQuery. + SetSupplierName("UNSET"). // Hack until we split the invoices table + SetSupplierAddressCountry("XX"). // Hack until we split the invoices table + ClearSupplierAddressPostalCode(). + ClearSupplierAddressCity(). + ClearSupplierAddressState(). + ClearSupplierAddressLine1(). + ClearSupplierAddressLine2(). + ClearSupplierAddressPhoneNumber() + + // Customer + updateQuery = updateQuery. + // CustomerID is immutable + SetCustomerName("UNSET"). // hack until we split the invoices table + ClearCustomerKey() + + updateQuery = updateQuery. + ClearCustomerAddressCountry(). + ClearCustomerAddressPostalCode(). + ClearCustomerAddressCity(). + ClearCustomerAddressState(). + ClearCustomerAddressLine1(). + ClearCustomerAddressLine2(). + ClearCustomerAddressPhoneNumber() + + // ExternalIDs + updateQuery = updateQuery. + ClearInvoicingAppExternalID(). + ClearPaymentAppExternalID() + + _, err = updateQuery.Save(ctx) + if err != nil { + return err + } + + if in.Lines.IsPresent() { + err := tx.updateGatheringLines(ctx, in.Lines.OrEmpty()) + if err != nil { + return err + } + } + + return nil + }) +} + +func (a *adapter) ListGatheringInvoices(ctx context.Context, input billing.ListGatheringInvoicesInput) (pagination.Result[billing.GatheringInvoice], error) { + if err := input.Validate(); err != nil { + return pagination.Result[billing.GatheringInvoice]{}, err + } + + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) (pagination.Result[billing.GatheringInvoice], error) { + query := tx.db.BillingInvoice.Query(). + Where(billinginvoice.StatusEQ(billing.StandardInvoiceStatusGathering)) + + if len(input.Namespaces) > 0 { + query = query.Where(billinginvoice.NamespaceIn(input.Namespaces...)) + } + + if len(input.ExcludedNamespaces) > 0 { + query = query.Where(billinginvoice.NamespaceNotIn(input.ExcludedNamespaces...)) + } + + if len(input.Customers) > 0 { + query = query.Where(billinginvoice.CustomerIDIn(input.Customers...)) + } + + if len(input.Currencies) > 0 { + query = query.Where(billinginvoice.CurrencyIn(input.Currencies...)) + } + + order := entutils.GetOrdering(sortx.OrderDefault) + if !input.Order.IsDefaultValue() { + order = entutils.GetOrdering(input.Order) + } + + if input.Expand.Has(billing.GatheringInvoiceExpandLines) { + query = a.expandGatheringInvoiceLines(query, input.Expand) + } + + query = filter.ApplyToQuery(query, &input.CollectionAt, billinginvoice.FieldCollectionAt) + if len(input.IDs) > 0 { + query = query.Where(billinginvoice.IDIn(input.IDs...)) + } + + switch input.OrderBy { + case api.InvoiceOrderByCustomerName: + query = query.Order(billinginvoice.ByCustomerName(order...)) + case api.InvoiceOrderByIssuedAt: + query = query.Order(billinginvoice.ByIssuedAt(order...)) + case api.InvoiceOrderByPeriodStart: + query = query.Order(billinginvoice.ByPeriodStart(order...)) + case api.InvoiceOrderByStatus: + query = query.Order(billinginvoice.ByStatus(order...)) + case api.InvoiceOrderByUpdatedAt: + query = query.Order(billinginvoice.ByUpdatedAt(order...)) + case api.InvoiceOrderByCreatedAt: + fallthrough + default: + query = query.Order(billinginvoice.ByCreatedAt(order...)) + } + + if !input.IncludeDeleted { + query = query.Where(billinginvoice.DeletedAtIsNil()) + } + + response := pagination.Result[billing.GatheringInvoice]{ + Page: input.Page, + } + + paged, err := query.Paginate(ctx, input.Page) + if err != nil { + return response, err + } + + result := make([]billing.GatheringInvoice, 0, len(paged.Items)) + for _, invoice := range paged.Items { + mapped, err := tx.mapGatheringInvoiceFromDB(ctx, invoice, input.Expand) + if err != nil { + return response, err + } + + result = append(result, mapped) + } + + response.TotalCount = paged.TotalCount + response.Items = result + + return response, nil + }) +} + +func (a *adapter) validateUpdateGatheringInvoiceRequest(req billing.GatheringInvoice, existing *db.BillingInvoice) error { + if req.Currency != existing.Currency { + return billing.ValidationError{ + Err: fmt.Errorf("currency cannot be changed"), + } + } + + if billing.InvoiceTypeStandard != existing.Type { + return billing.ValidationError{ + Err: fmt.Errorf("type cannot be changed"), + } + } + + if req.CustomerID != existing.CustomerID { + return billing.ValidationError{ + Err: fmt.Errorf("customer cannot be changed"), + } + } + + return nil +} + +func (a *adapter) DeleteGatheringInvoice(ctx context.Context, input billing.DeleteGatheringInvoiceAdapterInput) error { + if err := input.Validate(); err != nil { + return fmt.Errorf("validating delete gathering invoice input: %w", err) + } + + return entutils.TransactingRepoWithNoValue(ctx, a, func(ctx context.Context, tx *adapter) error { + invoice, err := tx.db.BillingInvoice.Query(). + Where(billinginvoice.ID(input.ID)). + Where(billinginvoice.Namespace(input.Namespace)). + Only(ctx) + if err != nil { + return err + } + + if invoice.Status != billing.StandardInvoiceStatusGathering { + return billing.ValidationError{ + Err: fmt.Errorf("invoice is not a gathering invoice [id=%s]", invoice.ID), + } + } + + if invoice.DeletedAt != nil { + return nil + } + + _, err = tx.db.BillingInvoice.Update(). + Where(billinginvoice.ID(input.ID)). + Where(billinginvoice.Namespace(input.Namespace)). + SetDeletedAt(clock.Now()). + Save(ctx) + if err != nil { + return err + } + + return nil + }) +} + +func (a *adapter) expandGatheringInvoiceLines(q *db.BillingInvoiceQuery, expand billing.GatheringInvoiceExpands) *db.BillingInvoiceQuery { + return q.WithBillingInvoiceLines(func(q *db.BillingInvoiceLineQuery) { + if !expand.Has(billing.GatheringInvoiceExpandDeletedLines) { + q = q.Where(billinginvoiceline.DeletedAtIsNil()) + } + + q. + Where(billinginvoiceline.TypeEQ(billing.InvoiceLineAdapterTypeUsageBased)). // Only include usage based lines (there are some detailed lines existing for gathering invoices) + Where(billinginvoiceline.ParentLineIDIsNil()). // Only include top-level lines (there are some detailed lines existing for gathering invoices) + WithUsageBasedLine(). + WithTaxCode() + }) +} + +func (a *adapter) GetGatheringInvoiceById(ctx context.Context, input billing.GetGatheringInvoiceByIdInput) (billing.GatheringInvoice, error) { + if err := input.Validate(); err != nil { + return billing.GatheringInvoice{}, fmt.Errorf("validating get gathering invoice by id input: %w", err) + } + + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) (billing.GatheringInvoice, error) { + query := tx.db.BillingInvoice.Query(). + Where(billinginvoice.ID(input.Invoice.ID)). + Where(billinginvoice.Namespace(input.Invoice.Namespace)) + + if input.Expand.Has(billing.GatheringInvoiceExpandLines) { + query = a.expandGatheringInvoiceLines(query, input.Expand) + } + + invoice, err := query.Only(ctx) + if err != nil { + if db.IsNotFound(err) { + return billing.GatheringInvoice{}, billing.NotFoundError{ + Err: fmt.Errorf("%w [id=%s]", billing.ErrInvoiceNotFound, input.Invoice.ID), + } + } + + return billing.GatheringInvoice{}, err + } + + return tx.mapGatheringInvoiceFromDB(ctx, invoice, input.Expand) + }) +} + +func (a *adapter) mapGatheringInvoiceFromDB(ctx context.Context, invoice *db.BillingInvoice, expand billing.GatheringInvoiceExpands) (billing.GatheringInvoice, error) { + if invoice.Status != billing.StandardInvoiceStatusGathering { + return billing.GatheringInvoice{}, fmt.Errorf("invoice is not a gathering invoice [id=%s]", invoice.ID) + } + + period := timeutil.ClosedPeriod{} + + if invoice.PeriodStart != nil && invoice.PeriodEnd != nil { + period = timeutil.ClosedPeriod{ + From: invoice.PeriodStart.In(time.UTC), + To: invoice.PeriodEnd.In(time.UTC), + } + } + + res := billing.GatheringInvoice{ + GatheringInvoiceBase: billing.GatheringInvoiceBase{ + ManagedResource: models.ManagedResource{ + NamespacedModel: models.NamespacedModel{ + Namespace: invoice.Namespace, + }, + ManagedModel: models.ManagedModel{ + CreatedAt: invoice.CreatedAt.In(time.UTC), + UpdatedAt: invoice.UpdatedAt.In(time.UTC), + DeletedAt: convert.TimePtrIn(invoice.DeletedAt, time.UTC), + }, + ID: invoice.ID, + Name: invoice.Number, + Description: invoice.Description, + }, + + Metadata: invoice.Metadata, + Number: invoice.Number, + CustomerID: invoice.CustomerID, + Currency: invoice.Currency, + ServicePeriod: period, + NextCollectionAt: convert.TimePtrIn(invoice.CollectionAt, time.UTC), + SchemaLevel: invoice.SchemaLevel, + }, + + Expands: expand, + } + + if expand.Has(billing.GatheringInvoiceExpandLines) { + mappedLines, err := a.mapGatheringInvoiceLinesFromDB(invoice.SchemaLevel, invoice.Edges.BillingInvoiceLines) + if err != nil { + return billing.GatheringInvoice{}, err + } + + if expand.Has(billing.GatheringInvoiceExpandSplitLineHierarchy) { + hierarchyByLineID, err := a.expandSplitLineHierarchy(ctx, invoice.Namespace, mappedLines.AsGenericLines()) + if err != nil { + return billing.GatheringInvoice{}, err + } + + mappedLinePtrs, err := withSplitLineHierarchyForLines(lo.Map(mappedLines, func(_ billing.GatheringLine, idx int) *billing.GatheringLine { + return &mappedLines[idx] + }), hierarchyByLineID) + if err != nil { + return billing.GatheringInvoice{}, err + } + + mappedLines = lo.Map(mappedLinePtrs, func(line *billing.GatheringLine, _ int) billing.GatheringLine { + return *line + }) + } + + res.Lines = billing.NewGatheringInvoiceLines(mappedLines) + } + + return res, nil +} diff --git a/billing/adapter/gatheringlines.go b/billing/adapter/gatheringlines.go new file mode 100644 index 0000000000000000000000000000000000000000..f7bb4a6f551ecb6dfb187fd202cbd3bf7a4c37b7 --- /dev/null +++ b/billing/adapter/gatheringlines.go @@ -0,0 +1,364 @@ +package billingadapter + +import ( + "context" + "fmt" + "time" + + "entgo.io/ent/dialect/sql" + "github.com/alpacahq/alpacadecimal" + "github.com/oklog/ulid/v2" + "github.com/samber/lo" + + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/ent/db" + "github.com/openmeterio/openmeter/openmeter/ent/db/billinginvoice" + "github.com/openmeterio/openmeter/openmeter/ent/db/billinginvoiceline" + "github.com/openmeterio/openmeter/openmeter/ent/db/billinginvoiceusagebasedlineconfig" + "github.com/openmeterio/openmeter/openmeter/productcatalog" + "github.com/openmeterio/openmeter/pkg/clock" + "github.com/openmeterio/openmeter/pkg/convert" + "github.com/openmeterio/openmeter/pkg/entitydiff" + "github.com/openmeterio/openmeter/pkg/framework/entutils" + "github.com/openmeterio/openmeter/pkg/models" + "github.com/openmeterio/openmeter/pkg/slicesx" + "github.com/openmeterio/openmeter/pkg/timeutil" +) + +func (a *adapter) HardDeleteGatheringInvoiceLines(ctx context.Context, invoiceID billing.InvoiceID, lineIDs []string) error { + if err := invoiceID.Validate(); err != nil { + return fmt.Errorf("validating invoice ID: %w", err) + } + + if len(lineIDs) == 0 { + return nil + } + + return entutils.TransactingRepoWithNoValue(ctx, a, func(ctx context.Context, tx *adapter) error { + // Let's validate the delete + invoiceHeader, err := tx.db.BillingInvoice.Query(). + Select(billinginvoice.FieldStatus, billinginvoice.FieldNamespace, billinginvoice.FieldCurrency). + Where(billinginvoice.ID(invoiceID.ID)). + Where(billinginvoice.Namespace(invoiceID.Namespace)). + Only(ctx) + if err != nil { + return err + } + + if invoiceHeader.Status != billing.StandardInvoiceStatusGathering { + return fmt.Errorf("invoice is not a gathering invoice [id=%s, namespace=%s, currency=%s]", invoiceID.ID, invoiceID.Namespace, invoiceHeader.Currency) + } + + // Let's determine the usage based line configs to delete + existingLines, err := tx.db.BillingInvoiceLine.Query(). + Where(billinginvoiceline.InvoiceID(invoiceID.ID)). + Where(billinginvoiceline.Namespace(invoiceID.Namespace)). + Where(billinginvoiceline.IDIn(lineIDs...)). + WithUsageBasedLine(). + All(ctx) + if err != nil { + return err + } + + usageBasedLineConfigIDs, err := slicesx.MapWithErr(existingLines, func(line *db.BillingInvoiceLine) (string, error) { + if line.Edges.UsageBasedLine == nil { + return "", fmt.Errorf("usage based line is missing [line_id=%s]", line.ID) + } + + return line.Edges.UsageBasedLine.ID, nil + }) + if err != nil { + return err + } + + nrDeleted, err := tx.db.BillingInvoiceLine.Delete(). + Where(billinginvoiceline.InvoiceID(invoiceID.ID)). + Where(billinginvoiceline.Namespace(invoiceID.Namespace)). + Where(billinginvoiceline.IDIn(lineIDs...)). + Exec(ctx) + if err != nil { + return err + } + + if nrDeleted != len(lineIDs) { + // Note: this causes a rollback of the transaction + return fmt.Errorf("failed to hard delete all gathering invoice lines [deleted=%d, linesToDelete=%d]", nrDeleted, len(lineIDs)) + } + + nrDeleted, err = tx.db.BillingInvoiceUsageBasedLineConfig.Delete(). + Where(billinginvoiceusagebasedlineconfig.IDIn(usageBasedLineConfigIDs...)). + Where(billinginvoiceusagebasedlineconfig.Namespace(invoiceID.Namespace)). + Exec(ctx) + if err != nil { + return err + } + + if nrDeleted != len(usageBasedLineConfigIDs) { + return fmt.Errorf("failed to hard delete all usage based line configs [deleted=%d, configsToDelete=%d]", nrDeleted, len(usageBasedLineConfigIDs)) + } + + return nil + }) +} + +type gatheringLineDiff struct { + Line entitydiff.Diff[*billing.GatheringLine] +} + +func diffGatheringInvoiceLines(lines billing.GatheringLines) (gatheringLineDiff, error) { + dbState := []*billing.GatheringLine{} + for _, line := range lines { + if line.DBState != nil { + dbState = append(dbState, line.DBState) + } + } + + linePtrs := lo.Map(lines, func(_ billing.GatheringLine, idx int) *billing.GatheringLine { + return &lines[idx] + }) + + diff := gatheringLineDiff{} + + err := entitydiff.DiffByID(entitydiff.DiffByIDInput[*billing.GatheringLine]{ + DBState: dbState, + ExpectedState: linePtrs, + HandleDelete: func(item *billing.GatheringLine) error { + diff.Line.NeedsDelete(item) + return nil + }, + HandleCreate: func(item *billing.GatheringLine) error { + diff.Line.NeedsCreate(item) + return nil + }, + HandleUpdate: func(item entitydiff.DiffUpdate[*billing.GatheringLine]) error { + diff.Line.NeedsUpdate(item) + return nil + }, + }) + if err != nil { + return gatheringLineDiff{}, err + } + + return diff, nil +} + +func (a *adapter) updateGatheringLines(ctx context.Context, lines billing.GatheringLines) error { + diff, err := diffGatheringInvoiceLines(lines) + if err != nil { + return err + } + + err = upsertWithOptions(ctx, a.db, diff.Line, upsertInput[*billing.GatheringLine, *db.BillingInvoiceUsageBasedLineConfigCreate]{ + Create: func(tx *db.Client, line *billing.GatheringLine) (*db.BillingInvoiceUsageBasedLineConfigCreate, error) { + if line.UBPConfigID == "" { + line.UBPConfigID = ulid.Make().String() + } + + create := tx.BillingInvoiceUsageBasedLineConfig.Create(). + SetNamespace(line.Namespace). + SetPriceType(line.Price.Type()). + SetPrice(lo.ToPtr(line.Price)). + SetFeatureKey(line.FeatureKey). + SetID(line.UBPConfigID) + + // unit_config is the rate card's unit_config snapshotted onto the gathering line. + // Like price it is mutable: UpdateUnitConfig in the conflict clause below resolves + // this column per row, so a re-sync writes the current config and a dropped config + // clears the stale snapshot (excluded.unit_config defaults to NULL when the row + // omits it). Left unset (not set to nil) on create so a fresh non-unit_config line + // stores SQL NULL rather than a JSON "null" literal. Gathering lines are + // pre-finalization, so there is no write-once concern here. + if line.UnitConfig != nil { + create = create.SetUnitConfig(line.UnitConfig) + } + + return create, nil + }, + UpsertItems: func(ctx context.Context, tx *db.Client, items []*db.BillingInvoiceUsageBasedLineConfigCreate) error { + return tx.BillingInvoiceUsageBasedLineConfig. + CreateBulk(items...). + OnConflict( + sql.ConflictColumns(billinginvoiceusagebasedlineconfig.FieldID), + sql.ResolveWithNewValues(), + ). + UpdateUnitConfig(). + Exec(ctx) + }, + }) + if err != nil { + return fmt.Errorf("creating usage based line configs: %w", err) + } + + invoiceLineUpsertConfig := upsertInput[*billing.GatheringLine, *db.BillingInvoiceLineCreate]{ + Create: func(tx *db.Client, line *billing.GatheringLine) (*db.BillingInvoiceLineCreate, error) { + if line.ID == "" { + line.ID = ulid.Make().String() + } + + create := tx.BillingInvoiceLine.Create(). + SetID(line.ID). + SetNamespace(line.Namespace). + SetInvoiceID(line.InvoiceID). + SetPeriodStart(line.ServicePeriod.From.In(time.UTC)). + SetPeriodEnd(line.ServicePeriod.To.In(time.UTC)). + SetNillableSplitLineGroupID(line.SplitLineGroupID). + SetNillableChargeID(line.ChargeID). + SetNillableDeletedAt(line.DeletedAt). + SetInvoiceAt(line.InvoiceAt.In(time.UTC)). + SetStatus(billing.InvoiceLineStatusValid). + SetManagedBy(line.ManagedBy). + SetEngine(line.Engine). + SetType(billing.InvoiceLineAdapterTypeUsageBased). + SetName(line.Name). + SetNillableDescription(line.Description). + SetCurrency(line.Currency). + SetMetadata(line.Metadata). + SetAnnotations(line.Annotations). + SetNillableChildUniqueReferenceID(line.ChildUniqueReferenceID). + // Totals + SetAmount(alpacadecimal.Zero). + SetChargesTotal(alpacadecimal.Zero). + SetCreditsTotal(alpacadecimal.Zero). + SetDiscountsTotal(alpacadecimal.Zero). + SetTaxesTotal(alpacadecimal.Zero). + SetTaxesInclusiveTotal(alpacadecimal.Zero). + SetTaxesExclusiveTotal(alpacadecimal.Zero). + SetTotal(alpacadecimal.Zero) + + if line.Subscription != nil { + create = create.SetSubscriptionID(line.Subscription.SubscriptionID). + SetSubscriptionPhaseID(line.Subscription.PhaseID). + SetSubscriptionItemID(line.Subscription.ItemID). + SetSubscriptionBillingPeriodFrom(line.Subscription.BillingPeriod.From.In(time.UTC)). + SetSubscriptionBillingPeriodTo(line.Subscription.BillingPeriod.To.In(time.UTC)) + } + + if line.TaxConfig != nil { + create = create.SetNillableTaxConfig(billing.FromProductCatalog(line.TaxConfig)). + SetNillableTaxCodeID(line.TaxConfig.TaxCodeID). + SetNillableTaxBehavior(line.TaxConfig.Behavior) + } + + if !line.RateCardDiscounts.IsEmpty() { + create = create.SetRatecardDiscounts(lo.ToPtr(line.RateCardDiscounts)) + } + + create = create. + SetUsageBasedLineID(line.UBPConfigID) + + return create, nil + }, + UpsertItems: func(ctx context.Context, tx *db.Client, items []*db.BillingInvoiceLineCreate) error { + return tx.BillingInvoiceLine. + CreateBulk(items...). + OnConflict(sql.ConflictColumns(billinginvoiceline.FieldID), + sql.ResolveWithNewValues(), + sql.ResolveWith(func(u *sql.UpdateSet) { + u.SetIgnore(billinginvoiceline.FieldCreatedAt) + })). + UpdateChildUniqueReferenceID(). + UpdateTaxConfig(). + UpdateTaxCodeID(). + UpdateTaxBehavior(). + UpdateDescription(). + UpdateRatecardDiscounts(). + Exec(ctx) + }, + MarkDeleted: func(ctx context.Context, line *billing.GatheringLine) (*billing.GatheringLine, error) { + line.DeletedAt = lo.ToPtr(clock.Now().In(time.UTC)) + return line, nil + }, + } + + if err := upsertWithOptions(ctx, a.db, diff.Line, invoiceLineUpsertConfig); err != nil { + return fmt.Errorf("creating lines: %w", err) + } + + return nil +} + +func (a *adapter) mapGatheringInvoiceLinesFromDB(schemaLevel int, dbLines []*db.BillingInvoiceLine) (billing.GatheringLines, error) { + return slicesx.MapWithErr(dbLines, func(dbLine *db.BillingInvoiceLine) (billing.GatheringLine, error) { + return a.mapGatheringInvoiceLineFromDB(schemaLevel, dbLine) + }) +} + +func (a *adapter) mapGatheringInvoiceLineFromDB(schemaLevel int, dbLine *db.BillingInvoiceLine) (billing.GatheringLine, error) { + if dbLine.Type != billing.InvoiceLineAdapterTypeUsageBased { + return billing.GatheringLine{}, fmt.Errorf("only usage based lines can be gathering invoice lines [line_id=%s]", dbLine.ID) + } + + ubpLine := dbLine.Edges.UsageBasedLine + if ubpLine == nil { + return billing.GatheringLine{}, fmt.Errorf("usage based line data is missing [line_id=%s]", dbLine.ID) + } + + line := billing.GatheringLine{ + GatheringLineBase: billing.GatheringLineBase{ + ManagedResource: models.NewManagedResource(models.ManagedResourceInput{ + Namespace: dbLine.Namespace, + ID: dbLine.ID, + CreatedAt: dbLine.CreatedAt.In(time.UTC), + UpdatedAt: dbLine.UpdatedAt.In(time.UTC), + DeletedAt: convert.TimePtrIn(dbLine.DeletedAt, time.UTC), + Name: dbLine.Name, + Description: dbLine.Description, + }), + + Metadata: dbLine.Metadata, + Annotations: dbLine.Annotations, + InvoiceID: dbLine.InvoiceID, + ManagedBy: dbLine.ManagedBy, + Engine: dbLine.Engine, + + ServicePeriod: timeutil.ClosedPeriod{ + From: dbLine.PeriodStart.In(time.UTC), + To: dbLine.PeriodEnd.In(time.UTC), + }, + + SplitLineGroupID: dbLine.SplitLineGroupID, + ChargeID: dbLine.ChargeID, + ChildUniqueReferenceID: dbLine.ChildUniqueReferenceID, + + InvoiceAt: dbLine.InvoiceAt.In(time.UTC), + + Currency: dbLine.Currency, + + TaxConfig: productcatalog.BackfillTaxConfig( + lo.EmptyableToPtr(dbLine.TaxConfig).ToProductCatalog(), + dbLine.TaxBehavior, + taxCodeFromInvoiceLineEdge(dbLine), + ), + RateCardDiscounts: lo.FromPtr(dbLine.RatecardDiscounts), + + UBPConfigID: ubpLine.ID, + FeatureKey: lo.FromPtr(ubpLine.FeatureKey), + Price: lo.FromPtr(ubpLine.Price), + UnitConfig: ubpLine.UnitConfig, + }, + } + + if dbLine.SubscriptionID != nil && dbLine.SubscriptionPhaseID != nil && dbLine.SubscriptionItemID != nil { + line.Subscription = &billing.SubscriptionReference{ + SubscriptionID: *dbLine.SubscriptionID, + PhaseID: *dbLine.SubscriptionPhaseID, + ItemID: *dbLine.SubscriptionItemID, + } + if dbLine.SubscriptionBillingPeriodFrom != nil && + dbLine.SubscriptionBillingPeriodTo != nil { + line.Subscription.BillingPeriod = timeutil.ClosedPeriod{ + From: dbLine.SubscriptionBillingPeriodFrom.In(time.UTC), + To: dbLine.SubscriptionBillingPeriodTo.In(time.UTC), + } + } + } + + cloned, err := line.WithoutDBState() + if err != nil { + return billing.GatheringLine{}, fmt.Errorf("cloning line: %w", err) + } + + line.DBState = lo.ToPtr(cloned) + + return line, nil +} diff --git a/billing/adapter/invoice.go b/billing/adapter/invoice.go new file mode 100644 index 0000000000000000000000000000000000000000..cae5fd8c43b7b1aa5906e59630950137c98027b8 --- /dev/null +++ b/billing/adapter/invoice.go @@ -0,0 +1,902 @@ +package billingadapter + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "entgo.io/ent/dialect/sql" + "github.com/samber/lo" + + "github.com/openmeterio/openmeter/api" + "github.com/openmeterio/openmeter/openmeter/app" + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/billing/models/externalid" + "github.com/openmeterio/openmeter/openmeter/billing/models/totals" + "github.com/openmeterio/openmeter/openmeter/customer" + "github.com/openmeterio/openmeter/openmeter/ent/db" + "github.com/openmeterio/openmeter/openmeter/ent/db/billinginvoice" + "github.com/openmeterio/openmeter/openmeter/ent/db/billinginvoiceline" + "github.com/openmeterio/openmeter/openmeter/ent/db/billinginvoicevalidationissue" + "github.com/openmeterio/openmeter/openmeter/ent/db/predicate" + "github.com/openmeterio/openmeter/openmeter/streaming" + "github.com/openmeterio/openmeter/pkg/clock" + "github.com/openmeterio/openmeter/pkg/convert" + "github.com/openmeterio/openmeter/pkg/filter" + "github.com/openmeterio/openmeter/pkg/framework/entutils" + "github.com/openmeterio/openmeter/pkg/models" + "github.com/openmeterio/openmeter/pkg/pagination" + "github.com/openmeterio/openmeter/pkg/sortx" + "github.com/openmeterio/openmeter/pkg/timeutil" +) + +var _ billing.InvoiceAdapter = (*adapter)(nil) + +func (a *adapter) GetStandardInvoiceById(ctx context.Context, in billing.GetStandardInvoiceByIdInput) (billing.StandardInvoice, error) { + if err := in.Validate(); err != nil { + return billing.StandardInvoice{}, billing.ValidationError{ + Err: err, + } + } + + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) (billing.StandardInvoice, error) { + query := tx.db.BillingInvoice.Query(). + Where(billinginvoice.ID(in.Invoice.ID)). + Where(billinginvoice.Namespace(in.Invoice.Namespace)). + Where(billinginvoice.StatusNEQ(billing.StandardInvoiceStatusGathering)). + WithBillingInvoiceValidationIssues(func(q *db.BillingInvoiceValidationIssueQuery) { + q.Where(billinginvoicevalidationissue.DeletedAtIsNil()) + }). + WithBillingWorkflowConfig(workflowConfigWithTaxCode) + + if in.Expand.Has(billing.StandardInvoiceExpandLines) { + query = tx.expandInvoiceLineItems(query, in.Expand) + } + + invoice, err := query.Only(ctx) + if err != nil { + if db.IsNotFound(err) { + return billing.StandardInvoice{}, billing.NotFoundError{ + Err: fmt.Errorf("%w [id=%s]", billing.ErrInvoiceNotFound, in.Invoice.ID), + } + } + + return billing.StandardInvoice{}, err + } + + return tx.mapStandardInvoiceFromDB(ctx, invoice, in.Expand) + }) +} + +func (a *adapter) expandInvoiceLineItems(query *db.BillingInvoiceQuery, expand billing.StandardInvoiceExpands) *db.BillingInvoiceQuery { + return query.WithBillingInvoiceLines(func(q *db.BillingInvoiceLineQuery) { + if !expand.Has(billing.StandardInvoiceExpandDeletedLines) { + q = q.Where(billinginvoiceline.DeletedAtIsNil()) + } + + requestedStatuses := []billing.InvoiceLineStatus{billing.InvoiceLineStatusValid} + + q = q.Where( + // Detailed lines are sub-lines of a line and should not be included in the top-level invoice + billinginvoiceline.StatusIn(requestedStatuses...), + ) + + a.expandLineItemsWithDetailedLines(q) + }) +} + +func (a *adapter) DeleteGatheringInvoices(ctx context.Context, input billing.DeleteGatheringInvoicesInput) error { + if err := input.Validate(); err != nil { + return billing.ValidationError{ + Err: err, + } + } + + return entutils.TransactingRepoWithNoValue(ctx, a, func(ctx context.Context, tx *adapter) error { + nAffected, err := tx.db.BillingInvoice.Update(). + Where(billinginvoice.IDIn(input.InvoiceIDs...)). + Where(billinginvoice.Namespace(input.Namespace)). + Where(billinginvoice.StatusEQ(billing.StandardInvoiceStatusGathering)). + ClearPeriodStart(). + ClearPeriodEnd(). + SetDeletedAt(clock.Now()). + Save(ctx) + if err != nil { + return err + } + + if nAffected != len(input.InvoiceIDs) { + return billing.ValidationError{ + Err: errors.New("invoices failed to delete"), + } + } + + return nil + }) +} + +func (a *adapter) ListInvoices(ctx context.Context, input billing.ListInvoicesAdapterInput) (billing.ListInvoicesResponse, error) { + if err := input.Validate(); err != nil { + return billing.ListInvoicesResponse{}, billing.ValidationError{ + Err: err, + } + } + + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) (billing.ListInvoicesResponse, error) { + // Note: we are not filtering for deleted invoices here (as in deleted_at is not nil), as we have the deleted + // status that we can use to filter for. + + query := tx.db.BillingInvoice.Query(). + WithBillingInvoiceValidationIssues(func(q *db.BillingInvoiceValidationIssueQuery) { + q.Where(billinginvoicevalidationissue.DeletedAtIsNil()) + }). + WithBillingWorkflowConfig(workflowConfigWithTaxCode) + + if len(input.Namespaces) > 0 { + query = query.Where(billinginvoice.NamespaceIn(input.Namespaces...)) + } + + query = filter.ApplyToQuery(query, input.CustomerID, billinginvoice.FieldCustomerID) + query = filter.ApplyToQuery(query, input.IssuedAt, billinginvoice.FieldIssuedAt) + query = filter.ApplyToQuery(query, input.PeriodStart, billinginvoice.FieldPeriodStart) + query = filter.ApplyToQuery(query, input.CreatedAt, billinginvoice.FieldCreatedAt) + + if len(input.IDs) > 0 { + query = query.Where(billinginvoice.IDIn(input.IDs...)) + } + + if !input.IncludeDeleted { + query = query.Where(billinginvoice.DeletedAtIsNil()) + } + + if input.OnlyGathering { + query = query.Where(billinginvoice.StatusEQ(billing.StandardInvoiceStatusGathering)) + } + + if input.OnlyStandard { + query = query.Where(billinginvoice.StatusNEQ(billing.StandardInvoiceStatusGathering)) + } + + if len(input.Statuses) > 0 { + query = query.Where(func(s *sql.Selector) { + s.Where(sql.Or( + lo.Map(input.Statuses, func(status string, _ int) *sql.Predicate { + return sql.Like(billinginvoice.FieldStatus, status+"%") + })..., + )) + }) + } + + if len(input.ExtendedStatuses) > 0 { + query = query.Where(billinginvoice.StatusIn(input.ExtendedStatuses...)) + } + + if input.DraftUntilLTE != nil { + query = query.Where(billinginvoice.DraftUntilLTE(*input.DraftUntilLTE)) + } + + if input.CollectionAtLTE != nil { + query = query.Where(billinginvoice.Or( + billinginvoice.CollectionAtLTE(*input.CollectionAtLTE), + billinginvoice.CollectionAtIsNil(), + )) + } + + if len(input.HasAvailableAction) > 0 { + query = query.Where( + billinginvoice.Or( + lo.Map( + input.HasAvailableAction, + func(action billing.InvoiceAvailableActionsFilter, _ int) predicate.BillingInvoice { + return entutils.JSONBKeyExistsInObject(billinginvoice.FieldStatusDetailsCache, "availableActions", string(action)) + }, + )..., + ), + ) + } + + if input.ExternalIDs != nil { + switch input.ExternalIDs.Type { + case billing.InvoicingExternalIDType: + query = query.Where(billinginvoice.InvoicingAppExternalIDIn(input.ExternalIDs.IDs...)) + case billing.PaymentExternalIDType: + query = query.Where(billinginvoice.PaymentAppExternalIDIn(input.ExternalIDs.IDs...)) + case billing.TaxExternalIDType: + query = query.Where(billinginvoice.TaxAppExternalIDIn(input.ExternalIDs.IDs...)) + } + } + + order := entutils.GetOrdering(sortx.OrderDefault) + if !input.Order.IsDefaultValue() { + order = entutils.GetOrdering(input.Order) + } + + if input.Expand.Has(billing.InvoiceExpandLines) { + query = tx.expandInvoiceLineItems(query, billing. + StandardInvoiceExpands{billing.StandardInvoiceExpandLines}. + SetOrUnsetIf(input.Expand.Has(billing.InvoiceExpandDeletedLines), billing.StandardInvoiceExpandDeletedLines)) + } + + switch input.OrderBy { + case api.InvoiceOrderByCustomerName: + query = query.Order(billinginvoice.ByCustomerName(order...)) + case api.InvoiceOrderByIssuedAt: + query = query.Order(billinginvoice.ByIssuedAt(order...)) + case api.InvoiceOrderByPeriodStart: + query = query.Order(billinginvoice.ByPeriodStart(order...)) + case api.InvoiceOrderByStatus: + query = query.Order(billinginvoice.ByStatus(order...)) + case api.InvoiceOrderByUpdatedAt: + query = query.Order(billinginvoice.ByUpdatedAt(order...)) + case api.InvoiceOrderByCreatedAt: + fallthrough + default: + query = query.Order(billinginvoice.ByCreatedAt(order...)) + } + + response := pagination.Result[billing.Invoice]{ + Page: input.Page, + } + + paged, err := query.Paginate(ctx, input.Page) + if err != nil { + return response, err + } + + result := make([]billing.Invoice, 0, len(paged.Items)) + for _, invoice := range paged.Items { + switch invoice.Status { + case billing.StandardInvoiceStatusGathering: + mapped, err := tx.mapGatheringInvoiceFromDB(ctx, invoice, billing.GatheringInvoiceExpands{}. + SetOrUnsetIf(input.Expand.Has(billing.InvoiceExpandLines), billing.GatheringInvoiceExpandLines). + SetOrUnsetIf(input.Expand.Has(billing.InvoiceExpandDeletedLines), billing.GatheringInvoiceExpandDeletedLines), + ) + if err != nil { + return response, err + } + result = append(result, billing.NewInvoice(mapped)) + default: + mapped, err := tx.mapStandardInvoiceFromDB(ctx, invoice, billing.StandardInvoiceExpands{}. + SetOrUnsetIf(input.Expand.Has(billing.InvoiceExpandLines), billing.StandardInvoiceExpandLines). + SetOrUnsetIf(input.Expand.Has(billing.InvoiceExpandDeletedLines), billing.StandardInvoiceExpandDeletedLines), + ) + if err != nil { + return response, err + } + + result = append(result, billing.NewInvoice(mapped)) + } + } + + response.TotalCount = paged.TotalCount + response.Items = result + + return response, nil + }) +} + +func (a *adapter) CreateInvoice(ctx context.Context, input billing.CreateInvoiceAdapterInput) (billing.CreateInvoiceAdapterRespone, error) { + if err := input.Validate(); err != nil { + return billing.CreateInvoiceAdapterRespone{}, err + } + + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) (billing.CreateInvoiceAdapterRespone, error) { + customer := input.Customer + supplier := input.Profile.Supplier + + // Clone the workflow config + clonedWorkflowConfig, err := tx.createWorkflowConfig(ctx, input.Namespace, input.Profile.WorkflowConfig) + if err != nil { + return billing.CreateInvoiceAdapterRespone{}, fmt.Errorf("clone workflow config: %w", err) + } + + currentSchemaLevel, err := tx.GetInvoiceDefaultSchemaLevel(ctx) + if err != nil { + return billing.CreateInvoiceAdapterRespone{}, fmt.Errorf("get invoice write schema level: %w", err) + } + + createMut := tx.db.BillingInvoice.Create(). + SetNamespace(input.Namespace). + SetMetadata(input.Metadata). + SetCurrency(input.Currency). + SetStatus(input.Status). + SetSourceBillingProfileID(input.Profile.ID). + SetType(input.Type). + SetNumber(input.Number). + SetNillableDescription(input.Description). + SetNillableDueAt(input.DueAt). + SetNillableIssuedAt(lo.EmptyableToPtr(input.IssuedAt)). + // Customer snapshot about usage attribution fields + SetCustomerID(input.Customer.ID). + SetNillableCustomerKey(input.Customer.Key). + // Workflow (cloned) + SetBillingWorkflowConfigID(clonedWorkflowConfig.ID). + // TODO[later]: By cloning the AppIDs here we could support changing the apps in the billing profile if needed + SetTaxAppID(input.Profile.Apps.Tax.GetID().ID). + SetInvoicingAppID(input.Profile.Apps.Invoicing.GetID().ID). + SetPaymentAppID(input.Profile.Apps.Payment.GetID().ID). + // Supplier contacts + SetNillableSupplierAddressCountry(supplier.Address.Country). + SetNillableSupplierAddressPostalCode(supplier.Address.PostalCode). + SetNillableSupplierAddressState(supplier.Address.State). + SetNillableSupplierAddressCity(supplier.Address.City). + SetNillableSupplierAddressLine1(supplier.Address.Line1). + SetNillableSupplierAddressLine2(supplier.Address.Line2). + SetNillableSupplierAddressPhoneNumber(supplier.Address.PhoneNumber). + SetSupplierName(supplier.Name). + SetNillableSupplierTaxCode(supplier.TaxCode). + SetNillableCollectionAt(normalizeOptionalTime(input.CollectionAt)). + SetSchemaLevel(currentSchemaLevel) + + createMut = totals.Set(createMut, input.Totals) + + if customer.BillingAddress != nil { + createMut = createMut. + // Customer contacts + SetNillableCustomerAddressCountry(customer.BillingAddress.Country). + SetNillableCustomerAddressPostalCode(customer.BillingAddress.PostalCode). + SetNillableCustomerAddressState(customer.BillingAddress.State). + SetNillableCustomerAddressCity(customer.BillingAddress.City). + SetNillableCustomerAddressLine1(customer.BillingAddress.Line1). + SetNillableCustomerAddressLine2(customer.BillingAddress.Line2). + SetNillableCustomerAddressPhoneNumber(customer.BillingAddress.PhoneNumber) + } + if usageAttr := mapCustomerUsageAttributionToDB(input.Customer); usageAttr != nil { + createMut = createMut.SetCustomerUsageAttribution(usageAttr) + } + createMut = createMut. + SetCustomerName(customer.Name) + + newInvoice, err := createMut.Save(ctx) + if err != nil { + return billing.CreateInvoiceAdapterRespone{}, err + } + + // Let's add required edges for mapping + newInvoice.Edges.BillingWorkflowConfig = clonedWorkflowConfig + + return tx.mapStandardInvoiceFromDB(ctx, newInvoice, billing.StandardInvoiceExpandAll) + }) +} + +type lineCountQueryOut struct { + InvoiceID string `json:"invoice_id"` + Count int64 `json:"count"` +} + +func (a *adapter) AssociatedLineCounts(ctx context.Context, input billing.AssociatedLineCountsAdapterInput) (billing.AssociatedLineCountsAdapterResponse, error) { + queryOut := []lineCountQueryOut{} + + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) (billing.AssociatedLineCountsAdapterResponse, error) { + err := tx.db.BillingInvoiceLine.Query(). + Where(billinginvoiceline.DeletedAtIsNil()). + Where(billinginvoiceline.Namespace(input.Namespace)). + Where(billinginvoiceline.InvoiceIDIn(input.InvoiceIDs...)). + Where(billinginvoiceline.StatusIn(billing.InvoiceLineStatusValid)). + GroupBy(billinginvoiceline.FieldInvoiceID). + Aggregate( + db.Count(), + ). + Scan(ctx, &queryOut) + if err != nil { + return billing.AssociatedLineCountsAdapterResponse{}, err + } + + res := lo.Associate(queryOut, func(q lineCountQueryOut) (billing.InvoiceID, int64) { + return billing.InvoiceID{ + Namespace: input.Namespace, + ID: q.InvoiceID, + }, q.Count + }) + + for _, invoiceID := range input.InvoiceIDs { + id := billing.InvoiceID{ + Namespace: input.Namespace, + ID: invoiceID, + } + if _, found := res[id]; !found { + res[id] = 0 + } + } + + return billing.AssociatedLineCountsAdapterResponse{ + Counts: res, + }, nil + }) +} + +func (a *adapter) validateUpdateRequest(req billing.UpdateStandardInvoiceAdapterInput, existing *db.BillingInvoice) error { + if req.Currency != existing.Currency { + return billing.ValidationError{ + Err: fmt.Errorf("currency cannot be changed"), + } + } + + if req.Type != existing.Type { + return billing.ValidationError{ + Err: fmt.Errorf("type cannot be changed"), + } + } + + if req.Customer.CustomerID != existing.CustomerID { + return billing.ValidationError{ + Err: fmt.Errorf("customer cannot be changed"), + } + } + + return nil +} + +// UpdateInvoice updates the specified invoice. +func (a *adapter) UpdateStandardInvoice(ctx context.Context, in billing.UpdateStandardInvoiceAdapterInput) (billing.StandardInvoice, error) { + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) (billing.StandardInvoice, error) { + existingInvoice, err := tx.db.BillingInvoice.Query(). + Where(billinginvoice.ID(in.ID)). + Where(billinginvoice.Namespace(in.Namespace)). + WithBillingWorkflowConfig(workflowConfigWithTaxCode). + Only(ctx) + if err != nil { + return in, err + } + + if err := tx.validateUpdateRequest(in, existingInvoice); err != nil { + return in, err + } + + updateQuery := tx.db.BillingInvoice.UpdateOneID(in.ID). + Where(billinginvoice.Namespace(in.Namespace)). + SetMetadata(in.Metadata). + // Currency is immutable + SetStatus(in.Status). + SetOrClearStatusDetailsCache(lo.EmptyableToPtr(in.StatusDetails)). + // Type is immutable + SetNumber(in.Number). + SetOrClearDescription(in.Description). + SetOrClearDueAt(convert.SafeToUTC(in.DueAt)). + SetOrClearCollectionAt(normalizeOptionalTime(in.CollectionAt)). + SetOrClearPaymentProcessingEnteredAt(convert.SafeToUTC(in.PaymentProcessingEnteredAt)). + SetOrClearDraftUntil(convert.SafeToUTC(in.DraftUntil)). + SetOrClearIssuedAt(convert.SafeToUTC(in.IssuedAt)). + SetOrClearDeletedAt(convert.SafeToUTC(in.DeletedAt)). + SetOrClearDeletionSource(lo.EmptyableToPtr(in.DeletionSource)). + SetOrClearSentToCustomerAt(convert.SafeToUTC(in.SentToCustomerAt)). + SetOrClearQuantitySnapshotedAt(convert.SafeToUTC(in.QuantitySnapshotedAt)) + + updateQuery = totals.Set(updateQuery, in.Totals) + + if in.Period != nil { + updateQuery = updateQuery. + SetPeriodStart(in.Period.From.In(time.UTC)). + SetPeriodEnd(in.Period.To.In(time.UTC)) + } else { + updateQuery = updateQuery. + ClearPeriodStart(). + ClearPeriodEnd() + } + + // Supplier + updateQuery = updateQuery. + SetSupplierName(in.Supplier.Name). + SetOrClearSupplierAddressCountry(in.Supplier.Address.Country). + SetOrClearSupplierAddressPostalCode(in.Supplier.Address.PostalCode). + SetOrClearSupplierAddressCity(in.Supplier.Address.City). + SetOrClearSupplierAddressState(in.Supplier.Address.State). + SetOrClearSupplierAddressLine1(in.Supplier.Address.Line1). + SetOrClearSupplierAddressLine2(in.Supplier.Address.Line2). + SetOrClearSupplierAddressPhoneNumber(in.Supplier.Address.PhoneNumber) + + // Customer + updateQuery = updateQuery. + // CustomerID is immutable + SetCustomerName(in.Customer.Name) + + if in.Customer.Key != nil { + updateQuery = updateQuery.SetCustomerKey(*in.Customer.Key) + } else { + updateQuery = updateQuery.ClearCustomerKey() + } + + if in.Customer.BillingAddress != nil { + updateQuery = updateQuery. + SetOrClearCustomerAddressCountry(in.Customer.BillingAddress.Country). + SetOrClearCustomerAddressPostalCode(in.Customer.BillingAddress.PostalCode). + SetOrClearCustomerAddressCity(in.Customer.BillingAddress.City). + SetOrClearCustomerAddressState(in.Customer.BillingAddress.State). + SetOrClearCustomerAddressLine1(in.Customer.BillingAddress.Line1). + SetOrClearCustomerAddressLine2(in.Customer.BillingAddress.Line2). + SetOrClearCustomerAddressPhoneNumber(in.Customer.BillingAddress.PhoneNumber) + } else { + updateQuery = updateQuery. + ClearCustomerAddressCountry(). + ClearCustomerAddressPostalCode(). + ClearCustomerAddressCity(). + ClearCustomerAddressState(). + ClearCustomerAddressLine1(). + ClearCustomerAddressLine2(). + ClearCustomerAddressPhoneNumber() + } + + updateQuery = externalid.UpdateInvoiceExternalID(updateQuery, in.ExternalIDs) + + _, err = updateQuery.Save(ctx) + if err != nil { + return in, err + } + + err = tx.persistValidationIssues(ctx, + billing.InvoiceID{ + Namespace: in.Namespace, + ID: in.ID, + }, in.ValidationIssues) + if err != nil { + return in, err + } + + // Update the workflow config + _, err = tx.updateWorkflowConfig(ctx, in.Namespace, existingInvoice.Edges.BillingWorkflowConfig.ID, in.Workflow.Config) + if err != nil { + return in, err + } + + updatedLines := billing.StandardInvoiceLines{} + if in.Lines.IsPresent() { + // Note: this only supports adding new lines or setting the DeletedAt field + // we don't support moving lines between invoices here, as the cross invoice + // coordination is not something the adapter should deal with. The service + // is needed to lock and recalculate both invoices or do the necessary splits. + + lines, err := tx.UpsertInvoiceLines(ctx, billing.UpsertInvoiceLinesAdapterInput{ + Namespace: in.Namespace, + Lines: in.Lines.OrEmpty(), + SchemaLevel: in.SchemaLevel, + InvoiceID: in.ID, + }) + if err != nil { + return in, err + } + + updatedLines = billing.NewStandardInvoiceLines(lines) + } + + // If we had just updated the lines, let's reuse that result, as it's quite an expensive operation + // to look up the lines again. + if in.ExpandedFields.Has(billing.StandardInvoiceExpandLines) && updatedLines.IsPresent() { + updatedInvoice, err := tx.GetStandardInvoiceById(ctx, billing.GetStandardInvoiceByIdInput{ + Invoice: billing.InvoiceID{ + Namespace: in.Namespace, + ID: in.ID, + }, + Expand: in.ExpandedFields.Without(billing.StandardInvoiceExpandLines), + }) + if err != nil { + return in, err + } + + updatedInvoice.Lines = updatedLines + // Let's make sure that subsequent calls preserve the same expansion settings + updatedInvoice.ExpandedFields = in.ExpandedFields + + return updatedInvoice, nil + } + + return tx.GetStandardInvoiceById(ctx, billing.GetStandardInvoiceByIdInput{ + Invoice: billing.InvoiceID{ + Namespace: in.Namespace, + ID: in.ID, + }, + Expand: in.ExpandedFields, + }) + }) +} + +func (a *adapter) GetInvoiceOwnership(ctx context.Context, in billing.GetInvoiceOwnershipAdapterInput) (billing.GetOwnershipAdapterResponse, error) { + if err := in.Validate(); err != nil { + return billing.GetOwnershipAdapterResponse{}, billing.ValidationError{ + Err: err, + } + } + + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) (billing.GetOwnershipAdapterResponse, error) { + dbInvoice, err := tx.db.BillingInvoice.Query(). + Where(billinginvoice.ID(in.ID)). + Where(billinginvoice.Namespace(in.Namespace)). + First(ctx) + if err != nil { + if db.IsNotFound(err) { + return billing.GetOwnershipAdapterResponse{}, billing.NotFoundError{ + Entity: billing.EntityInvoice, + ID: in.ID, + Err: err, + } + } + + return billing.GetOwnershipAdapterResponse{}, err + } + + return billing.GetOwnershipAdapterResponse{ + Namespace: dbInvoice.Namespace, + InvoiceID: dbInvoice.ID, + CustomerID: dbInvoice.CustomerID, + }, nil + }) +} + +func (a *adapter) mapStandardInvoiceBaseFromDB(invoice *db.BillingInvoice) billing.StandardInvoiceBase { + return billing.StandardInvoiceBase{ + ID: invoice.ID, + Namespace: invoice.Namespace, + Metadata: invoice.Metadata, + Currency: invoice.Currency, + Status: invoice.Status, + StatusDetails: invoice.StatusDetailsCache, + Type: invoice.Type, + Number: invoice.Number, + Description: invoice.Description, + DueAt: convert.TimePtrIn(invoice.DueAt, time.UTC), + DraftUntil: convert.TimePtrIn(invoice.DraftUntil, time.UTC), + SentToCustomerAt: convert.TimePtrIn(invoice.SentToCustomerAt, time.UTC), + QuantitySnapshotedAt: convert.TimePtrIn(invoice.QuantitySnapshotedAt, time.UTC), + Supplier: billing.SupplierContact{ + Name: invoice.SupplierName, + Address: models.Address{ + Country: invoice.SupplierAddressCountry, + PostalCode: invoice.SupplierAddressPostalCode, + City: invoice.SupplierAddressCity, + State: invoice.SupplierAddressState, + Line1: invoice.SupplierAddressLine1, + Line2: invoice.SupplierAddressLine2, + PhoneNumber: invoice.SupplierAddressPhoneNumber, + }, + TaxCode: invoice.SupplierTaxCode, + }, + + Customer: billing.InvoiceCustomer{ + Key: invoice.CustomerKey, + CustomerID: invoice.CustomerID, + Name: invoice.CustomerName, + BillingAddress: &models.Address{ + Country: invoice.CustomerAddressCountry, + PostalCode: invoice.CustomerAddressPostalCode, + City: invoice.CustomerAddressCity, + State: invoice.CustomerAddressState, + Line1: invoice.CustomerAddressLine1, + Line2: invoice.CustomerAddressLine2, + PhoneNumber: invoice.CustomerAddressPhoneNumber, + }, + UsageAttribution: mapCustomerUsageAttributionFromDB(invoice.CustomerID, invoice.CustomerKey, invoice.CustomerUsageAttribution), + }, + Period: mapPeriodFromDB(invoice.PeriodStart, invoice.PeriodEnd), + IssuedAt: convert.TimePtrIn(invoice.IssuedAt, time.UTC), + CreatedAt: invoice.CreatedAt.In(time.UTC), + UpdatedAt: invoice.UpdatedAt.In(time.UTC), + DeletedAt: convert.TimePtrIn(invoice.DeletedAt, time.UTC), + DeletionSource: lo.FromPtr(invoice.DeletionSource), + + CollectionAt: normalizeOptionalTime(invoice.CollectionAt), + PaymentProcessingEnteredAt: convert.TimePtrIn(invoice.PaymentProcessingEnteredAt, time.UTC), + + ExternalIDs: externalid.MapInvoiceExternalIDFromDB(invoice), + + SchemaLevel: invoice.SchemaLevel, + } +} + +func normalizeOptionalTime(t *time.Time) *time.Time { + // collection_at was historically stored through a non-nillable Ent field, so older rows + // can rehydrate as zero time instead of nil. Normalize that legacy representation here so + // nil consistently means "no collection required" in the domain model. + if t == nil || t.IsZero() { + return nil + } + + normalized := t.In(time.UTC) + + return &normalized +} + +func (a *adapter) mapStandardInvoiceFromDB(ctx context.Context, invoice *db.BillingInvoice, expand billing.StandardInvoiceExpands) (billing.StandardInvoice, error) { + base := a.mapStandardInvoiceBaseFromDB(invoice) + + res := billing.StandardInvoice{ + StandardInvoiceBase: base, + + Totals: totals.FromDB(invoice), + + ExpandedFields: expand, + } + + workflowConfig, err := mapWorkflowConfigFromDB(invoice.Edges.BillingWorkflowConfig) + if err != nil { + return billing.StandardInvoice{}, err + } + + res.Workflow = billing.InvoiceWorkflow{ + Config: workflowConfig, + SourceBillingProfileID: invoice.SourceBillingProfileID, + + AppReferences: billing.ProfileAppReferences{ + Tax: app.AppID{ + Namespace: invoice.Namespace, + ID: invoice.TaxAppID, + }, + Invoicing: app.AppID{ + Namespace: invoice.Namespace, + ID: invoice.InvoicingAppID, + }, + Payment: app.AppID{ + Namespace: invoice.Namespace, + ID: invoice.PaymentAppID, + }, + }, + } + + if expand.Has(billing.StandardInvoiceExpandLines) { + mappedLines, err := a.mapStandardInvoiceLinesFromDB(map[string]int{invoice.ID: invoice.SchemaLevel}, invoice.Edges.BillingInvoiceLines) + if err != nil { + return billing.StandardInvoice{}, err + } + + hierarchyByLineID, err := a.expandSplitLineHierarchy(ctx, invoice.Namespace, mappedLines.AsGenericLines()) + if err != nil { + return billing.StandardInvoice{}, err + } + + mappedLines, err = withSplitLineHierarchyForLines[*billing.StandardLine](mappedLines, hierarchyByLineID) + if err != nil { + return billing.StandardInvoice{}, err + } + + res.Lines = billing.NewStandardInvoiceLines(mappedLines) + } + + if len(invoice.Edges.BillingInvoiceValidationIssues) > 0 { + res.ValidationIssues = lo.Map(invoice.Edges.BillingInvoiceValidationIssues, func(issue *db.BillingInvoiceValidationIssue, _ int) billing.ValidationIssue { + return billing.ValidationIssue{ + ID: issue.ID, + CreatedAt: issue.CreatedAt.In(time.UTC), + UpdatedAt: issue.UpdatedAt.In(time.UTC), + DeletedAt: convert.TimePtrIn(issue.DeletedAt, time.UTC), + + Severity: issue.Severity, + Message: issue.Message, + Code: lo.FromPtr(issue.Code), + Component: billing.ComponentName(issue.Component), + Path: lo.FromPtr(issue.Path), + } + }) + } + + return res, nil +} + +func mapPeriodFromDB(start, end *time.Time) *timeutil.ClosedPeriod { + if start == nil || end == nil { + return nil + } + return &timeutil.ClosedPeriod{ + From: start.In(time.UTC), + To: end.In(time.UTC), + } +} + +func mapCustomerUsageAttributionFromDB(customerID string, customerKey *string, vua *billing.VersionedCustomerUsageAttribution) *streaming.CustomerUsageAttribution { + if vua == nil { + return nil + } + + switch vua.Type { + case billing.CustomerUsageAttributionTypeVersionV1: + // For version 1, we backfill the usage attribution from the explicit fields + return lo.ToPtr(streaming.NewCustomerUsageAttribution(customerID, customerKey, vua.CustomerUsageAttribution.SubjectKeys)) + case billing.CustomerUsageAttributionTypeVersionV2: + return &vua.CustomerUsageAttribution + default: + return nil + } +} + +func mapCustomerUsageAttributionToDB(customer customer.Customer) *billing.VersionedCustomerUsageAttribution { + // We allow invoices without usage attribution, but we don't store them in the database. + // We only allow them when lines are not usage based. + if err := customer.GetUsageAttribution().Validate(); err != nil { + return nil + } + + return &billing.VersionedCustomerUsageAttribution{ + Type: billing.CustomerUsageAttributionTypeVersionV2, + CustomerUsageAttribution: customer.GetUsageAttribution(), + } +} + +// IsAppUsed checks if the app is used in any invoice. +func (a *adapter) IsAppUsed(ctx context.Context, appID app.AppID) error { + if err := appID.Validate(); err != nil { + return billing.ValidationError{ + Err: fmt.Errorf("invalid app ID: %w", err), + } + } + + // Check if the app is used in any billing profile + err := a.isBillingProfileUsed(ctx, appID) + if err != nil { + return err + } + + // Check if the app is used in any invoice in gathering or issued states + usedInInvoices, err := a.db.BillingInvoice. + Query(). + Where(billinginvoice.Namespace(appID.Namespace)). + Where( + // The non-final states are listed here, so that we can make sure that all + // invoices can reach a final state before the app is removed. + billinginvoice.StatusIn( + billing.StandardInvoiceStatusGathering, + billing.StandardInvoiceStatusIssuingSyncing, + billing.StandardInvoiceStatusIssuingSyncFailed, + billing.StandardInvoiceStatusIssuingChargeBooking, + billing.StandardInvoiceStatusIssuingChargeBookingFailed, + billing.StandardInvoiceStatusIssued, + billing.StandardInvoiceStatusPaymentProcessingPending, + billing.StandardInvoiceStatusPaymentProcessingBookingAuthorized, + billing.StandardInvoiceStatusPaymentProcessingBookingAuthorizedFailed, + billing.StandardInvoiceStatusPaymentProcessingBookingAuthorizedAndSettled, + billing.StandardInvoiceStatusPaymentProcessingBookingAuthorizedAndSettledFailed, + billing.StandardInvoiceStatusPaymentProcessingAuthorized, + billing.StandardInvoiceStatusPaymentProcessingFailed, + billing.StandardInvoiceStatusPaymentProcessingActionRequired, + billing.StandardInvoiceStatusPaymentProcessingBookingSettled, + billing.StandardInvoiceStatusPaymentProcessingBookingSettledFailed, + billing.StandardInvoiceStatusOverdue, + ), + billinginvoice.DeletedAtIsNil(), + ). + Where( + billinginvoice.Or( + billinginvoice.InvoicingAppID(appID.ID), + billinginvoice.PaymentAppID(appID.ID), + billinginvoice.TaxAppID(appID.ID), + ), + ). + All(ctx) + if err != nil { + return err + } + + if len(usedInInvoices) > 0 { + return models.NewGenericConflictError(fmt.Errorf("app is used in %d non-finalized invoices: %s", len(usedInInvoices), strings.Join(lo.Map(usedInInvoices, func(invoice *db.BillingInvoice, _ int) string { + return fmt.Sprintf("%s[%s]", invoice.Number, invoice.ID) + }), ","))) + } + + return nil +} + +func (a *adapter) GetInvoiceType(ctx context.Context, input billing.GetInvoiceTypeAdapterInput) (billing.InvoiceType, error) { + if err := input.Validate(); err != nil { + return "", err + } + + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) (billing.InvoiceType, error) { + invoice, err := tx.db.BillingInvoice.Query(). + Where(billinginvoice.ID(input.ID)). + Where(billinginvoice.Namespace(input.Namespace)). + Only(ctx) + if err != nil { + if db.IsNotFound(err) { + return "", billing.NotFoundError{ + Err: fmt.Errorf("invoice not found: %w", err), + } + } + + return "", err + } + + if invoice.Status == billing.StandardInvoiceStatusGathering { + return billing.InvoiceTypeGathering, nil + } + + return billing.InvoiceTypeStandard, nil + }) +} diff --git a/billing/adapter/invoice_advancement.go b/billing/adapter/invoice_advancement.go new file mode 100644 index 0000000000000000000000000000000000000000..baa09dab5c2ce0325291ddcd468653253d4ef191 --- /dev/null +++ b/billing/adapter/invoice_advancement.go @@ -0,0 +1,125 @@ +package billingadapter + +import ( + "context" + "fmt" + + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/ent/db" + "github.com/openmeterio/openmeter/openmeter/ent/db/billinginvoice" + "github.com/openmeterio/openmeter/openmeter/ent/db/billinginvoicevalidationissue" + "github.com/openmeterio/openmeter/openmeter/ent/db/predicate" + "github.com/openmeterio/openmeter/pkg/framework/entutils" + "github.com/openmeterio/openmeter/pkg/sortx" +) + +func invoicePendingAdvancementPredicate(filter billing.InvoicePendingAdvancementFilter) predicate.BillingInvoice { + cutoff := filter.AsOf.Add(-filter.MinimumAge) + + // Available actions are cached when an invoice is persisted and do not change + // merely because time passes. The scheduled branches detect newly due states; + // the cached-action branch remains the fail-safe for other advanceable states. + return billinginvoice.And( + billinginvoice.StatusNEQ(billing.StandardInvoiceStatusGathering), + billinginvoice.Or( + // The automatic draft approval period has elapsed. + billinginvoice.And( + billinginvoice.StatusEQ(billing.StandardInvoiceStatusDraftWaitingAutoApproval), + billinginvoice.DraftUntilLTE(cutoff), + ), + // The invoice's quantity collection window has elapsed. + billinginvoice.And( + billinginvoice.StatusEQ(billing.StandardInvoiceStatusDraftWaitingForCollection), + billinginvoice.Or( + billinginvoice.CollectionAtLTE(cutoff), + billinginvoice.And( + billinginvoice.CollectionAtIsNil(), + billinginvoice.UpdatedAtLTE(cutoff), + ), + ), + ), + // The state machine exposes another immediately advanceable transition; + // this is the worker's fail-safe for invoices stuck between stable states. + billinginvoice.And( + entutils.JSONBKeyExistsInObject( + billinginvoice.FieldStatusDetailsCache, + "availableActions", + string(billing.InvoiceAvailableActionsFilterAdvance), + ), + billinginvoice.UpdatedAtLTE(cutoff), + ), + ), + ) +} + +func (a *adapter) ListStandardInvoicesPendingAdvancement(ctx context.Context, input billing.ListStandardInvoicesPendingAdvancementInput) ([]billing.StandardInvoice, error) { + if err := input.Validate(); err != nil { + return nil, billing.ValidationError{Err: err} + } + + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) ([]billing.StandardInvoice, error) { + query := tx.db.BillingInvoice.Query(). + WithBillingInvoiceValidationIssues(func(q *db.BillingInvoiceValidationIssueQuery) { + q.Where(billinginvoicevalidationissue.DeletedAtIsNil()) + }). + WithBillingWorkflowConfig(workflowConfigWithTaxCode). + Where( + billinginvoice.DeletedAtIsNil(), + invoicePendingAdvancementPredicate(billing.InvoicePendingAdvancementFilter{ + AsOf: input.AsOf, + MinimumAge: input.MinimumAge, + }), + ) + + if len(input.Namespaces) > 0 { + query.Where(billinginvoice.NamespaceIn(input.Namespaces...)) + } + + if len(input.IDs) > 0 { + query.Where(billinginvoice.IDIn(input.IDs...)) + } + + query.Order(billinginvoice.ByCreatedAt(entutils.GetOrdering(sortx.OrderDefault)...)) + + entities, err := query.All(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list standard invoices pending advancement: %w", err) + } + + invoices := make([]billing.StandardInvoice, 0, len(entities)) + for _, entity := range entities { + invoice, err := tx.mapStandardInvoiceFromDB(ctx, entity, billing.StandardInvoiceExpands{}) + if err != nil { + return nil, fmt.Errorf("failed to map standard invoice pending advancement: %w", err) + } + + invoices = append(invoices, invoice) + } + + return invoices, nil + }) +} + +func (a *adapter) CountStandardInvoicesPendingAdvancement(ctx context.Context, input billing.CountStandardInvoicesPendingAdvancementInput) (int64, error) { + if err := input.Validate(); err != nil { + return 0, billing.ValidationError{Err: err} + } + + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) (int64, error) { + query := tx.db.BillingInvoice.Query().Where( + billinginvoice.DeletedAtIsNil(), + invoicePendingAdvancementPredicate(input.Filter), + ) + + if len(input.ExcludedNamespaces) > 0 { + query.Where(billinginvoice.NamespaceNotIn(input.ExcludedNamespaces...)) + } + + count, err := query.Count(ctx) + if err != nil { + return 0, fmt.Errorf("failed to count standard invoices pending advancement: %w", err) + } + + return int64(count), nil + }) +} diff --git a/billing/adapter/invoiceapp.go b/billing/adapter/invoiceapp.go new file mode 100644 index 0000000000000000000000000000000000000000..eba8aea1a21f0c8af509d68e1bf52796f28df5bf --- /dev/null +++ b/billing/adapter/invoiceapp.go @@ -0,0 +1,41 @@ +package billingadapter + +import ( + "context" + "fmt" + + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/ent/db" + "github.com/openmeterio/openmeter/openmeter/ent/db/billinginvoice" + "github.com/openmeterio/openmeter/pkg/framework/entutils" +) + +var _ billing.InvoiceAppAdapter = (*adapter)(nil) + +func (a *adapter) UpdateInvoiceFields(ctx context.Context, in billing.UpdateInvoiceFieldsInput) error { + if err := in.Validate(); err != nil { + return billing.ValidationError{ + Err: err, + } + } + + return entutils.TransactingRepoWithNoValue(ctx, a, func(ctx context.Context, tx *adapter) error { + updateQuery := tx.db.BillingInvoice.UpdateOneID(in.Invoice.ID). + Where(billinginvoice.Namespace(in.Invoice.Namespace)) + + if in.SentToCustomerAt.IsPresent() { + updateQuery = updateQuery.SetOrClearSentToCustomerAt(in.SentToCustomerAt.OrEmpty()) + } + + _, err := updateQuery.Save(ctx) + if err != nil { + if db.IsNotFound(err) { + return fmt.Errorf("invoice not found [id=%s]", in.Invoice.ID) + } + + return err + } + + return nil + }) +} diff --git a/billing/adapter/invoicelinesplitgroup.go b/billing/adapter/invoicelinesplitgroup.go new file mode 100644 index 0000000000000000000000000000000000000000..e6ee34c5be88bbdd19f453b1540be9fb9a4d9389 --- /dev/null +++ b/billing/adapter/invoicelinesplitgroup.go @@ -0,0 +1,377 @@ +package billingadapter + +import ( + "context" + "fmt" + "time" + + "github.com/samber/lo" + + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/ent/db" + "github.com/openmeterio/openmeter/openmeter/ent/db/billinginvoicesplitlinegroup" + "github.com/openmeterio/openmeter/pkg/framework/entutils" + "github.com/openmeterio/openmeter/pkg/models" + "github.com/openmeterio/openmeter/pkg/slicesx" + "github.com/openmeterio/openmeter/pkg/timeutil" +) + +var _ billing.InvoiceSplitLineGroupAdapter = (*adapter)(nil) + +func (a *adapter) CreateSplitLineGroup(ctx context.Context, input billing.CreateSplitLineGroupAdapterInput) (billing.SplitLineGroup, error) { + if err := input.Validate(); err != nil { + return billing.SplitLineGroup{}, billing.ValidationError{ + Err: err, + } + } + + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) (billing.SplitLineGroup, error) { + create := tx.db.BillingInvoiceSplitLineGroup.Create(). + SetNamespace(input.Namespace). + SetNillableUniqueReferenceID(input.UniqueReferenceID). + SetName(input.Name). + SetNillableDescription(input.Description). + SetMetadata(input.Metadata). + SetServicePeriodStart(input.ServicePeriod.From.UTC()). + SetServicePeriodEnd(input.ServicePeriod.To.UTC()). + SetCurrency(input.Currency). + SetRatecardDiscounts(&input.RatecardDiscounts). + SetPrice(input.Price). + SetNillableFeatureKey(input.FeatureKey) + + if input.Subscription != nil { + create = create.SetSubscriptionID(input.Subscription.SubscriptionID). + SetSubscriptionPhaseID(input.Subscription.PhaseID). + SetSubscriptionItemID(input.Subscription.ItemID). + SetSubscriptionBillingPeriodFrom(input.Subscription.BillingPeriod.From.In(time.UTC)). + SetSubscriptionBillingPeriodTo(input.Subscription.BillingPeriod.To.In(time.UTC)) + } + + dbSplitLineGroup, err := create.Save(ctx) + if err != nil { + return billing.SplitLineGroup{}, err + } + + return tx.mapSplitLineGroupFromDB(dbSplitLineGroup) + }) +} + +func (a *adapter) UpdateSplitLineGroup(ctx context.Context, input billing.UpdateSplitLineGroupInput) (billing.SplitLineGroup, error) { + if err := input.Validate(); err != nil { + return billing.SplitLineGroup{}, billing.ValidationError{ + Err: err, + } + } + + // TODO[later]: we should consider creating a batch endpoint, but updates for split line groups are rare (e.g. subscription cancellation) + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) (billing.SplitLineGroup, error) { + updateQuery := tx.db.BillingInvoiceSplitLineGroup.UpdateOneID(input.ID). + SetName(input.Name). + SetOrClearDescription(input.Description). + SetMetadata(input.Metadata). + SetServicePeriodStart(input.ServicePeriod.From.UTC()). + SetServicePeriodEnd(input.ServicePeriod.To.UTC()). + SetRatecardDiscounts(&input.RatecardDiscounts). + Where( + billinginvoicesplitlinegroup.Namespace(input.Namespace), + ) + + dbSplitLineGroup, err := updateQuery.Save(ctx) + if err != nil { + return billing.SplitLineGroup{}, err + } + + return tx.mapSplitLineGroupFromDB(dbSplitLineGroup) + }) +} + +func (a *adapter) DeleteSplitLineGroup(ctx context.Context, input billing.DeleteSplitLineGroupInput) error { + if err := input.Validate(); err != nil { + return billing.ValidationError{ + Err: err, + } + } + + return entutils.TransactingRepoWithNoValue(ctx, a, func(ctx context.Context, tx *adapter) error { + nr, err := tx.db.BillingInvoiceSplitLineGroup.Delete(). + Where( + billinginvoicesplitlinegroup.Namespace(input.Namespace), + billinginvoicesplitlinegroup.ID(input.ID), + ).Exec(ctx) + if err != nil { + return err + } + + if nr != 1 { + return billing.NotFoundError{ + Err: fmt.Errorf("split line group not found [id=%s]", input.ID), + } + } + + return nil + }) +} + +func (a *adapter) GetSplitLineGroup(ctx context.Context, input billing.GetSplitLineGroupInput) (billing.SplitLineHierarchy, error) { + if err := input.Validate(); err != nil { + return billing.SplitLineHierarchy{}, billing.ValidationError{ + Err: err, + } + } + + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) (billing.SplitLineHierarchy, error) { + dbSplitLineGroup, err := tx.db.BillingInvoiceSplitLineGroup.Query(). + Where( + billinginvoicesplitlinegroup.Namespace(input.Namespace), + billinginvoicesplitlinegroup.ID(input.ID), + ). + WithBillingInvoiceLines(func(q *db.BillingInvoiceLineQuery) { + a.expandLineItems(q) + q.WithBillingInvoice(func(q *db.BillingInvoiceQuery) { + q.WithBillingWorkflowConfig(workflowConfigWithTaxCode) + }) + }). + First(ctx) + if err != nil { + if db.IsNotFound(err) { + return billing.SplitLineHierarchy{}, billing.NotFoundError{ + Err: fmt.Errorf("split line group not found [id=%s]", input.ID), + } + } + + return billing.SplitLineHierarchy{}, err + } + + return a.mapSplitLineHierarchyFromDB(ctx, dbSplitLineGroup) + }) +} + +func (a *adapter) mapSplitLineGroupFromDB(dbSplitLineGroup *db.BillingInvoiceSplitLineGroup) (billing.SplitLineGroup, error) { + if dbSplitLineGroup.Price == nil { + return billing.SplitLineGroup{}, fmt.Errorf("price is required") + } + + var subscriptionRef *billing.SubscriptionReference + if dbSplitLineGroup.SubscriptionID != nil || dbSplitLineGroup.SubscriptionPhaseID != nil || dbSplitLineGroup.SubscriptionItemID != nil { + subscriptionRef = &billing.SubscriptionReference{ + SubscriptionID: lo.FromPtr(dbSplitLineGroup.SubscriptionID), + PhaseID: lo.FromPtr(dbSplitLineGroup.SubscriptionPhaseID), + ItemID: lo.FromPtr(dbSplitLineGroup.SubscriptionItemID), + BillingPeriod: timeutil.ClosedPeriod{ + From: lo.FromPtr(dbSplitLineGroup.SubscriptionBillingPeriodFrom).In(time.UTC), + To: lo.FromPtr(dbSplitLineGroup.SubscriptionBillingPeriodTo).In(time.UTC), + }, + } + + if err := subscriptionRef.Validate(); err != nil { + return billing.SplitLineGroup{}, err + } + } + + return billing.SplitLineGroup{ + NamespacedID: models.NamespacedID{ + Namespace: dbSplitLineGroup.Namespace, + ID: dbSplitLineGroup.ID, + }, + ManagedModel: models.ManagedModel{ + CreatedAt: dbSplitLineGroup.CreatedAt, + UpdatedAt: dbSplitLineGroup.UpdatedAt, + DeletedAt: dbSplitLineGroup.DeletedAt, + }, + SplitLineGroupMutableFields: billing.SplitLineGroupMutableFields{ + Name: dbSplitLineGroup.Name, + Description: dbSplitLineGroup.Description, + Metadata: dbSplitLineGroup.Metadata, + + ServicePeriod: timeutil.ClosedPeriod{ + From: dbSplitLineGroup.ServicePeriodStart.UTC(), + To: dbSplitLineGroup.ServicePeriodEnd.UTC(), + }, + + RatecardDiscounts: lo.FromPtr(dbSplitLineGroup.RatecardDiscounts), + }, + UniqueReferenceID: dbSplitLineGroup.UniqueReferenceID, + + Currency: dbSplitLineGroup.Currency, + Price: dbSplitLineGroup.Price, + FeatureKey: dbSplitLineGroup.FeatureKey, + Subscription: subscriptionRef, + }, nil +} + +func (a *adapter) mapSplitLineHierarchyFromDB(ctx context.Context, dbSplitLineGroup *db.BillingInvoiceSplitLineGroup) (billing.SplitLineHierarchy, error) { + empty := billing.SplitLineHierarchy{} + + group, err := a.mapSplitLineGroupFromDB(dbSplitLineGroup) + if err != nil { + return empty, err + } + + mappedLines, err := a.mapSplitLineHierarchyLinesFromDB(ctx, dbSplitLineGroup.Edges.BillingInvoiceLines) + if err != nil { + return empty, err + } + + return billing.SplitLineHierarchy{ + Group: group, + Lines: mappedLines, + }, nil +} + +func (a *adapter) mapSplitLineHierarchyLinesFromDB(ctx context.Context, dbLines []*db.BillingInvoiceLine) ([]billing.LineWithInvoiceHeader, error) { + return slicesx.MapWithErr(dbLines, func(dbLine *db.BillingInvoiceLine) (billing.LineWithInvoiceHeader, error) { + if dbLine.Edges.BillingInvoice == nil { + return billing.LineWithInvoiceHeader{}, fmt.Errorf("billing invoice must be expanded when mapping split line hierarchy lines [id=%s]", dbLine.ID) + } + + switch dbLine.Edges.BillingInvoice.Status { + case billing.StandardInvoiceStatusGathering: + return a.mapSplitLineHierarchyGatheringLineFromDB(ctx, dbLine) + default: + return a.mapSplitLineHierarchyStandardLineFromDB(ctx, dbLine) + } + }) +} + +func (a *adapter) mapSplitLineHierarchyStandardLineFromDB(ctx context.Context, dbLine *db.BillingInvoiceLine) (billing.LineWithInvoiceHeader, error) { + line, err := a.mapStandardInvoiceLineWithoutReferences(dbLine) + if err != nil { + return billing.LineWithInvoiceHeader{}, err + } + + invoice, err := a.mapStandardInvoiceFromDB(ctx, dbLine.Edges.BillingInvoice, billing.StandardInvoiceExpands{}) + if err != nil { + return billing.LineWithInvoiceHeader{}, err + } + + return billing.NewLineWithInvoiceHeader(billing.StandardLineWithInvoiceHeader{ + Line: line, + Invoice: invoice, + }), nil +} + +func (a *adapter) mapSplitLineHierarchyGatheringLineFromDB(ctx context.Context, dbLine *db.BillingInvoiceLine) (billing.LineWithInvoiceHeader, error) { + line, err := a.mapGatheringInvoiceLineFromDB(dbLine.Edges.BillingInvoice.SchemaLevel, dbLine) + if err != nil { + return billing.LineWithInvoiceHeader{}, err + } + + invoice, err := a.mapGatheringInvoiceFromDB(ctx, dbLine.Edges.BillingInvoice, billing.GatheringInvoiceExpands{}) + if err != nil { + return billing.LineWithInvoiceHeader{}, err + } + + return billing.NewLineWithInvoiceHeader(billing.GatheringLineWithInvoiceHeader{ + Line: line, + Invoice: invoice, + }), nil +} + +type lineIdToSplitLineHierarchy map[string]*billing.SplitLineHierarchy + +// expandSplitLineHierarchy expands the given lines with their progressive line hierarchy +// This is done by fetching all the lines that are children of the given lines parent lines and then building +// the hierarchy. +func (a *adapter) expandSplitLineHierarchy(ctx context.Context, namespace string, lines []billing.GenericInvoiceLine) (lineIdToSplitLineHierarchy, error) { + // Let's collect all the lines with a parent line id set + + lineToGroupIDs := map[string]string{} + + for _, line := range lines { + if line.GetSplitLineGroupID() != nil { + lineToGroupIDs[line.GetID()] = *line.GetSplitLineGroupID() + } + } + + if len(lineToGroupIDs) == 0 { + return lineIdToSplitLineHierarchy{}, nil + } + + splitLineGroups, err := a.fetchAllSplitLineGroups(ctx, namespace, lo.Values(lineToGroupIDs)) + if err != nil { + return nil, err + } + + // Let's build the return values + hierarchyByLineID := map[string]*billing.SplitLineHierarchy{} + for _, splitLineGroup := range splitLineGroups { + for _, line := range splitLineGroup.Lines { + hierarchyByLineID[line.Line.GetID()] = &splitLineGroup + } + } + + return hierarchyByLineID, nil +} + +type splitLineSettableLines interface { + GetSplitLineGroupID() *string + GetID() string + SetSplitLineHierarchy(*billing.SplitLineHierarchy) +} + +func withSplitLineHierarchyForLines[T splitLineSettableLines](lines []T, hierarchyByLineID lineIdToSplitLineHierarchy) ([]T, error) { + for _, line := range lines { + if line.GetSplitLineGroupID() == nil { + continue + } + + hierarchy, ok := hierarchyByLineID[line.GetID()] + if !ok { + return nil, fmt.Errorf("split line group[%s] for line[%s] not found", *line.GetSplitLineGroupID(), line.GetID()) + } + + line.SetSplitLineHierarchy(hierarchy) + } + + return lines, nil +} + +func (a *adapter) fetchAllSplitLineGroups(ctx context.Context, namespace string, splitLineGroupIDs []string) ([]billing.SplitLineHierarchy, error) { + query := a.db.BillingInvoiceSplitLineGroup.Query(). + Where( + billinginvoicesplitlinegroup.Namespace(namespace), + billinginvoicesplitlinegroup.IDIn(splitLineGroupIDs...), + ). + WithBillingInvoiceLines(func(q *db.BillingInvoiceLineQuery) { + a.expandLineItems(q) + q.WithBillingInvoice(func(q *db.BillingInvoiceQuery) { + q.WithBillingWorkflowConfig(workflowConfigWithTaxCode) + }) // TODO[later]: we can consider loading this in a separate query, might be more efficient + }) + + dbSplitLineGroups, err := query.All(ctx) + if err != nil { + return nil, err + } + + return slicesx.MapWithErr(dbSplitLineGroups, func(dbSplitLineGroup *db.BillingInvoiceSplitLineGroup) (billing.SplitLineHierarchy, error) { + return a.mapSplitLineHierarchyFromDB(ctx, dbSplitLineGroup) + }) +} + +func (a *adapter) GetSplitLineGroupHeaders(ctx context.Context, input billing.GetSplitLineGroupHeadersInput) (billing.SplitLineGroupHeaders, error) { + if err := input.Validate(); err != nil { + return billing.SplitLineGroupHeaders{}, billing.ValidationError{ + Err: err, + } + } + + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) (billing.SplitLineGroupHeaders, error) { + dbSplitLineGroups, err := tx.db.BillingInvoiceSplitLineGroup.Query(). + Where(billinginvoicesplitlinegroup.Namespace(input.Namespace)). + Where(billinginvoicesplitlinegroup.IDIn(input.SplitLineGroupIDs...)). + All(ctx) + if err != nil { + return billing.SplitLineGroupHeaders{}, err + } + + splitLineGroups, err := slicesx.MapWithErr(dbSplitLineGroups, func(dbSplitLineGroup *db.BillingInvoiceSplitLineGroup) (billing.SplitLineGroup, error) { + return a.mapSplitLineGroupFromDB(dbSplitLineGroup) + }) + if err != nil { + return billing.SplitLineGroupHeaders{}, err + } + + return splitLineGroups, nil + }) +} diff --git a/billing/adapter/lock.go b/billing/adapter/lock.go new file mode 100644 index 0000000000000000000000000000000000000000..cd2ef0a8f108e1e26f8aebe4bba47957fa9d86aa --- /dev/null +++ b/billing/adapter/lock.go @@ -0,0 +1,63 @@ +package billingadapter + +import ( + "context" + "database/sql" + + entsql "entgo.io/ent/dialect/sql" + + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/ent/db/billingcustomerlock" + "github.com/openmeterio/openmeter/pkg/framework/entutils" +) + +var _ billing.CustomerOverrideAdapter = (*adapter)(nil) + +func (a *adapter) UpsertCustomerLock(ctx context.Context, input billing.UpsertCustomerLockAdapterInput) error { + err := a.db.BillingCustomerLock.Create(). + SetNamespace(input.Namespace). + SetCustomerID(input.ID). + OnConflict( + entsql.DoNothing(), + ). + Exec(ctx) + if err != nil { + // The do nothing returns no lines, so we have the record ready + if err == sql.ErrNoRows { + return nil + } + } + return nil +} + +func (a *adapter) LockCustomerForUpdate(ctx context.Context, input billing.LockCustomerForUpdateAdapterInput) error { + return entutils.TransactingRepoWithNoValue(ctx, a, func(ctx context.Context, tx *adapter) error { + if err := tx.UpsertCustomerLock(ctx, input); err != nil { + return err + } + + _, err := tx.db.BillingCustomerLock.Query(). + Where(billingcustomerlock.CustomerID(input.ID)). + Where(billingcustomerlock.Namespace(input.Namespace)). + ForUpdate(). + First(ctx) + if err != nil { + return err + } + + // Temp: until the migrations are complete + migrationStatus, err := tx.shouldInvoicesBeMigrated(ctx, input) + if err != nil { + return err + } + + if migrationStatus.shouldMigrate { + err := tx.migrateCustomerInvoices(ctx, input, migrationStatus.minSchemaLevel) + if err != nil { + return err + } + } + + return nil + }) +} diff --git a/billing/adapter/profile.go b/billing/adapter/profile.go new file mode 100644 index 0000000000000000000000000000000000000000..cd183cabb38fc4f39ca593cd8d0eafe8e0dc1487 --- /dev/null +++ b/billing/adapter/profile.go @@ -0,0 +1,538 @@ +package billingadapter + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/samber/lo" + + "github.com/openmeterio/openmeter/api" + "github.com/openmeterio/openmeter/openmeter/app" + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/customer" + "github.com/openmeterio/openmeter/openmeter/ent/db" + "github.com/openmeterio/openmeter/openmeter/ent/db/billingcustomeroverride" + "github.com/openmeterio/openmeter/openmeter/ent/db/billinginvoice" + "github.com/openmeterio/openmeter/openmeter/ent/db/billinginvoiceline" + "github.com/openmeterio/openmeter/openmeter/ent/db/billingprofile" + "github.com/openmeterio/openmeter/openmeter/ent/db/billingworkflowconfig" + dbcustomer "github.com/openmeterio/openmeter/openmeter/ent/db/customer" + "github.com/openmeterio/openmeter/openmeter/productcatalog" + taxcodeadapter "github.com/openmeterio/openmeter/openmeter/taxcode/adapter" + "github.com/openmeterio/openmeter/pkg/clock" + "github.com/openmeterio/openmeter/pkg/convert" + "github.com/openmeterio/openmeter/pkg/framework/entutils" + "github.com/openmeterio/openmeter/pkg/models" + "github.com/openmeterio/openmeter/pkg/pagination" + "github.com/openmeterio/openmeter/pkg/sortx" +) + +var _ billing.ProfileAdapter = (*adapter)(nil) + +// workflowConfigWithTaxCode is a reusable eager-load option that also loads the TaxCode edge +// on BillingWorkflowConfig, enabling BackfillTaxConfig on the read path. +var workflowConfigWithTaxCode = func(q *db.BillingWorkflowConfigQuery) { + q.WithTaxCode() +} + +func (a *adapter) CreateProfile(ctx context.Context, input billing.CreateProfileInput) (*billing.BaseProfile, error) { + if err := input.Validate(); err != nil { + return nil, billing.ValidationError{ + Err: err, + } + } + + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) (*billing.BaseProfile, error) { + // Create the new workflow config + dbWorkflowConfig, err := tx.createWorkflowConfig(ctx, input.Namespace, input.WorkflowConfig) + if err != nil { + return nil, err + } + + // Create the new profile + dbProfile, err := tx.db.BillingProfile.Create(). + SetNamespace(input.Namespace). + SetDefault(input.Default). + SetName(input.Name). + SetNillableDescription(input.Description). + SetSupplierName(input.Supplier.Name). + SetNillableSupplierTaxCode(input.Supplier.TaxCode). + SetSupplierAddressCountry(*input.Supplier.Address.Country). // Validation is done at service level + SetNillableSupplierAddressState(input.Supplier.Address.State). + SetNillableSupplierAddressCity(input.Supplier.Address.City). + SetNillableSupplierAddressPostalCode(input.Supplier.Address.PostalCode). + SetNillableSupplierAddressLine1(input.Supplier.Address.Line1). + SetNillableSupplierAddressLine2(input.Supplier.Address.Line2). + SetNillableSupplierAddressPhoneNumber(input.Supplier.Address.PhoneNumber). + SetWorkflowConfig(dbWorkflowConfig). + SetInvoicingAppID(input.Apps.Invoicing.ID). + SetPaymentAppID(input.Apps.Payment.ID). + SetTaxAppID(input.Apps.Tax.ID). + SetMetadata(input.Metadata). + Save(ctx) + if err != nil { + return nil, err + } + + // Hack: we need to add the edges back + dbProfile.Edges.WorkflowConfig = dbWorkflowConfig + + createdProfile, err := mapProfileFromDB(dbProfile) + if err != nil { + return nil, err + } + + return &createdProfile.BaseProfile, nil + }) +} + +func (a *adapter) createWorkflowConfig(ctx context.Context, ns string, input billing.WorkflowConfig) (*db.BillingWorkflowConfig, error) { + cmd := a.db.BillingWorkflowConfig.Create(). + SetNamespace(ns). + SetCollectionAlignment(input.Collection.Alignment). + SetLineCollectionPeriod(input.Collection.Interval.ISOString()). + SetInvoiceAutoAdvance(input.Invoicing.AutoAdvance). + SetInvoiceDraftPeriod(input.Invoicing.DraftPeriod.ISOString()). + SetInvoiceDueAfter(input.Invoicing.DueAfter.ISOString()). + SetInvoiceCollectionMethod(input.Payment.CollectionMethod). + SetInvoiceProgressiveBilling(input.Invoicing.ProgressiveBilling). + SetSubscriptionEndProrationMode(input.Invoicing.SubscriptionEndProrationMode). + SetNillableInvoiceDefaultTaxSettings(input.Invoicing.DefaultTaxConfig). + SetTaxEnabled(input.Tax.Enabled). + SetTaxEnforced(input.Tax.Enforced) + + if input.Collection.AnchoredAlignmentDetail != nil { + cmd = cmd.SetAnchoredAlignmentDetail(input.Collection.AnchoredAlignmentDetail) + } + + if cfg := input.Invoicing.DefaultTaxConfig; cfg != nil { + cmd = cmd.SetNillableTaxCodeID(cfg.TaxCodeID).SetNillableTaxBehavior(cfg.Behavior) + } + + saved, err := cmd.Save(ctx) + if err != nil { + return nil, err + } + + // Save never populates edges; manually fetch the TaxCode entity so that + // mapWorkflowConfigFromDB can call BackfillTaxConfig without a full node re-fetch + // (which would break pointer aliasing for AnchoredAlignmentDetail). + if saved.TaxCodeID != nil { + tc, err := a.db.TaxCode.Get(ctx, *saved.TaxCodeID) + if err != nil { + return nil, fmt.Errorf("fetching tax code edge after workflow config create: %w", err) + } + saved.Edges.TaxCode = tc + } + + return saved, nil +} + +func (a *adapter) GetProfile(ctx context.Context, input billing.GetProfileInput) (*billing.AdapterGetProfileResponse, error) { + if err := input.Validate(); err != nil { + return nil, err + } + + dbProfile, err := a.db.BillingProfile.Query(). + Where(billingprofile.Namespace(input.Profile.Namespace)). + Where(billingprofile.ID(input.Profile.ID)). + WithWorkflowConfig(workflowConfigWithTaxCode).First(ctx) + if err != nil { + if db.IsNotFound(err) { + return nil, billing.NotFoundError{ + Err: fmt.Errorf("%w [id=%s]", billing.ErrProfileNotFound, input.Profile.ID), + } + } + + return nil, err + } + + return mapProfileFromDB(dbProfile) +} + +func (a *adapter) ListProfiles(ctx context.Context, input billing.ListProfilesInput) (pagination.Result[billing.BaseProfile], error) { + query := a.db.BillingProfile.Query(). + Where(billingprofile.Namespace(input.Namespace)). + WithWorkflowConfig(workflowConfigWithTaxCode) + + if !input.IncludeArchived { + query = query.Where(billingprofile.DeletedAtIsNil()) + } + + order := entutils.GetOrdering(sortx.OrderDefault) + if !input.Order.IsDefaultValue() { + order = entutils.GetOrdering(input.Order) + } + + switch input.OrderBy { + case api.BillingProfileOrderByCreatedAt: + query = query.Order(billingprofile.ByCreatedAt(order...)) + case api.BillingProfileOrderByUpdatedAt: + query = query.Order(billingprofile.ByUpdatedAt(order...)) + case api.BillingProfileOrderByName: + query = query.Order(billingprofile.ByName(order...)) + case api.BillingProfileOrderByDefault: + query = query.Order(billingprofile.ByDefault(order...)) + default: + query = query.Order(billingprofile.ByCreatedAt(order...)) + } + + response := pagination.Result[billing.BaseProfile]{ + Page: input.Page, + } + + paged, err := query.Paginate(ctx, input.Page) + if err != nil { + return response, err + } + + result := make([]billing.BaseProfile, 0, len(paged.Items)) + for _, item := range paged.Items { + if item == nil { + a.logger.WarnContext(ctx, "invalid query result: nil billing profile received") + continue + } + + profile, err := mapProfileFromDB(item) + if err != nil { + return response, fmt.Errorf("cannot map profile: %w", err) + } + + result = append(result, profile.BaseProfile) + } + + response.TotalCount = paged.TotalCount + response.Items = result + + return response, nil +} + +func (a *adapter) GetDefaultProfile(ctx context.Context, input billing.GetDefaultProfileInput) (*billing.AdapterGetProfileResponse, error) { + if err := input.Validate(); err != nil { + return nil, err + } + + dbProfile, err := a.db.BillingProfile.Query(). + Where(billingprofile.Namespace(input.Namespace)). + Where(billingprofile.Default(true)). + Where(billingprofile.DeletedAtIsNil()). + WithWorkflowConfig(workflowConfigWithTaxCode). + Only(ctx) + if err != nil { + if db.IsNotFound(err) { + return nil, nil + } + + return nil, err + } + + return mapProfileFromDB(dbProfile) +} + +func (a *adapter) DeleteProfile(ctx context.Context, input billing.DeleteProfileInput) error { + if err := input.Validate(); err != nil { + return err + } + + return entutils.TransactingRepoWithNoValue(ctx, a, func(ctx context.Context, tx *adapter) error { + profile, err := tx.GetProfile(ctx, billing.GetProfileInput{ + Profile: input, + }) + if err != nil { + return err + } + + _, err = tx.db.BillingWorkflowConfig.UpdateOneID(profile.WorkflowConfigID). + Where(billingworkflowconfig.Namespace(profile.Namespace)). + SetDeletedAt(clock.Now()). + Save(ctx) + if err != nil { + return err + } + + _, err = tx.db.BillingProfile.UpdateOneID(input.ID). + Where(billingprofile.Namespace(input.Namespace)). + SetDeletedAt(clock.Now()). + Save(ctx) + if err != nil { + return err + } + + return nil + }) +} + +func (a *adapter) UpdateProfile(ctx context.Context, input billing.UpdateProfileAdapterInput) (*billing.BaseProfile, error) { + if err := input.Validate(); err != nil { + return nil, billing.ValidationError{ + Err: err, + } + } + + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) (*billing.BaseProfile, error) { + targetState := input.TargetState + + update := tx.db.BillingProfile.UpdateOneID(targetState.ID). + Where(billingprofile.Namespace(targetState.Namespace)). + SetName(targetState.Name). + SetNillableDescription(targetState.Description). + SetSupplierName(targetState.Supplier.Name). + SetSupplierAddressCountry(*targetState.Supplier.Address.Country). + SetDefault(targetState.Default). + SetOrClearSupplierTaxCode(targetState.Supplier.TaxCode). + SetOrClearSupplierAddressState(targetState.Supplier.Address.State). + SetOrClearSupplierAddressCity(targetState.Supplier.Address.City). + SetOrClearSupplierAddressPostalCode(targetState.Supplier.Address.PostalCode). + SetOrClearSupplierAddressLine1(targetState.Supplier.Address.Line1). + SetOrClearSupplierAddressLine2(targetState.Supplier.Address.Line2). + SetOrClearSupplierAddressPhoneNumber(targetState.Supplier.Address.PhoneNumber). + SetMetadata(targetState.Metadata) + + updatedProfile, err := update.Save(ctx) + if err != nil { + return nil, err + } + + updatedWorkflowConfig, err := tx.updateWorkflowConfig(ctx, targetState.Namespace, input.WorkflowConfigID, targetState.WorkflowConfig) + if err != nil { + return nil, err + } + + updatedProfile.Edges.WorkflowConfig = updatedWorkflowConfig + + updatedProfileEntity, err := mapProfileFromDB(updatedProfile) + if err != nil { + return nil, err + } + + return &updatedProfileEntity.BaseProfile, nil + }) +} + +func (a *adapter) GetUnpinnedCustomerIDsWithPaidSubscription(ctx context.Context, input billing.GetUnpinnedCustomerIDsWithPaidSubscriptionInput) ([]customer.CustomerID, error) { + if err := input.Validate(); err != nil { + return nil, err + } + + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) ([]customer.CustomerID, error) { + var out []customer.CustomerID + + err := tx.db.Customer.Query(). + Where( + dbcustomer.NamespaceEQ(input.Namespace), + dbcustomer.DeletedAtIsNil(), + // Has outstanding line items belonging to a subscription (a paid subscription always has at least + // one gathering line item, if there are still upcoming lines) + dbcustomer.HasBillingInvoiceWith( + billinginvoice.NamespaceEQ(input.Namespace), + billinginvoice.StatusEQ(billing.StandardInvoiceStatusGathering), + billinginvoice.DeletedAtIsNil(), + billinginvoice.HasBillingInvoiceLinesWith( + billinginvoiceline.DeletedAtIsNil(), + billinginvoiceline.StatusEQ(billing.InvoiceLineStatusValid), + billinginvoiceline.NamespaceEQ(input.Namespace), + billinginvoiceline.SubscriptionIDNotNil(), + ), + ), + // Has no customer override with explicit billing profile pinning + dbcustomer.Or( + // Either has a customer override with no billing profile id set + dbcustomer.HasBillingCustomerOverrideWith( + billingcustomeroverride.NamespaceEQ(input.Namespace), + billingcustomeroverride.DeletedAtIsNil(), + billingcustomeroverride.BillingProfileIDIsNil(), + ), + // Or has no customer override at all + dbcustomer.Not( + dbcustomer.HasBillingCustomerOverrideWith( + billingcustomeroverride.NamespaceEQ(input.Namespace), + billingcustomeroverride.DeletedAtIsNil(), + ), + ), + ), + ). + Select(dbcustomer.FieldNamespace, dbcustomer.FieldID). + Scan(ctx, &out) + if err != nil { + return nil, err + } + + return out, nil + }) +} + +// isBillingProfileUsed checks if the app is used in any billing profile +func (a *adapter) isBillingProfileUsed(ctx context.Context, appID app.AppID) error { + if err := appID.Validate(); err != nil { + return fmt.Errorf("invalid app id: %w", err) + } + + profiles, err := a.db.BillingProfile.Query(). + Where( + + billingprofile.Namespace(appID.Namespace), + billingprofile.Or( + billingprofile.InvoicingAppID(appID.ID), + billingprofile.PaymentAppID(appID.ID), + billingprofile.TaxAppID(appID.ID), + ), + billingprofile.DeletedAtIsNil(), + ). + All(ctx) + if err != nil { + return err + } + + if len(profiles) > 0 { + return models.NewGenericConflictError(fmt.Errorf("app is used in %d billing profiles: %s", len(profiles), strings.Join(lo.Map(profiles, func(profile *db.BillingProfile, _ int) string { + return fmt.Sprintf("%s[%s]", profile.Name, profile.ID) + }), ","))) + } + + return nil +} + +func (a *adapter) updateWorkflowConfig(ctx context.Context, ns string, id string, input billing.WorkflowConfig) (*db.BillingWorkflowConfig, error) { + cmd := a.db.BillingWorkflowConfig.UpdateOneID(id). + Where(billingworkflowconfig.Namespace(ns)). + SetCollectionAlignment(input.Collection.Alignment). + SetAnchoredAlignmentDetail(input.Collection.AnchoredAlignmentDetail). + SetLineCollectionPeriod(input.Collection.Interval.ISOString()). + SetInvoiceAutoAdvance(input.Invoicing.AutoAdvance). + SetInvoiceDraftPeriod(input.Invoicing.DraftPeriod.ISOString()). + SetInvoiceDueAfter(input.Invoicing.DueAfter.ISOString()). + SetInvoiceCollectionMethod(input.Payment.CollectionMethod). + SetInvoiceProgressiveBilling(input.Invoicing.ProgressiveBilling). + SetSubscriptionEndProrationMode(input.Invoicing.SubscriptionEndProrationMode). + SetOrClearInvoiceDefaultTaxSettings(input.Invoicing.DefaultTaxConfig). + SetTaxEnabled(input.Tax.Enabled). + SetTaxEnforced(input.Tax.Enforced) + + if cfg := input.Invoicing.DefaultTaxConfig; cfg != nil { + cmd = cmd.SetOrClearTaxCodeID(cfg.TaxCodeID).SetOrClearTaxBehavior(cfg.Behavior) + } else { + cmd = cmd.ClearTaxCodeID().ClearTaxBehavior() + } + + saved, err := cmd.Save(ctx) + if err != nil { + return nil, err + } + + // Save never populates edges; manually fetch the TaxCode entity so that + // mapWorkflowConfigFromDB can call BackfillTaxConfig without a full node re-fetch. + if saved.TaxCodeID != nil { + tc, err := a.db.TaxCode.Get(ctx, *saved.TaxCodeID) + if err != nil { + return nil, fmt.Errorf("fetching tax code edge after workflow config update: %w", err) + } + saved.Edges.TaxCode = tc + } + + return saved, nil +} + +func mapProfileFromDB(dbProfile *db.BillingProfile) (*billing.AdapterGetProfileResponse, error) { + if dbProfile == nil { + return nil, nil + } + + wfConfig, err := mapWorkflowConfigFromDB(dbProfile.Edges.WorkflowConfig) + if err != nil { + return nil, fmt.Errorf("cannot map workflow config: %w", err) + } + + return &billing.AdapterGetProfileResponse{ + BaseProfile: billing.BaseProfile{ + Namespace: dbProfile.Namespace, + ID: dbProfile.ID, + Default: dbProfile.Default, + Name: dbProfile.Name, + Description: dbProfile.Description, + Metadata: dbProfile.Metadata, + + CreatedAt: dbProfile.CreatedAt.In(time.UTC), + UpdatedAt: dbProfile.UpdatedAt.In(time.UTC), + DeletedAt: convert.TimePtrIn(dbProfile.DeletedAt, time.UTC), + + Supplier: billing.SupplierContact{ + Name: dbProfile.SupplierName, + Address: models.Address{ + Country: dbProfile.SupplierAddressCountry, + PostalCode: dbProfile.SupplierAddressPostalCode, + City: dbProfile.SupplierAddressCity, + State: dbProfile.SupplierAddressState, + Line1: dbProfile.SupplierAddressLine1, + Line2: dbProfile.SupplierAddressLine2, + PhoneNumber: dbProfile.SupplierAddressPhoneNumber, + }, + TaxCode: dbProfile.SupplierTaxCode, + }, + + WorkflowConfig: wfConfig, + + AppReferences: &billing.ProfileAppReferences{ + Tax: app.AppID{Namespace: dbProfile.Namespace, ID: dbProfile.TaxAppID}, + Invoicing: app.AppID{Namespace: dbProfile.Namespace, ID: dbProfile.InvoicingAppID}, + Payment: app.AppID{Namespace: dbProfile.Namespace, ID: dbProfile.PaymentAppID}, + }, + }, + WorkflowConfigID: dbProfile.Edges.WorkflowConfig.ID, + }, nil +} + +func mapWorkflowConfigFromDB(dbWC *db.BillingWorkflowConfig) (billing.WorkflowConfig, error) { + collectionInterval, err := dbWC.LineCollectionPeriod.Parse() + if err != nil { + return billing.WorkflowConfig{}, fmt.Errorf("cannot parse collection.interval: %w", err) + } + + draftPeriod, err := dbWC.InvoiceDraftPeriod.Parse() + if err != nil { + return billing.WorkflowConfig{}, fmt.Errorf("cannot parse invoicing.draftPeriod: %w", err) + } + + dueAfter, err := dbWC.InvoiceDueAfter.Parse() + if err != nil { + return billing.WorkflowConfig{}, fmt.Errorf("cannot parse invoicing.dueAfter: %w", err) + } + + invoicing := billing.InvoicingConfig{ + AutoAdvance: dbWC.InvoiceAutoAdvance, + DraftPeriod: draftPeriod, + DueAfter: dueAfter, + ProgressiveBilling: dbWC.InvoiceProgressiveBilling, + SubscriptionEndProrationMode: dbWC.SubscriptionEndProrationMode, + DefaultTaxConfig: lo.EmptyableToPtr(dbWC.InvoiceDefaultTaxSettings), + } + + if taxCodeRow, err := dbWC.Edges.TaxCodeOrErr(); err == nil { + tc, err := taxcodeadapter.MapTaxCodeFromEntity(taxCodeRow) + if err != nil { + return billing.WorkflowConfig{}, fmt.Errorf("mapping tax code for workflow config: %w", err) + } + + invoicing.DefaultTaxConfig = productcatalog.BackfillTaxConfig(invoicing.DefaultTaxConfig, dbWC.TaxBehavior, &tc) + } + + return billing.WorkflowConfig{ + Collection: billing.CollectionConfig{ + Alignment: dbWC.CollectionAlignment, + AnchoredAlignmentDetail: dbWC.AnchoredAlignmentDetail, + Interval: collectionInterval, + }, + + Invoicing: invoicing, + + Payment: billing.PaymentConfig{ + CollectionMethod: dbWC.InvoiceCollectionMethod, + }, + + Tax: billing.WorkflowTaxConfig{ + Enabled: dbWC.TaxEnabled, + Enforced: dbWC.TaxEnforced, + }, + }, nil +} diff --git a/billing/adapter/schemalevel.go b/billing/adapter/schemalevel.go new file mode 100644 index 0000000000000000000000000000000000000000..a2eccaf3b4393a714ccb08e73ffd429aefae7b89 --- /dev/null +++ b/billing/adapter/schemalevel.go @@ -0,0 +1,65 @@ +package billingadapter + +import ( + "context" + + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/customer" + entdb "github.com/openmeterio/openmeter/openmeter/ent/db" + "github.com/openmeterio/openmeter/openmeter/ent/db/billinginvoice" + "github.com/openmeterio/openmeter/openmeter/ent/db/billinginvoicewriteschemalevel" + "github.com/openmeterio/openmeter/pkg/framework/entutils" +) + +var _ billing.SchemaLevelAdapter = (*adapter)(nil) + +const ( + invoiceWriteSchemaLevelID = "write_schema_level" + DefaultInvoiceWriteSchemaLevel = 2 +) + +func (a *adapter) GetInvoiceDefaultSchemaLevel(ctx context.Context) (int, error) { + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) (int, error) { + record, err := tx.db.BillingInvoiceWriteSchemaLevel.Query(). + Where(billinginvoicewriteschemalevel.ID(invoiceWriteSchemaLevelID)). + First(ctx) + if err != nil { + if entdb.IsNotFound(err) { + return DefaultInvoiceWriteSchemaLevel, nil + } + return 0, err + } + return record.SchemaLevel, nil + }) +} + +func (a *adapter) SetInvoiceDefaultSchemaLevel(ctx context.Context, level int) error { + return entutils.TransactingRepoWithNoValue(ctx, a, func(ctx context.Context, tx *adapter) error { + return tx.db.BillingInvoiceWriteSchemaLevel.Create(). + SetID(invoiceWriteSchemaLevelID). + SetSchemaLevel(level). + OnConflictColumns(billinginvoicewriteschemalevel.FieldID). + UpdateSchemaLevel(). + Exec(ctx) + }) +} + +func (a *adapter) getSchemaLevelPerInvoice(ctx context.Context, customerID customer.CustomerID) (map[string]int, error) { + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) (map[string]int, error) { + invoices, err := tx.db.BillingInvoice.Query(). + Where(billinginvoice.Namespace(customerID.Namespace)). + Where(billinginvoice.CustomerID(customerID.ID)). + Select(billinginvoice.FieldID, billinginvoice.FieldSchemaLevel). + All(ctx) + if err != nil { + return nil, err + } + + out := make(map[string]int, len(invoices)) + for _, inv := range invoices { + out[inv.ID] = inv.SchemaLevel + } + + return out, nil + }) +} diff --git a/billing/adapter/schemamigration.go b/billing/adapter/schemamigration.go new file mode 100644 index 0000000000000000000000000000000000000000..834f3059ceb1e564ed30b22c26297e886edcceda --- /dev/null +++ b/billing/adapter/schemamigration.go @@ -0,0 +1,91 @@ +package billingadapter + +import ( + "context" + + entsql "entgo.io/ent/dialect/sql" + + "github.com/openmeterio/openmeter/openmeter/customer" + entdb "github.com/openmeterio/openmeter/openmeter/ent/db" + "github.com/openmeterio/openmeter/openmeter/ent/db/billinginvoice" + "github.com/openmeterio/openmeter/pkg/framework/entutils" +) + +type customerMigrationStatus struct { + shouldMigrate bool + minSchemaLevel int +} + +func (a *adapter) shouldInvoicesBeMigrated(ctx context.Context, customerID customer.CustomerID) (customerMigrationStatus, error) { + res, err := entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) (customerMigrationStatus, error) { + schemaLevel, err := tx.GetInvoiceDefaultSchemaLevel(ctx) + if err != nil { + return customerMigrationStatus{}, err + } + + minInvoice, err := tx.db.BillingInvoice.Query(). + Where(billinginvoice.Namespace(customerID.Namespace)). + Where(billinginvoice.CustomerID(customerID.ID)). + Order(billinginvoice.BySchemaLevel(entsql.OrderAsc())). + Select(billinginvoice.FieldSchemaLevel). + First(ctx) + if err != nil { + if entdb.IsNotFound(err) { + // No invoices for this customer -> nothing to migrate. + return customerMigrationStatus{ + shouldMigrate: false, + minSchemaLevel: schemaLevel, + }, nil + } + + return customerMigrationStatus{}, err + } + + return customerMigrationStatus{ + shouldMigrate: minInvoice.SchemaLevel < schemaLevel, + minSchemaLevel: minInvoice.SchemaLevel, + }, nil + }) + if err != nil { + return customerMigrationStatus{}, err + } + + return res, nil +} + +func (a *adapter) migrateCustomerInvoices(ctx context.Context, customerID customer.CustomerID, minLevel int) error { + return entutils.TransactingRepoWithNoValue(ctx, a, func(ctx context.Context, tx *adapter) error { + if minLevel == 1 { + err := tx.migrateSchemaLevel1(ctx, customerID) + if err != nil { + return err + } + } + + return nil + }) +} + +func (a *adapter) migrateSchemaLevel1(ctx context.Context, customerID customer.CustomerID) error { + return entutils.TransactingRepoWithNoValue(ctx, a, func(ctx context.Context, tx *adapter) error { + // Schema level 1 -> 2 migration is implemented as a DB function (see migrations). + rows, err := tx.db.QueryContext(ctx, `SELECT om_func_migrate_customer_invoices_to_schema_level_2($1)`, customerID.ID) + if err != nil { + return err + } + defer rows.Close() + + // The function returns the number of invoices updated (schema_level 1 -> 2). + var updatedInvoiceCount int64 + if rows.Next() { + if err := rows.Scan(&updatedInvoiceCount); err != nil { + return err + } + } + if err := rows.Err(); err != nil { + return err + } + + return nil + }) +} diff --git a/billing/adapter/stdinvoicelinediff.go b/billing/adapter/stdinvoicelinediff.go new file mode 100644 index 0000000000000000000000000000000000000000..1e58671ce141dd1057ff462c9af752d32cc449bc --- /dev/null +++ b/billing/adapter/stdinvoicelinediff.go @@ -0,0 +1,235 @@ +package billingadapter + +import ( + "fmt" + + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/pkg/entitydiff" + "github.com/openmeterio/openmeter/pkg/set" + "github.com/openmeterio/openmeter/pkg/slicesx" +) + +type ( + usageLineDiscountManagedWithLine = entitydiff.EqualerNestedEntity[billing.UsageLineDiscountManaged, *billing.StandardLine] + amountLineDiscountManagedWithLine = entitydiff.EqualerNestedEntity[billing.AmountLineDiscountManaged, *billing.StandardLine] + + detailedLineWithParent = entitydiff.NestedEntity[*billing.DetailedLine, *billing.StandardLine] + detailedLineDiff = entitydiff.Diff[detailedLineWithParent] + detailedLineAmountDiscountWithParent = entitydiff.EqualerNestedEntity[billing.AmountLineDiscountManaged, *billing.DetailedLine] + detailedLineAmountDiscountDiff = entitydiff.Diff[detailedLineAmountDiscountWithParent] +) + +type invoiceLineDiff struct { + Line entitydiff.Diff[*billing.StandardLine] + + // Dependant entities + UsageDiscounts entitydiff.Diff[usageLineDiscountManagedWithLine] + + // AffectedLineIDs contains the list of line IDs that are affected by the diff, even if they + // are not updated. We can use this to update the UpdatedAt of the lines if any of the dependant + // entities are updated. + AffectedLineIDs *set.Set[string] + + // ChildrenDiff contains the diff for the children of the line, we need to make this two-staged + // as first we need to make sure that the parent line IDs of the children are correct, and then + // we can update the children themselves. + + DetailedLine detailedLineDiff + DetailedLineAmountDiscounts detailedLineAmountDiscountDiff + DetailedLineAffectedLineIDs *set.Set[string] +} + +func diffInvoiceLines(lines []*billing.StandardLine) (invoiceLineDiff, error) { + diff := invoiceLineDiff{ + AffectedLineIDs: set.New[string](), + DetailedLineAffectedLineIDs: set.New[string](), + } + + // For now we are handling the dbState on a per line basis so that we don't have to make operations + // against the invoice itself. Going forward we can consider moving this to the invoice level, as this + // only makes sense for gathering invoices. + dbState := []*billing.StandardLine{} + for _, line := range lines { + if line.DBState != nil { + dbState = append(dbState, line.DBState) + } + } + + // Handle top level line diffs + err := entitydiff.DiffByID(entitydiff.DiffByIDInput[*billing.StandardLine]{ + DBState: dbState, + ExpectedState: lines, + HandleDelete: diff.DeleteLine, + HandleCreate: diff.CreateLine, + HandleUpdate: func(item entitydiff.DiffUpdate[*billing.StandardLine]) error { + if item.ExpectedState.UsageBased == nil { + return fmt.Errorf("expected state usage based is nil") + } + + if item.PersistedState.UsageBased == nil { + return fmt.Errorf("db state usage based is nil") + } + + if !item.ExpectedState.StandardLineBase.Equal(item.PersistedState.StandardLineBase) || !item.ExpectedState.UsageBased.Equal(item.PersistedState.UsageBased) { + diff.Line.NeedsUpdate(item) + } + + // Dependant entities + + diff.UsageDiscounts = diff.UsageDiscounts.Append(entitydiff.DiffByIDEqualer( + entitydiff.NewEqualersWithParent(item.ExpectedState.Discounts.Usage, item.ExpectedState), + entitydiff.NewEqualersWithParent(item.PersistedState.Discounts.Usage, item.PersistedState), + )) + + // Detailed line diffs + err := entitydiff.DiffByID(entitydiff.DiffByIDInput[*billing.DetailedLine]{ + DBState: slicesx.SliceToPtrSlice(item.PersistedState.DetailedLines), + ExpectedState: slicesx.SliceToPtrSlice(item.ExpectedState.DetailedLines), + HandleDelete: func(detailedLine *billing.DetailedLine) error { + if !item.PersistedState.IsDeleted() { + diff.AffectedLineIDs.Add(item.PersistedState.GetID()) + } + + return diff.DeleteDetailedLine(detailedLine, item.PersistedState) + }, + HandleCreate: func(detailedLine *billing.DetailedLine) error { + return diff.CreateDetailedLine(detailedLine, item.ExpectedState) + }, + HandleUpdate: func(detailedLine entitydiff.DiffUpdate[*billing.DetailedLine]) error { + if detailedLine.ExpectedState == nil { + return fmt.Errorf("detailed line expected state is nil or flat fee is nil") + } + + if detailedLine.PersistedState == nil { + return fmt.Errorf("detailed line db state is nil or flat fee is nil") + } + + if !detailedLine.ExpectedState.DetailedLineBase.Equal(detailedLine.PersistedState.DetailedLineBase) { + diff.DetailedLine.NeedsUpdate(entitydiff.DiffUpdate[detailedLineWithParent]{ + PersistedState: detailedLineWithParent{ + Entity: detailedLine.PersistedState, + Parent: item.PersistedState, + }, + ExpectedState: detailedLineWithParent{ + Entity: detailedLine.ExpectedState, + Parent: item.ExpectedState, + }, + }) + + if !item.ExpectedState.IsDeleted() { + diff.AffectedLineIDs.Add(item.PersistedState.ID) + } + } + + discountChanges := entitydiff.DiffByIDEqualer( + entitydiff.NewEqualersWithParent(detailedLine.ExpectedState.AmountDiscounts, detailedLine.ExpectedState), + entitydiff.NewEqualersWithParent(detailedLine.PersistedState.AmountDiscounts, detailedLine.PersistedState), + ) + + diff.DetailedLineAmountDiscounts = diff.DetailedLineAmountDiscounts.Append(discountChanges) + + if !discountChanges.IsEmpty() { + if !item.ExpectedState.IsDeleted() { + diff.AffectedLineIDs.Add(item.PersistedState.ID) + } + + if !detailedLine.ExpectedState.IsDeleted() { + diff.DetailedLineAffectedLineIDs.Add(detailedLine.PersistedState.ID) + } + } + + return nil + }, + }) + if err != nil { + return err + } + + return nil + }, + }) + if err != nil { + return diff, err + } + + return diff, nil +} + +func (d *invoiceLineDiff) DeleteLine(item *billing.StandardLine) error { + d.Line.NeedsDelete(item) + + for _, discount := range item.Discounts.Usage { + d.UsageDiscounts.NeedsDelete(usageLineDiscountManagedWithLine{ + Entity: discount, + Parent: item, + }) + } + + for idx := range item.DetailedLines { + if err := d.DeleteDetailedLine(&item.DetailedLines[idx], item); err != nil { + return err + } + } + + return nil +} + +func (d *invoiceLineDiff) CreateLine(item *billing.StandardLine) error { + d.Line.NeedsCreate(item) + + for _, usageDiscount := range item.Discounts.Usage { + d.UsageDiscounts.NeedsCreate(usageLineDiscountManagedWithLine{ + Entity: usageDiscount, + Parent: item, + }) + } + + for idx := range item.DetailedLines { + child := &item.DetailedLines[idx] + d.DetailedLine.NeedsCreate(detailedLineWithParent{ + Entity: child, + Parent: item, + }) + + for _, discount := range child.AmountDiscounts { + d.DetailedLineAmountDiscounts.NeedsCreate(detailedLineAmountDiscountWithParent{ + Entity: discount, + Parent: child, + }) + } + } + + return nil +} + +func (d *invoiceLineDiff) DeleteDetailedLine(item *billing.DetailedLine, parent *billing.StandardLine) error { + d.DetailedLine.NeedsDelete(detailedLineWithParent{ + Entity: item, + Parent: parent, + }) + + for _, discount := range item.AmountDiscounts { + d.DetailedLineAmountDiscounts.NeedsDelete(detailedLineAmountDiscountWithParent{ + Entity: discount, + Parent: item, + }) + } + + return nil +} + +func (d *invoiceLineDiff) CreateDetailedLine(item *billing.DetailedLine, parent *billing.StandardLine) error { + d.DetailedLine.NeedsCreate(detailedLineWithParent{ + Entity: item, + Parent: parent, + }) + + for _, discount := range item.AmountDiscounts { + d.DetailedLineAmountDiscounts.NeedsCreate(detailedLineAmountDiscountWithParent{ + Entity: discount, + Parent: item, + }) + } + + return nil +} diff --git a/billing/adapter/stdinvoicelinediff_test.go b/billing/adapter/stdinvoicelinediff_test.go new file mode 100644 index 0000000000000000000000000000000000000000..b6965b51f48e792397f46bfecee3279b721b0306 --- /dev/null +++ b/billing/adapter/stdinvoicelinediff_test.go @@ -0,0 +1,415 @@ +package billingadapter + +import ( + "fmt" + "testing" + + "github.com/alpacahq/alpacadecimal" + "github.com/samber/lo" + "github.com/stretchr/testify/require" + + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/billing/models/stddetailedline" + "github.com/openmeterio/openmeter/pkg/clock" + "github.com/openmeterio/openmeter/pkg/entitydiff" + "github.com/openmeterio/openmeter/pkg/models" +) + +type idDiff struct { + ToCreate []string + ToUpdate []string + ToDelete []string +} + +type lineDiffExpectation struct { + Line idDiff + + AmountDiscounts idDiff + + DetailedLine idDiff + DetailedLineAmountDiscounts idDiff + + AffectedLineIDs []string + DetailedLineAffectedLineIDs []string +} + +func TestInvoiceLineDiffing(t *testing.T) { + template := []*billing.StandardLine{ + { + StandardLineBase: billing.StandardLineBase{ + ManagedResource: models.NewManagedResource(models.ManagedResourceInput{ + ID: "1", + }), + }, + UsageBased: &billing.UsageBasedLine{}, + }, + { + StandardLineBase: billing.StandardLineBase{ + ManagedResource: models.NewManagedResource(models.ManagedResourceInput{ + ID: "2", + }), + }, + UsageBased: &billing.UsageBasedLine{}, + DetailedLines: billing.DetailedLines{ + { + DetailedLineBase: billing.DetailedLineBase{ + Base: stddetailedline.Base{ + ManagedResource: models.NewManagedResource(models.ManagedResourceInput{ + ID: "2.1", + }), + }, + }, + AmountDiscounts: newDetailedLineAmountDiscountsWithIDs("D2.1.1"), + }, + { + DetailedLineBase: billing.DetailedLineBase{ + Base: stddetailedline.Base{ + ManagedResource: models.NewManagedResource(models.ManagedResourceInput{ + ID: "2.2", + }), + }, + }, + }, + }, + }, + } + + t.Run("new line hierarchy (all lines are created)", func(t *testing.T) { + base := cloneLines(template) + + lineDiff, err := diffInvoiceLines(base) + require.NoError(t, err) + + requireDiff(t, lineDiffExpectation{ + Line: idDiff{ + ToCreate: []string{"1", "2"}, + }, + DetailedLine: idDiff{ + ToCreate: []string{"2.1", "2.2"}, + }, + DetailedLineAmountDiscounts: idDiff{ + ToCreate: []string{"D2.1.1"}, + }, + }, lineDiff) + }) + + t.Run("existing line hierarchy, no changes", func(t *testing.T) { + base := cloneLines(template) + snapshotAsDBState(t, base) + + lineDiff, err := diffInvoiceLines(base) + require.NoError(t, err) + + requireDiff(t, lineDiffExpectation{}, lineDiff) + }) + + t.Run("existing line hierarchy, one child line is deleted", func(t *testing.T) { + base := cloneLines(template) + snapshotAsDBState(t, base) + + require.True(t, removeDetailedLineByID(base[1], "2.1"), "child line 2.1 should be removed") + + lineDiff, err := diffInvoiceLines(base) + require.NoError(t, err) + + requireDiff(t, lineDiffExpectation{ + AffectedLineIDs: []string{"2"}, + DetailedLine: idDiff{ + ToDelete: []string{"2.1"}, + }, + DetailedLineAmountDiscounts: idDiff{ + ToDelete: []string{"D2.1.1"}, + }, + }, lineDiff) + }) + + t.Run("existing line hierarchy, one child line is changed", func(t *testing.T) { + base := cloneLines(template) + snapshotAsDBState(t, base) + + getDetailedLineByID(base[1], "2.1").Quantity = alpacadecimal.NewFromFloat(10) + + lineDiff, err := diffInvoiceLines(base) + require.NoError(t, err) + + requireDiff(t, lineDiffExpectation{ + AffectedLineIDs: []string{"2"}, + DetailedLine: idDiff{ + ToUpdate: []string{"2.1"}, + }, + }, lineDiff) + }) + + t.Run("existing line hierarchy, one parent line is changed", func(t *testing.T) { + base := cloneLines(template) + snapshotAsDBState(t, base) + + base[1].UsageBased.Quantity = lo.ToPtr(alpacadecimal.NewFromFloat(10)) + + lineDiff, err := diffInvoiceLines(base) + require.NoError(t, err) + + requireDiff(t, lineDiffExpectation{ + Line: idDiff{ + ToUpdate: []string{"2"}, + }, + }, lineDiff) + }) + + t.Run("a line is updated in the existing line hierarchy", func(t *testing.T) { + base := cloneLines(template) + snapshotAsDBState(t, base) + + // ID change should trigger a delete/update + changedLine := getDetailedLineByID(base[1], "2.1") + changedLine.ID = "" + changedLine.Description = lo.ToPtr("2.3") + + changedLine.AmountDiscounts[0].ID = "D2.1.3" + + lineDiff, err := diffInvoiceLines(base) + require.NoError(t, err) + + requireDiff(t, lineDiffExpectation{ + AffectedLineIDs: []string{"2"}, + DetailedLine: idDiff{ + ToDelete: []string{"2.1"}, + ToCreate: []string{"2.3"}, + }, + DetailedLineAmountDiscounts: idDiff{ + // The discount gets deleted + created + ToCreate: []string{"D2.1.3"}, + ToDelete: []string{"D2.1.1"}, + }, + }, lineDiff) + }) + + // Discount handling + t.Run("existing line hierarchy, one discount is deleted", func(t *testing.T) { + base := cloneLines(template) + snapshotAsDBState(t, base) + + getDetailedLineByID(base[1], "2.1").AmountDiscounts = nil + + lineDiff, err := diffInvoiceLines(base) + require.NoError(t, err) + + requireDiff(t, lineDiffExpectation{ + AffectedLineIDs: []string{"2"}, + DetailedLineAffectedLineIDs: []string{"2.1"}, + DetailedLineAmountDiscounts: idDiff{ + ToDelete: []string{"D2.1.1"}, + }, + }, lineDiff) + }) + + t.Run("existing line hierarchy, one discount is changed", func(t *testing.T) { + base := cloneLines(template) + snapshotAsDBState(t, base) + + getDetailedLineByID(base[1], "2.1").AmountDiscounts[0].Amount = alpacadecimal.NewFromFloat(20) + + lineDiff, err := diffInvoiceLines(base) + require.NoError(t, err) + + requireDiff(t, lineDiffExpectation{ + AffectedLineIDs: []string{"2"}, + DetailedLineAffectedLineIDs: []string{"2.1"}, + DetailedLineAmountDiscounts: idDiff{ + ToUpdate: []string{"D2.1.1"}, + }, + }, lineDiff) + }) + + t.Run("existing line hierarchy, one discount is added/old one is removed", func(t *testing.T) { + base := cloneLines(template) + snapshotAsDBState(t, base) + + discounts := getDetailedLineByID(base[1], "2.1").AmountDiscounts + + discounts[0].ID = "" + discounts[0].Description = lo.ToPtr("D2.1.2") + + lineDiff, err := diffInvoiceLines(base) + require.NoError(t, err) + + requireDiff(t, lineDiffExpectation{ + AffectedLineIDs: []string{"2"}, + DetailedLineAffectedLineIDs: []string{"2.1"}, + DetailedLineAmountDiscounts: idDiff{ + ToCreate: []string{"D2.1.2"}, + ToDelete: []string{"D2.1.1"}, + }, + }, lineDiff) + }) + + // DeletedAt handling + t.Run("support for detailed lines being deleted using deletedAt", func(t *testing.T) { + base := cloneLines(template) + snapshotAsDBState(t, base) + + getDetailedLineByID(base[1], "2.1").DeletedAt = lo.ToPtr(clock.Now()) + + lineDiff, err := diffInvoiceLines(base) + require.NoError(t, err) + + requireDiff(t, lineDiffExpectation{ + AffectedLineIDs: []string{"2"}, + DetailedLine: idDiff{ + ToDelete: []string{"2.1"}, + }, + DetailedLineAmountDiscounts: idDiff{ + ToDelete: []string{"D2.1.1"}, + }, + }, lineDiff) + }) + + t.Run("support for parent lines with children being deleted using deletedAt", func(t *testing.T) { + base := cloneLines(template) + snapshotAsDBState(t, base) + + base[1].DeletedAt = lo.ToPtr(clock.Now()) + + lineDiff, err := diffInvoiceLines(base) + require.NoError(t, err) + + requireDiff(t, lineDiffExpectation{ + Line: idDiff{ + ToDelete: []string{"2"}, + }, + DetailedLine: idDiff{ + ToDelete: []string{"2.1", "2.2"}, + }, + DetailedLineAmountDiscounts: idDiff{ + ToDelete: []string{"D2.1.1"}, + }, + }, lineDiff) + }) + + t.Run("support for parent lines without children being deleted using deletedAt", func(t *testing.T) { + base := cloneLines(template) + snapshotAsDBState(t, base) + + base[0].DeletedAt = lo.ToPtr(clock.Now()) + + lineDiff, err := diffInvoiceLines(base) + require.NoError(t, err) + + requireDiff(t, lineDiffExpectation{ + Line: idDiff{ + ToDelete: []string{"1"}, + }, + }, lineDiff) + }) + + t.Run("deleted, changed lines are not triggering updates", func(t *testing.T) { + base := cloneLines(template) + base[0].DeletedAt = lo.ToPtr(clock.Now()) + snapshotAsDBState(t, base) + base[0].Description = lo.ToPtr("test") + + lineDiff, err := diffInvoiceLines(base) + require.NoError(t, err) + + requireDiff(t, lineDiffExpectation{}, lineDiff) + }) +} + +func mapDiffToIDs[T entitydiff.Entity](in entitydiff.Diff[T], getDescription func(T) *string) idDiff { + return idDiff{ + ToCreate: lo.Map(in.Create, func(item T, _ int) string { + return lo.FromPtrOr(getDescription(item), item.GetID()) + }), + ToUpdate: lo.Map(in.Update, func(item entitydiff.DiffUpdate[T], _ int) string { + return lo.FromPtrOr(getDescription(item.PersistedState), item.PersistedState.GetID()) + }), + ToDelete: lo.Map(in.Delete, func(item T, _ int) string { + return lo.FromPtrOr(getDescription(item), item.GetID()) + }), + } +} + +func msgPrefix(prefix string, in ...interface{}) []interface{} { + if len(in) == 0 { + return []interface{}{prefix} + } + + if formatString, ok := in[0].(string); ok { + formatString = fmt.Sprintf("%s: %s", prefix, formatString) + return append([]interface{}{formatString}, in[1:]...) + } + + return in +} + +func requireIdDiffMatches[T entitydiff.Entity](t *testing.T, a idDiff, b entitydiff.Diff[T], getDescription func(T) *string, msgAndArgs ...interface{}) { + t.Helper() + + idDiffB := mapDiffToIDs(b, getDescription) + + require.ElementsMatch(t, a.ToCreate, idDiffB.ToCreate, msgPrefix("ToCreate", msgAndArgs...)) + require.ElementsMatch(t, a.ToUpdate, idDiffB.ToUpdate, msgPrefix("ToUpdate", msgAndArgs...)) + require.ElementsMatch(t, a.ToDelete, idDiffB.ToDelete, msgPrefix("ToDelete", msgAndArgs...)) +} + +func requireDiff(t *testing.T, expected lineDiffExpectation, actual invoiceLineDiff) { + t.Helper() + + requireIdDiffMatches(t, expected.Line, actual.Line, func(line *billing.StandardLine) *string { return line.GetDescription() }, "line diff") + + requireIdDiffMatches(t, expected.DetailedLine, actual.DetailedLine, func(line detailedLineWithParent) *string { return line.Entity.GetDescription() }, "detailed line diff") + requireIdDiffMatches(t, expected.DetailedLineAmountDiscounts, actual.DetailedLineAmountDiscounts, func(discount detailedLineAmountDiscountWithParent) *string { return discount.Entity.Description }, "detailed line amount discounts") + + require.ElementsMatch(t, expected.AffectedLineIDs, actual.AffectedLineIDs.AsSlice(), "affected line IDs") + require.ElementsMatch(t, expected.DetailedLineAffectedLineIDs, actual.DetailedLineAffectedLineIDs.AsSlice(), "detailed line affected line IDs") +} + +func cloneLines(lines []*billing.StandardLine) []*billing.StandardLine { + return lo.Map(lines, func(line *billing.StandardLine, _ int) *billing.StandardLine { + return lo.Must(line.Clone()) + }) +} + +// snapshotAsDBState saves the current state of the lines as if they were in the database +func snapshotAsDBState(t *testing.T, lines []*billing.StandardLine) { + t.Helper() + + for _, line := range lines { + err := line.SaveDBSnapshot() + require.NoError(t, err) + } +} + +func newDetailedLineAmountDiscountsWithIDs(ids ...string) billing.AmountLineDiscountsManaged { + return lo.Map(ids, func(id string, _ int) billing.AmountLineDiscountManaged { + return billing.AmountLineDiscountManaged{ + ManagedModelWithID: models.ManagedModelWithID{ + ID: id, + }, + AmountLineDiscount: billing.AmountLineDiscount{ + Amount: alpacadecimal.NewFromFloat(10), + }, + } + }) +} + +func getDetailedLineByID(l *billing.StandardLine, id string) *billing.DetailedLine { + for idx := range l.DetailedLines { + if l.DetailedLines[idx].ID == id { + return &l.DetailedLines[idx] + } + } + return nil +} + +func removeDetailedLineByID(l *billing.StandardLine, id string) bool { + toBeRemoved := getDetailedLineByID(l, id) + if toBeRemoved == nil { + return false + } + + l.DetailedLines = lo.Filter(l.DetailedLines, func(dl billing.DetailedLine, _ int) bool { + return dl.ID != id + }) + return true +} diff --git a/billing/adapter/stdinvoicelinemapper.go b/billing/adapter/stdinvoicelinemapper.go new file mode 100644 index 0000000000000000000000000000000000000000..bb78b188ea7c02e006e447909a775817ae82de56 --- /dev/null +++ b/billing/adapter/stdinvoicelinemapper.go @@ -0,0 +1,384 @@ +package billingadapter + +import ( + "fmt" + "time" + + "github.com/samber/lo" + + "github.com/openmeterio/openmeter/openmeter/app" + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/billing/models/externalid" + "github.com/openmeterio/openmeter/openmeter/billing/models/stddetailedline" + "github.com/openmeterio/openmeter/openmeter/billing/models/totals" + "github.com/openmeterio/openmeter/openmeter/ent/db" + "github.com/openmeterio/openmeter/openmeter/productcatalog" + "github.com/openmeterio/openmeter/openmeter/taxcode" + taxcodeadapter "github.com/openmeterio/openmeter/openmeter/taxcode/adapter" + "github.com/openmeterio/openmeter/pkg/convert" + "github.com/openmeterio/openmeter/pkg/models" + "github.com/openmeterio/openmeter/pkg/slicesx" + "github.com/openmeterio/openmeter/pkg/timeutil" +) + +func (a *adapter) mapStandardInvoiceLinesFromDB(schemaLevelByInvoiceID map[string]int, dbLines []*db.BillingInvoiceLine) (billing.StandardLines, error) { + lines := make([]*billing.StandardLine, 0, len(dbLines)) + + for _, dbLine := range dbLines { + line, err := a.mapStandardInvoiceLineWithoutReferences(dbLine) + if err != nil { + return nil, fmt.Errorf("mapping line [id=%s]: %w", dbLine.ID, err) + } + + schemaLevel, found := schemaLevelByInvoiceID[dbLine.InvoiceID] + if !found { + return nil, fmt.Errorf("schema level not found for invoice [id=%s]", dbLine.InvoiceID) + } + + if schemaLevel == 1 { + // Let's map any detailed lines + line.DetailedLines, err = slicesx.MapWithErr(dbLine.Edges.DetailedLines, a.mapStandardInvoiceDetailedLineFromDB) + if err != nil { + return nil, fmt.Errorf("mapping detailed lines [parentID=%s,id=%s]: %w", lo.FromPtr(dbLine.ParentLineID), dbLine.ID, err) + } + } else { + line.DetailedLines, err = slicesx.MapWithErr(dbLine.Edges.DetailedLinesV2, a.mapStandardInvoiceDetailedLineV2FromDB) + if err != nil { + return nil, fmt.Errorf("mapping detailed lines [parentID=%s,id=%s]: %w", lo.FromPtr(dbLine.ParentLineID), dbLine.ID, err) + } + } + + if err := line.SaveDBSnapshot(); err != nil { + return nil, fmt.Errorf("saving DB snapshot [id=%s]: %w", line.GetID(), err) + } + + lines = append(lines, line) + } + + return lines, nil +} + +func (a *adapter) mapStandardInvoiceLineWithoutReferences(dbLine *db.BillingInvoiceLine) (*billing.StandardLine, error) { + creditsApplied := lo.FromPtr(dbLine.CreditsApplied) + if len(creditsApplied) == 0 { + creditsApplied = nil + } + + invoiceLine := &billing.StandardLine{ + StandardLineBase: billing.StandardLineBase{ + ManagedResource: models.NewManagedResource(models.ManagedResourceInput{ + Namespace: dbLine.Namespace, + ID: dbLine.ID, + CreatedAt: dbLine.CreatedAt.In(time.UTC), + UpdatedAt: dbLine.UpdatedAt.In(time.UTC), + DeletedAt: convert.TimePtrIn(dbLine.DeletedAt, time.UTC), + Name: dbLine.Name, + Description: dbLine.Description, + }), + + Metadata: dbLine.Metadata, + Annotations: dbLine.Annotations, + InvoiceID: dbLine.InvoiceID, + ManagedBy: dbLine.ManagedBy, + Engine: dbLine.Engine, + + Period: timeutil.ClosedPeriod{ + From: dbLine.PeriodStart.In(time.UTC), + To: dbLine.PeriodEnd.In(time.UTC), + }, + + ParentLineID: dbLine.ParentLineID, + SplitLineGroupID: dbLine.SplitLineGroupID, + ChargeID: dbLine.ChargeID, + ChildUniqueReferenceID: dbLine.ChildUniqueReferenceID, + + InvoiceAt: dbLine.InvoiceAt.In(time.UTC), + OverrideCollectionPeriodEnd: convert.TimePtrIn(dbLine.OverrideCollectionPeriodEnd, time.UTC), + + Currency: dbLine.Currency, + + TaxConfig: backfillTaxConfigReferences( + lo.EmptyableToPtr(dbLine.TaxConfig), + dbLine.TaxBehavior, + taxCodeFromInvoiceLineEdge(dbLine), + ), + RateCardDiscounts: lo.FromPtr(dbLine.RatecardDiscounts), + CreditsApplied: creditsApplied, + Totals: totals.FromDB(dbLine), + ExternalIDs: externalid.MapLineExternalIDFromDB(dbLine), + }, + } + + if dbLine.SubscriptionID != nil && dbLine.SubscriptionPhaseID != nil && dbLine.SubscriptionItemID != nil { + invoiceLine.Subscription = &billing.SubscriptionReference{ + SubscriptionID: *dbLine.SubscriptionID, + PhaseID: *dbLine.SubscriptionPhaseID, + ItemID: *dbLine.SubscriptionItemID, + BillingPeriod: timeutil.ClosedPeriod{ + From: lo.FromPtr(dbLine.SubscriptionBillingPeriodFrom).In(time.UTC), + To: lo.FromPtr(dbLine.SubscriptionBillingPeriodTo).In(time.UTC), + }, + } + } + + if dbLine.Type != billing.InvoiceLineAdapterTypeUsageBased { + return nil, fmt.Errorf("only usage based lines can be top level lines [line_id=%s]", dbLine.ID) + } + + ubpLine := dbLine.Edges.UsageBasedLine + if ubpLine == nil { + return nil, fmt.Errorf("manual usage based line is missing") + } + + invoiceLine.UsageBased = &billing.UsageBasedLine{ + ConfigID: ubpLine.ID, + FeatureKey: lo.FromPtr(ubpLine.FeatureKey), + Price: ubpLine.Price, + Quantity: dbLine.Quantity, + MeteredQuantity: ubpLine.MeteredQuantity, + PreLinePeriodQuantity: ubpLine.PreLinePeriodQuantity, + MeteredPreLinePeriodQuantity: ubpLine.MeteredPreLinePeriodQuantity, + UnitConfig: ubpLine.UnitConfig, + } + + if len(dbLine.Edges.LineUsageDiscounts) > 0 { + discounts, err := slicesx.MapWithErr(dbLine.Edges.LineUsageDiscounts, a.mapStandardInvoiceLineUsageDiscountFromDB) + if err != nil { + return nil, fmt.Errorf("mapping invoice line usage discounts[%s] failed: %w", dbLine.ID, err) + } + + invoiceLine.Discounts.Usage = discounts + } + + return invoiceLine, nil +} + +func (a *adapter) mapStandardInvoiceDetailedLineFromDB(dbLine *db.BillingInvoiceLine) (billing.DetailedLine, error) { + // TODO: Once we move into a separate table we can get rid of these assertions + if dbLine.ParentLineID == nil { + return billing.DetailedLine{}, fmt.Errorf("detailed line parent line ID is required [detailed_line_id=%s]", dbLine.ID) + } + + creditsApplied := lo.FromPtr(dbLine.CreditsApplied) + if len(creditsApplied) == 0 { + creditsApplied = nil + } + + detailedLineBase := billing.DetailedLineBase{ + InvoiceID: dbLine.InvoiceID, + FeeLineConfigID: dbLine.Edges.FlatFeeLine.ID, + Base: stddetailedline.Base{ + ManagedResource: models.NewManagedResource(models.ManagedResourceInput{ + Namespace: dbLine.Namespace, + ID: dbLine.ID, + CreatedAt: dbLine.CreatedAt.In(time.UTC), + UpdatedAt: dbLine.UpdatedAt.In(time.UTC), + DeletedAt: convert.TimePtrIn(dbLine.DeletedAt, time.UTC), + Name: dbLine.Name, + Description: dbLine.Description, + }), + ChildUniqueReferenceID: lo.FromPtr(dbLine.ChildUniqueReferenceID), + ServicePeriod: timeutil.ClosedPeriod{ + From: dbLine.PeriodStart.In(time.UTC), + To: dbLine.PeriodEnd.In(time.UTC), + }, + PerUnitAmount: dbLine.Edges.FlatFeeLine.PerUnitAmount, + Quantity: lo.FromPtr(dbLine.Quantity), + Category: dbLine.Edges.FlatFeeLine.Category, + PaymentTerm: dbLine.Edges.FlatFeeLine.PaymentTerm, + Index: dbLine.Edges.FlatFeeLine.Index, + Currency: dbLine.Currency, + CreditsApplied: creditsApplied, + Totals: totals.FromDB(dbLine), + ExternalIDs: externalid.MapLineExternalIDFromDB(dbLine), + }, + } + + discounts, err := slicesx.MapWithErr(dbLine.Edges.LineAmountDiscounts, a.mapStandardInvoiceLineAmountDiscountFromDB) + if err != nil { + return billing.DetailedLine{}, fmt.Errorf("mapping invoice line amount discounts[%s] failed: %w", dbLine.ID, err) + } + + return billing.DetailedLine{ + DetailedLineBase: detailedLineBase, + AmountDiscounts: discounts, + }, nil +} + +func (a *adapter) mapStandardInvoiceDetailedLineV2FromDB(dbLine *db.BillingStandardInvoiceDetailedLine) (billing.DetailedLine, error) { + detailedLineBase := billing.DetailedLineBase{ + InvoiceID: dbLine.InvoiceID, + Base: stddetailedline.FromDB(dbLine), + } + + discounts, err := slicesx.MapWithErr(dbLine.Edges.AmountDiscounts, a.mapStandardInvoiceDetailedLineAmountDiscountFromDB) + if err != nil { + return billing.DetailedLine{}, fmt.Errorf("mapping invoice line amount discounts[%s] failed: %w", dbLine.ID, err) + } + + return billing.DetailedLine{ + DetailedLineBase: detailedLineBase, + AmountDiscounts: discounts, + }, nil +} + +func (a *adapter) mapStandardInvoiceLineUsageDiscountFromDB(dbDiscount *db.BillingInvoiceLineUsageDiscount) (billing.UsageLineDiscountManaged, error) { + base := billing.LineDiscountBase{ + Description: dbDiscount.Description, + ChildUniqueReferenceID: dbDiscount.ChildUniqueReferenceID, + ExternalIDs: externalid.MapLineExternalIDFromDB(dbDiscount), + } + + if dbDiscount.Reason == billing.MaximumSpendDiscountReason && dbDiscount.ReasonDetails == nil { + // Old (maximum spend) discounts do not have reason details + base.Reason = billing.NewDiscountReasonFrom(billing.MaximumSpendDiscount{}) + } else { + if dbDiscount.ReasonDetails == nil { + return billing.UsageLineDiscountManaged{}, fmt.Errorf("mapping invoice line discount[%s] failed: reason details is nil", dbDiscount.ID) + } + base.Reason = *dbDiscount.ReasonDetails + } + + managed := models.ManagedModelWithID{ + ID: dbDiscount.ID, + ManagedModel: models.ManagedModel{ + CreatedAt: dbDiscount.CreatedAt.In(time.UTC), + UpdatedAt: dbDiscount.UpdatedAt.In(time.UTC), + DeletedAt: convert.TimePtrIn(dbDiscount.DeletedAt, time.UTC), + }, + } + + return billing.UsageLineDiscountManaged{ + ManagedModelWithID: managed, + UsageLineDiscount: billing.UsageLineDiscount{ + LineDiscountBase: base, + Quantity: dbDiscount.Quantity, + PreLinePeriodQuantity: dbDiscount.PreLinePeriodQuantity, + }, + }, nil +} + +func (a *adapter) mapStandardInvoiceLineAmountDiscountFromDB(dbDiscount *db.BillingInvoiceLineDiscount) (billing.AmountLineDiscountManaged, error) { + base := billing.LineDiscountBase{ + Description: dbDiscount.Description, + ChildUniqueReferenceID: dbDiscount.ChildUniqueReferenceID, + ExternalIDs: externalid.MapLineExternalIDFromDB(dbDiscount), + } + + if dbDiscount.Reason == billing.MaximumSpendDiscountReason && dbDiscount.SourceDiscount == nil { + // Old (maximum spend) discounts do not have reason details + base.Reason = billing.NewDiscountReasonFrom(billing.MaximumSpendDiscount{}) + } else { + if dbDiscount.SourceDiscount == nil { + return billing.AmountLineDiscountManaged{}, fmt.Errorf("mapping invoice line discount[%s] failed: reason details is nil", dbDiscount.ID) + } + base.Reason = *dbDiscount.SourceDiscount + } + + managed := models.ManagedModelWithID{ + ID: dbDiscount.ID, + ManagedModel: models.ManagedModel{ + CreatedAt: dbDiscount.CreatedAt.In(time.UTC), + UpdatedAt: dbDiscount.UpdatedAt.In(time.UTC), + DeletedAt: convert.TimePtrIn(dbDiscount.DeletedAt, time.UTC), + }, + } + + return billing.AmountLineDiscountManaged{ + ManagedModelWithID: managed, + AmountLineDiscount: billing.AmountLineDiscount{ + LineDiscountBase: base, + Amount: dbDiscount.Amount, + RoundingAmount: lo.FromPtr(dbDiscount.RoundingAmount), + }, + }, nil +} + +func taxCodeFromInvoiceLineEdge(dbLine *db.BillingInvoiceLine) *taxcode.TaxCode { + tc, err := dbLine.Edges.TaxCodeOrErr() + if err != nil { + return nil + } + mapped, err := taxcodeadapter.MapTaxCodeFromEntity(tc) + if err != nil { + return nil + } + return &mapped +} + +// backfillTaxConfigReferences reconstructs the invoice-line TaxConfig read model from the +// persisted JSONB/config columns and the eagerly loaded TaxCode edge. +// +// Expected behavior: +// - always backfill the scalar legacy fields through productcatalog.BackfillTaxConfig(...) +// - only stamp TaxConfig.TaxCode when the resolved TaxCode entity matches the line's effective +// Stripe code / TaxCodeID after backfill and precedence rules are applied +// - if the line's own config takes precedence over the resolved TaxCode edge, keep the scalar +// config but do not attach a mismatching TaxCode snapshot +// +// TODO[later]: change the billing-facing types to expose TaxCodeSnapshot and TaxCodeReference +// fields explicitly so it is obvious what is the immutable invoice snapshot and what is the live +// reference to the tax entity. +func backfillTaxConfigReferences(snapshottedTaxConfig *billing.TaxConfig, persistedTaxBehavior *productcatalog.TaxBehavior, resolvedTaxCode *taxcode.TaxCode) *billing.TaxConfig { + if snapshottedTaxConfig == nil { + return billing.FromProductCatalog(productcatalog.BackfillTaxConfig(nil, persistedTaxBehavior, resolvedTaxCode)) + } + + backfilledTaxConfig := productcatalog.BackfillTaxConfig(snapshottedTaxConfig.ToProductCatalog(), persistedTaxBehavior, resolvedTaxCode) + + if backfilledTaxConfig == nil || resolvedTaxCode == nil { + return billing.FromProductCatalog(backfilledTaxConfig) + } + + if backfilledTaxConfig.TaxCodeID != nil && *backfilledTaxConfig.TaxCodeID != resolvedTaxCode.ID { + return billing.FromProductCatalog(backfilledTaxConfig) + } + + if backfilledTaxConfig.Stripe != nil { + mapping, ok := resolvedTaxCode.GetAppMapping(app.AppTypeStripe) + if !ok || mapping.TaxCode != backfilledTaxConfig.Stripe.Code { + return billing.FromProductCatalog(backfilledTaxConfig) + } + } + + result := billing.FromProductCatalog(backfilledTaxConfig) + result.TaxCode = resolvedTaxCode + + return result +} + +func (a *adapter) mapStandardInvoiceDetailedLineAmountDiscountFromDB(dbDiscount *db.BillingStandardInvoiceDetailedLineAmountDiscount) (billing.AmountLineDiscountManaged, error) { + base := billing.LineDiscountBase{ + Description: dbDiscount.Description, + ChildUniqueReferenceID: dbDiscount.ChildUniqueReferenceID, + ExternalIDs: externalid.MapLineExternalIDFromDB(dbDiscount), + } + + if dbDiscount.Reason == billing.MaximumSpendDiscountReason && dbDiscount.SourceDiscount == nil { + // Old (maximum spend) discounts do not have reason details + base.Reason = billing.NewDiscountReasonFrom(billing.MaximumSpendDiscount{}) + } else { + if dbDiscount.SourceDiscount == nil { + return billing.AmountLineDiscountManaged{}, fmt.Errorf("mapping invoice line discount[%s] failed: reason details is nil", dbDiscount.ID) + } + base.Reason = *dbDiscount.SourceDiscount + } + + managed := models.ManagedModelWithID{ + ID: dbDiscount.ID, + ManagedModel: models.ManagedModel{ + CreatedAt: dbDiscount.CreatedAt.In(time.UTC), + UpdatedAt: dbDiscount.UpdatedAt.In(time.UTC), + DeletedAt: convert.TimePtrIn(dbDiscount.DeletedAt, time.UTC), + }, + } + + return billing.AmountLineDiscountManaged{ + ManagedModelWithID: managed, + AmountLineDiscount: billing.AmountLineDiscount{ + LineDiscountBase: base, + Amount: dbDiscount.Amount, + RoundingAmount: lo.FromPtr(dbDiscount.RoundingAmount), + }, + }, nil +} diff --git a/billing/adapter/stdinvoicelines.go b/billing/adapter/stdinvoicelines.go new file mode 100644 index 0000000000000000000000000000000000000000..f75072dfc17b0f24571bc886fc4d2963add191af --- /dev/null +++ b/billing/adapter/stdinvoicelines.go @@ -0,0 +1,948 @@ +package billingadapter + +import ( + "context" + "errors" + "fmt" + "time" + + "entgo.io/ent/dialect/sql" + "github.com/oklog/ulid/v2" + "github.com/samber/lo" + + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/billing/models/externalid" + "github.com/openmeterio/openmeter/openmeter/billing/models/stddetailedline" + "github.com/openmeterio/openmeter/openmeter/billing/models/totals" + "github.com/openmeterio/openmeter/openmeter/customer" + "github.com/openmeterio/openmeter/openmeter/ent/db" + "github.com/openmeterio/openmeter/openmeter/ent/db/billinginvoice" + "github.com/openmeterio/openmeter/openmeter/ent/db/billinginvoiceflatfeelineconfig" + "github.com/openmeterio/openmeter/openmeter/ent/db/billinginvoiceline" + "github.com/openmeterio/openmeter/openmeter/ent/db/billinginvoicelinediscount" + "github.com/openmeterio/openmeter/openmeter/ent/db/billinginvoicelineusagediscount" + "github.com/openmeterio/openmeter/openmeter/ent/db/billinginvoicesplitlinegroup" + "github.com/openmeterio/openmeter/openmeter/ent/db/billinginvoiceusagebasedlineconfig" + "github.com/openmeterio/openmeter/openmeter/ent/db/billingstandardinvoicedetailedline" + "github.com/openmeterio/openmeter/openmeter/ent/db/billingstandardinvoicedetailedlineamountdiscount" + "github.com/openmeterio/openmeter/pkg/clock" + "github.com/openmeterio/openmeter/pkg/entitydiff" + "github.com/openmeterio/openmeter/pkg/framework/entutils" + "github.com/openmeterio/openmeter/pkg/slicesx" +) + +var _ billing.InvoiceLineAdapter = (*adapter)(nil) + +func (a *adapter) UpsertInvoiceLines(ctx context.Context, inputIn billing.UpsertInvoiceLinesAdapterInput) ([]*billing.StandardLine, error) { + // Given that the input's content is spread across multiple tables, we need to + // handle the upserting of the data in a more complex way. We will first upsert + // all items that yield an ID into their parent structs then we will create the + // parents. + + if err := inputIn.Validate(); err != nil { + return nil, err + } + + // Validate for missing functionality (this is put here, as we should remove them from here, + // once we have the functionality) + + clonedLines, err := inputIn.Lines.Clone() + if err != nil { + return nil, fmt.Errorf("cloning lines: %w", err) + } + + input := &billing.UpsertInvoiceLinesAdapterInput{ + Namespace: inputIn.Namespace, + Lines: clonedLines, + SchemaLevel: inputIn.SchemaLevel, + InvoiceID: inputIn.InvoiceID, + } + + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) ([]*billing.StandardLine, error) { + // Let's genereate the line diffs first + lineDiffs, err := diffInvoiceLines(input.Lines) + if err != nil { + return nil, fmt.Errorf("generating line diffs: %w", err) + } + + if input.SchemaLevel == 1 { + // Step 1: Let's create/upsert the line configs first + if err = tx.upsertFeeLineConfig(ctx, lineDiffs.DetailedLine); err != nil { + return nil, fmt.Errorf("upserting fee line configs: %w", err) + } + } + + if err := tx.upsertUsageBasedConfig(ctx, lineDiffs.Line); err != nil { + return nil, fmt.Errorf("upserting usage based line configs: %w", err) + } + + // Step 2: Let's create the lines, but not their detailed lines + invoiceLineUpsertConfig := upsertInput[*billing.StandardLine, *db.BillingInvoiceLineCreate]{ + Create: func(tx *db.Client, line *billing.StandardLine) (*db.BillingInvoiceLineCreate, error) { + if line.ID == "" { + line.ID = ulid.Make().String() + } + + create := tx.BillingInvoiceLine.Create(). + SetID(line.ID). + SetNamespace(line.Namespace). + SetInvoiceID(line.InvoiceID). + SetPeriodStart(line.Period.From.In(time.UTC)). + SetPeriodEnd(line.Period.To.In(time.UTC)). + SetNillableParentLineID(line.ParentLineID). + SetNillableSplitLineGroupID(line.SplitLineGroupID). + SetNillableChargeID(line.ChargeID). + SetNillableDeletedAt(line.DeletedAt). + SetInvoiceAt(line.InvoiceAt.In(time.UTC)). + SetNillableOverrideCollectionPeriodEnd(line.OverrideCollectionPeriodEnd). + SetStatus(billing.InvoiceLineStatusValid). + SetManagedBy(line.ManagedBy). + SetEngine(line.Engine). + SetType(billing.InvoiceLineAdapterTypeUsageBased). + SetName(line.Name). + SetNillableDescription(line.Description). + SetCurrency(line.Currency). + SetMetadata(line.Metadata). + SetAnnotations(line.Annotations). + SetNillableChildUniqueReferenceID(line.ChildUniqueReferenceID) + + create = externalid.CreateLineExternalID(create, line.ExternalIDs) + create = totals.Set(create, line.Totals) + + if len(line.CreditsApplied) > 0 { + create = create.SetCreditsApplied(&line.CreditsApplied) + } + + if line.Subscription != nil { + create = create.SetSubscriptionID(line.Subscription.SubscriptionID). + SetSubscriptionPhaseID(line.Subscription.PhaseID). + SetSubscriptionItemID(line.Subscription.ItemID). + SetSubscriptionBillingPeriodFrom(line.Subscription.BillingPeriod.From.In(time.UTC)). + SetSubscriptionBillingPeriodTo(line.Subscription.BillingPeriod.To.In(time.UTC)) + } + + if line.TaxConfig != nil { + create = create.SetTaxConfig(*line.TaxConfig). + SetNillableTaxCodeID(line.TaxConfig.TaxCodeID). + SetNillableTaxBehavior(line.TaxConfig.Behavior) + } + + if !line.RateCardDiscounts.IsEmpty() { + create = create.SetRatecardDiscounts(lo.ToPtr(line.RateCardDiscounts)) + } + + create = create. + SetNillableQuantity(line.UsageBased.Quantity). + SetUsageBasedLineID(line.UsageBased.ConfigID). + SetNillableFlatFeeLineID(nil) + + return create, nil + }, + UpsertItems: func(ctx context.Context, tx *db.Client, items []*db.BillingInvoiceLineCreate) error { + return tx.BillingInvoiceLine. + CreateBulk(items...). + OnConflict(sql.ConflictColumns(billinginvoiceline.FieldID), + sql.ResolveWithNewValues(), + sql.ResolveWith(func(u *sql.UpdateSet) { + u.SetIgnore(billinginvoiceline.FieldCreatedAt) + })). + UpdateQuantity(). + UpdateChildUniqueReferenceID(). + UpdateCreditsApplied(). + UpdateChargeID(). + UpdateOverrideCollectionPeriodEnd(). + UpdateTaxConfig(). + UpdateTaxCodeID(). + UpdateTaxBehavior(). + UpdateDescription(). + UpdateRatecardDiscounts(). + Exec(ctx) + }, + MarkDeleted: func(ctx context.Context, line *billing.StandardLine) (*billing.StandardLine, error) { + line.DeletedAt = lo.ToPtr(clock.Now().In(time.UTC)) + return line, nil + }, + } + + if err := upsertWithOptions(ctx, tx.db, lineDiffs.Line, invoiceLineUpsertConfig); err != nil { + return nil, fmt.Errorf("creating lines: %w", err) + } + + // Step 3: Let's create the detailed lines + if input.SchemaLevel == 1 { + if err := tx.upsertDetailedLines(ctx, lineDiffs.DetailedLine); err != nil { + return nil, fmt.Errorf("upserting detailed lines: %w", err) + } + // detailed line amount discounts + err = tx.upsertDetailedLineAmountDiscounts(ctx, lineDiffs.DetailedLineAmountDiscounts) + if err != nil { + return nil, fmt.Errorf("upserting detailed line amount discounts: %w", err) + } + } else { + if err := tx.upsertDetailedLinesV2(ctx, lineDiffs.DetailedLine); err != nil { + return nil, fmt.Errorf("upserting detailed lines: %w", err) + } + // detailed line amount discounts + err = tx.upsertDetailedLineAmountDiscountsV2(ctx, lineDiffs.DetailedLineAmountDiscounts) + if err != nil { + return nil, fmt.Errorf("upserting detailed line amount discounts: %w", err) + } + } + + // Step 4: Let's upsert anything else, that doesn't have strict ID requirements + + // Step 4a: Line Discounts + err = upsertWithOptions(ctx, tx.db, lineDiffs.UsageDiscounts, upsertInput[usageLineDiscountManagedWithLine, *db.BillingInvoiceLineUsageDiscountCreate]{ + Create: func(tx *db.Client, d usageLineDiscountManagedWithLine) (*db.BillingInvoiceLineUsageDiscountCreate, error) { + discount := d.Entity + + if discount.ID == "" { + discount.ID = ulid.Make().String() + } + + create := tx.BillingInvoiceLineUsageDiscount.Create(). + SetID(discount.ID). + SetNamespace(d.Parent.GetNamespace()). + SetLineID(d.Parent.GetID()). + SetReason(discount.Reason.Type()). + SetReasonDetails(lo.ToPtr(discount.Reason)). + SetQuantity(discount.Quantity). + SetNillablePreLinePeriodQuantity(discount.PreLinePeriodQuantity). + SetNillableDeletedAt(discount.DeletedAt). + SetNillableChildUniqueReferenceID(discount.ChildUniqueReferenceID). + SetNillableDescription(discount.Description) + + create = externalid.CreateLineExternalID(create, discount.ExternalIDs) + + return create, nil + }, + UpsertItems: func(ctx context.Context, tx *db.Client, items []*db.BillingInvoiceLineUsageDiscountCreate) error { + return tx.BillingInvoiceLineUsageDiscount. + CreateBulk(items...). + OnConflict( + sql.ConflictColumns(billinginvoicelineusagediscount.FieldID), + sql.ResolveWithNewValues(), + sql.ResolveWith(func(u *sql.UpdateSet) { + u.SetIgnore(billinginvoicelineusagediscount.FieldCreatedAt) + }), + ). + UpdatePreLinePeriodQuantity(). + UpdateDescription(). + UpdateChildUniqueReferenceID(). + UpdateDeletedAt(). + UpdateInvoicingAppExternalID(). + Exec(ctx) + }, + MarkDeleted: func(ctx context.Context, d usageLineDiscountManagedWithLine) (usageLineDiscountManagedWithLine, error) { + d.Entity.DeletedAt = lo.ToPtr(clock.Now().In(time.UTC)) + + return d, nil + }, + }) + if err != nil { + return nil, fmt.Errorf("upserting usage discounts: %w", err) + } + + // Step 4b: Taxes (TODO[later]: implement) + + // Step 5: Update updated_at for all the affected lines + if !lineDiffs.AffectedLineIDs.IsEmpty() { + err := tx.db.BillingInvoiceLine.Update(). + SetUpdatedAt(clock.Now().In(time.UTC)). + Where(billinginvoiceline.IDIn(lineDiffs.AffectedLineIDs.AsSlice()...)). + Exec(ctx) + if err != nil { + return nil, fmt.Errorf("updating updated_at for lines: %w", err) + } + } + + // Step 6: Refetch the lines, as due to the upserts we doesn't have a full view of the data + + // We will include deleted lines, as we need to return all the lines even if the edit function marked them as deleted. + return tx.refetchInvoiceLines(ctx, refetchInvoiceLinesInput{ + Namespace: input.Namespace, + LineIDs: lo.Map(input.Lines, func(line *billing.StandardLine, _ int) string { + return line.ID + }), + IncludeDeleted: true, + SchemaLevel: input.SchemaLevel, + InvoiceID: input.InvoiceID, + }) + }) +} + +func (a *adapter) upsertFeeLineConfig(ctx context.Context, in detailedLineDiff) error { + return upsertWithOptions(ctx, a.db, in, upsertInput[detailedLineWithParent, *db.BillingInvoiceFlatFeeLineConfigCreate]{ + Create: func(tx *db.Client, lineWithParent detailedLineWithParent) (*db.BillingInvoiceFlatFeeLineConfigCreate, error) { + line := lineWithParent.Entity + + if line.FeeLineConfigID == "" { + line.FeeLineConfigID = ulid.Make().String() + } + + create := tx.BillingInvoiceFlatFeeLineConfig.Create(). + SetNamespace(line.Namespace). + SetPerUnitAmount(line.PerUnitAmount). + SetCategory(line.Category). + SetPaymentTerm(line.PaymentTerm). + SetID(line.FeeLineConfigID). + SetNillableIndex(line.Index) + return create, nil + }, + UpsertItems: func(ctx context.Context, tx *db.Client, items []*db.BillingInvoiceFlatFeeLineConfigCreate) error { + return tx.BillingInvoiceFlatFeeLineConfig. + CreateBulk(items...). + OnConflict( + sql.ConflictColumns(billinginvoiceflatfeelineconfig.FieldID), + sql.ResolveWithNewValues(), + ). + UpdateIndex(). + Exec(ctx) + }, + }) +} + +func (a *adapter) upsertDetailedLines(ctx context.Context, in detailedLineDiff) error { + detailedLineUpsertConfig := upsertInput[detailedLineWithParent, *db.BillingInvoiceLineCreate]{ + Create: func(tx *db.Client, lineWithParent detailedLineWithParent) (*db.BillingInvoiceLineCreate, error) { + line := lineWithParent.Entity + + if line.ID == "" { + line.ID = ulid.Make().String() + } + + create := tx.BillingInvoiceLine.Create(). + SetID(line.ID). + SetNamespace(line.Namespace). + SetInvoiceID(line.InvoiceID). + SetPeriodStart(line.ServicePeriod.From.In(time.UTC)). + SetPeriodEnd(line.ServicePeriod.To.In(time.UTC)). + SetParentLineID(lineWithParent.Parent.ID). + SetInvoiceAt(lineWithParent.Parent.InvoiceAt.In(time.UTC)). + SetNillableDeletedAt(line.DeletedAt). + SetStatus(billing.InvoiceLineStatusDetailed). + SetManagedBy(billing.SystemManagedLine). + // Note: detailed lines should not have this field, but we set it until the data migartion is complete + SetEngine(billing.LineEngineTypeInvoice). + SetType(billing.InvoiceLineAdapterTypeFee). + SetName(line.Name). + SetNillableDescription(line.Description). + SetCurrency(line.Currency). + SetNillableChildUniqueReferenceID(lo.EmptyableToPtr(line.ChildUniqueReferenceID)) + + create = externalid.CreateLineExternalID(create, line.ExternalIDs) + create = totals.Set(create, line.Totals) + + if len(line.CreditsApplied) > 0 { + create = create.SetCreditsApplied(&line.CreditsApplied) + } + + create = create.SetQuantity(line.Quantity). + SetFlatFeeLineID(line.FeeLineConfigID). + SetNillableUsageBasedLineID(nil) + + return create, nil + }, + UpsertItems: func(ctx context.Context, tx *db.Client, items []*db.BillingInvoiceLineCreate) error { + return tx.BillingInvoiceLine. + CreateBulk(items...). + OnConflict(sql.ConflictColumns(billinginvoiceline.FieldID), + sql.ResolveWithNewValues(), + sql.ResolveWith(func(u *sql.UpdateSet) { + u.SetIgnore(billinginvoiceline.FieldCreatedAt) + })). + UpdateQuantity(). + UpdateChildUniqueReferenceID(). + UpdateCreditsApplied(). + UpdateDescription(). + Exec(ctx) + }, + MarkDeleted: func(ctx context.Context, line detailedLineWithParent) (detailedLineWithParent, error) { + line.Entity.DeletedAt = lo.ToPtr(clock.Now().In(time.UTC)) + return line, nil + }, + } + + return upsertWithOptions(ctx, a.db, in, detailedLineUpsertConfig) +} + +func (a *adapter) upsertDetailedLineAmountDiscounts(ctx context.Context, in detailedLineAmountDiscountDiff) error { + return upsertWithOptions(ctx, a.db, in, upsertInput[detailedLineAmountDiscountWithParent, *db.BillingInvoiceLineDiscountCreate]{ + Create: func(tx *db.Client, d detailedLineAmountDiscountWithParent) (*db.BillingInvoiceLineDiscountCreate, error) { + discount := d.Entity + + if discount.ID == "" { + discount.ID = ulid.Make().String() + } + + create := tx.BillingInvoiceLineDiscount.Create(). + SetID(discount.ID). + SetNamespace(d.Parent.GetNamespace()). + SetLineID(d.Parent.GetID()). + SetReason(discount.Reason.Type()). + SetSourceDiscount(lo.ToPtr(discount.Reason)). + SetAmount(discount.Amount). + SetNillableRoundingAmount(lo.EmptyableToPtr(discount.RoundingAmount)). + SetNillableDeletedAt(discount.DeletedAt). + SetNillableChildUniqueReferenceID(discount.ChildUniqueReferenceID). + SetNillableDescription(discount.Description) + + create = externalid.CreateLineExternalID(create, discount.ExternalIDs) + + return create, nil + }, + UpsertItems: func(ctx context.Context, tx *db.Client, items []*db.BillingInvoiceLineDiscountCreate) error { + return tx.BillingInvoiceLineDiscount. + CreateBulk(items...). + OnConflict( + sql.ConflictColumns(billinginvoicelinediscount.FieldID), + sql.ResolveWithNewValues(), + sql.ResolveWith(func(u *sql.UpdateSet) { + u.SetIgnore(billinginvoicelinediscount.FieldCreatedAt) + }), + ). + UpdateRoundingAmount(). + UpdateDescription(). + UpdateDeletedAt(). + UpdateChildUniqueReferenceID(). + UpdateSourceDiscount(). + UpdateInvoicingAppExternalID(). + Exec(ctx) + }, + MarkDeleted: func(ctx context.Context, d detailedLineAmountDiscountWithParent) (detailedLineAmountDiscountWithParent, error) { + d.Entity.DeletedAt = lo.ToPtr(clock.Now().In(time.UTC)) + + return d, nil + }, + }) +} + +func (a *adapter) upsertDetailedLinesV2(ctx context.Context, in detailedLineDiff) error { + detailedLineUpsertConfig := upsertInput[detailedLineWithParent, *db.BillingStandardInvoiceDetailedLineCreate]{ + Create: func(tx *db.Client, lineWithParent detailedLineWithParent) (*db.BillingStandardInvoiceDetailedLineCreate, error) { + line := lineWithParent.Entity + + if line.ID == "" { + line.ID = ulid.Make().String() + } + + create := tx.BillingStandardInvoiceDetailedLine.Create(). + SetID(line.ID). + SetNamespace(line.Namespace). + SetInvoiceID(line.InvoiceID). + SetParentLineID(lineWithParent.Parent.ID) + + create = stddetailedline.Create(create, line.Base) + + if len(line.CreditsApplied) > 0 { + create = create.SetCreditsApplied(&line.CreditsApplied) + } + + return create, nil + }, + UpsertItems: func(ctx context.Context, tx *db.Client, items []*db.BillingStandardInvoiceDetailedLineCreate) error { + return tx.BillingStandardInvoiceDetailedLine. + CreateBulk(items...). + OnConflict( + sql.ConflictColumns(billingstandardinvoicedetailedline.FieldID), + sql.ResolveWithNewValues(), + sql.ResolveWith(func(u *sql.UpdateSet) { + u.SetIgnore(billingstandardinvoicedetailedline.FieldCreatedAt) + }), + ). + UpdateChildUniqueReferenceID(). + UpdateDescription(). + UpdateIndex(). + UpdateDeletedAt(). + UpdateCreditsApplied(). + Exec(ctx) + }, + MarkDeleted: func(ctx context.Context, line detailedLineWithParent) (detailedLineWithParent, error) { + line.Entity.DeletedAt = lo.ToPtr(clock.Now().In(time.UTC)) + return line, nil + }, + } + + return upsertWithOptions(ctx, a.db, in, detailedLineUpsertConfig) +} + +func (a *adapter) upsertDetailedLineAmountDiscountsV2(ctx context.Context, in detailedLineAmountDiscountDiff) error { + return upsertWithOptions(ctx, a.db, in, upsertInput[detailedLineAmountDiscountWithParent, *db.BillingStandardInvoiceDetailedLineAmountDiscountCreate]{ + Create: func(tx *db.Client, d detailedLineAmountDiscountWithParent) (*db.BillingStandardInvoiceDetailedLineAmountDiscountCreate, error) { + discount := d.Entity + + if discount.ID == "" { + discount.ID = ulid.Make().String() + } + + create := tx.BillingStandardInvoiceDetailedLineAmountDiscount.Create(). + SetID(discount.ID). + SetNamespace(d.Parent.GetNamespace()). + SetLineID(d.Parent.GetID()). + SetReason(discount.Reason.Type()). + SetSourceDiscount(lo.ToPtr(discount.Reason)). + SetAmount(discount.Amount). + SetNillableRoundingAmount(lo.EmptyableToPtr(discount.RoundingAmount)). + SetNillableDeletedAt(discount.DeletedAt). + SetNillableChildUniqueReferenceID(discount.ChildUniqueReferenceID). + SetNillableDescription(discount.Description) + + create = externalid.CreateLineExternalID(create, discount.ExternalIDs) + + return create, nil + }, + UpsertItems: func(ctx context.Context, tx *db.Client, items []*db.BillingStandardInvoiceDetailedLineAmountDiscountCreate) error { + return tx.BillingStandardInvoiceDetailedLineAmountDiscount. + CreateBulk(items...). + OnConflict( + sql.ConflictColumns(billingstandardinvoicedetailedlineamountdiscount.FieldID), + sql.ResolveWithNewValues(), + sql.ResolveWith(func(u *sql.UpdateSet) { + u.SetIgnore(billingstandardinvoicedetailedlineamountdiscount.FieldCreatedAt) + }), + ). + UpdateRoundingAmount(). + UpdateDescription(). + UpdateDeletedAt(). + UpdateChildUniqueReferenceID(). + UpdateSourceDiscount(). + UpdateInvoicingAppExternalID(). + Exec(ctx) + }, + MarkDeleted: func(ctx context.Context, d detailedLineAmountDiscountWithParent) (detailedLineAmountDiscountWithParent, error) { + d.Entity.DeletedAt = lo.ToPtr(clock.Now().In(time.UTC)) + + return d, nil + }, + }) +} + +func (a *adapter) upsertUsageBasedConfig(ctx context.Context, lineDiffs entitydiff.Diff[*billing.StandardLine]) error { + return upsertWithOptions(ctx, a.db, lineDiffs, upsertInput[*billing.StandardLine, *db.BillingInvoiceUsageBasedLineConfigCreate]{ + Create: func(tx *db.Client, line *billing.StandardLine) (*db.BillingInvoiceUsageBasedLineConfigCreate, error) { + if line.UsageBased.ConfigID == "" { + line.UsageBased.ConfigID = ulid.Make().String() + } + + create := tx.BillingInvoiceUsageBasedLineConfig.Create(). + SetNamespace(line.Namespace). + SetPriceType(line.UsageBased.Price.Type()). + SetPrice(line.UsageBased.Price). + SetFeatureKey(line.UsageBased.FeatureKey). + SetID(line.UsageBased.ConfigID). + SetNillablePreLinePeriodQuantity(line.UsageBased.PreLinePeriodQuantity). + SetNillableMeteredQuantity(line.UsageBased.MeteredQuantity). + SetNillableMeteredPreLinePeriodQuantity(line.UsageBased.MeteredPreLinePeriodQuantity) + + // unit_config is the billing-time snapshot of the rate card's unit_config. It is + // mutable on a draft line like price: charges re-derivation, invoice edits, and + // charges patching re-upsert the line, and UpdateUnitConfig below makes the + // conflict clause resolve this column per row regardless of batch composition — + // a config-bearing row writes its config, a row whose config was dropped writes + // NULL and clears the stale snapshot (excluded.unit_config defaults to NULL when + // the row omits it). Finalized lines are protected behaviorally: they are never + // re-upserted through this path. Left unset (not set to nil) on create so a fresh + // row without a config stores SQL NULL rather than a JSON "null" literal. + if line.UsageBased.UnitConfig != nil { + create = create.SetUnitConfig(line.UsageBased.UnitConfig) + } + + return create, nil + }, + UpsertItems: func(ctx context.Context, tx *db.Client, items []*db.BillingInvoiceUsageBasedLineConfigCreate) error { + return tx.BillingInvoiceUsageBasedLineConfig. + CreateBulk(items...). + OnConflict( + sql.ConflictColumns(billinginvoiceusagebasedlineconfig.FieldID), + sql.ResolveWithNewValues(), + ). + UpdateUnitConfig(). + // Draft lines re-rate through this upsert, so the rated quantities are + // mutable too; resolve them per row so a nil-quantity sibling in the same + // batch cannot suppress another row's update via the shared column union. + UpdatePreLinePeriodQuantity(). + UpdateMeteredQuantity(). + UpdateMeteredPreLinePeriodQuantity(). + Exec(ctx) + }, + }) +} + +// TODO[OM-982]: Add pagination +func (a *adapter) ListInvoiceLines(ctx context.Context, input billing.ListInvoiceLinesAdapterInput) ([]*billing.StandardLine, error) { + if err := input.Validate(); err != nil { + return nil, err + } + + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) ([]*billing.StandardLine, error) { + query := tx.db.BillingInvoice.Query(). + Where(billinginvoice.Namespace(input.Namespace)) + + if input.CustomerID != "" { + query = query.Where(billinginvoice.CustomerID(input.CustomerID)) + } + + if len(input.InvoiceStatuses) > 0 { + query = query.Where(billinginvoice.StatusIn(input.InvoiceStatuses...)) + } + + query = query.WithBillingInvoiceLines(func(q *db.BillingInvoiceLineQuery) { + q = q.Where(billinginvoiceline.Namespace(input.Namespace)) + + if len(input.LineIDs) > 0 { + q = q.Where(billinginvoiceline.IDIn(input.LineIDs...)) + } + + if len(input.InvoiceIDs) > 0 { + q = q.Where(billinginvoiceline.InvoiceIDIn(input.InvoiceIDs...)) + } + + if !input.IncludeDeleted { + q = q.Where(billinginvoiceline.DeletedAtIsNil()) + } + + if len(input.Statuses) > 0 { + q = q.Where(billinginvoiceline.StatusIn(input.Statuses...)) + } + + tx.expandLineItemsWithDetailedLines(q) + }) + + dbInvoices, err := query.All(ctx) + if err != nil { + return nil, err + } + + lines := lo.FlatMap(dbInvoices, func(dbInvoice *db.BillingInvoice, _ int) []*db.BillingInvoiceLine { + return dbInvoice.Edges.BillingInvoiceLines + }) + + schemaLevelByInvoiceID := lo.SliceToMap(dbInvoices, func(dbInvoice *db.BillingInvoice) (string, int) { + return dbInvoice.ID, dbInvoice.SchemaLevel + }) + + mappedLines, err := tx.mapStandardInvoiceLinesFromDB(schemaLevelByInvoiceID, lines) + if err != nil { + return nil, err + } + + // Let's expand the line hierarchy so that we can have a full view of the split line groups + hierarchyByLineID, err := tx.expandSplitLineHierarchy(ctx, input.Namespace, mappedLines.AsGenericLines()) + if err != nil { + return nil, err + } + + mappedLines, err = withSplitLineHierarchyForLines[*billing.StandardLine](mappedLines, hierarchyByLineID) + if err != nil { + return nil, err + } + + return mappedLines, nil + }) +} + +// expandLineItems is a helper function to expand the line items in the query, detailed lines are not included +func (a *adapter) expandLineItems(q *db.BillingInvoiceLineQuery) *db.BillingInvoiceLineQuery { + return q.WithFlatFeeLine(). + WithUsageBasedLine(). + WithTaxCode(). + WithLineUsageDiscounts( + func(q *db.BillingInvoiceLineUsageDiscountQuery) { + q.Where(billinginvoicelineusagediscount.DeletedAtIsNil()) + }, + ). + WithLineAmountDiscounts( + func(q *db.BillingInvoiceLineDiscountQuery) { + q.Where(billinginvoicelinediscount.DeletedAtIsNil()) + }, + ) +} + +// expandLineItemsWithDetailedLines expands the invoice lines and their detailed lines if any exists +func (a *adapter) expandLineItemsWithDetailedLines(q *db.BillingInvoiceLineQuery) *db.BillingInvoiceLineQuery { + q = a.expandLineItems(q) + + q.WithDetailedLines(func(bilq *db.BillingInvoiceLineQuery) { + // We never include deleted detailed lines in the query, as we intent to keep them as history. + // + // If we want to reuse the deleted lines in ChildrenWithIDReuse, we must make sure that non-deleted lines are + // prioritized for reuse or we will end up with INSERT conflicts due to the child unique reference id uniqueness constraint. + bilq = bilq.Where(billinginvoiceline.DeletedAtIsNil()) + + a.expandLineItems(bilq) + }) + + q.WithDetailedLinesV2(func(bilq *db.BillingStandardInvoiceDetailedLineQuery) { + // We never include deleted detailed lines in the query, as we intent to keep them as history. + // + // If we want to reuse the deleted lines in ChildrenWithIDReuse, we must make sure that non-deleted lines are + // prioritized for reuse or we will end up with INSERT conflicts due to the child unique reference id uniqueness constraint. + bilq.Where(billingstandardinvoicedetailedline.DeletedAtIsNil()). + WithAmountDiscounts(func(bilq *db.BillingStandardInvoiceDetailedLineAmountDiscountQuery) { + bilq.Where(billingstandardinvoicedetailedlineamountdiscount.DeletedAtIsNil()) + }) + }) + + return q +} + +type refetchInvoiceLinesInput struct { + Namespace string + LineIDs []string + IncludeDeleted bool + SchemaLevel int + InvoiceID string +} + +func (i refetchInvoiceLinesInput) Validate() error { + if i.Namespace == "" { + return errors.New("namespace is required") + } + + if i.SchemaLevel < 1 { + return errors.New("schema level must be at least 1") + } + + if i.InvoiceID == "" { + return errors.New("invoice id is required") + } + + return nil +} + +func (a *adapter) refetchInvoiceLines(ctx context.Context, in refetchInvoiceLinesInput) ([]*billing.StandardLine, error) { + if err := in.Validate(); err != nil { + return nil, err + } + + query := a.db.BillingInvoiceLine.Query(). + Where(billinginvoiceline.Namespace(in.Namespace)). + Where(billinginvoiceline.IDIn(in.LineIDs...)) + + if !in.IncludeDeleted { + query = query.Where(billinginvoiceline.DeletedAtIsNil()) + } + + query = a.expandLineItemsWithDetailedLines(query) + + dbLines, err := query.All(ctx) + if err != nil { + return nil, fmt.Errorf("fetching lines: %w", err) + } + + if len(dbLines) != len(in.LineIDs) { + return nil, fmt.Errorf("not all lines were created") + } + + // Let's make sure that the lines are from the same invoice as the invoice ID passed + for _, line := range dbLines { + if line.InvoiceID != in.InvoiceID { + return nil, fmt.Errorf("line %s is not from the same invoice as the invoice ID passed", line.ID) + } + } + + dbLinesByID := lo.GroupBy(dbLines, func(line *db.BillingInvoiceLine) string { + return line.ID + }) + + dbLinesInSameOrder, err := slicesx.MapWithErr(in.LineIDs, func(id string) (*db.BillingInvoiceLine, error) { + line, ok := dbLinesByID[id] + if !ok || len(line) < 1 { + return nil, fmt.Errorf("line not found: %s", id) + } + + return line[0], nil + }) + if err != nil { + return nil, err + } + + lines, err := a.mapStandardInvoiceLinesFromDB(map[string]int{in.InvoiceID: in.SchemaLevel}, dbLinesInSameOrder) + if err != nil { + return nil, err + } + + // Let's expand the line hierarchy so that we can have a full view of the invoice during the upcoming calculations + hierarchyByLineID, err := a.expandSplitLineHierarchy(ctx, in.Namespace, lines.AsGenericLines()) + if err != nil { + return nil, err + } + + lines, err = withSplitLineHierarchyForLines(lines, hierarchyByLineID) + if err != nil { + return nil, err + } + + return lines, nil +} + +func (a *adapter) GetLinesForSubscription(ctx context.Context, in billing.GetLinesForSubscriptionInput) ([]billing.LineOrHierarchy, error) { + if err := in.Validate(); err != nil { + return nil, billing.ValidationError{ + Err: err, + } + } + + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) ([]billing.LineOrHierarchy, error) { + query := tx.db.BillingInvoiceLine.Query(). + Where(billinginvoiceline.Namespace(in.Namespace)). + Where(billinginvoiceline.SubscriptionID(in.SubscriptionID)). + Where(billinginvoiceline.ParentLineIDIsNil()). // This one is required so that we are not fetching split line's children directly, the mapper will handle that + Where( + billinginvoiceline.Or( + billinginvoiceline.DeletedAtIsNil(), + billinginvoiceline.And( + billinginvoiceline.DeletedAtNotNil(), + billinginvoiceline.ManagedByEQ(billing.ManuallyManagedLine), + ), + ), + ). + WithBillingInvoice() + + if !in.IncludeChargeManaged { + query = query.Where(billinginvoiceline.ChargeIDIsNil()) + } + + query = tx.expandLineItems(query) + + dbLines, err := query.All(ctx) + if err != nil { + return nil, fmt.Errorf("fetching lines: %w", err) + } + + // Let's make sure that the lines are loaded with their billing invoice + if err := errors.Join( + lo.Map(dbLines, func(line *db.BillingInvoiceLine, _ int) error { + if line.Edges.BillingInvoice == nil { + return fmt.Errorf("billing invoice not found for line [id=%s]", line.ID) + } + + return nil + })..., + ); err != nil { + return nil, err + } + + invoiceSchemaLevelByID, err := tx.getSchemaLevelPerInvoice(ctx, customer.CustomerID{ + Namespace: in.Namespace, + ID: in.CustomerID, + }) + if err != nil { + return nil, fmt.Errorf("getting schema level per invoice: %w", err) + } + + // map standard lines + dbStandardLines := lo.Filter(dbLines, func(line *db.BillingInvoiceLine, _ int) bool { + return line.Edges.BillingInvoice.Status != billing.StandardInvoiceStatusGathering + }) + + standardLines, err := tx.mapStandardInvoiceLinesFromDB(invoiceSchemaLevelByID, dbStandardLines) + if err != nil { + return nil, fmt.Errorf("mapping standard lines: %w", err) + } + + // map gathering lines + dbGatheringLines := lo.Filter(dbLines, func(line *db.BillingInvoiceLine, _ int) bool { + return line.Edges.BillingInvoice.Status == billing.StandardInvoiceStatusGathering + }) + + dbGatheringLinesByInvoiceID := lo.GroupBy(dbGatheringLines, func(line *db.BillingInvoiceLine) string { + return line.Edges.BillingInvoice.ID + }) + + gatheringLines := make([]billing.GatheringLine, 0, len(dbGatheringLines)) + for invoiceID, dbGatheringLinesForInvoice := range dbGatheringLinesByInvoiceID { + schemaLevel, found := invoiceSchemaLevelByID[invoiceID] + if !found { + return nil, fmt.Errorf("schema level not found for invoice [id=%s]", invoiceID) + } + + mappedLines, err := tx.mapGatheringInvoiceLinesFromDB(schemaLevel, dbGatheringLinesForInvoice) + if err != nil { + return nil, fmt.Errorf("mapping gathering lines: %w", err) + } + + gatheringLines = append(gatheringLines, mappedLines...) + } + + dbGroups, err := tx.db.BillingInvoiceSplitLineGroup.Query(). + Where(billinginvoicesplitlinegroup.Namespace(in.Namespace)). + Where(billinginvoicesplitlinegroup.SubscriptionID(in.SubscriptionID)). + WithBillingInvoiceLines(func(q *db.BillingInvoiceLineQuery) { + tx.expandLineItems(q) + q.WithBillingInvoice(func(q *db.BillingInvoiceQuery) { + q.WithBillingWorkflowConfig(workflowConfigWithTaxCode) + }) + }). + Where(billinginvoicesplitlinegroup.DeletedAtIsNil()). + All(ctx) + if err != nil { + return nil, fmt.Errorf("fetching split line groups: %w", err) + } + + groups, err := slicesx.MapWithErr(dbGroups, func(dbGroup *db.BillingInvoiceSplitLineGroup) (billing.SplitLineHierarchy, error) { + group, err := tx.mapSplitLineGroupFromDB(dbGroup) + if err != nil { + return billing.SplitLineHierarchy{}, err + } + + lines, err := tx.mapSplitLineHierarchyLinesFromDB(ctx, dbGroup.Edges.BillingInvoiceLines) + if err != nil { + return billing.SplitLineHierarchy{}, err + } + + return billing.SplitLineHierarchy{ + Group: group, + Lines: lines, + }, nil + }) + if err != nil { + return nil, fmt.Errorf("mapping groups: %w", err) + } + + // Sanity check: let's make sure that there are no items with overlapping childUniqueReferenceID + groupUniqueReferenceIDs := lo.Map( + lo.Filter( + groups, + func(group billing.SplitLineHierarchy, _ int) bool { + return group.Group.UniqueReferenceID != nil + }, + ), + func(group billing.SplitLineHierarchy, _ int) string { + return lo.FromPtr(group.Group.UniqueReferenceID) + }, + ) + + lineChildUniqueReferenceIDs := lo.Union( + lo.FilterMap(standardLines, func(line *billing.StandardLine, _ int) (string, bool) { + return lo.FromPtr(line.ChildUniqueReferenceID), line.ChildUniqueReferenceID != nil + }), + lo.FilterMap(gatheringLines, func(line billing.GatheringLine, _ int) (string, bool) { + return lo.FromPtr(line.ChildUniqueReferenceID), line.ChildUniqueReferenceID != nil + }), + ) + + overlappingChildUniqueReferenceIDs := lo.Intersect(groupUniqueReferenceIDs, lineChildUniqueReferenceIDs) + + if len(overlappingChildUniqueReferenceIDs) > 0 { + return nil, fmt.Errorf("overlapping childUniqueReferenceID: %v", overlappingChildUniqueReferenceIDs) + } + + // Let's map to the union type + out := make([]billing.LineOrHierarchy, 0, len(groups)+len(standardLines)+len(gatheringLines)) + + out = append(out, lo.Map(groups, func(h billing.SplitLineHierarchy, _ int) billing.LineOrHierarchy { + return billing.NewLineOrHierarchy(&h) + })...) + + out = append(out, lo.Map(standardLines, func(line *billing.StandardLine, _ int) billing.LineOrHierarchy { + return billing.NewLineOrHierarchy(line) + })...) + + out = append(out, lo.Map(gatheringLines, func(line billing.GatheringLine, _ int) billing.LineOrHierarchy { + return billing.NewLineOrHierarchy(line) + })...) + + return out, nil + }) +} diff --git a/billing/adapter/upsert.go b/billing/adapter/upsert.go new file mode 100644 index 0000000000000000000000000000000000000000..23016bda8a560403ec96f5485dadd4b6bfa9ea9e --- /dev/null +++ b/billing/adapter/upsert.go @@ -0,0 +1,78 @@ +package billingadapter + +import ( + "context" + + entdb "github.com/openmeterio/openmeter/openmeter/ent/db" + "github.com/openmeterio/openmeter/pkg/entitydiff" + "github.com/openmeterio/openmeter/pkg/slicesx" +) + +type upsertInput[T any, CreateBulkType any] struct { + Create func(*entdb.Client, T) (CreateBulkType, error) + UpsertItems func(context.Context, *entdb.Client, []CreateBulkType) error + MarkDeleted func(context.Context, T) (T, error) +} + +type upsertOption[T any, CreateBulkType any] func(upsertInput[T, CreateBulkType]) upsertInput[T, CreateBulkType] + +func upsertWithOptions[T entitydiff.Entity, CreateBulkType any](ctx context.Context, db *entdb.Client, itemDiff entitydiff.Diff[T], baseSettings upsertInput[T, CreateBulkType], options ...upsertOption[T, CreateBulkType]) error { + opts := baseSettings + for _, option := range options { + opts = option(opts) + } + + upsertItems := make([]CreateBulkType, 0, len(itemDiff.Create)+len(itemDiff.Update)+len(itemDiff.Delete)) + + // Delete must be first, as we might have a constraint that prevents us from creating the item if not deleted before. + if len(itemDiff.Delete) > 0 && opts.MarkDeleted != nil { + // We formulate delete as a soft delete update, so that any changes happening alongside the deletion are persisted + // to the database. + + toDelete, err := slicesx.MapWithErr(itemDiff.Delete, func(item T) (T, error) { + return opts.MarkDeleted(ctx, item) + }) + if err != nil { + return err + } + + deleteCommands, err := slicesx.MapWithErr(toDelete, func(item T) (CreateBulkType, error) { + return opts.Create(db, item) + }) + if err != nil { + return err + } + + upsertItems = append(upsertItems, deleteCommands...) + } + + if len(itemDiff.Create) > 0 { + toCreate, err := slicesx.MapWithErr(itemDiff.Create, func(item T) (CreateBulkType, error) { + return opts.Create(db, item) + }) + if err != nil { + return err + } + + upsertItems = append(upsertItems, toCreate...) + } + + if len(itemDiff.Update) > 0 { + toUpdate, err := slicesx.MapWithErr(itemDiff.Update, func(item entitydiff.DiffUpdate[T]) (CreateBulkType, error) { + return opts.Create(db, item.ExpectedState) + }) + if err != nil { + return err + } + + upsertItems = append(upsertItems, toUpdate...) + } + + if len(upsertItems) > 0 { + if err := opts.UpsertItems(ctx, db, upsertItems); err != nil { + return err + } + } + + return nil +} diff --git a/billing/adapter/validationissue.go b/billing/adapter/validationissue.go new file mode 100644 index 0000000000000000000000000000000000000000..525e63715f9f22bdfbac27ecfd55e87874a5d713 --- /dev/null +++ b/billing/adapter/validationissue.go @@ -0,0 +1,128 @@ +package billingadapter + +import ( + "context" + "crypto/sha256" + "time" + + "entgo.io/ent/dialect/sql" + "github.com/samber/lo" + + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/ent/db" + "github.com/openmeterio/openmeter/openmeter/ent/db/billinginvoicevalidationissue" + "github.com/openmeterio/openmeter/pkg/clock" +) + +type validationIssueWithDedupe struct { + issue billing.ValidationIssue + hash []byte +} + +func issueDedupeHash(issue billing.ValidationIssue) []byte { + algo := sha256.New() + + algo.Write([]byte(issue.Severity)) + algo.Write([]byte(issue.Code)) + algo.Write([]byte(issue.Message)) + algo.Write([]byte(issue.Component)) + algo.Write([]byte(issue.Path)) + return algo.Sum(nil) +} + +// persistValidationIssues persists the validation issues for the given invoice, it will remove any +// existing issues that are not present in the new list. It relies on consistent hashing to deduplicate +// issues. +func (a *adapter) persistValidationIssues(ctx context.Context, invoice billing.InvoiceID, issues []billing.ValidationIssue) error { + // FIXME (pmarton): Why do we need to deduplicate issues? + hashedIssues := lo.FindUniquesBy( + lo.Map(issues, func(issue billing.ValidationIssue, _ int) validationIssueWithDedupe { + return validationIssueWithDedupe{ + issue: issue, + hash: issueDedupeHash(issue), + } + }), + func(issue validationIssueWithDedupe) string { + return string(issue.hash) + }, + ) + + err := a.db.BillingInvoiceValidationIssue.Update(). + Where(billinginvoicevalidationissue.InvoiceID(invoice.ID)). + Where(billinginvoicevalidationissue.Namespace(invoice.Namespace)). + Where(billinginvoicevalidationissue.DedupeHashNotIn( + lo.Map(hashedIssues, func(hashedIssue validationIssueWithDedupe, _ int) []byte { + return hashedIssue.hash + })...)). + Where(billinginvoicevalidationissue.DeletedAtIsNil()). + SetDeletedAt(clock.Now()). + Exec(ctx) + if err != nil { + return err + } + + return a.db.BillingInvoiceValidationIssue.MapCreateBulk(hashedIssues, func(c *db.BillingInvoiceValidationIssueCreate, i int) { + hash := hashedIssues[i].hash + issue := hashedIssues[i].issue + + c.SetNamespace(invoice.Namespace). + SetInvoiceID(invoice.ID). + SetSeverity(issue.Severity). + SetMessage(issue.Message). + SetComponent(string(issue.Component)). + SetDedupeHash(hash) + if issue.Code != "" { + c.SetCode(issue.Code) + } + + if issue.Path != "" { + c.SetPath(issue.Path) + } + }).OnConflict( + sql.ConflictColumns( + billinginvoicevalidationissue.FieldNamespace, + billinginvoicevalidationissue.FieldInvoiceID, + billinginvoicevalidationissue.FieldDedupeHash, + ), + ). + UpdateNewValues(). + Update(func(u *db.BillingInvoiceValidationIssueUpsert) { + u.ClearDeletedAt() + u.SetUpdatedAt(clock.Now()) + }).Exec(ctx) +} + +type ValidationIssueWithDBMeta struct { + billing.ValidationIssue + + ID string `json:"id"` + DeletedAt *time.Time `json:"deletedAt,omitempty"` +} + +// IntropectValidationIssues returns the validation issues for the given invoice, this is not +// exposed via the adpter interface, as it's only used by tests to validate the state of the +// database. +func (a *adapter) IntrospectValidationIssues(ctx context.Context, invoice billing.InvoiceID) ([]ValidationIssueWithDBMeta, error) { + issues, err := a.db.BillingInvoiceValidationIssue.Query(). + Where(billinginvoicevalidationissue.InvoiceID(invoice.ID)). + Where(billinginvoicevalidationissue.Namespace(invoice.Namespace)). + Order(db.Asc(billinginvoicevalidationissue.FieldCreatedAt)). + All(ctx) + if err != nil { + return nil, err + } + + return lo.Map(issues, func(issue *db.BillingInvoiceValidationIssue, _ int) ValidationIssueWithDBMeta { + return ValidationIssueWithDBMeta{ + ValidationIssue: billing.ValidationIssue{ + Severity: issue.Severity, + Message: issue.Message, + Code: lo.FromPtr(issue.Code), + Component: billing.ComponentName(issue.Component), + Path: lo.FromPtr(issue.Path), + }, + ID: issue.ID, + DeletedAt: issue.DeletedAt, + } + }), nil +} diff --git a/billing/annotations.go b/billing/annotations.go new file mode 100644 index 0000000000000000000000000000000000000000..7f108457a9e638d7660ef14f115ad239b43e5ff2 --- /dev/null +++ b/billing/annotations.go @@ -0,0 +1,14 @@ +package billing + +const ( + // AnnotationSubscriptionSyncIgnore is used to mark a line or hierarchy as ignored in subscription syncing. + // Should be used in case there is a breaking change in the subscription synchronization process, preventing billing + // from issuing credit notes for the past periods. + AnnotationSubscriptionSyncIgnore = "billing.subscription.sync.ignore" + + // AnnotationSubscriptionSyncForceContinuousLines is used to force the creation of continuous subscription item lines. + // If the sync process finds a previously existing line with this annotation, and the next line generated will not start at the end of the previously + // found line, the sync process will adjust the start of the next line to the end of the previously found line, so that we don't have gaps in the + // invoices. + AnnotationSubscriptionSyncForceContinuousLines = "billing.subscription.sync.force-continuous-lines" +) diff --git a/billing/app.go b/billing/app.go new file mode 100644 index 0000000000000000000000000000000000000000..a6d7c7448c50b3202716bb91f34e3b215a26b82c --- /dev/null +++ b/billing/app.go @@ -0,0 +1,382 @@ +package billing + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/samber/lo" + "github.com/samber/mo" + + "github.com/openmeterio/openmeter/openmeter/app" + "github.com/openmeterio/openmeter/pkg/models" +) + +type UpsertResults struct { + invoiceNumber string + externalID string + + lineExternalIDs map[string]string + lineDiscountExternalIDs map[string]string +} + +func NewUpsertResults() *UpsertResults { + return &UpsertResults{ + lineExternalIDs: make(map[string]string), + lineDiscountExternalIDs: make(map[string]string), + } +} + +func (u *UpsertResults) GetInvoiceNumber() (string, bool) { + return u.invoiceNumber, u.invoiceNumber != "" +} + +func (u *UpsertResults) SetInvoiceNumber(invoiceNumber string) *UpsertResults { + u.invoiceNumber = invoiceNumber + return u +} + +func (u *UpsertResults) GetExternalID() (string, bool) { + return u.externalID, u.externalID != "" +} + +func (u *UpsertResults) SetExternalID(externalID string) *UpsertResults { + u.externalID = externalID + return u +} + +func (u *UpsertResults) AddLineExternalID(lineID string, externalID string) *UpsertResults { + u.lineExternalIDs[lineID] = externalID + return u +} + +func (u *UpsertResults) GetLineExternalID(lineID string) (string, bool) { + externalID, ok := u.lineExternalIDs[lineID] + return externalID, ok +} + +func (u *UpsertResults) GetLineExternalIDs() map[string]string { + return u.lineExternalIDs +} + +func (u *UpsertResults) AddLineDiscountExternalID(lineDiscountID string, externalID string) *UpsertResults { + u.lineDiscountExternalIDs[lineDiscountID] = externalID + return u +} + +func (u *UpsertResults) GetLineDiscountExternalID(lineDiscountID string) (string, bool) { + externalID, ok := u.lineDiscountExternalIDs[lineDiscountID] + return externalID, ok +} + +func (u *UpsertResults) GetLineDiscountExternalIDs() map[string]string { + return u.lineDiscountExternalIDs +} + +type UpsertStandardInvoiceResult = UpsertResults + +func NewUpsertStandardInvoiceResult() *UpsertStandardInvoiceResult { + return NewUpsertResults() +} + +type FinalizeStandardInvoiceResult struct { + invoiceNumber string + paymentExternalID string + sentToCustomerAt mo.Option[time.Time] +} + +func NewFinalizeStandardInvoiceResult() *FinalizeStandardInvoiceResult { + return &FinalizeStandardInvoiceResult{} +} + +func (f *FinalizeStandardInvoiceResult) GetPaymentExternalID() (string, bool) { + return f.paymentExternalID, f.paymentExternalID != "" +} + +func (f *FinalizeStandardInvoiceResult) SetPaymentExternalID(paymentExternalID string) *FinalizeStandardInvoiceResult { + f.paymentExternalID = paymentExternalID + return f +} + +func (u *FinalizeStandardInvoiceResult) GetInvoiceNumber() (string, bool) { + return u.invoiceNumber, u.invoiceNumber != "" +} + +func (f *FinalizeStandardInvoiceResult) SetInvoiceNumber(invoiceNumber string) *FinalizeStandardInvoiceResult { + f.invoiceNumber = invoiceNumber + return f +} + +func (f *FinalizeStandardInvoiceResult) GetSentToCustomerAt() (time.Time, bool) { + return f.sentToCustomerAt.OrEmpty(), f.sentToCustomerAt.IsPresent() +} + +func (f *FinalizeStandardInvoiceResult) SetSentToCustomerAt(sentToCustomerAt time.Time) *FinalizeStandardInvoiceResult { + f.sentToCustomerAt = mo.Some(sentToCustomerAt) + return f +} + +func (f *FinalizeStandardInvoiceResult) MergeIntoInvoice(invoice *StandardInvoice) error { + if paymentExternalID, ok := f.GetPaymentExternalID(); ok { + invoice.ExternalIDs.Payment = paymentExternalID + } + + if invoiceNumber, ok := f.GetInvoiceNumber(); ok { + invoice.Number = invoiceNumber + } + + if sentToCustomerAt, ok := f.GetSentToCustomerAt(); ok { + invoice.SentToCustomerAt = &sentToCustomerAt + } + + return nil +} + +type PostAdvanceHookResult struct { + trigger *InvoiceTriggerInput +} + +func NewPostAdvanceHookResult() *PostAdvanceHookResult { + return &PostAdvanceHookResult{} +} + +func (p *PostAdvanceHookResult) InvokeTrigger(trigger InvoiceTriggerInput) *PostAdvanceHookResult { + p.trigger = &trigger + return p +} + +func (p *PostAdvanceHookResult) GetTriggerToInvoke() *InvoiceTriggerInput { + return p.trigger +} + +// InvoicingApp is the interface that should be implemented by the app to handle the invoicing +// +// apps can also implement InvoicingAppPostAdvanceHook to perform additional actions after the invoice +// has been advanced + +// Warning: The received invoice is +// - read-only (e.g. any changes made to it are lost to prevent manipulation of the invoice state) +// - reflects the current in memory state of the invoice, thus if you fetched from the db +// an earlier version of the invoice will be passed, thus do not call any billingService methods +// from these callbacks. +type InvoicingApp interface { + // ValidateStandardInvoice validates if the app can run for the given invoice + ValidateStandardInvoice(ctx context.Context, invoice StandardInvoice) error + + // UpsertStandardInvoice upserts the invoice on the remote system, the invoice is read-only, the app should not modify it + // the recommended behavior is that the invoices FlattenLinesByID is used to get all lines, then the app should + // synchronize all the fee lines and store the external IDs in the result. + UpsertStandardInvoice(ctx context.Context, invoice StandardInvoice) (*UpsertStandardInvoiceResult, error) + + // FinalizeStandardInvoice finalizes the invoice on the remote system, starts the payment flow. It is safe to assume + // that the state machine have already performed an upsert as part of this state transition. + // + // If the payment is handled by a decoupled implementation (different app or app has strict separation of concerns) + // then the payment app will be called with FinalizePayment and that should return the external ID of the payment. (later) + FinalizeStandardInvoice(ctx context.Context, invoice StandardInvoice) (*FinalizeStandardInvoiceResult, error) + + // DeleteStandardInvoice deletes the invoice on the remote system, the invoice is read-only, the app should not modify it + // the invoice deletion is only invoked for non-finalized invoices. + DeleteStandardInvoice(ctx context.Context, invoice StandardInvoice) error +} + +type InvoicingAppPostAdvanceHook interface { + // PostAdvanceInvoiceHook is called after the invoice has been advanced to the next stable state + // (e.g. no next trigger is available) + // + // Can be used by the app to perform additional actions in case there are some post-processing steps + // required on the invoice. + PostAdvanceStandardInvoiceHook(ctx context.Context, invoice StandardInvoice) (*PostAdvanceHookResult, error) +} + +// InvoicingAppAsyncSyncer is an optional interface that can be implemented by the app to support +// asynchronous syncing of the invoice (e.g. when we are receiving the payload such as with custominvoicing app) +type InvoicingAppAsyncSyncer interface { + CanDraftSyncAdvance(invoice StandardInvoice) (bool, error) + CanIssuingSyncAdvance(invoice StandardInvoice) (bool, error) + // TODO: finalization check +} + +// GetApp returns the app from the app entity +func GetApp(app app.App) (InvoicingApp, error) { + customerApp, ok := app.(InvoicingApp) + if !ok { + return nil, AppError{ + AppID: app.GetID(), + AppType: app.GetType(), + Err: fmt.Errorf("is not an invoicing app"), + } + } + + return customerApp, nil +} + +// MergeIntoInvoice merges the upsert invoice result into the invoice. +func (r UpsertStandardInvoiceResult) MergeIntoInvoice(invoice *StandardInvoice) error { + // Let's merge the results into the invoice + if invoiceNumber, ok := r.GetInvoiceNumber(); ok { + invoice.Number = invoiceNumber + } + + if externalID, ok := r.GetExternalID(); ok { + invoice.ExternalIDs.Invoicing = externalID + } + + if !invoice.Lines.IsPresent() { + return errors.New("invoice has no expanded lines") + } + + var outErr error + + // Let's merge the line IDs + lineIDToExternalID := r.GetLineExternalIDs() + dicountIDToExternalID := r.GetLineDiscountExternalIDs() + + lines := invoice.Lines.OrEmpty() + + for _, line := range lines { + if externalID, ok := lineIDToExternalID[line.ID]; ok { + line.ExternalIDs.Invoicing = externalID + delete(lineIDToExternalID, line.ID) + } + + foundIDs := line.SetDiscountExternalIDs(dicountIDToExternalID) + for _, id := range foundIDs { + delete(dicountIDToExternalID, id) + } + + for idx, detailedLine := range line.DetailedLines { + if externalID, ok := lineIDToExternalID[detailedLine.ID]; ok { + line.DetailedLines[idx].ExternalIDs.Invoicing = externalID + delete(lineIDToExternalID, detailedLine.ID) + } + + foundIDs := line.DetailedLines[idx].SetDiscountExternalIDs(dicountIDToExternalID) + for _, id := range foundIDs { + delete(dicountIDToExternalID, id) + } + } + } + + if len(lineIDToExternalID) > 0 { + outErr = errors.Join(outErr, fmt.Errorf("some lines were not found in the invoice: ids=[%s]", strings.Join(lo.Keys(lineIDToExternalID), ", "))) + } + + if len(dicountIDToExternalID) > 0 { + outErr = errors.Join(outErr, fmt.Errorf("some line discounts were not found in the invoice: ids=[%s]", strings.Join(lo.Keys(dicountIDToExternalID), ", "))) + } + + return outErr +} + +type SyncInput interface { + models.Validator + + ValidateWithInvoice(invoice StandardInvoice) error + MergeIntoInvoice(invoice *StandardInvoice) error + GetAdditionalMetadata() map[string]string + GetInvoiceID() InvoiceID +} + +var _ SyncInput = (*SyncDraftStandardInvoiceInput)(nil) + +type SyncDraftStandardInvoiceInput struct { + InvoiceID InvoiceID + UpsertInvoiceResults *UpsertStandardInvoiceResult + AdditionalMetadata map[string]string + InvoiceValidator func(invoice StandardInvoice) error +} + +func (i SyncDraftStandardInvoiceInput) Validate() error { + var errs []error + + if err := i.InvoiceID.Validate(); err != nil { + errs = append(errs, err) + } + + if i.AdditionalMetadata == nil { + errs = append(errs, fmt.Errorf("additional metadata is required")) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +func (i SyncDraftStandardInvoiceInput) MergeIntoInvoice(invoice *StandardInvoice) error { + if invoice == nil { + return fmt.Errorf("invoice is required") + } + if i.UpsertInvoiceResults != nil { + return i.UpsertInvoiceResults.MergeIntoInvoice(invoice) + } + + return nil +} + +func (i SyncDraftStandardInvoiceInput) GetAdditionalMetadata() map[string]string { + return i.AdditionalMetadata +} + +func (i SyncDraftStandardInvoiceInput) GetInvoiceID() InvoiceID { + return i.InvoiceID +} + +func (i SyncDraftStandardInvoiceInput) ValidateWithInvoice(invoice StandardInvoice) error { + if i.InvoiceValidator != nil { + return i.InvoiceValidator(invoice) + } + + return nil +} + +var _ SyncInput = (*SyncIssuingStandardInvoiceInput)(nil) + +type SyncIssuingStandardInvoiceInput struct { + InvoiceID InvoiceID + FinalizeInvoiceResult *FinalizeStandardInvoiceResult + AdditionalMetadata map[string]string + InvoiceValidator func(invoice StandardInvoice) error +} + +func (i SyncIssuingStandardInvoiceInput) Validate() error { + var errs []error + + if err := i.InvoiceID.Validate(); err != nil { + errs = append(errs, err) + } + + if i.AdditionalMetadata == nil { + errs = append(errs, fmt.Errorf("additional metadata is required")) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +func (i SyncIssuingStandardInvoiceInput) MergeIntoInvoice(invoice *StandardInvoice) error { + if invoice == nil { + return fmt.Errorf("invoice is required") + } + + if i.FinalizeInvoiceResult != nil { + return i.FinalizeInvoiceResult.MergeIntoInvoice(invoice) + } + + return nil +} + +func (i SyncIssuingStandardInvoiceInput) GetAdditionalMetadata() map[string]string { + return i.AdditionalMetadata +} + +func (i SyncIssuingStandardInvoiceInput) GetInvoiceID() InvoiceID { + return i.InvoiceID +} + +func (i SyncIssuingStandardInvoiceInput) ValidateWithInvoice(invoice StandardInvoice) error { + if i.InvoiceValidator != nil { + return i.InvoiceValidator(invoice) + } + + return nil +} diff --git a/billing/change_source_test.go b/billing/change_source_test.go new file mode 100644 index 0000000000000000000000000000000000000000..794f8eafc976bdb31c4adcb8842041a499f74184 --- /dev/null +++ b/billing/change_source_test.go @@ -0,0 +1,15 @@ +package billing + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestChangeSourceRequire(t *testing.T) { + require.NoError(t, ChangeSourceSystem.Require(ChangeSourceSystem)) + require.NoError(t, ChangeSourceAPIRequest.Require(ChangeSourceAPIRequest)) + + require.ErrorContains(t, ChangeSourceAPIRequest.Require(ChangeSourceSystem), "must be system") + require.ErrorContains(t, ChangeSource("invalid").Require(ChangeSourceSystem), "invalid change source") +} diff --git a/billing/charges/adapter.go b/billing/charges/adapter.go new file mode 100644 index 0000000000000000000000000000000000000000..7a1614537c1e2411df15a5dc6d77dc345e3b61a0 --- /dev/null +++ b/billing/charges/adapter.go @@ -0,0 +1,59 @@ +package charges + +import ( + "context" + "errors" + "fmt" + + "github.com/openmeterio/openmeter/openmeter/billing/charges/meta" + "github.com/openmeterio/openmeter/openmeter/customer" + "github.com/openmeterio/openmeter/pkg/framework/entutils" + "github.com/openmeterio/openmeter/pkg/models" + "github.com/openmeterio/openmeter/pkg/pagination" +) + +type Adapter interface { + ChargesSearchAdapter + + entutils.TxCreator +} + +type ChargesSearchAdapter interface { + GetByIDs(ctx context.Context, input GetByIDsInput) (ChargeSearchItems, error) + ListCharges(ctx context.Context, input ListChargesInput) (pagination.Result[ChargeSearchItem], error) + ListCustomersToAdvance(ctx context.Context, input ListCustomersToAdvanceInput) (pagination.Result[customer.CustomerID], error) +} + +type ChargeSearchItem struct { + ID meta.ChargeID + Type meta.ChargeType + CustomerID string +} + +func (c *ChargeSearchItem) Validate() error { + var errs []error + if err := c.ID.Validate(); err != nil { + errs = append(errs, fmt.Errorf("id: %w", err)) + } + + if err := c.Type.Validate(); err != nil { + errs = append(errs, fmt.Errorf("type: %w", err)) + } + + if c.CustomerID == "" { + errs = append(errs, errors.New("customer ID is required")) + } + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +type ChargeSearchItems []ChargeSearchItem + +func (c ChargeSearchItems) Validate() error { + var errs []error + for idx, item := range c { + if err := item.Validate(); err != nil { + errs = append(errs, fmt.Errorf("item[%d]: %w", idx, err)) + } + } + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} diff --git a/billing/charges/adapter/adapter.go b/billing/charges/adapter/adapter.go new file mode 100644 index 0000000000000000000000000000000000000000..fdd9e175b8230df5dfd69e7c0ae87845f38cb1e2 --- /dev/null +++ b/billing/charges/adapter/adapter.go @@ -0,0 +1,72 @@ +package adapter + +import ( + "context" + "database/sql" + "errors" + "fmt" + "log/slog" + + "github.com/openmeterio/openmeter/openmeter/billing/charges" + entdb "github.com/openmeterio/openmeter/openmeter/ent/db" + "github.com/openmeterio/openmeter/pkg/framework/entutils" + "github.com/openmeterio/openmeter/pkg/framework/transaction" +) + +type Config struct { + Client *entdb.Client + Logger *slog.Logger +} + +func (c Config) Validate() error { + if c.Client == nil { + return errors.New("ent client is required") + } + + if c.Logger == nil { + return errors.New("logger is required") + } + + return nil +} + +func New(config Config) (charges.Adapter, error) { + if err := config.Validate(); err != nil { + return nil, err + } + + return &adapter{ + db: config.Client, + logger: config.Logger, + }, nil +} + +var _ charges.Adapter = (*adapter)(nil) + +type adapter struct { + db *entdb.Client + logger *slog.Logger +} + +func (a *adapter) Tx(ctx context.Context) (context.Context, transaction.Driver, error) { + txCtx, rawConfig, eDriver, err := a.db.HijackTx(ctx, &sql.TxOptions{ + ReadOnly: false, + }) + if err != nil { + return nil, nil, fmt.Errorf("failed to hijack transaction: %w", err) + } + return txCtx, entutils.NewTxDriver(eDriver, rawConfig), nil +} + +func (a *adapter) WithTx(ctx context.Context, tx *entutils.TxDriver) *adapter { + txDb := entdb.NewTxClientFromRawConfig(ctx, *tx.GetConfig()) + + return &adapter{ + db: txDb.Client(), + logger: a.logger, + } +} + +func (a *adapter) Self() *adapter { + return a +} diff --git a/billing/charges/adapter/search.go b/billing/charges/adapter/search.go new file mode 100644 index 0000000000000000000000000000000000000000..fcbee780415cbb06fb944adb9dd9b494bd8a9bf1 --- /dev/null +++ b/billing/charges/adapter/search.go @@ -0,0 +1,213 @@ +package adapter + +import ( + "context" + "fmt" + + "github.com/samber/lo" + + "github.com/openmeterio/openmeter/openmeter/billing/charges" + "github.com/openmeterio/openmeter/openmeter/billing/charges/meta" + "github.com/openmeterio/openmeter/openmeter/customer" + "github.com/openmeterio/openmeter/openmeter/ent/db" + dbchargessearchv1 "github.com/openmeterio/openmeter/openmeter/ent/db/chargessearchv1" + "github.com/openmeterio/openmeter/pkg/framework/entutils" + "github.com/openmeterio/openmeter/pkg/pagination" +) + +var _ charges.ChargesSearchAdapter = (*adapter)(nil) + +func (a *adapter) GetByIDs(ctx context.Context, input charges.GetByIDsInput) (charges.ChargeSearchItems, error) { + if err := input.Validate(); err != nil { + return nil, err + } + + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) (charges.ChargeSearchItems, error) { + dbCharges, err := tx.db.ChargesSearchV1.Query(). + Where(dbchargessearchv1.Namespace(input.Namespace)). + Where(dbchargessearchv1.IDIn(input.IDs...)). + All(ctx) + if err != nil { + return nil, err + } + + // Apply namespace filtering/ID checks + resultsInOrder, err := entutils.InIDOrder(input.Namespace, input.IDs, withIDAccessor(dbCharges)) + if err != nil { + return nil, err + } + + return lo.Map(resultsInOrder, func(result searchResultIDAccessor, _ int) charges.ChargeSearchItem { + return mapChargeSearchToChargeWithType(result.ChargesSearchV1) + }), nil + }) +} + +func (a *adapter) ListCharges(ctx context.Context, input charges.ListChargesInput) (pagination.Result[charges.ChargeSearchItem], error) { + if err := input.Validate(); err != nil { + return pagination.Result[charges.ChargeSearchItem]{}, err + } + + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) (pagination.Result[charges.ChargeSearchItem], error) { + query := tx.db.ChargesSearchV1.Query(). + Where(dbchargessearchv1.Namespace(input.Namespace)) + + if !input.IncludeDeleted { + if input.DeletedAtFilter == charges.ListChargesDeletedAtFilterBaseIntent { + query = query.Where(dbchargessearchv1.BaseIntentDeletedAtIsNil()) + } else { + query = query.Where(dbchargessearchv1.DeletedAtIsNil()) + } + } + + if len(input.CustomerIDs) > 0 { + query = query.Where(dbchargessearchv1.CustomerIDIn(input.CustomerIDs...)) + } + + if len(input.SubscriptionIDs) > 0 { + query = query.Where(dbchargessearchv1.SubscriptionIDIn(input.SubscriptionIDs...)) + } + + if len(input.ChargeTypes) > 0 { + query = query.Where(dbchargessearchv1.TypeIn(input.ChargeTypes...)) + } + + if len(input.StatusIn) > 0 { + query = query.Where(dbchargessearchv1.StatusIn(input.StatusIn...)) + } + + if len(input.StatusNotIn) > 0 { + query = query.Where(dbchargessearchv1.StatusNotIn(input.StatusNotIn...)) + } + + // Apply ordering: default to created_at asc with id as tie-breaker. + ord := entutils.GetOrdering(input.Order) + switch input.OrderBy { + case "id": + query = query.Order(dbchargessearchv1.ByID(ord...)) + case "service_period.from": + query = query.Order(dbchargessearchv1.ByServicePeriodFrom(ord...), dbchargessearchv1.ByID(ord...)) + case "billing_period.from": + query = query.Order(dbchargessearchv1.ByBillingPeriodFrom(ord...), dbchargessearchv1.ByID(ord...)) + default: // "created_at" or empty + query = query.Order(dbchargessearchv1.ByCreatedAt(ord...), dbchargessearchv1.ByID(ord...)) + } + + dbEntities, err := query.Paginate(ctx, input.Page) + if err != nil { + return pagination.Result[charges.ChargeSearchItem]{}, err + } + + return pagination.Result[charges.ChargeSearchItem]{ + Page: dbEntities.Page, + TotalCount: dbEntities.TotalCount, + Items: lo.Map(dbEntities.Items, func(item *db.ChargesSearchV1, _ int) charges.ChargeSearchItem { + return mapChargeSearchToChargeWithType(item) + }), + }, nil + }) +} + +func (a *adapter) ListCustomersToAdvance(ctx context.Context, input charges.ListCustomersToAdvanceInput) (pagination.Result[customer.CustomerID], error) { + if err := input.Validate(); err != nil { + return pagination.Result[customer.CustomerID]{}, err + } + + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) (pagination.Result[customer.CustomerID], error) { + query := tx.db.ChargesSearchV1.Query(). + Where( + dbchargessearchv1.DeletedAtIsNil(), + dbchargessearchv1.StatusNotIn(meta.ChargeStatusFinal, meta.ChargeStatusDeleted), + dbchargessearchv1.AdvanceAfterLTE(input.AdvanceAfterLTE), + ) + + if len(input.Namespaces) > 0 { + query = query.Where(dbchargessearchv1.NamespaceIn(input.Namespaces...)) + } + + var results []struct { + Namespace string `json:"namespace"` + CustomerID string `json:"customer_id"` + } + + err := query. + Order(dbchargessearchv1.ByNamespace(), dbchargessearchv1.ByCustomerID()). + GroupBy(dbchargessearchv1.FieldNamespace, dbchargessearchv1.FieldCustomerID). + Scan(ctx, &results) + if err != nil { + return pagination.Result[customer.CustomerID]{}, fmt.Errorf("list customers to advance: %w", err) + } + + // Apply pagination manually since GroupBy doesn't support Paginate directly + totalCount := len(results) + + page := input.Page + if page.IsZero() { + page = pagination.Page{ + PageSize: totalCount, + PageNumber: 1, + } + } + + start := page.Offset() + if start > totalCount { + start = totalCount + } + end := start + page.Limit() + if end > totalCount { + end = totalCount + } + + pageResults := results[start:end] + customers := make([]customer.CustomerID, 0, len(pageResults)) + for _, r := range pageResults { + customers = append(customers, customer.CustomerID{ + Namespace: r.Namespace, + ID: r.CustomerID, + }) + } + + return pagination.Result[customer.CustomerID]{ + Page: page, + TotalCount: totalCount, + Items: customers, + }, nil + }) +} + +func mapChargeSearchToChargeWithType(item *db.ChargesSearchV1) charges.ChargeSearchItem { + return charges.ChargeSearchItem{ + ID: meta.ChargeID{Namespace: item.Namespace, ID: item.ID}, + Type: item.Type, + CustomerID: item.CustomerID, + } +} + +var _ entutils.InIDOrderAccessor = (*searchResultIDAccessor)(nil) + +type searchResultIDAccessor struct { + *db.ChargesSearchV1 +} + +func (s searchResultIDAccessor) GetID() string { + return s.ID +} + +func (s searchResultIDAccessor) GetNamespace() string { + return s.Namespace +} + +func (s searchResultIDAccessor) GetChargeID() meta.ChargeID { + return meta.ChargeID{ + Namespace: s.Namespace, + ID: s.ID, + } +} + +func withIDAccessor(entity []*db.ChargesSearchV1) []searchResultIDAccessor { + return lo.Map(entity, func(entity *db.ChargesSearchV1, _ int) searchResultIDAccessor { + return searchResultIDAccessor{ + ChargesSearchV1: entity, + } + }) +} diff --git a/billing/charges/adapter/search_test.go b/billing/charges/adapter/search_test.go new file mode 100644 index 0000000000000000000000000000000000000000..3251f8fbf732529b646637189d4da12ceb31391e --- /dev/null +++ b/billing/charges/adapter/search_test.go @@ -0,0 +1,337 @@ +package adapter + +import ( + "context" + "log/slog" + "sort" + "testing" + "time" + + "github.com/alpacahq/alpacadecimal" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/billing/charges" + "github.com/openmeterio/openmeter/openmeter/billing/charges/flatfee" + "github.com/openmeterio/openmeter/openmeter/billing/charges/meta" + "github.com/openmeterio/openmeter/openmeter/customer" + "github.com/openmeterio/openmeter/openmeter/ent/db" + "github.com/openmeterio/openmeter/openmeter/productcatalog" + taxcodetestutils "github.com/openmeterio/openmeter/openmeter/taxcode/testutils" + "github.com/openmeterio/openmeter/openmeter/testutils" + "github.com/openmeterio/openmeter/pkg/currencyx" + "github.com/openmeterio/openmeter/pkg/pagination" +) + +func TestListCustomersToAdvance(t *testing.T) { + suite.Run(t, new(ListCustomersToAdvanceSuite)) +} + +type ListCustomersToAdvanceSuite struct { + suite.Suite + + testDB *testutils.TestDB + dbClient *db.Client + adapter charges.ChargesSearchAdapter + + taxCodeEnv *taxcodetestutils.TestEnv + taxCodeIDByNamespace map[string]string +} + +func (s *ListCustomersToAdvanceSuite) SetupSuite() { + t := s.T() + + s.testDB = testutils.InitPostgresDB(t, testutils.PostgresDBStateAtlasMigrated) + s.dbClient = db.NewClient(db.Driver(s.testDB.EntDriver.Driver())) + + a, err := New(Config{ + Client: s.dbClient, + Logger: slog.Default(), + }) + require.NoError(t, err) + s.adapter = a + + s.taxCodeEnv = taxcodetestutils.NewTestEnvFromClient(t, s.dbClient, slog.Default()) + s.taxCodeIDByNamespace = make(map[string]string) +} + +func (s *ListCustomersToAdvanceSuite) TearDownSuite() { + s.testDB.EntDriver.Close() + s.testDB.PGDriver.Close() +} + +// createCustomer creates a customer record and returns its generated ID. +func (s *ListCustomersToAdvanceSuite) createCustomer(namespace string) string { + s.T().Helper() + + c, err := s.dbClient.Customer.Create(). + SetNamespace(namespace). + SetName("test-customer"). + Save(context.Background()) + s.Require().NoError(err) + + return c.ID +} + +// insertFlatFeeCharge inserts a minimal flat fee charge row for testing the search view. +func (s *ListCustomersToAdvanceSuite) insertFlatFeeCharge(namespace, customerID string, status meta.ChargeStatus, advanceAfter *time.Time) string { + s.T().Helper() + + now := time.Now().UTC().Truncate(time.Microsecond) + taxCodeID, ok := s.taxCodeIDByNamespace[namespace] + if !ok { + taxCodeID = s.taxCodeEnv.CreateTaxCode(s.T(), namespace).ID + s.taxCodeIDByNamespace[namespace] = taxCodeID + } + + create := s.dbClient.ChargeFlatFee.Create(). + SetNamespace(namespace). + SetCustomerID(customerID). + SetStatus(status). + SetStatusDetailed(flatfee.Status(status)). + SetCurrency(currencyx.Code("USD")). + SetManagedBy(billing.SubscriptionManagedLine). + SetName("test-charge"). + SetPaymentTerm(productcatalog.InArrearsPaymentTerm). + SetInvoiceAt(now). + SetSettlementMode(productcatalog.CreditOnlySettlementMode). + SetTaxCodeID(taxCodeID). + SetProRating(flatfee.NoProratingAdapterMode). + SetAmountBeforeProration(alpacadecimal.NewFromInt(100)). + SetAmountAfterProration(alpacadecimal.NewFromInt(100)). + SetServicePeriodFrom(now). + SetServicePeriodTo(now.Add(time.Hour)). + SetBillingPeriodFrom(now). + SetBillingPeriodTo(now.Add(time.Hour)). + SetFullServicePeriodFrom(now). + SetFullServicePeriodTo(now.Add(time.Hour)) + + if advanceAfter != nil { + create = create.SetAdvanceAfter(*advanceAfter) + } + + charge, err := create.Save(context.Background()) + s.Require().NoError(err) + + return charge.ID +} + +func (s *ListCustomersToAdvanceSuite) TestListChargesDeletedAtFilter() { + ctx := s.T().Context() + ns := "test-list-charges-deleted-at-filter" + now := time.Now().UTC().Truncate(time.Microsecond) + deletedAt := now.Add(time.Minute) + + customerID := s.createCustomer(ns) + + liveChargeID := s.insertFlatFeeCharge(ns, customerID, meta.ChargeStatusActive, nil) + overrideDeletedChargeID := s.insertFlatFeeCharge(ns, customerID, meta.ChargeStatusActive, nil) + baseDeletedChargeID := s.insertFlatFeeCharge(ns, customerID, meta.ChargeStatusActive, nil) + + _, err := s.dbClient.ChargeFlatFee.UpdateOneID(overrideDeletedChargeID). + SetDeletedAt(deletedAt). + Save(ctx) + s.Require().NoError(err) + + _, err = s.dbClient.ChargeFlatFee.UpdateOneID(baseDeletedChargeID). + SetDeletedAt(deletedAt). + SetIntentDeletedAt(deletedAt). + Save(ctx) + s.Require().NoError(err) + + listIDs := func(input charges.ListChargesInput) []string { + s.T().Helper() + + input.Namespace = ns + input.ChargeTypes = []meta.ChargeType{meta.ChargeTypeFlatFee} + + result, err := s.adapter.ListCharges(ctx, input) + s.Require().NoError(err) + + out := make([]string, 0, len(result.Items)) + for _, item := range result.Items { + out = append(out, item.ID.ID) + } + + return out + } + + s.ElementsMatch([]string{liveChargeID}, listIDs(charges.ListChargesInput{})) + s.ElementsMatch([]string{liveChargeID}, listIDs(charges.ListChargesInput{ + DeletedAtFilter: charges.ListChargesDeletedAtFilterEffective, + })) + s.ElementsMatch([]string{liveChargeID, overrideDeletedChargeID}, listIDs(charges.ListChargesInput{ + DeletedAtFilter: charges.ListChargesDeletedAtFilterBaseIntent, + })) + s.ElementsMatch([]string{liveChargeID, overrideDeletedChargeID, baseDeletedChargeID}, listIDs(charges.ListChargesInput{ + IncludeDeleted: true, + })) +} + +func (s *ListCustomersToAdvanceSuite) TestReturnsOnlyEligibleCustomers() { + ctx := context.Background() + ns := "test-eligible" + now := time.Now().UTC().Truncate(time.Microsecond) + past := now.Add(-time.Hour) + future := now.Add(time.Hour) + + eligibleID := s.createCustomer(ns) + futureID := s.createCustomer(ns) + finalID := s.createCustomer(ns) + deletedID := s.createCustomer(ns) + nilID := s.createCustomer(ns) + + s.insertFlatFeeCharge(ns, eligibleID, meta.ChargeStatusActive, &past) + s.insertFlatFeeCharge(ns, futureID, meta.ChargeStatusActive, &future) + s.insertFlatFeeCharge(ns, finalID, meta.ChargeStatusFinal, &past) + s.insertFlatFeeCharge(ns, deletedID, meta.ChargeStatusDeleted, &past) + s.insertFlatFeeCharge(ns, nilID, meta.ChargeStatusActive, nil) + + result, err := s.adapter.ListCustomersToAdvance(ctx, charges.ListCustomersToAdvanceInput{ + Namespaces: []string{ns}, + AdvanceAfterLTE: now, + }) + s.Require().NoError(err) + + s.Require().Len(result.Items, 1) + s.Equal(customer.CustomerID{Namespace: ns, ID: eligibleID}, result.Items[0]) +} + +func (s *ListCustomersToAdvanceSuite) TestDeduplicatesCustomers() { + ctx := context.Background() + ns := "test-dedup" + past := time.Now().UTC().Add(-time.Hour).Truncate(time.Microsecond) + now := time.Now().UTC().Truncate(time.Microsecond) + + custID := s.createCustomer(ns) + + // Same customer with two charges + s.insertFlatFeeCharge(ns, custID, meta.ChargeStatusActive, &past) + s.insertFlatFeeCharge(ns, custID, meta.ChargeStatusActive, &past) + + result, err := s.adapter.ListCustomersToAdvance(ctx, charges.ListCustomersToAdvanceInput{ + Namespaces: []string{ns}, + AdvanceAfterLTE: now, + }) + s.Require().NoError(err) + + s.Require().Len(result.Items, 1) + s.Equal(custID, result.Items[0].ID) +} + +func (s *ListCustomersToAdvanceSuite) TestStableOrdering() { + ctx := context.Background() + past := time.Now().UTC().Add(-time.Hour).Truncate(time.Microsecond) + now := time.Now().UTC().Truncate(time.Microsecond) + + nsA := "test-order-a" + nsB := "test-order-b" + + custA1 := s.createCustomer(nsA) + custA2 := s.createCustomer(nsA) + custB1 := s.createCustomer(nsB) + custB2 := s.createCustomer(nsB) + + // Insert in deliberately non-sorted order + s.insertFlatFeeCharge(nsB, custB2, meta.ChargeStatusActive, &past) + s.insertFlatFeeCharge(nsA, custA1, meta.ChargeStatusActive, &past) + s.insertFlatFeeCharge(nsB, custB1, meta.ChargeStatusActive, &past) + s.insertFlatFeeCharge(nsA, custA2, meta.ChargeStatusActive, &past) + + result, err := s.adapter.ListCustomersToAdvance(ctx, charges.ListCustomersToAdvanceInput{ + Namespaces: []string{nsA, nsB}, + AdvanceAfterLTE: now, + }) + s.Require().NoError(err) + s.Require().Len(result.Items, 4) + + // Build expected order: sorted by (namespace, customer_id) + expected := []customer.CustomerID{ + {Namespace: nsA, ID: custA1}, + {Namespace: nsA, ID: custA2}, + {Namespace: nsB, ID: custB1}, + {Namespace: nsB, ID: custB2}, + } + sort.Slice(expected, func(i, j int) bool { + if expected[i].Namespace != expected[j].Namespace { + return expected[i].Namespace < expected[j].Namespace + } + return expected[i].ID < expected[j].ID + }) + + s.Equal(expected, result.Items) +} + +func (s *ListCustomersToAdvanceSuite) TestPagination() { + ctx := context.Background() + ns := "test-pagination" + past := time.Now().UTC().Add(-time.Hour).Truncate(time.Microsecond) + now := time.Now().UTC().Truncate(time.Microsecond) + + // Create 5 customers and collect their sorted IDs + var custIDs []string + for i := 0; i < 5; i++ { + custIDs = append(custIDs, s.createCustomer(ns)) + } + sort.Strings(custIDs) + + for _, id := range custIDs { + s.insertFlatFeeCharge(ns, id, meta.ChargeStatusActive, &past) + } + + // Page 1: size 2 + result, err := s.adapter.ListCustomersToAdvance(ctx, charges.ListCustomersToAdvanceInput{ + Page: pagination.Page{PageSize: 2, PageNumber: 1}, + Namespaces: []string{ns}, + AdvanceAfterLTE: now, + }) + s.Require().NoError(err) + s.Require().Len(result.Items, 2) + s.Equal(custIDs[0], result.Items[0].ID) + s.Equal(custIDs[1], result.Items[1].ID) + + // Page 2: size 2 + result, err = s.adapter.ListCustomersToAdvance(ctx, charges.ListCustomersToAdvanceInput{ + Page: pagination.Page{PageSize: 2, PageNumber: 2}, + Namespaces: []string{ns}, + AdvanceAfterLTE: now, + }) + s.Require().NoError(err) + s.Require().Len(result.Items, 2) + s.Equal(custIDs[2], result.Items[0].ID) + s.Equal(custIDs[3], result.Items[1].ID) + + // Page 3: size 2 - last page with 1 item + result, err = s.adapter.ListCustomersToAdvance(ctx, charges.ListCustomersToAdvanceInput{ + Page: pagination.Page{PageSize: 2, PageNumber: 3}, + Namespaces: []string{ns}, + AdvanceAfterLTE: now, + }) + s.Require().NoError(err) + s.Require().Len(result.Items, 1) + s.Equal(custIDs[4], result.Items[0].ID) +} + +func (s *ListCustomersToAdvanceSuite) TestNamespaceFilter() { + ctx := context.Background() + past := time.Now().UTC().Add(-time.Hour).Truncate(time.Microsecond) + now := time.Now().UTC().Truncate(time.Microsecond) + + includeID := s.createCustomer("ns-include") + s.createCustomer("ns-exclude") + excludeID := s.createCustomer("ns-exclude") + + s.insertFlatFeeCharge("ns-include", includeID, meta.ChargeStatusActive, &past) + s.insertFlatFeeCharge("ns-exclude", excludeID, meta.ChargeStatusActive, &past) + + result, err := s.adapter.ListCustomersToAdvance(ctx, charges.ListCustomersToAdvanceInput{ + Namespaces: []string{"ns-include"}, + AdvanceAfterLTE: now, + }) + s.Require().NoError(err) + + s.Require().Len(result.Items, 1) + s.Equal("ns-include", result.Items[0].Namespace) + s.Equal(includeID, result.Items[0].ID) +} diff --git a/billing/charges/charge.go b/billing/charges/charge.go new file mode 100644 index 0000000000000000000000000000000000000000..38002aa0a25c1ac9e67331db5f01f16a5bfa3d26 --- /dev/null +++ b/billing/charges/charge.go @@ -0,0 +1,544 @@ +package charges + +import ( + "errors" + "fmt" + + "github.com/samber/lo" + + "github.com/openmeterio/openmeter/openmeter/billing/charges/creditpurchase" + "github.com/openmeterio/openmeter/openmeter/billing/charges/flatfee" + "github.com/openmeterio/openmeter/openmeter/billing/charges/meta" + "github.com/openmeterio/openmeter/openmeter/billing/charges/usagebased" + "github.com/openmeterio/openmeter/openmeter/customer" + "github.com/openmeterio/openmeter/openmeter/productcatalog" + "github.com/openmeterio/openmeter/pkg/currencyx" + "github.com/openmeterio/openmeter/pkg/framework/entutils" + "github.com/openmeterio/openmeter/pkg/models" +) + +type Charge struct { + t meta.ChargeType + + flatFee *flatfee.Charge + usageBased *usagebased.Charge + creditPurchase *creditpurchase.Charge +} + +func (c Charge) Type() meta.ChargeType { + return c.t +} + +func NewCharge[T flatfee.Charge | usagebased.Charge | creditpurchase.Charge](ch T) Charge { + switch v := any(ch).(type) { + case flatfee.Charge: + return Charge{ + t: meta.ChargeTypeFlatFee, + flatFee: &v, + } + case creditpurchase.Charge: + return Charge{ + t: meta.ChargeTypeCreditPurchase, + creditPurchase: &v, + } + case usagebased.Charge: + return Charge{ + t: meta.ChargeTypeUsageBased, + usageBased: &v, + } + } + + return Charge{} +} + +func (c Charge) Validate() error { + switch c.t { + case meta.ChargeTypeFlatFee: + if c.flatFee == nil { + return models.NewGenericValidationError(fmt.Errorf("flat fee charge is nil")) + } + + return c.flatFee.Validate() + case meta.ChargeTypeCreditPurchase: + if c.creditPurchase == nil { + return models.NewGenericValidationError(fmt.Errorf("credit purchase charge is nil")) + } + + return c.creditPurchase.Validate() + case meta.ChargeTypeUsageBased: + if c.usageBased == nil { + return models.NewGenericValidationError(fmt.Errorf("usage based charge is nil")) + } + + return c.usageBased.Validate() + } + + return models.NewGenericValidationError(fmt.Errorf("invalid charge type: %s", c.t)) +} + +func (c Charge) AsFlatFeeCharge() (flatfee.Charge, error) { + if c.t != meta.ChargeTypeFlatFee { + return flatfee.Charge{}, fmt.Errorf("charge is not a flat fee charge") + } + + if c.flatFee == nil { + return flatfee.Charge{}, fmt.Errorf("flat fee charge is nil") + } + + return *c.flatFee, nil +} + +func (c Charge) AsCreditPurchaseCharge() (creditpurchase.Charge, error) { + if c.t != meta.ChargeTypeCreditPurchase { + return creditpurchase.Charge{}, fmt.Errorf("charge is not a credit purchase charge") + } + + if c.creditPurchase == nil { + return creditpurchase.Charge{}, fmt.Errorf("credit purchase charge is nil") + } + + return *c.creditPurchase, nil +} + +func (c Charge) AsUsageBasedCharge() (usagebased.Charge, error) { + if c.t != meta.ChargeTypeUsageBased { + return usagebased.Charge{}, fmt.Errorf("charge is not a usage based charge") + } + + if c.usageBased == nil { + return usagebased.Charge{}, fmt.Errorf("usage based charge is nil") + } + + return *c.usageBased, nil +} + +func (c Charge) GetChargeID() (meta.ChargeID, error) { + switch c.t { + case meta.ChargeTypeFlatFee: + if c.flatFee == nil { + return meta.ChargeID{}, fmt.Errorf("flat fee charge is nil") + } + + return c.flatFee.GetChargeID(), nil + case meta.ChargeTypeCreditPurchase: + if c.creditPurchase == nil { + return meta.ChargeID{}, fmt.Errorf("credit purchase charge is nil") + } + + return c.creditPurchase.GetChargeID(), nil + case meta.ChargeTypeUsageBased: + if c.usageBased == nil { + return meta.ChargeID{}, fmt.Errorf("usage based charge is nil") + } + + return c.usageBased.GetChargeID(), nil + } + + return meta.ChargeID{}, fmt.Errorf("invalid charge type: %s", c.t) +} + +func (c Charge) GetUniqueReferenceID() (*string, error) { + switch c.t { + case meta.ChargeTypeFlatFee: + if c.flatFee == nil { + return nil, fmt.Errorf("flat fee charge is nil") + } + + return c.flatFee.Intent.GetUniqueReferenceID(), nil + case meta.ChargeTypeCreditPurchase: + if c.creditPurchase == nil { + return nil, fmt.Errorf("credit purchase charge is nil") + } + + return c.creditPurchase.Intent.UniqueReferenceID, nil + case meta.ChargeTypeUsageBased: + if c.usageBased == nil { + return nil, fmt.Errorf("usage based charge is nil") + } + + return c.usageBased.Intent.GetUniqueReferenceID(), nil + } + + return nil, fmt.Errorf("invalid charge type: %s", c.t) +} + +func (c Charge) GetCustomerID() (customer.CustomerID, error) { + switch c.t { + case meta.ChargeTypeFlatFee: + if c.flatFee == nil { + return customer.CustomerID{}, fmt.Errorf("flat fee charge is nil") + } + + return c.flatFee.GetCustomerID(), nil + case meta.ChargeTypeCreditPurchase: + if c.creditPurchase == nil { + return customer.CustomerID{}, fmt.Errorf("credit purchase charge is nil") + } + + return c.creditPurchase.GetCustomerID(), nil + case meta.ChargeTypeUsageBased: + if c.usageBased == nil { + return customer.CustomerID{}, fmt.Errorf("usage based charge is nil") + } + + return c.usageBased.GetCustomerID(), nil + } + + return customer.CustomerID{}, fmt.Errorf("invalid charge type: %s", c.t) +} + +func (c Charge) GetCurrency() (currencyx.Code, error) { + switch c.t { + case meta.ChargeTypeFlatFee: + if c.flatFee == nil { + return "", fmt.Errorf("flat fee charge is nil") + } + + return c.flatFee.GetCurrency(), nil + case meta.ChargeTypeCreditPurchase: + if c.creditPurchase == nil { + return "", fmt.Errorf("credit purchase charge is nil") + } + + return c.creditPurchase.GetCurrency(), nil + case meta.ChargeTypeUsageBased: + if c.usageBased == nil { + return "", fmt.Errorf("usage based charge is nil") + } + + return c.usageBased.GetCurrency(), nil + } + + return "", fmt.Errorf("invalid charge type: %s", c.t) +} + +func (c Charge) SettlementMode() (productcatalog.SettlementMode, error) { + switch c.t { + case meta.ChargeTypeFlatFee: + if c.flatFee == nil { + return "", fmt.Errorf("flat fee charge is nil") + } + + return c.flatFee.Intent.GetSettlementMode(), nil + case meta.ChargeTypeUsageBased: + if c.usageBased == nil { + return "", fmt.Errorf("usage based charge is nil") + } + + return c.usageBased.Intent.GetSettlementMode(), nil + default: + return "", fmt.Errorf("settlement mode is not supported for charge type %s", c.t) + } +} + +var _ entutils.InIDOrderAccessor = (*Charge)(nil) + +func (c Charge) GetID() string { + id, err := c.GetChargeID() + if err != nil { + return "" + } + + return id.ID +} + +func (c Charge) GetNamespace() string { + id, err := c.GetChargeID() + if err != nil { + return "" + } + + return id.Namespace +} + +type Charges []Charge + +func (c Charges) Validate() error { + var errs []error + + for i, ch := range c { + if err := ch.Validate(); err != nil { + errs = append(errs, fmt.Errorf("charge [%d]: %w", i, err)) + } + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +type ChargeIntent struct { + t meta.ChargeType + + flatFee *flatfee.Intent + creditPurchase *creditpurchase.Intent + usageBased *usagebased.Intent +} + +func NewChargeIntent[T flatfee.Intent | usagebased.Intent | creditpurchase.Intent](ch T) ChargeIntent { + switch v := any(ch).(type) { + case flatfee.Intent: + return ChargeIntent{ + t: meta.ChargeTypeFlatFee, + flatFee: &v, + } + case creditpurchase.Intent: + return ChargeIntent{ + t: meta.ChargeTypeCreditPurchase, + creditPurchase: &v, + } + case usagebased.Intent: + return ChargeIntent{ + t: meta.ChargeTypeUsageBased, + usageBased: &v, + } + } + + return ChargeIntent{} +} + +func (i ChargeIntent) Type() meta.ChargeType { + return i.t +} + +func (i ChargeIntent) Validate() error { + switch i.t { + case meta.ChargeTypeFlatFee: + if i.flatFee == nil { + return models.NewGenericValidationError(fmt.Errorf("flat fee is nil")) + } + + return i.flatFee.Validate() + case meta.ChargeTypeCreditPurchase: + if i.creditPurchase == nil { + return models.NewGenericValidationError(fmt.Errorf("credit purchase is nil")) + } + + return i.creditPurchase.Validate() + case meta.ChargeTypeUsageBased: + if i.usageBased == nil { + return models.NewGenericValidationError(fmt.Errorf("usage based is nil")) + } + + return i.usageBased.Validate() + } + + return models.NewGenericValidationError(fmt.Errorf("invalid charge type: %s", i.t)) +} + +func (i ChargeIntent) AsFlatFeeIntent() (flatfee.Intent, error) { + if i.t != meta.ChargeTypeFlatFee { + return flatfee.Intent{}, fmt.Errorf("charge is not a flat fee charge") + } + + if i.flatFee == nil { + return flatfee.Intent{}, fmt.Errorf("flat fee is nil") + } + + return *i.flatFee, nil +} + +func (i ChargeIntent) AsCreditPurchaseIntent() (creditpurchase.Intent, error) { + if i.t != meta.ChargeTypeCreditPurchase { + return creditpurchase.Intent{}, fmt.Errorf("charge is not a credit purchase charge") + } + + if i.creditPurchase == nil { + return creditpurchase.Intent{}, fmt.Errorf("credit purchase is nil") + } + + return *i.creditPurchase, nil +} + +func (i ChargeIntent) AsUsageBasedIntent() (usagebased.Intent, error) { + if i.t != meta.ChargeTypeUsageBased { + return usagebased.Intent{}, fmt.Errorf("charge is not a usage based charge") + } + + if i.usageBased == nil { + return usagebased.Intent{}, fmt.Errorf("usage based is nil") + } + + return *i.usageBased, nil +} + +func (c ChargeIntent) GetUniqueReferenceID() (*string, error) { + switch c.t { + case meta.ChargeTypeFlatFee: + if c.flatFee == nil { + return nil, fmt.Errorf("flat fee charge is nil") + } + + return c.flatFee.Intent.UniqueReferenceID, nil + case meta.ChargeTypeCreditPurchase: + if c.creditPurchase == nil { + return nil, fmt.Errorf("credit purchase charge is nil") + } + + return c.creditPurchase.Intent.UniqueReferenceID, nil + case meta.ChargeTypeUsageBased: + if c.usageBased == nil { + return nil, fmt.Errorf("usage based charge is nil") + } + + return c.usageBased.Intent.UniqueReferenceID, nil + } + + return nil, fmt.Errorf("invalid charge type: %s", c.t) +} + +// TaxCodeID returns the intent's configured tax code ID. +// It is empty when no tax code is set. +func (i ChargeIntent) TaxCodeID() (string, error) { + switch i.t { + case meta.ChargeTypeFlatFee: + if i.flatFee == nil { + return "", fmt.Errorf("flat fee is nil") + } + + return i.flatFee.TaxConfig.TaxCodeID, nil + case meta.ChargeTypeUsageBased: + if i.usageBased == nil { + return "", fmt.Errorf("usage based is nil") + } + + return i.usageBased.TaxConfig.TaxCodeID, nil + case meta.ChargeTypeCreditPurchase: + if i.creditPurchase == nil { + return "", fmt.Errorf("credit purchase is nil") + } + + return i.creditPurchase.TaxConfig.TaxCodeID, nil + } + + return "", fmt.Errorf("unsupported charge type: %s", i.t) +} + +// WithTaxCodeID returns a copy of the intent with TaxCodeID set to id. +// Existing tax behavior and other intent fields are preserved. +func (i ChargeIntent) WithTaxCodeID(id string) (ChargeIntent, error) { + switch i.t { + case meta.ChargeTypeFlatFee: + if i.flatFee == nil { + return ChargeIntent{}, fmt.Errorf("flat fee is nil") + } + + intent := *i.flatFee + intent.TaxConfig.TaxCodeID = id + + return NewChargeIntent(intent), nil + case meta.ChargeTypeUsageBased: + if i.usageBased == nil { + return ChargeIntent{}, fmt.Errorf("usage based is nil") + } + + intent := *i.usageBased + intent.TaxConfig.TaxCodeID = id + + return NewChargeIntent(intent), nil + case meta.ChargeTypeCreditPurchase: + if i.creditPurchase == nil { + return ChargeIntent{}, fmt.Errorf("credit purchase is nil") + } + + intent := *i.creditPurchase + intent.TaxConfig.TaxCodeID = id + + return NewChargeIntent(intent), nil + } + + return ChargeIntent{}, fmt.Errorf("unsupported charge type: %s", i.t) +} + +type ChargeIntents []ChargeIntent + +func (i ChargeIntents) Validate() error { + var errs []error + + for idx, ch := range i { + if err := ch.Validate(); err != nil { + errs = append(errs, fmt.Errorf("[%d]: %w", idx, err)) + } + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +func (i ChargeIntents) CollectFeatureKeys() ([]string, error) { + keys := make([]string, 0, len(i)) + + for idx, ch := range i { + switch ch.Type() { + case meta.ChargeTypeFlatFee: + flatFee, err := ch.AsFlatFeeIntent() + if err != nil { + return nil, fmt.Errorf("converting flat fee intent[%d]: %w", idx, err) + } + if flatFee.FeatureKey != nil && *flatFee.FeatureKey != "" { + keys = append(keys, *flatFee.FeatureKey) + } + case meta.ChargeTypeUsageBased: + usageBased, err := ch.AsUsageBasedIntent() + if err != nil { + return nil, fmt.Errorf("converting usage based intent[%d]: %w", idx, err) + } + if usageBased.FeatureKey != "" { + keys = append(keys, usageBased.FeatureKey) + } + case meta.ChargeTypeCreditPurchase: + continue + default: + return nil, fmt.Errorf("unsupported charge type[%d]: %s", idx, ch.Type()) + } + } + + return lo.Uniq(keys), nil +} + +type ChargeIntentsByType struct { + FlatFee []WithIndex[flatfee.Intent] + CreditPurchase []WithIndex[creditpurchase.Intent] + UsageBased []WithIndex[usagebased.Intent] +} + +func (i ChargeIntents) ByType() (ChargeIntentsByType, error) { + out := ChargeIntentsByType{ + FlatFee: make([]WithIndex[flatfee.Intent], 0, len(i)), + CreditPurchase: make([]WithIndex[creditpurchase.Intent], 0, len(i)), + UsageBased: make([]WithIndex[usagebased.Intent], 0, len(i)), + } + + for idx, ch := range i { + switch ch.Type() { + case meta.ChargeTypeFlatFee: + if ch.flatFee == nil { + return ChargeIntentsByType{}, fmt.Errorf("flat fee intent[%d] is nil", idx) + } + + out.FlatFee = append(out.FlatFee, WithIndex[flatfee.Intent]{ + Index: idx, + Value: *ch.flatFee, + }) + case meta.ChargeTypeCreditPurchase: + if ch.creditPurchase == nil { + return ChargeIntentsByType{}, fmt.Errorf("credit purchase intent[%d] is nil", idx) + } + + out.CreditPurchase = append(out.CreditPurchase, WithIndex[creditpurchase.Intent]{ + Index: idx, + Value: *ch.creditPurchase, + }) + case meta.ChargeTypeUsageBased: + if ch.usageBased == nil { + return ChargeIntentsByType{}, fmt.Errorf("usage based intent[%d] is nil", idx) + } + + out.UsageBased = append(out.UsageBased, WithIndex[usagebased.Intent]{ + Index: idx, + Value: *ch.usageBased, + }) + default: + return ChargeIntentsByType{}, fmt.Errorf("unsupported charge type[%d]: %s", idx, ch.Type()) + } + } + + return out, nil +} diff --git a/billing/charges/creditpurchase/adapter.go b/billing/charges/creditpurchase/adapter.go new file mode 100644 index 0000000000000000000000000000000000000000..3927a366af9e92faaf74dc5a563d23152399c980 --- /dev/null +++ b/billing/charges/creditpurchase/adapter.go @@ -0,0 +1,227 @@ +package creditpurchase + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/openmeterio/openmeter/openmeter/billing/charges/meta" + "github.com/openmeterio/openmeter/openmeter/billing/charges/models/ledgertransaction" + "github.com/openmeterio/openmeter/openmeter/billing/charges/models/payment" + "github.com/openmeterio/openmeter/pkg/currencyx" + "github.com/openmeterio/openmeter/pkg/filter" + "github.com/openmeterio/openmeter/pkg/framework/entutils" + "github.com/openmeterio/openmeter/pkg/models" + "github.com/openmeterio/openmeter/pkg/pagination" +) + +type Adapter interface { + ChargeAdapter + CreditGrantAdapter + ExternalPaymentAdapter + InvoicedPaymentAdapter + + entutils.TxCreator +} + +type ChargeAdapter interface { + CreateCharge(ctx context.Context, in CreateChargeInput) (Charge, error) + UpdateCharge(ctx context.Context, charge ChargeBase) (ChargeBase, error) + MarkVoided(ctx context.Context, input MarkVoidedInput) (ChargeBase, error) + GetByIDs(ctx context.Context, ids GetByIDsInput) ([]Charge, error) + GetByID(ctx context.Context, id GetByIDInput) (Charge, error) + ListCharges(ctx context.Context, input ListChargesInput) (pagination.Result[Charge], error) + ListFundedCreditActivities(ctx context.Context, input ListFundedCreditActivitiesInput) (ListFundedCreditActivitiesResult, error) +} + +type ExternalPaymentAdapter interface { + CreateExternalPayment(ctx context.Context, chargeID meta.ChargeID, payment payment.ExternalCreateInput) (payment.External, error) + UpdateExternalPayment(ctx context.Context, payment payment.External) (payment.External, error) +} + +type CreditGrantAdapter interface { + CreateCreditGrant(ctx context.Context, chargeID meta.ChargeID, input CreateCreditGrantInput) (ledgertransaction.TimedGroupReference, error) +} + +type InvoicedPaymentAdapter interface { + CreateInvoicedPayment(ctx context.Context, chargeID meta.ChargeID, payment payment.InvoicedCreate) (payment.Invoiced, error) + UpdateInvoicedPayment(ctx context.Context, payment payment.Invoiced) (payment.Invoiced, error) +} + +type GetByIDsInput struct { + Namespace string + IDs []string + + Expands meta.Expands +} + +func (i GetByIDsInput) Validate() error { + var errs []error + + if i.Namespace == "" { + errs = append(errs, errors.New("namespace is required")) + } + + for _, id := range i.IDs { + if id == "" { + errs = append(errs, errors.New("id is required")) + } + } + + if err := i.Expands.Validate(); err != nil { + errs = append(errs, fmt.Errorf("expands: %w", err)) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +type GetByIDInput struct { + ChargeID meta.ChargeID + Expands meta.Expands +} + +func (i GetByIDInput) Validate() error { + var errs []error + if err := i.ChargeID.Validate(); err != nil { + errs = append(errs, fmt.Errorf("charge ID: %w", err)) + } + + if err := i.Expands.Validate(); err != nil { + errs = append(errs, fmt.Errorf("expands: %w", err)) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +type MarkVoidedInput struct { + ChargeID meta.ChargeID + VoidedAt time.Time +} + +func (i MarkVoidedInput) Validate() error { + var errs []error + + if err := i.ChargeID.Validate(); err != nil { + errs = append(errs, fmt.Errorf("charge ID: %w", err)) + } + + if i.VoidedAt.IsZero() { + errs = append(errs, errors.New("voided at is required")) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +type CreateChargeInput struct { + Namespace string + Intent Intent +} + +func (i CreateChargeInput) Validate() error { + var errs []error + if i.Namespace == "" { + errs = append(errs, errors.New("namespace is required")) + } + + if err := i.Intent.Validate(); err != nil { + errs = append(errs, fmt.Errorf("intent: %w", err)) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +type ListChargesInput struct { + pagination.Page + + Namespace string + CustomerIDs []string + + // Optional filters + Statuses []meta.ChargeStatus + Currencies []currencyx.Code + Key *filter.FilterString + // Voided filters by whether the charge has been voided. + Voided *bool + // Expiration filters by whether expires_at has passed as of a point in time. + Expiration *ListChargesExpirationFilter + + IncludeDeleted bool + Expands meta.Expands +} + +type ListChargesExpirationFilter struct { + AsOf time.Time + Expired bool +} + +func (f ListChargesExpirationFilter) Validate() error { + if f.AsOf.IsZero() { + return errors.New("as of is required") + } + + return nil +} + +func (i ListChargesInput) Validate() error { + var errs []error + + if i.Namespace == "" { + errs = append(errs, errors.New("namespace is required")) + } + + for _, customerID := range i.CustomerIDs { + if customerID == "" { + errs = append(errs, errors.New("customer id is required")) + } + } + + for _, status := range i.Statuses { + if err := status.Validate(); err != nil { + errs = append(errs, fmt.Errorf("status: %w", err)) + } + } + + for _, currency := range i.Currencies { + if err := currency.Validate(); err != nil { + errs = append(errs, fmt.Errorf("currency: %w", err)) + } + } + + if i.Key != nil { + if err := i.Key.Validate(); err != nil { + errs = append(errs, fmt.Errorf("key: %w", err)) + } + } + + if i.Expiration != nil { + if err := i.Expiration.Validate(); err != nil { + errs = append(errs, fmt.Errorf("expiration: %w", err)) + } + } + + if err := i.Expands.Validate(); err != nil { + errs = append(errs, fmt.Errorf("expands: %w", err)) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +type CreateCreditGrantInput struct { + TransactionGroupID string + GrantedAt time.Time +} + +func (i CreateCreditGrantInput) Validate() error { + var errs []error + + if i.TransactionGroupID == "" { + errs = append(errs, errors.New("transaction group ID is required")) + } + + if i.GrantedAt.IsZero() { + errs = append(errs, errors.New("granted at is required")) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} diff --git a/billing/charges/creditpurchase/adapter/adapter.go b/billing/charges/creditpurchase/adapter/adapter.go new file mode 100644 index 0000000000000000000000000000000000000000..21b0a60c247f00d2304385f0dbfa0e2aac2c4c09 --- /dev/null +++ b/billing/charges/creditpurchase/adapter/adapter.go @@ -0,0 +1,81 @@ +package adapter + +import ( + "context" + "database/sql" + "errors" + "fmt" + "log/slog" + + "github.com/openmeterio/openmeter/openmeter/billing/charges/creditpurchase" + "github.com/openmeterio/openmeter/openmeter/billing/charges/meta" + entdb "github.com/openmeterio/openmeter/openmeter/ent/db" + "github.com/openmeterio/openmeter/pkg/framework/entutils" + "github.com/openmeterio/openmeter/pkg/framework/transaction" +) + +type Config struct { + MetaAdapter meta.Adapter + Client *entdb.Client + Logger *slog.Logger +} + +func (c Config) Validate() error { + if c.Client == nil { + return errors.New("ent client is required") + } + + if c.Logger == nil { + return errors.New("logger is required") + } + + if c.MetaAdapter == nil { + return errors.New("meta adapter is required") + } + + return nil +} + +func New(config Config) (creditpurchase.Adapter, error) { + if err := config.Validate(); err != nil { + return nil, err + } + + return &adapter{ + db: config.Client, + logger: config.Logger, + metaAdapter: config.MetaAdapter, + }, nil +} + +var _ creditpurchase.Adapter = (*adapter)(nil) + +type adapter struct { + db *entdb.Client + logger *slog.Logger + metaAdapter meta.Adapter +} + +func (a *adapter) Tx(ctx context.Context) (context.Context, transaction.Driver, error) { + txCtx, rawConfig, eDriver, err := a.db.HijackTx(ctx, &sql.TxOptions{ + ReadOnly: false, + }) + if err != nil { + return nil, nil, fmt.Errorf("failed to hijack transaction: %w", err) + } + return txCtx, entutils.NewTxDriver(eDriver, rawConfig), nil +} + +func (a *adapter) WithTx(ctx context.Context, tx *entutils.TxDriver) *adapter { + txDb := entdb.NewTxClientFromRawConfig(ctx, *tx.GetConfig()) + + return &adapter{ + db: txDb.Client(), + logger: a.logger, + metaAdapter: a.metaAdapter, + } +} + +func (a *adapter) Self() *adapter { + return a +} diff --git a/billing/charges/creditpurchase/adapter/charge.go b/billing/charges/creditpurchase/adapter/charge.go new file mode 100644 index 0000000000000000000000000000000000000000..8e33e4ba095d6faa75903025ebe0e7ed8c4d657e --- /dev/null +++ b/billing/charges/creditpurchase/adapter/charge.go @@ -0,0 +1,258 @@ +package adapter + +import ( + "context" + "fmt" + + "github.com/lib/pq" + + "github.com/openmeterio/openmeter/openmeter/billing/charges/creditpurchase" + "github.com/openmeterio/openmeter/openmeter/billing/charges/meta" + metaadapter "github.com/openmeterio/openmeter/openmeter/billing/charges/meta/adapter" + "github.com/openmeterio/openmeter/openmeter/billing/charges/models/chargemeta" + "github.com/openmeterio/openmeter/openmeter/ent/db" + dbchargecreditpurchase "github.com/openmeterio/openmeter/openmeter/ent/db/chargecreditpurchase" + "github.com/openmeterio/openmeter/pkg/filter" + "github.com/openmeterio/openmeter/pkg/framework/entutils" + "github.com/openmeterio/openmeter/pkg/pagination" + "github.com/openmeterio/openmeter/pkg/slicesx" +) + +var _ creditpurchase.Adapter = (*adapter)(nil) + +func (a *adapter) UpdateCharge(ctx context.Context, charge creditpurchase.ChargeBase) (creditpurchase.ChargeBase, error) { + if err := charge.Validate(); err != nil { + return creditpurchase.ChargeBase{}, err + } + + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) (creditpurchase.ChargeBase, error) { + metaStatus, err := charge.Status.ToMetaChargeStatus() + if err != nil { + return creditpurchase.ChargeBase{}, err + } + + update := tx.db.ChargeCreditPurchase.UpdateOneID(charge.ID). + Where(dbchargecreditpurchase.NamespaceEQ(charge.Namespace)). + SetCreditAmount(charge.Intent.CreditAmount). + SetSettlement(charge.Intent.Settlement). + SetStatusDetailed(charge.Status) + + update, err = chargemeta.Update(update, chargemeta.UpdateInput{ + ManagedResource: charge.ManagedResource, + Intent: charge.Intent.Intent, + IntentMutableFields: charge.Intent.IntentMutableFields.IntentMutableFields, + Status: metaStatus, + }) + if err != nil { + return creditpurchase.ChargeBase{}, err + } + + dbCreditPurchase, err := update.Save(ctx) + if err != nil { + return creditpurchase.ChargeBase{}, err + } + + return MapChargeBaseFromDB(dbCreditPurchase), nil + }) +} + +func (a *adapter) CreateCharge(ctx context.Context, in creditpurchase.CreateChargeInput) (creditpurchase.Charge, error) { + if err := in.Validate(); err != nil { + return creditpurchase.Charge{}, err + } + + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) (creditpurchase.Charge, error) { + initialStatus := creditpurchase.StatusCreated + + metaStatus, err := initialStatus.ToMetaChargeStatus() + if err != nil { + return creditpurchase.Charge{}, err + } + + create := tx.db.ChargeCreditPurchase.Create(). + SetNamespace(in.Namespace). + SetCreditAmount(in.Intent.CreditAmount). + SetNillableEffectiveAt(meta.NormalizeOptionalTimestamp(in.Intent.EffectiveAt)). + SetNillableExpiresAt(meta.NormalizeOptionalTimestamp(in.Intent.ExpiresAt)). + SetNillablePriority(in.Intent.Priority). + SetFeatureFilters(pq.StringArray(in.Intent.FeatureFilters.Normalize())). + SetSettlement(in.Intent.Settlement). + SetNillableKey(in.Intent.Key). + SetStatusDetailed(initialStatus) + + create, err = chargemeta.Create(create, chargemeta.CreateInput{ + Namespace: in.Namespace, + Intent: in.Intent.Intent, + IntentMutableFields: in.Intent.IntentMutableFields.IntentMutableFields, + Status: metaStatus, + }) + if err != nil { + return creditpurchase.Charge{}, err + } + + dbCreditPurchase, err := create.Save(ctx) + if err != nil { + return creditpurchase.Charge{}, metaadapter.MapChargeConstraintError(err) + } + + err = tx.metaAdapter.RegisterCharges(ctx, meta.RegisterChargesInput{ + Namespace: in.Namespace, + Type: meta.ChargeTypeCreditPurchase, + Charges: []meta.IDWithUniqueReferenceID{ + { + ID: dbCreditPurchase.ID, + UniqueReferenceID: dbCreditPurchase.UniqueReferenceID, + }, + }, + }) + if err != nil { + return creditpurchase.Charge{}, err + } + + return MapCreditPurchaseChargeFromDB(dbCreditPurchase, meta.ExpandNone) + }) +} + +func (a *adapter) MarkVoided(ctx context.Context, input creditpurchase.MarkVoidedInput) (creditpurchase.ChargeBase, error) { + if err := input.Validate(); err != nil { + return creditpurchase.ChargeBase{}, err + } + + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) (creditpurchase.ChargeBase, error) { + dbCreditPurchase, err := tx.db.ChargeCreditPurchase.UpdateOneID(input.ChargeID.ID). + Where(dbchargecreditpurchase.NamespaceEQ(input.ChargeID.Namespace)). + SetVoidedAt(input.VoidedAt). + Save(ctx) + if err != nil { + return creditpurchase.ChargeBase{}, fmt.Errorf("marking credit purchase charge voided [id=%s]: %w", input.ChargeID.ID, err) + } + + return MapChargeBaseFromDB(dbCreditPurchase), nil + }) +} + +func (a *adapter) GetByID(ctx context.Context, input creditpurchase.GetByIDInput) (creditpurchase.Charge, error) { + if err := input.Validate(); err != nil { + return creditpurchase.Charge{}, err + } + + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) (creditpurchase.Charge, error) { + query := tx.db.ChargeCreditPurchase.Query(). + Where( + dbchargecreditpurchase.Namespace(input.ChargeID.Namespace), + dbchargecreditpurchase.ID(input.ChargeID.ID), + ) + + query = withExpands(query, input.Expands) + + entity, err := query.Only(ctx) + if err != nil { + return creditpurchase.Charge{}, fmt.Errorf("getting credit purchase charge [id=%s]: %w", input.ChargeID.ID, err) + } + + return MapCreditPurchaseChargeFromDB(entity, input.Expands) + }) +} + +func (a *adapter) GetByIDs(ctx context.Context, input creditpurchase.GetByIDsInput) ([]creditpurchase.Charge, error) { + if err := input.Validate(); err != nil { + return nil, err + } + + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) ([]creditpurchase.Charge, error) { + query := tx.db.ChargeCreditPurchase.Query(). + Where(dbchargecreditpurchase.Namespace(input.Namespace)). + Where(dbchargecreditpurchase.IDIn(input.IDs...)) + + query = withExpands(query, input.Expands) + + entities, err := query.All(ctx) + if err != nil { + return nil, err + } + + entitiesInOrder, err := entutils.InIDOrder(input.Namespace, input.IDs, entities) + if err != nil { + return nil, err + } + + return slicesx.MapWithErr(entitiesInOrder, func(entity *db.ChargeCreditPurchase) (creditpurchase.Charge, error) { + return MapCreditPurchaseChargeFromDB(entity, input.Expands) + }) + }) +} + +func (a *adapter) ListCharges(ctx context.Context, input creditpurchase.ListChargesInput) (pagination.Result[creditpurchase.Charge], error) { + if err := input.Validate(); err != nil { + return pagination.Result[creditpurchase.Charge]{}, err + } + + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) (pagination.Result[creditpurchase.Charge], error) { + query := tx.db.ChargeCreditPurchase.Query(). + Where(dbchargecreditpurchase.Namespace(input.Namespace)) + + if !input.IncludeDeleted { + query = query.Where(dbchargecreditpurchase.DeletedAtIsNil()) + } + + if len(input.CustomerIDs) > 0 { + query = query.Where(dbchargecreditpurchase.CustomerIDIn(input.CustomerIDs...)) + } + + if len(input.Statuses) > 0 { + query = query.Where(dbchargecreditpurchase.StatusIn(input.Statuses...)) + } + + if len(input.Currencies) > 0 { + query = query.Where(dbchargecreditpurchase.CurrencyIn(input.Currencies...)) + } + + if input.Voided != nil { + if *input.Voided { + query = query.Where(dbchargecreditpurchase.VoidedAtNotNil()) + } else { + query = query.Where(dbchargecreditpurchase.VoidedAtIsNil()) + } + } + + if input.Expiration != nil { + if input.Expiration.Expired { + query = query.Where(dbchargecreditpurchase.ExpiresAtLTE(input.Expiration.AsOf)) + } else { + query = query.Where(dbchargecreditpurchase.Or( + dbchargecreditpurchase.ExpiresAtIsNil(), + dbchargecreditpurchase.ExpiresAtGT(input.Expiration.AsOf), + )) + } + } + + query = filter.ApplyToQuery(query, input.Key, dbchargecreditpurchase.FieldKey) + + query = withExpands(query, input.Expands) + + res, err := query.Paginate(ctx, input.Page) + if err != nil { + return pagination.Result[creditpurchase.Charge]{}, err + } + + charges, err := slicesx.MapWithErr(res.Items, func(entity *db.ChargeCreditPurchase) (creditpurchase.Charge, error) { + return MapCreditPurchaseChargeFromDB(entity, input.Expands) + }) + if err != nil { + return pagination.Result[creditpurchase.Charge]{}, err + } + + return pagination.Result[creditpurchase.Charge]{ + Page: res.Page, + TotalCount: res.TotalCount, + Items: charges, + }, nil + }) +} + +func withExpands(query *db.ChargeCreditPurchaseQuery, expands meta.Expands) *db.ChargeCreditPurchaseQuery { + if expands.Has(meta.ExpandRealizations) { + query = query.WithCreditGrant().WithExternalPayment().WithInvoicedPayment() + } + return query +} diff --git a/billing/charges/creditpurchase/adapter/creditgrant.go b/billing/charges/creditpurchase/adapter/creditgrant.go new file mode 100644 index 0000000000000000000000000000000000000000..c42d85a5f7a73a173861fb3c9455c86b1edb975a --- /dev/null +++ b/billing/charges/creditpurchase/adapter/creditgrant.go @@ -0,0 +1,43 @@ +package adapter + +import ( + "context" + "fmt" + "time" + + "github.com/openmeterio/openmeter/openmeter/billing/charges/creditpurchase" + "github.com/openmeterio/openmeter/openmeter/billing/charges/meta" + "github.com/openmeterio/openmeter/openmeter/billing/charges/models/ledgertransaction" + "github.com/openmeterio/openmeter/pkg/framework/entutils" +) + +var _ creditpurchase.CreditGrantAdapter = (*adapter)(nil) + +func (a *adapter) CreateCreditGrant(ctx context.Context, chargeID meta.ChargeID, input creditpurchase.CreateCreditGrantInput) (ledgertransaction.TimedGroupReference, error) { + if err := chargeID.Validate(); err != nil { + return ledgertransaction.TimedGroupReference{}, err + } + + if err := input.Validate(); err != nil { + return ledgertransaction.TimedGroupReference{}, err + } + + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) (ledgertransaction.TimedGroupReference, error) { + entity, err := tx.db.ChargeCreditPurchaseCreditGrant.Create(). + SetNamespace(chargeID.Namespace). + SetChargeID(chargeID.ID). + SetTransactionGroupID(input.TransactionGroupID). + SetGrantedAt(input.GrantedAt.In(time.UTC)). + Save(ctx) + if err != nil { + return ledgertransaction.TimedGroupReference{}, fmt.Errorf("creating credit grant for charge [id=%s]: %w", chargeID.ID, err) + } + + return ledgertransaction.TimedGroupReference{ + GroupReference: ledgertransaction.GroupReference{ + TransactionGroupID: entity.TransactionGroupID, + }, + Time: entity.GrantedAt.In(time.UTC), + }, nil + }) +} diff --git a/billing/charges/creditpurchase/adapter/funded_credit_activity.go b/billing/charges/creditpurchase/adapter/funded_credit_activity.go new file mode 100644 index 0000000000000000000000000000000000000000..9c281cd6adc51417d5e7e74ed2ce2e442c06bfc0 --- /dev/null +++ b/billing/charges/creditpurchase/adapter/funded_credit_activity.go @@ -0,0 +1,196 @@ +package adapter + +import ( + "context" + "fmt" + "slices" + + "entgo.io/ent/dialect/sql" + "github.com/lib/pq" + "github.com/samber/mo" + + "github.com/openmeterio/openmeter/openmeter/billing/charges/creditpurchase" + "github.com/openmeterio/openmeter/openmeter/billing/charges/meta" + "github.com/openmeterio/openmeter/openmeter/ent/db" + dbchargecreditpurchase "github.com/openmeterio/openmeter/openmeter/ent/db/chargecreditpurchase" + dbchargecreditpurchasecreditgrant "github.com/openmeterio/openmeter/openmeter/ent/db/chargecreditpurchasecreditgrant" + "github.com/openmeterio/openmeter/openmeter/ent/db/predicate" +) + +func (a *adapter) ListFundedCreditActivities(ctx context.Context, input creditpurchase.ListFundedCreditActivitiesInput) (creditpurchase.ListFundedCreditActivitiesResult, error) { + return ListFundedCreditActivities(ctx, a.db, input) +} + +func ListFundedCreditActivities(ctx context.Context, dbClient *db.Client, input creditpurchase.ListFundedCreditActivitiesInput) (creditpurchase.ListFundedCreditActivitiesResult, error) { + creditPurchasePredicates := []predicate.ChargeCreditPurchase{ + dbchargecreditpurchase.Namespace(input.Customer.Namespace), + dbchargecreditpurchase.CustomerIDEQ(input.Customer.ID), + dbchargecreditpurchase.DeletedAtIsNil(), + } + if featurePredicate := fundedCreditActivityFeatureFilterPredicate(input.FeatureFilter); featurePredicate != nil { + creditPurchasePredicates = append(creditPurchasePredicates, featurePredicate) + } + + query := dbClient.ChargeCreditPurchaseCreditGrant.Query(). + Where( + dbchargecreditpurchasecreditgrant.Namespace(input.Customer.Namespace), + dbchargecreditpurchasecreditgrant.DeletedAtIsNil(), + dbchargecreditpurchasecreditgrant.HasCreditPurchaseWith(creditPurchasePredicates...), + ). + WithCreditPurchase(func(q *db.ChargeCreditPurchaseQuery) { + q.Where( + dbchargecreditpurchase.Namespace(input.Customer.Namespace), + dbchargecreditpurchase.DeletedAtIsNil(), + ) + }). + Limit(input.Limit + 1) + + if input.Before != nil { + query = query.Order( + dbchargecreditpurchasecreditgrant.ByGrantedAt(sql.OrderAsc()), + dbchargecreditpurchasecreditgrant.ByCreditPurchaseField(dbchargecreditpurchase.FieldCreatedAt, sql.OrderAsc()), + dbchargecreditpurchasecreditgrant.ByChargeID(sql.OrderAsc()), + ) + } else { + query = query.Order( + dbchargecreditpurchasecreditgrant.ByGrantedAt(sql.OrderDesc()), + dbchargecreditpurchasecreditgrant.ByCreditPurchaseField(dbchargecreditpurchase.FieldCreatedAt, sql.OrderDesc()), + dbchargecreditpurchasecreditgrant.ByChargeID(sql.OrderDesc()), + ) + } + + if input.Currency != nil { + query = query.Where(dbchargecreditpurchasecreditgrant.HasCreditPurchaseWith(dbchargecreditpurchase.CurrencyEQ(*input.Currency))) + } + + if input.AsOf != nil { + query = query.Where(dbchargecreditpurchasecreditgrant.GrantedAtLTE(*input.AsOf)) + } + + if input.After != nil { + query = query.Where(fundedCreditActivityAfterPredicate(*input.After)) + } + + if input.Before != nil { + query = query.Where(fundedCreditActivityBeforePredicate(*input.Before)) + } + + entities, err := query.All(ctx) + if err != nil { + return creditpurchase.ListFundedCreditActivitiesResult{}, fmt.Errorf("list funded credit activities: %w", err) + } + + hasMore := len(entities) > input.Limit + if hasMore { + entities = entities[:input.Limit] + } + + items := make([]creditpurchase.FundedCreditActivity, 0, len(entities)) + for _, entity := range entities { + creditPurchase, err := entity.Edges.CreditPurchaseOrErr() + if err != nil { + return creditpurchase.ListFundedCreditActivitiesResult{}, fmt.Errorf("credit purchase not loaded for grant %s: %w", entity.ID, err) + } + + items = append(items, creditpurchase.FundedCreditActivity{ + ChargeID: meta.ChargeID{ + Namespace: creditPurchase.Namespace, + ID: creditPurchase.ID, + }, + ChargeCreatedAt: creditPurchase.CreatedAt, + FundedAt: entity.GrantedAt, + TransactionGroupID: entity.TransactionGroupID, + Currency: creditPurchase.Currency, + Amount: creditPurchase.CreditAmount, + Name: creditPurchase.Name, + Description: creditPurchase.Description, + }) + } + + if input.Before != nil { + slices.Reverse(items) + } + + var nextCursor *creditpurchase.FundedCreditActivityCursor + if hasMore && len(items) > 0 { + next := items[len(items)-1] + nextCursor = &creditpurchase.FundedCreditActivityCursor{ + FundedAt: next.FundedAt, + ChargeCreatedAt: next.ChargeCreatedAt, + ChargeID: next.ChargeID, + } + } + + hasPrevious := input.After != nil + if input.Before != nil { + hasPrevious = hasMore + } + + return creditpurchase.ListFundedCreditActivitiesResult{ + Items: items, + NextCursor: nextCursor, + HasPrevious: hasPrevious, + }, nil +} + +func fundedCreditActivityFeatureFilterPredicate(filter mo.Option[creditpurchase.FeatureFilters]) predicate.ChargeCreditPurchase { + if filter.IsAbsent() { + return nil + } + + features := filter.OrEmpty() + if features == nil { + return dbchargecreditpurchase.FeatureFiltersIsNil() + } + features = features.Normalize() + if len(features) == 0 { + return nil + } + + return dbchargecreditpurchase.Or( + dbchargecreditpurchase.FeatureFiltersIsNil(), + func(s *sql.Selector) { + s.Where(sql.P(func(b *sql.Builder) { + b.Ident(s.C(dbchargecreditpurchase.FieldFeatureFilters)).WriteString(" @> ").Arg(pq.StringArray{features[0]}) + })) + }, + ) +} + +func fundedCreditActivityAfterPredicate(cursor creditpurchase.FundedCreditActivityCursor) predicate.ChargeCreditPurchaseCreditGrant { + return dbchargecreditpurchasecreditgrant.Or( + dbchargecreditpurchasecreditgrant.GrantedAtLT(cursor.FundedAt), + dbchargecreditpurchasecreditgrant.And( + dbchargecreditpurchasecreditgrant.GrantedAtEQ(cursor.FundedAt), + dbchargecreditpurchasecreditgrant.HasCreditPurchaseWith( + dbchargecreditpurchase.CreatedAtLT(cursor.ChargeCreatedAt), + ), + ), + dbchargecreditpurchasecreditgrant.And( + dbchargecreditpurchasecreditgrant.GrantedAtEQ(cursor.FundedAt), + dbchargecreditpurchasecreditgrant.HasCreditPurchaseWith( + dbchargecreditpurchase.CreatedAtEQ(cursor.ChargeCreatedAt), + ), + dbchargecreditpurchasecreditgrant.ChargeIDLT(cursor.ChargeID.ID), + ), + ) +} + +func fundedCreditActivityBeforePredicate(cursor creditpurchase.FundedCreditActivityCursor) predicate.ChargeCreditPurchaseCreditGrant { + return dbchargecreditpurchasecreditgrant.Or( + dbchargecreditpurchasecreditgrant.GrantedAtGT(cursor.FundedAt), + dbchargecreditpurchasecreditgrant.And( + dbchargecreditpurchasecreditgrant.GrantedAtEQ(cursor.FundedAt), + dbchargecreditpurchasecreditgrant.HasCreditPurchaseWith( + dbchargecreditpurchase.CreatedAtGT(cursor.ChargeCreatedAt), + ), + ), + dbchargecreditpurchasecreditgrant.And( + dbchargecreditpurchasecreditgrant.GrantedAtEQ(cursor.FundedAt), + dbchargecreditpurchasecreditgrant.HasCreditPurchaseWith( + dbchargecreditpurchase.CreatedAtEQ(cursor.ChargeCreatedAt), + ), + dbchargecreditpurchasecreditgrant.ChargeIDGT(cursor.ChargeID.ID), + ), + ) +} diff --git a/billing/charges/creditpurchase/adapter/funded_credit_activity_test.go b/billing/charges/creditpurchase/adapter/funded_credit_activity_test.go new file mode 100644 index 0000000000000000000000000000000000000000..e9a4486a1fe9ffba79efbb6d41feee5c67e0908c --- /dev/null +++ b/billing/charges/creditpurchase/adapter/funded_credit_activity_test.go @@ -0,0 +1,454 @@ +package adapter + +import ( + "context" + "log/slog" + "testing" + "time" + + "github.com/alpacahq/alpacadecimal" + "github.com/lib/pq" + "github.com/oklog/ulid/v2" + "github.com/samber/mo" + "github.com/stretchr/testify/suite" + + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/billing/charges/creditpurchase" + "github.com/openmeterio/openmeter/openmeter/billing/charges/meta" + "github.com/openmeterio/openmeter/openmeter/customer" + "github.com/openmeterio/openmeter/openmeter/ent/db" + taxcodetestutils "github.com/openmeterio/openmeter/openmeter/taxcode/testutils" + "github.com/openmeterio/openmeter/openmeter/testutils" + "github.com/openmeterio/openmeter/pkg/currencyx" +) + +func TestListFundedCreditActivities(t *testing.T) { + suite.Run(t, new(ListFundedCreditActivitiesSuite)) +} + +type ListFundedCreditActivitiesSuite struct { + suite.Suite + + testDB *testutils.TestDB + dbClient *db.Client + + taxCodeEnv *taxcodetestutils.TestEnv +} + +func (s *ListFundedCreditActivitiesSuite) SetupSuite() { + t := s.T() + + s.testDB = testutils.InitPostgresDB(t, testutils.PostgresDBStateAtlasMigrated) + s.dbClient = db.NewClient(db.Driver(s.testDB.EntDriver.Driver())) + + s.taxCodeEnv = taxcodetestutils.NewTestEnvFromClient(t, s.dbClient, slog.Default()) +} + +func (s *ListFundedCreditActivitiesSuite) TearDownSuite() { + s.testDB.EntDriver.Close() + s.testDB.PGDriver.Close() +} + +func (s *ListFundedCreditActivitiesSuite) createCustomer(namespace string) string { + s.T().Helper() + + c, err := s.dbClient.Customer.Create(). + SetNamespace(namespace). + SetName("test-customer"). + Save(context.Background()) + s.Require().NoError(err) + + return c.ID +} + +func (s *ListFundedCreditActivitiesSuite) insertCreditPurchaseWithGrant( + namespace string, + customerID string, + currency currencyx.Code, + chargeCreatedAt time.Time, + fundedAt time.Time, + name string, + description *string, + features ...creditpurchase.FeatureFilters, +) meta.ChargeID { + s.T().Helper() + + servicePeriodTo := chargeCreatedAt.Add(time.Hour) + + create := s.dbClient.ChargeCreditPurchase.Create(). + SetNamespace(namespace). + SetCustomerID(customerID). + SetServicePeriodFrom(chargeCreatedAt). + SetServicePeriodTo(servicePeriodTo). + SetBillingPeriodFrom(chargeCreatedAt). + SetBillingPeriodTo(servicePeriodTo). + SetFullServicePeriodFrom(chargeCreatedAt). + SetFullServicePeriodTo(servicePeriodTo). + SetStatus(meta.ChargeStatusCreated). + SetStatusDetailed(creditpurchase.StatusCreated). + SetCurrency(currency). + SetManagedBy(billing.SubscriptionManagedLine). + SetName(name). + SetNillableDescription(description). + SetTaxCodeID(s.taxCodeEnv.CreateTaxCode(s.T(), namespace).ID). + SetCreditAmount(alpacadecimal.NewFromInt(100)). + SetSettlement(creditpurchase.NewSettlement(creditpurchase.PromotionalSettlement{})). + SetCreatedAt(chargeCreatedAt). + SetUpdatedAt(chargeCreatedAt) + if len(features) > 0 && features[0] != nil { + create.SetFeatureFilters(pq.StringArray(features[0].Normalize())) + } + + chargeEntity, err := create.Save(s.T().Context()) + s.Require().NoError(err) + + _, err = s.dbClient.ChargeCreditPurchaseCreditGrant.Create(). + SetNamespace(namespace). + SetChargeID(chargeEntity.ID). + SetTransactionGroupID(ulid.Make().String()). + SetGrantedAt(fundedAt). + SetCreditPurchaseID(chargeEntity.ID). + SetCreatedAt(fundedAt). + SetUpdatedAt(fundedAt). + Save(s.T().Context()) + s.Require().NoError(err) + + return meta.ChargeID{ + Namespace: namespace, + ID: chargeEntity.ID, + } +} + +func (s *ListFundedCreditActivitiesSuite) TestPaginatesWithAfter() { + ctx := context.Background() + ns := "test-funded-activity-cursors" + customerID := s.createCustomer(ns) + base := time.Now().UTC().Truncate(time.Microsecond) + + idNewest := s.insertCreditPurchaseWithGrant( + ns, + customerID, + currencyx.Code("USD"), + base.Add(1*time.Minute), + base.Add(3*time.Minute), + "newest-funded", + nil, + ) + idMiddle := s.insertCreditPurchaseWithGrant( + ns, + customerID, + currencyx.Code("USD"), + base.Add(3*time.Minute), + base.Add(2*time.Minute), + "middle-funded", + nil, + ) + idOldest := s.insertCreditPurchaseWithGrant( + ns, + customerID, + currencyx.Code("USD"), + base.Add(2*time.Minute), + base.Add(2*time.Minute), + "oldest-funded", + nil, + ) + + customerRef := customer.CustomerID{Namespace: ns, ID: customerID} + + page1, err := ListFundedCreditActivities(ctx, s.dbClient, creditpurchase.ListFundedCreditActivitiesInput{ + Customer: customerRef, + Limit: 2, + }) + s.Require().NoError(err) + s.Require().Len(page1.Items, 2) + s.False(page1.HasPrevious) + s.NotNil(page1.NextCursor) + s.Equal(idNewest, page1.Items[0].ChargeID) + s.Equal(idMiddle, page1.Items[1].ChargeID) + + page2, err := ListFundedCreditActivities(ctx, s.dbClient, creditpurchase.ListFundedCreditActivitiesInput{ + Customer: customerRef, + Limit: 2, + After: page1.NextCursor, + }) + s.Require().NoError(err) + s.Require().Len(page2.Items, 1) + s.True(page2.HasPrevious) + s.Nil(page2.NextCursor) + s.Equal(idOldest, page2.Items[0].ChargeID) +} + +func (s *ListFundedCreditActivitiesSuite) TestPaginatesWithBefore() { + ctx := context.Background() + ns := "test-funded-activity-before" + customerID := s.createCustomer(ns) + base := time.Now().UTC().Truncate(time.Microsecond) + + s.insertCreditPurchaseWithGrant( + ns, + customerID, + currencyx.Code("USD"), + base.Add(30*time.Second), + base.Add(5*time.Minute), + "funded-5", + nil, + ) + s.insertCreditPurchaseWithGrant( + ns, + customerID, + currencyx.Code("USD"), + base.Add(1*time.Minute), + base.Add(4*time.Minute), + "funded-4", + nil, + ) + s.insertCreditPurchaseWithGrant( + ns, + customerID, + currencyx.Code("USD"), + base.Add(2*time.Minute), + base.Add(3*time.Minute), + "funded-3", + nil, + ) + s.insertCreditPurchaseWithGrant( + ns, + customerID, + currencyx.Code("USD"), + base.Add(3*time.Minute), + base.Add(2*time.Minute), + "funded-2", + nil, + ) + s.insertCreditPurchaseWithGrant( + ns, + customerID, + currencyx.Code("USD"), + base.Add(4*time.Minute), + base.Add(1*time.Minute), + "funded-1", + nil, + ) + + customerRef := customer.CustomerID{Namespace: ns, ID: customerID} + + initialPage, err := ListFundedCreditActivities(ctx, s.dbClient, creditpurchase.ListFundedCreditActivitiesInput{ + Customer: customerRef, + Limit: 2, + }) + s.Require().NoError(err) + s.Require().NotNil(initialPage.NextCursor) + s.Require().Len(initialPage.Items, 2) + s.Equal("funded-5", initialPage.Items[0].Name) + s.Equal("funded-4", initialPage.Items[1].Name) + + page2, err := ListFundedCreditActivities(ctx, s.dbClient, creditpurchase.ListFundedCreditActivitiesInput{ + Customer: customerRef, + Limit: 2, + After: initialPage.NextCursor, + }) + s.Require().NoError(err) + s.Require().Len(page2.Items, 2) + s.Equal("funded-3", page2.Items[0].Name) + s.Equal("funded-2", page2.Items[1].Name) + + page1, err := ListFundedCreditActivities(ctx, s.dbClient, creditpurchase.ListFundedCreditActivitiesInput{ + Customer: customerRef, + Limit: 2, + Before: &creditpurchase.FundedCreditActivityCursor{ + FundedAt: page2.Items[1].FundedAt, + ChargeCreatedAt: page2.Items[1].ChargeCreatedAt, + ChargeID: page2.Items[1].ChargeID, + }, + }) + s.Require().NoError(err) + s.Require().Len(page1.Items, 2) + s.Require().NotNil(page1.NextCursor) + s.Equal("funded-4", page1.Items[0].Name) + s.Equal("funded-3", page1.Items[1].Name) + + pageForward, err := ListFundedCreditActivities(ctx, s.dbClient, creditpurchase.ListFundedCreditActivitiesInput{ + Customer: customerRef, + Limit: 2, + After: page1.NextCursor, + }) + s.Require().NoError(err) + s.Require().Len(pageForward.Items, 2) + s.Equal("funded-2", pageForward.Items[0].Name) + s.Equal("funded-1", pageForward.Items[1].Name) +} + +func (s *ListFundedCreditActivitiesSuite) TestFiltersByCurrency() { + ctx := context.Background() + ns := "test-funded-activity-currency" + customerID := s.createCustomer(ns) + base := time.Now().UTC().Truncate(time.Microsecond) + + idUSD := s.insertCreditPurchaseWithGrant( + ns, + customerID, + currencyx.Code("USD"), + base.Add(1*time.Minute), + base.Add(2*time.Minute), + "usd-funded", + nil, + ) + s.insertCreditPurchaseWithGrant( + ns, + customerID, + currencyx.Code("EUR"), + base.Add(2*time.Minute), + base.Add(3*time.Minute), + "eur-funded", + nil, + ) + + usd := currencyx.Code("USD") + result, err := ListFundedCreditActivities(ctx, s.dbClient, creditpurchase.ListFundedCreditActivitiesInput{ + Customer: customer.CustomerID{Namespace: ns, ID: customerID}, + Limit: 10, + Currency: &usd, + }) + s.Require().NoError(err) + s.Require().Len(result.Items, 1) + s.Equal(idUSD, result.Items[0].ChargeID) + s.Equal(usd, result.Items[0].Currency) +} + +func (s *ListFundedCreditActivitiesSuite) TestFiltersByAsOf() { + ctx := context.Background() + ns := "test-funded-activity-as-of" + customerID := s.createCustomer(ns) + base := time.Now().UTC().Truncate(time.Microsecond) + + idVisible := s.insertCreditPurchaseWithGrant( + ns, + customerID, + currencyx.Code("USD"), + base, + base.Add(time.Hour), + "visible-funded", + nil, + ) + s.insertCreditPurchaseWithGrant( + ns, + customerID, + currencyx.Code("USD"), + base, + base.Add(2*time.Hour), + "future-funded", + nil, + ) + + asOf := base.Add(time.Hour) + result, err := ListFundedCreditActivities(ctx, s.dbClient, creditpurchase.ListFundedCreditActivitiesInput{ + Customer: customer.CustomerID{Namespace: ns, ID: customerID}, + Limit: 10, + AsOf: &asOf, + }) + s.Require().NoError(err) + s.Require().Len(result.Items, 1) + s.Equal(idVisible, result.Items[0].ChargeID) + s.Equal("visible-funded", result.Items[0].Name) +} + +func (s *ListFundedCreditActivitiesSuite) TestFiltersByFeatureFilter() { + ctx := context.Background() + ns := "test-funded-activity-feature-filter" + customerID := s.createCustomer(ns) + customerRef := customer.CustomerID{Namespace: ns, ID: customerID} + base := time.Now().UTC().Truncate(time.Microsecond) + + s.insertCreditPurchaseWithGrant( + ns, + customerID, + currencyx.Code("USD"), + base, + base.Add(1*time.Minute), + "unrestricted-funded", + nil, + ) + s.insertCreditPurchaseWithGrant( + ns, + customerID, + currencyx.Code("USD"), + base.Add(1*time.Minute), + base.Add(2*time.Minute), + "feature-a-funded", + nil, + creditpurchase.FeatureFilters{"feature-a"}, + ) + s.insertCreditPurchaseWithGrant( + ns, + customerID, + currencyx.Code("USD"), + base.Add(2*time.Minute), + base.Add(3*time.Minute), + "feature-a-b-funded", + nil, + creditpurchase.FeatureFilters{"feature-a", "feature-b"}, + ) + s.insertCreditPurchaseWithGrant( + ns, + customerID, + currencyx.Code("USD"), + base.Add(3*time.Minute), + base.Add(4*time.Minute), + "feature-b-funded", + nil, + creditpurchase.FeatureFilters{"feature-b"}, + ) + + all, err := ListFundedCreditActivities(ctx, s.dbClient, creditpurchase.ListFundedCreditActivitiesInput{ + Customer: customerRef, + Limit: 10, + }) + s.Require().NoError(err) + s.Require().Equal([]string{ + "feature-b-funded", + "feature-a-b-funded", + "feature-a-funded", + "unrestricted-funded", + }, fundedActivityNames(all.Items)) + + unrestricted, err := ListFundedCreditActivities(ctx, s.dbClient, creditpurchase.ListFundedCreditActivitiesInput{ + Customer: customerRef, + Limit: 10, + FeatureFilter: mo.Some[creditpurchase.FeatureFilters](nil), + }) + s.Require().NoError(err) + s.Require().Equal([]string{"unrestricted-funded"}, fundedActivityNames(unrestricted.Items)) + + featureA, err := ListFundedCreditActivities(ctx, s.dbClient, creditpurchase.ListFundedCreditActivitiesInput{ + Customer: customerRef, + Limit: 10, + FeatureFilter: mo.Some(creditpurchase.FeatureFilters{"feature-a"}), + }) + s.Require().NoError(err) + s.Require().Equal([]string{ + "feature-a-b-funded", + "feature-a-funded", + "unrestricted-funded", + }, fundedActivityNames(featureA.Items)) + + featureB, err := ListFundedCreditActivities(ctx, s.dbClient, creditpurchase.ListFundedCreditActivitiesInput{ + Customer: customerRef, + Limit: 10, + FeatureFilter: mo.Some(creditpurchase.FeatureFilters{"feature-b"}), + }) + s.Require().NoError(err) + s.Require().Equal([]string{ + "feature-b-funded", + "feature-a-b-funded", + "unrestricted-funded", + }, fundedActivityNames(featureB.Items)) +} + +func fundedActivityNames(items []creditpurchase.FundedCreditActivity) []string { + names := make([]string, 0, len(items)) + for _, item := range items { + names = append(names, item.Name) + } + + return names +} diff --git a/billing/charges/creditpurchase/adapter/mapper.go b/billing/charges/creditpurchase/adapter/mapper.go new file mode 100644 index 0000000000000000000000000000000000000000..b728db289063a28b06adf7efcbb7d128768fb004 --- /dev/null +++ b/billing/charges/creditpurchase/adapter/mapper.go @@ -0,0 +1,91 @@ +package adapter + +import ( + "fmt" + "time" + + "github.com/samber/lo" + + "github.com/openmeterio/openmeter/openmeter/billing/charges/creditpurchase" + "github.com/openmeterio/openmeter/openmeter/billing/charges/meta" + "github.com/openmeterio/openmeter/openmeter/billing/charges/models/chargemeta" + "github.com/openmeterio/openmeter/openmeter/billing/charges/models/ledgertransaction" + "github.com/openmeterio/openmeter/openmeter/billing/charges/models/payment" + entdb "github.com/openmeterio/openmeter/openmeter/ent/db" + "github.com/openmeterio/openmeter/pkg/convert" +) + +func MapChargeBaseFromDB(dbEntity *entdb.ChargeCreditPurchase) creditpurchase.ChargeBase { + mappedMeta := chargemeta.MapFromDB(dbEntity) + + return creditpurchase.ChargeBase{ + ManagedResource: mappedMeta.ManagedResource, + Status: dbEntity.StatusDetailed, + Intent: creditpurchase.Intent{ + Intent: mappedMeta.Intent, + IntentMutableFields: creditpurchase.IntentMutableFields{ + IntentMutableFields: mappedMeta.IntentMutableFields, + CreditAmount: dbEntity.CreditAmount, + EffectiveAt: convert.SafeToUTC(dbEntity.EffectiveAt), + ExpiresAt: convert.SafeToUTC(dbEntity.ExpiresAt), + Priority: dbEntity.Priority, + FeatureFilters: creditpurchase.FeatureFilters(dbEntity.FeatureFilters).Normalize(), + Settlement: dbEntity.Settlement, + }, + Key: dbEntity.Key, + }, + State: creditpurchase.State{ + VoidedAt: convert.SafeToUTC(dbEntity.VoidedAt), + }, + } +} + +func MapCreditPurchaseChargeFromDB(dbEntity *entdb.ChargeCreditPurchase, expands meta.Expands) (creditpurchase.Charge, error) { + chargeBase := MapChargeBaseFromDB(dbEntity) + + var creditGrantRealization *ledgertransaction.TimedGroupReference + var externalPaymentSettlement *payment.External + var invoiceSettlement *payment.Invoiced + if expands.Has(meta.ExpandRealizations) { + dbCreditGrant, err := dbEntity.Edges.CreditGrantOrErr() + if _, ok := lo.ErrorsAs[*entdb.NotLoadedError](err); ok { + return creditpurchase.Charge{}, fmt.Errorf("credit grant not loaded for credit purchase charge [id=%s]: %w", dbEntity.ID, err) + } + + if dbCreditGrant != nil { + creditGrantRealization = &ledgertransaction.TimedGroupReference{ + GroupReference: ledgertransaction.GroupReference{ + TransactionGroupID: dbCreditGrant.TransactionGroupID, + }, + Time: dbCreditGrant.GrantedAt.In(time.UTC), + } + } + + dbExternalPaymentSettlement, err := dbEntity.Edges.ExternalPaymentOrErr() + if _, ok := lo.ErrorsAs[*entdb.NotLoadedError](err); ok { + return creditpurchase.Charge{}, fmt.Errorf("external payment settlement not loaded for credit purchase charge [id=%s]: %w", dbEntity.ID, err) + } + + if dbExternalPaymentSettlement != nil { + externalPaymentSettlement = lo.ToPtr(payment.MapExternalFromDB(dbExternalPaymentSettlement)) + } + + dbInvoicedPaymentSettlement, err := dbEntity.Edges.InvoicedPaymentOrErr() + if _, ok := lo.ErrorsAs[*entdb.NotLoadedError](err); ok { + return creditpurchase.Charge{}, fmt.Errorf("invoiced payment settlement not loaded for credit purchase charge [id=%s]: %w", dbEntity.ID, err) + } + + if dbInvoicedPaymentSettlement != nil { + invoiceSettlement = lo.ToPtr(payment.MapInvoicedFromDB(dbInvoicedPaymentSettlement)) + } + } + + return creditpurchase.Charge{ + ChargeBase: chargeBase, + Realizations: creditpurchase.Realizations{ + CreditGrantRealization: creditGrantRealization, + ExternalPaymentSettlement: externalPaymentSettlement, + InvoiceSettlement: invoiceSettlement, + }, + }, nil +} diff --git a/billing/charges/creditpurchase/adapter/payment.go b/billing/charges/creditpurchase/adapter/payment.go new file mode 100644 index 0000000000000000000000000000000000000000..fc6b0b2e78caac2db116b90a3e7941775a1e87c1 --- /dev/null +++ b/billing/charges/creditpurchase/adapter/payment.go @@ -0,0 +1,99 @@ +package adapter + +import ( + "context" + + "github.com/openmeterio/openmeter/openmeter/billing/charges/meta" + "github.com/openmeterio/openmeter/openmeter/billing/charges/models/payment" + "github.com/openmeterio/openmeter/openmeter/ent/db/chargecreditpurchaseexternalpayment" + "github.com/openmeterio/openmeter/openmeter/ent/db/chargecreditpurchaseinvoicedpayment" + "github.com/openmeterio/openmeter/pkg/framework/entutils" +) + +func (a *adapter) CreateExternalPayment(ctx context.Context, chargeID meta.ChargeID, in payment.ExternalCreateInput) (payment.External, error) { + if err := chargeID.Validate(); err != nil { + return payment.External{}, err + } + + if err := in.Validate(); err != nil { + return payment.External{}, err + } + + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) (payment.External, error) { + create := tx.db.ChargeCreditPurchaseExternalPayment.Create(). + SetChargeID(chargeID.ID) + + create = payment.CreateExternal(create, in) + + entity, err := create.Save(ctx) + if err != nil { + return payment.External{}, err + } + + return payment.MapExternalFromDB(entity), nil + }) +} + +func (a *adapter) UpdateExternalPayment(ctx context.Context, in payment.External) (payment.External, error) { + if err := in.Validate(); err != nil { + return payment.External{}, err + } + + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) (payment.External, error) { + update := tx.db.ChargeCreditPurchaseExternalPayment.UpdateOneID(in.ID). + Where(chargecreditpurchaseexternalpayment.Namespace(in.Namespace)) + + updated := payment.UpdateExternal(update, in) + + entity, err := updated.Save(ctx) + if err != nil { + return payment.External{}, err + } + + return payment.MapExternalFromDB(entity), nil + }) +} + +func (a *adapter) CreateInvoicedPayment(ctx context.Context, chargeID meta.ChargeID, in payment.InvoicedCreate) (payment.Invoiced, error) { + if err := chargeID.Validate(); err != nil { + return payment.Invoiced{}, err + } + + if err := in.Validate(); err != nil { + return payment.Invoiced{}, err + } + + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) (payment.Invoiced, error) { + create := tx.db.ChargeCreditPurchaseInvoicedPayment.Create(). + SetChargeID(chargeID.ID) + + create = payment.CreateInvoiced(create, in) + + entity, err := create.Save(ctx) + if err != nil { + return payment.Invoiced{}, err + } + + return payment.MapInvoicedFromDB(entity), nil + }) +} + +func (a *adapter) UpdateInvoicedPayment(ctx context.Context, in payment.Invoiced) (payment.Invoiced, error) { + if err := in.Validate(); err != nil { + return payment.Invoiced{}, err + } + + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) (payment.Invoiced, error) { + update := tx.db.ChargeCreditPurchaseInvoicedPayment.UpdateOneID(in.ID). + Where(chargecreditpurchaseinvoicedpayment.Namespace(in.Namespace)) + + updated := payment.UpdateInvoiced(update, in) + + entity, err := updated.Save(ctx) + if err != nil { + return payment.Invoiced{}, err + } + + return payment.MapInvoicedFromDB(entity), nil + }) +} diff --git a/billing/charges/creditpurchase/charge.go b/billing/charges/creditpurchase/charge.go new file mode 100644 index 0000000000000000000000000000000000000000..3c47be4581d0e3705e8d7fd4701dc57535173549 --- /dev/null +++ b/billing/charges/creditpurchase/charge.go @@ -0,0 +1,305 @@ +package creditpurchase + +import ( + "errors" + "fmt" + "time" + + "github.com/alpacahq/alpacadecimal" + "github.com/samber/lo" + + "github.com/openmeterio/openmeter/openmeter/billing/charges/meta" + "github.com/openmeterio/openmeter/openmeter/billing/charges/models/ledgertransaction" + "github.com/openmeterio/openmeter/openmeter/billing/charges/models/payment" + "github.com/openmeterio/openmeter/openmeter/customer" + "github.com/openmeterio/openmeter/pkg/clock" + "github.com/openmeterio/openmeter/pkg/currencyx" + "github.com/openmeterio/openmeter/pkg/models" + "github.com/openmeterio/openmeter/pkg/timeutil" +) + +type ChargeBase struct { + meta.ManagedResource + + Intent Intent `json:"intent"` + Status Status `json:"status"` + + State State `json:"state"` +} + +func (c ChargeBase) Validate() error { + var errs []error + + if err := c.ManagedResource.Validate(); err != nil { + errs = append(errs, fmt.Errorf("managed resource: %w", err)) + } + + if err := c.Intent.Validate(); err != nil { + errs = append(errs, fmt.Errorf("intent: %w", err)) + } + + if err := c.Status.Validate(); err != nil { + errs = append(errs, fmt.Errorf("status: %w", err)) + } + + if err := c.State.Validate(); err != nil { + errs = append(errs, fmt.Errorf("state: %w", err)) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +func (c ChargeBase) GetChargeID() meta.ChargeID { + return meta.ChargeID{ + Namespace: c.Namespace, + ID: c.ID, + } +} + +func (c ChargeBase) GetCustomerID() customer.CustomerID { + return customer.CustomerID{ + Namespace: c.Namespace, + ID: c.Intent.CustomerID, + } +} + +func (c ChargeBase) GetCurrency() currencyx.Code { + return c.Intent.Currency +} + +func (c ChargeBase) ErrorAttributes() models.Attributes { + return models.Attributes{ + "charge_id": c.ID, + "namespace": c.Namespace, + "charge_type": string(meta.ChargeTypeCreditPurchase), + } +} + +var _ meta.ChargeAccessor = (*Charge)(nil) + +type Charge struct { + ChargeBase + + Realizations Realizations `json:"realizations"` +} + +func (c Charge) GetStatus() Status { + return c.Status +} + +func (c Charge) WithStatus(status Status) Charge { + c.Status = status + return c +} + +func (c Charge) GetBase() ChargeBase { + return c.ChargeBase +} + +func (c Charge) WithBase(base ChargeBase) Charge { + c.ChargeBase = base + return c +} + +func (c Charge) Validate() error { + var errs []error + + if err := c.ChargeBase.Validate(); err != nil { + errs = append(errs, fmt.Errorf("charge base: %w", err)) + } + + if err := c.Realizations.Validate(); err != nil { + errs = append(errs, fmt.Errorf("realizations: %w", err)) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +type Intent struct { + meta.Intent + IntentMutableFields + + // Key is the optional idempotency key: a retried create with the same key returns a conflict. + Key *string `json:"key,omitempty"` +} + +type IntentMutableFields struct { + meta.IntentMutableFields + + CreditAmount alpacadecimal.Decimal `json:"amount"` + // EffectiveAt is the time at which the credit purchase is effective. + // When set, the credit purchase service period is pinned to this instant. + EffectiveAt *time.Time `json:"effectiveAt"` + ExpiresAt *time.Time `json:"expiresAt"` + Priority *int `json:"priority"` + + FeatureFilters FeatureFilters `json:"featureFilters,omitempty"` + + // Settlement intent + Settlement Settlement `json:"settlement"` +} + +func (i Intent) Normalized() Intent { + i.IntentMutableFields = i.IntentMutableFields.Normalized(i.Currency) + + return i +} + +func (f IntentMutableFields) Normalized(currency currencyx.Code) IntentMutableFields { + f.IntentMutableFields = f.IntentMutableFields.Normalized() + f.EffectiveAt = meta.NormalizeOptionalTimestamp(f.EffectiveAt) + f.ExpiresAt = meta.NormalizeOptionalTimestamp(f.ExpiresAt) + f.FeatureFilters = f.FeatureFilters.Normalize() + + if f.EffectiveAt != nil { + period := timeutil.ClosedPeriod{ + From: lo.FromPtr(f.EffectiveAt), + To: lo.FromPtr(f.EffectiveAt), + } + f.ServicePeriod = period + f.FullServicePeriod = period + f.BillingPeriod = period + } + + calc, err := currencyx.NewCurrencyBuilder(currencyx.CurrencyTypeFiat). + WithCode(currency). + Build() + if err == nil { + f.CreditAmount = calc.RoundToPrecision(f.CreditAmount) + } + + return f +} + +func (f IntentMutableFields) CalculateEffectiveAt() time.Time { + return lo.FromPtrOr(f.EffectiveAt, clock.Now().UTC()) +} + +func (f IntentMutableFields) Validate() error { + var errs []error + + if err := f.IntentMutableFields.Validate(); err != nil { + errs = append(errs, fmt.Errorf("intent mutable fields: %w", err)) + } + + if !f.CreditAmount.IsPositive() { + errs = append(errs, fmt.Errorf("credit amount must be positive")) + } + + if err := f.Settlement.Validate(); err != nil { + errs = append(errs, fmt.Errorf("settlement: %w", err)) + } + + if err := f.FeatureFilters.Validate(); err != nil { + errs = append(errs, fmt.Errorf("feature filters: %w", err)) + } + + switch f.Settlement.Type() { + case SettlementTypeInvoice: + if _, err := f.Settlement.AsInvoiceSettlement(); err != nil { + errs = append(errs, fmt.Errorf("settlement: %w", err)) + } + case SettlementTypeExternal: + if _, err := f.Settlement.AsExternalSettlement(); err != nil { + errs = append(errs, fmt.Errorf("settlement: %w", err)) + } + } + + if f.ExpiresAt != nil && !f.ExpiresAt.After(f.CalculateEffectiveAt()) { + errs = append(errs, fmt.Errorf("expires at must be after effective at")) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +func (i Intent) CalculateEffectiveAt() time.Time { + return i.IntentMutableFields.CalculateEffectiveAt() +} + +func (i Intent) Validate() error { + var errs []error + + if err := i.Intent.Validate(); err != nil { + errs = append(errs, fmt.Errorf("intent meta: %w", err)) + } + + if err := i.IntentMutableFields.Validate(); err != nil { + errs = append(errs, err) + } + + switch i.Settlement.Type() { + case SettlementTypeInvoice: + settlement, err := i.Settlement.AsInvoiceSettlement() + if err == nil && settlement.Currency != i.Currency { + errs = append(errs, fmt.Errorf("settlement currency %q must match credit currency %q", settlement.Currency, i.Currency)) + } + case SettlementTypeExternal: + settlement, err := i.Settlement.AsExternalSettlement() + if err == nil && settlement.Currency != i.Currency { + errs = append(errs, fmt.Errorf("settlement currency %q must match credit currency %q", settlement.Currency, i.Currency)) + } + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +// State holds durable base-row scheduling fields for the credit purchase charge. +type State struct { + // VoidedAt is set when the remaining value was forfeited through the + // ledger void flow; the breakage records stay the accounting source of truth. + VoidedAt *time.Time `json:"voidedAt,omitempty"` +} + +func (s State) Validate() error { + return nil +} + +// Realizations holds expand-only data loaded from child tables (edges). +type Realizations struct { + CreditGrantRealization *ledgertransaction.TimedGroupReference `json:"creditGrantRealization"` + ExternalPaymentSettlement *payment.External `json:"externalPaymentSettlement"` + InvoiceSettlement *payment.Invoiced `json:"invoiceSettlement"` +} + +func (r Realizations) Validate() error { + var errs []error + + if r.CreditGrantRealization != nil { + if err := r.CreditGrantRealization.Validate(); err != nil { + errs = append(errs, fmt.Errorf("credit grant realization: %w", err)) + } + } + + if r.ExternalPaymentSettlement != nil { + if err := r.ExternalPaymentSettlement.Validate(); err != nil { + errs = append(errs, fmt.Errorf("external payment settlement: %w", err)) + } + } + + if r.InvoiceSettlement != nil { + if err := r.InvoiceSettlement.Validate(); err != nil { + errs = append(errs, fmt.Errorf("invoice settlement: %w", err)) + } + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +type UpdateExternalPaymentStateInput struct { + ChargeID meta.ChargeID + TargetPaymentState payment.Status +} + +func (i UpdateExternalPaymentStateInput) Validate() error { + var errs []error + + if err := i.ChargeID.Validate(); err != nil { + errs = append(errs, fmt.Errorf("charge ID: %w", err)) + } + + if err := i.TargetPaymentState.Validate(); err != nil { + errs = append(errs, fmt.Errorf("target payment state: %w", err)) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} diff --git a/billing/charges/creditpurchase/charge_test.go b/billing/charges/creditpurchase/charge_test.go new file mode 100644 index 0000000000000000000000000000000000000000..d54c5b906edd09c3f640c95eb3d8c30dc012cfce --- /dev/null +++ b/billing/charges/creditpurchase/charge_test.go @@ -0,0 +1,73 @@ +package creditpurchase + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/openmeterio/openmeter/openmeter/billing/charges/meta" + "github.com/openmeterio/openmeter/pkg/timeutil" +) + +func TestIntentNormalizedPinsServicePeriodsToEffectiveAt(t *testing.T) { + effectiveAt := time.Date(2026, 4, 17, 11, 23, 0, 0, time.UTC) + originalPeriod := timeutil.ClosedPeriod{ + From: effectiveAt.Add(-time.Hour), + To: effectiveAt.Add(time.Hour), + } + + intent := Intent{ + IntentMutableFields: IntentMutableFields{ + IntentMutableFields: meta.IntentMutableFields{ + ServicePeriod: originalPeriod, + FullServicePeriod: originalPeriod, + BillingPeriod: originalPeriod, + }, + EffectiveAt: &effectiveAt, + }, + } + + got := intent.Normalized() + + expectedPeriod := timeutil.ClosedPeriod{From: effectiveAt, To: effectiveAt} + require.Equal(t, expectedPeriod, got.ServicePeriod) + require.Equal(t, expectedPeriod, got.FullServicePeriod) + require.Equal(t, expectedPeriod, got.BillingPeriod) +} + +func TestFeatureFiltersNormalize(t *testing.T) { + require.Equal(t, FeatureFilters{"api-calls", "storage"}, FeatureFilters([]string{"storage", "api-calls", "storage"}).Normalize()) +} + +func TestFeatureFiltersValidate(t *testing.T) { + t.Run("valid", func(t *testing.T) { + require.NoError(t, FeatureFilters([]string{"api-calls", "storage"}).Validate()) + }) + + t.Run("empty key", func(t *testing.T) { + require.Error(t, FeatureFilters([]string{""}).Validate()) + }) + + t.Run("duplicate key", func(t *testing.T) { + require.Error(t, FeatureFilters([]string{"api-calls", "api-calls"}).Validate()) + }) +} + +func TestFeatureFiltersValidateAsFeatureFilter(t *testing.T) { + t.Run("valid", func(t *testing.T) { + require.NoError(t, FeatureFilters([]string{"api-calls"}).ValidateAsFeatureFilter()) + }) + + t.Run("empty", func(t *testing.T) { + require.Error(t, FeatureFilters(nil).ValidateAsFeatureFilter()) + }) + + t.Run("multiple", func(t *testing.T) { + require.Error(t, FeatureFilters([]string{"api-calls", "storage"}).ValidateAsFeatureFilter()) + }) + + t.Run("invalid feature", func(t *testing.T) { + require.Error(t, FeatureFilters([]string{""}).ValidateAsFeatureFilter()) + }) +} diff --git a/billing/charges/creditpurchase/errors.go b/billing/charges/creditpurchase/errors.go new file mode 100644 index 0000000000000000000000000000000000000000..e2c2b4919019ef32d85d1d5069abfc86663acce6 --- /dev/null +++ b/billing/charges/creditpurchase/errors.go @@ -0,0 +1,18 @@ +package creditpurchase + +import ( + "net/http" + + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/models" +) + +const ErrCodeCreditPurchaseChargeNotActive models.ErrorCode = "credit_purchase_charge_not_active" + +var ErrCreditPurchaseChargeNotActive = models.NewValidationIssue( + ErrCodeCreditPurchaseChargeNotActive, + "credit purchase charge is not active", + models.WithFieldString("namespace"), + models.WithCriticalSeverity(), + commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest), +) diff --git a/billing/charges/creditpurchase/featurefilters.go b/billing/charges/creditpurchase/featurefilters.go new file mode 100644 index 0000000000000000000000000000000000000000..8e1e193df11c60edc8168a905e42bca418574e02 --- /dev/null +++ b/billing/charges/creditpurchase/featurefilters.go @@ -0,0 +1,50 @@ +package creditpurchase + +import ( + "errors" + "fmt" + + "github.com/openmeterio/openmeter/pkg/models" + "github.com/openmeterio/openmeter/pkg/slicesx" +) + +type FeatureFilters []string + +func (f FeatureFilters) Validate() error { + var errs []error + + for i, key := range f { + if key == "" { + errs = append(errs, fmt.Errorf("[%d]: feature key is required", i)) + } + } + + if len(f.Normalize()) != len(f) { + errs = append(errs, errors.New("duplicate feature key")) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +func (f FeatureFilters) Normalize() FeatureFilters { + return FeatureFilters(slicesx.Normalize([]string(f))) +} + +// ValidateAsFeatureFilter validates the singular customer-facing filter form. +// Credit routes may be restricted to multiple features, but a spendability +// query can only ask for one feature at a time. +func (f FeatureFilters) ValidateAsFeatureFilter() error { + switch len(f) { + case 0: + return errors.New("features are required when feature filter is restricted") + case 1: + default: + return errors.New("feature filter supports exactly one feature") + } + + if err := f.Validate(); err != nil { + return fmt.Errorf("features: %w", err) + } + + return nil +} diff --git a/billing/charges/creditpurchase/funded_credit_activity.go b/billing/charges/creditpurchase/funded_credit_activity.go new file mode 100644 index 0000000000000000000000000000000000000000..2f0d603f8aa90b94b43be597caf9c2e16e65f778 --- /dev/null +++ b/billing/charges/creditpurchase/funded_credit_activity.go @@ -0,0 +1,124 @@ +package creditpurchase + +import ( + "errors" + "fmt" + "time" + + "github.com/alpacahq/alpacadecimal" + "github.com/samber/mo" + + "github.com/openmeterio/openmeter/openmeter/billing/charges/meta" + "github.com/openmeterio/openmeter/openmeter/customer" + "github.com/openmeterio/openmeter/pkg/currencyx" + "github.com/openmeterio/openmeter/pkg/models" +) + +type FundedCreditActivityCursor struct { + FundedAt time.Time + ChargeCreatedAt time.Time + ChargeID meta.ChargeID +} + +func (c FundedCreditActivityCursor) Validate() error { + var errs []error + + if c.FundedAt.IsZero() { + errs = append(errs, fmt.Errorf("funded_at is required")) + } + + if c.ChargeCreatedAt.IsZero() { + errs = append(errs, fmt.Errorf("charge_created_at is required")) + } + + if err := c.ChargeID.Validate(); err != nil { + errs = append(errs, fmt.Errorf("charge_id: %w", err)) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +type FundedCreditActivity struct { + ChargeID meta.ChargeID + ChargeCreatedAt time.Time + FundedAt time.Time + TransactionGroupID string + Currency currencyx.Code + Amount alpacadecimal.Decimal + Name string + Description *string +} + +type ListFundedCreditActivitiesInput struct { + Customer customer.CustomerID + Limit int + After *FundedCreditActivityCursor + Before *FundedCreditActivityCursor + Currency *currencyx.Code + AsOf *time.Time + + FeatureFilter mo.Option[FeatureFilters] +} + +func (i ListFundedCreditActivitiesInput) Validate() error { + var errs []error + + if err := i.Customer.Validate(); err != nil { + errs = append(errs, fmt.Errorf("customer: %w", err)) + } + + if i.Limit < 1 { + errs = append(errs, fmt.Errorf("limit must be greater than 0")) + } + + if i.After != nil { + if err := i.After.Validate(); err != nil { + errs = append(errs, fmt.Errorf("after: %w", err)) + } + } + + if i.Before != nil { + if err := i.Before.Validate(); err != nil { + errs = append(errs, fmt.Errorf("before: %w", err)) + } + } + + if i.After != nil && i.Before != nil { + errs = append(errs, fmt.Errorf("after and before cannot be set together")) + } + + if i.Currency != nil { + if err := i.Currency.Validate(); err != nil { + errs = append(errs, fmt.Errorf("currency: %w", err)) + } + } + + if i.AsOf != nil && i.AsOf.IsZero() { + errs = append(errs, fmt.Errorf("asOf must not be zero")) + } + + if err := validateFeatureFilter(i.FeatureFilter); err != nil { + errs = append(errs, fmt.Errorf("feature filter: %w", err)) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +func validateFeatureFilter(filter mo.Option[FeatureFilters]) error { + if filter.IsAbsent() { + return nil + } + + features := filter.OrEmpty() + if features == nil { + return nil + } + + return features.ValidateAsFeatureFilter() +} + +type ListFundedCreditActivitiesResult struct { + Items []FundedCreditActivity + NextCursor *FundedCreditActivityCursor + HasPrevious bool +} diff --git a/billing/charges/creditpurchase/handler.go b/billing/charges/creditpurchase/handler.go new file mode 100644 index 0000000000000000000000000000000000000000..da164d366d2dd1f69716aab86647533059cb8667 --- /dev/null +++ b/billing/charges/creditpurchase/handler.go @@ -0,0 +1,68 @@ +package creditpurchase + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/openmeterio/openmeter/openmeter/billing/charges/models/ledgertransaction" + "github.com/openmeterio/openmeter/pkg/models" +) + +// CreditPurchaseHandler is the interface for handling credit purchase charges. +// It is used to handle the different types of credit purchase charges (promotional, external, invoice). +// +// Promotional credit purchases are handled by the OnPromotionalCreditPurchase method only. +// +// Cost basis > 0 credit purchases are handled by the OnCreditPurchaseInitiated method, which is the initial call. +// Happy path: +// - OnCreditPurchaseInitiated is called +// - OnCreditPurchasePaymentAuthorized is called +// - OnCreditPurchasePaymentSettled is called +// +// Failed payment can occur either after the OnCreditPurchaseInitiated or after the OnCreditPurchasePaymentAuthorized call. + +type Handler interface { + // Promotional credit handler methods (cost basis == 0) + // ---------------------------------------------------- + + // OnPromotionalCreditPurchase is called when a promotional credit purchase is created (e.g. costbasis is 0) + // For promotional credit purchases we don't call any of the payment handler methods. + OnPromotionalCreditPurchase(ctx context.Context, charge Charge) (ledgertransaction.GroupReference, error) + + // Credit purchase handler methods (cost basis > 0) + // ------------------------------------------------ + + // OnCreditPurchaseInitiated is called when a credit purchase is initiated that is going to be settled by + // a payment (either external or a standard invoice) + // Initial call + OnCreditPurchaseInitiated(ctx context.Context, charge Charge) (ledgertransaction.GroupReference, error) + + // OnCreditPurchasePaymentAuthorized is called when a credit purchase payment is authorized for a credit + // purchase. + OnCreditPurchasePaymentAuthorized(ctx context.Context, input PaymentEventInput) (ledgertransaction.GroupReference, error) + + // OnCreditPurchasePaymentSettled is called when a credit purchase payment is settled for a credit + // purchase. + OnCreditPurchasePaymentSettled(ctx context.Context, input PaymentEventInput) (ledgertransaction.GroupReference, error) +} + +type PaymentEventInput struct { + Charge Charge `json:"charge"` + EventAt time.Time `json:"eventAt"` +} + +func (i PaymentEventInput) Validate() error { + var errs []error + + if err := i.Charge.Validate(); err != nil { + errs = append(errs, fmt.Errorf("charge: %w", err)) + } + + if i.EventAt.IsZero() { + errs = append(errs, fmt.Errorf("event at is required")) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} diff --git a/billing/charges/creditpurchase/lineengine/engine.go b/billing/charges/creditpurchase/lineengine/engine.go new file mode 100644 index 0000000000000000000000000000000000000000..f94075081fdfb2455efbee1d41f7856e1fd163b5 --- /dev/null +++ b/billing/charges/creditpurchase/lineengine/engine.go @@ -0,0 +1,150 @@ +package lineengine + +import ( + "context" + "fmt" + + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/billing/rating" + "github.com/openmeterio/openmeter/openmeter/billing/service/invoicecalc" + "github.com/openmeterio/openmeter/pkg/slicesx" +) + +var ( + _ billing.LineEngine = (*Engine)(nil) + _ billing.LineCalculator = (*Engine)(nil) +) + +type Config struct { + RatingService rating.Service +} + +func (c Config) Validate() error { + if c.RatingService == nil { + return fmt.Errorf("rating service is required") + } + + return nil +} + +type Engine struct { + ratingService rating.Service +} + +func New(config Config) (*Engine, error) { + if err := config.Validate(); err != nil { + return nil, err + } + + return &Engine{ + ratingService: config.RatingService, + }, nil +} + +func (e *Engine) GetLineEngineType() billing.LineEngineType { + return billing.LineEngineTypeChargeCreditPurchase +} + +func (e *Engine) IsLineBillableAsOf(_ context.Context, input billing.IsLineBillableAsOfInput) (bool, error) { + if err := input.Validate(); err != nil { + return false, fmt.Errorf("validating input: %w", err) + } + + // Billing enforces that credit purchases are never progressively billed, so there is no + // engine-side partial-period filtering to do here. + return true, nil +} + +func (e *Engine) SplitGatheringLine(_ context.Context, _ billing.SplitGatheringLineInput) (billing.SplitGatheringLineResult, error) { + return billing.SplitGatheringLineResult{}, fmt.Errorf("credit purchase line is not progressively billed") +} + +func (e *Engine) BuildStandardInvoiceLines(ctx context.Context, input billing.BuildStandardInvoiceLinesInput) (billing.StandardLines, error) { + stdLines, err := slicesx.MapWithErr(input.GatheringLines, func(gatheringLine billing.GatheringLine) (*billing.StandardLine, error) { + stdLine, err := gatheringLine.AsNewStandardLine(input.Invoice.ID) + if err != nil { + return nil, fmt.Errorf("converting gathering line to standard line: %w", err) + } + + return stdLine, nil + }) + if err != nil { + return nil, err + } + + return e.CalculateLines(billing.CalculateLinesInput{ + Invoice: input.Invoice, + Lines: stdLines, + }) +} + +func (e *Engine) BuildStandardLinesForGatheringPreview(_ context.Context, input billing.BuildStandardInvoiceLinesInput) (billing.StandardLines, error) { + return input.GatheringLines.ToStandardLines(input.Invoice.ID) +} + +func (e *Engine) OnCollectionCompleted(_ context.Context, input billing.OnCollectionCompletedInput) (billing.StandardLines, error) { + return input.Lines, nil +} + +func (e *Engine) OnStandardInvoiceCreated(_ context.Context, input billing.OnStandardInvoiceCreatedInput) (billing.StandardLines, error) { + return input.Lines, nil +} + +func (e *Engine) ValidateMutableInvoiceLineEditViaAPI(_ context.Context, _ billing.OnMutableInvoiceUpdateInput) error { + return billing.ErrCannotUpdateChargeManagedLine +} + +func (e *Engine) OnMutableInvoiceLinesEditedViaAPI(_ context.Context, _ billing.OnMutableInvoiceUpdateInput) (billing.OnMutableInvoiceUpdateResult, error) { + return billing.OnMutableInvoiceUpdateResult{}, billing.ErrCannotUpdateChargeManagedLine +} + +func (e *Engine) OnMutableStandardLinesDeletedBySystem(_ context.Context, _ billing.OnMutableStandardLinesDeletedInput) error { + return nil +} + +func (e *Engine) OnUnsupportedCreditNote(_ context.Context, _ billing.OnUnsupportedCreditNoteInput) error { + return nil +} + +func (e *Engine) OnInvoiceIssued(_ context.Context, _ billing.OnInvoiceIssuedInput) error { + return nil +} + +func (e *Engine) OnPaymentAuthorized(_ context.Context, _ billing.OnPaymentAuthorizedInput) error { + return nil +} + +func (e *Engine) OnPaymentSettled(_ context.Context, _ billing.OnPaymentSettledInput) error { + return nil +} + +func (e *Engine) CalculateLines(input billing.CalculateLinesInput) (billing.StandardLines, error) { + if input.Invoice.ID == "" { + return nil, fmt.Errorf("invoice id is required") + } + + if len(input.Lines) == 0 { + return nil, fmt.Errorf("lines are required") + } + + for _, stdLine := range input.Lines { + if stdLine.ChargeID == nil { + return nil, fmt.Errorf("credit purchase standard line[%s]: charge id is required", stdLine.ID) + } + + generatedDetailedLines, err := e.ratingService.GenerateDetailedLines(stdLine) + if err != nil { + return nil, fmt.Errorf("generating detailed lines for line[%s]: %w", stdLine.ID, err) + } + + if err := invoicecalc.MergeGeneratedDetailedLines(stdLine, generatedDetailedLines); err != nil { + return nil, fmt.Errorf("merging detailed lines for line[%s]: %w", stdLine.ID, err) + } + + if err := stdLine.Validate(); err != nil { + return nil, fmt.Errorf("validating standard line[%s]: %w", stdLine.ID, err) + } + } + + return input.Lines, nil +} diff --git a/billing/charges/creditpurchase/service.go b/billing/charges/creditpurchase/service.go new file mode 100644 index 0000000000000000000000000000000000000000..36567f7b5b795871d553956131167fbff9b63e4a --- /dev/null +++ b/billing/charges/creditpurchase/service.go @@ -0,0 +1,81 @@ +package creditpurchase + +import ( + "context" + "errors" + "fmt" + + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/pkg/models" + "github.com/openmeterio/openmeter/pkg/pagination" +) + +type Service interface { + CreditPurchaseService + ExternalPaymentLifecycle + InvoicePaymentLifecycle +} + +type CreditPurchaseService interface { + // Create creates one credit-purchase charge. Credit purchases are a separate + // lifecycle from flat-fee/usage-based overrides and intentionally accept a + // single intent at a time. + Create(ctx context.Context, input CreateInput) (ChargeWithGatheringLine, error) + + // GetByIDs loads credit-purchase charges for payment and grant lifecycle + // checks; credit-purchase charges do not participate in intent overrides. + GetByIDs(ctx context.Context, input GetByIDsInput) ([]Charge, error) + // List returns credit-purchase charges for customer/API views, independent + // from invoice-backed flat-fee and usage-based line-engine ownership. + List(ctx context.Context, input ListChargesInput) (pagination.Result[Charge], error) + // ListFundedCreditActivities reports grant-side activity funded by credit + // purchases, not invoice-line override state. + ListFundedCreditActivities(ctx context.Context, input ListFundedCreditActivitiesInput) (ListFundedCreditActivitiesResult, error) + // MarkVoided records the void time on the charge row. Callers run it in the + // same transaction as the ledger void booking. + MarkVoided(ctx context.Context, input MarkVoidedInput) (ChargeBase, error) +} + +type ChargeWithGatheringLine struct { + Charge Charge + GatheringLineToCreate *billing.GatheringLine +} + +type ExternalPaymentLifecycle interface { + // HandleExternalPaymentAuthorized records authorization for externally paid + // credit purchases before settlement grants credits. + HandleExternalPaymentAuthorized(ctx context.Context, charge Charge) (Charge, error) + // HandleExternalPaymentSettled finalizes externally paid credit purchases + // and funds the related credit grant. + HandleExternalPaymentSettled(ctx context.Context, charge Charge) (Charge, error) +} + +type InvoicePaymentLifecycle interface { + // PostInvoicePaymentAuthorized records authorization for invoice-backed + // credit purchases after billing confirms the standard line payment. + PostInvoicePaymentAuthorized(ctx context.Context, charge Charge, lineWithHeader billing.StandardLineWithInvoiceHeader) error + // PostInvoicePaymentSettled finalizes invoice-backed credit purchases and + // funds credits after the standard line payment settles. + PostInvoicePaymentSettled(ctx context.Context, charge Charge, lineWithHeader billing.StandardLineWithInvoiceHeader) error + // PostInvoiceDraftCreated attaches invoice-backed credit purchases to their + // persisted standard invoice line before payment lifecycle callbacks run. + PostInvoiceDraftCreated(ctx context.Context, charge Charge, lineWithHeader billing.StandardLineWithInvoiceHeader) error +} + +type CreateInput struct { + Namespace string + Intent Intent +} + +func (i CreateInput) Validate() error { + var errs []error + if i.Namespace == "" { + errs = append(errs, errors.New("namespace is required")) + } + + if err := i.Intent.Validate(); err != nil { + errs = append(errs, fmt.Errorf("intent: %w", err)) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} diff --git a/billing/charges/creditpurchase/service/create.go b/billing/charges/creditpurchase/service/create.go new file mode 100644 index 0000000000000000000000000000000000000000..afb3ab9c0ec8111888e973afa7183e5141b99f58 --- /dev/null +++ b/billing/charges/creditpurchase/service/create.go @@ -0,0 +1,140 @@ +package service + +import ( + "context" + "fmt" + + "github.com/samber/lo" + + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/billing/charges/creditpurchase" + "github.com/openmeterio/openmeter/openmeter/productcatalog" + "github.com/openmeterio/openmeter/pkg/currencyx" + "github.com/openmeterio/openmeter/pkg/framework/transaction" + "github.com/openmeterio/openmeter/pkg/models" +) + +func (s *service) Create(ctx context.Context, input creditpurchase.CreateInput) (creditpurchase.ChargeWithGatheringLine, error) { + input.Intent.IntentMutableFields = input.Intent.IntentMutableFields.Normalized(input.Intent.Currency) + + if err := input.Validate(); err != nil { + return creditpurchase.ChargeWithGatheringLine{}, err + } + + return transaction.Run(ctx, s.adapter, func(ctx context.Context) (creditpurchase.ChargeWithGatheringLine, error) { + // Let's create the credit purchase charge + charge, err := s.adapter.CreateCharge(ctx, creditpurchase.CreateChargeInput(input)) + if err != nil { + return creditpurchase.ChargeWithGatheringLine{}, err + } + + // Let's activate the state machine for the credit purchase charge + switch charge.Intent.Settlement.Type() { + case creditpurchase.SettlementTypePromotional: + stateMachine, err := NewPromotionalCreditPurchaseStateMachine(StateMachineConfig{ + Charge: charge, + Adapter: s.adapter, + Service: s, + }) + if err != nil { + return creditpurchase.ChargeWithGatheringLine{}, fmt.Errorf("new promotional state machine: %w", err) + } + + advancedCharge, err := stateMachine.AdvanceUntilStateStable(ctx) + if err != nil { + return creditpurchase.ChargeWithGatheringLine{}, fmt.Errorf("advance promotional state machine: %w", err) + } + + if advancedCharge != nil { + charge = *advancedCharge + } + case creditpurchase.SettlementTypeInvoice: + // noop, as we will transition to active state when the invoice is created, as + // - invocing based charges are driven by the invoice state machine + // - we should set the active state when the invoice is created, not when the credit purchase is created + case creditpurchase.SettlementTypeExternal: + charge, err = s.onExternalCreditPurchase(ctx, charge) + default: + return creditpurchase.ChargeWithGatheringLine{}, fmt.Errorf("invalid credit purchase settlement type: %s", charge.Intent.Settlement.Type()) + } + if err != nil { + return creditpurchase.ChargeWithGatheringLine{}, err + } + + // For invoice settlement, prepare the gathering line (actual invoicing happens after TX commits) + if charge.Intent.Settlement.Type() == creditpurchase.SettlementTypeInvoice { + gatheringLine, err := s.buildInvoiceCreditPurchaseGatheringLine(charge) + if err != nil { + return creditpurchase.ChargeWithGatheringLine{}, fmt.Errorf("building invoice credit purchase gathering line: %w", err) + } + + return creditpurchase.ChargeWithGatheringLine{ + Charge: charge, + GatheringLineToCreate: &gatheringLine, + }, nil + } + + return creditpurchase.ChargeWithGatheringLine{ + Charge: charge, + }, nil + }) +} + +func (s *service) buildInvoiceCreditPurchaseGatheringLine(charge creditpurchase.Charge) (billing.GatheringLine, error) { + invoiceSettlement, err := charge.Intent.Settlement.AsInvoiceSettlement() + if err != nil { + return billing.GatheringLine{}, err + } + + intent := charge.Intent + + // Total cost = credit amount * cost basis (e.g., 100 credits * $0.5 = $50) + totalCost := intent.CreditAmount.Mul(invoiceSettlement.CostBasis) + calc, err := currencyx.NewCurrencyBuilder(currencyx.CurrencyTypeFiat). + WithCode(invoiceSettlement.Currency). + Build() + if err != nil { + return billing.GatheringLine{}, fmt.Errorf("creating currency calculator: %w", err) + } + totalCost = calc.RoundToPrecision(totalCost) + + // Clone metadata and add credit-purchase specific annotations + annotations, err := charge.Intent.Annotations.Clone() + if err != nil { + return billing.GatheringLine{}, fmt.Errorf("cloning annotations: %w", err) + } + + if annotations == nil { + annotations = models.Annotations{} + } + + annotations[billing.AnnotationKeyTaxable] = lo.ToPtr("false") + annotations[billing.AnnotationKeyReason] = lo.ToPtr(billing.AnnotationValueReasonCreditPurchase) + + return billing.GatheringLine{ + GatheringLineBase: billing.GatheringLineBase{ + ManagedResource: models.NewManagedResource(models.ManagedResourceInput{ + Namespace: charge.Namespace, + Name: intent.Name, + Description: intent.Description, + }), + Metadata: intent.Metadata.Clone(), + Annotations: annotations, + ManagedBy: intent.ManagedBy, + Price: lo.FromPtr( + productcatalog.NewPriceFrom( + productcatalog.FlatPrice{ + Amount: totalCost, + PaymentTerm: productcatalog.InAdvancePaymentTerm, + }, + ), + ), + Currency: invoiceSettlement.Currency, + ServicePeriod: intent.ServicePeriod, + InvoiceAt: intent.CalculateEffectiveAt(), + TaxConfig: lo.ToPtr(intent.TaxConfig.ToTaxConfig()), + ChargeID: lo.ToPtr(charge.ID), + Engine: billing.LineEngineTypeChargeCreditPurchase, + }, + }, nil +} diff --git a/billing/charges/creditpurchase/service/external.go b/billing/charges/creditpurchase/service/external.go new file mode 100644 index 0000000000000000000000000000000000000000..91cc134943097ae08c8d53607248e612796cc2fa --- /dev/null +++ b/billing/charges/creditpurchase/service/external.go @@ -0,0 +1,186 @@ +package service + +import ( + "context" + "fmt" + + "github.com/samber/lo" + + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/billing/charges/creditpurchase" + "github.com/openmeterio/openmeter/openmeter/billing/charges/meta" + "github.com/openmeterio/openmeter/pkg/framework/transaction" +) + +func (s *service) onExternalCreditPurchase(ctx context.Context, charge creditpurchase.Charge) (creditpurchase.Charge, error) { + externalCreditPurchaseSettlement, err := charge.Intent.Settlement.AsExternalSettlement() + if err != nil { + return creditpurchase.Charge{}, err + } + + trigger, err := externalInitialPaymentTrigger(externalCreditPurchaseSettlement.InitialStatus) + if err != nil { + return creditpurchase.Charge{}, err + } + + stateMachine, err := s.newExternalCreditPurchaseStateMachine(charge) + if err != nil { + return creditpurchase.Charge{}, fmt.Errorf("new external state machine: %w", err) + } + + advancedCharge, err := stateMachine.AdvanceUntilStateStable(ctx) + if err != nil { + return creditpurchase.Charge{}, fmt.Errorf("advance external state machine: %w", err) + } + + charge = lo.FromPtrOr(advancedCharge, charge) + + if trigger == "" { + return charge, nil + } + + charge, err = stateMachine.handleExternalPaymentLifecycleTrigger(ctx, trigger) + if err != nil { + return creditpurchase.Charge{}, fmt.Errorf("fire external payment trigger %s: %w", trigger, err) + } + + return charge, nil +} + +func externalInitialPaymentTrigger(status creditpurchase.InitialPaymentSettlementStatus) (meta.Trigger, error) { + switch status { + case creditpurchase.CreatedInitialPaymentSettlementStatus: + return "", nil + case creditpurchase.AuthorizedInitialPaymentSettlementStatus: + return billing.TriggerAuthorized, nil + case creditpurchase.SettledInitialPaymentSettlementStatus: + return billing.TriggerPaid, nil + default: + return "", fmt.Errorf("invalid initial payment settlement status: %s", status) + } +} + +type ExternalCreditPurchaseStateMachine struct { + *stateMachine +} + +func NewExternalCreditPurchaseStateMachine(config StateMachineConfig) (*ExternalCreditPurchaseStateMachine, error) { + if err := config.Validate(); err != nil { + return nil, fmt.Errorf("validate: %w", err) + } + + if config.Realizations == nil { + return nil, fmt.Errorf("realizations service is required") + } + + if config.Charge.Intent.Settlement.Type() != creditpurchase.SettlementTypeExternal { + return nil, fmt.Errorf("charge %s is not external", config.Charge.ID) + } + + stateMachine, err := newStateMachineBase(config) + if err != nil { + return nil, fmt.Errorf("failed to create external credit purchase state machine: %w", err) + } + + out := &ExternalCreditPurchaseStateMachine{ + stateMachine: stateMachine, + } + out.configureStates() + + return out, nil +} + +func (s *ExternalCreditPurchaseStateMachine) configureStates() { + s.Configure(creditpurchase.StatusCreated). + Permit(meta.TriggerNext, creditpurchase.StatusActiveInitialCreditGrant) + + s.Configure(creditpurchase.StatusActive). + Permit(meta.TriggerNext, creditpurchase.StatusActiveInitialCreditGrant) + + s.Configure(creditpurchase.StatusActiveInitialCreditGrant). + Permit(meta.TriggerNext, creditpurchase.StatusActivePaymentPending). + OnActive(s.GrantCredits) + + s.Configure(creditpurchase.StatusActivePaymentPending). + Permit(billing.TriggerAuthorized, creditpurchase.StatusActivePaymentAuthorized). + Permit(billing.TriggerPaid, creditpurchase.StatusActivePaymentPaidAndAuthorized) + + s.Configure(creditpurchase.StatusActivePaymentAuthorized). + OnActive(s.AuthorizeExternalPayment). + Permit(billing.TriggerPaid, creditpurchase.StatusActivePaymentSettled) + + s.Configure(creditpurchase.StatusActivePaymentPaidAndAuthorized). + Permit(meta.TriggerNext, creditpurchase.StatusActivePaymentSettled). + OnActive(s.AuthorizeExternalPayment) + + s.Configure(creditpurchase.StatusActivePaymentSettled). + Permit(meta.TriggerNext, creditpurchase.StatusFinal). + OnActive(s.SettleExternalPayment) + + s.Configure(creditpurchase.StatusFinal) +} + +func (s *ExternalCreditPurchaseStateMachine) GrantCredits(ctx context.Context) error { + updatedCharge, err := s.Realizations.GrantCredits(ctx, s.Charge) + if err != nil { + return err + } + + s.Charge = updatedCharge + return nil +} + +func (s *ExternalCreditPurchaseStateMachine) AuthorizeExternalPayment(ctx context.Context) error { + updatedCharge, err := s.Realizations.AuthorizeExternalPayment(ctx, s.Charge) + if err != nil { + return err + } + + s.Charge = updatedCharge + return nil +} + +func (s *ExternalCreditPurchaseStateMachine) SettleExternalPayment(ctx context.Context) error { + updatedCharge, err := s.Realizations.SettleExternalPayment(ctx, s.Charge) + if err != nil { + return err + } + + s.Charge = updatedCharge + return nil +} + +func (s *service) newExternalCreditPurchaseStateMachine(charge creditpurchase.Charge) (*ExternalCreditPurchaseStateMachine, error) { + return NewExternalCreditPurchaseStateMachine(StateMachineConfig{ + Charge: charge, + Adapter: s.adapter, + Realizations: s.realizations, + }) +} + +func (s *ExternalCreditPurchaseStateMachine) handleExternalPaymentLifecycleTrigger(ctx context.Context, trigger meta.Trigger) (creditpurchase.Charge, error) { + if _, err := s.AdvanceUntilStateStable(ctx); err != nil { + return creditpurchase.Charge{}, fmt.Errorf("advance external state machine: %w", err) + } + + return s.FireAndAdvanceUntilStateStable(ctx, trigger) +} + +func (s *service) HandleExternalPaymentAuthorized(ctx context.Context, charge creditpurchase.Charge) (creditpurchase.Charge, error) { + return s.handleExternalPaymentTrigger(ctx, charge, billing.TriggerAuthorized) +} + +func (s *service) HandleExternalPaymentSettled(ctx context.Context, charge creditpurchase.Charge) (creditpurchase.Charge, error) { + return s.handleExternalPaymentTrigger(ctx, charge, billing.TriggerPaid) +} + +func (s *service) handleExternalPaymentTrigger(ctx context.Context, charge creditpurchase.Charge, trigger meta.Trigger) (creditpurchase.Charge, error) { + return transaction.Run(ctx, s.adapter, func(ctx context.Context) (creditpurchase.Charge, error) { + stateMachine, err := s.newExternalCreditPurchaseStateMachine(charge) + if err != nil { + return creditpurchase.Charge{}, err + } + + return stateMachine.handleExternalPaymentLifecycleTrigger(ctx, trigger) + }) +} diff --git a/billing/charges/creditpurchase/service/external_test.go b/billing/charges/creditpurchase/service/external_test.go new file mode 100644 index 0000000000000000000000000000000000000000..3e73adb11709d2f5198190f03e9cc5c56597094b --- /dev/null +++ b/billing/charges/creditpurchase/service/external_test.go @@ -0,0 +1,745 @@ +package service + +import ( + "context" + "testing" + "time" + + "github.com/alpacahq/alpacadecimal" + "github.com/samber/lo" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/billing/charges/creditpurchase" + creditpurchaserealizations "github.com/openmeterio/openmeter/openmeter/billing/charges/creditpurchase/service/realizations" + "github.com/openmeterio/openmeter/openmeter/billing/charges/lineage" + "github.com/openmeterio/openmeter/openmeter/billing/charges/meta" + "github.com/openmeterio/openmeter/openmeter/billing/charges/models/ledgertransaction" + "github.com/openmeterio/openmeter/openmeter/billing/charges/models/payment" + "github.com/openmeterio/openmeter/pkg/currencyx" + "github.com/openmeterio/openmeter/pkg/models" + "github.com/openmeterio/openmeter/pkg/timeutil" +) + +func TestExternalCreditPurchaseStateMachineAdvancesThroughGrantToPaymentPending(t *testing.T) { + for _, status := range []creditpurchase.Status{ + creditpurchase.StatusCreated, + creditpurchase.StatusActive, + } { + t.Run(string(status), func(t *testing.T) { + // given: + // - an external credit-purchase charge in a pre-payment lifecycle status + // when: + // - the external state machine advances until stable + // then: + // - it enters the initial credit grant state, grants credits, then persists payment-pending + charge := newExternalStateMachineTestChargeWithInput(externalStateMachineTestChargeInput{ + status: status, + costBasis: alpacadecimal.NewFromFloat(0.5), + creditAmount: alpacadecimal.NewFromFloat(100), + initialStatus: creditpurchase.CreatedInitialPaymentSettlementStatus, + featureFilters: creditpurchase.FeatureFilters{"storage", "api-calls", "storage"}, + }) + adapter := &externalStateMachineAdapter{} + lineageService := &externalStateMachineLineage{} + handler := &externalStateMachineHandler{} + handler.On("OnCreditPurchaseInitiated", mock.Anything, mock.Anything). + Run(func(args mock.Arguments) { + charge := args.Get(1).(creditpurchase.Charge) + require.Equal(t, creditpurchase.StatusActiveInitialCreditGrant, charge.Status) + require.Nil(t, charge.Realizations.CreditGrantRealization) + require.Nil(t, charge.Realizations.ExternalPaymentSettlement) + }). + Return(ledgertransaction.GroupReference{TransactionGroupID: "initiated-ledger-tx"}, nil). + Once() + lineageService.On("BackfillAdvanceLineageSegments", + mock.Anything, + mock.MatchedBy(func(input lineage.BackfillAdvanceLineageSegmentsInput) bool { + return input.Namespace == charge.Namespace && + input.CustomerID == charge.Intent.CustomerID && + input.Currency == charge.Intent.Currency && + input.Amount.Equal(charge.Intent.CreditAmount) && + input.BackingTransactionGroupID == "initiated-ledger-tx" && + len(input.FeatureFilters) == 2 && + input.FeatureFilters[0] == "api-calls" && + input.FeatureFilters[1] == "storage" + })). + Return(nil). + Once() + realizationsService := newExternalStateMachineRealizations(t, adapter, handler, lineageService) + + stateMachine, err := NewExternalCreditPurchaseStateMachine(StateMachineConfig{ + Charge: charge, + Adapter: adapter, + Realizations: realizationsService, + }) + require.NoError(t, err) + + advancedCharge, err := stateMachine.AdvanceUntilStateStable(t.Context()) + + require.NoError(t, err) + require.NotNil(t, advancedCharge) + require.Equal(t, creditpurchase.StatusActivePaymentPending, advancedCharge.Status) + require.NotNil(t, advancedCharge.Realizations.CreditGrantRealization) + require.Equal(t, "initiated-ledger-tx", advancedCharge.Realizations.CreditGrantRealization.TransactionGroupID) + require.Nil(t, advancedCharge.Realizations.ExternalPaymentSettlement) + require.Equal(t, 1, adapter.createCreditGrantCalls) + require.Equal(t, 2, adapter.updateChargeCalls) + require.Equal(t, []creditpurchase.Status{ + creditpurchase.StatusActiveInitialCreditGrant, + creditpurchase.StatusActivePaymentPending, + }, adapter.updatedBaseStatuses) + handler.AssertExpectations(t) + lineageService.AssertExpectations(t) + }) + } +} + +func TestExternalCreditPurchaseStateMachineUsesRoundedCreditAmount(t *testing.T) { + // given: + // - a payment-pending external credit-purchase charge with a sub-cent credit amount + // when: + // - the current external helpers grant credits and authorize payment + // then: + // - lineage and payment realization both use the currency-rounded credit amount + expectedAmount := alpacadecimal.NewFromFloat(100.12) + charge := newExternalStateMachineTestChargeWithInput(externalStateMachineTestChargeInput{ + status: creditpurchase.StatusActivePaymentPending, + costBasis: alpacadecimal.NewFromFloat(0.5), + creditAmount: alpacadecimal.NewFromFloat(100.123), + initialStatus: creditpurchase.CreatedInitialPaymentSettlementStatus, + }) + + currency, err := currencyx.NewCurrencyBuilder(currencyx.CurrencyTypeFiat). + WithCode(charge.Intent.Currency). + Build() + require.NoError(t, err) + require.True(t, currency.IsRoundedToPrecision(charge.Intent.CreditAmount)) + require.Equal(t, 100.12, charge.Intent.CreditAmount.InexactFloat64()) + + adapter := &externalStateMachineAdapter{} + lineageService := &externalStateMachineLineage{} + handler := &externalStateMachineHandler{} + handler.On("OnCreditPurchaseInitiated", mock.Anything, mock.Anything). + Run(func(args mock.Arguments) { + charge := args.Get(1).(creditpurchase.Charge) + require.Equal(t, expectedAmount.InexactFloat64(), charge.Intent.CreditAmount.InexactFloat64()) + }). + Return(ledgertransaction.GroupReference{TransactionGroupID: "initiated-ledger-tx"}, nil). + Once() + handler.On("OnCreditPurchasePaymentAuthorized", mock.Anything, mock.Anything). + Run(func(args mock.Arguments) { + input := args.Get(1).(creditpurchase.PaymentEventInput) + require.Equal(t, expectedAmount.InexactFloat64(), input.Charge.Intent.CreditAmount.InexactFloat64()) + }). + Return(ledgertransaction.GroupReference{TransactionGroupID: "authorized-ledger-tx"}, nil). + Once() + realizationsService := newExternalStateMachineRealizations(t, adapter, handler, lineageService) + + lineageService.On("BackfillAdvanceLineageSegments", + mock.Anything, + mock.MatchedBy(func(input lineage.BackfillAdvanceLineageSegmentsInput) bool { + return input.Amount.Equal(expectedAmount) + })). + Return(nil). + Once() + + stateMachine, err := NewExternalCreditPurchaseStateMachine(StateMachineConfig{ + Charge: charge, + Adapter: adapter, + Realizations: realizationsService, + }) + require.NoError(t, err) + + err = stateMachine.GrantCredits(t.Context()) + require.NoError(t, err) + + err = stateMachine.FireAndActivate(t.Context(), billing.TriggerAuthorized) + require.NoError(t, err) + + require.Equal(t, expectedAmount.InexactFloat64(), adapter.createdExternalPayment.Amount.InexactFloat64()) + handler.AssertExpectations(t) + lineageService.AssertExpectations(t) +} + +func TestExternalCreditPurchaseServiceRoutesInitialStatuses(t *testing.T) { + for _, tc := range []struct { + name string + initialStatus creditpurchase.InitialPaymentSettlementStatus + wantStatus creditpurchase.Status + wantPaymentStatus *payment.Status + wantAuthorizedCalls int + wantAuthorizedStatus creditpurchase.Status + wantSettledCalls int + wantSettledStatus creditpurchase.Status + wantCreatePaymentCalls int + wantUpdatePaymentCalls int + wantUpdateChargeStatuses []creditpurchase.Status + }{ + { + name: "created", + initialStatus: creditpurchase.CreatedInitialPaymentSettlementStatus, + wantStatus: creditpurchase.StatusActivePaymentPending, + wantUpdateChargeStatuses: []creditpurchase.Status{ + creditpurchase.StatusActiveInitialCreditGrant, + creditpurchase.StatusActivePaymentPending, + }, + }, + { + name: "authorized", + initialStatus: creditpurchase.AuthorizedInitialPaymentSettlementStatus, + wantStatus: creditpurchase.StatusActivePaymentAuthorized, + wantPaymentStatus: lo.ToPtr(payment.StatusAuthorized), + wantAuthorizedCalls: 1, + wantAuthorizedStatus: creditpurchase.StatusActivePaymentAuthorized, + wantCreatePaymentCalls: 1, + wantUpdateChargeStatuses: []creditpurchase.Status{ + creditpurchase.StatusActiveInitialCreditGrant, + creditpurchase.StatusActivePaymentPending, + creditpurchase.StatusActivePaymentAuthorized, + }, + }, + { + name: "settled", + initialStatus: creditpurchase.SettledInitialPaymentSettlementStatus, + wantStatus: creditpurchase.StatusFinal, + wantPaymentStatus: lo.ToPtr(payment.StatusSettled), + wantAuthorizedCalls: 1, + wantAuthorizedStatus: creditpurchase.StatusActivePaymentPaidAndAuthorized, + wantSettledCalls: 1, + wantSettledStatus: creditpurchase.StatusActivePaymentSettled, + wantCreatePaymentCalls: 1, + wantUpdatePaymentCalls: 1, + wantUpdateChargeStatuses: []creditpurchase.Status{ + creditpurchase.StatusActiveInitialCreditGrant, + creditpurchase.StatusActivePaymentPending, + creditpurchase.StatusActivePaymentPaidAndAuthorized, + creditpurchase.StatusActivePaymentSettled, + creditpurchase.StatusFinal, + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + // given: + // - an external credit-purchase charge in the created state + // when: + // - the credit-purchase service starts the external lifecycle + // then: + // - it grants credits first, then routes the initial payment status through the expected transitions + charge := newExternalStateMachineTestChargeWithInput(externalStateMachineTestChargeInput{ + status: creditpurchase.StatusCreated, + costBasis: alpacadecimal.NewFromFloat(0.5), + creditAmount: alpacadecimal.NewFromFloat(100), + initialStatus: tc.initialStatus, + }) + + adapter := &externalStateMachineAdapter{} + lineageService := &externalStateMachineLineage{} + + handler := &externalStateMachineHandler{} + handler.On("OnCreditPurchaseInitiated", mock.Anything, mock.Anything). + Run(func(args mock.Arguments) { + charge := args.Get(1).(creditpurchase.Charge) + require.Equal(t, creditpurchase.StatusActiveInitialCreditGrant, charge.Status) + require.Nil(t, charge.Realizations.CreditGrantRealization) + require.Nil(t, charge.Realizations.ExternalPaymentSettlement) + }). + Return(ledgertransaction.GroupReference{TransactionGroupID: "initiated-ledger-tx"}, nil). + Once() + lineageService.On("BackfillAdvanceLineageSegments", + mock.Anything, + mock.MatchedBy(func(input lineage.BackfillAdvanceLineageSegmentsInput) bool { + return input.Namespace == charge.Namespace && + input.CustomerID == charge.Intent.CustomerID && + input.Currency == charge.Intent.Currency && + input.Amount.Equal(charge.Intent.CreditAmount) && + input.BackingTransactionGroupID == "initiated-ledger-tx" && + len(input.FeatureFilters) == 0 + })). + Return(nil). + Once() + if tc.wantAuthorizedCalls > 0 { + handler.On("OnCreditPurchasePaymentAuthorized", mock.Anything, mock.Anything). + Run(func(args mock.Arguments) { + input := args.Get(1).(creditpurchase.PaymentEventInput) + require.Equal(t, tc.wantAuthorizedStatus, input.Charge.Status) + require.NotNil(t, input.Charge.Realizations.CreditGrantRealization) + require.Nil(t, input.Charge.Realizations.ExternalPaymentSettlement) + }). + Return(ledgertransaction.GroupReference{TransactionGroupID: "authorized-ledger-tx"}, nil). + Once() + } + if tc.wantSettledCalls > 0 { + handler.On("OnCreditPurchasePaymentSettled", mock.Anything, mock.Anything). + Run(func(args mock.Arguments) { + input := args.Get(1).(creditpurchase.PaymentEventInput) + require.Equal(t, tc.wantSettledStatus, input.Charge.Status) + require.NotNil(t, input.Charge.Realizations.CreditGrantRealization) + require.NotNil(t, input.Charge.Realizations.ExternalPaymentSettlement) + require.Equal(t, payment.StatusAuthorized, input.Charge.Realizations.ExternalPaymentSettlement.Status) + }). + Return(ledgertransaction.GroupReference{TransactionGroupID: "settled-ledger-tx"}, nil). + Once() + } + realizationsService := newExternalStateMachineRealizations(t, adapter, handler, lineageService) + svc := &service{ + adapter: adapter, + realizations: realizationsService, + } + + got, err := svc.onExternalCreditPurchase(t.Context(), charge) + + require.NoError(t, err) + require.Equal(t, tc.wantStatus, got.Status) + require.NotNil(t, got.Realizations.CreditGrantRealization) + require.Equal(t, "initiated-ledger-tx", got.Realizations.CreditGrantRealization.TransactionGroupID) + if tc.wantPaymentStatus == nil { + require.Nil(t, got.Realizations.ExternalPaymentSettlement) + } else { + require.NotNil(t, got.Realizations.ExternalPaymentSettlement) + require.Equal(t, *tc.wantPaymentStatus, got.Realizations.ExternalPaymentSettlement.Status) + } + require.Equal(t, 1, adapter.createCreditGrantCalls) + require.Equal(t, tc.wantCreatePaymentCalls, adapter.createExternalPaymentCalls) + require.Equal(t, tc.wantUpdatePaymentCalls, adapter.updateExternalPaymentCalls) + require.Equal(t, len(tc.wantUpdateChargeStatuses), adapter.updateChargeCalls) + require.Equal(t, tc.wantUpdateChargeStatuses, adapter.updatedBaseStatuses) + require.Equal(t, tc.wantStatus, adapter.updatedBase.Status) + handler.AssertExpectations(t) + lineageService.AssertExpectations(t) + }) + } +} + +func TestExternalCreditPurchaseStateMachineGrantCreditsRejectsInvalidExternalSettlement(t *testing.T) { + for _, tc := range []struct { + name string + costBasis alpacadecimal.Decimal + initialStatus creditpurchase.InitialPaymentSettlementStatus + wantErr string + }{ + { + name: "zero cost basis", + costBasis: alpacadecimal.Zero, + initialStatus: creditpurchase.CreatedInitialPaymentSettlementStatus, + wantErr: "cost basis must be positive", + }, + { + name: "negative cost basis", + costBasis: alpacadecimal.NewFromFloat(-0.5), + initialStatus: creditpurchase.CreatedInitialPaymentSettlementStatus, + wantErr: "cost basis must be positive", + }, + { + name: "invalid initial status", + costBasis: alpacadecimal.NewFromFloat(0.5), + initialStatus: creditpurchase.InitialPaymentSettlementStatus("invalid"), + wantErr: "initial status", + }, + } { + t.Run(tc.name, func(t *testing.T) { + // given: + // - an external credit-purchase charge with invalid settlement input + // when: + // - the grant-credit action tries to create the credit grant realization + // then: + // - it fails with a validation error before creating realizations + charge := newExternalStateMachineTestChargeWithInput(externalStateMachineTestChargeInput{ + status: creditpurchase.StatusActivePaymentPending, + costBasis: tc.costBasis, + creditAmount: alpacadecimal.NewFromFloat(100), + initialStatus: tc.initialStatus, + }) + adapter := &externalStateMachineAdapter{} + handler := &externalStateMachineHandler{} + lineageService := &externalStateMachineLineage{} + realizationsService := newExternalStateMachineRealizations(t, adapter, handler, lineageService) + + stateMachine, err := NewExternalCreditPurchaseStateMachine(StateMachineConfig{ + Charge: charge, + Adapter: adapter, + Realizations: realizationsService, + }) + require.NoError(t, err) + + err = stateMachine.GrantCredits(t.Context()) + + require.Error(t, err) + require.ErrorContains(t, err, tc.wantErr) + require.True(t, models.IsGenericValidationError(err)) + require.Zero(t, adapter.createCreditGrantCalls) + require.Zero(t, adapter.createExternalPaymentCalls) + require.Zero(t, adapter.updateChargeCalls) + handler.AssertNotCalled(t, "OnCreditPurchaseInitiated", mock.Anything, mock.Anything) + lineageService.AssertNotCalled(t, "BackfillAdvanceLineageSegments", mock.Anything, mock.Anything) + }) + } +} + +func TestExternalCreditPurchaseStateMachineAuthorizesAndSettlesPayment(t *testing.T) { + // given: + // - an active external credit-purchase charge with granted credits + // when: + // - the payment is authorized and then settled + // then: + // - payment realization moves to settled and the charge becomes final + charge := newGrantedExternalCreditPurchaseCharge(creditpurchase.StatusActivePaymentPending) + handler := &externalStateMachineHandler{} + handler.On("OnCreditPurchasePaymentAuthorized", mock.Anything, mock.Anything). + Run(func(args mock.Arguments) { + input := args.Get(1).(creditpurchase.PaymentEventInput) + require.Equal(t, creditpurchase.StatusActivePaymentAuthorized, input.Charge.Status) + require.NotNil(t, input.Charge.Realizations.CreditGrantRealization) + require.Nil(t, input.Charge.Realizations.ExternalPaymentSettlement) + }). + Return(ledgertransaction.GroupReference{TransactionGroupID: "authorized-ledger-tx"}, nil). + Once() + handler.On("OnCreditPurchasePaymentSettled", mock.Anything, mock.Anything). + Run(func(args mock.Arguments) { + input := args.Get(1).(creditpurchase.PaymentEventInput) + require.Equal(t, creditpurchase.StatusActivePaymentSettled, input.Charge.Status) + require.NotNil(t, input.Charge.Realizations.ExternalPaymentSettlement) + require.Equal(t, payment.StatusAuthorized, input.Charge.Realizations.ExternalPaymentSettlement.Status) + require.Equal(t, "authorized-ledger-tx", input.Charge.Realizations.ExternalPaymentSettlement.Authorized.TransactionGroupID) + }). + Return(ledgertransaction.GroupReference{TransactionGroupID: "settled-ledger-tx"}, nil). + Once() + adapter := &externalStateMachineAdapter{} + lineageService := &externalStateMachineLineage{} + realizationsService := newExternalStateMachineRealizations(t, adapter, handler, lineageService) + + stateMachine, err := NewExternalCreditPurchaseStateMachine(StateMachineConfig{ + Charge: charge, + Adapter: adapter, + Realizations: realizationsService, + }) + require.NoError(t, err) + + err = stateMachine.FireAndActivate(t.Context(), billing.TriggerAuthorized) + require.NoError(t, err) + require.Equal(t, creditpurchase.StatusActivePaymentAuthorized, stateMachine.GetCharge().Status) + require.NotNil(t, stateMachine.GetCharge().Realizations.ExternalPaymentSettlement) + require.Equal(t, payment.StatusAuthorized, stateMachine.GetCharge().Realizations.ExternalPaymentSettlement.Status) + require.Equal(t, "authorized-ledger-tx", stateMachine.GetCharge().Realizations.ExternalPaymentSettlement.Authorized.TransactionGroupID) + require.Equal(t, 1, adapter.createExternalPaymentCalls) + require.Equal(t, 1, adapter.updateChargeCalls) + require.Equal(t, creditpurchase.StatusActivePaymentAuthorized, adapter.updatedBase.Status) + + settledCharge, err := stateMachine.handleExternalPaymentLifecycleTrigger(t.Context(), billing.TriggerPaid) + + require.NoError(t, err) + require.Equal(t, creditpurchase.StatusFinal, settledCharge.Status) + require.NotNil(t, settledCharge.Realizations.ExternalPaymentSettlement) + require.Equal(t, payment.StatusSettled, settledCharge.Realizations.ExternalPaymentSettlement.Status) + require.Equal(t, "authorized-ledger-tx", settledCharge.Realizations.ExternalPaymentSettlement.Authorized.TransactionGroupID) + require.Equal(t, "settled-ledger-tx", settledCharge.Realizations.ExternalPaymentSettlement.Settled.TransactionGroupID) + require.Equal(t, 1, adapter.updateExternalPaymentCalls) + require.Equal(t, 3, adapter.updateChargeCalls) + require.Equal(t, []creditpurchase.Status{ + creditpurchase.StatusActivePaymentAuthorized, + creditpurchase.StatusActivePaymentSettled, + creditpurchase.StatusFinal, + }, adapter.updatedBaseStatuses) + handler.AssertExpectations(t) +} + +func TestExternalCreditPurchaseStateMachineAuthorizationUsesRealizationDuplicateGuard(t *testing.T) { + // given: + // - a payment-pending external credit-purchase charge that already has an authorized payment realization + // when: + // - the state machine receives another authorized trigger + // then: + // - the realization service reports the duplicate payment and the charge status is not persisted + charge := newExternalStateMachineTestCharge(creditpurchase.StatusActivePaymentPending, alpacadecimal.NewFromFloat(0.5)) + charge.Realizations.CreditGrantRealization = &ledgertransaction.TimedGroupReference{ + GroupReference: ledgertransaction.GroupReference{TransactionGroupID: "initiated-ledger-tx"}, + Time: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + } + charge.Realizations.ExternalPaymentSettlement = &payment.External{ + Payment: payment.Payment{ + NamespacedID: models.NamespacedID{ + Namespace: charge.Namespace, + ID: "external-payment-1", + }, + Base: payment.Base{ + ServicePeriod: charge.Intent.ServicePeriod, + Amount: charge.Intent.CreditAmount, + Status: payment.StatusAuthorized, + Authorized: &ledgertransaction.TimedGroupReference{ + GroupReference: ledgertransaction.GroupReference{TransactionGroupID: "authorized-ledger-tx"}, + Time: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + }, + }, + }, + } + + adapter := &externalStateMachineAdapter{} + lineageService := &externalStateMachineLineage{} + handler := &externalStateMachineHandler{} + realizationsService := newExternalStateMachineRealizations(t, adapter, handler, lineageService) + + stateMachine, err := NewExternalCreditPurchaseStateMachine(StateMachineConfig{ + Charge: charge, + Adapter: adapter, + Realizations: realizationsService, + }) + require.NoError(t, err) + + err = stateMachine.FireAndActivate(t.Context(), billing.TriggerAuthorized) + + require.Error(t, err) + require.ErrorIs(t, err, payment.ErrPaymentAlreadyAuthorized) + require.Zero(t, adapter.createExternalPaymentCalls) + require.Zero(t, adapter.updateChargeCalls) + handler.AssertNotCalled(t, "OnCreditPurchasePaymentAuthorized", mock.Anything, mock.Anything) +} + +func TestExternalCreditPurchaseStateMachineAuthorizesAndSettlesInSingleTransition(t *testing.T) { + // given: + // - a payment-pending external credit-purchase charge with no payment realization + // when: + // - the state machine receives the paid trigger + // then: + // - it books authorization before settlement and persists the final charge status + charge := newGrantedExternalCreditPurchaseCharge(creditpurchase.StatusActivePaymentPending) + handler := &externalStateMachineHandler{} + handler.On("OnCreditPurchasePaymentAuthorized", mock.Anything, mock.Anything). + Run(func(args mock.Arguments) { + input := args.Get(1).(creditpurchase.PaymentEventInput) + require.Equal(t, creditpurchase.StatusActivePaymentPaidAndAuthorized, input.Charge.Status) + require.Nil(t, input.Charge.Realizations.ExternalPaymentSettlement) + }). + Return(ledgertransaction.GroupReference{TransactionGroupID: "authorized-ledger-tx"}, nil). + Once() + handler.On("OnCreditPurchasePaymentSettled", mock.Anything, mock.Anything). + Run(func(args mock.Arguments) { + input := args.Get(1).(creditpurchase.PaymentEventInput) + require.Equal(t, creditpurchase.StatusActivePaymentSettled, input.Charge.Status) + require.NotNil(t, input.Charge.Realizations.ExternalPaymentSettlement) + require.Equal(t, payment.StatusAuthorized, input.Charge.Realizations.ExternalPaymentSettlement.Status) + require.Equal(t, "authorized-ledger-tx", input.Charge.Realizations.ExternalPaymentSettlement.Authorized.TransactionGroupID) + }). + Return(ledgertransaction.GroupReference{TransactionGroupID: "settled-ledger-tx"}, nil). + Once() + adapter := &externalStateMachineAdapter{} + lineageService := &externalStateMachineLineage{} + realizationsService := newExternalStateMachineRealizations(t, adapter, handler, lineageService) + + stateMachine, err := NewExternalCreditPurchaseStateMachine(StateMachineConfig{ + Charge: charge, + Adapter: adapter, + Realizations: realizationsService, + }) + require.NoError(t, err) + + settledCharge, err := stateMachine.handleExternalPaymentLifecycleTrigger(t.Context(), billing.TriggerPaid) + + require.NoError(t, err) + require.Equal(t, creditpurchase.StatusFinal, settledCharge.Status) + require.NotNil(t, settledCharge.Realizations.ExternalPaymentSettlement) + require.Equal(t, payment.StatusSettled, settledCharge.Realizations.ExternalPaymentSettlement.Status) + require.Equal(t, "authorized-ledger-tx", settledCharge.Realizations.ExternalPaymentSettlement.Authorized.TransactionGroupID) + require.Equal(t, "settled-ledger-tx", settledCharge.Realizations.ExternalPaymentSettlement.Settled.TransactionGroupID) + require.Equal(t, 1, adapter.createExternalPaymentCalls) + require.Equal(t, 1, adapter.updateExternalPaymentCalls) + require.Equal(t, 3, adapter.updateChargeCalls) + require.Equal(t, []creditpurchase.Status{ + creditpurchase.StatusActivePaymentPaidAndAuthorized, + creditpurchase.StatusActivePaymentSettled, + creditpurchase.StatusFinal, + }, adapter.updatedBaseStatuses) + handler.AssertExpectations(t) +} + +func newGrantedExternalCreditPurchaseCharge(status creditpurchase.Status) creditpurchase.Charge { + charge := newExternalStateMachineTestCharge(status, alpacadecimal.NewFromFloat(0.5)) + charge.Realizations.CreditGrantRealization = &ledgertransaction.TimedGroupReference{ + GroupReference: ledgertransaction.GroupReference{TransactionGroupID: "initiated-ledger-tx"}, + Time: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + } + + return charge +} + +func newExternalStateMachineRealizations( + t *testing.T, + adapter creditpurchase.Adapter, + handler creditpurchase.Handler, + lineageService lineage.Service, +) *creditpurchaserealizations.Service { + t.Helper() + + realizationsService, err := creditpurchaserealizations.New(creditpurchaserealizations.Config{ + Adapter: adapter, + Handler: handler, + Lineage: lineageService, + }) + require.NoError(t, err) + + return realizationsService +} + +type externalStateMachineTestChargeInput struct { + status creditpurchase.Status + costBasis alpacadecimal.Decimal + creditAmount alpacadecimal.Decimal + initialStatus creditpurchase.InitialPaymentSettlementStatus + + featureFilters creditpurchase.FeatureFilters +} + +func newExternalStateMachineTestCharge(status creditpurchase.Status, costBasis alpacadecimal.Decimal) creditpurchase.Charge { + return newExternalStateMachineTestChargeWithInput(externalStateMachineTestChargeInput{ + status: status, + costBasis: costBasis, + creditAmount: alpacadecimal.NewFromFloat(100), + initialStatus: creditpurchase.CreatedInitialPaymentSettlementStatus, + }) +} + +func newExternalStateMachineTestChargeWithInput(input externalStateMachineTestChargeInput) creditpurchase.Charge { + period := timeutil.ClosedPeriod{ + From: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + To: time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC), + } + + intent := creditpurchase.Intent{ + Intent: meta.Intent{ + CustomerID: "customer-1", + Currency: currencyx.Code("USD"), + }, + IntentMutableFields: creditpurchase.IntentMutableFields{ + IntentMutableFields: meta.IntentMutableFields{ + Name: "test external credits", + ServicePeriod: period, + FullServicePeriod: period, + BillingPeriod: period, + }, + CreditAmount: input.creditAmount, + FeatureFilters: input.featureFilters, + Settlement: creditpurchase.NewSettlement(creditpurchase.ExternalSettlement{ + GenericSettlement: creditpurchase.GenericSettlement{ + Currency: currencyx.Code("USD"), + CostBasis: input.costBasis, + }, + InitialStatus: input.initialStatus, + }), + }, + }.Normalized() + + return creditpurchase.Charge{ + ChargeBase: creditpurchase.ChargeBase{ + ManagedResource: meta.ManagedResource{ + NamespacedModel: models.NamespacedModel{ + Namespace: "test-namespace", + }, + ManagedModel: models.ManagedModel{ + CreatedAt: period.From, + UpdatedAt: period.From, + }, + ID: "charge-1", + }, + Intent: intent, + Status: input.status, + }, + } +} + +type externalStateMachineAdapter struct { + creditpurchase.Adapter + + updateChargeCalls int + updatedBase creditpurchase.ChargeBase + updatedBaseStatuses []creditpurchase.Status + + createCreditGrantCalls int + createdGrantChargeID meta.ChargeID + createdGrantInput creditpurchase.CreateCreditGrantInput + + createExternalPaymentCalls int + createdExternalPaymentID meta.ChargeID + createdExternalPayment payment.ExternalCreateInput + + updateExternalPaymentCalls int + updatedExternalPayment payment.External +} + +func (a *externalStateMachineAdapter) UpdateCharge(ctx context.Context, charge creditpurchase.ChargeBase) (creditpurchase.ChargeBase, error) { + a.updateChargeCalls++ + a.updatedBase = charge + a.updatedBaseStatuses = append(a.updatedBaseStatuses, charge.Status) + return charge, nil +} + +func (a *externalStateMachineAdapter) CreateCreditGrant(ctx context.Context, chargeID meta.ChargeID, input creditpurchase.CreateCreditGrantInput) (ledgertransaction.TimedGroupReference, error) { + a.createCreditGrantCalls++ + a.createdGrantChargeID = chargeID + a.createdGrantInput = input + return ledgertransaction.TimedGroupReference{ + GroupReference: ledgertransaction.GroupReference{ + TransactionGroupID: input.TransactionGroupID, + }, + Time: input.GrantedAt, + }, nil +} + +func (a *externalStateMachineAdapter) CreateExternalPayment(ctx context.Context, chargeID meta.ChargeID, input payment.ExternalCreateInput) (payment.External, error) { + a.createExternalPaymentCalls++ + a.createdExternalPaymentID = chargeID + a.createdExternalPayment = input + return payment.External{ + Payment: payment.Payment{ + NamespacedID: models.NamespacedID{ + Namespace: input.Namespace, + ID: "external-payment-1", + }, + ManagedModel: models.ManagedModel{ + CreatedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + }, + Base: input.Base, + }, + }, nil +} + +func (a *externalStateMachineAdapter) UpdateExternalPayment(ctx context.Context, paymentSettlement payment.External) (payment.External, error) { + a.updateExternalPaymentCalls++ + a.updatedExternalPayment = paymentSettlement + return paymentSettlement, nil +} + +type externalStateMachineHandler struct { + creditpurchase.Handler + mock.Mock +} + +func (h *externalStateMachineHandler) OnCreditPurchaseInitiated(ctx context.Context, charge creditpurchase.Charge) (ledgertransaction.GroupReference, error) { + args := h.Called(ctx, charge) + return args.Get(0).(ledgertransaction.GroupReference), args.Error(1) +} + +func (h *externalStateMachineHandler) OnCreditPurchasePaymentAuthorized(ctx context.Context, input creditpurchase.PaymentEventInput) (ledgertransaction.GroupReference, error) { + args := h.Called(ctx, input) + return args.Get(0).(ledgertransaction.GroupReference), args.Error(1) +} + +func (h *externalStateMachineHandler) OnCreditPurchasePaymentSettled(ctx context.Context, input creditpurchase.PaymentEventInput) (ledgertransaction.GroupReference, error) { + args := h.Called(ctx, input) + return args.Get(0).(ledgertransaction.GroupReference), args.Error(1) +} + +type externalStateMachineLineage struct { + lineage.Service + mock.Mock +} + +func (l *externalStateMachineLineage) BackfillAdvanceLineageSegments(ctx context.Context, input lineage.BackfillAdvanceLineageSegmentsInput) error { + args := l.Called(ctx, input) + return args.Error(0) +} + +var ( + _ creditpurchase.Handler = (*externalStateMachineHandler)(nil) + _ lineage.Service = (*externalStateMachineLineage)(nil) +) diff --git a/billing/charges/creditpurchase/service/funded_credit_activity.go b/billing/charges/creditpurchase/service/funded_credit_activity.go new file mode 100644 index 0000000000000000000000000000000000000000..644d58c14a709d3eece2040b841016c58304a11e --- /dev/null +++ b/billing/charges/creditpurchase/service/funded_credit_activity.go @@ -0,0 +1,18 @@ +package service + +import ( + "context" + + "github.com/openmeterio/openmeter/openmeter/billing/charges/creditpurchase" + "github.com/openmeterio/openmeter/pkg/framework/transaction" +) + +func (s *service) ListFundedCreditActivities(ctx context.Context, input creditpurchase.ListFundedCreditActivitiesInput) (creditpurchase.ListFundedCreditActivitiesResult, error) { + if err := input.Validate(); err != nil { + return creditpurchase.ListFundedCreditActivitiesResult{}, err + } + + return transaction.Run(ctx, s.adapter, func(ctx context.Context) (creditpurchase.ListFundedCreditActivitiesResult, error) { + return s.adapter.ListFundedCreditActivities(ctx, input) + }) +} diff --git a/billing/charges/creditpurchase/service/get.go b/billing/charges/creditpurchase/service/get.go new file mode 100644 index 0000000000000000000000000000000000000000..3ba25807ee900cc807709ede2b17e9611e4ea9af --- /dev/null +++ b/billing/charges/creditpurchase/service/get.go @@ -0,0 +1,39 @@ +package service + +import ( + "context" + + "github.com/openmeterio/openmeter/openmeter/billing/charges/creditpurchase" + "github.com/openmeterio/openmeter/pkg/framework/transaction" + "github.com/openmeterio/openmeter/pkg/pagination" +) + +func (s *service) GetByIDs(ctx context.Context, input creditpurchase.GetByIDsInput) ([]creditpurchase.Charge, error) { + if err := input.Validate(); err != nil { + return nil, err + } + + return transaction.Run(ctx, s.adapter, func(ctx context.Context) ([]creditpurchase.Charge, error) { + return s.adapter.GetByIDs(ctx, input) + }) +} + +func (s *service) List(ctx context.Context, input creditpurchase.ListChargesInput) (pagination.Result[creditpurchase.Charge], error) { + if err := input.Validate(); err != nil { + return pagination.Result[creditpurchase.Charge]{}, err + } + + return transaction.Run(ctx, s.adapter, func(ctx context.Context) (pagination.Result[creditpurchase.Charge], error) { + return s.adapter.ListCharges(ctx, input) + }) +} + +func (s *service) MarkVoided(ctx context.Context, input creditpurchase.MarkVoidedInput) (creditpurchase.ChargeBase, error) { + if err := input.Validate(); err != nil { + return creditpurchase.ChargeBase{}, err + } + + return transaction.Run(ctx, s.adapter, func(ctx context.Context) (creditpurchase.ChargeBase, error) { + return s.adapter.MarkVoided(ctx, input) + }) +} diff --git a/billing/charges/creditpurchase/service/invoice.go b/billing/charges/creditpurchase/service/invoice.go new file mode 100644 index 0000000000000000000000000000000000000000..f76310212839c4ab71b156db50a4a9f6d15c6a31 --- /dev/null +++ b/billing/charges/creditpurchase/service/invoice.go @@ -0,0 +1,131 @@ +package service + +import ( + "context" + "fmt" + + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/billing/charges/creditpurchase" + "github.com/openmeterio/openmeter/openmeter/billing/charges/lineage" + "github.com/openmeterio/openmeter/openmeter/billing/charges/models/ledgertransaction" + "github.com/openmeterio/openmeter/openmeter/billing/charges/models/payment" + "github.com/openmeterio/openmeter/pkg/clock" + "github.com/openmeterio/openmeter/pkg/framework/transaction" +) + +func (s *service) PostInvoiceDraftCreated(ctx context.Context, charge creditpurchase.Charge, lineWithHeader billing.StandardLineWithInvoiceHeader) error { + return transaction.RunWithNoValue(ctx, s.adapter, func(ctx context.Context) error { + ledgerTransactionGroupReference, err := s.handler.OnCreditPurchaseInitiated(ctx, charge) + if err != nil { + return err + } + + if _, err := s.adapter.CreateCreditGrant(ctx, charge.GetChargeID(), creditpurchase.CreateCreditGrantInput{ + TransactionGroupID: ledgerTransactionGroupReference.TransactionGroupID, + GrantedAt: clock.Now(), + }); err != nil { + return err + } + + if ledgerTransactionGroupReference.TransactionGroupID != "" { + if err := s.lineage.BackfillAdvanceLineageSegments(ctx, lineage.BackfillAdvanceLineageSegmentsInput{ + Namespace: charge.Namespace, + CustomerID: charge.Intent.CustomerID, + Currency: charge.Intent.Currency, + Amount: charge.Intent.CreditAmount, + BackingTransactionGroupID: ledgerTransactionGroupReference.TransactionGroupID, + FeatureFilters: charge.Intent.FeatureFilters.Normalize(), + }); err != nil { + return err + } + } + + charge.Status = creditpurchase.StatusActive + + _, err = s.adapter.UpdateCharge(ctx, charge.ChargeBase) + return err + }) +} + +// PostInvoicePaymentAuthorized is called when the invoice is approved/issued. +// It's invoked from the billing service's PostUpdate hook, already within a transaction. +func (s *service) PostInvoicePaymentAuthorized(ctx context.Context, charge creditpurchase.Charge, lineWithHeader billing.StandardLineWithInvoiceHeader) error { + if charge.Realizations.InvoiceSettlement != nil { + return fmt.Errorf("invoice settlement already authorized - settlement already exists: %s", charge.Realizations.InvoiceSettlement.InvoiceID) + } + + eventAt := clock.Now() + ledgerTransactionGroupReference, err := s.handler.OnCreditPurchasePaymentAuthorized(ctx, creditpurchase.PaymentEventInput{ + Charge: charge, + EventAt: eventAt, + }) + if err != nil { + return err + } + + newPaymentSettlement := payment.InvoicedCreate{ + Namespace: charge.Namespace, + Base: payment.Base{ + ServicePeriod: charge.Intent.ServicePeriod, + Amount: charge.Intent.CreditAmount, + Authorized: &ledgertransaction.TimedGroupReference{ + GroupReference: ledgerTransactionGroupReference, + Time: eventAt, + }, + Status: payment.StatusAuthorized, + }, + InvoiceID: lineWithHeader.Invoice.ID, + LineID: lineWithHeader.Line.ID, + } + + _, err = s.adapter.CreateInvoicedPayment(ctx, charge.GetChargeID(), newPaymentSettlement) + if err != nil { + return err + } + + return nil +} + +// PostInvoicePaymentSettled is called when the invoice is paid. +// It's invoked from the billing service's PostUpdate hook, already within a transaction. +func (s *service) PostInvoicePaymentSettled(ctx context.Context, charge creditpurchase.Charge, lineWithHeader billing.StandardLineWithInvoiceHeader) error { + // Idempotency check: if already settled, skip processing + if charge.Realizations.InvoiceSettlement == nil { + return fmt.Errorf("invoice settlement not found") + } + + if charge.Realizations.InvoiceSettlement.Settled != nil { + return fmt.Errorf("invoice settlement already settled") + } + + paymentSettlement := *charge.Realizations.InvoiceSettlement + + eventAt := clock.Now() + ledgerTransactionGroupReference, err := s.handler.OnCreditPurchasePaymentSettled(ctx, creditpurchase.PaymentEventInput{ + Charge: charge, + EventAt: eventAt, + }) + if err != nil { + return err + } + + paymentSettlement.Settled = &ledgertransaction.TimedGroupReference{ + GroupReference: ledgerTransactionGroupReference, + Time: eventAt, + } + + paymentSettlement.Status = payment.StatusSettled + + if _, err := s.adapter.UpdateInvoicedPayment(ctx, paymentSettlement); err != nil { + return err + } + + // Update charge status to final + charge.Status = creditpurchase.StatusFinal + + if _, err := s.adapter.UpdateCharge(ctx, charge.ChargeBase); err != nil { + return err + } + + return nil +} diff --git a/billing/charges/creditpurchase/service/promotional.go b/billing/charges/creditpurchase/service/promotional.go new file mode 100644 index 0000000000000000000000000000000000000000..278a67679f1cdfdc97455ce26812c43f413b1a5a --- /dev/null +++ b/billing/charges/creditpurchase/service/promotional.go @@ -0,0 +1,99 @@ +package service + +import ( + "context" + "fmt" + + "github.com/openmeterio/openmeter/openmeter/billing/charges/creditpurchase" + "github.com/openmeterio/openmeter/openmeter/billing/charges/lineage" + "github.com/openmeterio/openmeter/openmeter/billing/charges/meta" + "github.com/openmeterio/openmeter/pkg/clock" + "github.com/openmeterio/openmeter/pkg/statelessx" +) + +func (s *service) grantPromotionalCredit(ctx context.Context, charge creditpurchase.Charge) (creditpurchase.Charge, error) { + if charge.Realizations.CreditGrantRealization != nil && charge.Realizations.CreditGrantRealization.TransactionGroupID != "" { + return creditpurchase.Charge{}, fmt.Errorf("promotional credit grant already realized [charge_id=%s, transaction_group_id=%s]", charge.ID, charge.Realizations.CreditGrantRealization.TransactionGroupID) + } + + ledgerTransactionGroupReference, err := s.handler.OnPromotionalCreditPurchase(ctx, charge) + if err != nil { + return creditpurchase.Charge{}, err + } + + grantRealization, err := s.adapter.CreateCreditGrant(ctx, charge.GetChargeID(), creditpurchase.CreateCreditGrantInput{ + TransactionGroupID: ledgerTransactionGroupReference.TransactionGroupID, + GrantedAt: clock.Now(), + }) + if err != nil { + return creditpurchase.Charge{}, err + } + + charge.Realizations.CreditGrantRealization = &grantRealization + + if ledgerTransactionGroupReference.TransactionGroupID != "" { + if err := s.lineage.BackfillAdvanceLineageSegments(ctx, lineage.BackfillAdvanceLineageSegmentsInput{ + Namespace: charge.Namespace, + CustomerID: charge.Intent.CustomerID, + Currency: charge.Intent.Currency, + Amount: charge.Intent.CreditAmount, + BackingTransactionGroupID: ledgerTransactionGroupReference.TransactionGroupID, + FeatureFilters: charge.Intent.FeatureFilters.Normalize(), + }); err != nil { + return creditpurchase.Charge{}, err + } + } + + return charge, nil +} + +type PromotionalCreditpurchaseStateMachine struct { + *stateMachine +} + +func NewPromotionalCreditPurchaseStateMachine(config StateMachineConfig) (*PromotionalCreditpurchaseStateMachine, error) { + if err := config.Validate(); err != nil { + return nil, fmt.Errorf("validate: %w", err) + } + + if config.Service == nil { + return nil, fmt.Errorf("service is required") + } + + if config.Charge.Intent.Settlement.Type() != creditpurchase.SettlementTypePromotional { + return nil, fmt.Errorf("charge %s is not promotional", config.Charge.ID) + } + + stateMachine, err := newStateMachineBase(config) + if err != nil { + return nil, fmt.Errorf("failed to create promotional credit purchase state machine: %w", err) + } + + out := &PromotionalCreditpurchaseStateMachine{ + stateMachine: stateMachine, + } + out.configureStates() + + return out, nil +} + +func (s *PromotionalCreditpurchaseStateMachine) configureStates() { + s.Configure(creditpurchase.StatusCreated). + Permit(meta.TriggerNext, creditpurchase.StatusFinal) + + s.Configure(creditpurchase.StatusActive). + Permit(meta.TriggerNext, creditpurchase.StatusFinal) + + s.Configure(creditpurchase.StatusFinal). + OnEntry(statelessx.EntryFunc(s.GrantPromotionalCredit)) +} + +func (s *PromotionalCreditpurchaseStateMachine) GrantPromotionalCredit(ctx context.Context) error { + charge, err := s.Service.grantPromotionalCredit(ctx, s.Charge) + if err != nil { + return err + } + + s.Charge = charge + return nil +} diff --git a/billing/charges/creditpurchase/service/promotional_test.go b/billing/charges/creditpurchase/service/promotional_test.go new file mode 100644 index 0000000000000000000000000000000000000000..9bf6b723f98583ff00773456d4f8054d56956e16 --- /dev/null +++ b/billing/charges/creditpurchase/service/promotional_test.go @@ -0,0 +1,329 @@ +package service + +import ( + "context" + "testing" + "time" + + "github.com/alpacahq/alpacadecimal" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/openmeterio/openmeter/openmeter/billing/charges/creditpurchase" + "github.com/openmeterio/openmeter/openmeter/billing/charges/lineage" + "github.com/openmeterio/openmeter/openmeter/billing/charges/meta" + "github.com/openmeterio/openmeter/openmeter/billing/charges/models/ledgertransaction" + "github.com/openmeterio/openmeter/pkg/currencyx" + "github.com/openmeterio/openmeter/pkg/models" + "github.com/openmeterio/openmeter/pkg/timeutil" +) + +func TestPromotionalCreditPurchaseStateMachineAdvancesCreatedChargeToFinal(t *testing.T) { + // given: + // - a created promotional credit-purchase charge + // when: + // - the promotional state machine advances until stable + // then: + // - it grants the promotional credits, backfills lineage, and persists the final status + stateMachine, charge, adapter, lineageService := newPromotionalStateMachineTestMachine( + t, + creditpurchase.StatusCreated, + ) + + advancedCharge, err := stateMachine.AdvanceUntilStateStable(t.Context()) + + require.NoError(t, err) + require.NotNil(t, advancedCharge) + require.Equal(t, creditpurchase.StatusFinal, advancedCharge.Status) + require.Equal(t, creditpurchase.StatusFinal, adapter.updatedBase.Status) + require.NotNil(t, advancedCharge.Realizations.CreditGrantRealization) + require.NotEmpty(t, advancedCharge.Realizations.CreditGrantRealization.TransactionGroupID) + require.Equal(t, 1, adapter.createCreditGrantCalls) + require.Equal(t, charge.GetChargeID(), adapter.createdGrantChargeID) + require.Equal(t, advancedCharge.Realizations.CreditGrantRealization.TransactionGroupID, adapter.createdGrantInput.TransactionGroupID) + require.False(t, adapter.createdGrantInput.GrantedAt.IsZero()) + lineageService.AssertExpectations(t) +} + +func TestPromotionalCreditPurchaseStateMachineAdvancesActiveChargeToFinal(t *testing.T) { + // given: + // - an active promotional credit-purchase charge + // when: + // - the promotional state machine advances until stable + // then: + // - it still grants once and persists the final status + stateMachine, _, adapter, lineageService := newPromotionalStateMachineTestMachine( + t, + creditpurchase.StatusActive, + ) + + advancedCharge, err := stateMachine.AdvanceUntilStateStable(t.Context()) + + require.NoError(t, err) + require.NotNil(t, advancedCharge) + require.Equal(t, creditpurchase.StatusFinal, advancedCharge.Status) + require.Equal(t, 1, adapter.createCreditGrantCalls) + require.Equal(t, 1, adapter.updateChargeCalls) + lineageService.AssertExpectations(t) +} + +func TestPromotionalCreditPurchaseStateMachineRejectsExistingCreditGrant(t *testing.T) { + // given: + // - a promotional charge that already has a credit grant realization + // when: + // - the promotional state machine attempts to grant credits + // then: + // - it fails before creating another grant + charge := newPromotionalStateMachineTestCharge(creditpurchase.StatusCreated) + charge.Realizations.CreditGrantRealization = &ledgertransaction.TimedGroupReference{ + GroupReference: ledgertransaction.GroupReference{ + TransactionGroupID: "existing-ledger-tx", + }, + Time: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + } + + adapter := &promotionalStateMachineAdapter{} + lineageService := &promotionalStateMachineLineage{} + svc := &service{ + adapter: adapter, + handler: &promotionalStateMachineHandler{}, + lineage: lineageService, + } + + stateMachine, err := NewPromotionalCreditPurchaseStateMachine(StateMachineConfig{ + Charge: charge, + Adapter: adapter, + Service: svc, + }) + require.NoError(t, err) + + advancedCharge, err := stateMachine.AdvanceUntilStateStable(t.Context()) + + require.Error(t, err) + require.ErrorContains(t, err, "promotional credit grant already realized") + require.Nil(t, advancedCharge) + require.Zero(t, adapter.createCreditGrantCalls) + require.Zero(t, adapter.updateChargeCalls) + lineageService.AssertNotCalled(t, "BackfillAdvanceLineageSegments", mock.Anything, mock.Anything) +} + +func TestPromotionalCreditPurchaseStateMachineReturnsNilForFinalCharge(t *testing.T) { + // given: + // - a final promotional credit-purchase charge + // when: + // - the promotional state machine advances until stable + // then: + // - it is already stable and does not call side-effect handlers + adapter := &promotionalStateMachineAdapter{} + svc := &service{ + adapter: adapter, + handler: &promotionalStateMachineHandler{}, + lineage: &promotionalStateMachineLineage{}, + } + + stateMachine, err := NewPromotionalCreditPurchaseStateMachine(StateMachineConfig{ + Charge: newPromotionalStateMachineTestCharge(creditpurchase.StatusFinal), + Adapter: adapter, + Service: svc, + }) + require.NoError(t, err) + + advancedCharge, err := stateMachine.AdvanceUntilStateStable(t.Context()) + + require.NoError(t, err) + require.Nil(t, advancedCharge) + require.Zero(t, adapter.createCreditGrantCalls) + require.Zero(t, adapter.updateChargeCalls) +} + +func TestPromotionalCreditPurchaseStateMachineRejectsNonPromotionalCharge(t *testing.T) { + // given: + // - a credit-purchase charge with invoice settlement + // when: + // - the promotional state machine is constructed + // then: + // - construction fails before any lifecycle side effect can happen + charge := newPromotionalStateMachineTestCharge(creditpurchase.StatusCreated) + charge.Intent.Settlement = creditpurchase.NewSettlement(creditpurchase.InvoiceSettlement{}) + + _, err := NewPromotionalCreditPurchaseStateMachine(StateMachineConfig{ + Charge: charge, + Adapter: &promotionalStateMachineAdapter{}, + Service: &service{}, + }) + + require.Error(t, err) + require.ErrorContains(t, err, "is not promotional") +} + +func TestPromotionalCreditPurchaseStateMachineRejectsMissingAdapter(t *testing.T) { + // given: + // - a promotional credit-purchase charge without persistence + // when: + // - the promotional state machine is constructed + // then: + // - construction fails before lifecycle methods can dereference the adapter + _, err := NewPromotionalCreditPurchaseStateMachine(StateMachineConfig{ + Charge: newPromotionalStateMachineTestCharge(creditpurchase.StatusCreated), + Service: &service{}, + }) + + require.Error(t, err) + require.ErrorContains(t, err, "adapter is required") +} + +func TestPromotionalCreditPurchaseStateMachineRejectsMissingService(t *testing.T) { + // given: + // - a promotional credit-purchase charge without runtime service dependencies + // when: + // - the promotional state machine is constructed + // then: + // - construction fails before final-state entry can dereference the service + _, err := NewPromotionalCreditPurchaseStateMachine(StateMachineConfig{ + Charge: newPromotionalStateMachineTestCharge(creditpurchase.StatusCreated), + Adapter: &promotionalStateMachineAdapter{}, + }) + + require.Error(t, err) + require.ErrorContains(t, err, "service is required") +} + +func newPromotionalStateMachineTestMachine( + t *testing.T, + status creditpurchase.Status, +) (*PromotionalCreditpurchaseStateMachine, creditpurchase.Charge, *promotionalStateMachineAdapter, *promotionalStateMachineLineage) { + t.Helper() + + charge := newPromotionalStateMachineTestCharge(status) + adapter := &promotionalStateMachineAdapter{} + lineageService := &promotionalStateMachineLineage{} + handler := &promotionalStateMachineHandler{ + onPromotionalCreditPurchase: func(ctx context.Context, charge creditpurchase.Charge) (ledgertransaction.GroupReference, error) { + return ledgertransaction.GroupReference{ + TransactionGroupID: "ledger-tx-1", + }, nil + }, + } + svc := &service{ + adapter: adapter, + handler: handler, + lineage: lineageService, + } + + lineageService.On("BackfillAdvanceLineageSegments", + mock.Anything, + mock.MatchedBy(func(input lineage.BackfillAdvanceLineageSegmentsInput) bool { + return input.Namespace == charge.Namespace && + input.CustomerID == charge.Intent.CustomerID && + input.Currency == charge.Intent.Currency && + input.Amount.Equal(charge.Intent.CreditAmount) && + input.BackingTransactionGroupID != "" + })). + Return(nil). + Once() + + stateMachine, err := NewPromotionalCreditPurchaseStateMachine(StateMachineConfig{ + Charge: charge, + Adapter: adapter, + Service: svc, + }) + require.NoError(t, err) + + return stateMachine, charge, adapter, lineageService +} + +func newPromotionalStateMachineTestCharge(status creditpurchase.Status) creditpurchase.Charge { + period := timeutil.ClosedPeriod{ + From: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + To: time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC), + } + + return creditpurchase.Charge{ + ChargeBase: creditpurchase.ChargeBase{ + ManagedResource: meta.ManagedResource{ + NamespacedModel: models.NamespacedModel{ + Namespace: "test-namespace", + }, + ManagedModel: models.ManagedModel{ + CreatedAt: period.From, + UpdatedAt: period.From, + }, + ID: "charge-1", + }, + Intent: creditpurchase.Intent{ + Intent: meta.Intent{ + CustomerID: "customer-1", + Currency: currencyx.Code("USD"), + }, + IntentMutableFields: creditpurchase.IntentMutableFields{ + IntentMutableFields: meta.IntentMutableFields{ + Name: "test promotional credits", + ServicePeriod: period, + FullServicePeriod: period, + BillingPeriod: period, + }, + CreditAmount: alpacadecimal.NewFromFloat(100), + Settlement: creditpurchase.NewSettlement(creditpurchase.PromotionalSettlement{}), + }, + }, + Status: status, + }, + } +} + +type promotionalStateMachineAdapter struct { + creditpurchase.Adapter + + updateChargeCalls int + updatedBase creditpurchase.ChargeBase + createCreditGrantCalls int + createdGrantChargeID meta.ChargeID + createdGrantInput creditpurchase.CreateCreditGrantInput +} + +func (a *promotionalStateMachineAdapter) UpdateCharge(ctx context.Context, charge creditpurchase.ChargeBase) (creditpurchase.ChargeBase, error) { + a.updateChargeCalls++ + a.updatedBase = charge + return charge, nil +} + +func (a *promotionalStateMachineAdapter) CreateCreditGrant(ctx context.Context, chargeID meta.ChargeID, input creditpurchase.CreateCreditGrantInput) (ledgertransaction.TimedGroupReference, error) { + a.createCreditGrantCalls++ + a.createdGrantChargeID = chargeID + a.createdGrantInput = input + return ledgertransaction.TimedGroupReference{ + GroupReference: ledgertransaction.GroupReference{ + TransactionGroupID: input.TransactionGroupID, + }, + Time: input.GrantedAt, + }, nil +} + +type promotionalStateMachineHandler struct { + creditpurchase.Handler + + onPromotionalCreditPurchase func(ctx context.Context, charge creditpurchase.Charge) (ledgertransaction.GroupReference, error) +} + +func (h *promotionalStateMachineHandler) OnPromotionalCreditPurchase(ctx context.Context, charge creditpurchase.Charge) (ledgertransaction.GroupReference, error) { + if h.onPromotionalCreditPurchase == nil { + return ledgertransaction.GroupReference{}, nil + } + + return h.onPromotionalCreditPurchase(ctx, charge) +} + +type promotionalStateMachineLineage struct { + lineage.Service + mock.Mock +} + +func (l *promotionalStateMachineLineage) BackfillAdvanceLineageSegments(ctx context.Context, input lineage.BackfillAdvanceLineageSegmentsInput) error { + args := l.Called(ctx, input) + return args.Error(0) +} + +var ( + _ creditpurchase.Handler = (*promotionalStateMachineHandler)(nil) + _ lineage.Service = (*promotionalStateMachineLineage)(nil) +) diff --git a/billing/charges/creditpurchase/service/realizations/service.go b/billing/charges/creditpurchase/service/realizations/service.go new file mode 100644 index 0000000000000000000000000000000000000000..5ef2a4c36323dabb23cbcd5fa3bea15860867414 --- /dev/null +++ b/billing/charges/creditpurchase/service/realizations/service.go @@ -0,0 +1,196 @@ +package realizations + +import ( + "context" + "errors" + "fmt" + + "github.com/openmeterio/openmeter/openmeter/billing/charges/creditpurchase" + "github.com/openmeterio/openmeter/openmeter/billing/charges/lineage" + "github.com/openmeterio/openmeter/openmeter/billing/charges/models/ledgertransaction" + "github.com/openmeterio/openmeter/openmeter/billing/charges/models/payment" + "github.com/openmeterio/openmeter/pkg/clock" + "github.com/openmeterio/openmeter/pkg/models" +) + +// Service owns credit-purchase realization mechanics. It must not decide which +// lifecycle trigger should fire or which charge status should be entered. +type Service struct { + adapter creditpurchase.Adapter + handler creditpurchase.Handler + lineage lineage.Service +} + +type Config struct { + Adapter creditpurchase.Adapter + Handler creditpurchase.Handler + Lineage lineage.Service +} + +func (c Config) Validate() error { + var errs []error + + if c.Adapter == nil { + errs = append(errs, errors.New("adapter is required")) + } + + if c.Handler == nil { + errs = append(errs, errors.New("handler is required")) + } + + if c.Lineage == nil { + errs = append(errs, errors.New("lineage service is required")) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +func New(config Config) (*Service, error) { + if err := config.Validate(); err != nil { + return nil, err + } + + return &Service{ + adapter: config.Adapter, + handler: config.Handler, + lineage: config.Lineage, + }, nil +} + +func (s *Service) GrantCredits(ctx context.Context, charge creditpurchase.Charge) (creditpurchase.Charge, error) { + externalSettlement, err := charge.Intent.Settlement.AsExternalSettlement() + if err != nil { + return creditpurchase.Charge{}, err + } + + if err := externalSettlement.Validate(); err != nil { + return creditpurchase.Charge{}, err + } + + if charge.Realizations.CreditGrantRealization != nil && charge.Realizations.CreditGrantRealization.TransactionGroupID != "" { + return creditpurchase.Charge{}, fmt.Errorf("external credit grant already realized [charge_id=%s, transaction_group_id=%s]", charge.ID, charge.Realizations.CreditGrantRealization.TransactionGroupID) + } + + ledgerTransactionGroupReference, err := s.handler.OnCreditPurchaseInitiated(ctx, charge) + if err != nil { + return creditpurchase.Charge{}, err + } + + grantRealization, err := s.adapter.CreateCreditGrant(ctx, charge.GetChargeID(), creditpurchase.CreateCreditGrantInput{ + TransactionGroupID: ledgerTransactionGroupReference.TransactionGroupID, + GrantedAt: clock.Now(), + }) + if err != nil { + return creditpurchase.Charge{}, err + } + + charge.Realizations.CreditGrantRealization = &grantRealization + + if ledgerTransactionGroupReference.TransactionGroupID != "" { + if err := s.lineage.BackfillAdvanceLineageSegments(ctx, lineage.BackfillAdvanceLineageSegmentsInput{ + Namespace: charge.Namespace, + CustomerID: charge.Intent.CustomerID, + Currency: charge.Intent.Currency, + Amount: charge.Intent.CreditAmount, + BackingTransactionGroupID: ledgerTransactionGroupReference.TransactionGroupID, + FeatureFilters: charge.Intent.FeatureFilters.Normalize(), + }); err != nil { + return creditpurchase.Charge{}, err + } + } + + return charge, nil +} + +func (s *Service) AuthorizeExternalPayment(ctx context.Context, charge creditpurchase.Charge) (creditpurchase.Charge, error) { + if charge.Realizations.ExternalPaymentSettlement != nil { + return creditpurchase.Charge{}, payment.ErrPaymentAlreadyAuthorized. + WithAttrs(charge.ErrorAttributes()). + WithAttrs(charge.Realizations.ExternalPaymentSettlement.ErrorAttributes()) + } + + eventAt := clock.Now() + ledgerTransactionGroupReference, err := s.handler.OnCreditPurchasePaymentAuthorized(ctx, creditpurchase.PaymentEventInput{ + Charge: charge, + EventAt: eventAt, + }) + if err != nil { + return creditpurchase.Charge{}, err + } + + newPaymentSettlement := payment.ExternalCreateInput{ + Namespace: charge.Namespace, + Base: payment.Base{ + ServicePeriod: charge.Intent.ServicePeriod, + Amount: charge.Intent.CreditAmount, + Authorized: &ledgertransaction.TimedGroupReference{ + GroupReference: ledgerTransactionGroupReference, + Time: eventAt, + }, + Status: payment.StatusAuthorized, + }, + } + + paymentSettlement, err := s.adapter.CreateExternalPayment(ctx, charge.GetChargeID(), newPaymentSettlement) + if err != nil { + return creditpurchase.Charge{}, err + } + + charge.Realizations.ExternalPaymentSettlement = &paymentSettlement + + return charge, nil +} + +func (s *Service) SettleExternalPayment(ctx context.Context, charge creditpurchase.Charge) (creditpurchase.Charge, error) { + if charge.Realizations.ExternalPaymentSettlement == nil { + return creditpurchase.Charge{}, payment.ErrCannotSettleNotAuthorizedPayment. + WithAttrs(charge.ErrorAttributes()) + } + + paymentSettlement := *charge.Realizations.ExternalPaymentSettlement + + if paymentSettlement.Status != payment.StatusAuthorized { + return creditpurchase.Charge{}, payment.ErrPaymentAlreadySettled. + WithAttrs(charge.ErrorAttributes()). + WithAttrs(paymentSettlement.ErrorAttributes()) + } + + eventAt := clock.Now() + ledgerTransactionGroupReference, err := s.handler.OnCreditPurchasePaymentSettled(ctx, creditpurchase.PaymentEventInput{ + Charge: charge, + EventAt: eventAt, + }) + if err != nil { + return creditpurchase.Charge{}, err + } + + paymentSettlement.Settled = &ledgertransaction.TimedGroupReference{ + GroupReference: ledgerTransactionGroupReference, + Time: eventAt, + } + + paymentSettlement.Status = payment.StatusSettled + + paymentSettlement, err = s.adapter.UpdateExternalPayment(ctx, paymentSettlement) + if err != nil { + return creditpurchase.Charge{}, err + } + + charge.Realizations.ExternalPaymentSettlement = &paymentSettlement + + return charge, nil +} + +func (s *Service) AuthorizeAndSettleExternalPayment(ctx context.Context, charge creditpurchase.Charge) (creditpurchase.Charge, error) { + charge, err := s.AuthorizeExternalPayment(ctx, charge) + if err != nil { + return creditpurchase.Charge{}, err + } + + charge, err = s.SettleExternalPayment(ctx, charge) + if err != nil { + return creditpurchase.Charge{}, err + } + + return charge, nil +} diff --git a/billing/charges/creditpurchase/service/service.go b/billing/charges/creditpurchase/service/service.go new file mode 100644 index 0000000000000000000000000000000000000000..5bbbbf63a7eef245cdebe25466ffe7abaf22c0e8 --- /dev/null +++ b/billing/charges/creditpurchase/service/service.go @@ -0,0 +1,71 @@ +package service + +import ( + "errors" + "fmt" + + "github.com/openmeterio/openmeter/openmeter/billing/charges/creditpurchase" + creditpurchaserealizations "github.com/openmeterio/openmeter/openmeter/billing/charges/creditpurchase/service/realizations" + "github.com/openmeterio/openmeter/openmeter/billing/charges/lineage" + "github.com/openmeterio/openmeter/openmeter/billing/charges/meta" +) + +type Config struct { + Adapter creditpurchase.Adapter + Handler creditpurchase.Handler + Lineage lineage.Service + MetaAdapter meta.Adapter +} + +func (c Config) Validate() error { + var errs []error + + if c.Adapter == nil { + errs = append(errs, errors.New("adapter cannot be null")) + } + + if c.Handler == nil { + errs = append(errs, errors.New("credit purchase handler cannot be null")) + } + + if c.Lineage == nil { + errs = append(errs, errors.New("lineage service cannot be null")) + } + + if c.MetaAdapter == nil { + errs = append(errs, errors.New("meta adapter cannot be null")) + } + + return errors.Join(errs...) +} + +func New(config Config) (creditpurchase.Service, error) { + if err := config.Validate(); err != nil { + return nil, err + } + + realizations, err := creditpurchaserealizations.New(creditpurchaserealizations.Config{ + Adapter: config.Adapter, + Handler: config.Handler, + Lineage: config.Lineage, + }) + if err != nil { + return nil, fmt.Errorf("realizations: %w", err) + } + + return &service{ + adapter: config.Adapter, + handler: config.Handler, + lineage: config.Lineage, + metaAdapter: config.MetaAdapter, + realizations: realizations, + }, nil +} + +type service struct { + adapter creditpurchase.Adapter + metaAdapter meta.Adapter + handler creditpurchase.Handler + lineage lineage.Service + realizations *creditpurchaserealizations.Service +} diff --git a/billing/charges/creditpurchase/service/statemachine.go b/billing/charges/creditpurchase/service/statemachine.go new file mode 100644 index 0000000000000000000000000000000000000000..448bb0f38c2e1d7aa9e6a9d07d337031b00b66cf --- /dev/null +++ b/billing/charges/creditpurchase/service/statemachine.go @@ -0,0 +1,101 @@ +package service + +import ( + "context" + "errors" + "fmt" + + "github.com/openmeterio/openmeter/openmeter/billing/charges/creditpurchase" + creditpurchaserealizations "github.com/openmeterio/openmeter/openmeter/billing/charges/creditpurchase/service/realizations" + "github.com/openmeterio/openmeter/openmeter/billing/charges/meta" + chargestatemachine "github.com/openmeterio/openmeter/openmeter/billing/charges/statemachine" + "github.com/openmeterio/openmeter/pkg/models" +) + +type stateMachine struct { + *chargestatemachine.Machine[creditpurchase.Charge, creditpurchase.ChargeBase, creditpurchase.Status] + + Adapter creditpurchase.Adapter + Realizations *creditpurchaserealizations.Service + Service *service + + CreditNotesSupported bool +} + +type StateMachine = chargestatemachine.StateMachine[creditpurchase.Charge] + +type StateMachineConfig struct { + Charge creditpurchase.Charge + + Adapter creditpurchase.Adapter + Realizations *creditpurchaserealizations.Service + Service *service + + CreditNotesSupported bool +} + +func (c StateMachineConfig) Validate() error { + var errs []error + + if c.Charge.ID == "" { + errs = append(errs, errors.New("charge ID is required")) + } + + if c.Adapter == nil { + errs = append(errs, errors.New("adapter is required")) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +func newStateMachineBase(config StateMachineConfig) (*stateMachine, error) { + if err := config.Validate(); err != nil { + return nil, fmt.Errorf("config: %w", err) + } + + out := &stateMachine{ + Adapter: config.Adapter, + Realizations: config.Realizations, + Service: config.Service, + CreditNotesSupported: config.CreditNotesSupported, + } + + machine, err := chargestatemachine.New(chargestatemachine.Config[creditpurchase.Charge, creditpurchase.ChargeBase, creditpurchase.Status]{ + Charge: config.Charge, + Persistence: chargestatemachine.Persistence[creditpurchase.Charge, creditpurchase.ChargeBase]{ + UpdateBase: func(ctx context.Context, base creditpurchase.ChargeBase) (creditpurchase.ChargeBase, error) { + return out.Adapter.UpdateCharge(ctx, base) + }, + Refetch: func(ctx context.Context, chargeID meta.ChargeID) (creditpurchase.Charge, error) { + return out.Adapter.GetByID(ctx, creditpurchase.GetByIDInput{ + ChargeID: chargeID, + Expands: meta.Expands{meta.ExpandRealizations}, + }) + }, + }, + }) + if err != nil { + return nil, fmt.Errorf("failed to create state machine: %w", err) + } + + out.Machine = machine + + return out, nil +} + +func (s *stateMachine) FireAndAdvanceUntilStateStable(ctx context.Context, trigger meta.Trigger) (creditpurchase.Charge, error) { + if err := s.FireAndActivate(ctx, trigger); err != nil { + return creditpurchase.Charge{}, err + } + + advancedCharge, err := s.AdvanceUntilStateStable(ctx) + if err != nil { + return creditpurchase.Charge{}, err + } + + if advancedCharge != nil { + return *advancedCharge, nil + } + + return s.GetCharge(), nil +} diff --git a/billing/charges/creditpurchase/settlement.go b/billing/charges/creditpurchase/settlement.go new file mode 100644 index 0000000000000000000000000000000000000000..8545c4f415cfec06ac0f605131ba6927f3bfa8a6 --- /dev/null +++ b/billing/charges/creditpurchase/settlement.go @@ -0,0 +1,329 @@ +package creditpurchase + +import ( + "encoding/json" + "errors" + "fmt" + "slices" + + "github.com/alpacahq/alpacadecimal" + + "github.com/openmeterio/openmeter/pkg/currencyx" + "github.com/openmeterio/openmeter/pkg/models" +) + +type SettlementType string + +const ( + SettlementTypeInvoice SettlementType = "invoice" + SettlementTypeExternal SettlementType = "external" + SettlementTypePromotional SettlementType = "promotional" +) + +func (s SettlementType) Validate() error { + if !slices.Contains(s.Values(), string(s)) { + return models.NewGenericValidationError(fmt.Errorf("invalid credit purchase settlement type: %s", s)) + } + return nil +} + +func (s SettlementType) Values() []string { + return []string{ + string(SettlementTypeInvoice), + string(SettlementTypeExternal), + string(SettlementTypePromotional), + } +} + +type GenericSettlement struct { + Currency currencyx.Code `json:"currency"` + CostBasis alpacadecimal.Decimal `json:"costBasis"` +} + +func (s GenericSettlement) Validate() error { + var errs []error + + if err := s.Currency.Validate(); err != nil { + errs = append(errs, fmt.Errorf("settlement currency: %w", err)) + } + + if !s.CostBasis.IsPositive() { + errs = append(errs, fmt.Errorf("cost basis must be positive")) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +type InvoiceSettlement struct { + GenericSettlement +} + +func (s InvoiceSettlement) Validate() error { + var errs []error + + if err := s.GenericSettlement.Validate(); err != nil { + errs = append(errs, fmt.Errorf("generic settlement: %w", err)) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +type InitialPaymentSettlementStatus string + +const ( + CreatedInitialPaymentSettlementStatus InitialPaymentSettlementStatus = "created" + AuthorizedInitialPaymentSettlementStatus InitialPaymentSettlementStatus = "authorized" + SettledInitialPaymentSettlementStatus InitialPaymentSettlementStatus = "settled" +) + +func (s InitialPaymentSettlementStatus) Validate() error { + if !slices.Contains(s.Values(), string(s)) { + return models.NewGenericValidationError(fmt.Errorf("invalid payment settlement status: %s", s)) + } + return nil +} + +func (s InitialPaymentSettlementStatus) Values() []string { + return []string{ + string(CreatedInitialPaymentSettlementStatus), + string(AuthorizedInitialPaymentSettlementStatus), + string(SettledInitialPaymentSettlementStatus), + } +} + +func (s InitialPaymentSettlementStatus) In(statuses ...InitialPaymentSettlementStatus) bool { + return slices.Contains(statuses, s) +} + +type ExternalSettlement struct { + GenericSettlement + + InitialStatus InitialPaymentSettlementStatus `json:"status"` +} + +func (s ExternalSettlement) Validate() error { + var errs []error + + if err := s.InitialStatus.Validate(); err != nil { + errs = append(errs, fmt.Errorf("initial status: %w", err)) + } + + if err := s.GenericSettlement.Validate(); err != nil { + errs = append(errs, err) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +type PromotionalSettlement struct{} + +func (s PromotionalSettlement) Validate() error { + return nil +} + +type Settlement struct { + t SettlementType + + invoice *InvoiceSettlement + external *ExternalSettlement + promotional *PromotionalSettlement +} + +func (s Settlement) MarshalJSON() ([]byte, error) { + var serde interface{} + + switch s.t { + case SettlementTypeInvoice: + if s.invoice == nil { + return nil, fmt.Errorf("settlement: invoice is nil") + } + + serde = struct { + Type SettlementType `json:"type"` + *InvoiceSettlement + }{ + Type: SettlementTypeInvoice, + InvoiceSettlement: s.invoice, + } + case SettlementTypeExternal: + if s.external == nil { + return nil, fmt.Errorf("settlement: external is nil") + } + + serde = struct { + Type SettlementType `json:"type"` + *ExternalSettlement + }{ + Type: SettlementTypeExternal, + ExternalSettlement: s.external, + } + case SettlementTypePromotional: + serde = struct { + Type SettlementType `json:"type"` + }{ + Type: SettlementTypePromotional, + } + default: + return nil, fmt.Errorf("invalid credit purchase settlement type: %s", s.t) + } + + b, err := json.Marshal(serde) + if err != nil { + return nil, fmt.Errorf("failed to JSON serialize CreditPurchaseSettlement: %w", err) + } + + return b, nil +} + +func (s *Settlement) UnmarshalJSON(bytes []byte) error { + serde := &struct { + Type SettlementType `json:"type"` + }{} + + if err := json.Unmarshal(bytes, serde); err != nil { + return fmt.Errorf("failed to JSON deserialize CreditPurchaseSettlement type: %w", err) + } + + switch serde.Type { + case SettlementTypeInvoice: + v := &InvoiceSettlement{} + if err := json.Unmarshal(bytes, v); err != nil { + return fmt.Errorf("failed to JSON deserialize InvoiceCreditPurchaseSettlement: %w", err) + } + + s.invoice = v + s.t = SettlementTypeInvoice + case SettlementTypeExternal: + v := &ExternalSettlement{} + if err := json.Unmarshal(bytes, v); err != nil { + return fmt.Errorf("failed to JSON deserialize ExternalCreditPurchaseSettlement: %w", err) + } + + s.external = v + s.t = SettlementTypeExternal + case SettlementTypePromotional: + s.promotional = &PromotionalSettlement{} + s.t = SettlementTypePromotional + default: + return fmt.Errorf("invalid credit purchase settlement type: %s", serde.Type) + } + + return nil +} + +func NewSettlement[T InvoiceSettlement | ExternalSettlement | PromotionalSettlement](settlement T) Settlement { + switch v := any(settlement).(type) { + case InvoiceSettlement: + return Settlement{ + t: SettlementTypeInvoice, + invoice: &v, + } + case ExternalSettlement: + return Settlement{ + t: SettlementTypeExternal, + external: &v, + } + case PromotionalSettlement: + return Settlement{ + t: SettlementTypePromotional, + promotional: &v, + } + default: + return Settlement{} + } +} + +func (s Settlement) Type() SettlementType { + return s.t +} + +func (s Settlement) Validate() error { + switch s.t { + case SettlementTypeInvoice: + if s.invoice == nil { + return models.NewGenericValidationError(fmt.Errorf("invoice is required")) + } + + if err := s.invoice.Validate(); err != nil { + return models.NewGenericValidationError(fmt.Errorf("invoice: %w", err)) + } + case SettlementTypeExternal: + if s.external == nil { + return models.NewGenericValidationError(fmt.Errorf("external is required")) + } + + if err := s.external.Validate(); err != nil { + return models.NewGenericValidationError(fmt.Errorf("external: %w", err)) + } + case SettlementTypePromotional: + if s.promotional == nil { + return models.NewGenericValidationError(fmt.Errorf("promotional is required")) + } + + if err := s.promotional.Validate(); err != nil { + return models.NewGenericValidationError(fmt.Errorf("promotional: %w", err)) + } + default: + return models.NewGenericValidationError(fmt.Errorf("invalid credit purchase settlement type: %s", s.t)) + } + return nil +} + +func (s Settlement) AsInvoiceSettlement() (InvoiceSettlement, error) { + if s.t != SettlementTypeInvoice { + return InvoiceSettlement{}, fmt.Errorf("settlement is not an invoice settlement") + } + + if s.invoice == nil { + return InvoiceSettlement{}, fmt.Errorf("invoice is nil") + } + + return *s.invoice, nil +} + +func (s Settlement) AsExternalSettlement() (ExternalSettlement, error) { + if s.t != SettlementTypeExternal { + return ExternalSettlement{}, fmt.Errorf("settlement is not an external settlement") + } + + if s.external == nil { + return ExternalSettlement{}, fmt.Errorf("external is nil") + } + + return *s.external, nil +} + +func (s Settlement) AsPromotionalSettlement() (PromotionalSettlement, error) { + if s.t != SettlementTypePromotional { + return PromotionalSettlement{}, fmt.Errorf("settlement is not a promotional settlement") + } + + if s.promotional == nil { + return PromotionalSettlement{}, fmt.Errorf("promotional is nil") + } + + return *s.promotional, nil +} + +// Common getters + +func (s Settlement) GetCostBasis() (alpacadecimal.Decimal, error) { + switch s.t { + case SettlementTypeInvoice: + if s.invoice == nil { + return alpacadecimal.Zero, fmt.Errorf("invoice is nil") + } + + return s.invoice.CostBasis, nil + case SettlementTypeExternal: + if s.external == nil { + return alpacadecimal.Zero, fmt.Errorf("external is nil") + } + + return s.external.CostBasis, nil + case SettlementTypePromotional: + return alpacadecimal.Zero, nil + default: + return alpacadecimal.Zero, fmt.Errorf("invalid settlement type: %s", s.t) + } +} diff --git a/billing/charges/creditpurchase/settlement_test.go b/billing/charges/creditpurchase/settlement_test.go new file mode 100644 index 0000000000000000000000000000000000000000..c7385b4b308f84926d7f2cb4ae2ccef4a6b974e7 --- /dev/null +++ b/billing/charges/creditpurchase/settlement_test.go @@ -0,0 +1,52 @@ +package creditpurchase + +import ( + "testing" + + "github.com/alpacahq/alpacadecimal" + "github.com/stretchr/testify/require" + + "github.com/openmeterio/openmeter/pkg/currencyx" + "github.com/openmeterio/openmeter/pkg/models" +) + +func TestGenericSettlementValidateRequiresPositiveCostBasis(t *testing.T) { + for _, tc := range []struct { + name string + costBasis alpacadecimal.Decimal + wantErr bool + }{ + { + name: "positive", + costBasis: alpacadecimal.NewFromFloat(0.5), + }, + { + name: "zero", + costBasis: alpacadecimal.Zero, + wantErr: true, + }, + { + name: "negative", + costBasis: alpacadecimal.NewFromFloat(-0.5), + wantErr: true, + }, + } { + t.Run(tc.name, func(t *testing.T) { + settlement := GenericSettlement{ + Currency: currencyx.Code("USD"), + CostBasis: tc.costBasis, + } + + err := settlement.Validate() + + if tc.wantErr { + require.Error(t, err) + require.ErrorContains(t, err, "cost basis must be positive") + require.True(t, models.IsGenericValidationError(err)) + return + } + + require.NoError(t, err) + }) + } +} diff --git a/billing/charges/creditpurchase/statemachine.go b/billing/charges/creditpurchase/statemachine.go new file mode 100644 index 0000000000000000000000000000000000000000..255e04a8d1843303868577a6a905693f65575045 --- /dev/null +++ b/billing/charges/creditpurchase/statemachine.go @@ -0,0 +1,52 @@ +package creditpurchase + +import ( + "fmt" + "slices" + + "github.com/openmeterio/openmeter/openmeter/billing/charges/meta" + "github.com/openmeterio/openmeter/pkg/models" +) + +type Status string + +const ( + StatusCreated Status = Status(meta.ChargeStatusCreated) + StatusActive Status = Status(meta.ChargeStatusActive) + StatusActivePaymentPending Status = "active.payment.pending" + StatusActiveInitialCreditGrant Status = "active.initial_credit_grant" + StatusActivePaymentAuthorized Status = "active.payment.authorized" + StatusActivePaymentSettled Status = "active.payment.settled" + StatusActivePaymentPaidAndAuthorized Status = "active.payment.paid_and_authorized" + StatusFinal Status = Status(meta.ChargeStatusFinal) + StatusDeleted Status = Status(meta.ChargeStatusDeleted) +) + +func (Status) Values() []string { + return []string{ + string(StatusCreated), + string(StatusActive), + string(StatusActiveInitialCreditGrant), + string(StatusActivePaymentPending), + string(StatusActivePaymentAuthorized), + string(StatusActivePaymentPaidAndAuthorized), + string(StatusActivePaymentSettled), + string(StatusFinal), + string(StatusDeleted), + } +} + +func (s Status) Validate() error { + if !slices.Contains(s.Values(), string(s)) { + return models.NewGenericValidationError(fmt.Errorf("invalid status: %s", s)) + } + return nil +} + +func (s Status) ToMetaChargeStatus() (meta.ChargeStatus, error) { + if err := s.Validate(); err != nil { + return meta.ChargeStatusCreated, err + } + + return meta.DetailedStatusToMetaStatus(string(s)) +} diff --git a/billing/charges/errors.go b/billing/charges/errors.go new file mode 100644 index 0000000000000000000000000000000000000000..8103319ac72974910d9df9a4720cbfe37db0b2f7 --- /dev/null +++ b/billing/charges/errors.go @@ -0,0 +1,50 @@ +package charges + +import ( + "net/http" + + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/models" +) + +const ErrCodeChargeNamespaceEmpty models.ErrorCode = "charge_namespace_empty" + +var ErrChargeNamespaceEmpty = models.NewValidationIssue( + ErrCodeChargeNamespaceEmpty, + "namespace must not be empty", + models.WithFieldString("namespace"), + models.WithCriticalSeverity(), + commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest), +) + +const ErrCodeChargeNotFound models.ErrorCode = "charge_not_found" + +var ErrChargeNotFound = models.NewValidationIssue( + ErrCodeChargeNotFound, + "charge not found", + models.WithFieldString("id"), + models.WithCriticalSeverity(), + commonhttp.WithHTTPStatusCodeAttribute(http.StatusNotFound), +) + +func NewChargeNotFoundError(namespace, id string) error { + return ErrChargeNotFound.WithAttr("namespace", namespace).WithAttr("id", id) +} + +const ErrCodeChargeInvalid models.ErrorCode = "charge_invalid" + +var ErrChargeInvalid = models.NewValidationIssue( + ErrCodeChargeInvalid, + "charge is invalid", + models.WithCriticalSeverity(), + commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest), +) + +const ErrCodeCreditRealizationsAlreadyAllocated models.ErrorCode = "credit_realizations_already_allocated" + +var ErrCreditRealizationsAlreadyAllocated = models.NewValidationIssue( + ErrCodeCreditRealizationsAlreadyAllocated, + "credit realizations already allocated", + models.WithCriticalSeverity(), + commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest), +) diff --git a/billing/charges/events.go b/billing/charges/events.go new file mode 100644 index 0000000000000000000000000000000000000000..9abc79fe7f06def839d57f7b0f67052b9b11c3e9 --- /dev/null +++ b/billing/charges/events.go @@ -0,0 +1,44 @@ +package charges + +import ( + "fmt" + + "github.com/openmeterio/openmeter/openmeter/event/metadata" + "github.com/openmeterio/openmeter/pkg/models" +) + +const ( + EventSubsystem metadata.EventSubsystem = "billing" +) + +type AdvanceChargesEvent struct { + Namespace string `json:"namespace"` + CustomerID string `json:"customer_id"` +} + +func (e AdvanceChargesEvent) EventName() string { + return metadata.GetEventName(metadata.EventType{ + Subsystem: EventSubsystem, + Name: "charges.advance", + Version: "v1", + }) +} + +func (e AdvanceChargesEvent) EventMetadata() metadata.EventMetadata { + return metadata.EventMetadata{ + Source: metadata.ComposeResourcePath(e.Namespace, metadata.EntityCustomer, e.CustomerID), + Subject: metadata.ComposeResourcePath(e.Namespace, metadata.EntityCustomer, e.CustomerID), + } +} + +func (e AdvanceChargesEvent) Validate() error { + if e.Namespace == "" { + return models.NewGenericValidationError(fmt.Errorf("namespace cannot be empty")) + } + + if e.CustomerID == "" { + return models.NewGenericValidationError(fmt.Errorf("customer_id cannot be empty")) + } + + return nil +} diff --git a/billing/charges/features.go b/billing/charges/features.go new file mode 100644 index 0000000000000000000000000000000000000000..9afa6280c92ad8492b8249a2fb237f34fd0d5b53 --- /dev/null +++ b/billing/charges/features.go @@ -0,0 +1,7 @@ +package charges + +// CreditNotesSupportedByLineUpdater controls whether charge-backed immutable +// invoice-line proration can materialize replacement gathering lines. The +// default behavior is false until the invoice line updater supports credit +// notes for correcting immutable invoice history. +var CreditNotesSupportedByLineUpdater = false diff --git a/billing/charges/flatfee/adapter.go b/billing/charges/flatfee/adapter.go new file mode 100644 index 0000000000000000000000000000000000000000..12f0c785318f2d80bb7d15d348a6af7f45866f05 --- /dev/null +++ b/billing/charges/flatfee/adapter.go @@ -0,0 +1,247 @@ +package flatfee + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/alpacahq/alpacadecimal" + + "github.com/openmeterio/openmeter/openmeter/billing/charges/meta" + "github.com/openmeterio/openmeter/openmeter/billing/charges/models/creditrealization" + "github.com/openmeterio/openmeter/openmeter/billing/charges/models/invoicedusage" + "github.com/openmeterio/openmeter/openmeter/billing/charges/models/payment" + "github.com/openmeterio/openmeter/pkg/framework/entutils" + "github.com/openmeterio/openmeter/pkg/models" + "github.com/openmeterio/openmeter/pkg/timeutil" +) + +type Adapter interface { + ChargeAdapter + ChargeDetailedLineAdapter + ChargeCreditAllocationAdapter + ChargeRunAdapter + ChargeInvoicedUsageAdapter + ChargePaymentAdapter + + entutils.TxCreator +} + +type ChargeAdapter interface { + CreateCharges(ctx context.Context, charges CreateChargesInput) ([]Charge, error) + + UpdateCharge(ctx context.Context, charge ChargeBase) (ChargeBase, error) + CreateChargeOverride(ctx context.Context, charge ChargeBase, override IntentMutableFields) (ChargeBase, error) + DeleteChargeOverride(ctx context.Context, charge ChargeBase) (ChargeBase, error) + UpdateSubscriptionItemID(ctx context.Context, charge Charge, newSubscriptionItemID string) (Charge, error) + DeleteCharge(ctx context.Context, charge Charge) error + GetByIDs(ctx context.Context, ids GetByIDsInput) ([]Charge, error) + GetByID(ctx context.Context, id GetByIDInput) (Charge, error) +} + +type ChargeDetailedLineAdapter interface { + UpsertDetailedLines(ctx context.Context, runID RealizationRunID, lines DetailedLines) error + FetchCurrentRunDetailedLines(ctx context.Context, charge Charge) (Charge, error) +} + +type ChargeInvoicedUsageAdapter interface { + CreateInvoicedUsage(ctx context.Context, input CreateInvoicedUsageInput) (invoicedusage.AccruedUsage, error) +} + +type ChargeRunAdapter interface { + CreateCurrentRun(ctx context.Context, input CreateCurrentRunInput) (RealizationRunBase, error) + UpdateRealizationRun(ctx context.Context, input UpdateRealizationRunInput) (RealizationRunBase, error) + DetachCurrentRun(ctx context.Context, chargeID meta.ChargeID) error +} + +type ChargeCreditAllocationAdapter interface { + CreateCreditAllocations(ctx context.Context, runID RealizationRunID, creditAllocations creditrealization.CreateInputs) (creditrealization.Realizations, error) +} + +type ChargePaymentAdapter interface { + CreatePayment(ctx context.Context, runID RealizationRunID, paymentSettlement payment.InvoicedCreate) (payment.Invoiced, error) + UpdatePayment(ctx context.Context, paymentSettlement payment.Invoiced) (payment.Invoiced, error) +} + +type CreateCurrentRunInput struct { + Charge ChargeBase + ServicePeriod timeutil.ClosedPeriod + AmountAfterProration alpacadecimal.Decimal + NoFiatTransactionRequired bool + Immutable bool + LineID *string + InvoiceID *string +} + +func (i CreateCurrentRunInput) Validate() error { + var errs []error + + if err := i.Charge.Validate(); err != nil { + errs = append(errs, fmt.Errorf("charge: %w", err)) + } + + if err := i.ServicePeriod.Validate(); err != nil { + errs = append(errs, fmt.Errorf("service period: %w", err)) + } + + if i.AmountAfterProration.IsNegative() { + errs = append(errs, fmt.Errorf("amount after proration cannot be negative")) + } + + if i.LineID != nil && *i.LineID == "" { + errs = append(errs, fmt.Errorf("line id must be non-empty")) + } + + if i.InvoiceID != nil && *i.InvoiceID == "" { + errs = append(errs, fmt.Errorf("invoice id must be non-empty")) + } + + if (i.LineID == nil) != (i.InvoiceID == nil) { + errs = append(errs, fmt.Errorf("line id and invoice id must be provided together")) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +type CreateInvoicedUsageInput struct { + RunID RealizationRunID + LineID string + InvoiceID string + InvoicedUsage invoicedusage.AccruedUsage +} + +func (i CreateInvoicedUsageInput) Validate() error { + var errs []error + + if err := i.RunID.Validate(); err != nil { + errs = append(errs, fmt.Errorf("run ID: %w", err)) + } + + if i.InvoiceID == "" { + errs = append(errs, fmt.Errorf("invoice ID is required")) + } + + if i.LineID == "" { + errs = append(errs, fmt.Errorf("line ID is required")) + } + + if err := i.InvoicedUsage.Validate(); err != nil { + errs = append(errs, fmt.Errorf("invoiced usage: %w", err)) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +type IntentWithInitialStatus struct { + Intent Intent + + FeatureID *string + InitialStatus Status + InitialAdvanceAfter *time.Time + AmountAfterProration alpacadecimal.Decimal + NoFiatTransactionRequired bool +} + +func (i IntentWithInitialStatus) Validate() error { + var errs []error + if err := i.Intent.Validate(); err != nil { + errs = append(errs, fmt.Errorf("intent: %w", err)) + } + + if i.AmountAfterProration.IsNegative() { + errs = append(errs, fmt.Errorf("amount after proration cannot be negative")) + } + + if err := i.InitialStatus.Validate(); err != nil { + errs = append(errs, fmt.Errorf("initial status: %w", err)) + } + + if i.InitialAdvanceAfter != nil && i.InitialAdvanceAfter.IsZero() { + errs = append(errs, fmt.Errorf("initial advance after cannot be zero")) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +type GetByIDsInput struct { + Namespace string + IDs []string + + Expands meta.Expands +} + +func (i GetByIDsInput) Validate() error { + var errs []error + + if i.Namespace == "" { + errs = append(errs, errors.New("namespace is required")) + } + + for _, id := range i.IDs { + if id == "" { + errs = append(errs, errors.New("id is required")) + } + } + + if err := validateExpands(i.Expands); err != nil { + errs = append(errs, fmt.Errorf("expands: %w", err)) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +type GetByIDInput struct { + ChargeID meta.ChargeID + Expands meta.Expands +} + +func (i GetByIDInput) Validate() error { + var errs []error + if err := i.ChargeID.Validate(); err != nil { + errs = append(errs, fmt.Errorf("charge ID: %w", err)) + } + + if err := validateExpands(i.Expands); err != nil { + errs = append(errs, fmt.Errorf("expands: %w", err)) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +type CreateChargesInput struct { + Namespace string + Intents []IntentWithInitialStatus +} + +func (i CreateChargesInput) Validate() error { + var errs []error + + if i.Namespace == "" { + errs = append(errs, errors.New("namespace is required")) + } + + for idx, intent := range i.Intents { + if err := intent.Validate(); err != nil { + errs = append(errs, fmt.Errorf("intent [%d]: %w", idx, err)) + } + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +func validateExpands(expands meta.Expands) error { + if err := expands.Validate(); err != nil { + return err + } + + if expands.Has(meta.ExpandDetailedLines) && !expands.Has(meta.ExpandRealizations) { + return fmt.Errorf("%q requires %q", meta.ExpandDetailedLines, meta.ExpandRealizations) + } + + if expands.Has(meta.ExpandDeletedRealizations) && !expands.Has(meta.ExpandRealizations) { + return fmt.Errorf("%q requires %q", meta.ExpandDeletedRealizations, meta.ExpandRealizations) + } + + return nil +} diff --git a/billing/charges/flatfee/adapter/adapter.go b/billing/charges/flatfee/adapter/adapter.go new file mode 100644 index 0000000000000000000000000000000000000000..762b0fd184f72163e769d14e8d9fbd8d86b270a6 --- /dev/null +++ b/billing/charges/flatfee/adapter/adapter.go @@ -0,0 +1,81 @@ +package adapter + +import ( + "context" + "database/sql" + "errors" + "fmt" + "log/slog" + + "github.com/openmeterio/openmeter/openmeter/billing/charges/flatfee" + "github.com/openmeterio/openmeter/openmeter/billing/charges/meta" + entdb "github.com/openmeterio/openmeter/openmeter/ent/db" + "github.com/openmeterio/openmeter/pkg/framework/entutils" + "github.com/openmeterio/openmeter/pkg/framework/transaction" +) + +type Config struct { + MetaAdapter meta.Adapter + Client *entdb.Client + Logger *slog.Logger +} + +func (c Config) Validate() error { + if c.Client == nil { + return errors.New("ent client is required") + } + + if c.Logger == nil { + return errors.New("logger is required") + } + + if c.MetaAdapter == nil { + return errors.New("meta adapter is required") + } + + return nil +} + +func New(config Config) (flatfee.Adapter, error) { + if err := config.Validate(); err != nil { + return nil, err + } + + return &adapter{ + db: config.Client, + logger: config.Logger, + metaAdapter: config.MetaAdapter, + }, nil +} + +var _ flatfee.Adapter = (*adapter)(nil) + +type adapter struct { + db *entdb.Client + logger *slog.Logger + metaAdapter meta.Adapter +} + +func (a *adapter) Tx(ctx context.Context) (context.Context, transaction.Driver, error) { + txCtx, rawConfig, eDriver, err := a.db.HijackTx(ctx, &sql.TxOptions{ + ReadOnly: false, + }) + if err != nil { + return nil, nil, fmt.Errorf("failed to hijack transaction: %w", err) + } + return txCtx, entutils.NewTxDriver(eDriver, rawConfig), nil +} + +func (a *adapter) WithTx(ctx context.Context, tx *entutils.TxDriver) *adapter { + txDb := entdb.NewTxClientFromRawConfig(ctx, *tx.GetConfig()) + + return &adapter{ + db: txDb.Client(), + logger: a.logger, + metaAdapter: a.metaAdapter, + } +} + +func (a *adapter) Self() *adapter { + return a +} diff --git a/billing/charges/flatfee/adapter/charge.go b/billing/charges/flatfee/adapter/charge.go new file mode 100644 index 0000000000000000000000000000000000000000..408ceeff011df28066f95e95890782732c02e90c --- /dev/null +++ b/billing/charges/flatfee/adapter/charge.go @@ -0,0 +1,387 @@ +package adapter + +import ( + "context" + "fmt" + "time" + + "github.com/samber/lo" + + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/billing/charges/flatfee" + "github.com/openmeterio/openmeter/openmeter/billing/charges/meta" + metaadapter "github.com/openmeterio/openmeter/openmeter/billing/charges/meta/adapter" + "github.com/openmeterio/openmeter/openmeter/billing/charges/models/chargemeta" + "github.com/openmeterio/openmeter/openmeter/ent/db" + dbchargeflatfee "github.com/openmeterio/openmeter/openmeter/ent/db/chargeflatfee" + dbchargeflatfeeoverride "github.com/openmeterio/openmeter/openmeter/ent/db/chargeflatfeeoverride" + dbchargeflatfeerun "github.com/openmeterio/openmeter/openmeter/ent/db/chargeflatfeerun" + "github.com/openmeterio/openmeter/pkg/clock" + "github.com/openmeterio/openmeter/pkg/convert" + "github.com/openmeterio/openmeter/pkg/framework/entutils" + "github.com/openmeterio/openmeter/pkg/models" + "github.com/openmeterio/openmeter/pkg/slicesx" +) + +var _ flatfee.ChargeAdapter = (*adapter)(nil) + +func (a *adapter) UpdateCharge(ctx context.Context, charge flatfee.ChargeBase) (flatfee.ChargeBase, error) { + if err := charge.ManagedModel.Validate(); err != nil { + return flatfee.ChargeBase{}, err + } + + if err := charge.Validate(); err != nil { + return flatfee.ChargeBase{}, err + } + + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) (flatfee.ChargeBase, error) { + metaStatus, err := charge.Status.ToMetaChargeStatus() + if err != nil { + return flatfee.ChargeBase{}, err + } + + intent := charge.Intent.GetBaseIntent() + + var discounts *billing.Discounts + if intent.PercentageDiscounts != nil { + discounts = &billing.Discounts{Percentage: intent.PercentageDiscounts} + } + + proRating, err := proRatingConfigToDB(intent.ProRating) + if err != nil { + return flatfee.ChargeBase{}, err + } + + update := tx.db.ChargeFlatFee.UpdateOneID(charge.ID). + Where(dbchargeflatfee.NamespaceEQ(charge.Namespace)). + SetPaymentTerm(intent.PaymentTerm). + SetOrClearIntentDeletedAt(convert.TimePtrIn(intent.IntentDeletedAt, time.UTC)). + SetInvoiceAt(meta.NormalizeTimestamp(intent.InvoiceAt).In(time.UTC)). + SetOrClearFeatureID(charge.State.FeatureID). + SetProRating(proRating). + SetStatusDetailed(charge.Status). + SetAmountBeforeProration(intent.AmountBeforeProration). + SetAmountAfterProration(charge.State.AmountAfterProration) + if discounts != nil { + update = update.SetDiscounts(discounts) + } else { + update = update.ClearDiscounts() + } + + update, err = chargemeta.Update(update, chargemeta.UpdateInput{ + ManagedResource: charge.ManagedResource, + Intent: intent.Intent, + IntentMutableFields: intent.IntentMutableFields.IntentMutableFields, + Status: metaStatus, + AdvanceAfter: meta.NormalizeOptionalTimestamp(charge.State.AdvanceAfter), + }) + if err != nil { + return flatfee.ChargeBase{}, err + } + + update = update.SetOrClearDeletedAt(convert.TimePtrIn(charge.Intent.GetDeletedAt(), time.UTC)) + + dbUpdatedChargeBase, err := update.Save(ctx) + if err != nil { + return flatfee.ChargeBase{}, err + } + + if overrideLayer := charge.Intent.GetOverrideLayerMutableFields(); overrideLayer != nil { + intentOverride, err := tx.updateIntentOverride(ctx, charge.GetChargeID(), overrideLayer) + if err != nil { + return flatfee.ChargeBase{}, fmt.Errorf("updating flat fee charge override: %w", err) + } + + dbUpdatedChargeBase.Edges.IntentOverride = intentOverride + } + + return MapChargeBaseFromDB(dbUpdatedChargeBase), nil + }) +} + +func (a *adapter) UpdateSubscriptionItemID(ctx context.Context, charge flatfee.Charge, newSubscriptionItemID string) (flatfee.Charge, error) { + if err := charge.ManagedModel.Validate(); err != nil { + return flatfee.Charge{}, err + } + + if err := charge.Validate(); err != nil { + return flatfee.Charge{}, err + } + + if newSubscriptionItemID == "" { + return flatfee.Charge{}, fmt.Errorf("subscription item ID is required") + } + + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) (flatfee.Charge, error) { + // TODO: make subscription_item_id immutable again once subscription edits + // no longer recreate the item ID for logical item updates. + updatedChargeBase, err := tx.db.ChargeFlatFee.UpdateOneID(charge.ID). + Where(dbchargeflatfee.NamespaceEQ(charge.Namespace)). + SetSubscriptionItemID(newSubscriptionItemID). + Save(ctx) + if err != nil { + return flatfee.Charge{}, err + } + + override, err := tx.db.ChargeFlatFeeOverride.Query(). + Where(dbchargeflatfeeoverride.NamespaceEQ(charge.Namespace)). + Where(dbchargeflatfeeoverride.ChargeIDEQ(charge.ID)). + Only(ctx) + if err != nil && !db.IsNotFound(err) { + return flatfee.Charge{}, err + } + + updatedChargeBase.Edges.IntentOverride = override + charge.ChargeBase = MapChargeBaseFromDB(updatedChargeBase) + + return charge, nil + }) +} + +func (a *adapter) DeleteCharge(ctx context.Context, charge flatfee.Charge) error { + if err := charge.ManagedModel.Validate(); err != nil { + return err + } + + if err := charge.Validate(); err != nil { + return err + } + + return entutils.TransactingRepoWithNoValue(ctx, a, func(ctx context.Context, tx *adapter) error { + update := tx.db.ChargeFlatFee.UpdateOneID(charge.ID). + Where(dbchargeflatfee.NamespaceEQ(charge.Namespace)) + + err := charge.Intent.MutateEffective(func(intentMutableFields *flatfee.IntentMutableFields) { + intentMutableFields.IntentDeletedAt = lo.ToPtr(clock.Now()) + }) + if err != nil { + return err + } + + charge.DeletedAt = charge.Intent.GetDeletedAt() + charge.Status = flatfee.StatusDeleted + + metaStatus, err := charge.Status.ToMetaChargeStatus() + if err != nil { + return err + } + + update = update.SetStatusDetailed(charge.Status) + + baseIntent := charge.Intent.GetBaseIntent() + + update, err = chargemeta.Update(update, chargemeta.UpdateInput{ + ManagedResource: charge.ManagedResource, + Intent: baseIntent.Intent, + IntentMutableFields: baseIntent.IntentMutableFields.IntentMutableFields, + Status: metaStatus, + }) + if err != nil { + return err + } + + update = update. + SetOrClearIntentDeletedAt(convert.TimePtrIn(baseIntent.IntentDeletedAt, time.UTC)). + SetOrClearDeletedAt(convert.TimePtrIn(charge.Intent.GetDeletedAt(), time.UTC)) + + if _, err := update.Save(ctx); err != nil { + return err + } + + if overrideLayer := charge.Intent.GetOverrideLayerMutableFields(); overrideLayer != nil { + if _, err := tx.updateIntentOverride(ctx, charge.GetChargeID(), overrideLayer); err != nil { + return fmt.Errorf("updating flat fee intent override: %w", err) + } + } + + return tx.metaAdapter.DeleteRegisteredCharge(ctx, charge.GetChargeID()) + }) +} + +func (a *adapter) CreateCharges(ctx context.Context, in flatfee.CreateChargesInput) ([]flatfee.Charge, error) { + if err := in.Validate(); err != nil { + return nil, err + } + + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) ([]flatfee.Charge, error) { + creates, err := slicesx.MapWithErr(in.Intents, func(intent flatfee.IntentWithInitialStatus) (*db.ChargeFlatFeeCreate, error) { + return tx.buildCreateFlatFeeCharge(in.Namespace, intent) + }) + if err != nil { + return nil, err + } + + entities, err := tx.db.ChargeFlatFee.CreateBulk(creates...).Save(ctx) + if err != nil { + return nil, metaadapter.MapChargeConstraintError(err) + } + + // Let's reserve the charge IDs + err = tx.metaAdapter.RegisterCharges(ctx, meta.RegisterChargesInput{ + Namespace: in.Namespace, + Type: meta.ChargeTypeFlatFee, + Charges: lo.Map(entities, func(entity *db.ChargeFlatFee, idx int) meta.IDWithUniqueReferenceID { + return meta.IDWithUniqueReferenceID{ + ID: entity.ID, + UniqueReferenceID: entity.UniqueReferenceID, + } + }), + }) + if err != nil { + return nil, err + } + + out := make([]flatfee.Charge, 0, len(entities)) + for _, entity := range entities { + charge, err := MapChargeFlatFeeFromDB(entity, meta.ExpandNone) + if err != nil { + return nil, err + } + out = append(out, charge) + } + + return out, nil + }) +} + +func (a *adapter) GetByIDs(ctx context.Context, input flatfee.GetByIDsInput) ([]flatfee.Charge, error) { + if err := input.Validate(); err != nil { + return nil, err + } + + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) ([]flatfee.Charge, error) { + query := tx.db.ChargeFlatFee.Query(). + Where(dbchargeflatfee.Namespace(input.Namespace)). + Where(dbchargeflatfee.IDIn(input.IDs...)). + WithIntentOverride() + + if input.Expands.Has(meta.ExpandRealizations) { + query = expandRealizations(query) + } + + entities, err := query.All(ctx) + if err != nil { + return nil, err + } + + entitiesInOrder, err := entutils.InIDOrder(input.Namespace, input.IDs, entities) + if err != nil { + return nil, err + } + + out, err := slicesx.MapWithErr(entitiesInOrder, func(entity *db.ChargeFlatFee) (flatfee.Charge, error) { + return MapChargeFlatFeeFromDB(entity, input.Expands) + }) + if err != nil { + return nil, err + } + + if input.Expands.Has(meta.ExpandDetailedLines) { + return slicesx.MapWithErr(out, func(charge flatfee.Charge) (flatfee.Charge, error) { + return tx.FetchCurrentRunDetailedLines(ctx, charge) + }) + } + + return out, nil + }) +} + +func (a *adapter) GetByID(ctx context.Context, input flatfee.GetByIDInput) (flatfee.Charge, error) { + if err := input.Validate(); err != nil { + return flatfee.Charge{}, err + } + + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) (flatfee.Charge, error) { + query := tx.db.ChargeFlatFee.Query(). + Where(dbchargeflatfee.Namespace(input.ChargeID.Namespace)). + Where(dbchargeflatfee.ID(input.ChargeID.ID)). + WithIntentOverride() + + if input.Expands.Has(meta.ExpandRealizations) { + query = expandRealizations(query) + } + + entity, err := query.First(ctx) + if err != nil { + if db.IsNotFound(err) { + return flatfee.Charge{}, models.NewGenericNotFoundError(fmt.Errorf("flat fee charge [id=%s] not found", input.ChargeID)) + } + + return flatfee.Charge{}, fmt.Errorf("querying flat fee charge [id=%s]: %w", input.ChargeID, err) + } + + charge, err := MapChargeFlatFeeFromDB(entity, input.Expands) + if err != nil { + return flatfee.Charge{}, err + } + + if input.Expands.Has(meta.ExpandDetailedLines) { + return tx.FetchCurrentRunDetailedLines(ctx, charge) + } + + return charge, nil + }) +} + +func expandRealizations(query *db.ChargeFlatFeeQuery) *db.ChargeFlatFeeQuery { + return query.WithRuns(func(query *db.ChargeFlatFeeRunQuery) { + query. + Order( + dbchargeflatfeerun.ByServicePeriodTo(), + dbchargeflatfeerun.ByCreatedAt(), + ). + WithCreditAllocations(). + WithInvoicedUsage(). + WithPayment() + }) +} + +func (a *adapter) buildCreateFlatFeeCharge(ns string, intentWithStatus flatfee.IntentWithInitialStatus) (*db.ChargeFlatFeeCreate, error) { + metaStatus, err := intentWithStatus.InitialStatus.ToMetaChargeStatus() + if err != nil { + return nil, err + } + + intent := intentWithStatus.Intent + + var discounts *billing.Discounts + if intent.PercentageDiscounts != nil { + discounts = &billing.Discounts{Percentage: intent.PercentageDiscounts} + } + + proRating, err := proRatingConfigToDB(intent.ProRating) + if err != nil { + return nil, err + } + + create := a.db.ChargeFlatFee.Create(). + SetNamespace(ns). + SetNillableDeletedAt(convert.TimePtrIn(intent.IntentDeletedAt, time.UTC)). + SetNillableIntentDeletedAt(convert.TimePtrIn(intent.IntentDeletedAt, time.UTC)). + SetPaymentTerm(intent.PaymentTerm). + SetInvoiceAt(meta.NormalizeTimestamp(intent.InvoiceAt).In(time.UTC)). + SetSettlementMode(intent.SettlementMode). + SetNillableFeatureID(intentWithStatus.FeatureID). + SetNillableFeatureKey(intent.FeatureKey). + SetStatusDetailed(intentWithStatus.InitialStatus). + SetProRating(proRating). + SetAmountBeforeProration(intent.AmountBeforeProration). + SetAmountAfterProration(intentWithStatus.AmountAfterProration) + + if discounts != nil { + create = create.SetDiscounts(discounts) + } + + create, err = chargemeta.Create[*db.ChargeFlatFeeCreate](create, chargemeta.CreateInput{ + Namespace: ns, + Intent: intent.Intent, + IntentMutableFields: intent.IntentMutableFields.IntentMutableFields, + Status: metaStatus, + AdvanceAfter: meta.NormalizeOptionalTimestamp(intentWithStatus.InitialAdvanceAfter), + }) + if err != nil { + return nil, err + } + + return create, nil +} diff --git a/billing/charges/flatfee/adapter/credits.go b/billing/charges/flatfee/adapter/credits.go new file mode 100644 index 0000000000000000000000000000000000000000..b0ec362a6877b13d6e23ae729b8efea5a9fbf82e --- /dev/null +++ b/billing/charges/flatfee/adapter/credits.go @@ -0,0 +1,61 @@ +package adapter + +import ( + "context" + "fmt" + + "github.com/samber/lo" + + "github.com/openmeterio/openmeter/openmeter/billing/charges/flatfee" + "github.com/openmeterio/openmeter/openmeter/billing/charges/models/creditrealization" + "github.com/openmeterio/openmeter/openmeter/ent/db" + dbchargeflatfeerun "github.com/openmeterio/openmeter/openmeter/ent/db/chargeflatfeerun" + "github.com/openmeterio/openmeter/pkg/framework/entutils" + "github.com/openmeterio/openmeter/pkg/slicesx" +) + +var _ flatfee.ChargeCreditAllocationAdapter = (*adapter)(nil) + +func (a *adapter) CreateCreditAllocations(ctx context.Context, runID flatfee.RealizationRunID, creditAllocations creditrealization.CreateInputs) (creditrealization.Realizations, error) { + if err := runID.Validate(); err != nil { + return creditrealization.Realizations{}, err + } + + if err := creditAllocations.Validate(); err != nil { + return creditrealization.Realizations{}, err + } + + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) (creditrealization.Realizations, error) { + if _, err := tx.db.ChargeFlatFeeRun.Query(). + Where( + dbchargeflatfeerun.NamespaceEQ(runID.Namespace), + dbchargeflatfeerun.IDEQ(runID.ID), + ). + Only(ctx); err != nil { + return creditrealization.Realizations{}, fmt.Errorf("querying flat fee run [run_id=%s]: %w", runID.ID, err) + } + + dbEntities, err := tx.db.ChargeFlatFeeRunCreditAllocations.CreateBulk( + lo.Map(creditAllocations, func(creditAllocation creditrealization.CreateInput, idx int) *db.ChargeFlatFeeRunCreditAllocationsCreate { + create := tx.db.ChargeFlatFeeRunCreditAllocations.Create(). + SetRunID(runID.ID) + + create = creditrealization.Create(create, runID.Namespace, idx, creditAllocation) + + return create + })..., + ).Save(ctx) + if err != nil { + return creditrealization.Realizations{}, err + } + + realizations, err := slicesx.MapWithErr(dbEntities, func(entity *db.ChargeFlatFeeRunCreditAllocations) (creditrealization.Realization, error) { + return creditrealization.MapFromDB(entity), nil + }) + if err != nil { + return creditrealization.Realizations{}, err + } + + return realizations, nil + }) +} diff --git a/billing/charges/flatfee/adapter/detailedline.go b/billing/charges/flatfee/adapter/detailedline.go new file mode 100644 index 0000000000000000000000000000000000000000..001e578b80034a533d0703965952bbce4082d9fb --- /dev/null +++ b/billing/charges/flatfee/adapter/detailedline.go @@ -0,0 +1,153 @@ +package adapter + +import ( + "context" + "fmt" + "time" + + "entgo.io/ent/dialect/sql" + "github.com/oklog/ulid/v2" + "github.com/samber/lo" + "github.com/samber/mo" + + "github.com/openmeterio/openmeter/openmeter/billing/charges/flatfee" + "github.com/openmeterio/openmeter/openmeter/billing/models/stddetailedline" + entdb "github.com/openmeterio/openmeter/openmeter/ent/db" + dbchargeflatfeerundetailedline "github.com/openmeterio/openmeter/openmeter/ent/db/chargeflatfeerundetailedline" + "github.com/openmeterio/openmeter/pkg/clock" + "github.com/openmeterio/openmeter/pkg/framework/entutils" +) + +var _ flatfee.ChargeDetailedLineAdapter = (*adapter)(nil) + +func (a *adapter) FetchCurrentRunDetailedLines(ctx context.Context, charge flatfee.Charge) (flatfee.Charge, error) { + if charge.Realizations.CurrentRun == nil { + return flatfee.Charge{}, fmt.Errorf("current run is required to fetch flat fee detailed lines for charge %s", charge.GetChargeID()) + } + + currentRunID := charge.Realizations.CurrentRun.ID + if err := currentRunID.Validate(); err != nil { + return flatfee.Charge{}, fmt.Errorf("current run ID: %w", err) + } + + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) (flatfee.Charge, error) { + dbLines, err := tx.db.ChargeFlatFeeRunDetailedLine.Query(). + Where( + dbchargeflatfeerundetailedline.NamespaceEQ(charge.Namespace), + dbchargeflatfeerundetailedline.RunIDEQ(currentRunID.ID), + dbchargeflatfeerundetailedline.DeletedAtIsNil(), + ). + All(ctx) + if err != nil { + return flatfee.Charge{}, err + } + + lines := make(flatfee.DetailedLines, 0, len(dbLines)) + for _, dbLine := range dbLines { + lines = append(lines, stddetailedline.FromDB(dbLine)) + } + + sortDetailedLines(lines) + charge.Realizations.CurrentRun.DetailedLines = mo.Some(lines) + + return charge, nil + }) +} + +func (a *adapter) UpsertDetailedLines(ctx context.Context, runID flatfee.RealizationRunID, lines flatfee.DetailedLines) error { + if err := runID.Validate(); err != nil { + return err + } + + if err := lines.Validate(); err != nil { + return err + } + + return entutils.TransactingRepoWithNoValue(ctx, a, func(ctx context.Context, tx *adapter) error { + createBuilders := make([]*entdb.ChargeFlatFeeRunDetailedLineCreate, 0, len(lines)) + + for _, line := range lines { + lineToPersist := line.Clone() + lineToPersist.Namespace = runID.Namespace + lineToPersist.DeletedAt = nil + + create, err := buildDetailedLineCreate(tx.db, runID, lineToPersist) + if err != nil { + return err + } + + createBuilders = append(createBuilders, create) + } + + now := clock.Now().In(time.UTC) + deleteQuery := tx.db.ChargeFlatFeeRunDetailedLine.Update(). + Where( + dbchargeflatfeerundetailedline.NamespaceEQ(runID.Namespace), + dbchargeflatfeerundetailedline.RunIDEQ(runID.ID), + dbchargeflatfeerundetailedline.DeletedAtIsNil(), + ). + SetDeletedAt(now) + + childRefsToKeep := lo.Map(lines, func(line flatfee.DetailedLine, _ int) string { + return line.ChildUniqueReferenceID + }) + if len(childRefsToKeep) > 0 { + deleteQuery = deleteQuery.Where( + dbchargeflatfeerundetailedline.ChildUniqueReferenceIDNotIn(childRefsToKeep...), + ) + } + + if _, err := deleteQuery.Save(ctx); err != nil { + return err + } + + if len(createBuilders) == 0 { + return nil + } + + return tx.db.ChargeFlatFeeRunDetailedLine.CreateBulk(createBuilders...). + OnConflict( + sql.ConflictColumns( + dbchargeflatfeerundetailedline.FieldNamespace, + dbchargeflatfeerundetailedline.FieldRunID, + dbchargeflatfeerundetailedline.FieldChildUniqueReferenceID, + ), + sql.ConflictWhere(sql.IsNull(dbchargeflatfeerundetailedline.FieldDeletedAt)), + sql.ResolveWithNewValues(), + sql.ResolveWith(func(u *sql.UpdateSet) { + u.SetIgnore(dbchargeflatfeerundetailedline.FieldCreatedAt) + u.SetIgnore(dbchargeflatfeerundetailedline.FieldID) + }), + ). + UpdateDescription(). + UpdateIndex(). + UpdatePricerReferenceID(). + UpdateDeletedAt(). + UpdateInvoicingAppExternalID(). + UpdateChildUniqueReferenceID(). + UpdateCreditsApplied(). + UpdateAnnotations(). + UpdateMetadata(). + Exec(ctx) + }) +} + +func buildDetailedLineCreate(db *entdb.Client, runID flatfee.RealizationRunID, line flatfee.DetailedLine) (*entdb.ChargeFlatFeeRunDetailedLineCreate, error) { + if line.ID == "" { + line.ID = ulid.Make().String() + } + + create := db.ChargeFlatFeeRunDetailedLine.Create(). + SetID(line.ID). + SetNamespace(runID.Namespace). + SetRunID(runID.ID). + SetPricerReferenceID(line.ChildUniqueReferenceID) + + create = stddetailedline.Create(create, line) + + if len(line.CreditsApplied) > 0 { + create = create.SetCreditsApplied(&line.CreditsApplied) + } + + return create, nil +} diff --git a/billing/charges/flatfee/adapter/detailedline_test.go b/billing/charges/flatfee/adapter/detailedline_test.go new file mode 100644 index 0000000000000000000000000000000000000000..e8a75b96e3ad32217e3a2695cb741e8fb98aeb08 --- /dev/null +++ b/billing/charges/flatfee/adapter/detailedline_test.go @@ -0,0 +1,286 @@ +package adapter + +import ( + "log/slog" + "testing" + "time" + + "github.com/alpacahq/alpacadecimal" + "github.com/samber/lo" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/billing/charges/flatfee" + chargesmeta "github.com/openmeterio/openmeter/openmeter/billing/charges/meta" + metaadapter "github.com/openmeterio/openmeter/openmeter/billing/charges/meta/adapter" + "github.com/openmeterio/openmeter/openmeter/billing/models/stddetailedline" + "github.com/openmeterio/openmeter/openmeter/billing/models/totals" + entdb "github.com/openmeterio/openmeter/openmeter/ent/db" + dbchargeflatfee "github.com/openmeterio/openmeter/openmeter/ent/db/chargeflatfee" + dbchargeflatfeerundetailedline "github.com/openmeterio/openmeter/openmeter/ent/db/chargeflatfeerundetailedline" + "github.com/openmeterio/openmeter/openmeter/productcatalog" + taxcodetestutils "github.com/openmeterio/openmeter/openmeter/taxcode/testutils" + "github.com/openmeterio/openmeter/openmeter/testutils" + "github.com/openmeterio/openmeter/pkg/currencyx" + "github.com/openmeterio/openmeter/pkg/models" + "github.com/openmeterio/openmeter/pkg/timeutil" +) + +func TestFlatFeeDetailedLineAdapter(t *testing.T) { + suite.Run(t, new(FlatFeeDetailedLineAdapterSuite)) +} + +type FlatFeeDetailedLineAdapterSuite struct { + suite.Suite + + testDB *testutils.TestDB + dbClient *entdb.Client + adapter flatfee.Adapter + + taxCodeEnv *taxcodetestutils.TestEnv +} + +type newDetailedLineInput struct { + Charge flatfee.Charge + ServicePeriod timeutil.ClosedPeriod + ChildUniqueReferenceID string + Quantity int64 + Description *string +} + +func (s *FlatFeeDetailedLineAdapterSuite) SetupSuite() { + t := s.T() + + s.testDB = testutils.InitPostgresDB(t, testutils.PostgresDBStateAtlasMigrated) + s.dbClient = entdb.NewClient(entdb.Driver(s.testDB.EntDriver.Driver())) + + metaAdapter, err := metaadapter.New(metaadapter.Config{ + Client: s.dbClient, + Logger: slog.Default(), + }) + require.NoError(t, err) + + a, err := New(Config{ + Client: s.dbClient, + Logger: slog.Default(), + MetaAdapter: metaAdapter, + }) + require.NoError(t, err) + + s.adapter = a + s.taxCodeEnv = taxcodetestutils.NewTestEnvFromClient(t, s.dbClient, slog.Default()) +} + +func (s *FlatFeeDetailedLineAdapterSuite) TearDownSuite() { + s.dbClient.Close() + s.testDB.EntDriver.Close() + s.testDB.PGDriver.Close() +} + +func (s *FlatFeeDetailedLineAdapterSuite) TestUpsertDetailedLinesReplacesAndSoftDeletesByChildUniqueReferenceID() { + ctx := s.T().Context() + namespace := "flatfee-detailedline-adapter" + customerID := s.createCustomer(namespace) + taxCodeID := s.taxCodeEnv.CreateTaxCode(s.T(), namespace).ID + + servicePeriod := timeutil.ClosedPeriod{ + From: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + To: time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC), + } + + createdCharges, err := s.adapter.CreateCharges(ctx, flatfee.CreateChargesInput{ + Namespace: namespace, + Intents: []flatfee.IntentWithInitialStatus{ + { + Intent: flatfee.Intent{ + Intent: chargesmeta.Intent{ + ManagedBy: billing.SubscriptionManagedLine, + CustomerID: customerID, + Currency: currencyx.Code("USD"), + TaxConfig: productcatalog.TaxCodeConfig{ + TaxCodeID: taxCodeID, + }, + }, + IntentMutableFields: flatfee.IntentMutableFields{ + IntentMutableFields: chargesmeta.IntentMutableFields{ + Name: "flat-fee-charge", + ServicePeriod: servicePeriod, + FullServicePeriod: servicePeriod, + BillingPeriod: servicePeriod, + }, + InvoiceAt: servicePeriod.To, + PaymentTerm: productcatalog.InAdvancePaymentTerm, + AmountBeforeProration: alpacadecimal.NewFromInt(10), + ProRating: productcatalog.ProRatingConfig{ + Enabled: false, + Mode: productcatalog.ProRatingModeProratePrices, + }, + }, + SettlementMode: productcatalog.CreditThenInvoiceSettlementMode, + }, + InitialStatus: flatfee.StatusCreated, + AmountAfterProration: alpacadecimal.NewFromInt(10), + }, + }, + }) + s.Require().NoError(err) + s.Require().Len(createdCharges, 1) + + charge := createdCharges[0] + run, err := s.adapter.CreateCurrentRun(ctx, flatfee.CreateCurrentRunInput{ + Charge: charge.ChargeBase, + ServicePeriod: servicePeriod, + AmountAfterProration: alpacadecimal.NewFromInt(10), + }) + s.Require().NoError(err) + runID := run.ID + + initialLines := flatfee.DetailedLines{ + s.newDetailedLine(newDetailedLineInput{ + Charge: charge, + ServicePeriod: servicePeriod, + ChildUniqueReferenceID: "keep", + Quantity: 1, + Description: lo.ToPtr("old description"), + }), + s.newDetailedLine(newDetailedLineInput{ + Charge: charge, + ServicePeriod: servicePeriod, + ChildUniqueReferenceID: "delete", + Quantity: 2, + Description: lo.ToPtr("delete me"), + }), + } + s.Require().NoError(s.adapter.UpsertDetailedLines(ctx, runID, initialLines)) + + replacementLines := flatfee.DetailedLines{ + s.newDetailedLine(newDetailedLineInput{ + Charge: charge, + ServicePeriod: servicePeriod, + ChildUniqueReferenceID: "keep", + Quantity: 3, + }), + s.newDetailedLine(newDetailedLineInput{ + Charge: charge, + ServicePeriod: servicePeriod, + ChildUniqueReferenceID: "new", + Quantity: 4, + Description: lo.ToPtr("new description"), + }), + } + s.Require().NoError(s.adapter.UpsertDetailedLines(ctx, runID, replacementLines)) + + fetchedCharge, err := s.adapter.GetByID(ctx, flatfee.GetByIDInput{ + ChargeID: charge.GetChargeID(), + Expands: chargesmeta.Expands{ + chargesmeta.ExpandRealizations, + chargesmeta.ExpandDetailedLines, + }, + }) + s.Require().NoError(err) + s.Require().NotNil(fetchedCharge.Realizations.CurrentRun) + s.True(fetchedCharge.Realizations.CurrentRun.DetailedLines.IsPresent()) + s.Len(fetchedCharge.Realizations.CurrentRun.DetailedLines.OrEmpty(), 2) + s.Equal("keep", fetchedCharge.Realizations.CurrentRun.DetailedLines.OrEmpty()[0].ChildUniqueReferenceID) + s.Equal("new", fetchedCharge.Realizations.CurrentRun.DetailedLines.OrEmpty()[1].ChildUniqueReferenceID) + s.Equal(float64(3), fetchedCharge.Realizations.CurrentRun.DetailedLines.OrEmpty()[0].Quantity.InexactFloat64()) + s.Nil(fetchedCharge.Realizations.CurrentRun.DetailedLines.OrEmpty()[0].Description) + + dbCharge, err := s.dbClient.ChargeFlatFee.Query(). + Where( + dbchargeflatfee.NamespaceEQ(namespace), + dbchargeflatfee.IDEQ(charge.ID), + ). + Only(ctx) + s.Require().NoError(err) + s.Require().NotNil(dbCharge.CurrentRealizationRunID) + s.Equal(runID.ID, *dbCharge.CurrentRealizationRunID) + + keptRow, err := s.dbClient.ChargeFlatFeeRunDetailedLine.Query(). + Where( + dbchargeflatfeerundetailedline.NamespaceEQ(namespace), + dbchargeflatfeerundetailedline.RunIDEQ(runID.ID), + dbchargeflatfeerundetailedline.ChildUniqueReferenceIDEQ("keep"), + dbchargeflatfeerundetailedline.DeletedAtIsNil(), + ). + Only(ctx) + s.Require().NoError(err) + s.Equal("keep", keptRow.PricerReferenceID) + + newRow, err := s.dbClient.ChargeFlatFeeRunDetailedLine.Query(). + Where( + dbchargeflatfeerundetailedline.NamespaceEQ(namespace), + dbchargeflatfeerundetailedline.RunIDEQ(runID.ID), + dbchargeflatfeerundetailedline.ChildUniqueReferenceIDEQ("new"), + dbchargeflatfeerundetailedline.DeletedAtIsNil(), + ). + Only(ctx) + s.Require().NoError(err) + s.Equal("new", newRow.PricerReferenceID) + + deletedRow, err := s.dbClient.ChargeFlatFeeRunDetailedLine.Query(). + Where( + dbchargeflatfeerundetailedline.NamespaceEQ(namespace), + dbchargeflatfeerundetailedline.RunIDEQ(runID.ID), + dbchargeflatfeerundetailedline.ChildUniqueReferenceIDEQ("delete"), + ). + Only(ctx) + s.Require().NoError(err) + s.NotNil(deletedRow.DeletedAt) +} + +func (s *FlatFeeDetailedLineAdapterSuite) TestFetchCurrentRunDetailedLinesRequiresCurrentRun() { + ctx := s.T().Context() + + _, err := s.adapter.FetchCurrentRunDetailedLines(ctx, flatfee.Charge{ + ChargeBase: flatfee.ChargeBase{ + ManagedResource: chargesmeta.ManagedResource{ + NamespacedModel: models.NamespacedModel{ + Namespace: "flatfee-detailedline-adapter", + }, + ID: "charge-id", + }, + }, + }) + s.Require().ErrorContains(err, "current run is required") +} + +func (s *FlatFeeDetailedLineAdapterSuite) createCustomer(namespace string) string { + s.T().Helper() + + customer, err := s.dbClient.Customer.Create(). + SetNamespace(namespace). + SetName("test-customer"). + Save(s.T().Context()) + s.Require().NoError(err) + + return customer.ID +} + +func (s *FlatFeeDetailedLineAdapterSuite) newDetailedLine(input newDetailedLineInput) flatfee.DetailedLine { + s.T().Helper() + + totalAmount := alpacadecimal.NewFromFloat(0.1).Mul(alpacadecimal.NewFromInt(input.Quantity)) + baseIntent := input.Charge.Intent.GetBaseIntent() + + return flatfee.DetailedLine{ + ManagedResource: models.NewManagedResource(models.ManagedResourceInput{ + Namespace: input.Charge.Namespace, + Name: "Detailed line", + Description: input.Description, + }), + ServicePeriod: input.ServicePeriod, + Currency: input.Charge.Intent.GetCurrency(), + ChildUniqueReferenceID: input.ChildUniqueReferenceID, + PaymentTerm: baseIntent.PaymentTerm, + PerUnitAmount: alpacadecimal.NewFromFloat(0.1), + Quantity: alpacadecimal.NewFromInt(input.Quantity), + Category: stddetailedline.CategoryRegular, + Totals: totals.Totals{ + Amount: totalAmount, + ChargesTotal: totalAmount, + Total: totalAmount, + }, + } +} diff --git a/billing/charges/flatfee/adapter/intentoverride.go b/billing/charges/flatfee/adapter/intentoverride.go new file mode 100644 index 0000000000000000000000000000000000000000..e732d29be22ee8f1a7d84a79c36b7cdec26adc23 --- /dev/null +++ b/billing/charges/flatfee/adapter/intentoverride.go @@ -0,0 +1,230 @@ +package adapter + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/samber/lo" + + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/billing/charges/flatfee" + "github.com/openmeterio/openmeter/openmeter/billing/charges/meta" + entdb "github.com/openmeterio/openmeter/openmeter/ent/db" + dbchargeflatfee "github.com/openmeterio/openmeter/openmeter/ent/db/chargeflatfee" + dbchargeflatfeeoverride "github.com/openmeterio/openmeter/openmeter/ent/db/chargeflatfeeoverride" + "github.com/openmeterio/openmeter/pkg/convert" + "github.com/openmeterio/openmeter/pkg/framework/entutils" + "github.com/openmeterio/openmeter/pkg/timeutil" +) + +func mapIntentOverrideFromDB(dbOverride *entdb.ChargeFlatFeeOverride) *flatfee.IntentMutableFields { + if dbOverride == nil { + return nil + } + + var percentageDiscounts *billing.PercentageDiscount + if dbOverride.Discounts != nil { + percentageDiscounts = dbOverride.Discounts.Percentage + } + + return &flatfee.IntentMutableFields{ + IntentMutableFields: meta.IntentMutableFields{ + Name: dbOverride.Name, + Description: dbOverride.Description, + Metadata: lo.FromPtr(dbOverride.Metadata), + ServicePeriod: closedPeriodFromDB(dbOverride.ServicePeriodFrom, dbOverride.ServicePeriodTo), + FullServicePeriod: closedPeriodFromDB(dbOverride.FullServicePeriodFrom, dbOverride.FullServicePeriodTo), + BillingPeriod: closedPeriodFromDB(dbOverride.BillingPeriodFrom, dbOverride.BillingPeriodTo), + }, + IntentDeletedAt: convert.TimePtrIn(dbOverride.IntentDeletedAt, time.UTC), + InvoiceAt: dbOverride.InvoiceAt.UTC(), + PaymentTerm: dbOverride.PaymentTerm, + ProRating: lo.FromPtr(dbOverride.ProRating), + AmountBeforeProration: dbOverride.AmountBeforeProration, + PercentageDiscounts: percentageDiscounts, + } +} + +func (a *adapter) CreateChargeOverride(ctx context.Context, charge flatfee.ChargeBase, override flatfee.IntentMutableFields) (flatfee.ChargeBase, error) { + if err := charge.ManagedModel.Validate(); err != nil { + return flatfee.ChargeBase{}, err + } + + if err := charge.Validate(); err != nil { + return flatfee.ChargeBase{}, err + } + + if err := override.Validate(); err != nil { + return flatfee.ChargeBase{}, fmt.Errorf("validating intent override: %w", err) + } + + if charge.Intent.HasOverrideLayer() { + return flatfee.ChargeBase{}, errors.New("intent override already exists") + } + + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) (flatfee.ChargeBase, error) { + dbIntentOverride, err := tx.createIntentOverride(ctx, charge.GetChargeID(), override) + if err != nil { + return flatfee.ChargeBase{}, err + } + + deletedAt := convert.TimePtrIn(dbIntentOverride.IntentDeletedAt, time.UTC) + dbCharge, err := tx.db.ChargeFlatFee.UpdateOneID(charge.ID). + Where(dbchargeflatfee.NamespaceEQ(charge.Namespace)). + SetOrClearDeletedAt(deletedAt). + Save(ctx) + if err != nil { + return flatfee.ChargeBase{}, fmt.Errorf("updating flat fee effective deleted at: %w", err) + } + + dbCharge.Edges.IntentOverride = dbIntentOverride + + return MapChargeBaseFromDB(dbCharge), nil + }) +} + +func (a *adapter) DeleteChargeOverride(ctx context.Context, charge flatfee.ChargeBase) (flatfee.ChargeBase, error) { + if err := charge.ManagedModel.Validate(); err != nil { + return flatfee.ChargeBase{}, err + } + + if err := charge.Validate(); err != nil { + return flatfee.ChargeBase{}, err + } + + if !charge.Intent.HasOverrideLayer() { + return flatfee.ChargeBase{}, errors.New("intent override is required") + } + + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) (flatfee.ChargeBase, error) { + affectedRows, err := tx.db.ChargeFlatFeeOverride.Delete(). + Where(dbchargeflatfeeoverride.NamespaceEQ(charge.Namespace)). + Where(dbchargeflatfeeoverride.ChargeIDEQ(charge.ID)). + Exec(ctx) + if err != nil { + return flatfee.ChargeBase{}, fmt.Errorf("deleting flat fee intent override: %w", err) + } + + if affectedRows == 0 { + return flatfee.ChargeBase{}, fmt.Errorf("intent override does not exist") + } + + baseIntent := charge.Intent.GetBaseIntent() + deletedAt := convert.TimePtrIn(baseIntent.IntentDeletedAt, time.UTC) + _, err = tx.db.ChargeFlatFee.UpdateOneID(charge.ID). + Where(dbchargeflatfee.NamespaceEQ(charge.Namespace)). + SetOrClearDeletedAt(deletedAt). + Save(ctx) + if err != nil { + return flatfee.ChargeBase{}, fmt.Errorf("updating flat fee effective deleted at: %w", err) + } + + charge.Intent = baseIntent.AsOverridableIntent() + charge.DeletedAt = deletedAt + + return charge, nil + }) +} + +func (a *adapter) createIntentOverride(ctx context.Context, chargeID meta.ChargeID, override flatfee.IntentMutableFields) (*entdb.ChargeFlatFeeOverride, error) { + if err := chargeID.Validate(); err != nil { + return nil, fmt.Errorf("charge id: %w", err) + } + + normalized := override.Normalized("") + if err := normalized.Validate(); err != nil { + return nil, fmt.Errorf("validating intent override: %w", err) + } + + create := a.db.ChargeFlatFeeOverride.Create(). + SetNamespace(chargeID.Namespace). + SetChargeID(chargeID.ID). + SetFlatFeeID(chargeID.ID). + SetName(normalized.Name). + SetNillableDescription(normalized.Description). + SetNillableIntentDeletedAt(convert.TimePtrIn(normalized.IntentDeletedAt, time.UTC)). + SetServicePeriodFrom(normalized.ServicePeriod.From.UTC()). + SetServicePeriodTo(normalized.ServicePeriod.To.UTC()). + SetFullServicePeriodFrom(normalized.FullServicePeriod.From.UTC()). + SetFullServicePeriodTo(normalized.FullServicePeriod.To.UTC()). + SetBillingPeriodFrom(normalized.BillingPeriod.From.UTC()). + SetBillingPeriodTo(normalized.BillingPeriod.To.UTC()). + SetInvoiceAt(normalized.InvoiceAt.UTC()). + SetPaymentTerm(normalized.PaymentTerm). + SetProRating(&normalized.ProRating). + SetAmountBeforeProration(normalized.AmountBeforeProration) + if normalized.Metadata != nil { + create = create.SetMetadata(&normalized.Metadata) + } + if normalized.PercentageDiscounts != nil { + create = create.SetDiscounts(&billing.Discounts{Percentage: normalized.PercentageDiscounts}) + } + + return create.Save(ctx) +} + +func (a *adapter) updateIntentOverride(ctx context.Context, chargeID meta.ChargeID, override *flatfee.IntentMutableFields) (*entdb.ChargeFlatFeeOverride, error) { + if err := chargeID.Validate(); err != nil { + return nil, fmt.Errorf("charge id: %w", err) + } + + normalized := override.Normalized("") + if err := normalized.Validate(); err != nil { + return nil, fmt.Errorf("validating intent override: %w", err) + } + + update := a.db.ChargeFlatFeeOverride.Update(). + Where(dbchargeflatfeeoverride.NamespaceEQ(chargeID.Namespace)). + Where(dbchargeflatfeeoverride.ChargeIDEQ(chargeID.ID)). + SetName(normalized.Name). + SetOrClearDescription(normalized.Description). + SetOrClearIntentDeletedAt(convert.TimePtrIn(normalized.IntentDeletedAt, time.UTC)). + SetServicePeriodFrom(normalized.ServicePeriod.From.UTC()). + SetServicePeriodTo(normalized.ServicePeriod.To.UTC()). + SetFullServicePeriodFrom(normalized.FullServicePeriod.From.UTC()). + SetFullServicePeriodTo(normalized.FullServicePeriod.To.UTC()). + SetBillingPeriodFrom(normalized.BillingPeriod.From.UTC()). + SetBillingPeriodTo(normalized.BillingPeriod.To.UTC()). + SetInvoiceAt(normalized.InvoiceAt.UTC()). + SetPaymentTerm(normalized.PaymentTerm). + SetProRating(&normalized.ProRating). + SetAmountBeforeProration(normalized.AmountBeforeProration) + if normalized.Metadata == nil { + update = update.ClearMetadata() + } else { + update = update.SetMetadata(&normalized.Metadata) + } + if normalized.PercentageDiscounts == nil { + update = update.ClearDiscounts() + } else { + update = update.SetDiscounts(&billing.Discounts{Percentage: normalized.PercentageDiscounts}) + } + + affectedRows, err := update.Save(ctx) + if err != nil { + return nil, fmt.Errorf("updating intent override for charge[%s]: %w", chargeID.ID, err) + } + + if affectedRows == 0 { + return nil, fmt.Errorf("intent override does not exist for charge[%s]", chargeID.ID) + } + + dbOverride, err := a.db.ChargeFlatFeeOverride.Query(). + Where(dbchargeflatfeeoverride.NamespaceEQ(chargeID.Namespace)). + Where(dbchargeflatfeeoverride.ChargeIDEQ(chargeID.ID)). + Only(ctx) + if err != nil { + return nil, fmt.Errorf("querying updated intent override for charge[%s]: %w", chargeID.ID, err) + } + + return dbOverride, nil +} + +func closedPeriodFromDB(from, to time.Time) timeutil.ClosedPeriod { + return timeutil.ClosedPeriod{ + From: from.UTC(), + To: to.UTC(), + } +} diff --git a/billing/charges/flatfee/adapter/intentoverride_test.go b/billing/charges/flatfee/adapter/intentoverride_test.go new file mode 100644 index 0000000000000000000000000000000000000000..4cd94e611cb234d9eebcb9e0215f5c77a03b4075 --- /dev/null +++ b/billing/charges/flatfee/adapter/intentoverride_test.go @@ -0,0 +1,349 @@ +package adapter + +import ( + "log/slog" + "testing" + "time" + + "github.com/alpacahq/alpacadecimal" + "github.com/samber/lo" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/billing/charges/flatfee" + chargesmeta "github.com/openmeterio/openmeter/openmeter/billing/charges/meta" + metaadapter "github.com/openmeterio/openmeter/openmeter/billing/charges/meta/adapter" + entdb "github.com/openmeterio/openmeter/openmeter/ent/db" + "github.com/openmeterio/openmeter/openmeter/productcatalog" + taxcodetestutils "github.com/openmeterio/openmeter/openmeter/taxcode/testutils" + "github.com/openmeterio/openmeter/openmeter/testutils" + "github.com/openmeterio/openmeter/pkg/clock" + "github.com/openmeterio/openmeter/pkg/currencyx" + "github.com/openmeterio/openmeter/pkg/models" + "github.com/openmeterio/openmeter/pkg/timeutil" +) + +func TestFlatFeeIntentOverrideAdapter(t *testing.T) { + suite.Run(t, new(FlatFeeIntentOverrideAdapterSuite)) +} + +type FlatFeeIntentOverrideAdapterSuite struct { + suite.Suite + + testDB *testutils.TestDB + dbClient *entdb.Client + adapter flatfee.Adapter + + taxCodeEnv *taxcodetestutils.TestEnv +} + +func (s *FlatFeeIntentOverrideAdapterSuite) SetupSuite() { + t := s.T() + + s.testDB = testutils.InitPostgresDB(t, testutils.PostgresDBStateAtlasMigrated) + s.dbClient = entdb.NewClient(entdb.Driver(s.testDB.EntDriver.Driver())) + + metaAdapter, err := metaadapter.New(metaadapter.Config{ + Client: s.dbClient, + Logger: slog.Default(), + }) + require.NoError(t, err) + + a, err := New(Config{ + Client: s.dbClient, + Logger: slog.Default(), + MetaAdapter: metaAdapter, + }) + require.NoError(t, err) + + s.taxCodeEnv = taxcodetestutils.NewTestEnvFromClient(t, s.dbClient, slog.Default()) + s.adapter = a +} + +func (s *FlatFeeIntentOverrideAdapterSuite) TearDownSuite() { + s.dbClient.Close() + s.testDB.EntDriver.Close() + s.testDB.PGDriver.Close() +} + +func (s *FlatFeeIntentOverrideAdapterSuite) TestUpdateAndReadIntentOverride() { + ctx := s.T().Context() + namespace := "flatfee-intentoverride-adapter" + charge := s.createCharge(namespace) + + overrideServicePeriod := timeutil.ClosedPeriod{ + From: time.Date(2026, 1, 10, 0, 0, 0, 0, time.UTC), + To: time.Date(2026, 1, 20, 0, 0, 0, 0, time.UTC), + } + overrideFullServicePeriod := timeutil.ClosedPeriod{ + From: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + To: time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC), + } + overrideBillingPeriod := timeutil.ClosedPeriod{ + From: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + To: time.Date(2026, 1, 31, 0, 0, 0, 0, time.UTC), + } + overrideInvoiceAt := time.Date(2026, 1, 21, 0, 0, 0, 0, time.UTC) + amountBeforeProration := alpacadecimal.NewFromInt(42) + paymentTerm := productcatalog.InAdvancePaymentTerm + overrideDiscountCorrelationID := "01J00000000000000000000000" + proRating := productcatalog.ProRatingConfig{ + Enabled: true, + Mode: productcatalog.ProRatingModeProratePrices, + } + + s.Require().NoError(charge.Intent.Mutate(chargesmeta.ChangeTargetBase, func(fields *flatfee.IntentMutableFields) { + fields.IntentDeletedAt = lo.ToPtr(time.Date(2026, 1, 5, 0, 0, 0, 0, time.UTC)) + })) + override := flatfee.IntentMutableFields{ + IntentMutableFields: chargesmeta.IntentMutableFields{ + Name: "manual flat fee", + Description: lo.ToPtr("manual description"), + Metadata: models.Metadata{ + "source": "manual", + }, + ServicePeriod: overrideServicePeriod, + FullServicePeriod: overrideFullServicePeriod, + BillingPeriod: overrideBillingPeriod, + }, + InvoiceAt: overrideInvoiceAt, + PaymentTerm: paymentTerm, + ProRating: proRating, + AmountBeforeProration: amountBeforeProration, + PercentageDiscounts: &billing.PercentageDiscount{ + PercentageDiscount: productcatalog.PercentageDiscount{ + Percentage: models.NewPercentage(10), + }, + CorrelationID: overrideDiscountCorrelationID, + }, + } + + chargeWithMissingOverride := charge.ChargeBase + chargeWithMissingOverride.Intent = flatfee.NewOverridableIntent(charge.Intent.GetBaseIntent(), &override) + _, err := s.adapter.UpdateCharge(ctx, chargeWithMissingOverride) + s.Require().ErrorContains(err, "override does not exist") + + updated, err := s.adapter.UpdateCharge(ctx, charge.ChargeBase) + s.Require().NoError(err) + s.NotNil(updated.Intent.GetBaseIntent().IntentDeletedAt) + fetchedBeforeOverrideCreate, err := s.adapter.GetByID(ctx, flatfee.GetByIDInput{ + ChargeID: charge.GetChargeID(), + }) + s.Require().NoError(err) + s.Nil(fetchedBeforeOverrideCreate.Intent.GetOverrideLayerMutableFields()) + s.NotNil(fetchedBeforeOverrideCreate.DeletedAt) + + updated, err = s.adapter.CreateChargeOverride(ctx, updated, override) + s.Require().NoError(err) + s.Nil(updated.DeletedAt) + s.requireOverrideMatches(updated.Intent.GetOverrideLayerMutableFields(), overrideServicePeriod, overrideFullServicePeriod, overrideBillingPeriod, overrideInvoiceAt, overrideDiscountCorrelationID) + + _, err = s.adapter.CreateChargeOverride(ctx, updated, override) + s.Require().Error(err) + + overrideInvoiceAt = time.Date(2026, 1, 22, 0, 0, 0, 0, time.UTC) + s.Require().NoError(updated.Intent.Mutate(chargesmeta.ChangeTargetOverride, func(fields *flatfee.IntentMutableFields) { + fields.InvoiceAt = overrideInvoiceAt + })) + updated, err = s.adapter.UpdateCharge(ctx, updated) + s.Require().NoError(err) + s.requireOverrideMatches(updated.Intent.GetOverrideLayerMutableFields(), overrideServicePeriod, overrideFullServicePeriod, overrideBillingPeriod, overrideInvoiceAt, overrideDiscountCorrelationID) + + s.Require().NoError(updated.Intent.Mutate(chargesmeta.ChangeTargetOverride, func(fields *flatfee.IntentMutableFields) { + fields.Description = nil + fields.Metadata = nil + fields.PercentageDiscounts = nil + })) + updated, err = s.adapter.UpdateCharge(ctx, updated) + s.Require().NoError(err) + updatedOverride := updated.Intent.GetOverrideLayerMutableFields() + s.Require().NotNil(updatedOverride) + s.Nil(updatedOverride.Description) + s.Nil(updatedOverride.Metadata) + s.Nil(updatedOverride.PercentageDiscounts) + + fetched, err := s.adapter.GetByID(ctx, flatfee.GetByIDInput{ + ChargeID: charge.GetChargeID(), + }) + s.Require().NoError(err) + fetchedOverride := fetched.Intent.GetOverrideLayerMutableFields() + s.Require().NotNil(fetchedOverride) + s.Nil(fetchedOverride.Description) + s.Nil(fetchedOverride.Metadata) + s.Nil(fetchedOverride.PercentageDiscounts) + s.Equal(updated.Intent.GetBaseIntent().TaxConfig, fetched.Intent.GetTaxConfig()) + expectedFeatureKey := "" + if updated.Intent.GetBaseIntent().FeatureKey != nil { + expectedFeatureKey = *updated.Intent.GetBaseIntent().FeatureKey + } + s.Equal(expectedFeatureKey, fetched.Intent.GetFeatureKey()) + + fetchedByIDs, err := s.adapter.GetByIDs(ctx, flatfee.GetByIDsInput{ + Namespace: namespace, + IDs: []string{charge.ID}, + }) + s.Require().NoError(err) + s.Require().Len(fetchedByIDs, 1) + fetchedByIDOverride := fetchedByIDs[0].Intent.GetOverrideLayerMutableFields() + s.Require().NotNil(fetchedByIDOverride) + s.Nil(fetchedByIDOverride.Description) + s.Nil(fetchedByIDOverride.Metadata) + s.Nil(fetchedByIDOverride.PercentageDiscounts) + + cleared, err := s.adapter.DeleteChargeOverride(ctx, fetched.ChargeBase) + s.Require().NoError(err) + s.Nil(cleared.Intent.GetOverrideLayerMutableFields()) + s.NotNil(cleared.DeletedAt) + + fetchedAfterClear, err := s.adapter.GetByID(ctx, flatfee.GetByIDInput{ + ChargeID: charge.GetChargeID(), + }) + s.Require().NoError(err) + s.Nil(fetchedAfterClear.Intent.GetOverrideLayerMutableFields()) + s.NotNil(fetchedAfterClear.DeletedAt) +} + +func (s *FlatFeeIntentOverrideAdapterSuite) TestDeleteChargeWithIntentOverrideDeletesOverrideIntent() { + ctx := s.T().Context() + namespace := "flatfee-intentoverride-delete" + charge := s.createCharge(namespace) + deletedAt := time.Date(2026, 1, 15, 0, 0, 0, 0, time.UTC) + clock.FreezeTime(deletedAt) + defer clock.UnFreeze() + + baseIntent := charge.Intent.GetBaseIntent() + override := flatfee.IntentMutableFields{ + IntentMutableFields: chargesmeta.IntentMutableFields{ + Name: "manual flat fee", + ServicePeriod: baseIntent.ServicePeriod, + FullServicePeriod: baseIntent.FullServicePeriod, + BillingPeriod: baseIntent.BillingPeriod, + }, + InvoiceAt: baseIntent.InvoiceAt, + PaymentTerm: baseIntent.PaymentTerm, + ProRating: baseIntent.ProRating, + AmountBeforeProration: baseIntent.AmountBeforeProration, + } + + chargeWithMissingOverride := charge.ChargeBase + chargeWithMissingOverride.Intent = flatfee.NewOverridableIntent(baseIntent, &override) + _, err := s.adapter.UpdateCharge(ctx, chargeWithMissingOverride) + s.Require().ErrorContains(err, "override does not exist") + + updated, err := s.adapter.UpdateCharge(ctx, charge.ChargeBase) + s.Require().NoError(err) + updated, err = s.adapter.CreateChargeOverride(ctx, updated, override) + s.Require().NoError(err) + updatedOverride := updated.Intent.GetOverrideLayerMutableFields() + s.Require().NotNil(updatedOverride) + s.Nil(updated.Intent.GetBaseIntent().IntentDeletedAt) + s.Nil(updatedOverride.IntentDeletedAt) + s.Nil(updated.DeletedAt) + + s.Require().NoError(s.adapter.DeleteCharge(ctx, flatfee.Charge{ChargeBase: updated})) + + fetched, err := s.adapter.GetByID(ctx, flatfee.GetByIDInput{ + ChargeID: charge.GetChargeID(), + }) + s.Require().NoError(err) + s.Equal(flatfee.StatusDeleted, fetched.Status) + fetchedOverride := fetched.Intent.GetOverrideLayerMutableFields() + s.Nil(fetched.Intent.GetBaseIntent().IntentDeletedAt) + s.Require().NotNil(fetchedOverride) + s.Require().NotNil(fetchedOverride.IntentDeletedAt) + s.Require().NotNil(fetched.DeletedAt) + s.Equal(deletedAt, *fetchedOverride.IntentDeletedAt) + s.Equal(deletedAt, *fetched.DeletedAt) +} + +func (s *FlatFeeIntentOverrideAdapterSuite) requireOverrideMatches( + override *flatfee.IntentMutableFields, + servicePeriod timeutil.ClosedPeriod, + fullServicePeriod timeutil.ClosedPeriod, + billingPeriod timeutil.ClosedPeriod, + invoiceAt time.Time, + discountCorrelationID string, +) { + s.T().Helper() + + s.Require().NotNil(override) + s.Equal("manual flat fee", override.Name) + s.Equal("manual description", lo.FromPtr(override.Description)) + s.Equal(models.Metadata{"source": "manual"}, override.Metadata) + s.Equal(servicePeriod, override.ServicePeriod) + s.Equal(fullServicePeriod, override.FullServicePeriod) + s.Equal(billingPeriod, override.BillingPeriod) + s.Equal(invoiceAt, override.InvoiceAt) + s.Equal(productcatalog.InAdvancePaymentTerm, override.PaymentTerm) + s.True(override.ProRating.Enabled) + s.Equal(productcatalog.ProRatingModeProratePrices, override.ProRating.Mode) + s.Equal(float64(42), override.AmountBeforeProration.InexactFloat64()) + s.Require().NotNil(override.PercentageDiscounts) + s.Equal(models.NewPercentage(10), override.PercentageDiscounts.Percentage) + s.Equal(discountCorrelationID, override.PercentageDiscounts.CorrelationID) +} + +func (s *FlatFeeIntentOverrideAdapterSuite) createCharge(namespace string) flatfee.Charge { + s.T().Helper() + + customerID := s.createCustomer(namespace) + taxCodeID := s.taxCodeEnv.CreateTaxCode(s.T(), namespace).ID + servicePeriod := timeutil.ClosedPeriod{ + From: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + To: time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC), + } + + createdCharges, err := s.adapter.CreateCharges(s.T().Context(), flatfee.CreateChargesInput{ + Namespace: namespace, + Intents: []flatfee.IntentWithInitialStatus{ + { + Intent: flatfee.Intent{ + Intent: chargesmeta.Intent{ + ManagedBy: billing.SubscriptionManagedLine, + CustomerID: customerID, + Currency: currencyx.Code("USD"), + TaxConfig: productcatalog.TaxCodeConfig{ + TaxCodeID: taxCodeID, + }, + }, + IntentMutableFields: flatfee.IntentMutableFields{ + IntentMutableFields: chargesmeta.IntentMutableFields{ + Name: "flat-fee-charge", + ServicePeriod: servicePeriod, + FullServicePeriod: servicePeriod, + BillingPeriod: servicePeriod, + }, + InvoiceAt: servicePeriod.To, + PaymentTerm: productcatalog.InAdvancePaymentTerm, + AmountBeforeProration: alpacadecimal.NewFromInt(10), + ProRating: productcatalog.ProRatingConfig{ + Enabled: false, + Mode: productcatalog.ProRatingModeProratePrices, + }, + }, + SettlementMode: productcatalog.CreditThenInvoiceSettlementMode, + }, + InitialStatus: flatfee.StatusCreated, + AmountAfterProration: alpacadecimal.NewFromInt(10), + }, + }, + }) + s.Require().NoError(err) + s.Require().Len(createdCharges, 1) + s.Nil(createdCharges[0].Intent.GetOverrideLayerMutableFields()) + + return createdCharges[0] +} + +func (s *FlatFeeIntentOverrideAdapterSuite) createCustomer(namespace string) string { + s.T().Helper() + + customer, err := s.dbClient.Customer.Create(). + SetNamespace(namespace). + SetName("test-customer"). + Save(s.T().Context()) + s.Require().NoError(err) + + return customer.ID +} diff --git a/billing/charges/flatfee/adapter/mapper.go b/billing/charges/flatfee/adapter/mapper.go new file mode 100644 index 0000000000000000000000000000000000000000..8fcf8713db9ef71454d331852f6ed3dea3c3f5c5 --- /dev/null +++ b/billing/charges/flatfee/adapter/mapper.go @@ -0,0 +1,192 @@ +package adapter + +import ( + "fmt" + "slices" + "time" + + "github.com/samber/lo" + + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/billing/charges/flatfee" + "github.com/openmeterio/openmeter/openmeter/billing/charges/meta" + "github.com/openmeterio/openmeter/openmeter/billing/charges/models/chargemeta" + "github.com/openmeterio/openmeter/openmeter/billing/charges/models/creditrealization" + "github.com/openmeterio/openmeter/openmeter/billing/charges/models/invoicedusage" + "github.com/openmeterio/openmeter/openmeter/billing/charges/models/payment" + "github.com/openmeterio/openmeter/openmeter/billing/models/stddetailedline" + "github.com/openmeterio/openmeter/openmeter/billing/models/totals" + entdb "github.com/openmeterio/openmeter/openmeter/ent/db" + "github.com/openmeterio/openmeter/openmeter/productcatalog" + "github.com/openmeterio/openmeter/pkg/convert" + "github.com/openmeterio/openmeter/pkg/framework/entutils" + "github.com/openmeterio/openmeter/pkg/timeutil" +) + +// MapFlatFeeChargeFromDB converts a DB Charge entity (with loaded FlatFee edge) to a FlatFeeCharge. +func MapChargeFlatFeeFromDB(entity *entdb.ChargeFlatFee, expands meta.Expands) (flatfee.Charge, error) { + charge := flatfee.Charge{ + ChargeBase: MapChargeBaseFromDB(entity), + } + + if expands.Has(meta.ExpandRealizations) { + realizations, err := mapRealizationsFromDB(entity) + if err != nil { + return flatfee.Charge{}, fmt.Errorf("mapping flat fee charge [id=%s]: %w", entity.ID, err) + } + charge.Realizations = realizations + } + + return charge, nil +} + +func mapRealizationsFromDB(entity *entdb.ChargeFlatFee) (flatfee.Realizations, error) { + dbRuns, err := entity.Edges.RunsOrErr() + if err != nil { + return flatfee.Realizations{}, fmt.Errorf("runs not loaded for flat fee charge [id=%s]: %w", entity.ID, err) + } + + var realizations flatfee.Realizations + for _, dbRun := range dbRuns { + run, err := mapRealizationRunFromDB(dbRun) + if err != nil { + return flatfee.Realizations{}, fmt.Errorf("mapping flat fee realization run [id=%s]: %w", dbRun.ID, err) + } + + if entity.CurrentRealizationRunID != nil && dbRun.ID == *entity.CurrentRealizationRunID { + realizations.CurrentRun = &run + continue + } + + realizations.PriorRuns = append(realizations.PriorRuns, run) + } + + if entity.CurrentRealizationRunID != nil && realizations.CurrentRun == nil { + return flatfee.Realizations{}, fmt.Errorf("current realization run [id=%s] not loaded for flat fee charge [id=%s]", *entity.CurrentRealizationRunID, entity.ID) + } + + return realizations, nil +} + +func mapRealizationRunBaseFromDB(dbRun *entdb.ChargeFlatFeeRun) flatfee.RealizationRunBase { + return flatfee.RealizationRunBase{ + ID: flatfee.RealizationRunID{ + Namespace: dbRun.Namespace, + ID: dbRun.ID, + }, + ManagedModel: entutils.MapTimeMixinFromDB(dbRun), + + LineID: dbRun.LineID, + InvoiceID: dbRun.InvoiceID, + Type: dbRun.Type, + InitialType: dbRun.InitialType, + ServicePeriod: timeutil.ClosedPeriod{From: dbRun.ServicePeriodFrom.UTC(), To: dbRun.ServicePeriodTo.UTC()}, + AmountAfterProration: dbRun.AmountAfterProration, + Totals: totals.FromDB(dbRun), + NoFiatTransactionRequired: dbRun.NoFiatTransactionRequired, + Immutable: dbRun.Immutable, + } +} + +func mapRealizationRunFromDB(dbRun *entdb.ChargeFlatFeeRun) (flatfee.RealizationRun, error) { + run := flatfee.RealizationRun{ + RealizationRunBase: mapRealizationRunBaseFromDB(dbRun), + } + + dbCreditsAllocated, err := dbRun.Edges.CreditAllocationsOrErr() + if _, ok := lo.ErrorsAs[*entdb.NotLoadedError](err); ok { + return flatfee.RealizationRun{}, fmt.Errorf("credits allocated not loaded for flat fee charge run [id=%s]", dbRun.ID) + } + + for _, credit := range dbCreditsAllocated { + run.CreditRealizations = append(run.CreditRealizations, creditrealization.MapFromDB(credit)) + } + + dbInvoiceUsage, err := dbRun.Edges.InvoicedUsageOrErr() + if _, ok := lo.ErrorsAs[*entdb.NotLoadedError](err); ok { + return flatfee.RealizationRun{}, fmt.Errorf("invoice usage not loaded for flat fee charge run [id=%s]", dbRun.ID) + } + + if dbInvoiceUsage != nil { + usage := invoicedusage.MapAccruedUsageFromDB(dbInvoiceUsage) + run.AccruedUsage = &usage + } + + dbPayment, err := dbRun.Edges.PaymentOrErr() + if _, ok := lo.ErrorsAs[*entdb.NotLoadedError](err); ok { + return flatfee.RealizationRun{}, fmt.Errorf("payment not loaded for flat fee charge run [id=%s]", dbRun.ID) + } + + if dbPayment != nil { + paymentState := payment.MapInvoicedFromDB(dbPayment) + run.Payment = &paymentState + } + + return run, nil +} + +func sortDetailedLines(lines flatfee.DetailedLines) { + slices.SortStableFunc(lines, stddetailedline.Compare[flatfee.DetailedLine]) +} + +func MapChargeBaseFromDB(entity *entdb.ChargeFlatFee) flatfee.ChargeBase { + var percentageDiscounts *billing.PercentageDiscount + if entity.Discounts != nil { + percentageDiscounts = entity.Discounts.Percentage + } + + mappedMeta := chargemeta.MapFromDB(entity) + + return flatfee.ChargeBase{ + ManagedResource: mappedMeta.ManagedResource, + Status: entity.StatusDetailed, + State: flatfee.State{ + AdvanceAfter: mappedMeta.AdvanceAfter, + FeatureID: entity.FeatureID, + AmountAfterProration: entity.AmountAfterProration, + }, + Intent: flatfee.NewOverridableIntent(flatfee.Intent{ + Intent: mappedMeta.Intent, + SettlementMode: entity.SettlementMode, + FeatureKey: entity.FeatureKey, + IntentMutableFields: flatfee.IntentMutableFields{ + IntentMutableFields: mappedMeta.IntentMutableFields, + InvoiceAt: entity.InvoiceAt.UTC(), + IntentDeletedAt: convert.TimePtrIn(entity.IntentDeletedAt, time.UTC), + PaymentTerm: entity.PaymentTerm, + PercentageDiscounts: percentageDiscounts, + ProRating: proRatingConfigFromDB(entity.ProRating), + AmountBeforeProration: entity.AmountBeforeProration, + }, + }, mapIntentOverrideFromDB(entity.Edges.IntentOverride)), + } +} + +// proRatingConfigFromDB converts a DB ProRatingModeAdapterEnum to a ProRatingConfig. +func proRatingConfigFromDB(pr flatfee.ProRatingModeAdapterEnum) productcatalog.ProRatingConfig { + switch pr { + case flatfee.ProratePricesProratingAdapterMode: + return productcatalog.ProRatingConfig{ + Enabled: true, + Mode: productcatalog.ProRatingModeProratePrices, + } + default: + return productcatalog.ProRatingConfig{ + Enabled: false, + Mode: productcatalog.ProRatingModeProratePrices, + } + } +} + +// proRatingConfigToDB converts a ProRatingConfig to a DB ProRatingModeAdapterEnum. +func proRatingConfigToDB(pc productcatalog.ProRatingConfig) (flatfee.ProRatingModeAdapterEnum, error) { + if !pc.Enabled { + return flatfee.NoProratingAdapterMode, nil + } + + if pc.Mode == productcatalog.ProRatingModeProratePrices { + return flatfee.ProratePricesProratingAdapterMode, nil + } + + return "", fmt.Errorf("invalid pro rating mode: %s", pc.Mode) +} diff --git a/billing/charges/flatfee/adapter/payment.go b/billing/charges/flatfee/adapter/payment.go new file mode 100644 index 0000000000000000000000000000000000000000..3e0dc6829a9c3df684c35c3792e0153ab37fe42b --- /dev/null +++ b/billing/charges/flatfee/adapter/payment.go @@ -0,0 +1,57 @@ +package adapter + +import ( + "context" + "fmt" + + "github.com/openmeterio/openmeter/openmeter/billing/charges/flatfee" + "github.com/openmeterio/openmeter/openmeter/billing/charges/models/payment" + "github.com/openmeterio/openmeter/openmeter/ent/db/chargeflatfeerunpayment" + "github.com/openmeterio/openmeter/pkg/framework/entutils" +) + +var _ flatfee.ChargePaymentAdapter = (*adapter)(nil) + +func (a *adapter) CreatePayment(ctx context.Context, runID flatfee.RealizationRunID, paymentSettlement payment.InvoicedCreate) (payment.Invoiced, error) { + if err := runID.Validate(); err != nil { + return payment.Invoiced{}, err + } + + if err := paymentSettlement.Validate(); err != nil { + return payment.Invoiced{}, err + } + + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) (payment.Invoiced, error) { + create := tx.db.ChargeFlatFeeRunPayment.Create(). + SetRunID(runID.ID) + + create = payment.CreateInvoiced(create, paymentSettlement) + + entity, err := create.Save(ctx) + if err != nil { + return payment.Invoiced{}, fmt.Errorf("creating flat fee run payment [run_id=%s]: %w", runID.ID, err) + } + + return payment.MapInvoicedFromDB(entity), nil + }) +} + +func (a *adapter) UpdatePayment(ctx context.Context, paymentSettlement payment.Invoiced) (payment.Invoiced, error) { + if err := paymentSettlement.Validate(); err != nil { + return payment.Invoiced{}, err + } + + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) (payment.Invoiced, error) { + update := tx.db.ChargeFlatFeeRunPayment.UpdateOneID(paymentSettlement.ID). + Where(chargeflatfeerunpayment.Namespace(paymentSettlement.Namespace)) + + updated := payment.UpdateInvoiced(update, paymentSettlement) + + entity, err := updated.Save(ctx) + if err != nil { + return payment.Invoiced{}, err + } + + return payment.MapInvoicedFromDB(entity), nil + }) +} diff --git a/billing/charges/flatfee/adapter/realizationrun.go b/billing/charges/flatfee/adapter/realizationrun.go new file mode 100644 index 0000000000000000000000000000000000000000..aa27413f98efec9112a998262ff9ea43530ba508 --- /dev/null +++ b/billing/charges/flatfee/adapter/realizationrun.go @@ -0,0 +1,146 @@ +package adapter + +import ( + "context" + "fmt" + + "github.com/openmeterio/openmeter/openmeter/billing/charges/flatfee" + "github.com/openmeterio/openmeter/openmeter/billing/charges/meta" + "github.com/openmeterio/openmeter/openmeter/billing/models/totals" + dbchargeflatfee "github.com/openmeterio/openmeter/openmeter/ent/db/chargeflatfee" + dbchargeflatfeerun "github.com/openmeterio/openmeter/openmeter/ent/db/chargeflatfeerun" + "github.com/openmeterio/openmeter/pkg/framework/entutils" +) + +var _ flatfee.ChargeRunAdapter = (*adapter)(nil) + +func (a *adapter) CreateCurrentRun(ctx context.Context, input flatfee.CreateCurrentRunInput) (flatfee.RealizationRunBase, error) { + if err := input.Validate(); err != nil { + return flatfee.RealizationRunBase{}, err + } + + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) (flatfee.RealizationRunBase, error) { + dbCharge, err := tx.db.ChargeFlatFee.Query(). + Where( + dbchargeflatfee.NamespaceEQ(input.Charge.Namespace), + dbchargeflatfee.IDEQ(input.Charge.ID), + ). + ForUpdate(). + Only(ctx) + if err != nil { + return flatfee.RealizationRunBase{}, fmt.Errorf("querying flat fee charge [id=%s]: %w", input.Charge.ID, err) + } + + if dbCharge.CurrentRealizationRunID != nil { + return flatfee.RealizationRunBase{}, fmt.Errorf("flat fee charge [id=%s] already has current run [run_id=%s]", input.Charge.ID, *dbCharge.CurrentRealizationRunID) + } + + runCreate := tx.db.ChargeFlatFeeRun.Create(). + SetNamespace(dbCharge.Namespace). + SetChargeID(dbCharge.ID). + SetType(flatfee.RealizationRunTypeFinalRealization). + SetInitialType(flatfee.RealizationRunTypeFinalRealization). + SetServicePeriodFrom(input.ServicePeriod.From). + SetServicePeriodTo(input.ServicePeriod.To). + SetAmountAfterProration(input.AmountAfterProration). + SetNoFiatTransactionRequired(input.NoFiatTransactionRequired). + SetImmutable(input.Immutable). + SetNillableLineID(input.LineID). + SetNillableInvoiceID(input.InvoiceID) + + runCreate = totals.Set(runCreate, totals.Totals{}) + + dbRun, err := runCreate.Save(ctx) + if err != nil { + return flatfee.RealizationRunBase{}, fmt.Errorf("creating current flat fee realization run [charge_id=%s]: %w", dbCharge.ID, err) + } + + if _, err := tx.db.ChargeFlatFee.UpdateOneID(dbCharge.ID). + Where(dbchargeflatfee.NamespaceEQ(dbCharge.Namespace)). + SetCurrentRealizationRunID(dbRun.ID). + Save(ctx); err != nil { + return flatfee.RealizationRunBase{}, fmt.Errorf("setting flat fee current run [charge_id=%s, run_id=%s]: %w", dbCharge.ID, dbRun.ID, err) + } + + return mapRealizationRunBaseFromDB(dbRun), nil + }) +} + +func (a *adapter) UpdateRealizationRun(ctx context.Context, input flatfee.UpdateRealizationRunInput) (flatfee.RealizationRunBase, error) { + input = input.Normalized() + + if err := input.Validate(); err != nil { + return flatfee.RealizationRunBase{}, err + } + + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) (flatfee.RealizationRunBase, error) { + update := tx.db.ChargeFlatFeeRun.UpdateOneID(input.ID.ID). + Where(dbchargeflatfeerun.NamespaceEQ(input.ID.Namespace)) + + if input.Type.IsPresent() { + update = update.SetType(input.Type.OrEmpty()) + } + + if input.DeletedAt.IsPresent() { + update = update.SetOrClearDeletedAt(input.DeletedAt.OrEmpty()) + } + + if input.LineID.IsPresent() { + update = update.SetOrClearLineID(input.LineID.OrEmpty()) + } + + if input.InvoiceID.IsPresent() { + update = update.SetOrClearInvoiceID(input.InvoiceID.OrEmpty()) + } + + if input.ServicePeriod.IsPresent() { + servicePeriod := input.ServicePeriod.OrEmpty() + update = update. + SetServicePeriodFrom(servicePeriod.From). + SetServicePeriodTo(servicePeriod.To) + } + + if input.AmountAfterProration.IsPresent() { + update = update.SetAmountAfterProration(input.AmountAfterProration.OrEmpty()) + } + + if input.Totals.IsPresent() { + update = totals.Set(update, input.Totals.OrEmpty()) + } + + if input.NoFiatTransactionRequired.IsPresent() { + update = update.SetNoFiatTransactionRequired(input.NoFiatTransactionRequired.OrEmpty()) + } + + if input.Immutable.IsPresent() { + update = update.SetImmutable(input.Immutable.OrEmpty()) + } + + dbRun, err := update.Save(ctx) + if err != nil { + return flatfee.RealizationRunBase{}, fmt.Errorf("updating flat fee realization run [run_id=%s]: %w", input.ID.ID, err) + } + + return mapRealizationRunBaseFromDB(dbRun), nil + }) +} + +func (a *adapter) DetachCurrentRun(ctx context.Context, chargeID meta.ChargeID) error { + if err := chargeID.Validate(); err != nil { + return err + } + + return entutils.TransactingRepoWithNoValue(ctx, a, func(ctx context.Context, tx *adapter) error { + if _, err := tx.db.ChargeFlatFee.Update(). + Where( + dbchargeflatfee.NamespaceEQ(chargeID.Namespace), + dbchargeflatfee.IDEQ(chargeID.ID), + ). + ClearCurrentRealizationRunID(). + Save(ctx); err != nil { + return fmt.Errorf("detach flat fee current run [charge_id=%s]: %w", chargeID.ID, err) + } + + return nil + }) +} diff --git a/billing/charges/flatfee/adapter/realizationrun_test.go b/billing/charges/flatfee/adapter/realizationrun_test.go new file mode 100644 index 0000000000000000000000000000000000000000..a5dd860305a40a7dd3e970a396a1181011783c07 --- /dev/null +++ b/billing/charges/flatfee/adapter/realizationrun_test.go @@ -0,0 +1,143 @@ +package adapter + +import ( + "log/slog" + "testing" + "time" + + "github.com/alpacahq/alpacadecimal" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/billing/charges/flatfee" + chargesmeta "github.com/openmeterio/openmeter/openmeter/billing/charges/meta" + metaadapter "github.com/openmeterio/openmeter/openmeter/billing/charges/meta/adapter" + entdb "github.com/openmeterio/openmeter/openmeter/ent/db" + "github.com/openmeterio/openmeter/openmeter/productcatalog" + taxcodetestutils "github.com/openmeterio/openmeter/openmeter/taxcode/testutils" + "github.com/openmeterio/openmeter/openmeter/testutils" + "github.com/openmeterio/openmeter/pkg/currencyx" + "github.com/openmeterio/openmeter/pkg/timeutil" +) + +func TestFlatFeeRealizationRunAdapter(t *testing.T) { + suite.Run(t, new(FlatFeeRealizationRunAdapterSuite)) +} + +type FlatFeeRealizationRunAdapterSuite struct { + suite.Suite + + testDB *testutils.TestDB + dbClient *entdb.Client + adapter flatfee.Adapter + + taxCodeEnv *taxcodetestutils.TestEnv +} + +func (s *FlatFeeRealizationRunAdapterSuite) SetupSuite() { + t := s.T() + + s.testDB = testutils.InitPostgresDB(t, testutils.PostgresDBStateAtlasMigrated) + s.dbClient = entdb.NewClient(entdb.Driver(s.testDB.EntDriver.Driver())) + + metaAdapter, err := metaadapter.New(metaadapter.Config{ + Client: s.dbClient, + Logger: slog.Default(), + }) + require.NoError(t, err) + + a, err := New(Config{ + Client: s.dbClient, + Logger: slog.Default(), + MetaAdapter: metaAdapter, + }) + require.NoError(t, err) + + s.adapter = a + s.taxCodeEnv = taxcodetestutils.NewTestEnvFromClient(t, s.dbClient, slog.Default()) +} + +func (s *FlatFeeRealizationRunAdapterSuite) TearDownSuite() { + s.dbClient.Close() + s.testDB.EntDriver.Close() + s.testDB.PGDriver.Close() +} + +func (s *FlatFeeRealizationRunAdapterSuite) TestCreateCurrentRunFailsWhenCurrentRunAlreadyAttached() { + ctx := s.T().Context() + namespace := "flatfee-current-run-adapter" + customerID := s.createCustomer(namespace) + taxCodeID := s.taxCodeEnv.CreateTaxCode(s.T(), namespace).ID + + servicePeriod := timeutil.ClosedPeriod{ + From: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + To: time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC), + } + + createdCharges, err := s.adapter.CreateCharges(ctx, flatfee.CreateChargesInput{ + Namespace: namespace, + Intents: []flatfee.IntentWithInitialStatus{ + { + Intent: flatfee.Intent{ + Intent: chargesmeta.Intent{ + ManagedBy: billing.SubscriptionManagedLine, + CustomerID: customerID, + Currency: currencyx.Code("USD"), + TaxConfig: productcatalog.TaxCodeConfig{ + TaxCodeID: taxCodeID, + }, + }, + IntentMutableFields: flatfee.IntentMutableFields{ + IntentMutableFields: chargesmeta.IntentMutableFields{ + Name: "flat-fee-charge", + ServicePeriod: servicePeriod, + FullServicePeriod: servicePeriod, + BillingPeriod: servicePeriod, + }, + InvoiceAt: servicePeriod.To, + PaymentTerm: productcatalog.InAdvancePaymentTerm, + AmountBeforeProration: alpacadecimal.NewFromInt(10), + ProRating: productcatalog.ProRatingConfig{ + Enabled: false, + Mode: productcatalog.ProRatingModeProratePrices, + }, + }, + SettlementMode: productcatalog.CreditThenInvoiceSettlementMode, + }, + InitialStatus: flatfee.StatusCreated, + AmountAfterProration: alpacadecimal.NewFromInt(10), + }, + }, + }) + s.Require().NoError(err) + s.Require().Len(createdCharges, 1) + + run, err := s.adapter.CreateCurrentRun(ctx, flatfee.CreateCurrentRunInput{ + Charge: createdCharges[0].ChargeBase, + ServicePeriod: servicePeriod, + AmountAfterProration: alpacadecimal.NewFromInt(10), + }) + s.Require().NoError(err) + s.Nil(run.LineID) + s.Nil(run.InvoiceID) + + _, err = s.adapter.CreateCurrentRun(ctx, flatfee.CreateCurrentRunInput{ + Charge: createdCharges[0].ChargeBase, + ServicePeriod: servicePeriod, + AmountAfterProration: alpacadecimal.NewFromInt(10), + }) + s.Require().ErrorContains(err, "already has current run") +} + +func (s *FlatFeeRealizationRunAdapterSuite) createCustomer(namespace string) string { + s.T().Helper() + + customer, err := s.dbClient.Customer.Create(). + SetNamespace(namespace). + SetName("test-customer"). + Save(s.T().Context()) + s.Require().NoError(err) + + return customer.ID +} diff --git a/billing/charges/flatfee/adapter/usage.go b/billing/charges/flatfee/adapter/usage.go new file mode 100644 index 0000000000000000000000000000000000000000..6efd5a327ec3a9ab99a5b366cf3a0aab9a8860e1 --- /dev/null +++ b/billing/charges/flatfee/adapter/usage.go @@ -0,0 +1,41 @@ +package adapter + +import ( + "context" + "fmt" + + "github.com/openmeterio/openmeter/openmeter/billing/charges/flatfee" + "github.com/openmeterio/openmeter/openmeter/billing/charges/models/invoicedusage" + dbchargeflatfeerun "github.com/openmeterio/openmeter/openmeter/ent/db/chargeflatfeerun" + "github.com/openmeterio/openmeter/pkg/framework/entutils" +) + +var _ flatfee.ChargeInvoicedUsageAdapter = (*adapter)(nil) + +func (a *adapter) CreateInvoicedUsage(ctx context.Context, input flatfee.CreateInvoicedUsageInput) (invoicedusage.AccruedUsage, error) { + if err := input.Validate(); err != nil { + return invoicedusage.AccruedUsage{}, err + } + + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) (invoicedusage.AccruedUsage, error) { + if _, err := tx.db.ChargeFlatFeeRun.UpdateOneID(input.RunID.ID). + Where(dbchargeflatfeerun.Namespace(input.RunID.Namespace)). + SetLineID(input.LineID). + SetInvoiceID(input.InvoiceID). + Save(ctx); err != nil { + return invoicedusage.AccruedUsage{}, fmt.Errorf("updating flat fee run invoice refs [run_id=%s]: %w", input.RunID.ID, err) + } + + create := tx.db.ChargeFlatFeeRunInvoicedUsage.Create(). + SetRunID(input.RunID.ID) + + create = invoicedusage.Create(create, input.RunID.Namespace, input.InvoicedUsage) + + entity, err := create.Save(ctx) + if err != nil { + return invoicedusage.AccruedUsage{}, err + } + + return invoicedusage.MapAccruedUsageFromDB(entity), nil + }) +} diff --git a/billing/charges/flatfee/bookedat.go b/billing/charges/flatfee/bookedat.go new file mode 100644 index 0000000000000000000000000000000000000000..5a72fcfc472932ef9eaa4bc101f8ce568d877b49 --- /dev/null +++ b/billing/charges/flatfee/bookedat.go @@ -0,0 +1,17 @@ +package flatfee + +import ( + "time" + + "github.com/openmeterio/openmeter/openmeter/productcatalog" + "github.com/openmeterio/openmeter/pkg/timeutil" +) + +// UsageBookedAt returns the ledger booking time for a flat-fee service period. +func UsageBookedAt(paymentTerm productcatalog.PaymentTermType, servicePeriod timeutil.ClosedPeriod) time.Time { + if paymentTerm == productcatalog.InArrearsPaymentTerm { + return servicePeriod.To + } + + return servicePeriod.From +} diff --git a/billing/charges/flatfee/charge.go b/billing/charges/flatfee/charge.go new file mode 100644 index 0000000000000000000000000000000000000000..7143f0518622f3a2faa452a72e17cf89abf052e5 --- /dev/null +++ b/billing/charges/flatfee/charge.go @@ -0,0 +1,615 @@ +package flatfee + +import ( + "errors" + "fmt" + "slices" + "time" + + "github.com/alpacahq/alpacadecimal" + "github.com/samber/lo" + + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/billing/charges/meta" + "github.com/openmeterio/openmeter/openmeter/customer" + "github.com/openmeterio/openmeter/openmeter/productcatalog" + "github.com/openmeterio/openmeter/pkg/currencyx" + "github.com/openmeterio/openmeter/pkg/models" + "github.com/openmeterio/openmeter/pkg/timeutil" +) + +type ChargeBase struct { + meta.ManagedResource + + Intent OverridableIntent `json:"intent"` + Status Status `json:"status"` + + State State `json:"state"` +} + +func (c ChargeBase) Validate() error { + var errs []error + + if err := c.ManagedResource.Validate(); err != nil { + errs = append(errs, fmt.Errorf("managed resource: %w", err)) + } + + if err := c.Intent.Validate(); err != nil { + errs = append(errs, fmt.Errorf("intent: %w", err)) + } + + if err := c.Status.Validate(); err != nil { + errs = append(errs, fmt.Errorf("status: %w", err)) + } + + if err := c.State.Validate(); err != nil { + errs = append(errs, fmt.Errorf("state: %w", err)) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +func (c ChargeBase) GetChargeID() meta.ChargeID { + return meta.ChargeID{ + Namespace: c.Namespace, + ID: c.ID, + } +} + +func (c ChargeBase) GetCustomerID() customer.CustomerID { + return customer.CustomerID{ + Namespace: c.Namespace, + ID: c.Intent.GetCustomerID(), + } +} + +func (c ChargeBase) GetCurrency() currencyx.Code { + return c.Intent.GetCurrency() +} + +func (c ChargeBase) ErrorAttributes() models.Attributes { + return models.Attributes{ + "charge_id": c.ID, + "namespace": c.Namespace, + "charge_type": string(meta.ChargeTypeFlatFee), + } +} + +var _ meta.ChargeAccessor = (*Charge)(nil) + +type Charge struct { + ChargeBase + + Realizations Realizations `json:"realizations"` +} + +func (c Charge) GetStatus() Status { + return c.Status +} + +func (c Charge) WithStatus(status Status) Charge { + c.Status = status + return c +} + +func (c Charge) GetBase() ChargeBase { + return c.ChargeBase +} + +func (c Charge) WithBase(base ChargeBase) Charge { + c.ChargeBase = base + return c +} + +func (c Charge) Validate() error { + var errs []error + + if err := c.ChargeBase.Validate(); err != nil { + errs = append(errs, fmt.Errorf("charge base: %w", err)) + } + + if err := c.Realizations.Validate(); err != nil { + errs = append(errs, fmt.Errorf("realizations: %w", err)) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +type Intent struct { + meta.Intent + IntentMutableFields `json:"intentMutableFields"` + SettlementMode productcatalog.SettlementMode `json:"settlementMode"` + FeatureKey *string `json:"featureKey,omitempty"` +} + +func (i Intent) Normalized() Intent { + i.IntentMutableFields = i.IntentMutableFields.Normalized(i.Currency) + + return i +} + +// AsOverridableIntent maps the intent's mutable fields as the base layer. +func (i Intent) AsOverridableIntent() OverridableIntent { + return OverridableIntent{ + intent: i.Intent, + baseLayer: i.IntentMutableFields, + settlementMode: i.SettlementMode, + featureKey: i.FeatureKey, + } +} + +func (i Intent) Validate() error { + var errs []error + + if err := i.Intent.Validate(); err != nil { + errs = append(errs, err) + } + + if err := i.IntentMutableFields.Validate(); err != nil { + errs = append(errs, err) + } + + if err := i.SettlementMode.Validate(); err != nil { + errs = append(errs, fmt.Errorf("settlement mode: %w", err)) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +// CalculateAmountAfterProration computes the prorated amount from AmountBeforeProration, +// ServicePeriod, and FullServicePeriod. Returns AmountBeforeProration when proration is +// not applicable (disabled, unsupported mode, or zero-length periods). +func (i Intent) CalculateAmountAfterProration() (alpacadecimal.Decimal, error) { + if !i.ProRating.Enabled { + return i.AmountBeforeProration, nil + } + + if i.ProRating.Mode != productcatalog.ProRatingModeProratePrices { + return i.AmountBeforeProration, nil + } + + servicePeriodDuration := int64(i.ServicePeriod.Duration()) + fullServicePeriodDuration := int64(i.FullServicePeriod.Duration()) + + // Proration must never increase the amount beyond AmountBeforeProration. + // Zero-length periods or ServicePeriod >= FullServicePeriod means no proration applies. + if servicePeriodDuration == 0 || fullServicePeriodDuration == 0 || servicePeriodDuration >= fullServicePeriodDuration { + return i.AmountBeforeProration, nil + } + + percentage := alpacadecimal.NewFromInt(servicePeriodDuration).Div(alpacadecimal.NewFromInt(fullServicePeriodDuration)) + amount := i.AmountBeforeProration.Mul(percentage) + + calc, err := currencyx.NewCurrencyBuilder(currencyx.CurrencyTypeFiat). + WithCode(i.Currency). + Build() + if err != nil { + return alpacadecimal.Decimal{}, fmt.Errorf("creating currency calculator: %w", err) + } + + return calc.RoundToPrecision(amount), nil +} + +// OverridableIntent stores the immutable intent plus the base and optional +// override mutable layers. Direct layer access is error-prone because callers +// must manually decide which layer is active; this API centralizes that choice +// so reads and mutations use the correct override layer when present. +type OverridableIntent struct { + intent meta.Intent + + baseLayer IntentMutableFields + overrideLayer *IntentMutableFields + + settlementMode productcatalog.SettlementMode + featureKey *string +} + +func NewOverridableIntent(baseIntent Intent, overrideLayer *IntentMutableFields) OverridableIntent { + return OverridableIntent{ + intent: baseIntent.Intent, + baseLayer: baseIntent.IntentMutableFields, + overrideLayer: overrideLayer, + settlementMode: baseIntent.SettlementMode, + featureKey: baseIntent.FeatureKey, + } +} + +func (i OverridableIntent) Normalized() OverridableIntent { + i.baseLayer = i.baseLayer.Normalized(i.intent.Currency) + if i.overrideLayer != nil { + overrideLayer := i.overrideLayer.Normalized(i.intent.Currency) + i.overrideLayer = &overrideLayer + } + + return i +} + +func (i OverridableIntent) GetCustomerID() string { + return i.intent.CustomerID +} + +func (i OverridableIntent) GetCurrency() currencyx.Code { + return i.intent.Currency +} + +func (i OverridableIntent) GetSettlementMode() productcatalog.SettlementMode { + return i.settlementMode +} + +func (i OverridableIntent) GetUniqueReferenceID() *string { + return i.intent.UniqueReferenceID +} + +func (i OverridableIntent) GetSubscription() *meta.SubscriptionReference { + if i.intent.Subscription == nil { + return nil + } + + subscription := *i.intent.Subscription + + return &subscription +} + +func (i OverridableIntent) Validate() error { + var errs []error + + if err := i.intent.Validate(); err != nil { + errs = append(errs, fmt.Errorf("intent: %w", err)) + } + + if err := i.baseLayer.Validate(); err != nil { + errs = append(errs, fmt.Errorf("base layer: %w", err)) + } + + if i.overrideLayer != nil { + if err := i.overrideLayer.Validate(); err != nil { + errs = append(errs, fmt.Errorf("override layer: %w", err)) + } + } + + if err := i.settlementMode.Validate(); err != nil { + errs = append(errs, fmt.Errorf("settlement mode: %w", err)) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +// GetEffectiveIntent returns the customer-facing intent by combining the +// immutable intent with the active mutable layer. +// +// WARNING: this clones and normalizes the intent and mutable fields. Prefer the +// narrower effective getters when only a few fields are required. +func (i OverridableIntent) GetEffectiveIntent() Intent { + var featureKey *string + if i.featureKey != nil { + featureKey = lo.ToPtr(*i.featureKey) + } + + intent := Intent{ + Intent: i.intent.Clone(), + IntentMutableFields: i.baseLayer.Clone(), + SettlementMode: i.settlementMode, + FeatureKey: featureKey, + } + + if i.overrideLayer != nil { + intent.IntentMutableFields = i.overrideLayer.Clone() + } + + return intent.Normalized() +} + +// GetEffectiveServicePeriod returns the service period from the active mutable +// layer, preferring the override layer when it is present. +func (i OverridableIntent) GetEffectiveServicePeriod() timeutil.ClosedPeriod { + if i.overrideLayer != nil { + return i.overrideLayer.ServicePeriod + } + + return i.baseLayer.ServicePeriod +} + +// GetEffectiveInvoiceAt returns the invoice-at timestamp from the active +// mutable layer, preferring the override layer when it is present. +func (i OverridableIntent) GetEffectiveInvoiceAt() time.Time { + if i.overrideLayer != nil { + return i.overrideLayer.InvoiceAt + } + + return i.baseLayer.InvoiceAt +} + +// GetEffectivePaymentTerm returns the payment term from the active mutable +// layer, preferring the override layer when it is present. +func (i OverridableIntent) GetEffectivePaymentTerm() productcatalog.PaymentTermType { + if i.overrideLayer != nil { + return i.overrideLayer.PaymentTerm + } + + return i.baseLayer.PaymentTerm +} + +// GetFeatureKey returns the immutable flat-fee feature key from the +// base intent. Override layers cannot change feature attribution. +func (i OverridableIntent) GetFeatureKey() string { + if i.featureKey == nil { + return "" + } + + return *i.featureKey +} + +// GetTaxConfig returns the immutable tax config from the base intent. +// Override layers cannot change tax attribution. +func (i OverridableIntent) GetTaxConfig() productcatalog.TaxCodeConfig { + return i.intent.TaxConfig +} + +// GetEffectiveMetaIntentMutableFields returns the shared meta mutable fields +// from the active mutable layer, preferring the override layer when it is +// present. +func (i OverridableIntent) GetEffectiveMetaIntentMutableFields() meta.IntentMutableFields { + if i.overrideLayer != nil { + return i.overrideLayer.IntentMutableFields + } + + return i.baseLayer.IntentMutableFields +} + +func (i OverridableIntent) GetBaseManagedBy() billing.InvoiceLineManagedBy { + return i.intent.ManagedBy +} + +func (i OverridableIntent) GetBaseIntent() Intent { + var featureKey *string + if i.featureKey != nil { + featureKey = lo.ToPtr(*i.featureKey) + } + + return Intent{ + Intent: i.intent.Clone(), + IntentMutableFields: i.baseLayer.Clone(), + SettlementMode: i.settlementMode, + FeatureKey: featureKey, + } +} + +func (i OverridableIntent) GetIntentForTarget(target meta.ChangeTarget) (Intent, error) { + var featureKey *string + if i.featureKey != nil { + featureKey = lo.ToPtr(*i.featureKey) + } + + out := Intent{ + Intent: i.intent.Clone(), + SettlementMode: i.settlementMode, + FeatureKey: featureKey, + } + + switch target { + case meta.ChangeTargetBase: + out.IntentMutableFields = i.baseLayer.Clone() + case meta.ChangeTargetOverride: + if i.overrideLayer == nil { + return Intent{}, fmt.Errorf("override layer not present for charge") + } + + out.IntentMutableFields = i.overrideLayer.Clone() + default: + return Intent{}, fmt.Errorf("invalid change target: %s", target) + } + + return out, nil +} + +func (i OverridableIntent) GetOverrideLayerMutableFields() *IntentMutableFields { + if i.overrideLayer == nil { + return nil + } + + return lo.ToPtr(i.overrideLayer.Clone()) +} + +func (i OverridableIntent) HasOverrideLayer() bool { + return i.overrideLayer != nil +} + +func (i OverridableIntent) GetDeletedAt() *time.Time { + if i.overrideLayer != nil { + return i.overrideLayer.IntentDeletedAt + } + + return i.baseLayer.IntentDeletedAt +} + +func (i OverridableIntent) CalculateAmountAfterProration() (alpacadecimal.Decimal, error) { + // TODO[later,performance]: We should not clone for this, but this is not on a hot path. + return i.GetEffectiveIntent().CalculateAmountAfterProration() +} + +func (i *OverridableIntent) MutateEffective(editFn func(*IntentMutableFields)) error { + target := meta.ChangeTargetBase + if i.overrideLayer != nil { + target = meta.ChangeTargetOverride + } + + return i.Mutate(target, editFn) +} + +// Mutate edits the requested intent mutable field layer. +// +// The callback always receives a non-nil pointer to a cloned mutable-field value. +// The clone is written back only after it normalizes and validates, so validation +// errors do not partially mutate the intent. +func (i *OverridableIntent) Mutate(target meta.ChangeTarget, editFn func(*IntentMutableFields)) error { + var targetFields IntentMutableFields + switch target { + case meta.ChangeTargetBase: + targetFields = i.baseLayer.Clone() + case meta.ChangeTargetOverride: + if i.overrideLayer == nil { + return fmt.Errorf("override layer not present for charge") + } + + targetFields = i.overrideLayer.Clone() + } + + editFn(&targetFields) + + normalizedFields := targetFields.Normalized(i.intent.Currency) + if err := normalizedFields.Validate(); err != nil { + return fmt.Errorf("validating intent: %w", err) + } + + switch target { + case meta.ChangeTargetBase: + i.baseLayer = normalizedFields + case meta.ChangeTargetOverride: + if i.overrideLayer == nil { + return fmt.Errorf("override layer not present for charge") + } + + i.overrideLayer = &normalizedFields + default: + return fmt.Errorf("invalid change target: %s", target) + } + + return nil +} + +type IntentMutableFields struct { + meta.IntentMutableFields + + // IntentDeletedAt marks the flat-fee base/original intent as deleted. + // Adapters derive the effective charge DeletedAt from this value when no intent override is present. + IntentDeletedAt *time.Time `json:"intentDeletedAt,omitempty"` + + InvoiceAt time.Time `json:"invoiceAt"` + PaymentTerm productcatalog.PaymentTermType `json:"paymentTerm"` + PercentageDiscounts *billing.PercentageDiscount `json:"percentageDiscounts"` + + ProRating productcatalog.ProRatingConfig `json:"proRating"` + AmountBeforeProration alpacadecimal.Decimal `json:"amountBeforeProration"` +} + +func (f IntentMutableFields) Normalized(currency currencyx.Code) IntentMutableFields { + f.IntentMutableFields = f.IntentMutableFields.Normalized() + f.InvoiceAt = meta.NormalizeTimestamp(f.InvoiceAt) + + calc, err := currencyx.NewCurrencyBuilder(currencyx.CurrencyTypeFiat). + WithCode(currency). + Build() + if err == nil { + f.AmountBeforeProration = calc.RoundToPrecision(f.AmountBeforeProration) + } + + return f +} + +func (f IntentMutableFields) Clone() IntentMutableFields { + out := f + out.IntentMutableFields = f.IntentMutableFields.Clone() + + if f.PercentageDiscounts != nil { + out.PercentageDiscounts = lo.ToPtr(f.PercentageDiscounts.Clone()) + } + + return out +} + +func (f IntentMutableFields) Validate() error { + var errs []error + + if err := f.IntentMutableFields.Validate(); err != nil { + errs = append(errs, err) + } + + if f.AmountBeforeProration.IsNegative() { + errs = append(errs, fmt.Errorf("amount before proration cannot be negative")) + } + + if !slices.Contains(productcatalog.PaymentTermType("").Values(), string(f.PaymentTerm)) { + errs = append(errs, fmt.Errorf("invalid payment term %s", f.PaymentTerm)) + } + + if f.InvoiceAt.IsZero() { + errs = append(errs, fmt.Errorf("invoice at is required")) + } + + if f.PercentageDiscounts != nil { + if err := f.PercentageDiscounts.Validate(); err != nil { + errs = append(errs, fmt.Errorf("percentage discounts: %w", err)) + } + } + + if err := f.ProRating.Validate(); err != nil { + errs = append(errs, fmt.Errorf("pro rating: %w", err)) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +type State struct { + AdvanceAfter *time.Time `json:"advanceAfter,omitempty"` + FeatureID *string `json:"featureId,omitempty"` + AmountAfterProration alpacadecimal.Decimal `json:"amountAfterProration"` +} + +func (s State) Normalized() State { + s.AdvanceAfter = meta.NormalizeOptionalTimestamp(s.AdvanceAfter) + + return s +} + +func (s State) Validate() error { + var errs []error + + if s.AdvanceAfter != nil { + if s.AdvanceAfter.IsZero() { + errs = append(errs, fmt.Errorf("advance after is required")) + } + } + + if s.AmountAfterProration.IsNegative() { + errs = append(errs, fmt.Errorf("amount after proration cannot be negative")) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +type Realizations struct { + CurrentRun *RealizationRun `json:"currentRun,omitempty"` + PriorRuns RealizationRuns `json:"priorRuns,omitempty"` +} + +func (r Realizations) GetByLineID(lineID string) (RealizationRun, error) { + if r.CurrentRun != nil { + if r.CurrentRun.LineID != nil && *r.CurrentRun.LineID == lineID { + return *r.CurrentRun, nil + } + } + + for _, run := range r.PriorRuns { + if run.LineID != nil && *run.LineID == lineID { + return run, nil + } + } + + return RealizationRun{}, fmt.Errorf("realization run not found [line_id=%s]", lineID) +} + +func (r Realizations) Validate() error { + var errs []error + + if r.CurrentRun != nil { + if err := r.CurrentRun.Validate(); err != nil { + errs = append(errs, fmt.Errorf("current run: %w", err)) + } + } + + if err := r.PriorRuns.Validate(); err != nil { + errs = append(errs, fmt.Errorf("prior runs: %w", err)) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} diff --git a/billing/charges/flatfee/charge_test.go b/billing/charges/flatfee/charge_test.go new file mode 100644 index 0000000000000000000000000000000000000000..270f2fb99cdbdfe403cfd851fe99119b172997ae --- /dev/null +++ b/billing/charges/flatfee/charge_test.go @@ -0,0 +1,174 @@ +package flatfee + +import ( + "testing" + "time" + + "github.com/alpacahq/alpacadecimal" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/openmeterio/openmeter/openmeter/billing/charges/meta" + "github.com/openmeterio/openmeter/openmeter/productcatalog" + "github.com/openmeterio/openmeter/pkg/currencyx" + "github.com/openmeterio/openmeter/pkg/datetime" + "github.com/openmeterio/openmeter/pkg/timeutil" +) + +func TestCalculateAmountAfterProration(t *testing.T) { + // 2026-01-01 to 2026-02-01 (full month) + fullMonthStart := datetime.MustParseTimeInLocation(t, "2026-01-01T00:00:00Z", time.UTC).AsTime() + fullMonthEnd := datetime.MustParseTimeInLocation(t, "2026-02-01T00:00:00Z", time.UTC).AsTime() + // 2026-01-01 to 2026-01-16 (half month, 15 out of 31 days) + halfMonthEnd := datetime.MustParseTimeInLocation(t, "2026-01-16T00:00:00Z", time.UTC).AsTime() + + fullMonth := timeutil.ClosedPeriod{ + From: fullMonthStart, + To: fullMonthEnd, + } + + halfMonth := timeutil.ClosedPeriod{ + From: fullMonthStart, + To: halfMonthEnd, + } + + amount100 := alpacadecimal.NewFromInt(100) + + baseIntent := func() Intent { + return Intent{ + Intent: meta.Intent{ + CustomerID: "cust-1", + Currency: currencyx.Code("USD"), + ManagedBy: "system", + }, + IntentMutableFields: IntentMutableFields{ + IntentMutableFields: meta.IntentMutableFields{ + Name: "test", + ServicePeriod: halfMonth, + FullServicePeriod: fullMonth, + BillingPeriod: fullMonth, + }, + InvoiceAt: fullMonthStart, + PaymentTerm: productcatalog.InAdvancePaymentTerm, + AmountBeforeProration: amount100, + ProRating: productcatalog.ProRatingConfig{ + Enabled: true, + Mode: productcatalog.ProRatingModeProratePrices, + }, + }, + SettlementMode: productcatalog.CreditThenInvoiceSettlementMode, + } + } + + t.Run("proration disabled returns full amount", func(t *testing.T) { + intent := baseIntent() + intent.ProRating = productcatalog.ProRatingConfig{ + Enabled: false, + Mode: productcatalog.ProRatingModeProratePrices, + } + + result, err := intent.CalculateAmountAfterProration() + require.NoError(t, err) + assert.True(t, result.Equal(amount100), "expected %s, got %s", amount100, result) + }) + + t.Run("equal periods returns full amount", func(t *testing.T) { + intent := baseIntent() + intent.ServicePeriod = fullMonth + intent.FullServicePeriod = fullMonth + + result, err := intent.CalculateAmountAfterProration() + require.NoError(t, err) + assert.True(t, result.Equal(amount100), "expected %s, got %s", amount100, result) + }) + + t.Run("half period returns prorated amount", func(t *testing.T) { + intent := baseIntent() + + result, err := intent.CalculateAmountAfterProration() + require.NoError(t, err) + + // 15 days out of 31 days = 100 * 15/31 = 48.387... rounded to 48.39 for USD + expected := alpacadecimal.NewFromFloat(48.39) + assert.True(t, result.Equal(expected), "expected %s, got %s", expected, result) + }) + + t.Run("zero length service period returns full amount", func(t *testing.T) { + intent := baseIntent() + intent.ServicePeriod = timeutil.ClosedPeriod{ + From: fullMonthStart, + To: fullMonthStart, + } + + result, err := intent.CalculateAmountAfterProration() + require.NoError(t, err) + assert.True(t, result.Equal(amount100), "expected %s, got %s", amount100, result) + }) + + t.Run("zero length full service period returns full amount", func(t *testing.T) { + intent := baseIntent() + intent.FullServicePeriod = timeutil.ClosedPeriod{ + From: fullMonthStart, + To: fullMonthStart, + } + + result, err := intent.CalculateAmountAfterProration() + require.NoError(t, err) + assert.True(t, result.Equal(amount100), "expected %s, got %s", amount100, result) + }) + + t.Run("rounds to currency precision", func(t *testing.T) { + intent := baseIntent() + // 10 days out of 31 = 100 * 10/31 = 32.258... rounded to 32.26 for USD + tenDaysEnd := datetime.MustParseTimeInLocation(t, "2026-01-11T00:00:00Z", time.UTC).AsTime() + intent.ServicePeriod = timeutil.ClosedPeriod{ + From: fullMonthStart, + To: tenDaysEnd, + } + + result, err := intent.CalculateAmountAfterProration() + require.NoError(t, err) + + expected := alpacadecimal.NewFromFloat(32.26) + assert.True(t, result.Equal(expected), "expected %s, got %s", expected, result) + }) + + t.Run("JPY rounds to zero decimal places", func(t *testing.T) { + intent := baseIntent() + intent.Currency = currencyx.Code("JPY") + intent.AmountBeforeProration = alpacadecimal.NewFromInt(1000) + // 10 days out of 31 = 1000 * 10/31 = 322.580... rounded to 323 for JPY + tenDaysEnd := datetime.MustParseTimeInLocation(t, "2026-01-11T00:00:00Z", time.UTC).AsTime() + intent.ServicePeriod = timeutil.ClosedPeriod{ + From: fullMonthStart, + To: tenDaysEnd, + } + + result, err := intent.CalculateAmountAfterProration() + require.NoError(t, err) + + expected := alpacadecimal.NewFromInt(323) + assert.True(t, result.Equal(expected), "expected %s, got %s", expected, result) + }) + + t.Run("service period exceeding full period returns full amount", func(t *testing.T) { + intent := baseIntent() + // ServicePeriod is longer than FullServicePeriod — proration must not increase the amount + intent.ServicePeriod = timeutil.ClosedPeriod{ + From: fullMonthStart, + To: datetime.MustParseTimeInLocation(t, "2026-03-01T00:00:00Z", time.UTC).AsTime(), + } + + result, err := intent.CalculateAmountAfterProration() + require.NoError(t, err) + assert.True(t, result.Equal(amount100), "expected %s, got %s", amount100, result) + }) + + t.Run("invalid currency returns error", func(t *testing.T) { + intent := baseIntent() + intent.Currency = currencyx.Code("INVALID") + + _, err := intent.CalculateAmountAfterProration() + require.Error(t, err) + }) +} diff --git a/billing/charges/flatfee/detailedline.go b/billing/charges/flatfee/detailedline.go new file mode 100644 index 0000000000000000000000000000000000000000..d40b821bce92b293716cba9ba7edf4fa9e5d807f --- /dev/null +++ b/billing/charges/flatfee/detailedline.go @@ -0,0 +1,33 @@ +package flatfee + +import ( + "errors" + "fmt" + + "github.com/samber/lo" + + "github.com/openmeterio/openmeter/openmeter/billing/models/stddetailedline" + "github.com/openmeterio/openmeter/pkg/models" +) + +type DetailedLine = stddetailedline.Base + +type DetailedLines []DetailedLine + +func (l DetailedLines) Clone() DetailedLines { + return lo.Map(l, func(dl DetailedLine, _ int) DetailedLine { + return dl.Clone() + }) +} + +func (l DetailedLines) Validate() error { + var errs []error + + for idx, line := range l { + if err := line.Validate(); err != nil { + errs = append(errs, fmt.Errorf("[%d]: %w", idx, err)) + } + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} diff --git a/billing/charges/flatfee/handler.go b/billing/charges/flatfee/handler.go new file mode 100644 index 0000000000000000000000000000000000000000..b4b7d6cc6d676b1ac0c0aeae540bbacbcfb96674 --- /dev/null +++ b/billing/charges/flatfee/handler.go @@ -0,0 +1,163 @@ +package flatfee + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/alpacahq/alpacadecimal" + + "github.com/openmeterio/openmeter/openmeter/billing/charges/lineage" + "github.com/openmeterio/openmeter/openmeter/billing/charges/models/creditrealization" + "github.com/openmeterio/openmeter/openmeter/billing/charges/models/ledgertransaction" + "github.com/openmeterio/openmeter/openmeter/billing/models/totals" + "github.com/openmeterio/openmeter/pkg/currencyx" + "github.com/openmeterio/openmeter/pkg/models" + "github.com/openmeterio/openmeter/pkg/timeutil" +) + +type OnAllocateCreditsInput struct { + Charge Charge `json:"charge"` + ServicePeriod timeutil.ClosedPeriod `json:"servicePeriod"` + BookedAt time.Time `json:"bookedAt"` + // PreTaxAmountToAllocate is the pre-tax amount to allocate from credits. + // The input charge's settlement mode governs whether this may create a negative balance. + PreTaxAmountToAllocate alpacadecimal.Decimal `json:"preTaxAmountToAllocate"` +} + +func (i OnAllocateCreditsInput) Validate() error { + var errs []error + + if err := i.Charge.Validate(); err != nil { + errs = append(errs, fmt.Errorf("charge: %w", err)) + } + + if err := i.ServicePeriod.Validate(); err != nil { + errs = append(errs, fmt.Errorf("service period: %w", err)) + } + + if i.BookedAt.IsZero() { + errs = append(errs, fmt.Errorf("booked at is required")) + } + + if i.PreTaxAmountToAllocate.IsNegative() { + errs = append(errs, fmt.Errorf("pre tax amount to allocate cannot be negative")) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +type OnInvoiceUsageAccruedInput struct { + Charge Charge `json:"charge"` + ServicePeriod timeutil.ClosedPeriod `json:"servicePeriod"` + BookedAt time.Time `json:"bookedAt"` + Totals totals.Totals `json:"totals"` +} + +func (i OnInvoiceUsageAccruedInput) Validate() error { + var errs []error + + if err := i.Charge.Validate(); err != nil { + errs = append(errs, fmt.Errorf("charge: %w", err)) + } + + if err := i.ServicePeriod.Validate(); err != nil { + errs = append(errs, fmt.Errorf("service period: %w", err)) + } + + if i.BookedAt.IsZero() { + errs = append(errs, fmt.Errorf("booked at is required")) + } + + if err := i.Totals.Validate(); err != nil { + errs = append(errs, fmt.Errorf("totals: %w", err)) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +type CorrectCreditAllocationsInput struct { + Charge Charge `json:"charge"` + BookedAt time.Time `json:"bookedAt"` + + Corrections creditrealization.CorrectionRequest `json:"corrections"` + LineageSegmentsByRealization lineage.ActiveSegmentsByRealizationID `json:"-"` +} + +func (i CorrectCreditAllocationsInput) Validate() error { + var errs []error + + if err := i.Charge.Validate(); err != nil { + errs = append(errs, fmt.Errorf("charge: %w", err)) + } + + if i.BookedAt.IsZero() { + errs = append(errs, fmt.Errorf("booked at is required")) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +func (i CorrectCreditAllocationsInput) ValidateWith(currencyCalculator currencyx.Currency) error { + var errs []error + + if err := i.Validate(); err != nil { + return err + } + + if err := i.Corrections.ValidateWith(currencyCalculator); err != nil { + errs = append(errs, fmt.Errorf("corrections: %w", err)) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +type PaymentEventInput struct { + Charge Charge `json:"charge"` + EventAt time.Time `json:"eventAt"` + Amount alpacadecimal.Decimal `json:"amount"` +} + +func (i PaymentEventInput) Validate() error { + var errs []error + + if err := i.Charge.Validate(); err != nil { + errs = append(errs, fmt.Errorf("charge: %w", err)) + } + + if i.EventAt.IsZero() { + errs = append(errs, fmt.Errorf("event at is required")) + } + + if i.Amount.IsNegative() { + errs = append(errs, fmt.Errorf("amount cannot be negative")) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +type ( + OnPaymentAuthorizedInput = PaymentEventInput + OnPaymentSettledInput = PaymentEventInput +) + +type Handler interface { + // OnAllocateCredits is called when a flat fee allocates credits. + OnAllocateCredits(ctx context.Context, input OnAllocateCreditsInput) (creditrealization.CreateAllocationInputs, error) + + // OnFlatFeeStandardInvoiceUsageAccrued is called when the remaining usage is sent to the customer on a standard invoice. + OnInvoiceUsageAccrued(ctx context.Context, input OnInvoiceUsageAccruedInput) (ledgertransaction.GroupReference, error) + + // OnCorrectCreditAllocations is called when a credit allocation needs to be corrected. + OnCorrectCreditAllocations(ctx context.Context, input CorrectCreditAllocationsInput) (creditrealization.CreateCorrectionInputs, error) + + // OnFlatFeePaymentAuthorized is called when a flat fee payment is authorized. + OnPaymentAuthorized(ctx context.Context, input OnPaymentAuthorizedInput) (ledgertransaction.GroupReference, error) + + // OnFlatFeePaymentSettled is called when a flat fee payment is settled. + OnPaymentSettled(ctx context.Context, input OnPaymentSettledInput) (ledgertransaction.GroupReference, error) + + // OnFlatFeePaymentUncollectible is called when a flat fee payment is uncollectible + OnPaymentUncollectible(ctx context.Context, charge Charge) (ledgertransaction.GroupReference, error) +} diff --git a/billing/charges/flatfee/prorating.go b/billing/charges/flatfee/prorating.go new file mode 100644 index 0000000000000000000000000000000000000000..d782005e91c41a8ee0cb2f6ea3014ff1eaca1492 --- /dev/null +++ b/billing/charges/flatfee/prorating.go @@ -0,0 +1,17 @@ +package flatfee + +import "github.com/openmeterio/openmeter/openmeter/productcatalog" + +type ProRatingModeAdapterEnum string + +const ( + ProratePricesProratingAdapterMode ProRatingModeAdapterEnum = ProRatingModeAdapterEnum(productcatalog.ProRatingModeProratePrices) + NoProratingAdapterMode ProRatingModeAdapterEnum = "no_prorate" +) + +func (e ProRatingModeAdapterEnum) Values() []string { + return []string{ + string(ProratePricesProratingAdapterMode), + string(NoProratingAdapterMode), + } +} diff --git a/billing/charges/flatfee/realizationrun.go b/billing/charges/flatfee/realizationrun.go new file mode 100644 index 0000000000000000000000000000000000000000..c4dec66d2f145842968c42098323272de1620b69 --- /dev/null +++ b/billing/charges/flatfee/realizationrun.go @@ -0,0 +1,271 @@ +package flatfee + +import ( + "errors" + "fmt" + "slices" + "time" + + "github.com/alpacahq/alpacadecimal" + "github.com/samber/mo" + + "github.com/openmeterio/openmeter/openmeter/billing/charges/meta" + "github.com/openmeterio/openmeter/openmeter/billing/charges/models/creditrealization" + "github.com/openmeterio/openmeter/openmeter/billing/charges/models/invoicedusage" + "github.com/openmeterio/openmeter/openmeter/billing/charges/models/payment" + "github.com/openmeterio/openmeter/openmeter/billing/models/totals" + "github.com/openmeterio/openmeter/pkg/models" + "github.com/openmeterio/openmeter/pkg/timeutil" +) + +type RealizationRunType string + +const ( + RealizationRunTypeFinalRealization RealizationRunType = "final_realization" + RealizationRunTypeInvalidDueToUnsupportedCreditNote RealizationRunType = "invalid_due_to_unsupported_credit_note" +) + +func (t RealizationRunType) Values() []string { + return []string{ + string(RealizationRunTypeFinalRealization), + string(RealizationRunTypeInvalidDueToUnsupportedCreditNote), + } +} + +func (t RealizationRunType) Validate() error { + if !slices.Contains(t.Values(), string(t)) { + return models.NewGenericValidationError(fmt.Errorf("invalid realization run type: %s", t)) + } + + return nil +} + +func (t RealizationRunType) IsVoidedBillingHistory() bool { + return t == RealizationRunTypeInvalidDueToUnsupportedCreditNote +} + +type RealizationRunID models.NamespacedID + +func (i RealizationRunID) Validate() error { + return models.NamespacedID(i).Validate() +} + +type UpdateRealizationRunInput struct { + ID RealizationRunID + + Type mo.Option[RealizationRunType] `json:"type"` + DeletedAt mo.Option[*time.Time] `json:"deletedAt,omitempty"` + LineID mo.Option[*string] `json:"lineId,omitempty"` + InvoiceID mo.Option[*string] `json:"invoiceId,omitempty"` + ServicePeriod mo.Option[timeutil.ClosedPeriod] `json:"servicePeriod"` + AmountAfterProration mo.Option[alpacadecimal.Decimal] `json:"amountAfterProration"` + Totals mo.Option[totals.Totals] `json:"totals"` + NoFiatTransactionRequired mo.Option[bool] `json:"noFiatTransactionRequired"` + Immutable mo.Option[bool] `json:"immutable"` +} + +func (r UpdateRealizationRunInput) Normalized() UpdateRealizationRunInput { + if r.ServicePeriod.IsPresent() { + r.ServicePeriod = mo.Some(meta.NormalizeClosedPeriod(r.ServicePeriod.OrEmpty())) + } + + return r +} + +func (r UpdateRealizationRunInput) Validate() error { + var errs []error + + if err := r.ID.Validate(); err != nil { + errs = append(errs, fmt.Errorf("namespaced id: %w", err)) + } + + if r.Type.IsPresent() { + if err := r.Type.OrEmpty().Validate(); err != nil { + errs = append(errs, fmt.Errorf("type: %w", err)) + } + } + + if r.DeletedAt.IsPresent() { + deletedAt := r.DeletedAt.OrEmpty() + if deletedAt != nil && deletedAt.IsZero() { + errs = append(errs, fmt.Errorf("deleted at must be non-zero when set")) + } + } + + if r.LineID.IsPresent() { + lineID := r.LineID.OrEmpty() + if lineID != nil && *lineID == "" { + errs = append(errs, fmt.Errorf("line id must be non-empty")) + } + } + + if r.InvoiceID.IsPresent() { + invoiceID := r.InvoiceID.OrEmpty() + if invoiceID != nil && *invoiceID == "" { + errs = append(errs, fmt.Errorf("invoice id must be non-empty")) + } + } + + if r.ServicePeriod.IsPresent() { + if err := r.ServicePeriod.OrEmpty().Validate(); err != nil { + errs = append(errs, fmt.Errorf("service period: %w", err)) + } + } + + if r.AmountAfterProration.IsPresent() && r.AmountAfterProration.OrEmpty().IsNegative() { + errs = append(errs, fmt.Errorf("amount after proration must be zero or positive")) + } + + if r.Totals.IsPresent() { + if err := r.Totals.OrEmpty().Validate(); err != nil { + errs = append(errs, fmt.Errorf("totals: %w", err)) + } + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +type RealizationRunBase struct { + ID RealizationRunID `json:"id"` + models.ManagedModel + + LineID *string `json:"lineId,omitempty"` + InvoiceID *string `json:"invoiceId,omitempty"` + + Type RealizationRunType `json:"type"` + InitialType RealizationRunType `json:"initialType"` + + ServicePeriod timeutil.ClosedPeriod `json:"servicePeriod"` + AmountAfterProration alpacadecimal.Decimal `json:"amountAfterProration"` + Totals totals.Totals `json:"totals"` + NoFiatTransactionRequired bool `json:"noFiatTransactionRequired"` + // Immutable means the backing invoice line can no longer be updated in place. + // When true, deleting this run requires issuing a credit note instead of mutating the invoice line. + Immutable bool `json:"immutable"` +} + +func (r RealizationRunBase) Normalized() RealizationRunBase { + r.ServicePeriod = meta.NormalizeClosedPeriod(r.ServicePeriod) + + return r +} + +func (r RealizationRunBase) Validate() error { + var errs []error + + if err := r.ID.Validate(); err != nil { + errs = append(errs, fmt.Errorf("namespaced id: %w", err)) + } + + if err := r.ManagedModel.Validate(); err != nil { + errs = append(errs, fmt.Errorf("managed model: %w", err)) + } + + if r.LineID != nil && *r.LineID == "" { + errs = append(errs, fmt.Errorf("line id must be non-empty")) + } + + if r.InvoiceID != nil && *r.InvoiceID == "" { + errs = append(errs, fmt.Errorf("invoice id must be non-empty")) + } + + if err := r.Type.Validate(); err != nil { + errs = append(errs, fmt.Errorf("type: %w", err)) + } + + if err := r.InitialType.Validate(); err != nil { + errs = append(errs, fmt.Errorf("initial type: %w", err)) + } + + if r.InitialType == RealizationRunTypeInvalidDueToUnsupportedCreditNote { + errs = append(errs, fmt.Errorf("initial type cannot be %s", RealizationRunTypeInvalidDueToUnsupportedCreditNote)) + } + + if r.ServicePeriod.From.IsZero() { + errs = append(errs, fmt.Errorf("service period from must be set")) + } + + if r.ServicePeriod.To.IsZero() { + errs = append(errs, fmt.Errorf("service period to must be set")) + } + + if r.ServicePeriod.To.Before(r.ServicePeriod.From) { + errs = append(errs, fmt.Errorf("service period to must be after service period from")) + } + + if r.AmountAfterProration.IsNegative() { + errs = append(errs, fmt.Errorf("amount after proration must be zero or positive")) + } + + if err := r.Totals.Validate(); err != nil { + errs = append(errs, fmt.Errorf("totals: %w", err)) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +type RealizationRun struct { + RealizationRunBase + + CreditRealizations creditrealization.Realizations `json:"creditRealizations"` + AccruedUsage *invoicedusage.AccruedUsage `json:"accruedUsage"` + Payment *payment.Invoiced `json:"payment"` + DetailedLines mo.Option[DetailedLines] `json:"detailedLines,omitzero"` +} + +func (r RealizationRun) Validate() error { + var errs []error + + if err := r.RealizationRunBase.Validate(); err != nil { + errs = append(errs, fmt.Errorf("realization run: %w", err)) + } + + if err := r.CreditRealizations.Validate(); err != nil { + errs = append(errs, fmt.Errorf("credit realizations: %w", err)) + } + + if r.AccruedUsage != nil { + if err := r.AccruedUsage.Validate(); err != nil { + errs = append(errs, fmt.Errorf("accrued usage: %w", err)) + } + } + + if r.Payment != nil { + if err := r.Payment.Validate(); err != nil { + errs = append(errs, fmt.Errorf("payment: %w", err)) + } + } + + if r.DetailedLines.IsPresent() { + if err := r.DetailedLines.OrEmpty().Validate(); err != nil { + errs = append(errs, fmt.Errorf("detailed lines: %w", err)) + } + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +// IsVoidedBillingHistory reports whether this run must be ignored as billing +// history. Deleted runs were already cleaned up through billing; unsupported +// credit-note runs are retained for audit even though the invoice line should +// have been removed once prorating/credit-note support exists. +func (r RealizationRun) IsVoidedBillingHistory() bool { + if r.Type.IsVoidedBillingHistory() { + return true + } + + return r.DeletedAt != nil +} + +type RealizationRuns []RealizationRun + +func (r RealizationRuns) Validate() error { + var errs []error + for idx, run := range r { + if err := run.Validate(); err != nil { + errs = append(errs, fmt.Errorf("realization run[%d]: %w", idx, err)) + } + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} diff --git a/billing/charges/flatfee/service.go b/billing/charges/flatfee/service.go new file mode 100644 index 0000000000000000000000000000000000000000..d184cfd7f8d0b3d95b052198b70663b1e876bd44 --- /dev/null +++ b/billing/charges/flatfee/service.go @@ -0,0 +1,97 @@ +package flatfee + +import ( + "context" + "errors" + "fmt" + + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/billing/charges/meta" + "github.com/openmeterio/openmeter/openmeter/productcatalog/feature" + "github.com/openmeterio/openmeter/pkg/models" +) + +type Service interface { + FlatFeeService + GetLineEngine() billing.LineEngine +} + +type FlatFeeService interface { + // Create returns one result for each input intent, preserving input order. + // Invoice-line create flows rely on this to merge charge target state back + // onto the matching billing-preallocated line identity. + Create(ctx context.Context, input CreateInput) ([]ChargeWithGatheringLine, error) + // GetByIDs loads flat-fee charges. Request realization expansions when the + // caller needs invoice-line, credit-allocation, or payment lifecycle state. + GetByIDs(ctx context.Context, input GetByIDsInput) ([]Charge, error) + // GetByID loads one flat-fee charge. Effective behavior may come from an + // override layer, while subscription sync should compare the base intent. + GetByID(ctx context.Context, input GetByIDInput) (Charge, error) + // UpdateSubscriptionItemID repairs subscription ownership metadata on the + // base intent; it must not rewrite an active customer-facing override layer. + UpdateSubscriptionItemID(ctx context.Context, charge Charge, newSubscriptionItemID string) (Charge, error) + // AdvanceCharge drives one charge through its lifecycle. Invoice-backed + // changes are emitted as invoice patches for the billing boundary to consume. + AdvanceCharge(ctx context.Context, input AdvanceChargeInput) (*Charge, error) + // TriggerPatch applies an explicit base/override target patch and then + // reconciles invoice artifacts from the effective flat-fee intent. + TriggerPatch(ctx context.Context, charge meta.ChargeID, patch meta.Patch) (meta.TriggerPatchResult[Charge], error) +} + +type CreateInput struct { + Namespace string + Intents []Intent + FeatureMeters feature.FeatureMeters +} + +func (i CreateInput) Validate() error { + var errs []error + if i.Namespace == "" { + errs = append(errs, errors.New("namespace is required")) + } + + for idx, intent := range i.Intents { + if err := intent.Validate(); err != nil { + errs = append(errs, fmt.Errorf("intent [%d]: %w", idx, err)) + } + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +type ChargeWithGatheringLine struct { + Charge Charge + GatheringLineToCreate *billing.GatheringLine +} + +type GetByMetasInput struct { + Namespace string + Expands meta.Expands + Charges meta.Charges +} + +func (i GetByMetasInput) Validate() error { + var errs []error + + if i.Namespace == "" { + errs = append(errs, errors.New("namespace is required")) + } + + if err := i.Charges.Validate(); err != nil { + errs = append(errs, fmt.Errorf("charges: %w", err)) + } + + if err := i.Expands.Validate(); err != nil { + errs = append(errs, fmt.Errorf("expands: %w", err)) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +type AdvanceChargeInput struct { + ChargeID meta.ChargeID +} + +func (i AdvanceChargeInput) Validate() error { + return i.ChargeID.Validate() +} diff --git a/billing/charges/flatfee/service/create.go b/billing/charges/flatfee/service/create.go new file mode 100644 index 0000000000000000000000000000000000000000..37101e865d0058d7e45b3a0d6392d829734df540 --- /dev/null +++ b/billing/charges/flatfee/service/create.go @@ -0,0 +1,215 @@ +package service + +import ( + "context" + "fmt" + "time" + + "github.com/samber/lo" + + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/billing/charges/flatfee" + "github.com/openmeterio/openmeter/openmeter/billing/charges/meta" + "github.com/openmeterio/openmeter/openmeter/productcatalog" + "github.com/openmeterio/openmeter/pkg/framework/transaction" + "github.com/openmeterio/openmeter/pkg/models" + "github.com/openmeterio/openmeter/pkg/slicesx" + "github.com/openmeterio/openmeter/pkg/timeutil" +) + +func (s *service) Create(ctx context.Context, input flatfee.CreateInput) ([]flatfee.ChargeWithGatheringLine, error) { + if err := input.Validate(); err != nil { + return nil, err + } + + if len(input.Intents) == 0 { + return nil, nil + } + + return transaction.Run(ctx, s.adapter, func(ctx context.Context) ([]flatfee.ChargeWithGatheringLine, error) { + // Let's create all the flat fee charges in bulk + intentsWithStatus, err := slicesx.MapWithErr(input.Intents, func(intent flatfee.Intent) (flatfee.IntentWithInitialStatus, error) { + chargeIntent := intent.Normalized() + + amountAfterProration, err := chargeIntent.CalculateAmountAfterProration() + if err != nil { + return flatfee.IntentWithInitialStatus{}, fmt.Errorf("calculating amount after proration: %w", err) + } + + var featureID *string + if chargeIntent.FeatureKey != nil && *chargeIntent.FeatureKey != "" { + featureMeter, err := input.FeatureMeters.Get(*chargeIntent.FeatureKey, false) + if err != nil { + return flatfee.IntentWithInitialStatus{}, fmt.Errorf("resolve flat fee feature for key %s: %w", *chargeIntent.FeatureKey, err) + } + featureID = lo.ToPtr(featureMeter.Feature.ID) + } + + return flatfee.IntentWithInitialStatus{ + Intent: chargeIntent, + FeatureID: featureID, + InitialStatus: flatfee.StatusCreated, + InitialAdvanceAfter: lo.ToPtr(meta.NormalizeTimestamp(chargeIntent.InvoiceAt)), + AmountAfterProration: amountAfterProration, + NoFiatTransactionRequired: chargeIntent.SettlementMode == productcatalog.CreditOnlySettlementMode || amountAfterProration.IsZero(), + }, nil + }) + if err != nil { + return nil, err + } + + charges, err := s.adapter.CreateCharges(ctx, flatfee.CreateChargesInput{ + Namespace: input.Namespace, + Intents: intentsWithStatus, + }) + if err != nil { + return nil, err + } + + // Preserve the input-intent order when returning charge results. Billing + // API-created line handling pairs each returned charge target with the + // preallocated source line at the same index. + return slicesx.MapWithErr(charges, func(charge flatfee.Charge) (flatfee.ChargeWithGatheringLine, error) { + // For credit only flat fees we are not relying on the invoicing stack at all, so we can return early. + if charge.Intent.GetSettlementMode() == productcatalog.CreditOnlySettlementMode { + return flatfee.ChargeWithGatheringLine{ + Charge: charge, + }, nil + } + + // Zero-amount flat-fee charges are tracked as charges, but they + // must not materialize billable invoice lines. + if charge.State.AmountAfterProration.IsZero() { + return flatfee.ChargeWithGatheringLine{ + Charge: charge, + }, nil + } + + gatheringLine, err := buildFlatFeeGatheringLine(buildFlatFeeGatheringLineInput{ + Charge: charge, + ServicePeriod: charge.Intent.GetEffectiveServicePeriod(), + InvoiceAt: charge.Intent.GetEffectiveInvoiceAt(), + }) + if err != nil { + return flatfee.ChargeWithGatheringLine{}, err + } + + return flatfee.ChargeWithGatheringLine{ + Charge: charge, + GatheringLineToCreate: &gatheringLine, + }, nil + }) + }) +} + +type buildFlatFeeGatheringLineInput struct { + Charge flatfee.Charge + ServicePeriod timeutil.ClosedPeriod + InvoiceAt time.Time +} + +func (i buildFlatFeeGatheringLineInput) Validate() error { + if err := i.Charge.Validate(); err != nil { + return fmt.Errorf("charge: %w", err) + } + + if err := i.ServicePeriod.Validate(); err != nil { + return fmt.Errorf("service period: %w", err) + } + + if i.InvoiceAt.IsZero() { + return fmt.Errorf("invoice at is required") + } + + if i.Charge.Intent.GetSettlementMode() != productcatalog.CreditThenInvoiceSettlementMode { + return fmt.Errorf("charge %s is not credit_then_invoice", i.Charge.ID) + } + + return nil +} + +func buildFlatFeeGatheringLine(input buildFlatFeeGatheringLineInput) (billing.GatheringLine, error) { + if err := input.Validate(); err != nil { + return billing.GatheringLine{}, err + } + + flatFee := input.Charge + lineIntent := flatFee.Intent.GetEffectiveIntent() + lineIntent.ServicePeriod = input.ServicePeriod + lineIntent.InvoiceAt = input.InvoiceAt + lineIntent = lineIntent.Normalized() + + if err := lineIntent.Validate(); err != nil { + return billing.GatheringLine{}, fmt.Errorf("validating line intent: %w", err) + } + + amountAfterProration, err := lineIntent.CalculateAmountAfterProration() + if err != nil { + return billing.GatheringLine{}, fmt.Errorf("calculating amount after proration: %w", err) + } + + var subscription *billing.SubscriptionReference + if lineIntent.Subscription != nil { + subscription = &billing.SubscriptionReference{ + SubscriptionID: lineIntent.Subscription.SubscriptionID, + PhaseID: lineIntent.Subscription.PhaseID, + ItemID: lineIntent.Subscription.ItemID, + BillingPeriod: timeutil.ClosedPeriod{ + From: lineIntent.BillingPeriod.From, + To: lineIntent.BillingPeriod.To, + }, + } + } + + clonedAnnotations, err := lineIntent.Annotations.Clone() + if err != nil { + return billing.GatheringLine{}, fmt.Errorf("cloning annotations: %w", err) + } + + managedBy := lineIntent.ManagedBy + if flatFee.Intent.HasOverrideLayer() { + managedBy = billing.ManuallyManagedLine + } + + gatheringLine := billing.GatheringLine{ + GatheringLineBase: billing.GatheringLineBase{ + ManagedResource: models.NewManagedResource(models.ManagedResourceInput{ + Namespace: flatFee.Namespace, + Name: lineIntent.Name, + Description: lineIntent.Description, + }), + + Metadata: lineIntent.Metadata.Clone(), + Annotations: clonedAnnotations, + ManagedBy: managedBy, + + Price: lo.FromPtr( + productcatalog.NewPriceFrom( + productcatalog.FlatPrice{ + Amount: amountAfterProration, + PaymentTerm: lineIntent.PaymentTerm, + }, + ), + ), + FeatureKey: lo.FromPtr(lineIntent.FeatureKey), + + Currency: lineIntent.Currency, + ServicePeriod: lineIntent.ServicePeriod, + InvoiceAt: lineIntent.InvoiceAt, + + TaxConfig: lo.ToPtr(lineIntent.TaxConfig.ToTaxConfig()), + + Engine: billing.LineEngineTypeChargeFlatFee, + ChargeID: lo.ToPtr(flatFee.ID), + Subscription: subscription, + }, + } + + if lineIntent.PercentageDiscounts != nil { + gatheringLine.RateCardDiscounts = billing.Discounts{ + Percentage: lineIntent.PercentageDiscounts.CloneOrNil(), + } + } + + return gatheringLine, nil +} diff --git a/billing/charges/flatfee/service/creditheninvoice.go b/billing/charges/flatfee/service/creditheninvoice.go new file mode 100644 index 0000000000000000000000000000000000000000..f97e6d51084fb30ab616966b8f649c33076b7b5b --- /dev/null +++ b/billing/charges/flatfee/service/creditheninvoice.go @@ -0,0 +1,701 @@ +package service + +import ( + "context" + "fmt" + "time" + + "github.com/alpacahq/alpacadecimal" + "github.com/samber/lo" + + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/billing/charges/flatfee" + flatfeerealizations "github.com/openmeterio/openmeter/openmeter/billing/charges/flatfee/service/realizations" + "github.com/openmeterio/openmeter/openmeter/billing/charges/invoiceupdater" + "github.com/openmeterio/openmeter/openmeter/billing/charges/meta" + "github.com/openmeterio/openmeter/openmeter/billing/charges/models/payment" + "github.com/openmeterio/openmeter/openmeter/productcatalog" + "github.com/openmeterio/openmeter/pkg/clock" + "github.com/openmeterio/openmeter/pkg/models" + "github.com/openmeterio/openmeter/pkg/statelessx" + "github.com/openmeterio/openmeter/pkg/timeutil" +) + +type CreditThenInvoiceStateMachine struct { + *stateMachine +} + +type periodPatch interface { + Op() meta.PatchType + GetTargetLayer(meta.LayeredIntentReader) (meta.ChangeTarget, error) + GetNewServicePeriodTo() time.Time + GetNewFullServicePeriodTo() time.Time + GetNewBillingPeriodTo() time.Time + GetNewInvoiceAt() time.Time + ValidateWith(meta.IntentMutableFields) error +} + +var ( + _ periodPatch = meta.PatchExtend{} + _ periodPatch = meta.PatchShrink{} +) + +func NewCreditThenInvoiceStateMachine(config StateMachineConfig) (*CreditThenInvoiceStateMachine, error) { + if err := config.Validate(); err != nil { + return nil, fmt.Errorf("validate: %w", err) + } + + if config.Charge.Intent.GetSettlementMode() != productcatalog.CreditThenInvoiceSettlementMode { + return nil, fmt.Errorf("charge %s is not credit_then_invoice", config.Charge.ID) + } + + stateMachine, err := newStateMachineBase(config) + if err != nil { + return nil, fmt.Errorf("new state machine: %w", err) + } + + out := &CreditThenInvoiceStateMachine{ + stateMachine: stateMachine, + } + out.configureStates() + + return out, nil +} + +func (s *CreditThenInvoiceStateMachine) configureStates() { + s.Configure(flatfee.StatusCreated). + // Zero-amount CTI flat fees intentionally skip the billing line + // engine. Once invoice_at is reached there will be no gathering + // line to produce TriggerInvoiceCreated, so the charge closes + // directly from created. + Permit( + meta.TriggerNext, + flatfee.StatusFinal, + statelessx.BoolFn(s.IsAfterInvoiceAtAndZeroAmount), + ). + // Non-zero CTI flat fees become invoiceable at invoice_at. The line + // engine creates the realization run from the standard invoice line, + // which can happen before the service period starts for in-advance + // flat fees. + Permit( + meta.TriggerNext, + flatfee.StatusActive, + statelessx.BoolFn(s.IsAfterInvoiceAtAndNonZeroAmount), + ). + InternalTransition(meta.TriggerDelete, statelessx.WithParameters(s.DeleteCharge)). + InternalTransition(meta.TriggerExtend, statelessx.WithParameters(s.ExtendCharge)). + InternalTransition(meta.TriggerShrink, statelessx.WithParameters(s.ShrinkCharge)). + InternalTransition(meta.TriggerLineManualEdit, statelessx.WithParameters(s.LineManualEdit)). + Permit(meta.TriggerAttachInvoiceLine, flatfee.StatusActiveRealizationProcessing). + OnActive(s.AdvanceAfterInvoiceAt) + + s.Configure(flatfee.StatusActive). + // This also repairs previously active zero-amount charges. They have + // no line-engine path left, so active must not become their terminal + // operational state. + Permit(meta.TriggerNext, flatfee.StatusFinal, statelessx.BoolFn(s.IsZeroAmount)). + Permit(meta.TriggerInvoiceCreated, flatfee.StatusActiveRealizationStarted). + InternalTransition(meta.TriggerDelete, statelessx.WithParameters(s.DeleteCharge)). + InternalTransition(meta.TriggerExtend, statelessx.WithParameters(s.ExtendCharge)). + InternalTransition(meta.TriggerShrink, statelessx.WithParameters(s.ShrinkCharge)). + InternalTransition(meta.TriggerLineManualEdit, statelessx.WithParameters(s.LineManualEdit)). + OnActive(s.AdvanceAfterServicePeriodTo) + + s.Configure(flatfee.StatusActiveRealizationStarted). + Permit(meta.TriggerNext, flatfee.StatusActiveRealizationWaitingForCollection). + InternalTransition(meta.TriggerDelete, statelessx.WithParameters(s.DeleteCharge)). + InternalTransition(meta.TriggerExtend, statelessx.WithParameters(s.ExtendCharge)). + InternalTransition(meta.TriggerShrink, statelessx.WithParameters(s.ShrinkCharge)). + InternalTransition(meta.TriggerLineManualEdit, statelessx.WithParameters(s.LineManualEdit)). + OnEntryFrom(meta.TriggerInvoiceCreated, statelessx.WithParameters(s.StartRealization)) + + s.Configure(flatfee.StatusActiveRealizationWaitingForCollection). + Permit(meta.TriggerCollectionCompleted, flatfee.StatusActiveRealizationProcessing). + InternalTransition(meta.TriggerDelete, statelessx.WithParameters(s.DeleteCharge)). + InternalTransition(meta.TriggerExtend, statelessx.WithParameters(s.ExtendCharge)). + InternalTransition(meta.TriggerShrink, statelessx.WithParameters(s.ShrinkCharge)). + InternalTransition(meta.TriggerLineManualEdit, statelessx.WithParameters(s.LineManualEdit)) + + s.Configure(flatfee.StatusActiveRealizationProcessing). + Permit(meta.TriggerInvoiceIssued, flatfee.StatusActiveRealizationIssuing). + InternalTransition(meta.TriggerDelete, statelessx.WithParameters(s.DeleteCharge)). + InternalTransition(meta.TriggerExtend, statelessx.WithParameters(s.ExtendCharge)). + InternalTransition(meta.TriggerShrink, statelessx.WithParameters(s.ShrinkCharge)). + InternalTransition(meta.TriggerLineManualEdit, statelessx.WithParameters(s.LineManualEdit)). + OnEntryFrom(meta.TriggerAttachInvoiceLine, statelessx.WithParameters(s.AttachInvoiceLine)) + + s.Configure(flatfee.StatusActiveRealizationIssuing). + Permit(meta.TriggerNext, flatfee.StatusActiveRealizationCompleted). + InternalTransition(meta.TriggerDelete, statelessx.WithParameters(s.DeleteCharge)). + InternalTransition(meta.TriggerExtend, statelessx.WithParameters(s.UnsupportedExtendOperation)). + InternalTransition(meta.TriggerShrink, statelessx.WithParameters(s.UnsupportedShrinkOperation)). + InternalTransition(meta.TriggerLineManualEdit, statelessx.WithParameters(s.UnsupportedLineManualEditOperation)). + OnEntryFrom(meta.TriggerInvoiceIssued, statelessx.WithParameters(s.AccrueInvoiceUsage)) + + s.Configure(flatfee.StatusActiveRealizationCompleted). + Permit(meta.TriggerNext, flatfee.StatusActiveAwaitingPaymentSettlement). + InternalTransition(meta.TriggerDelete, statelessx.WithParameters(s.DeleteCharge)). + InternalTransition(meta.TriggerExtend, statelessx.WithParameters(s.UnsupportedExtendOperation)). + InternalTransition(meta.TriggerShrink, statelessx.WithParameters(s.UnsupportedShrinkOperation)). + InternalTransition(meta.TriggerLineManualEdit, statelessx.WithParameters(s.UnsupportedLineManualEditOperation)) + + s.Configure(flatfee.StatusActiveAwaitingPaymentSettlement). + Permit(meta.TriggerNext, flatfee.StatusFinal, statelessx.BoolFn(s.AreAllPaymentsSettled)). + InternalTransition(meta.TriggerDelete, statelessx.WithParameters(s.DeleteCharge)). + InternalTransition(meta.TriggerExtend, statelessx.WithParameters(s.ExtendCharge)). + InternalTransition(meta.TriggerShrink, statelessx.WithParameters(s.ShrinkCharge)). + InternalTransition(meta.TriggerLineManualEdit, statelessx.WithParameters(s.LineManualEdit)) + + s.Configure(flatfee.StatusFinal). + InternalTransition(meta.TriggerDelete, statelessx.WithParameters(s.DeleteCharge)). + InternalTransition(meta.TriggerExtend, statelessx.WithParameters(s.ExtendCharge)). + InternalTransition(meta.TriggerShrink, statelessx.WithParameters(s.ShrinkCharge)). + InternalTransition(meta.TriggerLineManualEdit, statelessx.WithParameters(s.LineManualEdit)). + OnActive(s.ClearAdvanceAfter) + + s.Configure(flatfee.StatusDeleted). + InternalTransition(meta.TriggerExtend, statelessx.WithParameters(s.UnsupportedExtendOperation)). + InternalTransition(meta.TriggerShrink, statelessx.WithParameters(s.UnsupportedShrinkOperation)). + InternalTransition(meta.TriggerLineManualEdit, statelessx.WithParameters(s.UnsupportedLineManualEditOperation)) +} + +func (s *CreditThenInvoiceStateMachine) DeleteCharge(ctx context.Context, patch meta.PatchDelete) error { + deletedAt := lo.ToPtr(clock.Now()) + target, err := patch.GetTargetLayer(s.Charge.Intent) + if err != nil { + return fmt.Errorf("getting patch target layer: %w", err) + } + if err := s.rejectHiddenIntentTarget(target); err != nil { + return err + } + + if err := s.mutateIntentLayer(ctx, target, func(fields *flatfee.IntentMutableFields) { + fields.IntentDeletedAt = deletedAt + }); err != nil { + return fmt.Errorf("deleting intent: %w", err) + } + + s.Charge.Status = flatfee.StatusDeleted + + patches := invoiceupdater.Patches{ + invoiceupdater.NewDeleteGatheringLineByChargeIDPatch(s.Charge.ID), + } + currentRun := s.Charge.Realizations.CurrentRun + if currentRun != nil && currentRun.LineID != nil && currentRun.InvoiceID != nil { + patches = append(patches, invoiceupdater.NewDeleteLinePatch( + billing.LineID{ + Namespace: s.Charge.Namespace, + ID: *currentRun.LineID, + }, + *currentRun.InvoiceID, + )) + + if err := s.Adapter.DetachCurrentRun(ctx, s.Charge.GetChargeID()); err != nil { + return fmt.Errorf("detach current run before deleting charge: %w", err) + } + + s.Charge.Realizations.PriorRuns = append(s.Charge.Realizations.PriorRuns, *currentRun) + s.Charge.Realizations.CurrentRun = nil + } + + s.AddInvoicePatch(patches...) + + if err := s.Adapter.DeleteCharge(ctx, s.Charge); err != nil { + return fmt.Errorf("delete charge: %w", err) + } + + if err := s.RefetchCharge(ctx); err != nil { + return fmt.Errorf("get charge: %w", err) + } + + return nil +} + +func (s *CreditThenInvoiceStateMachine) ExtendCharge(ctx context.Context, patch meta.PatchExtend) error { + invoicingStateInput, err := s.applyPeriodPatch(patch) + if err != nil { + return err + } + + return s.reconcileInvoicingState(ctx, invoicingStateInput) +} + +func (s *CreditThenInvoiceStateMachine) ShrinkCharge(ctx context.Context, patch meta.PatchShrink) error { + invoicingStateInput, err := s.applyPeriodPatch(patch) + if err != nil { + return err + } + + return s.reconcileInvoicingState(ctx, invoicingStateInput) +} + +func (s *CreditThenInvoiceStateMachine) LineManualEdit(ctx context.Context, patch meta.PatchLineManualEdit) error { + target, err := patch.GetTargetLayer(s.Charge.Intent) + if err != nil { + return fmt.Errorf("getting patch target layer: %w", err) + } + if err := s.rejectHiddenIntentTarget(target); err != nil { + return err + } + + override := patch.GetOverride() + if err := meta.ValidateInvoiceLineOverrideDoesNotChangeImmutableChargeIntentFields(override); err != nil { + return err + } + + editedLine, err := override.ChangesToApply.Apply(override.ExistingLine) + if err != nil { + return fmt.Errorf("applying line manual edit: %w", err) + } + + lineType := editedLine.AsInvoiceLine().Type() + if chargeID := editedLine.GetChargeID(); chargeID == nil || *chargeID != s.Charge.ID { + return fmt.Errorf("line[%s]: charge id must match flat-fee charge[%s]", editedLine.GetID(), s.Charge.ID) + } + + switch lineType { + case billing.InvoiceLineTypeGathering: + if s.Charge.Realizations.CurrentRun != nil { + return fmt.Errorf("partially-realized charge [charge_id=%s,run_id=%s]: %w", + s.Charge.ID, + s.Charge.Realizations.CurrentRun.ID.ID, + billing.ErrCannotUpdateChargeManagedLine) + } + case billing.InvoiceLineTypeStandard: + currentRun := s.Charge.Realizations.CurrentRun + if currentRun == nil { + return fmt.Errorf("missing current run [charge_id=%s,line_id=%s]: %w", s.Charge.ID, editedLine.GetID(), billing.ErrCannotUpdateChargeManagedLine) + } + + if currentRun.Immutable { + return fmt.Errorf("immutable current run [charge_id=%s,run_id=%s]: %w", s.Charge.ID, currentRun.ID.ID, billing.ErrCannotUpdateChargeManagedLine) + } + + if currentRun.LineID == nil || *currentRun.LineID != editedLine.GetID() { + return fmt.Errorf("run line mismatch [charge_id=%s,run_id=%s,line_id=%s,run_line_id=%s]: %w", + s.Charge.ID, + currentRun.ID.ID, + editedLine.GetID(), + lo.FromPtr(currentRun.LineID), + billing.ErrCannotUpdateChargeManagedLine) + } + + if currentRun.InvoiceID == nil || *currentRun.InvoiceID != editedLine.GetInvoiceID() { + return fmt.Errorf("run invoice mismatch [charge_id=%s,run_id=%s,invoice_id=%s,run_invoice_id=%s]: %w", + s.Charge.ID, + currentRun.ID.ID, + editedLine.GetInvoiceID(), + lo.FromPtr(currentRun.InvoiceID), + billing.ErrCannotUpdateChargeManagedLine) + } + default: + return fmt.Errorf("unsupported line manual edit type [charge_id=%s,line_id=%s,line_type=%s]: %w", + s.Charge.ID, + editedLine.GetID(), + lineType, + billing.ErrCannotUpdateChargeManagedLine) + } + + overrideFields, err := s.intentMutableFieldsFromLineManualEdit(editedLine) + if err != nil { + return fmt.Errorf("building intent override: %w", err) + } + + oldAmountAfterProration := s.Charge.State.AmountAfterProration + + effectiveIntent := s.Charge.Intent.GetEffectiveIntent() + effectiveIntent.IntentMutableFields = overrideFields + amountAfterProration, err := effectiveIntent.CalculateAmountAfterProration() + if err != nil { + return fmt.Errorf("calculating amount after proration: %w", err) + } + + if amountAfterProration.IsZero() { + // TODO: support zero-proration manual line edits by modeling the API + // result as a line deletion/detach instead of an updated line. + // Until then, reject explicitly before persisting the override. + return billing.ErrInvoiceLineZeroAmountDeleteInstead + } + + if err := s.mutateIntentLayer(ctx, target, func(fields *flatfee.IntentMutableFields) { + *fields = overrideFields + }); err != nil { + return fmt.Errorf("setting line manual edit intent: %w", err) + } + + return s.reconcileInvoicingState(ctx, reconcileInvoicingStateInput{ + Op: meta.PatchTypeLineManualEdit, + Period: s.Charge.Intent.GetEffectiveServicePeriod(), + Intent: s.Charge.Intent, + OldAmountAfterProration: oldAmountAfterProration, + NewAmountAfterProration: amountAfterProration, + }) +} + +func (s *CreditThenInvoiceStateMachine) applyPeriodPatch(patch periodPatch) (reconcileInvoicingStateInput, error) { + target, err := patch.GetTargetLayer(s.Charge.Intent) + if err != nil { + return reconcileInvoicingStateInput{}, fmt.Errorf("getting patch target layer: %w", err) + } + if err := s.rejectHiddenIntentTarget(target); err != nil { + return reconcileInvoicingStateInput{}, err + } + + targetIntent, err := s.Charge.Intent.GetIntentForTarget(target) + if err != nil { + return reconcileInvoicingStateInput{}, fmt.Errorf("getting %s intent: %w", target, err) + } + + if err := patch.ValidateWith(targetIntent.IntentMutableFields.IntentMutableFields); err != nil { + return reconcileInvoicingStateInput{}, fmt.Errorf("validate %s patch: %w", patch.Op(), err) + } + intent := s.Charge.Intent + if err := intent.Mutate(target, func(fields *flatfee.IntentMutableFields) { + fields.ServicePeriod.To = patch.GetNewServicePeriodTo() + fields.FullServicePeriod.To = patch.GetNewFullServicePeriodTo() + fields.BillingPeriod.To = patch.GetNewBillingPeriodTo() + fields.InvoiceAt = patch.GetNewInvoiceAt() + }); err != nil { + return reconcileInvoicingStateInput{}, fmt.Errorf("mutating %s intent: %w", target, err) + } + + s.Charge.Intent = intent + + amountAfterProration, err := intent.CalculateAmountAfterProration() + if err != nil { + return reconcileInvoicingStateInput{}, fmt.Errorf("calculating amount after proration: %w", err) + } + + return reconcileInvoicingStateInput{ + Op: patch.Op(), + Period: intent.GetEffectiveServicePeriod(), + Intent: intent, + OldAmountAfterProration: s.Charge.State.AmountAfterProration, + NewAmountAfterProration: amountAfterProration, + }, nil +} + +func (s *CreditThenInvoiceStateMachine) UnsupportedExtendOperation(_ context.Context, _ meta.PatchExtend) error { + return models.NewGenericPreConditionFailedError( + fmt.Errorf("cannot extend flat-fee charge in status %s; retry after billing advances", s.Charge.Status), + ) +} + +func (s *CreditThenInvoiceStateMachine) UnsupportedShrinkOperation(_ context.Context, _ meta.PatchShrink) error { + return models.NewGenericPreConditionFailedError( + fmt.Errorf("cannot shrink flat-fee charge in status %s; retry after billing advances", s.Charge.Status), + ) +} + +// StartRealization creates the current run. The line engine maps the run back +// onto the returned standard line before billing persists line updates. +func (s *CreditThenInvoiceStateMachine) StartRealization(ctx context.Context, input billing.StandardLineWithInvoiceHeader) error { + if err := input.Validate(); err != nil { + return err + } + + result, err := s.Realizations.StartCreditThenInvoiceRun(ctx, flatfeerealizations.StartCreditThenInvoiceRunInput{ + Charge: s.Charge, + Line: *input.Line, + Invoice: input.Invoice, + }) + if err != nil { + return fmt.Errorf("start credit-then-invoice run: %w", err) + } + + s.Charge.Realizations.CurrentRun = &result.Run + + return nil +} + +// AttachInvoiceLine turns a manually created charge into an invoice-backed +// charge by attaching its first realization run to the billing-preallocated +// standard line identity. The emitted patch is local to the API invoice edit +// flow: the line engine consumes it and returns the realized target state to +// billing instead of sending it through the subscription-sync invoice updater. +func (s *CreditThenInvoiceStateMachine) AttachInvoiceLine(ctx context.Context, input billing.StandardLineWithInvoiceHeader) error { + if err := input.Validate(); err != nil { + return err + } + + if s.Charge.Realizations.CurrentRun != nil { + return models.NewGenericPreConditionFailedError( + fmt.Errorf("cannot attach invoice line to flat-fee charge %s because current realization run %s already exists", s.Charge.ID, s.Charge.Realizations.CurrentRun.ID.ID), + ) + } + + amountAfterProration, err := s.Charge.Intent.CalculateAmountAfterProration() + if err != nil { + return fmt.Errorf("calculating amount after proration: %w", err) + } + + if amountAfterProration.IsZero() { + return billing.ErrInvoiceLineZeroAmountCreate + } + + gatheringLine, err := buildFlatFeeGatheringLine(buildFlatFeeGatheringLineInput{ + Charge: s.Charge, + ServicePeriod: s.Charge.Intent.GetEffectiveServicePeriod(), + InvoiceAt: s.Charge.Intent.GetEffectiveInvoiceAt(), + }) + if err != nil { + return fmt.Errorf("creating flat-fee attach target line: %w", err) + } + + line, err := gatheringLine.AsNewStandardLine(input.Invoice.ID) + if err != nil { + return fmt.Errorf("converting flat-fee attach target to standard line: %w", err) + } + + line.ID = input.Line.ID + + result, err := s.Realizations.StartCreditThenInvoiceRun(ctx, flatfeerealizations.StartCreditThenInvoiceRunInput{ + Charge: s.Charge, + Line: *line, + Invoice: input.Invoice, + }) + if err != nil { + return fmt.Errorf("start attached credit-then-invoice run: %w", err) + } + + s.Charge.Realizations.CurrentRun = &result.Run + + if err := populateFlatFeeStandardLineFromRun(line, result.Run); err != nil { + return fmt.Errorf("mapping attached flat-fee run to standard line[%s]: %w", line.ID, err) + } + + s.AddInvoicePatch(invoiceupdater.NewUpdateLinePatch(line.AsGenericLine())) + + return nil +} + +func (s *CreditThenInvoiceStateMachine) AccrueInvoiceUsage(ctx context.Context, input billing.StandardLineWithInvoiceHeader) error { + if err := input.Validate(); err != nil { + return err + } + + result, err := s.Realizations.AccrueInvoiceUsage(ctx, flatfeerealizations.AccrueInvoiceUsageInput{ + Charge: s.Charge, + LineWithHeader: input, + }) + if err != nil { + return fmt.Errorf("post invoice issued: %w", err) + } + + // The state machine persists this clear through StatusFinal's ClearAdvanceAfter hook. + s.Charge.Realizations.CurrentRun = &result.Run + s.Charge.State.AdvanceAfter = nil + + return nil +} + +func (s *CreditThenInvoiceStateMachine) AreAllPaymentsSettled() bool { + run := s.Charge.Realizations.CurrentRun + if run == nil { + return false + } + + if run.AccruedUsage == nil || run.NoFiatTransactionRequired { + return true + } + + if run.Payment == nil { + return false + } + + return run.Payment.Status == payment.StatusSettled +} + +type reconcileInvoicingStateInput struct { + Op meta.PatchType + Period timeutil.ClosedPeriod + Intent flatfee.OverridableIntent + OldAmountAfterProration alpacadecimal.Decimal + NewAmountAfterProration alpacadecimal.Decimal +} + +func (s *CreditThenInvoiceStateMachine) reconcileInvoicingState(ctx context.Context, input reconcileInvoicingStateInput) error { + currentRun := s.Charge.Realizations.CurrentRun + + // TODO(credit-note support): this branch is a temporary fallback for + // immutable invoice lines until the line updater can correct them with + // credit notes. The normal patch flow below assumes immutable invoice + // history can be adjusted safely; while that is false, we update the + // charge intent/state but avoid creating replacement billable work for + // the already-invoiced period. + if !s.CreditNotesSupported { + // Case 1: We are trying to shrink an immutable invoice, but credit notes are not supported yet. + + // the immutable invoice cannot be corrected safely. Emit only the delete patch so the invoice + // updater records an immutable-invoice warning; do not create replacement billable work for the + // same already-invoiced period. + // + // This prevents charging both the non-prorated and prorated amounts. + if currentRun != nil && currentRun.Immutable && !input.NewAmountAfterProration.Equal(input.OldAmountAfterProration) { + if currentRun.LineID == nil { + return models.NewGenericPreConditionFailedError( + fmt.Errorf("cannot %s flat-fee charge %s because current realization run %s does not have a persisted line reference", input.Op, s.Charge.ID, currentRun.ID.ID), + ) + } + + if currentRun.InvoiceID == nil { + return models.NewGenericPreConditionFailedError( + fmt.Errorf("cannot %s flat-fee charge %s because current realization run %s does not have a persisted invoice reference", input.Op, s.Charge.ID, currentRun.ID.ID), + ) + } + + s.Charge.Intent = input.Intent + s.Charge.State.AmountAfterProration = input.NewAmountAfterProration + + s.AddInvoicePatch(invoiceupdater.NewDeleteLinePatch( + billing.LineID{ + Namespace: s.Charge.Namespace, + ID: *currentRun.LineID, + }, + *currentRun.InvoiceID, + )) + + return nil + } + } + + s.Charge.Intent = input.Intent + s.Charge.State.AmountAfterProration = input.NewAmountAfterProration + + updatedGatheringLine, err := buildFlatFeeGatheringLine(buildFlatFeeGatheringLineInput{ + Charge: s.Charge, + ServicePeriod: input.Period, + InvoiceAt: s.Charge.Intent.GetEffectiveInvoiceAt(), + }) + if err != nil { + return fmt.Errorf("creating gathering line for %s period: %w", input.Op, err) + } + + // We are in pre-active state, so only the gathering line exists + if currentRun == nil { + if input.NewAmountAfterProration.IsZero() { + // A zero patch target has no invoice artifact to wait for. Keep it + // terminal and clear advancement so the charge worker stops + // selecting it. + s.AddInvoicePatch(invoiceupdater.NewDeleteGatheringLineByChargeIDPatch(s.Charge.ID)) + s.Charge.Status = flatfee.StatusFinal + s.Charge.State.AdvanceAfter = nil + return nil + } + + // Gathering invoices do not have a charge realization run yet, so the + // invoice artifact is derived entirely from the effective charge intent. + // Updating by charge ID is enough here: no downstream state points at + // gathering-line detailed rows, and billing can retain the existing + // pending line identity. + s.AddInvoicePatch(invoiceupdater.NewUpsertGatheringLineByChargeIDPatch(s.Charge.ID, updatedGatheringLine)) + // A zero charge can become billable again after extend/shrink. Move it + // back to created so normal invoice_at advancement and invoicing can + // recreate the CTI lifecycle. + s.Charge.Status = flatfee.StatusCreated + return s.AdvanceAfterInvoiceAt(ctx) + } + + // Run exists, so we started the billing cycle, thus we don't have a gathering line, but we do have a standard line + + // Let's validate that the run has a persisted line references, before continuing + if currentRun.LineID == nil { + return models.NewGenericPreConditionFailedError( + fmt.Errorf("cannot %s flat-fee charge %s because current realization run %s does not have a persisted line reference", input.Op, s.Charge.ID, currentRun.ID.ID), + ) + } + + if currentRun.InvoiceID == nil { + return models.NewGenericPreConditionFailedError( + fmt.Errorf("cannot %s flat-fee charge %s because current realization run %s does not have a persisted invoice reference", input.Op, s.Charge.ID, currentRun.ID.ID), + ) + } + + // If the run is not immutable, we can just update the invoice standard line. + if !currentRun.Immutable { + // Case #1: If the new amount is zero we just need to delete the old line + if input.NewAmountAfterProration.IsZero() { + s.AddInvoicePatch(invoiceupdater.NewDeleteLinePatch( + billing.LineID{ + Namespace: s.Charge.Namespace, + ID: *currentRun.LineID, + }, + *currentRun.InvoiceID, + )) + + if err := s.Adapter.DetachCurrentRun(ctx, s.Charge.GetChargeID()); err != nil { + return fmt.Errorf("detach zero-amount current run: %w", err) + } + + s.Charge.Realizations.PriorRuns = append(s.Charge.Realizations.PriorRuns, *currentRun) + s.Charge.Realizations.CurrentRun = nil + + // The mutable standard-line deletion hook owns credit correction + // for the detached run. After the line is removed, a zero-amount + // charge has no remaining invoice lifecycle to wait for. + s.Charge.Status = flatfee.StatusFinal + s.Charge.State.AdvanceAfter = nil + + return nil + } + + line, err := updatedGatheringLine.AsNewStandardLine(*currentRun.InvoiceID) + if err != nil { + return fmt.Errorf("converting %s flat-fee gathering line target to standard line: %w", input.Op, err) + } + + line.ID = *currentRun.LineID + + // The invoice updater rebuilt the mutable standard line from the new + // charge intent, but the charge realization run still describes the old + // line amount and credit allocations. Reconcile them before handing the + // updated line back to billing. + result, err := s.Realizations.ReconcileStandardLineToIntent(ctx, flatfeerealizations.ReconcileStandardLineToIntentInput{ + Charge: s.Charge, + Run: *currentRun, + Line: *line, + AllocateAt: flatfee.UsageBookedAt(s.Charge.Intent.GetEffectivePaymentTerm(), currentRun.ServicePeriod), + }) + if err != nil { + return fmt.Errorf("reconcile standard line to intent for %s flat-fee charge[%s]: %w", input.Op, s.Charge.ID, err) + } + + s.Charge.Realizations.CurrentRun = &result.Run + line = &result.Line + + genericLine, err := line.AsInvoiceLine().AsGenericLine() + if err != nil { + return fmt.Errorf("converting %s flat-fee standard line[%s] to generic line: %w", input.Op, *currentRun.LineID, err) + } + + s.AddInvoicePatch(invoiceupdater.NewUpdateLinePatch(genericLine)) + return nil + } + + // Final case: we have an immutable invoice, so we need to invoke the prorating path, unless the amount haven't changed + if input.NewAmountAfterProration.Equal(input.OldAmountAfterProration) { + return nil + } + + // We need to trigger a prorating for the new amount + + s.AddInvoicePatch(invoiceupdater.NewDeleteLinePatch( + billing.LineID{ + Namespace: s.Charge.Namespace, + ID: *currentRun.LineID, + }, + *currentRun.InvoiceID, + )) + + if err := s.Adapter.DetachCurrentRun(ctx, s.Charge.GetChargeID()); err != nil { + return fmt.Errorf("detach immutable current run: %w", err) + } + + s.AddInvoicePatch(invoiceupdater.NewCreateLinePatch(updatedGatheringLine)) + + s.Charge.Realizations.PriorRuns = append(s.Charge.Realizations.PriorRuns, *currentRun) + s.Charge.Realizations.CurrentRun = nil + + s.Charge.Status = flatfee.StatusCreated + return s.AdvanceAfterInvoiceAt(ctx) +} diff --git a/billing/charges/flatfee/service/creditsonly.go b/billing/charges/flatfee/service/creditsonly.go new file mode 100644 index 0000000000000000000000000000000000000000..eaca07df76dcdee111e2e50dd73996d620a587a1 --- /dev/null +++ b/billing/charges/flatfee/service/creditsonly.go @@ -0,0 +1,289 @@ +package service + +import ( + "context" + "fmt" + + "github.com/alpacahq/alpacadecimal" + "github.com/samber/lo" + "github.com/samber/mo" + + "github.com/openmeterio/openmeter/openmeter/billing/charges/flatfee" + flatfeerealizations "github.com/openmeterio/openmeter/openmeter/billing/charges/flatfee/service/realizations" + "github.com/openmeterio/openmeter/openmeter/billing/charges/meta" + "github.com/openmeterio/openmeter/openmeter/billing/models/totals" + "github.com/openmeterio/openmeter/openmeter/productcatalog" + "github.com/openmeterio/openmeter/pkg/clock" + "github.com/openmeterio/openmeter/pkg/currencyx" + "github.com/openmeterio/openmeter/pkg/statelessx" +) + +type CreditsOnlyStateMachine struct { + *stateMachine +} + +func NewCreditsOnlyStateMachine(config StateMachineConfig) (*CreditsOnlyStateMachine, error) { + if err := config.Validate(); err != nil { + return nil, fmt.Errorf("validate: %w", err) + } + + if config.Charge.Intent.GetSettlementMode() != productcatalog.CreditOnlySettlementMode { + return nil, fmt.Errorf("charge %s is not credit_only", config.Charge.ID) + } + + stateMachine, err := newStateMachineBase(config) + if err != nil { + return nil, fmt.Errorf("new state machine: %w", err) + } + + out := &CreditsOnlyStateMachine{ + stateMachine: stateMachine, + } + out.configureStates() + + return out, nil +} + +func (s *CreditsOnlyStateMachine) configureStates() { + s.Configure(flatfee.StatusCreated). + Permit(meta.TriggerNext, flatfee.StatusActive, statelessx.BoolFn(s.IsAfterInvoiceAt)). + InternalTransition(meta.TriggerDelete, statelessx.WithParameters(s.DeleteCharge)). + InternalTransition(meta.TriggerExtend, statelessx.WithParameters(s.ExtendCharge)). + InternalTransition(meta.TriggerShrink, statelessx.WithParameters(s.ShrinkCharge)). + OnActive( + s.AdvanceAfterInvoiceAt, + ) + + s.Configure(flatfee.StatusActive). + Permit(meta.TriggerNext, flatfee.StatusFinal, statelessx.BoolFn(s.IsAfterBookedAt)). + InternalTransition(meta.TriggerDelete, statelessx.WithParameters(s.DeleteCharge)). + InternalTransition(meta.TriggerExtend, statelessx.WithParameters(s.ExtendCharge)). + InternalTransition(meta.TriggerShrink, statelessx.WithParameters(s.ShrinkCharge)). + OnActive( + s.AdvanceAfterBookedAt, + ) + + s.Configure(flatfee.StatusFinal). + InternalTransition(meta.TriggerDelete, statelessx.WithParameters(s.DeleteCharge)). + InternalTransition(meta.TriggerExtend, statelessx.WithParameters(s.ExtendCharge)). + InternalTransition(meta.TriggerShrink, statelessx.WithParameters(s.ShrinkCharge)). + OnActive( + statelessx.AllOf( + s.AllocateCredits, + s.ClearAdvanceAfter, + ), + ) +} + +func (s *CreditsOnlyStateMachine) IsAfterBookedAt() bool { + return !clock.Now().Before(flatfee.UsageBookedAt( + s.Charge.Intent.GetEffectivePaymentTerm(), + s.Charge.Intent.GetEffectiveServicePeriod(), + )) +} + +func (s *CreditsOnlyStateMachine) AdvanceAfterBookedAt(ctx context.Context) error { + s.Charge.State.AdvanceAfter = lo.ToPtr(meta.NormalizeTimestamp(flatfee.UsageBookedAt( + s.Charge.Intent.GetEffectivePaymentTerm(), + s.Charge.Intent.GetEffectiveServicePeriod(), + ))) + return nil +} + +func (s *CreditsOnlyStateMachine) AllocateCredits(ctx context.Context) error { + currency, err := currencyx.NewCurrencyBuilder(currencyx.CurrencyTypeFiat). + WithCode(s.Charge.Intent.GetCurrency()). + Build() + if err != nil { + return fmt.Errorf("get currency calculator: %w", err) + } + + amount := currency.RoundToPrecision(s.Charge.State.AmountAfterProration) + + if amount.IsNegative() { + return fmt.Errorf("charge total is negative [charge_id=%s, amount=%s]", s.Charge.ID, amount.String()) + } + + if s.Charge.Realizations.CurrentRun == nil { + runBase, err := s.Adapter.CreateCurrentRun(ctx, flatfee.CreateCurrentRunInput{ + Charge: s.Charge.ChargeBase, + ServicePeriod: s.Charge.Intent.GetEffectiveServicePeriod(), + AmountAfterProration: amount, + NoFiatTransactionRequired: true, // We are in credits-only mode + }) + if err != nil { + return fmt.Errorf("create current run: %w", err) + } + + s.Charge.Realizations.CurrentRun = &flatfee.RealizationRun{ + RealizationRunBase: runBase, + } + } + + if s.Charge.Realizations.CurrentRun != nil && len(s.Charge.Realizations.CurrentRun.CreditRealizations) > 0 { + return s.reconcileCurrentRunCredits(ctx, amount) + } + + result, err := s.Realizations.AllocateCreditsOnly(ctx, flatfeerealizations.AllocateCreditsOnlyInput{ + Charge: s.Charge, + Amount: amount, + CurrencyCalculator: currency, + }) + if err != nil { + return fmt.Errorf("allocate credits: %w", err) + } + + s.Charge.Realizations.CurrentRun.CreditRealizations = append(s.Charge.Realizations.CurrentRun.CreditRealizations, result.Realizations...) + return nil +} + +func (s *CreditsOnlyStateMachine) ExtendCharge(ctx context.Context, patch meta.PatchExtend) error { + return s.applyPeriodPatch(ctx, patch) +} + +func (s *CreditsOnlyStateMachine) ShrinkCharge(ctx context.Context, patch meta.PatchShrink) error { + return s.applyPeriodPatch(ctx, patch) +} + +func (s *CreditsOnlyStateMachine) applyPeriodPatch(ctx context.Context, patch periodPatch) error { + target, err := patch.GetTargetLayer(s.Charge.Intent) + if err != nil { + return fmt.Errorf("getting patch target layer: %w", err) + } + + if err := s.rejectHiddenIntentTarget(target); err != nil { + return err + } + + targetIntent, err := s.Charge.Intent.GetIntentForTarget(target) + if err != nil { + return fmt.Errorf("getting %s intent: %w", target, err) + } + + if err := patch.ValidateWith(targetIntent.IntentMutableFields.IntentMutableFields); err != nil { + return fmt.Errorf("validate %s patch: %w", patch.Op(), err) + } + + intent := s.Charge.Intent + if err := intent.Mutate(target, func(fields *flatfee.IntentMutableFields) { + fields.ServicePeriod.To = patch.GetNewServicePeriodTo() + fields.FullServicePeriod.To = patch.GetNewFullServicePeriodTo() + fields.BillingPeriod.To = patch.GetNewBillingPeriodTo() + fields.InvoiceAt = patch.GetNewInvoiceAt() + }); err != nil { + return fmt.Errorf("mutating %s intent: %w", target, err) + } + + s.Charge.Intent = intent + + amountAfterProration, err := intent.CalculateAmountAfterProration() + if err != nil { + return fmt.Errorf("calculating amount after proration: %w", err) + } + s.Charge.State.AmountAfterProration = amountAfterProration + + if s.Charge.Realizations.CurrentRun == nil { + return nil + } + + return s.reconcileCurrentRunCredits(ctx, amountAfterProration) +} + +func (s *CreditsOnlyStateMachine) reconcileCurrentRunCredits(ctx context.Context, amount alpacadecimal.Decimal) error { + currentRun := s.Charge.Realizations.CurrentRun + if currentRun == nil { + return nil + } + + currency, err := currencyx.NewCurrencyBuilder(currencyx.CurrencyTypeFiat). + WithCode(s.Charge.Intent.GetCurrency()). + Build() + if err != nil { + return fmt.Errorf("get currency calculator: %w", err) + } + + amount = currency.RoundToPrecision(amount) + servicePeriod := s.Charge.Intent.GetEffectiveServicePeriod() + run := *currentRun + run.ServicePeriod = servicePeriod + + reconcileResult, err := s.Realizations.ReconcileCredits(ctx, flatfeerealizations.ReconcileCreditRealizationsInput{ + Charge: s.Charge, + Run: run, + AllocateAt: flatfee.UsageBookedAt(s.Charge.Intent.GetEffectivePaymentTerm(), servicePeriod), + TargetAmount: amount, + CurrencyCalculator: currency, + }) + if err != nil { + return fmt.Errorf("reconcile credits for run %s: %w", run.ID.ID, err) + } + + run.CreditRealizations = append(run.CreditRealizations, reconcileResult.Realizations...) + + runBase, err := s.Adapter.UpdateRealizationRun(ctx, flatfee.UpdateRealizationRunInput{ + ID: run.ID, + ServicePeriod: mo.Some(servicePeriod), + AmountAfterProration: mo.Some(amount), + Totals: mo.Some(totals.Totals{ + Amount: amount, + CreditsTotal: amount, + Total: alpacadecimal.Zero, + }), + NoFiatTransactionRequired: mo.Some(true), + }) + if err != nil { + return fmt.Errorf("update credit-only run: %w", err) + } + + run.RealizationRunBase = runBase + s.Charge.Realizations.CurrentRun = &run + return nil +} + +func (s *CreditsOnlyStateMachine) DeleteCharge(ctx context.Context, patch meta.PatchDelete) error { + deletedAt := lo.ToPtr(clock.Now()) + target, err := patch.GetTargetLayer(s.Charge.Intent) + if err != nil { + return fmt.Errorf("getting patch target layer: %w", err) + } + + if err := s.rejectHiddenIntentTarget(target); err != nil { + return err + } + + if err := s.mutateIntentLayer(ctx, target, func(fields *flatfee.IntentMutableFields) { + fields.IntentDeletedAt = deletedAt + }); err != nil { + return fmt.Errorf("deleting intent: %w", err) + } + + s.Charge.Status = flatfee.StatusDeleted + + if patch.GetPolicy().CreditRefundPolicy == meta.CreditRefundPolicyCorrect && s.Charge.Realizations.CurrentRun != nil { + currency, err := currencyx.NewCurrencyBuilder(currencyx.CurrencyTypeFiat). + WithCode(s.Charge.Intent.GetCurrency()). + Build() + if err != nil { + return fmt.Errorf("get currency calculator: %w", err) + } + + if _, err := s.Realizations.CorrectAllCredits(ctx, flatfeerealizations.CorrectAllCreditRealizationsInput{ + Charge: s.Charge, + Run: *s.Charge.Realizations.CurrentRun, + AllocateAt: flatfee.UsageBookedAt(s.Charge.Intent.GetEffectivePaymentTerm(), s.Charge.Realizations.CurrentRun.ServicePeriod), + CurrencyCalculator: currency, + }); err != nil { + return fmt.Errorf("correct credits: %w", err) + } + } + + if err := s.Adapter.DeleteCharge(ctx, s.Charge); err != nil { + return fmt.Errorf("delete charge: %w", err) + } + + if err := s.RefetchCharge(ctx); err != nil { + return fmt.Errorf("get charge: %w", err) + } + + return nil +} diff --git a/billing/charges/flatfee/service/get.go b/billing/charges/flatfee/service/get.go new file mode 100644 index 0000000000000000000000000000000000000000..4cee001bed6e589a6e09082abd948b83bccfc63d --- /dev/null +++ b/billing/charges/flatfee/service/get.go @@ -0,0 +1,28 @@ +package service + +import ( + "context" + + "github.com/openmeterio/openmeter/openmeter/billing/charges/flatfee" + "github.com/openmeterio/openmeter/pkg/framework/transaction" +) + +func (s *service) GetByIDs(ctx context.Context, input flatfee.GetByIDsInput) ([]flatfee.Charge, error) { + if err := input.Validate(); err != nil { + return nil, err + } + + return transaction.Run(ctx, s.adapter, func(ctx context.Context) ([]flatfee.Charge, error) { + return s.adapter.GetByIDs(ctx, input) + }) +} + +func (s *service) GetByID(ctx context.Context, input flatfee.GetByIDInput) (flatfee.Charge, error) { + if err := input.Validate(); err != nil { + return flatfee.Charge{}, err + } + + return transaction.Run(ctx, s.adapter, func(ctx context.Context) (flatfee.Charge, error) { + return s.adapter.GetByID(ctx, input) + }) +} diff --git a/billing/charges/flatfee/service/lineengine.go b/billing/charges/flatfee/service/lineengine.go new file mode 100644 index 0000000000000000000000000000000000000000..a08624868a3406c008d64bd89de36839d160cf7b --- /dev/null +++ b/billing/charges/flatfee/service/lineengine.go @@ -0,0 +1,924 @@ +package service + +import ( + "context" + "fmt" + + "github.com/samber/lo" + "github.com/samber/mo" + + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/billing/charges/flatfee" + flatfeerealizations "github.com/openmeterio/openmeter/openmeter/billing/charges/flatfee/service/realizations" + "github.com/openmeterio/openmeter/openmeter/billing/charges/invoiceupdater" + "github.com/openmeterio/openmeter/openmeter/billing/charges/meta" + "github.com/openmeterio/openmeter/openmeter/productcatalog" + "github.com/openmeterio/openmeter/pkg/clock" + "github.com/openmeterio/openmeter/pkg/currencyx" + "github.com/openmeterio/openmeter/pkg/slicesx" +) + +var _ billing.LineEngine = (*LineEngine)(nil) + +type LineEngine struct { + service *service +} + +func (e *LineEngine) GetLineEngineType() billing.LineEngineType { + return billing.LineEngineTypeChargeFlatFee +} + +func (e *LineEngine) IsLineBillableAsOf(_ context.Context, input billing.IsLineBillableAsOfInput) (bool, error) { + if err := input.Validate(); err != nil { + return false, fmt.Errorf("validating input: %w", err) + } + + return true, nil +} + +func (e *LineEngine) SplitGatheringLine(context.Context, billing.SplitGatheringLineInput) (billing.SplitGatheringLineResult, error) { + return billing.SplitGatheringLineResult{}, fmt.Errorf("flat fee line is not progressively billed") +} + +func (e *LineEngine) BuildStandardInvoiceLines(ctx context.Context, input billing.BuildStandardInvoiceLinesInput) (billing.StandardLines, error) { + stdLines, err := slicesx.MapWithErr(input.GatheringLines, func(gatheringLine billing.GatheringLine) (*billing.StandardLine, error) { + stdLine, err := gatheringLine.AsNewStandardLine(input.Invoice.ID) + if err != nil { + return nil, fmt.Errorf("converting gathering line to standard line: %w", err) + } + + return stdLine, nil + }) + if err != nil { + return nil, err + } + + return stdLines, nil +} + +func (e *LineEngine) BuildStandardLinesForGatheringPreview(ctx context.Context, input billing.BuildStandardInvoiceLinesInput) (billing.StandardLines, error) { + if err := input.Validate(); err != nil { + return nil, fmt.Errorf("validating input: %w", err) + } + + stdLines, err := input.GatheringLines.ToStandardLines(input.Invoice.ID) + if err != nil { + return nil, err + } + + chargesByID, err := e.getChargesForStandardLineEvent(ctx, billing.StandardLineEventInput{ + Invoice: input.Invoice, + Lines: stdLines, + }, meta.Expands{ + meta.ExpandRealizations, + }) + if err != nil { + return nil, err + } + + for _, stdLine := range stdLines { + charge, ok := chargesByID[*stdLine.ChargeID] + if !ok { + return nil, fmt.Errorf("flat fee charge[%s] not found for gathering preview line[%s]", *stdLine.ChargeID, stdLine.ID) + } + + previewResult, err := e.service.realizations.BuildCreditThenInvoiceGatheringPreviewRun(flatfeerealizations.BuildCreditThenInvoiceGatheringPreviewRunInput{ + Charge: charge, + Line: *stdLine, + }) + if err != nil { + return nil, fmt.Errorf("building gathering preview run for line[%s]: %w", stdLine.ID, err) + } + + if err := populateFlatFeeStandardLineFromRun(stdLine, previewResult.Run); err != nil { + return nil, fmt.Errorf("populating gathering preview line[%s] from run: %w", stdLine.ID, err) + } + + if err := stdLine.Validate(); err != nil { + return nil, fmt.Errorf("validating gathering preview line[%s]: %w", stdLine.ID, err) + } + } + + return stdLines, nil +} + +func (e *LineEngine) OnStandardInvoiceCreated(ctx context.Context, input billing.OnStandardInvoiceCreatedInput) (billing.StandardLines, error) { + if err := input.Validate(); err != nil { + return nil, fmt.Errorf("validating input: %w", err) + } + + stdLines, err := slicesx.MapWithErr(input.Lines, func(stdLine *billing.StandardLine) (*billing.StandardLine, error) { + stateMachine, err := e.newStateMachineForStandardLine(ctx, stdLine) + if err != nil { + return nil, err + } + + if _, err := stateMachine.AdvanceUntilStateStable(ctx); err != nil { + return nil, fmt.Errorf("advancing flat fee charge[%s]: %w", stateMachine.GetCharge().ID, err) + } + + if err := stateMachine.FireAndActivate(ctx, meta.TriggerInvoiceCreated, billing.StandardLineWithInvoiceHeader{ + Line: stdLine, + Invoice: input.Invoice, + }); err != nil { + return nil, fmt.Errorf("triggering %s for charge[%s]: %w", meta.TriggerInvoiceCreated, stateMachine.GetCharge().ID, err) + } + + if _, err := stateMachine.AdvanceUntilStateStable(ctx); err != nil { + return nil, fmt.Errorf("advancing flat fee charge[%s] after %s: %w", stateMachine.GetCharge().ID, meta.TriggerInvoiceCreated, err) + } + + charge := stateMachine.GetCharge() + if charge.Realizations.CurrentRun == nil { + return nil, fmt.Errorf("flat fee charge[%s]: current run is required for line[%s]", charge.ID, stdLine.ID) + } + + if err := populateFlatFeeStandardLineFromRun(stdLine, *charge.Realizations.CurrentRun); err != nil { + return nil, fmt.Errorf("populating standard line from run for charge[%s]: %w", charge.ID, err) + } + + if err := stdLine.Validate(); err != nil { + return nil, fmt.Errorf("validating standard line[%s]: %w", stdLine.ID, err) + } + + return stdLine, nil + }) + if err != nil { + return nil, err + } + + return stdLines, nil +} + +func (e *LineEngine) OnCollectionCompleted(ctx context.Context, input billing.OnCollectionCompletedInput) (billing.StandardLines, error) { + if err := input.Validate(); err != nil { + return nil, fmt.Errorf("validating input: %w", err) + } + + for _, stdLine := range input.Lines { + stateMachine, err := e.newStateMachineForStandardLine(ctx, stdLine) + if err != nil { + return nil, err + } + + canFire, err := stateMachine.CanFire(ctx, meta.TriggerCollectionCompleted) + if err != nil { + return nil, fmt.Errorf("checking collection_completed for charge[%s]: %w", stateMachine.GetCharge().ID, err) + } + + if !canFire { + continue + } + + if err := stateMachine.FireAndActivate(ctx, meta.TriggerCollectionCompleted); err != nil { + return nil, fmt.Errorf("triggering collection_completed for charge[%s]: %w", stateMachine.GetCharge().ID, err) + } + + if _, err := stateMachine.AdvanceUntilStateStable(ctx); err != nil { + return nil, fmt.Errorf("advancing flat fee charge[%s] after collection_completed: %w", stateMachine.GetCharge().ID, err) + } + } + + return input.Lines, nil +} + +func (e *LineEngine) OnMutableInvoiceLinesEditedViaAPI(ctx context.Context, input billing.OnMutableInvoiceUpdateInput) (billing.OnMutableInvoiceUpdateResult, error) { + if err := input.Validate(); err != nil { + return billing.OnMutableInvoiceUpdateResult{}, fmt.Errorf("validating input: %w", err) + } + + createdLines, err := e.createManualInvoiceLines(ctx, input) + if err != nil { + return billing.OnMutableInvoiceUpdateResult{}, err + } + + updatedLines, err := slicesx.MapWithErr(input.Updated, func(override billing.InvoiceLineOverride) (billing.GenericInvoiceLine, error) { + chargeID := override.ExistingLine.GetChargeID() + if chargeID == nil || *chargeID == "" { + return nil, fmt.Errorf("flat fee line[%s]: charge id is required", override.ExistingLine.GetID()) + } + + charge, err := e.service.GetByID(ctx, flatfee.GetByIDInput{ + ChargeID: meta.ChargeID{ + Namespace: override.ExistingLine.GetLineID().Namespace, + ID: *chargeID, + }, + Expands: meta.Expands{ + meta.ExpandRealizations, + }, + }) + if err != nil { + return nil, fmt.Errorf("getting flat fee charge for line[%s]: %w", override.ExistingLine.GetID(), err) + } + + if charge.Intent.GetSettlementMode() != productcatalog.CreditThenInvoiceSettlementMode { + return nil, fmt.Errorf( + "flat fee line[%s]: unsupported settlement mode for API edit: %s", + override.ExistingLine.GetID(), + charge.Intent.GetSettlementMode(), + ) + } + + stateMachine, err := e.service.newStateMachineForCharge(charge) + if err != nil { + return nil, fmt.Errorf("new state machine for flat fee charge[%s]: %w", charge.ID, err) + } + + lineManualEditPatch, err := meta.NewPatchLineManualEdit(meta.NewPatchLineManualEditInput{ + ChangeSource: billing.ChangeSourceAPIRequest, + Override: override, + }) + if err != nil { + return nil, fmt.Errorf("creating flat-fee line manual edit patch for line[%s]: %w", override.ExistingLine.GetID(), err) + } + + if err := stateMachine.FireAndActivate(ctx, meta.TriggerLineManualEdit, lineManualEditPatch); err != nil { + return nil, fmt.Errorf("triggering %s for charge[%s]: %w", meta.TriggerLineManualEdit, charge.ID, err) + } + + patches := stateMachine.DrainInvoicePatches() + var targetLine billing.GenericInvoiceLine + switch override.ExistingLine.AsInvoiceLine().Type() { + case billing.InvoiceLineTypeStandard: + updatePatch, err := patches.RequireSingularLineUpdatePatchForTarget(override.ExistingLine) + if err != nil { + return nil, fmt.Errorf("line[%s]: validating line manual edit update patch target: %w", override.ExistingLine.GetID(), err) + } + + targetLine = updatePatch.TargetState + case billing.InvoiceLineTypeGathering: + gatheringPatch, err := patches.RequireSingularGatheringLinePatchForCharge(*chargeID) + if err != nil { + return nil, fmt.Errorf("line[%s]: validating line manual edit gathering patch target: %w", override.ExistingLine.GetID(), err) + } + + switch gatheringPatch.Op() { + case invoiceupdater.PatchOpUpsertGatheringLineByChargeID: + upsertPatch, err := gatheringPatch.AsUpsertGatheringLineByChargeIDPatch() + if err != nil { + return nil, fmt.Errorf("line[%s]: getting line manual edit gathering upsert patch: %w", override.ExistingLine.GetID(), err) + } + + targetLine = upsertPatch.TargetState.AsGenericLine() + case invoiceupdater.PatchOpDeleteGatheringLineByChargeID: + // TODO: support zero-proration manual gathering-line edits by + // modeling the API result as a line deletion/detach instead of + // an updated line. + return nil, fmt.Errorf("line[%s]: zero-proration manual gathering-line edits are not supported yet: %w", override.ExistingLine.GetID(), billing.ErrInvoiceLineZeroAmountDeleteInstead) + default: + return nil, fmt.Errorf("line[%s]: expected line manual edit gathering patch, got %s", override.ExistingLine.GetID(), gatheringPatch.Op()) + } + default: + return nil, billing.ErrCannotUpdateChargeManagedLine + } + + updatedLine, err := override.ExistingLine.WithTargetState(targetLine) + if err != nil { + return nil, fmt.Errorf("line[%s]: merging line manual edit patch target state: %w", override.ExistingLine.GetID(), err) + } + + return updatedLine, nil + }) + if err != nil { + return billing.OnMutableInvoiceUpdateResult{}, err + } + + for _, line := range input.Deleted { + chargeID := line.GetChargeID() + if chargeID == nil || *chargeID == "" { + return billing.OnMutableInvoiceUpdateResult{}, fmt.Errorf("flat fee line[%s]: charge id is required", line.GetID()) + } + + charge, err := e.service.GetByID(ctx, flatfee.GetByIDInput{ + ChargeID: meta.ChargeID{ + Namespace: line.GetLineID().Namespace, + ID: *chargeID, + }, + Expands: meta.Expands{ + meta.ExpandRealizations, + }, + }) + if err != nil { + return billing.OnMutableInvoiceUpdateResult{}, fmt.Errorf("getting flat fee charge for deleted line[%s]: %w", line.GetID(), err) + } + + if charge.Intent.GetSettlementMode() != productcatalog.CreditThenInvoiceSettlementMode { + return billing.OnMutableInvoiceUpdateResult{}, fmt.Errorf( + "flat fee line[%s]: unsupported settlement mode for API delete: %s", + line.GetID(), + charge.Intent.GetSettlementMode(), + ) + } + + if err := validateManualDeleteLine(charge, line); err != nil { + return billing.OnMutableInvoiceUpdateResult{}, err + } + + stateMachine, err := e.service.newStateMachineForCharge(charge) + if err != nil { + return billing.OnMutableInvoiceUpdateResult{}, fmt.Errorf("new state machine for flat fee charge[%s]: %w", charge.ID, err) + } + + deletePatch, err := meta.NewPatchDelete(meta.NewPatchDeleteInput{ + ChangeSource: billing.ChangeSourceAPIRequest, + Policy: meta.RefundAsCreditsDeletePolicy, + }) + if err != nil { + return billing.OnMutableInvoiceUpdateResult{}, fmt.Errorf("creating flat fee line[%s] manual delete patch: %w", line.GetID(), err) + } + + if err := stateMachine.FireAndActivate(ctx, meta.TriggerDelete, deletePatch); err != nil { + return billing.OnMutableInvoiceUpdateResult{}, fmt.Errorf("triggering %s for charge[%s]: %w", meta.TriggerDelete, charge.ID, err) + } + + if err := e.handleManualDeleteInvoicePatches(ctx, input.Invoice, line, *chargeID, stateMachine.DrainInvoicePatches()); err != nil { + return billing.OnMutableInvoiceUpdateResult{}, err + } + } + + return billing.OnMutableInvoiceUpdateResult{ + CreatedLines: createdLines, + UpdatedLines: updatedLines, + }, nil +} + +func (e *LineEngine) ValidateMutableInvoiceLineEditViaAPI(ctx context.Context, input billing.OnMutableInvoiceUpdateInput) error { + if err := input.Validate(); err != nil { + return fmt.Errorf("validating input: %w", err) + } + + for _, line := range input.Created { + if _, err := intentFromManualCreatedLine(ctx, input.Invoice, line, input.DefaultTaxCodeResolvers.Invoicing); err != nil { + if line == nil { + return fmt.Errorf("building manually created flat-fee charge intent: %w", err) + } + + return fmt.Errorf("building manually created flat-fee charge intent for line[%s]: %w", line.GetID(), err) + } + } + + for _, override := range input.Updated { + if err := e.validateManualUpdateLineViaAPI(ctx, override); err != nil { + return err + } + } + + for _, line := range input.Deleted { + if err := e.validateManualDeleteLineViaAPI(ctx, line); err != nil { + return err + } + } + + return nil +} + +func (e *LineEngine) validateManualUpdateLineViaAPI(ctx context.Context, override billing.InvoiceLineOverride) error { + chargeID := override.ExistingLine.GetChargeID() + if chargeID == nil || *chargeID == "" { + return fmt.Errorf("flat fee line[%s]: charge id is required", override.ExistingLine.GetID()) + } + + charge, err := e.service.GetByID(ctx, flatfee.GetByIDInput{ + ChargeID: meta.ChargeID{ + Namespace: override.ExistingLine.GetLineID().Namespace, + ID: *chargeID, + }, + Expands: meta.Expands{ + meta.ExpandRealizations, + }, + }) + if err != nil { + return fmt.Errorf("getting flat fee charge for line[%s]: %w", override.ExistingLine.GetID(), err) + } + + if charge.Intent.GetSettlementMode() != productcatalog.CreditThenInvoiceSettlementMode { + return fmt.Errorf( + "flat fee line[%s]: unsupported settlement mode for API edit: %s", + override.ExistingLine.GetID(), + charge.Intent.GetSettlementMode(), + ) + } + + if err := override.ExistingLine.AsInvoiceLine().Type().Require(billing.InvoiceLineTypeStandard, billing.InvoiceLineTypeGathering); err != nil { + return fmt.Errorf("flat fee line[%s]: unsupported line type for API edit: %s", override.ExistingLine.GetID(), override.ExistingLine.AsInvoiceLine().Type()) + } + + if _, err := meta.NewPatchLineManualEdit(meta.NewPatchLineManualEditInput{ + ChangeSource: billing.ChangeSourceAPIRequest, + Override: override, + }); err != nil { + return fmt.Errorf("validating flat-fee line manual edit patch for line[%s]: %w", override.ExistingLine.GetID(), err) + } + + return nil +} + +func (e *LineEngine) validateManualDeleteLineViaAPI(ctx context.Context, line billing.GenericInvoiceLine) error { + chargeID := line.GetChargeID() + if chargeID == nil || *chargeID == "" { + return fmt.Errorf("flat fee line[%s]: charge id is required", line.GetID()) + } + + charge, err := e.service.GetByID(ctx, flatfee.GetByIDInput{ + ChargeID: meta.ChargeID{ + Namespace: line.GetLineID().Namespace, + ID: *chargeID, + }, + Expands: meta.Expands{ + meta.ExpandRealizations, + }, + }) + if err != nil { + return fmt.Errorf("getting flat fee charge for deleted line[%s]: %w", line.GetID(), err) + } + + if charge.Intent.GetSettlementMode() != productcatalog.CreditThenInvoiceSettlementMode { + return fmt.Errorf( + "flat fee line[%s]: unsupported settlement mode for API delete: %s", + line.GetID(), + charge.Intent.GetSettlementMode(), + ) + } + + return validateManualDeleteLine(charge, line) +} + +type manualCreatedInvoiceLine struct { + sourceLine billing.GenericInvoiceLine + intent flatfee.Intent +} + +func (e *LineEngine) createManualInvoiceLines(ctx context.Context, input billing.OnMutableInvoiceUpdateInput) ([]billing.GenericInvoiceLine, error) { + if len(input.Created) == 0 { + return nil, nil + } + + if input.Invoice == nil { + return nil, fmt.Errorf("invoice is required") + } + + created, err := slicesx.MapWithErr(input.Created, func(line billing.GenericInvoiceLine) (manualCreatedInvoiceLine, error) { + intent, err := intentFromManualCreatedLine(ctx, input.Invoice, line, input.DefaultTaxCodeResolvers.Invoicing) + if err != nil { + if line == nil { + return manualCreatedInvoiceLine{}, fmt.Errorf("building manually created flat-fee charge intent: %w", err) + } + + return manualCreatedInvoiceLine{}, fmt.Errorf("building manually created flat-fee charge intent for line[%s]: %w", line.GetID(), err) + } + + return manualCreatedInvoiceLine{ + sourceLine: line, + intent: intent, + }, nil + }) + if err != nil { + return nil, err + } + + createdCharges, err := e.service.Create(ctx, flatfee.CreateInput{ + Namespace: input.Invoice.GetInvoiceID().Namespace, + Intents: lo.Map(created, func(line manualCreatedInvoiceLine, _ int) flatfee.Intent { + return line.intent + }), + }) + if err != nil { + return nil, fmt.Errorf("creating manually managed flat-fee charges: %w", err) + } + + if len(createdCharges) != len(created) { + return nil, fmt.Errorf("expected %d manually created flat-fee charges, got %d", len(created), len(createdCharges)) + } + + out := make([]billing.GenericInvoiceLine, 0, len(createdCharges)) + for idx, charge := range createdCharges { + sourceLine := created[idx].sourceLine + switch sourceLine.AsInvoiceLine().Type() { + case billing.InvoiceLineTypeGathering: + if charge.GatheringLineToCreate == nil { + return nil, fmt.Errorf("line[%s]: manually created flat-fee charge[%s] did not create a gathering line", sourceLine.GetID(), charge.Charge.ID) + } + + line, err := sourceLine.WithTargetState(charge.GatheringLineToCreate.AsGenericLine()) + if err != nil { + return nil, fmt.Errorf("line[%s]: merging manually created flat-fee charge target state: %w", sourceLine.GetID(), err) + } + + out = append(out, line) + case billing.InvoiceLineTypeStandard: + line, err := e.attachManualStandardLine(ctx, input.Invoice, sourceLine, charge.Charge) + if err != nil { + return nil, err + } + + out = append(out, line) + default: + return nil, fmt.Errorf("unsupported manually created flat-fee line type [charge_id=%s,line_id=%s,line_type=%s]: %w", + charge.Charge.ID, + sourceLine.GetID(), + sourceLine.AsInvoiceLine().Type(), + billing.ErrCannotUpdateChargeManagedLine) + } + } + + return out, nil +} + +func (e *LineEngine) attachManualStandardLine(ctx context.Context, invoice billing.GenericInvoiceReader, sourceLine billing.GenericInvoiceLine, charge flatfee.Charge) (billing.GenericInvoiceLine, error) { + standardInvoice, err := invoice.AsInvoice().AsStandardInvoice() + if err != nil { + return nil, fmt.Errorf("getting standard invoice for created line[%s]: %w", sourceLine.GetID(), err) + } + + standardLine, err := sourceLine.AsInvoiceLine().AsStandardLine() + if err != nil { + return nil, fmt.Errorf("getting created standard line[%s]: %w", sourceLine.GetID(), err) + } + + stateMachine, err := e.service.newStateMachineForCharge(charge) + if err != nil { + return nil, fmt.Errorf("new state machine for flat fee charge[%s]: %w", charge.ID, err) + } + + if err := stateMachine.FireAndActivate(ctx, meta.TriggerAttachInvoiceLine, billing.StandardLineWithInvoiceHeader{ + Line: &standardLine, + Invoice: standardInvoice, + }); err != nil { + return nil, fmt.Errorf("triggering %s for charge[%s]: %w", meta.TriggerAttachInvoiceLine, charge.ID, err) + } + + patches := stateMachine.DrainInvoicePatches() + updatePatch, err := patches.RequireSingularLineUpdatePatchForTarget(sourceLine) + if err != nil { + return nil, fmt.Errorf("line[%s]: validating attach update patch target: %w", sourceLine.GetID(), err) + } + + line, err := sourceLine.WithTargetState(updatePatch.TargetState) + if err != nil { + return nil, fmt.Errorf("line[%s]: merging attach patch target state: %w", sourceLine.GetID(), err) + } + + return line, nil +} + +func validateManualDeleteLine(charge flatfee.Charge, line billing.GenericInvoiceLine) error { + switch line.AsInvoiceLine().Type() { + case billing.InvoiceLineTypeGathering: + if charge.Realizations.CurrentRun != nil { + return fmt.Errorf("cannot delete gathering line with current run [charge_id=%s,run_id=%s,line_id=%s]: %w", + charge.ID, + charge.Realizations.CurrentRun.ID.ID, + line.GetID(), + billing.ErrCannotUpdateChargeManagedLine) + } + case billing.InvoiceLineTypeStandard: + currentRun := charge.Realizations.CurrentRun + if currentRun == nil { + return fmt.Errorf("missing current run [charge_id=%s,line_id=%s]: %w", charge.ID, line.GetID(), billing.ErrCannotUpdateChargeManagedLine) + } + + if currentRun.Immutable { + return fmt.Errorf("immutable current run [charge_id=%s,run_id=%s,line_id=%s]: %w", charge.ID, currentRun.ID.ID, line.GetID(), billing.ErrCannotUpdateChargeManagedLine) + } + + if currentRun.LineID == nil || *currentRun.LineID != line.GetID() { + return fmt.Errorf("line[%s]: current realization run must be attached to deleted line", line.GetID()) + } + + if currentRun.InvoiceID == nil || *currentRun.InvoiceID != line.GetInvoiceID() { + return fmt.Errorf("line[%s]: current realization run must be attached to deleted invoice", line.GetID()) + } + default: + return billing.ErrCannotUpdateChargeManagedLine + } + + return nil +} + +func (e *LineEngine) handleManualDeleteInvoicePatches(ctx context.Context, invoice billing.GenericInvoiceReader, line billing.GenericInvoiceLine, chargeID string, patches invoiceupdater.Patches) error { + if len(patches) == 0 { + return fmt.Errorf("line[%s]: expected manual delete invoice patches", line.GetID()) + } + + for _, patch := range patches { + switch patch.Op() { + case invoiceupdater.PatchOpDeleteGatheringLineByChargeID: + deletePatch, err := patch.AsDeleteGatheringLineByChargeIDPatch() + if err != nil { + return fmt.Errorf("line[%s]: getting manual delete gathering-line patch: %w", line.GetID(), err) + } + + if err := deletePatch.RequireCharge(chargeID); err != nil { + return fmt.Errorf("line[%s]: validating manual delete gathering-line patch target: %w", line.GetID(), err) + } + case invoiceupdater.PatchOpLineDelete: + deletePatch, err := patch.AsDeleteLinePatch() + if err != nil { + return fmt.Errorf("line[%s]: getting manual delete line patch: %w", line.GetID(), err) + } + + if err := deletePatch.RequireTarget(line); err != nil { + return fmt.Errorf("line[%s]: validating manual delete line patch target: %w", line.GetID(), err) + } + + standardInvoice, err := invoice.AsInvoice().AsStandardInvoice() + if err != nil { + return fmt.Errorf("line[%s]: getting standard invoice for manual delete cleanup: %w", line.GetID(), err) + } + + standardLine, err := line.AsInvoiceLine().AsStandardLine() + if err != nil { + return fmt.Errorf("line[%s]: getting standard line for manual delete cleanup: %w", line.GetID(), err) + } + + if err := e.cleanupDeletedStandardLines(ctx, billing.StandardLineEventInput{ + Invoice: standardInvoice, + Lines: billing.StandardLines{&standardLine}, + }); err != nil { + return fmt.Errorf("line[%s]: cleaning up manual delete line patch: %w", line.GetID(), err) + } + default: + return fmt.Errorf("line[%s]: unexpected manual delete invoice patch %s", line.GetID(), patch.Op()) + } + } + + return nil +} + +func (e *LineEngine) OnMutableStandardLinesDeletedBySystem(ctx context.Context, input billing.OnMutableStandardLinesDeletedInput) error { + if err := input.Validate(); err != nil { + return fmt.Errorf("validating input: %w", err) + } + + return e.cleanupDeletedStandardLines(ctx, input) +} + +func (e *LineEngine) cleanupDeletedStandardLines(ctx context.Context, input billing.StandardLineEventInput) error { + chargesByID, err := e.getChargesForStandardLineEvent(ctx, input, meta.Expands{ + meta.ExpandRealizations, + }) + if err != nil { + return fmt.Errorf("getting flat fee charges for deleted standard lines: %w", err) + } + + for _, stdLine := range input.Lines { + charge, ok := chargesByID[*stdLine.ChargeID] + if !ok { + return fmt.Errorf("flat fee charge[%s] not found for deleted standard line[%s]", *stdLine.ChargeID, stdLine.ID) + } + + run, err := charge.Realizations.GetByLineID(stdLine.ID) + if err != nil { + return err + } + + if run.DeletedAt != nil { + return fmt.Errorf("flat fee standard line[%s] cannot be deleted because realization run[%s] is already deleted", stdLine.ID, run.ID.ID) + } + + if run.InvoiceID == nil || *run.InvoiceID != input.Invoice.ID { + return fmt.Errorf("flat fee standard line[%s] cannot be deleted because realization run[%s] is not associated with invoice[%s]", stdLine.ID, run.ID.ID, input.Invoice.ID) + } + + if run.AccruedUsage != nil { + return fmt.Errorf("flat fee standard line[%s] cannot be deleted because realization run[%s] has invoice accrued allocation", stdLine.ID, run.ID.ID) + } + + if run.Payment != nil { + return fmt.Errorf("flat fee standard line[%s] cannot be deleted because realization run[%s] has payment allocation", stdLine.ID, run.ID.ID) + } + + if charge.Realizations.CurrentRun != nil && charge.Realizations.CurrentRun.ID.ID == run.ID.ID { + return fmt.Errorf("flat fee standard line[%s] cannot be deleted because realization run[%s] is still current for charge[%s]", stdLine.ID, run.ID.ID, charge.ID) + } + + currency, err := currencyx.NewCurrencyBuilder(currencyx.CurrencyTypeFiat). + WithCode(charge.Intent.GetCurrency()). + Build() + if err != nil { + return fmt.Errorf("getting currency calculator for charge[%s]: %w", charge.ID, err) + } + + if _, err := e.service.realizations.CorrectAllCredits(ctx, flatfeerealizations.CorrectAllCreditRealizationsInput{ + Charge: charge, + Run: run, + AllocateAt: flatfee.UsageBookedAt(charge.Intent.GetEffectivePaymentTerm(), run.ServicePeriod), + CurrencyCalculator: currency, + }); err != nil { + return fmt.Errorf("correcting credits for deleted flat fee standard line[%s] run[%s]: %w", stdLine.ID, run.ID.ID, err) + } + + if err := e.service.adapter.UpsertDetailedLines(ctx, run.ID, nil); err != nil { + return fmt.Errorf("deleting detailed lines for deleted flat fee standard line[%s] run[%s]: %w", stdLine.ID, run.ID.ID, err) + } + + if _, err := e.service.adapter.UpdateRealizationRun(ctx, flatfee.UpdateRealizationRunInput{ + ID: run.ID, + DeletedAt: mo.Some(lo.ToPtr(clock.Now())), + }); err != nil { + return fmt.Errorf("marking realization run[%s] deleted for flat fee standard line[%s]: %w", run.ID.ID, stdLine.ID, err) + } + } + + return nil +} + +func (e *LineEngine) OnUnsupportedCreditNote(ctx context.Context, input billing.OnUnsupportedCreditNoteInput) error { + if err := input.Validate(); err != nil { + return fmt.Errorf("validating input: %w", err) + } + + chargesByID, err := e.getChargesForStandardLineEvent(ctx, input, meta.Expands{ + meta.ExpandRealizations, + }) + if err != nil { + return fmt.Errorf("getting flat fee charges for unsupported credit note: %w", err) + } + + for _, stdLine := range input.Lines { + charge, ok := chargesByID[*stdLine.ChargeID] + if !ok { + return fmt.Errorf("flat fee charge[%s] not found for unsupported credit note line[%s]", *stdLine.ChargeID, stdLine.ID) + } + + // Unsupported credit notes void the run for future billing history, but + // they must not mark it deleted; deleted runs mean invoice/ledger cleanup + // already happened, while this state preserves audit history. + run, err := charge.Realizations.GetByLineID(stdLine.ID) + if err != nil { + return err + } + + if run.InvoiceID == nil || *run.InvoiceID != input.Invoice.ID { + return fmt.Errorf("flat fee standard line[%s] cannot be marked unsupported credit note because realization run[%s] is not associated with invoice[%s]", stdLine.ID, run.ID.ID, input.Invoice.ID) + } + + if run.DeletedAt != nil { + return fmt.Errorf("flat fee standard line[%s] cannot be marked unsupported credit note because realization run[%s] is already deleted", stdLine.ID, run.ID.ID) + } + + if run.Type == flatfee.RealizationRunTypeInvalidDueToUnsupportedCreditNote { + continue + } + + if _, err := e.service.adapter.UpdateRealizationRun(ctx, flatfee.UpdateRealizationRunInput{ + ID: run.ID, + Type: mo.Some(flatfee.RealizationRunTypeInvalidDueToUnsupportedCreditNote), + }); err != nil { + return fmt.Errorf("marking realization run[%s] invalid due to unsupported credit note for flat fee standard line[%s]: %w", run.ID.ID, stdLine.ID, err) + } + } + + return nil +} + +func (e *LineEngine) newStateMachineForStandardLine(ctx context.Context, stdLine *billing.StandardLine) (StateMachine, error) { + if stdLine == nil { + return nil, fmt.Errorf("flat fee standard line is nil") + } + + if stdLine.ChargeID == nil || *stdLine.ChargeID == "" { + return nil, fmt.Errorf("flat fee standard line[%s]: charge id is required", stdLine.ID) + } + + charge, err := e.service.GetByID(ctx, flatfee.GetByIDInput{ + ChargeID: meta.ChargeID{ + Namespace: stdLine.Namespace, + ID: *stdLine.ChargeID, + }, + Expands: meta.Expands{ + meta.ExpandRealizations, + }, + }) + if err != nil { + return nil, fmt.Errorf("getting flat fee charge for line[%s]: %w", stdLine.ID, err) + } + + if charge.Intent.GetSettlementMode() != productcatalog.CreditThenInvoiceSettlementMode { + return nil, fmt.Errorf( + "flat fee standard line[%s]: unsupported settlement mode for standard invoice lifecycle: %s", + stdLine.ID, + charge.Intent.GetSettlementMode(), + ) + } + + stateMachine, err := e.service.newStateMachineForCharge(charge) + if err != nil { + return nil, fmt.Errorf("new state machine for flat fee charge[%s]: %w", charge.ID, err) + } + + return stateMachine, nil +} + +func (e *LineEngine) getChargesForStandardLineEvent(ctx context.Context, input billing.StandardLineEventInput, expands meta.Expands) (map[string]flatfee.Charge, error) { + chargeIDs := make([]string, 0, len(input.Lines)) + seenChargeIDs := make(map[string]struct{}, len(input.Lines)) + + for _, stdLine := range input.Lines { + if stdLine.ChargeID == nil || *stdLine.ChargeID == "" { + return nil, fmt.Errorf("flat fee standard line[%s]: charge id is required", stdLine.ID) + } + + if stdLine.Namespace != input.Invoice.Namespace { + return nil, fmt.Errorf("flat fee standard line[%s]: namespace %s does not match invoice namespace %s", stdLine.ID, stdLine.Namespace, input.Invoice.Namespace) + } + + if _, ok := seenChargeIDs[*stdLine.ChargeID]; ok { + continue + } + + seenChargeIDs[*stdLine.ChargeID] = struct{}{} + chargeIDs = append(chargeIDs, *stdLine.ChargeID) + } + + charges, err := e.service.GetByIDs(ctx, flatfee.GetByIDsInput{ + Namespace: input.Invoice.Namespace, + IDs: chargeIDs, + Expands: expands, + }) + if err != nil { + return nil, fmt.Errorf("getting flat fee charges: %w", err) + } + + return lo.KeyBy(charges, func(charge flatfee.Charge) string { + return charge.ID + }), nil +} + +func (e *LineEngine) OnInvoiceIssued(ctx context.Context, input billing.OnInvoiceIssuedInput) error { + if err := input.Validate(); err != nil { + return fmt.Errorf("validating input: %w", err) + } + + for _, stdLine := range input.Lines { + stateMachine, err := e.newStateMachineForStandardLine(ctx, stdLine) + if err != nil { + return err + } + + if err := stateMachine.FireAndActivate(ctx, meta.TriggerInvoiceIssued, billing.StandardLineWithInvoiceHeader{ + Line: stdLine, + Invoice: input.Invoice, + }); err != nil { + return fmt.Errorf("triggering invoice_issued for charge[%s]: %w", stateMachine.GetCharge().ID, err) + } + + if _, err := stateMachine.AdvanceUntilStateStable(ctx); err != nil { + return fmt.Errorf("advancing flat fee charge[%s] after invoice_issued: %w", stateMachine.GetCharge().ID, err) + } + } + + return nil +} + +func (e *LineEngine) OnPaymentAuthorized(ctx context.Context, input billing.OnPaymentAuthorizedInput) error { + if err := input.Validate(); err != nil { + return fmt.Errorf("validating input: %w", err) + } + + for _, stdLine := range input.Lines { + stateMachine, err := e.newStateMachineForStandardLine(ctx, stdLine) + if err != nil { + return err + } + + if err := e.service.postInvoicePaymentAuthorized(ctx, stateMachine.GetCharge(), billing.StandardLineWithInvoiceHeader{ + Line: stdLine, + Invoice: input.Invoice, + }); err != nil { + return fmt.Errorf("authorizing invoice payment for charge[%s]: %w", stateMachine.GetCharge().ID, err) + } + } + + return nil +} + +func (e *LineEngine) OnPaymentSettled(ctx context.Context, input billing.OnPaymentSettledInput) error { + if err := input.Validate(); err != nil { + return fmt.Errorf("validating input: %w", err) + } + + for _, stdLine := range input.Lines { + stateMachine, err := e.newStateMachineForStandardLine(ctx, stdLine) + if err != nil { + return err + } + + if err := e.service.postInvoicePaymentSettled(ctx, stateMachine.GetCharge(), billing.StandardLineWithInvoiceHeader{ + Line: stdLine, + Invoice: input.Invoice, + }); err != nil { + return fmt.Errorf("settling invoice payment for charge[%s]: %w", stateMachine.GetCharge().ID, err) + } + + if err := stateMachine.RefetchCharge(ctx); err != nil { + return fmt.Errorf("refetching flat fee charge[%s]: %w", stateMachine.GetCharge().ID, err) + } + + if _, err := stateMachine.AdvanceUntilStateStable(ctx); err != nil { + return fmt.Errorf("advancing flat fee charge[%s] after payment settlement: %w", stateMachine.GetCharge().ID, err) + } + } + + return nil +} diff --git a/billing/charges/flatfee/service/linemapper.go b/billing/charges/flatfee/service/linemapper.go new file mode 100644 index 0000000000000000000000000000000000000000..d1bd014783de91aaef5ded7ce15caab3b9c4cb7a --- /dev/null +++ b/billing/charges/flatfee/service/linemapper.go @@ -0,0 +1,71 @@ +package service + +import ( + "fmt" + "time" + + "github.com/samber/lo" + + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/billing/charges/flatfee" + "github.com/openmeterio/openmeter/pkg/currencyx" +) + +func populateFlatFeeStandardLineFromRun(stdLine *billing.StandardLine, run flatfee.RealizationRun) error { + currency, err := currencyx.NewCurrencyBuilder(currencyx.CurrencyTypeFiat). + WithCode(stdLine.Currency). + Build() + if err != nil { + return fmt.Errorf("creating currency calculator: %w", err) + } + + creditsApplied, err := run.CreditRealizations.AsCreditsApplied() + if err != nil { + return err + } + + stdLine.CreditsApplied = creditsApplied + + mappedDetailedLines, err := mapFlatFeeDetailedLines(stdLine, run) + if err != nil { + return fmt.Errorf("mapping run detailed lines: %w", err) + } + + mappedDetailedLines, err = mappedDetailedLines.WithCreditsApplied(stdLine.CreditsApplied, currency) + if err != nil { + return fmt.Errorf("applying run credits to detailed lines: %w", err) + } + + stdLine.DetailedLines = stdLine.DetailedLinesWithIDReuse(mappedDetailedLines) + stdLine.Totals = stdLine.DetailedLines.SumTotals().RoundToPrecision(currency) + + expectedTotals := run.Totals.RoundToPrecision(currency) + if !stdLine.Totals.Equal(expectedTotals) { + return fmt.Errorf("mapped line totals do not match run totals [line_id=%s run_id=%s line_total=%s run_total=%s]", + stdLine.ID, run.ID.ID, stdLine.Totals.Total.String(), expectedTotals.Total.String()) + } + + return nil +} + +func mapFlatFeeDetailedLines(stdLine *billing.StandardLine, run flatfee.RealizationRun) (billing.DetailedLines, error) { + if run.DetailedLines.IsAbsent() { + return nil, fmt.Errorf("run %s detailed lines must be expanded", run.ID.ID) + } + + return lo.Map(run.DetailedLines.OrEmpty(), func(line flatfee.DetailedLine, _ int) billing.DetailedLine { + base := line.Clone() + base.Namespace = stdLine.Namespace + base.ID = "" + base.CreatedAt = time.Time{} + base.UpdatedAt = time.Time{} + base.DeletedAt = nil + + return billing.DetailedLine{ + DetailedLineBase: billing.DetailedLineBase{ + Base: base, + InvoiceID: stdLine.InvoiceID, + }, + } + }), nil +} diff --git a/billing/charges/flatfee/service/manualedit.go b/billing/charges/flatfee/service/manualedit.go new file mode 100644 index 0000000000000000000000000000000000000000..91da1e2269609c773643e7661d8acff6ee6e52af --- /dev/null +++ b/billing/charges/flatfee/service/manualedit.go @@ -0,0 +1,179 @@ +package service + +import ( + "context" + "fmt" + + "github.com/samber/lo" + + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/billing/charges/flatfee" + "github.com/openmeterio/openmeter/openmeter/billing/charges/meta" + "github.com/openmeterio/openmeter/openmeter/productcatalog" + "github.com/openmeterio/openmeter/pkg/models" +) + +func (s *CreditThenInvoiceStateMachine) UnsupportedLineManualEditOperation(_ context.Context, _ meta.PatchLineManualEdit) error { + return models.NewGenericPreConditionFailedError( + fmt.Errorf("cannot manually edit flat-fee charge in status %s; retry after billing advances", s.Charge.Status), + ) +} + +func (s *CreditThenInvoiceStateMachine) intentMutableFieldsFromLineManualEdit(line billing.GenericInvoiceLineReader) (flatfee.IntentMutableFields, error) { + if line == nil { + return flatfee.IntentMutableFields{}, fmt.Errorf("line is required") + } + + price := line.GetPrice() + if price == nil { + return flatfee.IntentMutableFields{}, fmt.Errorf("line[%s]: price is required", line.GetID()) + } + + flatPrice, err := price.AsFlat() + if err != nil { + return flatfee.IntentMutableFields{}, fmt.Errorf("getting flat price from line[%s]: %w", line.GetID(), err) + } + + out := s.Charge.Intent.GetEffectiveIntent().IntentMutableFields + out.Name = line.GetName() + out.Description = line.GetDescription() + out.Metadata = line.GetMetadata().Clone() + out.ServicePeriod = line.GetServicePeriod() + if invoiceAtAccessor, ok := line.(billing.InvoiceAtAccessor); ok { + out.InvoiceAt = invoiceAtAccessor.GetInvoiceAt() + } else { + // Standard invoice lines do not carry their own invoice-at value, so + // keep the current effective charge intent's invoice-at for standard-line edits. + out.InvoiceAt = s.Charge.Intent.GetEffectiveInvoiceAt() + } + out.PaymentTerm = flatPrice.PaymentTerm + out.AmountBeforeProration = flatPrice.Amount + out.PercentageDiscounts = line.GetRateCardDiscounts().Percentage.CloneOrNil() + + out = out.Normalized(s.Charge.Intent.GetCurrency()) + if err := out.Validate(); err != nil { + return flatfee.IntentMutableFields{}, err + } + + return out, nil +} + +func intentFromManualCreatedLine( + ctx context.Context, + invoice billing.GenericInvoiceReader, + line billing.GenericInvoiceLineReader, + defaultInvoicingTaxCodeResolver billing.DefaultTaxCodeResolver, +) (flatfee.Intent, error) { + if invoice == nil { + return flatfee.Intent{}, fmt.Errorf("invoice is required") + } + + if line == nil { + return flatfee.Intent{}, fmt.Errorf("line is required") + } + + if line.GetID() == "" { + return flatfee.Intent{}, fmt.Errorf("line id is required") + } + + if chargeID := line.GetChargeID(); chargeID != nil && *chargeID != "" { + return flatfee.Intent{}, fmt.Errorf("line[%s]: charge id must be empty for manual create", line.GetID()) + } + + price := line.GetPrice() + if price == nil { + return flatfee.Intent{}, fmt.Errorf("line[%s]: price is required", line.GetID()) + } + + flatPrice, err := price.AsFlat() + if err != nil { + return flatfee.Intent{}, fmt.Errorf("getting flat price from line[%s]: %w", line.GetID(), err) + } + + annotations, err := line.GetAnnotations().Clone() + if err != nil { + return flatfee.Intent{}, fmt.Errorf("cloning line[%s] annotations: %w", line.GetID(), err) + } + + servicePeriod := line.GetServicePeriod() + invoiceAt := line.GetCreatedAt() + if invoiceAtAccessor, ok := line.(billing.InvoiceAtAccessor); ok { + invoiceAt = invoiceAtAccessor.GetInvoiceAt() + } else { + // New standard lines do not expose invoice-at as generic scheduling + // input. For charge-backed manual creates, derive the intent schedule + // from the flat-fee payment term instead of the line's display-only + // StandardLine.InvoiceAt field. + switch flatPrice.PaymentTerm { + case productcatalog.InAdvancePaymentTerm: + invoiceAt = servicePeriod.From + case productcatalog.InArrearsPaymentTerm: + invoiceAt = servicePeriod.To + } + } + + taxConfig := productcatalog.TaxCodeConfig{} + if lineTaxConfig := line.GetTaxConfig(); lineTaxConfig != nil { + taxConfig = productcatalog.TaxCodeConfigFrom(lineTaxConfig.ToProductCatalog()) + } + + intent := flatfee.Intent{ + Intent: meta.Intent{ + ManagedBy: billing.ManuallyManagedLine, + CustomerID: invoice.GetCustomerID().ID, + Annotations: annotations, + Currency: line.GetCurrency(), + TaxConfig: taxConfig, + }, + IntentMutableFields: flatfee.IntentMutableFields{ + IntentMutableFields: meta.IntentMutableFields{ + Name: line.GetName(), + Description: line.GetDescription(), + Metadata: line.GetMetadata().Clone(), + ServicePeriod: servicePeriod, + FullServicePeriod: servicePeriod, + BillingPeriod: servicePeriod, + }, + InvoiceAt: invoiceAt, + PaymentTerm: flatPrice.PaymentTerm, + PercentageDiscounts: nil, + ProRating: productcatalog.ProRatingConfig{}, + AmountBeforeProration: flatPrice.Amount, + }, + FeatureKey: lo.EmptyableToPtr(line.GetFeatureKey()), + SettlementMode: productcatalog.CreditThenInvoiceSettlementMode, + } + + if line.GetRateCardDiscounts().Percentage != nil { + intent.PercentageDiscounts = lo.ToPtr(line.GetRateCardDiscounts().Percentage.Clone()) + } + + intent = intent.Normalized() + if intent.TaxConfig.TaxCodeID == "" { + if defaultInvoicingTaxCodeResolver == nil { + return flatfee.Intent{}, fmt.Errorf("line[%s]: default invoicing tax code resolver is required", line.GetID()) + } + + defaultTaxCodeID, err := defaultInvoicingTaxCodeResolver(ctx) + if err != nil { + return flatfee.Intent{}, fmt.Errorf("resolving default invoicing tax code: %w", err) + } + + intent.TaxConfig.TaxCodeID = defaultTaxCodeID + } + + if err := intent.Validate(); err != nil { + return flatfee.Intent{}, err + } + + amountAfterProration, err := intent.CalculateAmountAfterProration() + if err != nil { + return flatfee.Intent{}, fmt.Errorf("calculating amount after proration: %w", err) + } + + if amountAfterProration.IsZero() { + return flatfee.Intent{}, billing.ErrInvoiceLineZeroAmountCreate + } + + return intent, nil +} diff --git a/billing/charges/flatfee/service/payment.go b/billing/charges/flatfee/service/payment.go new file mode 100644 index 0000000000000000000000000000000000000000..6d563db8ce4b7c45df1a18140020123689cf765c --- /dev/null +++ b/billing/charges/flatfee/service/payment.go @@ -0,0 +1,179 @@ +package service + +import ( + "context" + "fmt" + + "github.com/alpacahq/alpacadecimal" + + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/billing/charges/flatfee" + "github.com/openmeterio/openmeter/openmeter/billing/charges/models/ledgertransaction" + "github.com/openmeterio/openmeter/openmeter/billing/charges/models/payment" + "github.com/openmeterio/openmeter/pkg/clock" + "github.com/openmeterio/openmeter/pkg/framework/transaction" +) + +func (s *service) postInvoicePaymentAuthorized(ctx context.Context, charge flatfee.Charge, lineWithHeader billing.StandardLineWithInvoiceHeader) error { + if err := lineWithHeader.Validate(); err != nil { + return fmt.Errorf("validating line with header: %w", err) + } + + return transaction.RunWithNoValue(ctx, s.adapter, func(ctx context.Context) error { + run, err := charge.Realizations.GetByLineID(lineWithHeader.Line.ID) + if err != nil { + return err + } + + if err := validatePaymentRunForLine(charge, run, lineWithHeader); err != nil { + return err + } + + if run.NoFiatTransactionRequired { + return nil + } + + if run.Payment != nil { + return payment.ErrPaymentAlreadyAuthorized. + WithAttrs(charge.ErrorAttributes()). + WithAttrs(run.Payment.ErrorAttributes()) + } + + paymentTotal, err := getPaymentTotal(run) + if err != nil { + return err + } + + eventAt := clock.Now() + ledgerTransactionGroupReference, err := s.handler.OnPaymentAuthorized(ctx, flatfee.OnPaymentAuthorizedInput{ + Charge: charge, + EventAt: eventAt, + Amount: paymentTotal, + }) + if err != nil { + return err + } + + newPaymentSettlement := payment.InvoicedCreate{ + Namespace: charge.Namespace, + LineID: lineWithHeader.Line.ID, + InvoiceID: lineWithHeader.Invoice.ID, + Base: payment.Base{ + ServicePeriod: run.ServicePeriod, + Amount: lineWithHeader.Line.Totals.Total, + Authorized: &ledgertransaction.TimedGroupReference{ + GroupReference: ledgertransaction.GroupReference{ + TransactionGroupID: ledgerTransactionGroupReference.TransactionGroupID, + }, + Time: eventAt, + }, + Status: payment.StatusAuthorized, + }, + } + + if _, err := s.adapter.CreatePayment(ctx, run.ID, newPaymentSettlement); err != nil { + return err + } + + return nil + }) +} + +func (s *service) postInvoicePaymentSettled(ctx context.Context, charge flatfee.Charge, lineWithHeader billing.StandardLineWithInvoiceHeader) error { + if err := lineWithHeader.Validate(); err != nil { + return fmt.Errorf("validating line with header: %w", err) + } + + return transaction.RunWithNoValue(ctx, s.adapter, func(ctx context.Context) error { + run, err := charge.Realizations.GetByLineID(lineWithHeader.Line.ID) + if err != nil { + return err + } + + if err := validatePaymentRunForLine(charge, run, lineWithHeader); err != nil { + return err + } + + if run.NoFiatTransactionRequired { + return nil + } + + if run.Payment == nil { + return payment.ErrCannotSettleNotAuthorizedPayment.WithAttrs(charge.ErrorAttributes()) + } + + paymentSettlement := *run.Payment + + if paymentSettlement.LineID != lineWithHeader.Line.ID { + return fmt.Errorf("payment settlement line ID does not match the line ID: %s != %s", paymentSettlement.LineID, lineWithHeader.Line.ID) + } + + if paymentSettlement.Status != payment.StatusAuthorized { + return payment.ErrPaymentAlreadySettled.WithAttrs(charge.ErrorAttributes()) + } + + paymentTotal, err := getPaymentTotal(run) + if err != nil { + return err + } + + eventAt := clock.Now() + ledgerTransactionGroupReference, err := s.handler.OnPaymentSettled(ctx, flatfee.OnPaymentSettledInput{ + Charge: charge, + EventAt: eventAt, + Amount: paymentTotal, + }) + if err != nil { + return err + } + + paymentSettlement.Settled = &ledgertransaction.TimedGroupReference{ + GroupReference: ledgertransaction.GroupReference{ + TransactionGroupID: ledgerTransactionGroupReference.TransactionGroupID, + }, + Time: eventAt, + } + + paymentSettlement.Status = payment.StatusSettled + + paymentSettlement, err = s.adapter.UpdatePayment(ctx, paymentSettlement) + if err != nil { + return err + } + + return nil + }) +} + +func getPaymentTotal(run flatfee.RealizationRun) (alpacadecimal.Decimal, error) { + if run.NoFiatTransactionRequired { + return alpacadecimal.Decimal{}, fmt.Errorf("fiat payment total is not required for no-fiat run[%s]", run.ID.ID) + } + + if run.AccruedUsage == nil { + return alpacadecimal.Decimal{}, fmt.Errorf("accrued invoice usage is required for run[%s]", run.ID.ID) + } + + amount := run.AccruedUsage.Totals.Total + if amount.IsZero() { + return alpacadecimal.Decimal{}, fmt.Errorf("non-zero accrued invoice usage total is required for fiat-backed run[%s]", run.ID.ID) + } + + return amount, nil +} + +func validatePaymentRunForLine(charge flatfee.Charge, run flatfee.RealizationRun, lineWithHeader billing.StandardLineWithInvoiceHeader) error { + if lineWithHeader.Line.ChargeID == nil || *lineWithHeader.Line.ChargeID != charge.ID { + return fmt.Errorf("line charge id must match charge") + } + + if run.LineID == nil || *run.LineID != lineWithHeader.Line.ID { + return fmt.Errorf("realization run line id must match standard line") + } + + if run.InvoiceID == nil || *run.InvoiceID != lineWithHeader.Invoice.ID { + return fmt.Errorf("realization run invoice id must match invoice") + } + + return nil +} diff --git a/billing/charges/flatfee/service/payment_test.go b/billing/charges/flatfee/service/payment_test.go new file mode 100644 index 0000000000000000000000000000000000000000..9fda6eb9384f3ab46267a4c3381f3daf08b2ce40 --- /dev/null +++ b/billing/charges/flatfee/service/payment_test.go @@ -0,0 +1,108 @@ +package service + +import ( + "testing" + "time" + + "github.com/alpacahq/alpacadecimal" + "github.com/stretchr/testify/require" + + "github.com/openmeterio/openmeter/openmeter/billing/charges/flatfee" + "github.com/openmeterio/openmeter/openmeter/billing/charges/models/invoicedusage" + "github.com/openmeterio/openmeter/openmeter/billing/models/totals" + "github.com/openmeterio/openmeter/pkg/models" + "github.com/openmeterio/openmeter/pkg/timeutil" +) + +func TestGetPaymentTotal(t *testing.T) { + servicePeriod := timeutil.ClosedPeriod{ + From: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + To: time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC), + } + + baseRun := flatfee.RealizationRun{ + RealizationRunBase: flatfee.RealizationRunBase{ + ID: flatfee.RealizationRunID{ + Namespace: "ns", + ID: "run", + }, + ManagedModel: models.ManagedModel{ + CreatedAt: servicePeriod.From, + UpdatedAt: servicePeriod.From, + }, + Type: flatfee.RealizationRunTypeFinalRealization, + InitialType: flatfee.RealizationRunTypeFinalRealization, + ServicePeriod: servicePeriod, + AmountAfterProration: alpacadecimal.NewFromInt(5), + }, + } + + t.Run("missing accrued usage returns an error", func(t *testing.T) { + // given: + // - a run has no accrued invoice usage + // when: + // - the payment total is requested + // then: + // - the payment path fails instead of silently booking zero + _, err := getPaymentTotal(baseRun) + require.Error(t, err) + require.Contains(t, err.Error(), "accrued invoice usage is required") + }) + + t.Run("zero total on fiat-backed run returns an error", func(t *testing.T) { + // given: + // - a fiat-backed run has accrued usage with a zero total + // when: + // - the payment total is requested + // then: + // - the payment path fails instead of hiding the inconsistent state + run := baseRun + run.AccruedUsage = &invoicedusage.AccruedUsage{ + ServicePeriod: servicePeriod, + Totals: totals.Totals{}, + } + + _, err := getPaymentTotal(run) + require.Error(t, err) + require.Contains(t, err.Error(), "non-zero accrued invoice usage total is required") + }) + + t.Run("no-fiat run returns an error", func(t *testing.T) { + // given: + // - a no-fiat run has accrued usage with a zero total + // when: + // - the payment total is requested + // then: + // - the payment path fails because no-fiat runs should skip payment booking + run := baseRun + run.NoFiatTransactionRequired = true + run.AccruedUsage = &invoicedusage.AccruedUsage{ + ServicePeriod: servicePeriod, + Totals: totals.Totals{}, + } + + _, err := getPaymentTotal(run) + require.Error(t, err) + require.Contains(t, err.Error(), "fiat payment total is not required") + }) + + t.Run("positive total is returned", func(t *testing.T) { + // given: + // - a run has positive accrued invoice usage + // when: + // - the payment total is requested + // then: + // - the accrued total is returned exactly + run := baseRun + run.AccruedUsage = &invoicedusage.AccruedUsage{ + ServicePeriod: servicePeriod, + Totals: totals.Totals{ + Total: alpacadecimal.NewFromInt(5), + }, + } + + total, err := getPaymentTotal(run) + require.NoError(t, err) + require.Equal(t, float64(5), total.InexactFloat64()) + }) +} diff --git a/billing/charges/flatfee/service/realizations/correct.go b/billing/charges/flatfee/service/realizations/correct.go new file mode 100644 index 0000000000000000000000000000000000000000..da7630909555a99cdfa767bb0293702b3f230bc2 --- /dev/null +++ b/billing/charges/flatfee/service/realizations/correct.go @@ -0,0 +1,229 @@ +package realizations + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/alpacahq/alpacadecimal" + "github.com/samber/lo" + + "github.com/openmeterio/openmeter/openmeter/billing/charges/flatfee" + "github.com/openmeterio/openmeter/openmeter/billing/charges/models/creditrealization" + "github.com/openmeterio/openmeter/pkg/currencyx" + "github.com/openmeterio/openmeter/pkg/models" +) + +// ReconcileCreditRealizationsInput describes the desired credit allocation +// total for a run whose billable amount changed while the invoice line stayed +// mutable. +type ReconcileCreditRealizationsInput struct { + Charge flatfee.Charge + Run flatfee.RealizationRun + AllocateAt time.Time + TargetAmount alpacadecimal.Decimal + CurrencyCalculator currencyx.Currency +} + +func (i ReconcileCreditRealizationsInput) Validate() error { + var errs []error + + if err := i.Charge.Validate(); err != nil { + errs = append(errs, fmt.Errorf("charge: %w", err)) + } + + if err := i.Run.Validate(); err != nil { + errs = append(errs, fmt.Errorf("run: %w", err)) + } + + if i.AllocateAt.IsZero() { + errs = append(errs, errors.New("allocate at is required")) + } + + if i.TargetAmount.IsNegative() { + errs = append(errs, errors.New("target amount must be zero or positive")) + } + + if i.CurrencyCalculator == nil { + errs = append(errs, errors.New("currency calculator is required")) + } else { + if err := i.CurrencyCalculator.Validate(); err != nil { + errs = append(errs, fmt.Errorf("currency calculator: %w", err)) + } + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +type ReconcileCreditRealizationsResult struct { + Delta alpacadecimal.Decimal + Realizations creditrealization.Realizations +} + +// ReconcileCredits adjusts a run's credit realizations to match TargetAmount. +// +// A positive delta allocates additional credits through the flat-fee handler. +// A negative delta creates credit corrections against the existing allocation +// lineage. A zero delta deliberately does nothing, because the current run is +// already backed by the right amount of credit realization rows. +func (s *Service) ReconcileCredits(ctx context.Context, in ReconcileCreditRealizationsInput) (ReconcileCreditRealizationsResult, error) { + // NOTE: its not pretty to validate input twice, but better to be on the safe side. + if err := in.Validate(); err != nil { + return ReconcileCreditRealizationsResult{}, err + } + + in.TargetAmount = in.CurrencyCalculator.RoundToPrecision(in.TargetAmount) + + if err := in.Validate(); err != nil { + return ReconcileCreditRealizationsResult{}, err + } + + currentAmount := in.CurrencyCalculator.RoundToPrecision(in.Run.CreditRealizations.Sum()) + delta := in.CurrencyCalculator.RoundToPrecision(in.TargetAmount.Sub(currentAmount)) + + result := ReconcileCreditRealizationsResult{ + Delta: delta, + } + + switch { + case delta.IsPositive(): + // The mutable standard line grew, so the run needs extra credit + // allocations for the new amount. + handlerInput := flatfee.OnAllocateCreditsInput{ + Charge: in.Charge, + ServicePeriod: in.Run.ServicePeriod, + BookedAt: in.AllocateAt, + PreTaxAmountToAllocate: delta, + } + if err := handlerInput.Validate(); err != nil { + return ReconcileCreditRealizationsResult{}, fmt.Errorf("validating allocate credits input: %w", err) + } + + creditAllocations, err := s.handler.OnAllocateCredits(ctx, handlerInput) + if err != nil { + return ReconcileCreditRealizationsResult{}, fmt.Errorf("allocate credits for flat fee: %w", err) + } + + creditAllocationsWithLineID := creditrealization.CreateAllocationInputs(lo.Map(creditAllocations, func(allocation creditrealization.CreateAllocationInput, _ int) creditrealization.CreateAllocationInput { + allocation.LineID = in.Run.LineID + return allocation + })) + + if len(creditAllocationsWithLineID) > 0 { + realizations, err := s.createCreditAllocations(ctx, in.Charge, in.Run.ID, creditAllocationsWithLineID.AsCreateInputs()) + if err != nil { + return ReconcileCreditRealizationsResult{}, fmt.Errorf("create credit allocations: %w", err) + } + + result.Realizations = realizations + } + case delta.IsNegative(): + // The mutable standard line shrank. Correct the existing credit + // realization lineage instead of creating unrelated negative rows. + realizationIDs := lo.Map(in.Run.CreditRealizations, func(realization creditrealization.Realization, _ int) string { + return realization.ID + }) + lineageSegmentsByRealization, err := s.lineage.LoadActiveSegmentsByRealizationID(ctx, in.Charge.Namespace, realizationIDs) + if err != nil { + return ReconcileCreditRealizationsResult{}, fmt.Errorf("load active lineage segments for run: %w", err) + } + + corrections, err := in.Run.CreditRealizations.Correct( + delta, + in.CurrencyCalculator, + func(req creditrealization.CorrectionRequest) (creditrealization.CreateCorrectionInputs, error) { + return s.handler.OnCorrectCreditAllocations(ctx, flatfee.CorrectCreditAllocationsInput{ + Charge: in.Charge, + BookedAt: in.AllocateAt, + Corrections: req, + LineageSegmentsByRealization: lineageSegmentsByRealization, + }) + }, + ) + if err != nil { + return ReconcileCreditRealizationsResult{}, fmt.Errorf("correct credits for run %s: %w", in.Run.ID.ID, err) + } + + if len(corrections) > 0 { + realizations, err := s.createCreditAllocations(ctx, in.Charge, in.Run.ID, corrections) + if err != nil { + return ReconcileCreditRealizationsResult{}, fmt.Errorf("create credit corrections for run %s: %w", in.Run.ID.ID, err) + } + + result.Realizations = realizations + } + case delta.IsZero(): + } + + return result, nil +} + +type CorrectAllCreditRealizationsInput struct { + Charge flatfee.Charge + Run flatfee.RealizationRun + AllocateAt time.Time + CurrencyCalculator currencyx.Currency +} + +func (i CorrectAllCreditRealizationsInput) Validate() error { + if err := i.Charge.Validate(); err != nil { + return fmt.Errorf("charge: %w", err) + } + + if err := i.Run.Validate(); err != nil { + return fmt.Errorf("run: %w", err) + } + + if i.AllocateAt.IsZero() { + return fmt.Errorf("allocate at is required") + } + + if err := i.CurrencyCalculator.Validate(); err != nil { + return fmt.Errorf("currency calculator: %w", err) + } + + return nil +} + +type CorrectAllCreditRealizationsResult struct { + Realizations creditrealization.Realizations +} + +func (s *Service) CorrectAllCredits(ctx context.Context, in CorrectAllCreditRealizationsInput) (CorrectAllCreditRealizationsResult, error) { + if err := in.Validate(); err != nil { + return CorrectAllCreditRealizationsResult{}, err + } + + realizationIDs := lo.Map(in.Run.CreditRealizations, func(realization creditrealization.Realization, _ int) string { + return realization.ID + }) + lineageSegmentsByRealization, err := s.lineage.LoadActiveSegmentsByRealizationID(ctx, in.Charge.Namespace, realizationIDs) + if err != nil { + return CorrectAllCreditRealizationsResult{}, fmt.Errorf("load active lineage segments: %w", err) + } + + corrections, err := in.Run.CreditRealizations.CorrectAll(in.CurrencyCalculator, func(req creditrealization.CorrectionRequest) (creditrealization.CreateCorrectionInputs, error) { + return s.handler.OnCorrectCreditAllocations(ctx, flatfee.CorrectCreditAllocationsInput{ + Charge: in.Charge, + BookedAt: in.AllocateAt, + Corrections: req, + LineageSegmentsByRealization: lineageSegmentsByRealization, + }) + }) + if err != nil { + return CorrectAllCreditRealizationsResult{}, fmt.Errorf("correct credits: %w", err) + } + + result := CorrectAllCreditRealizationsResult{} + if len(corrections) > 0 { + realizations, err := s.createCreditAllocations(ctx, in.Charge, in.Run.ID, corrections) + if err != nil { + return CorrectAllCreditRealizationsResult{}, fmt.Errorf("create credit corrections: %w", err) + } + + result.Realizations = realizations + } + + return result, nil +} diff --git a/billing/charges/flatfee/service/realizations/creditsonly.go b/billing/charges/flatfee/service/realizations/creditsonly.go new file mode 100644 index 0000000000000000000000000000000000000000..fbe36885011b9b36f4303324944dc49163857551 --- /dev/null +++ b/billing/charges/flatfee/service/realizations/creditsonly.go @@ -0,0 +1,122 @@ +package realizations + +import ( + "context" + "fmt" + + "github.com/alpacahq/alpacadecimal" + "github.com/samber/mo" + + "github.com/openmeterio/openmeter/openmeter/billing/charges/flatfee" + "github.com/openmeterio/openmeter/openmeter/billing/charges/models/creditrealization" + "github.com/openmeterio/openmeter/openmeter/billing/models/totals" + "github.com/openmeterio/openmeter/pkg/currencyx" + "github.com/openmeterio/openmeter/pkg/framework/transaction" + "github.com/openmeterio/openmeter/pkg/models" +) + +type AllocateCreditsOnlyInput struct { + Charge flatfee.Charge + Amount alpacadecimal.Decimal + CurrencyCalculator currencyx.Currency +} + +func (i AllocateCreditsOnlyInput) Validate() error { + if err := i.Charge.Validate(); err != nil { + return fmt.Errorf("charge: %w", err) + } + + if i.Amount.IsNegative() { + return fmt.Errorf("amount cannot be negative") + } + + if i.CurrencyCalculator == nil { + return fmt.Errorf("currency calculator is required") + } + + if i.CurrencyCalculator != nil { + if err := i.CurrencyCalculator.Validate(); err != nil { + return fmt.Errorf("currency calculator: %w", err) + } + } + + return nil +} + +type AllocateCreditsOnlyResult struct { + Allocated alpacadecimal.Decimal + Realizations creditrealization.Realizations +} + +func (s *Service) AllocateCreditsOnly(ctx context.Context, in AllocateCreditsOnlyInput) (AllocateCreditsOnlyResult, error) { + if err := in.Validate(); err != nil { + return AllocateCreditsOnlyResult{}, err + } + + in.Amount = in.CurrencyCalculator.RoundToPrecision(in.Amount) + + if in.Amount.IsZero() { + return AllocateCreditsOnlyResult{}, nil + } + + servicePeriod := in.Charge.Intent.GetEffectiveServicePeriod() + input := flatfee.OnAllocateCreditsInput{ + Charge: in.Charge, + ServicePeriod: servicePeriod, + BookedAt: flatfee.UsageBookedAt(in.Charge.Intent.GetEffectivePaymentTerm(), servicePeriod), + PreTaxAmountToAllocate: in.Amount, + } + if err := input.Validate(); err != nil { + return AllocateCreditsOnlyResult{}, fmt.Errorf("validate input: %w", err) + } + + creditAllocations, err := s.handler.OnAllocateCredits(ctx, input) + if err != nil { + return AllocateCreditsOnlyResult{}, fmt.Errorf("allocate credits: %w", err) + } + + allocated := in.CurrencyCalculator.RoundToPrecision(creditAllocations.Sum()) + if !allocated.Equal(in.Amount) { + return AllocateCreditsOnlyResult{}, models.NewGenericValidationError( + fmt.Errorf("credit allocations do not match total [charge_id=%s, total=%s, allocations_sum=%s]", + in.Charge.ID, in.Amount.String(), allocated.String()), + ) + } + + result := AllocateCreditsOnlyResult{ + Allocated: allocated, + } + + if len(creditAllocations) > 0 { + if in.Charge.Realizations.CurrentRun == nil { + return AllocateCreditsOnlyResult{}, fmt.Errorf("current run is required") + } + + realizations, err := transaction.Run(ctx, s.adapter, func(ctx context.Context) (creditrealization.Realizations, error) { + realizations, err := s.createCreditAllocations(ctx, in.Charge, in.Charge.Realizations.CurrentRun.ID, creditAllocations.AsCreateInputs()) + if err != nil { + return nil, fmt.Errorf("create credit allocations: %w", err) + } + + if _, err := s.adapter.UpdateRealizationRun(ctx, flatfee.UpdateRealizationRunInput{ + ID: in.Charge.Realizations.CurrentRun.ID, + Totals: mo.Some(totals.Totals{ + Amount: allocated, + CreditsTotal: allocated, + Total: alpacadecimal.Zero, + }), + }); err != nil { + return nil, fmt.Errorf("update credit-only run totals: %w", err) + } + + return realizations, nil + }) + if err != nil { + return AllocateCreditsOnlyResult{}, err + } + + result.Realizations = realizations + } + + return result, nil +} diff --git a/billing/charges/flatfee/service/realizations/credittheninvoice.go b/billing/charges/flatfee/service/realizations/credittheninvoice.go new file mode 100644 index 0000000000000000000000000000000000000000..0c98f6069a5fd7b1963382f598a48a3373354580 --- /dev/null +++ b/billing/charges/flatfee/service/realizations/credittheninvoice.go @@ -0,0 +1,397 @@ +package realizations + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/samber/lo" + "github.com/samber/mo" + + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/billing/charges/flatfee" + "github.com/openmeterio/openmeter/openmeter/billing/charges/invoiceupdater" + "github.com/openmeterio/openmeter/openmeter/billing/charges/models/creditrealization" + billingrating "github.com/openmeterio/openmeter/openmeter/billing/rating" + "github.com/openmeterio/openmeter/openmeter/billing/service/invoicecalc" + "github.com/openmeterio/openmeter/pkg/currencyx" + "github.com/openmeterio/openmeter/pkg/framework/transaction" + "github.com/openmeterio/openmeter/pkg/models" +) + +type StartCreditThenInvoiceRunInput struct { + Charge flatfee.Charge + Line billing.StandardLine + Invoice billing.StandardInvoice +} + +func (i StartCreditThenInvoiceRunInput) Validate() error { + var errs []error + + if err := i.Charge.Validate(); err != nil { + errs = append(errs, fmt.Errorf("charge: %w", err)) + } + + if err := i.Line.Validate(); err != nil { + errs = append(errs, fmt.Errorf("line: %w", err)) + } + + if err := i.Invoice.Validate(); err != nil { + errs = append(errs, fmt.Errorf("invoice: %w", err)) + } + + lineChargeID := "" + if i.Line.ChargeID != nil { + lineChargeID = *i.Line.ChargeID + } + + if i.Line.ChargeID == nil || *i.Line.ChargeID != i.Charge.ID { + errs = append(errs, fmt.Errorf("line charge id mismatch: got %s, want %s", lineChargeID, i.Charge.ID)) + } + + if i.Line.InvoiceID != i.Invoice.ID { + errs = append(errs, fmt.Errorf("line invoice id mismatch: got %s, want %s", i.Line.InvoiceID, i.Invoice.ID)) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +type StartCreditThenInvoiceRunResult struct { + Run flatfee.RealizationRun +} + +func (s *Service) StartCreditThenInvoiceRun(ctx context.Context, in StartCreditThenInvoiceRunInput) (StartCreditThenInvoiceRunResult, error) { + if err := in.Validate(); err != nil { + return StartCreditThenInvoiceRunResult{}, err + } + + return transaction.Run(ctx, s.adapter, func(ctx context.Context) (StartCreditThenInvoiceRunResult, error) { + currency, err := currencyx.NewCurrencyBuilder(currencyx.CurrencyTypeFiat). + WithCode(in.Charge.Intent.GetCurrency()). + Build() + if err != nil { + return StartCreditThenInvoiceRunResult{}, fmt.Errorf("get currency calculator: %w", err) + } + + amountAfterProration, err := invoiceupdater.GetFlatFeePerUnitAmount(&in.Line) + if err != nil { + return StartCreditThenInvoiceRunResult{}, fmt.Errorf("get flat fee line amount: %w", err) + } + + amountAfterProration = currency.RoundToPrecision(amountAfterProration) + + runBase, err := s.adapter.CreateCurrentRun(ctx, flatfee.CreateCurrentRunInput{ + Charge: in.Charge.ChargeBase, + ServicePeriod: in.Line.Period, + AmountAfterProration: amountAfterProration, + NoFiatTransactionRequired: amountAfterProration.IsZero(), + Immutable: false, + LineID: lo.ToPtr(in.Line.ID), + InvoiceID: lo.ToPtr(in.Invoice.ID), + }) + if err != nil { + return StartCreditThenInvoiceRunResult{}, fmt.Errorf("create current run: %w", err) + } + + result := StartCreditThenInvoiceRunResult{ + Run: flatfee.RealizationRun{ + RealizationRunBase: runBase, + }, + } + + charge := in.Charge + charge.Realizations.CurrentRun = &flatfee.RealizationRun{ + RealizationRunBase: runBase, + } + + line, err := rateFlatFeeLine(in.Line, s.ratingService) + if err != nil { + return StartCreditThenInvoiceRunResult{}, err + } + + creditAllocationTarget := currency.RoundToPrecision(line.Totals.Total) + + if !creditAllocationTarget.IsZero() { + handlerInput := flatfee.OnAllocateCreditsInput{ + Charge: charge, + ServicePeriod: in.Line.Period, + BookedAt: flatfee.UsageBookedAt(charge.Intent.GetEffectivePaymentTerm(), in.Line.Period), + PreTaxAmountToAllocate: creditAllocationTarget, + } + if err := handlerInput.Validate(); err != nil { + return StartCreditThenInvoiceRunResult{}, fmt.Errorf("validating allocate credits input: %w", err) + } + + creditAllocations, err := s.handler.OnAllocateCredits(ctx, handlerInput) + if err != nil { + return StartCreditThenInvoiceRunResult{}, fmt.Errorf("allocate credits for flat fee: %w", err) + } + + creditAllocationsWithLineID := creditrealization.CreateAllocationInputs(lo.Map(creditAllocations, func(allocation creditrealization.CreateAllocationInput, _ int) creditrealization.CreateAllocationInput { + allocation.LineID = lo.ToPtr(in.Line.ID) + return allocation + })) + + if len(creditAllocationsWithLineID) > 0 { + realizations, err := s.createCreditAllocations(ctx, charge, runBase.ID, creditAllocationsWithLineID.AsCreateInputs()) + if err != nil { + return StartCreditThenInvoiceRunResult{}, fmt.Errorf("creating credit realizations: %w", err) + } + + result.Run.CreditRealizations = realizations + } + } + + creditsApplied, err := result.Run.CreditRealizations.AsCreditsApplied() + if err != nil { + return StartCreditThenInvoiceRunResult{}, fmt.Errorf("mapping credit realizations to credits applied: %w", err) + } + + mappedLine, err := applyCreditsToFlatFeeLine(*line, creditsApplied, currency) + if err != nil { + return StartCreditThenInvoiceRunResult{}, err + } + + detailedLines := flatfee.DetailedLines(lo.Map(mappedLine.DetailedLines, func(detailedLine billing.DetailedLine, _ int) flatfee.DetailedLine { + return detailedLine.Base.Clone() + })) + + if err := s.adapter.UpsertDetailedLines(ctx, runBase.ID, detailedLines); err != nil { + return StartCreditThenInvoiceRunResult{}, fmt.Errorf("persisting detailed lines for line[%s]: %w", line.ID, err) + } + + runBase, err = s.adapter.UpdateRealizationRun(ctx, flatfee.UpdateRealizationRunInput{ + ID: runBase.ID, + Totals: mo.Some(mappedLine.Totals), + NoFiatTransactionRequired: mo.Some(mappedLine.Totals.Total.IsZero()), + }) + if err != nil { + return StartCreditThenInvoiceRunResult{}, fmt.Errorf("updating run totals for line[%s]: %w", line.ID, err) + } + + result.Run.RealizationRunBase = runBase + result.Run.DetailedLines = mo.Some(detailedLines) + + return result, nil + }) +} + +// ReconcileStandardLineToIntentInput describes a mutable CTI standard invoice +// line that has already been rebuilt from the latest charge intent, plus the +// realization run that still reflects the previous line state. +type ReconcileStandardLineToIntentInput struct { + // Charge is the flat-fee charge whose intent produced Line. + Charge flatfee.Charge + // Run is the current mutable realization run backing Line. + Run flatfee.RealizationRun + // Line is the desired standard invoice line after applying the latest + // charge intent. + Line billing.StandardLine + // AllocateAt is used as the ledger timestamp when reconciliation needs to + // allocate or correct credit rows. + AllocateAt time.Time +} + +func (i ReconcileStandardLineToIntentInput) Validate() error { + var errs []error + + if err := i.Charge.Validate(); err != nil { + errs = append(errs, fmt.Errorf("charge: %w", err)) + } + + if err := i.Run.Validate(); err != nil { + errs = append(errs, fmt.Errorf("run: %w", err)) + } + + if err := i.Line.Validate(); err != nil { + errs = append(errs, fmt.Errorf("line: %w", err)) + } + + if i.AllocateAt.IsZero() { + errs = append(errs, errors.New("allocate at is required")) + } + + lineChargeID := lo.FromPtrOr(i.Line.ChargeID, "") + if lineChargeID != i.Charge.ID { + errs = append(errs, fmt.Errorf("line charge id mismatch: got %s, want %s", lineChargeID, i.Charge.ID)) + } + + runLineID := lo.FromPtrOr(i.Run.LineID, "") + + if runLineID != i.Line.ID { + errs = append(errs, fmt.Errorf("run line id mismatch: got %s, want %s", runLineID, i.Line.ID)) + } + + runInvoiceID := lo.FromPtrOr(i.Run.InvoiceID, "") + + if runInvoiceID != i.Line.InvoiceID { + errs = append(errs, fmt.Errorf("run invoice id mismatch: got %s, want %s", runInvoiceID, i.Line.InvoiceID)) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +// ReconcileStandardLineToIntentResult returns both sides of the reconciliation: +// the persisted run aggregate and the standard line that billing should write back. +type ReconcileStandardLineToIntentResult struct { + Run flatfee.RealizationRun + // Line includes recalculated credits, detailed lines, and totals. + Line billing.StandardLine +} + +// ReconcileStandardLineToIntent brings a mutable credit_then_invoice standard +// invoice line and its realization run back in sync after the charge intent +// changed. +// +// The caller passes the freshly rebuilt standard line. This method treats that +// line as the desired state, computes its prorated amount, reconciles the run's +// credit allocations to that amount, maps the resulting credit realizations +// back to billing CreditsApplied, regenerates detailed lines/totals, persists +// charge-owned detailed lines, and updates the run aggregate. +func (s *Service) ReconcileStandardLineToIntent(ctx context.Context, in ReconcileStandardLineToIntentInput) (ReconcileStandardLineToIntentResult, error) { + if err := in.Validate(); err != nil { + return ReconcileStandardLineToIntentResult{}, err + } + + return transaction.Run(ctx, s.adapter, func(ctx context.Context) (ReconcileStandardLineToIntentResult, error) { + currency, err := currencyx.NewCurrencyBuilder(currencyx.CurrencyTypeFiat). + WithCode(in.Charge.Intent.GetCurrency()). + Build() + if err != nil { + return ReconcileStandardLineToIntentResult{}, fmt.Errorf("get currency calculator: %w", err) + } + + amountAfterProration, err := invoiceupdater.GetFlatFeePerUnitAmount(&in.Line) + if err != nil { + return ReconcileStandardLineToIntentResult{}, fmt.Errorf("get flat fee line amount: %w", err) + } + + amountAfterProration = currency.RoundToPrecision(amountAfterProration) + + run := in.Run + // The rebuilt line may carry a prorated period that differs from the + // persisted run. Use the line period for both credit allocation and the + // run update so ledger and invoice state describe the same service + // window. + run.ServicePeriod = in.Line.Period + + line, err := rateFlatFeeLine(in.Line, s.ratingService) + if err != nil { + return ReconcileStandardLineToIntentResult{}, err + } + + creditAllocationTarget := currency.RoundToPrecision(line.Totals.Total) + + reconcileResult, err := s.ReconcileCredits(ctx, ReconcileCreditRealizationsInput{ + Charge: in.Charge, + Run: run, + AllocateAt: in.AllocateAt, + TargetAmount: creditAllocationTarget, + CurrencyCalculator: currency, + }) + if err != nil { + return ReconcileStandardLineToIntentResult{}, fmt.Errorf("reconcile credits for run %s: %w", run.ID.ID, err) + } + + run.CreditRealizations = append(run.CreditRealizations, reconcileResult.Realizations...) + + creditsApplied, err := run.CreditRealizations.AsCreditsApplied() + if err != nil { + return ReconcileStandardLineToIntentResult{}, fmt.Errorf("mapping credit realizations to credits applied: %w", err) + } + + mappedLine, err := applyCreditsToFlatFeeLine(*line, creditsApplied, currency) + if err != nil { + return ReconcileStandardLineToIntentResult{}, err + } + + detailedLines := flatfee.DetailedLines(lo.Map(mappedLine.DetailedLines, func(detailedLine billing.DetailedLine, _ int) flatfee.DetailedLine { + return detailedLine.Base.Clone() + })) + + if err := s.adapter.UpsertDetailedLines(ctx, run.ID, detailedLines); err != nil { + return ReconcileStandardLineToIntentResult{}, fmt.Errorf("persisting detailed lines for line[%s]: %w", line.ID, err) + } + + runBase, err := s.adapter.UpdateRealizationRun(ctx, flatfee.UpdateRealizationRunInput{ + ID: run.ID, + ServicePeriod: mo.Some(line.Period), + AmountAfterProration: mo.Some(amountAfterProration), + Totals: mo.Some(mappedLine.Totals), + NoFiatTransactionRequired: mo.Some(mappedLine.Totals.Total.IsZero()), + }) + if err != nil { + return ReconcileStandardLineToIntentResult{}, fmt.Errorf("updating run totals for line[%s]: %w", line.ID, err) + } + + run.RealizationRunBase = runBase + run.DetailedLines = mo.Some(detailedLines) + + return ReconcileStandardLineToIntentResult{ + Run: run, + Line: *mappedLine, + }, nil + }) +} + +func rateFlatFeeLine(line billing.StandardLine, ratingService billingrating.Service) (*billing.StandardLine, error) { + // Keep the caller-facing line shape on ratedLine, and use ratingLine only + // to clear split metadata for pricing before merging generated details back. + ratedLine, err := line.Clone() + if err != nil { + return nil, fmt.Errorf("cloning line: %w", err) + } + + ratedLine.CreditsApplied = nil + + ratingLine, err := line.Clone() + if err != nil { + return nil, fmt.Errorf("cloning rating line: %w", err) + } + + ratingLine.CreditsApplied = nil + // Flat-fee charges materialize their own billable periods. Subscription + // split-line metadata must not make the flat pricer skip an otherwise + // billable in-advance or in-arrears charge run. + ratingLine.SplitLineGroupID = nil + ratingLine.SplitLineHierarchy = nil + + generatedDetailedLines, err := ratingService.GenerateDetailedLines(ratingLine, billingrating.WithCreditsMutatorDisabled()) + if err != nil { + return nil, fmt.Errorf("generating detailed lines for line[%s]: %w", ratedLine.ID, err) + } + + if err := invoicecalc.MergeGeneratedDetailedLines(ratedLine, generatedDetailedLines); err != nil { + return nil, fmt.Errorf("merging generated detailed lines for line[%s]: %w", ratedLine.ID, err) + } + + if err := ratedLine.Validate(); err != nil { + return nil, fmt.Errorf("validating standard line[%s]: %w", ratedLine.ID, err) + } + + return ratedLine, nil +} + +func applyCreditsToFlatFeeLine(line billing.StandardLine, creditsApplied billing.CreditsApplied, currencyCalculator currencyx.Currency) (*billing.StandardLine, error) { + mappedLine, err := line.Clone() + if err != nil { + return nil, fmt.Errorf("cloning line: %w", err) + } + + mappedLine.CreditsApplied = creditsApplied + + detailedLines, err := mappedLine.DetailedLines.WithCreditsApplied(creditsApplied, currencyCalculator) + if err != nil { + return nil, fmt.Errorf("applying credits to detailed lines for line[%s]: %w", mappedLine.ID, err) + } + + mappedLine.DetailedLines = detailedLines + mappedLine.Totals = mappedLine.DetailedLines.SumTotals().RoundToPrecision(currencyCalculator) + + if err := mappedLine.Validate(); err != nil { + return nil, fmt.Errorf("validating standard line[%s]: %w", mappedLine.ID, err) + } + + return mappedLine, nil +} diff --git a/billing/charges/flatfee/service/realizations/invoiceaccrued.go b/billing/charges/flatfee/service/realizations/invoiceaccrued.go new file mode 100644 index 0000000000000000000000000000000000000000..7b8c2c55dfe6187e7f9e7c92dc8604ae1939aac1 --- /dev/null +++ b/billing/charges/flatfee/service/realizations/invoiceaccrued.go @@ -0,0 +1,123 @@ +package realizations + +import ( + "context" + "errors" + "fmt" + + "github.com/samber/mo" + + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/billing/charges/flatfee" + "github.com/openmeterio/openmeter/openmeter/billing/charges/models/invoicedusage" + "github.com/openmeterio/openmeter/pkg/framework/transaction" + "github.com/openmeterio/openmeter/pkg/models" +) + +type AccrueInvoiceUsageInput struct { + Charge flatfee.Charge + LineWithHeader billing.StandardLineWithInvoiceHeader +} + +func (i AccrueInvoiceUsageInput) Validate() error { + var errs []error + + if err := i.Charge.Validate(); err != nil { + errs = append(errs, fmt.Errorf("charge: %w", err)) + } + + if err := i.LineWithHeader.Validate(); err != nil { + errs = append(errs, fmt.Errorf("line with header: %w", err)) + } + + if i.Charge.Realizations.CurrentRun == nil { + errs = append(errs, fmt.Errorf("current run is required")) + } else { + currentRun := i.Charge.Realizations.CurrentRun + + if currentRun.AccruedUsage != nil { + errs = append(errs, fmt.Errorf("accrued invoice usage already exists for charge %s", i.Charge.GetChargeID())) + } + + if i.LineWithHeader.Line != nil { + if currentRun.LineID == nil || *currentRun.LineID != i.LineWithHeader.Line.ID { + errs = append(errs, fmt.Errorf("current run line id must match standard line")) + } + } + + if currentRun.InvoiceID == nil || *currentRun.InvoiceID != i.LineWithHeader.Invoice.ID { + errs = append(errs, fmt.Errorf("current run invoice id must match invoice")) + } + } + + if i.LineWithHeader.Line != nil { + if i.LineWithHeader.Line.ChargeID == nil || *i.LineWithHeader.Line.ChargeID != i.Charge.ID { + errs = append(errs, fmt.Errorf("line charge id must match charge")) + } + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +type AccrueInvoiceUsageResult struct { + AccruedUsage *invoicedusage.AccruedUsage + Run flatfee.RealizationRun +} + +func (s *Service) AccrueInvoiceUsage(ctx context.Context, in AccrueInvoiceUsageInput) (AccrueInvoiceUsageResult, error) { + if err := in.Validate(); err != nil { + return AccrueInvoiceUsageResult{}, err + } + + return transaction.Run(ctx, s.adapter, func(ctx context.Context) (AccrueInvoiceUsageResult, error) { + currentRun := *in.Charge.Realizations.CurrentRun + line := *in.LineWithHeader.Line + + result := AccrueInvoiceUsageResult{ + Run: currentRun, + } + + if !line.Totals.Total.IsZero() { + ledgerTransactionRef, err := s.handler.OnInvoiceUsageAccrued(ctx, flatfee.OnInvoiceUsageAccruedInput{ + Charge: in.Charge, + ServicePeriod: line.Period, + BookedAt: flatfee.UsageBookedAt(in.Charge.Intent.GetEffectivePaymentTerm(), line.Period), + Totals: line.Totals, + }) + if err != nil { + return AccrueInvoiceUsageResult{}, fmt.Errorf("on flat fee standard invoice usage accrued: %w", err) + } + + accruedUsage := invoicedusage.AccruedUsage{ + ServicePeriod: line.Period, + Totals: line.Totals, + LedgerTransaction: &ledgerTransactionRef, + } + + accruedUsage, err = s.adapter.CreateInvoicedUsage(ctx, flatfee.CreateInvoicedUsageInput{ + RunID: currentRun.ID, + LineID: line.ID, + InvoiceID: in.LineWithHeader.Invoice.ID, + InvoicedUsage: accruedUsage, + }) + if err != nil { + return AccrueInvoiceUsageResult{}, fmt.Errorf("creating standard invoice accrued usage: %w", err) + } + + result.AccruedUsage = &accruedUsage + result.Run.AccruedUsage = &accruedUsage + } + + runBase, err := s.adapter.UpdateRealizationRun(ctx, flatfee.UpdateRealizationRunInput{ + ID: currentRun.ID, + Immutable: mo.Some(true), + }) + if err != nil { + return AccrueInvoiceUsageResult{}, fmt.Errorf("updating standard invoice run: %w", err) + } + + result.Run.RealizationRunBase = runBase + + return result, nil + }) +} diff --git a/billing/charges/flatfee/service/realizations/preview.go b/billing/charges/flatfee/service/realizations/preview.go new file mode 100644 index 0000000000000000000000000000000000000000..93a6d5225dd410fdb7ca3dc170b303a6f2b04f55 --- /dev/null +++ b/billing/charges/flatfee/service/realizations/preview.go @@ -0,0 +1,104 @@ +package realizations + +import ( + "errors" + "fmt" + + "github.com/samber/lo" + "github.com/samber/mo" + + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/billing/charges/flatfee" + "github.com/openmeterio/openmeter/openmeter/billing/charges/invoiceupdater" + "github.com/openmeterio/openmeter/openmeter/productcatalog" + "github.com/openmeterio/openmeter/pkg/currencyx" + "github.com/openmeterio/openmeter/pkg/models" +) + +type BuildCreditThenInvoiceGatheringPreviewRunInput struct { + Charge flatfee.Charge + Line billing.StandardLine +} + +func (i BuildCreditThenInvoiceGatheringPreviewRunInput) Validate() error { + var errs []error + + if err := i.Charge.Validate(); err != nil { + errs = append(errs, fmt.Errorf("charge: %w", err)) + } + + if err := i.Line.Validate(); err != nil { + errs = append(errs, fmt.Errorf("line: %w", err)) + } + + lineChargeID := lo.FromPtrOr(i.Line.ChargeID, "") + if lineChargeID != i.Charge.ID { + errs = append(errs, fmt.Errorf("line charge id mismatch: got %s, want %s", lineChargeID, i.Charge.ID)) + } + + if i.Charge.Intent.GetSettlementMode() != productcatalog.CreditThenInvoiceSettlementMode { + errs = append(errs, fmt.Errorf("unsupported settlement mode for gathering preview: %s", i.Charge.Intent.GetSettlementMode())) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +type BuildCreditThenInvoiceGatheringPreviewRunResult struct { + Run flatfee.RealizationRun +} + +// BuildCreditThenInvoiceGatheringPreviewRun creates the charge run shape needed +// to map a gathering invoice preview line without persisting realization state. +// Preview intentionally does not allocate credits: get/list expansion must stay +// side-effect-free, so returned standard lines show charge-rated totals before +// charge credit allocation. +func (s *Service) BuildCreditThenInvoiceGatheringPreviewRun(in BuildCreditThenInvoiceGatheringPreviewRunInput) (BuildCreditThenInvoiceGatheringPreviewRunResult, error) { + if err := in.Validate(); err != nil { + return BuildCreditThenInvoiceGatheringPreviewRunResult{}, err + } + + currency, err := currencyx.NewCurrencyBuilder(currencyx.CurrencyTypeFiat). + WithCode(in.Charge.Intent.GetCurrency()). + Build() + if err != nil { + return BuildCreditThenInvoiceGatheringPreviewRunResult{}, fmt.Errorf("get currency calculator: %w", err) + } + + amountAfterProration, err := invoiceupdater.GetFlatFeePerUnitAmount(&in.Line) + if err != nil { + return BuildCreditThenInvoiceGatheringPreviewRunResult{}, fmt.Errorf("get flat fee line amount: %w", err) + } + + amountAfterProration = currency.RoundToPrecision(amountAfterProration) + + line, err := rateFlatFeeLine(in.Line, s.ratingService) + if err != nil { + return BuildCreditThenInvoiceGatheringPreviewRunResult{}, err + } + + detailedLines := flatfee.DetailedLines(lo.Map(line.DetailedLines, func(detailedLine billing.DetailedLine, _ int) flatfee.DetailedLine { + return detailedLine.Base.Clone() + })) + + runTotals := line.Totals.RoundToPrecision(currency) + runType := flatfee.RealizationRunTypeFinalRealization + run := flatfee.RealizationRun{ + RealizationRunBase: flatfee.RealizationRunBase{ + ID: flatfee.RealizationRunID{ + Namespace: in.Line.Namespace, + ID: fmt.Sprintf("preview-%s", in.Line.ID), + }, + LineID: lo.ToPtr(in.Line.ID), + InvoiceID: lo.ToPtr(in.Line.InvoiceID), + Type: runType, + InitialType: runType, + ServicePeriod: in.Line.Period, + AmountAfterProration: amountAfterProration, + Totals: runTotals, + NoFiatTransactionRequired: runTotals.Total.IsZero(), + }, + DetailedLines: mo.Some(detailedLines), + } + + return BuildCreditThenInvoiceGatheringPreviewRunResult{Run: run}, nil +} diff --git a/billing/charges/flatfee/service/realizations/service.go b/billing/charges/flatfee/service/realizations/service.go new file mode 100644 index 0000000000000000000000000000000000000000..e299089c1fed3b0fc8eeaf70e557ab03d5da84f1 --- /dev/null +++ b/billing/charges/flatfee/service/realizations/service.go @@ -0,0 +1,92 @@ +package realizations + +import ( + "context" + "errors" + "fmt" + + "github.com/samber/lo" + + "github.com/openmeterio/openmeter/openmeter/billing/charges/flatfee" + "github.com/openmeterio/openmeter/openmeter/billing/charges/lineage" + "github.com/openmeterio/openmeter/openmeter/billing/charges/models/creditrealization" + "github.com/openmeterio/openmeter/openmeter/billing/rating" +) + +// Service owns flat-fee realization mechanics: credit allocation/correction and +// realization lineage persistence. It must not make state-machine decisions. +type Service struct { + adapter flatfee.Adapter + handler flatfee.Handler + lineage lineage.Service + ratingService rating.Service +} + +type Config struct { + Adapter flatfee.Adapter + Handler flatfee.Handler + Lineage lineage.Service + RatingService rating.Service +} + +func (c Config) Validate() error { + var errs []error + + if c.Adapter == nil { + errs = append(errs, errors.New("adapter is required")) + } + + if c.Handler == nil { + errs = append(errs, errors.New("handler is required")) + } + + if c.Lineage == nil { + errs = append(errs, errors.New("lineage service is required")) + } + + if c.RatingService == nil { + errs = append(errs, errors.New("rating service is required")) + } + + return errors.Join(errs...) +} + +func New(config Config) (*Service, error) { + if err := config.Validate(); err != nil { + return nil, err + } + + return &Service{ + adapter: config.Adapter, + handler: config.Handler, + lineage: config.Lineage, + ratingService: config.RatingService, + }, nil +} + +func (s *Service) createCreditAllocations(ctx context.Context, charge flatfee.Charge, runID flatfee.RealizationRunID, creditAllocations creditrealization.CreateInputs) (creditrealization.Realizations, error) { + realizations, err := s.adapter.CreateCreditAllocations(ctx, runID, creditAllocations) + if err != nil { + return creditrealization.Realizations{}, err + } + featureKey := charge.Intent.GetFeatureKey() + if err := s.lineage.CreateInitialLineages(ctx, lineage.CreateInitialLineagesInput{ + Namespace: charge.Namespace, + ChargeID: charge.ID, + CustomerID: charge.Intent.GetCustomerID(), + Currency: charge.Intent.GetCurrency(), + Features: lo.Ternary(featureKey == "", nil, []string{featureKey}), + Realizations: realizations, + }); err != nil { + return creditrealization.Realizations{}, fmt.Errorf("create initial credit realization lineages: %w", err) + } + + if err := s.lineage.PersistCorrectionLineageSegments(ctx, lineage.PersistCorrectionLineageSegmentsInput{ + Namespace: charge.Namespace, + Realizations: realizations, + }); err != nil { + return creditrealization.Realizations{}, fmt.Errorf("persist correction lineage segments: %w", err) + } + + return realizations, nil +} diff --git a/billing/charges/flatfee/service/service.go b/billing/charges/flatfee/service/service.go new file mode 100644 index 0000000000000000000000000000000000000000..1a515c26249a5690bc377bc7a96167ef264fbd0e --- /dev/null +++ b/billing/charges/flatfee/service/service.go @@ -0,0 +1,110 @@ +package service + +import ( + "errors" + "sync/atomic" + "testing" + + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/billing/charges" + "github.com/openmeterio/openmeter/openmeter/billing/charges/flatfee" + flatfeerealizations "github.com/openmeterio/openmeter/openmeter/billing/charges/flatfee/service/realizations" + "github.com/openmeterio/openmeter/openmeter/billing/charges/lineage" + "github.com/openmeterio/openmeter/openmeter/billing/charges/meta" + "github.com/openmeterio/openmeter/openmeter/billing/rating" + "github.com/openmeterio/openmeter/pkg/framework/lockr" +) + +type Config struct { + Adapter flatfee.Adapter + Handler flatfee.Handler + Lineage lineage.Service + MetaAdapter meta.Adapter + Locker *lockr.Locker + RatingService rating.Service +} + +func (c Config) Validate() error { + var errs []error + + if c.Adapter == nil { + errs = append(errs, errors.New("adapter cannot be null")) + } + + if c.Handler == nil { + errs = append(errs, errors.New("handler cannot be null")) + } + + if c.Lineage == nil { + errs = append(errs, errors.New("lineage service cannot be null")) + } + + if c.MetaAdapter == nil { + errs = append(errs, errors.New("meta adapter cannot be null")) + } + + if c.Locker == nil { + errs = append(errs, errors.New("locker cannot be null")) + } + + if c.RatingService == nil { + errs = append(errs, errors.New("rating service cannot be null")) + } + + return errors.Join(errs...) +} + +func New(config Config) (flatfee.Service, error) { + if err := config.Validate(); err != nil { + return nil, err + } + + realizations, err := flatfeerealizations.New(flatfeerealizations.Config{ + Adapter: config.Adapter, + Handler: config.Handler, + Lineage: config.Lineage, + RatingService: config.RatingService, + }) + if err != nil { + return nil, err + } + + svc := &service{ + adapter: config.Adapter, + handler: config.Handler, + metaAdapter: config.MetaAdapter, + locker: config.Locker, + realizations: realizations, + } + svc.creditNotesSupported.Store(charges.CreditNotesSupportedByLineUpdater) + + return svc, nil +} + +type service struct { + adapter flatfee.Adapter + handler flatfee.Handler + metaAdapter meta.Adapter + locker *lockr.Locker + realizations *flatfeerealizations.Service + creditNotesSupported atomic.Bool +} + +func (s *service) GetLineEngine() billing.LineEngine { + return &LineEngine{ + service: s, + } +} + +// SetCreditNotesSupportedByLineUpdater sets the credit notes supported by the line updater. +// This is used to test the credit notes supported by the line updater, but must not be used +// in production code. +func (s *service) SetCreditNotesSupportedByLineUpdater(t *testing.T, supported bool) error { + if t == nil { + return errors.New("testing is nil") + } + + t.Helper() + s.creditNotesSupported.Store(supported) + return nil +} diff --git a/billing/charges/flatfee/service/statemachine.go b/billing/charges/flatfee/service/statemachine.go new file mode 100644 index 0000000000000000000000000000000000000000..6a2749637dd627b475694c587e6e8b269d6a6bc9 --- /dev/null +++ b/billing/charges/flatfee/service/statemachine.go @@ -0,0 +1,197 @@ +package service + +import ( + "context" + "errors" + "fmt" + + "github.com/samber/lo" + + "github.com/openmeterio/openmeter/openmeter/billing/charges/flatfee" + flatfeerealizations "github.com/openmeterio/openmeter/openmeter/billing/charges/flatfee/service/realizations" + "github.com/openmeterio/openmeter/openmeter/billing/charges/meta" + chargestatemachine "github.com/openmeterio/openmeter/openmeter/billing/charges/statemachine" + "github.com/openmeterio/openmeter/pkg/clock" + "github.com/openmeterio/openmeter/pkg/models" +) + +type stateMachine struct { + *chargestatemachine.Machine[flatfee.Charge, flatfee.ChargeBase, flatfee.Status] + + Adapter flatfee.Adapter + Realizations *flatfeerealizations.Service + Service *service + + CreditNotesSupported bool +} + +type StateMachine = chargestatemachine.StateMachine[flatfee.Charge] + +type StateMachineConfig struct { + Charge flatfee.Charge + + Adapter flatfee.Adapter + Realizations *flatfeerealizations.Service + Service *service + + CreditNotesSupported bool +} + +func (c StateMachineConfig) Validate() error { + var errs []error + + if err := c.Charge.Validate(); err != nil { + errs = append(errs, fmt.Errorf("charge: %w", err)) + } + + if c.Adapter == nil { + errs = append(errs, errors.New("adapter is required")) + } + + if c.Realizations == nil { + errs = append(errs, errors.New("realizations service is required")) + } + + if c.Service == nil { + errs = append(errs, errors.New("service is required")) + } + + return errors.Join(errs...) +} + +func newStateMachineBase(config StateMachineConfig) (*stateMachine, error) { + if err := config.Validate(); err != nil { + return nil, fmt.Errorf("config: %w", err) + } + + out := &stateMachine{ + Adapter: config.Adapter, + Realizations: config.Realizations, + Service: config.Service, + CreditNotesSupported: config.CreditNotesSupported, + } + + machine, err := chargestatemachine.New(chargestatemachine.Config[flatfee.Charge, flatfee.ChargeBase, flatfee.Status]{ + Charge: config.Charge, + Persistence: chargestatemachine.Persistence[flatfee.Charge, flatfee.ChargeBase]{ + UpdateBase: func(ctx context.Context, base flatfee.ChargeBase) (flatfee.ChargeBase, error) { + return out.Adapter.UpdateCharge(ctx, base) + }, + Refetch: func(ctx context.Context, chargeID meta.ChargeID) (flatfee.Charge, error) { + return out.Adapter.GetByID(ctx, flatfee.GetByIDInput{ + ChargeID: chargeID, + Expands: meta.Expands{meta.ExpandRealizations}, + }) + }, + }, + }) + if err != nil { + return nil, fmt.Errorf("new machine: %w", err) + } + + out.Machine = machine + + return out, nil +} + +// mutateIntentLayer mutates the requested intent layer, creating a new override +// layer first when the target is override and the charge has no override yet. +func (s *stateMachine) mutateIntentLayer(ctx context.Context, target meta.ChangeTarget, editFn func(*flatfee.IntentMutableFields)) error { + switch target { + case meta.ChangeTargetBase: + if err := s.Charge.Intent.Mutate(meta.ChangeTargetBase, editFn); err != nil { + return fmt.Errorf("mutating base intent: %w", err) + } + case meta.ChangeTargetOverride: + if s.Charge.Intent.HasOverrideLayer() { + if err := s.Charge.Intent.Mutate(meta.ChangeTargetOverride, editFn); err != nil { + return fmt.Errorf("mutating override intent: %w", err) + } + + return nil + } + + effectiveIntent := s.Charge.Intent.GetEffectiveIntent() + overrideFields := effectiveIntent.IntentMutableFields + editFn(&overrideFields) + overrideFields = overrideFields.Normalized(effectiveIntent.Currency) + if err := overrideFields.Validate(); err != nil { + return fmt.Errorf("validating override intent: %w", err) + } + + base, err := s.Adapter.CreateChargeOverride(ctx, s.Charge.ChargeBase, overrideFields) + if err != nil { + return fmt.Errorf("creating override intent: %w", err) + } + + s.Charge.ChargeBase = base + default: + return fmt.Errorf("invalid change target: %s", target) + } + + return nil +} + +// rejectHiddenIntentTarget prevents lifecycle state machines from processing a +// hidden source intent. When an override layer exists, the override is the +// active customer-facing charge: it owns status transitions, realization runs, +// credit corrections, and invoice patches. Subscription-owned base/source +// changes must be applied before state-machine dispatch by service-level +// reconciliation, not interpreted as lifecycle events. +func (s *stateMachine) rejectHiddenIntentTarget(target meta.ChangeTarget) error { + if target == meta.ChangeTargetBase && s.Charge.Intent.HasOverrideLayer() { + return models.NewGenericPreConditionFailedError( + fmt.Errorf("cannot mutate hidden base intent while override intent is active"), + ) + } + + return nil +} + +func (s *stateMachine) IsInsideServicePeriod() bool { + return !clock.Now().Before(s.Charge.Intent.GetEffectiveServicePeriod().From) +} + +func (s *stateMachine) IsInsideServicePeriodAndZeroAmount() bool { + return s.IsInsideServicePeriod() && s.Charge.State.AmountAfterProration.IsZero() +} + +func (s *stateMachine) IsInsideServicePeriodAndNonZeroAmount() bool { + return s.IsInsideServicePeriod() && !s.Charge.State.AmountAfterProration.IsZero() +} + +func (s *stateMachine) IsAfterInvoiceAt() bool { + return !clock.Now().Before(s.Charge.Intent.GetEffectiveInvoiceAt()) +} + +func (s *stateMachine) IsAfterInvoiceAtAndZeroAmount() bool { + return s.IsAfterInvoiceAt() && s.Charge.State.AmountAfterProration.IsZero() +} + +func (s *stateMachine) IsAfterInvoiceAtAndNonZeroAmount() bool { + return s.IsAfterInvoiceAt() && !s.Charge.State.AmountAfterProration.IsZero() +} + +func (s *stateMachine) IsZeroAmount() bool { + return s.Charge.State.AmountAfterProration.IsZero() +} + +func (s *stateMachine) AdvanceAfterServicePeriodFrom(ctx context.Context) error { + s.Charge.State.AdvanceAfter = lo.ToPtr(meta.NormalizeTimestamp(s.Charge.Intent.GetEffectiveServicePeriod().From)) + return nil +} + +func (s *stateMachine) AdvanceAfterInvoiceAt(ctx context.Context) error { + s.Charge.State.AdvanceAfter = lo.ToPtr(meta.NormalizeTimestamp(s.Charge.Intent.GetEffectiveInvoiceAt())) + return nil +} + +func (s *stateMachine) AdvanceAfterServicePeriodTo(ctx context.Context) error { + s.Charge.State.AdvanceAfter = lo.ToPtr(meta.NormalizeTimestamp(s.Charge.Intent.GetEffectiveServicePeriod().To)) + return nil +} + +func (s *stateMachine) ClearAdvanceAfter(ctx context.Context) error { + s.Charge.State.AdvanceAfter = nil + return nil +} diff --git a/billing/charges/flatfee/service/subscription.go b/billing/charges/flatfee/service/subscription.go new file mode 100644 index 0000000000000000000000000000000000000000..842c35bc2f587883d28c51372a0135be923d4bfa --- /dev/null +++ b/billing/charges/flatfee/service/subscription.go @@ -0,0 +1,33 @@ +package service + +import ( + "context" + "errors" + "fmt" + + "github.com/openmeterio/openmeter/openmeter/billing/charges/flatfee" + "github.com/openmeterio/openmeter/pkg/framework/transaction" + "github.com/openmeterio/openmeter/pkg/models" +) + +func (s *service) UpdateSubscriptionItemID(ctx context.Context, charge flatfee.Charge, newSubscriptionItemID string) (flatfee.Charge, error) { + var errs []error + + if err := charge.Validate(); err != nil { + errs = append(errs, fmt.Errorf("charge: %w", err)) + } + + if newSubscriptionItemID == "" { + errs = append(errs, errors.New("subscription item ID is required")) + } + + if err := models.NewNillableGenericValidationError(errors.Join(errs...)); err != nil { + return flatfee.Charge{}, err + } + + return transaction.Run(ctx, s.adapter, func(ctx context.Context) (flatfee.Charge, error) { + return s.adapter.UpdateSubscriptionItemID(ctx, charge, newSubscriptionItemID) + }) +} + +var _ flatfee.FlatFeeService = (*service)(nil) diff --git a/billing/charges/flatfee/service/triggers.go b/billing/charges/flatfee/service/triggers.go new file mode 100644 index 0000000000000000000000000000000000000000..125aaf1640bb3613be29140422c9b8c877e1799f --- /dev/null +++ b/billing/charges/flatfee/service/triggers.go @@ -0,0 +1,211 @@ +package service + +import ( + "context" + "fmt" + + "github.com/openmeterio/openmeter/openmeter/billing/charges" + "github.com/openmeterio/openmeter/openmeter/billing/charges/flatfee" + "github.com/openmeterio/openmeter/openmeter/billing/charges/meta" + "github.com/openmeterio/openmeter/openmeter/productcatalog" + "github.com/openmeterio/openmeter/pkg/clock" + "github.com/openmeterio/openmeter/pkg/framework/transaction" + "github.com/openmeterio/openmeter/pkg/models" +) + +func (s *service) AdvanceCharge(ctx context.Context, input flatfee.AdvanceChargeInput) (*flatfee.Charge, error) { + if err := input.Validate(); err != nil { + return nil, fmt.Errorf("validate: %w", err) + } + + return s.withLockedCharge(ctx, input.ChargeID, func(ctx context.Context, charge flatfee.Charge) (*flatfee.Charge, error) { + stateMachine, err := s.newStateMachineForCharge(charge) + if err != nil { + return nil, fmt.Errorf("new state machine: %w", err) + } + + return stateMachine.AdvanceUntilStateStable(ctx) + }) +} + +func (s *service) TriggerPatch(ctx context.Context, chargeID meta.ChargeID, patch meta.Patch) (meta.TriggerPatchResult[flatfee.Charge], error) { + if err := patch.Validate(); err != nil { + return meta.TriggerPatchResult[flatfee.Charge]{}, fmt.Errorf("patch: %w", err) + } + + if err := chargeID.Validate(); err != nil { + return meta.TriggerPatchResult[flatfee.Charge]{}, fmt.Errorf("chargeID: %w", err) + } + + var result meta.TriggerPatchResult[flatfee.Charge] + + charge, err := s.withLockedCharge(ctx, chargeID, func(ctx context.Context, charge flatfee.Charge) (*flatfee.Charge, error) { + chargeWithUpdatedBase, err := applyBaseIntentPatchForOverriddenCharge(charge, patch) + if err != nil { + return nil, err + } + + if chargeWithUpdatedBase != nil { + // Hidden base/source intent changes are subscription reconciliation, + // not customer-facing lifecycle events. Persist the source intent and + // skip the state machine because the active override owns lifecycle + // state and hidden targets are rejected there. + updatedChargeBase, err := s.adapter.UpdateCharge(ctx, chargeWithUpdatedBase.ChargeBase) + if err != nil { + return nil, fmt.Errorf("updating flat fee charge[%s] base intent: %w", chargeWithUpdatedBase.ID, err) + } + + chargeWithUpdatedBase.ChargeBase = updatedChargeBase + + return chargeWithUpdatedBase, nil + } + + stateMachine, err := s.newStateMachineForCharge(charge) + if err != nil { + return nil, fmt.Errorf("new state machine: %w", err) + } + + err = stateMachine.FireAndActivate(ctx, patch.Trigger(), patch) + if err != nil { + return nil, err + } + + charge = stateMachine.GetCharge() + result.InvoicePatches = stateMachine.DrainInvoicePatches() + + return &charge, nil + }) + if err != nil { + return meta.TriggerPatchResult[flatfee.Charge]{}, err + } + + result.Charge = charge + + return result, nil +} + +func applyBaseIntentPatchForOverriddenCharge(charge flatfee.Charge, patch meta.Patch) (*flatfee.Charge, error) { + target, err := patch.GetTargetLayer(charge.Intent) + if err != nil { + return nil, fmt.Errorf("getting patch target layer: %w", err) + } + + if target != meta.ChangeTargetBase || !charge.Intent.HasOverrideLayer() { + return nil, nil + } + + switch patch := patch.(type) { + case meta.PatchDelete: + if err := charge.Intent.Mutate(meta.ChangeTargetBase, func(fields *flatfee.IntentMutableFields) { + deletedAt := clock.Now() + fields.IntentDeletedAt = &deletedAt + }); err != nil { + return nil, fmt.Errorf("mutating base intent for %s patch: %w", patch.Op(), err) + } + + return &charge, nil + case meta.PatchShrink: + if err := mutateBaseIntentPeriodForOverriddenCharge(&charge, patch); err != nil { + return nil, err + } + + return &charge, nil + case meta.PatchExtend: + if err := mutateBaseIntentPeriodForOverriddenCharge(&charge, patch); err != nil { + return nil, err + } + + return &charge, nil + } + + return nil, nil +} + +func mutateBaseIntentPeriodForOverriddenCharge(charge *flatfee.Charge, patch periodPatch) error { + targetIntent, err := charge.Intent.GetIntentForTarget(meta.ChangeTargetBase) + if err != nil { + return fmt.Errorf("getting base intent: %w", err) + } + + if err := patch.ValidateWith(targetIntent.IntentMutableFields.IntentMutableFields); err != nil { + return fmt.Errorf("validate %s patch: %w", patch.Op(), err) + } + + if err := charge.Intent.Mutate(meta.ChangeTargetBase, func(fields *flatfee.IntentMutableFields) { + fields.ServicePeriod.To = patch.GetNewServicePeriodTo() + fields.FullServicePeriod.To = patch.GetNewFullServicePeriodTo() + fields.BillingPeriod.To = patch.GetNewBillingPeriodTo() + fields.InvoiceAt = patch.GetNewInvoiceAt() + }); err != nil { + return fmt.Errorf("mutating base intent for %s patch: %w", patch.Op(), err) + } + + return nil +} + +func (s *service) getStateMachineConfigForCharge(charge flatfee.Charge) StateMachineConfig { + return StateMachineConfig{ + Charge: charge, + Adapter: s.adapter, + Realizations: s.realizations, + Service: s, + CreditNotesSupported: s.creditNotesSupported.Load(), + } +} + +func (s *service) newStateMachineForCharge(charge flatfee.Charge) (StateMachine, error) { + return s.newStateMachine(s.getStateMachineConfigForCharge(charge)) +} + +func (s *service) newStateMachine(config StateMachineConfig) (StateMachine, error) { + switch config.Charge.Intent.GetSettlementMode() { + case productcatalog.CreditOnlySettlementMode: + stateMachine, err := NewCreditsOnlyStateMachine(config) + if err != nil { + return nil, err + } + + return stateMachine, nil + case productcatalog.CreditThenInvoiceSettlementMode: + stateMachine, err := NewCreditThenInvoiceStateMachine(config) + if err != nil { + return nil, err + } + + return stateMachine, nil + default: + return nil, models.NewGenericNotImplementedError( + fmt.Errorf("unsupported settlement mode %s for flat fee charge %s", config.Charge.Intent.GetSettlementMode(), config.Charge.ID), + ) + } +} + +func (s *service) withLockedCharge(ctx context.Context, chargeID meta.ChargeID, fn func(ctx context.Context, charge flatfee.Charge) (*flatfee.Charge, error)) (*flatfee.Charge, error) { + return transaction.Run(ctx, s.adapter, func(ctx context.Context) (*flatfee.Charge, error) { + key, err := charges.NewLockKeyForCharge(chargeID) + if err != nil { + return nil, fmt.Errorf("get charge lock key: %w", err) + } + + if err := s.locker.LockForTX(ctx, key); err != nil { + return nil, fmt.Errorf("lock charge: %w", err) + } + + fetchedCharges, err := s.adapter.GetByIDs(ctx, flatfee.GetByIDsInput{ + Namespace: chargeID.Namespace, + IDs: []string{chargeID.ID}, + Expands: meta.Expands{meta.ExpandRealizations}, + }) + if err != nil { + return nil, fmt.Errorf("get charge: %w", err) + } + + if len(fetchedCharges) == 0 { + return nil, fmt.Errorf("charge not found [id=%s]", chargeID.ID) + } + + charge := fetchedCharges[0] + + return fn(ctx, charge) + }) +} diff --git a/billing/charges/flatfee/statemachine.go b/billing/charges/flatfee/statemachine.go new file mode 100644 index 0000000000000000000000000000000000000000..b9817944c7a8af9d3e40dae2aee020e8a56301a5 --- /dev/null +++ b/billing/charges/flatfee/statemachine.go @@ -0,0 +1,56 @@ +package flatfee + +import ( + "fmt" + "slices" + + "github.com/openmeterio/openmeter/openmeter/billing/charges/meta" + "github.com/openmeterio/openmeter/pkg/models" +) + +type Status string + +const ( + StatusCreated Status = Status(meta.ChargeStatusCreated) + StatusActive Status = Status(meta.ChargeStatusActive) + + StatusActiveRealizationStarted Status = "active.realization.started" + StatusActiveRealizationWaitingForCollection Status = "active.realization.waiting_for_collection" + StatusActiveRealizationProcessing Status = "active.realization.processing" + StatusActiveRealizationIssuing Status = "active.realization.issuing" + StatusActiveRealizationCompleted Status = "active.realization.completed" + StatusActiveAwaitingPaymentSettlement Status = "active.awaiting_payment_settlement" + + StatusFinal Status = Status(meta.ChargeStatusFinal) + StatusDeleted Status = Status(meta.ChargeStatusDeleted) +) + +func (Status) Values() []string { + return []string{ + string(StatusCreated), + string(StatusActive), + string(StatusActiveRealizationStarted), + string(StatusActiveRealizationWaitingForCollection), + string(StatusActiveRealizationProcessing), + string(StatusActiveRealizationIssuing), + string(StatusActiveRealizationCompleted), + string(StatusActiveAwaitingPaymentSettlement), + string(StatusFinal), + string(StatusDeleted), + } +} + +func (s Status) Validate() error { + if !slices.Contains(s.Values(), string(s)) { + return models.NewGenericValidationError(fmt.Errorf("invalid status: %s", s)) + } + return nil +} + +func (s Status) ToMetaChargeStatus() (meta.ChargeStatus, error) { + if err := s.Validate(); err != nil { + return meta.ChargeStatusCreated, err + } + + return meta.DetailedStatusToMetaStatus(string(s)) +} diff --git a/billing/charges/helpers.go b/billing/charges/helpers.go new file mode 100644 index 0000000000000000000000000000000000000000..ef7c1ff6e1ec8dbe4a32a3f5a903d322be303946 --- /dev/null +++ b/billing/charges/helpers.go @@ -0,0 +1,6 @@ +package charges + +type WithIndex[T any] struct { + Index int + Value T +} diff --git a/billing/charges/invoiceupdater/feehelper.go b/billing/charges/invoiceupdater/feehelper.go new file mode 100644 index 0000000000000000000000000000000000000000..c685e1bcf9a3ac5f12b9c8338c83ddd715ff40b7 --- /dev/null +++ b/billing/charges/invoiceupdater/feehelper.go @@ -0,0 +1,62 @@ +package invoiceupdater + +import ( + "fmt" + + "github.com/alpacahq/alpacadecimal" + "github.com/samber/lo" + + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/productcatalog" +) + +func IsFlatFee(line billing.GenericInvoiceLineReader) bool { + if line == nil { + return false + } + + price := line.GetPrice() + if price == nil { + return false + } + + return price.Type() == productcatalog.FlatPriceType +} + +func GetFlatFeePerUnitAmount(line billing.GenericInvoiceLineReader) (alpacadecimal.Decimal, error) { + if line == nil { + return alpacadecimal.Zero, fmt.Errorf("line is nil") + } + + price := line.GetPrice() + if price == nil { + return alpacadecimal.Zero, fmt.Errorf("line missing flat-fee metadata") + } + + flatPrice, err := price.AsFlat() + if err != nil { + return alpacadecimal.Zero, err + } + + return flatPrice.Amount, nil +} + +func SetFlatFeePerUnitAmount(line billing.GenericInvoiceLine, perUnitAmount alpacadecimal.Decimal) error { + if line == nil { + return fmt.Errorf("line is nil") + } + + price := line.GetPrice() + if price == nil { + return fmt.Errorf("line missing flat-fee metadata") + } + + flatPrice, err := price.AsFlat() + if err != nil { + return err + } + + flatPrice.Amount = perUnitAmount + line.SetPrice(lo.FromPtr(productcatalog.NewPriceFrom(flatPrice))) + return nil +} diff --git a/billing/charges/invoiceupdater/invoiceupdate.go b/billing/charges/invoiceupdater/invoiceupdate.go new file mode 100644 index 0000000000000000000000000000000000000000..9ab26e2f005c66ac74b134d2f39c0c6b45b4d345 --- /dev/null +++ b/billing/charges/invoiceupdater/invoiceupdate.go @@ -0,0 +1,743 @@ +package invoiceupdater + +import ( + "context" + "errors" + "fmt" + "log/slog" + "strings" + + "github.com/alpacahq/alpacadecimal" + "github.com/samber/lo" + + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/customer" + "github.com/openmeterio/openmeter/openmeter/streaming" + "github.com/openmeterio/openmeter/pkg/clock" + "github.com/openmeterio/openmeter/pkg/currencyx" +) + +const invoiceUpdaterComponentName billing.ComponentName = "charges.invoiceupdater" + +type Updater interface { + ApplyPatches(ctx context.Context, customerID customer.CustomerID, patches Patches) error +} + +type updater struct { + billingService billing.Service + logger *slog.Logger +} + +type Config struct { + BillingService billing.Service + Logger *slog.Logger +} + +func (c Config) Validate() error { + var errs []error + + if c.BillingService == nil { + errs = append(errs, errors.New("billing service cannot be null")) + } + + if c.Logger == nil { + errs = append(errs, errors.New("logger cannot be null")) + } + + return errors.Join(errs...) +} + +func New(config Config) (Updater, error) { + if err := config.Validate(); err != nil { + return nil, err + } + + return &updater{ + billingService: config.BillingService, + logger: config.Logger, + }, nil +} + +func (u *updater) ApplyPatches(ctx context.Context, customerID customer.CustomerID, patches Patches) error { + patchesParsed, err := u.parsePatches(patches) + if err != nil { + return fmt.Errorf("parsing patches: %w", err) + } + + if err := u.resolveGatheringLineDeletesByChargeID(ctx, customerID, &patchesParsed); err != nil { + return fmt.Errorf("resolving gathering line deletes by charge ID: %w", err) + } + + if err := u.resolveGatheringLineUpsertsByChargeID(ctx, customerID, &patchesParsed); err != nil { + return fmt.Errorf("resolving gathering line upserts by charge ID: %w", err) + } + + err = u.provisionUpcomingLines(ctx, customerID, patchesParsed.newLines) + if err != nil { + return fmt.Errorf("provisioning upcoming lines: %w", err) + } + + invoicesByID, err := u.listInvoicesByID(ctx, customerID.Namespace, lo.Keys(patchesParsed.updatedLinesByInvoiceID)) + if err != nil { + return fmt.Errorf("listing invoices: %w", err) + } + + for invoiceID, linePatches := range patchesParsed.updatedLinesByInvoiceID { + namespacedInvoiceID := billing.InvoiceID{ + Namespace: customerID.Namespace, + ID: invoiceID, + } + + invoice, ok := invoicesByID[invoiceID] + if !ok { + return fmt.Errorf("getting invoice: invoice[%s/%s] not found", customerID.Namespace, invoiceID) + } + + if invoice.Type() == billing.InvoiceTypeGathering { + if err := u.updateGatheringInvoice(ctx, namespacedInvoiceID, linePatches); err != nil { + return fmt.Errorf("updating gathering invoice: %w", err) + } + + continue + } + + standardInvoice, err := invoice.AsStandardInvoice() + if err != nil { + return fmt.Errorf("converting invoice to standard invoice: %w", err) + } + + if !standardInvoice.StatusDetails.Immutable { + if err := u.updateMutableStandardInvoice(ctx, standardInvoice, linePatches); err != nil { + return fmt.Errorf("updating mutable invoice: %w", err) + } + + continue + } + + if err := u.updateImmutableInvoice(ctx, standardInvoice, linePatches); err != nil { + return fmt.Errorf("updating immutable invoice: %w", err) + } + } + + return nil +} + +func (u *updater) listInvoicesByID(ctx context.Context, namespace string, invoiceIDs []string) (map[string]billing.Invoice, error) { + if len(invoiceIDs) == 0 { + return map[string]billing.Invoice{}, nil + } + + resp, err := u.billingService.ListInvoices(ctx, billing.ListInvoicesInput{ + Namespaces: []string{namespace}, + IDs: invoiceIDs, + IncludeDeleted: true, + }) + if err != nil { + return nil, err + } + + invoicesByID := make(map[string]billing.Invoice, len(resp.Items)) + for _, invoice := range resp.Items { + genericInvoice, err := invoice.AsGenericInvoice() + if err != nil { + return nil, fmt.Errorf("converting invoice to generic invoice: %w", err) + } + + invoicesByID[genericInvoice.GetID()] = invoice + } + + return invoicesByID, nil +} + +func (u *updater) LogPatches(patches Patches, invoicesByID map[string]billing.Invoice) { + suppressedDryRunPatches := 0 + + for _, patch := range patches { + if !isDryRunLoggablePatch(patch, invoicesByID) { + suppressedDryRunPatches++ + continue + } + + patch.Log(u.logger) + } + + if suppressedDryRunPatches > 0 { + u.logger.Info("suppressed dry run patches", "count", suppressedDryRunPatches) + } +} + +func isDryRunLoggablePatch(patch Patch, invoicesByID map[string]billing.Invoice) bool { + switch patch.Op() { + case PatchOpLineCreate: + createPatch, err := patch.AsCreateLinePatch() + if err != nil { + return true + } + + // Missing current-period pending lines are expected catch-up work for subscription + // sync. Dry-run output should focus on actionable drift on already materialized + // resources, so we suppress create-line logs only when they belong to the current + // billing period. + return !isCurrentBillingPeriod(createPatch.Line) + case PatchOpLineDelete: + deletePatch, err := patch.AsDeleteLinePatch() + if err != nil { + return true + } + + return isMutableInvoice(deletePatch.InvoiceID, invoicesByID) + case PatchOpLineUpdate: + updatePatch, err := patch.AsUpdateLinePatch() + if err != nil { + return true + } + + return isMutableInvoice(updatePatch.TargetState.GetInvoiceID(), invoicesByID) + case PatchOpDeleteGatheringLineByChargeID: + return true + default: + return true + } +} + +func isCurrentBillingPeriod(line billing.GatheringLine) bool { + subscriptionRef := line.GetSubscriptionReference() + if subscriptionRef == nil { + return false + } + + now := clock.Now().UTC() + billingPeriod := subscriptionRef.BillingPeriod + + return !now.Before(billingPeriod.From) && now.Before(billingPeriod.To) +} + +func isMutableInvoice(invoiceID string, invoicesByID map[string]billing.Invoice) bool { + invoice, ok := invoicesByID[invoiceID] + if !ok { + return true + } + + if invoice.Type() == billing.InvoiceTypeGathering { + return true + } + + standardInvoice, err := invoice.AsStandardInvoice() + if err != nil { + return true + } + + return !standardInvoice.StatusDetails.Immutable +} + +type patchesParsed struct { + newLines []billing.GatheringLine + + updatedLinesByInvoiceID map[string]invoicePatches + + gatheringLineDeletesByChargeID []string + gatheringLineUpsertsByChargeID map[string]PatchUpsertGatheringLineByChargeID +} + +type invoicePatches struct { + updatedLines []invoiceLineUpdatePatch + deletedLines []invoiceLineDeletePatch +} + +type invoiceLineUpdatePatch struct { + line billing.GenericInvoiceLine + op PatchOperation +} + +type invoiceLineDeletePatch struct { + line billing.LineID + op PatchOperation +} + +func (u *updater) parsePatches(patches Patches) (patchesParsed, error) { + parsed := patchesParsed{ + updatedLinesByInvoiceID: make(map[string]invoicePatches), + gatheringLineUpsertsByChargeID: make(map[string]PatchUpsertGatheringLineByChargeID), + } + + for _, patch := range patches { + switch patch.Op() { + case PatchOpLineCreate: + create, err := patch.AsCreateLinePatch() + if err != nil { + return patchesParsed{}, fmt.Errorf("getting line: %w", err) + } + + parsed.newLines = append(parsed.newLines, create.Line) + case PatchOpLineDelete: + deletePatch, err := patch.AsDeleteLinePatch() + if err != nil { + return patchesParsed{}, fmt.Errorf("getting line: %w", err) + } + + lineUpdates := parsed.updatedLinesByInvoiceID[deletePatch.InvoiceID] + lineUpdates.deletedLines = append(lineUpdates.deletedLines, invoiceLineDeletePatch{ + line: deletePatch.Line, + op: patch.Op(), + }) + parsed.updatedLinesByInvoiceID[deletePatch.InvoiceID] = lineUpdates + case PatchOpLineUpdate: + update, err := patch.AsUpdateLinePatch() + if err != nil { + return patchesParsed{}, fmt.Errorf("getting line: %w", err) + } + + lineUpdates := parsed.updatedLinesByInvoiceID[update.TargetState.GetInvoiceID()] + lineUpdates.updatedLines = append(lineUpdates.updatedLines, invoiceLineUpdatePatch{ + line: update.TargetState, + op: patch.Op(), + }) + parsed.updatedLinesByInvoiceID[update.TargetState.GetInvoiceID()] = lineUpdates + case PatchOpDeleteGatheringLineByChargeID: + deletePatch, err := patch.AsDeleteGatheringLineByChargeIDPatch() + if err != nil { + return patchesParsed{}, fmt.Errorf("getting charge ID: %w", err) + } + + parsed.gatheringLineDeletesByChargeID = append(parsed.gatheringLineDeletesByChargeID, deletePatch.ChargeID) + case PatchOpUpsertGatheringLineByChargeID: + updatePatch, err := patch.AsUpsertGatheringLineByChargeIDPatch() + if err != nil { + return patchesParsed{}, fmt.Errorf("getting gathering line upsert: %w", err) + } + + parsed.gatheringLineUpsertsByChargeID[updatePatch.ChargeID] = updatePatch + default: + return patchesParsed{}, fmt.Errorf("unexpected patch operation: %s", patch.Op()) + } + } + + return parsed, nil +} + +func (u *updater) provisionUpcomingLines(ctx context.Context, customerID customer.CustomerID, lines []billing.GatheringLine) error { + if len(lines) == 0 { + return nil + } + + linesByCurrency := lo.GroupBy(lines, func(l billing.GatheringLine) currencyx.Code { + return l.Currency + }) + + for currency, lines := range linesByCurrency { + _, err := u.billingService.CreatePendingInvoiceLines(ctx, billing.CreatePendingInvoiceLinesInput{ + Customer: customerID, + Currency: currency, + Lines: lines, + }) + if err != nil { + return fmt.Errorf("creating pending invoice lines: %w", err) + } + } + + return nil +} + +func (u *updater) resolveGatheringLineDeletesByChargeID(ctx context.Context, customerID customer.CustomerID, parsed *patchesParsed) error { + if len(parsed.gatheringLineDeletesByChargeID) == 0 { + return nil + } + + chargeIDs := make(map[string]struct{}, len(parsed.gatheringLineDeletesByChargeID)) + for _, chargeID := range parsed.gatheringLineDeletesByChargeID { + chargeIDs[chargeID] = struct{}{} + } + + invoices, err := u.billingService.ListGatheringInvoices(ctx, billing.ListGatheringInvoicesInput{ + Namespaces: []string{customerID.Namespace}, + Customers: []string{customerID.ID}, + Expand: billing.GatheringInvoiceExpands{ + billing.GatheringInvoiceExpandLines, + }, + }) + if err != nil { + return fmt.Errorf("listing gathering invoices: %w", err) + } + + for _, invoice := range invoices.Items { + for _, line := range invoice.Lines.OrEmpty() { + if line.DeletedAt != nil || line.ChargeID == nil { + continue + } + + if _, ok := chargeIDs[*line.ChargeID]; !ok { + continue + } + + lineUpdates := parsed.updatedLinesByInvoiceID[invoice.ID] + lineUpdates.deletedLines = append(lineUpdates.deletedLines, invoiceLineDeletePatch{ + line: line.GetLineID(), + op: PatchOpDeleteGatheringLineByChargeID, + }) + parsed.updatedLinesByInvoiceID[invoice.ID] = lineUpdates + } + } + + return nil +} + +func (u *updater) resolveGatheringLineUpsertsByChargeID(ctx context.Context, customerID customer.CustomerID, parsed *patchesParsed) error { + if len(parsed.gatheringLineUpsertsByChargeID) == 0 { + return nil + } + + invoices, err := u.billingService.ListGatheringInvoices(ctx, billing.ListGatheringInvoicesInput{ + Namespaces: []string{customerID.Namespace}, + Customers: []string{customerID.ID}, + Expand: billing.GatheringInvoiceExpands{ + billing.GatheringInvoiceExpandLines, + }, + }) + if err != nil { + return fmt.Errorf("listing gathering invoices: %w", err) + } + + for _, invoice := range invoices.Items { + for _, line := range invoice.Lines.OrEmpty() { + if line.DeletedAt != nil || line.ChargeID == nil { + continue + } + + updatePatch, ok := parsed.gatheringLineUpsertsByChargeID[*line.ChargeID] + if !ok { + continue + } + + genericLine := line.AsGenericLine() + mergedLine, err := genericLine.WithTargetState(updatePatch.TargetState.AsGenericLine()) + if err != nil { + return fmt.Errorf("merging gathering line[%s] update by charge[%s]: %w", line.ID, *line.ChargeID, err) + } + + lineUpdates := parsed.updatedLinesByInvoiceID[invoice.ID] + lineUpdates.updatedLines = append(lineUpdates.updatedLines, invoiceLineUpdatePatch{ + line: mergedLine, + op: PatchOpUpsertGatheringLineByChargeID, + }) + parsed.updatedLinesByInvoiceID[invoice.ID] = lineUpdates + delete(parsed.gatheringLineUpsertsByChargeID, *line.ChargeID) + } + } + + for _, upsertPatch := range parsed.gatheringLineUpsertsByChargeID { + parsed.newLines = append(parsed.newLines, upsertPatch.TargetState) + } + + return nil +} + +func (u *updater) updateMutableStandardInvoice(ctx context.Context, invoice billing.StandardInvoice, linePatches invoicePatches) error { + updatedInvoice, err := u.billingService.UpdateStandardInvoice(ctx, billing.UpdateStandardInvoiceInput{ + Invoice: invoice.GetInvoiceID(), + ChangeSource: billing.ChangeSourceSystem, + IncludeDeletedLines: true, + EditFn: func(invoice *billing.StandardInvoice) error { + for _, deletePatch := range linePatches.deletedLines { + lineID := deletePatch.line + line := invoice.Lines.GetByID(lineID.ID) + if line == nil { + return fmt.Errorf("line[%s] not found in the invoice, cannot delete", lineID) + } + if err := ensureLineHasChargeID(line, deletePatch.op); err != nil { + return err + } + + line.DeletedAt = lo.ToPtr(clock.Now()) + } + + for _, updatePatch := range linePatches.updatedLines { + targetState := updatePatch.line + targetStandardLine, err := targetState.AsInvoiceLine().AsStandardLine() + if err != nil { + return fmt.Errorf("line[%s] is not a standard line, cannot update: %w", targetState.GetID(), err) + } + + line := invoice.Lines.GetByID(targetStandardLine.ID) + if line == nil { + return fmt.Errorf("line[%s] not found in the invoice, cannot update", targetStandardLine.ID) + } + if err := ensureLineHasChargeID(line, updatePatch.op); err != nil { + return err + } + if err := ensureLineHasChargeID(&targetStandardLine, updatePatch.op); err != nil { + return err + } + + // The charges invoice updater only owns charge-backed lines. Charge line + // engines own quantity snapshots and detailed-line projection, so the + // target state must not be passed through billing's generic snapshotter here. + + if ok := invoice.Lines.ReplaceByID(targetStandardLine.ID, &targetStandardLine); !ok { + return fmt.Errorf("line[%s/%s] not found in the invoice, cannot update", targetStandardLine.ID, lo.FromPtrOr(targetStandardLine.ChildUniqueReferenceID, "nil")) + } + } + + return nil + }, + }) + if err != nil { + return fmt.Errorf("updating invoice[%s]: %w", invoice.ID, err) + } + + if updatedInvoice.Lines.NonDeletedLineCount() == 0 { + if updatedInvoice.Status == billing.StandardInvoiceStatusGathering { + return nil + } + + invoice, err := u.billingService.DeleteInvoice(ctx, billing.DeleteInvoiceInput{ + Invoice: updatedInvoice.GetInvoiceID(), + DeletionSource: billing.ChangeSourceSystem, + }) + if err != nil { + return fmt.Errorf("deleting empty invoice: %w", err) + } + + if invoice.Status == billing.StandardInvoiceStatusDeleteFailed { + u.logger.WarnContext(ctx, "empty invoice deletion failed", + "invoice.id", invoice.ID, + "invoice.namespace", invoice.Namespace, + "validation_issues", strings.Join( + lo.Map(invoice.ValidationIssues, func(i billing.ValidationIssue, _ int) string { + return fmt.Sprintf("[id=%s] %s: %s", i.ID, i.Code, i.Message) + }), + ", ")) + } + } + + return nil +} + +func (u *updater) updateGatheringInvoice(ctx context.Context, invoiceID billing.InvoiceID, linePatches invoicePatches) error { + _, err := u.billingService.UpdateGatheringInvoice(ctx, billing.UpdateGatheringInvoiceInput{ + Invoice: invoiceID, + ChangeSource: billing.ChangeSourceSystem, + IncludeDeletedLines: true, + EditFn: func(invoice *billing.GatheringInvoice) error { + for _, deletePatch := range linePatches.deletedLines { + lineID := deletePatch.line + line, ok := invoice.Lines.GetByID(lineID.ID) + if !ok { + return fmt.Errorf("line[%s] not found in the invoice, cannot delete", lineID) + } + if err := ensureLineHasChargeID(&line, deletePatch.op); err != nil { + return err + } + + line.DeletedAt = lo.ToPtr(clock.Now()) + + if err := invoice.Lines.ReplaceByID(line); err != nil { + return fmt.Errorf("setting line[%s]: %w", lineID, err) + } + } + + for _, updatePatch := range linePatches.updatedLines { + targetStateGeneric := updatePatch.line + targetGatheringLine, err := targetStateGeneric.AsInvoiceLine().AsGatheringLine() + if err != nil { + return fmt.Errorf("line[%s] is not a gathering line, cannot update: %w", targetStateGeneric.GetID(), err) + } + if err := ensureLineHasChargeID(&targetGatheringLine, updatePatch.op); err != nil { + return err + } + + if err := invoice.Lines.ReplaceByID(targetGatheringLine); err != nil { + return fmt.Errorf("setting line[%s]: %w", targetGatheringLine.ID, err) + } + } + + return nil + }, + }) + + return err +} + +func (u *updater) updateImmutableInvoice(ctx context.Context, invoice billing.StandardInvoice, linePatches invoicePatches) error { + invoice, err := u.billingService.GetStandardInvoiceById(ctx, billing.GetStandardInvoiceByIdInput{ + Invoice: invoice.GetInvoiceID(), + Expand: billing.StandardInvoiceExpandAll, + }) + if err != nil { + return fmt.Errorf("getting invoice: %w", err) + } + + validationIssues := []billing.ValidationIssue{} + unsupportedCreditNoteLines := billing.StandardLines{} + + for _, deletePatch := range linePatches.deletedLines { + line := invoice.Lines.GetByID(deletePatch.line.ID) + if line != nil { + if err := ensureLineHasChargeID(line, deletePatch.op); err != nil { + return err + } + } + validationIssues = append(validationIssues, + newValidationIssueOnLine(line, "line should be deleted, but the invoice is immutable"), + ) + + if line != nil { + unsupportedCreditNoteLines = append(unsupportedCreditNoteLines, line) + } + } + + for _, updatePatch := range linePatches.updatedLines { + targetState := updatePatch.line + existingLine := invoice.Lines.GetByID(targetState.GetID()) + if existingLine == nil { + return fmt.Errorf("line[%s] not found in the invoice, cannot update", targetState.GetID()) + } + if err := ensureLineHasChargeID(existingLine, updatePatch.op); err != nil { + return err + } + + targetStandardLine, err := targetState.AsInvoiceLine().AsStandardLine() + if err != nil { + return fmt.Errorf("line[%s] is not a standard line, cannot update: %w", targetState.GetID(), err) + } + if err := ensureLineHasChargeID(&targetStandardLine, updatePatch.op); err != nil { + return err + } + + if IsFlatFee(targetState) { + existingPerUnitAmount, err := GetFlatFeePerUnitAmount(existingLine) + if err != nil { + return fmt.Errorf("getting flat fee per unit amount: %w", err) + } + + targetPerUnitAmount, err := GetFlatFeePerUnitAmount(targetState) + if err != nil { + return fmt.Errorf("getting flat fee per unit amount: %w", err) + } + + if !existingPerUnitAmount.Equal(targetPerUnitAmount) { + validationIssues = append(validationIssues, + newValidationIssueOnLine(existingLine, "flat fee line's per unit amount cannot be changed on immutable invoice (new per unit amount: %s)", + targetPerUnitAmount.String()), + ) + + continue + } + + if !targetState.GetServicePeriod().Truncate(streaming.MinimumWindowSizeDuration).Equal(existingLine.GetServicePeriod().Truncate(streaming.MinimumWindowSizeDuration)) { + validationIssues = append(validationIssues, + newValidationIssueOnLine(existingLine, "flat fee line's service period cannot be changed on immutable invoice"), + ) + } + + continue + } + + if !targetState.GetServicePeriod().Truncate(streaming.MinimumWindowSizeDuration).Equal(existingLine.GetServicePeriod().Truncate(streaming.MinimumWindowSizeDuration)) { + existingQty := standardLineUsageQuantity(existingLine) + targetQty := standardLineUsageQuantity(&targetStandardLine) + if existingQty == nil || targetQty == nil || !targetQty.Equal(*existingQty) { + validationIssues = append(validationIssues, + newValidationIssueOnLine(existingLine, "usage based line's quantity cannot be changed on immutable invoice (new qty: %s)", + standardLineUsageQuantityString(&targetStandardLine)), + ) + } + } + } + + if len(unsupportedCreditNoteLines) > 0 { + if err := u.billingService.OnUnsupportedCreditNote(ctx, billing.OnUnsupportedCreditNoteInput{ + Invoice: invoice, + Lines: unsupportedCreditNoteLines, + }); err != nil { + return fmt.Errorf("handling unsupported credit note for invoice[%s]: %w", invoice.ID, err) + } + } + + if len(validationIssues) > 0 { + mergedValidationIssues, wasChange := u.mergeValidationIssues(invoice, validationIssues) + if !wasChange { + return nil + } + + return u.billingService.UpsertValidationIssues(ctx, billing.UpsertValidationIssuesInput{ + Invoice: invoice.GetInvoiceID(), + Issues: mergedValidationIssues, + }) + } + + return nil +} + +func standardLineUsageQuantity(line *billing.StandardLine) *alpacadecimal.Decimal { + if line == nil || line.UsageBased == nil { + return nil + } + + return line.UsageBased.Quantity +} + +func standardLineUsageQuantityString(line *billing.StandardLine) string { + qty := standardLineUsageQuantity(line) + if qty == nil { + return "nil" + } + + return qty.String() +} + +func ensureLineHasChargeID(line billing.GenericInvoiceLineReader, operation PatchOperation) error { + if line == nil { + return fmt.Errorf("line is nil for patch operation[%s]", operation) + } + + chargeID, err := line.AsInvoiceLine().GetChargeID() + if err != nil { + return fmt.Errorf("line[%s] charge id: %w", line.GetID(), err) + } + + if chargeID == nil || *chargeID == "" { + return fmt.Errorf("line[%s] has no charge ID, charges invoice updater cannot apply patch operation[%s] to non-charge lines", line.GetID(), operation) + } + + return nil +} + +func newValidationIssueOnLine(line *billing.StandardLine, message string, a ...any) billing.ValidationIssue { + if line == nil { + return billing.ValidationIssue{ + Severity: billing.ValidationIssueSeverityCritical, + Message: "line not found in the invoice, cannot update", + Code: billing.ImmutableInvoiceHandlingNotSupportedErrorCode, + Component: invoiceUpdaterComponentName, + Path: "lines/nil", + } + } + + return billing.ValidationIssue{ + Severity: billing.ValidationIssueSeverityWarning, + Message: fmt.Sprintf(message, a...), + Code: billing.ImmutableInvoiceHandlingNotSupportedErrorCode, + Component: invoiceUpdaterComponentName, + Path: fmt.Sprintf("lines/%s", line.ID), + } +} + +func (u *updater) mergeValidationIssues(invoice billing.StandardInvoice, issues []billing.ValidationIssue) (billing.ValidationIssues, bool) { + changed := false + + for _, issue := range issues { + _, found := lo.Find(invoice.ValidationIssues, func(i billing.ValidationIssue) bool { + return i.Path == issue.Path && i.Component == invoiceUpdaterComponentName && i.Code == billing.ImmutableInvoiceHandlingNotSupportedErrorCode && + i.Message == issue.Message + }) + + if found { + continue + } + + changed = true + invoice.ValidationIssues = append(invoice.ValidationIssues, issue) + } + + return invoice.ValidationIssues, changed +} diff --git a/billing/charges/invoiceupdater/patch.go b/billing/charges/invoiceupdater/patch.go new file mode 100644 index 0000000000000000000000000000000000000000..bc84a998bf6c66e6bc3712652cb839b9b788b21f --- /dev/null +++ b/billing/charges/invoiceupdater/patch.go @@ -0,0 +1,218 @@ +package invoiceupdater + +import ( + "fmt" + "log/slog" + + "github.com/openmeterio/openmeter/openmeter/billing" +) + +type PatchOperation string + +const ( + PatchOpLineCreate PatchOperation = "line_create" + PatchOpLineDelete PatchOperation = "line_delete" + PatchOpLineUpdate PatchOperation = "line_update" + PatchOpDeleteGatheringLineByChargeID PatchOperation = "delete_gathering_line_by_charge_id" + PatchOpUpsertGatheringLineByChargeID PatchOperation = "upsert_gathering_line_by_charge_id" +) + +type PatchLineCreate struct { + Line billing.GatheringLine +} + +type PatchLineDelete struct { + Line billing.LineID + InvoiceID string +} + +func (p PatchLineDelete) RequireTarget(line billing.GenericInvoiceLineReader) error { + if line == nil { + return fmt.Errorf("line is required") + } + + lineID := line.GetLineID() + if p.Line != lineID { + return fmt.Errorf("target line[%s] does not match line[%s]", p.Line, lineID) + } + + if p.InvoiceID != line.GetInvoiceID() { + return fmt.Errorf("target invoice[%s] does not match invoice[%s]", p.InvoiceID, line.GetInvoiceID()) + } + + return nil +} + +type PatchLineUpdate struct { + TargetState billing.GenericInvoiceLine +} + +type PatchDeleteGatheringLineByChargeID struct { + ChargeID string +} + +type PatchUpsertGatheringLineByChargeID struct { + ChargeID string + TargetState billing.GatheringLine +} + +type Patch struct { + op PatchOperation + + createLinePatch PatchLineCreate + deleteLinePatch PatchLineDelete + updateLinePatch PatchLineUpdate + deleteGatheringLineByChargeIDPatch PatchDeleteGatheringLineByChargeID + upsertGatheringLineByChargeIDPatch PatchUpsertGatheringLineByChargeID +} + +func (p Patch) Op() PatchOperation { + return p.op +} + +func (p Patch) AsCreateLinePatch() (PatchLineCreate, error) { + if p.op != PatchOpLineCreate { + return PatchLineCreate{}, fmt.Errorf("expected create line patch, got %s", p.op) + } + + return p.createLinePatch, nil +} + +func (p Patch) AsDeleteLinePatch() (PatchLineDelete, error) { + if p.op != PatchOpLineDelete { + return PatchLineDelete{}, fmt.Errorf("expected delete line patch, got %s", p.op) + } + + return p.deleteLinePatch, nil +} + +func (p Patch) AsUpdateLinePatch() (PatchLineUpdate, error) { + if p.op != PatchOpLineUpdate { + return PatchLineUpdate{}, fmt.Errorf("expected update line patch, got %s", p.op) + } + + return p.updateLinePatch, nil +} + +func (p PatchLineUpdate) RequireTarget(line billing.GenericInvoiceLineReader) error { + if line == nil { + return fmt.Errorf("line is required") + } + + if p.TargetState == nil { + return fmt.Errorf("target state is required") + } + + targetLineID := p.TargetState.GetLineID() + lineID := line.GetLineID() + if targetLineID != lineID { + return fmt.Errorf("target line[%s] does not match line[%s]", targetLineID, lineID) + } + + if p.TargetState.GetInvoiceID() != line.GetInvoiceID() { + return fmt.Errorf("target invoice[%s] does not match invoice[%s]", p.TargetState.GetInvoiceID(), line.GetInvoiceID()) + } + + return nil +} + +func (p Patch) AsDeleteGatheringLineByChargeIDPatch() (PatchDeleteGatheringLineByChargeID, error) { + if p.op != PatchOpDeleteGatheringLineByChargeID { + return PatchDeleteGatheringLineByChargeID{}, fmt.Errorf("expected delete gathering line by charge ID patch, got %s", p.op) + } + + return p.deleteGatheringLineByChargeIDPatch, nil +} + +func (p PatchDeleteGatheringLineByChargeID) RequireCharge(chargeID string) error { + if p.ChargeID != chargeID { + return fmt.Errorf("target charge[%s] does not match charge[%s]", p.ChargeID, chargeID) + } + + return nil +} + +func (p Patch) AsUpsertGatheringLineByChargeIDPatch() (PatchUpsertGatheringLineByChargeID, error) { + if p.op != PatchOpUpsertGatheringLineByChargeID { + return PatchUpsertGatheringLineByChargeID{}, fmt.Errorf("expected upsert gathering line by charge ID patch, got %s", p.op) + } + + return p.upsertGatheringLineByChargeIDPatch, nil +} + +func (p PatchUpsertGatheringLineByChargeID) RequireCharge(chargeID string) error { + if p.ChargeID != chargeID { + return fmt.Errorf("target charge[%s] does not match charge[%s]", p.ChargeID, chargeID) + } + + targetChargeID := p.TargetState.GetChargeID() + if targetChargeID == nil || *targetChargeID != chargeID { + return fmt.Errorf("target state references unexpected charge") + } + + return nil +} + +func NewDeleteLinePatch(lineID billing.LineID, invoiceID string) Patch { + return Patch{ + op: PatchOpLineDelete, + deleteLinePatch: PatchLineDelete{ + Line: lineID, + InvoiceID: invoiceID, + }, + } +} + +func NewDeleteGatheringLineByChargeIDPatch(chargeID string) Patch { + return Patch{ + op: PatchOpDeleteGatheringLineByChargeID, + deleteGatheringLineByChargeIDPatch: PatchDeleteGatheringLineByChargeID{ + ChargeID: chargeID, + }, + } +} + +func NewUpsertGatheringLineByChargeIDPatch(chargeID string, targetState billing.GatheringLine) Patch { + return Patch{ + op: PatchOpUpsertGatheringLineByChargeID, + upsertGatheringLineByChargeIDPatch: PatchUpsertGatheringLineByChargeID{ + ChargeID: chargeID, + TargetState: targetState, + }, + } +} + +func NewUpdateLinePatch(line billing.GenericInvoiceLine) Patch { + return Patch{ + op: PatchOpLineUpdate, + updateLinePatch: PatchLineUpdate{ + TargetState: line, + }, + } +} + +func NewCreateLinePatch(line billing.GatheringLine) Patch { + return Patch{ + op: PatchOpLineCreate, + createLinePatch: PatchLineCreate{ + Line: line, + }, + } +} + +func (p Patch) Log(logger *slog.Logger) { + switch p.op { + case PatchOpLineCreate: + logger.Info("create line patch", "line_id", p.createLinePatch.Line.GetLineID().ID, "new_service_period_from", p.createLinePatch.Line.GetServicePeriod().From, "new_service_period_to", p.createLinePatch.Line.GetServicePeriod().To, "unique_reference_id", p.createLinePatch.Line.GetChildUniqueReferenceID()) + case PatchOpLineDelete: + logger.Info("delete line patch", "line_id", p.deleteLinePatch.Line, "invoice_id", p.deleteLinePatch.InvoiceID) + case PatchOpLineUpdate: + logger.Info("update line patch", "line_id", p.updateLinePatch.TargetState.GetLineID().ID, "invoice_id", p.updateLinePatch.TargetState.GetInvoiceID(), "new_service_period_from", p.updateLinePatch.TargetState.GetServicePeriod().From, "new_service_period_to", p.updateLinePatch.TargetState.GetServicePeriod().To, "unique_reference_id", p.updateLinePatch.TargetState.GetChildUniqueReferenceID()) + case PatchOpDeleteGatheringLineByChargeID: + logger.Info("delete gathering line by charge id patch", "charge_id", p.deleteGatheringLineByChargeIDPatch.ChargeID) + case PatchOpUpsertGatheringLineByChargeID: + logger.Info("upsert gathering line by charge id patch", "charge_id", p.upsertGatheringLineByChargeIDPatch.ChargeID, "target_line_id", p.upsertGatheringLineByChargeIDPatch.TargetState.GetLineID().ID, "new_service_period_from", p.upsertGatheringLineByChargeIDPatch.TargetState.GetServicePeriod().From, "new_service_period_to", p.upsertGatheringLineByChargeIDPatch.TargetState.GetServicePeriod().To, "unique_reference_id", p.upsertGatheringLineByChargeIDPatch.TargetState.GetChildUniqueReferenceID()) + default: + logger.Info("unknown patch operation", "operation", p.op) + } +} diff --git a/billing/charges/invoiceupdater/patches.go b/billing/charges/invoiceupdater/patches.go new file mode 100644 index 0000000000000000000000000000000000000000..20adf1f8d0ab039f32e9571b6ee7fc51f3cb53dd --- /dev/null +++ b/billing/charges/invoiceupdater/patches.go @@ -0,0 +1,145 @@ +package invoiceupdater + +import ( + "fmt" + + "github.com/openmeterio/openmeter/openmeter/billing" +) + +type Patches []Patch + +// BisectByInvoiceID splits the patches into two groups: one for the invoice ID before the patch and one for the invoice ID after the patch. +// +// First return value is the patches with the invoice ID, the second return value is the patches without the invoice ID. +// Corner cases: +// - Any gathering invoice line patch will be in the `rest` group. +// - Any create line patch will be in the `rest` group. +func (p Patches) BisectByStandardInvoiceID(invoiceID string) (Patches, Patches, error) { + invoicePatches := make(Patches, 0, len(p)) + rest := make(Patches, 0, len(p)) + + for _, patch := range p { + switch patch.Op() { + case PatchOpLineDelete: + val, err := patch.AsDeleteLinePatch() + if err != nil { + return nil, nil, fmt.Errorf("failed to convert patch to delete line patch: %w", err) + } + if val.InvoiceID == invoiceID { + invoicePatches = append(invoicePatches, patch) + } else { + rest = append(rest, patch) + } + case PatchOpLineUpdate: + val, err := patch.AsUpdateLinePatch() + if err != nil { + return nil, nil, fmt.Errorf("failed to convert patch to update line patch: %w", err) + } + if val.TargetState.GetInvoiceID() == invoiceID { + invoicePatches = append(invoicePatches, patch) + } else { + rest = append(rest, patch) + } + default: + rest = append(rest, patch) + } + } + + return invoicePatches, rest, nil +} + +func (p Patches) RequireSingularStandardInvoiceLineDeletePatch() (PatchLineDelete, error) { + patch, err := p.requireSingularPatch("standard invoice line delete") + if err != nil { + return PatchLineDelete{}, err + } + + return patch.AsDeleteLinePatch() +} + +func (p Patches) RequireSingularLineUpdatePatchForTarget(line billing.GenericInvoiceLineReader) (PatchLineUpdate, error) { + patch, err := p.requireSingularPatch("line update") + if err != nil { + return PatchLineUpdate{}, err + } + + updatePatch, err := patch.AsUpdateLinePatch() + if err != nil { + return PatchLineUpdate{}, err + } + + if err := updatePatch.RequireTarget(line); err != nil { + return PatchLineUpdate{}, err + } + + return updatePatch, nil +} + +func (p Patches) RequireSingularGatheringLinePatchForCharge(chargeID string) (Patch, error) { + patch, err := p.requireSingularPatch("gathering line by charge") + if err != nil { + return Patch{}, err + } + + switch patch.Op() { + case PatchOpUpsertGatheringLineByChargeID: + upsertPatch, err := patch.AsUpsertGatheringLineByChargeIDPatch() + if err != nil { + return Patch{}, err + } + + if err := upsertPatch.RequireCharge(chargeID); err != nil { + return Patch{}, err + } + + return patch, nil + case PatchOpDeleteGatheringLineByChargeID: + deletePatch, err := patch.AsDeleteGatheringLineByChargeIDPatch() + if err != nil { + return Patch{}, err + } + + if err := deletePatch.RequireCharge(chargeID); err != nil { + return Patch{}, err + } + + return patch, nil + default: + return Patch{}, fmt.Errorf("expected gathering line by charge patch, got %s", patch.Op()) + } +} + +func (p Patches) RequireType(op PatchOperation, countMatcher func(int) error) error { + if err := countMatcher(len(p)); err != nil { + return err + } + + for _, patch := range p { + if patch.Op() != op { + return fmt.Errorf("expected %s patch, got %s", op, patch.Op()) + } + } + + return nil +} + +func CountLessThanOrEqualTo(c int) func(int) error { + return func(count int) error { + if count > c { + return fmt.Errorf("expected less than or equal to %d, got %d", c, count) + } + return nil + } +} + +func (p Patches) requireSingularPatch(kind string) (Patch, error) { + if len(p) == 0 { + return Patch{}, fmt.Errorf("no %s patches provided", kind) + } + + if len(p) > 1 { + return Patch{}, fmt.Errorf("expected singular %s patch, got %d", kind, len(p)) + } + + return p[0], nil +} diff --git a/billing/charges/invoiceupdater/testutils.go b/billing/charges/invoiceupdater/testutils.go new file mode 100644 index 0000000000000000000000000000000000000000..ed7a1c5c3517c5e48b3f59cd07513adf376b9e50 --- /dev/null +++ b/billing/charges/invoiceupdater/testutils.go @@ -0,0 +1,27 @@ +package invoiceupdater + +import ( + "context" + "errors" + "testing" + + "github.com/openmeterio/openmeter/openmeter/customer" +) + +type unimplementedUpdater struct { + t testing.TB +} + +func NewUnimplementedUpdater(t testing.TB) Updater { + return unimplementedUpdater{ + t: t, + } +} + +func (u unimplementedUpdater) ApplyPatches(context.Context, customer.CustomerID, Patches) error { + if u.t != nil { + u.t.Helper() + } + + return errors.New("invoice updater is not implemented") +} diff --git a/billing/charges/lineage/adapter/adapter.go b/billing/charges/lineage/adapter/adapter.go new file mode 100644 index 0000000000000000000000000000000000000000..00fd24bd18de942fb154fb0f981ac66f1a756b8a --- /dev/null +++ b/billing/charges/lineage/adapter/adapter.go @@ -0,0 +1,62 @@ +package adapter + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/openmeterio/openmeter/openmeter/billing/charges/lineage" + entdb "github.com/openmeterio/openmeter/openmeter/ent/db" + "github.com/openmeterio/openmeter/pkg/framework/entutils" + "github.com/openmeterio/openmeter/pkg/framework/transaction" +) + +type Config struct { + Client *entdb.Client +} + +func (c Config) Validate() error { + if c.Client == nil { + return errors.New("ent client is required") + } + + return nil +} + +func New(config Config) (lineage.Adapter, error) { + if err := config.Validate(); err != nil { + return nil, err + } + + return &adapter{ + db: config.Client, + }, nil +} + +type adapter struct { + db *entdb.Client +} + +func (a *adapter) Tx(ctx context.Context) (context.Context, transaction.Driver, error) { + txCtx, rawConfig, eDriver, err := a.db.HijackTx(ctx, &sql.TxOptions{ + ReadOnly: false, + }) + if err != nil { + return nil, nil, fmt.Errorf("failed to hijack transaction: %w", err) + } + + return txCtx, entutils.NewTxDriver(eDriver, rawConfig), nil +} + +func (a *adapter) WithTx(ctx context.Context, tx *entutils.TxDriver) *adapter { + txDB := entdb.NewTxClientFromRawConfig(ctx, *tx.GetConfig()) + + return &adapter{ + db: txDB.Client(), + } +} + +func (a *adapter) Self() *adapter { + return a +} diff --git a/billing/charges/lineage/adapter/lineage.go b/billing/charges/lineage/adapter/lineage.go new file mode 100644 index 0000000000000000000000000000000000000000..4c5ac7e06f38b92a76d29949320711baaca62084 --- /dev/null +++ b/billing/charges/lineage/adapter/lineage.go @@ -0,0 +1,264 @@ +package adapter + +import ( + "context" + "fmt" + "time" + + "github.com/lib/pq" + "github.com/oklog/ulid/v2" + "github.com/samber/lo" + + "github.com/openmeterio/openmeter/openmeter/billing/charges/lineage" + "github.com/openmeterio/openmeter/openmeter/billing/charges/models/creditrealization" + entdb "github.com/openmeterio/openmeter/openmeter/ent/db" + "github.com/openmeterio/openmeter/openmeter/ent/db/creditrealizationlineage" + "github.com/openmeterio/openmeter/openmeter/ent/db/creditrealizationlineagesegment" + "github.com/openmeterio/openmeter/pkg/currencyx" + "github.com/openmeterio/openmeter/pkg/framework/entutils" +) + +func LoadActiveSegmentsByRealizationID( + ctx context.Context, + db *entdb.Client, + namespace string, + realizationIDs []string, +) (lineage.ActiveSegmentsByRealizationID, error) { + repo := &adapter{db: db} + + return entutils.TransactingRepo(ctx, repo, func(ctx context.Context, tx *adapter) (lineage.ActiveSegmentsByRealizationID, error) { + if len(realizationIDs) == 0 { + return lineage.ActiveSegmentsByRealizationID{}, nil + } + + lineages, err := tx.db.CreditRealizationLineage.Query(). + Where( + creditrealizationlineage.Namespace(namespace), + creditrealizationlineage.RootRealizationIDIn(realizationIDs...), + ). + WithSegments(func(q *entdb.CreditRealizationLineageSegmentQuery) { + q.Where(creditrealizationlineagesegment.ClosedAtIsNil()). + Order(creditrealizationlineagesegment.ByCreatedAt()) + }). + All(ctx) + if err != nil { + return nil, err + } + + return lo.SliceToMap(lineages, func(entry *entdb.CreditRealizationLineage) (string, []lineage.Segment) { + return entry.RootRealizationID, lo.Map(entry.Edges.Segments, func(segment *entdb.CreditRealizationLineageSegment, _ int) lineage.Segment { + return mapSegment(segment) + }) + }), nil + }) +} + +func (a *adapter) LoadActiveSegmentsByRealizationID( + ctx context.Context, + namespace string, + realizationIDs []string, +) (lineage.ActiveSegmentsByRealizationID, error) { + return LoadActiveSegmentsByRealizationID(ctx, a.db, namespace, realizationIDs) +} + +func (a *adapter) CreateLineages(ctx context.Context, input lineage.CreateLineagesInput) error { + return entutils.TransactingRepoWithNoValue(ctx, a, func(ctx context.Context, tx *adapter) error { + rootCreates := make([]*entdb.CreditRealizationLineageCreate, 0, len(input.Specs)) + segmentCreates := make([]*entdb.CreditRealizationLineageSegmentCreate, 0, len(input.Specs)) + + for _, spec := range input.Specs { + rootCreates = append(rootCreates, tx.db.CreditRealizationLineage.Create(). + SetID(spec.LineageID). + SetNamespace(input.Namespace). + SetChargeID(input.ChargeID). + SetRootRealizationID(spec.RootRealizationID). + SetCustomerID(input.CustomerID). + SetCurrency(input.Currency). + SetOriginKind(spec.OriginKind). + SetAdvanceFeatures(pq.StringArray(spec.AdvanceFeatures)), + ) + segmentCreates = append(segmentCreates, tx.db.CreditRealizationLineageSegment.Create(). + SetLineageID(spec.LineageID). + SetAmount(spec.Amount). + SetState(spec.InitialState), + ) + } + + if _, err := tx.db.CreditRealizationLineage.CreateBulk(rootCreates...).Save(ctx); err != nil { + return fmt.Errorf("create credit realization lineages: %w", err) + } + if _, err := tx.db.CreditRealizationLineageSegment.CreateBulk(segmentCreates...).Save(ctx); err != nil { + return fmt.Errorf("create initial credit realization lineage segments: %w", err) + } + + return nil + }) +} + +func (a *adapter) LoadLineagesByCustomer(ctx context.Context, input lineage.LoadLineagesByCustomerInput) ([]lineage.Lineage, error) { + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) ([]lineage.Lineage, error) { + lineages, err := tx.db.CreditRealizationLineage.Query(). + Where( + creditrealizationlineage.Namespace(input.Namespace), + creditrealizationlineage.CustomerIDEQ(input.CustomerID), + creditrealizationlineage.CurrencyEQ(input.Currency), + ). + WithSegments(func(q *entdb.CreditRealizationLineageSegmentQuery) { + q.Where(creditrealizationlineagesegment.ClosedAtIsNil()). + Order(creditrealizationlineagesegment.ByCreatedAt()) + }). + Order(creditrealizationlineage.ByCreatedAt()). + All(ctx) + if err != nil { + return nil, err + } + + return lo.Map(lineages, mapLineage), nil + }) +} + +func (a *adapter) LockCorrectionLineages(ctx context.Context, namespace string, realizationIDs []string) ([]lineage.Lineage, error) { + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) ([]lineage.Lineage, error) { + if _, err := entutils.GetDriverFromContext(ctx); err != nil { + return nil, fmt.Errorf("lock correction lineages must be called in a transaction: %w", err) + } + + lineages, err := tx.db.CreditRealizationLineage.Query(). + Where( + creditrealizationlineage.Namespace(namespace), + creditrealizationlineage.RootRealizationIDIn(realizationIDs...), + ). + WithSegments(func(q *entdb.CreditRealizationLineageSegmentQuery) { + q.Where(creditrealizationlineagesegment.ClosedAtIsNil()). + Order(creditrealizationlineagesegment.ByCreatedAt()) + }). + Order(creditrealizationlineage.ByCreatedAt()). + ForUpdate(). + All(ctx) + if err != nil { + return nil, err + } + + return lo.Map(lineages, mapLineage), nil + }) +} + +func (a *adapter) LockAdvanceLineagesForBackfill(ctx context.Context, namespace string, customerID string, currency currencyx.Code) ([]lineage.Lineage, error) { + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) ([]lineage.Lineage, error) { + if _, err := entutils.GetDriverFromContext(ctx); err != nil { + return nil, fmt.Errorf("lock advance lineages for backfill must be called in a transaction: %w", err) + } + + lineages, err := tx.db.CreditRealizationLineage.Query(). + Where( + creditrealizationlineage.Namespace(namespace), + creditrealizationlineage.CustomerIDEQ(customerID), + creditrealizationlineage.CurrencyEQ(currency), + creditrealizationlineage.HasSegmentsWith( + creditrealizationlineagesegment.ClosedAtIsNil(), + creditrealizationlineagesegment.StateEQ(creditrealization.LineageSegmentStateAdvanceUncovered), + ), + ). + Order(creditrealizationlineage.ByCreatedAt()). + ForUpdate(). + All(ctx) + if err != nil { + return nil, err + } + + return lo.Map(lineages, func(entry *entdb.CreditRealizationLineage, _ int) lineage.Lineage { + return lineage.Lineage{ + ID: entry.ID, + ChargeID: entry.ChargeID, + RootRealizationID: entry.RootRealizationID, + CustomerID: entry.CustomerID, + Currency: entry.Currency, + OriginKind: entry.OriginKind, + AdvanceFeatures: []string(entry.AdvanceFeatures), + } + }), nil + }) +} + +func (a *adapter) ListActiveSegments(ctx context.Context, input lineage.ListActiveSegmentsInput) ([]lineage.Segment, error) { + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) ([]lineage.Segment, error) { + query := tx.db.CreditRealizationLineageSegment.Query(). + Where( + creditrealizationlineagesegment.ClosedAtIsNil(), + creditrealizationlineagesegment.LineageIDIn(input.LineageIDs...), + ). + Order(creditrealizationlineagesegment.ByCreatedAt()) + + if input.State != nil { + query = query.Where(creditrealizationlineagesegment.StateEQ(*input.State)) + } + + segments, err := query.All(ctx) + if err != nil { + return nil, err + } + + return lo.Map(segments, func(segment *entdb.CreditRealizationLineageSegment, _ int) lineage.Segment { + return mapSegment(segment) + }), nil + }) +} + +func (a *adapter) CloseSegment(ctx context.Context, segmentID string, closedAt time.Time) error { + return entutils.TransactingRepoWithNoValue(ctx, a, func(ctx context.Context, tx *adapter) error { + if _, err := tx.db.CreditRealizationLineageSegment.UpdateOneID(segmentID). + SetClosedAt(closedAt). + Save(ctx); err != nil { + return err + } + + return nil + }) +} + +func (a *adapter) CreateSegment(ctx context.Context, input lineage.CreateSegmentInput) error { + if err := input.Validate(); err != nil { + return fmt.Errorf("create lineage segment: %w", err) + } + + return entutils.TransactingRepoWithNoValue(ctx, a, func(ctx context.Context, tx *adapter) error { + create := tx.db.CreditRealizationLineageSegment.Create(). + SetID(ulid.Make().String()). + SetLineageID(input.LineageID). + SetAmount(input.Amount). + SetState(input.State). + SetNillableBackingTransactionGroupID(input.BackingTransactionGroupID). + SetNillableSourceState(input.SourceState). + SetNillableSourceBackingTransactionGroupID(input.SourceBackingTransactionGroupID) + + _, err := create.Save(ctx) + return err + }) +} + +func mapLineage(entry *entdb.CreditRealizationLineage, _ int) lineage.Lineage { + return lineage.Lineage{ + ID: entry.ID, + ChargeID: entry.ChargeID, + RootRealizationID: entry.RootRealizationID, + CustomerID: entry.CustomerID, + Currency: entry.Currency, + OriginKind: entry.OriginKind, + AdvanceFeatures: []string(entry.AdvanceFeatures), + Segments: lo.Map(entry.Edges.Segments, func(segment *entdb.CreditRealizationLineageSegment, _ int) lineage.Segment { + return mapSegment(segment) + }), + } +} + +func mapSegment(segment *entdb.CreditRealizationLineageSegment) lineage.Segment { + return lineage.Segment{ + ID: segment.ID, + LineageID: segment.LineageID, + Amount: segment.Amount, + State: segment.State, + BackingTransactionGroupID: segment.BackingTransactionGroupID, + SourceState: segment.SourceState, + SourceBackingTransactionGroupID: segment.SourceBackingTransactionGroupID, + } +} diff --git a/billing/charges/lineage/lineage.go b/billing/charges/lineage/lineage.go new file mode 100644 index 0000000000000000000000000000000000000000..f6d967b7eb2a121d0983deb9f67e44b5c37d1579 --- /dev/null +++ b/billing/charges/lineage/lineage.go @@ -0,0 +1,104 @@ +package lineage + +import ( + "errors" + "fmt" + "sort" + + "github.com/alpacahq/alpacadecimal" + "github.com/samber/lo" + + "github.com/openmeterio/openmeter/openmeter/billing/charges/models/creditrealization" +) + +func SortCorrectionPersistSegments(segments []Segment) []Segment { + sorted := append([]Segment(nil), segments...) + + sort.SliceStable(sorted, func(i, j int) bool { + precedence := func(state creditrealization.LineageSegmentState) int { + switch state { + case creditrealization.LineageSegmentStateEarningsRecognized: + return 0 + case creditrealization.LineageSegmentStateAdvanceBackfilled: + return 1 + case creditrealization.LineageSegmentStateAdvanceUncovered: + return 2 + case creditrealization.LineageSegmentStateRealCredit: + return 3 + default: + return 4 + } + } + + return precedence(sorted[i].State) < precedence(sorted[j].State) + }) + + return sorted +} + +func MinDecimal(a, b alpacadecimal.Decimal) alpacadecimal.Decimal { + if a.GreaterThan(b) { + return b + } + + return a +} + +func FilterAdvanceLineagesForBackfill(lineages []Lineage, featureFilters []string) []Lineage { + return lo.Filter(lineages, func(entry Lineage, _ int) bool { + return FeatureFiltersMatchAdvance(featureFilters, entry.AdvanceFeatures) + }) +} + +func FeatureFiltersMatchAdvance(featureFilters []string, advanceFeatures []string) bool { + if len(featureFilters) == 0 { + return true + } + + if len(advanceFeatures) == 0 { + return false + } + + for _, feature := range advanceFeatures { + if lo.Contains(featureFilters, feature) { + return true + } + } + + return false +} + +func (s Segment) Validate() error { + var errs []error + + if !s.Amount.IsPositive() { + errs = append(errs, errors.New("amount must be positive")) + } + + if err := s.State.Validate(); err != nil { + errs = append(errs, fmt.Errorf("state: %w", err)) + } + + switch s.State { + case creditrealization.LineageSegmentStateAdvanceBackfilled: + if s.BackingTransactionGroupID == nil || *s.BackingTransactionGroupID == "" { + errs = append(errs, errors.New("backing transaction group id is required for advance_backfilled")) + } + case creditrealization.LineageSegmentStateEarningsRecognized: + if s.BackingTransactionGroupID == nil || *s.BackingTransactionGroupID == "" { + errs = append(errs, errors.New("backing transaction group id is required for earnings_recognized")) + } + switch { + case s.SourceState == nil: + errs = append(errs, errors.New("source state is required for earnings_recognized")) + case *s.SourceState == creditrealization.LineageSegmentStateEarningsRecognized: + errs = append(errs, errors.New("source state cannot be earnings_recognized")) + case *s.SourceState == creditrealization.LineageSegmentStateAdvanceBackfilled: + if s.SourceBackingTransactionGroupID == nil || *s.SourceBackingTransactionGroupID == "" { + errs = append(errs, errors.New("source backing transaction group id is required when source state is advance_backfilled")) + } + } + } + + return errors.Join(errs...) +} diff --git a/billing/charges/lineage/lineage_test.go b/billing/charges/lineage/lineage_test.go new file mode 100644 index 0000000000000000000000000000000000000000..4e5831e88c8ce12aef2b03c2a2b41afcb4928de8 --- /dev/null +++ b/billing/charges/lineage/lineage_test.go @@ -0,0 +1,35 @@ +package lineage + +import ( + "testing" + + "github.com/alpacahq/alpacadecimal" + "github.com/stretchr/testify/require" + + "github.com/openmeterio/openmeter/openmeter/billing/charges/models/creditrealization" +) + +func TestSegmentValidateRequiresSourceBackingTransactionGroupForAdvanceBackfilledSource(t *testing.T) { + sourceState := creditrealization.LineageSegmentStateAdvanceBackfilled + backingTransactionGroupID := "recognition-txg" + + err := Segment{ + Amount: alpacadecimal.NewFromInt(10), + State: creditrealization.LineageSegmentStateEarningsRecognized, + BackingTransactionGroupID: &backingTransactionGroupID, + SourceState: &sourceState, + }.Validate() + + require.Error(t, err) + require.ErrorContains(t, err, "source backing transaction group id is required when source state is advance_backfilled") +} + +func TestFeatureFiltersMatchAdvance(t *testing.T) { + require.True(t, FeatureFiltersMatchAdvance(nil, nil)) + require.True(t, FeatureFiltersMatchAdvance(nil, []string{"api-calls"})) + require.True(t, FeatureFiltersMatchAdvance([]string{"api-calls"}, []string{"api-calls"})) + require.True(t, FeatureFiltersMatchAdvance([]string{"api-calls", "storage"}, []string{"storage"})) + + require.False(t, FeatureFiltersMatchAdvance([]string{"api-calls"}, nil)) + require.False(t, FeatureFiltersMatchAdvance([]string{"api-calls"}, []string{"storage"})) +} diff --git a/billing/charges/lineage/service.go b/billing/charges/lineage/service.go new file mode 100644 index 0000000000000000000000000000000000000000..57c414f9ad12a5e4defb8bf7a4aebe8a4a9e934c --- /dev/null +++ b/billing/charges/lineage/service.go @@ -0,0 +1,243 @@ +package lineage + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/alpacahq/alpacadecimal" + + "github.com/openmeterio/openmeter/openmeter/billing/charges/models/creditrealization" + "github.com/openmeterio/openmeter/pkg/currencyx" + "github.com/openmeterio/openmeter/pkg/framework/entutils" +) + +type Service interface { + CreateInitialLineages(ctx context.Context, input CreateInitialLineagesInput) error + LoadActiveSegmentsByRealizationID(ctx context.Context, namespace string, realizationIDs []string) (ActiveSegmentsByRealizationID, error) + LoadLineagesByCustomer(ctx context.Context, input LoadLineagesByCustomerInput) ([]Lineage, error) + PersistCorrectionLineageSegments(ctx context.Context, input PersistCorrectionLineageSegmentsInput) error + BackfillAdvanceLineageSegments(ctx context.Context, input BackfillAdvanceLineageSegmentsInput) error + CloseSegment(ctx context.Context, segmentID string, closedAt time.Time) error + CreateSegment(ctx context.Context, input CreateSegmentInput) error +} + +type Adapter interface { + entutils.TxCreator + + CreateLineages(ctx context.Context, input CreateLineagesInput) error + LoadActiveSegmentsByRealizationID(ctx context.Context, namespace string, realizationIDs []string) (ActiveSegmentsByRealizationID, error) + LoadLineagesByCustomer(ctx context.Context, input LoadLineagesByCustomerInput) ([]Lineage, error) + LockCorrectionLineages(ctx context.Context, namespace string, realizationIDs []string) ([]Lineage, error) + LockAdvanceLineagesForBackfill(ctx context.Context, namespace string, customerID string, currency currencyx.Code) ([]Lineage, error) + ListActiveSegments(ctx context.Context, input ListActiveSegmentsInput) ([]Segment, error) + CloseSegment(ctx context.Context, segmentID string, closedAt time.Time) error + CreateSegment(ctx context.Context, input CreateSegmentInput) error +} + +type CreateInitialLineagesInput struct { + Namespace string + ChargeID string + CustomerID string + Currency currencyx.Code + Features []string + Realizations creditrealization.Realizations +} + +func (i CreateInitialLineagesInput) Validate() error { + var errs []error + + if i.Namespace == "" { + errs = append(errs, errors.New("namespace is required")) + } + if i.ChargeID == "" { + errs = append(errs, errors.New("charge id is required")) + } + if i.CustomerID == "" { + errs = append(errs, errors.New("customer id is required")) + } + if err := i.Currency.Validate(); err != nil { + errs = append(errs, fmt.Errorf("currency: %w", err)) + } + if err := i.Realizations.Validate(); err != nil { + errs = append(errs, fmt.Errorf("realizations: %w", err)) + } + + return errors.Join(errs...) +} + +type PersistCorrectionLineageSegmentsInput struct { + Namespace string + Realizations creditrealization.Realizations +} + +func (i PersistCorrectionLineageSegmentsInput) Validate() error { + var errs []error + + if i.Namespace == "" { + errs = append(errs, errors.New("namespace is required")) + } + + for idx, realization := range i.Realizations { + if realization.Type != creditrealization.TypeCorrection { + continue + } + + if realization.CorrectsRealizationID == nil || *realization.CorrectsRealizationID == "" { + errs = append(errs, fmt.Errorf("realizations[%d]: corrects realization id is required for corrections", idx)) + } + } + + return errors.Join(errs...) +} + +type BackfillAdvanceLineageSegmentsInput struct { + Namespace string + CustomerID string + Currency currencyx.Code + Amount alpacadecimal.Decimal + BackingTransactionGroupID string + FeatureFilters []string +} + +func (i BackfillAdvanceLineageSegmentsInput) Validate() error { + var errs []error + + if i.Namespace == "" { + errs = append(errs, errors.New("namespace is required")) + } + if i.CustomerID == "" { + errs = append(errs, errors.New("customer id is required")) + } + if err := i.Currency.Validate(); err != nil { + errs = append(errs, fmt.Errorf("currency: %w", err)) + } + if !i.Amount.IsPositive() { + errs = append(errs, errors.New("amount must be positive")) + } + if i.BackingTransactionGroupID == "" { + errs = append(errs, errors.New("backing transaction group id is required")) + } + + return errors.Join(errs...) +} + +type LoadLineagesByCustomerInput struct { + Namespace string + CustomerID string + Currency currencyx.Code +} + +func (i LoadLineagesByCustomerInput) Validate() error { + var errs []error + + if i.Namespace == "" { + errs = append(errs, errors.New("namespace is required")) + } + if i.CustomerID == "" { + errs = append(errs, errors.New("customer id is required")) + } + if err := i.Currency.Validate(); err != nil { + errs = append(errs, fmt.Errorf("currency: %w", err)) + } + + return errors.Join(errs...) +} + +type CreateLineagesInput struct { + Namespace string + ChargeID string + CustomerID string + Currency currencyx.Code + Specs []creditrealization.InitialLineageSpec +} + +type ListActiveSegmentsInput struct { + LineageIDs []string + State *creditrealization.LineageSegmentState +} + +type CreateSegmentInput struct { + LineageID string + Amount alpacadecimal.Decimal + State creditrealization.LineageSegmentState + BackingTransactionGroupID *string + SourceState *creditrealization.LineageSegmentState + SourceBackingTransactionGroupID *string +} + +func (i CreateSegmentInput) Validate() error { + var errs []error + + if i.LineageID == "" { + errs = append(errs, errors.New("lineage id is required")) + } + if !i.Amount.IsPositive() { + errs = append(errs, errors.New("amount must be positive")) + } + if err := i.State.Validate(); err != nil { + errs = append(errs, fmt.Errorf("state: %w", err)) + } + + switch i.State { + case creditrealization.LineageSegmentStateAdvanceBackfilled, + creditrealization.LineageSegmentStateEarningsRecognized: + if i.BackingTransactionGroupID == nil || *i.BackingTransactionGroupID == "" { + errs = append(errs, fmt.Errorf("backing transaction group id is required for %s segments", i.State)) + } + default: + if i.BackingTransactionGroupID != nil && *i.BackingTransactionGroupID == "" { + errs = append(errs, errors.New("backing transaction group id must not be empty when provided")) + } + } + switch i.State { + case creditrealization.LineageSegmentStateEarningsRecognized: + if i.SourceState == nil { + errs = append(errs, errors.New("source state is required for earnings_recognized segments")) + } else { + if err := i.SourceState.Validate(); err != nil { + errs = append(errs, fmt.Errorf("source state: %w", err)) + } + if *i.SourceState == creditrealization.LineageSegmentStateEarningsRecognized { + errs = append(errs, errors.New("source state cannot be earnings_recognized")) + } + if *i.SourceState == creditrealization.LineageSegmentStateAdvanceBackfilled && + (i.SourceBackingTransactionGroupID == nil || *i.SourceBackingTransactionGroupID == "") { + errs = append(errs, errors.New("source backing transaction group id is required when source state is advance_backfilled")) + } + } + default: + if i.SourceState != nil { + errs = append(errs, errors.New("source state is only valid for earnings_recognized segments")) + } + if i.SourceBackingTransactionGroupID != nil && *i.SourceBackingTransactionGroupID == "" { + errs = append(errs, errors.New("source backing transaction group id must not be empty when provided")) + } + } + + return errors.Join(errs...) +} + +type Lineage struct { + ID string + ChargeID string + RootRealizationID string + CustomerID string + Currency currencyx.Code + OriginKind creditrealization.LineageOriginKind + AdvanceFeatures []string + Segments []Segment +} + +type Segment struct { + ID string + LineageID string + Amount alpacadecimal.Decimal + State creditrealization.LineageSegmentState + BackingTransactionGroupID *string + SourceState *creditrealization.LineageSegmentState + SourceBackingTransactionGroupID *string +} + +type ActiveSegmentsByRealizationID map[string][]Segment diff --git a/billing/charges/lineage/service/service.go b/billing/charges/lineage/service/service.go new file mode 100644 index 0000000000000000000000000000000000000000..17495ad2eeed36550814df9a2a2fa1cc383db2dd --- /dev/null +++ b/billing/charges/lineage/service/service.go @@ -0,0 +1,256 @@ +package service + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/alpacahq/alpacadecimal" + + "github.com/openmeterio/openmeter/openmeter/billing/charges/lineage" + "github.com/openmeterio/openmeter/openmeter/billing/charges/models/creditrealization" + "github.com/openmeterio/openmeter/pkg/clock" + "github.com/openmeterio/openmeter/pkg/framework/transaction" +) + +type Config struct { + Adapter lineage.Adapter +} + +func (c Config) Validate() error { + if c.Adapter == nil { + return errors.New("adapter cannot be null") + } + + return nil +} + +func New(config Config) (lineage.Service, error) { + if err := config.Validate(); err != nil { + return nil, err + } + + return &service{ + adapter: config.Adapter, + }, nil +} + +type service struct { + adapter lineage.Adapter +} + +func (s *service) CreateInitialLineages(ctx context.Context, input lineage.CreateInitialLineagesInput) error { + if err := input.Validate(); err != nil { + return err + } + + return transaction.RunWithNoValue(ctx, s.adapter, func(ctx context.Context) error { + specs, err := creditrealization.InitialLineageSpecs(input.Realizations) + if err != nil { + return fmt.Errorf("build initial credit realization lineage specs: %w", err) + } + for idx := range specs { + if specs[idx].OriginKind == creditrealization.LineageOriginKindAdvance { + specs[idx].AdvanceFeatures = input.Features + } + } + if len(specs) == 0 { + return nil + } + + return s.adapter.CreateLineages(ctx, lineage.CreateLineagesInput{ + Namespace: input.Namespace, + ChargeID: input.ChargeID, + CustomerID: input.CustomerID, + Currency: input.Currency, + Specs: specs, + }) + }) +} + +func (s *service) LoadActiveSegmentsByRealizationID(ctx context.Context, namespace string, realizationIDs []string) (lineage.ActiveSegmentsByRealizationID, error) { + if len(realizationIDs) == 0 { + return lineage.ActiveSegmentsByRealizationID{}, nil + } + + segmentsByRealizationID, err := s.adapter.LoadActiveSegmentsByRealizationID(ctx, namespace, realizationIDs) + if err != nil { + return nil, fmt.Errorf("load active lineage segments: %w", err) + } + + return segmentsByRealizationID, nil +} + +func (s *service) LoadLineagesByCustomer(ctx context.Context, input lineage.LoadLineagesByCustomerInput) ([]lineage.Lineage, error) { + if err := input.Validate(); err != nil { + return nil, err + } + + return s.adapter.LoadLineagesByCustomer(ctx, input) +} + +func (s *service) PersistCorrectionLineageSegments(ctx context.Context, input lineage.PersistCorrectionLineageSegmentsInput) error { + if err := input.Validate(); err != nil { + return err + } + + return transaction.RunWithNoValue(ctx, s.adapter, func(ctx context.Context) error { + correctionAmountsByRealizationID := make(map[string]alpacadecimal.Decimal, len(input.Realizations)) + correctionOrder := make([]string, 0) + + for _, realization := range input.Realizations { + if realization.Type != creditrealization.TypeCorrection || realization.CorrectsRealizationID == nil { + continue + } + + correctsRealizationID := *realization.CorrectsRealizationID + if _, ok := correctionAmountsByRealizationID[correctsRealizationID]; !ok { + correctionOrder = append(correctionOrder, correctsRealizationID) + } + + correctionAmountsByRealizationID[correctsRealizationID] = correctionAmountsByRealizationID[correctsRealizationID].Add(realization.Amount.Abs()) + } + + if len(correctionOrder) == 0 { + return nil + } + + lineages, err := s.adapter.LockCorrectionLineages(ctx, input.Namespace, correctionOrder) + if err != nil { + return fmt.Errorf("lock lineages for correction persistence: %w", err) + } + + lineagesByRealizationID := make(map[string]lineage.Lineage, len(lineages)) + for _, entry := range lineages { + lineagesByRealizationID[entry.RootRealizationID] = entry + } + + now := clock.Now().Truncate(time.Microsecond) + + for _, realizationID := range correctionOrder { + entry, ok := lineagesByRealizationID[realizationID] + if !ok { + continue + } + + remaining := correctionAmountsByRealizationID[realizationID] + for _, segment := range lineage.SortCorrectionPersistSegments(entry.Segments) { + if !remaining.IsPositive() { + break + } + + consumedAmount := lineage.MinDecimal(segment.Amount, remaining) + if !consumedAmount.IsPositive() { + continue + } + + if err := s.adapter.CloseSegment(ctx, segment.ID, now); err != nil { + return fmt.Errorf("close active lineage segment %s: %w", segment.ID, err) + } + + remainder := segment.Amount.Sub(consumedAmount) + if remainder.IsPositive() { + if err := s.adapter.CreateSegment(ctx, lineage.CreateSegmentInput{ + LineageID: segment.LineageID, + Amount: remainder, + State: segment.State, + BackingTransactionGroupID: segment.BackingTransactionGroupID, + SourceState: segment.SourceState, + SourceBackingTransactionGroupID: segment.SourceBackingTransactionGroupID, + }); err != nil { + return fmt.Errorf("create lineage segment remainder for %s: %w", segment.ID, err) + } + } + + remaining = remaining.Sub(consumedAmount) + } + + if remaining.IsPositive() { + return fmt.Errorf("correction amount %s exceeds active lineage coverage for realization %s", remaining.String(), realizationID) + } + } + + return nil + }) +} + +func (s *service) BackfillAdvanceLineageSegments(ctx context.Context, input lineage.BackfillAdvanceLineageSegmentsInput) error { + if err := input.Validate(); err != nil { + return err + } + + return transaction.RunWithNoValue(ctx, s.adapter, func(ctx context.Context) error { + lineages, err := s.adapter.LockAdvanceLineagesForBackfill(ctx, input.Namespace, input.CustomerID, input.Currency) + if err != nil { + return fmt.Errorf("lock advance lineages for backfill: %w", err) + } + if len(lineages) == 0 { + return nil + } + lineages = lineage.FilterAdvanceLineagesForBackfill(lineages, input.FeatureFilters) + if len(lineages) == 0 { + return nil + } + + lineageIDs := make([]string, 0, len(lineages)) + for _, entry := range lineages { + lineageIDs = append(lineageIDs, entry.ID) + } + + state := creditrealization.LineageSegmentStateAdvanceUncovered + segments, err := s.adapter.ListActiveSegments(ctx, lineage.ListActiveSegmentsInput{ + LineageIDs: lineageIDs, + State: &state, + }) + if err != nil { + return fmt.Errorf("query active uncovered advance lineage segments: %w", err) + } + + now := clock.Now().Truncate(time.Microsecond) + remaining := input.Amount + + for _, segment := range segments { + if !remaining.IsPositive() { + break + } + + coveredAmount := lineage.MinDecimal(segment.Amount, remaining) + if err := s.adapter.CloseSegment(ctx, segment.ID, now); err != nil { + return fmt.Errorf("close uncovered advance lineage segment %s: %w", segment.ID, err) + } + + remainder := segment.Amount.Sub(coveredAmount) + if remainder.IsPositive() { + if err := s.adapter.CreateSegment(ctx, lineage.CreateSegmentInput{ + LineageID: segment.LineageID, + Amount: remainder, + State: creditrealization.LineageSegmentStateAdvanceUncovered, + }); err != nil { + return fmt.Errorf("create uncovered advance lineage remainder for segment %s: %w", segment.ID, err) + } + } + + if err := s.adapter.CreateSegment(ctx, lineage.CreateSegmentInput{ + LineageID: segment.LineageID, + Amount: coveredAmount, + State: creditrealization.LineageSegmentStateAdvanceBackfilled, + BackingTransactionGroupID: &input.BackingTransactionGroupID, + }); err != nil { + return fmt.Errorf("create backfilled advance lineage segment for segment %s: %w", segment.ID, err) + } + + remaining = remaining.Sub(coveredAmount) + } + + return nil + }) +} + +func (s *service) CloseSegment(ctx context.Context, segmentID string, closedAt time.Time) error { + return s.adapter.CloseSegment(ctx, segmentID, closedAt) +} + +func (s *service) CreateSegment(ctx context.Context, input lineage.CreateSegmentInput) error { + return s.adapter.CreateSegment(ctx, input) +} diff --git a/billing/charges/linerouter/linerouter.go b/billing/charges/linerouter/linerouter.go new file mode 100644 index 0000000000000000000000000000000000000000..a265cb11f8c75e9cf668511abccc7c12b84c168e --- /dev/null +++ b/billing/charges/linerouter/linerouter.go @@ -0,0 +1,91 @@ +package linerouter + +import ( + "errors" + "fmt" + + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/productcatalog" + "github.com/openmeterio/openmeter/pkg/featuregate" + "github.com/openmeterio/openmeter/pkg/models" +) + +var _ billing.CreateLineRouter = (*Router)(nil) + +type Config struct { + CreditsEnabled bool + CreditThenInvoiceEnabled bool + FeatureGate *featuregate.FeatureGateChecker +} + +func (c Config) Validate() error { + var errs []error + + if err := c.FeatureGate.Validate(); err != nil { + errs = append(errs, fmt.Errorf("feature gate: %w", err)) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +type Router struct { + creditsEnabled bool + creditThenInvoiceEnabled bool + featureGate *featuregate.FeatureGateChecker +} + +func New(config Config) (*Router, error) { + if err := config.Validate(); err != nil { + return nil, err + } + + return &Router{ + creditsEnabled: config.CreditsEnabled, + creditThenInvoiceEnabled: config.CreditThenInvoiceEnabled, + featureGate: config.FeatureGate, + }, nil +} + +func (r *Router) GetLineEngineForCreateLine(line billing.GenericInvoiceLineReader) (billing.LineEngineType, error) { + if line == nil { + return "", fmt.Errorf("line is required") + } + + available, err := r.chargesAvailable(line) + if err != nil { + return "", err + } + + if !available { + return billing.LineEngineTypeInvoice, nil + } + + return lineEngineFromPrice(line) +} + +func (r *Router) chargesAvailable(line billing.GenericInvoiceLineReader) (bool, error) { + if !r.creditsEnabled || !r.creditThenInvoiceEnabled { + return false, nil + } + + namespace := line.GetLineID().Namespace + if namespace == "" { + return false, fmt.Errorf("line[%s]: namespace is required", line.GetID()) + } + + return r.featureGate.Enabled(namespace, r.featureGate.Flags.Credits()) +} + +func lineEngineFromPrice(line billing.GenericInvoiceLineReader) (billing.LineEngineType, error) { + price := line.GetPrice() + if price == nil { + return "", fmt.Errorf("line[%s]: price is required", line.GetID()) + } + + switch price.Type() { + case productcatalog.FlatPriceType: + return billing.LineEngineTypeChargeFlatFee, nil + default: + return billing.LineEngineTypeChargeUsageBased, nil + } +} diff --git a/billing/charges/linerouter/linerouter_test.go b/billing/charges/linerouter/linerouter_test.go new file mode 100644 index 0000000000000000000000000000000000000000..f426310137c61808fe9e829fdaf6045548181d86 --- /dev/null +++ b/billing/charges/linerouter/linerouter_test.go @@ -0,0 +1,177 @@ +package linerouter + +import ( + "testing" + + "github.com/alpacahq/alpacadecimal" + "github.com/stretchr/testify/require" + + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/productcatalog" + "github.com/openmeterio/openmeter/pkg/featuregate" + "github.com/openmeterio/openmeter/pkg/models" +) + +func TestRouterGetLineEngineForCreateLine(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + creditsEnabled bool + creditThenInvoice bool + featureGate featuregate.Gate + line billing.GenericInvoiceLineReader + expectedEngine billing.LineEngineType + expectedErr string + }{ + { + name: "credits disabled falls back to invoice", + creditsEnabled: false, + creditThenInvoice: true, + featureGate: featuregate.NewNoop(), + line: newRouterTestLine(productcatalog.FlatPriceType, ""), + expectedEngine: billing.LineEngineTypeInvoice, + }, + { + name: "feature gate disabled falls back to invoice", + creditsEnabled: true, + creditThenInvoice: true, + featureGate: alwaysFalseGate{}, + line: newRouterTestLine(productcatalog.FlatPriceType, ""), + expectedEngine: billing.LineEngineTypeInvoice, + }, + { + name: "credit then invoice disabled falls back to invoice", + creditsEnabled: true, + creditThenInvoice: false, + featureGate: featuregate.NewNoop(), + line: newRouterTestLine(productcatalog.FlatPriceType, ""), + expectedEngine: billing.LineEngineTypeInvoice, + }, + { + name: "enabled flat price routes to flat fee engine", + creditsEnabled: true, + creditThenInvoice: true, + featureGate: featuregate.NewNoop(), + line: newRouterTestLine(productcatalog.FlatPriceType, ""), + expectedEngine: billing.LineEngineTypeChargeFlatFee, + }, + { + name: "enabled unit price routes to usage based engine", + creditsEnabled: true, + creditThenInvoice: true, + featureGate: featuregate.NewNoop(), + line: newRouterTestLine(productcatalog.UnitPriceType, ""), + expectedEngine: billing.LineEngineTypeChargeUsageBased, + }, + { + name: "credits disabled ignores existing engine and falls back to invoice", + creditsEnabled: false, + creditThenInvoice: true, + featureGate: featuregate.NewNoop(), + line: newRouterTestLine(productcatalog.FlatPriceType, billing.LineEngineTypeChargeFlatFee), + expectedEngine: billing.LineEngineTypeInvoice, + }, + { + name: "enabled existing engine is replaced by price route", + creditsEnabled: true, + creditThenInvoice: true, + featureGate: featuregate.NewNoop(), + line: newRouterTestLine(productcatalog.FlatPriceType, billing.LineEngineTypeChargeUsageBased), + expectedEngine: billing.LineEngineTypeChargeFlatFee, + }, + { + name: "enabled nil price errors", + creditsEnabled: true, + creditThenInvoice: true, + featureGate: featuregate.NewNoop(), + line: newRouterTestLineWithPrice(nil, ""), + expectedErr: "price is required", + }, + { + name: "nil line errors", + creditsEnabled: true, + creditThenInvoice: true, + featureGate: featuregate.NewNoop(), + expectedErr: "line is required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + router := newRouterForTest(t, tt.creditsEnabled, tt.creditThenInvoice, tt.featureGate) + engine, err := router.GetLineEngineForCreateLine(tt.line) + + if tt.expectedErr != "" { + require.ErrorContains(t, err, tt.expectedErr) + return + } + + require.NoError(t, err) + require.Equal(t, tt.expectedEngine, engine) + }) + } +} + +func TestNewRequiresFeatureGate(t *testing.T) { + t.Parallel() + + _, err := New(Config{ + CreditsEnabled: true, + CreditThenInvoiceEnabled: true, + }) + + require.ErrorContains(t, err, "feature gate is required") +} + +func newRouterForTest(t testing.TB, creditsEnabled, creditThenInvoice bool, gate featuregate.Gate) *Router { + t.Helper() + + router, err := New(Config{ + CreditsEnabled: creditsEnabled, + CreditThenInvoiceEnabled: creditThenInvoice, + FeatureGate: featuregate.NewFeatureGateChecker(gate, featuregate.Flags{ + featuregate.CtxKeyCredits: string(featuregate.CtxKeyCredits), + }, map[featuregate.FeatureFlag]bool{featuregate.CtxKeyCredits: true}), + }) + require.NoError(t, err) + + return router +} + +func newRouterTestLine(priceType productcatalog.PriceType, engine billing.LineEngineType) *billing.StandardLine { + switch priceType { + case productcatalog.FlatPriceType: + return newRouterTestLineWithPrice(productcatalog.NewPriceFrom(productcatalog.FlatPrice{ + Amount: alpacadecimal.NewFromInt(100), + }), engine) + default: + return newRouterTestLineWithPrice(productcatalog.NewPriceFrom(productcatalog.UnitPrice{ + Amount: alpacadecimal.NewFromInt(100), + }), engine) + } +} + +func newRouterTestLineWithPrice(price *productcatalog.Price, engine billing.LineEngineType) *billing.StandardLine { + return &billing.StandardLine{ + StandardLineBase: billing.StandardLineBase{ + ManagedResource: models.ManagedResource{ + NamespacedModel: models.NamespacedModel{Namespace: "ns"}, + ID: "line-1", + Name: "line-1", + }, + Engine: engine, + }, + UsageBased: &billing.UsageBasedLine{ + Price: price, + }, + } +} + +type alwaysFalseGate struct{} + +func (alwaysFalseGate) EvaluateBool(_, _ string, _ bool) (bool, error) { + return false, nil +} diff --git a/billing/charges/lock.go b/billing/charges/lock.go new file mode 100644 index 0000000000000000000000000000000000000000..2ea00ad849d0c294950d6b062c4847cef29546fe --- /dev/null +++ b/billing/charges/lock.go @@ -0,0 +1,16 @@ +package charges + +import ( + "fmt" + + "github.com/openmeterio/openmeter/openmeter/billing/charges/meta" + "github.com/openmeterio/openmeter/pkg/framework/lockr" +) + +func NewLockKeyForCharge(chargeID meta.ChargeID) (lockr.Key, error) { + if err := chargeID.Validate(); err != nil { + return nil, fmt.Errorf("charge ID: %w", err) + } + + return lockr.NewKey("namespace", chargeID.Namespace, "charge", chargeID.ID) +} diff --git a/billing/charges/meta/adapter.go b/billing/charges/meta/adapter.go new file mode 100644 index 0000000000000000000000000000000000000000..aaff73033c1853d13eaeea2096a125280ae70ef2 --- /dev/null +++ b/billing/charges/meta/adapter.go @@ -0,0 +1,49 @@ +package meta + +import ( + "context" + "errors" + "fmt" + + "github.com/openmeterio/openmeter/pkg/framework/entutils" + "github.com/openmeterio/openmeter/pkg/models" +) + +type Adapter interface { + RegisterCharges(ctx context.Context, in RegisterChargesInput) error + DeleteRegisteredCharge(ctx context.Context, in DeleteRegisteredChargeInput) error + + entutils.TxCreator +} + +type RegisterChargesInput struct { + Namespace string + Type ChargeType + + Charges []IDWithUniqueReferenceID +} + +func (i RegisterChargesInput) Validate() error { + var errs []error + if i.Namespace == "" { + errs = append(errs, errors.New("namespace is required")) + } + + if err := i.Type.Validate(); err != nil { + errs = append(errs, fmt.Errorf("type: %w", err)) + } + + for idx, charge := range i.Charges { + if charge.ID == "" { + errs = append(errs, fmt.Errorf("charge [%d]: ID is required", idx)) + } + } + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +type IDWithUniqueReferenceID struct { + ID string + UniqueReferenceID *string +} + +type DeleteRegisteredChargeInput = ChargeID diff --git a/billing/charges/meta/adapter/adapter.go b/billing/charges/meta/adapter/adapter.go new file mode 100644 index 0000000000000000000000000000000000000000..b6989f0a885aeb19d93caaec2afb057f6869f5c4 --- /dev/null +++ b/billing/charges/meta/adapter/adapter.go @@ -0,0 +1,72 @@ +package adapter + +import ( + "context" + "database/sql" + "errors" + "fmt" + "log/slog" + + "github.com/openmeterio/openmeter/openmeter/billing/charges/meta" + entdb "github.com/openmeterio/openmeter/openmeter/ent/db" + "github.com/openmeterio/openmeter/pkg/framework/entutils" + "github.com/openmeterio/openmeter/pkg/framework/transaction" +) + +type Config struct { + Client *entdb.Client + Logger *slog.Logger +} + +func (c Config) Validate() error { + if c.Client == nil { + return errors.New("ent client is required") + } + + if c.Logger == nil { + return errors.New("logger is required") + } + + return nil +} + +func New(config Config) (meta.Adapter, error) { + if err := config.Validate(); err != nil { + return nil, err + } + + return &adapter{ + db: config.Client, + logger: config.Logger, + }, nil +} + +var _ meta.Adapter = (*adapter)(nil) + +type adapter struct { + db *entdb.Client + logger *slog.Logger +} + +func (a *adapter) Tx(ctx context.Context) (context.Context, transaction.Driver, error) { + txCtx, rawConfig, eDriver, err := a.db.HijackTx(ctx, &sql.TxOptions{ + ReadOnly: false, + }) + if err != nil { + return nil, nil, fmt.Errorf("failed to hijack transaction: %w", err) + } + return txCtx, entutils.NewTxDriver(eDriver, rawConfig), nil +} + +func (a *adapter) WithTx(ctx context.Context, tx *entutils.TxDriver) *adapter { + txDb := entdb.NewTxClientFromRawConfig(ctx, *tx.GetConfig()) + + return &adapter{ + db: txDb.Client(), + logger: a.logger, + } +} + +func (a *adapter) Self() *adapter { + return a +} diff --git a/billing/charges/meta/adapter/charges.go b/billing/charges/meta/adapter/charges.go new file mode 100644 index 0000000000000000000000000000000000000000..1cd7a25fad392c9fe3a1d40fe5299f70e32feea9 --- /dev/null +++ b/billing/charges/meta/adapter/charges.go @@ -0,0 +1,63 @@ +package adapter + +import ( + "context" + "fmt" + + "github.com/openmeterio/openmeter/openmeter/billing/charges/meta" + "github.com/openmeterio/openmeter/openmeter/ent/db" + chargedb "github.com/openmeterio/openmeter/openmeter/ent/db/charge" + "github.com/openmeterio/openmeter/pkg/clock" + "github.com/openmeterio/openmeter/pkg/framework/entutils" + "github.com/openmeterio/openmeter/pkg/slicesx" +) + +func (a *adapter) RegisterCharges(ctx context.Context, in meta.RegisterChargesInput) error { + if err := in.Validate(); err != nil { + return err + } + + return entutils.TransactingRepoWithNoValue(ctx, a, func(ctx context.Context, tx *adapter) error { + creates, err := slicesx.MapWithErr(in.Charges, func(charge meta.IDWithUniqueReferenceID) (*db.ChargeCreate, error) { + create := tx.db.Charge.Create(). + SetNamespace(in.Namespace). + SetType(in.Type). + SetID(charge.ID). + SetNillableUniqueReferenceID(charge.UniqueReferenceID). + SetCreatedAt(clock.Now()) + + switch in.Type { + case meta.ChargeTypeFlatFee: + create = create.SetChargeFlatFeeID(charge.ID) + case meta.ChargeTypeUsageBased: + create = create.SetChargeUsageBasedID(charge.ID) + case meta.ChargeTypeCreditPurchase: + create = create.SetChargeCreditPurchaseID(charge.ID) + default: + return nil, fmt.Errorf("unknown charge type: %s", in.Type) + } + + return create, nil + }) + if err != nil { + return err + } + + _, err = tx.db.Charge.CreateBulk(creates...).Save(ctx) + return err + }) +} + +func (a *adapter) DeleteRegisteredCharge(ctx context.Context, in meta.DeleteRegisteredChargeInput) error { + if err := in.Validate(); err != nil { + return err + } + + return entutils.TransactingRepoWithNoValue(ctx, a, func(ctx context.Context, tx *adapter) error { + return tx.db.Charge.UpdateOneID(in.ID). + Where( + chargedb.DeletedAtIsNil(), + chargedb.Namespace(in.Namespace), + ).SetDeletedAt(clock.Now()).Exec(ctx) + }) +} diff --git a/billing/charges/meta/adapter/errors.go b/billing/charges/meta/adapter/errors.go new file mode 100644 index 0000000000000000000000000000000000000000..a830a93bdd7c97b124a9e1ee680c4ac2d3586b5f --- /dev/null +++ b/billing/charges/meta/adapter/errors.go @@ -0,0 +1,32 @@ +package adapter + +import ( + "fmt" + + "entgo.io/ent/dialect/sql/sqlgraph" + + entdb "github.com/openmeterio/openmeter/openmeter/ent/db" + "github.com/openmeterio/openmeter/pkg/models" +) + +// MapChargeConstraintError translates an ent DB constraint violation errors. +func MapChargeConstraintError(err error) error { + if err == nil || !entdb.IsConstraintError(err) { + return err + } + + switch { + case sqlgraph.IsUniqueConstraintError(err): + return models.NewGenericConflictError( + fmt.Errorf("charge conflicts with an existing charge: %w", err), + ) + case sqlgraph.IsForeignKeyConstraintError(err): + return models.NewGenericValidationError( + fmt.Errorf("charge references a resource that does not exist: %w", err), + ) + default: + return models.NewGenericValidationError( + fmt.Errorf("charge violates a database constraint: %w", err), + ) + } +} diff --git a/billing/charges/meta/charge.go b/billing/charges/meta/charge.go new file mode 100644 index 0000000000000000000000000000000000000000..627c8e9e4725a6acdb51ebf3fc88aa592af7b470 --- /dev/null +++ b/billing/charges/meta/charge.go @@ -0,0 +1,182 @@ +package meta + +import ( + "errors" + "fmt" + "slices" + "strings" + "time" + + "github.com/samber/lo" + + "github.com/openmeterio/openmeter/openmeter/customer" + "github.com/openmeterio/openmeter/pkg/currencyx" + "github.com/openmeterio/openmeter/pkg/expand" + "github.com/openmeterio/openmeter/pkg/models" +) + +type ChargeID models.NamespacedID + +func (i ChargeID) Validate() error { + return models.NamespacedID(i).Validate() +} + +type ChargeIDs []ChargeID + +func (i ChargeIDs) Validate() error { + var errs []error + for idx, id := range i { + if err := id.Validate(); err != nil { + errs = append(errs, fmt.Errorf("charge ID [%d]: %w", idx, err)) + } + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +func (i ChargeIDs) ToNamespacedIDs() []models.NamespacedID { + return lo.Map(i, func(id ChargeID, _ int) models.NamespacedID { + return models.NamespacedID{ + Namespace: id.Namespace, + ID: id.ID, + } + }) +} + +type ChargeType string + +const ( + ChargeTypeFlatFee ChargeType = "flat_fee" + ChargeTypeUsageBased ChargeType = "usage_based" + ChargeTypeCreditPurchase ChargeType = "credit_purchase" +) + +func (t ChargeType) Values() []string { + return []string{ + string(ChargeTypeFlatFee), + string(ChargeTypeUsageBased), + string(ChargeTypeCreditPurchase), + } +} + +func (t ChargeType) Validate() error { + if !slices.Contains(t.Values(), string(t)) { + return models.NewGenericValidationError(fmt.Errorf("invalid charge type: %s", t)) + } + + return nil +} + +type Expand string + +const ( + ExpandRealizations Expand = "realizations" + ExpandRealtimeUsage Expand = "realtime_usage" + ExpandDetailedLines Expand = "detailed_lines" + ExpandDeletedRealizations Expand = "deleted_realizations" +) + +func (e Expand) Values() []Expand { + return []Expand{ + ExpandRealizations, + ExpandRealtimeUsage, + ExpandDetailedLines, + ExpandDeletedRealizations, + } +} + +var ExpandNone Expands = nil + +type Expands = expand.Expand[Expand] + +type ChargeAccessor interface { + GetChargeID() ChargeID + GetCustomerID() customer.CustomerID + GetCurrency() currencyx.Code + ErrorAttributes() models.Attributes +} + +type ChargeStatus string + +const ( + // ChargeStatusCreated is the status of a charge that is created and is not yet active. + ChargeStatusCreated ChargeStatus = "created" + // ChargeStatusActive is the status of a charge that is active and is not yet fully settled for the service period. + ChargeStatusActive ChargeStatus = "active" + // ChargeStatusFinal is the status of a charge that is final and is fully settled for the service period. The charge will not receive any additional + // late events in the future. + ChargeStatusFinal ChargeStatus = "final" + // ChargeStatusDeleted is the status of a charge that is deleted no further actions are possible on it. + ChargeStatusDeleted ChargeStatus = "deleted" +) + +func (s ChargeStatus) Values() []string { + return []string{ + string(ChargeStatusCreated), + string(ChargeStatusActive), + string(ChargeStatusFinal), + string(ChargeStatusDeleted), + } +} + +func (s ChargeStatus) Validate() error { + if !slices.Contains(s.Values(), string(s)) { + return models.NewGenericValidationError(fmt.Errorf("invalid charge status: %s", s)) + } + + return nil +} + +func DetailedStatusToMetaStatus(status string) (ChargeStatus, error) { + metaStatus := ChargeStatus(strings.SplitN(status, ".", 2)[0]) + if err := metaStatus.Validate(); err != nil { + return ChargeStatusCreated, fmt.Errorf("invalid status: %s", status) + } + + return metaStatus, nil +} + +type Charge struct { + ManagedResource + + Intent Intent + IntentMutableFields IntentMutableFields + Status ChargeStatus + AdvanceAfter *time.Time +} + +func (c Charge) Validate() error { + var errs []error + + if err := c.Intent.Validate(); err != nil { + errs = append(errs, fmt.Errorf("intent: %w", err)) + } + + if err := c.IntentMutableFields.Validate(); err != nil { + errs = append(errs, fmt.Errorf("intent mutable fields: %w", err)) + } + + if err := c.Status.Validate(); err != nil { + errs = append(errs, fmt.Errorf("status: %w", err)) + } + + if err := c.ManagedResource.Validate(); err != nil { + errs = append(errs, fmt.Errorf("managed resource: %w", err)) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +type Charges []Charge + +func (c Charges) Validate() error { + var errs []error + + for i, ch := range c { + if err := ch.Validate(); err != nil { + errs = append(errs, fmt.Errorf("charge [%d]: %w", i, err)) + } + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} diff --git a/billing/charges/meta/errors.go b/billing/charges/meta/errors.go new file mode 100644 index 0000000000000000000000000000000000000000..551329902dfd5cdbb9e296bed0ef24fc37723974 --- /dev/null +++ b/billing/charges/meta/errors.go @@ -0,0 +1,17 @@ +package meta + +import ( + "net/http" + + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/models" +) + +const ErrCodeUnsupported models.ErrorCode = "unsupported" + +var ErrUnsupported = models.NewValidationIssue( + ErrCodeUnsupported, + "unsupported", + models.WithCriticalSeverity(), + commonhttp.WithHTTPStatusCodeAttribute(http.StatusInternalServerError), +) diff --git a/billing/charges/meta/intent.go b/billing/charges/meta/intent.go new file mode 100644 index 0000000000000000000000000000000000000000..1dc16e7e23ec46353855b20d8fbf9835580c12e7 --- /dev/null +++ b/billing/charges/meta/intent.go @@ -0,0 +1,127 @@ +package meta + +import ( + "errors" + "fmt" + "maps" + "slices" + + "github.com/samber/lo" + + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/productcatalog" + "github.com/openmeterio/openmeter/pkg/currencyx" + "github.com/openmeterio/openmeter/pkg/models" + "github.com/openmeterio/openmeter/pkg/timeutil" +) + +type Intent struct { + ManagedBy billing.InvoiceLineManagedBy `json:"managedBy"` + CustomerID string `json:"customerID"` + + Annotations models.Annotations `json:"annotations"` + + Currency currencyx.Code `json:"currency"` + TaxConfig productcatalog.TaxCodeConfig `json:"taxConfig"` + + UniqueReferenceID *string `json:"childUniqueReferenceID"` + Subscription *SubscriptionReference `json:"subscription"` +} + +func (i Intent) Clone() Intent { + out := i + + // Keep intent cloning infallible for developer ergonomics; annotations are + // only shallow-cloned here so GetEffectiveIntent does not need an error return. + out.Annotations = maps.Clone(i.Annotations) + + if i.UniqueReferenceID != nil { + out.UniqueReferenceID = lo.ToPtr(*i.UniqueReferenceID) + } + + if i.Subscription != nil { + out.Subscription = lo.ToPtr(*i.Subscription) + } + + if i.TaxConfig.Behavior != nil { + out.TaxConfig.Behavior = lo.ToPtr(*i.TaxConfig.Behavior) + } + + return out +} + +func (i Intent) Validate() error { + var errs []error + + if !slices.Contains(billing.InvoiceLineManagedBy("").Values(), string(i.ManagedBy)) { + errs = append(errs, fmt.Errorf("invalid managed by %s", i.ManagedBy)) + } + + if i.CustomerID == "" { + errs = append(errs, fmt.Errorf("customer ID is required")) + } + + if err := i.Currency.Validate(); err != nil { + errs = append(errs, fmt.Errorf("currency: %w", err)) + } + + if err := i.TaxConfig.Validate(); err != nil { + errs = append(errs, fmt.Errorf("tax config: %w", err)) + } + + if i.Subscription != nil { + if err := i.Subscription.Validate(); err != nil { + errs = append(errs, fmt.Errorf("subscription: %w", err)) + } + } + + if i.UniqueReferenceID != nil && *i.UniqueReferenceID == "" { + errs = append(errs, fmt.Errorf("unique reference ID cannot be empty")) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +type IntentMutableFields struct { + Name string `json:"name"` + Description *string `json:"description"` + Metadata models.Metadata `json:"metadata"` + + ServicePeriod timeutil.ClosedPeriod `json:"servicePeriod"` + FullServicePeriod timeutil.ClosedPeriod `json:"fullServicePeriod"` + BillingPeriod timeutil.ClosedPeriod `json:"billingPeriod"` +} + +func (i IntentMutableFields) Clone() IntentMutableFields { + out := i + + if i.Description != nil { + out.Description = lo.ToPtr(*i.Description) + } + + out.Metadata = i.Metadata.Clone() + + return out +} + +func (i IntentMutableFields) Validate() error { + var errs []error + + if i.Name == "" { + errs = append(errs, fmt.Errorf("name is required")) + } + + if err := i.ServicePeriod.Validate(); err != nil { + errs = append(errs, fmt.Errorf("service period: %w", err)) + } + + if err := i.FullServicePeriod.Validate(); err != nil { + errs = append(errs, fmt.Errorf("full service period: %w", err)) + } + + if err := i.BillingPeriod.Validate(); err != nil { + errs = append(errs, fmt.Errorf("billing period: %w", err)) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} diff --git a/billing/charges/meta/patch.go b/billing/charges/meta/patch.go new file mode 100644 index 0000000000000000000000000000000000000000..a1ebbcb8f39265c335a427ea65c6e99919781c40 --- /dev/null +++ b/billing/charges/meta/patch.go @@ -0,0 +1,84 @@ +package meta + +import ( + "context" + "errors" + "fmt" + "slices" + + "github.com/qmuntal/stateless" + + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/billing/charges/invoiceupdater" + "github.com/openmeterio/openmeter/pkg/models" +) + +type PatchType string + +const ( + PatchTypeExtend PatchType = "extend" + PatchTypeShrink PatchType = "shrink" + PatchTypeDelete PatchType = "delete" + PatchTypeLineManualEdit PatchType = "line_manual_edit" + PatchTypeShrinkToRealizedPeriod PatchType = "shrink_to_realized_period" +) + +type ChangeTarget string + +const ( + ChangeTargetBase ChangeTarget = "base" + ChangeTargetOverride ChangeTarget = "override" +) + +func (t ChangeTarget) Values() []ChangeTarget { + return []ChangeTarget{ + ChangeTargetBase, + ChangeTargetOverride, + } +} + +func (t ChangeTarget) Validate() error { + if !slices.Contains(t.Values(), t) { + return models.NewGenericValidationError(fmt.Errorf("invalid change target: %s", t)) + } + + return nil +} + +type LayeredIntentReader interface { + GetBaseManagedBy() billing.InvoiceLineManagedBy + HasOverrideLayer() bool +} + +func apiPatchTargetLayer(intent LayeredIntentReader) (ChangeTarget, error) { + if intent == nil { + return "", errors.New("intent is required") + } + + if intent.HasOverrideLayer() || intent.GetBaseManagedBy() != billing.ManuallyManagedLine { + return ChangeTargetOverride, nil + } + + return ChangeTargetBase, nil +} + +type Patch interface { + models.Validator + + Op() PatchType + Trigger() stateless.Trigger + GetTargetLayer(LayeredIntentReader) (ChangeTarget, error) +} + +type TriggerPatchResult[T any] struct { + Charge *T + InvoicePatches invoiceupdater.Patches +} + +// PatchAction adapts a generic Patch action to a concrete patch action when +// statelessx.AllOfWithParameters requires strict typing for composed actions. +func PatchAction[T Patch](fn func(context.Context, Patch) error) func(context.Context, T) error { + return func(ctx context.Context, patch T) error { + return fn(ctx, patch) + } +} diff --git a/billing/charges/meta/patch_target_test.go b/billing/charges/meta/patch_target_test.go new file mode 100644 index 0000000000000000000000000000000000000000..a4b800600580ebd87a20142241c444aa466b9def --- /dev/null +++ b/billing/charges/meta/patch_target_test.go @@ -0,0 +1,227 @@ +package meta + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/openmeterio/openmeter/openmeter/billing" +) + +type layeredIntentReaderForTest struct { + baseManagedBy billing.InvoiceLineManagedBy + hasOverride bool +} + +func (r layeredIntentReaderForTest) GetBaseManagedBy() billing.InvoiceLineManagedBy { + return r.baseManagedBy +} + +func (r layeredIntentReaderForTest) HasOverrideLayer() bool { + return r.hasOverride +} + +func TestPeriodPatchGetTargetLayer(t *testing.T) { + patch := PatchExtend{changeSource: billing.ChangeSourceSystem} + + got, err := patch.GetTargetLayer(layeredIntentReaderForTest{ + baseManagedBy: billing.SubscriptionManagedLine, + }) + + require.NoError(t, err) + require.Equal(t, ChangeTargetBase, got) +} + +func TestPeriodPatchValidateRejectsAPIChange(t *testing.T) { + base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + patch := PatchExtend{ + changeSource: billing.ChangeSourceAPIRequest, + newServicePeriodTo: base.AddDate(0, 1, 0), + newFullServicePeriodTo: base.AddDate(0, 1, 0), + newBillingPeriodTo: base.AddDate(0, 1, 0), + newInvoiceAt: base.AddDate(0, 1, 0), + } + + require.ErrorContains(t, patch.Validate(), "change source") +} + +func TestDeletePatchGetTargetLayer(t *testing.T) { + tests := []struct { + name string + patch PatchDelete + intent layeredIntentReaderForTest + want ChangeTarget + }{ + { + name: "system change targets base", + patch: PatchDelete{ + changeSource: billing.ChangeSourceSystem, + }, + intent: layeredIntentReaderForTest{ + baseManagedBy: billing.SubscriptionManagedLine, + }, + want: ChangeTargetBase, + }, + { + name: "api change on manual base without override targets base", + patch: PatchDelete{ + changeSource: billing.ChangeSourceAPIRequest, + }, + intent: layeredIntentReaderForTest{ + baseManagedBy: billing.ManuallyManagedLine, + }, + want: ChangeTargetBase, + }, + { + name: "api change with override targets override", + patch: PatchDelete{ + changeSource: billing.ChangeSourceAPIRequest, + }, + intent: layeredIntentReaderForTest{ + baseManagedBy: billing.ManuallyManagedLine, + hasOverride: true, + }, + want: ChangeTargetOverride, + }, + { + name: "api change on subscription base targets override", + patch: PatchDelete{ + changeSource: billing.ChangeSourceAPIRequest, + }, + intent: layeredIntentReaderForTest{ + baseManagedBy: billing.SubscriptionManagedLine, + }, + want: ChangeTargetOverride, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.patch.GetTargetLayer(tt.intent) + + require.NoError(t, err) + require.Equal(t, tt.want, got) + }) + } +} + +func TestDeletePatchGetTargetLayerRejectsMissingAPIIntent(t *testing.T) { + patch := PatchDelete{changeSource: billing.ChangeSourceAPIRequest} + + _, err := patch.GetTargetLayer(nil) + + require.ErrorContains(t, err, "intent is required") +} + +func TestLineManualEditPatchGetTargetLayer(t *testing.T) { + tests := []struct { + name string + intent layeredIntentReaderForTest + want ChangeTarget + }{ + { + name: "manual base without override targets base", + intent: layeredIntentReaderForTest{ + baseManagedBy: billing.ManuallyManagedLine, + }, + want: ChangeTargetBase, + }, + { + name: "manual base with override targets override", + intent: layeredIntentReaderForTest{ + baseManagedBy: billing.ManuallyManagedLine, + hasOverride: true, + }, + want: ChangeTargetOverride, + }, + { + name: "subscription base targets override", + intent: layeredIntentReaderForTest{ + baseManagedBy: billing.SubscriptionManagedLine, + }, + want: ChangeTargetOverride, + }, + } + + patch := PatchLineManualEdit{changeSource: billing.ChangeSourceAPIRequest} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := patch.GetTargetLayer(tt.intent) + + require.NoError(t, err) + require.Equal(t, tt.want, got) + }) + } +} + +func TestLineManualEditPatchGetTargetLayerRejectsMissingIntent(t *testing.T) { + patch := PatchLineManualEdit{changeSource: billing.ChangeSourceAPIRequest} + + _, err := patch.GetTargetLayer(nil) + + require.ErrorContains(t, err, "intent is required") +} + +func TestLineManualEditPatchValidateRejectsSystemChange(t *testing.T) { + patch := PatchLineManualEdit{changeSource: billing.ChangeSourceSystem} + + require.Error(t, patch.Validate()) +} + +func TestShrinkToRealizedPeriodPatchGetTargetLayer(t *testing.T) { + tests := []struct { + name string + intent layeredIntentReaderForTest + want ChangeTarget + }{ + { + name: "manual base without override targets base", + intent: layeredIntentReaderForTest{ + baseManagedBy: billing.ManuallyManagedLine, + }, + want: ChangeTargetBase, + }, + { + name: "manual base with override targets override", + intent: layeredIntentReaderForTest{ + baseManagedBy: billing.ManuallyManagedLine, + hasOverride: true, + }, + want: ChangeTargetOverride, + }, + { + name: "subscription base targets override", + intent: layeredIntentReaderForTest{ + baseManagedBy: billing.SubscriptionManagedLine, + }, + want: ChangeTargetOverride, + }, + } + + patch := PatchShrinkToRealizedPeriod{changeSource: billing.ChangeSourceAPIRequest} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := patch.GetTargetLayer(tt.intent) + + require.NoError(t, err) + require.Equal(t, tt.want, got) + }) + } +} + +func TestShrinkToRealizedPeriodPatchGetTargetLayerRejectsMissingIntent(t *testing.T) { + patch := PatchShrinkToRealizedPeriod{changeSource: billing.ChangeSourceAPIRequest} + + _, err := patch.GetTargetLayer(nil) + + require.ErrorContains(t, err, "intent is required") +} + +func TestShrinkToRealizedPeriodPatchValidateRejectsSystemChange(t *testing.T) { + patch := PatchShrinkToRealizedPeriod{changeSource: billing.ChangeSourceSystem} + + require.Error(t, patch.Validate()) +} diff --git a/billing/charges/meta/patchdelete.go b/billing/charges/meta/patchdelete.go new file mode 100644 index 0000000000000000000000000000000000000000..7ea5fb7bf79adabf2650d066d129cb65a12731ed --- /dev/null +++ b/billing/charges/meta/patchdelete.go @@ -0,0 +1,183 @@ +package meta + +import ( + "errors" + "fmt" + "slices" + + "github.com/qmuntal/stateless" + + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/pkg/models" +) + +var ( + _ Patch = (*PatchDelete)(nil) + TriggerDelete = stateless.Trigger("delete") +) + +type PatchDelete struct { + changeSource billing.ChangeSource + policy PatchDeletePolicy +} + +type NewPatchDeleteInput struct { + ChangeSource billing.ChangeSource + Policy PatchDeletePolicy +} + +func (i NewPatchDeleteInput) Validate() error { + var errs []error + + if err := i.ChangeSource.Validate(); err != nil { + errs = append(errs, fmt.Errorf("change source: %w", err)) + } + + if err := i.Policy.Validate(); err != nil { + errs = append(errs, fmt.Errorf("policy: %w", err)) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +func NewPatchDelete(input NewPatchDeleteInput) (PatchDelete, error) { + if err := input.Validate(); err != nil { + return PatchDelete{}, err + } + + patch := PatchDelete{ + changeSource: input.ChangeSource, + policy: input.Policy, + } + if err := patch.Validate(); err != nil { + return PatchDelete{}, err + } + + return patch, nil +} + +func (p PatchDelete) GetChangeSource() billing.ChangeSource { + return p.changeSource +} + +func (p PatchDelete) GetTargetLayer(intent LayeredIntentReader) (ChangeTarget, error) { + if err := p.GetChangeSource().Validate(); err != nil { + return "", fmt.Errorf("change source: %w", err) + } + + if p.GetChangeSource() == billing.ChangeSourceAPIRequest { + return apiPatchTargetLayer(intent) + } + + return ChangeTargetBase, nil +} + +func (p PatchDelete) GetPolicy() PatchDeletePolicy { + return p.policy +} + +func (p PatchDelete) Op() PatchType { + return PatchTypeDelete +} + +func (p PatchDelete) Trigger() stateless.Trigger { + return TriggerDelete +} + +func (p PatchDelete) Validate() error { + var errs []error + + if err := p.GetChangeSource().Validate(); err != nil { + errs = append(errs, fmt.Errorf("change source: %w", err)) + } + + if err := p.GetPolicy().Validate(); err != nil { + errs = append(errs, fmt.Errorf("policy: %w", err)) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +type CreditRefundPolicy string + +var _ models.Validator = (*CreditRefundPolicy)(nil) + +const ( + // CreditRefundPolicyCorrect will refund the credit to the customer by reversing the credit transactions. + CreditRefundPolicyCorrect CreditRefundPolicy = "correct" + // CreditRefundPolicyIgnore will ignore the credit and leave it as is without performing any action. + CreditRefundPolicyIgnore CreditRefundPolicy = "ignore" +) + +func (p CreditRefundPolicy) Values() []CreditRefundPolicy { + return []CreditRefundPolicy{ + CreditRefundPolicyCorrect, + CreditRefundPolicyIgnore, + } +} + +func (p CreditRefundPolicy) Validate() error { + if !slices.Contains(p.Values(), p) { + return models.NewGenericValidationError(fmt.Errorf("invalid credit refund policy: %s", p)) + } + + return nil +} + +type InvoiceRefundPolicy string + +var _ models.Validator = (*InvoiceRefundPolicy)(nil) + +const ( + // InvoiceRefundPolicyRefund will refund the payment to the customer using the app's refund functionality. + InvoiceRefundPolicyRefund InvoiceRefundPolicy = "refund" + // InvoiceRefundPolicyGrantCredits will grant credits to the customer to cover the payment amount. + InvoiceRefundPolicyGrantCredits InvoiceRefundPolicy = "grant_credits" + // InvoiceRefundPolicyIgnore will ignore the payment and leave it as is without performing any action. (this can be used + // to settle the payment manually) + InvoiceRefundPolicyIgnore InvoiceRefundPolicy = "ignore" +) + +func (p InvoiceRefundPolicy) Values() []InvoiceRefundPolicy { + return []InvoiceRefundPolicy{ + InvoiceRefundPolicyRefund, + InvoiceRefundPolicyGrantCredits, + InvoiceRefundPolicyIgnore, + } +} + +func (p InvoiceRefundPolicy) Validate() error { + if !slices.Contains(p.Values(), p) { + return models.NewGenericValidationError(fmt.Errorf("invalid invoice refund policy: %s", p)) + } + + return nil +} + +var _ models.Validator = (*PatchDeletePolicy)(nil) + +type PatchDeletePolicy struct { + CreditRefundPolicy CreditRefundPolicy + InvoiceRefundPolicy InvoiceRefundPolicy +} + +func (p PatchDeletePolicy) Validate() error { + var errs []error + + if err := p.CreditRefundPolicy.Validate(); err != nil { + errs = append(errs, fmt.Errorf("credit refund policy: %w", err)) + } + + if err := p.InvoiceRefundPolicy.Validate(); err != nil { + errs = append(errs, fmt.Errorf("invoice refund policy: %w", err)) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +// RefundAsCreditsDeletePolicy is a policy that will refund the usage as credits to the customer. For now this can +// be considered as the default policy for delete patches. +var RefundAsCreditsDeletePolicy PatchDeletePolicy = PatchDeletePolicy{ + CreditRefundPolicy: CreditRefundPolicyCorrect, + InvoiceRefundPolicy: InvoiceRefundPolicyGrantCredits, +} diff --git a/billing/charges/meta/patchextend.go b/billing/charges/meta/patchextend.go new file mode 100644 index 0000000000000000000000000000000000000000..a2ad3c599f6cb7e6b9e7dba9a41fde337faa573f --- /dev/null +++ b/billing/charges/meta/patchextend.go @@ -0,0 +1,162 @@ +package meta + +import ( + "errors" + "fmt" + "time" + + "github.com/qmuntal/stateless" + + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/pkg/models" +) + +var ( + _ Patch = (*PatchExtend)(nil) + TriggerExtend = stateless.Trigger("extend") +) + +type PatchExtend struct { + changeSource billing.ChangeSource + newServicePeriodTo time.Time + newFullServicePeriodTo time.Time + newBillingPeriodTo time.Time + newInvoiceAt time.Time +} + +type NewPatchExtendInput struct { + ChangeSource billing.ChangeSource + NewServicePeriodTo time.Time + NewFullServicePeriodTo time.Time + NewBillingPeriodTo time.Time + NewInvoiceAt time.Time +} + +func (i NewPatchExtendInput) Validate() error { + var errs []error + + if err := i.ChangeSource.Require(billing.ChangeSourceSystem); err != nil { + errs = append(errs, fmt.Errorf("change source: %w", err)) + } + + if i.NewServicePeriodTo.IsZero() { + errs = append(errs, errors.New("new service period to is required")) + } + + if i.NewFullServicePeriodTo.IsZero() { + errs = append(errs, errors.New("new full service period to is required")) + } + + if i.NewBillingPeriodTo.IsZero() { + errs = append(errs, errors.New("new billing period to is required")) + } + + if i.NewInvoiceAt.IsZero() { + errs = append(errs, errors.New("new invoice at is required")) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +func NewPatchExtend(input NewPatchExtendInput) (PatchExtend, error) { + if err := input.Validate(); err != nil { + return PatchExtend{}, err + } + + patch := PatchExtend{ + changeSource: input.ChangeSource, + newServicePeriodTo: NormalizeTimestamp(input.NewServicePeriodTo), + newFullServicePeriodTo: NormalizeTimestamp(input.NewFullServicePeriodTo), + newBillingPeriodTo: NormalizeTimestamp(input.NewBillingPeriodTo), + newInvoiceAt: NormalizeTimestamp(input.NewInvoiceAt), + } + if err := patch.Validate(); err != nil { + return PatchExtend{}, err + } + + return patch, nil +} + +func (p PatchExtend) GetChangeSource() billing.ChangeSource { + return p.changeSource +} + +func (p PatchExtend) GetTargetLayer(LayeredIntentReader) (ChangeTarget, error) { + if err := p.GetChangeSource().Require(billing.ChangeSourceSystem); err != nil { + return "", fmt.Errorf("change source: %w", err) + } + + return ChangeTargetBase, nil +} + +func (p PatchExtend) GetNewServicePeriodTo() time.Time { + return p.newServicePeriodTo +} + +func (p PatchExtend) GetNewFullServicePeriodTo() time.Time { + return p.newFullServicePeriodTo +} + +func (p PatchExtend) GetNewBillingPeriodTo() time.Time { + return p.newBillingPeriodTo +} + +func (p PatchExtend) GetNewInvoiceAt() time.Time { + return p.newInvoiceAt +} + +func (p PatchExtend) Op() PatchType { + return PatchTypeExtend +} + +func (p PatchExtend) Trigger() stateless.Trigger { + return TriggerExtend +} + +func (p PatchExtend) Validate() error { + var errs []error + + if err := p.GetChangeSource().Require(billing.ChangeSourceSystem); err != nil { + errs = append(errs, fmt.Errorf("change source: %w", err)) + } + + if p.GetNewServicePeriodTo().IsZero() { + errs = append(errs, errors.New("new service period to is required")) + } + + if p.GetNewFullServicePeriodTo().IsZero() { + errs = append(errs, errors.New("new full service period to is required")) + } + + if p.GetNewBillingPeriodTo().IsZero() { + errs = append(errs, errors.New("new billing period to is required")) + } + + if p.GetNewInvoiceAt().IsZero() { + errs = append(errs, errors.New("new invoice at is required")) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +func (p PatchExtend) ValidateWith(intent IntentMutableFields) error { + var errs []error + + if err := p.Validate(); err != nil { + errs = append(errs, err) + } + + if !p.GetNewServicePeriodTo().After(intent.ServicePeriod.To) { + errs = append(errs, fmt.Errorf("new service period to must be greater than existing service period to")) + } + + if p.GetNewFullServicePeriodTo().Before(intent.FullServicePeriod.To) { + errs = append(errs, fmt.Errorf("new full service period to must be greater than or equal to existing full service period to")) + } + + if p.GetNewBillingPeriodTo().Before(intent.BillingPeriod.To) { + errs = append(errs, fmt.Errorf("new billing period to must be greater than or equal to existing billing period to")) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} diff --git a/billing/charges/meta/patchextend_test.go b/billing/charges/meta/patchextend_test.go new file mode 100644 index 0000000000000000000000000000000000000000..bb6f69cef9015df1b208c72281aaad425ac22b0a --- /dev/null +++ b/billing/charges/meta/patchextend_test.go @@ -0,0 +1,133 @@ +package meta + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/pkg/timeutil" +) + +func TestPatchExtendValidateWith(t *testing.T) { + base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + + intent := IntentMutableFields{ + ServicePeriod: timeutil.ClosedPeriod{ + From: base, + To: base.AddDate(0, 1, 0), + }, + FullServicePeriod: timeutil.ClosedPeriod{ + From: base, + To: base.AddDate(0, 2, 0), + }, + BillingPeriod: timeutil.ClosedPeriod{ + From: base, + To: base.AddDate(0, 3, 0), + }, + } + + tests := []struct { + name string + patch PatchExtend + wantErr bool + }{ + { + name: "rejects missing change source", + patch: PatchExtend{ + newServicePeriodTo: intent.ServicePeriod.To.Add(time.Hour), + newFullServicePeriodTo: intent.FullServicePeriod.To, + newBillingPeriodTo: intent.BillingPeriod.To, + newInvoiceAt: intent.ServicePeriod.To.Add(time.Hour), + }, + wantErr: true, + }, + { + name: "allows service period extension with unchanged full service and billing periods", + patch: mustNewPatchExtend(t, NewPatchExtendInput{ + ChangeSource: billing.ChangeSourceSystem, + NewServicePeriodTo: intent.ServicePeriod.To.Add(time.Hour), + NewFullServicePeriodTo: intent.FullServicePeriod.To, + NewBillingPeriodTo: intent.BillingPeriod.To, + NewInvoiceAt: intent.ServicePeriod.To.Add(time.Hour), + }), + }, + { + name: "rejects unchanged service period end", + patch: mustNewPatchExtend(t, NewPatchExtendInput{ + ChangeSource: billing.ChangeSourceSystem, + NewServicePeriodTo: intent.ServicePeriod.To, + NewFullServicePeriodTo: intent.FullServicePeriod.To, + NewBillingPeriodTo: intent.BillingPeriod.To, + NewInvoiceAt: intent.ServicePeriod.To, + }), + wantErr: true, + }, + { + name: "rejects earlier service period end", + patch: mustNewPatchExtend(t, NewPatchExtendInput{ + ChangeSource: billing.ChangeSourceSystem, + NewServicePeriodTo: intent.ServicePeriod.To.Add(-time.Hour), + NewFullServicePeriodTo: intent.FullServicePeriod.To, + NewBillingPeriodTo: intent.BillingPeriod.To, + NewInvoiceAt: intent.ServicePeriod.To.Add(-time.Hour), + }), + wantErr: true, + }, + { + name: "rejects earlier full service period end", + patch: mustNewPatchExtend(t, NewPatchExtendInput{ + ChangeSource: billing.ChangeSourceSystem, + NewServicePeriodTo: intent.ServicePeriod.To.Add(time.Hour), + NewFullServicePeriodTo: intent.FullServicePeriod.To.Add(-time.Hour), + NewBillingPeriodTo: intent.BillingPeriod.To, + NewInvoiceAt: intent.ServicePeriod.To.Add(time.Hour), + }), + wantErr: true, + }, + { + name: "rejects earlier billing period end", + patch: mustNewPatchExtend(t, NewPatchExtendInput{ + ChangeSource: billing.ChangeSourceSystem, + NewServicePeriodTo: intent.ServicePeriod.To.Add(time.Hour), + NewFullServicePeriodTo: intent.FullServicePeriod.To, + NewBillingPeriodTo: intent.BillingPeriod.To.Add(-time.Hour), + NewInvoiceAt: intent.ServicePeriod.To.Add(time.Hour), + }), + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.patch.ValidateWith(intent) + if tt.wantErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + }) + } +} + +func TestNewPatchExtendInputValidateRequiresChangeSource(t *testing.T) { + base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + + _, err := NewPatchExtend(NewPatchExtendInput{ + NewServicePeriodTo: base.AddDate(0, 1, 0), + NewFullServicePeriodTo: base.AddDate(0, 1, 0), + NewBillingPeriodTo: base.AddDate(0, 1, 0), + NewInvoiceAt: base.AddDate(0, 1, 0), + }) + require.Error(t, err) +} + +func mustNewPatchExtend(t *testing.T, input NewPatchExtendInput) PatchExtend { + t.Helper() + + patch, err := NewPatchExtend(input) + require.NoError(t, err) + return patch +} diff --git a/billing/charges/meta/patchlinemanualedit.go b/billing/charges/meta/patchlinemanualedit.go new file mode 100644 index 0000000000000000000000000000000000000000..ad12ce942bbb201dbc2d348e8eaf8fb49b6aa4d1 --- /dev/null +++ b/billing/charges/meta/patchlinemanualedit.go @@ -0,0 +1,108 @@ +package meta + +import ( + "errors" + "fmt" + + "github.com/qmuntal/stateless" + + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/pkg/models" +) + +var _ Patch = (*PatchLineManualEdit)(nil) + +type PatchLineManualEdit struct { + changeSource billing.ChangeSource + override billing.InvoiceLineOverride +} + +type NewPatchLineManualEditInput struct { + ChangeSource billing.ChangeSource + Override billing.InvoiceLineOverride +} + +func (i NewPatchLineManualEditInput) Validate() error { + var errs []error + + if err := i.ChangeSource.Require(billing.ChangeSourceAPIRequest); err != nil { + errs = append(errs, fmt.Errorf("change source: %w", err)) + } + + if err := i.Override.Validate(); err != nil { + errs = append(errs, fmt.Errorf("override: %w", err)) + } + + if err := ValidateInvoiceLineOverrideDoesNotChangeImmutableChargeIntentFields(i.Override); err != nil { + errs = append(errs, fmt.Errorf("override: %w", err)) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +func ValidateInvoiceLineOverrideDoesNotChangeImmutableChargeIntentFields(override billing.InvoiceLineOverride) error { + lineID := "" + if override.ExistingLine != nil { + lineID = override.ExistingLine.GetID() + } + + // Feature key and tax config are immutable charge intent fields. Letting + // invoice-line overrides mutate them would make ledger provenance point at + // a charge whose base billing context no longer matches the edited line. + if override.ChangesToApply.FeatureKey.IsPresent() { + return fmt.Errorf("line[%s]: %w", lineID, billing.ErrInvoiceLineFeatureKeyEditNotSupported) + } + + if override.ChangesToApply.TaxConfig.IsPresent() { + return fmt.Errorf("line[%s]: %w", lineID, billing.ErrInvoiceLineTaxConfigEditNotSupported) + } + + return nil +} + +func NewPatchLineManualEdit(input NewPatchLineManualEditInput) (PatchLineManualEdit, error) { + if err := input.Validate(); err != nil { + return PatchLineManualEdit{}, err + } + + patch := PatchLineManualEdit{ + changeSource: input.ChangeSource, + override: input.Override, + } + if err := patch.Validate(); err != nil { + return PatchLineManualEdit{}, err + } + + return patch, nil +} + +func (p PatchLineManualEdit) GetOverride() billing.InvoiceLineOverride { + return p.override +} + +func (p PatchLineManualEdit) GetChangeSource() billing.ChangeSource { + return p.changeSource +} + +func (p PatchLineManualEdit) GetTargetLayer(intent LayeredIntentReader) (ChangeTarget, error) { + if err := p.GetChangeSource().Require(billing.ChangeSourceAPIRequest); err != nil { + return "", fmt.Errorf("change source: %w", err) + } + + return apiPatchTargetLayer(intent) +} + +func (p PatchLineManualEdit) Op() PatchType { + return PatchTypeLineManualEdit +} + +func (p PatchLineManualEdit) Trigger() stateless.Trigger { + return TriggerLineManualEdit +} + +func (p PatchLineManualEdit) Validate() error { + return NewPatchLineManualEditInput{ + ChangeSource: p.GetChangeSource(), + Override: p.GetOverride(), + }.Validate() +} diff --git a/billing/charges/meta/patchlinemanualedit_test.go b/billing/charges/meta/patchlinemanualedit_test.go new file mode 100644 index 0000000000000000000000000000000000000000..89dec09b00329b0c7297a077e90f0beb44dbea91 --- /dev/null +++ b/billing/charges/meta/patchlinemanualedit_test.go @@ -0,0 +1,33 @@ +package meta + +import ( + "errors" + "testing" + + "github.com/samber/mo" + "github.com/stretchr/testify/require" + + "github.com/openmeterio/openmeter/openmeter/billing" +) + +func TestValidateInvoiceLineOverrideDoesNotChangeImmutableChargeIntentFieldsRejectsFeatureKeyChange(t *testing.T) { + err := ValidateInvoiceLineOverrideDoesNotChangeImmutableChargeIntentFields(billing.InvoiceLineOverride{ + ChangesToApply: billing.ExistingLineOverride{ + FeatureKey: mo.Some("new-feature"), + }, + }) + + require.Error(t, err) + require.True(t, errors.Is(err, billing.ErrInvoiceLineFeatureKeyEditNotSupported)) +} + +func TestValidateInvoiceLineOverrideDoesNotChangeImmutableChargeIntentFieldsRejectsTaxConfigChange(t *testing.T) { + err := ValidateInvoiceLineOverrideDoesNotChangeImmutableChargeIntentFields(billing.InvoiceLineOverride{ + ChangesToApply: billing.ExistingLineOverride{ + TaxConfig: mo.Some(&billing.TaxConfig{}), + }, + }) + + require.Error(t, err) + require.True(t, errors.Is(err, billing.ErrInvoiceLineTaxConfigEditNotSupported)) +} diff --git a/billing/charges/meta/patchshrink.go b/billing/charges/meta/patchshrink.go new file mode 100644 index 0000000000000000000000000000000000000000..610babd276500d7907110d1e1a21daaef6ae5065 --- /dev/null +++ b/billing/charges/meta/patchshrink.go @@ -0,0 +1,174 @@ +package meta + +import ( + "errors" + "fmt" + "time" + + "github.com/qmuntal/stateless" + + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/pkg/models" +) + +var ( + _ Patch = (*PatchShrink)(nil) + TriggerShrink = stateless.Trigger("shrink") +) + +type PatchShrink struct { + changeSource billing.ChangeSource + newServicePeriodTo time.Time + newFullServicePeriodTo time.Time + newBillingPeriodTo time.Time + newInvoiceAt time.Time +} + +type NewPatchShrinkInput struct { + ChangeSource billing.ChangeSource + NewServicePeriodTo time.Time + NewFullServicePeriodTo time.Time + NewBillingPeriodTo time.Time + NewInvoiceAt time.Time +} + +func (i NewPatchShrinkInput) Validate() error { + var errs []error + + if err := i.ChangeSource.Require(billing.ChangeSourceSystem); err != nil { + errs = append(errs, fmt.Errorf("change source: %w", err)) + } + + if i.NewServicePeriodTo.IsZero() { + errs = append(errs, fmt.Errorf("new service period to is required")) + } + + if i.NewFullServicePeriodTo.IsZero() { + errs = append(errs, fmt.Errorf("new full service period to is required")) + } + + if i.NewBillingPeriodTo.IsZero() { + errs = append(errs, fmt.Errorf("new billing period to is required")) + } + + if i.NewInvoiceAt.IsZero() { + errs = append(errs, fmt.Errorf("new invoice at is required")) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +func NewPatchShrink(input NewPatchShrinkInput) (PatchShrink, error) { + if err := input.Validate(); err != nil { + return PatchShrink{}, err + } + + patch := PatchShrink{ + changeSource: input.ChangeSource, + newServicePeriodTo: NormalizeTimestamp(input.NewServicePeriodTo), + newFullServicePeriodTo: NormalizeTimestamp(input.NewFullServicePeriodTo), + newBillingPeriodTo: NormalizeTimestamp(input.NewBillingPeriodTo), + newInvoiceAt: NormalizeTimestamp(input.NewInvoiceAt), + } + if err := patch.Validate(); err != nil { + return PatchShrink{}, err + } + + return patch, nil +} + +func (p PatchShrink) GetChangeSource() billing.ChangeSource { + return p.changeSource +} + +func (p PatchShrink) GetTargetLayer(LayeredIntentReader) (ChangeTarget, error) { + if err := p.GetChangeSource().Require(billing.ChangeSourceSystem); err != nil { + return "", fmt.Errorf("change source: %w", err) + } + + return ChangeTargetBase, nil +} + +func (p PatchShrink) GetNewServicePeriodTo() time.Time { + return p.newServicePeriodTo +} + +func (p PatchShrink) GetNewFullServicePeriodTo() time.Time { + return p.newFullServicePeriodTo +} + +func (p PatchShrink) GetNewBillingPeriodTo() time.Time { + return p.newBillingPeriodTo +} + +func (p PatchShrink) GetNewInvoiceAt() time.Time { + return p.newInvoiceAt +} + +func (p PatchShrink) Op() PatchType { + return PatchTypeShrink +} + +func (p PatchShrink) Trigger() stateless.Trigger { + return TriggerShrink +} + +func (p PatchShrink) Validate() error { + var errs []error + + if err := p.GetChangeSource().Require(billing.ChangeSourceSystem); err != nil { + errs = append(errs, fmt.Errorf("change source: %w", err)) + } + + if p.GetNewServicePeriodTo().IsZero() { + errs = append(errs, fmt.Errorf("new service period to is required")) + } + + if p.GetNewFullServicePeriodTo().IsZero() { + errs = append(errs, fmt.Errorf("new full service period to is required")) + } + + if p.GetNewBillingPeriodTo().IsZero() { + errs = append(errs, fmt.Errorf("new billing period to is required")) + } + + if p.GetNewInvoiceAt().IsZero() { + errs = append(errs, fmt.Errorf("new invoice at is required")) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +func (p PatchShrink) ValidateWith(intent IntentMutableFields) error { + var errs []error + + if err := p.Validate(); err != nil { + errs = append(errs, err) + } + + if !p.GetNewServicePeriodTo().Before(intent.ServicePeriod.To) { + errs = append(errs, fmt.Errorf("new service period to must be less than existing service period to")) + } + + if !p.GetNewServicePeriodTo().After(intent.ServicePeriod.From) { + errs = append(errs, fmt.Errorf("new service period to must be greater than existing service period from")) + } + + if p.GetNewFullServicePeriodTo().After(intent.FullServicePeriod.To) { + errs = append(errs, fmt.Errorf("new full service period to must be less than or equal to existing full service period to")) + } + + if !p.GetNewFullServicePeriodTo().After(intent.FullServicePeriod.From) { + errs = append(errs, fmt.Errorf("new full service period to must be greater than existing full service period from")) + } + + if p.GetNewBillingPeriodTo().After(intent.BillingPeriod.To) { + errs = append(errs, fmt.Errorf("new billing period to must be less than or equal to existing billing period to")) + } + + if !p.GetNewBillingPeriodTo().After(intent.BillingPeriod.From) { + errs = append(errs, fmt.Errorf("new billing period to must be greater than existing billing period from")) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} diff --git a/billing/charges/meta/patchshrink_test.go b/billing/charges/meta/patchshrink_test.go new file mode 100644 index 0000000000000000000000000000000000000000..2703d4120a73c505ac282f5f978f42028fb16bb1 --- /dev/null +++ b/billing/charges/meta/patchshrink_test.go @@ -0,0 +1,198 @@ +package meta + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/pkg/timeutil" +) + +func TestPatchShrinkValidateWith(t *testing.T) { + base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + + intent := IntentMutableFields{ + ServicePeriod: timeutil.ClosedPeriod{ + From: base, + To: base.AddDate(0, 1, 0), + }, + FullServicePeriod: timeutil.ClosedPeriod{ + From: base, + To: base.AddDate(0, 2, 0), + }, + BillingPeriod: timeutil.ClosedPeriod{ + From: base, + To: base.AddDate(0, 3, 0), + }, + } + + tests := []struct { + name string + patch PatchShrink + wantErr bool + }{ + { + name: "allows service period shrink with unchanged full service and billing periods", + patch: mustNewPatchShrink(t, NewPatchShrinkInput{ + ChangeSource: billing.ChangeSourceSystem, + NewServicePeriodTo: intent.ServicePeriod.To.Add(-time.Hour), + NewFullServicePeriodTo: intent.FullServicePeriod.To, + NewBillingPeriodTo: intent.BillingPeriod.To, + NewInvoiceAt: intent.ServicePeriod.To.Add(-time.Hour), + }), + }, + { + name: "allows full service and billing period shrink", + patch: mustNewPatchShrink(t, NewPatchShrinkInput{ + ChangeSource: billing.ChangeSourceSystem, + NewServicePeriodTo: intent.ServicePeriod.To.Add(-time.Hour), + NewFullServicePeriodTo: intent.FullServicePeriod.To.Add(-time.Hour), + NewBillingPeriodTo: intent.BillingPeriod.To.Add(-time.Hour), + NewInvoiceAt: intent.ServicePeriod.To.Add(-time.Hour), + }), + }, + { + name: "rejects unchanged service period end", + patch: mustNewPatchShrink(t, NewPatchShrinkInput{ + ChangeSource: billing.ChangeSourceSystem, + NewServicePeriodTo: intent.ServicePeriod.To, + NewFullServicePeriodTo: intent.FullServicePeriod.To, + NewBillingPeriodTo: intent.BillingPeriod.To, + NewInvoiceAt: intent.ServicePeriod.To, + }), + wantErr: true, + }, + { + name: "rejects later service period end", + patch: mustNewPatchShrink(t, NewPatchShrinkInput{ + ChangeSource: billing.ChangeSourceSystem, + NewServicePeriodTo: intent.ServicePeriod.To.Add(time.Hour), + NewFullServicePeriodTo: intent.FullServicePeriod.To, + NewBillingPeriodTo: intent.BillingPeriod.To, + NewInvoiceAt: intent.ServicePeriod.To.Add(time.Hour), + }), + wantErr: true, + }, + { + name: "rejects service period end at service period start", + patch: mustNewPatchShrink(t, NewPatchShrinkInput{ + ChangeSource: billing.ChangeSourceSystem, + NewServicePeriodTo: intent.ServicePeriod.From, + NewFullServicePeriodTo: intent.FullServicePeriod.To, + NewBillingPeriodTo: intent.BillingPeriod.To, + NewInvoiceAt: intent.ServicePeriod.From, + }), + wantErr: true, + }, + { + name: "rejects service period end before service period start", + patch: mustNewPatchShrink(t, NewPatchShrinkInput{ + ChangeSource: billing.ChangeSourceSystem, + NewServicePeriodTo: intent.ServicePeriod.From.Add(-time.Hour), + NewFullServicePeriodTo: intent.FullServicePeriod.To, + NewBillingPeriodTo: intent.BillingPeriod.To, + NewInvoiceAt: intent.ServicePeriod.From.Add(-time.Hour), + }), + wantErr: true, + }, + { + name: "rejects later full service period end", + patch: mustNewPatchShrink(t, NewPatchShrinkInput{ + ChangeSource: billing.ChangeSourceSystem, + NewServicePeriodTo: intent.ServicePeriod.To.Add(-time.Hour), + NewFullServicePeriodTo: intent.FullServicePeriod.To.Add(time.Hour), + NewBillingPeriodTo: intent.BillingPeriod.To, + NewInvoiceAt: intent.ServicePeriod.To.Add(-time.Hour), + }), + wantErr: true, + }, + { + name: "rejects full service period end at full service period start", + patch: mustNewPatchShrink(t, NewPatchShrinkInput{ + ChangeSource: billing.ChangeSourceSystem, + NewServicePeriodTo: intent.ServicePeriod.To.Add(-time.Hour), + NewFullServicePeriodTo: intent.FullServicePeriod.From, + NewBillingPeriodTo: intent.BillingPeriod.To, + NewInvoiceAt: intent.ServicePeriod.To.Add(-time.Hour), + }), + wantErr: true, + }, + { + name: "rejects later billing period end", + patch: mustNewPatchShrink(t, NewPatchShrinkInput{ + ChangeSource: billing.ChangeSourceSystem, + NewServicePeriodTo: intent.ServicePeriod.To.Add(-time.Hour), + NewFullServicePeriodTo: intent.FullServicePeriod.To, + NewBillingPeriodTo: intent.BillingPeriod.To.Add(time.Hour), + NewInvoiceAt: intent.ServicePeriod.To.Add(-time.Hour), + }), + wantErr: true, + }, + { + name: "rejects billing period end at billing period start", + patch: mustNewPatchShrink(t, NewPatchShrinkInput{ + ChangeSource: billing.ChangeSourceSystem, + NewServicePeriodTo: intent.ServicePeriod.To.Add(-time.Hour), + NewFullServicePeriodTo: intent.FullServicePeriod.To, + NewBillingPeriodTo: intent.BillingPeriod.From, + NewInvoiceAt: intent.ServicePeriod.To.Add(-time.Hour), + }), + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.patch.ValidateWith(intent) + if tt.wantErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + }) + } +} + +func TestNewPatchShrinkInputValidateRequiresChangeSource(t *testing.T) { + base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + + _, err := NewPatchShrink(NewPatchShrinkInput{ + NewServicePeriodTo: base.AddDate(0, 1, 0), + NewFullServicePeriodTo: base.AddDate(0, 1, 0), + NewBillingPeriodTo: base.AddDate(0, 1, 0), + NewInvoiceAt: base.AddDate(0, 1, 0), + }) + require.Error(t, err) +} + +func TestPatchShrinkGetTargetLayer(t *testing.T) { + patch := PatchShrink{changeSource: billing.ChangeSourceSystem} + + got, err := patch.GetTargetLayer(layeredIntentReaderForTest{ + baseManagedBy: billing.SubscriptionManagedLine, + }) + + require.NoError(t, err) + require.Equal(t, ChangeTargetBase, got) +} + +func TestPatchShrinkGetTargetLayerRejectsAPIChange(t *testing.T) { + patch := PatchShrink{changeSource: billing.ChangeSourceAPIRequest} + + _, err := patch.GetTargetLayer(layeredIntentReaderForTest{ + baseManagedBy: billing.SubscriptionManagedLine, + }) + + require.ErrorContains(t, err, "change source") +} + +func mustNewPatchShrink(t *testing.T, input NewPatchShrinkInput) PatchShrink { + t.Helper() + + patch, err := NewPatchShrink(input) + require.NoError(t, err) + return patch +} diff --git a/billing/charges/meta/patchshrinktorealizedperiod.go b/billing/charges/meta/patchshrinktorealizedperiod.go new file mode 100644 index 0000000000000000000000000000000000000000..f05da817d6b072ea631393c392ffdff87408bf22 --- /dev/null +++ b/billing/charges/meta/patchshrinktorealizedperiod.go @@ -0,0 +1,103 @@ +package meta + +import ( + "errors" + "fmt" + "time" + + "github.com/qmuntal/stateless" + + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/pkg/models" +) + +var _ Patch = (*PatchShrinkToRealizedPeriod)(nil) + +type PatchShrinkToRealizedPeriod struct { + changeSource billing.ChangeSource + newServicePeriodEnd time.Time +} + +type NewPatchShrinkToRealizedPeriodInput struct { + ChangeSource billing.ChangeSource + NewServicePeriodEnd time.Time +} + +func (i NewPatchShrinkToRealizedPeriodInput) Validate() error { + var errs []error + + if err := i.ChangeSource.Require(billing.ChangeSourceAPIRequest); err != nil { + errs = append(errs, fmt.Errorf("change source: %w", err)) + } + + if i.NewServicePeriodEnd.IsZero() { + errs = append(errs, fmt.Errorf("new service period end is required")) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +func NewPatchShrinkToRealizedPeriod(input NewPatchShrinkToRealizedPeriodInput) (PatchShrinkToRealizedPeriod, error) { + if err := input.Validate(); err != nil { + return PatchShrinkToRealizedPeriod{}, err + } + + patch := PatchShrinkToRealizedPeriod{ + changeSource: input.ChangeSource, + newServicePeriodEnd: NormalizeTimestamp(input.NewServicePeriodEnd), + } + if err := patch.Validate(); err != nil { + return PatchShrinkToRealizedPeriod{}, err + } + + return patch, nil +} + +func (p PatchShrinkToRealizedPeriod) GetChangeSource() billing.ChangeSource { + return p.changeSource +} + +func (p PatchShrinkToRealizedPeriod) GetTargetLayer(intent LayeredIntentReader) (ChangeTarget, error) { + if err := p.GetChangeSource().Require(billing.ChangeSourceAPIRequest); err != nil { + return "", fmt.Errorf("change source: %w", err) + } + + return apiPatchTargetLayer(intent) +} + +func (p PatchShrinkToRealizedPeriod) GetNewServicePeriodEnd() time.Time { + return p.newServicePeriodEnd +} + +func (p PatchShrinkToRealizedPeriod) Op() PatchType { + return PatchTypeShrinkToRealizedPeriod +} + +func (p PatchShrinkToRealizedPeriod) Trigger() stateless.Trigger { + return TriggerShrinkToRealizedPeriod +} + +func (p PatchShrinkToRealizedPeriod) Validate() error { + return NewPatchShrinkToRealizedPeriodInput{ + ChangeSource: p.GetChangeSource(), + NewServicePeriodEnd: p.GetNewServicePeriodEnd(), + }.Validate() +} + +func (p PatchShrinkToRealizedPeriod) ValidateWith(intent IntentMutableFields) error { + var errs []error + + if err := p.Validate(); err != nil { + errs = append(errs, err) + } + + if !p.GetNewServicePeriodEnd().Before(intent.ServicePeriod.To) { + errs = append(errs, fmt.Errorf("new service period end must be less than existing service period to")) + } + + if !p.GetNewServicePeriodEnd().After(intent.ServicePeriod.From) { + errs = append(errs, fmt.Errorf("new service period end must be greater than existing service period from")) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} diff --git a/billing/charges/meta/resource.go b/billing/charges/meta/resource.go new file mode 100644 index 0000000000000000000000000000000000000000..a5d7d8562d7685b30b121d4c09f6551c868b9c61 --- /dev/null +++ b/billing/charges/meta/resource.go @@ -0,0 +1,39 @@ +package meta + +import ( + "errors" + "fmt" + + "github.com/openmeterio/openmeter/pkg/models" +) + +type ManagedResource struct { + models.NamespacedModel + models.ManagedModel + ID string `json:"id"` +} + +func (r ManagedResource) Validate() error { + var errs []error + + if err := r.NamespacedModel.Validate(); err != nil { + errs = append(errs, fmt.Errorf("namespaced model: %w", err)) + } + + if err := r.ManagedModel.Validate(); err != nil { + errs = append(errs, fmt.Errorf("managed model: %w", err)) + } + + if r.ID == "" { + errs = append(errs, fmt.Errorf("id is required")) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +func (r ManagedResource) GetChargeID() ChargeID { + return ChargeID{ + Namespace: r.Namespace, + ID: r.ID, + } +} diff --git a/billing/charges/meta/service.go b/billing/charges/meta/service.go new file mode 100644 index 0000000000000000000000000000000000000000..56115ed57a9eb7bde1ebbb2330dbb91e29f14fe9 --- /dev/null +++ b/billing/charges/meta/service.go @@ -0,0 +1,5 @@ +package meta + +// Service layer is not needed in this package (it's just a db wrapper), if this changes, please start adding +// transaction.Run and a proper service layer. +type Service = Adapter diff --git a/billing/charges/meta/subscription.go b/billing/charges/meta/subscription.go new file mode 100644 index 0000000000000000000000000000000000000000..64412d211800852e38390779fc94f1296b31fe73 --- /dev/null +++ b/billing/charges/meta/subscription.go @@ -0,0 +1,32 @@ +package meta + +import ( + "errors" + "fmt" + + "github.com/openmeterio/openmeter/pkg/models" +) + +type SubscriptionReference struct { + SubscriptionID string `json:"subscriptionID"` + PhaseID string `json:"phaseID"` + ItemID string `json:"itemID"` +} + +func (r SubscriptionReference) Validate() error { + var errs []error + + if r.SubscriptionID == "" { + errs = append(errs, fmt.Errorf("subscription ID is required")) + } + + if r.PhaseID == "" { + errs = append(errs, fmt.Errorf("phase ID is required")) + } + + if r.ItemID == "" { + errs = append(errs, fmt.Errorf("item ID is required")) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} diff --git a/billing/charges/meta/timestamps.go b/billing/charges/meta/timestamps.go new file mode 100644 index 0000000000000000000000000000000000000000..c82d767da475362056d3ed51c2a677c45e60fbec --- /dev/null +++ b/billing/charges/meta/timestamps.go @@ -0,0 +1,40 @@ +package meta + +import ( + "time" + + "github.com/openmeterio/openmeter/openmeter/streaming" + "github.com/openmeterio/openmeter/pkg/timeutil" +) + +func NormalizeTimestamp(t time.Time) time.Time { + if t.IsZero() { + return t + } + + return t.UTC().Truncate(streaming.MinimumWindowSizeDuration) +} + +func NormalizeOptionalTimestamp(t *time.Time) *time.Time { + if t == nil || t.IsZero() { + return nil + } + + normalized := NormalizeTimestamp(*t) + return &normalized +} + +func NormalizeClosedPeriod(period timeutil.ClosedPeriod) timeutil.ClosedPeriod { + return timeutil.ClosedPeriod{ + From: NormalizeTimestamp(period.From), + To: NormalizeTimestamp(period.To), + } +} + +func (i IntentMutableFields) Normalized() IntentMutableFields { + i.ServicePeriod = NormalizeClosedPeriod(i.ServicePeriod) + i.FullServicePeriod = NormalizeClosedPeriod(i.FullServicePeriod) + i.BillingPeriod = NormalizeClosedPeriod(i.BillingPeriod) + + return i +} diff --git a/billing/charges/meta/triggers.go b/billing/charges/meta/triggers.go new file mode 100644 index 0000000000000000000000000000000000000000..5e1c664d5bfaa8ec01e4d81ec6ec4f23c04684ac --- /dev/null +++ b/billing/charges/meta/triggers.go @@ -0,0 +1,15 @@ +package meta + +import "github.com/qmuntal/stateless" + +type Trigger = stateless.Trigger + +var ( + TriggerNext Trigger = "next" + TriggerInvoiceCreated Trigger = "invoice_created" + TriggerCollectionCompleted Trigger = "collection_completed" + TriggerInvoiceIssued Trigger = "invoice_issued" + TriggerLineManualEdit Trigger = "line_manual_edit" + TriggerShrinkToRealizedPeriod Trigger = "shrink_to_realized_period" + TriggerAttachInvoiceLine Trigger = "attach_invoice_line" +) diff --git a/billing/charges/models/chargemeta/mixin.go b/billing/charges/models/chargemeta/mixin.go new file mode 100644 index 0000000000000000000000000000000000000000..264daa548af68cd4fe890531acfe2f21b66c6c06 --- /dev/null +++ b/billing/charges/models/chargemeta/mixin.go @@ -0,0 +1,338 @@ +package chargemeta + +import ( + "time" + + "entgo.io/ent" + "entgo.io/ent/dialect" + "entgo.io/ent/dialect/entsql" + "entgo.io/ent/schema/field" + "entgo.io/ent/schema/index" + "entgo.io/ent/schema/mixin" + + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/billing/charges/meta" + "github.com/openmeterio/openmeter/openmeter/productcatalog" + "github.com/openmeterio/openmeter/pkg/convert" + "github.com/openmeterio/openmeter/pkg/currencyx" + "github.com/openmeterio/openmeter/pkg/framework/entutils" + "github.com/openmeterio/openmeter/pkg/models" + "github.com/openmeterio/openmeter/pkg/timeutil" +) + +type Mixin = entutils.RecursiveMixin[metaMixin] + +type metaMixin struct { + mixin.Schema +} + +func (metaMixin) Mixin() []ent.Mixin { + return []ent.Mixin{ + entutils.AnnotationsMixin{}, + entutils.ResourceMixin{}, + } +} + +func (metaMixin) Fields() []ent.Field { + return []ent.Field{ + field.String("customer_id"). + NotEmpty(). + Immutable(). + SchemaType(map[string]string{ + dialect.Postgres: "char(26)", + }), + + field.Time("service_period_from"), + field.Time("service_period_to"), + field.Time("billing_period_from"), + field.Time("billing_period_to"), + field.Time("full_service_period_from"), + field.Time("full_service_period_to"), + + field.Enum("status"). + GoType(meta.ChargeStatus("")), + + field.String("unique_reference_id"). + Immutable(). + Optional(). + Nillable(), + + field.String("currency"). + GoType(currencyx.Code("")). + NotEmpty(). + Immutable(). + SchemaType(map[string]string{ + dialect.Postgres: "varchar(3)", + }), + + field.Enum("managed_by"). + GoType(billing.InvoiceLineManagedBy("")). + Immutable(), + + // Subscriptions metadata + field.String("subscription_id"). + Optional(). + Nillable(). + Immutable(), + + field.String("subscription_phase_id"). + Optional(). + Nillable(). + Immutable(), + + field.String("subscription_item_id"). + Optional(). + Nillable(), + + field.Time("advance_after"). + Optional(). + Nillable(), + field.String("tax_code_id"). + NotEmpty(). + Immutable(). + SchemaType(map[string]string{ + dialect.Postgres: "char(26)", + }), + field.Enum("tax_behavior"). + GoType(productcatalog.TaxBehavior("")). + Optional(). + Nillable(). + Immutable(), + } +} + +func (metaMixin) Indexes() []ent.Index { + return []ent.Index{ + index.Fields("namespace", "customer_id", "unique_reference_id"). + Annotations( + entsql.IndexWhere("unique_reference_id IS NOT NULL AND deleted_at IS NULL"), + ). + Unique(), + } +} + +type CreateInput struct { + Namespace string + + Intent meta.Intent + IntentMutableFields meta.IntentMutableFields + + Status meta.ChargeStatus + AdvanceAfter *time.Time +} + +type Creator[T any] interface { + entutils.NamespaceMixinCreator[T] + entutils.AnnotationsMixinSetter[T] + entutils.TimeMixinCreator[T] + + SetCustomerID(customerID string) T + SetCurrency(currency currencyx.Code) T + SetNillableUniqueReferenceID(uniqueReferenceID *string) T + SetNillableSubscriptionID(subscriptionID *string) T + SetNillableSubscriptionPhaseID(subscriptionPhaseID *string) T + SetNillableSubscriptionItemID(subscriptionItemID *string) T + + // Mutable fields + SetName(name string) T + SetNillableDescription(description *string) T + SetMetadata(metadata map[string]string) T + SetAnnotations(annotations models.Annotations) T + SetServicePeriodFrom(servicePeriodFrom time.Time) T + SetServicePeriodTo(servicePeriodTo time.Time) T + SetBillingPeriodFrom(billingPeriodFrom time.Time) T + SetBillingPeriodTo(billingPeriodTo time.Time) T + SetFullServicePeriodFrom(fullServicePeriodFrom time.Time) T + SetFullServicePeriodTo(fullServicePeriodTo time.Time) T + SetStatus(status meta.ChargeStatus) T + SetNillableAdvanceAfter(advanceAfter *time.Time) T + SetManagedBy(managedBy billing.InvoiceLineManagedBy) T + SetTaxCodeID(taxCodeID string) T + SetNillableTaxBehavior(taxBehavior *productcatalog.TaxBehavior) T +} + +type Updater[T any] interface { + SetName(name string) T + SetOrClearDescription(description *string) T + SetMetadata(metadata map[string]string) T + SetAnnotations(annotations models.Annotations) T + SetServicePeriodFrom(servicePeriodFrom time.Time) T + SetServicePeriodTo(servicePeriodTo time.Time) T + SetBillingPeriodFrom(billingPeriodFrom time.Time) T + SetBillingPeriodTo(billingPeriodTo time.Time) T + SetFullServicePeriodFrom(fullServicePeriodFrom time.Time) T + SetFullServicePeriodTo(fullServicePeriodTo time.Time) T + SetStatus(status meta.ChargeStatus) T + SetOrClearAdvanceAfter(advanceAfter *time.Time) T +} + +func Create[T Creator[T]](creator Creator[T], in CreateInput) (T, error) { + in.IntentMutableFields = in.IntentMutableFields.Normalized() + in.AdvanceAfter = meta.NormalizeOptionalTimestamp(in.AdvanceAfter) + + if err := in.Intent.Validate(); err != nil { + var empty T + return empty, err + } + + if err := in.IntentMutableFields.Validate(); err != nil { + var empty T + return empty, err + } + + var subscriptionID *string + if in.Intent.Subscription != nil { + subscriptionID = &in.Intent.Subscription.SubscriptionID + } + var subscriptionPhaseID *string + if in.Intent.Subscription != nil { + subscriptionPhaseID = &in.Intent.Subscription.PhaseID + } + var subscriptionItemID *string + if in.Intent.Subscription != nil { + subscriptionItemID = &in.Intent.Subscription.ItemID + } + + return creator. + SetNamespace(in.Namespace). + SetName(in.IntentMutableFields.Name). + SetNillableDescription(in.IntentMutableFields.Description). + SetMetadata(in.IntentMutableFields.Metadata). + SetAnnotations(in.Intent.Annotations). + SetCustomerID(in.Intent.CustomerID). + SetServicePeriodFrom(in.IntentMutableFields.ServicePeriod.From.UTC()). + SetServicePeriodTo(in.IntentMutableFields.ServicePeriod.To.UTC()). + SetBillingPeriodFrom(in.IntentMutableFields.BillingPeriod.From.UTC()). + SetBillingPeriodTo(in.IntentMutableFields.BillingPeriod.To.UTC()). + SetFullServicePeriodFrom(in.IntentMutableFields.FullServicePeriod.From.UTC()). + SetFullServicePeriodTo(in.IntentMutableFields.FullServicePeriod.To.UTC()). + SetStatus(in.Status). + SetCurrency(in.Intent.Currency). + SetManagedBy(in.Intent.ManagedBy). + SetNillableUniqueReferenceID(in.Intent.UniqueReferenceID). + SetNillableAdvanceAfter(convert.SafeToUTC(in.AdvanceAfter)). + SetNillableSubscriptionID(subscriptionID). + SetNillableSubscriptionPhaseID(subscriptionPhaseID). + SetNillableSubscriptionItemID(subscriptionItemID). + SetTaxCodeID(in.Intent.TaxConfig.TaxCodeID). + SetNillableTaxBehavior(in.Intent.TaxConfig.Behavior), nil +} + +type UpdateInput struct { + meta.ManagedResource + Intent meta.Intent + IntentMutableFields meta.IntentMutableFields + + Status meta.ChargeStatus + AdvanceAfter *time.Time +} + +func Update[T Updater[T]](updater Updater[T], in UpdateInput) (T, error) { + in.IntentMutableFields = in.IntentMutableFields.Normalized() + in.AdvanceAfter = meta.NormalizeOptionalTimestamp(in.AdvanceAfter) + + if err := in.IntentMutableFields.Validate(); err != nil { + var empty T + return empty, err + } + + if err := in.Intent.Validate(); err != nil { + var empty T + return empty, err + } + + return updater. + SetName(in.IntentMutableFields.Name). + SetOrClearDescription(in.IntentMutableFields.Description). + SetMetadata(in.IntentMutableFields.Metadata). + SetAnnotations(in.Intent.Annotations). + SetServicePeriodFrom(in.IntentMutableFields.ServicePeriod.From.UTC()). + SetServicePeriodTo(in.IntentMutableFields.ServicePeriod.To.UTC()). + SetBillingPeriodFrom(in.IntentMutableFields.BillingPeriod.From.UTC()). + SetBillingPeriodTo(in.IntentMutableFields.BillingPeriod.To.UTC()). + SetFullServicePeriodFrom(in.IntentMutableFields.FullServicePeriod.From.UTC()). + SetFullServicePeriodTo(in.IntentMutableFields.FullServicePeriod.To.UTC()). + SetStatus(in.Status). + SetOrClearAdvanceAfter(in.AdvanceAfter), nil +} + +type Getter[T any] interface { + entutils.TimeMixinGetter + entutils.NamespaceMixinGetter + entutils.IDMixinGetter + entutils.AnnotationsMixinGetter + + GetStatus() meta.ChargeStatus + GetName() string + GetDescription() *string + GetMetadata() map[string]string + GetAnnotations() models.Annotations + GetManagedBy() billing.InvoiceLineManagedBy + GetCustomerID() string + GetCurrency() currencyx.Code + GetServicePeriodFrom() time.Time + GetServicePeriodTo() time.Time + GetAdvanceAfter() *time.Time + GetFullServicePeriodFrom() time.Time + GetFullServicePeriodTo() time.Time + GetBillingPeriodFrom() time.Time + GetBillingPeriodTo() time.Time + GetUniqueReferenceID() *string + GetSubscriptionID() *string + GetSubscriptionPhaseID() *string + GetSubscriptionItemID() *string + GetTaxCodeID() string + GetTaxBehavior() *productcatalog.TaxBehavior +} + +func MapFromDB[T Getter[T]](entity T) meta.Charge { + var subscriptionReference *meta.SubscriptionReference + if entity.GetSubscriptionID() != nil && entity.GetSubscriptionPhaseID() != nil && entity.GetSubscriptionItemID() != nil { + subscriptionReference = &meta.SubscriptionReference{ + SubscriptionID: *entity.GetSubscriptionID(), + PhaseID: *entity.GetSubscriptionPhaseID(), + ItemID: *entity.GetSubscriptionItemID(), + } + } + + return meta.Charge{ + ManagedResource: meta.ManagedResource{ + NamespacedModel: models.NamespacedModel{ + Namespace: entity.GetNamespace(), + }, + ManagedModel: entutils.MapTimeMixinFromDB(entity), + ID: entity.GetID(), + }, + Intent: meta.Intent{ + ManagedBy: entity.GetManagedBy(), + CustomerID: entity.GetCustomerID(), + Annotations: entity.GetAnnotations(), + Currency: entity.GetCurrency(), + TaxConfig: productcatalog.TaxCodeConfig{ + TaxCodeID: entity.GetTaxCodeID(), + Behavior: entity.GetTaxBehavior(), + }, + UniqueReferenceID: entity.GetUniqueReferenceID(), + Subscription: subscriptionReference, + }, + IntentMutableFields: meta.IntentMutableFields{ + Name: entity.GetName(), + Description: entity.GetDescription(), + Metadata: entity.GetMetadata(), + ServicePeriod: timeutil.ClosedPeriod{ + From: entity.GetServicePeriodFrom().UTC(), + To: entity.GetServicePeriodTo().UTC(), + }, + FullServicePeriod: timeutil.ClosedPeriod{ + From: entity.GetFullServicePeriodFrom().UTC(), + To: entity.GetFullServicePeriodTo().UTC(), + }, + BillingPeriod: timeutil.ClosedPeriod{ + From: entity.GetBillingPeriodFrom().UTC(), + To: entity.GetBillingPeriodTo().UTC(), + }, + }, + Status: entity.GetStatus(), + AdvanceAfter: entity.GetAdvanceAfter(), + } +} diff --git a/billing/charges/models/creditrealization/allocation.go b/billing/charges/models/creditrealization/allocation.go new file mode 100644 index 0000000000000000000000000000000000000000..1feca4b3266f5ec0dbc78730a19f684552103e11 --- /dev/null +++ b/billing/charges/models/creditrealization/allocation.go @@ -0,0 +1,87 @@ +package creditrealization + +import ( + "errors" + "fmt" + + "github.com/alpacahq/alpacadecimal" + "github.com/samber/lo" + + "github.com/openmeterio/openmeter/openmeter/billing/charges/models/ledgertransaction" + "github.com/openmeterio/openmeter/pkg/models" + "github.com/openmeterio/openmeter/pkg/timeutil" +) + +type CreateAllocationInput struct { + // ID is the ID of the credit realization, if empty a new ID will be generated. + ID string `json:"id"` + Annotations models.Annotations `json:"annotations"` + ServicePeriod timeutil.ClosedPeriod `json:"servicePeriod"` + + LedgerTransaction ledgertransaction.GroupReference `json:"ledgerTransaction"` + + Amount alpacadecimal.Decimal `json:"amount"` + + // LineID is the standard invoice line ID that the credit was allocated to. + // If nil, the credit is not allocated to any invoice line (e.g. line is still in gathering, + // credit_only mode without invoicing, etc.) + LineID *string `json:"lineID"` +} + +func (i CreateAllocationInput) Validate() error { + var errs []error + + if err := i.ServicePeriod.Validate(); err != nil { + errs = append(errs, fmt.Errorf("service period: %w", err)) + } + + if !i.Amount.IsPositive() { + errs = append(errs, fmt.Errorf("amount must be positive")) + } + + if err := i.LedgerTransaction.Validate(); err != nil { + errs = append(errs, fmt.Errorf("ledger transaction: %w", err)) + } + + if i.LineID != nil && *i.LineID == "" { + errs = append(errs, fmt.Errorf("line ID must be non-empty")) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +type CreateAllocationInputs []CreateAllocationInput + +func (i CreateAllocationInputs) Validate() error { + var errs []error + + for idx, input := range i { + if err := input.Validate(); err != nil { + errs = append(errs, fmt.Errorf("create allocation input[%d]: %w", idx, err)) + } + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +func (i CreateAllocationInputs) AsCreateInputs() CreateInputs { + return lo.Map(i, func(input CreateAllocationInput, _ int) CreateInput { + return CreateInput{ + ID: input.ID, + Annotations: input.Annotations, + ServicePeriod: input.ServicePeriod, + LedgerTransaction: input.LedgerTransaction, + Amount: input.Amount, + Type: TypeAllocation, + LineID: input.LineID, + } + }) +} + +func (i CreateAllocationInputs) Sum() alpacadecimal.Decimal { + sum := alpacadecimal.Zero + for _, input := range i { + sum = sum.Add(input.Amount) + } + return sum +} diff --git a/billing/charges/models/creditrealization/correction.go b/billing/charges/models/creditrealization/correction.go new file mode 100644 index 0000000000000000000000000000000000000000..14d0e031b901926cfe50d32d656a20796bb233e9 --- /dev/null +++ b/billing/charges/models/creditrealization/correction.go @@ -0,0 +1,197 @@ +package creditrealization + +import ( + "errors" + "fmt" + + "github.com/alpacahq/alpacadecimal" + "github.com/samber/lo" + + "github.com/openmeterio/openmeter/openmeter/billing/charges/models/ledgertransaction" + "github.com/openmeterio/openmeter/pkg/currencyx" + "github.com/openmeterio/openmeter/pkg/models" + "github.com/openmeterio/openmeter/pkg/slicesx" +) + +type CorrectionRequest []CorrectionRequestItem + +func (c CorrectionRequest) ValidateWith(currency currencyx.Currency) error { + var errs []error + + if currency == nil { + errs = append(errs, errors.New("currency is required")) + } + + if currency != nil { + for idx, item := range c { + if err := item.ValidateWith(currency); err != nil { + errs = append(errs, fmt.Errorf("correction request item[%d]: %w", idx, err)) + } + } + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +type CorrectionRequestItem struct { + Allocation Realization `json:"allocation"` + // Amount is the amount of the correction request. + // It is non-positive and rounded to the smallest denomination. + Amount alpacadecimal.Decimal `json:"amount"` +} + +func (i CorrectionRequestItem) ValidateWith(currency currencyx.Currency) error { + var errs []error + + if err := i.Allocation.Validate(); err != nil { + errs = append(errs, fmt.Errorf("allocation: %w", err)) + } + + if i.Amount.IsPositive() { + errs = append(errs, fmt.Errorf("amount must not be positive")) + } + + if currency == nil { + errs = append(errs, errors.New("currency is required")) + } + + if currency != nil && !currency.IsRoundedToPrecision(i.Amount) { + errs = append(errs, fmt.Errorf("amount must be a multiple of the smallest denomination")) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +func (i CorrectionRequestItem) NormalizeWith(currency currencyx.Currency) CorrectionRequestItem { + i.Amount = currency.RoundToPrecision(i.Amount) + return i +} + +type CreateCorrectionInput struct { + // ID is the ID of the correction, if empty a new ID will be generated. + ID string `json:"id"` + Annotations models.Annotations `json:"annotations"` + + LedgerTransaction ledgertransaction.GroupReference `json:"ledgerTransaction"` + + // Amount is the amount of the correction. + // Expectations: + // - It must be non-positive + // - It must be rounded to the smallest denomination + Amount alpacadecimal.Decimal `json:"amount"` + + // CorrectsRealizationID is the ID of the realization that this correction is correcting. + CorrectsRealizationID string `json:"correctsRealizationID"` +} + +func (i CreateCorrectionInput) NormalizeWith(currency currencyx.Currency) CreateCorrectionInput { + i.Amount = currency.RoundToPrecision(i.Amount) + return i +} + +func (i CreateCorrectionInput) ValidateWith(currency currencyx.Currency) error { + var errs []error + + if i.Amount.IsPositive() { + errs = append(errs, fmt.Errorf("amount must not be positive")) + } + + if !currency.IsRoundedToPrecision(i.Amount) { + errs = append(errs, fmt.Errorf("amount must be rounded to currency precision")) + } + + if i.CorrectsRealizationID == "" { + errs = append(errs, fmt.Errorf("corrects realization id is required")) + } + + if err := i.LedgerTransaction.Validate(); err != nil { + errs = append(errs, fmt.Errorf("ledger transaction: %w", err)) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +type CreateCorrectionInputs []CreateCorrectionInput + +func (i CreateCorrectionInputs) NormalizeWith(currency currencyx.Currency) CreateCorrectionInputs { + return lo.Map(i, func(input CreateCorrectionInput, _ int) CreateCorrectionInput { + return input.NormalizeWith(currency) + }) +} + +func (i CreateCorrectionInputs) ValidateWith(existingRealizations Realizations, totalAmountToCorrect alpacadecimal.Decimal, currency currencyx.Currency) error { + var errs []error + + if totalAmountToCorrect.IsNegative() { + errs = append(errs, fmt.Errorf("total amount to correct must not be negative")) + } + + if !currency.IsRoundedToPrecision(totalAmountToCorrect) { + errs = append(errs, fmt.Errorf("total amount to correct must be rounded to currency precision")) + } + + for idx, input := range i { + if err := input.ValidateWith(currency); err != nil { + errs = append(errs, fmt.Errorf("correction input[%d]: %w", idx, err)) + } + } + + realizationsWithRemainingAmount, err := existingRealizations.allocationsWithCorrections() + if err != nil { + errs = append(errs, fmt.Errorf("getting allocations with remaining amount: %w", err)) + return models.NewNillableGenericValidationError(errors.Join(errs...)) + } + + realizationsWithRemainingAmountByID := lo.KeyBy(realizationsWithRemainingAmount, func(allocationWithCorrections allocationWithCorrections) string { + return allocationWithCorrections.Allocation.ID + }) + + correctionTotal := alpacadecimal.NewFromFloat(0) + for _, input := range i { + correctionTotal = correctionTotal.Add(input.Amount.Abs()) + } + + if !currency.RoundToPrecision(correctionTotal).Equal(totalAmountToCorrect) { + errs = append(errs, fmt.Errorf("corrections total %s does not match the requested amount %s", correctionTotal, totalAmountToCorrect)) + } + + for idx, input := range i { + correctsRealization, ok := realizationsWithRemainingAmountByID[input.CorrectsRealizationID] + + if !ok { + errs = append(errs, fmt.Errorf("correction input[%d]: corrects realization id %s not found or is not a correction", idx, input.CorrectsRealizationID)) + break // let's stop validating we are depending on a corrupt state already + } + + if input.Amount.Abs().GreaterThan(correctsRealization.RemainingAmount) { + errs = append(errs, fmt.Errorf("correction input[%d]: amount to correct is greater than the remaining amount for allocation %s", idx, input.CorrectsRealizationID)) + break // let's stop validating we are depending on a corrupt state already + } + + correctsRealization.RemainingAmount = correctsRealization.RemainingAmount.Add(input.Amount) + realizationsWithRemainingAmountByID[input.CorrectsRealizationID] = correctsRealization + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +func (i CreateCorrectionInputs) AsCreateInputs(existingRealizations Realizations) ([]CreateInput, error) { + existingAllocations := existingRealizations.AllocationsByID() + + return slicesx.MapWithErr(i, func(input CreateCorrectionInput) (CreateInput, error) { + allocation, ok := existingAllocations[input.CorrectsRealizationID] + if !ok { + return CreateInput{}, fmt.Errorf("allocation %s not found", input.CorrectsRealizationID) + } + + return CreateInput{ + ID: input.ID, + Annotations: input.Annotations, + ServicePeriod: allocation.ServicePeriod, + LedgerTransaction: input.LedgerTransaction, + Amount: input.Amount, + Type: TypeCorrection, + CorrectsRealizationID: lo.ToPtr(input.CorrectsRealizationID), + }, nil + }) +} diff --git a/billing/charges/models/creditrealization/correction_test.go b/billing/charges/models/creditrealization/correction_test.go new file mode 100644 index 0000000000000000000000000000000000000000..c4b530e709b5ac1677727c99e68a20b534f7aeda --- /dev/null +++ b/billing/charges/models/creditrealization/correction_test.go @@ -0,0 +1,1139 @@ +package creditrealization + +import ( + "errors" + "testing" + "time" + + "github.com/alpacahq/alpacadecimal" + "github.com/google/uuid" + "github.com/samber/lo" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/openmeterio/openmeter/openmeter/billing/charges/models/ledgertransaction" + "github.com/openmeterio/openmeter/pkg/currencyx" + "github.com/openmeterio/openmeter/pkg/models" + "github.com/openmeterio/openmeter/pkg/timeutil" +) + +// testCurrency returns a USD calculator for tests. +func testCurrency(t *testing.T) currencyx.Currency { + t.Helper() + + currency, err := currencyx.NewCurrencyBuilder(currencyx.CurrencyTypeFiat). + WithCode(currencyx.Code("USD")). + Build() + require.NoError(t, err) + + return currency +} + +var testServicePeriod = timeutil.ClosedPeriod{ + From: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), + To: time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC), +} + +// allocationBuilder builds Realization entries of type allocation for tests. +type allocationBuilder struct { + createdAt time.Time + sortHint int +} + +func newAllocationBuilder() *allocationBuilder { + return &allocationBuilder{ + createdAt: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), + } +} + +func (b *allocationBuilder) withCreatedAt(t time.Time) *allocationBuilder { + b.createdAt = t + return b +} + +func (b *allocationBuilder) build(amount float64) Realization { + id := uuid.New().String() + b.sortHint++ + + return Realization{ + NamespacedModel: models.NamespacedModel{ + Namespace: "test-ns", + }, + ManagedModel: models.ManagedModel{ + CreatedAt: b.createdAt, + UpdatedAt: b.createdAt, + }, + CreateInput: CreateInput{ + ID: id, + ServicePeriod: testServicePeriod, + Amount: alpacadecimal.NewFromFloat(amount), + LedgerTransaction: ledgertransaction.GroupReference{ + TransactionGroupID: uuid.New().String(), + }, + Type: TypeAllocation, + }, + SortHint: b.sortHint, + } +} + +// correctionFor builds a correction Realization targeting the given allocation. +func correctionFor(allocation Realization, amount float64) Realization { + return Realization{ + NamespacedModel: models.NamespacedModel{ + Namespace: "test-ns", + }, + ManagedModel: models.ManagedModel{ + CreatedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), + }, + CreateInput: CreateInput{ + ID: uuid.New().String(), + ServicePeriod: allocation.ServicePeriod, + Amount: alpacadecimal.NewFromFloat(amount), + LedgerTransaction: ledgertransaction.GroupReference{ + TransactionGroupID: uuid.New().String(), + }, + Type: TypeCorrection, + CorrectsRealizationID: lo.ToPtr(allocation.ID), + }, + SortHint: 0, + } +} + +// correctionInputsSum returns the total amount across all correction inputs. +func correctionInputsSum(inputs CreateCorrectionInputs) alpacadecimal.Decimal { + sum := alpacadecimal.Zero + for _, input := range inputs { + sum = sum.Add(input.Amount.Abs()) + } + return sum +} + +// correctionRequestAmounts extracts the correction amounts as float64 slice for easy assertion. +func correctionRequestAmounts(cr CorrectionRequest) []float64 { + out := make([]float64, len(cr)) + for i, item := range cr { + out[i] = item.Amount.InexactFloat64() + } + return out +} + +// correctionRequestAllocationIDs extracts the allocation IDs from a correction request. +func correctionRequestAllocationIDs(cr CorrectionRequest) []string { + out := make([]string, len(cr)) + for i, item := range cr { + out[i] = item.Allocation.ID + } + return out +} + +func TestCreateCorrectionRequest(t *testing.T) { + t.Run("full revert of single allocation", func(t *testing.T) { + b := newAllocationBuilder() + alloc := b.build(10) + + cr, err := Realizations{alloc}.CreateCorrectionRequest( + alpacadecimal.NewFromFloat(-10), + testCurrency(t), + ) + + require.NoError(t, err) + require.Len(t, cr, 1) + assert.Equal(t, alloc.ID, cr[0].Allocation.ID) + assert.Equal(t, -10.0, cr[0].Amount.InexactFloat64()) + }) + + t.Run("partial revert of single allocation", func(t *testing.T) { + b := newAllocationBuilder() + alloc := b.build(10) + + cr, err := Realizations{alloc}.CreateCorrectionRequest( + alpacadecimal.NewFromFloat(-3), + testCurrency(t), + ) + + require.NoError(t, err) + require.Len(t, cr, 1) + assert.Equal(t, -3.0, cr[0].Amount.InexactFloat64()) + }) + + t.Run("full revert spanning multiple allocations in reverse order", func(t *testing.T) { + b := newAllocationBuilder() + a1 := b.build(5) + a2 := b.build(3) + a3 := b.build(2) + + cr, err := Realizations{a1, a2, a3}.CreateCorrectionRequest( + alpacadecimal.NewFromFloat(-10), + testCurrency(t), + ) + + require.NoError(t, err) + require.Len(t, cr, 3) + // Reverse order: a3, a2, a1 + assert.Equal(t, []string{a3.ID, a2.ID, a1.ID}, correctionRequestAllocationIDs(cr)) + assert.Equal(t, []float64{-2, -3, -5}, correctionRequestAmounts(cr)) + }) + + t.Run("partial revert spanning multiple allocations", func(t *testing.T) { + b := newAllocationBuilder() + a1 := b.build(5) + a2 := b.build(3) + a3 := b.build(2) + + cr, err := Realizations{a1, a2, a3}.CreateCorrectionRequest( + alpacadecimal.NewFromFloat(-7), + testCurrency(t), + ) + + require.NoError(t, err) + require.Len(t, cr, 3) + // Reverse: a3 fully ($2), a2 fully ($3), a1 partially ($2) + assert.Equal(t, []string{a3.ID, a2.ID, a1.ID}, correctionRequestAllocationIDs(cr)) + assert.Equal(t, []float64{-2, -3, -2}, correctionRequestAmounts(cr)) + }) + + t.Run("revert with already-corrected allocation uses remaining", func(t *testing.T) { + b := newAllocationBuilder() + alloc := b.build(10) + correction := correctionFor(alloc, -4) + + cr, err := Realizations{alloc, correction}.CreateCorrectionRequest( + alpacadecimal.NewFromFloat(-6), + testCurrency(t), + ) + + require.NoError(t, err) + require.Len(t, cr, 1) + assert.Equal(t, alloc.ID, cr[0].Allocation.ID) + assert.Equal(t, -6.0, cr[0].Amount.InexactFloat64()) + }) + + t.Run("skips fully corrected allocations", func(t *testing.T) { + b := newAllocationBuilder() + a1 := b.build(5) + c1 := correctionFor(a1, -5) // fully corrected + a2 := b.build(5) + + cr, err := Realizations{a1, a2, c1}.CreateCorrectionRequest( + alpacadecimal.NewFromFloat(-5), + testCurrency(t), + ) + + require.NoError(t, err) + require.Len(t, cr, 1) + assert.Equal(t, a2.ID, cr[0].Allocation.ID) + assert.Equal(t, -5.0, cr[0].Amount.InexactFloat64()) + }) + + t.Run("reverse order by CreatedAt", func(t *testing.T) { + t1 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + t2 := time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC) + t3 := time.Date(2024, 1, 3, 0, 0, 0, 0, time.UTC) + + b1 := newAllocationBuilder().withCreatedAt(t1) + b2 := newAllocationBuilder().withCreatedAt(t2) + b3 := newAllocationBuilder().withCreatedAt(t3) + + a1 := b1.build(5) + a2 := b2.build(5) + a3 := b3.build(5) + + cr, err := Realizations{a1, a2, a3}.CreateCorrectionRequest( + alpacadecimal.NewFromFloat(-15), + testCurrency(t), + ) + + require.NoError(t, err) + require.Len(t, cr, 3) + assert.Equal(t, []string{a3.ID, a2.ID, a1.ID}, correctionRequestAllocationIDs(cr)) + }) + + t.Run("reverse order by SortHint within same CreatedAt", func(t *testing.T) { + b := newAllocationBuilder() + a1 := b.build(5) // sortHint=1 + a2 := b.build(5) // sortHint=2 + a3 := b.build(5) // sortHint=3 + + cr, err := Realizations{a1, a2, a3}.CreateCorrectionRequest( + alpacadecimal.NewFromFloat(-15), + testCurrency(t), + ) + + require.NoError(t, err) + require.Len(t, cr, 3) + assert.Equal(t, []string{a3.ID, a2.ID, a1.ID}, correctionRequestAllocationIDs(cr)) + }) + + t.Run("zero amount returns no correction request", func(t *testing.T) { + b := newAllocationBuilder() + alloc := b.build(10) + + cr, err := Realizations{alloc}.CreateCorrectionRequest( + alpacadecimal.Zero, + testCurrency(t), + ) + + require.NoError(t, err) + assert.Nil(t, cr) + }) + + t.Run("error: positive amount", func(t *testing.T) { + b := newAllocationBuilder() + alloc := b.build(10) + + _, err := Realizations{alloc}.CreateCorrectionRequest( + alpacadecimal.NewFromFloat(5), + testCurrency(t), + ) + + require.Error(t, err) + assert.Contains(t, err.Error(), "amount must not be positive") + }) + + t.Run("error: insufficient funds", func(t *testing.T) { + b := newAllocationBuilder() + alloc := b.build(5) + + _, err := Realizations{alloc}.CreateCorrectionRequest( + alpacadecimal.NewFromFloat(-10), + testCurrency(t), + ) + + require.ErrorIs(t, err, ErrInsufficientFunds) + }) + + t.Run("error: insufficient after existing corrections", func(t *testing.T) { + b := newAllocationBuilder() + alloc := b.build(10) + correction := correctionFor(alloc, -8) + + _, err := Realizations{alloc, correction}.CreateCorrectionRequest( + alpacadecimal.NewFromFloat(-5), + testCurrency(t), + ) + + require.ErrorIs(t, err, ErrInsufficientFunds) + }) + + t.Run("amount is rounded to currency precision before planning", func(t *testing.T) { + b := newAllocationBuilder() + alloc := b.build(10) + + cr, err := Realizations{alloc}.CreateCorrectionRequest( + alpacadecimal.NewFromFloat(-1.005), + testCurrency(t), + ) + + require.NoError(t, err) + require.Len(t, cr, 1) + assert.Equal(t, -1.01, cr[0].Amount.InexactFloat64()) + }) + + t.Run("tiny negative that rounds to zero is a no-op", func(t *testing.T) { + b := newAllocationBuilder() + alloc := b.build(10) + + cr, err := Realizations{alloc}.CreateCorrectionRequest( + alpacadecimal.NewFromFloat(-0.004), + testCurrency(t), + ) + + require.NoError(t, err) + assert.Len(t, cr, 0) + }) + + t.Run("error: all allocations fully corrected", func(t *testing.T) { + b := newAllocationBuilder() + a1 := b.build(5) + c1 := correctionFor(a1, -5) + a2 := b.build(3) + c2 := correctionFor(a2, -3) + + _, err := Realizations{a1, a2, c1, c2}.CreateCorrectionRequest( + alpacadecimal.NewFromFloat(-1), + testCurrency(t), + ) + + require.ErrorIs(t, err, ErrInsufficientFunds) + }) + + t.Run("smallest denomination: $0.01 correction", func(t *testing.T) { + b := newAllocationBuilder() + alloc := b.build(0.01) + + cr, err := Realizations{alloc}.CreateCorrectionRequest( + alpacadecimal.NewFromFloat(-0.01), + testCurrency(t), + ) + + require.NoError(t, err) + require.Len(t, cr, 1) + assert.Equal(t, -0.01, cr[0].Amount.InexactFloat64()) + }) + + t.Run("many allocations, tiny correction touches only last", func(t *testing.T) { + b := newAllocationBuilder() + a1 := b.build(100) + a2 := b.build(100) + a3 := b.build(100) + + cr, err := Realizations{a1, a2, a3}.CreateCorrectionRequest( + alpacadecimal.NewFromFloat(-0.01), + testCurrency(t), + ) + + require.NoError(t, err) + require.Len(t, cr, 1) + assert.Equal(t, a3.ID, cr[0].Allocation.ID) + assert.Equal(t, -0.01, cr[0].Amount.InexactFloat64()) + }) + + t.Run("exact boundary: request equals remaining after corrections", func(t *testing.T) { + b := newAllocationBuilder() + alloc := b.build(10) + correction := correctionFor(alloc, -7) + + cr, err := Realizations{alloc, correction}.CreateCorrectionRequest( + alpacadecimal.NewFromFloat(-3), + testCurrency(t), + ) + + require.NoError(t, err) + require.Len(t, cr, 1) + assert.Equal(t, -3.0, cr[0].Amount.InexactFloat64()) + }) +} + +func TestCreateCorrectionInputsValidateWith(t *testing.T) { + t.Run("valid single correction", func(t *testing.T) { + b := newAllocationBuilder() + alloc := b.build(10) + currency := testCurrency(t) + + inputs := CreateCorrectionInputs{ + { + Amount: alpacadecimal.NewFromFloat(-3), + CorrectsRealizationID: alloc.ID, + LedgerTransaction: ledgertransaction.GroupReference{ + TransactionGroupID: uuid.New().String(), + }, + }, + } + + err := inputs.ValidateWith(Realizations{alloc}, correctionInputsSum(inputs), currency) + require.NoError(t, err) + }) + + t.Run("valid multiple corrections same allocation", func(t *testing.T) { + b := newAllocationBuilder() + alloc := b.build(10) + currency := testCurrency(t) + + inputs := CreateCorrectionInputs{ + { + Amount: alpacadecimal.NewFromFloat(-3), + CorrectsRealizationID: alloc.ID, + LedgerTransaction: ledgertransaction.GroupReference{ + TransactionGroupID: uuid.New().String(), + }, + }, + { + Amount: alpacadecimal.NewFromFloat(-4), + CorrectsRealizationID: alloc.ID, + LedgerTransaction: ledgertransaction.GroupReference{ + TransactionGroupID: uuid.New().String(), + }, + }, + } + + err := inputs.ValidateWith(Realizations{alloc}, correctionInputsSum(inputs), currency) + require.NoError(t, err) + }) + + t.Run("valid corrections drain allocation exactly", func(t *testing.T) { + b := newAllocationBuilder() + alloc := b.build(10) + currency := testCurrency(t) + + inputs := CreateCorrectionInputs{ + { + Amount: alpacadecimal.NewFromFloat(-5), + CorrectsRealizationID: alloc.ID, + LedgerTransaction: ledgertransaction.GroupReference{ + TransactionGroupID: uuid.New().String(), + }, + }, + { + Amount: alpacadecimal.NewFromFloat(-5), + CorrectsRealizationID: alloc.ID, + LedgerTransaction: ledgertransaction.GroupReference{ + TransactionGroupID: uuid.New().String(), + }, + }, + } + + err := inputs.ValidateWith(Realizations{alloc}, correctionInputsSum(inputs), currency) + require.NoError(t, err) + }) + + t.Run("valid corrections across different allocations", func(t *testing.T) { + b := newAllocationBuilder() + a1 := b.build(5) + a2 := b.build(5) + currency := testCurrency(t) + + inputs := CreateCorrectionInputs{ + { + Amount: alpacadecimal.NewFromFloat(-3), + CorrectsRealizationID: a1.ID, + LedgerTransaction: ledgertransaction.GroupReference{ + TransactionGroupID: uuid.New().String(), + }, + }, + { + Amount: alpacadecimal.NewFromFloat(-4), + CorrectsRealizationID: a2.ID, + LedgerTransaction: ledgertransaction.GroupReference{ + TransactionGroupID: uuid.New().String(), + }, + }, + } + + err := inputs.ValidateWith(Realizations{a1, a2}, correctionInputsSum(inputs), currency) + require.NoError(t, err) + }) + + t.Run("error: correction exceeds remaining", func(t *testing.T) { + b := newAllocationBuilder() + alloc := b.build(10) + existingCorrection := correctionFor(alloc, -8) + currency := testCurrency(t) + + inputs := CreateCorrectionInputs{ + { + Amount: alpacadecimal.NewFromFloat(-5), + CorrectsRealizationID: alloc.ID, + LedgerTransaction: ledgertransaction.GroupReference{ + TransactionGroupID: uuid.New().String(), + }, + }, + } + + err := inputs.ValidateWith(Realizations{alloc, existingCorrection}, correctionInputsSum(inputs), currency) + require.Error(t, err) + assert.Contains(t, err.Error(), "greater than the remaining amount") + }) + + t.Run("error: correction for nonexistent allocation", func(t *testing.T) { + b := newAllocationBuilder() + alloc := b.build(10) + currency := testCurrency(t) + + inputs := CreateCorrectionInputs{ + { + Amount: alpacadecimal.NewFromFloat(-5), + CorrectsRealizationID: uuid.New().String(), // doesn't exist + LedgerTransaction: ledgertransaction.GroupReference{ + TransactionGroupID: uuid.New().String(), + }, + }, + } + + err := inputs.ValidateWith(Realizations{alloc}, correctionInputsSum(inputs), currency) + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") + }) + + t.Run("error: empty corrects realization ID", func(t *testing.T) { + currency := testCurrency(t) + + inputs := CreateCorrectionInputs{ + { + Amount: alpacadecimal.NewFromFloat(-5), + CorrectsRealizationID: "", + LedgerTransaction: ledgertransaction.GroupReference{ + TransactionGroupID: uuid.New().String(), + }, + }, + } + + err := inputs.ValidateWith(Realizations{}, correctionInputsSum(inputs), currency) + require.Error(t, err) + assert.Contains(t, err.Error(), "corrects realization id is required") + }) + + t.Run("error: amount not rounded", func(t *testing.T) { + b := newAllocationBuilder() + alloc := b.build(10) + currency := testCurrency(t) + + inputs := CreateCorrectionInputs{ + { + Amount: alpacadecimal.NewFromFloat(-1.001), + CorrectsRealizationID: alloc.ID, + LedgerTransaction: ledgertransaction.GroupReference{ + TransactionGroupID: uuid.New().String(), + }, + }, + } + + err := inputs.ValidateWith(Realizations{alloc}, correctionInputsSum(inputs), currency) + require.Error(t, err) + assert.Contains(t, err.Error(), "rounded to currency precision") + }) + + t.Run("error: second correction tips allocation negative", func(t *testing.T) { + b := newAllocationBuilder() + alloc := b.build(10) + currency := testCurrency(t) + + inputs := CreateCorrectionInputs{ + { + Amount: alpacadecimal.NewFromFloat(-6), + CorrectsRealizationID: alloc.ID, + LedgerTransaction: ledgertransaction.GroupReference{ + TransactionGroupID: uuid.New().String(), + }, + }, + { + Amount: alpacadecimal.NewFromFloat(-6), + CorrectsRealizationID: alloc.ID, + LedgerTransaction: ledgertransaction.GroupReference{ + TransactionGroupID: uuid.New().String(), + }, + }, + } + + err := inputs.ValidateWith(Realizations{alloc}, correctionInputsSum(inputs), currency) + require.Error(t, err) + assert.Contains(t, err.Error(), "greater than the remaining amount") + }) + + t.Run("error: correction targets a correction, not an allocation", func(t *testing.T) { + b := newAllocationBuilder() + alloc := b.build(10) + correction := correctionFor(alloc, -3) + currency := testCurrency(t) + + inputs := CreateCorrectionInputs{ + { + Amount: alpacadecimal.NewFromFloat(-2), + CorrectsRealizationID: correction.ID, // points to a correction, not allocation + LedgerTransaction: ledgertransaction.GroupReference{ + TransactionGroupID: uuid.New().String(), + }, + }, + } + + err := inputs.ValidateWith(Realizations{alloc, correction}, correctionInputsSum(inputs), currency) + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") + }) + + t.Run("zero total amount to correct is allowed when corrections are zero", func(t *testing.T) { + b := newAllocationBuilder() + alloc := b.build(10) + currency := testCurrency(t) + + inputs := CreateCorrectionInputs{ + { + Amount: alpacadecimal.Zero, + CorrectsRealizationID: alloc.ID, + LedgerTransaction: ledgertransaction.GroupReference{ + TransactionGroupID: uuid.New().String(), + }, + }, + } + + err := inputs.ValidateWith(Realizations{alloc}, alpacadecimal.Zero, currency) + require.NoError(t, err) + }) + + t.Run("error: total amount to correct not rounded", func(t *testing.T) { + b := newAllocationBuilder() + alloc := b.build(10) + currency := testCurrency(t) + + inputs := CreateCorrectionInputs{ + { + Amount: alpacadecimal.NewFromFloat(-3), + CorrectsRealizationID: alloc.ID, + LedgerTransaction: ledgertransaction.GroupReference{ + TransactionGroupID: uuid.New().String(), + }, + }, + } + + err := inputs.ValidateWith(Realizations{alloc}, alpacadecimal.NewFromFloat(3.001), currency) + require.Error(t, err) + assert.Contains(t, err.Error(), "total amount to correct must be rounded to currency precision") + }) +} + +func TestCreateCorrectionInputsAsCreateInputs(t *testing.T) { + t.Run("maps fields correctly", func(t *testing.T) { + b := newAllocationBuilder() + alloc := b.build(10) + txGroupID := uuid.New().String() + annotations := models.Annotations{"key": "value"} + + inputs := CreateCorrectionInputs{ + { + ID: uuid.New().String(), + Annotations: annotations, + Amount: alpacadecimal.NewFromFloat(-3), + LedgerTransaction: ledgertransaction.GroupReference{ + TransactionGroupID: txGroupID, + }, + CorrectsRealizationID: alloc.ID, + }, + } + + result, err := inputs.AsCreateInputs(Realizations{alloc}) + require.NoError(t, err) + require.Len(t, result, 1) + + out := result[0] + assert.Equal(t, inputs[0].ID, out.ID) + assert.Equal(t, annotations, out.Annotations) + assert.Equal(t, TypeCorrection, out.Type) + assert.Equal(t, lo.ToPtr(alloc.ID), out.CorrectsRealizationID) + assert.Equal(t, alloc.ServicePeriod, out.ServicePeriod) + assert.Equal(t, txGroupID, out.LedgerTransaction.TransactionGroupID) + assert.Equal(t, -3.0, out.Amount.InexactFloat64()) + assert.Nil(t, out.LineID) + }) + + t.Run("empty ID is preserved", func(t *testing.T) { + b := newAllocationBuilder() + alloc := b.build(10) + + inputs := CreateCorrectionInputs{ + { + Amount: alpacadecimal.NewFromFloat(-3), + LedgerTransaction: ledgertransaction.GroupReference{ + TransactionGroupID: uuid.New().String(), + }, + CorrectsRealizationID: alloc.ID, + }, + } + + result, err := inputs.AsCreateInputs(Realizations{alloc}) + require.NoError(t, err) + assert.Empty(t, result[0].ID) + }) + + t.Run("multiple corrections map to correct allocations", func(t *testing.T) { + b := newAllocationBuilder() + a1 := b.build(5) + a2 := b.build(7) + + inputs := CreateCorrectionInputs{ + { + Amount: alpacadecimal.NewFromFloat(-2), + LedgerTransaction: ledgertransaction.GroupReference{ + TransactionGroupID: uuid.New().String(), + }, + CorrectsRealizationID: a1.ID, + }, + { + Amount: alpacadecimal.NewFromFloat(-4), + LedgerTransaction: ledgertransaction.GroupReference{ + TransactionGroupID: uuid.New().String(), + }, + CorrectsRealizationID: a2.ID, + }, + } + + result, err := inputs.AsCreateInputs(Realizations{a1, a2}) + require.NoError(t, err) + require.Len(t, result, 2) + + assert.Equal(t, a1.ServicePeriod, result[0].ServicePeriod) + assert.Equal(t, a2.ServicePeriod, result[1].ServicePeriod) + }) + + t.Run("error: unknown allocation ID", func(t *testing.T) { + b := newAllocationBuilder() + alloc := b.build(10) + + inputs := CreateCorrectionInputs{ + { + Amount: alpacadecimal.NewFromFloat(-3), + LedgerTransaction: ledgertransaction.GroupReference{ + TransactionGroupID: uuid.New().String(), + }, + CorrectsRealizationID: uuid.New().String(), + }, + } + + _, err := inputs.AsCreateInputs(Realizations{alloc}) + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") + }) +} + +func TestCorrectionEndToEnd(t *testing.T) { + t.Run("partial revert flow", func(t *testing.T) { + b := newAllocationBuilder() + a1 := b.build(5) + a2 := b.build(3) + a3 := b.build(2) + currency := testCurrency(t) + realizations := Realizations{a1, a2, a3} + + // Step 1: create correction request + cr, err := realizations.CreateCorrectionRequest( + alpacadecimal.NewFromFloat(-4), + currency, + ) + require.NoError(t, err) + + // Build correction inputs from the request + correctionInputs := make(CreateCorrectionInputs, len(cr)) + txGroupID := uuid.New().String() + for i, item := range cr { + correctionInputs[i] = CreateCorrectionInput{ + Amount: item.Amount, + CorrectsRealizationID: item.Allocation.ID, + LedgerTransaction: ledgertransaction.GroupReference{ + TransactionGroupID: txGroupID, + }, + } + } + + // Step 2: validate + err = correctionInputs.ValidateWith(realizations, alpacadecimal.NewFromFloat(4), currency) + require.NoError(t, err) + + // Step 3: convert to adapter inputs + adapterInputs, err := correctionInputs.AsCreateInputs(realizations) + require.NoError(t, err) + + // All adapter inputs should be valid + for _, input := range adapterInputs { + assert.Equal(t, TypeCorrection, input.Type) + assert.NotNil(t, input.CorrectsRealizationID) + } + + err = CreateInputs(adapterInputs).Validate() + require.NoError(t, err) + }) + + t.Run("full revert flow", func(t *testing.T) { + b := newAllocationBuilder() + a1 := b.build(5) + a2 := b.build(3) + currency := testCurrency(t) + realizations := Realizations{a1, a2} + + cr, err := realizations.CreateCorrectionRequest( + alpacadecimal.NewFromFloat(-8), + currency, + ) + require.NoError(t, err) + + correctionInputs := make(CreateCorrectionInputs, len(cr)) + txGroupID := uuid.New().String() + for i, item := range cr { + correctionInputs[i] = CreateCorrectionInput{ + Amount: item.Amount, + CorrectsRealizationID: item.Allocation.ID, + LedgerTransaction: ledgertransaction.GroupReference{ + TransactionGroupID: txGroupID, + }, + } + } + + err = correctionInputs.ValidateWith(realizations, alpacadecimal.NewFromFloat(8), currency) + require.NoError(t, err) + + adapterInputs, err := correctionInputs.AsCreateInputs(realizations) + require.NoError(t, err) + + // Sum of corrections equals total allocated + sum := alpacadecimal.Zero + for _, input := range adapterInputs { + sum = sum.Add(input.Amount) + } + assert.Equal(t, -8.0, sum.InexactFloat64()) + }) + + t.Run("revert with prior corrections", func(t *testing.T) { + b := newAllocationBuilder() + a1 := b.build(10) + a2 := b.build(5) + c1 := correctionFor(a1, -4) // a1 has $6 remaining + currency := testCurrency(t) + realizations := Realizations{a1, a2, c1} + + // Request -8: should take -5 from a2, -3 from a1. + cr, err := realizations.CreateCorrectionRequest( + alpacadecimal.NewFromFloat(-8), + currency, + ) + require.NoError(t, err) + + correctionInputs := make(CreateCorrectionInputs, len(cr)) + txGroupID := uuid.New().String() + for i, item := range cr { + correctionInputs[i] = CreateCorrectionInput{ + Amount: item.Amount, + CorrectsRealizationID: item.Allocation.ID, + LedgerTransaction: ledgertransaction.GroupReference{ + TransactionGroupID: txGroupID, + }, + } + } + + err = correctionInputs.ValidateWith(realizations, alpacadecimal.NewFromFloat(8), currency) + require.NoError(t, err) + + adapterInputs, err := correctionInputs.AsCreateInputs(realizations) + require.NoError(t, err) + require.Len(t, adapterInputs, 2) + + err = CreateInputs(adapterInputs).Validate() + require.NoError(t, err) + }) + + t.Run("sequential partial reverts", func(t *testing.T) { + b := newAllocationBuilder() + alloc := b.build(10) + currency := testCurrency(t) + realizations := Realizations{alloc} + + // First correction: -3. + cr1, err := realizations.CreateCorrectionRequest( + alpacadecimal.NewFromFloat(-3), + currency, + ) + require.NoError(t, err) + + // Simulate the first correction being applied + firstCorrection := correctionFor(alloc, -3) + realizations = append(realizations, firstCorrection) + + // Second correction: -4 (from 7 remaining). + cr2, err := realizations.CreateCorrectionRequest( + alpacadecimal.NewFromFloat(-4), + currency, + ) + require.NoError(t, err) + + assert.Equal(t, -3.0, cr1[0].Amount.InexactFloat64()) + assert.Equal(t, -4.0, cr2[0].Amount.InexactFloat64()) + }) +} + +// correctionCallback returns a callback for Correct() that maps the correction request items +// into CreateCorrectionInputs using a shared ledger transaction group ID. +func correctionCallback(txGroupID string) func(req CorrectionRequest) (CreateCorrectionInputs, error) { + return func(req CorrectionRequest) (CreateCorrectionInputs, error) { + out := make(CreateCorrectionInputs, len(req)) + for i, item := range req { + out[i] = CreateCorrectionInput{ + Amount: item.Amount, + CorrectsRealizationID: item.Allocation.ID, + LedgerTransaction: ledgertransaction.GroupReference{ + TransactionGroupID: txGroupID, + }, + } + } + return out, nil + } +} + +func TestCorrect(t *testing.T) { + t.Run("partial revert", func(t *testing.T) { + b := newAllocationBuilder() + a1 := b.build(5) + a2 := b.build(3) + currency := testCurrency(t) + realizations := Realizations{a1, a2} + + result, err := realizations.Correct( + alpacadecimal.NewFromFloat(-4), + currency, + correctionCallback(uuid.New().String()), + ) + + require.NoError(t, err) + require.NotEmpty(t, result) + + for _, input := range result { + assert.Equal(t, TypeCorrection, input.Type) + assert.NotNil(t, input.CorrectsRealizationID) + assert.True(t, input.Amount.IsNegative(), "correction CreateInput amount should be negative") + } + }) + + t.Run("full revert", func(t *testing.T) { + b := newAllocationBuilder() + a1 := b.build(5) + a2 := b.build(3) + currency := testCurrency(t) + realizations := Realizations{a1, a2} + + result, err := realizations.Correct( + alpacadecimal.NewFromFloat(-8), + currency, + correctionCallback(uuid.New().String()), + ) + + require.NoError(t, err) + require.Len(t, result, 2) + + sum := result.Sum() + assert.Equal(t, -8.0, sum.InexactFloat64()) + }) + + t.Run("with existing corrections", func(t *testing.T) { + b := newAllocationBuilder() + a1 := b.build(10) + c1 := correctionFor(a1, -4) + currency := testCurrency(t) + realizations := Realizations{a1, c1} + + result, err := realizations.Correct( + alpacadecimal.NewFromFloat(-6), + currency, + correctionCallback(uuid.New().String()), + ) + + require.NoError(t, err) + require.Len(t, result, 1) + assert.Equal(t, -6.0, result[0].Amount.InexactFloat64()) + }) + + t.Run("error: insufficient funds propagated", func(t *testing.T) { + b := newAllocationBuilder() + alloc := b.build(5) + currency := testCurrency(t) + realizations := Realizations{alloc} + + _, err := realizations.Correct( + alpacadecimal.NewFromFloat(-10), + currency, + correctionCallback(uuid.New().String()), + ) + + require.ErrorIs(t, err, ErrInsufficientFunds) + }) + + t.Run("zero amount is a no-op", func(t *testing.T) { + b := newAllocationBuilder() + alloc := b.build(10) + currency := testCurrency(t) + realizations := Realizations{alloc} + + out, err := realizations.Correct( + alpacadecimal.Zero, + currency, + correctionCallback(uuid.New().String()), + ) + + require.NoError(t, err) + assert.Nil(t, out) + }) + + t.Run("error: callback error propagated", func(t *testing.T) { + b := newAllocationBuilder() + alloc := b.build(10) + currency := testCurrency(t) + realizations := Realizations{alloc} + + cbErr := errors.New("ledger unavailable") + _, err := realizations.Correct( + alpacadecimal.NewFromFloat(-5), + currency, + func(req CorrectionRequest) (CreateCorrectionInputs, error) { + return nil, cbErr + }, + ) + + require.ErrorIs(t, err, cbErr) + }) + + t.Run("error: callback returns mismatched corrections", func(t *testing.T) { + b := newAllocationBuilder() + alloc := b.build(10) + currency := testCurrency(t) + realizations := Realizations{alloc} + + _, err := realizations.Correct( + alpacadecimal.NewFromFloat(-5), + currency, + func(req CorrectionRequest) (CreateCorrectionInputs, error) { + // Return a correction whose total doesn't match the requested amount + return CreateCorrectionInputs{ + { + Amount: alpacadecimal.NewFromFloat(-4), + CorrectsRealizationID: alloc.ID, + LedgerTransaction: ledgertransaction.GroupReference{ + TransactionGroupID: uuid.New().String(), + }, + }, + }, nil + }, + ) + + require.Error(t, err) + assert.Contains(t, err.Error(), "does not match the requested amount") + }) + + t.Run("error: callback returns correction for unknown allocation", func(t *testing.T) { + b := newAllocationBuilder() + alloc := b.build(10) + currency := testCurrency(t) + realizations := Realizations{alloc} + + _, err := realizations.Correct( + alpacadecimal.NewFromFloat(-5), + currency, + func(req CorrectionRequest) (CreateCorrectionInputs, error) { + return CreateCorrectionInputs{ + { + Amount: alpacadecimal.NewFromFloat(-5), + CorrectsRealizationID: uuid.New().String(), // unknown + LedgerTransaction: ledgertransaction.GroupReference{ + TransactionGroupID: uuid.New().String(), + }, + }, + }, nil + }, + ) + + require.Error(t, err) + }) + + t.Run("callback receives correct correction request items", func(t *testing.T) { + b := newAllocationBuilder() + a1 := b.build(5) + a2 := b.build(3) + a3 := b.build(2) + currency := testCurrency(t) + realizations := Realizations{a1, a2, a3} + + var capturedReq CorrectionRequest + _, err := realizations.Correct( + alpacadecimal.NewFromFloat(-7), + currency, + func(req CorrectionRequest) (CreateCorrectionInputs, error) { + capturedReq = req + return correctionCallback(uuid.New().String())(req) + }, + ) + + require.NoError(t, err) + // Should be in reverse order: a3, a2, a1 partial + require.Len(t, capturedReq, 3) + assert.Equal(t, a3.ID, capturedReq[0].Allocation.ID) + assert.Equal(t, -2.0, capturedReq[0].Amount.InexactFloat64()) + assert.Equal(t, a2.ID, capturedReq[1].Allocation.ID) + assert.Equal(t, -3.0, capturedReq[1].Amount.InexactFloat64()) + assert.Equal(t, a1.ID, capturedReq[2].Allocation.ID) + assert.Equal(t, -2.0, capturedReq[2].Amount.InexactFloat64()) + }) +} diff --git a/billing/charges/models/creditrealization/lineage.go b/billing/charges/models/creditrealization/lineage.go new file mode 100644 index 0000000000000000000000000000000000000000..a8d3f195719cf552f48ab084026f1bce8197c45e --- /dev/null +++ b/billing/charges/models/creditrealization/lineage.go @@ -0,0 +1,98 @@ +package creditrealization + +import ( + "fmt" + "slices" + + "github.com/openmeterio/openmeter/pkg/models" +) + +const AnnotationLineageOriginKind = "billing.credit_realization.lineage_origin_kind" + +type LineageOriginKind string + +const ( + LineageOriginKindRealCredit LineageOriginKind = "real_credit" + LineageOriginKindAdvance LineageOriginKind = "advance" +) + +func (k LineageOriginKind) Values() []string { + return []string{ + string(LineageOriginKindRealCredit), + string(LineageOriginKindAdvance), + } +} + +func (k LineageOriginKind) Validate() error { + if !slices.Contains(k.Values(), string(k)) { + return fmt.Errorf("invalid credit realization lineage origin kind: %s", k) + } + + return nil +} + +func LineageAnnotations(originKind LineageOriginKind) models.Annotations { + return models.Annotations{ + AnnotationLineageOriginKind: string(originKind), + } +} + +func LineageOriginKindFromAnnotations(annotations models.Annotations) (LineageOriginKind, error) { + originKind, ok := annotations.GetString(AnnotationLineageOriginKind) + if !ok { + return "", fmt.Errorf("missing credit realization lineage origin kind annotation") + } + + out := LineageOriginKind(originKind) + if err := out.Validate(); err != nil { + return "", err + } + + return out, nil +} + +type LineageSegmentState string + +const ( + // LineageSegmentStateRealCredit marks value that is still backed by the original + // real-credit source and has not passed through advance/backfill flows. + LineageSegmentStateRealCredit LineageSegmentState = "real_credit" + // LineageSegmentStateAdvanceUncovered marks value that was collected as advance-backed + // usage and is still not covered by a later credit purchase. + LineageSegmentStateAdvanceUncovered LineageSegmentState = "advance_uncovered" + // LineageSegmentStateAdvanceBackfilled marks value that was originally advance-backed + // usage but was later covered by a credit purchase. + LineageSegmentStateAdvanceBackfilled LineageSegmentState = "advance_backfilled" + // LineageSegmentStateEarningsRecognized marks value that has been recognized as earnings + // on the ledger (moved from accrued to earnings). BackingTransactionGroupID points to + // the recognition ledger transaction group. + LineageSegmentStateEarningsRecognized LineageSegmentState = "earnings_recognized" +) + +func (s LineageSegmentState) Values() []string { + return []string{ + string(LineageSegmentStateRealCredit), + string(LineageSegmentStateAdvanceUncovered), + string(LineageSegmentStateAdvanceBackfilled), + string(LineageSegmentStateEarningsRecognized), + } +} + +func (s LineageSegmentState) Validate() error { + if !slices.Contains(s.Values(), string(s)) { + return fmt.Errorf("invalid credit realization lineage segment state: %s", s) + } + + return nil +} + +func InitialLineageSegmentState(originKind LineageOriginKind) LineageSegmentState { + switch originKind { + case LineageOriginKindRealCredit: + return LineageSegmentStateRealCredit + case LineageOriginKindAdvance: + return LineageSegmentStateAdvanceUncovered + default: + return "" + } +} diff --git a/billing/charges/models/creditrealization/lineage_specs.go b/billing/charges/models/creditrealization/lineage_specs.go new file mode 100644 index 0000000000000000000000000000000000000000..6b2a8569a8c8890edfb870f7d968209f64cfea01 --- /dev/null +++ b/billing/charges/models/creditrealization/lineage_specs.go @@ -0,0 +1,47 @@ +package creditrealization + +import ( + "fmt" + + "github.com/alpacahq/alpacadecimal" + "github.com/oklog/ulid/v2" +) + +type InitialLineageSpec struct { + LineageID string + RootRealizationID string + OriginKind LineageOriginKind + InitialState LineageSegmentState + Amount alpacadecimal.Decimal + AdvanceFeatures []string +} + +func InitialLineageSpecs(realizations Realizations) ([]InitialLineageSpec, error) { + out := make([]InitialLineageSpec, 0, len(realizations)) + + for _, realization := range realizations { + if realization.Type != TypeAllocation { + continue + } + + originKind, err := LineageOriginKindFromAnnotations(realization.Annotations) + if err != nil { + continue + } + + initialState := InitialLineageSegmentState(originKind) + if err := initialState.Validate(); err != nil { + return nil, fmt.Errorf("realization %s initial lineage state: %w", realization.ID, err) + } + + out = append(out, InitialLineageSpec{ + LineageID: ulid.Make().String(), + RootRealizationID: realization.ID, + OriginKind: originKind, + InitialState: initialState, + Amount: realization.Amount, + }) + } + + return out, nil +} diff --git a/billing/charges/models/creditrealization/mixin.go b/billing/charges/models/creditrealization/mixin.go new file mode 100644 index 0000000000000000000000000000000000000000..9cc0a4b109bbf606c6c029a1010eb21e0293702f --- /dev/null +++ b/billing/charges/models/creditrealization/mixin.go @@ -0,0 +1,173 @@ +package creditrealization + +import ( + "slices" + "time" + + "entgo.io/ent" + "entgo.io/ent/dialect" + "entgo.io/ent/schema/edge" + "entgo.io/ent/schema/field" + "entgo.io/ent/schema/mixin" + "github.com/alpacahq/alpacadecimal" + + "github.com/openmeterio/openmeter/openmeter/billing/charges/models/ledgertransaction" + "github.com/openmeterio/openmeter/pkg/framework/entutils" + "github.com/openmeterio/openmeter/pkg/models" + "github.com/openmeterio/openmeter/pkg/timeutil" +) + +type Mixin struct { + entutils.RecursiveMixin[mixinBase] + + SelfReferenceType any +} + +type mixinBase struct { + mixin.Schema +} + +func (mixinBase) Mixin() []ent.Mixin { + return []ent.Mixin{ + entutils.NamespaceMixin{}, + entutils.IDMixin{}, + entutils.TimeMixin{}, + entutils.AnnotationsMixin{}, + } +} + +func (m mixinBase) Fields() []ent.Field { + return []ent.Field{ + field.String("line_id"). + SchemaType(map[string]string{ + dialect.Postgres: "char(26)", + }). + Optional(). + NotEmpty(). + Nillable(), + + field.Other("amount", alpacadecimal.Decimal{}). + SchemaType(map[string]string{ + dialect.Postgres: "numeric", + }), + + field.Time("service_period_from"), + field.Time("service_period_to"), + + field.String("ledger_transaction_group_id"). + SchemaType(map[string]string{ + dialect.Postgres: "char(26)", + }). + NotEmpty(). + Immutable(), + + field.Int("sort_hint"), + + field.Enum("type"). + GoType(Type("")). + Immutable(), + + field.String("corrects_realization_id"). + SchemaType(map[string]string{ + dialect.Postgres: "char(26)", + }). + Optional(). + NotEmpty(). + Nillable(), + } +} + +func (mixinBase) Edges() []ent.Edge { + return nil +} + +func (m Mixin) Edges() []ent.Edge { + edges := m.RecursiveMixin.Edges() + if m.SelfReferenceType == nil { + return edges + } + + return slices.Concat(edges, []ent.Edge{ + edge.To("allocation", m.SelfReferenceType). + Field("corrects_realization_id"). + Unique(). + From("corrections"), + }) +} + +type Creator[T any] interface { + SetID(id string) T + SetNamespace(namespace string) T + SetAnnotations(annotations models.Annotations) T + SetLineID(lineID string) T + SetNillableLineID(lineID *string) T + SetAmount(amount alpacadecimal.Decimal) T + SetServicePeriodFrom(servicePeriodFrom time.Time) T + SetServicePeriodTo(servicePeriodTo time.Time) T + SetLedgerTransactionGroupID(ledgerTransactionGroupID string) T + SetType(t Type) T + SetNillableCorrectsRealizationID(correctsRealizationID *string) T + SetSortHint(sortHint int) T +} + +func Create[T Creator[T]](creator Creator[T], ns string, sortHint int, realization CreateInput) T { + create := creator.SetAnnotations(realization.Annotations). + SetNamespace(ns). + SetNillableLineID(realization.LineID). + SetAmount(realization.Amount). + SetServicePeriodFrom(realization.ServicePeriod.From.In(time.UTC)). + SetServicePeriodTo(realization.ServicePeriod.To.In(time.UTC)). + SetLedgerTransactionGroupID(realization.LedgerTransaction.TransactionGroupID). + SetSortHint(sortHint). + SetType(realization.Type). + SetNillableCorrectsRealizationID(realization.CorrectsRealizationID) + + if realization.ID != "" { + create = create.SetID(realization.ID) + } + + return create +} + +type Getter interface { + entutils.TimeMixinGetter + entutils.NamespaceMixinGetter + entutils.IDMixinGetter + entutils.AnnotationsMixinGetter + + GetLineID() *string + GetAmount() alpacadecimal.Decimal + GetServicePeriodFrom() time.Time + GetServicePeriodTo() time.Time + GetLedgerTransactionGroupID() string + GetSortHint() int + GetType() Type + GetCorrectsRealizationID() *string +} + +func MapFromDB(dbEntity Getter) Realization { + return Realization{ + NamespacedModel: models.NamespacedModel{ + Namespace: dbEntity.GetNamespace(), + }, + ManagedModel: entutils.MapTimeMixinFromDB(dbEntity), + + CreateInput: CreateInput{ + ID: dbEntity.GetID(), + Annotations: dbEntity.GetAnnotations(), + + ServicePeriod: timeutil.ClosedPeriod{ + From: dbEntity.GetServicePeriodFrom().In(time.UTC), + To: dbEntity.GetServicePeriodTo().In(time.UTC), + }, + Amount: dbEntity.GetAmount(), + LedgerTransaction: ledgertransaction.GroupReference{ + TransactionGroupID: dbEntity.GetLedgerTransactionGroupID(), + }, + LineID: dbEntity.GetLineID(), + Type: dbEntity.GetType(), + CorrectsRealizationID: dbEntity.GetCorrectsRealizationID(), + }, + SortHint: dbEntity.GetSortHint(), + } +} diff --git a/billing/charges/models/creditrealization/models.go b/billing/charges/models/creditrealization/models.go new file mode 100644 index 0000000000000000000000000000000000000000..1b181adf827c1c6f2ddd2a8f14f3eb0ff6d3c4da --- /dev/null +++ b/billing/charges/models/creditrealization/models.go @@ -0,0 +1,132 @@ +package creditrealization + +import ( + "errors" + "fmt" + "slices" + + "github.com/alpacahq/alpacadecimal" + + "github.com/openmeterio/openmeter/openmeter/billing/charges/models/ledgertransaction" + "github.com/openmeterio/openmeter/pkg/models" + "github.com/openmeterio/openmeter/pkg/timeutil" +) + +type CreateInput struct { + // ID is the ID of the credit realization, if empty a new ID will be generated. + ID string `json:"id"` + Annotations models.Annotations `json:"annotations"` + ServicePeriod timeutil.ClosedPeriod `json:"servicePeriod"` + + LedgerTransaction ledgertransaction.GroupReference `json:"ledgerTransaction"` + + Amount alpacadecimal.Decimal `json:"amount"` + + // LineID is the standard invoice line ID that the credit was allocated to. + // If nil, the credit is not allocated to any invoice line (e.g. line is still in gathering, + // credit_only mode without invoicing, etc.) + LineID *string `json:"lineID"` + + Type Type `json:"type"` + CorrectsRealizationID *string `json:"correctsRealizationID"` +} + +type Type string + +const ( + TypeAllocation Type = "allocation" + TypeCorrection Type = "correction" +) + +func (t Type) Values() []string { + return []string{ + string(TypeAllocation), + string(TypeCorrection), + } +} + +func (t Type) Validate() error { + if !slices.Contains(t.Values(), string(t)) { + return models.NewGenericValidationError(fmt.Errorf("invalid credit realization type: %s", t)) + } + return nil +} + +func (i CreateInput) Validate() error { + var errs []error + + if err := i.ServicePeriod.Validate(); err != nil { + errs = append(errs, fmt.Errorf("service period: %w", err)) + } + + if err := i.Type.Validate(); err != nil { + errs = append(errs, fmt.Errorf("type: %w", err)) + } + + switch i.Type { + case TypeAllocation: + if !i.Amount.IsPositive() { + errs = append(errs, fmt.Errorf("amount must be positive")) + } + case TypeCorrection: + if i.CorrectsRealizationID == nil { + errs = append(errs, fmt.Errorf("corrects realization ID is required")) + } + + if i.Amount.IsPositive() { + errs = append(errs, fmt.Errorf("amount must not be positive")) + } + } + + if err := i.LedgerTransaction.Validate(); err != nil { + errs = append(errs, fmt.Errorf("ledger transaction: %w", err)) + } + + if i.LineID != nil && *i.LineID == "" { + errs = append(errs, fmt.Errorf("line ID must be non-empty")) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +type CreateInputs []CreateInput + +func (i CreateInputs) Validate() error { + var errs []error + + for idx, input := range i { + if err := input.Validate(); err != nil { + errs = append(errs, fmt.Errorf("credit realization input[%d]: %w", idx, err)) + } + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +func (i CreateInputs) Sum() alpacadecimal.Decimal { + sum := alpacadecimal.Zero + for _, input := range i { + sum = sum.Add(input.Amount) + } + return sum +} + +type Realization struct { + models.NamespacedModel + models.ManagedModel + CreateInput + + // SortHint is the hint for the order of the credit realizations created in the same batch. + // Given collection is in priority order, reverting any transaction group should happen in reverse order. + SortHint int `json:"sortHint"` +} + +func (r Realization) Validate() error { + var errs []error + + if err := r.CreateInput.Validate(); err != nil { + errs = append(errs, fmt.Errorf("credit realization input: %w", err)) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} diff --git a/billing/charges/models/creditrealization/realizations.go b/billing/charges/models/creditrealization/realizations.go new file mode 100644 index 0000000000000000000000000000000000000000..0139a75489bb5a84a39030961caff205850a0a16 --- /dev/null +++ b/billing/charges/models/creditrealization/realizations.go @@ -0,0 +1,226 @@ +package creditrealization + +import ( + "errors" + "fmt" + "slices" + + "github.com/alpacahq/alpacadecimal" + "github.com/samber/lo" + "github.com/samber/lo/mutable" + + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/pkg/currencyx" + "github.com/openmeterio/openmeter/pkg/models" + "github.com/openmeterio/openmeter/pkg/slicesx" +) + +type Realizations []Realization + +func (r Realizations) Validate() error { + var errs []error + + for idx, realization := range r { + if err := realization.Validate(); err != nil { + errs = append(errs, fmt.Errorf("credit realization[%d]: %w", idx, err)) + } + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +func (r Realizations) Sum() alpacadecimal.Decimal { + sum := alpacadecimal.Zero + for _, realization := range r { + sum = sum.Add(realization.Amount) + } + return sum +} + +func (r Realizations) AllocationsByID() map[string]Realization { + return lo.KeyBy( + lo.Filter(r, func(realization Realization, _ int) bool { + return realization.Type == TypeAllocation + }), + func(realization Realization) string { + return realization.ID + }, + ) +} + +func (r Realizations) AsCreditsApplied() (billing.CreditsApplied, error) { + allocationsWithCorrections, err := r.allocationsWithCorrections() + if err != nil { + return nil, err + } + + creditsApplied := make(billing.CreditsApplied, 0, len(allocationsWithCorrections)) + for _, allocationWithCorrections := range allocationsWithCorrections { + if !allocationWithCorrections.RemainingAmount.IsPositive() { + continue + } + + creditsApplied = append(creditsApplied, billing.CreditApplied{ + Amount: allocationWithCorrections.RemainingAmount, + CreditRealizationID: allocationWithCorrections.Allocation.ID, + }) + } + + return creditsApplied, nil +} + +var ErrInsufficientFunds = models.NewGenericValidationError(errors.New("insufficient funds")) + +func (r Realizations) CreateCorrectionRequest(amount alpacadecimal.Decimal, currency currencyx.Currency) (CorrectionRequest, error) { + if currency == nil { + return nil, models.NewGenericValidationError(errors.New("currency is not initialized")) + } + + if amount.IsPositive() { + return CorrectionRequest{}, models.NewGenericValidationError(errors.New("amount must not be positive")) + } + + amount = currency.RoundToPrecision(amount) + if amount.IsZero() { + return nil, nil + } + + allocationsWithCorrections, err := r.allocationsWithCorrections() + if err != nil { + return CorrectionRequest{}, err + } + + mutable.Reverse(allocationsWithCorrections) + + out := make(CorrectionRequest, 0, len(allocationsWithCorrections)) + amountToCorrect := amount.Abs() + for _, allocationWithCorrections := range allocationsWithCorrections { + if allocationWithCorrections.RemainingAmount.IsZero() { + continue + } + + if allocationWithCorrections.RemainingAmount.GreaterThan(amountToCorrect) { + out = append(out, CorrectionRequestItem{ + Allocation: allocationWithCorrections.Allocation, + Amount: amountToCorrect.Neg(), + }) + + amountToCorrect = alpacadecimal.Zero + break + } + + out = append(out, CorrectionRequestItem{ + Allocation: allocationWithCorrections.Allocation, + Amount: allocationWithCorrections.RemainingAmount.Neg(), + }) + + amountToCorrect = amountToCorrect.Sub(allocationWithCorrections.RemainingAmount) + } + + if amountToCorrect.IsPositive() { + return CorrectionRequest{}, ErrInsufficientFunds + } + + return out, nil +} + +func (r Realizations) Correct(amount alpacadecimal.Decimal, currency currencyx.Currency, cb func(req CorrectionRequest) (CreateCorrectionInputs, error)) (CreateInputs, error) { + if currency == nil { + return nil, models.NewGenericValidationError(errors.New("currency is required")) + } + + req, err := r.CreateCorrectionRequest(amount, currency) + if err != nil { + return nil, err + } + + if len(req) == 0 { + return nil, nil + } + + if err := req.ValidateWith(currency); err != nil { + return nil, err + } + + corrections, err := cb(req) + if err != nil { + return nil, err + } + corrections = corrections.NormalizeWith(currency) + + if err := corrections.ValidateWith(r, amount.Abs(), currency); err != nil { + return nil, err + } + + return corrections.AsCreateInputs(r) +} + +func (r Realizations) CorrectAll(currency currencyx.Currency, cb func(req CorrectionRequest) (CreateCorrectionInputs, error)) (CreateInputs, error) { + if currency == nil { + return nil, models.NewGenericValidationError(errors.New("currency is required")) + } + + total := r.Sum() + if total.IsZero() { + return nil, nil + } + + return r.Correct(total.Neg(), currency, cb) +} + +type allocationWithCorrections struct { + Allocation Realization + Corrections []Realization + RemainingAmount alpacadecimal.Decimal +} + +// allocationsWithCorrections returns the allocations with the corrections that can are applied to them, +// the return value is sorted by creation order (reverts should happen in reverse order). +func (r Realizations) allocationsWithCorrections() ([]allocationWithCorrections, error) { + // let's collect the corrections by allocation ID + + corrections := lo.Filter(r, func(realization Realization, _ int) bool { + return realization.Type == TypeCorrection + }) + + correctionsByAllocationID := lo.GroupBy(corrections, func(correction Realization) string { + return lo.FromPtr(correction.CorrectsRealizationID) + }) + if _, ok := correctionsByAllocationID[""]; ok { + return nil, models.NewGenericValidationError(errors.New("correction for unknown allocation")) + } + + // let's collect the allocations by allocation ID + realizations := lo.Filter(r, func(realization Realization, _ int) bool { + return realization.Type == TypeAllocation + }) + + // Let's sort the allocations by createdAt + sortHint + slices.SortStableFunc(realizations, func(a, b Realization) int { + cmpCreatedAt := a.CreatedAt.Compare(b.CreatedAt) + if cmpCreatedAt != 0 { + return cmpCreatedAt + } + + return a.SortHint - b.SortHint + }) + + // let's assign the allocations to the corrections + return slicesx.MapWithErr(realizations, func(allocation Realization) (allocationWithCorrections, error) { + remainingAmount := allocation.Amount + corrections, hasCorrections := correctionsByAllocationID[allocation.ID] + if hasCorrections { + remainingAmount = remainingAmount.Add(corrections.Sum()) + } + + if remainingAmount.IsNegative() { + return allocationWithCorrections{}, models.NewGenericValidationError(fmt.Errorf("remaining amount is negative for allocation %s", allocation.ID)) + } + + return allocationWithCorrections{ + Allocation: allocation, + Corrections: corrections, + RemainingAmount: remainingAmount, + }, nil + }) +} diff --git a/billing/charges/models/invoicedusage/mixin.go b/billing/charges/models/invoicedusage/mixin.go new file mode 100644 index 0000000000000000000000000000000000000000..2f00f226fb9fba9cd888cddcde3b0fa5d8d2443f --- /dev/null +++ b/billing/charges/models/invoicedusage/mixin.go @@ -0,0 +1,108 @@ +package invoicedusage + +import ( + "time" + + "entgo.io/ent" + "entgo.io/ent/dialect" + "entgo.io/ent/schema/field" + + "github.com/openmeterio/openmeter/openmeter/billing/charges/models/ledgertransaction" + "github.com/openmeterio/openmeter/openmeter/billing/models/totals" + "github.com/openmeterio/openmeter/pkg/framework/entutils" + "github.com/openmeterio/openmeter/pkg/models" + "github.com/openmeterio/openmeter/pkg/timeutil" +) + +type Mixin = entutils.RecursiveMixin[mixin] + +type mixin struct { + ent.Schema +} + +func (mixin) Mixin() []ent.Mixin { + return []ent.Mixin{ + entutils.NamespaceMixin{}, + entutils.IDMixin{}, + entutils.TimeMixin{}, + entutils.AnnotationsMixin{}, + totals.Mixin{}, + } +} + +func (mixin) Fields() []ent.Field { + return []ent.Field{ + field.Time("service_period_from"), + field.Time("service_period_to"), + field.String("ledger_transaction_group_id"). + SchemaType(map[string]string{ + dialect.Postgres: "char(26)", + }). + Optional(). + NotEmpty(). + Nillable(), + } +} + +type Creator[T any] interface { + entutils.NamespaceMixinCreator[T] + entutils.IDMixinCreator[T] + entutils.TimeMixinCreator[T] + entutils.AnnotationsMixinSetter[T] + totals.Setter[T] + SetServicePeriodFrom(servicePeriodFrom time.Time) T + SetServicePeriodTo(servicePeriodTo time.Time) T + SetNillableLedgerTransactionGroupID(ledgerTransactionGroupID *string) T +} + +func Create[T Creator[T]](creator T, ns string, invoicedUsage AccruedUsage) T { + var trnsGroupID *string + if invoicedUsage.LedgerTransaction != nil { + trnsGroupID = &invoicedUsage.LedgerTransaction.TransactionGroupID + } + + creator = creator.SetAnnotations(invoicedUsage.Annotations). + SetNamespace(ns). + SetServicePeriodFrom(invoicedUsage.ServicePeriod.From.In(time.UTC)). + SetServicePeriodTo(invoicedUsage.ServicePeriod.To.In(time.UTC)). + SetNillableLedgerTransactionGroupID(trnsGroupID) + + creator = totals.Set(creator, invoicedUsage.Totals) + + return creator +} + +type Getter interface { + entutils.TimeMixinGetter + entutils.NamespaceMixinGetter + entutils.IDMixinGetter + entutils.AnnotationsMixinGetter + totals.TotalsGetter + GetServicePeriodFrom() time.Time + GetServicePeriodTo() time.Time + GetLedgerTransactionGroupID() *string +} + +func MapAccruedUsageFromDB(dbEntity Getter) AccruedUsage { + var ledgerTransaction *ledgertransaction.GroupReference + if dbEntity.GetLedgerTransactionGroupID() != nil { + ledgerTransaction = &ledgertransaction.GroupReference{ + TransactionGroupID: *dbEntity.GetLedgerTransactionGroupID(), + } + } + + return AccruedUsage{ + NamespacedID: models.NamespacedID{ + Namespace: dbEntity.GetNamespace(), + ID: dbEntity.GetID(), + }, + ManagedModel: entutils.MapTimeMixinFromDB(dbEntity), + Annotations: dbEntity.GetAnnotations(), + ServicePeriod: timeutil.ClosedPeriod{ + From: dbEntity.GetServicePeriodFrom().In(time.UTC), + To: dbEntity.GetServicePeriodTo().In(time.UTC), + }, + LedgerTransaction: ledgerTransaction, + Totals: totals.FromDB(dbEntity), + } +} diff --git a/billing/charges/models/invoicedusage/stdinvoice.go b/billing/charges/models/invoicedusage/stdinvoice.go new file mode 100644 index 0000000000000000000000000000000000000000..db4acdd1dc8fbaa258e21fb668b4f58409745e0c --- /dev/null +++ b/billing/charges/models/invoicedusage/stdinvoice.go @@ -0,0 +1,42 @@ +package invoicedusage + +import ( + "errors" + "fmt" + + "github.com/openmeterio/openmeter/openmeter/billing/charges/models/ledgertransaction" + "github.com/openmeterio/openmeter/openmeter/billing/models/totals" + "github.com/openmeterio/openmeter/pkg/models" + "github.com/openmeterio/openmeter/pkg/timeutil" +) + +type AccruedUsage struct { + models.NamespacedID + models.ManagedModel + + Annotations models.Annotations `json:"annotations"` + ServicePeriod timeutil.ClosedPeriod `json:"servicePeriod"` + LedgerTransaction *ledgertransaction.GroupReference `json:"ledgerTransaction"` + + Totals totals.Totals `json:"totals"` +} + +func (r AccruedUsage) Validate() error { + var errs []error + + if err := r.ServicePeriod.Validate(); err != nil { + errs = append(errs, fmt.Errorf("service period: %w", err)) + } + + if err := r.Totals.Validate(); err != nil { + errs = append(errs, fmt.Errorf("totals: %w", err)) + } + + if r.LedgerTransaction != nil { + if err := r.LedgerTransaction.Validate(); err != nil { + errs = append(errs, fmt.Errorf("ledger transaction: %w", err)) + } + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} diff --git a/billing/charges/models/ledgertransaction/ledger.go b/billing/charges/models/ledgertransaction/ledger.go new file mode 100644 index 0000000000000000000000000000000000000000..7afb73cec1998ab55c2219329c784fa755f5ebb0 --- /dev/null +++ b/billing/charges/models/ledgertransaction/ledger.go @@ -0,0 +1,68 @@ +package ledgertransaction + +import ( + "errors" + "fmt" + "time" + + "github.com/openmeterio/openmeter/pkg/models" +) + +// LedgerTransactionGroupReference is a reference to a ledger transaction group. +// It is used to track payment settlement transactions. +type GroupReference struct { + TransactionGroupID string `json:"transactionGroupID"` +} + +func (r GroupReference) Validate() error { + var errs []error + + if r.TransactionGroupID == "" { + errs = append(errs, fmt.Errorf("transaction group ID is required")) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +func (r *GroupReference) GetIDOrNull() *string { + if r == nil || r.TransactionGroupID == "" { + return nil + } + + return &r.TransactionGroupID +} + +type TimedGroupReference struct { + GroupReference + Time time.Time `json:"time"` +} + +func (r TimedGroupReference) Validate() error { + var errs []error + + if err := r.GroupReference.Validate(); err != nil { + errs = append(errs, fmt.Errorf("ledger transaction group reference: %w", err)) + } + + if r.Time.IsZero() { + errs = append(errs, fmt.Errorf("time is required")) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +func (r *TimedGroupReference) GetIDOrNull() *string { + if r == nil || r.TransactionGroupID == "" { + return nil + } + + return r.GroupReference.GetIDOrNull() +} + +func (r *TimedGroupReference) GetTimeOrNull() *time.Time { + if r == nil { + return nil + } + + return &r.Time +} diff --git a/billing/charges/models/payment/errors.go b/billing/charges/models/payment/errors.go new file mode 100644 index 0000000000000000000000000000000000000000..03a780fc394691d4a71a7ddeb0904f3d9d4369dd --- /dev/null +++ b/billing/charges/models/payment/errors.go @@ -0,0 +1,35 @@ +package payment + +import ( + "net/http" + + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/models" +) + +const ErrCodePaymentAlreadyAuthorized models.ErrorCode = "payment_already_authorized" + +var ErrPaymentAlreadyAuthorized = models.NewValidationIssue( + ErrCodePaymentAlreadyAuthorized, + "payment already authorized", + models.WithCriticalSeverity(), + commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest), +) + +const ErrCodePaymentAlreadySettled models.ErrorCode = "payment_already_settled" + +var ErrPaymentAlreadySettled = models.NewValidationIssue( + ErrCodePaymentAlreadySettled, + "payment already settled", + models.WithCriticalSeverity(), + commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest), +) + +const ErrCodeCannotSettleNotAuthorizedPayment models.ErrorCode = "cannot_settle_not_authorized_payment" + +var ErrCannotSettleNotAuthorizedPayment = models.NewValidationIssue( + ErrCodeCannotSettleNotAuthorizedPayment, + "cannot settle an unauthorized payment", + models.WithCriticalSeverity(), + commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest), +) diff --git a/billing/charges/models/payment/external.go b/billing/charges/models/payment/external.go new file mode 100644 index 0000000000000000000000000000000000000000..1e5637967a6ad7a15bef8edcc2ffb643ce177356 --- /dev/null +++ b/billing/charges/models/payment/external.go @@ -0,0 +1,59 @@ +package payment + +import ( + "errors" + "fmt" + + "github.com/openmeterio/openmeter/pkg/models" +) + +var _ models.Validator = (*ExternalCreateInput)(nil) + +type ExternalCreateInput struct { + Base + + Namespace string `json:"namespace"` +} + +func (i ExternalCreateInput) Validate() error { + var errs []error + + if i.Namespace == "" { + errs = append(errs, fmt.Errorf("namespace is required")) + } + + if err := i.Base.Validate(); err != nil { + errs = append(errs, fmt.Errorf("payment settlement base: %w", err)) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +type External struct { + Payment +} + +func (r External) ErrorAttributes() models.Attributes { + return models.Attributes{ + PaymentSettlementStatusAttributeKey: string(r.Status), + PaymentSettlementTypeAttributeKey: string(PaymentSettlementTypeExternal), + paymentSettlementIDAttributeKey: r.ID, + } +} + +type ExternalMixin = Mixin + +func CreateExternal[T Creator[T]](creator Creator[T], payment ExternalCreateInput) T { + return Create(creator, payment.Namespace, payment.Base) +} + +func MapExternalFromDB(dbEntity Getter) External { + payment := mapPaymentFromDB(dbEntity) + return External{ + Payment: payment, + } +} + +func UpdateExternal[T Updater[T]](updater Updater[T], in External) T { + return Update(updater, in.Payment) +} diff --git a/billing/charges/models/payment/invoiced.go b/billing/charges/models/payment/invoiced.go new file mode 100644 index 0000000000000000000000000000000000000000..3bd7b1c73793609e47e0cc2efaac314ed33678ca --- /dev/null +++ b/billing/charges/models/payment/invoiced.go @@ -0,0 +1,139 @@ +package payment + +import ( + "errors" + "fmt" + + "entgo.io/ent" + "entgo.io/ent/dialect" + "entgo.io/ent/schema/field" + + "github.com/openmeterio/openmeter/pkg/framework/entutils" + "github.com/openmeterio/openmeter/pkg/models" +) + +type InvoicedMixin = entutils.RecursiveMixin[invoicedMixin] + +type invoicedMixin struct { + ent.Schema +} + +func (invoicedMixin) Mixin() []ent.Mixin { + return []ent.Mixin{ + Mixin{}, + } +} + +func (invoicedMixin) Fields() []ent.Field { + return []ent.Field{ + field.String("line_id"). + SchemaType(map[string]string{ + dialect.Postgres: "char(26)", + }). + Immutable(), + field.String("invoice_id"). + SchemaType(map[string]string{ + dialect.Postgres: "char(26)", + }). + Immutable(), + } +} + +type InvoicedCreate struct { + Base + + Namespace string `json:"namespace"` + LineID string `json:"lineID"` + InvoiceID string `json:"invoiceID"` +} + +func (i InvoicedCreate) Validate() error { + var errs []error + + if i.Namespace == "" { + errs = append(errs, fmt.Errorf("namespace is required")) + } + + if err := i.Base.Validate(); err != nil { + errs = append(errs, fmt.Errorf("payment settlement base: %w", err)) + } + + if i.LineID == "" { + errs = append(errs, fmt.Errorf("line ID is required")) + } + + if i.InvoiceID == "" { + errs = append(errs, fmt.Errorf("invoice ID is required")) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +type InvoicedCreator[T any] interface { + Creator[T] + SetLineID(lineID string) T + SetInvoiceID(invoiceID string) T +} + +func CreateInvoiced[T InvoicedCreator[T]](creator InvoicedCreator[T], in InvoicedCreate) T { + creator = Create(creator, in.Namespace, in.Base) + creator = creator.SetInvoiceID(in.InvoiceID) + return creator.SetLineID(in.LineID) +} + +type InvoicedUpdater[T any] = Updater[T] + +func UpdateInvoiced[T InvoicedUpdater[T]](updater InvoicedUpdater[T], in Invoiced) T { + return Update(updater, in.Payment) +} + +// InvoicePayment represents a payment settlement using a standard invoice managed +// by the OpenMeter platform. +type Invoiced struct { + Payment + + LineID string `json:"lineID"` + InvoiceID string `json:"invoiceID"` +} + +var _ models.Validator = (*Invoiced)(nil) + +func (r Invoiced) Validate() error { + var errs []error + + if r.LineID == "" { + errs = append(errs, fmt.Errorf("line ID is required")) + } + + if err := r.Payment.Validate(); err != nil { + errs = append(errs, fmt.Errorf("payment: %w", err)) + } + + if r.InvoiceID == "" { + errs = append(errs, fmt.Errorf("invoice ID is required")) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +func (r Invoiced) ErrorAttributes() models.Attributes { + return models.Attributes{ + PaymentSettlementStatusAttributeKey: string(r.Status), + PaymentSettlementTypeAttributeKey: string(PaymentSettlementTypeStandardInvoice), + paymentSettlementIDAttributeKey: r.ID, + } +} + +type InvoicedGetter interface { + Getter + GetLineID() string + GetInvoiceID() string +} + +func MapInvoicedFromDB(dbEntity InvoicedGetter) Invoiced { + return Invoiced{ + Payment: mapPaymentFromDB(dbEntity), + LineID: dbEntity.GetLineID(), + InvoiceID: dbEntity.GetInvoiceID(), + } +} diff --git a/billing/charges/models/payment/mixin.go b/billing/charges/models/payment/mixin.go new file mode 100644 index 0000000000000000000000000000000000000000..7f498ddc71b58e5ccadcf2509fb52e9d9a43e18b --- /dev/null +++ b/billing/charges/models/payment/mixin.go @@ -0,0 +1,170 @@ +package payment + +import ( + "time" + + "entgo.io/ent" + "entgo.io/ent/dialect" + "entgo.io/ent/schema/field" + "github.com/alpacahq/alpacadecimal" + + "github.com/openmeterio/openmeter/openmeter/billing/charges/models/ledgertransaction" + "github.com/openmeterio/openmeter/pkg/convert" + "github.com/openmeterio/openmeter/pkg/framework/entutils" + "github.com/openmeterio/openmeter/pkg/models" + "github.com/openmeterio/openmeter/pkg/timeutil" +) + +type Mixin = entutils.RecursiveMixin[mixin] + +type mixin struct { + ent.Schema +} + +func (mixin) Mixin() []ent.Mixin { + return []ent.Mixin{ + entutils.NamespaceMixin{}, + entutils.IDMixin{}, + entutils.TimeMixin{}, + entutils.AnnotationsMixin{}, + } +} + +func (mixin) Fields() []ent.Field { + return []ent.Field{ + field.Time("service_period_from"), + field.Time("service_period_to"), + + field.Enum("status"). + GoType(Status("")), + + field.Other("amount", alpacadecimal.Decimal{}). + SchemaType(map[string]string{ + dialect.Postgres: "numeric", + }), + + // TODO: Let's add edges to ledger + field.String("authorized_transaction_group_id"). + SchemaType(map[string]string{ + dialect.Postgres: "char(26)", + }). + Optional(). + NotEmpty(). + Nillable(), + + field.Time("authorized_at").Optional().Nillable(), + + field.String("settled_transaction_group_id"). + SchemaType(map[string]string{ + dialect.Postgres: "char(26)", + }). + Optional(). + NotEmpty(). + Nillable(), + + field.Time("settled_at").Optional().Nillable(), + } +} + +type MutableFieldSetter[T any] interface { + SetAmount(amount alpacadecimal.Decimal) T + SetStatus(status Status) T + SetServicePeriodFrom(servicePeriodFrom time.Time) T + SetServicePeriodTo(servicePeriodTo time.Time) T + SetNillableAuthorizedTransactionGroupID(authorizedTransactionGroupID *string) T + SetNillableAuthorizedAt(authorizedAt *time.Time) T + SetNillableSettledTransactionGroupID(settledTransactionGroupID *string) T + SetNillableSettledAt(settledAt *time.Time) T +} + +type Creator[T any] interface { + entutils.NamespaceMixinCreator[T] + entutils.AnnotationsMixinSetter[T] + entutils.TimeMixinCreator[T] + MutableFieldSetter[T] +} + +func Create[T Creator[T]](creator Creator[T], namespace string, paymentSettlement Base) T { + return creator.SetAnnotations(paymentSettlement.Annotations). + SetNamespace(namespace). + SetServicePeriodFrom(paymentSettlement.ServicePeriod.From). + SetServicePeriodTo(paymentSettlement.ServicePeriod.To). + SetAmount(paymentSettlement.Amount). + SetStatus(paymentSettlement.Status). + SetNillableAuthorizedTransactionGroupID(paymentSettlement.Authorized.GetIDOrNull()). + SetNillableAuthorizedAt(paymentSettlement.Authorized.GetTimeOrNull()). + SetNillableSettledTransactionGroupID(paymentSettlement.Settled.GetIDOrNull()). + SetNillableSettledAt(paymentSettlement.Settled.GetTimeOrNull()) +} + +type Updater[T any] interface { + entutils.AnnotationsMixinSetter[T] + entutils.TimeMixinUpdater[T] + MutableFieldSetter[T] +} + +func Update[T Updater[T]](updater Updater[T], in Payment) T { + return updater.SetAnnotations(in.Annotations). + SetServicePeriodFrom(in.ServicePeriod.From). + SetServicePeriodTo(in.ServicePeriod.To). + SetAmount(in.Amount). + SetStatus(in.Status). + SetNillableDeletedAt(convert.TimePtrIn(in.DeletedAt, time.UTC)). + SetNillableAuthorizedTransactionGroupID(in.Authorized.GetIDOrNull()). + SetNillableAuthorizedAt(convert.TimePtrIn(in.Authorized.GetTimeOrNull(), time.UTC)). + SetNillableSettledTransactionGroupID(in.Settled.GetIDOrNull()). + SetNillableSettledAt(convert.TimePtrIn(in.Settled.GetTimeOrNull(), time.UTC)) +} + +type Getter interface { + entutils.NamespaceMixinGetter + entutils.IDMixinGetter + entutils.TimeMixinGetter + entutils.AnnotationsMixinGetter + GetServicePeriodFrom() time.Time + GetServicePeriodTo() time.Time + GetAmount() alpacadecimal.Decimal + GetStatus() Status + GetAuthorizedTransactionGroupID() *string + GetAuthorizedAt() *time.Time + GetSettledTransactionGroupID() *string + GetSettledAt() *time.Time +} + +func mapBaseFromDB(dbEntity Getter) Base { + return Base{ + Annotations: dbEntity.GetAnnotations(), + ServicePeriod: timeutil.ClosedPeriod{ + From: dbEntity.GetServicePeriodFrom().In(time.UTC), + To: dbEntity.GetServicePeriodTo().In(time.UTC), + }, + Status: dbEntity.GetStatus(), + Amount: dbEntity.GetAmount(), + Authorized: mapTimedLedgerTransactionGroupReferenceFromDB(dbEntity.GetAuthorizedTransactionGroupID(), dbEntity.GetAuthorizedAt()), + Settled: mapTimedLedgerTransactionGroupReferenceFromDB(dbEntity.GetSettledTransactionGroupID(), dbEntity.GetSettledAt()), + } +} + +func mapPaymentFromDB(dbEntity Getter) Payment { + return Payment{ + NamespacedID: models.NamespacedID{ + Namespace: dbEntity.GetNamespace(), + ID: dbEntity.GetID(), + }, + ManagedModel: entutils.MapTimeMixinFromDB(dbEntity), + Base: mapBaseFromDB(dbEntity), + } +} + +func mapTimedLedgerTransactionGroupReferenceFromDB(reference *string, at *time.Time) *ledgertransaction.TimedGroupReference { + if reference == nil || at == nil { + return nil + } + + return &ledgertransaction.TimedGroupReference{ + GroupReference: ledgertransaction.GroupReference{ + TransactionGroupID: *reference, + }, + Time: at.In(time.UTC), + } +} diff --git a/billing/charges/models/payment/models.go b/billing/charges/models/payment/models.go new file mode 100644 index 0000000000000000000000000000000000000000..df34c9ce072a2896b6875a149593036df4e2b293 --- /dev/null +++ b/billing/charges/models/payment/models.go @@ -0,0 +1,128 @@ +package payment + +import ( + "errors" + "fmt" + "slices" + + "github.com/alpacahq/alpacadecimal" + + "github.com/openmeterio/openmeter/openmeter/billing/charges/models/ledgertransaction" + "github.com/openmeterio/openmeter/pkg/models" + "github.com/openmeterio/openmeter/pkg/timeutil" +) + +const ( + PaymentSettlementStatusAttributeKey = "payment_settlement_status" + PaymentSettlementTypeAttributeKey = "payment_settlement_type" + paymentSettlementIDAttributeKey = "payment_settlement_id" + + // TODO: make sure we have a single constant for each payment settlement type + PaymentSettlementTypeExternal = "type_external" + PaymentSettlementTypeStandardInvoice = "type_standard_invoice" +) + +type Status string + +const ( + StatusAuthorized Status = "authorized" + StatusSettled Status = "settled" +) + +func (o Status) Values() []string { + return []string{ + string(StatusAuthorized), + string(StatusSettled), + } +} + +func (o Status) Validate() error { + if !slices.Contains(o.Values(), string(o)) { + return models.NewGenericValidationError(fmt.Errorf("invalid payment settlement status: %s", o)) + } + return nil +} + +// Base represents the generic payment settlement properties that are common to all payment settlements. +type Base struct { + Annotations models.Annotations `json:"annotations"` + ServicePeriod timeutil.ClosedPeriod `json:"servicePeriod"` + + Status Status `json:"status"` + Amount alpacadecimal.Decimal `json:"amount"` + + Authorized *ledgertransaction.TimedGroupReference `json:"authorized"` + Settled *ledgertransaction.TimedGroupReference `json:"settled"` +} + +func (r Base) Validate() error { + var errs []error + + if err := r.Status.Validate(); err != nil { + errs = append(errs, fmt.Errorf("status: %w", err)) + } + + if err := r.ServicePeriod.Validate(); err != nil { + errs = append(errs, fmt.Errorf("service period: %w", err)) + } + + if r.Authorized != nil { + if err := r.Authorized.Validate(); err != nil { + errs = append(errs, fmt.Errorf("authorized: %w", err)) + } + } + + if r.Settled != nil { + if err := r.Settled.Validate(); err != nil { + errs = append(errs, fmt.Errorf("settled: %w", err)) + } + } + + if !r.Amount.IsPositive() { + errs = append(errs, fmt.Errorf("amount must be positive")) + } + + switch r.Status { + case StatusAuthorized: + if r.Authorized == nil { + errs = append(errs, fmt.Errorf("authorization transaction data is missing for authorized status")) + } + case StatusSettled: + if r.Settled == nil { + errs = append(errs, fmt.Errorf("settlement transaction data is missing for settled status")) + } + + if r.Authorized == nil { + errs = append(errs, fmt.Errorf("authorization transaction data is missing for settled status")) + } + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +type Payment struct { + models.NamespacedID + models.ManagedModel + + Base +} + +var _ models.Validator = (*Payment)(nil) + +func (r Payment) Validate() error { + var errs []error + + if err := r.Base.Validate(); err != nil { + errs = append(errs, fmt.Errorf("base: %w", err)) + } + + if err := r.NamespacedID.Validate(); err != nil { + errs = append(errs, fmt.Errorf("namespaced ID: %w", err)) + } + + if err := r.ManagedModel.Validate(); err != nil { + errs = append(errs, fmt.Errorf("managed model: %w", err)) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} diff --git a/billing/charges/patch.go b/billing/charges/patch.go new file mode 100644 index 0000000000000000000000000000000000000000..ec20969852b6bf28ae90b40499620c0b8b56a45e --- /dev/null +++ b/billing/charges/patch.go @@ -0,0 +1,83 @@ +package charges + +import ( + "errors" + "fmt" + + "github.com/samber/lo" + + "github.com/openmeterio/openmeter/openmeter/billing/charges/meta" + "github.com/openmeterio/openmeter/openmeter/customer" + "github.com/openmeterio/openmeter/pkg/models" +) + +type Patch = meta.Patch + +var _ models.Validator = (*ApplyPatchesInput)(nil) + +type ApplyPatchesInput struct { + CustomerID customer.CustomerID + Creates ChargeIntents + + // PatchesByChargeID is a map of charge ID to the patches to apply to the charge. This format is used to make sure + // there's only a single patch affecting a single charge. + PatchesByChargeID map[string]Patch +} + +func (i ApplyPatchesInput) Validate() error { + var errs []error + if err := i.CustomerID.Validate(); err != nil { + errs = append(errs, fmt.Errorf("customer ID: %w", err)) + } + + if err := i.Creates.Validate(); err != nil { + errs = append(errs, fmt.Errorf("creates: %w", err)) + } + + for chargeID, patch := range i.PatchesByChargeID { + if chargeID == "" { + errs = append(errs, fmt.Errorf("charge ID is required")) + continue + } + + if patch == nil { + errs = append(errs, fmt.Errorf("patch for charge ID %s is nil", chargeID)) + continue + } + + if err := patch.Validate(); err != nil { + errs = append(errs, fmt.Errorf("patch for charge ID %s: %w", chargeID, err)) + } + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +// ConcatenateApplyPatchesInputs concatenates the given inputs into a single input, while enforcing uniqueness constraints. +func ConcatenateApplyPatchesInputs(inputs ...ApplyPatchesInput) (ApplyPatchesInput, error) { + if len(inputs) == 0 { + return ApplyPatchesInput{}, nil + } + + result := ApplyPatchesInput{ + CustomerID: inputs[0].CustomerID, + Creates: make(ChargeIntents, 0, lo.SumBy(inputs, func(input ApplyPatchesInput) int { return len(input.Creates) })), + PatchesByChargeID: make(map[string]Patch, lo.SumBy(inputs, func(input ApplyPatchesInput) int { return len(input.PatchesByChargeID) })), + } + + for _, input := range inputs { + result.Creates = append(result.Creates, input.Creates...) + for chargeID, patch := range input.PatchesByChargeID { + if _, exists := result.PatchesByChargeID[chargeID]; exists { + return ApplyPatchesInput{}, fmt.Errorf("duplicate charge ID: %s", chargeID) + } + result.PatchesByChargeID[chargeID] = patch + } + } + + return result, nil +} + +func (i ApplyPatchesInput) IsEmpty() bool { + return len(i.PatchesByChargeID) == 0 && len(i.Creates) == 0 +} diff --git a/billing/charges/service/advance.go b/billing/charges/service/advance.go new file mode 100644 index 0000000000000000000000000000000000000000..a82200e8790476cef225f613acc65b867855ce54 --- /dev/null +++ b/billing/charges/service/advance.go @@ -0,0 +1,130 @@ +package service + +import ( + "context" + "fmt" + + "github.com/samber/lo" + + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/billing/charges" + "github.com/openmeterio/openmeter/openmeter/billing/charges/flatfee" + "github.com/openmeterio/openmeter/openmeter/billing/charges/meta" + "github.com/openmeterio/openmeter/openmeter/billing/charges/usagebased" + "github.com/openmeterio/openmeter/pkg/currencyx" + "github.com/openmeterio/openmeter/pkg/framework/transaction" +) + +func (s *service) AdvanceCharges(ctx context.Context, input charges.AdvanceChargesInput) (charges.Charges, error) { + if err := input.Validate(); err != nil { + return nil, err + } + + if err := s.validateNamespaceLockdown(input.Customer.Namespace); err != nil { + return nil, err + } + + advancedCharges, err := transaction.Run(ctx, s.adapter, func(ctx context.Context) (charges.Charges, error) { + inScopeCharges, err := s.ListCharges(ctx, charges.ListChargesInput{ + Namespace: input.Customer.Namespace, + StatusNotIn: []meta.ChargeStatus{meta.ChargeStatusFinal}, + CustomerIDs: []string{input.Customer.ID}, + Expands: meta.Expands{meta.ExpandRealizations}, + }) + if err != nil { + return nil, fmt.Errorf("list charges: %w", err) + } + + chargesByType, err := chargesByType(inScopeCharges.Items) + if err != nil { + return nil, fmt.Errorf("get charges by type: %w", err) + } + + if len(chargesByType.usageBased) == 0 && len(chargesByType.flatFees) == 0 { + return charges.Charges{}, nil + } + + advancedCharges := make(charges.Charges, 0, len(chargesByType.usageBased)+len(chargesByType.flatFees)) + + for _, charge := range chargesByType.flatFees { + advancedCharge, err := s.flatFeeService.AdvanceCharge(ctx, flatfee.AdvanceChargeInput{ + ChargeID: charge.GetChargeID(), + }) + if err != nil { + return nil, fmt.Errorf("advance flat fee charge %s: %w", charge.ID, err) + } + + if advancedCharge == nil { + continue + } + + advancedCharges = append(advancedCharges, charges.NewCharge(*advancedCharge)) + } + + // Advance usage-based charges + if len(chargesByType.usageBased) > 0 { + customerOverride, err := s.billingService.GetCustomerOverride(ctx, billing.GetCustomerOverrideInput{ + Customer: input.Customer, + Expand: billing.CustomerOverrideExpand{ + Customer: true, + }, + }) + if err != nil { + return nil, fmt.Errorf("get customer override: %w", err) + } + + featureMeters, err := s.featureService.ResolveFeatureMeters(ctx, input.Customer.Namespace, chargesByType.usageBased.GetFeatureKeysOrIDs()...) + if err != nil { + return nil, fmt.Errorf("resolve feature meters: %w", err) + } + + for _, charge := range chargesByType.usageBased { + advancedCharge, err := s.usageBasedService.AdvanceCharge(ctx, usagebased.AdvanceChargeInput{ + ChargeID: charge.GetChargeID(), + CustomerOverride: customerOverride, + FeatureMeters: featureMeters, + }) + if err != nil { + return nil, fmt.Errorf("advance usage based charge %s: %w", charge.ID, err) + } + + if advancedCharge == nil { + continue + } + + advancedCharges = append(advancedCharges, charges.NewCharge(*advancedCharge)) + } + } + + currencies, err := collectCurrencies(advancedCharges) + if err != nil { + return nil, err + } + + if err := s.recognizeCustomerEarnings(ctx, input.Customer, currencies...); err != nil { + return nil, err + } + + return advancedCharges, nil + }) + if err != nil { + return nil, err + } + + return advancedCharges, nil +} + +func collectCurrencies(chargeList charges.Charges) ([]currencyx.Code, error) { + out := make([]currencyx.Code, 0, len(chargeList)) + + for _, c := range chargeList { + currency, err := c.GetCurrency() + if err != nil { + return nil, fmt.Errorf("get charge currency: %w", err) + } + + out = append(out, currency) + } + + return lo.Uniq(out), nil +} diff --git a/billing/charges/service/advance_test.go b/billing/charges/service/advance_test.go new file mode 100644 index 0000000000000000000000000000000000000000..61c3bdf4c8d4b1d8c4d8082635d59a6271b0dc11 --- /dev/null +++ b/billing/charges/service/advance_test.go @@ -0,0 +1,238 @@ +package service + +import ( + "testing" + "time" + + "github.com/alpacahq/alpacadecimal" + "github.com/samber/lo" + "github.com/stretchr/testify/suite" + + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/billing/charges" + "github.com/openmeterio/openmeter/openmeter/billing/charges/meta" + "github.com/openmeterio/openmeter/openmeter/productcatalog" + "github.com/openmeterio/openmeter/pkg/clock" + "github.com/openmeterio/openmeter/pkg/datetime" + "github.com/openmeterio/openmeter/pkg/timeutil" +) + +func TestAdvanceCharges(t *testing.T) { + suite.Run(t, new(AdvanceChargesTestSuite)) +} + +type AdvanceChargesTestSuite struct { + BaseSuite +} + +func (s *AdvanceChargesTestSuite) SetupSuite() { + s.BaseSuite.SetupSuite() +} + +func (s *AdvanceChargesTestSuite) TearDownTest() { + s.BaseSuite.TearDownTest() +} + +func (s *AdvanceChargesTestSuite) TestAdvanceChargesReturnsEmptyForAlreadyActiveCreditCharges() { + ctx := s.T().Context() + ns := s.GetUniqueNamespace("charges-service-advance-usage-only") + s.ProvisionDefaultTaxCodes(ctx, ns) + + cust := s.CreateTestCustomer(ns, "test-subject") + s.NotEmpty(cust.ID) + + sandboxApp := s.InstallSandboxApp(s.T(), ns) + _ = s.ProvisionBillingProfile(ctx, ns, sandboxApp.GetID()) + + apiRequestsTotal := s.SetupApiRequestsTotalFeature(ctx, ns) + + servicePeriod := timeutil.ClosedPeriod{ + From: datetime.MustParseTimeInLocation(s.T(), "2026-01-01T00:00:00Z", time.UTC).AsTime(), + To: datetime.MustParseTimeInLocation(s.T(), "2026-02-01T00:00:00Z", time.UTC).AsTime(), + } + + clock.SetTime(servicePeriod.From) + + createdCharges, err := s.Charges.Create(ctx, charges.CreateInput{ + Namespace: ns, + Intents: charges.ChargeIntents{ + s.createMockChargeIntent(createMockChargeIntentInput{ + customer: cust.GetID(), + currency: USD, + servicePeriod: servicePeriod, + settlementMode: productcatalog.CreditThenInvoiceSettlementMode, + price: productcatalog.NewPriceFrom(productcatalog.FlatPrice{ + Amount: alpacadecimal.NewFromFloat(100), + PaymentTerm: productcatalog.InAdvancePaymentTerm, + }), + name: "flat-fee", + managedBy: billing.SubscriptionManagedLine, + uniqueReferenceID: "flat-fee", + }), + s.createMockChargeIntent(createMockChargeIntentInput{ + customer: cust.GetID(), + currency: USD, + servicePeriod: servicePeriod, + settlementMode: productcatalog.CreditOnlySettlementMode, + price: productcatalog.NewPriceFrom(productcatalog.UnitPrice{ + Amount: alpacadecimal.NewFromFloat(100), + }), + name: "usage-based", + managedBy: billing.SubscriptionManagedLine, + uniqueReferenceID: "usage-based", + featureKey: apiRequestsTotal.Feature.Key, + }), + }, + }) + s.NoError(err) + s.Len(createdCharges, 2) + + // Create auto-advances credit-then-invoice flat fee charges that start now. + flatFeeCharge, err := createdCharges[0].AsFlatFeeCharge() + s.NoError(err) + s.Equal(meta.ChargeStatusActive, meta.ChargeStatus(flatFeeCharge.Status)) + + // Create auto-advances credit-only usage-based charges: the returned charge is already active. + usageBasedCharge, err := createdCharges[1].AsUsageBasedCharge() + s.NoError(err) + s.Equal(meta.ChargeStatusActive, meta.ChargeStatus(usageBasedCharge.Status)) + s.NotNil(usageBasedCharge.State.AdvanceAfter) + s.True(servicePeriod.To.Equal(*usageBasedCharge.State.AdvanceAfter)) + + // AdvanceCharges is a noop: both charges are already active and not yet past the service period. + advancedCharges, err := s.Charges.AdvanceCharges(ctx, charges.AdvanceChargesInput{ + Customer: cust.GetID(), + }) + s.NoError(err) + s.Empty(advancedCharges) + + fetchedFlatFee := s.mustGetChargeByID(lo.Must(createdCharges[0].GetChargeID())) + s.Equal(meta.ChargeTypeFlatFee, fetchedFlatFee.Type()) + fetchedFlatFeeCharge, err := fetchedFlatFee.AsFlatFeeCharge() + s.NoError(err) + s.Equal(flatFeeCharge.Status, fetchedFlatFeeCharge.Status) + + // DB state matches what Create returned. + fetchedUsageBased := s.mustGetChargeByID(usageBasedCharge.GetChargeID()) + usageBasedFromDB, err := fetchedUsageBased.AsUsageBasedCharge() + s.NoError(err) + s.Equal(usageBasedCharge.Status, usageBasedFromDB.Status) + s.NotNil(usageBasedFromDB.State.AdvanceAfter) + s.True(servicePeriod.To.Equal(*usageBasedFromDB.State.AdvanceAfter)) +} + +func (s *AdvanceChargesTestSuite) TestAdvanceChargesActivatesCreditThenInvoiceFlatFeeAtServicePeriodStart() { + ctx := s.T().Context() + ns := s.GetUniqueNamespace("charges-service-advance-empty") + s.ProvisionDefaultTaxCodes(ctx, ns) + + cust := s.CreateTestCustomer(ns, "test-subject") + s.NotEmpty(cust.ID) + + sandboxApp := s.InstallSandboxApp(s.T(), ns) + _ = s.ProvisionBillingProfile(ctx, ns, sandboxApp.GetID()) + + servicePeriod := timeutil.ClosedPeriod{ + From: datetime.MustParseTimeInLocation(s.T(), "2026-03-01T00:00:00Z", time.UTC).AsTime(), + To: datetime.MustParseTimeInLocation(s.T(), "2026-04-01T00:00:00Z", time.UTC).AsTime(), + } + + clock.SetTime(servicePeriod.From.Add(-time.Second)) + + _, err := s.Charges.Create(ctx, charges.CreateInput{ + Namespace: ns, + Intents: charges.ChargeIntents{ + s.createMockChargeIntent(createMockChargeIntentInput{ + customer: cust.GetID(), + currency: USD, + servicePeriod: servicePeriod, + settlementMode: productcatalog.CreditThenInvoiceSettlementMode, + price: productcatalog.NewPriceFrom(productcatalog.FlatPrice{ + Amount: alpacadecimal.NewFromFloat(100), + PaymentTerm: productcatalog.InAdvancePaymentTerm, + }), + name: "flat-fee-only", + managedBy: billing.SubscriptionManagedLine, + uniqueReferenceID: "flat-fee-only", + }), + }, + }) + s.NoError(err) + + clock.SetTime(servicePeriod.From) + + advancedCharges, err := s.Charges.AdvanceCharges(ctx, charges.AdvanceChargesInput{ + Customer: cust.GetID(), + }) + s.NoError(err) + s.Len(advancedCharges, 1) + + flatFeeCharge, err := advancedCharges[0].AsFlatFeeCharge() + s.NoError(err) + s.Equal(meta.ChargeStatusActive, meta.ChargeStatus(flatFeeCharge.Status)) +} + +func (s *AdvanceChargesTestSuite) TestAdvanceChargesActivatesCreditThenInvoiceUsageBasedChargesAtServicePeriodStart() { + ctx := s.T().Context() + ns := s.GetUniqueNamespace("charges-service-advance-credit-then-invoice") + s.ProvisionDefaultTaxCodes(ctx, ns) + + cust := s.CreateTestCustomer(ns, "test-subject") + s.NotEmpty(cust.ID) + + sandboxApp := s.InstallSandboxApp(s.T(), ns) + _ = s.ProvisionBillingProfile(ctx, ns, sandboxApp.GetID()) + + apiRequestsTotal := s.SetupApiRequestsTotalFeature(ctx, ns) + + servicePeriod := timeutil.ClosedPeriod{ + From: datetime.MustParseTimeInLocation(s.T(), "2026-05-01T00:00:00Z", time.UTC).AsTime(), + To: datetime.MustParseTimeInLocation(s.T(), "2026-06-01T00:00:00Z", time.UTC).AsTime(), + } + + clock.SetTime(servicePeriod.From) + + createdCharges, err := s.Charges.Create(ctx, charges.CreateInput{ + Namespace: ns, + Intents: charges.ChargeIntents{ + s.createMockChargeIntent(createMockChargeIntentInput{ + customer: cust.GetID(), + currency: USD, + servicePeriod: servicePeriod, + settlementMode: productcatalog.CreditThenInvoiceSettlementMode, + price: productcatalog.NewPriceFrom(productcatalog.UnitPrice{ + Amount: alpacadecimal.NewFromFloat(100), + }), + name: "usage-based-cti", + managedBy: billing.SubscriptionManagedLine, + uniqueReferenceID: "usage-based-cti", + featureKey: apiRequestsTotal.Feature.Key, + }), + }, + }) + s.NoError(err) + s.Len(createdCharges, 1) + + usageBasedChargeID, err := createdCharges[0].GetChargeID() + s.NoError(err) + + advancedCharges, err := s.Charges.AdvanceCharges(ctx, charges.AdvanceChargesInput{ + Customer: cust.GetID(), + }) + s.NoError(err) + s.Len(advancedCharges, 1) + s.Equal(meta.ChargeTypeUsageBased, advancedCharges[0].Type()) + + advancedCharge, err := advancedCharges[0].AsUsageBasedCharge() + s.NoError(err) + s.Equal(meta.ChargeStatusActive, meta.ChargeStatus(advancedCharge.Status)) + s.NotNil(advancedCharge.State.AdvanceAfter) + s.True(servicePeriod.To.Equal(*advancedCharge.State.AdvanceAfter)) + + fetchedCharge := s.mustGetChargeByID(usageBasedChargeID) + usageBasedCharge, err := fetchedCharge.AsUsageBasedCharge() + s.NoError(err) + s.Equal(meta.ChargeStatusActive, meta.ChargeStatus(usageBasedCharge.Status)) + s.NotNil(usageBasedCharge.State.AdvanceAfter) + s.True(servicePeriod.To.Equal(*usageBasedCharge.State.AdvanceAfter)) +}