diff --git a/api/billing/fees/.air.toml b/api/billing/fees/.air.toml index e1c05683..16f8c34b 100644 --- a/api/billing/fees/.air.toml +++ b/api/billing/fees/.air.toml @@ -1,32 +1,46 @@ -# Config file for Air in TOML format - -root = "./../.." +root = "." +testdata_dir = "testdata" tmp_dir = "tmp" [build] -cmd = "go build -o app -ldflags \"-X 'github.com/tech/sendico/billing/fees/internal/appversion.BuildUser=$(whoami)' -X 'github.com/tech/sendico/billing/fees/internal/appversion.Version=$APP_V' -X 'github.com/tech/sendico/billing/fees/internal/appversion.Branch=$BUILD_BRANCH' -X 'github.com/tech/sendico/billing/fees/internal/appversion.Revision=$GIT_REV' -X 'github.com/tech/sendico/billing/fees/internal/appversion.BuildDate=$(date)'\"" -bin = "./app" -full_bin = "./app --debug --config.file=config.yml" -include_ext = ["go", "yaml", "yml"] -exclude_dir = ["billing/fees/tmp", "pkg/.git", "billing/fees/env"] -exclude_regex = ["_test\\.go"] -exclude_unchanged = true -follow_symlink = true -log = "air.log" -delay = 0 -stop_on_error = true -send_interrupt = true -kill_delay = 500 -args_bin = [] - -[log] -time = false + args_bin = [] + entrypoint = "./tmp/main" + cmd = "go build -o ./tmp/main ." + delay = 1000 + exclude_dir = ["assets", "tmp", "vendor", "testdata"] + exclude_file = [] + exclude_regex = ["_test.go", "_templ.go"] + exclude_unchanged = false + follow_symlink = false + full_bin = "" + include_dir = [] + include_ext = ["go", "tpl", "tmpl", "html"] + include_file = [] + kill_delay = "0s" + log = "build-errors.log" + poll = false + poll_interval = 0 + post_cmd = [] + pre_cmd = [] + rerun = false + rerun_delay = 500 + send_interrupt = false + stop_on_error = false [color] -main = "magenta" -watcher = "cyan" -build = "yellow" -runner = "green" + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + main_only = false + time = false [misc] -clean_on_exit = true + clean_on_exit = false + +[screen] + clear_on_rebuild = false + keep_scroll = true diff --git a/api/billing/fees/config.dev.yml b/api/billing/fees/config.dev.yml new file mode 100644 index 00000000..cb96e107 --- /dev/null +++ b/api/billing/fees/config.dev.yml @@ -0,0 +1,42 @@ +runtime: + shutdown_timeout_seconds: 15 + +grpc: + network: tcp + address: ":50060" + advertise_host: "dev-billing-fees" + enable_reflection: true + enable_health: true + +metrics: + address: ":9402" + +database: + driver: mongodb + settings: + host_env: FEES_MONGO_HOST + port_env: FEES_MONGO_PORT + database_env: FEES_MONGO_DATABASE + user_env: FEES_MONGO_USER + password_env: FEES_MONGO_PASSWORD + auth_source_env: FEES_MONGO_AUTH_SOURCE + replica_set_env: FEES_MONGO_REPLICA_SET + +messaging: + driver: NATS + settings: + url_env: NATS_URL + host_env: NATS_HOST + port_env: NATS_PORT + username_env: NATS_USER + password_env: NATS_PASSWORD + broker_name: Billing Fees Service + max_reconnects: 10 + reconnect_wait: 5 + buffer_size: 1024 + +oracle: + address: "dev-fx-oracle:50051" + dial_timeout_seconds: 5 + call_timeout_seconds: 3 + insecure: true diff --git a/api/billing/fees/go.mod b/api/billing/fees/go.mod index 6cd5deac..d68aee8d 100644 --- a/api/billing/fees/go.mod +++ b/api/billing/fees/go.mod @@ -1,6 +1,6 @@ module github.com/tech/sendico/billing/fees -go 1.25.3 +go 1.25.6 replace github.com/tech/sendico/pkg => ../../pkg diff --git a/api/billing/fees/internal/service/fees/internal/calculator/impl.go b/api/billing/fees/internal/service/fees/internal/calculator/impl.go index e961c269..882aa538 100644 --- a/api/billing/fees/internal/service/fees/internal/calculator/impl.go +++ b/api/billing/fees/internal/service/fees/internal/calculator/impl.go @@ -366,13 +366,36 @@ func ruleMatchesAttributes(rule model.FeeRule, attributes map[string]string) boo if attributes == nil { return false } - if attrValue, ok := attributes[key]; !ok || attrValue != value { + attrValue, ok := attributes[key] + if !ok { + return false + } + if !matchesAttributeValue(value, attrValue) { return false } } return true } +func matchesAttributeValue(expected, actual string) bool { + trimmed := strings.TrimSpace(expected) + if trimmed == "" { + return actual == "" + } + + values := strings.Split(trimmed, ",") + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + if value == "*" || value == actual { + return true + } + } + return false +} + func mapLineType(lineType string) accountingv1.PostingLineType { switch strings.ToLower(lineType) { case "tax": diff --git a/api/billing/fees/internal/service/fees/internal/resolver/impl.go b/api/billing/fees/internal/service/fees/internal/resolver/impl.go index d1dbbcf3..5a500281 100644 --- a/api/billing/fees/internal/service/fees/internal/resolver/impl.go +++ b/api/billing/fees/internal/service/fees/internal/resolver/impl.go @@ -3,6 +3,7 @@ package resolver import ( "context" "errors" + "strings" "time" "github.com/tech/sendico/billing/fees/storage" @@ -95,6 +96,24 @@ func (r *feeResolver) ResolveFeeRule(ctx context.Context, orgRef *primitive.Obje return nil, nil, err } + selectedFields := []zap.Field{ + zap.String("trigger", string(trigger)), + zap.Time("booked_at", at), + zap.Any("attributes", attrs), + zap.String("rule_id", rule.RuleID), + zap.Int("rule_priority", rule.Priority), + zap.Any("rule_applies_to", rule.AppliesTo), + zap.Time("rule_effective_from", rule.EffectiveFrom), + } + if rule.EffectiveTo != nil { + selectedFields = append(selectedFields, zap.Time("rule_effective_to", *rule.EffectiveTo)) + } + if orgRef != nil && !orgRef.IsZero() { + selectedFields = append(selectedFields, mzap.ObjRef("org_ref", *orgRef)) + } + selectedFields = append(selectedFields, zapFieldsForPlan(plan)...) + r.logger.Debug("Selected fee rule", selectedFields...) + return plan, rule, nil } @@ -159,13 +178,36 @@ func matchesAppliesTo(appliesTo map[string]string, attrs map[string]string) bool if attrs == nil { return false } - if attrs[key] != value { + attrValue, ok := attrs[key] + if !ok { + return false + } + if !matchesAppliesValue(value, attrValue) { return false } } return true } +func matchesAppliesValue(expected, actual string) bool { + trimmed := strings.TrimSpace(expected) + if trimmed == "" { + return actual == "" + } + + values := strings.Split(trimmed, ",") + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + if value == "*" || value == actual { + return true + } + } + return false +} + func zapFieldsForPlan(plan *model.FeePlan) []zap.Field { if plan == nil { return []zap.Field{zap.Bool("plan_present", false)} diff --git a/api/billing/fees/internal/service/fees/internal/resolver/resolver_test.go b/api/billing/fees/internal/service/fees/internal/resolver/resolver_test.go index 8ce32397..ff547fe4 100644 --- a/api/billing/fees/internal/service/fees/internal/resolver/resolver_test.go +++ b/api/billing/fees/internal/service/fees/internal/resolver/resolver_test.go @@ -189,6 +189,47 @@ func TestResolver_AppliesToFiltering(t *testing.T) { } } +func TestResolver_AppliesToFilteringSupportsListsAndWildcard(t *testing.T) { + t.Helper() + + now := time.Now() + plan := &model.FeePlan{ + Active: true, + EffectiveFrom: now.Add(-time.Hour), + Rules: []model.FeeRule{ + {RuleID: "network_multi", Trigger: model.TriggerCapture, Priority: 300, Percentage: "0.03", AppliesTo: map[string]string{"network": "tron, solana"}, EffectiveFrom: now.Add(-time.Hour)}, + {RuleID: "asset_any", Trigger: model.TriggerCapture, Priority: 200, Percentage: "0.02", AppliesTo: map[string]string{"asset": "*"}, EffectiveFrom: now.Add(-time.Hour)}, + {RuleID: "default", Trigger: model.TriggerCapture, Priority: 100, Percentage: "0.05", EffectiveFrom: now.Add(-time.Hour)}, + }, + } + store := &memoryPlansStore{plans: []*model.FeePlan{plan}} + resolver := New(store, zap.NewNop()) + + _, rule, err := resolver.ResolveFeeRule(context.Background(), nil, model.TriggerCapture, now, map[string]string{"network": "tron"}) + if err != nil { + t.Fatalf("expected list match rule, got error: %v", err) + } + if rule.RuleID != "network_multi" { + t.Fatalf("expected network list rule, got %s", rule.RuleID) + } + + _, rule, err = resolver.ResolveFeeRule(context.Background(), nil, model.TriggerCapture, now, map[string]string{"asset": "USDT"}) + if err != nil { + t.Fatalf("expected wildcard rule, got error: %v", err) + } + if rule.RuleID != "asset_any" { + t.Fatalf("expected asset wildcard rule, got %s", rule.RuleID) + } + + _, rule, err = resolver.ResolveFeeRule(context.Background(), nil, model.TriggerCapture, now, map[string]string{"network": "eth"}) + if err != nil { + t.Fatalf("expected default rule, got error: %v", err) + } + if rule.RuleID != "default" { + t.Fatalf("expected default rule, got %s", rule.RuleID) + } +} + func TestResolver_MissingTriggerReturnsErr(t *testing.T) { t.Helper() diff --git a/api/billing/fees/storage/mongo/store/plans.go b/api/billing/fees/storage/mongo/store/plans.go index 568a0b06..a80cefe2 100644 --- a/api/billing/fees/storage/mongo/store/plans.go +++ b/api/billing/fees/storage/mongo/store/plans.go @@ -10,10 +10,10 @@ import ( "github.com/tech/sendico/billing/fees/storage" "github.com/tech/sendico/billing/fees/storage/model" - dmath "github.com/tech/sendico/pkg/decimal" "github.com/tech/sendico/pkg/db/repository" "github.com/tech/sendico/pkg/db/repository/builder" ri "github.com/tech/sendico/pkg/db/repository/index" + dmath "github.com/tech/sendico/pkg/decimal" "github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/mlogger" m "github.com/tech/sendico/pkg/model" @@ -257,11 +257,46 @@ func normalizeAppliesTo(applies map[string]string) string { sort.Strings(keys) parts := make([]string, 0, len(keys)) for _, k := range keys { - parts = append(parts, k+"="+applies[k]) + parts = append(parts, k+"="+normalizeAppliesToValue(applies[k])) } return strings.Join(parts, ",") } +func normalizeAppliesToValue(value string) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "" + } + + values := strings.Split(trimmed, ",") + seen := make(map[string]struct{}, len(values)) + normalized := make([]string, 0, len(values)) + hasWildcard := false + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + if value == "*" { + hasWildcard = true + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + normalized = append(normalized, value) + } + if hasWildcard { + return "*" + } + if len(normalized) == 0 { + return "" + } + sort.Strings(normalized) + return strings.Join(normalized, ",") +} + func (p *plansStore) ensureNoOverlap(ctx context.Context, plan *model.FeePlan) error { if plan == nil || !plan.Active { return nil