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"