diff --git a/apier/v1/accounts.go b/apier/v1/accounts.go index dbfdba763..a12a0b345 100644 --- a/apier/v1/accounts.go +++ b/apier/v1/accounts.go @@ -714,7 +714,7 @@ func (apierSv1 *APIerSv1) RemoveBalances(ctx *context.Context, attr *utils.AttrS } // TransferBalance initiates a balance transfer between accounts, immediately executing the configured actions. -func (apierSv1 *APIerSv1) TransferBalance(ctx *context.Context, attr utils.AttrTransferBalance, reply *string) (err error) { +func (apierSv1 *APIerSv1) TransferBalance(ctx *context.Context, attr utils.AttrTransferBalance, reply *string) error { // Check for missing mandatory fields in the request attributes. if missing := utils.MissingStructFields(&attr, []string{ @@ -729,12 +729,18 @@ func (apierSv1 *APIerSv1) TransferBalance(ctx *context.Context, attr utils.AttrT attr.Tenant = apierSv1.Config.GeneralCfg().DefaultTenant } - // Marshal extra parameters including the destination account and balance ID. - var extraParams []byte - extraParams, err = json.Marshal(map[string]string{ - utils.DestinationAccountID: attr.Tenant + ":" + attr.DestinationAccountID, - utils.DestinationBalanceID: attr.DestinationBalanceID, - }) + // Marshal extra parameters including the destination account, balance ID, + // and optional reference value. + tmp := struct { + DestinationAccountID string + DestinationBalanceID string + DestinationReferenceValue *float64 + }{ + DestinationAccountID: attr.Tenant + ":" + attr.DestinationAccountID, + DestinationBalanceID: attr.DestinationBalanceID, + DestinationReferenceValue: attr.DestinationReferenceValue, + } + extraParams, err := json.Marshal(tmp) if err != nil { return utils.NewErrServerError(err) } diff --git a/engine/action.go b/engine/action.go index 47be2350e..223c6449d 100644 --- a/engine/action.go +++ b/engine/action.go @@ -152,9 +152,9 @@ func RegisterActionFunc(action string, f actionTypeFunc) { // transferBalanceAction transfers units between accounts' balances. // It ensures both source and destination balances are of the same type and non-expired. -// Destination account and balance IDs are obtained from Action's ExtraParameters. -// ExtraParameters should be a JSON string containing keys 'DestAccountID' and 'DestBalanceID', -// which identify the destination account and balance for the transfer. +// Destination account and balance IDs, and optionally a reference value, are obtained from Action's ExtraParameters. +// If a reference value is specified, the transfer ensures the destination balance reaches this value. +// If the destination account is different from the source, it is locked during the transfer. func transferBalanceAction(srcAcc *Account, act *Action, _ Actions, fltrS *FilterS, _ any, _ time.Time, _ ActionConnCfg) error { if srcAcc == nil { return errors.New("source account is nil") @@ -174,66 +174,92 @@ func transferBalanceAction(srcAcc *Account, act *Action, _ Actions, fltrS *Filte return errors.New("source balance not found or expired") } - transferUnits := act.Balance.GetValue() - if transferUnits == 0 { - return errors.New("balance value is missing or 0") - } - if srcBalance.ID != utils.MetaDefault && transferUnits > srcBalance.Value { - return utils.ErrInsufficientCredit - } - - accDestInfo := struct { - DestinationAccountID string - DestinationBalanceID string + destInfo := struct { + AccID string `json:"DestinationAccountID"` + BalID string `json:"DestinationBalanceID"` + RefVal *float64 `json:"DestinationReferenceValue"` }{} - if err := json.Unmarshal([]byte(act.ExtraParameters), &accDestInfo); err != nil { + if err := json.Unmarshal([]byte(act.ExtraParameters), &destInfo); err != nil { return err } + // Lock the destination account if different from source, otherwise + // pass without lock key and timeout. + diffAcnts := srcAcc.ID != destInfo.AccID + var lockTimeout time.Duration + lockKeys := make([]string, 0, 1) + if diffAcnts { + lockTimeout = config.CgrConfig().GeneralCfg().LockingTimeout + lockKeys = append(lockKeys, utils.AccountPrefix+destInfo.AccID) + } + // This guard is meant to lock the destination account as we are making changes - // to it. It was not needed for the source account due to it being locked from - // outside this function. + // to it. It is needed for the source account due to it being locked from outside + // this function. guardErr := guardian.Guardian.Guard(func() error { - destAcc, err := dm.GetAccount(accDestInfo.DestinationAccountID) - if err != nil { - return fmt.Errorf("retrieving destination account failed: %w", err) + + var destAcc *Account + switch diffAcnts { + case true: + var err error + if destAcc, err = dm.GetAccount(destInfo.AccID); err != nil { + return fmt.Errorf("retrieving destination account failed: %w", err) + } + case false: + destAcc = srcAcc } if destAcc.BalanceMap == nil { destAcc.BalanceMap = make(map[string]Balances) } - // We look for the destination balance only through balances of the same - // type as the source balance. - destBalance := destAcc.GetBalanceWithID(srcBalanceType, accDestInfo.DestinationBalanceID) + // Look for the destination balance only through balances of the same type as the source balance. + destBalance := destAcc.GetBalanceWithID(srcBalanceType, destInfo.BalID) if destBalance != nil && destBalance.IsExpiredAt(time.Now()) { return errors.New("destination balance expired") } if destBalance == nil { - // Destination Balance was not found. It will be - // created and added to the balance map. + // Destination Balance was not found. Create it and add it to the balance map. destBalance = &Balance{ - ID: accDestInfo.DestinationBalanceID, + ID: destInfo.BalID, Uuid: utils.GenUUID(), } destAcc.BalanceMap[srcBalanceType] = append(destAcc.BalanceMap[srcBalanceType], destBalance) } + // If DestinationReferenceValue is specified adjust transferUnits to make the + // destination balance match the DestinationReferenceValue. + transferUnits := act.Balance.GetValue() + if destInfo.RefVal != nil { + transferUnits = *destInfo.RefVal - destBalance.Value + } + if transferUnits == 0 { + return errors.New("transfer amount is missing or 0") + } + if srcBalance.ID != utils.MetaDefault && transferUnits > srcBalance.Value { + return fmt.Errorf("insufficient credits in source balance %q (account %q) for transfer of %.2f units", + srcBalance.ID, srcAcc.ID, transferUnits) + } + if destBalance.ID != utils.MetaDefault && -transferUnits > destBalance.Value { + return fmt.Errorf("insufficient credits in destination balance %q (account %q) for transfer of %.2f units", + destBalance.ID, destAcc.ID, transferUnits) + } + srcBalance.SubtractValue(transferUnits) srcBalance.dirty = true destBalance.AddValue(transferUnits) destBalance.dirty = true - destAcc.InitCounters() - destAcc.ExecuteActionTriggers(act, fltrS) - - if err := dm.SetAccount(destAcc); err != nil { - return fmt.Errorf("updating destination account failed: %w", err) + if diffAcnts { + destAcc.InitCounters() + destAcc.ExecuteActionTriggers(act, fltrS) + if err := dm.SetAccount(destAcc); err != nil { + return fmt.Errorf("updating destination account failed: %w", err) + } } return nil - }, config.CgrConfig().GeneralCfg().LockingTimeout, - utils.AccountPrefix+accDestInfo.DestinationAccountID) + }, lockTimeout, lockKeys...) if guardErr != nil { return guardErr } @@ -401,18 +427,40 @@ func cdrLogAction(acc *Account, a *Action, acs Actions, _ *FilterS, extraData an } continue case utils.MetaTransferBalance: - cdr.Cost = action.Balance.GetValue() cdr.Account = utils.SplitConcatenatedKey(acc.ID)[1] // Extract ID from TenantID. - accDestInfo := struct { - DestinationAccountID string - DestinationBalanceID string + destInfo := struct { + AccID string `json:"DestinationAccountID"` + BalID string `json:"DestinationBalanceID"` + RefVal *float64 `json:"DestinationReferenceValue"` }{} - if err := json.Unmarshal([]byte(action.ExtraParameters), &accDestInfo); err != nil { + if err := json.Unmarshal([]byte(action.ExtraParameters), &destInfo); err != nil { return err } - cdr.Destination = utils.SplitConcatenatedKey(accDestInfo.DestinationAccountID)[1] // Extract ID from TenantID. + + transferUnits := action.Balance.GetValue() + if destInfo.RefVal != nil { + tmpDestVal := 0.0 + _, srcBalanceType := acc.FindBalanceByID(*action.Balance.ID) + destAcc := acc + if acc.ID != destInfo.AccID { + destAcc, err = dm.GetAccount(destInfo.AccID) + if err != nil { + return fmt.Errorf("retrieving destination account failed: %w", err) + } + } + if destAcc.BalanceMap != nil { + destBalance := destAcc.GetBalanceWithID(srcBalanceType, destInfo.BalID) + if destBalance != nil { + tmpDestVal = destBalance.Value + } + } + transferUnits = *destInfo.RefVal - tmpDestVal + } + cdr.Cost = transferUnits + cdr.Destination = utils.SplitConcatenatedKey(destInfo.AccID)[1] // Extract ID from TenantID. cdr.ExtraFields[utils.SourceBalanceID] = *action.Balance.ID - cdr.ExtraFields[utils.DestinationBalanceID] = accDestInfo.DestinationBalanceID + cdr.ExtraFields[utils.DestinationBalanceID] = destInfo.BalID + cdr.ExtraFields[utils.DestinationAccountID] = destInfo.AccID } cdrs = append(cdrs, cdr) diff --git a/engine/actions_test.go b/engine/actions_test.go index 8745778bb..ce52b88ad 100644 --- a/engine/actions_test.go +++ b/engine/actions_test.go @@ -4274,6 +4274,169 @@ func TestActionsTransferBalance(t *testing.T) { expectedSrcBalance: 7, expectedDestBalance: 8, }, + { + name: "SuccessfulTransferNegativeSrcBalance", + srcAcc: &Account{ + ID: "cgrates.org:ACC_SRC", + BalanceMap: map[string]Balances{ + utils.MetaMonetary: { + { + ID: "*default", + Value: 2, + }, + }, + }, + }, + destAcc: &Account{ + ID: "cgrates.org:ACC_DEST", + BalanceMap: map[string]Balances{ + utils.MetaMonetary: { + { + ID: "BALANCE_DEST", + Value: 5, + }, + }, + }, + }, + act: &Action{ + Id: "ACT_TRANSFER_BALANCE", + ActionType: utils.MetaTransferBalance, + ExtraParameters: `{ + "DestinationAccountID": "cgrates.org:ACC_DEST", + "DestinationBalanceID": "BALANCE_DEST" + }`, + Balance: &BalanceFilter{ + ID: utils.StringPointer("*default"), + Value: &utils.ValueFormula{ + Static: 3, + }, + }, + }, + expectedSrcBalance: -1, + expectedDestBalance: 8, + }, + { + name: "SuccessfulTransferNegativeDestBalance", + srcAcc: &Account{ + ID: "cgrates.org:ACC_SRC", + BalanceMap: map[string]Balances{ + utils.MetaMonetary: { + { + ID: "BALANCE_SRC", + Value: 5, + }, + }, + }, + }, + destAcc: &Account{ + ID: "cgrates.org:ACC_DEST", + BalanceMap: map[string]Balances{ + utils.MetaMonetary: { + { + ID: "*default", + Value: 2, + }, + }, + }, + }, + act: &Action{ + Id: "ACT_TRANSFER_BALANCE", + ActionType: utils.MetaTransferBalance, + ExtraParameters: `{ + "DestinationAccountID": "cgrates.org:ACC_DEST", + "DestinationBalanceID": "*default" + }`, + Balance: &BalanceFilter{ + ID: utils.StringPointer("BALANCE_SRC"), + Value: &utils.ValueFormula{ + Static: -3, + }, + }, + }, + expectedSrcBalance: 8, + expectedDestBalance: -1, + }, + { + name: "SuccessfulTransferRefNegativeSrcBalance", + srcAcc: &Account{ + ID: "cgrates.org:ACC_SRC", + BalanceMap: map[string]Balances{ + utils.MetaMonetary: { + { + ID: "*default", + Value: 2, + }, + }, + }, + }, + destAcc: &Account{ + ID: "cgrates.org:ACC_DEST", + BalanceMap: map[string]Balances{ + utils.MetaMonetary: { + { + ID: "BALANCE_DEST", + Value: 5, + }, + }, + }, + }, + act: &Action{ + Id: "ACT_TRANSFER_BALANCE", + ActionType: utils.MetaTransferBalance, + ExtraParameters: `{ + "DestinationAccountID": "cgrates.org:ACC_DEST", + "DestinationBalanceID": "BALANCE_DEST", + "DestinationReferenceValue": 8 + }`, + Balance: &BalanceFilter{ + ID: utils.StringPointer("*default"), + }, + }, + expectedSrcBalance: -1, + expectedDestBalance: 8, + }, + { + name: "SuccessfulTransferRefNegativeDestBalance", + srcAcc: &Account{ + ID: "cgrates.org:ACC_SRC", + BalanceMap: map[string]Balances{ + utils.MetaMonetary: { + { + ID: "BALANCE_SRC", + Value: 10, + }, + }, + }, + }, + destAcc: &Account{ + ID: "cgrates.org:ACC_DEST", + BalanceMap: map[string]Balances{ + utils.MetaMonetary: { + { + ID: "*default", + Value: 5, + }, + }, + }, + }, + act: &Action{ + Id: "ACT_TRANSFER_BALANCE", + ActionType: utils.MetaTransferBalance, + ExtraParameters: `{ + "DestinationAccountID": "cgrates.org:ACC_DEST", + "DestinationBalanceID": "*default", + "DestinationReferenceValue": -0.01 + }`, + Balance: &BalanceFilter{ + ID: utils.StringPointer("BALANCE_SRC"), + Value: &utils.ValueFormula{ + Static: 3, + }, + }, + }, + expectedSrcBalance: 15.01, + expectedDestBalance: -0.01, + }, { name: "NilAccount", expectedErr: "source account is nil", @@ -4460,7 +4623,7 @@ func TestActionsTransferBalance(t *testing.T) { }, }, }, - expectedErr: "balance value is missing or 0", + expectedErr: "transfer amount is missing or 0", }, { name: "NotEnoughFundsInSourceBalance", @@ -4499,7 +4662,47 @@ func TestActionsTransferBalance(t *testing.T) { }, }, }, - expectedErr: "INSUFFICIENT_CREDIT", + expectedErr: `insufficient credits in source balance "BALANCE_SRC" (account "cgrates.org:ACC_SRC") for transfer of 3.00 units`, + }, + { + name: "NotEnoughFundsInDestBalance", + srcAcc: &Account{ + ID: "cgrates.org:ACC_SRC", + BalanceMap: map[string]Balances{ + utils.MetaMonetary: { + { + ID: "BALANCE_SRC", + Value: 1, + }, + }, + }, + }, + destAcc: &Account{ + ID: "cgrates.org:ACC_DEST", + BalanceMap: map[string]Balances{ + utils.MetaMonetary: { + { + ID: "BALANCE_DEST", + Value: 5, + }, + }, + }, + }, + act: &Action{ + Id: "ACT_TRANSFER_BALANCE", + ExtraParameters: `{ + "DestinationAccountID": "cgrates.org:ACC_DEST", + "DestinationBalanceID": "BALANCE_DEST", + "DestinationReferenceValue": -0.01 + }`, + Balance: &BalanceFilter{ + ID: utils.StringPointer("BALANCE_SRC"), + Value: &utils.ValueFormula{ // should be ignored (overwritten by the ReferenceValue) + Static: 3, + }, + }, + }, + expectedErr: `insufficient credits in destination balance "BALANCE_DEST" (account "cgrates.org:ACC_DEST") for transfer of -5.01 units`, }, { name: "DestinationBalanceFailedUpdate", diff --git a/general_tests/transfer_balance_it_test.go b/general_tests/transfer_balance_it_test.go index 424bc5e56..3a1c7cb04 100644 --- a/general_tests/transfer_balance_it_test.go +++ b/general_tests/transfer_balance_it_test.go @@ -21,6 +21,7 @@ along with this program. If not, see package general_tests import ( + "fmt" "sort" "testing" "time" @@ -304,3 +305,181 @@ ACT_TRANSFER,*transfer_balance,"{""DestinationAccountID"":""cgrates.org:ACC_DEST } }) } + +func TestATExportAndTransfer(t *testing.T) { + switch *utils.DBType { + case utils.MetaInternal: + case utils.MetaMySQL, utils.MetaMongo, utils.MetaPostgres: + t.SkipNow() + default: + t.Fatal("unsupported dbtype value") + } + + content := `{ + +"general": { + "log_level": 7, + "reply_timeout": "30s" +}, + +"data_db": { + "db_type": "*internal" +}, + +"stor_db": { + "db_type": "*internal" +}, + +"cdrs": { + "enabled": true, + "rals_conns": ["*localhost"] +}, + +"rals": { + "enabled": true +}, + +"schedulers": { + "enabled": true, + "cdrs_conns": ["*internal"] +}, + +"apiers": { + "enabled": true, + "scheduler_conns": ["*internal"], + "ees_conns": ["*localhost"] +}, + +"ees": { + "enabled": true, + "exporters": [ + { + "id": "test_exporter", + "type": "*virt", + "flags": ["*log"], + "attempts": 1, + "synchronous": true + } + ] +} + +}` + + tpFiles := map[string]string{ + utils.AccountActionsCsv: `#Tenant,Account,ActionPlanId,ActionTriggersId,AllowNegative,Disabled +cgrates.org,1001,PACKAGE_1001,EMPTY_BALANCE_TRIGGER,,`, + utils.ActionPlansCsv: `#Id,ActionsId,TimingId,Weight +PACKAGE_1001,ACT_TOPUP_INITIAL,*asap, +#PACKAGE_1001,ACT_TOPUP_BUFFER,*asap, +#PACKAGE_1001,ACT_TOPUP_TRANSFER,*monthly,`, + utils.ActionsCsv: `#ActionsId[0],Action[1],ExtraParameters[2],Filter[3],BalanceId[4],BalanceType[5],Categories[6],DestinationIds[7],RatingSubject[8],SharedGroup[9],ExpiryTime[10],TimingIds[11],Units[12],BalanceWeight[13],BalanceBlocker[14],BalanceDisabled[15],Weight[16] +ACT_TOPUP_INITIAL,*topup_reset,,,main,*data,,,,,*unlimited,,11000,10,,,20 +ACT_TOPUP_INITIAL,*transfer_balance,"{""DestinationAccountID"":""cgrates.org:1001"",""DestinationBalanceID"":""buffer"",""DestinationReferenceValue"":10000}",,main,,,,,,,,,,,,10 +ACT_TOPUP_INITIAL,*cdrlog,"{""ToR"":""*data""}",,,,,,,,,,,,,,0 +ACT_EXPORT,*export,test_exporter,,,,,,,,,,,,,, +ACT_TOPUP_TRANSFER,*topup,,,main,*data,,,,,,,5000,,,,30 +ACT_TOPUP_TRANSFER,*transfer_balance,"{""DestinationAccountID"":""cgrates.org:1001"",""DestinationBalanceID"":""buffer"",""DestinationReferenceValue"":10000}",,main,,,,,,,,,,,,20 +ACT_TOPUP_TRANSFER,*cdrlog,"{""ToR"":""*data""}",,,,,,,,,,,,,,10 +ACT_TOPUP_TRANSFER,*reset_triggers,,,,,,,,,,,,,,,0`, + utils.ActionTriggersCsv: `#Tag[0],UniqueId[1],ThresholdType[2],ThresholdValue[3],Recurrent[4],MinSleep[5],ExpiryTime[6],ActivationTime[7],BalanceTag[8],BalanceType[9],BalanceCategories[10],BalanceDestinationIds[11],BalanceRatingSubject[12],BalanceSharedGroup[13],BalanceExpiryTime[14],BalanceTimingIds[15],BalanceWeight[16],BalanceBlocker[17],BalanceDisabled[18],ActionsId[19],Weight[20] +EMPTY_BALANCE_TRIGGER,,*min_balance,9999,false,0,,,buffer,*data,,,,,,,,,,ACT_EXPORT, +#EMPTY_BALANCE_TRIGGER,,*min_balance,0,false,0,,,buffer,*data,,,,,,,,,,ACT_EXPORT,`, + } + + testEnv := TestEnvironment{ + ConfigJSON: content, + TpFiles: tpFiles, + // LogBuffer: &bytes.Buffer{}, + } + // defer fmt.Println(testEnv.LogBuffer) + client, _ := testEnv.Setup(t, 0) + + t.Run("ProcessCDRs", func(t *testing.T) { + i := 0 + processCDRs := func(t *testing.T, count int, amount int) { + // fmt.Println("===========ProcessCDRs===========") + t.Helper() + var reply string + for range count { + i++ + if err := client.Call(context.Background(), utils.CDRsV1ProcessEvent, + &engine.ArgV1ProcessEvent{ + Flags: []string{utils.MetaRALs}, + CGREvent: utils.CGREvent{ + Tenant: "cgrates.org", + ID: fmt.Sprintf("event%d", i), + Event: map[string]any{ + utils.RunID: "*default", + utils.Tenant: "cgrates.org", + utils.Category: "data", + utils.ToR: utils.MetaData, + utils.OriginID: fmt.Sprintf("processCDR%d", i), + utils.OriginHost: "127.0.0.1", + utils.RequestType: utils.MetaPostpaid, + utils.AccountField: "1001", + utils.Destination: "1002", + utils.SetupTime: time.Date(2021, time.February, 2, 16, 14, 50, 0, time.UTC), + utils.AnswerTime: time.Date(2021, time.February, 2, 16, 15, 0, 0, time.UTC), + utils.Usage: amount, + }, + }, + }, &reply); err != nil { + t.Errorf("CDRsV1ProcessEvent(%d) err: %v", i, err) + } + } + } + + checkAccountAndCDRs := func(t *testing.T) { + // fmt.Println("===========CheckAcc===========") + t.Helper() + var acnts []*engine.Account + err := client.Call(context.Background(), utils.APIerSv2GetAccounts, + &utils.AttrGetAccounts{ + Tenant: "cgrates.org", + }, &acnts) + t.Logf("APIerSv2GetAccounts err: %v", err) + var cdrs []*engine.CDR + err = client.Call(context.Background(), utils.CDRsV1GetCDRs, &utils.RPCCDRsFilterWithAPIOpts{ + RPCCDRsFilter: &utils.RPCCDRsFilter{ + RunIDs: []string{utils.MetaTopUp, utils.MetaTransferBalance}, + }}, &cdrs) + t.Logf("CDRsV1GetCDRs err: %v", err) + + // fmt.Println(utils.ToJSON(acnts[0].BalanceMap["*data"])) + // fmt.Println(utils.ToJSON(cdrs)) + } + + executeAction := func(t *testing.T, id string) { + var reply string + attrsEA := &utils.AttrExecuteAction{Tenant: "cgrates.org", Account: "1001", ActionsId: id} + if err := client.Call(context.Background(), utils.APIerSv1ExecuteAction, attrsEA, &reply); err != nil { + t.Errorf("APIerSv1ExecuteAction err: %v", err) + } + } + + time.Sleep(100 * time.Millisecond) + checkAccountAndCDRs(t) // main 1000 buffer 10000 total 11000 + processCDRs(t, 2, 600) // -1200 + export + checkAccountAndCDRs(t) // main 0 buffer 9800 total 9800 + executeAction(t, "ACT_TOPUP_TRANSFER") // +5000 + checkAccountAndCDRs(t) // main 4800 buffer 10000 total 14800 + processCDRs(t, 8, 600) // -4800 (no export) + checkAccountAndCDRs(t) // main 0 buffer 10000 total 10000 + processCDRs(t, 1, 600) // -600 + export + checkAccountAndCDRs(t) // main 0 buffer 9400 total 9400 + + var reply string + if err := client.Call(context.Background(), utils.APIerSv1TransferBalance, utils.AttrTransferBalance{ + Tenant: "cgrates.org", + SourceAccountID: "1001", + SourceBalanceID: "main", + DestinationAccountID: "1001", + DestinationBalanceID: "buffer", + DestinationReferenceValue: utils.Float64Pointer(5000), + Cdrlog: true, + }, &reply); err != nil { + t.Error(err) + } + checkAccountAndCDRs(t) // main 4400 buffer 5000 total 9400 + }) +} diff --git a/utils/apitpdata.go b/utils/apitpdata.go index d85080e32..8547a40ce 100644 --- a/utils/apitpdata.go +++ b/utils/apitpdata.go @@ -880,14 +880,15 @@ type AttrBalance struct { } type AttrTransferBalance struct { - Tenant string - SourceAccountID string - SourceBalanceID string - DestinationAccountID string - DestinationBalanceID string - Units float64 - Cdrlog bool - APIOpts map[string]any + Tenant string + SourceAccountID string + SourceBalanceID string + DestinationAccountID string + DestinationBalanceID string + DestinationReferenceValue *float64 + Units float64 + Cdrlog bool + APIOpts map[string]any } // TPResourceProfile is used in APIs to manage remotely offline ResourceProfile