Add reference value functionality to *transfer_balance action

The *transfer_balance action can now use a reference value to ensure
the destination balance reaches a specified amount. If the destination
balance exceeds the reference value, the excess is transferred back
to the source balance. If the destination balance is below the
reference value, the required amount is transferred from the source
balance to the destination balance to reach the specified reference
value. An error is returned if the transfer cannot achieve the
specified reference value.

Used by specifying DestinationReferenceValue inside ExtraParameters.

Other *transfer_balance changes:
- used json tags when unmarshaling ExtraParameters in order to be
able to shorten the names of the fields
- lock the destination account only if it's different from the
source account. It is still passed to the Guard function but
without a lock key and with 0 timeout.
- if the transfer happens within the same account, update the
account and execute its ActionTriggers only once.
- moved transfer units validation after retrieving/creating the
destination balance

*cdrlog action has been updated to create cdrs for reference
*transfer_balance actions, although improvements are needed and
the functionality is not completely tested.

APIerSv1.TransferBalance has been updated to take into account the
ReferenceValue parameter.

Added new *transfer_balance action unit tests to account for the
new changes.

Added integration tests (incomplete for now, but functionality
has been tested manually).
This commit is contained in:
ionutboangiu
2024-06-13 14:51:30 +03:00
committed by Dan Christian Bogos
parent 1fd77cafa5
commit 49f6c5982e
5 changed files with 494 additions and 57 deletions

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -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",

View File

@@ -21,6 +21,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>
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
})
}

View File

@@ -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