15 Commits

Author SHA1 Message Date
da72121109 Merge pull request 'ledger account reference removed' (#87) from fees-86 into main
Some checks failed
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/mntx_gateway 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/billing_fees Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline failed
ci/woodpecker/push/frontend Pipeline failed
Reviewed-on: #87
2025-12-11 22:32:26 +00:00
Stephan D
5bebadf17c ledger account reference removed 2025-12-11 23:30:42 +01:00
1bab0b14ef Merge pull request 'fixed currency pair validation' (#85) from currency-84 into main
Some checks failed
ci/woodpecker/push/chain_gateway Pipeline is pending
ci/woodpecker/push/db Pipeline is pending
ci/woodpecker/push/frontend Pipeline is pending
ci/woodpecker/push/fx_ingestor Pipeline is pending
ci/woodpecker/push/fx_oracle Pipeline is pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/mntx_gateway 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/billing_fees Pipeline failed
ci/woodpecker/push/bff Pipeline failed
Reviewed-on: #85
2025-12-11 22:27:42 +00:00
Stephan D
39f323d050 fixed currency pair validation 2025-12-11 23:27:15 +01:00
7cd9e14618 Merge pull request 'logging-84' (#83) from logging-84 into main
All checks were successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #83
2025-12-11 21:37:04 +00:00
Stephan D
b77d2c16ab improved logging 2025-12-11 22:25:51 +01:00
Stephan D
324f150950 improved logging 2025-12-11 22:25:04 +01:00
dd6bcf843c Merge pull request 'fixed payment orchestrator address' (#81) from connectivity-81 into main
Some checks failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/nats Pipeline failed
ci/woodpecker/push/mntx_gateway Pipeline failed
ci/woodpecker/push/notification Pipeline failed
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #81
2025-12-11 20:53:04 +00:00
Stephan D
874cc4971b fixed payment orchestrator address 2025-12-11 21:52:37 +01:00
bfe4695b2d Merge pull request 'config fix' (#80) from discovery-79 into main
Some checks failed
ci/woodpecker/push/fx_oracle Pipeline is pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/mntx_gateway 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/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline failed
ci/woodpecker/push/frontend Pipeline failed
Reviewed-on: #80
2025-12-11 20:39:07 +00:00
Stephan D
99161c8e7d config fix 2025-12-11 21:38:32 +01:00
6901791dd2 Merge pull request 'default currency resolver' (#78) from currency-76 into main
Some checks failed
ci/woodpecker/push/fx_oracle Pipeline is pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/mntx_gateway 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/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline failed
ci/woodpecker/push/frontend Pipeline failed
Reviewed-on: #78
2025-12-11 20:24:10 +00:00
Stephan D
acb3d14b47 default currency resolver 2025-12-11 21:23:35 +01:00
aa5f7e271e Merge pull request 'fix currencies validation' (#76) from currencies-75 into main
Some checks failed
ci/woodpecker/push/fx_oracle Pipeline is pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/mntx_gateway 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/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline failed
ci/woodpecker/push/frontend Pipeline failed
Reviewed-on: #76
2025-12-11 20:06:22 +00:00
Stephan D
0a01995f53 fix currencies validation 2025-12-11 21:05:43 +01:00
18 changed files with 447 additions and 94 deletions

View File

@@ -90,10 +90,6 @@ func (c *quoteCalculator) Compute(ctx context.Context, plan *model.FeePlan, inte
}
ledgerAccountRef := strings.TrimSpace(rule.LedgerAccountRef)
if ledgerAccountRef == "" {
c.logger.Warn("fee rule missing ledger account reference", zap.String("rule_id", rule.RuleID))
continue
}
amount, scale, calcErr := c.calculateRuleAmount(baseAmount, baseScale, rule)
if calcErr != nil {

View File

@@ -38,23 +38,23 @@ func New(plans storage.PlansStore, logger *zap.Logger) *feeResolver {
}
}
func (r *feeResolver) ResolveFeeRule(ctx context.Context, orgID *primitive.ObjectID, trigger model.Trigger, at time.Time, attrs map[string]string) (*model.FeePlan, *model.FeeRule, error) {
func (r *feeResolver) ResolveFeeRule(ctx context.Context, orgRef *primitive.ObjectID, trigger model.Trigger, at time.Time, attrs map[string]string) (*model.FeePlan, *model.FeeRule, error) {
if r.plans == nil {
return nil, nil, merrors.InvalidArgument("fees: plans store is required")
}
// Try org-specific first if provided.
if orgID != nil && !orgID.IsZero() {
if plan, err := r.getOrgPlan(ctx, *orgID, at); err == nil {
if orgRef != nil && !orgRef.IsZero() {
if plan, err := r.getOrgPlan(ctx, *orgRef, at); err == nil {
if rule, selErr := selectRule(plan, trigger, at, attrs); selErr == nil {
return plan, rule, nil
} else if !errors.Is(selErr, ErrNoFeeRuleFound) {
r.logger.Warn("failed selecting rule for org plan", zap.Error(selErr), zap.String("org_ref", orgID.Hex()))
r.logger.Warn("failed selecting rule for org plan", zap.Error(selErr), zap.String("org_ref", orgRef.Hex()))
return nil, nil, selErr
}
r.logger.Debug("no matching rule in org plan; falling back to global", zap.String("org_ref", orgID.Hex()))
r.logger.Debug("no matching rule in org plan; falling back to global", zap.String("org_ref", orgRef.Hex()))
} else if !errors.Is(err, storage.ErrFeePlanNotFound) {
r.logger.Warn("failed resolving org fee plan", zap.Error(err), zap.String("org_ref", orgID.Hex()))
r.logger.Warn("failed resolving org fee plan", zap.Error(err), zap.String("org_ref", orgRef.Hex()))
return nil, nil, err
}
}

View File

@@ -32,8 +32,8 @@ func TestResolver_GlobalFallbackWhenOrgMissing(t *testing.T) {
if err != nil {
t.Fatalf("expected fallback to global, got error: %v", err)
}
if !plan.GetOrganizationRef().IsZero() {
t.Fatalf("expected global plan, got orgRef %s", plan.GetOrganizationRef().Hex())
if plan.OrganizationRef != nil && !plan.OrganizationRef.IsZero() {
t.Fatalf("expected global plan, got orgRef %s", plan.OrganizationRef.Hex())
}
if rule.RuleID != "global_capture" {
t.Fatalf("unexpected rule selected: %s", rule.RuleID)
@@ -59,8 +59,7 @@ func TestResolver_OrgOverridesGlobal(t *testing.T) {
{RuleID: "org_capture", Trigger: model.TriggerCapture, Priority: 10, Percentage: "0.03", EffectiveFrom: now.Add(-time.Hour)},
},
}
orgPlan.SetOrganizationRef(org)
orgPlan.OrganizationRef = &org
store := &memoryPlansStore{plans: []*model.FeePlan{globalPlan, orgPlan}}
resolver := New(store, zap.NewNop())
@@ -95,7 +94,7 @@ func TestResolver_SelectsHighestPriority(t *testing.T) {
{RuleID: "high", Trigger: model.TriggerCapture, Priority: 200, Percentage: "0.03", EffectiveFrom: now.Add(-time.Hour)},
},
}
plan.SetOrganizationRef(org)
plan.OrganizationRef = &org
store := &memoryPlansStore{plans: []*model.FeePlan{plan}}
resolver := New(store, zap.NewNop())
@@ -136,7 +135,7 @@ func TestResolver_EffectiveDateFiltering(t *testing.T) {
{RuleID: "expired", Trigger: model.TriggerCapture, Priority: 100, Percentage: "0.05", EffectiveFrom: past, EffectiveTo: &past},
},
}
orgPlan.SetOrganizationRef(org)
orgPlan.OrganizationRef = &org
globalPlan := &model.FeePlan{
Active: true,
@@ -221,7 +220,7 @@ func TestResolver_MultipleActivePlansConflict(t *testing.T) {
{RuleID: "r1", Trigger: model.TriggerCapture, Priority: 10, Percentage: "0.05", EffectiveFrom: now.Add(-time.Hour)},
},
}
p1.SetOrganizationRef(org)
p1.OrganizationRef = &org
p2 := &model.FeePlan{
Active: true,
EffectiveFrom: now.Add(-30 * time.Minute),
@@ -229,7 +228,7 @@ func TestResolver_MultipleActivePlansConflict(t *testing.T) {
{RuleID: "r2", Trigger: model.TriggerCapture, Priority: 20, Percentage: "0.03", EffectiveFrom: now.Add(-time.Hour)},
},
}
p2.SetOrganizationRef(org)
p2.OrganizationRef = &org
store := &memoryPlansStore{plans: []*model.FeePlan{p1, p2}}
resolver := New(store, zap.NewNop())
@@ -263,7 +262,7 @@ func (m *memoryPlansStore) GetActivePlan(ctx context.Context, orgRef primitive.O
func (m *memoryPlansStore) FindActiveOrgPlan(_ context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error) {
var matches []*model.FeePlan
for _, plan := range m.plans {
if plan == nil || plan.GetOrganizationRef() != orgRef {
if plan == nil || plan.OrganizationRef == nil || plan.OrganizationRef.IsZero() || (*plan.OrganizationRef != orgRef) {
continue
}
if !plan.Active {
@@ -289,7 +288,7 @@ func (m *memoryPlansStore) FindActiveOrgPlan(_ context.Context, orgRef primitive
func (m *memoryPlansStore) FindActiveGlobalPlan(_ context.Context, at time.Time) (*model.FeePlan, error) {
var matches []*model.FeePlan
for _, plan := range m.plans {
if plan == nil || !plan.GetOrganizationRef().IsZero() {
if plan == nil || ((plan.OrganizationRef != nil) && !plan.OrganizationRef.IsZero()) {
continue
}
if !plan.Active {

View File

@@ -0,0 +1,88 @@
package fees
import (
"strings"
"time"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
"go.uber.org/zap"
)
func requestLogFields(meta *feesv1.RequestMeta, intent *feesv1.Intent) []zap.Field {
fields := logFieldsFromRequestMeta(meta)
fields = append(fields, logFieldsFromIntent(intent)...)
return fields
}
func logFieldsFromRequestMeta(meta *feesv1.RequestMeta) []zap.Field {
if meta == nil {
return nil
}
fields := make([]zap.Field, 0, 4)
if org := strings.TrimSpace(meta.GetOrganizationRef()); org != "" {
fields = append(fields, zap.String("organization_ref", org))
}
fields = append(fields, logFieldsFromTrace(meta.GetTrace())...)
return fields
}
func logFieldsFromIntent(intent *feesv1.Intent) []zap.Field {
if intent == nil {
return nil
}
fields := make([]zap.Field, 0, 5)
if trigger := intent.GetTrigger(); trigger != feesv1.Trigger_TRIGGER_UNSPECIFIED {
fields = append(fields, zap.String("trigger", trigger.String()))
}
if base := intent.GetBaseAmount(); base != nil {
if amount := strings.TrimSpace(base.GetAmount()); amount != "" {
fields = append(fields, zap.String("base_amount", amount))
}
if currency := strings.TrimSpace(base.GetCurrency()); currency != "" {
fields = append(fields, zap.String("base_currency", currency))
}
}
if booked := intent.GetBookedAt(); booked != nil && booked.IsValid() {
fields = append(fields, zap.Time("booked_at", booked.AsTime()))
}
if attrs := intent.GetAttributes(); len(attrs) > 0 {
fields = append(fields, zap.Int("attributes_count", len(attrs)))
}
return fields
}
func logFieldsFromTrace(trace *tracev1.TraceContext) []zap.Field {
if trace == nil {
return nil
}
fields := make([]zap.Field, 0, 3)
if reqRef := strings.TrimSpace(trace.GetRequestRef()); reqRef != "" {
fields = append(fields, zap.String("request_ref", reqRef))
}
if idem := strings.TrimSpace(trace.GetIdempotencyKey()); idem != "" {
fields = append(fields, zap.String("idempotency_key", idem))
}
if traceRef := strings.TrimSpace(trace.GetTraceRef()); traceRef != "" {
fields = append(fields, zap.String("trace_ref", traceRef))
}
return fields
}
func logFieldsFromTokenPayload(payload *feeQuoteTokenPayload) []zap.Field {
if payload == nil {
return nil
}
fields := make([]zap.Field, 0, 6)
if org := strings.TrimSpace(payload.OrganizationRef); org != "" {
fields = append(fields, zap.String("organization_ref", org))
}
if payload.ExpiresAtUnixMs > 0 {
fields = append(fields,
zap.Int64("expires_at_unix_ms", payload.ExpiresAtUnixMs),
zap.Time("expires_at", time.UnixMilli(payload.ExpiresAtUnixMs)))
}
fields = append(fields, logFieldsFromIntent(payload.Intent)...)
fields = append(fields, logFieldsFromTrace(payload.Trace)...)
return fields
}

View File

@@ -72,26 +72,57 @@ func (s *Service) Register(router routers.GRPC) error {
}
func (s *Service) QuoteFees(ctx context.Context, req *feesv1.QuoteFeesRequest) (resp *feesv1.QuoteFeesResponse, err error) {
var (
meta *feesv1.RequestMeta
intent *feesv1.Intent
)
if req != nil {
meta = req.GetMeta()
intent = req.GetIntent()
}
logger := s.logger.With(requestLogFields(meta, intent)...)
start := s.clock.Now()
trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED
if req != nil && req.GetIntent() != nil {
trigger = req.GetIntent().GetTrigger()
if intent != nil {
trigger = intent.GetTrigger()
}
var fxUsed bool
defer func() {
statusLabel := statusFromError(err)
linesCount := 0
appliedCount := 0
if err == nil && resp != nil {
fxUsed = resp.GetFxUsed() != nil
linesCount = len(resp.GetLines())
appliedCount = len(resp.GetApplied())
}
observeMetrics("quote", trigger, statusLabel, fxUsed, time.Since(start))
logFields := []zap.Field{
zap.String("status", statusLabel),
zap.Duration("duration", time.Since(start)),
zap.Bool("fx_used", fxUsed),
zap.String("trigger", trigger.String()),
zap.Int("lines", linesCount),
zap.Int("applied_rules", appliedCount),
}
if err != nil {
logger.Warn("QuoteFees finished", append(logFields, zap.Error(err))...)
return
}
logger.Info("QuoteFees finished", logFields...)
}()
logger.Debug("QuoteFees request received")
if err = s.validateQuoteRequest(req); err != nil {
return nil, err
}
orgRef, parseErr := primitive.ObjectIDFromHex(req.GetMeta().GetOrganizationRef())
if parseErr != nil {
logger.Warn("QuoteFees invalid organization_ref", zap.Error(parseErr))
err = status.Error(codes.InvalidArgument, "invalid organization_ref")
return nil, err
}
@@ -112,20 +143,59 @@ func (s *Service) QuoteFees(ctx context.Context, req *feesv1.QuoteFeesRequest) (
}
func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFeesRequest) (resp *feesv1.PrecomputeFeesResponse, err error) {
var (
meta *feesv1.RequestMeta
intent *feesv1.Intent
)
if req != nil {
meta = req.GetMeta()
intent = req.GetIntent()
}
logger := s.logger.With(requestLogFields(meta, intent)...)
start := s.clock.Now()
trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED
if req != nil && req.GetIntent() != nil {
trigger = req.GetIntent().GetTrigger()
if intent != nil {
trigger = intent.GetTrigger()
}
var fxUsed bool
var (
fxUsed bool
expiresAt time.Time
)
defer func() {
statusLabel := statusFromError(err)
linesCount := 0
appliedCount := 0
if err == nil && resp != nil {
fxUsed = resp.GetFxUsed() != nil
linesCount = len(resp.GetLines())
appliedCount = len(resp.GetApplied())
if ts := resp.GetExpiresAt(); ts != nil {
expiresAt = ts.AsTime()
}
}
observeMetrics("precompute", trigger, statusLabel, fxUsed, time.Since(start))
logFields := []zap.Field{
zap.String("status", statusLabel),
zap.Duration("duration", time.Since(start)),
zap.Bool("fx_used", fxUsed),
zap.String("trigger", trigger.String()),
zap.Int("lines", linesCount),
zap.Int("applied_rules", appliedCount),
}
if !expiresAt.IsZero() {
logFields = append(logFields, zap.Time("expires_at", expiresAt))
}
if err != nil {
logger.Warn("PrecomputeFees finished", append(logFields, zap.Error(err))...)
return
}
logger.Info("PrecomputeFees finished", logFields...)
}()
logger.Debug("PrecomputeFees request received")
if err = s.validatePrecomputeRequest(req); err != nil {
return nil, err
}
@@ -134,6 +204,7 @@ func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFees
orgRef, parseErr := primitive.ObjectIDFromHex(req.GetMeta().GetOrganizationRef())
if parseErr != nil {
logger.Warn("PrecomputeFees invalid organization_ref", zap.Error(parseErr))
err = status.Error(codes.InvalidArgument, "invalid organization_ref")
return nil, err
}
@@ -148,7 +219,7 @@ func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFees
if ttl <= 0 {
ttl = 60000
}
expiresAt := now.Add(time.Duration(ttl) * time.Millisecond)
expiresAt = now.Add(time.Duration(ttl) * time.Millisecond)
payload := feeQuoteTokenPayload{
OrganizationRef: req.GetMeta().GetOrganizationRef(),
@@ -159,7 +230,7 @@ func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFees
var token string
if token, err = encodeTokenPayload(payload); err != nil {
s.logger.Warn("failed to encode fee quote token", zap.Error(err))
logger.Warn("failed to encode fee quote token", zap.Error(err))
err = status.Error(codes.Internal, "failed to encode fee quote token")
return nil, err
}
@@ -176,9 +247,18 @@ func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFees
}
func (s *Service) ValidateFeeToken(ctx context.Context, req *feesv1.ValidateFeeTokenRequest) (resp *feesv1.ValidateFeeTokenResponse, err error) {
tokenLen := 0
if req != nil {
tokenLen = len(strings.TrimSpace(req.GetFeeQuoteToken()))
}
logger := s.logger.With(zap.Int("token_length", tokenLen))
start := s.clock.Now()
trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED
var fxUsed bool
var (
fxUsed bool
resultReason string
)
defer func() {
statusLabel := statusFromError(err)
if err == nil && resp != nil {
@@ -191,9 +271,28 @@ func (s *Service) ValidateFeeToken(ctx context.Context, req *feesv1.ValidateFeeT
}
}
observeMetrics("validate", trigger, statusLabel, fxUsed, time.Since(start))
logFields := []zap.Field{
zap.String("status", statusLabel),
zap.Duration("duration", time.Since(start)),
zap.Bool("fx_used", fxUsed),
zap.String("trigger", trigger.String()),
zap.Bool("valid", resp != nil && resp.GetValid()),
}
if resultReason != "" {
logFields = append(logFields, zap.String("reason", resultReason))
}
if err != nil {
logger.Warn("ValidateFeeToken finished", append(logFields, zap.Error(err))...)
return
}
logger.Info("ValidateFeeToken finished", logFields...)
}()
logger.Debug("ValidateFeeToken request received")
if req == nil || strings.TrimSpace(req.GetFeeQuoteToken()) == "" {
resultReason = "missing_token"
err = status.Error(codes.InvalidArgument, "fee_quote_token is required")
return nil, err
}
@@ -202,21 +301,29 @@ func (s *Service) ValidateFeeToken(ctx context.Context, req *feesv1.ValidateFeeT
payload, decodeErr := decodeTokenPayload(req.GetFeeQuoteToken())
if decodeErr != nil {
s.logger.Warn("failed to decode fee quote token", zap.Error(decodeErr))
resultReason = "invalid_token"
logger.Warn("failed to decode fee quote token", zap.Error(decodeErr))
resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "invalid_token"}
return resp, nil
}
trigger = payload.Intent.GetTrigger()
logger = logger.With(logFieldsFromTokenPayload(&payload)...)
if payload.Intent != nil {
trigger = payload.Intent.GetTrigger()
}
if now.UnixMilli() > payload.ExpiresAtUnixMs {
resultReason = "expired"
logger.Info("fee quote token expired")
resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "expired"}
return resp, nil
}
orgRef, parseErr := primitive.ObjectIDFromHex(payload.OrganizationRef)
if parseErr != nil {
s.logger.Warn("token contained invalid organization reference", zap.Error(parseErr))
resultReason = "invalid_token"
logger.Warn("token contained invalid organization reference", zap.Error(parseErr))
resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "invalid_token"}
return resp, nil
}
@@ -280,6 +387,16 @@ func (s *Service) computeQuoteWithTime(ctx context.Context, orgRef primitive.Obj
bookedAt = intent.GetBookedAt().AsTime()
}
logFields := []zap.Field{
zap.Time("booked_at_used", bookedAt),
}
if !orgRef.IsZero() {
logFields = append(logFields, zap.String("organization_ref", orgRef.Hex()))
}
logFields = append(logFields, logFieldsFromIntent(intent)...)
logFields = append(logFields, logFieldsFromTrace(trace)...)
logger := s.logger.With(logFields...)
var orgPtr *primitive.ObjectID
if !orgRef.IsZero() {
orgPtr = &orgRef
@@ -288,7 +405,7 @@ func (s *Service) computeQuoteWithTime(ctx context.Context, orgRef primitive.Obj
plan, rule, err := s.resolver.ResolveFeeRule(ctx, orgPtr, convertTrigger(intent.GetTrigger()), bookedAt, intent.GetAttributes())
if err != nil {
switch {
case errors.Is(err, merrors.ErrNoData):
case errors.Is(err, merrors.ErrNoData):
return nil, nil, nil, status.Error(codes.NotFound, "fee rule not found")
case errors.Is(err, merrors.ErrDataConflict):
return nil, nil, nil, status.Error(codes.FailedPrecondition, "conflicting fee rules")
@@ -297,7 +414,7 @@ func (s *Service) computeQuoteWithTime(ctx context.Context, orgRef primitive.Obj
case errors.Is(err, storage.ErrFeePlanNotFound):
return nil, nil, nil, status.Error(codes.NotFound, "fee plan not found")
default:
s.logger.Warn("failed to resolve fee rule", zap.Error(err))
logger.Warn("failed to resolve fee rule", zap.Error(err))
return nil, nil, nil, status.Error(codes.Internal, "failed to resolve fee rule")
}
}
@@ -313,7 +430,7 @@ func (s *Service) computeQuoteWithTime(ctx context.Context, orgRef primitive.Obj
if errors.Is(calcErr, merrors.ErrInvalidArg) {
return nil, nil, nil, status.Error(codes.InvalidArgument, calcErr.Error())
}
s.logger.Warn("failed to compute fee quote", zap.Error(calcErr))
logger.Warn("failed to compute fee quote", zap.Error(calcErr))
return nil, nil, nil, status.Error(codes.Internal, "failed to compute fee quote")
}

View File

@@ -49,7 +49,7 @@ func TestQuoteFees_ComputesDerivedLines(t *testing.T) {
},
}
plan.SetID(primitive.NewObjectID())
plan.SetOrganizationRef(orgRef)
plan.OrganizationRef = &orgRef
service := NewService(
zap.NewNop(),
@@ -163,7 +163,7 @@ func TestQuoteFees_FiltersByAttributesAndDates(t *testing.T) {
},
}
plan.SetID(primitive.NewObjectID())
plan.SetOrganizationRef(orgRef)
plan.OrganizationRef = &orgRef
service := NewService(
zap.NewNop(),
@@ -224,7 +224,7 @@ func TestQuoteFees_RoundingDown(t *testing.T) {
},
}
plan.SetID(primitive.NewObjectID())
plan.SetOrganizationRef(orgRef)
plan.OrganizationRef = &orgRef
service := NewService(
zap.NewNop(),
@@ -277,7 +277,7 @@ func TestQuoteFees_UsesInjectedCalculator(t *testing.T) {
},
}
plan.SetID(primitive.NewObjectID())
plan.SetOrganizationRef(orgRef)
plan.OrganizationRef = &orgRef
result := &types.CalculationResult{
Lines: []*feesv1.DerivedPostingLine{
@@ -353,7 +353,7 @@ func TestQuoteFees_PopulatesFxUsed(t *testing.T) {
},
}
plan.SetID(primitive.NewObjectID())
plan.SetOrganizationRef(orgRef)
plan.OrganizationRef = &orgRef
fakeOracle := &oracleclient.Fake{
LatestRateFn: func(ctx context.Context, req oracleclient.LatestRateParams) (*oracleclient.RateSnapshot, error) {
@@ -452,7 +452,7 @@ func (s *stubPlansStore) FindActiveOrgPlan(_ context.Context, orgRef primitive.O
if s.plan == nil {
return nil, storage.ErrFeePlanNotFound
}
if s.plan.GetOrganizationRef() != orgRef {
if (s.plan.OrganizationRef != nil) && (*s.plan.OrganizationRef != orgRef) {
return nil, storage.ErrFeePlanNotFound
}
if !s.plan.Active {

View File

@@ -5,6 +5,7 @@ import (
"github.com/tech/sendico/pkg/db/storable"
"github.com/tech/sendico/pkg/model"
"go.mongodb.org/mongo-driver/bson/primitive"
)
const (
@@ -25,14 +26,14 @@ const (
// FeePlan describes a collection of fee rules for an organisation.
type FeePlan struct {
storable.Base `bson:",inline" json:",inline"`
model.OrganizationBoundBase `bson:",inline" json:",inline"`
model.Describable `bson:",inline" json:",inline"`
Active bool `bson:"active" json:"active"`
EffectiveFrom time.Time `bson:"effectiveFrom" json:"effectiveFrom"`
EffectiveTo *time.Time `bson:"effectiveTo,omitempty" json:"effectiveTo,omitempty"`
Rules []FeeRule `bson:"rules,omitempty" json:"rules,omitempty"`
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
storable.Base `bson:",inline" json:",inline"`
model.Describable `bson:",inline" json:",inline"`
OrganizationRef *primitive.ObjectID `bson:"organizationRef,omitempty" json:"organizationRef,omitempty"`
Active bool `bson:"active" json:"active"`
EffectiveFrom time.Time `bson:"effectiveFrom" json:"effectiveFrom"`
EffectiveTo *time.Time `bson:"effectiveTo,omitempty" json:"effectiveTo,omitempty"`
Rules []FeeRule `bson:"rules,omitempty" json:"rules,omitempty"`
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
}
// Collection implements storable.Storable.

View File

@@ -61,3 +61,6 @@ card_gateways:
monetix:
funding_address: "wallet_funding_monetix"
fee_address: "wallet_fee_monetix"
fee_ledger_accounts:
monetix: "ledger:fees:monetix"

View File

@@ -46,6 +46,7 @@ type config struct {
Gateway clientConfig `yaml:"gateway"`
Oracle clientConfig `yaml:"oracle"`
CardGateways map[string]cardGatewayRouteConfig `yaml:"card_gateways"`
FeeAccounts map[string]string `yaml:"fee_ledger_accounts"`
}
type clientConfig struct {
@@ -159,6 +160,9 @@ func (i *Imp) Start() error {
if routes := buildCardGatewayRoutes(cfg.CardGateways); len(routes) > 0 {
opts = append(opts, orchestrator.WithCardGatewayRoutes(routes))
}
if feeAccounts := buildFeeLedgerAccounts(cfg.FeeAccounts); len(feeAccounts) > 0 {
opts = append(opts, orchestrator.WithFeeLedgerAccounts(feeAccounts))
}
return orchestrator.NewService(logger, repo, opts...), nil
}
@@ -323,3 +327,19 @@ func buildCardGatewayRoutes(src map[string]cardGatewayRouteConfig) map[string]or
}
return result
}
func buildFeeLedgerAccounts(src map[string]string) map[string]string {
if len(src) == 0 {
return nil
}
result := make(map[string]string, len(src))
for key, account := range src {
k := strings.ToLower(strings.TrimSpace(key))
v := strings.TrimSpace(account)
if k == "" || v == "" {
continue
}
result[k] = v
}
return result
}

View File

@@ -383,6 +383,22 @@ func feeBreakdownFromQuote(quote *orchestratorv1.PaymentQuote) []*chainv1.Servic
return breakdown
}
func assignLedgerAccounts(lines []*feesv1.DerivedPostingLine, account string) []*feesv1.DerivedPostingLine {
if account == "" || len(lines) == 0 {
return lines
}
for _, line := range lines {
if line == nil {
continue
}
if strings.TrimSpace(line.GetLedgerAccountRef()) != "" {
continue
}
line.LedgerAccountRef = account
}
return lines
}
func moneyEquals(a, b *moneyv1.Money) bool {
if a == nil || b == nil {
return false

View File

@@ -113,6 +113,24 @@ func WithCardGatewayRoutes(routes map[string]CardGatewayRoute) Option {
}
}
// WithFeeLedgerAccounts maps gateway identifiers to ledger accounts used for fees.
func WithFeeLedgerAccounts(routes map[string]string) Option {
return func(s *Service) {
if len(routes) == 0 {
return
}
s.deps.feeLedgerAccounts = make(map[string]string, len(routes))
for k, v := range routes {
key := strings.ToLower(strings.TrimSpace(k))
val := strings.TrimSpace(v)
if key == "" || val == "" {
continue
}
s.deps.feeLedgerAccounts[key] = val
}
}
}
// WithClock overrides the default clock.
func WithClock(clock clockpkg.Clock) Option {
return func(s *Service) {

View File

@@ -52,7 +52,9 @@ func (s *Service) buildPaymentQuote(ctx context.Context, orgRef string, req *orc
} else if amount != nil {
feeCurrency = amount.GetCurrency()
}
feeTotal := extractFeeTotal(feeQuote.GetLines(), feeCurrency)
feeLines := cloneFeeLines(feeQuote.GetLines())
s.assignFeeLedgerAccounts(intent, feeLines)
feeTotal := extractFeeTotal(feeLines, feeCurrency)
var networkFee *chainv1.EstimateTransferFeeResponse
if shouldEstimateNetworkFee(intent) {
@@ -69,7 +71,7 @@ func (s *Service) buildPaymentQuote(ctx context.Context, orgRef string, req *orc
DebitAmount: debitAmount,
ExpectedSettlementAmount: settlementAmount,
ExpectedFeeTotal: feeTotal,
FeeLines: cloneFeeLines(feeQuote.GetLines()),
FeeLines: feeLines,
FeeRules: cloneFeeRules(feeQuote.GetApplied()),
FxQuote: fxQuote,
NetworkFee: networkFee,
@@ -207,3 +209,53 @@ func (s *Service) requestFXQuote(ctx context.Context, orgRef string, req *orches
}
return quoteToProto(quote), nil
}
func (s *Service) feeLedgerAccountForIntent(intent *orchestratorv1.PaymentIntent) string {
if intent == nil || len(s.deps.feeLedgerAccounts) == 0 {
return ""
}
key := s.gatewayKeyFromIntent(intent)
if key == "" {
return ""
}
return strings.TrimSpace(s.deps.feeLedgerAccounts[key])
}
func (s *Service) assignFeeLedgerAccounts(intent *orchestratorv1.PaymentIntent, lines []*feesv1.DerivedPostingLine) {
account := s.feeLedgerAccountForIntent(intent)
key := s.gatewayKeyFromIntent(intent)
missing := 0
for _, line := range lines {
if line == nil {
continue
}
if strings.TrimSpace(line.GetLedgerAccountRef()) == "" {
missing++
}
}
if missing == 0 {
return
}
if account == "" {
s.logger.Debug("no fee ledger account mapping found", zap.String("gateway", key), zap.Int("missing_lines", missing))
return
}
assignLedgerAccounts(lines, account)
s.logger.Debug("applied fee ledger account mapping", zap.String("gateway", key), zap.String("ledger_account", account), zap.Int("lines", missing))
}
func (s *Service) gatewayKeyFromIntent(intent *orchestratorv1.PaymentIntent) string {
if intent == nil {
return ""
}
key := strings.TrimSpace(intent.GetAttributes()["gateway"])
if key == "" {
if dest := intent.GetDestination(); dest != nil && dest.GetCard() != nil {
key = defaultCardGateway
}
}
return strings.ToLower(key)
}

View File

@@ -41,12 +41,13 @@ type Service struct {
}
type serviceDependencies struct {
fees feesDependency
ledger ledgerDependency
gateway gatewayDependency
oracle oracleDependency
mntx mntxDependency
cardRoutes map[string]CardGatewayRoute
fees feesDependency
ledger ledgerDependency
gateway gatewayDependency
oracle oracleDependency
mntx mntxDependency
cardRoutes map[string]CardGatewayRoute
feeLedgerAccounts map[string]string
}
type handlerSet struct {

View File

@@ -90,8 +90,8 @@ api:
call_timeout_seconds: 5
insecure: true
payment_orchestrator:
address: sendico_payment_orchestrator:50062
address_env: PAYMENT_ORCHESTRATOR_ADDRESS
address: sendico_payments_orchestrator:50062
address_env: PAYMENTS_ADDRESS
dial_timeout_seconds: 5
call_timeout_seconds: 5
insecure: true

View File

@@ -15,6 +15,12 @@ type PaymentIntent struct {
Attributes map[string]string `json:"attributes,omitempty"`
}
type AssetResolverStub struct{}
func (a *AssetResolverStub) IsSupported(_ string) bool {
return true
}
func (p *PaymentIntent) Validate() error {
// Kind must be set (non-zero)
var zeroKind PaymentKind
@@ -33,7 +39,8 @@ func (p *PaymentIntent) Validate() error {
if p.Amount == nil {
return merrors.InvalidArgument("amount is required", "intent.amount")
}
if err := ValidateMoney(p.Amount); err != nil {
//TODO: collect supported currencies and validate against them
if err := ValidateMoney(p.Amount, &AssetResolverStub{}); err != nil {
return err
}

View File

@@ -1,30 +1,77 @@
package srequest
import (
"regexp"
"strings"
"github.com/shopspring/decimal"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model"
)
func ValidateMoney(m *model.Money) error {
if m.Amount == "" {
return merrors.InvalidArgument("amount is required", "intent.amount")
}
if m.Currency == "" {
// AssetResolver defines environment-specific supported assets.
// Implementations should check:
// - fiat assets (ISO-4217)
// - crypto assets supported by gateways / FX providers
type AssetResolver interface {
IsSupported(ticker string) bool
}
// Precompile regex for efficiency.
var currencySyntax = regexp.MustCompile(`^[A-Z0-9]{2,10}$`)
// ValidateCurrency validates currency syntax and checks dictionary via assetResolver.
func ValidateCurrency(cur string, assetResolver AssetResolver) error {
// Basic presence
if strings.TrimSpace(cur) == "" {
return merrors.InvalidArgument("currency is required", "intent.currency")
}
if _, err := decimal.NewFromString(m.Amount); err != nil {
return merrors.InvalidArgument("invalid amount decimal", "intent.amount")
// Normalize
cur = strings.ToUpper(strings.TrimSpace(cur))
// Syntax check
if !currencySyntax.MatchString(cur) {
return merrors.InvalidArgument(
"invalid currency format (must be AZ09, length 210)",
"intent.currency",
)
}
if len(m.Currency) != 3 {
return merrors.InvalidArgument("currency must be 3 letters", "intent.currency")
// Dictionary validation
if assetResolver == nil {
return merrors.InvalidArgument("asset resolver is not configured", "intent.currency")
}
for _, c := range m.Currency {
if c < 'A' || c > 'Z' {
return merrors.InvalidArgument("currency must be uppercase A-Z", "intent.currency")
}
if !assetResolver.IsSupported(cur) {
return merrors.InvalidArgument("unsupported currency/asset", "intent.currency")
}
return nil
}
func ValidateMoney(m *model.Money, assetResolver AssetResolver) error {
if m == nil {
return merrors.InvalidArgument("money is required", "intent.amount")
}
// 1) Basic presence
if strings.TrimSpace(m.Amount) == "" {
return merrors.InvalidArgument("amount is required", "intent.amount")
}
// 2) Validate decimal amount
amount, err := decimal.NewFromString(m.Amount)
if err != nil {
return merrors.InvalidArgument("invalid decimal amount", "intent.amount")
}
if amount.IsNegative() {
return merrors.InvalidArgument("amount must be >= 0", "intent.amount")
}
// 3) Validate currency via helper
if err := ValidateCurrency(m.Currency, assetResolver); err != nil {
return err
}
return nil
@@ -36,31 +83,15 @@ type CurrencyPair struct {
}
func (p *CurrencyPair) Validate() error {
if p.Base == "" {
return merrors.InvalidArgument("base currency is required", "intent.fx.pair.base")
if p == nil {
return merrors.InvalidArgument("currency pair is required", "currncy_pair")
}
if p.Quote == "" {
return merrors.InvalidArgument("quote currency is required", "intent.fx.pair.quote")
if err := ValidateCurrency(p.Base, nil); err != nil {
return merrors.InvalidArgument("invalid base currency in pair: "+err.Error(), "currency_pair.base")
}
if len(p.Base) != 3 {
return merrors.InvalidArgument("base currency must be 3 letters", "intent.fx.pair.base")
if err := ValidateCurrency(p.Quote, nil); err != nil {
return merrors.InvalidArgument("invalid quote currency in pair: "+err.Error(), "currency_pair.quote")
}
if len(p.Quote) != 3 {
return merrors.InvalidArgument("quote currency must be 3 letters", "intent.fx.pair.quote")
}
for _, c := range p.Base {
if c < 'A' || c > 'Z' {
return merrors.InvalidArgument("base currency must be uppercase A-Z", "intent.fx.pair.base")
}
}
for _, c := range p.Quote {
if c < 'A' || c > 'Z' {
return merrors.InvalidArgument("quote currency must be uppercase A-Z", "intent.fx.pair.quote")
}
}
return nil
}

View File

@@ -31,6 +31,7 @@ services:
NATS_PASSWORD: ${NATS_PASSWORD}
CHAIN_GATEWAY_ADDRESS: ${CHAIN_GATEWAY_SERVICE_NAME}:${CHAIN_GATEWAY_GRPC_PORT}
LEDGER_ADDRESS: ${LEDGER_SERVICE_NAME}:${LEDGER_GRPC_PORT}
PAYMENTS_ADDRESS: ${PAYMENTS_SERVICE_NAME}:${PAYMENTS_GRPC_PORT}
MONGO_HOST: ${MONGO_HOST}
MONGO_PORT: ${MONGO_PORT}
MONGO_DATABASE: ${MONGO_DATABASE}

View File

@@ -82,6 +82,9 @@ enum ResourceType {
@JsonValue('payment_methods')
paymentMethods,
@JsonValue('payment_orchestrator')
paymentOrchestrator,
/// Represents permissions service
@JsonValue('permissions')
permissions,