Merge pull request 'granular fees plans' (#349) from fees-347 into main
Some checks failed
ci/woodpecker/push/db Pipeline is pending
ci/woodpecker/push/discovery Pipeline is pending
ci/woodpecker/push/frontend Pipeline is pending
ci/woodpecker/push/fx_ingestor Pipeline is pending
ci/woodpecker/push/nats Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/fx_oracle Pipeline is pending
ci/woodpecker/push/gateway_chain Pipeline is pending
ci/woodpecker/push/gateway_mntx Pipeline is pending
ci/woodpecker/push/gateway_tgsettle Pipeline is pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/bff Pipeline failed
ci/woodpecker/push/billing_fees Pipeline failed
Some checks failed
ci/woodpecker/push/db Pipeline is pending
ci/woodpecker/push/discovery Pipeline is pending
ci/woodpecker/push/frontend Pipeline is pending
ci/woodpecker/push/fx_ingestor Pipeline is pending
ci/woodpecker/push/nats Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/fx_oracle Pipeline is pending
ci/woodpecker/push/gateway_chain Pipeline is pending
ci/woodpecker/push/gateway_mntx Pipeline is pending
ci/woodpecker/push/gateway_tgsettle Pipeline is pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/bff Pipeline failed
ci/woodpecker/push/billing_fees Pipeline failed
Reviewed-on: #349
This commit was merged in pull request #349.
This commit is contained in:
@@ -1,32 +1,46 @@
|
|||||||
# Config file for Air in TOML format
|
root = "."
|
||||||
|
testdata_dir = "testdata"
|
||||||
root = "./../.."
|
|
||||||
tmp_dir = "tmp"
|
tmp_dir = "tmp"
|
||||||
|
|
||||||
[build]
|
[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 = []
|
args_bin = []
|
||||||
|
entrypoint = "./tmp/main"
|
||||||
[log]
|
cmd = "go build -o ./tmp/main ."
|
||||||
time = false
|
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]
|
[color]
|
||||||
main = "magenta"
|
app = ""
|
||||||
watcher = "cyan"
|
|
||||||
build = "yellow"
|
build = "yellow"
|
||||||
|
main = "magenta"
|
||||||
runner = "green"
|
runner = "green"
|
||||||
|
watcher = "cyan"
|
||||||
|
|
||||||
|
[log]
|
||||||
|
main_only = false
|
||||||
|
time = false
|
||||||
|
|
||||||
[misc]
|
[misc]
|
||||||
clean_on_exit = true
|
clean_on_exit = false
|
||||||
|
|
||||||
|
[screen]
|
||||||
|
clear_on_rebuild = false
|
||||||
|
keep_scroll = true
|
||||||
|
|||||||
42
api/billing/fees/config.dev.yml
Normal file
42
api/billing/fees/config.dev.yml
Normal file
@@ -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
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
module github.com/tech/sendico/billing/fees
|
module github.com/tech/sendico/billing/fees
|
||||||
|
|
||||||
go 1.25.3
|
go 1.25.6
|
||||||
|
|
||||||
replace github.com/tech/sendico/pkg => ../../pkg
|
replace github.com/tech/sendico/pkg => ../../pkg
|
||||||
|
|
||||||
|
|||||||
@@ -366,13 +366,36 @@ func ruleMatchesAttributes(rule model.FeeRule, attributes map[string]string) boo
|
|||||||
if attributes == nil {
|
if attributes == nil {
|
||||||
return false
|
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 false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true
|
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 {
|
func mapLineType(lineType string) accountingv1.PostingLineType {
|
||||||
switch strings.ToLower(lineType) {
|
switch strings.ToLower(lineType) {
|
||||||
case "tax":
|
case "tax":
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package resolver
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/tech/sendico/billing/fees/storage"
|
"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
|
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
|
return plan, rule, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,13 +178,36 @@ func matchesAppliesTo(appliesTo map[string]string, attrs map[string]string) bool
|
|||||||
if attrs == nil {
|
if attrs == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if attrs[key] != value {
|
attrValue, ok := attrs[key]
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !matchesAppliesValue(value, attrValue) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true
|
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 {
|
func zapFieldsForPlan(plan *model.FeePlan) []zap.Field {
|
||||||
if plan == nil {
|
if plan == nil {
|
||||||
return []zap.Field{zap.Bool("plan_present", false)}
|
return []zap.Field{zap.Bool("plan_present", false)}
|
||||||
|
|||||||
@@ -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) {
|
func TestResolver_MissingTriggerReturnsErr(t *testing.T) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ import (
|
|||||||
|
|
||||||
"github.com/tech/sendico/billing/fees/storage"
|
"github.com/tech/sendico/billing/fees/storage"
|
||||||
"github.com/tech/sendico/billing/fees/storage/model"
|
"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"
|
||||||
"github.com/tech/sendico/pkg/db/repository/builder"
|
"github.com/tech/sendico/pkg/db/repository/builder"
|
||||||
ri "github.com/tech/sendico/pkg/db/repository/index"
|
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/merrors"
|
||||||
"github.com/tech/sendico/pkg/mlogger"
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
m "github.com/tech/sendico/pkg/model"
|
m "github.com/tech/sendico/pkg/model"
|
||||||
@@ -257,11 +257,46 @@ func normalizeAppliesTo(applies map[string]string) string {
|
|||||||
sort.Strings(keys)
|
sort.Strings(keys)
|
||||||
parts := make([]string, 0, len(keys))
|
parts := make([]string, 0, len(keys))
|
||||||
for _, k := range keys {
|
for _, k := range keys {
|
||||||
parts = append(parts, k+"="+applies[k])
|
parts = append(parts, k+"="+normalizeAppliesToValue(applies[k]))
|
||||||
}
|
}
|
||||||
return strings.Join(parts, ",")
|
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 {
|
func (p *plansStore) ensureNoOverlap(ctx context.Context, plan *model.FeePlan) error {
|
||||||
if plan == nil || !plan.Active {
|
if plan == nil || !plan.Active {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
Reference in New Issue
Block a user