granular fees plans

This commit is contained in:
Stephan D
2026-01-30 15:28:03 +01:00
parent 51f5b0804a
commit d5016547d0
8 changed files with 230 additions and 33 deletions

View File

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

View File

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

View File

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