From 31842bf3f5469b0fc7ca27183f2c9d0cf7e97e62 Mon Sep 17 00:00:00 2001 From: ionutboangiu Date: Fri, 5 Apr 2024 20:49:40 +0300 Subject: [PATCH] Add possibility to set/overwrite balance Factors through API Constructor looks inside the params' balance map for the Factors key. It expects either a string representing a JSON serialized map or the map itself. BalanceFilter Clone function has been updated to set a deep copy of the original Factors map instead of a shallow one. BalanceFilter getter function for Factors now returns nil instead of an empty map. It's slightly more memory efficient and assignment to this map will not be attempted, so it's panic proof. BalanceFilter.ModifyBalance now updates Factors only if the key is found in the request params' Balance map. Setting Factors to null is also possible as long as the Factors key exists and is set to null. Note: only *set_balance can overwrite the Factors map, all the others can only set it if the balance does exist prior to sending the request. Update balance integration tests. --- engine/action.go | 2 + engine/balance_filter.go | 72 ++++++++++----- engine/balances.go | 2 +- general_tests/balance_it_test.go | 150 ++++++++++++++++++++++++------- 4 files changed, 170 insertions(+), 56 deletions(-) diff --git a/engine/action.go b/engine/action.go index cd5c26070..47be2350e 100644 --- a/engine/action.go +++ b/engine/action.go @@ -1145,6 +1145,8 @@ func (cdrP *cdrLogProvider) FieldAsInterface(fldPath []string) (data any, err er data = cdrP.action.Balance.Categories.String() case utils.SharedGroups: data = cdrP.action.Balance.SharedGroups.String() + case utils.Factors: + data = cdrP.action.Balance.Factors.String() } case utils.MetaAct: switch fldPath[1] { diff --git a/engine/balance_filter.go b/engine/balance_filter.go index ca75caa9e..cbe946af3 100644 --- a/engine/balance_filter.go +++ b/engine/balance_filter.go @@ -19,6 +19,7 @@ along with this program. If not, see package engine import ( + "encoding/json" "fmt" "math" "reflect" @@ -47,8 +48,8 @@ type BalanceFilter struct { } // NewBalanceFilter creates a new BalanceFilter based on given filter -func NewBalanceFilter(filter map[string]any, defaultTimezone string) (bf *BalanceFilter, err error) { - bf = new(BalanceFilter) +func NewBalanceFilter(filter map[string]any, defaultTimezone string) (*BalanceFilter, error) { + bf := new(BalanceFilter) if id, has := filter[utils.ID]; has { bf.ID = utils.StringPointer(utils.IfaceAsString(id)) } @@ -59,23 +60,23 @@ func NewBalanceFilter(filter map[string]any, defaultTimezone string) (bf *Balanc // bf.Type = utils.StringPointer(utils.IfaceAsString(ty)) // } if val, has := filter[utils.Value]; has { - var value float64 - if value, err = utils.IfaceAsTFloat64(val); err != nil { - return + value, err := utils.IfaceAsTFloat64(val) + if err != nil { + return nil, err } bf.Value = &utils.ValueFormula{Static: math.Abs(value)} } if exp, has := filter[utils.ExpiryTime]; has { - var expTime time.Time - if expTime, err = utils.IfaceAsTime(exp, defaultTimezone); err != nil { - return + expTime, err := utils.IfaceAsTime(exp, defaultTimezone) + if err != nil { + return nil, err } bf.ExpirationDate = utils.TimePointer(expTime) } if weight, has := filter[utils.Weight]; has { - var value float64 - if value, err = utils.IfaceAsFloat64(weight); err != nil { - return + value, err := utils.IfaceAsFloat64(weight) + if err != nil { + return nil, err } bf.Weight = utils.Float64Pointer(value) } @@ -95,20 +96,39 @@ func NewBalanceFilter(filter map[string]any, defaultTimezone string) (bf *Balanc bf.TimingIDs = utils.StringMapPointer(utils.ParseStringMap(utils.IfaceAsString(tim))) } if dis, has := filter[utils.Disabled]; has { - var value bool - if value, err = utils.IfaceAsBool(dis); err != nil { - return + value, err := utils.IfaceAsBool(dis) + if err != nil { + return nil, err } bf.Disabled = utils.BoolPointer(value) } if blk, has := filter[utils.Blocker]; has { - var value bool - if value, err = utils.IfaceAsBool(blk); err != nil { - return + value, err := utils.IfaceAsBool(blk) + if err != nil { + return nil, err } bf.Blocker = utils.BoolPointer(value) } - return + if facVal, has := filter[utils.Factors]; has { + var err error + var facBytes []byte + + switch v := facVal.(type) { + case string: + facBytes = []byte(v) + default: + facBytes, err = json.Marshal(v) + if err != nil { + return nil, err + } + } + var vf ValueFactors + if err := json.Unmarshal(facBytes, &vf); err != nil { + return nil, err + } + bf.Factors = &vf + } + return bf, nil } func (bp *BalanceFilter) CreateBalance() *Balance { @@ -125,7 +145,7 @@ func (bp *BalanceFilter) CreateBalance() *Balance { Timings: bp.Timings, TimingIDs: bp.GetTimingIDs(), Disabled: bp.GetDisabled(), - Factors: bp.GetFactor(), + Factors: bp.GetFactors(), Blocker: bp.GetBlocker(), } return b.Clone() @@ -187,8 +207,11 @@ func (bf *BalanceFilter) Clone() *BalanceFilter { *result.Disabled = *bf.Disabled } if bf.Factors != nil { - result.Factors = new(ValueFactors) - *result.Factors = *bf.Factors + cln := make(ValueFactors, len(*bf.Factors)) + for key, value := range *bf.Factors { + cln[key] = value + } + result.Factors = &cln } if bf.Blocker != nil { result.Blocker = new(bool) @@ -359,9 +382,9 @@ func (bp *BalanceFilter) GetExpirationDate() time.Time { return *bp.ExpirationDate } -func (bp *BalanceFilter) GetFactor() ValueFactors { +func (bp *BalanceFilter) GetFactors() ValueFactors { if bp == nil || bp.Factors == nil { - return ValueFactors{} + return nil } return *bp.Factors } @@ -408,6 +431,9 @@ func (bf *BalanceFilter) ModifyBalance(b *Balance) { if bf.Weight != nil { b.Weight = *bf.Weight } + if bf.Factors != nil { + b.Factors = *bf.Factors + } if bf.Blocker != nil { b.Blocker = *bf.Blocker } diff --git a/engine/balances.go b/engine/balances.go index 58c14fd4c..238ddb537 100644 --- a/engine/balances.go +++ b/engine/balances.go @@ -851,7 +851,7 @@ func (bl *BalanceSummary) FieldAsInterface(fldPath []string) (val any, err error } } -// debitUnits will debit units for call descriptor. +// debit will debit units for call descriptor. // returns the amount debited within cc func (b *Balance) debit(cd *CallDescriptor, ub *Account, moneyBalances Balances, count, dryRun, debitConnectFee, isUnitBal bool, fltrS *FilterS) (cc *CallCost, err error) { diff --git a/general_tests/balance_it_test.go b/general_tests/balance_it_test.go index 5e31eca1d..65bc21adb 100644 --- a/general_tests/balance_it_test.go +++ b/general_tests/balance_it_test.go @@ -25,6 +25,7 @@ import ( "time" "github.com/cgrates/birpc/context" + v1 "github.com/cgrates/cgrates/apier/v1" "github.com/cgrates/cgrates/engine" "github.com/cgrates/cgrates/sessions" "github.com/cgrates/cgrates/utils" @@ -267,8 +268,10 @@ cgrates.org,sms,1001,2014-01-14T00:00:00Z,RP_ANY,`, // unit were subtracted from the *monetary balance. // 4. Try to refund the debit made in the previous step to check whether factor is taken // into consideration for refunds as well. -// 5. Do the above steps also for SessionSv1.ProcessCDR with a different usage. -// 6. Initiate a prepaid session (usage 10s), update it twice (usages 5s and 2s), terminate, +// 5. Do the above steps also for SessionSv1.ProcessCDR with a different usage and a different +// factor (that we overwrite through the APIerSv1.SetBalance API). +// 6. Set a *voice balance with 100s. Initiate a prepaid session (usage 10s), update it twice +// (usages 5s and 2s), terminate, // and process CDR. // 7. Check to see if balance_voice was debitted 34s ((10s+5s+2s) * voiceFactor, where // voiceFactor is 2) and then also check if it applies correctly for refund (similar @@ -306,7 +309,8 @@ func TestBalanceFactor(t *testing.T) { }, "schedulers": { - "enabled": true + "enabled": true, + "cdrs_conns": ["*internal"] }, "apiers": { @@ -334,7 +338,6 @@ cgrates.org,1001,PACKAGE_1001,,,`, PACKAGE_1001,ACT_TOPUP,*asap,10`, 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,*topup_reset,"{""smsFactor"":4}",,balance_sms,*sms,,,,,*unlimited,,10,20,false,false,20 -ACT_TOPUP,*topup_reset,"{""voiceFactor"":2}",,balance_voice,*voice,call,,,,*unlimited,,100s,20,false,false,20 ACT_TOPUP,*topup_reset,,,balance_monetary,*monetary,,*any,,,*unlimited,,5,10,false,false,20`, utils.ChargersCsv: `#Id,ActionsId,TimingId,Weight #Tenant,ID,FilterIDs,ActivationInterval,RunID,AttributeIDs,Weight @@ -373,23 +376,18 @@ cgrates.org,call,1001,2014-01-14T00:00:00Z,RP_VOICE,`, if err := client.Call(context.Background(), utils.APIerSv2GetAccount, attrs, &acnt); err != nil { t.Fatal(err) } - if len(acnt.BalanceMap) != 3 || + if len(acnt.BalanceMap) != 2 || len(acnt.BalanceMap[utils.MetaMonetary]) != 1 || - len(acnt.BalanceMap[utils.MetaSMS]) != 1 || - len(acnt.BalanceMap[utils.MetaVoice]) != 1 { - t.Fatalf("expected account to have one balance of type *monetary, one of type *sms and one of type *voice, received %v", acnt) + len(acnt.BalanceMap[utils.MetaSMS]) != 1 { + t.Fatalf("expected account to have one balance of type *monetary and one of type *sms received %v", acnt) } smsBalance := acnt.BalanceMap[utils.MetaSMS][0] if smsBalance.ID != "balance_sms" || smsBalance.Value != 10 { - t.Fatalf("received account with unexpected *sms balance: %v", smsBalance) + t.Fatalf("*sms balance value: want %v, got %v", 10, smsBalance) } monetaryBalance := acnt.BalanceMap[utils.MetaMonetary][0] if monetaryBalance.ID != "balance_monetary" || monetaryBalance.Value != 5 { - t.Fatalf("received account with unexpected *monetary balance: %v", monetaryBalance) - } - voiceBalance := acnt.BalanceMap[utils.MetaVoice][0] - if voiceBalance.ID != "balance_voice" || voiceBalance.Value != float64(100*time.Second) { - t.Fatalf("received account with unexpected *voice balance: %v", voiceBalance) + t.Fatalf("*monetary balance value: want %v, got %v", 5, monetaryBalance) } }) @@ -431,19 +429,23 @@ cgrates.org,call,1001,2014-01-14T00:00:00Z,RP_VOICE,`, if len(cdrs) != 1 { t.Fatalf("expected to receive only one CDR: %v", utils.ToJSON(cdrs)) } - smsBalanceValue, err := cdrs[0].CostDetails.FieldAsInterface([]string{"AccountSummary", "BalanceSummaries[0]", "Value"}) + smsBalanceValue, err := cdrs[0].CostDetails.FieldAsInterface([]string{ + "AccountSummary", "BalanceSummaries", "balance_sms", "Value", + }) if err != nil { t.Fatalf("could not retrieve *sms balance current value: %v", err) } if smsBalanceValue != 2. { - t.Errorf("unexpected balance value: expected %v, received %v", 2., smsBalanceValue) + t.Errorf("*sms balance value: want %v, got %v", 2, smsBalanceValue) } - monetaryBalanceValue, err := cdrs[0].CostDetails.FieldAsInterface([]string{"AccountSummary", "BalanceSummaries[2]", "Value"}) + monetaryBalanceValue, err := cdrs[0].CostDetails.FieldAsInterface([]string{ + "AccountSummary", "BalanceSummaries", "balance_monetary", "Value", + }) if err != nil { t.Fatalf("could not retrieve *sms balance current value: %v", err) } if monetaryBalanceValue != 3. { - t.Errorf("unexpected balance value: expected %v, received %v", 3., monetaryBalanceValue) + t.Errorf("monetary balance value: want %v, got %v", 3, monetaryBalanceValue) } // Attempt refund to check if factor also applies when refunding increments. @@ -472,18 +474,78 @@ cgrates.org,call,1001,2014-01-14T00:00:00Z,RP_VOICE,`, } smsBalance := acnt.BalanceMap[utils.MetaSMS][0] if smsBalance.ID != "balance_sms" || smsBalance.Value != 10 { - t.Fatalf("received account with unexpected *sms balance: %v", smsBalance) + t.Errorf("*sms balance: want %v, got %v", 10, smsBalance) } monetaryBalance := acnt.BalanceMap[utils.MetaMonetary][0] if monetaryBalance.ID != "balance_monetary" || monetaryBalance.Value != 5 { - t.Fatalf("received account with unexpected *monetary balance: %v", monetaryBalance) + t.Errorf("*monetary balance: want %v, got %v", 5, monetaryBalance) } - smsBalanceFactor, err := cdrs[0].CostDetails.FieldAsInterface([]string{"AccountSummary", "BalanceSummaries[0]", "Factors", "smsFactor"}) + smsBalanceFactor, err := cdrs[0].CostDetails.FieldAsInterface([]string{ + "AccountSummary", "BalanceSummaries", "balance_sms", "Factors", "smsFactor", + }) if err != nil { t.Fatalf("could not retrieve *sms balance factor: %v", err) } if smsBalanceFactor != 4. { - t.Errorf("unexpected balance factor: expected %v, received %v", 4., smsBalanceValue) + t.Errorf("balance factor: want %v, got %v", 4, smsBalanceValue) + } + }) + + t.Run("AlterBalanceFactorThroughAPI", func(t *testing.T) { + // Decrease the smsFactor from balance_sms from 4 to 3. + var replySetBalance string + if err := client.Call(context.Background(), utils.APIerSv1SetBalance, + &utils.AttrSetBalance{ + Tenant: "cgrates.org", + Account: "1001", + BalanceType: utils.MetaSMS, + // Value: 100_000_000_000, // 100s + // Value: 20, + Balance: map[string]any{ + utils.ID: "balance_sms", + utils.Factors: map[string]float64{ + "smsFactor": 3, + }, + }, + ActionExtraData: &map[string]any{ + "BalanceID": "~*acnt.BalanceID", + "NewFactors": "~*acnt.Factors", + }, + Cdrlog: true, + }, &replySetBalance); err != nil { + t.Error(err) + } + + var cdrs []*engine.CDR + if err := client.Call(context.Background(), utils.CDRsV1GetCDRs, &utils.RPCCDRsFilterWithAPIOpts{ + RPCCDRsFilter: &utils.RPCCDRsFilter{ + RunIDs: []string{"*set_balance"}, + }}, &cdrs); err != nil { + t.Fatal(err) + } + if len(cdrs) != 1 || + cdrs[0].Cost != 0 || + cdrs[0].ExtraFields[utils.BalanceID] != "balance_sms" || + cdrs[0].ExtraFields["NewFactors"] != "{\"smsFactor\":3}" || + cdrs[0].RunID != utils.MetaSetBalance || + cdrs[0].Source != utils.CDRLog || + cdrs[0].ToR != utils.MetaSMS { + t.Errorf("unexpected cdr received: %v", utils.ToJSON(cdrs)) + } + + var acnt engine.Account + if err := client.Call(context.Background(), utils.APIerSv2GetAccount, + &utils.AttrGetAccount{ + Tenant: "cgrates.org", + Account: "1001", + }, &acnt); err != nil { + t.Error(err) + } + updatedSMSBalance := acnt.BalanceMap[utils.MetaSMS][0] + if updatedSMSBalance.ID != "balance_sms" || + updatedSMSBalance.Value != 10 || + updatedSMSBalance.Factors["smsFactor"] != 3 { + t.Fatalf("updated balance_sms: want Value 10 and smsFactor 3, got %v", updatedSMSBalance) } }) @@ -522,23 +584,47 @@ cgrates.org,call,1001,2014-01-14T00:00:00Z,RP_VOICE,`, if len(cdrs) != 1 { t.Fatalf("expected to receive only one CDR: %v", utils.ToJSON(cdrs)) } - smsBalanceValue, err := cdrs[0].CostDetails.FieldAsInterface([]string{"AccountSummary", "BalanceSummaries[0]", "Value"}) + smsBalanceValue, err := cdrs[0].CostDetails.FieldAsInterface([]string{"AccountSummary", "BalanceSummaries", "balance_sms", "Value"}) if err != nil { t.Fatalf("could not retrieve *sms balance current value: %v", err) } - if smsBalanceValue != 2. { - t.Errorf("unexpected balance value: expected %v, received %v", 2., smsBalanceValue) + if smsBalanceValue != 1. { + t.Errorf("balance value: want %v, got %v", 1., smsBalanceValue) } - monetaryBalanceValue, err := cdrs[0].CostDetails.FieldAsInterface([]string{"AccountSummary", "BalanceSummaries[2]", "Value"}) + monetaryBalanceValue, err := cdrs[0].CostDetails.FieldAsInterface([]string{"AccountSummary", "BalanceSummaries", "balance_monetary", "Value"}) if err != nil { t.Fatalf("could not retrieve *sms balance current value: %v", err) } - if monetaryBalanceValue != 1. { - t.Errorf("unexpected balance value: expected %v, received %v", 1., monetaryBalanceValue) + if monetaryBalanceValue != 2. { + t.Errorf("balance value: want %v, got %v", 2., monetaryBalanceValue) } }) t.Run("PrepaidSession", func(t *testing.T) { + /* + Add balance via API. Should be equivalent to the following Actions.csv entry: + ACT_TOPUP,*topup_reset,"{""voiceFactor"":2}",,balance_voice,*voice,call,,,,*unlimited,,100s,20,false,false,20 + */ + var replyAddBalance string + if err := client.Call(context.Background(), utils.APIerSv1AddBalance, + &v1.AttrAddBalance{ + Tenant: "cgrates.org", + Account: "1001", + BalanceType: utils.MetaVoice, + Value: 100_000_000_000, // 100s + Balance: map[string]any{ + utils.ID: "balance_voice", + utils.Categories: "call", + utils.ExpiryTime: utils.MetaUnlimited, + utils.Weight: 20, + utils.Factors: map[string]float64{ + "voiceFactor": 2, + }, + }, + Overwrite: true, + }, &replyAddBalance); err != nil { + t.Error(err) + } var replyInit sessions.V1InitSessionReply if err := client.Call(context.Background(), utils.SessionSv1InitiateSession, &sessions.V1InitSessionArgs{ @@ -688,12 +774,12 @@ cgrates.org,call,1001,2014-01-14T00:00:00Z,RP_VOICE,`, if len(cdrs) != 1 { t.Fatalf("expected to receive only one CDR: %v", utils.ToJSON(cdrs)) } - voiceBalanceValue, err := cdrs[0].CostDetails.FieldAsInterface([]string{"AccountSummary", "BalanceSummaries[1]", "Value"}) + voiceBalanceValue, err := cdrs[0].CostDetails.FieldAsInterface([]string{"AccountSummary", "BalanceSummaries", "balance_voice", "Value"}) if err != nil { t.Fatalf("could not retrieve *voice balance current value: %v", err) } if voiceBalanceValue != float64(66*time.Second) { - t.Errorf("unexpected balance value: expected %v, received %v", float64(66*time.Second), voiceBalanceValue) + t.Errorf("*voice balance value: want %v, got %v", float64(66*time.Second), voiceBalanceValue) } // Attempt refund to check if factor also applies when refunding increments. @@ -720,7 +806,7 @@ cgrates.org,call,1001,2014-01-14T00:00:00Z,RP_VOICE,`, } voiceBalance := acnt.BalanceMap[utils.MetaVoice][0] if voiceBalance.ID != "balance_voice" || voiceBalance.Value != float64(100*time.Second) { - t.Fatalf("received account with unexpected *voice balance: %v", voiceBalance) + t.Fatalf("*voice balance value: want %v, got %v", 100_000_000_000, voiceBalance) } }) } @@ -761,7 +847,7 @@ func TestBalanceCDRLog(t *testing.T) { "schedulers": { "enabled": true, - "cdrs_conns": ["*localhost"] + "cdrs_conns": ["*internal"] }, "apiers": {