diff --git a/apier/v1/accounts.go b/apier/v1/accounts.go index 8dc6d112c..256bc6645 100644 --- a/apier/v1/accounts.go +++ b/apier/v1/accounts.go @@ -19,7 +19,9 @@ along with this program. If not, see package v1 import ( + "encoding/json" "errors" + "fmt" "math" "slices" "strings" @@ -712,6 +714,92 @@ func (apierSv1 *APIerSv1) RemoveBalances(ctx *context.Context, attr *utils.AttrS return nil } +// TransferBalance sets a temporary *transfer_balance action, which will be executed immediately. +func (apierSv1 *APIerSv1) TransferBalance(ctx *context.Context, attr utils.AttrTransferBalance, reply *string) (err error) { + if missing := utils.MissingStructFields(&attr, []string{ + utils.SrcAccountID, utils.SrcBalanceID, + utils.DestAccountID, utils.DestBalanceID, + utils.Units, utils.BalanceType}); len(missing) != 0 { + return utils.NewErrMandatoryIeMissing(missing...) + } + if attr.Tenant == "" { + attr.Tenant = apierSv1.Config.GeneralCfg().DefaultTenant + } + + // Set *transfer_balance action. + actionID := fmt.Sprintf("tmp_act_%s", utils.UUIDSha1Prefix()) + if !attr.Overwrite { + var exists bool + if exists, err = apierSv1.DataManager.HasData(utils.ActionPrefix, actionID, ""); err != nil { + return utils.NewErrServerError(err) + } else if exists { + return utils.ErrExists + } + } + var extraParams []byte + extraParams, err = json.Marshal(map[string]string{ + utils.DestAccountID: attr.Tenant + ":" + attr.DestAccountID, + utils.DestBalanceID: attr.DestBalanceID, + }) + if err != nil { + return utils.NewErrServerError(err) + } + action := &engine.Action{ + Id: actionID, + ActionType: utils.MetaTransferBalance, + ExtraParameters: string(extraParams), + Balance: &engine.BalanceFilter{ + ID: utils.StringPointer(attr.SrcBalanceID), + Type: utils.StringPointer(attr.BalanceType), + Value: &utils.ValueFormula{Static: attr.Units}, + }, + } + if err = apierSv1.DataManager.SetActions(actionID, engine.Actions{action}); err != nil { + return utils.NewErrServerError(err) + } + + // Remove the action. + defer func() { + if removeErr := apierSv1.DataManager.RemoveActions(actionID); err != nil { + err = errors.Join(err, removeErr) + return + } + if reloadCacheErr := apierSv1.ConnMgr.Call(context.TODO(), apierSv1.Config.ApierCfg().CachesConns, + utils.CacheSv1ReloadCache, &utils.AttrReloadCacheWithAPIOpts{ + ActionIDs: []string{actionID}, + }, reply); reloadCacheErr != nil { + err = errors.Join(err, reloadCacheErr) + return + } + if setLoadIDErr := apierSv1.DataManager.SetLoadIDs(map[string]int64{utils.CacheActions: time.Now().UnixNano()}); setLoadIDErr != nil { + err = errors.Join(err, setLoadIDErr) + return + } + *reply = utils.OK + }() + + if err = apierSv1.ConnMgr.Call(context.TODO(), apierSv1.Config.ApierCfg().CachesConns, + utils.CacheSv1ReloadCache, &utils.AttrReloadCacheWithAPIOpts{ + ActionIDs: []string{actionID}, + }, reply); err != nil { + return utils.NewErrServerError(err) + } + //generate a loadID for CacheActions and store it in database + if err = apierSv1.DataManager.SetLoadIDs(map[string]int64{utils.CacheActions: time.Now().UnixNano()}); err != nil { + return utils.NewErrServerError(err) + } + + // Execute the action. + at := &engine.ActionTiming{ + ActionsID: actionID, + } + at.SetAccountIDs(utils.StringMap{utils.ConcatenatedKey(attr.Tenant, attr.SrcAccountID): true}) + if err = at.Execute(apierSv1.FilterS); err != nil { + return utils.NewErrServerError(err) + } + return nil +} + func (apierSv1 *APIerSv1) GetAccountsCount(ctx *context.Context, attr *utils.TenantWithAPIOpts, reply *int) (err error) { tnt := attr.Tenant if tnt == utils.EmptyString { diff --git a/general_tests/transfer_balance_it_test.go b/general_tests/transfer_balance_it_test.go index 2151713a5..a933c2709 100644 --- a/general_tests/transfer_balance_it_test.go +++ b/general_tests/transfer_balance_it_test.go @@ -21,7 +21,6 @@ along with this program. If not, see package general_tests import ( - "bytes" "sort" "testing" @@ -81,13 +80,10 @@ ACT_TOPUP_DEST,*topup_reset,,,balance_dest,*monetary,,*any,,,*unlimited,,10,10,f ACT_TRANSFER,*transfer_balance,"{""DestAccountID"":""cgrates.org:ACC_DEST"",""DestBalanceID"":""balance_dest""}",,balance_src,*monetary,,,,,*unlimited,,4,,,,`, } - buf := &bytes.Buffer{} testEnv := TestEnvironment{ - Name: "TestTransferBalance", - // Encoding: *encoding, + Name: "TestTransferBalance", ConfigJSON: content, TpFiles: tpFiles, - LogBuffer: buf, } client, _, shutdown, err := testEnv.Setup(t, *waitRater) if err != nil { @@ -134,7 +130,7 @@ ACT_TRANSFER,*transfer_balance,"{""DestAccountID"":""cgrates.org:ACC_DEST"",""De } }) - t.Run("CheckFinalBalances", func(t *testing.T) { + t.Run("CheckBalancesAfterActionExecute", func(t *testing.T) { var acnts []*engine.Account if err := client.Call(context.Background(), utils.APIerSv2GetAccounts, &utils.AttrGetAccounts{ @@ -163,4 +159,49 @@ ACT_TRANSFER,*transfer_balance,"{""DestAccountID"":""cgrates.org:ACC_DEST"",""De t.Errorf("received account with unexpected balance: %v", balance) } }) + + t.Run("TransferBalanceByAPI", func(t *testing.T) { + var reply string + if err := client.Call(context.Background(), utils.APIerSv1TransferBalance, utils.AttrTransferBalance{ + Tenant: "cgrates.org", + SrcAccountID: "ACC_SRC", + SrcBalanceID: "balance_src", + DestAccountID: "ACC_DEST", + DestBalanceID: "balance_dest", + Units: 2, + BalanceType: utils.MetaMonetary, + }, &reply); err != nil { + t.Error(err) + } + }) + + t.Run("CheckBalancesAfterTransferBalanceAPI", func(t *testing.T) { + var acnts []*engine.Account + if err := client.Call(context.Background(), utils.APIerSv2GetAccounts, + &utils.AttrGetAccounts{ + Tenant: "cgrates.org", + }, &acnts); err != nil { + t.Error(err) + } + if len(acnts) != 2 { + t.Fatal("expecting 2 accounts to be retrieved") + } + sort.Slice(acnts, func(i, j int) bool { + return acnts[i].ID > acnts[j].ID + }) + if len(acnts[0].BalanceMap) != 1 || len(acnts[0].BalanceMap[utils.MetaMonetary]) != 1 { + t.Errorf("expected account to have only one balance of type *monetary, received %v", acnts[0]) + } + balance := acnts[0].BalanceMap[utils.MetaMonetary][0] + if balance.ID != "balance_src" || balance.Value != 4 { + t.Errorf("received account with unexpected balance: %v", balance) + } + if len(acnts[1].BalanceMap) != 1 || len(acnts[1].BalanceMap[utils.MetaMonetary]) != 1 { + t.Errorf("expected account to have only one balance of type *monetary, received %v", acnts[1]) + } + balance = acnts[1].BalanceMap[utils.MetaMonetary][0] + if balance.ID != "balance_dest" || balance.Value != 16 { + t.Errorf("received account with unexpected balance: %v", balance) + } + }) } diff --git a/utils/apitpdata.go b/utils/apitpdata.go index e81888f9c..c3192d2fc 100644 --- a/utils/apitpdata.go +++ b/utils/apitpdata.go @@ -879,6 +879,18 @@ type AttrBalance struct { Cdrlog bool } +type AttrTransferBalance struct { + Tenant string + SrcAccountID string + SrcBalanceID string + DestAccountID string + DestBalanceID string + Units float64 + BalanceType string + Overwrite bool + Cdrlog bool +} + // TPResourceProfile is used in APIs to manage remotely offline ResourceProfile type TPResourceProfile struct { TPid string diff --git a/utils/consts.go b/utils/consts.go index 978f8d051..1cae442f2 100644 --- a/utils/consts.go +++ b/utils/consts.go @@ -491,6 +491,10 @@ const ( EventSource = "EventSource" AccountID = "AccountID" AccountIDs = "AccountIDs" + SrcAccountID = "SrcAccountID" + DestAccountID = "DestAccountID" + SrcBalanceID = "SrcBalanceID" + DestBalanceID = "DestBalanceID" ResourceID = "ResourceID" TotalUsage = "TotalUsage" StatID = "StatID" @@ -1319,6 +1323,7 @@ const ( APIerSv1ExportToFolder = "APIerSv1.ExportToFolder" APIerSv1GetCost = "APIerSv1.GetCost" APIerSv1SetBalance = "APIerSv1.SetBalance" + APIerSv1TransferBalance = "APIerSv1.TransferBalance" APIerSv1GetFilter = "APIerSv1.GetFilter" APIerSv1GetFilterIndexes = "APIerSv1.GetFilterIndexes" APIerSv1RemoveFilterIndexes = "APIerSv1.RemoveFilterIndexes"