1 Commits

Author SHA1 Message Date
Arseni
32cccc7895 Added PostHog 2025-12-09 21:02:37 +03:00
321 changed files with 2358 additions and 10365 deletions

4
.gitignore vendored
View File

@@ -8,6 +8,4 @@ devtools_options.yaml
untranslated.txt untranslated.txt
generate_protos.sh generate_protos.sh
update_dep.sh update_dep.sh
.vscode/ .vscode/
.gocache/
.cache/

View File

@@ -11,7 +11,7 @@ require (
github.com/tech/sendico/pkg v0.1.0 github.com/tech/sendico/pkg v0.1.0
go.mongodb.org/mongo-driver v1.17.6 go.mongodb.org/mongo-driver v1.17.6
go.uber.org/zap v1.27.1 go.uber.org/zap v1.27.1
google.golang.org/grpc v1.78.0 google.golang.org/grpc v1.77.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
@@ -31,7 +31,7 @@ require (
github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/montanaflynn/stats v0.7.1 // indirect github.com/montanaflynn/stats v0.7.1 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nats-io/nats.go v1.48.0 // indirect github.com/nats-io/nats.go v1.47.0 // indirect
github.com/nats-io/nkeys v0.4.12 // indirect github.com/nats-io/nkeys v0.4.12 // indirect
github.com/nats-io/nuid v1.0.1 // indirect github.com/nats-io/nuid v1.0.1 // indirect
github.com/prometheus/client_golang v1.23.2 github.com/prometheus/client_golang v1.23.2
@@ -49,6 +49,6 @@ require (
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect golang.org/x/text v0.32.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/protobuf v1.36.11 google.golang.org/protobuf v1.36.10
) )

View File

@@ -95,8 +95,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U= github.com/nats-io/nats.go v1.47.0 h1:YQdADw6J/UfGUd2Oy6tn4Hq6YHxCaJrVKayxxFqYrgM=
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= github.com/nats-io/nats.go v1.47.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc= github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg= github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
@@ -212,12 +212,12 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

View File

@@ -90,6 +90,10 @@ func (c *quoteCalculator) Compute(ctx context.Context, plan *model.FeePlan, inte
} }
ledgerAccountRef := strings.TrimSpace(rule.LedgerAccountRef) 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) amount, scale, calcErr := c.calculateRuleAmount(baseAmount, baseScale, rule)
if calcErr != nil { if calcErr != nil {
@@ -109,8 +113,7 @@ func (c *quoteCalculator) Compute(ctx context.Context, plan *model.FeePlan, inte
entrySide := mapEntrySide(rule.EntrySide) entrySide := mapEntrySide(rule.EntrySide)
if entrySide == accountingv1.EntrySide_ENTRY_SIDE_UNSPECIFIED { if entrySide == accountingv1.EntrySide_ENTRY_SIDE_UNSPECIFIED {
// Default fees to debit (i.e. charge the customer) when entry side is not specified. entrySide = accountingv1.EntrySide_ENTRY_SIDE_CREDIT
entrySide = accountingv1.EntrySide_ENTRY_SIDE_DEBIT
} }
meta := map[string]string{ meta := map[string]string{

View File

@@ -38,23 +38,23 @@ func New(plans storage.PlansStore, logger *zap.Logger) *feeResolver {
} }
} }
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) { 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) {
if r.plans == nil { if r.plans == nil {
return nil, nil, merrors.InvalidArgument("fees: plans store is required") return nil, nil, merrors.InvalidArgument("fees: plans store is required")
} }
// Try org-specific first if provided. // Try org-specific first if provided.
if orgRef != nil && !orgRef.IsZero() { if orgID != nil && !orgID.IsZero() {
if plan, err := r.getOrgPlan(ctx, *orgRef, at); err == nil { if plan, err := r.getOrgPlan(ctx, *orgID, at); err == nil {
if rule, selErr := selectRule(plan, trigger, at, attrs); selErr == nil { if rule, selErr := selectRule(plan, trigger, at, attrs); selErr == nil {
return plan, rule, nil return plan, rule, nil
} else if !errors.Is(selErr, ErrNoFeeRuleFound) { } else if !errors.Is(selErr, ErrNoFeeRuleFound) {
r.logger.Warn("failed selecting rule for org plan", zap.Error(selErr), zap.String("org_ref", orgRef.Hex())) r.logger.Warn("failed selecting rule for org plan", zap.Error(selErr), zap.String("org_ref", orgID.Hex()))
return nil, nil, selErr return nil, nil, selErr
} }
r.logger.Debug("no matching rule in org plan; falling back to global", zap.String("org_ref", orgRef.Hex())) r.logger.Debug("no matching rule in org plan; falling back to global", zap.String("org_ref", orgID.Hex()))
} else if !errors.Is(err, storage.ErrFeePlanNotFound) { } else if !errors.Is(err, storage.ErrFeePlanNotFound) {
r.logger.Warn("failed resolving org fee plan", zap.Error(err), zap.String("org_ref", orgRef.Hex())) r.logger.Warn("failed resolving org fee plan", zap.Error(err), zap.String("org_ref", orgID.Hex()))
return nil, nil, err return nil, nil, err
} }
} }

View File

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

View File

@@ -1,88 +0,0 @@
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,57 +72,26 @@ func (s *Service) Register(router routers.GRPC) error {
} }
func (s *Service) QuoteFees(ctx context.Context, req *feesv1.QuoteFeesRequest) (resp *feesv1.QuoteFeesResponse, err 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() start := s.clock.Now()
trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED
if intent != nil { if req != nil && req.GetIntent() != nil {
trigger = intent.GetTrigger() trigger = req.GetIntent().GetTrigger()
} }
var fxUsed bool var fxUsed bool
defer func() { defer func() {
statusLabel := statusFromError(err) statusLabel := statusFromError(err)
linesCount := 0
appliedCount := 0
if err == nil && resp != nil { if err == nil && resp != nil {
fxUsed = resp.GetFxUsed() != nil fxUsed = resp.GetFxUsed() != nil
linesCount = len(resp.GetLines())
appliedCount = len(resp.GetApplied())
} }
observeMetrics("quote", trigger, statusLabel, fxUsed, time.Since(start)) 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 { if err = s.validateQuoteRequest(req); err != nil {
return nil, err return nil, err
} }
orgRef, parseErr := primitive.ObjectIDFromHex(req.GetMeta().GetOrganizationRef()) orgRef, parseErr := primitive.ObjectIDFromHex(req.GetMeta().GetOrganizationRef())
if parseErr != nil { if parseErr != nil {
logger.Warn("QuoteFees invalid organization_ref", zap.Error(parseErr))
err = status.Error(codes.InvalidArgument, "invalid organization_ref") err = status.Error(codes.InvalidArgument, "invalid organization_ref")
return nil, err return nil, err
} }
@@ -143,59 +112,20 @@ 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) { 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() start := s.clock.Now()
trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED
if intent != nil { if req != nil && req.GetIntent() != nil {
trigger = intent.GetTrigger() trigger = req.GetIntent().GetTrigger()
} }
var ( var fxUsed bool
fxUsed bool
expiresAt time.Time
)
defer func() { defer func() {
statusLabel := statusFromError(err) statusLabel := statusFromError(err)
linesCount := 0
appliedCount := 0
if err == nil && resp != nil { if err == nil && resp != nil {
fxUsed = resp.GetFxUsed() != 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)) 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 { if err = s.validatePrecomputeRequest(req); err != nil {
return nil, err return nil, err
} }
@@ -204,7 +134,6 @@ func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFees
orgRef, parseErr := primitive.ObjectIDFromHex(req.GetMeta().GetOrganizationRef()) orgRef, parseErr := primitive.ObjectIDFromHex(req.GetMeta().GetOrganizationRef())
if parseErr != nil { if parseErr != nil {
logger.Warn("PrecomputeFees invalid organization_ref", zap.Error(parseErr))
err = status.Error(codes.InvalidArgument, "invalid organization_ref") err = status.Error(codes.InvalidArgument, "invalid organization_ref")
return nil, err return nil, err
} }
@@ -219,7 +148,7 @@ func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFees
if ttl <= 0 { if ttl <= 0 {
ttl = 60000 ttl = 60000
} }
expiresAt = now.Add(time.Duration(ttl) * time.Millisecond) expiresAt := now.Add(time.Duration(ttl) * time.Millisecond)
payload := feeQuoteTokenPayload{ payload := feeQuoteTokenPayload{
OrganizationRef: req.GetMeta().GetOrganizationRef(), OrganizationRef: req.GetMeta().GetOrganizationRef(),
@@ -230,7 +159,7 @@ func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFees
var token string var token string
if token, err = encodeTokenPayload(payload); err != nil { if token, err = encodeTokenPayload(payload); err != nil {
logger.Warn("failed to encode fee quote token", zap.Error(err)) s.logger.Warn("failed to encode fee quote token", zap.Error(err))
err = status.Error(codes.Internal, "failed to encode fee quote token") err = status.Error(codes.Internal, "failed to encode fee quote token")
return nil, err return nil, err
} }
@@ -247,18 +176,9 @@ 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) { 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() start := s.clock.Now()
trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED
var ( var fxUsed bool
fxUsed bool
resultReason string
)
defer func() { defer func() {
statusLabel := statusFromError(err) statusLabel := statusFromError(err)
if err == nil && resp != nil { if err == nil && resp != nil {
@@ -271,28 +191,9 @@ func (s *Service) ValidateFeeToken(ctx context.Context, req *feesv1.ValidateFeeT
} }
} }
observeMetrics("validate", trigger, statusLabel, fxUsed, time.Since(start)) 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()) == "" { if req == nil || strings.TrimSpace(req.GetFeeQuoteToken()) == "" {
resultReason = "missing_token"
err = status.Error(codes.InvalidArgument, "fee_quote_token is required") err = status.Error(codes.InvalidArgument, "fee_quote_token is required")
return nil, err return nil, err
} }
@@ -301,29 +202,21 @@ func (s *Service) ValidateFeeToken(ctx context.Context, req *feesv1.ValidateFeeT
payload, decodeErr := decodeTokenPayload(req.GetFeeQuoteToken()) payload, decodeErr := decodeTokenPayload(req.GetFeeQuoteToken())
if decodeErr != nil { if decodeErr != nil {
resultReason = "invalid_token" s.logger.Warn("failed to decode fee quote token", zap.Error(decodeErr))
logger.Warn("failed to decode fee quote token", zap.Error(decodeErr))
resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "invalid_token"} resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "invalid_token"}
return resp, nil return resp, nil
} }
logger = logger.With(logFieldsFromTokenPayload(&payload)...) trigger = payload.Intent.GetTrigger()
if payload.Intent != nil {
trigger = payload.Intent.GetTrigger()
}
if now.UnixMilli() > payload.ExpiresAtUnixMs { if now.UnixMilli() > payload.ExpiresAtUnixMs {
resultReason = "expired"
logger.Info("fee quote token expired")
resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "expired"} resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "expired"}
return resp, nil return resp, nil
} }
orgRef, parseErr := primitive.ObjectIDFromHex(payload.OrganizationRef) orgRef, parseErr := primitive.ObjectIDFromHex(payload.OrganizationRef)
if parseErr != nil { if parseErr != nil {
resultReason = "invalid_token" s.logger.Warn("token contained invalid organization reference", zap.Error(parseErr))
logger.Warn("token contained invalid organization reference", zap.Error(parseErr))
resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "invalid_token"} resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "invalid_token"}
return resp, nil return resp, nil
} }
@@ -387,16 +280,6 @@ func (s *Service) computeQuoteWithTime(ctx context.Context, orgRef primitive.Obj
bookedAt = intent.GetBookedAt().AsTime() 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 var orgPtr *primitive.ObjectID
if !orgRef.IsZero() { if !orgRef.IsZero() {
orgPtr = &orgRef orgPtr = &orgRef
@@ -405,7 +288,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()) plan, rule, err := s.resolver.ResolveFeeRule(ctx, orgPtr, convertTrigger(intent.GetTrigger()), bookedAt, intent.GetAttributes())
if err != nil { if err != nil {
switch { 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") return nil, nil, nil, status.Error(codes.NotFound, "fee rule not found")
case errors.Is(err, merrors.ErrDataConflict): case errors.Is(err, merrors.ErrDataConflict):
return nil, nil, nil, status.Error(codes.FailedPrecondition, "conflicting fee rules") return nil, nil, nil, status.Error(codes.FailedPrecondition, "conflicting fee rules")
@@ -414,7 +297,7 @@ func (s *Service) computeQuoteWithTime(ctx context.Context, orgRef primitive.Obj
case errors.Is(err, storage.ErrFeePlanNotFound): case errors.Is(err, storage.ErrFeePlanNotFound):
return nil, nil, nil, status.Error(codes.NotFound, "fee plan not found") return nil, nil, nil, status.Error(codes.NotFound, "fee plan not found")
default: default:
logger.Warn("failed to resolve fee rule", zap.Error(err)) s.logger.Warn("failed to resolve fee rule", zap.Error(err))
return nil, nil, nil, status.Error(codes.Internal, "failed to resolve fee rule") return nil, nil, nil, status.Error(codes.Internal, "failed to resolve fee rule")
} }
} }
@@ -430,7 +313,7 @@ func (s *Service) computeQuoteWithTime(ctx context.Context, orgRef primitive.Obj
if errors.Is(calcErr, merrors.ErrInvalidArg) { if errors.Is(calcErr, merrors.ErrInvalidArg) {
return nil, nil, nil, status.Error(codes.InvalidArgument, calcErr.Error()) return nil, nil, nil, status.Error(codes.InvalidArgument, calcErr.Error())
} }
logger.Warn("failed to compute fee quote", zap.Error(calcErr)) s.logger.Warn("failed to compute fee quote", zap.Error(calcErr))
return nil, nil, nil, status.Error(codes.Internal, "failed to compute fee quote") 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.SetID(primitive.NewObjectID())
plan.OrganizationRef = &orgRef plan.SetOrganizationRef(orgRef)
service := NewService( service := NewService(
zap.NewNop(), zap.NewNop(),
@@ -163,7 +163,7 @@ func TestQuoteFees_FiltersByAttributesAndDates(t *testing.T) {
}, },
} }
plan.SetID(primitive.NewObjectID()) plan.SetID(primitive.NewObjectID())
plan.OrganizationRef = &orgRef plan.SetOrganizationRef(orgRef)
service := NewService( service := NewService(
zap.NewNop(), zap.NewNop(),
@@ -224,7 +224,7 @@ func TestQuoteFees_RoundingDown(t *testing.T) {
}, },
} }
plan.SetID(primitive.NewObjectID()) plan.SetID(primitive.NewObjectID())
plan.OrganizationRef = &orgRef plan.SetOrganizationRef(orgRef)
service := NewService( service := NewService(
zap.NewNop(), zap.NewNop(),
@@ -277,7 +277,7 @@ func TestQuoteFees_UsesInjectedCalculator(t *testing.T) {
}, },
} }
plan.SetID(primitive.NewObjectID()) plan.SetID(primitive.NewObjectID())
plan.OrganizationRef = &orgRef plan.SetOrganizationRef(orgRef)
result := &types.CalculationResult{ result := &types.CalculationResult{
Lines: []*feesv1.DerivedPostingLine{ Lines: []*feesv1.DerivedPostingLine{
@@ -353,7 +353,7 @@ func TestQuoteFees_PopulatesFxUsed(t *testing.T) {
}, },
} }
plan.SetID(primitive.NewObjectID()) plan.SetID(primitive.NewObjectID())
plan.OrganizationRef = &orgRef plan.SetOrganizationRef(orgRef)
fakeOracle := &oracleclient.Fake{ fakeOracle := &oracleclient.Fake{
LatestRateFn: func(ctx context.Context, req oracleclient.LatestRateParams) (*oracleclient.RateSnapshot, error) { 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 { if s.plan == nil {
return nil, storage.ErrFeePlanNotFound return nil, storage.ErrFeePlanNotFound
} }
if (s.plan.OrganizationRef != nil) && (*s.plan.OrganizationRef != orgRef) { if s.plan.GetOrganizationRef() != orgRef {
return nil, storage.ErrFeePlanNotFound return nil, storage.ErrFeePlanNotFound
} }
if !s.plan.Active { if !s.plan.Active {

View File

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

View File

@@ -11,18 +11,12 @@ market:
- driver: CBR - driver: CBR
settings: settings:
base_url: "https://www.cbr.ru" base_url: "https://www.cbr.ru"
user_agent: "Mozilla/5.0 (compatible; SendicoFX/1.0; +https://app.sendico.io)"
accept_header: "application/xml,text/xml;q=0.9,*/*;q=0.8"
pairs: pairs:
BINANCE: BINANCE:
- base: "USDT" - base: "USDT"
quote: "EUR" quote: "EUR"
symbol: "EURUSDT" symbol: "EURUSDT"
invert: true invert: true
- base: "USD"
quote: "USDT"
symbol: "USDTUSD"
invert: true
- base: "UAH" - base: "UAH"
quote: "USDT" quote: "USDT"
symbol: "USDTUAH" symbol: "USDTUAH"

View File

@@ -32,7 +32,7 @@ require (
github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/montanaflynn/stats v0.7.1 // indirect github.com/montanaflynn/stats v0.7.1 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nats-io/nats.go v1.48.0 // indirect github.com/nats-io/nats.go v1.47.0 // indirect
github.com/nats-io/nkeys v0.4.12 // indirect github.com/nats-io/nkeys v0.4.12 // indirect
github.com/nats-io/nuid v1.0.1 // indirect github.com/nats-io/nuid v1.0.1 // indirect
github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect
@@ -49,7 +49,7 @@ require (
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect golang.org/x/text v0.32.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/grpc v1.78.0 // indirect google.golang.org/grpc v1.77.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect google.golang.org/protobuf v1.36.10 // indirect
) )

View File

@@ -95,8 +95,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U= github.com/nats-io/nats.go v1.47.0 h1:YQdADw6J/UfGUd2Oy6tn4Hq6YHxCaJrVKayxxFqYrgM=
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= github.com/nats-io/nats.go v1.47.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc= github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg= github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
@@ -212,12 +212,12 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

View File

@@ -85,7 +85,6 @@ func (s *Service) executePoll(ctx context.Context) error {
func (s *Service) pollOnce(ctx context.Context) error { func (s *Service) pollOnce(ctx context.Context) error {
var firstErr error var firstErr error
failures := 0
for _, pair := range s.pairs { for _, pair := range s.pairs {
start := time.Now() start := time.Now()
err := s.upsertPair(ctx, pair) err := s.upsertPair(ctx, pair)
@@ -97,24 +96,14 @@ func (s *Service) pollOnce(ctx context.Context) error {
if firstErr == nil { if firstErr == nil {
firstErr = err firstErr = err
} }
failures++
s.logger.Warn("Failed to ingest pair", s.logger.Warn("Failed to ingest pair",
zap.String("symbol", pair.Symbol), zap.String("symbol", pair.Symbol),
zap.String("source", pair.Source.String()), zap.String("source", pair.Source.String()),
zap.String("provider", pair.Provider),
zap.String("base", pair.Base),
zap.String("quote", pair.Quote),
zap.Bool("invert", pair.Invert),
zap.Duration("elapsed", elapsed), zap.Duration("elapsed", elapsed),
zap.Error(err), zap.Error(err),
) )
} }
} }
if failures > 0 {
s.logger.Warn("Ingestion poll completed with failures", zap.Int("failures", failures), zap.Int("total", len(s.pairs)))
} else {
s.logger.Info("Ingestion poll completed", zap.Int("total", len(s.pairs)))
}
return firstErr return firstErr
} }
@@ -126,7 +115,7 @@ func (s *Service) upsertPair(ctx context.Context, pair config.Pair) error {
ticker, err := connector.FetchTicker(ctx, pair.Symbol) ticker, err := connector.FetchTicker(ctx, pair.Symbol)
if err != nil { if err != nil {
return merrors.InternalWrap(err, "fetch ticker: "+pair.Symbol) return merrors.InternalWrap(err, "fetch ticker")
} }
bid, err := parseDecimal(ticker.BidPrice) bid, err := parseDecimal(ticker.BidPrice)
@@ -183,14 +172,9 @@ func (s *Service) upsertPair(ctx context.Context, pair config.Pair) error {
s.logger.Debug("Snapshot ingested", s.logger.Debug("Snapshot ingested",
zap.String("pair", pair.Base+"/"+pair.Quote), zap.String("pair", pair.Base+"/"+pair.Quote),
zap.String("provider", pair.Provider), zap.String("provider", pair.Provider),
zap.String("source", pair.Source.String()),
zap.String("provider_ref", snapshot.ProviderRef),
zap.String("bid", snapshot.Bid), zap.String("bid", snapshot.Bid),
zap.String("ask", snapshot.Ask), zap.String("ask", snapshot.Ask),
zap.String("mid", snapshot.Mid), zap.String("mid", snapshot.Mid),
zap.String("spread_bps", snapshot.SpreadBps),
zap.Int64("asof_unix_ms", snapshot.AsOfUnixMs),
zap.String("rate_ref", snapshot.RateRef),
) )
return nil return nil

View File

@@ -23,7 +23,7 @@ import (
type cbrConnector struct { type cbrConnector struct {
id mmodel.Driver id mmodel.Driver
provider string provider string
http *httpClient client *http.Client
base string base string
dailyPath string dailyPath string
directoryPath string directoryPath string
@@ -60,8 +60,6 @@ func NewConnector(logger mlogger.Logger, settings model.SettingsT) (mmodel.Conne
directoryPath := defaultDirectoryPath directoryPath := defaultDirectoryPath
dailyPath := defaultDailyPath dailyPath := defaultDailyPath
dynamicPath := defaultDynamicPath dynamicPath := defaultDynamicPath
userAgent := defaultUserAgent
acceptHeader := defaultAccept
if settings != nil { if settings != nil {
if value, ok := settings["base_url"].(string); ok && strings.TrimSpace(value) != "" { if value, ok := settings["base_url"].(string); ok && strings.TrimSpace(value) != "" {
@@ -79,12 +77,6 @@ func NewConnector(logger mlogger.Logger, settings model.SettingsT) (mmodel.Conne
if value, ok := settings["dynamic_path"].(string); ok && strings.TrimSpace(value) != "" { if value, ok := settings["dynamic_path"].(string); ok && strings.TrimSpace(value) != "" {
dynamicPath = strings.TrimSpace(value) dynamicPath = strings.TrimSpace(value)
} }
if value, ok := settings["user_agent"].(string); ok && strings.TrimSpace(value) != "" {
userAgent = strings.TrimSpace(value)
}
if value, ok := settings["accept_header"].(string); ok && strings.TrimSpace(value) != "" {
acceptHeader = strings.TrimSpace(value)
}
dialTimeout = common.DurationSetting(settings, "dial_timeout_seconds", dialTimeout) dialTimeout = common.DurationSetting(settings, "dial_timeout_seconds", dialTimeout)
dialKeepAlive = common.DurationSetting(settings, "dial_keep_alive_seconds", dialKeepAlive) dialKeepAlive = common.DurationSetting(settings, "dial_keep_alive_seconds", dialKeepAlive)
tlsHandshakeTimeout = common.DurationSetting(settings, "tls_handshake_timeout_seconds", tlsHandshakeTimeout) tlsHandshakeTimeout = common.DurationSetting(settings, "tls_handshake_timeout_seconds", tlsHandshakeTimeout)
@@ -107,24 +99,13 @@ func NewConnector(logger mlogger.Logger, settings model.SettingsT) (mmodel.Conne
transport = customTransport transport = customTransport
} }
client := &http.Client{
Timeout: requestTimeout,
Transport: transport,
}
referer := parsed.String()
connector := &cbrConnector{ connector := &cbrConnector{
id: mmodel.DriverCBR, id: mmodel.DriverCBR,
provider: provider, provider: provider,
http: newHTTPClient( client: &http.Client{
logger, Timeout: requestTimeout,
client, Transport: transport,
httpClientOptions{ },
userAgent: userAgent,
accept: acceptHeader,
referer: referer,
},
),
base: strings.TrimRight(parsed.String(), "/"), base: strings.TrimRight(parsed.String(), "/"),
dailyPath: dailyPath, dailyPath: dailyPath,
directoryPath: directoryPath, directoryPath: directoryPath,
@@ -180,32 +161,20 @@ func (c *cbrConnector) refreshDirectory() error {
return err return err
} }
req, err := c.http.NewRequest(context.Background(), http.MethodGet, endpoint) req, err := http.NewRequest(http.MethodGet, endpoint, nil)
if err != nil { if err != nil {
return merrors.InternalWrap(err, "cbr: build directory request") return merrors.InternalWrap(err, "cbr: build directory request")
} }
resp, err := c.http.Do(req) resp, err := c.client.Do(req)
if err != nil { if err != nil {
c.logger.Warn( c.logger.Warn("CBR directory request failed", zap.Error(err))
"CBR directory request failed",
zap.Error(err),
zap.String("endpoint", endpoint),
zap.String("referer", c.http.headerValue("Referer")),
zap.String("user_agent", c.http.headerValue("User-Agent")),
)
return merrors.InternalWrap(err, "cbr: directory request failed") return merrors.InternalWrap(err, "cbr: directory request failed")
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
c.logger.Warn( c.logger.Warn("CBR directory returned non-OK status", zap.Int("status", resp.StatusCode))
"CBR directory returned non-OK status",
zap.Int("status", resp.StatusCode),
zap.String("endpoint", endpoint),
zap.String("referer", c.http.headerValue("Referer")),
zap.String("user_agent", c.http.headerValue("User-Agent")),
)
return merrors.Internal("cbr: unexpected status " + strconv.Itoa(resp.StatusCode)) return merrors.Internal("cbr: unexpected status " + strconv.Itoa(resp.StatusCode))
} }
@@ -214,13 +183,12 @@ func (c *cbrConnector) refreshDirectory() error {
var directory valuteDirectory var directory valuteDirectory
if err := decoder.Decode(&directory); err != nil { if err := decoder.Decode(&directory); err != nil {
c.logger.Warn("CBR directory decode failed", zap.Error(err), zap.String("endpoint", endpoint)) c.logger.Warn("CBR directory decode failed", zap.Error(err))
return merrors.InternalWrap(err, "cbr: decode directory") return merrors.InternalWrap(err, "cbr: decode directory")
} }
mapping, err := buildValuteMapping(c.logger.Named("mapper"), directory.Items) mapping, err := buildValuteMapping(directory.Items)
if err != nil { if err != nil {
c.logger.Warn("Failed to build currencies mapping", zap.Error(err), zap.String("endpoint", endpoint))
return err return err
} }
@@ -232,32 +200,23 @@ func (c *cbrConnector) refreshDirectory() error {
func (c *cbrConnector) fetchDailyRate(ctx context.Context, valute valuteInfo) (string, error) { func (c *cbrConnector) fetchDailyRate(ctx context.Context, valute valuteInfo) (string, error) {
endpoint, err := c.buildURL(c.dailyPath, nil) endpoint, err := c.buildURL(c.dailyPath, nil)
if err != nil { if err != nil {
c.logger.Warn("Failed to build daily fetch URL", zap.Error(err), zap.String("path", c.dailyPath))
return "", err return "", err
} }
req, err := c.http.NewRequest(ctx, http.MethodGet, endpoint) req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil { if err != nil {
c.logger.Warn("Failed to request daily rate", zap.Error(err), zap.String("endpoint", endpoint))
return "", merrors.InternalWrap(err, "cbr: build daily request") return "", merrors.InternalWrap(err, "cbr: build daily request")
} }
resp, err := c.http.Do(req) resp, err := c.client.Do(req)
if err != nil { if err != nil {
c.logger.Warn("CBR daily request failed", c.logger.Warn("CBR daily request failed", zap.String("currency", valute.ISOCharCode), zap.Error(err))
zap.Error(err), zap.String("currency", valute.ISOCharCode),
zap.String("endpoint", endpoint), zap.String("referer", c.http.headerValue("Referer")),
zap.String("user_agent", c.http.headerValue("User-Agent")),
)
return "", merrors.InternalWrap(err, "cbr: daily request failed") return "", merrors.InternalWrap(err, "cbr: daily request failed")
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
c.logger.Warn( c.logger.Warn("CBR daily returned non-OK status", zap.Int("status", resp.StatusCode))
"CBR daily returned non-OK status", zap.Int("status", resp.StatusCode),
zap.String("currency", valute.ISOCharCode), zap.String("endpoint", endpoint),
)
return "", merrors.Internal("cbr: unexpected status " + strconv.Itoa(resp.StatusCode)) return "", merrors.Internal("cbr: unexpected status " + strconv.Itoa(resp.StatusCode))
} }
@@ -266,9 +225,7 @@ func (c *cbrConnector) fetchDailyRate(ctx context.Context, valute valuteInfo) (s
var payload dailyRates var payload dailyRates
if err := decoder.Decode(&payload); err != nil { if err := decoder.Decode(&payload); err != nil {
c.logger.Warn("CBR daily decode failed", zap.Error(err), c.logger.Warn("CBR daily decode failed", zap.Error(err))
zap.String("currency", valute.ISOCharCode), zap.String("endpoint", endpoint),
)
return "", merrors.InternalWrap(err, "cbr: decode daily response") return "", merrors.InternalWrap(err, "cbr: decode daily response")
} }
@@ -290,40 +247,25 @@ func (c *cbrConnector) fetchHistoricalRate(ctx context.Context, valute valuteInf
"date_req2": date.Format("02/01/2006"), "date_req2": date.Format("02/01/2006"),
"VAL_NM_RQ": valute.ID, "VAL_NM_RQ": valute.ID,
} }
dateStr := date.Format("2006-01-02")
endpoint, err := c.buildURL(c.dynamicPath, query) endpoint, err := c.buildURL(c.dynamicPath, query)
if err != nil { if err != nil {
return "", err return "", err
} }
req, err := c.http.NewRequest(ctx, http.MethodGet, endpoint) req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil { if err != nil {
return "", merrors.InternalWrap(err, "cbr: build historical request") return "", merrors.InternalWrap(err, "cbr: build historical request")
} }
resp, err := c.http.Do(req) resp, err := c.client.Do(req)
if err != nil { if err != nil {
c.logger.Warn( c.logger.Warn("CBR historical request failed", zap.String("currency", valute.ISOCharCode), zap.Error(err))
"CBR historical request failed",
zap.String("currency", valute.ISOCharCode),
zap.String("date", dateStr),
zap.String("endpoint", endpoint),
zap.String("referer", c.http.headerValue("Referer")),
zap.String("user_agent", c.http.headerValue("User-Agent")),
zap.Error(err),
)
return "", merrors.InternalWrap(err, "cbr: historical request failed") return "", merrors.InternalWrap(err, "cbr: historical request failed")
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
c.logger.Warn( c.logger.Warn("CBR historical returned non-OK status", zap.Int("status", resp.StatusCode))
"CBR historical returned non-OK status",
zap.Int("status", resp.StatusCode),
zap.String("currency", valute.ISOCharCode),
zap.String("date", dateStr),
zap.String("endpoint", endpoint),
)
return "", merrors.Internal("cbr: unexpected status " + strconv.Itoa(resp.StatusCode)) return "", merrors.Internal("cbr: unexpected status " + strconv.Itoa(resp.StatusCode))
} }
@@ -332,13 +274,7 @@ func (c *cbrConnector) fetchHistoricalRate(ctx context.Context, valute valuteInf
var payload dynamicRates var payload dynamicRates
if err := decoder.Decode(&payload); err != nil { if err := decoder.Decode(&payload); err != nil {
c.logger.Warn( c.logger.Warn("CBR historical decode failed", zap.Error(err))
"CBR historical decode failed",
zap.String("currency", valute.ISOCharCode),
zap.String("date", dateStr),
zap.String("endpoint", endpoint),
zap.Error(err),
)
return "", merrors.InternalWrap(err, "cbr: decode historical response") return "", merrors.InternalWrap(err, "cbr: decode historical response")
} }
@@ -401,7 +337,7 @@ type valuteMapping struct {
byID map[string]valuteInfo byID map[string]valuteInfo
} }
func buildValuteMapping(logger *zap.Logger, items []valuteItem) (*valuteMapping, error) { func buildValuteMapping(items []valuteItem) (*valuteMapping, error) {
byISO := make(map[string]valuteInfo, len(items)) byISO := make(map[string]valuteInfo, len(items))
byID := make(map[string]valuteInfo, len(items)) byID := make(map[string]valuteInfo, len(items))
byNum := make(map[string]string, len(items)) byNum := make(map[string]string, len(items))
@@ -412,19 +348,12 @@ func buildValuteMapping(logger *zap.Logger, items []valuteItem) (*valuteMapping,
isoNum := strings.TrimSpace(item.ISONum) isoNum := strings.TrimSpace(item.ISONum)
name := strings.TrimSpace(item.Name) name := strings.TrimSpace(item.Name)
engName := strings.TrimSpace(item.EngName) engName := strings.TrimSpace(item.EngName)
nominal, err := parseNominal(item.NominalStr) nominal, err := parseNominal(item.NominalStr)
if err != nil { if err != nil {
return nil, merrors.InvalidDataType("cbr: parse directory nominal: " + err.Error()) return nil, merrors.InvalidDataType("cbr: parse directory nominal: " + err.Error())
} }
if id == "" || isoChar == "" { if id == "" || isoChar == "" {
logger.Info("Skipping invalid currency entry", return nil, merrors.InvalidDataType("cbr: directory contains entry with empty id or iso code")
zap.String("id", id),
zap.String("iso_char", isoChar),
zap.String("name", name),
)
continue
} }
info := valuteInfo{ info := valuteInfo{
@@ -436,76 +365,12 @@ func buildValuteMapping(logger *zap.Logger, items []valuteItem) (*valuteMapping,
Nominal: nominal, Nominal: nominal,
} }
// Handle duplicate ISO char codes (e.g. DEM with different IDs / nominals). if existing, ok := byISO[isoChar]; ok && existing.ID != id {
if existing, ok := byISO[isoChar]; ok { return nil, merrors.InvalidDataType("cbr: duplicate ISO code " + isoChar)
// Same ISO + same ID: duplicate entry, just ignore.
if existing.ID == id {
logger.Debug("Duplicate directory entry for same ISO and ID, ignoring",
zap.String("iso_code", isoChar),
zap.String("id", id),
)
continue
}
// Different IDs but same ISO char.
// Choose canonical entry:
// 1) Prefer nominal == 1
// 2) Otherwise prefer smaller nominal
keepExisting := true
if existing.Nominal != 1 && info.Nominal == 1 {
keepExisting = false
} else if existing.Nominal == 1 && info.Nominal != 1 {
keepExisting = true
} else if info.Nominal < existing.Nominal {
keepExisting = false
}
if keepExisting {
logger.Warn("Ignoring duplicate ISO currency entry with less preferred nominal",
zap.String("iso_code", isoChar),
zap.String("existing_id", existing.ID),
zap.Int64("existing_nominal", existing.Nominal),
zap.String("new_id", info.ID),
zap.Int64("new_nominal", info.Nominal),
)
// We keep the old one, just skip the new.
continue
}
// Replace existing mapping with the new, more canonical one.
logger.Warn("Replacing currency mapping due to more canonical nominal",
zap.String("iso_code", isoChar),
zap.String("old_id", existing.ID),
zap.Int64("old_nominal", existing.Nominal),
zap.String("new_id", info.ID),
zap.Int64("new_nominal", info.Nominal),
)
// Update byID: drop old ID, add new one
delete(byID, existing.ID)
byID[id] = info
// Update ISO mapping
byISO[isoChar] = info
// Update numeric-code index if present
if isoNum != "" {
if existingID, ok := byNum[isoNum]; ok && existingID != id {
return nil, merrors.InvalidDataType("cbr: duplicate ISO numeric code " + isoNum)
}
byNum[isoNum] = id
}
continue
} }
// No existing ISO entry, do normal uniqueness checks.
if existing, ok := byID[id]; ok && existing.ISOCharCode != isoChar { if existing, ok := byID[id]; ok && existing.ISOCharCode != isoChar {
return nil, merrors.InvalidDataType("cbr: duplicate valute id " + id) return nil, merrors.InvalidDataType("cbr: duplicate valute id " + id)
} }
if isoNum != "" { if isoNum != "" {
if existingID, ok := byNum[isoNum]; ok && existingID != id { if existingID, ok := byNum[isoNum]; ok && existingID != id {
return nil, merrors.InvalidDataType("cbr: duplicate ISO numeric code " + isoNum) return nil, merrors.InvalidDataType("cbr: duplicate ISO numeric code " + isoNum)
@@ -513,8 +378,6 @@ func buildValuteMapping(logger *zap.Logger, items []valuteItem) (*valuteMapping,
byNum[isoNum] = id byNum[isoNum] = id
} }
logger.Info("Installing currency code", zap.String("iso_code", isoChar), zap.String("id", id), zap.Int64("nominal", nominal))
byISO[isoChar] = info byISO[isoChar] = info
byID[id] = info byID[id] = info
} }

View File

@@ -1,85 +0,0 @@
package cbr
import (
"context"
"net/http"
"strings"
"go.uber.org/zap"
)
const (
defaultUserAgent = "Mozilla/5.0 (compatible; SendicoFX/1.0; +https://app.sendico.io)"
defaultAccept = "application/xml,text/xml;q=0.9,*/*;q=0.8"
)
// httpClient wraps http.Client to ensure CBR requests always carry required headers.
type httpClient struct {
client *http.Client
headers http.Header
logger *zap.Logger
}
type httpClientOptions struct {
userAgent string
accept string
referer string
}
func newHTTPClient(logger *zap.Logger, client *http.Client, opts httpClientOptions) *httpClient {
userAgent := opts.userAgent
if strings.TrimSpace(userAgent) == "" {
userAgent = defaultUserAgent
}
accept := opts.accept
if strings.TrimSpace(accept) == "" {
accept = defaultAccept
}
referer := opts.referer
if strings.TrimSpace(referer) == "" {
referer = defaultCBRBaseURL
}
l := logger.Named("http_client")
headers := make(http.Header, 3)
headers.Set("User-Agent", userAgent)
headers.Set("Accept", accept)
headers.Set("Referer", referer)
l.Info("HTTP client initialized", zap.String("user_agent", userAgent),
zap.String("accept", accept), zap.String("referrer", referer))
return &httpClient{
client: client,
headers: headers,
logger: l,
}
}
func (h *httpClient) NewRequest(ctx context.Context, method, endpoint string) (*http.Request, error) {
return http.NewRequestWithContext(ctx, method, endpoint, nil)
}
func (h *httpClient) Do(req *http.Request) (*http.Response, error) {
enriched := req.Clone(req.Context())
for key, values := range h.headers {
if enriched.Header.Get(key) != "" {
continue
}
for _, value := range values {
enriched.Header.Add(key, value)
}
}
r, err := h.client.Do(enriched)
if err != nil {
h.logger.Warn("HTTP request failed", zap.Error(err), zap.String("method", req.Method),
zap.String("url", req.URL.String()))
}
return r, err
}
func (h *httpClient) headerValue(name string) string {
return h.headers.Get(name)
}

View File

@@ -40,15 +40,15 @@ func main() {
application, err := app.New(logger, *configFile) application, err := app.New(logger, *configFile)
if err != nil { if err != nil {
logger.Error("Failed to initialise application", zap.Error(err)) logger.Fatal("Failed to initialise application", zap.Error(err))
} else { }
if err := application.Run(ctx); err != nil {
if errors.Is(err, context.Canceled) { if err := application.Run(ctx); err != nil {
logger.Info("FX ingestor stopped") if errors.Is(err, context.Canceled) {
return logger.Info("FX ingestor stopped")
} return
logger.Error("Ingestor terminated with error", zap.Error(err))
} }
logger.Fatal("Ingestor terminated with error", zap.Error(err))
} }
logger.Info("FX ingestor stopped") logger.Info("FX ingestor stopped")

View File

@@ -13,8 +13,8 @@ require (
github.com/tech/sendico/pkg v0.1.0 github.com/tech/sendico/pkg v0.1.0
go.mongodb.org/mongo-driver v1.17.6 go.mongodb.org/mongo-driver v1.17.6
go.uber.org/zap v1.27.1 go.uber.org/zap v1.27.1
google.golang.org/grpc v1.78.0 google.golang.org/grpc v1.77.0
google.golang.org/protobuf v1.36.11 google.golang.org/protobuf v1.36.10
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
@@ -33,7 +33,7 @@ require (
github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/montanaflynn/stats v0.7.1 // indirect github.com/montanaflynn/stats v0.7.1 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nats-io/nats.go v1.48.0 // indirect github.com/nats-io/nats.go v1.47.0 // indirect
github.com/nats-io/nkeys v0.4.12 // indirect github.com/nats-io/nkeys v0.4.12 // indirect
github.com/nats-io/nuid v1.0.1 // indirect github.com/nats-io/nuid v1.0.1 // indirect
github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect
@@ -50,5 +50,5 @@ require (
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect golang.org/x/text v0.32.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
) )

View File

@@ -95,8 +95,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U= github.com/nats-io/nats.go v1.47.0 h1:YQdADw6J/UfGUd2Oy6tn4Hq6YHxCaJrVKayxxFqYrgM=
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= github.com/nats-io/nats.go v1.47.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc= github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg= github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
@@ -212,12 +212,12 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

View File

@@ -8,9 +8,9 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"github.com/tech/sendico/fx/storage/model" "github.com/tech/sendico/fx/storage/model"
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
smodel "github.com/tech/sendico/pkg/model"
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
"go.mongodb.org/mongo-driver/bson/primitive" "go.mongodb.org/mongo-driver/bson/primitive"
) )
@@ -138,11 +138,11 @@ func (qc *quoteComputation) buildModelQuote(firm bool, expiryMillis int64, req *
Pair: qc.pair.Pair, Pair: qc.pair.Pair,
Side: qc.sideModel, Side: qc.sideModel,
Price: formatRat(qc.priceRounded, qc.priceScale), Price: formatRat(qc.priceRounded, qc.priceScale),
BaseAmount: smodel.Money{ BaseAmount: model.Money{
Currency: qc.pair.Pair.Base, Currency: qc.pair.Pair.Base,
Amount: formatRat(qc.baseRounded, qc.baseScale), Amount: formatRat(qc.baseRounded, qc.baseScale),
}, },
QuoteAmount: smodel.Money{ QuoteAmount: model.Money{
Currency: qc.pair.Pair.Quote, Currency: qc.pair.Pair.Quote,
Amount: formatRat(qc.quoteRounded, qc.quoteScale), Amount: formatRat(qc.quoteRounded, qc.quoteScale),
}, },
@@ -170,13 +170,10 @@ func buildQuoteMeta(meta *oraclev1.RequestMeta) *model.QuoteMeta {
} }
trace := meta.GetTrace() trace := meta.GetTrace()
qm := &model.QuoteMeta{ qm := &model.QuoteMeta{
TenantRef: meta.GetTenantRef(), RequestRef: deriveRequestRef(meta, trace),
} TenantRef: meta.GetTenantRef(),
TraceRef: deriveTraceRef(meta, trace),
if trace != nil { IdempotencyKey: deriveIdempotencyKey(meta, trace),
qm.RequestRef = trace.GetRequestRef()
qm.TraceRef = trace.GetTraceRef()
qm.IdempotencyKey = trace.GetIdempotencyKey()
} }
if org := strings.TrimSpace(meta.GetOrganizationRef()); org != "" { if org := strings.TrimSpace(meta.GetOrganizationRef()); org != "" {
if objID, err := primitive.ObjectIDFromHex(org); err == nil { if objID, err := primitive.ObjectIDFromHex(org); err == nil {
@@ -203,3 +200,24 @@ func computeExpiry(now time.Time, ttlMs int64) (int64, error) {
} }
return now.Add(time.Duration(ttlMs) * time.Millisecond).UnixMilli(), nil return now.Add(time.Duration(ttlMs) * time.Millisecond).UnixMilli(), nil
} }
func deriveRequestRef(meta *oraclev1.RequestMeta, trace *tracev1.TraceContext) string {
if trace != nil && trace.GetRequestRef() != "" {
return trace.GetRequestRef()
}
return meta.GetRequestRef()
}
func deriveTraceRef(meta *oraclev1.RequestMeta, trace *tracev1.TraceContext) string {
if trace != nil && trace.GetTraceRef() != "" {
return trace.GetTraceRef()
}
return meta.GetTraceRef()
}
func deriveIdempotencyKey(meta *oraclev1.RequestMeta, trace *tracev1.TraceContext) string {
if trace != nil && trace.GetIdempotencyKey() != "" {
return trace.GetIdempotencyKey()
}
return meta.GetIdempotencyKey()
}

View File

@@ -1,90 +0,0 @@
package oracle
import (
"strings"
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
"go.uber.org/zap"
)
func quoteRequestFields(req *oraclev1.GetQuoteRequest) []zap.Field {
if req == nil {
return nil
}
fields := requestMetaFields(req.GetMeta())
if pair := req.GetPair(); pair != nil {
if base := strings.TrimSpace(pair.GetBase()); base != "" {
fields = append(fields, zap.String("pair_base", base))
}
if quote := strings.TrimSpace(pair.GetQuote()); quote != "" {
fields = append(fields, zap.String("pair_quote", quote))
}
}
if side := req.GetSide(); side != fxv1.Side_SIDE_UNSPECIFIED {
fields = append(fields, zap.String("side", side.String()))
}
if req.GetFirm() {
fields = append(fields, zap.Bool("firm", req.GetFirm()))
}
if ttl := req.GetTtlMs(); ttl > 0 {
fields = append(fields, zap.Int64("ttl_ms", ttl))
}
if maxAge := req.GetMaxAgeMs(); maxAge > 0 {
fields = append(fields, zap.Int32("max_age_ms", maxAge))
}
if provider := strings.TrimSpace(req.GetPreferredProvider()); provider != "" {
fields = append(fields, zap.String("preferred_provider", provider))
}
fields = append(fields, moneyFields("base_amount", req.GetBaseAmount())...)
fields = append(fields, moneyFields("quote_amount", req.GetQuoteAmount())...)
return fields
}
func requestMetaFields(meta *oraclev1.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))
}
if tenant := strings.TrimSpace(meta.GetTenantRef()); tenant != "" {
fields = append(fields, zap.String("tenant_ref", tenant))
}
fields = append(fields, traceFields(meta.GetTrace())...)
return fields
}
func moneyFields(prefix string, money *moneyv1.Money) []zap.Field {
if money == nil {
return nil
}
fields := make([]zap.Field, 0, 2)
if amt := strings.TrimSpace(money.GetAmount()); amt != "" {
fields = append(fields, zap.String(prefix, amt))
}
if ccy := strings.TrimSpace(money.GetCurrency()); ccy != "" {
fields = append(fields, zap.String(prefix+"_currency", ccy))
}
return fields
}
func traceFields(trace *tracev1.TraceContext) []zap.Field {
if trace == nil {
return nil
}
fields := make([]zap.Field, 0, 3)
if ref := strings.TrimSpace(trace.GetTraceRef()); ref != "" {
fields = append(fields, zap.String("trace_ref", ref))
}
if idem := strings.TrimSpace(trace.GetIdempotencyKey()); idem != "" {
fields = append(fields, zap.String("idempotency_key", idem))
}
if req := strings.TrimSpace(trace.GetRequestRef()); req != "" {
fields = append(fields, zap.String("request_ref", req))
}
return fields
}

View File

@@ -3,6 +3,7 @@ package oracle
import ( import (
"math/big" "math/big"
"strings" "strings"
"time"
"github.com/tech/sendico/fx/storage/model" "github.com/tech/sendico/fx/storage/model"
"github.com/tech/sendico/pkg/decimal" "github.com/tech/sendico/pkg/decimal"
@@ -60,3 +61,7 @@ func priceFromRate(rate *model.RateSnapshot, side fxv1.Side) (*big.Rat, error) {
return ratFromString(priceStr) return ratFromString(priceStr)
} }
func timeFromUnixMilli(ms int64) time.Time {
return time.Unix(0, ms*int64(time.Millisecond))
}

View File

@@ -101,27 +101,22 @@ func (s *Service) getQuoteResponder(ctx context.Context, req *oraclev1.GetQuoteR
if req == nil { if req == nil {
req = &oraclev1.GetQuoteRequest{} req = &oraclev1.GetQuoteRequest{}
} }
logger := s.logger.With(quoteRequestFields(req)...) s.logger.Debug("Handling GetQuote", zap.String("pair", req.GetPair().GetBase()+"/"+req.GetPair().GetQuote()), zap.Bool("firm", req.GetFirm()))
logger.Debug("Handling GetQuote")
if req.GetSide() == fxv1.Side_SIDE_UNSPECIFIED { if req.GetSide() == fxv1.Side_SIDE_UNSPECIFIED {
logger.Warn("GetQuote invalid: side missing")
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, errSideRequired) return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, errSideRequired)
} }
if req.GetBaseAmount() != nil && req.GetQuoteAmount() != nil { if req.GetBaseAmount() != nil && req.GetQuoteAmount() != nil {
logger.Warn("GetQuote invalid: both base_amount and quote_amount provided")
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, errAmountsMutuallyExclusive) return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, errAmountsMutuallyExclusive)
} }
if req.GetBaseAmount() == nil && req.GetQuoteAmount() == nil { if req.GetBaseAmount() == nil && req.GetQuoteAmount() == nil {
logger.Warn("GetQuote invalid: amount missing")
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, errAmountRequired) return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, errAmountRequired)
} }
if err := s.pingStorage(ctx); err != nil { if err := s.pingStorage(ctx); err != nil {
logger.Warn("Storage unavailable during GetQuote", zap.Error(err)) s.logger.Warn("Storage unavailable during GetQuote", zap.Error(err))
return gsresponse.Unavailable[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err) return gsresponse.Unavailable[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
} }
pairMsg := req.GetPair() pairMsg := req.GetPair()
if pairMsg == nil || strings.TrimSpace(pairMsg.GetBase()) == "" || strings.TrimSpace(pairMsg.GetQuote()) == "" { if pairMsg == nil || strings.TrimSpace(pairMsg.GetBase()) == "" || strings.TrimSpace(pairMsg.GetQuote()) == "" {
logger.Warn("GetQuote invalid: pair missing")
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, errEmptyRequest) return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, errEmptyRequest)
} }
pairKey := model.CurrencyPair{Base: strings.ToUpper(pairMsg.GetBase()), Quote: strings.ToUpper(pairMsg.GetQuote())} pairKey := model.CurrencyPair{Base: strings.ToUpper(pairMsg.GetBase()), Quote: strings.ToUpper(pairMsg.GetQuote())}
@@ -130,10 +125,8 @@ func (s *Service) getQuoteResponder(ctx context.Context, req *oraclev1.GetQuoteR
if err != nil { if err != nil {
switch { switch {
case errors.Is(err, merrors.ErrNoData): case errors.Is(err, merrors.ErrNoData):
logger.Warn("pair not supported", zap.String("pair", pairKey.Base+"/"+pairKey.Quote))
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, merrors.InvalidArgument("pair_not_supported")) return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, merrors.InvalidArgument("pair_not_supported"))
default: default:
logger.Warn("GetQuote failed to load pair", zap.Error(err))
return gsresponse.Internal[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err) return gsresponse.Internal[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
} }
} }
@@ -150,10 +143,8 @@ func (s *Service) getQuoteResponder(ctx context.Context, req *oraclev1.GetQuoteR
if err != nil { if err != nil {
switch { switch {
case errors.Is(err, merrors.ErrNoData): case errors.Is(err, merrors.ErrNoData):
logger.Warn("rate not found", zap.String("pair", pairKey.Base+"/"+pairKey.Quote), zap.String("provider", provider))
return gsresponse.FailedPrecondition[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, "rate_not_found", err) return gsresponse.FailedPrecondition[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, "rate_not_found", err)
default: default:
logger.Warn("GetQuote failed to load rate", zap.Error(err), zap.String("pair", pairKey.Base+"/"+pairKey.Quote), zap.String("provider", provider))
return gsresponse.Internal[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err) return gsresponse.Internal[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
} }
} }
@@ -162,31 +153,27 @@ func (s *Service) getQuoteResponder(ctx context.Context, req *oraclev1.GetQuoteR
if maxAge := req.GetMaxAgeMs(); maxAge > 0 { if maxAge := req.GetMaxAgeMs(); maxAge > 0 {
age := now.UnixMilli() - rate.AsOfUnixMs age := now.UnixMilli() - rate.AsOfUnixMs
if age > int64(maxAge) { if age > int64(maxAge) {
logger.Warn("Rate snapshot stale", zap.Int64("age_ms", age), zap.Int32("max_age_ms", req.GetMaxAgeMs()), zap.String("pair", pairKey.Base+"/"+pairKey.Quote), zap.String("provider", provider)) s.logger.Warn("Rate snapshot stale", zap.Int64("age_ms", age), zap.Int32("max_age_ms", req.GetMaxAgeMs()), zap.String("pair", pairKey.Base+"/"+pairKey.Quote), zap.String("provider", provider))
return gsresponse.FailedPrecondition[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, "stale_rate", merrors.InvalidArgument("rate older than allowed window")) return gsresponse.FailedPrecondition[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, "stale_rate", merrors.InvalidArgument("rate older than allowed window"))
} }
} }
comp, err := newQuoteComputation(pair, rate, req.GetSide(), provider) comp, err := newQuoteComputation(pair, rate, req.GetSide(), provider)
if err != nil { if err != nil {
logger.Warn("GetQuote invalid input", zap.Error(err))
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err) return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
} }
if req.GetBaseAmount() != nil { if req.GetBaseAmount() != nil {
if err := comp.withBaseInput(req.GetBaseAmount()); err != nil { if err := comp.withBaseInput(req.GetBaseAmount()); err != nil {
logger.Warn("GetQuote invalid base input", zap.Error(err))
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err) return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
} }
} else if req.GetQuoteAmount() != nil { } else if req.GetQuoteAmount() != nil {
if err := comp.withQuoteInput(req.GetQuoteAmount()); err != nil { if err := comp.withQuoteInput(req.GetQuoteAmount()); err != nil {
logger.Warn("GetQuote invalid quote input", zap.Error(err))
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err) return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
} }
} }
if err := comp.compute(); err != nil { if err := comp.compute(); err != nil {
logger.Warn("GetQuote computation failed", zap.Error(err))
return gsresponse.Internal[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err) return gsresponse.Internal[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
} }
@@ -208,14 +195,12 @@ func (s *Service) getQuoteResponder(ctx context.Context, req *oraclev1.GetQuoteR
if err := s.storage.Quotes().Issue(ctx, quoteModel); err != nil { if err := s.storage.Quotes().Issue(ctx, quoteModel); err != nil {
switch { switch {
case errors.Is(err, merrors.ErrDataConflict): case errors.Is(err, merrors.ErrDataConflict):
logger.Warn("GetQuote conflict issuing firm quote", zap.Error(err), zap.String("quote_ref", quoteModel.QuoteRef))
return gsresponse.Conflict[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err) return gsresponse.Conflict[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
default: default:
logger.Warn("GetQuote failed to issue firm quote", zap.Error(err), zap.String("quote_ref", quoteModel.QuoteRef))
return gsresponse.Internal[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err) return gsresponse.Internal[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
} }
} }
logger.Info("Firm quote stored", zap.String("quote_ref", quoteModel.QuoteRef), zap.String("pair", pairKey.Base+"/"+pairKey.Quote), zap.String("provider", quoteModel.Provider), zap.Int64("expires_at_ms", quoteModel.ExpiresAtUnixMs)) s.logger.Info("Firm quote stored", zap.String("quote_ref", quoteModel.QuoteRef), zap.String("pair", pairKey.Base+"/"+pairKey.Quote), zap.String("provider", quoteModel.Provider), zap.Int64("expires_at_ms", quoteModel.ExpiresAtUnixMs))
} }
resp := &oraclev1.GetQuoteResponse{ resp := &oraclev1.GetQuoteResponse{
@@ -229,24 +214,18 @@ func (s *Service) validateQuoteResponder(ctx context.Context, req *oraclev1.Vali
if req == nil { if req == nil {
req = &oraclev1.ValidateQuoteRequest{} req = &oraclev1.ValidateQuoteRequest{}
} }
logger := s.logger.With(requestMetaFields(req.GetMeta())...) s.logger.Debug("Handling ValidateQuote", zap.String("quote_ref", req.GetQuoteRef()))
if ref := strings.TrimSpace(req.GetQuoteRef()); ref != "" {
logger = logger.With(zap.String("quote_ref", ref))
}
logger.Debug("Handling ValidateQuote")
if req.GetQuoteRef() == "" { if req.GetQuoteRef() == "" {
logger.Warn("ValidateQuote invalid: quote_ref missing")
return gsresponse.InvalidArgument[oraclev1.ValidateQuoteResponse](s.logger, mservice.FXOracle, errQuoteRefRequired) return gsresponse.InvalidArgument[oraclev1.ValidateQuoteResponse](s.logger, mservice.FXOracle, errQuoteRefRequired)
} }
if err := s.pingStorage(ctx); err != nil { if err := s.pingStorage(ctx); err != nil {
logger.Warn("Storage unavailable during ValidateQuote", zap.Error(err)) s.logger.Warn("Storage unavailable during ValidateQuote", zap.Error(err))
return gsresponse.Unavailable[oraclev1.ValidateQuoteResponse](s.logger, mservice.FXOracle, err) return gsresponse.Unavailable[oraclev1.ValidateQuoteResponse](s.logger, mservice.FXOracle, err)
} }
quote, err := s.storage.Quotes().GetByRef(ctx, req.GetQuoteRef()) quote, err := s.storage.Quotes().GetByRef(ctx, req.GetQuoteRef())
if err != nil { if err != nil {
switch { switch {
case errors.Is(err, merrors.ErrNoData): case errors.Is(err, merrors.ErrNoData):
logger.Warn("ValidateQuote: quote not found", zap.String("quote_ref", req.GetQuoteRef()))
resp := &oraclev1.ValidateQuoteResponse{ resp := &oraclev1.ValidateQuoteResponse{
Meta: buildResponseMeta(req.GetMeta()), Meta: buildResponseMeta(req.GetMeta()),
Quote: nil, Quote: nil,
@@ -255,7 +234,6 @@ func (s *Service) validateQuoteResponder(ctx context.Context, req *oraclev1.Vali
} }
return gsresponse.Success(resp) return gsresponse.Success(resp)
default: default:
logger.Warn("ValidateQuote failed", zap.Error(err))
return gsresponse.Internal[oraclev1.ValidateQuoteResponse](s.logger, mservice.FXOracle, err) return gsresponse.Internal[oraclev1.ValidateQuoteResponse](s.logger, mservice.FXOracle, err)
} }
} }
@@ -277,11 +255,6 @@ func (s *Service) validateQuoteResponder(ctx context.Context, req *oraclev1.Vali
Valid: valid, Valid: valid,
Reason: reason, Reason: reason,
} }
if !valid {
logger.Info("ValidateQuote invalid", zap.String("reason", reason), zap.Bool("firm", quote.Firm))
} else {
logger.Debug("ValidateQuote valid", zap.Bool("firm", quote.Firm))
}
return gsresponse.Success(resp) return gsresponse.Success(resp)
} }
@@ -289,43 +262,29 @@ func (s *Service) consumeQuoteResponder(ctx context.Context, req *oraclev1.Consu
if req == nil { if req == nil {
req = &oraclev1.ConsumeQuoteRequest{} req = &oraclev1.ConsumeQuoteRequest{}
} }
logger := s.logger.With(requestMetaFields(req.GetMeta())...) s.logger.Debug("Handling ConsumeQuote", zap.String("quote_ref", req.GetQuoteRef()), zap.String("ledger_txn_ref", req.GetLedgerTxnRef()))
if ref := strings.TrimSpace(req.GetQuoteRef()); ref != "" {
logger = logger.With(zap.String("quote_ref", ref))
}
if ledger := strings.TrimSpace(req.GetLedgerTxnRef()); ledger != "" {
logger = logger.With(zap.String("ledger_txn_ref", ledger))
}
logger.Debug("Handling ConsumeQuote")
if req.GetQuoteRef() == "" { if req.GetQuoteRef() == "" {
logger.Warn("ConsumeQuote invalid: quote_ref missing")
return gsresponse.InvalidArgument[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, errQuoteRefRequired) return gsresponse.InvalidArgument[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, errQuoteRefRequired)
} }
if req.GetLedgerTxnRef() == "" { if req.GetLedgerTxnRef() == "" {
logger.Warn("ConsumeQuote invalid: ledger_txn_ref missing")
return gsresponse.InvalidArgument[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, errLedgerTxnRefRequired) return gsresponse.InvalidArgument[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, errLedgerTxnRefRequired)
} }
if err := s.pingStorage(ctx); err != nil { if err := s.pingStorage(ctx); err != nil {
logger.Warn("Storage unavailable during ConsumeQuote", zap.Error(err)) s.logger.Warn("Storage unavailable during ConsumeQuote", zap.Error(err))
return gsresponse.Unavailable[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, err) return gsresponse.Unavailable[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, err)
} }
_, err := s.storage.Quotes().Consume(ctx, req.GetQuoteRef(), req.GetLedgerTxnRef(), time.Now()) _, err := s.storage.Quotes().Consume(ctx, req.GetQuoteRef(), req.GetLedgerTxnRef(), time.Now())
if err != nil { if err != nil {
switch { switch {
case errors.Is(err, storage.ErrQuoteExpired): case errors.Is(err, storage.ErrQuoteExpired):
logger.Warn("ConsumeQuote failed: expired")
return gsresponse.FailedPrecondition[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, "expired", err) return gsresponse.FailedPrecondition[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, "expired", err)
case errors.Is(err, storage.ErrQuoteConsumed): case errors.Is(err, storage.ErrQuoteConsumed):
logger.Warn("ConsumeQuote failed: already consumed")
return gsresponse.FailedPrecondition[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, "consumed", err) return gsresponse.FailedPrecondition[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, "consumed", err)
case errors.Is(err, storage.ErrQuoteNotFirm): case errors.Is(err, storage.ErrQuoteNotFirm):
logger.Warn("ConsumeQuote failed: quote not firm")
return gsresponse.FailedPrecondition[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, "not_firm", err) return gsresponse.FailedPrecondition[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, "not_firm", err)
case errors.Is(err, merrors.ErrNoData): case errors.Is(err, merrors.ErrNoData):
logger.Warn("ConsumeQuote failed: quote not found")
return gsresponse.NotFound[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, err) return gsresponse.NotFound[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, err)
default: default:
logger.Warn("ConsumeQuote failed", zap.Error(err))
return gsresponse.Internal[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, err) return gsresponse.Internal[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, err)
} }
} }
@@ -335,7 +294,7 @@ func (s *Service) consumeQuoteResponder(ctx context.Context, req *oraclev1.Consu
Consumed: true, Consumed: true,
Reason: "consumed", Reason: "consumed",
} }
logger.Info("Quote consumed") s.logger.Debug("Quote consumed", zap.String("quote_ref", req.GetQuoteRef()), zap.String("ledger_txn_ref", req.GetLedgerTxnRef()))
return gsresponse.Success(resp) return gsresponse.Success(resp)
} }
@@ -343,21 +302,13 @@ func (s *Service) latestRateResponder(ctx context.Context, req *oraclev1.LatestR
if req == nil { if req == nil {
req = &oraclev1.LatestRateRequest{} req = &oraclev1.LatestRateRequest{}
} }
logger := s.logger.With(requestMetaFields(req.GetMeta())...) s.logger.Debug("Handling LatestRate", zap.String("pair", req.GetPair().GetBase()+"/"+req.GetPair().GetQuote()))
if pair := req.GetPair(); pair != nil {
logger = logger.With(zap.String("pair_base", strings.TrimSpace(pair.GetBase())), zap.String("pair_quote", strings.TrimSpace(pair.GetQuote())))
}
if provider := strings.TrimSpace(req.GetProvider()); provider != "" {
logger = logger.With(zap.String("provider", provider))
}
logger.Debug("Handling LatestRate")
if err := s.pingStorage(ctx); err != nil { if err := s.pingStorage(ctx); err != nil {
logger.Warn("Storage unavailable during LatestRate", zap.Error(err)) s.logger.Warn("Storage unavailable during LatestRate", zap.Error(err))
return gsresponse.Unavailable[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err) return gsresponse.Unavailable[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err)
} }
pairMsg := req.GetPair() pairMsg := req.GetPair()
if pairMsg == nil || strings.TrimSpace(pairMsg.GetBase()) == "" || strings.TrimSpace(pairMsg.GetQuote()) == "" { if pairMsg == nil || strings.TrimSpace(pairMsg.GetBase()) == "" || strings.TrimSpace(pairMsg.GetQuote()) == "" {
logger.Warn("LatestRate invalid: pair missing")
return gsresponse.InvalidArgument[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, errEmptyRequest) return gsresponse.InvalidArgument[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, errEmptyRequest)
} }
pair := model.CurrencyPair{Base: strings.ToUpper(pairMsg.GetBase()), Quote: strings.ToUpper(pairMsg.GetQuote())} pair := model.CurrencyPair{Base: strings.ToUpper(pairMsg.GetBase()), Quote: strings.ToUpper(pairMsg.GetQuote())}
@@ -366,10 +317,8 @@ func (s *Service) latestRateResponder(ctx context.Context, req *oraclev1.LatestR
if err != nil { if err != nil {
switch { switch {
case errors.Is(err, merrors.ErrNoData): case errors.Is(err, merrors.ErrNoData):
logger.Warn("LatestRate pair not found")
return gsresponse.NotFound[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err) return gsresponse.NotFound[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err)
default: default:
logger.Warn("LatestRate failed to load pair", zap.Error(err))
return gsresponse.Internal[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err) return gsresponse.Internal[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err)
} }
} }
@@ -386,10 +335,8 @@ func (s *Service) latestRateResponder(ctx context.Context, req *oraclev1.LatestR
if err != nil { if err != nil {
switch { switch {
case errors.Is(err, merrors.ErrNoData): case errors.Is(err, merrors.ErrNoData):
logger.Warn("LatestRate not found", zap.String("provider", provider))
return gsresponse.NotFound[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err) return gsresponse.NotFound[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err)
default: default:
logger.Warn("LatestRate failed", zap.Error(err))
return gsresponse.Internal[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err) return gsresponse.Internal[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err)
} }
} }
@@ -398,7 +345,6 @@ func (s *Service) latestRateResponder(ctx context.Context, req *oraclev1.LatestR
Meta: buildResponseMeta(req.GetMeta()), Meta: buildResponseMeta(req.GetMeta()),
Rate: rateModelToProto(rate), Rate: rateModelToProto(rate),
} }
logger.Debug("LatestRate succeeded", zap.String("provider", provider), zap.Int64("asof_unix_ms", rate.AsOfUnixMs))
return gsresponse.Success(resp) return gsresponse.Success(resp)
} }
@@ -406,15 +352,13 @@ func (s *Service) listPairsResponder(ctx context.Context, req *oraclev1.ListPair
if req == nil { if req == nil {
req = &oraclev1.ListPairsRequest{} req = &oraclev1.ListPairsRequest{}
} }
logger := s.logger.With(requestMetaFields(req.GetMeta())...) s.logger.Debug("Handling ListPairs")
logger.Debug("Handling ListPairs")
if err := s.pingStorage(ctx); err != nil { if err := s.pingStorage(ctx); err != nil {
logger.Warn("Storage unavailable during ListPairs", zap.Error(err)) s.logger.Warn("Storage unavailable during ListPairs", zap.Error(err))
return gsresponse.Unavailable[oraclev1.ListPairsResponse](s.logger, mservice.FXOracle, err) return gsresponse.Unavailable[oraclev1.ListPairsResponse](s.logger, mservice.FXOracle, err)
} }
pairs, err := s.storage.Pairs().ListEnabled(ctx) pairs, err := s.storage.Pairs().ListEnabled(ctx)
if err != nil { if err != nil {
logger.Warn("ListPairs failed", zap.Error(err))
return gsresponse.Internal[oraclev1.ListPairsResponse](s.logger, mservice.FXOracle, err) return gsresponse.Internal[oraclev1.ListPairsResponse](s.logger, mservice.FXOracle, err)
} }
result := make([]*oraclev1.PairMeta, 0, len(pairs)) result := make([]*oraclev1.PairMeta, 0, len(pairs))
@@ -425,7 +369,7 @@ func (s *Service) listPairsResponder(ctx context.Context, req *oraclev1.ListPair
Meta: buildResponseMeta(req.GetMeta()), Meta: buildResponseMeta(req.GetMeta()),
Pairs: result, Pairs: result,
} }
logger.Debug("ListPairs returning metadata", zap.Int("pairs", len(resp.GetPairs()))) s.logger.Debug("ListPairs returning metadata", zap.Int("pairs", len(resp.GetPairs())))
return gsresponse.Success(resp) return gsresponse.Success(resp)
} }

View File

@@ -9,7 +9,6 @@ import (
"github.com/tech/sendico/fx/storage" "github.com/tech/sendico/fx/storage"
"github.com/tech/sendico/fx/storage/model" "github.com/tech/sendico/fx/storage/model"
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
smodel "github.com/tech/sendico/pkg/model"
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1" tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
@@ -382,8 +381,8 @@ func TestServiceValidateQuote(t *testing.T) {
Pair: model.CurrencyPair{Base: "USD", Quote: "EUR"}, Pair: model.CurrencyPair{Base: "USD", Quote: "EUR"},
Side: model.QuoteSideBuyBaseSellQuote, Side: model.QuoteSideBuyBaseSellQuote,
Price: "1.10", Price: "1.10",
BaseAmount: smodel.Money{Currency: "USD", Amount: "100"}, BaseAmount: model.Money{Currency: "USD", Amount: "100"},
QuoteAmount: smodel.Money{Currency: "EUR", Amount: "110"}, QuoteAmount: model.Money{Currency: "EUR", Amount: "110"},
ExpiresAtUnixMs: now.UnixMilli(), ExpiresAtUnixMs: now.UnixMilli(),
Status: model.QuoteStatusIssued, Status: model.QuoteStatusIssued,
}, nil }, nil

View File

@@ -4,9 +4,9 @@ import (
"strings" "strings"
"github.com/tech/sendico/fx/storage/model" "github.com/tech/sendico/fx/storage/model"
smodel "github.com/tech/sendico/pkg/model"
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
) )
@@ -15,11 +15,18 @@ func buildResponseMeta(meta *oraclev1.RequestMeta) *oraclev1.ResponseMeta {
if meta == nil { if meta == nil {
return resp return resp
} }
resp.RequestRef = meta.GetRequestRef()
resp.TraceRef = meta.GetTraceRef()
trace := meta.GetTrace() trace := meta.GetTrace()
if trace != nil { if trace == nil {
resp.Trace = trace trace = &tracev1.TraceContext{
RequestRef: meta.GetRequestRef(),
IdempotencyKey: meta.GetIdempotencyKey(),
TraceRef: meta.GetTraceRef(),
}
} }
resp.Trace = trace
return resp return resp
} }
@@ -42,7 +49,7 @@ func quoteModelToProto(q *model.Quote) *oraclev1.Quote {
} }
} }
func moneyModelToProto(m *smodel.Money) *moneyv1.Money { func moneyModelToProto(m *model.Money) *moneyv1.Money {
if m == nil { if m == nil {
return nil return nil
} }

View File

@@ -28,5 +28,5 @@ require (
golang.org/x/crypto v0.46.0 // indirect golang.org/x/crypto v0.46.0 // indirect
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.19.0 // indirect
golang.org/x/text v0.32.0 // indirect golang.org/x/text v0.32.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect google.golang.org/protobuf v1.36.10 // indirect
) )

View File

@@ -169,7 +169,7 @@ golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBn
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -4,7 +4,6 @@ import (
"time" "time"
"github.com/tech/sendico/pkg/db/storable" "github.com/tech/sendico/pkg/db/storable"
"github.com/tech/sendico/pkg/model"
) )
// Quote represents a firm or indicative quote persisted by the oracle. // Quote represents a firm or indicative quote persisted by the oracle.
@@ -17,8 +16,8 @@ type Quote struct {
Pair CurrencyPair `bson:"pair" json:"pair"` Pair CurrencyPair `bson:"pair" json:"pair"`
Side QuoteSide `bson:"side" json:"side"` Side QuoteSide `bson:"side" json:"side"`
Price string `bson:"price" json:"price"` Price string `bson:"price" json:"price"`
BaseAmount model.Money `bson:"baseAmount" json:"baseAmount"` BaseAmount Money `bson:"baseAmount" json:"baseAmount"`
QuoteAmount model.Money `bson:"quoteAmount" json:"quoteAmount"` QuoteAmount Money `bson:"quoteAmount" json:"quoteAmount"`
AmountType QuoteAmountType `bson:"amountType" json:"amountType"` AmountType QuoteAmountType `bson:"amountType" json:"amountType"`
ExpiresAtUnixMs int64 `bson:"expiresAtUnixMs" json:"expiresAtUnixMs"` ExpiresAtUnixMs int64 `bson:"expiresAtUnixMs" json:"expiresAtUnixMs"`
ExpiresAt *time.Time `bson:"expiresAt,omitempty" json:"expiresAt,omitempty"` ExpiresAt *time.Time `bson:"expiresAt,omitempty" json:"expiresAt,omitempty"`

View File

@@ -51,6 +51,12 @@ type CurrencyPair struct {
Quote string `bson:"quote" json:"quote"` Quote string `bson:"quote" json:"quote"`
} }
// Money represents an exact decimal amount with its currency.
type Money struct {
Currency string `bson:"currency" json:"currency"`
Amount string `bson:"amount" json:"amount"`
}
// QuoteMeta carries request-scoped metadata associated with a quote. // QuoteMeta carries request-scoped metadata associated with a quote.
type QuoteMeta struct { type QuoteMeta struct {
model.OrganizationBoundBase `bson:",inline" json:",inline"` model.OrganizationBoundBase `bson:",inline" json:",inline"`

View File

@@ -34,18 +34,16 @@ messaging:
reconnect_wait: 5 reconnect_wait: 5
chains: chains:
- name: tron_mainnet - name: arbitrum_one
chain_id: 728126428 # 0x2b6653dc rpc_url_env: CHAIN_GATEWAY_ARBITRUM_RPC_URL
native_token: TRX
rpc_url_env: CHAIN_GATEWAY_RPC_URL
tokens: tokens:
- symbol: USDT
contract: "0xa614f803b6fd780986a42c78ec9c7f77e6ded13c"
- symbol: USDC - symbol: USDC
contract: "0x3487b63d30b5b2c87fb7ffa8bcfade38eaac1abe" contract: "0xaf88d065e77c8cc2239327c5edb3a432268e5831"
- symbol: USDT
contract: "0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9"
service_wallet: service_wallet:
chain: tron_mainnet chain: arbitrum_one
address_env: CHAIN_GATEWAY_SERVICE_WALLET_ADDRESS address_env: CHAIN_GATEWAY_SERVICE_WALLET_ADDRESS
private_key_env: CHAIN_GATEWAY_SERVICE_WALLET_KEY private_key_env: CHAIN_GATEWAY_SERVICE_WALLET_KEY
@@ -60,4 +58,3 @@ key_management:
cache: cache:
wallet_balance_ttl_seconds: 120 wallet_balance_ttl_seconds: 120
rpc_request_timeout_seconds: 15

View File

@@ -15,14 +15,14 @@ require (
github.com/tech/sendico/pkg v0.1.0 github.com/tech/sendico/pkg v0.1.0
go.mongodb.org/mongo-driver v1.17.6 go.mongodb.org/mongo-driver v1.17.6
go.uber.org/zap v1.27.1 go.uber.org/zap v1.27.1
google.golang.org/grpc v1.78.0 google.golang.org/grpc v1.77.0
google.golang.org/protobuf v1.36.11 google.golang.org/protobuf v1.36.10
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
require ( require (
github.com/Microsoft/go-winio v0.6.2 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251223223124-03e3cef63e04 // indirect github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251208031133-be43a854e4be // indirect
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/bits-and-blooms/bitset v1.24.4 // indirect github.com/bits-and-blooms/bitset v1.24.4 // indirect
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
@@ -60,7 +60,7 @@ require (
github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/montanaflynn/stats v0.7.1 // indirect github.com/montanaflynn/stats v0.7.1 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nats-io/nats.go v1.48.0 // indirect github.com/nats-io/nats.go v1.47.0 // indirect
github.com/nats-io/nkeys v0.4.12 // indirect github.com/nats-io/nkeys v0.4.12 // indirect
github.com/nats-io/nuid v1.0.1 // indirect github.com/nats-io/nuid v1.0.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
@@ -86,5 +86,5 @@ require (
golang.org/x/sys v0.39.0 // indirect golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect golang.org/x/text v0.32.0 // indirect
golang.org/x/time v0.14.0 // indirect golang.org/x/time v0.14.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
) )

View File

@@ -6,8 +6,8 @@ github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ=
github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo= github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251223223124-03e3cef63e04 h1:wCr/SrKzMrtW9wG85ApPfncRr7ajzkRevhsWnCkl2sE= github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251208031133-be43a854e4be h1:1LtMLkGIqE5IQZ7Vdh4zv8A6LECInKF86/fTVxKxYLE=
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251223223124-03e3cef63e04/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI= github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251208031133-be43a854e4be/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0= github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0=
github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU= github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
@@ -207,8 +207,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U= github.com/nats-io/nats.go v1.47.0 h1:YQdADw6J/UfGUd2Oy6tn4Hq6YHxCaJrVKayxxFqYrgM=
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= github.com/nats-io/nats.go v1.47.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc= github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg= github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
@@ -362,12 +362,12 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

View File

@@ -2,7 +2,6 @@ package serverimp
import ( import (
"context" "context"
"fmt"
"os" "os"
"strings" "strings"
"time" "time"
@@ -11,7 +10,6 @@ import (
"github.com/tech/sendico/gateway/chain/internal/keymanager" "github.com/tech/sendico/gateway/chain/internal/keymanager"
vaultmanager "github.com/tech/sendico/gateway/chain/internal/keymanager/vault" vaultmanager "github.com/tech/sendico/gateway/chain/internal/keymanager/vault"
gatewayservice "github.com/tech/sendico/gateway/chain/internal/service/gateway" gatewayservice "github.com/tech/sendico/gateway/chain/internal/service/gateway"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/rpcclient"
gatewayshared "github.com/tech/sendico/gateway/chain/internal/service/gateway/shared" gatewayshared "github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/chain/storage" "github.com/tech/sendico/gateway/chain/storage"
gatewaymongo "github.com/tech/sendico/gateway/chain/storage/mongo" gatewaymongo "github.com/tech/sendico/gateway/chain/storage/mongo"
@@ -32,8 +30,6 @@ type Imp struct {
config *config config *config
app *grpcapp.App[storage.Repository] app *grpcapp.App[storage.Repository]
rpcClients *rpcclient.Clients
} }
type config struct { type config struct {
@@ -88,9 +84,6 @@ func (i *Imp) Shutdown() {
defer cancel() defer cancel()
i.app.Shutdown(ctx) i.app.Shutdown(ctx)
if i.rpcClients != nil {
i.rpcClients.Close()
}
} }
func (i *Imp) Start() error { func (i *Imp) Start() error {
@@ -105,17 +98,7 @@ func (i *Imp) Start() error {
} }
cl := i.logger.Named("config") cl := i.logger.Named("config")
networkConfigs, err := resolveNetworkConfigs(cl.Named("network"), cfg.Chains) networkConfigs := resolveNetworkConfigs(cl.Named("network"), cfg.Chains)
if err != nil {
i.logger.Error("invalid chain network configuration", zap.Error(err))
return err
}
rpcClients, err := rpcclient.Prepare(context.Background(), i.logger.Named("rpc"), networkConfigs)
if err != nil {
i.logger.Error("failed to prepare rpc clients", zap.Error(err))
return err
}
i.rpcClients = rpcClients
walletConfig := resolveServiceWallet(cl.Named("wallet"), cfg.ServiceWallet) walletConfig := resolveServiceWallet(cl.Named("wallet"), cfg.ServiceWallet)
keyManager, err := resolveKeyManager(i.logger.Named("key_manager"), cfg.KeyManagement) keyManager, err := resolveKeyManager(i.logger.Named("key_manager"), cfg.KeyManagement)
if err != nil { if err != nil {
@@ -123,13 +106,12 @@ func (i *Imp) Start() error {
} }
serviceFactory := func(logger mlogger.Logger, repo storage.Repository, producer msg.Producer) (grpcapp.Service, error) { serviceFactory := func(logger mlogger.Logger, repo storage.Repository, producer msg.Producer) (grpcapp.Service, error) {
executor := gatewayservice.NewOnChainExecutor(logger, keyManager, rpcClients) executor := gatewayservice.NewOnChainExecutor(logger, keyManager)
opts := []gatewayservice.Option{ opts := []gatewayservice.Option{
gatewayservice.WithNetworks(networkConfigs), gatewayservice.WithNetworks(networkConfigs),
gatewayservice.WithServiceWallet(walletConfig), gatewayservice.WithServiceWallet(walletConfig),
gatewayservice.WithKeyManager(keyManager), gatewayservice.WithKeyManager(keyManager),
gatewayservice.WithTransferExecutor(executor), gatewayservice.WithTransferExecutor(executor),
gatewayservice.WithRPCClients(rpcClients),
gatewayservice.WithSettings(cfg.Settings), gatewayservice.WithSettings(cfg.Settings),
} }
return gatewayservice.NewService(logger, repo, producer, opts...), nil return gatewayservice.NewService(logger, repo, producer, opts...), nil
@@ -175,7 +157,7 @@ func (i *Imp) loadConfig() (*config, error) {
return cfg, nil return cfg, nil
} }
func resolveNetworkConfigs(logger mlogger.Logger, chains []chainConfig) ([]gatewayshared.Network, error) { func resolveNetworkConfigs(logger mlogger.Logger, chains []chainConfig) []gatewayshared.Network {
result := make([]gatewayshared.Network, 0, len(chains)) result := make([]gatewayshared.Network, 0, len(chains))
for _, chain := range chains { for _, chain := range chains {
if strings.TrimSpace(chain.Name) == "" { if strings.TrimSpace(chain.Name) == "" {
@@ -184,8 +166,7 @@ func resolveNetworkConfigs(logger mlogger.Logger, chains []chainConfig) ([]gatew
} }
rpcURL := strings.TrimSpace(os.Getenv(chain.RPCURLEnv)) rpcURL := strings.TrimSpace(os.Getenv(chain.RPCURLEnv))
if rpcURL == "" { if rpcURL == "" {
logger.Error("RPC url not configured", zap.String("chain", chain.Name), zap.String("env", chain.RPCURLEnv)) logger.Warn("chain RPC endpoint not configured", zap.String("chain", chain.Name), zap.String("env", chain.RPCURLEnv))
return nil, merrors.InvalidArgument(fmt.Sprintf("chain RPC endpoint not configured (chain=%s env=%s)", chain.Name, chain.RPCURLEnv))
} }
contracts := make([]gatewayshared.TokenContract, 0, len(chain.Tokens)) contracts := make([]gatewayshared.TokenContract, 0, len(chain.Tokens))
for _, token := range chain.Tokens { for _, token := range chain.Tokens {
@@ -221,7 +202,7 @@ func resolveNetworkConfigs(logger mlogger.Logger, chains []chainConfig) ([]gatew
TokenConfigs: contracts, TokenConfigs: contracts,
}) })
} }
return result, nil return result
} }
func resolveServiceWallet(logger mlogger.Logger, cfg serviceWalletConfig) gatewayshared.ServiceWallet { func resolveServiceWallet(logger mlogger.Logger, cfg serviceWalletConfig) gatewayshared.ServiceWallet {

View File

@@ -2,9 +2,7 @@ package transfer
import ( import (
"context" "context"
"time"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/rpcclient"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared" "github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/chain/storage" "github.com/tech/sendico/gateway/chain/storage"
clockpkg "github.com/tech/sendico/pkg/clock" clockpkg "github.com/tech/sendico/pkg/clock"
@@ -13,10 +11,9 @@ import (
type Deps struct { type Deps struct {
Logger mlogger.Logger Logger mlogger.Logger
Networks *rpcclient.Registry Networks map[string]shared.Network
Storage storage.Repository Storage storage.Repository
Clock clockpkg.Clock Clock clockpkg.Clock
RPCTimeout time.Duration
EnsureRepository func(context.Context) error EnsureRepository func(context.Context) error
LaunchExecution func(transferRef, sourceWalletRef string, network shared.Network) LaunchExecution func(transferRef, sourceWalletRef string, network shared.Network)
} }

View File

@@ -63,7 +63,7 @@ func (c *estimateTransferFeeCommand) Execute(ctx context.Context, req *chainv1.E
} }
networkKey := strings.ToLower(strings.TrimSpace(sourceWallet.Network)) networkKey := strings.ToLower(strings.TrimSpace(sourceWallet.Network))
networkCfg, ok := c.deps.Networks.Network(networkKey) networkCfg, ok := c.deps.Networks[networkKey]
if !ok { if !ok {
c.deps.Logger.Warn("unsupported chain", zap.String("network", networkKey)) c.deps.Logger.Warn("unsupported chain", zap.String("network", networkKey))
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain for wallet")) return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain for wallet"))
@@ -85,7 +85,7 @@ func (c *estimateTransferFeeCommand) Execute(ctx context.Context, req *chainv1.E
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err) return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
} }
feeMoney, err := estimateNetworkFee(ctx, c.deps.Logger, networkCfg, c.deps.RPCTimeout, sourceWallet, destinationAddress, amount) feeMoney, err := estimateNetworkFee(ctx, c.deps.Logger, networkCfg, sourceWallet, destinationAddress, amount)
if err != nil { if err != nil {
c.deps.Logger.Warn("fee estimation failed", zap.Error(err)) c.deps.Logger.Warn("fee estimation failed", zap.Error(err))
return gsresponse.Auto[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err) return gsresponse.Auto[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
@@ -98,7 +98,7 @@ func (c *estimateTransferFeeCommand) Execute(ctx context.Context, req *chainv1.E
return gsresponse.Success(resp) return gsresponse.Success(resp)
} }
func estimateNetworkFee(ctx context.Context, logger mlogger.Logger, network shared.Network, timeout time.Duration, wallet *model.ManagedWallet, destination string, amount *moneyv1.Money) (*moneyv1.Money, error) { func estimateNetworkFee(ctx context.Context, logger mlogger.Logger, network shared.Network, wallet *model.ManagedWallet, destination string, amount *moneyv1.Money) (*moneyv1.Money, error) {
rpcURL := strings.TrimSpace(network.RPCURL) rpcURL := strings.TrimSpace(network.RPCURL)
if rpcURL == "" { if rpcURL == "" {
return nil, merrors.InvalidArgument("network rpc url not configured") return nil, merrors.InvalidArgument("network rpc url not configured")
@@ -122,10 +122,7 @@ func estimateNetworkFee(ctx context.Context, logger mlogger.Logger, network shar
} }
defer client.Close() defer client.Close()
if timeout <= 0 { timeoutCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
timeout = 15 * time.Second
}
timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel() defer cancel()
tokenABI, err := abi.JSON(strings.NewReader(erc20TransferABI)) tokenABI, err := abi.JSON(strings.NewReader(erc20TransferABI))

View File

@@ -78,7 +78,7 @@ func (c *submitTransferCommand) Execute(ctx context.Context, req *chainv1.Submit
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("organization_ref mismatch with wallet")) return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("organization_ref mismatch with wallet"))
} }
networkKey := strings.ToLower(strings.TrimSpace(sourceWallet.Network)) networkKey := strings.ToLower(strings.TrimSpace(sourceWallet.Network))
networkCfg, ok := c.deps.Networks.Network(networkKey) networkCfg, ok := c.deps.Networks[networkKey]
if !ok { if !ok {
c.deps.Logger.Warn("unsupported chain", zap.String("network", networkKey)) c.deps.Logger.Warn("unsupported chain", zap.String("network", networkKey))
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain for wallet")) return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain for wallet"))

View File

@@ -9,7 +9,6 @@ import (
"github.com/tech/sendico/gateway/chain/storage/model" "github.com/tech/sendico/gateway/chain/storage/model"
"github.com/tech/sendico/pkg/api/routers/gsresponse" "github.com/tech/sendico/pkg/api/routers/gsresponse"
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
pkgmodel "github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice" "github.com/tech/sendico/pkg/mservice"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
"go.uber.org/zap" "go.uber.org/zap"
@@ -60,7 +59,7 @@ func (c *createManagedWalletCommand) Execute(ctx context.Context, req *chainv1.C
c.deps.Logger.Warn("unsupported chain", zap.Any("chain", asset.GetChain())) c.deps.Logger.Warn("unsupported chain", zap.Any("chain", asset.GetChain()))
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain")) return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain"))
} }
networkCfg, ok := c.deps.Networks.Network(chainKey) networkCfg, ok := c.deps.Networks[chainKey]
if !ok { if !ok {
c.deps.Logger.Warn("unsupported chain in config", zap.String("chain", chainKey)) c.deps.Logger.Warn("unsupported chain in config", zap.String("chain", chainKey))
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain")) return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain"))
@@ -96,31 +95,7 @@ func (c *createManagedWalletCommand) Execute(ctx context.Context, req *chainv1.C
return gsresponse.Internal[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.Internal("key manager returned empty address")) return gsresponse.Internal[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.Internal("key manager returned empty address"))
} }
metadata := shared.CloneMetadata(req.GetMetadata())
desc := req.GetDescribable()
name := strings.TrimSpace(desc.GetName())
if name == "" {
name = strings.TrimSpace(metadata["name"])
}
var description *string
if desc != nil && desc.Description != nil {
if trimmed := strings.TrimSpace(desc.GetDescription()); trimmed != "" {
description = &trimmed
}
}
if description == nil {
if trimmed := strings.TrimSpace(metadata["description"]); trimmed != "" {
description = &trimmed
}
}
if name == "" {
name = walletRef
}
wallet := &model.ManagedWallet{ wallet := &model.ManagedWallet{
Describable: pkgmodel.Describable{
Name: name,
},
IdempotencyKey: idempotencyKey, IdempotencyKey: idempotencyKey,
WalletRef: walletRef, WalletRef: walletRef,
OrganizationRef: organizationRef, OrganizationRef: organizationRef,
@@ -131,10 +106,7 @@ func (c *createManagedWalletCommand) Execute(ctx context.Context, req *chainv1.C
DepositAddress: strings.ToLower(keyInfo.Address), DepositAddress: strings.ToLower(keyInfo.Address),
KeyReference: keyInfo.KeyID, KeyReference: keyInfo.KeyID,
Status: model.ManagedWalletStatusActive, Status: model.ManagedWalletStatusActive,
Metadata: metadata, Metadata: shared.CloneMetadata(req.GetMetadata()),
}
if description != nil {
wallet.Describable.Description = description
} }
created, err := c.deps.Storage.Wallets().Create(ctx, wallet) created, err := c.deps.Storage.Wallets().Create(ctx, wallet)

View File

@@ -5,7 +5,7 @@ import (
"time" "time"
"github.com/tech/sendico/gateway/chain/internal/keymanager" "github.com/tech/sendico/gateway/chain/internal/keymanager"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/rpcclient" "github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/chain/storage" "github.com/tech/sendico/gateway/chain/storage"
clockpkg "github.com/tech/sendico/pkg/clock" clockpkg "github.com/tech/sendico/pkg/clock"
"github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mlogger"
@@ -13,19 +13,17 @@ import (
type Deps struct { type Deps struct {
Logger mlogger.Logger Logger mlogger.Logger
Networks *rpcclient.Registry Networks map[string]shared.Network
KeyManager keymanager.Manager KeyManager keymanager.Manager
Storage storage.Repository Storage storage.Repository
Clock clockpkg.Clock Clock clockpkg.Clock
BalanceCacheTTL time.Duration BalanceCacheTTL time.Duration
RPCTimeout time.Duration
EnsureRepository func(context.Context) error EnsureRepository func(context.Context) error
} }
func (d Deps) WithLogger(name string) Deps { func (d Deps) WithLogger(name string) Deps {
if d.Logger == nil { if d.Logger != nil {
panic("wallet deps: logger is required") d.Logger = d.Logger.Named(name)
} }
d.Logger = d.Logger.Named(name)
return d return d
} }

View File

@@ -2,7 +2,6 @@ package wallet
import ( import (
"context" "context"
"fmt"
"math/big" "math/big"
"strings" "strings"
"time" "time"
@@ -15,92 +14,49 @@ import (
"github.com/tech/sendico/gateway/chain/storage/model" "github.com/tech/sendico/gateway/chain/storage/model"
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
"go.uber.org/zap"
) )
func onChainWalletBalance(ctx context.Context, deps Deps, wallet *model.ManagedWallet) (*moneyv1.Money, error) { func onChainWalletBalance(ctx context.Context, deps Deps, wallet *model.ManagedWallet) (*moneyv1.Money, error) {
logger := deps.Logger network := deps.Networks[strings.ToLower(strings.TrimSpace(wallet.Network))]
registry := deps.Networks
networkKey := strings.ToLower(strings.TrimSpace(wallet.Network))
network, ok := registry.Network(networkKey)
if !ok {
logger.Warn("Requested network is not configured",
zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", networkKey),
)
return nil, merrors.Internal(fmt.Sprintf("Requested network '%s' is not configured", networkKey))
}
rpcURL := strings.TrimSpace(network.RPCURL) rpcURL := strings.TrimSpace(network.RPCURL)
logFields := []zap.Field{
zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", networkKey),
zap.String("token_symbol", strings.ToUpper(strings.TrimSpace(wallet.TokenSymbol))),
zap.String("contract", strings.ToLower(strings.TrimSpace(wallet.ContractAddress))),
zap.String("wallet_address", strings.ToLower(strings.TrimSpace(wallet.DepositAddress))),
}
if rpcURL == "" { if rpcURL == "" {
logger.Warn("Network rpc url is not configured", logFields...)
return nil, merrors.Internal("network rpc url is not configured") return nil, merrors.Internal("network rpc url is not configured")
} }
contract := strings.TrimSpace(wallet.ContractAddress) contract := strings.TrimSpace(wallet.ContractAddress)
if contract == "" || !common.IsHexAddress(contract) { if contract == "" || !common.IsHexAddress(contract) {
logger.Warn("Invalid contract address for balance fetch", logFields...)
return nil, merrors.InvalidArgument("invalid contract address") return nil, merrors.InvalidArgument("invalid contract address")
} }
if wallet.DepositAddress == "" || !common.IsHexAddress(wallet.DepositAddress) { if wallet.DepositAddress == "" || !common.IsHexAddress(wallet.DepositAddress) {
logger.Warn("Invalid wallet address for balance fetch", logFields...)
return nil, merrors.InvalidArgument("invalid wallet address") return nil, merrors.InvalidArgument("invalid wallet address")
} }
logger.Info("Fetching on-chain wallet balance", logFields...) client, err := ethclient.DialContext(ctx, rpcURL)
client, err := registry.Client(networkKey)
if err != nil { if err != nil {
logger.Warn("Failed to fetch rpc client", append(logFields, zap.Error(err))...) return nil, merrors.Internal("failed to connect rpc: " + err.Error())
return nil, err
} }
defer client.Close()
timeout := deps.RPCTimeout timeoutCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
if timeout <= 0 {
timeout = 10 * time.Second
}
timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel() defer cancel()
tokenABI, err := abi.JSON(strings.NewReader(erc20ABIJSON)) tokenABI, err := abi.JSON(strings.NewReader(erc20ABIJSON))
if err != nil { if err != nil {
logger.Warn("Failed to parse erc20 abi", append(logFields, zap.Error(err))...)
return nil, merrors.Internal("failed to parse erc20 abi: " + err.Error()) return nil, merrors.Internal("failed to parse erc20 abi: " + err.Error())
} }
tokenAddr := common.HexToAddress(contract) tokenAddr := common.HexToAddress(contract)
walletAddr := common.HexToAddress(wallet.DepositAddress) walletAddr := common.HexToAddress(wallet.DepositAddress)
logger.Debug("Calling token decimals", logFields...)
decimals, err := readDecimals(timeoutCtx, client, tokenABI, tokenAddr) decimals, err := readDecimals(timeoutCtx, client, tokenABI, tokenAddr)
if err != nil { if err != nil {
logger.Warn("Token decimals call failed", append(logFields, zap.Error(err))...)
return nil, err return nil, err
} }
logger.Debug("Calling token balanceOf", append(logFields, zap.Uint8("decimals", decimals))...)
bal, err := readBalanceOf(timeoutCtx, client, tokenABI, tokenAddr, walletAddr) bal, err := readBalanceOf(timeoutCtx, client, tokenABI, tokenAddr, walletAddr)
if err != nil { if err != nil {
logger.Warn("Token balanceOf call failed", append(logFields, zap.Uint8("decimals", decimals), zap.Error(err))...)
return nil, err return nil, err
} }
dec := decimal.NewFromBigInt(bal, 0).Shift(-int32(decimals)) dec := decimal.NewFromBigInt(bal, 0).Shift(-int32(decimals))
logger.Info("On-chain wallet balance fetched",
append(logFields,
zap.Uint8("decimals", decimals),
zap.String("balance_raw", bal.String()),
zap.String("balance", dec.String()),
)...,
)
return &moneyv1.Money{Currency: wallet.TokenSymbol, Amount: dec.String()}, nil return &moneyv1.Money{Currency: wallet.TokenSymbol, Amount: dec.String()}, nil
} }

View File

@@ -1,11 +1,8 @@
package wallet package wallet
import ( import (
"strings"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared" "github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/chain/storage/model" "github.com/tech/sendico/gateway/chain/storage/model"
describablev1 "github.com/tech/sendico/pkg/proto/common/describable/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
"google.golang.org/protobuf/types/known/timestamppb" "google.golang.org/protobuf/types/known/timestamppb"
) )
@@ -19,25 +16,6 @@ func toProtoManagedWallet(wallet *model.ManagedWallet) *chainv1.ManagedWallet {
TokenSymbol: wallet.TokenSymbol, TokenSymbol: wallet.TokenSymbol,
ContractAddress: wallet.ContractAddress, ContractAddress: wallet.ContractAddress,
} }
name := strings.TrimSpace(wallet.Name)
if name == "" {
name = strings.TrimSpace(wallet.Metadata["name"])
}
if name == "" {
name = wallet.WalletRef
}
description := ""
switch {
case wallet.Description != nil:
description = strings.TrimSpace(*wallet.Description)
default:
description = strings.TrimSpace(wallet.Metadata["description"])
}
desc := &describablev1.Describable{Name: name}
if description != "" {
desc.Description = &description
}
return &chainv1.ManagedWallet{ return &chainv1.ManagedWallet{
WalletRef: wallet.WalletRef, WalletRef: wallet.WalletRef,
OrganizationRef: wallet.OrganizationRef, OrganizationRef: wallet.OrganizationRef,
@@ -48,7 +26,6 @@ func toProtoManagedWallet(wallet *model.ManagedWallet) *chainv1.ManagedWallet {
Metadata: shared.CloneMetadata(wallet.Metadata), Metadata: shared.CloneMetadata(wallet.Metadata),
CreatedAt: timestamppb.New(wallet.CreatedAt.UTC()), CreatedAt: timestamppb.New(wallet.CreatedAt.UTC()),
UpdatedAt: timestamppb.New(wallet.UpdatedAt.UTC()), UpdatedAt: timestamppb.New(wallet.UpdatedAt.UTC()),
Describable: desc,
} }
} }

View File

@@ -5,6 +5,7 @@ import (
"errors" "errors"
"math/big" "math/big"
"strings" "strings"
"sync"
"time" "time"
"github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum"
@@ -13,7 +14,6 @@ import (
"github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/ethclient" "github.com/ethereum/go-ethereum/ethclient"
"github.com/shopspring/decimal" "github.com/shopspring/decimal"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/rpcclient"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared" "github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
"go.uber.org/zap" "go.uber.org/zap"
@@ -30,11 +30,11 @@ type TransferExecutor interface {
} }
// NewOnChainExecutor constructs a TransferExecutor that talks to an EVM-compatible chain. // NewOnChainExecutor constructs a TransferExecutor that talks to an EVM-compatible chain.
func NewOnChainExecutor(logger mlogger.Logger, keyManager keymanager.Manager, clients *rpcclient.Clients) TransferExecutor { func NewOnChainExecutor(logger mlogger.Logger, keyManager keymanager.Manager) TransferExecutor {
return &onChainExecutor{ return &onChainExecutor{
logger: logger.Named("executor"), logger: logger.Named("executor"),
keyManager: keyManager, keyManager: keyManager,
clients: clients, clients: map[string]*ethclient.Client{},
} }
} }
@@ -42,33 +42,34 @@ type onChainExecutor struct {
logger mlogger.Logger logger mlogger.Logger
keyManager keymanager.Manager keyManager keymanager.Manager
clients *rpcclient.Clients mu sync.Mutex
clients map[string]*ethclient.Client
} }
func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Transfer, source *model.ManagedWallet, destinationAddress string, network shared.Network) (string, error) { func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Transfer, source *model.ManagedWallet, destinationAddress string, network shared.Network) (string, error) {
if o.keyManager == nil { if o.keyManager == nil {
o.logger.Warn("key manager not configured") o.logger.Error("key manager not configured")
return "", executorInternal("key manager is not configured", nil) return "", executorInternal("key manager is not configured", nil)
} }
rpcURL := strings.TrimSpace(network.RPCURL) rpcURL := strings.TrimSpace(network.RPCURL)
if rpcURL == "" { if rpcURL == "" {
o.logger.Warn("network rpc url missing", zap.String("network", network.Name)) o.logger.Error("network rpc url missing", zap.String("network", network.Name))
return "", executorInvalid("network rpc url is not configured") return "", executorInvalid("network rpc url is not configured")
} }
if source == nil || transfer == nil { if source == nil || transfer == nil {
o.logger.Warn("transfer context missing") o.logger.Error("transfer context missing")
return "", executorInvalid("transfer context missing") return "", executorInvalid("transfer context missing")
} }
if strings.TrimSpace(source.KeyReference) == "" { if strings.TrimSpace(source.KeyReference) == "" {
o.logger.Warn("source wallet missing key reference", zap.String("wallet_ref", source.WalletRef)) o.logger.Error("source wallet missing key reference", zap.String("wallet_ref", source.WalletRef))
return "", executorInvalid("source wallet missing key reference") return "", executorInvalid("source wallet missing key reference")
} }
if strings.TrimSpace(source.DepositAddress) == "" { if strings.TrimSpace(source.DepositAddress) == "" {
o.logger.Warn("source wallet missing deposit address", zap.String("wallet_ref", source.WalletRef)) o.logger.Error("source wallet missing deposit address", zap.String("wallet_ref", source.WalletRef))
return "", executorInvalid("source wallet missing deposit address") return "", executorInvalid("source wallet missing deposit address")
} }
if !common.IsHexAddress(destinationAddress) { if !common.IsHexAddress(destinationAddress) {
o.logger.Warn("invalid destination address", zap.String("transfer_ref", transfer.TransferRef), zap.String("address", destinationAddress)) o.logger.Error("invalid destination address", zap.String("transfer_ref", transfer.TransferRef), zap.String("address", destinationAddress))
return "", executorInvalid("invalid destination address " + destinationAddress) return "", executorInvalid("invalid destination address " + destinationAddress)
} }
@@ -79,7 +80,7 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
zap.String("destination", strings.ToLower(destinationAddress)), zap.String("destination", strings.ToLower(destinationAddress)),
) )
client, err := o.clients.Client(network.Name) client, err := o.getClient(ctx, rpcURL)
if err != nil { if err != nil {
o.logger.Warn("failed to initialise rpc client", o.logger.Warn("failed to initialise rpc client",
zap.String("network", network.Name), zap.String("network", network.Name),
@@ -213,6 +214,30 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
return txHash, nil return txHash, nil
} }
func (o *onChainExecutor) getClient(ctx context.Context, rpcURL string) (*ethclient.Client, error) {
o.mu.Lock()
client, ok := o.clients[rpcURL]
o.mu.Unlock()
if ok {
return client, nil
}
c, err := ethclient.DialContext(ctx, rpcURL)
if err != nil {
return nil, executorInternal("failed to connect to rpc "+rpcURL, err)
}
o.mu.Lock()
defer o.mu.Unlock()
if existing, ok := o.clients[rpcURL]; ok {
// Another routine initialised it in the meantime; prefer the existing client and close the new one.
c.Close()
return existing, nil
}
o.clients[rpcURL] = c
return c, nil
}
func (o *onChainExecutor) AwaitConfirmation(ctx context.Context, network shared.Network, txHash string) (*types.Receipt, error) { func (o *onChainExecutor) AwaitConfirmation(ctx context.Context, network shared.Network, txHash string) (*types.Receipt, error) {
if strings.TrimSpace(txHash) == "" { if strings.TrimSpace(txHash) == "" {
o.logger.Warn("missing transaction hash for confirmation", zap.String("network", network.Name)) o.logger.Warn("missing transaction hash for confirmation", zap.String("network", network.Name))
@@ -224,7 +249,7 @@ func (o *onChainExecutor) AwaitConfirmation(ctx context.Context, network shared.
return nil, executorInvalid("network rpc url is not configured") return nil, executorInvalid("network rpc url is not configured")
} }
client, err := o.clients.Client(network.Name) client, err := o.getClient(ctx, rpcURL)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@@ -4,7 +4,6 @@ import (
"strings" "strings"
"github.com/tech/sendico/gateway/chain/internal/keymanager" "github.com/tech/sendico/gateway/chain/internal/keymanager"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/rpcclient"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared" "github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
clockpkg "github.com/tech/sendico/pkg/clock" clockpkg "github.com/tech/sendico/pkg/clock"
) )
@@ -26,13 +25,6 @@ func WithTransferExecutor(executor TransferExecutor) Option {
} }
} }
// WithRPCClients configures pre-initialised RPC clients.
func WithRPCClients(clients *rpcclient.Clients) Option {
return func(s *Service) {
s.rpcClients = clients
}
}
// WithNetworks configures supported blockchain networks. // WithNetworks configures supported blockchain networks.
func WithNetworks(networks []shared.Network) Option { func WithNetworks(networks []shared.Network) Option {
return func(s *Service) { return func(s *Service) {

View File

@@ -1,174 +0,0 @@
package rpcclient
import (
"context"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/ethereum/go-ethereum/ethclient"
"github.com/ethereum/go-ethereum/rpc"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"go.uber.org/zap"
)
// Clients holds pre-initialised RPC clients keyed by network name.
type Clients struct {
logger mlogger.Logger
clients map[string]*ethclient.Client
}
// Prepare dials all configured networks up front and returns a ready-to-use client set.
func Prepare(ctx context.Context, logger mlogger.Logger, networks []shared.Network) (*Clients, error) {
if logger == nil {
return nil, merrors.Internal("rpc clients: logger is required")
}
clientLogger := logger.Named("rpc_client")
result := &Clients{
logger: clientLogger,
clients: make(map[string]*ethclient.Client),
}
for _, network := range networks {
name := strings.ToLower(strings.TrimSpace(network.Name))
rpcURL := strings.TrimSpace(network.RPCURL)
if name == "" {
clientLogger.Warn("Skipping network with empty name during rpc client preparation")
continue
}
if rpcURL == "" {
result.Close()
err := merrors.InvalidArgument(fmt.Sprintf("rpc url not configured for network %s", name))
clientLogger.Warn("rpc url missing", zap.String("network", name))
return nil, err
}
fields := []zap.Field{
zap.String("network", name),
}
clientLogger.Info("initialising rpc client", fields...)
dialCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
httpClient := &http.Client{
Transport: &loggingRoundTripper{
logger: clientLogger,
network: name,
base: http.DefaultTransport,
},
}
rpcCli, err := rpc.DialOptions(dialCtx, rpcURL, rpc.WithHTTPClient(httpClient))
cancel()
if err != nil {
result.Close()
clientLogger.Warn("failed to dial rpc endpoint", append(fields, zap.Error(err))...)
return nil, merrors.Internal(fmt.Sprintf("rpc dial failed for %s: %s", name, err.Error()))
}
client := ethclient.NewClient(rpcCli)
result.clients[name] = client
clientLogger.Info("rpc client ready", fields...)
}
if len(result.clients) == 0 {
clientLogger.Warn("No rpc clients were initialised")
return nil, merrors.InvalidArgument("no rpc clients initialised")
} else {
clientLogger.Info("RPC clients initialised", zap.Int("count", len(result.clients)))
}
return result, nil
}
// Client returns a prepared client for the given network name.
func (c *Clients) Client(network string) (*ethclient.Client, error) {
if c == nil {
return nil, merrors.Internal("rpc clients not initialised")
}
name := strings.ToLower(strings.TrimSpace(network))
client, ok := c.clients[name]
if !ok {
return nil, merrors.InvalidArgument(fmt.Sprintf("rpc client not configured for network %s", name))
}
return client, nil
}
// Close tears down all RPC clients, logging each close.
func (c *Clients) Close() {
if c == nil {
return
}
for name, client := range c.clients {
client.Close()
if c.logger != nil {
c.logger.Info("rpc client closed", zap.String("network", name))
}
}
}
type loggingRoundTripper struct {
logger mlogger.Logger
network string
endpoint string
base http.RoundTripper
}
func (l *loggingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
if l.base == nil {
l.base = http.DefaultTransport
}
var reqBody []byte
if req.Body != nil {
raw, _ := io.ReadAll(req.Body)
reqBody = raw
req.Body = io.NopCloser(strings.NewReader(string(raw)))
}
fields := []zap.Field{
zap.String("network", l.network),
zap.String("rpc_endpoint", l.endpoint),
}
if len(reqBody) > 0 {
fields = append(fields, zap.String("rpc_request", truncate(string(reqBody), 2048)))
}
l.logger.Debug("rpc request", fields...)
resp, err := l.base.RoundTrip(req)
if err != nil {
l.logger.Warn("rpc http request failed", append(fields, zap.Error(err))...)
return nil, err
}
bodyBytes, _ := io.ReadAll(resp.Body)
resp.Body.Close()
resp.Body = io.NopCloser(strings.NewReader(string(bodyBytes)))
respFields := append(fields,
zap.Int("status_code", resp.StatusCode),
)
if len(bodyBytes) > 0 {
respFields = append(respFields, zap.String("rpc_response", truncate(string(bodyBytes), 2048)))
}
if resp.StatusCode >= 400 {
l.logger.Warn("RPC response error", respFields...)
} else {
// Log response content so downstream parse failures can be inspected without debug logs.
l.logger.Warn("RPC response", respFields...)
}
return resp, nil
}
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
if max <= 3 {
return s[:max]
}
return s[:max-3] + "..."
}

View File

@@ -1,45 +0,0 @@
package rpcclient
import (
"strings"
"github.com/ethereum/go-ethereum/ethclient"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
"github.com/tech/sendico/pkg/merrors"
)
// Registry binds static network metadata with prepared RPC clients.
type Registry struct {
networks map[string]shared.Network
clients *Clients
}
// NewRegistry constructs a registry keyed by lower-cased network name.
func NewRegistry(networks map[string]shared.Network, clients *Clients) *Registry {
return &Registry{
networks: networks,
clients: clients,
}
}
// Network fetches network metadata by key (case-insensitive).
func (r *Registry) Network(key string) (shared.Network, bool) {
if r == nil || len(r.networks) == 0 {
return shared.Network{}, false
}
n, ok := r.networks[strings.ToLower(strings.TrimSpace(key))]
return n, ok
}
// Client returns the prepared RPC client for the given network name.
func (r *Registry) Client(key string) (*ethclient.Client, error) {
if r == nil || r.clients == nil {
return nil, merrors.Internal("rpc clients not initialised")
}
return r.clients.Client(strings.ToLower(strings.TrimSpace(key)))
}
// Networks exposes the registry map for iteration when needed.
func (r *Registry) Networks() map[string]shared.Network {
return r.networks
}

View File

@@ -7,7 +7,6 @@ import (
"github.com/tech/sendico/gateway/chain/internal/service/gateway/commands" "github.com/tech/sendico/gateway/chain/internal/service/gateway/commands"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/commands/transfer" "github.com/tech/sendico/gateway/chain/internal/service/gateway/commands/transfer"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/commands/wallet" "github.com/tech/sendico/gateway/chain/internal/service/gateway/commands/wallet"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/rpcclient"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared" "github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/chain/storage" "github.com/tech/sendico/gateway/chain/storage"
"github.com/tech/sendico/pkg/api/routers" "github.com/tech/sendico/pkg/api/routers"
@@ -39,13 +38,11 @@ type Service struct {
settings CacheSettings settings CacheSettings
networks map[string]shared.Network networks map[string]shared.Network
serviceWallet shared.ServiceWallet serviceWallet shared.ServiceWallet
keyManager keymanager.Manager keyManager keymanager.Manager
executor TransferExecutor executor TransferExecutor
rpcClients *rpcclient.Clients commands commands.Registry
networkRegistry *rpcclient.Registry
commands commands.Registry
chainv1.UnimplementedChainGatewayServiceServer chainv1.UnimplementedChainGatewayServiceServer
} }
@@ -76,7 +73,6 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro
svc.networks = map[string]shared.Network{} svc.networks = map[string]shared.Network{}
} }
svc.settings = svc.settings.withDefaults() svc.settings = svc.settings.withDefaults()
svc.networkRegistry = rpcclient.NewRegistry(svc.networks, svc.rpcClients)
svc.commands = commands.NewRegistry(commands.RegistryDeps{ svc.commands = commands.NewRegistry(commands.RegistryDeps{
Wallet: commandsWalletDeps(svc), Wallet: commandsWalletDeps(svc),
@@ -135,12 +131,11 @@ func (s *Service) ensureRepository(ctx context.Context) error {
func commandsWalletDeps(s *Service) wallet.Deps { func commandsWalletDeps(s *Service) wallet.Deps {
return wallet.Deps{ return wallet.Deps{
Logger: s.logger.Named("command"), Logger: s.logger.Named("command"),
Networks: s.networkRegistry, Networks: s.networks,
KeyManager: s.keyManager, KeyManager: s.keyManager,
Storage: s.storage, Storage: s.storage,
Clock: s.clock, Clock: s.clock,
BalanceCacheTTL: s.settings.walletBalanceCacheTTL(), BalanceCacheTTL: s.settings.walletBalanceCacheTTL(),
RPCTimeout: s.settings.rpcTimeout(),
EnsureRepository: s.ensureRepository, EnsureRepository: s.ensureRepository,
} }
} }
@@ -148,10 +143,9 @@ func commandsWalletDeps(s *Service) wallet.Deps {
func commandsTransferDeps(s *Service) transfer.Deps { func commandsTransferDeps(s *Service) transfer.Deps {
return transfer.Deps{ return transfer.Deps{
Logger: s.logger.Named("transfer_cmd"), Logger: s.logger.Named("transfer_cmd"),
Networks: s.networkRegistry, Networks: s.networks,
Storage: s.storage, Storage: s.storage,
Clock: s.clock, Clock: s.clock,
RPCTimeout: s.settings.rpcTimeout(),
EnsureRepository: s.ensureRepository, EnsureRepository: s.ensureRepository,
LaunchExecution: s.launchTransferExecution, LaunchExecution: s.launchTransferExecution,
} }

View File

@@ -3,18 +3,15 @@ package gateway
import "time" import "time"
const defaultWalletBalanceCacheTTL = 120 * time.Second const defaultWalletBalanceCacheTTL = 120 * time.Second
const defaultRPCRequestTimeout = 15 * time.Second
// CacheSettings holds tunable gateway behaviour. // CacheSettings holds tunable gateway behaviour.
type CacheSettings struct { type CacheSettings struct {
WalletBalanceCacheTTLSeconds int `yaml:"wallet_balance_ttl_seconds"` WalletBalanceCacheTTLSeconds int `yaml:"wallet_balance_ttl_seconds"`
RPCRequestTimeoutSeconds int `yaml:"rpc_request_timeout_seconds"`
} }
func defaultSettings() CacheSettings { func defaultSettings() CacheSettings {
return CacheSettings{ return CacheSettings{
WalletBalanceCacheTTLSeconds: int(defaultWalletBalanceCacheTTL.Seconds()), WalletBalanceCacheTTLSeconds: int(defaultWalletBalanceCacheTTL.Seconds()),
RPCRequestTimeoutSeconds: int(defaultRPCRequestTimeout.Seconds()),
} }
} }
@@ -22,9 +19,6 @@ func (s CacheSettings) withDefaults() CacheSettings {
if s.WalletBalanceCacheTTLSeconds <= 0 { if s.WalletBalanceCacheTTLSeconds <= 0 {
s.WalletBalanceCacheTTLSeconds = int(defaultWalletBalanceCacheTTL.Seconds()) s.WalletBalanceCacheTTLSeconds = int(defaultWalletBalanceCacheTTL.Seconds())
} }
if s.RPCRequestTimeoutSeconds <= 0 {
s.RPCRequestTimeoutSeconds = int(defaultRPCRequestTimeout.Seconds())
}
return s return s
} }
@@ -34,10 +28,3 @@ func (s CacheSettings) walletBalanceCacheTTL() time.Duration {
} }
return time.Duration(s.WalletBalanceCacheTTLSeconds) * time.Second return time.Duration(s.WalletBalanceCacheTTLSeconds) * time.Second
} }
func (s CacheSettings) rpcTimeout() time.Duration {
if s.RPCRequestTimeoutSeconds <= 0 {
return defaultRPCRequestTimeout
}
return time.Duration(s.RPCRequestTimeoutSeconds) * time.Second
}

View File

@@ -24,7 +24,7 @@ func (s *Service) launchTransferExecution(transferRef, sourceWalletRef string, n
defer cancel() defer cancel()
if err := s.executeTransfer(ctx, ref, walletRef, net); err != nil { if err := s.executeTransfer(ctx, ref, walletRef, net); err != nil {
s.logger.Warn("failed to execute transfer", zap.String("transfer_ref", ref), zap.Error(err)) s.logger.Error("failed to execute transfer", zap.String("transfer_ref", ref), zap.Error(err))
} }
}(transferRef, sourceWalletRef, network) }(transferRef, sourceWalletRef, network)
} }

View File

@@ -5,7 +5,6 @@ import (
"time" "time"
"github.com/tech/sendico/pkg/db/storable" "github.com/tech/sendico/pkg/db/storable"
pkgmodel "github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice" "github.com/tech/sendico/pkg/mservice"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
) )
@@ -20,8 +19,7 @@ const (
// ManagedWallet represents a user-controlled on-chain wallet managed by the service. // ManagedWallet represents a user-controlled on-chain wallet managed by the service.
type ManagedWallet struct { type ManagedWallet struct {
storable.Base `bson:",inline" json:",inline"` storable.Base `bson:",inline" json:",inline"`
pkgmodel.Describable `bson:",inline" json:",inline"`
IdempotencyKey string `bson:"idempotencyKey" json:"idempotencyKey"` IdempotencyKey string `bson:"idempotencyKey" json:"idempotencyKey"`
WalletRef string `bson:"walletRef" json:"walletRef"` WalletRef string `bson:"walletRef" json:"walletRef"`
@@ -79,15 +77,6 @@ func (m *ManagedWallet) Normalize() {
m.WalletRef = strings.TrimSpace(m.WalletRef) m.WalletRef = strings.TrimSpace(m.WalletRef)
m.OrganizationRef = strings.TrimSpace(m.OrganizationRef) m.OrganizationRef = strings.TrimSpace(m.OrganizationRef)
m.OwnerRef = strings.TrimSpace(m.OwnerRef) m.OwnerRef = strings.TrimSpace(m.OwnerRef)
m.Name = strings.TrimSpace(m.Name)
if m.Description != nil {
desc := strings.TrimSpace(*m.Description)
if desc == "" {
m.Description = nil
} else {
m.Description = &desc
}
}
m.Network = strings.TrimSpace(strings.ToLower(m.Network)) m.Network = strings.TrimSpace(strings.ToLower(m.Network))
m.TokenSymbol = strings.TrimSpace(strings.ToUpper(m.TokenSymbol)) m.TokenSymbol = strings.TrimSpace(strings.ToUpper(m.TokenSymbol))
m.ContractAddress = strings.TrimSpace(strings.ToLower(m.ContractAddress)) m.ContractAddress = strings.TrimSpace(strings.ToLower(m.ContractAddress))

View File

@@ -1,84 +0,0 @@
package client
import (
"context"
"strings"
"time"
"github.com/tech/sendico/pkg/merrors"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
// Client wraps the Monetix gateway gRPC API.
type Client interface {
CreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error)
CreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error)
GetCardPayoutStatus(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error)
Close() error
}
type gatewayClient struct {
conn *grpc.ClientConn
client mntxv1.MntxGatewayServiceClient
cfg Config
}
// New dials the Monetix gateway.
func New(ctx context.Context, cfg Config, opts ...grpc.DialOption) (Client, error) {
cfg.setDefaults()
if strings.TrimSpace(cfg.Address) == "" {
return nil, merrors.InvalidArgument("mntx: address is required")
}
dialCtx, cancel := context.WithTimeout(ctx, cfg.DialTimeout)
defer cancel()
dialOpts := make([]grpc.DialOption, 0, len(opts)+1)
dialOpts = append(dialOpts, grpc.WithTransportCredentials(insecure.NewCredentials()))
dialOpts = append(dialOpts, opts...)
conn, err := grpc.DialContext(dialCtx, cfg.Address, dialOpts...)
if err != nil {
return nil, merrors.Internal("mntx: dial failed: " + err.Error())
}
return &gatewayClient{
conn: conn,
client: mntxv1.NewMntxGatewayServiceClient(conn),
cfg: cfg,
}, nil
}
func (g *gatewayClient) Close() error {
if g.conn != nil {
return g.conn.Close()
}
return nil
}
func (g *gatewayClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
timeout := g.cfg.CallTimeout
if timeout <= 0 {
timeout = 5 * time.Second
}
return context.WithTimeout(ctx, timeout)
}
func (g *gatewayClient) CreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) {
ctx, cancel := g.callContext(ctx)
defer cancel()
return g.client.CreateCardPayout(ctx, req)
}
func (g *gatewayClient) CreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error) {
ctx, cancel := g.callContext(ctx)
defer cancel()
return g.client.CreateCardTokenPayout(ctx, req)
}
func (g *gatewayClient) GetCardPayoutStatus(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error) {
ctx, cancel := g.callContext(ctx)
defer cancel()
return g.client.GetCardPayoutStatus(ctx, req)
}

View File

@@ -1,19 +0,0 @@
package client
import "time"
// Config holds Monetix gateway client settings.
type Config struct {
Address string
DialTimeout time.Duration
CallTimeout time.Duration
}
func (c *Config) setDefaults() {
if c.DialTimeout <= 0 {
c.DialTimeout = 5 * time.Second
}
if c.CallTimeout <= 0 {
c.CallTimeout = 10 * time.Second
}
}

View File

@@ -1,37 +0,0 @@
package client
import (
"context"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
)
// Fake implements Client for tests.
type Fake struct {
CreateCardPayoutFn func(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error)
CreateCardTokenPayoutFn func(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error)
GetCardPayoutStatusFn func(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error)
}
func (f *Fake) CreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) {
if f.CreateCardPayoutFn != nil {
return f.CreateCardPayoutFn(ctx, req)
}
return &mntxv1.CardPayoutResponse{}, nil
}
func (f *Fake) CreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error) {
if f.CreateCardTokenPayoutFn != nil {
return f.CreateCardTokenPayoutFn(ctx, req)
}
return &mntxv1.CardTokenPayoutResponse{}, nil
}
func (f *Fake) GetCardPayoutStatus(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error) {
if f.GetCardPayoutStatusFn != nil {
return f.GetCardPayoutStatusFn(ctx, req)
}
return &mntxv1.GetCardPayoutStatusResponse{}, nil
}
func (f *Fake) Close() error { return nil }

View File

@@ -11,8 +11,8 @@ require (
github.com/shopspring/decimal v1.4.0 github.com/shopspring/decimal v1.4.0
github.com/tech/sendico/pkg v0.1.0 github.com/tech/sendico/pkg v0.1.0
go.uber.org/zap v1.27.1 go.uber.org/zap v1.27.1
google.golang.org/grpc v1.78.0 google.golang.org/grpc v1.77.0
google.golang.org/protobuf v1.36.11 google.golang.org/protobuf v1.36.10
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
@@ -30,7 +30,7 @@ require (
github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/montanaflynn/stats v0.7.1 // indirect github.com/montanaflynn/stats v0.7.1 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nats-io/nats.go v1.48.0 // indirect github.com/nats-io/nats.go v1.47.0 // indirect
github.com/nats-io/nkeys v0.4.12 // indirect github.com/nats-io/nkeys v0.4.12 // indirect
github.com/nats-io/nuid v1.0.1 // indirect github.com/nats-io/nuid v1.0.1 // indirect
github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect
@@ -50,5 +50,5 @@ require (
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect golang.org/x/text v0.32.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
) )

View File

@@ -95,8 +95,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U= github.com/nats-io/nats.go v1.47.0 h1:YQdADw6J/UfGUd2Oy6tn4Hq6YHxCaJrVKayxxFqYrgM=
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= github.com/nats-io/nats.go v1.47.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc= github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg= github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
@@ -214,12 +214,12 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

View File

@@ -11,8 +11,8 @@ require (
github.com/tech/sendico/pkg v0.1.0 github.com/tech/sendico/pkg v0.1.0
go.mongodb.org/mongo-driver v1.17.6 go.mongodb.org/mongo-driver v1.17.6
go.uber.org/zap v1.27.1 go.uber.org/zap v1.27.1
google.golang.org/grpc v1.78.0 google.golang.org/grpc v1.77.0
google.golang.org/protobuf v1.36.11 google.golang.org/protobuf v1.36.10
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
@@ -33,7 +33,7 @@ require (
github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/montanaflynn/stats v0.7.1 // indirect github.com/montanaflynn/stats v0.7.1 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nats-io/nats.go v1.48.0 // indirect github.com/nats-io/nats.go v1.47.0 // indirect
github.com/nats-io/nkeys v0.4.12 // indirect github.com/nats-io/nkeys v0.4.12 // indirect
github.com/nats-io/nuid v1.0.1 // indirect github.com/nats-io/nuid v1.0.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
@@ -51,5 +51,5 @@ require (
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect golang.org/x/text v0.32.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
) )

View File

@@ -95,8 +95,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U= github.com/nats-io/nats.go v1.47.0 h1:YQdADw6J/UfGUd2Oy6tn4Hq6YHxCaJrVKayxxFqYrgM=
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= github.com/nats-io/nats.go v1.47.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc= github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg= github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
@@ -214,12 +214,12 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

View File

@@ -62,6 +62,12 @@ const (
OutboxStatusFailed OutboxStatus = "failed" OutboxStatusFailed OutboxStatus = "failed"
) )
// Money represents an exact decimal amount with its currency.
type Money struct {
Currency string `bson:"currency" json:"currency"`
Amount string `bson:"amount" json:"amount"` // stored as string for exact decimal representation
}
// LedgerMeta carries organization-scoped metadata for ledger entities. // LedgerMeta carries organization-scoped metadata for ledger entities.
type LedgerMeta struct { type LedgerMeta struct {
model.OrganizationBoundBase `bson:",inline" json:",inline"` model.OrganizationBoundBase `bson:",inline" json:",inline"`

View File

@@ -33,7 +33,7 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/montanaflynn/stats v0.7.1 // indirect github.com/montanaflynn/stats v0.7.1 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nats-io/nats.go v1.48.0 // indirect github.com/nats-io/nats.go v1.47.0 // indirect
github.com/nats-io/nkeys v0.4.12 // indirect github.com/nats-io/nkeys v0.4.12 // indirect
github.com/nats-io/nuid v1.0.1 // indirect github.com/nats-io/nuid v1.0.1 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_golang v1.23.2 // indirect
@@ -52,7 +52,7 @@ require (
golang.org/x/net v0.48.0 // indirect golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect golang.org/x/sys v0.39.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/grpc v1.78.0 // indirect google.golang.org/grpc v1.77.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect google.golang.org/protobuf v1.36.10 // indirect
) )

View File

@@ -99,8 +99,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U= github.com/nats-io/nats.go v1.47.0 h1:YQdADw6J/UfGUd2Oy6tn4Hq6YHxCaJrVKayxxFqYrgM=
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= github.com/nats-io/nats.go v1.47.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc= github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg= github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
@@ -227,12 +227,12 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

View File

@@ -17,8 +17,6 @@ import (
// Client exposes typed helpers around the payment orchestrator gRPC API. // Client exposes typed helpers around the payment orchestrator gRPC API.
type Client interface { type Client interface {
QuotePayment(ctx context.Context, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.QuotePaymentResponse, error) QuotePayment(ctx context.Context, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.QuotePaymentResponse, error)
QuotePayments(ctx context.Context, req *orchestratorv1.QuotePaymentsRequest) (*orchestratorv1.QuotePaymentsResponse, error)
InitiatePayments(ctx context.Context, req *orchestratorv1.InitiatePaymentsRequest) (*orchestratorv1.InitiatePaymentsResponse, error)
InitiatePayment(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error) InitiatePayment(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error)
CancelPayment(ctx context.Context, req *orchestratorv1.CancelPaymentRequest) (*orchestratorv1.CancelPaymentResponse, error) CancelPayment(ctx context.Context, req *orchestratorv1.CancelPaymentRequest) (*orchestratorv1.CancelPaymentResponse, error)
GetPayment(ctx context.Context, req *orchestratorv1.GetPaymentRequest) (*orchestratorv1.GetPaymentResponse, error) GetPayment(ctx context.Context, req *orchestratorv1.GetPaymentRequest) (*orchestratorv1.GetPaymentResponse, error)
@@ -31,8 +29,6 @@ type Client interface {
type grpcOrchestratorClient interface { type grpcOrchestratorClient interface {
QuotePayment(ctx context.Context, in *orchestratorv1.QuotePaymentRequest, opts ...grpc.CallOption) (*orchestratorv1.QuotePaymentResponse, error) QuotePayment(ctx context.Context, in *orchestratorv1.QuotePaymentRequest, opts ...grpc.CallOption) (*orchestratorv1.QuotePaymentResponse, error)
QuotePayments(ctx context.Context, in *orchestratorv1.QuotePaymentsRequest, opts ...grpc.CallOption) (*orchestratorv1.QuotePaymentsResponse, error)
InitiatePayments(ctx context.Context, in *orchestratorv1.InitiatePaymentsRequest, opts ...grpc.CallOption) (*orchestratorv1.InitiatePaymentsResponse, error)
InitiatePayment(ctx context.Context, in *orchestratorv1.InitiatePaymentRequest, opts ...grpc.CallOption) (*orchestratorv1.InitiatePaymentResponse, error) InitiatePayment(ctx context.Context, in *orchestratorv1.InitiatePaymentRequest, opts ...grpc.CallOption) (*orchestratorv1.InitiatePaymentResponse, error)
CancelPayment(ctx context.Context, in *orchestratorv1.CancelPaymentRequest, opts ...grpc.CallOption) (*orchestratorv1.CancelPaymentResponse, error) CancelPayment(ctx context.Context, in *orchestratorv1.CancelPaymentRequest, opts ...grpc.CallOption) (*orchestratorv1.CancelPaymentResponse, error)
GetPayment(ctx context.Context, in *orchestratorv1.GetPaymentRequest, opts ...grpc.CallOption) (*orchestratorv1.GetPaymentResponse, error) GetPayment(ctx context.Context, in *orchestratorv1.GetPaymentRequest, opts ...grpc.CallOption) (*orchestratorv1.GetPaymentResponse, error)
@@ -101,18 +97,6 @@ func (c *orchestratorClient) QuotePayment(ctx context.Context, req *orchestrator
return c.client.QuotePayment(ctx, req) return c.client.QuotePayment(ctx, req)
} }
func (c *orchestratorClient) QuotePayments(ctx context.Context, req *orchestratorv1.QuotePaymentsRequest) (*orchestratorv1.QuotePaymentsResponse, error) {
ctx, cancel := c.callContext(ctx)
defer cancel()
return c.client.QuotePayments(ctx, req)
}
func (c *orchestratorClient) InitiatePayments(ctx context.Context, req *orchestratorv1.InitiatePaymentsRequest) (*orchestratorv1.InitiatePaymentsResponse, error) {
ctx, cancel := c.callContext(ctx)
defer cancel()
return c.client.InitiatePayments(ctx, req)
}
func (c *orchestratorClient) InitiatePayment(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error) { func (c *orchestratorClient) InitiatePayment(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error) {
ctx, cancel := c.callContext(ctx) ctx, cancel := c.callContext(ctx)
defer cancel() defer cancel()

View File

@@ -9,8 +9,6 @@ import (
// Fake implements Client for tests. // Fake implements Client for tests.
type Fake struct { type Fake struct {
QuotePaymentFn func(ctx context.Context, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.QuotePaymentResponse, error) QuotePaymentFn func(ctx context.Context, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.QuotePaymentResponse, error)
QuotePaymentsFn func(ctx context.Context, req *orchestratorv1.QuotePaymentsRequest) (*orchestratorv1.QuotePaymentsResponse, error)
InitiatePaymentsFn func(ctx context.Context, req *orchestratorv1.InitiatePaymentsRequest) (*orchestratorv1.InitiatePaymentsResponse, error)
InitiatePaymentFn func(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error) InitiatePaymentFn func(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error)
CancelPaymentFn func(ctx context.Context, req *orchestratorv1.CancelPaymentRequest) (*orchestratorv1.CancelPaymentResponse, error) CancelPaymentFn func(ctx context.Context, req *orchestratorv1.CancelPaymentRequest) (*orchestratorv1.CancelPaymentResponse, error)
GetPaymentFn func(ctx context.Context, req *orchestratorv1.GetPaymentRequest) (*orchestratorv1.GetPaymentResponse, error) GetPaymentFn func(ctx context.Context, req *orchestratorv1.GetPaymentRequest) (*orchestratorv1.GetPaymentResponse, error)
@@ -28,20 +26,6 @@ func (f *Fake) QuotePayment(ctx context.Context, req *orchestratorv1.QuotePaymen
return &orchestratorv1.QuotePaymentResponse{}, nil return &orchestratorv1.QuotePaymentResponse{}, nil
} }
func (f *Fake) QuotePayments(ctx context.Context, req *orchestratorv1.QuotePaymentsRequest) (*orchestratorv1.QuotePaymentsResponse, error) {
if f.QuotePaymentsFn != nil {
return f.QuotePaymentsFn(ctx, req)
}
return &orchestratorv1.QuotePaymentsResponse{}, nil
}
func (f *Fake) InitiatePayments(ctx context.Context, req *orchestratorv1.InitiatePaymentsRequest) (*orchestratorv1.InitiatePaymentsResponse, error) {
if f.InitiatePaymentsFn != nil {
return f.InitiatePaymentsFn(ctx, req)
}
return &orchestratorv1.InitiatePaymentsResponse{}, nil
}
func (f *Fake) InitiatePayment(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error) { func (f *Fake) InitiatePayment(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error) {
if f.InitiatePaymentFn != nil { if f.InitiatePaymentFn != nil {
return f.InitiatePaymentFn(ctx, req) return f.InitiatePaymentFn(ctx, req)

View File

@@ -56,11 +56,3 @@ oracle:
dial_timeout_seconds: 5 dial_timeout_seconds: 5
call_timeout_seconds: 3 call_timeout_seconds: 3
insecure: true insecure: true
card_gateways:
monetix:
funding_address: "wallet_funding_monetix"
fee_address: "wallet_fee_monetix"
fee_ledger_accounts:
monetix: "ledger:fees:monetix"

View File

@@ -8,8 +8,6 @@ replace github.com/tech/sendico/billing/fees => ../../billing/fees
replace github.com/tech/sendico/gateway/chain => ../../gateway/chain replace github.com/tech/sendico/gateway/chain => ../../gateway/chain
replace github.com/tech/sendico/gateway/mntx => ../../gateway/mntx
replace github.com/tech/sendico/fx/oracle => ../../fx/oracle replace github.com/tech/sendico/fx/oracle => ../../fx/oracle
replace github.com/tech/sendico/ledger => ../../ledger replace github.com/tech/sendico/ledger => ../../ledger
@@ -19,13 +17,12 @@ require (
github.com/shopspring/decimal v1.4.0 github.com/shopspring/decimal v1.4.0
github.com/tech/sendico/fx/oracle v0.0.0-00010101000000-000000000000 github.com/tech/sendico/fx/oracle v0.0.0-00010101000000-000000000000
github.com/tech/sendico/gateway/chain v0.0.0-00010101000000-000000000000 github.com/tech/sendico/gateway/chain v0.0.0-00010101000000-000000000000
github.com/tech/sendico/gateway/mntx v0.0.0-00010101000000-000000000000
github.com/tech/sendico/ledger v0.0.0-00010101000000-000000000000 github.com/tech/sendico/ledger v0.0.0-00010101000000-000000000000
github.com/tech/sendico/pkg v0.1.0 github.com/tech/sendico/pkg v0.1.0
go.mongodb.org/mongo-driver v1.17.6 go.mongodb.org/mongo-driver v1.17.6
go.uber.org/zap v1.27.1 go.uber.org/zap v1.27.1
google.golang.org/grpc v1.78.0 google.golang.org/grpc v1.77.0
google.golang.org/protobuf v1.36.11 google.golang.org/protobuf v1.36.10
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
@@ -45,7 +42,7 @@ require (
github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/montanaflynn/stats v0.7.1 // indirect github.com/montanaflynn/stats v0.7.1 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nats-io/nats.go v1.48.0 // indirect github.com/nats-io/nats.go v1.47.0 // indirect
github.com/nats-io/nkeys v0.4.12 // indirect github.com/nats-io/nkeys v0.4.12 // indirect
github.com/nats-io/nuid v1.0.1 // indirect github.com/nats-io/nuid v1.0.1 // indirect
github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect
@@ -62,5 +59,5 @@ require (
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect golang.org/x/text v0.32.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
) )

View File

@@ -95,8 +95,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U= github.com/nats-io/nats.go v1.47.0 h1:YQdADw6J/UfGUd2Oy6tn4Hq6YHxCaJrVKayxxFqYrgM=
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= github.com/nats-io/nats.go v1.47.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc= github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg= github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
@@ -119,8 +119,8 @@ github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+L
github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI=
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI= github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI=
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
@@ -215,12 +215,12 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

View File

@@ -7,8 +7,8 @@ import (
"strings" "strings"
"time" "time"
oracleclient "github.com/tech/sendico/fx/oracle/client"
chainclient "github.com/tech/sendico/gateway/chain/client" chainclient "github.com/tech/sendico/gateway/chain/client"
oracleclient "github.com/tech/sendico/fx/oracle/client"
ledgerclient "github.com/tech/sendico/ledger/client" ledgerclient "github.com/tech/sendico/ledger/client"
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrator" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrator"
"github.com/tech/sendico/payments/orchestrator/storage" "github.com/tech/sendico/payments/orchestrator/storage"
@@ -41,12 +41,10 @@ type Imp struct {
type config struct { type config struct {
*grpcapp.Config `yaml:",inline"` *grpcapp.Config `yaml:",inline"`
Fees clientConfig `yaml:"fees"` Fees clientConfig `yaml:"fees"`
Ledger clientConfig `yaml:"ledger"` Ledger clientConfig `yaml:"ledger"`
Gateway clientConfig `yaml:"gateway"` Gateway clientConfig `yaml:"gateway"`
Oracle clientConfig `yaml:"oracle"` Oracle clientConfig `yaml:"oracle"`
CardGateways map[string]cardGatewayRouteConfig `yaml:"card_gateways"`
FeeAccounts map[string]string `yaml:"fee_ledger_accounts"`
} }
type clientConfig struct { type clientConfig struct {
@@ -56,11 +54,6 @@ type clientConfig struct {
InsecureTransport bool `yaml:"insecure"` InsecureTransport bool `yaml:"insecure"`
} }
type cardGatewayRouteConfig struct {
FundingAddress string `yaml:"funding_address"`
FeeAddress string `yaml:"fee_address"`
}
func (c clientConfig) address() string { func (c clientConfig) address() string {
return strings.TrimSpace(c.Address) return strings.TrimSpace(c.Address)
} }
@@ -114,7 +107,7 @@ func (i *Imp) Shutdown() {
func (i *Imp) Start() error { func (i *Imp) Start() error {
cfg, err := i.loadConfig() cfg, err := i.loadConfig()
if err != nil { if err != nil {
return err return err
} }
i.config = cfg i.config = cfg
@@ -157,12 +150,6 @@ func (i *Imp) Start() error {
if oracleClient != nil { if oracleClient != nil {
opts = append(opts, orchestrator.WithOracleClient(oracleClient)) opts = append(opts, orchestrator.WithOracleClient(oracleClient))
} }
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 return orchestrator.NewService(logger, repo, opts...), nil
} }
@@ -309,37 +296,3 @@ func (i *Imp) loadConfig() (*config, error) {
return cfg, nil return cfg, nil
} }
func buildCardGatewayRoutes(src map[string]cardGatewayRouteConfig) map[string]orchestrator.CardGatewayRoute {
if len(src) == 0 {
return nil
}
result := make(map[string]orchestrator.CardGatewayRoute, len(src))
for key, route := range src {
trimmedKey := strings.TrimSpace(key)
if trimmedKey == "" {
continue
}
result[trimmedKey] = orchestrator.CardGatewayRoute{
FundingAddress: strings.TrimSpace(route.FundingAddress),
FeeAddress: strings.TrimSpace(route.FeeAddress),
}
}
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

@@ -1,252 +0,0 @@
package orchestrator
import (
"context"
"strings"
"github.com/shopspring/decimal"
"github.com/tech/sendico/payments/orchestrator/storage/model"
"github.com/tech/sendico/pkg/merrors"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
"go.uber.org/zap"
)
const defaultCardGateway = "monetix"
func (s *Service) cardRoute(gateway string) (CardGatewayRoute, error) {
if len(s.deps.cardRoutes) == 0 {
s.logger.Warn("card routing not configured", zap.String("gateway", gateway))
return CardGatewayRoute{}, merrors.InvalidArgument("card routing not configured")
}
key := strings.ToLower(strings.TrimSpace(gateway))
if key == "" {
key = defaultCardGateway
}
route, ok := s.deps.cardRoutes[key]
if !ok {
s.logger.Warn("card routing missing for gateway", zap.String("gateway", key))
return CardGatewayRoute{}, merrors.InvalidArgument("card routing missing for gateway " + key)
}
if strings.TrimSpace(route.FundingAddress) == "" {
s.logger.Warn("card routing missing funding address", zap.String("gateway", key))
return CardGatewayRoute{}, merrors.InvalidArgument("card funding address is required for gateway " + key)
}
return route, nil
}
func (s *Service) submitCardFundingTransfers(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote) error {
if payment == nil {
return merrors.InvalidArgument("payment is required")
}
intent := payment.Intent
source := intent.Source.ManagedWallet
if source == nil || strings.TrimSpace(source.ManagedWalletRef) == "" {
return merrors.InvalidArgument("card funding: source managed wallet is required")
}
if !s.deps.gateway.available() {
s.logger.Warn("card funding aborted: chain gateway unavailable")
return merrors.InvalidArgument("card funding: chain gateway unavailable")
}
route, err := s.cardRoute(defaultCardGateway)
if err != nil {
return err
}
amount := cloneMoney(intent.Amount)
if amount == nil {
return merrors.InvalidArgument("card funding: amount is required")
}
exec := payment.Execution
if exec == nil {
exec = &model.ExecutionRefs{}
}
// Transfer payout amount to funding wallet.
fundReq := &chainv1.SubmitTransferRequest{
IdempotencyKey: payment.IdempotencyKey + ":card:fund",
OrganizationRef: payment.OrganizationRef.Hex(),
SourceWalletRef: strings.TrimSpace(source.ManagedWalletRef),
Destination: &chainv1.TransferDestination{
Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: strings.TrimSpace(route.FundingAddress)},
},
Amount: amount,
Metadata: cloneMetadata(payment.Metadata),
ClientReference: payment.PaymentRef,
}
fundResp, err := s.deps.gateway.client.SubmitTransfer(ctx, fundReq)
if err != nil {
s.logger.Warn("card funding transfer failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef))
return err
}
if fundResp != nil && fundResp.GetTransfer() != nil {
exec.ChainTransferRef = strings.TrimSpace(fundResp.GetTransfer().GetTransferRef())
}
s.logger.Info("card funding transfer submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", exec.ChainTransferRef))
feeMoney := quote.GetExpectedFeeTotal()
if feeMoney != nil && strings.TrimSpace(feeMoney.GetAmount()) != "" {
if strings.TrimSpace(route.FeeAddress) == "" {
return merrors.InvalidArgument("card funding: fee address is required when fee exists")
}
feeDecimal, err := decimalFromMoney(feeMoney)
if err != nil {
return err
}
if feeDecimal.IsPositive() {
feeReq := &chainv1.SubmitTransferRequest{
IdempotencyKey: payment.IdempotencyKey + ":card:fee",
OrganizationRef: payment.OrganizationRef.Hex(),
SourceWalletRef: strings.TrimSpace(source.ManagedWalletRef),
Destination: &chainv1.TransferDestination{
Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: strings.TrimSpace(route.FeeAddress)},
},
Amount: feeMoney,
Metadata: cloneMetadata(payment.Metadata),
ClientReference: payment.PaymentRef,
}
feeResp, feeErr := s.deps.gateway.client.SubmitTransfer(ctx, feeReq)
if feeErr != nil {
s.logger.Warn("card fee transfer failed", zap.Error(feeErr), zap.String("payment_ref", payment.PaymentRef))
return feeErr
}
if feeResp != nil && feeResp.GetTransfer() != nil {
exec.FeeTransferRef = strings.TrimSpace(feeResp.GetTransfer().GetTransferRef())
}
s.logger.Info("card fee transfer submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", exec.FeeTransferRef))
}
}
payment.Execution = exec
return nil
}
func (s *Service) submitCardPayout(ctx context.Context, payment *model.Payment) error {
if payment == nil {
return merrors.InvalidArgument("payment is required")
}
intent := payment.Intent
card := intent.Destination.Card
if card == nil {
return merrors.InvalidArgument("card payout: card endpoint is required")
}
amount := cloneMoney(intent.Amount)
if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" {
return merrors.InvalidArgument("card payout: amount is required")
}
amtDec, err := decimalFromMoney(amount)
if err != nil {
return err
}
minor := amtDec.Mul(decimal.NewFromInt(100)).IntPart()
payoutID := payment.PaymentRef
currency := strings.TrimSpace(amount.GetCurrency())
holder := strings.TrimSpace(card.Cardholder)
meta := cloneMetadata(payment.Metadata)
var (
state *mntxv1.CardPayoutState
)
if token := strings.TrimSpace(card.Token); token != "" {
req := &mntxv1.CardTokenPayoutRequest{
PayoutId: payoutID,
AmountMinor: minor,
Currency: currency,
CardToken: token,
CardHolder: holder,
MaskedPan: strings.TrimSpace(card.MaskedPan),
Metadata: meta,
}
resp, err := s.deps.mntx.client.CreateCardTokenPayout(ctx, req)
if err != nil {
s.logger.Warn("card token payout failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef))
return err
}
state = resp.GetPayout()
} else if pan := strings.TrimSpace(card.Pan); pan != "" {
req := &mntxv1.CardPayoutRequest{
PayoutId: payoutID,
AmountMinor: minor,
Currency: currency,
CardPan: pan,
CardExpYear: card.ExpYear,
CardExpMonth: card.ExpMonth,
CardHolder: holder,
Metadata: meta,
}
resp, err := s.deps.mntx.client.CreateCardPayout(ctx, req)
if err != nil {
s.logger.Warn("card payout failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef))
return err
}
state = resp.GetPayout()
} else {
return merrors.InvalidArgument("card payout: either token or pan must be provided")
}
if state == nil {
return merrors.Internal("card payout: missing payout state")
}
recordCardPayoutState(payment, state)
if payment.Execution == nil {
payment.Execution = &model.ExecutionRefs{}
}
if payment.Execution.CardPayoutRef == "" {
payment.Execution.CardPayoutRef = strings.TrimSpace(state.GetPayoutId())
}
s.logger.Info("card payout submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("payout_id", payment.Execution.CardPayoutRef))
return nil
}
func recordCardPayoutState(payment *model.Payment, state *mntxv1.CardPayoutState) {
if payment == nil || state == nil {
return
}
if payment.CardPayout == nil {
payment.CardPayout = &model.CardPayout{}
}
payment.CardPayout.PayoutRef = strings.TrimSpace(state.GetPayoutId())
payment.CardPayout.ProviderPaymentID = strings.TrimSpace(state.GetProviderPaymentId())
payment.CardPayout.Status = state.GetStatus().String()
payment.CardPayout.FailureReason = strings.TrimSpace(state.GetProviderMessage())
payment.CardPayout.ProviderCode = strings.TrimSpace(state.GetProviderCode())
if payment.CardPayout.CardCountry == "" && payment.Intent.Destination.Card != nil {
payment.CardPayout.CardCountry = strings.TrimSpace(payment.Intent.Destination.Card.Country)
}
if payment.CardPayout.MaskedPan == "" && payment.Intent.Destination.Card != nil {
payment.CardPayout.MaskedPan = strings.TrimSpace(payment.Intent.Destination.Card.MaskedPan)
}
payment.CardPayout.GatewayReference = strings.TrimSpace(state.GetPayoutId())
}
func applyCardPayoutUpdate(payment *model.Payment, payout *mntxv1.CardPayoutState) {
if payment == nil || payout == nil {
return
}
recordCardPayoutState(payment, payout)
if payment.Execution == nil {
payment.Execution = &model.ExecutionRefs{}
}
if payment.Execution.CardPayoutRef == "" {
payment.Execution.CardPayoutRef = strings.TrimSpace(payout.GetPayoutId())
}
payment.State = mapMntxStatusToState(payout.GetStatus())
switch payout.GetStatus() {
case mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED:
payment.FailureCode = model.PaymentFailureCodeUnspecified
payment.FailureReason = ""
case mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED:
payment.FailureCode = model.PaymentFailureCodePolicy
payment.FailureReason = strings.TrimSpace(payout.GetProviderMessage())
default:
// leave as-is for pending/unspecified
}
}

View File

@@ -1,97 +0,0 @@
package orchestrator
import (
"context"
"time"
"github.com/tech/sendico/payments/orchestrator/storage"
"github.com/tech/sendico/payments/orchestrator/storage/model"
"github.com/tech/sendico/pkg/mlogger"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
)
type paymentEngine interface {
EnsureRepository(ctx context.Context) error
BuildPaymentQuote(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.PaymentQuote, time.Time, error)
ResolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, error)
ExecutePayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, quote *orchestratorv1.PaymentQuote) error
Repository() storage.Repository
}
type defaultPaymentEngine struct {
svc *Service
}
func (e defaultPaymentEngine) EnsureRepository(ctx context.Context) error {
return e.svc.ensureRepository(ctx)
}
func (e defaultPaymentEngine) BuildPaymentQuote(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.PaymentQuote, time.Time, error) {
return e.svc.buildPaymentQuote(ctx, orgRef, req)
}
func (e defaultPaymentEngine) ResolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, error) {
return e.svc.resolvePaymentQuote(ctx, in)
}
func (e defaultPaymentEngine) ExecutePayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, quote *orchestratorv1.PaymentQuote) error {
return e.svc.executePayment(ctx, store, payment, quote)
}
func (e defaultPaymentEngine) Repository() storage.Repository {
return e.svc.storage
}
type paymentCommandFactory struct {
engine paymentEngine
logger mlogger.Logger
}
func newPaymentCommandFactory(engine paymentEngine, logger mlogger.Logger) *paymentCommandFactory {
return &paymentCommandFactory{
engine: engine,
logger: logger.Named("commands"),
}
}
func (f *paymentCommandFactory) QuotePayment() *quotePaymentCommand {
return &quotePaymentCommand{
engine: f.engine,
logger: f.logger.Named("quote_payment"),
}
}
func (f *paymentCommandFactory) QuotePayments() *quotePaymentsCommand {
return &quotePaymentsCommand{
engine: f.engine,
logger: f.logger.Named("quote_payments"),
}
}
func (f *paymentCommandFactory) InitiatePayment() *initiatePaymentCommand {
return &initiatePaymentCommand{
engine: f.engine,
logger: f.logger.Named("initiate_payment"),
}
}
func (f *paymentCommandFactory) InitiatePayments() *initiatePaymentsCommand {
return &initiatePaymentsCommand{
engine: f.engine,
logger: f.logger.Named("initiate_payments"),
}
}
func (f *paymentCommandFactory) CancelPayment() *cancelPaymentCommand {
return &cancelPaymentCommand{
engine: f.engine,
logger: f.logger.Named("cancel_payment"),
}
}
func (f *paymentCommandFactory) InitiateConversion() *initiateConversionCommand {
return &initiateConversionCommand{
engine: f.engine,
logger: f.logger.Named("initiate_conversion"),
}
}

View File

@@ -5,7 +5,9 @@ import (
"time" "time"
"github.com/tech/sendico/payments/orchestrator/storage/model" "github.com/tech/sendico/payments/orchestrator/storage/model"
"github.com/tech/sendico/pkg/merrors"
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
@@ -18,14 +20,13 @@ func intentFromProto(src *orchestratorv1.PaymentIntent) model.PaymentIntent {
return model.PaymentIntent{} return model.PaymentIntent{}
} }
intent := model.PaymentIntent{ intent := model.PaymentIntent{
Kind: modelKindFromProto(src.GetKind()), Kind: modelKindFromProto(src.GetKind()),
Source: endpointFromProto(src.GetSource()), Source: endpointFromProto(src.GetSource()),
Destination: endpointFromProto(src.GetDestination()), Destination: endpointFromProto(src.GetDestination()),
Amount: cloneMoney(src.GetAmount()), Amount: cloneMoney(src.GetAmount()),
RequiresFX: src.GetRequiresFx(), RequiresFX: src.GetRequiresFx(),
FeePolicy: src.GetFeePolicy(), FeePolicy: src.GetFeePolicy(),
SettlementMode: src.GetSettlementMode(), Attributes: cloneMetadata(src.GetAttributes()),
Attributes: cloneMetadata(src.GetAttributes()),
} }
if src.GetFx() != nil { if src.GetFx() != nil {
intent.FX = fxIntentFromProto(src.GetFx()) intent.FX = fxIntentFromProto(src.GetFx())
@@ -66,19 +67,6 @@ func endpointFromProto(src *orchestratorv1.PaymentEndpoint) model.PaymentEndpoin
} }
return result return result
} }
if card := src.GetCard(); card != nil {
result.Type = model.EndpointTypeCard
result.Card = &model.CardEndpoint{
Pan: strings.TrimSpace(card.GetPan()),
Token: strings.TrimSpace(card.GetToken()),
Cardholder: strings.TrimSpace(card.GetCardholderName()),
ExpMonth: card.GetExpMonth(),
ExpYear: card.GetExpYear(),
Country: strings.TrimSpace(card.GetCountry()),
MaskedPan: strings.TrimSpace(card.GetMaskedPan()),
}
return result
}
return result return result
} }
@@ -108,6 +96,7 @@ func quoteSnapshotToModel(src *orchestratorv1.PaymentQuote) *model.PaymentQuoteS
FeeRules: cloneFeeRules(src.GetFeeRules()), FeeRules: cloneFeeRules(src.GetFeeRules()),
FXQuote: cloneFXQuote(src.GetFxQuote()), FXQuote: cloneFXQuote(src.GetFxQuote()),
NetworkFee: cloneNetworkEstimate(src.GetNetworkFee()), NetworkFee: cloneNetworkEstimate(src.GetNetworkFee()),
FeeQuoteToken: strings.TrimSpace(src.GetFeeQuoteToken()),
QuoteRef: strings.TrimSpace(src.GetQuoteRef()), QuoteRef: strings.TrimSpace(src.GetQuoteRef()),
} }
} }
@@ -127,18 +116,6 @@ func toProtoPayment(src *model.Payment) *orchestratorv1.Payment {
Execution: protoExecutionFromModel(src.Execution), Execution: protoExecutionFromModel(src.Execution),
Metadata: cloneMetadata(src.Metadata), Metadata: cloneMetadata(src.Metadata),
} }
if src.CardPayout != nil {
payment.CardPayout = &orchestratorv1.CardPayout{
PayoutRef: src.CardPayout.PayoutRef,
ProviderPaymentId: src.CardPayout.ProviderPaymentID,
Status: src.CardPayout.Status,
FailureReason: src.CardPayout.FailureReason,
CardCountry: src.CardPayout.CardCountry,
MaskedPan: src.CardPayout.MaskedPan,
ProviderCode: src.CardPayout.ProviderCode,
GatewayReference: src.CardPayout.GatewayReference,
}
}
if src.CreatedAt.IsZero() { if src.CreatedAt.IsZero() {
payment.CreatedAt = timestamppb.New(time.Now().UTC()) payment.CreatedAt = timestamppb.New(time.Now().UTC())
} else { } else {
@@ -152,14 +129,13 @@ func toProtoPayment(src *model.Payment) *orchestratorv1.Payment {
func protoIntentFromModel(src model.PaymentIntent) *orchestratorv1.PaymentIntent { func protoIntentFromModel(src model.PaymentIntent) *orchestratorv1.PaymentIntent {
intent := &orchestratorv1.PaymentIntent{ intent := &orchestratorv1.PaymentIntent{
Kind: protoKindFromModel(src.Kind), Kind: protoKindFromModel(src.Kind),
Source: protoEndpointFromModel(src.Source), Source: protoEndpointFromModel(src.Source),
Destination: protoEndpointFromModel(src.Destination), Destination: protoEndpointFromModel(src.Destination),
Amount: cloneMoney(src.Amount), Amount: cloneMoney(src.Amount),
RequiresFx: src.RequiresFX, RequiresFx: src.RequiresFX,
FeePolicy: src.FeePolicy, FeePolicy: src.FeePolicy,
SettlementMode: src.SettlementMode, Attributes: cloneMetadata(src.Attributes),
Attributes: cloneMetadata(src.Attributes),
} }
if src.FX != nil { if src.FX != nil {
intent.Fx = protoFXIntentFromModel(src.FX) intent.Fx = protoFXIntentFromModel(src.FX)
@@ -200,23 +176,6 @@ func protoEndpointFromModel(src model.PaymentEndpoint) *orchestratorv1.PaymentEn
}, },
} }
} }
case model.EndpointTypeCard:
if src.Card != nil {
card := &orchestratorv1.CardEndpoint{
CardholderName: src.Card.Cardholder,
ExpMonth: src.Card.ExpMonth,
ExpYear: src.Card.ExpYear,
Country: src.Card.Country,
MaskedPan: src.Card.MaskedPan,
}
if pan := strings.TrimSpace(src.Card.Pan); pan != "" {
card.Card = &orchestratorv1.CardEndpoint_Pan{Pan: pan}
}
if token := strings.TrimSpace(src.Card.Token); token != "" {
card.Card = &orchestratorv1.CardEndpoint_Token{Token: token}
}
endpoint.Endpoint = &orchestratorv1.PaymentEndpoint_Card{Card: card}
}
default: default:
// leave unspecified // leave unspecified
} }
@@ -246,8 +205,6 @@ func protoExecutionFromModel(src *model.ExecutionRefs) *orchestratorv1.Execution
CreditEntryRef: src.CreditEntryRef, CreditEntryRef: src.CreditEntryRef,
FxEntryRef: src.FXEntryRef, FxEntryRef: src.FXEntryRef,
ChainTransferRef: src.ChainTransferRef, ChainTransferRef: src.ChainTransferRef,
CardPayoutRef: src.CardPayoutRef,
FeeTransferRef: src.FeeTransferRef,
} }
} }
@@ -263,6 +220,7 @@ func modelQuoteToProto(src *model.PaymentQuoteSnapshot) *orchestratorv1.PaymentQ
FeeRules: cloneFeeRules(src.FeeRules), FeeRules: cloneFeeRules(src.FeeRules),
FxQuote: cloneFXQuote(src.FXQuote), FxQuote: cloneFXQuote(src.FXQuote),
NetworkFee: cloneNetworkEstimate(src.NetworkFee), NetworkFee: cloneNetworkEstimate(src.NetworkFee),
FeeQuoteToken: src.FeeQuoteToken,
QuoteRef: strings.TrimSpace(src.QuoteRef), QuoteRef: strings.TrimSpace(src.QuoteRef),
} }
} }
@@ -411,3 +369,60 @@ func cloneNetworkEstimate(resp *chainv1.EstimateTransferFeeResponse) *chainv1.Es
} }
return nil return nil
} }
func protoFailureToModel(code orchestratorv1.PaymentFailureCode) model.PaymentFailureCode {
switch code {
case orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_BALANCE:
return model.PaymentFailureCodeBalance
case orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_LEDGER:
return model.PaymentFailureCodeLedger
case orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_FX:
return model.PaymentFailureCodeFX
case orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_CHAIN:
return model.PaymentFailureCodeChain
case orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_FEES:
return model.PaymentFailureCodeFees
case orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_POLICY:
return model.PaymentFailureCodePolicy
default:
return model.PaymentFailureCodeUnspecified
}
}
func applyProtoPaymentToModel(src *orchestratorv1.Payment, dst *model.Payment) error {
if src == nil || dst == nil {
return merrors.InvalidArgument("payment payload is required")
}
dst.PaymentRef = strings.TrimSpace(src.GetPaymentRef())
dst.IdempotencyKey = strings.TrimSpace(src.GetIdempotencyKey())
dst.Intent = intentFromProto(src.GetIntent())
dst.State = modelStateFromProto(src.GetState())
dst.FailureCode = protoFailureToModel(src.GetFailureCode())
dst.FailureReason = strings.TrimSpace(src.GetFailureReason())
dst.Metadata = cloneMetadata(src.GetMetadata())
dst.LastQuote = quoteSnapshotToModel(src.GetLastQuote())
dst.Execution = executionFromProto(src.GetExecution())
return nil
}
func executionFromProto(src *orchestratorv1.ExecutionRefs) *model.ExecutionRefs {
if src == nil {
return nil
}
return &model.ExecutionRefs{
DebitEntryRef: strings.TrimSpace(src.GetDebitEntryRef()),
CreditEntryRef: strings.TrimSpace(src.GetCreditEntryRef()),
FXEntryRef: strings.TrimSpace(src.GetFxEntryRef()),
ChainTransferRef: strings.TrimSpace(src.GetChainTransferRef()),
}
}
func ensurePageRequest(req *orchestratorv1.ListPaymentsRequest) *paginationv1.CursorPageRequest {
if req == nil {
return &paginationv1.CursorPageRequest{}
}
if req.GetPage() == nil {
return &paginationv1.CursorPageRequest{}
}
return req.GetPage()
}

View File

@@ -1,69 +0,0 @@
package orchestrator
import (
"testing"
"github.com/tech/sendico/payments/orchestrator/storage/model"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
)
func TestEndpointFromProtoCard(t *testing.T) {
protoEndpoint := &orchestratorv1.PaymentEndpoint{
Endpoint: &orchestratorv1.PaymentEndpoint_Card{
Card: &orchestratorv1.CardEndpoint{
Card: &orchestratorv1.CardEndpoint_Pan{Pan: " 411111 "},
CardholderName: " Jane Doe ",
ExpMonth: 12,
ExpYear: 2030,
Country: " US ",
MaskedPan: " ****1111 ",
},
},
Metadata: map[string]string{"k": "v"},
}
modelEndpoint := endpointFromProto(protoEndpoint)
if modelEndpoint.Type != model.EndpointTypeCard {
t.Fatalf("expected card type, got %s", modelEndpoint.Type)
}
if modelEndpoint.Card == nil {
t.Fatalf("card payload missing")
}
if modelEndpoint.Card.Pan != "411111" || modelEndpoint.Card.Cardholder != "Jane Doe" || modelEndpoint.Card.Country != "US" || modelEndpoint.Card.MaskedPan != "****1111" {
t.Fatalf("card payload not trimmed as expected: %#v", modelEndpoint.Card)
}
if modelEndpoint.Metadata["k"] != "v" {
t.Fatalf("metadata not preserved")
}
}
func TestProtoEndpointFromModelCard(t *testing.T) {
modelEndpoint := model.PaymentEndpoint{
Type: model.EndpointTypeCard,
Card: &model.CardEndpoint{
Token: "tok_123",
Cardholder: "Jane",
ExpMonth: 1,
ExpYear: 2028,
Country: "GB",
MaskedPan: "****1234",
},
Metadata: map[string]string{"k": "v"},
}
protoEndpoint := protoEndpointFromModel(modelEndpoint)
card := protoEndpoint.GetCard()
if card == nil {
t.Fatalf("card payload missing in proto")
}
token, ok := card.Card.(*orchestratorv1.CardEndpoint_Token)
if !ok || token.Token != "tok_123" {
t.Fatalf("expected token payload, got %T %#v", card.Card, card.Card)
}
if card.GetCardholderName() != "Jane" || card.GetCountry() != "GB" || card.GetMaskedPan() != "****1234" {
t.Fatalf("card details mismatch: %#v", card)
}
if protoEndpoint.GetMetadata()["k"] != "v" {
t.Fatalf("metadata not preserved in proto endpoint")
}
}

View File

@@ -3,30 +3,215 @@ package orchestrator
import ( import (
"context" "context"
"strings" "strings"
"time"
oracleclient "github.com/tech/sendico/fx/oracle/client"
"github.com/tech/sendico/payments/orchestrator/storage" "github.com/tech/sendico/payments/orchestrator/storage"
"github.com/tech/sendico/payments/orchestrator/storage/model" "github.com/tech/sendico/payments/orchestrator/storage/model"
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger" feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
"go.mongodb.org/mongo-driver/bson/primitive" "go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap" "go.uber.org/zap"
"google.golang.org/protobuf/types/known/timestamppb"
) )
type paymentExecutor struct { func (s *Service) buildPaymentQuote(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.PaymentQuote, time.Time, error) {
deps *serviceDependencies intent := req.GetIntent()
logger mlogger.Logger amount := intent.GetAmount()
svc *Service fxSide := fxv1.Side_SIDE_UNSPECIFIED
if intent.GetFx() != nil {
fxSide = intent.GetFx().GetSide()
}
var fxQuote *oraclev1.Quote
var err error
if shouldRequestFX(intent) {
fxQuote, err = s.requestFXQuote(ctx, orgRef, req)
if err != nil {
return nil, time.Time{}, err
}
}
payAmount, settlementAmountBeforeFees := resolveTradeAmounts(amount, fxQuote, fxSide)
feeBaseAmount := payAmount
if feeBaseAmount == nil {
feeBaseAmount = cloneMoney(amount)
}
feeQuote, err := s.quoteFees(ctx, orgRef, req, feeBaseAmount)
if err != nil {
return nil, time.Time{}, err
}
feeCurrency := ""
if feeBaseAmount != nil {
feeCurrency = feeBaseAmount.GetCurrency()
} else if amount != nil {
feeCurrency = amount.GetCurrency()
}
feeTotal := extractFeeTotal(feeQuote.GetLines(), feeCurrency)
var networkFee *chainv1.EstimateTransferFeeResponse
if shouldEstimateNetworkFee(intent) {
networkFee, err = s.estimateNetworkFee(ctx, intent)
if err != nil {
return nil, time.Time{}, err
}
}
debitAmount, settlementAmount := computeAggregates(payAmount, settlementAmountBeforeFees, feeTotal, networkFee, fxQuote)
quote := &orchestratorv1.PaymentQuote{
DebitAmount: debitAmount,
ExpectedSettlementAmount: settlementAmount,
ExpectedFeeTotal: feeTotal,
FeeLines: cloneFeeLines(feeQuote.GetLines()),
FeeRules: cloneFeeRules(feeQuote.GetApplied()),
FxQuote: fxQuote,
NetworkFee: networkFee,
FeeQuoteToken: feeQuote.GetFeeQuoteToken(),
}
expiresAt := quoteExpiry(s.clock.Now(), feeQuote, fxQuote)
return quote, expiresAt, nil
} }
func newPaymentExecutor(deps *serviceDependencies, logger mlogger.Logger, svc *Service) *paymentExecutor { func (s *Service) quoteFees(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest, baseAmount *moneyv1.Money) (*feesv1.PrecomputeFeesResponse, error) {
return &paymentExecutor{deps: deps, logger: logger, svc: svc} if !s.fees.available() {
return &feesv1.PrecomputeFeesResponse{}, nil
}
intent := req.GetIntent()
amount := cloneMoney(baseAmount)
if amount == nil {
amount = cloneMoney(intent.GetAmount())
}
feeIntent := &feesv1.Intent{
Trigger: triggerFromKind(intent.GetKind(), intent.GetRequiresFx()),
BaseAmount: amount,
BookedAt: timestamppb.New(s.clock.Now()),
OriginType: "payments.orchestrator.quote",
OriginRef: strings.TrimSpace(req.GetIdempotencyKey()),
Attributes: cloneMetadata(intent.GetAttributes()),
}
timeout := req.GetMeta().GetTrace()
ctxTimeout, cancel := s.withTimeout(ctx, s.fees.timeout)
defer cancel()
resp, err := s.fees.client.PrecomputeFees(ctxTimeout, &feesv1.PrecomputeFeesRequest{
Meta: &feesv1.RequestMeta{
OrganizationRef: orgRef,
Trace: timeout,
},
Intent: feeIntent,
TtlMs: defaultFeeQuoteTTLMillis,
})
if err != nil {
s.logger.Error("fees precompute failed", zap.Error(err))
return nil, merrors.Internal("fees_precompute_failed")
}
return resp, nil
} }
func (p *paymentExecutor) executePayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, quote *orchestratorv1.PaymentQuote) error { func (s *Service) estimateNetworkFee(ctx context.Context, intent *orchestratorv1.PaymentIntent) (*chainv1.EstimateTransferFeeResponse, error) {
if !s.gateway.available() {
return nil, nil
}
req := &chainv1.EstimateTransferFeeRequest{
Amount: cloneMoney(intent.GetAmount()),
}
if src := intent.GetSource().GetManagedWallet(); src != nil {
req.SourceWalletRef = strings.TrimSpace(src.GetManagedWalletRef())
}
if dst := intent.GetDestination().GetManagedWallet(); dst != nil {
req.Destination = &chainv1.TransferDestination{
Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: strings.TrimSpace(dst.GetManagedWalletRef())},
}
}
if dst := intent.GetDestination().GetExternalChain(); dst != nil {
req.Destination = &chainv1.TransferDestination{
Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: strings.TrimSpace(dst.GetAddress())},
Memo: strings.TrimSpace(dst.GetMemo()),
}
req.Asset = dst.GetAsset()
}
if req.Asset == nil {
if src := intent.GetSource().GetManagedWallet(); src != nil {
req.Asset = src.GetAsset()
}
}
resp, err := s.gateway.client.EstimateTransferFee(ctx, req)
if err != nil {
s.logger.Error("chain gateway fee estimation failed", zap.Error(err))
return nil, merrors.Internal("chain_gateway_fee_estimation_failed")
}
return resp, nil
}
func (s *Service) requestFXQuote(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest) (*oraclev1.Quote, error) {
if !s.oracle.available() {
return nil, nil
}
intent := req.GetIntent()
meta := req.GetMeta()
fxIntent := intent.GetFx()
if fxIntent == nil {
return nil, nil
}
ttl := fxIntent.GetTtlMs()
if ttl <= 0 {
ttl = defaultOracleTTLMillis
}
params := oracleclient.GetQuoteParams{
Meta: oracleclient.RequestMeta{
OrganizationRef: orgRef,
Trace: meta.GetTrace(),
},
Pair: fxIntent.GetPair(),
Side: fxIntent.GetSide(),
Firm: fxIntent.GetFirm(),
TTL: time.Duration(ttl) * time.Millisecond,
PreferredProvider: strings.TrimSpace(fxIntent.GetPreferredProvider()),
}
if fxIntent.GetMaxAgeMs() > 0 {
params.MaxAge = time.Duration(fxIntent.GetMaxAgeMs()) * time.Millisecond
}
if amount := intent.GetAmount(); amount != nil {
pair := fxIntent.GetPair()
if pair != nil {
switch {
case strings.EqualFold(amount.GetCurrency(), pair.GetBase()):
params.BaseAmount = cloneMoney(amount)
case strings.EqualFold(amount.GetCurrency(), pair.GetQuote()):
params.QuoteAmount = cloneMoney(amount)
default:
params.BaseAmount = cloneMoney(amount)
}
} else {
params.BaseAmount = cloneMoney(amount)
}
}
quote, err := s.oracle.client.GetQuote(ctx, params)
if err != nil {
s.logger.Error("fx oracle quote failed", zap.Error(err))
return nil, merrors.Internal("fx_quote_failed")
}
return quoteToProto(quote), nil
}
func (s *Service) executePayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, quote *orchestratorv1.PaymentQuote) error {
if store == nil { if store == nil {
return errStorageUnavailable return errStorageUnavailable
} }
@@ -34,7 +219,6 @@ func (p *paymentExecutor) executePayment(ctx context.Context, store storage.Paym
charges := ledgerChargesFromFeeLines(quote.GetFeeLines()) charges := ledgerChargesFromFeeLines(quote.GetFeeLines())
ledgerNeeded := requiresLedger(payment) ledgerNeeded := requiresLedger(payment)
chainNeeded := requiresChain(payment) chainNeeded := requiresChain(payment)
cardNeeded := payment.Intent.Destination.Type == model.EndpointTypeCard
exec := payment.Execution exec := payment.Execution
if exec == nil { if exec == nil {
@@ -42,26 +226,25 @@ func (p *paymentExecutor) executePayment(ctx context.Context, store storage.Paym
} }
if ledgerNeeded { if ledgerNeeded {
if !p.deps.ledger.available() { if !s.ledger.available() {
return p.failPayment(ctx, store, payment, model.PaymentFailureCodeLedger, "ledger_client_unavailable", merrors.Internal("ledger_client_unavailable")) return s.failPayment(ctx, store, payment, model.PaymentFailureCodeLedger, "ledger_client_unavailable", merrors.Internal("ledger_client_unavailable"))
} }
if err := p.performLedgerOperation(ctx, payment, quote, charges); err != nil { if err := s.performLedgerOperation(ctx, payment, quote, charges); err != nil {
return p.failPayment(ctx, store, payment, model.PaymentFailureCodeLedger, strings.TrimSpace(err.Error()), err) return s.failPayment(ctx, store, payment, model.PaymentFailureCodeLedger, strings.TrimSpace(err.Error()), err)
} }
payment.State = model.PaymentStateFundsReserved payment.State = model.PaymentStateFundsReserved
if err := p.persistPayment(ctx, store, payment); err != nil { if err := s.persistPayment(ctx, store, payment); err != nil {
return err return err
} }
p.logger.Info("ledger reservation completed", zap.String("payment_ref", payment.PaymentRef))
} }
if chainNeeded { if chainNeeded {
if !p.deps.gateway.available() { if !s.gateway.available() {
return p.failPayment(ctx, store, payment, model.PaymentFailureCodeChain, "chain_client_unavailable", merrors.Internal("chain_client_unavailable")) return s.failPayment(ctx, store, payment, model.PaymentFailureCodeChain, "chain_client_unavailable", merrors.Internal("chain_client_unavailable"))
} }
resp, err := p.submitChainTransfer(ctx, payment, quote) resp, err := s.submitChainTransfer(ctx, payment, quote)
if err != nil { if err != nil {
return p.failPayment(ctx, store, payment, model.PaymentFailureCodeChain, strings.TrimSpace(err.Error()), err) return s.failPayment(ctx, store, payment, model.PaymentFailureCodeChain, strings.TrimSpace(err.Error()), err)
} }
exec = payment.Execution exec = payment.Execution
if exec == nil { if exec == nil {
@@ -72,42 +255,17 @@ func (p *paymentExecutor) executePayment(ctx context.Context, store storage.Paym
} }
payment.Execution = exec payment.Execution = exec
payment.State = model.PaymentStateSubmitted payment.State = model.PaymentStateSubmitted
if err := p.persistPayment(ctx, store, payment); err != nil { if err := s.persistPayment(ctx, store, payment); err != nil {
return err return err
} }
p.logger.Info("chain transfer submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", exec.ChainTransferRef))
if !cardNeeded {
return nil
}
}
if cardNeeded {
if !p.deps.mntx.available() {
return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, "card_gateway_unavailable", merrors.Internal("card_gateway_unavailable"))
}
if err := p.svc.submitCardFundingTransfers(ctx, payment, quote); err != nil {
return p.failPayment(ctx, store, payment, model.PaymentFailureCodeChain, strings.TrimSpace(err.Error()), err)
}
if err := p.svc.submitCardPayout(ctx, payment); err != nil {
return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, strings.TrimSpace(err.Error()), err)
}
payment.State = model.PaymentStateSubmitted
if err := p.persistPayment(ctx, store, payment); err != nil {
return err
}
p.logger.Info("card payout submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("card_payout_ref", payment.Execution.CardPayoutRef))
return nil return nil
} }
payment.State = model.PaymentStateSettled payment.State = model.PaymentStateSettled
if err := p.persistPayment(ctx, store, payment); err != nil { return s.persistPayment(ctx, store, payment)
return err
}
p.logger.Info("payment settled without chain", zap.String("payment_ref", payment.PaymentRef))
return nil
} }
func (p *paymentExecutor) performLedgerOperation(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote, charges []*ledgerv1.PostingLine) error { func (s *Service) performLedgerOperation(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote, charges []*ledgerv1.PostingLine) error {
intent := payment.Intent intent := payment.Intent
if payment.OrganizationRef == primitive.NilObjectID { if payment.OrganizationRef == primitive.NilObjectID {
return merrors.InvalidArgument("ledger: organization_ref is required") return merrors.InvalidArgument("ledger: organization_ref is required")
@@ -127,7 +285,7 @@ func (p *paymentExecutor) performLedgerOperation(ctx context.Context, payment *m
switch intent.Kind { switch intent.Kind {
case model.PaymentKindFXConversion: case model.PaymentKindFXConversion:
if err := p.applyFX(ctx, payment, quote, charges, description, metadata, exec); err != nil { if err := s.applyFX(ctx, payment, quote, charges, description, metadata, exec); err != nil {
return err return err
} }
case model.PaymentKindInternalTransfer, model.PaymentKindPayout, model.PaymentKindUnspecified: case model.PaymentKindInternalTransfer, model.PaymentKindPayout, model.PaymentKindUnspecified:
@@ -145,7 +303,7 @@ func (p *paymentExecutor) performLedgerOperation(ctx context.Context, payment *m
Charges: charges, Charges: charges,
Metadata: metadata, Metadata: metadata,
} }
resp, err := p.deps.ledger.client.TransferInternal(ctx, req) resp, err := s.ledger.client.TransferInternal(ctx, req)
if err != nil { if err != nil {
return err return err
} }
@@ -158,7 +316,7 @@ func (p *paymentExecutor) performLedgerOperation(ctx context.Context, payment *m
return nil return nil
} }
func (p *paymentExecutor) applyFX(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote, charges []*ledgerv1.PostingLine, description string, metadata map[string]string, exec *model.ExecutionRefs) error { func (s *Service) applyFX(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote, charges []*ledgerv1.PostingLine, description string, metadata map[string]string, exec *model.ExecutionRefs) error {
intent := payment.Intent intent := payment.Intent
source := intent.Source.Ledger source := intent.Source.Ledger
destination := intent.Destination.Ledger destination := intent.Destination.Ledger
@@ -196,7 +354,7 @@ func (p *paymentExecutor) applyFX(ctx context.Context, payment *model.Payment, q
Charges: charges, Charges: charges,
Metadata: metadata, Metadata: metadata,
} }
resp, err := p.deps.ledger.client.ApplyFXWithCharges(ctx, req) resp, err := s.ledger.client.ApplyFXWithCharges(ctx, req)
if err != nil { if err != nil {
return err return err
} }
@@ -205,7 +363,7 @@ func (p *paymentExecutor) applyFX(ctx context.Context, payment *model.Payment, q
return nil return nil
} }
func (p *paymentExecutor) submitChainTransfer(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote) (*chainv1.SubmitTransferResponse, error) { func (s *Service) submitChainTransfer(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote) (*chainv1.SubmitTransferResponse, error) {
intent := payment.Intent intent := payment.Intent
source := intent.Source.ManagedWallet source := intent.Source.ManagedWallet
destination := intent.Destination destination := intent.Destination
@@ -231,23 +389,23 @@ func (p *paymentExecutor) submitChainTransfer(ctx context.Context, payment *mode
Metadata: cloneMetadata(payment.Metadata), Metadata: cloneMetadata(payment.Metadata),
ClientReference: payment.PaymentRef, ClientReference: payment.PaymentRef,
} }
return p.deps.gateway.client.SubmitTransfer(ctx, req) return s.gateway.client.SubmitTransfer(ctx, req)
} }
func (p *paymentExecutor) persistPayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment) error { func (s *Service) persistPayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment) error {
if store == nil { if store == nil {
return errStorageUnavailable return errStorageUnavailable
} }
return store.Update(ctx, payment) return store.Update(ctx, payment)
} }
func (p *paymentExecutor) failPayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, code model.PaymentFailureCode, reason string, err error) error { func (s *Service) failPayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, code model.PaymentFailureCode, reason string, err error) error {
payment.State = model.PaymentStateFailed payment.State = model.PaymentStateFailed
payment.FailureCode = code payment.FailureCode = code
payment.FailureReason = strings.TrimSpace(reason) payment.FailureReason = strings.TrimSpace(reason)
if store != nil { if store != nil {
if updateErr := store.Update(ctx, payment); updateErr != nil { if updateErr := store.Update(ctx, payment); updateErr != nil {
p.logger.Error("failed to persist payment failure", zap.Error(updateErr), zap.String("payment_ref", payment.PaymentRef)) s.logger.Error("failed to persist payment failure", zap.Error(updateErr), zap.String("payment_ref", payment.PaymentRef))
} }
} }
if err != nil { if err != nil {
@@ -256,21 +414,6 @@ func (p *paymentExecutor) failPayment(ctx context.Context, store storage.Payment
return merrors.Internal(reason) return merrors.Internal(reason)
} }
func paymentDescription(payment *model.Payment) string {
if payment == nil {
return ""
}
if val := strings.TrimSpace(payment.Intent.Attributes["description"]); val != "" {
return val
}
if payment.Metadata != nil {
if val := strings.TrimSpace(payment.Metadata["description"]); val != "" {
return val
}
}
return payment.PaymentRef
}
func resolveLedgerAccounts(intent model.PaymentIntent) (string, string, error) { func resolveLedgerAccounts(intent model.PaymentIntent) (string, string, error) {
source := intent.Source.Ledger source := intent.Source.Ledger
destination := intent.Destination.Ledger destination := intent.Destination.Ledger
@@ -289,6 +432,21 @@ func resolveLedgerAccounts(intent model.PaymentIntent) (string, string, error) {
return strings.TrimSpace(source.LedgerAccountRef), to, nil return strings.TrimSpace(source.LedgerAccountRef), to, nil
} }
func paymentDescription(payment *model.Payment) string {
if payment == nil {
return ""
}
if val := strings.TrimSpace(payment.Intent.Attributes["description"]); val != "" {
return val
}
if payment.Metadata != nil {
if val := strings.TrimSpace(payment.Metadata["description"]); val != "" {
return val
}
}
return payment.PaymentRef
}
func requiresLedger(payment *model.Payment) bool { func requiresLedger(payment *model.Payment) bool {
if payment == nil { if payment == nil {
return false return false

View File

@@ -1,446 +0,0 @@
package orchestrator
import (
"context"
"errors"
"strings"
"time"
"github.com/tech/sendico/payments/orchestrator/storage"
"github.com/tech/sendico/payments/orchestrator/storage/model"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap"
)
type quotePaymentCommand struct {
engine paymentEngine
logger mlogger.Logger
}
func (h *quotePaymentCommand) Execute(ctx context.Context, req *orchestratorv1.QuotePaymentRequest) gsresponse.Responder[orchestratorv1.QuotePaymentResponse] {
if err := h.engine.EnsureRepository(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
orgRef, orgID, err := validateMetaAndOrgRef(req.GetMeta())
if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if err := requireNonNilIntent(req.GetIntent()); err != nil {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
intent := req.GetIntent()
quote, expiresAt, err := h.engine.BuildPaymentQuote(ctx, orgRef, req)
if err != nil {
return gsresponse.Auto[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if !req.GetPreviewOnly() {
quotesStore, err := ensureQuotesStore(h.engine.Repository())
if err != nil {
return gsresponse.Unavailable[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
quoteRef := primitive.NewObjectID().Hex()
quote.QuoteRef = quoteRef
record := &model.PaymentQuoteRecord{
QuoteRef: quoteRef,
Intent: intentFromProto(intent),
Quote: quoteSnapshotToModel(quote),
ExpiresAt: expiresAt,
}
record.SetID(primitive.NewObjectID())
record.SetOrganizationRef(orgID)
if err := quotesStore.Create(ctx, record); err != nil {
return gsresponse.Auto[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
h.logger.Info("stored payment quote", zap.String("quote_ref", quoteRef), zap.String("org_ref", orgID.Hex()))
}
return gsresponse.Success(&orchestratorv1.QuotePaymentResponse{Quote: quote})
}
type quotePaymentsCommand struct {
engine paymentEngine
logger mlogger.Logger
}
func (h *quotePaymentsCommand) Execute(ctx context.Context, req *orchestratorv1.QuotePaymentsRequest) gsresponse.Responder[orchestratorv1.QuotePaymentsResponse] {
if err := h.engine.EnsureRepository(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
orgRef, orgID, err := validateMetaAndOrgRef(req.GetMeta())
if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
intents := req.GetIntents()
if len(intents) == 0 {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("intents are required"))
}
baseKey := strings.TrimSpace(req.GetIdempotencyKey())
quotes := make([]*orchestratorv1.PaymentQuote, 0, len(intents))
expires := make([]time.Time, 0, len(intents))
for i, intent := range intents {
if err := requireNonNilIntent(intent); err != nil {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
quoteReq := &orchestratorv1.QuotePaymentRequest{
Meta: req.GetMeta(),
IdempotencyKey: perIntentIdempotencyKey(baseKey, i, len(intents)),
Intent: intent,
PreviewOnly: req.GetPreviewOnly(),
}
quote, expiresAt, err := h.engine.BuildPaymentQuote(ctx, orgRef, quoteReq)
if err != nil {
return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
quotes = append(quotes, quote)
expires = append(expires, expiresAt)
}
aggregate, err := aggregatePaymentQuotes(quotes)
if err != nil {
return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InternalWrap(err, "quote aggregation failed"))
}
expiresAt, ok := minQuoteExpiry(expires)
if !ok {
return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.Internal("quote expiry missing"))
}
quoteRef := ""
if !req.GetPreviewOnly() {
quotesStore, err := ensureQuotesStore(h.engine.Repository())
if err != nil {
return gsresponse.Unavailable[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
quoteRef = primitive.NewObjectID().Hex()
record := &model.PaymentQuoteRecord{
QuoteRef: quoteRef,
Intents: intentsFromProto(intents),
Quotes: quoteSnapshotsFromProto(quotes),
ExpiresAt: expiresAt,
}
record.SetID(primitive.NewObjectID())
record.SetOrganizationRef(orgID)
if err := quotesStore.Create(ctx, record); err != nil {
return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
h.logger.Info("stored payment quotes", zap.String("quote_ref", quoteRef), zap.String("org_ref", orgID.Hex()))
}
return gsresponse.Success(&orchestratorv1.QuotePaymentsResponse{
QuoteRef: quoteRef,
Aggregate: aggregate,
Quotes: quotes,
})
}
type initiatePaymentsCommand struct {
engine paymentEngine
logger mlogger.Logger
}
func (h *initiatePaymentsCommand) Execute(ctx context.Context, req *orchestratorv1.InitiatePaymentsRequest) gsresponse.Responder[orchestratorv1.InitiatePaymentsResponse] {
if err := h.engine.EnsureRepository(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
_, orgID, err := validateMetaAndOrgRef(req.GetMeta())
if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
idempotencyKey, err := requireIdempotencyKey(req.GetIdempotencyKey())
if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
quoteRef := strings.TrimSpace(req.GetQuoteRef())
if quoteRef == "" {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("quote_ref is required"))
}
quotesStore, err := ensureQuotesStore(h.engine.Repository())
if err != nil {
return gsresponse.Unavailable[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
record, err := quotesStore.GetByRef(ctx, orgID, quoteRef)
if err != nil {
if errors.Is(err, storage.ErrQuoteNotFound) {
return gsresponse.FailedPrecondition[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, "quote_not_found", merrors.InvalidArgument("quote_ref not found or expired"))
}
return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
intents := record.Intents
quotes := record.Quotes
if len(intents) == 0 && record.Intent.Kind != "" && record.Intent.Kind != model.PaymentKindUnspecified {
intents = []model.PaymentIntent{record.Intent}
}
if len(quotes) == 0 && record.Quote != nil {
quotes = []*model.PaymentQuoteSnapshot{record.Quote}
}
if len(intents) == 0 || len(quotes) == 0 || len(intents) != len(quotes) {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("stored quote payload is incomplete"))
}
store, err := ensurePaymentsStore(h.engine.Repository())
if err != nil {
return gsresponse.Unavailable[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
payments := make([]*orchestratorv1.Payment, 0, len(intents))
for i := range intents {
intentProto := protoIntentFromModel(intents[i])
if err := requireNonNilIntent(intentProto); err != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
quoteProto := modelQuoteToProto(quotes[i])
if quoteProto == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("stored quote is empty"))
}
quoteProto.QuoteRef = quoteRef
perKey := perIntentIdempotencyKey(idempotencyKey, i, len(intents))
if existing, err := getPaymentByIdempotencyKey(ctx, store, orgID, perKey); err == nil && existing != nil {
payments = append(payments, toProtoPayment(existing))
continue
} else if err != nil && !errors.Is(err, storage.ErrPaymentNotFound) {
return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
entity := newPayment(orgID, intentProto, perKey, req.GetMetadata(), quoteProto)
if err = store.Create(ctx, entity); err != nil {
if errors.Is(err, storage.ErrDuplicatePayment) {
return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.DataConflict("payment already exists"))
}
return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if err := h.engine.ExecutePayment(ctx, store, entity, quoteProto); err != nil {
return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
payments = append(payments, toProtoPayment(entity))
}
return gsresponse.Success(&orchestratorv1.InitiatePaymentsResponse{Payments: payments})
}
type initiatePaymentCommand struct {
engine paymentEngine
logger mlogger.Logger
}
func (h *initiatePaymentCommand) Execute(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) gsresponse.Responder[orchestratorv1.InitiatePaymentResponse] {
if err := h.engine.EnsureRepository(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
orgRef, orgID, err := validateMetaAndOrgRef(req.GetMeta())
if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
intent := req.GetIntent()
if err := requireNonNilIntent(intent); err != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
idempotencyKey, err := requireIdempotencyKey(req.GetIdempotencyKey())
if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
store, err := ensurePaymentsStore(h.engine.Repository())
if err != nil {
return gsresponse.Unavailable[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if existing, err := getPaymentByIdempotencyKey(ctx, store, orgID, idempotencyKey); err == nil && existing != nil {
h.logger.Debug("idempotent payment request reused", zap.String("payment_ref", existing.PaymentRef), zap.String("org_ref", orgID.Hex()))
return gsresponse.Success(&orchestratorv1.InitiatePaymentResponse{Payment: toProtoPayment(existing)})
} else if err != nil && !errors.Is(err, storage.ErrPaymentNotFound) {
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
quoteSnapshot, err := h.engine.ResolvePaymentQuote(ctx, quoteResolutionInput{
OrgRef: orgRef,
OrgID: orgID,
Meta: req.GetMeta(),
Intent: intent,
QuoteRef: req.GetQuoteRef(),
IdempotencyKey: req.GetIdempotencyKey(),
})
if err != nil {
if qerr, ok := err.(quoteResolutionError); ok {
switch qerr.code {
case "quote_not_found":
return gsresponse.FailedPrecondition[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, qerr.code, qerr.err)
case "quote_expired":
return gsresponse.FailedPrecondition[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, qerr.code, qerr.err)
case "quote_intent_mismatch":
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, qerr.err)
default:
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, qerr.err)
}
}
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if quoteSnapshot == nil {
quoteSnapshot = &orchestratorv1.PaymentQuote{}
}
entity := newPayment(orgID, intent, idempotencyKey, req.GetMetadata(), quoteSnapshot)
if err = store.Create(ctx, entity); err != nil {
if errors.Is(err, storage.ErrDuplicatePayment) {
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.DataConflict("payment already exists"))
}
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if err := h.engine.ExecutePayment(ctx, store, entity, quoteSnapshot); err != nil {
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
h.logger.Info("payment initiated", zap.String("payment_ref", entity.PaymentRef), zap.String("org_ref", orgID.Hex()), zap.String("kind", intent.GetKind().String()))
return gsresponse.Success(&orchestratorv1.InitiatePaymentResponse{
Payment: toProtoPayment(entity),
})
}
type cancelPaymentCommand struct {
engine paymentEngine
logger mlogger.Logger
}
func (h *cancelPaymentCommand) Execute(ctx context.Context, req *orchestratorv1.CancelPaymentRequest) gsresponse.Responder[orchestratorv1.CancelPaymentResponse] {
if err := h.engine.EnsureRepository(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
paymentRef, err := requirePaymentRef(req.GetPaymentRef())
if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
store, err := ensurePaymentsStore(h.engine.Repository())
if err != nil {
return gsresponse.Unavailable[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
payment, err := store.GetByPaymentRef(ctx, paymentRef)
if err != nil {
return paymentNotFoundResponder[orchestratorv1.CancelPaymentResponse](mservice.PaymentOrchestrator, h.logger, err)
}
if payment.State != model.PaymentStateAccepted {
reason := merrors.InvalidArgument("payment cannot be cancelled in current state")
return gsresponse.FailedPrecondition[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, "payment_not_cancellable", reason)
}
payment.State = model.PaymentStateCancelled
payment.FailureCode = model.PaymentFailureCodePolicy
payment.FailureReason = strings.TrimSpace(req.GetReason())
if err := store.Update(ctx, payment); err != nil {
return gsresponse.Auto[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
h.logger.Info("payment cancelled", zap.String("payment_ref", payment.PaymentRef), zap.String("org_ref", payment.OrganizationRef.Hex()))
return gsresponse.Success(&orchestratorv1.CancelPaymentResponse{Payment: toProtoPayment(payment)})
}
type initiateConversionCommand struct {
engine paymentEngine
logger mlogger.Logger
}
func (h *initiateConversionCommand) Execute(ctx context.Context, req *orchestratorv1.InitiateConversionRequest) gsresponse.Responder[orchestratorv1.InitiateConversionResponse] {
if err := h.engine.EnsureRepository(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
orgRef, orgID, err := validateMetaAndOrgRef(req.GetMeta())
if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
}
idempotencyKey, err := requireIdempotencyKey(req.GetIdempotencyKey())
if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req.GetSource() == nil || req.GetSource().GetLedger() == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("source ledger endpoint is required"))
}
if req.GetDestination() == nil || req.GetDestination().GetLedger() == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("destination ledger endpoint is required"))
}
fxIntent := req.GetFx()
if fxIntent == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("fx intent is required"))
}
store, err := ensurePaymentsStore(h.engine.Repository())
if err != nil {
return gsresponse.Unavailable[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if existing, err := getPaymentByIdempotencyKey(ctx, store, orgID, idempotencyKey); err == nil && existing != nil {
h.logger.Debug("idempotent conversion request reused", zap.String("payment_ref", existing.PaymentRef), zap.String("org_ref", orgID.Hex()))
return gsresponse.Success(&orchestratorv1.InitiateConversionResponse{Conversion: toProtoPayment(existing)})
} else if err != nil && !errors.Is(err, storage.ErrPaymentNotFound) {
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
}
amount, err := conversionAmountFromMetadata(req.GetMetadata(), fxIntent)
if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
}
intentProto := &orchestratorv1.PaymentIntent{
Kind: orchestratorv1.PaymentKind_PAYMENT_KIND_FX_CONVERSION,
Source: req.GetSource(),
Destination: req.GetDestination(),
Amount: amount,
RequiresFx: true,
Fx: fxIntent,
FeePolicy: req.GetFeePolicy(),
}
quote, _, err := h.engine.BuildPaymentQuote(ctx, orgRef, &orchestratorv1.QuotePaymentRequest{
Meta: req.GetMeta(),
IdempotencyKey: req.GetIdempotencyKey(),
Intent: intentProto,
})
if err != nil {
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
}
entity := newPayment(orgID, intentProto, idempotencyKey, req.GetMetadata(), quote)
if err = store.Create(ctx, entity); err != nil {
if errors.Is(err, storage.ErrDuplicatePayment) {
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, merrors.DataConflict("payment already exists"))
}
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if err := h.engine.ExecutePayment(ctx, store, entity, quote); err != nil {
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
}
h.logger.Info("conversion initiated", zap.String("payment_ref", entity.PaymentRef), zap.String("org_ref", orgID.Hex()))
return gsresponse.Success(&orchestratorv1.InitiateConversionResponse{
Conversion: toProtoPayment(entity),
})
}

View File

@@ -1,139 +0,0 @@
package orchestrator
import (
"context"
"strings"
"github.com/tech/sendico/payments/orchestrator/storage"
"github.com/tech/sendico/payments/orchestrator/storage/model"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
"go.uber.org/zap"
)
type paymentEventHandler struct {
repo storage.Repository
ensureRepo func(ctx context.Context) error
logger mlogger.Logger
}
func newPaymentEventHandler(repo storage.Repository, ensure func(ctx context.Context) error, logger mlogger.Logger) *paymentEventHandler {
return &paymentEventHandler{
repo: repo,
ensureRepo: ensure,
logger: logger,
}
}
func (h *paymentEventHandler) processTransferUpdate(ctx context.Context, req *orchestratorv1.ProcessTransferUpdateRequest) gsresponse.Responder[orchestratorv1.ProcessTransferUpdateResponse] {
if err := h.ensureRepo(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req == nil || req.GetEvent() == nil || req.GetEvent().GetTransfer() == nil {
return gsresponse.InvalidArgument[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("transfer event is required"))
}
transfer := req.GetEvent().GetTransfer()
transferRef := strings.TrimSpace(transfer.GetTransferRef())
if transferRef == "" {
return gsresponse.InvalidArgument[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("transfer_ref is required"))
}
store := h.repo.Payments()
if store == nil {
return gsresponse.Unavailable[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
}
payment, err := store.GetByChainTransferRef(ctx, transferRef)
if err != nil {
return paymentNotFoundResponder[orchestratorv1.ProcessTransferUpdateResponse](mservice.PaymentOrchestrator, h.logger, err)
}
applyTransferStatus(req.GetEvent(), payment)
if err := store.Update(ctx, payment); err != nil {
return gsresponse.Auto[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, err)
}
h.logger.Info("transfer update applied", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", transferRef), zap.Any("state", payment.State))
return gsresponse.Success(&orchestratorv1.ProcessTransferUpdateResponse{Payment: toProtoPayment(payment)})
}
func (h *paymentEventHandler) processDepositObserved(ctx context.Context, req *orchestratorv1.ProcessDepositObservedRequest) gsresponse.Responder[orchestratorv1.ProcessDepositObservedResponse] {
if err := h.ensureRepo(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.ProcessDepositObservedResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req == nil || req.GetEvent() == nil {
return gsresponse.InvalidArgument[orchestratorv1.ProcessDepositObservedResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("deposit event is required"))
}
event := req.GetEvent()
walletRef := strings.TrimSpace(event.GetWalletRef())
if walletRef == "" {
return gsresponse.InvalidArgument[orchestratorv1.ProcessDepositObservedResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("wallet_ref is required"))
}
store := h.repo.Payments()
if store == nil {
return gsresponse.Unavailable[orchestratorv1.ProcessDepositObservedResponse](h.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
}
filter := &model.PaymentFilter{
States: []model.PaymentState{model.PaymentStateSubmitted, model.PaymentStateFundsReserved},
DestinationRef: walletRef,
}
result, err := store.List(ctx, filter)
if err != nil {
return gsresponse.Auto[orchestratorv1.ProcessDepositObservedResponse](h.logger, mservice.PaymentOrchestrator, err)
}
for _, payment := range result.Items {
if payment.Intent.Destination.Type != model.EndpointTypeManagedWallet {
continue
}
if !moneyEquals(payment.Intent.Amount, event.GetAmount()) {
continue
}
payment.State = model.PaymentStateSettled
payment.FailureCode = model.PaymentFailureCodeUnspecified
payment.FailureReason = ""
if payment.Execution == nil {
payment.Execution = &model.ExecutionRefs{}
}
if payment.Execution.ChainTransferRef == "" {
payment.Execution.ChainTransferRef = strings.TrimSpace(event.GetTransactionHash())
}
if err := store.Update(ctx, payment); err != nil {
return gsresponse.Auto[orchestratorv1.ProcessDepositObservedResponse](h.logger, mservice.PaymentOrchestrator, err)
}
h.logger.Info("deposit observed matched payment", zap.String("payment_ref", payment.PaymentRef), zap.String("wallet_ref", walletRef))
return gsresponse.Success(&orchestratorv1.ProcessDepositObservedResponse{Payment: toProtoPayment(payment)})
}
return gsresponse.Success(&orchestratorv1.ProcessDepositObservedResponse{})
}
func (h *paymentEventHandler) processCardPayoutUpdate(ctx context.Context, req *orchestratorv1.ProcessCardPayoutUpdateRequest) gsresponse.Responder[orchestratorv1.ProcessCardPayoutUpdateResponse] {
if err := h.ensureRepo(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.ProcessCardPayoutUpdateResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req == nil || req.GetEvent() == nil || req.GetEvent().GetPayout() == nil {
return gsresponse.InvalidArgument[orchestratorv1.ProcessCardPayoutUpdateResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("event is required"))
}
payout := req.GetEvent().GetPayout()
paymentRef := strings.TrimSpace(payout.GetPayoutId())
if paymentRef == "" {
return gsresponse.InvalidArgument[orchestratorv1.ProcessCardPayoutUpdateResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("payout_id is required"))
}
store := h.repo.Payments()
if store == nil {
return gsresponse.Unavailable[orchestratorv1.ProcessCardPayoutUpdateResponse](h.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
}
payment, err := store.GetByPaymentRef(ctx, paymentRef)
if err != nil {
return paymentNotFoundResponder[orchestratorv1.ProcessCardPayoutUpdateResponse](mservice.PaymentOrchestrator, h.logger, err)
}
applyCardPayoutUpdate(payment, payout)
if err := store.Update(ctx, payment); err != nil {
return gsresponse.Auto[orchestratorv1.ProcessCardPayoutUpdateResponse](h.logger, mservice.PaymentOrchestrator, err)
}
h.logger.Info("card payout update applied", zap.String("payment_ref", payment.PaymentRef), zap.String("payout_id", paymentRef), zap.Any("state", payment.State))
return gsresponse.Success(&orchestratorv1.ProcessCardPayoutUpdateResponse{
Payment: toProtoPayment(payment),
})
}

View File

@@ -1,80 +0,0 @@
package orchestrator
import (
"context"
"github.com/tech/sendico/payments/orchestrator/storage"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
"go.uber.org/zap"
)
type paymentQueryHandler struct {
repo storage.Repository
ensureRepo func(ctx context.Context) error
logger mlogger.Logger
}
func newPaymentQueryHandler(repo storage.Repository, ensure func(ctx context.Context) error, logger mlogger.Logger) *paymentQueryHandler {
return &paymentQueryHandler{
repo: repo,
ensureRepo: ensure,
logger: logger,
}
}
func (h *paymentQueryHandler) getPayment(ctx context.Context, req *orchestratorv1.GetPaymentRequest) gsresponse.Responder[orchestratorv1.GetPaymentResponse] {
if err := h.ensureRepo(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.GetPaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.GetPaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
paymentRef, err := requirePaymentRef(req.GetPaymentRef())
if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.GetPaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
store, err := ensurePaymentsStore(h.repo)
if err != nil {
return gsresponse.Unavailable[orchestratorv1.GetPaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
entity, err := store.GetByPaymentRef(ctx, paymentRef)
if err != nil {
return paymentNotFoundResponder[orchestratorv1.GetPaymentResponse](mservice.PaymentOrchestrator, h.logger, err)
}
h.logger.Debug("payment fetched", zap.String("payment_ref", paymentRef))
return gsresponse.Success(&orchestratorv1.GetPaymentResponse{Payment: toProtoPayment(entity)})
}
func (h *paymentQueryHandler) listPayments(ctx context.Context, req *orchestratorv1.ListPaymentsRequest) gsresponse.Responder[orchestratorv1.ListPaymentsResponse] {
if err := h.ensureRepo(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.ListPaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.ListPaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
store, err := ensurePaymentsStore(h.repo)
if err != nil {
return gsresponse.Unavailable[orchestratorv1.ListPaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
filter := filterFromProto(req)
result, err := store.List(ctx, filter)
if err != nil {
return gsresponse.Auto[orchestratorv1.ListPaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
resp := &orchestratorv1.ListPaymentsResponse{
Page: &paginationv1.CursorPageResponse{
NextCursor: result.NextCursor,
},
}
resp.Payments = make([]*orchestratorv1.Payment, 0, len(result.Items))
for _, item := range result.Items {
resp.Payments = append(resp.Payments, toProtoPayment(item))
}
h.logger.Debug("payments listed", zap.Int("count", len(resp.Payments)), zap.String("next_cursor", resp.GetPage().GetNextCursor()))
return gsresponse.Success(resp)
}

View File

@@ -145,7 +145,7 @@ func resolveTradeAmounts(intentAmount *moneyv1.Money, fxQuote *oraclev1.Quote, s
} }
} }
func computeAggregates(pay, settlement, fee *moneyv1.Money, network *chainv1.EstimateTransferFeeResponse, fxQuote *oraclev1.Quote, mode orchestratorv1.SettlementMode) (*moneyv1.Money, *moneyv1.Money) { func computeAggregates(pay, settlement, fee *moneyv1.Money, network *chainv1.EstimateTransferFeeResponse, fxQuote *oraclev1.Quote) (*moneyv1.Money, *moneyv1.Money) {
if pay == nil { if pay == nil {
return nil, nil return nil, nil
} }
@@ -166,7 +166,7 @@ func computeAggregates(pay, settlement, fee *moneyv1.Money, network *chainv1.Est
} }
} }
applyChargeToDebit := func(m *moneyv1.Money) { adjustDebit := func(m *moneyv1.Money) {
converted, err := ensureCurrency(m, pay.GetCurrency(), fxQuote) converted, err := ensureCurrency(m, pay.GetCurrency(), fxQuote)
if err != nil || converted == nil { if err != nil || converted == nil {
return return
@@ -176,7 +176,7 @@ func computeAggregates(pay, settlement, fee *moneyv1.Money, network *chainv1.Est
} }
} }
applyChargeToSettlement := func(m *moneyv1.Money) { adjustSettlement := func(m *moneyv1.Money) {
converted, err := ensureCurrency(m, settlementCurrency, fxQuote) converted, err := ensureCurrency(m, settlementCurrency, fxQuote)
if err != nil || converted == nil { if err != nil || converted == nil {
return return
@@ -186,22 +186,12 @@ func computeAggregates(pay, settlement, fee *moneyv1.Money, network *chainv1.Est
} }
} }
switch mode { adjustDebit(fee)
case orchestratorv1.SettlementMode_SETTLEMENT_MODE_FIX_RECEIVED: adjustSettlement(fee)
// Sender pays the fee: keep settlement fixed, increase debit.
applyChargeToDebit(fee)
default:
// Recipient pays the fee (default): reduce settlement, keep debit fixed.
applyChargeToSettlement(fee)
}
if network != nil && network.GetNetworkFee() != nil { if network != nil && network.GetNetworkFee() != nil {
switch mode { adjustDebit(network.GetNetworkFee())
case orchestratorv1.SettlementMode_SETTLEMENT_MODE_FIX_RECEIVED: adjustSettlement(network.GetNetworkFee())
applyChargeToDebit(network.GetNetworkFee())
default:
applyChargeToSettlement(network.GetNetworkFee())
}
} }
return makeMoney(pay.GetCurrency(), debitDecimal), makeMoney(settlementCurrency, settlementDecimal) return makeMoney(pay.GetCurrency(), debitDecimal), makeMoney(settlementCurrency, settlementDecimal)
@@ -214,6 +204,20 @@ func decimalFromMoney(m *moneyv1.Money) (decimal.Decimal, error) {
return decimal.NewFromString(m.GetAmount()) return decimal.NewFromString(m.GetAmount())
} }
func decimalFromMoneyMatching(reference, candidate *moneyv1.Money) (*decimal.Decimal, error) {
if reference == nil || candidate == nil {
return nil, nil
}
if !strings.EqualFold(reference.GetCurrency(), candidate.GetCurrency()) {
return nil, nil
}
value, err := decimal.NewFromString(candidate.GetAmount())
if err != nil {
return nil, err
}
return &value, nil
}
func makeMoney(currency string, value decimal.Decimal) *moneyv1.Money { func makeMoney(currency string, value decimal.Decimal) *moneyv1.Money {
return &moneyv1.Money{ return &moneyv1.Money{
Currency: currency, Currency: currency,
@@ -379,22 +383,6 @@ func feeBreakdownFromQuote(quote *orchestratorv1.PaymentQuote) []*chainv1.Servic
return breakdown 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 { func moneyEquals(a, b *moneyv1.Money) bool {
if a == nil || b == nil { if a == nil || b == nil {
return false return false

View File

@@ -7,7 +7,6 @@ import (
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
) )
func TestResolveTradeAmountsBuyBase(t *testing.T) { func TestResolveTradeAmountsBuyBase(t *testing.T) {
@@ -48,32 +47,11 @@ func TestComputeAggregatesConvertsCurrencies(t *testing.T) {
}, },
} }
debit, settlement := computeAggregates(pay, settle, fee, network, fxQuote, orchestratorv1.SettlementMode_SETTLEMENT_MODE_FIX_RECEIVED) debit, settlement := computeAggregates(pay, settle, fee, network, fxQuote)
if debit.GetCurrency() != "USD" || debit.GetAmount() != "115" { if debit.GetCurrency() != "USD" || debit.GetAmount() != "115" {
t.Fatalf("expected debit 115 USD, got %s %s", debit.GetCurrency(), debit.GetAmount()) t.Fatalf("expected debit 115 USD, got %s %s", debit.GetCurrency(), debit.GetAmount())
} }
if settlement.GetCurrency() != "EUR" || settlement.GetAmount() != "50" { if settlement.GetCurrency() != "EUR" || settlement.GetAmount() != "42.5" {
t.Fatalf("expected settlement 50 EUR, got %s %s", settlement.GetCurrency(), settlement.GetAmount()) t.Fatalf("expected settlement 42.5 EUR, got %s %s", settlement.GetCurrency(), settlement.GetAmount())
}
}
func TestComputeAggregatesRecipientPaysFee(t *testing.T) {
pay := &moneyv1.Money{Currency: "USDT", Amount: "100"}
settle := &moneyv1.Money{Currency: "RUB", Amount: "7932"} // 100 * 79.32
fee := &moneyv1.Money{Currency: "USDT", Amount: "7"} // 7% of 100
fxQuote := &oraclev1.Quote{
Pair: &fxv1.CurrencyPair{Base: "USDT", Quote: "RUB"},
Side: fxv1.Side_SELL_BASE_BUY_QUOTE,
Price: &moneyv1.Decimal{
Value: "79.32",
},
}
debit, settlement := computeAggregates(pay, settle, fee, nil, fxQuote, orchestratorv1.SettlementMode_SETTLEMENT_MODE_FIX_SOURCE)
if debit.GetCurrency() != "USDT" || debit.GetAmount() != "100" {
t.Fatalf("expected debit 100 USDT, got %s %s", debit.GetCurrency(), debit.GetAmount())
}
if settlement.GetCurrency() != "RUB" || settlement.GetAmount() != "7376.76" {
t.Fatalf("expected settlement 7376.76 RUB, got %s %s", settlement.GetCurrency(), settlement.GetAmount())
} }
} }

View File

@@ -4,11 +4,9 @@ import (
"context" "context"
"time" "time"
"github.com/tech/sendico/payments/orchestrator/storage/model"
"github.com/tech/sendico/pkg/api/routers/gsresponse" "github.com/tech/sendico/pkg/api/routers/gsresponse"
"github.com/tech/sendico/pkg/mservice" "github.com/tech/sendico/pkg/mservice"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
) )
@@ -53,17 +51,10 @@ func shouldEstimateNetworkFee(intent *orchestratorv1.PaymentIntent) bool {
if intent == nil { if intent == nil {
return false return false
} }
dest := intent.GetDestination()
if dest == nil {
return false
}
if dest.GetCard() != nil {
return false
}
if intent.GetKind() == orchestratorv1.PaymentKind_PAYMENT_KIND_PAYOUT { if intent.GetKind() == orchestratorv1.PaymentKind_PAYMENT_KIND_PAYOUT {
return true return true
} }
if dest.GetManagedWallet() != nil || dest.GetExternalChain() != nil { if intent.GetDestination().GetManagedWallet() != nil || intent.GetDestination().GetExternalChain() != nil {
return true return true
} }
return false return false
@@ -78,16 +69,3 @@ func shouldRequestFX(intent *orchestratorv1.PaymentIntent) bool {
} }
return intent.GetFx() != nil && intent.GetFx().GetPair() != nil return intent.GetFx() != nil && intent.GetFx().GetPair() != nil
} }
func mapMntxStatusToState(status mntxv1.PayoutStatus) model.PaymentState {
switch status {
case mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED:
return model.PaymentStateSettled
case mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED:
return model.PaymentStateFailed
case mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING:
return model.PaymentStateSubmitted
default:
return model.PaymentStateUnspecified
}
}

View File

@@ -1,51 +0,0 @@
package orchestrator
import (
"testing"
"github.com/tech/sendico/payments/orchestrator/storage/model"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
)
func TestShouldEstimateNetworkFeeSkipsCard(t *testing.T) {
intent := &orchestratorv1.PaymentIntent{
Kind: orchestratorv1.PaymentKind_PAYMENT_KIND_PAYOUT,
Destination: &orchestratorv1.PaymentEndpoint{
Endpoint: &orchestratorv1.PaymentEndpoint_Card{
Card: &orchestratorv1.CardEndpoint{},
},
},
}
if shouldEstimateNetworkFee(intent) {
t.Fatalf("expected network fee estimation to be skipped for card payouts")
}
}
func TestShouldEstimateNetworkFeeManagedWallet(t *testing.T) {
intent := &orchestratorv1.PaymentIntent{
Destination: &orchestratorv1.PaymentEndpoint{
Endpoint: &orchestratorv1.PaymentEndpoint_ManagedWallet{
ManagedWallet: &orchestratorv1.ManagedWalletEndpoint{ManagedWalletRef: "mw"},
},
},
}
if !shouldEstimateNetworkFee(intent) {
t.Fatalf("expected network fee estimation when destination is managed wallet")
}
}
func TestMapMntxStatusToState(t *testing.T) {
if mapMntxStatusToState(mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED) != model.PaymentStateSettled {
t.Fatalf("processed should map to settled")
}
if mapMntxStatusToState(mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED) != model.PaymentStateFailed {
t.Fatalf("failed should map to failed")
}
if mapMntxStatusToState(mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING) != model.PaymentStateSubmitted {
t.Fatalf("pending should map to submitted")
}
if mapMntxStatusToState(mntxv1.PayoutStatus_PAYOUT_STATUS_UNSPECIFIED) != model.PaymentStateUnspecified {
t.Fatalf("unspecified should map to unspecified")
}
}

View File

@@ -1,12 +1,10 @@
package orchestrator package orchestrator
import ( import (
"strings"
"time" "time"
oracleclient "github.com/tech/sendico/fx/oracle/client" oracleclient "github.com/tech/sendico/fx/oracle/client"
chainclient "github.com/tech/sendico/gateway/chain/client" chainclient "github.com/tech/sendico/gateway/chain/client"
mntxclient "github.com/tech/sendico/gateway/mntx/client"
ledgerclient "github.com/tech/sendico/ledger/client" ledgerclient "github.com/tech/sendico/ledger/client"
clockpkg "github.com/tech/sendico/pkg/clock" clockpkg "github.com/tech/sendico/pkg/clock"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
@@ -48,24 +46,10 @@ func (o oracleDependency) available() bool {
return o.client != nil return o.client != nil
} }
type mntxDependency struct {
client mntxclient.Client
}
func (m mntxDependency) available() bool {
return m.client != nil
}
// CardGatewayRoute maps a gateway to its funding and fee destinations (addresses).
type CardGatewayRoute struct {
FundingAddress string
FeeAddress string
}
// WithFeeEngine wires the fee engine client. // WithFeeEngine wires the fee engine client.
func WithFeeEngine(client feesv1.FeeEngineClient, timeout time.Duration) Option { func WithFeeEngine(client feesv1.FeeEngineClient, timeout time.Duration) Option {
return func(s *Service) { return func(s *Service) {
s.deps.fees = feesDependency{ s.fees = feesDependency{
client: client, client: client,
timeout: timeout, timeout: timeout,
} }
@@ -75,59 +59,21 @@ func WithFeeEngine(client feesv1.FeeEngineClient, timeout time.Duration) Option
// WithLedgerClient wires the ledger client. // WithLedgerClient wires the ledger client.
func WithLedgerClient(client ledgerclient.Client) Option { func WithLedgerClient(client ledgerclient.Client) Option {
return func(s *Service) { return func(s *Service) {
s.deps.ledger = ledgerDependency{client: client} s.ledger = ledgerDependency{client: client}
} }
} }
// WithChainGatewayClient wires the chain gateway client. // WithChainGatewayClient wires the chain gateway client.
func WithChainGatewayClient(client chainclient.Client) Option { func WithChainGatewayClient(client chainclient.Client) Option {
return func(s *Service) { return func(s *Service) {
s.deps.gateway = gatewayDependency{client: client} s.gateway = gatewayDependency{client: client}
} }
} }
// WithOracleClient wires the FX oracle client. // WithOracleClient wires the FX oracle client.
func WithOracleClient(client oracleclient.Client) Option { func WithOracleClient(client oracleclient.Client) Option {
return func(s *Service) { return func(s *Service) {
s.deps.oracle = oracleDependency{client: client} s.oracle = oracleDependency{client: client}
}
}
// WithMntxGateway wires the Monetix gateway client.
func WithMntxGateway(client mntxclient.Client) Option {
return func(s *Service) {
s.deps.mntx = mntxDependency{client: client}
}
}
// WithCardGatewayRoutes configures funding/fee wallet routing per gateway.
func WithCardGatewayRoutes(routes map[string]CardGatewayRoute) Option {
return func(s *Service) {
if len(routes) == 0 {
return
}
s.deps.cardRoutes = make(map[string]CardGatewayRoute, len(routes))
for k, v := range routes {
s.deps.cardRoutes[strings.ToLower(strings.TrimSpace(k))] = v
}
}
}
// 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
}
} }
} }

View File

@@ -1,145 +0,0 @@
package orchestrator
import (
"fmt"
"sort"
"strings"
"time"
"github.com/shopspring/decimal"
"github.com/tech/sendico/payments/orchestrator/storage/model"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
)
func perIntentIdempotencyKey(base string, index int, total int) string {
base = strings.TrimSpace(base)
if base == "" {
return ""
}
if total <= 1 {
return base
}
return fmt.Sprintf("%s:%d", base, index+1)
}
func minQuoteExpiry(expires []time.Time) (time.Time, bool) {
var min time.Time
for _, exp := range expires {
if exp.IsZero() {
continue
}
if min.IsZero() || exp.Before(min) {
min = exp
}
}
if min.IsZero() {
return time.Time{}, false
}
return min, true
}
func aggregatePaymentQuotes(quotes []*orchestratorv1.PaymentQuote) (*orchestratorv1.PaymentQuoteAggregate, error) {
if len(quotes) == 0 {
return nil, nil
}
debitTotals := map[string]decimal.Decimal{}
settlementTotals := map[string]decimal.Decimal{}
feeTotals := map[string]decimal.Decimal{}
networkTotals := map[string]decimal.Decimal{}
for _, quote := range quotes {
if quote == nil {
continue
}
if err := accumulateMoney(debitTotals, quote.GetDebitAmount()); err != nil {
return nil, err
}
if err := accumulateMoney(settlementTotals, quote.GetExpectedSettlementAmount()); err != nil {
return nil, err
}
if err := accumulateMoney(feeTotals, quote.GetExpectedFeeTotal()); err != nil {
return nil, err
}
if nf := quote.GetNetworkFee(); nf != nil {
if err := accumulateMoney(networkTotals, nf.GetNetworkFee()); err != nil {
return nil, err
}
}
}
return &orchestratorv1.PaymentQuoteAggregate{
DebitAmounts: totalsToMoney(debitTotals),
ExpectedSettlementAmounts: totalsToMoney(settlementTotals),
ExpectedFeeTotals: totalsToMoney(feeTotals),
NetworkFeeTotals: totalsToMoney(networkTotals),
}, nil
}
func accumulateMoney(totals map[string]decimal.Decimal, money *moneyv1.Money) error {
if money == nil {
return nil
}
currency := strings.TrimSpace(money.GetCurrency())
if currency == "" {
return nil
}
amount, err := decimal.NewFromString(money.GetAmount())
if err != nil {
return err
}
if current, ok := totals[currency]; ok {
totals[currency] = current.Add(amount)
return nil
}
totals[currency] = amount
return nil
}
func totalsToMoney(totals map[string]decimal.Decimal) []*moneyv1.Money {
if len(totals) == 0 {
return nil
}
currencies := make([]string, 0, len(totals))
for currency := range totals {
currencies = append(currencies, currency)
}
sort.Strings(currencies)
result := make([]*moneyv1.Money, 0, len(currencies))
for _, currency := range currencies {
amount := totals[currency]
result = append(result, &moneyv1.Money{
Amount: amount.String(),
Currency: currency,
})
}
return result
}
func intentsFromProto(intents []*orchestratorv1.PaymentIntent) []model.PaymentIntent {
if len(intents) == 0 {
return nil
}
result := make([]model.PaymentIntent, 0, len(intents))
for _, intent := range intents {
result = append(result, intentFromProto(intent))
}
return result
}
func quoteSnapshotsFromProto(quotes []*orchestratorv1.PaymentQuote) []*model.PaymentQuoteSnapshot {
if len(quotes) == 0 {
return nil
}
result := make([]*model.PaymentQuoteSnapshot, 0, len(quotes))
for _, quote := range quotes {
if quote == nil {
continue
}
if snapshot := quoteSnapshotToModel(quote); snapshot != nil {
result = append(result, snapshot)
}
}
return result
}

View File

@@ -1,102 +0,0 @@
package orchestrator
import (
"testing"
"time"
"github.com/shopspring/decimal"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
)
func TestAggregatePaymentQuotes(t *testing.T) {
quotes := []*orchestratorv1.PaymentQuote{
{
DebitAmount: &moneyv1.Money{Amount: "10", Currency: "USD"},
ExpectedSettlementAmount: &moneyv1.Money{Amount: "8", Currency: "EUR"},
ExpectedFeeTotal: &moneyv1.Money{Amount: "1", Currency: "USD"},
NetworkFee: &chainv1.EstimateTransferFeeResponse{
NetworkFee: &moneyv1.Money{Amount: "0.5", Currency: "USD"},
},
},
{
DebitAmount: &moneyv1.Money{Amount: "5", Currency: "USD"},
ExpectedSettlementAmount: &moneyv1.Money{Amount: "1000", Currency: "NGN"},
ExpectedFeeTotal: &moneyv1.Money{Amount: "2", Currency: "USD"},
NetworkFee: &chainv1.EstimateTransferFeeResponse{
NetworkFee: &moneyv1.Money{Amount: "100", Currency: "NGN"},
},
},
}
agg, err := aggregatePaymentQuotes(quotes)
if err != nil {
t.Fatalf("aggregatePaymentQuotes returned error: %v", err)
}
assertMoneyTotals(t, agg.GetDebitAmounts(), map[string]string{"USD": "15"})
assertMoneyTotals(t, agg.GetExpectedSettlementAmounts(), map[string]string{"EUR": "8", "NGN": "1000"})
assertMoneyTotals(t, agg.GetExpectedFeeTotals(), map[string]string{"USD": "3"})
assertMoneyTotals(t, agg.GetNetworkFeeTotals(), map[string]string{"USD": "0.5", "NGN": "100"})
}
func TestAggregatePaymentQuotesInvalidAmount(t *testing.T) {
quotes := []*orchestratorv1.PaymentQuote{
{
DebitAmount: &moneyv1.Money{Amount: "bad", Currency: "USD"},
},
}
if _, err := aggregatePaymentQuotes(quotes); err == nil {
t.Fatal("expected error for invalid amount")
}
}
func TestMinQuoteExpiry(t *testing.T) {
now := time.Now().UTC()
later := now.Add(10 * time.Minute)
earliest := now.Add(5 * time.Minute)
min, ok := minQuoteExpiry([]time.Time{later, {}, earliest})
if !ok {
t.Fatal("expected min expiry to be set")
}
if !min.Equal(earliest) {
t.Fatalf("expected min expiry %v, got %v", earliest, min)
}
if _, ok := minQuoteExpiry([]time.Time{{}}); ok {
t.Fatal("expected min expiry to be unset")
}
}
func assertMoneyTotals(t *testing.T, list []*moneyv1.Money, expected map[string]string) {
t.Helper()
got := make(map[string]decimal.Decimal, len(list))
for _, item := range list {
if item == nil {
continue
}
val, err := decimal.NewFromString(item.GetAmount())
if err != nil {
t.Fatalf("invalid money amount %q: %v", item.GetAmount(), err)
}
got[item.GetCurrency()] = val
}
if len(got) != len(expected) {
t.Fatalf("expected %d totals, got %d", len(expected), len(got))
}
for currency, amount := range expected {
val, err := decimal.NewFromString(amount)
if err != nil {
t.Fatalf("invalid expected amount %q: %v", amount, err)
}
gotVal, ok := got[currency]
if !ok {
t.Fatalf("missing currency %s", currency)
}
if !gotVal.Equal(val) {
t.Fatalf("expected %s %s, got %s", amount, currency, gotVal.String())
}
}
}

View File

@@ -1,261 +0,0 @@
package orchestrator
import (
"context"
"strings"
"time"
oracleclient "github.com/tech/sendico/fx/oracle/client"
"github.com/tech/sendico/pkg/merrors"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
"go.uber.org/zap"
"google.golang.org/protobuf/types/known/timestamppb"
)
func (s *Service) buildPaymentQuote(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.PaymentQuote, time.Time, error) {
intent := req.GetIntent()
amount := intent.GetAmount()
fxSide := fxv1.Side_SIDE_UNSPECIFIED
if intent.GetFx() != nil {
fxSide = intent.GetFx().GetSide()
}
var fxQuote *oraclev1.Quote
var err error
if shouldRequestFX(intent) {
fxQuote, err = s.requestFXQuote(ctx, orgRef, req)
if err != nil {
return nil, time.Time{}, err
}
s.logger.Debug("fx quote attached to payment quote", zap.String("org_ref", orgRef))
}
payAmount, settlementAmountBeforeFees := resolveTradeAmounts(amount, fxQuote, fxSide)
feeBaseAmount := payAmount
if feeBaseAmount == nil {
feeBaseAmount = cloneMoney(amount)
}
feeQuote, err := s.quoteFees(ctx, orgRef, req, feeBaseAmount)
if err != nil {
return nil, time.Time{}, err
}
feeCurrency := ""
if feeBaseAmount != nil {
feeCurrency = feeBaseAmount.GetCurrency()
} else if amount != nil {
feeCurrency = amount.GetCurrency()
}
feeLines := cloneFeeLines(feeQuote.GetLines())
s.assignFeeLedgerAccounts(intent, feeLines)
feeTotal := extractFeeTotal(feeLines, feeCurrency)
var networkFee *chainv1.EstimateTransferFeeResponse
if shouldEstimateNetworkFee(intent) {
networkFee, err = s.estimateNetworkFee(ctx, intent)
if err != nil {
return nil, time.Time{}, err
}
s.logger.Debug("network fee estimated", zap.String("org_ref", orgRef))
}
debitAmount, settlementAmount := computeAggregates(payAmount, settlementAmountBeforeFees, feeTotal, networkFee, fxQuote, intent.GetSettlementMode())
quote := &orchestratorv1.PaymentQuote{
DebitAmount: debitAmount,
ExpectedSettlementAmount: settlementAmount,
ExpectedFeeTotal: feeTotal,
FeeLines: feeLines,
FeeRules: cloneFeeRules(feeQuote.GetApplied()),
FxQuote: fxQuote,
NetworkFee: networkFee,
}
expiresAt := quoteExpiry(s.clock.Now(), feeQuote, fxQuote)
return quote, expiresAt, nil
}
func (s *Service) quoteFees(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest, baseAmount *moneyv1.Money) (*feesv1.PrecomputeFeesResponse, error) {
if !s.deps.fees.available() {
return &feesv1.PrecomputeFeesResponse{}, nil
}
intent := req.GetIntent()
amount := cloneMoney(baseAmount)
if amount == nil {
amount = cloneMoney(intent.GetAmount())
}
feeIntent := &feesv1.Intent{
Trigger: triggerFromKind(intent.GetKind(), intent.GetRequiresFx()),
BaseAmount: amount,
BookedAt: timestamppb.New(s.clock.Now()),
OriginType: "payments.orchestrator.quote",
OriginRef: strings.TrimSpace(req.GetIdempotencyKey()),
Attributes: cloneMetadata(intent.GetAttributes()),
}
timeout := req.GetMeta().GetTrace()
ctxTimeout, cancel := s.withTimeout(ctx, s.deps.fees.timeout)
defer cancel()
resp, err := s.deps.fees.client.PrecomputeFees(ctxTimeout, &feesv1.PrecomputeFeesRequest{
Meta: &feesv1.RequestMeta{
OrganizationRef: orgRef,
Trace: timeout,
},
Intent: feeIntent,
TtlMs: defaultFeeQuoteTTLMillis,
})
if err != nil {
s.logger.Warn("fees precompute failed", zap.Error(err))
return nil, merrors.Internal("fees_precompute_failed")
}
return resp, nil
}
func (s *Service) estimateNetworkFee(ctx context.Context, intent *orchestratorv1.PaymentIntent) (*chainv1.EstimateTransferFeeResponse, error) {
if !s.deps.gateway.available() {
return nil, nil
}
req := &chainv1.EstimateTransferFeeRequest{
Amount: cloneMoney(intent.GetAmount()),
}
if src := intent.GetSource().GetManagedWallet(); src != nil {
req.SourceWalletRef = strings.TrimSpace(src.GetManagedWalletRef())
}
if dst := intent.GetDestination().GetManagedWallet(); dst != nil {
req.Destination = &chainv1.TransferDestination{
Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: strings.TrimSpace(dst.GetManagedWalletRef())},
}
}
if dst := intent.GetDestination().GetExternalChain(); dst != nil {
req.Destination = &chainv1.TransferDestination{
Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: strings.TrimSpace(dst.GetAddress())},
Memo: strings.TrimSpace(dst.GetMemo()),
}
req.Asset = dst.GetAsset()
}
if req.Asset == nil {
if src := intent.GetSource().GetManagedWallet(); src != nil {
req.Asset = src.GetAsset()
}
}
resp, err := s.deps.gateway.client.EstimateTransferFee(ctx, req)
if err != nil {
s.logger.Warn("chain gateway fee estimation failed", zap.Error(err))
return nil, merrors.Internal("chain_gateway_fee_estimation_failed")
}
return resp, nil
}
func (s *Service) requestFXQuote(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest) (*oraclev1.Quote, error) {
if !s.deps.oracle.available() {
return nil, nil
}
intent := req.GetIntent()
meta := req.GetMeta()
fxIntent := intent.GetFx()
if fxIntent == nil {
return nil, nil
}
ttl := fxIntent.GetTtlMs()
if ttl <= 0 {
ttl = defaultOracleTTLMillis
}
params := oracleclient.GetQuoteParams{
Meta: oracleclient.RequestMeta{
OrganizationRef: orgRef,
Trace: meta.GetTrace(),
},
Pair: fxIntent.GetPair(),
Side: fxIntent.GetSide(),
Firm: fxIntent.GetFirm(),
TTL: time.Duration(ttl) * time.Millisecond,
PreferredProvider: strings.TrimSpace(fxIntent.GetPreferredProvider()),
}
if fxIntent.GetMaxAgeMs() > 0 {
params.MaxAge = time.Duration(fxIntent.GetMaxAgeMs()) * time.Millisecond
}
if amount := intent.GetAmount(); amount != nil {
pair := fxIntent.GetPair()
if pair != nil {
switch {
case strings.EqualFold(amount.GetCurrency(), pair.GetBase()):
params.BaseAmount = cloneMoney(amount)
case strings.EqualFold(amount.GetCurrency(), pair.GetQuote()):
params.QuoteAmount = cloneMoney(amount)
default:
params.BaseAmount = cloneMoney(amount)
}
} else {
params.BaseAmount = cloneMoney(amount)
}
}
quote, err := s.deps.oracle.client.GetQuote(ctx, params)
if err != nil {
s.logger.Warn("fx oracle quote failed", zap.Error(err))
return nil, merrors.Internal("fx_quote_failed")
}
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

@@ -19,21 +19,19 @@ func TestRequestFXQuoteUsesQuoteAmountWhenCurrencyMatchesQuote(t *testing.T) {
svc := &Service{ svc := &Service{
logger: zap.NewNop(), logger: zap.NewNop(),
clock: testClock{now: time.Now()}, clock: testClock{now: time.Now()},
deps: serviceDependencies{ oracle: oracleDependency{
oracle: oracleDependency{ client: &oracleclient.Fake{
client: &oracleclient.Fake{ GetQuoteFn: func(ctx context.Context, params oracleclient.GetQuoteParams) (*oracleclient.Quote, error) {
GetQuoteFn: func(ctx context.Context, params oracleclient.GetQuoteParams) (*oracleclient.Quote, error) { captured = params
captured = params return &oracleclient.Quote{
return &oracleclient.Quote{ QuoteRef: "q",
QuoteRef: "q", Pair: params.Pair,
Pair: params.Pair, Side: params.Side,
Side: params.Side, Price: "1.1",
Price: "1.1", BaseAmount: params.BaseAmount,
BaseAmount: params.BaseAmount, QuoteAmount: params.QuoteAmount,
QuoteAmount: params.QuoteAmount, ExpiresAt: time.Now(),
ExpiresAt: time.Now(), }, nil
}, nil
},
}, },
}, },
}, },

View File

@@ -2,14 +2,21 @@ package orchestrator
import ( import (
"context" "context"
"strings"
"github.com/tech/sendico/payments/orchestrator/storage" "github.com/tech/sendico/payments/orchestrator/storage"
"github.com/tech/sendico/payments/orchestrator/storage/model" "github.com/tech/sendico/payments/orchestrator/storage/model"
"github.com/tech/sendico/pkg/api/routers" "github.com/tech/sendico/pkg/api/routers"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
clockpkg "github.com/tech/sendico/pkg/clock" clockpkg "github.com/tech/sendico/pkg/clock"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
"go.mongodb.org/mongo-driver/bson/primitive"
"google.golang.org/grpc" "google.golang.org/grpc"
"google.golang.org/protobuf/proto"
) )
type serviceError string type serviceError string
@@ -33,33 +40,14 @@ type Service struct {
storage storage.Repository storage storage.Repository
clock clockpkg.Clock clock clockpkg.Clock
deps serviceDependencies fees feesDependency
h handlerSet ledger ledgerDependency
comp componentSet gateway gatewayDependency
oracle oracleDependency
orchestratorv1.UnimplementedPaymentOrchestratorServer orchestratorv1.UnimplementedPaymentOrchestratorServer
} }
type serviceDependencies struct {
fees feesDependency
ledger ledgerDependency
gateway gatewayDependency
oracle oracleDependency
mntx mntxDependency
cardRoutes map[string]CardGatewayRoute
feeLedgerAccounts map[string]string
}
type handlerSet struct {
commands *paymentCommandFactory
queries *paymentQueryHandler
events *paymentEventHandler
}
type componentSet struct {
executor *paymentExecutor
}
// NewService constructs a payment orchestrator service. // NewService constructs a payment orchestrator service.
func NewService(logger mlogger.Logger, repo storage.Repository, opts ...Option) *Service { func NewService(logger mlogger.Logger, repo storage.Repository, opts ...Option) *Service {
svc := &Service{ svc := &Service{
@@ -80,30 +68,9 @@ func NewService(logger mlogger.Logger, repo storage.Repository, opts ...Option)
svc.clock = clockpkg.NewSystem() svc.clock = clockpkg.NewSystem()
} }
engine := defaultPaymentEngine{svc: svc}
svc.h.commands = newPaymentCommandFactory(engine, svc.logger)
svc.h.queries = newPaymentQueryHandler(svc.storage, svc.ensureRepository, svc.logger.Named("queries"))
svc.h.events = newPaymentEventHandler(svc.storage, svc.ensureRepository, svc.logger.Named("events"))
svc.comp.executor = newPaymentExecutor(&svc.deps, svc.logger.Named("payment_executor"), svc)
return svc return svc
} }
func (s *Service) ensureHandlers() {
if s.h.commands == nil {
s.h.commands = newPaymentCommandFactory(defaultPaymentEngine{svc: s}, s.logger)
}
if s.h.queries == nil {
s.h.queries = newPaymentQueryHandler(s.storage, s.ensureRepository, s.logger.Named("queries"))
}
if s.h.events == nil {
s.h.events = newPaymentEventHandler(s.storage, s.ensureRepository, s.logger.Named("events"))
}
if s.comp.executor == nil {
s.comp.executor = newPaymentExecutor(&s.deps, s.logger.Named("payment_executor"), s)
}
}
// Register attaches the service to the supplied gRPC router. // Register attaches the service to the supplied gRPC router.
func (s *Service) Register(router routers.GRPC) error { func (s *Service) Register(router routers.GRPC) error {
return router.Register(func(reg grpc.ServiceRegistrar) { return router.Register(func(reg grpc.ServiceRegistrar) {
@@ -113,71 +80,474 @@ func (s *Service) Register(router routers.GRPC) error {
// QuotePayment aggregates downstream quotes. // QuotePayment aggregates downstream quotes.
func (s *Service) QuotePayment(ctx context.Context, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.QuotePaymentResponse, error) { func (s *Service) QuotePayment(ctx context.Context, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.QuotePaymentResponse, error) {
s.ensureHandlers() return executeUnary(ctx, s, "QuotePayment", s.quotePaymentHandler, req)
return executeUnary(ctx, s, "QuotePayment", s.h.commands.QuotePayment().Execute, req)
}
// QuotePayments aggregates downstream quotes for multiple intents.
func (s *Service) QuotePayments(ctx context.Context, req *orchestratorv1.QuotePaymentsRequest) (*orchestratorv1.QuotePaymentsResponse, error) {
s.ensureHandlers()
return executeUnary(ctx, s, "QuotePayments", s.h.commands.QuotePayments().Execute, req)
} }
// InitiatePayment captures a payment intent and reserves funds orchestration. // InitiatePayment captures a payment intent and reserves funds orchestration.
func (s *Service) InitiatePayment(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error) { func (s *Service) InitiatePayment(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error) {
s.ensureHandlers() return executeUnary(ctx, s, "InitiatePayment", s.initiatePaymentHandler, req)
return executeUnary(ctx, s, "InitiatePayment", s.h.commands.InitiatePayment().Execute, req)
}
// InitiatePayments executes multiple payments using a stored quote reference.
func (s *Service) InitiatePayments(ctx context.Context, req *orchestratorv1.InitiatePaymentsRequest) (*orchestratorv1.InitiatePaymentsResponse, error) {
s.ensureHandlers()
return executeUnary(ctx, s, "InitiatePayments", s.h.commands.InitiatePayments().Execute, req)
} }
// CancelPayment attempts to cancel an in-flight payment. // CancelPayment attempts to cancel an in-flight payment.
func (s *Service) CancelPayment(ctx context.Context, req *orchestratorv1.CancelPaymentRequest) (*orchestratorv1.CancelPaymentResponse, error) { func (s *Service) CancelPayment(ctx context.Context, req *orchestratorv1.CancelPaymentRequest) (*orchestratorv1.CancelPaymentResponse, error) {
s.ensureHandlers() return executeUnary(ctx, s, "CancelPayment", s.cancelPaymentHandler, req)
return executeUnary(ctx, s, "CancelPayment", s.h.commands.CancelPayment().Execute, req)
} }
// GetPayment returns a stored payment record. // GetPayment returns a stored payment record.
func (s *Service) GetPayment(ctx context.Context, req *orchestratorv1.GetPaymentRequest) (*orchestratorv1.GetPaymentResponse, error) { func (s *Service) GetPayment(ctx context.Context, req *orchestratorv1.GetPaymentRequest) (*orchestratorv1.GetPaymentResponse, error) {
s.ensureHandlers() return executeUnary(ctx, s, "GetPayment", s.getPaymentHandler, req)
return executeUnary(ctx, s, "GetPayment", s.h.queries.getPayment, req)
} }
// ListPayments lists stored payment records. // ListPayments lists stored payment records.
func (s *Service) ListPayments(ctx context.Context, req *orchestratorv1.ListPaymentsRequest) (*orchestratorv1.ListPaymentsResponse, error) { func (s *Service) ListPayments(ctx context.Context, req *orchestratorv1.ListPaymentsRequest) (*orchestratorv1.ListPaymentsResponse, error) {
s.ensureHandlers() return executeUnary(ctx, s, "ListPayments", s.listPaymentsHandler, req)
return executeUnary(ctx, s, "ListPayments", s.h.queries.listPayments, req)
} }
// InitiateConversion orchestrates standalone FX conversions. // InitiateConversion orchestrates standalone FX conversions.
func (s *Service) InitiateConversion(ctx context.Context, req *orchestratorv1.InitiateConversionRequest) (*orchestratorv1.InitiateConversionResponse, error) { func (s *Service) InitiateConversion(ctx context.Context, req *orchestratorv1.InitiateConversionRequest) (*orchestratorv1.InitiateConversionResponse, error) {
s.ensureHandlers() return executeUnary(ctx, s, "InitiateConversion", s.initiateConversionHandler, req)
return executeUnary(ctx, s, "InitiateConversion", s.h.commands.InitiateConversion().Execute, req)
} }
// ProcessTransferUpdate reconciles chain events back into payment state. // ProcessTransferUpdate reconciles chain events back into payment state.
func (s *Service) ProcessTransferUpdate(ctx context.Context, req *orchestratorv1.ProcessTransferUpdateRequest) (*orchestratorv1.ProcessTransferUpdateResponse, error) { func (s *Service) ProcessTransferUpdate(ctx context.Context, req *orchestratorv1.ProcessTransferUpdateRequest) (*orchestratorv1.ProcessTransferUpdateResponse, error) {
s.ensureHandlers() return executeUnary(ctx, s, "ProcessTransferUpdate", s.processTransferUpdateHandler, req)
return executeUnary(ctx, s, "ProcessTransferUpdate", s.h.events.processTransferUpdate, req)
} }
// ProcessDepositObserved reconciles deposit events to ledger. // ProcessDepositObserved reconciles deposit events to ledger.
func (s *Service) ProcessDepositObserved(ctx context.Context, req *orchestratorv1.ProcessDepositObservedRequest) (*orchestratorv1.ProcessDepositObservedResponse, error) { func (s *Service) ProcessDepositObserved(ctx context.Context, req *orchestratorv1.ProcessDepositObservedRequest) (*orchestratorv1.ProcessDepositObservedResponse, error) {
s.ensureHandlers() return executeUnary(ctx, s, "ProcessDepositObserved", s.processDepositObservedHandler, req)
return executeUnary(ctx, s, "ProcessDepositObserved", s.h.events.processDepositObserved, req)
} }
// ProcessCardPayoutUpdate reconciles card payout events back into payment state. func (s *Service) quotePaymentHandler(ctx context.Context, req *orchestratorv1.QuotePaymentRequest) gsresponse.Responder[orchestratorv1.QuotePaymentResponse] {
func (s *Service) ProcessCardPayoutUpdate(ctx context.Context, req *orchestratorv1.ProcessCardPayoutUpdateRequest) (*orchestratorv1.ProcessCardPayoutUpdateResponse, error) { if err := s.ensureRepository(ctx); err != nil {
s.ensureHandlers() return gsresponse.Unavailable[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
return executeUnary(ctx, s, "ProcessCardPayoutUpdate", s.h.events.processCardPayoutUpdate, req) }
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
meta := req.GetMeta()
if meta == nil {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("meta is required"))
}
orgRef := strings.TrimSpace(meta.GetOrganizationRef())
if orgRef == "" {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("organization_ref is required"))
}
orgObjectID, parseErr := primitive.ObjectIDFromHex(orgRef)
if parseErr != nil {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("organization_ref must be a valid objectID"))
}
intent := req.GetIntent()
if intent == nil {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("intent is required"))
}
if intent.GetAmount() == nil {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("intent.amount is required"))
}
quote, expiresAt, err := s.buildPaymentQuote(ctx, orgRef, req)
if err != nil {
return gsresponse.Auto[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
}
if !req.GetPreviewOnly() {
quotesStore := s.storage.Quotes()
if quotesStore == nil {
return gsresponse.Unavailable[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
}
quoteRef := primitive.NewObjectID().Hex()
quote.QuoteRef = quoteRef
record := &model.PaymentQuoteRecord{
QuoteRef: quoteRef,
Intent: intentFromProto(intent),
Quote: quoteSnapshotToModel(quote),
ExpiresAt: expiresAt,
}
record.SetID(primitive.NewObjectID())
record.SetOrganizationRef(orgObjectID)
if err := quotesStore.Create(ctx, record); err != nil {
return gsresponse.Auto[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
}
}
return gsresponse.Success(&orchestratorv1.QuotePaymentResponse{Quote: quote})
} }
func (s *Service) executePayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, quote *orchestratorv1.PaymentQuote) error { func (s *Service) initiatePaymentHandler(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) gsresponse.Responder[orchestratorv1.InitiatePaymentResponse] {
s.ensureHandlers() if err := s.ensureRepository(ctx); err != nil {
return s.comp.executor.executePayment(ctx, store, payment, quote) return gsresponse.Unavailable[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
}
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
meta := req.GetMeta()
if meta == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("meta is required"))
}
orgRef := strings.TrimSpace(meta.GetOrganizationRef())
if orgRef == "" {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("organization_ref is required"))
}
orgObjectID, parseErr := primitive.ObjectIDFromHex(orgRef)
if parseErr != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("organization_ref must be a valid objectID"))
}
intent := req.GetIntent()
if intent == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("intent is required"))
}
if intent.GetAmount() == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("intent.amount is required"))
}
idempotencyKey := strings.TrimSpace(req.GetIdempotencyKey())
if idempotencyKey == "" {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("idempotency_key is required"))
}
store := s.storage.Payments()
if store == nil {
return gsresponse.Unavailable[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
}
existing, err := store.GetByIdempotencyKey(ctx, orgObjectID, idempotencyKey)
if err == nil && existing != nil {
return gsresponse.Success(&orchestratorv1.InitiatePaymentResponse{
Payment: toProtoPayment(existing),
})
}
if err != nil && err != storage.ErrPaymentNotFound {
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
}
quoteRef := strings.TrimSpace(req.GetQuoteRef())
quote := strings.TrimSpace(req.GetFeeQuoteToken())
var quoteSnapshot *orchestratorv1.PaymentQuote
if quoteRef != "" {
quotesStore := s.storage.Quotes()
if quotesStore == nil {
return gsresponse.Unavailable[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
}
record, err := quotesStore.GetByRef(ctx, orgObjectID, quoteRef)
if err != nil {
if err == storage.ErrQuoteNotFound {
return gsresponse.FailedPrecondition[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, "quote_not_found", merrors.InvalidArgument("quote_ref not found or expired"))
}
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
}
if !record.ExpiresAt.IsZero() && s.clock.Now().After(record.ExpiresAt) {
return gsresponse.FailedPrecondition[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, "quote_expired", merrors.InvalidArgument("quote_ref expired"))
}
if !proto.Equal(protoIntentFromModel(record.Intent), intent) {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("quote_ref does not match intent"))
}
quoteSnapshot = modelQuoteToProto(record.Quote)
if quoteSnapshot == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("stored quote is empty"))
}
quoteSnapshot.QuoteRef = quoteRef
} else if quote == "" {
quoteSnapshot, _, err = s.buildPaymentQuote(ctx, orgRef, &orchestratorv1.QuotePaymentRequest{
Meta: req.GetMeta(),
IdempotencyKey: req.GetIdempotencyKey(),
Intent: req.GetIntent(),
PreviewOnly: false,
})
if err != nil {
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
}
} else {
quoteSnapshot = &orchestratorv1.PaymentQuote{FeeQuoteToken: quote}
}
entity := &model.Payment{}
entity.SetID(primitive.NewObjectID())
entity.SetOrganizationRef(orgObjectID)
entity.PaymentRef = entity.GetID().Hex()
entity.IdempotencyKey = idempotencyKey
entity.State = model.PaymentStateAccepted
entity.Intent = intentFromProto(intent)
entity.Metadata = cloneMetadata(req.GetMetadata())
entity.LastQuote = quoteSnapshotToModel(quoteSnapshot)
entity.Normalize()
if err = store.Create(ctx, entity); err != nil {
if err == storage.ErrDuplicatePayment {
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.DataConflict("payment already exists"))
}
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
}
if quoteSnapshot == nil {
quoteSnapshot = &orchestratorv1.PaymentQuote{}
}
if err := s.executePayment(ctx, store, entity, quoteSnapshot); err != nil {
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
}
return gsresponse.Success(&orchestratorv1.InitiatePaymentResponse{
Payment: toProtoPayment(entity),
})
}
func (s *Service) cancelPaymentHandler(ctx context.Context, req *orchestratorv1.CancelPaymentRequest) gsresponse.Responder[orchestratorv1.CancelPaymentResponse] {
if err := s.ensureRepository(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.CancelPaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
}
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.CancelPaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
paymentRef := strings.TrimSpace(req.GetPaymentRef())
if paymentRef == "" {
return gsresponse.InvalidArgument[orchestratorv1.CancelPaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("payment_ref is required"))
}
store := s.storage.Payments()
if store == nil {
return gsresponse.Unavailable[orchestratorv1.CancelPaymentResponse](s.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
}
payment, err := store.GetByPaymentRef(ctx, paymentRef)
if err != nil {
if err == storage.ErrPaymentNotFound {
return gsresponse.NotFound[orchestratorv1.CancelPaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
}
return gsresponse.Auto[orchestratorv1.CancelPaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
}
if payment.State != model.PaymentStateAccepted {
reason := merrors.InvalidArgument("payment cannot be cancelled in current state")
return gsresponse.FailedPrecondition[orchestratorv1.CancelPaymentResponse](s.logger, mservice.PaymentOrchestrator, "payment_not_cancellable", reason)
}
payment.State = model.PaymentStateCancelled
payment.FailureCode = model.PaymentFailureCodePolicy
payment.FailureReason = strings.TrimSpace(req.GetReason())
if err := store.Update(ctx, payment); err != nil {
return gsresponse.Auto[orchestratorv1.CancelPaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
}
return gsresponse.Success(&orchestratorv1.CancelPaymentResponse{Payment: toProtoPayment(payment)})
}
func (s *Service) getPaymentHandler(ctx context.Context, req *orchestratorv1.GetPaymentRequest) gsresponse.Responder[orchestratorv1.GetPaymentResponse] {
if err := s.ensureRepository(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.GetPaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
}
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.GetPaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
paymentRef := strings.TrimSpace(req.GetPaymentRef())
if paymentRef == "" {
return gsresponse.InvalidArgument[orchestratorv1.GetPaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("payment_ref is required"))
}
store := s.storage.Payments()
if store == nil {
return gsresponse.Unavailable[orchestratorv1.GetPaymentResponse](s.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
}
entity, err := store.GetByPaymentRef(ctx, paymentRef)
if err != nil {
if err == storage.ErrPaymentNotFound {
return gsresponse.NotFound[orchestratorv1.GetPaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
}
return gsresponse.Auto[orchestratorv1.GetPaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
}
return gsresponse.Success(&orchestratorv1.GetPaymentResponse{Payment: toProtoPayment(entity)})
}
func (s *Service) listPaymentsHandler(ctx context.Context, req *orchestratorv1.ListPaymentsRequest) gsresponse.Responder[orchestratorv1.ListPaymentsResponse] {
if err := s.ensureRepository(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.ListPaymentsResponse](s.logger, mservice.PaymentOrchestrator, err)
}
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.ListPaymentsResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
store := s.storage.Payments()
if store == nil {
return gsresponse.Unavailable[orchestratorv1.ListPaymentsResponse](s.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
}
filter := filterFromProto(req)
result, err := store.List(ctx, filter)
if err != nil {
return gsresponse.Auto[orchestratorv1.ListPaymentsResponse](s.logger, mservice.PaymentOrchestrator, err)
}
resp := &orchestratorv1.ListPaymentsResponse{
Page: &paginationv1.CursorPageResponse{
NextCursor: result.NextCursor,
},
}
resp.Payments = make([]*orchestratorv1.Payment, 0, len(result.Items))
for _, item := range result.Items {
resp.Payments = append(resp.Payments, toProtoPayment(item))
}
return gsresponse.Success(resp)
}
func (s *Service) initiateConversionHandler(ctx context.Context, req *orchestratorv1.InitiateConversionRequest) gsresponse.Responder[orchestratorv1.InitiateConversionResponse] {
if err := s.ensureRepository(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, err)
}
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
meta := req.GetMeta()
if meta == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("meta is required"))
}
orgRef := strings.TrimSpace(meta.GetOrganizationRef())
if orgRef == "" {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("organization_ref is required"))
}
orgObjectID, parseErr := primitive.ObjectIDFromHex(orgRef)
if parseErr != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("organization_ref must be a valid objectID"))
}
idempotencyKey := strings.TrimSpace(req.GetIdempotencyKey())
if idempotencyKey == "" {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("idempotency_key is required"))
}
if req.GetSource() == nil || req.GetSource().GetLedger() == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("source ledger endpoint is required"))
}
if req.GetDestination() == nil || req.GetDestination().GetLedger() == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("destination ledger endpoint is required"))
}
fxIntent := req.GetFx()
if fxIntent == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("fx intent is required"))
}
store := s.storage.Payments()
if store == nil {
return gsresponse.Unavailable[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
}
if existing, err := store.GetByIdempotencyKey(ctx, orgObjectID, idempotencyKey); err == nil && existing != nil {
return gsresponse.Success(&orchestratorv1.InitiateConversionResponse{Conversion: toProtoPayment(existing)})
} else if err != nil && err != storage.ErrPaymentNotFound {
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, err)
}
amount, err := conversionAmountFromMetadata(req.GetMetadata(), fxIntent)
if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, err)
}
intentProto := &orchestratorv1.PaymentIntent{
Kind: orchestratorv1.PaymentKind_PAYMENT_KIND_FX_CONVERSION,
Source: req.GetSource(),
Destination: req.GetDestination(),
Amount: amount,
RequiresFx: true,
Fx: fxIntent,
FeePolicy: req.GetFeePolicy(),
}
quote, _, err := s.buildPaymentQuote(ctx, orgRef, &orchestratorv1.QuotePaymentRequest{
Meta: req.GetMeta(),
IdempotencyKey: req.GetIdempotencyKey(),
Intent: intentProto,
})
if err != nil {
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, err)
}
entity := &model.Payment{}
entity.SetID(primitive.NewObjectID())
entity.SetOrganizationRef(orgObjectID)
entity.PaymentRef = entity.GetID().Hex()
entity.IdempotencyKey = idempotencyKey
entity.State = model.PaymentStateAccepted
entity.Intent = intentFromProto(intentProto)
entity.Metadata = cloneMetadata(req.GetMetadata())
entity.LastQuote = quoteSnapshotToModel(quote)
entity.Normalize()
if err = store.Create(ctx, entity); err != nil {
if err == storage.ErrDuplicatePayment {
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, merrors.DataConflict("payment already exists"))
}
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, err)
}
if err := s.executePayment(ctx, store, entity, quote); err != nil {
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, err)
}
return gsresponse.Success(&orchestratorv1.InitiateConversionResponse{
Conversion: toProtoPayment(entity),
})
}
func (s *Service) processTransferUpdateHandler(ctx context.Context, req *orchestratorv1.ProcessTransferUpdateRequest) gsresponse.Responder[orchestratorv1.ProcessTransferUpdateResponse] {
if err := s.ensureRepository(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.ProcessTransferUpdateResponse](s.logger, mservice.PaymentOrchestrator, err)
}
if req == nil || req.GetEvent() == nil || req.GetEvent().GetTransfer() == nil {
return gsresponse.InvalidArgument[orchestratorv1.ProcessTransferUpdateResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("transfer event is required"))
}
transfer := req.GetEvent().GetTransfer()
transferRef := strings.TrimSpace(transfer.GetTransferRef())
if transferRef == "" {
return gsresponse.InvalidArgument[orchestratorv1.ProcessTransferUpdateResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("transfer_ref is required"))
}
store := s.storage.Payments()
if store == nil {
return gsresponse.Unavailable[orchestratorv1.ProcessTransferUpdateResponse](s.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
}
payment, err := store.GetByChainTransferRef(ctx, transferRef)
if err != nil {
if err == storage.ErrPaymentNotFound {
return gsresponse.NotFound[orchestratorv1.ProcessTransferUpdateResponse](s.logger, mservice.PaymentOrchestrator, err)
}
return gsresponse.Auto[orchestratorv1.ProcessTransferUpdateResponse](s.logger, mservice.PaymentOrchestrator, err)
}
applyTransferStatus(req.GetEvent(), payment)
if err := store.Update(ctx, payment); err != nil {
return gsresponse.Auto[orchestratorv1.ProcessTransferUpdateResponse](s.logger, mservice.PaymentOrchestrator, err)
}
return gsresponse.Success(&orchestratorv1.ProcessTransferUpdateResponse{Payment: toProtoPayment(payment)})
}
func (s *Service) processDepositObservedHandler(ctx context.Context, req *orchestratorv1.ProcessDepositObservedRequest) gsresponse.Responder[orchestratorv1.ProcessDepositObservedResponse] {
if err := s.ensureRepository(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.ProcessDepositObservedResponse](s.logger, mservice.PaymentOrchestrator, err)
}
if req == nil || req.GetEvent() == nil {
return gsresponse.InvalidArgument[orchestratorv1.ProcessDepositObservedResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("deposit event is required"))
}
event := req.GetEvent()
walletRef := strings.TrimSpace(event.GetWalletRef())
if walletRef == "" {
return gsresponse.InvalidArgument[orchestratorv1.ProcessDepositObservedResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("wallet_ref is required"))
}
store := s.storage.Payments()
if store == nil {
return gsresponse.Unavailable[orchestratorv1.ProcessDepositObservedResponse](s.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
}
filter := &model.PaymentFilter{
States: []model.PaymentState{model.PaymentStateSubmitted, model.PaymentStateFundsReserved},
DestinationRef: walletRef,
}
result, err := store.List(ctx, filter)
if err != nil {
return gsresponse.Auto[orchestratorv1.ProcessDepositObservedResponse](s.logger, mservice.PaymentOrchestrator, err)
}
for _, payment := range result.Items {
if payment.Intent.Destination.Type != model.EndpointTypeManagedWallet {
continue
}
if !moneyEquals(payment.Intent.Amount, event.GetAmount()) {
continue
}
payment.State = model.PaymentStateSettled
payment.FailureCode = model.PaymentFailureCodeUnspecified
payment.FailureReason = ""
if payment.Execution == nil {
payment.Execution = &model.ExecutionRefs{}
}
if payment.Execution.ChainTransferRef == "" {
payment.Execution.ChainTransferRef = strings.TrimSpace(event.GetTransactionHash())
}
if err := store.Update(ctx, payment); err != nil {
return gsresponse.Auto[orchestratorv1.ProcessDepositObservedResponse](s.logger, mservice.PaymentOrchestrator, err)
}
return gsresponse.Success(&orchestratorv1.ProcessDepositObservedResponse{Payment: toProtoPayment(payment)})
}
return gsresponse.Success(&orchestratorv1.ProcessDepositObservedResponse{})
} }

View File

@@ -1,165 +0,0 @@
package orchestrator
import (
"context"
"errors"
"strings"
"github.com/tech/sendico/payments/orchestrator/storage"
"github.com/tech/sendico/payments/orchestrator/storage/model"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
"go.mongodb.org/mongo-driver/bson/primitive"
"google.golang.org/protobuf/proto"
)
func validateMetaAndOrgRef(meta *orchestratorv1.RequestMeta) (string, primitive.ObjectID, error) {
if meta == nil {
return "", primitive.NilObjectID, merrors.InvalidArgument("meta is required")
}
orgRef := strings.TrimSpace(meta.GetOrganizationRef())
if orgRef == "" {
return "", primitive.NilObjectID, merrors.InvalidArgument("organization_ref is required")
}
orgID, err := primitive.ObjectIDFromHex(orgRef)
if err != nil {
return "", primitive.NilObjectID, merrors.InvalidArgument("organization_ref must be a valid objectID")
}
return orgRef, orgID, nil
}
func requireIdempotencyKey(k string) (string, error) {
key := strings.TrimSpace(k)
if key == "" {
return "", merrors.InvalidArgument("idempotency_key is required")
}
return key, nil
}
func requirePaymentRef(ref string) (string, error) {
val := strings.TrimSpace(ref)
if val == "" {
return "", merrors.InvalidArgument("payment_ref is required")
}
return val, nil
}
func requireNonNilIntent(intent *orchestratorv1.PaymentIntent) error {
if intent == nil {
return merrors.InvalidArgument("intent is required")
}
if intent.GetAmount() == nil {
return merrors.InvalidArgument("intent.amount is required")
}
return nil
}
func ensurePaymentsStore(repo storage.Repository) (storage.PaymentsStore, error) {
if repo == nil {
return nil, errStorageUnavailable
}
store := repo.Payments()
if store == nil {
return nil, errStorageUnavailable
}
return store, nil
}
func ensureQuotesStore(repo storage.Repository) (storage.QuotesStore, error) {
if repo == nil {
return nil, errStorageUnavailable
}
store := repo.Quotes()
if store == nil {
return nil, errStorageUnavailable
}
return store, nil
}
func getPaymentByIdempotencyKey(ctx context.Context, store storage.PaymentsStore, orgID primitive.ObjectID, key string) (*model.Payment, error) {
payment, err := store.GetByIdempotencyKey(ctx, orgID, key)
if err != nil {
return nil, err
}
return payment, nil
}
type quoteResolutionInput struct {
OrgRef string
OrgID primitive.ObjectID
Meta *orchestratorv1.RequestMeta
Intent *orchestratorv1.PaymentIntent
QuoteRef string
IdempotencyKey string
}
type quoteResolutionError struct {
code string
err error
}
func (e quoteResolutionError) Error() string { return e.err.Error() }
func (s *Service) resolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, error) {
if ref := strings.TrimSpace(in.QuoteRef); ref != "" {
quotesStore, err := ensureQuotesStore(s.storage)
if err != nil {
return nil, err
}
record, err := quotesStore.GetByRef(ctx, in.OrgID, ref)
if err != nil {
if errors.Is(err, storage.ErrQuoteNotFound) {
return nil, quoteResolutionError{code: "quote_not_found", err: merrors.InvalidArgument("quote_ref not found or expired")}
}
return nil, err
}
if !record.ExpiresAt.IsZero() && s.clock.Now().After(record.ExpiresAt) {
return nil, quoteResolutionError{code: "quote_expired", err: merrors.InvalidArgument("quote_ref expired")}
}
if !proto.Equal(protoIntentFromModel(record.Intent), in.Intent) {
return nil, quoteResolutionError{code: "quote_intent_mismatch", err: merrors.InvalidArgument("quote_ref does not match intent")}
}
quote := modelQuoteToProto(record.Quote)
if quote == nil {
return nil, merrors.InvalidArgument("stored quote is empty")
}
quote.QuoteRef = ref
return quote, nil
}
req := &orchestratorv1.QuotePaymentRequest{
Meta: in.Meta,
IdempotencyKey: in.IdempotencyKey,
Intent: in.Intent,
PreviewOnly: false,
}
quote, _, err := s.buildPaymentQuote(ctx, in.OrgRef, req)
if err != nil {
return nil, err
}
return quote, nil
}
func newPayment(orgID primitive.ObjectID, intent *orchestratorv1.PaymentIntent, idempotencyKey string, metadata map[string]string, quote *orchestratorv1.PaymentQuote) *model.Payment {
entity := &model.Payment{}
entity.SetID(primitive.NewObjectID())
entity.SetOrganizationRef(orgID)
entity.PaymentRef = entity.GetID().Hex()
entity.IdempotencyKey = idempotencyKey
entity.State = model.PaymentStateAccepted
entity.Intent = intentFromProto(intent)
entity.Metadata = cloneMetadata(metadata)
entity.LastQuote = quoteSnapshotToModel(quote)
entity.Normalize()
return entity
}
func paymentNotFoundResponder[T any](svc mservice.Type, logger mlogger.Logger, err error) gsresponse.Responder[T] {
if errors.Is(err, storage.ErrPaymentNotFound) {
return gsresponse.NotFound[T](logger, svc, err)
}
return gsresponse.Auto[T](logger, svc, err)
}

View File

@@ -1,236 +0,0 @@
package orchestrator
import (
"context"
"testing"
"time"
"github.com/tech/sendico/payments/orchestrator/storage"
"github.com/tech/sendico/payments/orchestrator/storage/model"
clockpkg "github.com/tech/sendico/pkg/clock"
mloggerfactory "github.com/tech/sendico/pkg/mlogger/factory"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
"go.mongodb.org/mongo-driver/bson/primitive"
)
func TestValidateMetaAndOrgRef(t *testing.T) {
org := primitive.NewObjectID()
meta := &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()}
ref, id, err := validateMetaAndOrgRef(meta)
if err != nil {
t.Fatalf("expected nil error: %v", err)
}
if ref != org.Hex() || id != org {
t.Fatalf("unexpected org parsing: %s %s", ref, id.Hex())
}
if _, _, err := validateMetaAndOrgRef(nil); err == nil {
t.Fatalf("expected error on nil meta")
}
if _, _, err := validateMetaAndOrgRef(&orchestratorv1.RequestMeta{OrganizationRef: ""}); err == nil {
t.Fatalf("expected error on empty orgRef")
}
if _, _, err := validateMetaAndOrgRef(&orchestratorv1.RequestMeta{OrganizationRef: "bad"}); err == nil {
t.Fatalf("expected error on invalid orgRef")
}
}
func TestRequireIdempotencyKey(t *testing.T) {
if _, err := requireIdempotencyKey(" "); err == nil {
t.Fatalf("expected error for empty key")
}
val, err := requireIdempotencyKey(" key ")
if err != nil || val != "key" {
t.Fatalf("unexpected result %s err %v", val, err)
}
}
func TestNewPayment(t *testing.T) {
org := primitive.NewObjectID()
intent := &orchestratorv1.PaymentIntent{
Amount: &moneyv1.Money{Currency: "USD", Amount: "10"},
SettlementMode: orchestratorv1.SettlementMode_SETTLEMENT_MODE_FIX_RECEIVED,
}
quote := &orchestratorv1.PaymentQuote{QuoteRef: "q1"}
p := newPayment(org, intent, "idem", map[string]string{"k": "v"}, quote)
if p.PaymentRef == "" || p.IdempotencyKey != "idem" || p.State != model.PaymentStateAccepted {
t.Fatalf("unexpected payment fields: %+v", p)
}
if p.Intent.Amount == nil || p.Intent.Amount.GetAmount() != "10" {
t.Fatalf("intent not copied")
}
if p.Intent.SettlementMode != orchestratorv1.SettlementMode_SETTLEMENT_MODE_FIX_RECEIVED {
t.Fatalf("settlement mode not preserved")
}
if p.LastQuote == nil || p.LastQuote.QuoteRef != "q1" {
t.Fatalf("quote not copied")
}
}
func TestResolvePaymentQuote_NotFound(t *testing.T) {
org := primitive.NewObjectID()
svc := &Service{
storage: stubRepo{quotes: &helperQuotesStore{}},
clock: clockpkg.NewSystem(),
}
_, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{
OrgRef: org.Hex(),
OrgID: org,
Meta: &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()},
Intent: &orchestratorv1.PaymentIntent{Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}},
QuoteRef: "missing",
})
if qerr, ok := err.(quoteResolutionError); !ok || qerr.code != "quote_not_found" {
t.Fatalf("expected quote_not_found, got %v", err)
}
}
func TestResolvePaymentQuote_Expired(t *testing.T) {
org := primitive.NewObjectID()
intent := &orchestratorv1.PaymentIntent{Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}}
record := &model.PaymentQuoteRecord{
QuoteRef: "q1",
Intent: intentFromProto(intent),
Quote: &model.PaymentQuoteSnapshot{},
ExpiresAt: time.Now().Add(-time.Minute),
}
svc := &Service{
storage: stubRepo{quotes: &helperQuotesStore{records: map[string]*model.PaymentQuoteRecord{"q1": record}}},
clock: clockpkg.NewSystem(),
}
_, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{
OrgRef: org.Hex(),
OrgID: org,
Meta: &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()},
Intent: intent,
QuoteRef: "q1",
})
if qerr, ok := err.(quoteResolutionError); !ok || qerr.code != "quote_expired" {
t.Fatalf("expected quote_expired, got %v", err)
}
}
func TestInitiatePaymentIdempotency(t *testing.T) {
logger := mloggerfactory.NewLogger(false)
org := primitive.NewObjectID()
store := newHelperPaymentStore()
svc := NewService(logger, stubRepo{
payments: store,
}, WithClock(clockpkg.NewSystem()))
svc.ensureHandlers()
intent := &orchestratorv1.PaymentIntent{
Amount: &moneyv1.Money{Currency: "USD", Amount: "1"},
}
req := &orchestratorv1.InitiatePaymentRequest{
Meta: &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()},
Intent: intent,
IdempotencyKey: "k1",
}
resp, err := svc.h.commands.InitiatePayment().Execute(context.Background(), req)(context.Background())
if err != nil {
t.Fatalf("first call failed: %v", err)
}
resp2, err := svc.h.commands.InitiatePayment().Execute(context.Background(), req)(context.Background())
if err != nil {
t.Fatalf("second call failed: %v", err)
}
if resp == nil || resp2 == nil || resp.Payment.GetPaymentRef() != resp2.Payment.GetPaymentRef() {
t.Fatalf("idempotent call returned different payments")
}
}
// --- test doubles ---
type stubRepo struct {
payments storage.PaymentsStore
quotes storage.QuotesStore
pingErr error
}
func (s stubRepo) Ping(context.Context) error { return s.pingErr }
func (s stubRepo) Payments() storage.PaymentsStore { return s.payments }
func (s stubRepo) Quotes() storage.QuotesStore { return s.quotes }
type helperPaymentStore struct {
byRef map[string]*model.Payment
byIdem map[string]*model.Payment
byChain map[string]*model.Payment
}
func newHelperPaymentStore() *helperPaymentStore {
return &helperPaymentStore{
byRef: make(map[string]*model.Payment),
byIdem: make(map[string]*model.Payment),
byChain: make(map[string]*model.Payment),
}
}
func (s *helperPaymentStore) Create(_ context.Context, p *model.Payment) error {
if _, ok := s.byRef[p.PaymentRef]; ok {
return storage.ErrDuplicatePayment
}
s.byRef[p.PaymentRef] = p
if p.IdempotencyKey != "" {
s.byIdem[p.IdempotencyKey] = p
}
if p.Execution != nil && p.Execution.ChainTransferRef != "" {
s.byChain[p.Execution.ChainTransferRef] = p
}
return nil
}
func (s *helperPaymentStore) Update(_ context.Context, p *model.Payment) error {
if p == nil {
return storage.ErrPaymentNotFound
}
if _, ok := s.byRef[p.PaymentRef]; !ok {
return storage.ErrPaymentNotFound
}
s.byRef[p.PaymentRef] = p
if p.IdempotencyKey != "" {
s.byIdem[p.IdempotencyKey] = p
}
return nil
}
func (s *helperPaymentStore) GetByPaymentRef(_ context.Context, ref string) (*model.Payment, error) {
if p, ok := s.byRef[ref]; ok {
return p, nil
}
return nil, storage.ErrPaymentNotFound
}
func (s *helperPaymentStore) GetByIdempotencyKey(_ context.Context, _ primitive.ObjectID, key string) (*model.Payment, error) {
if p, ok := s.byIdem[key]; ok {
return p, nil
}
return nil, storage.ErrPaymentNotFound
}
func (s *helperPaymentStore) GetByChainTransferRef(_ context.Context, ref string) (*model.Payment, error) {
if p, ok := s.byChain[ref]; ok {
return p, nil
}
return nil, storage.ErrPaymentNotFound
}
func (s *helperPaymentStore) List(_ context.Context, _ *model.PaymentFilter) (*model.PaymentList, error) {
return &model.PaymentList{}, nil
}
type helperQuotesStore struct {
records map[string]*model.PaymentQuoteRecord
}
func (s *helperQuotesStore) Create(_ context.Context, _ *model.PaymentQuoteRecord) error { return nil }
func (s *helperQuotesStore) GetByRef(_ context.Context, _ primitive.ObjectID, ref string) (*model.PaymentQuoteRecord, error) {
if s.records == nil {
return nil, storage.ErrQuoteNotFound
}
if rec, ok := s.records[ref]; ok {
return rec, nil
}
return nil, storage.ErrQuoteNotFound
}

View File

@@ -32,13 +32,11 @@ func TestExecutePayment_FXConversionSettled(t *testing.T) {
logger: zap.NewNop(), logger: zap.NewNop(),
clock: testClock{now: time.Now()}, clock: testClock{now: time.Now()},
storage: repo, storage: repo,
deps: serviceDependencies{ ledger: ledgerDependency{client: &ledgerclient.Fake{
ledger: ledgerDependency{client: &ledgerclient.Fake{ ApplyFXWithChargesFn: func(ctx context.Context, req *ledgerv1.FXRequest) (*ledgerv1.PostResponse, error) {
ApplyFXWithChargesFn: func(ctx context.Context, req *ledgerv1.FXRequest) (*ledgerv1.PostResponse, error) { return &ledgerv1.PostResponse{JournalEntryRef: "fx-entry"}, nil
return &ledgerv1.PostResponse{JournalEntryRef: "fx-entry"}, nil },
}, }},
}},
},
} }
payment := &model.Payment{ payment := &model.Payment{
@@ -90,13 +88,11 @@ func TestExecutePayment_ChainFailure(t *testing.T) {
logger: zap.NewNop(), logger: zap.NewNop(),
clock: testClock{now: time.Now()}, clock: testClock{now: time.Now()},
storage: repo, storage: repo,
deps: serviceDependencies{ gateway: gatewayDependency{client: &chainclient.Fake{
gateway: gatewayDependency{client: &chainclient.Fake{ SubmitTransferFn: func(ctx context.Context, req *chainv1.SubmitTransferRequest) (*chainv1.SubmitTransferResponse, error) {
SubmitTransferFn: func(ctx context.Context, req *chainv1.SubmitTransferRequest) (*chainv1.SubmitTransferResponse, error) { return nil, errors.New("chain failure")
return nil, errors.New("chain failure") },
}, }},
}},
},
} }
payment := &model.Payment{ payment := &model.Payment{
@@ -150,7 +146,6 @@ func TestProcessTransferUpdateHandler_Settled(t *testing.T) {
clock: testClock{now: time.Now()}, clock: testClock{now: time.Now()},
storage: &stubRepository{store: store}, storage: &stubRepository{store: store},
} }
svc.h.events = newPaymentEventHandler(svc.storage, svc.ensureRepository, svc.logger)
req := &orchestratorv1.ProcessTransferUpdateRequest{ req := &orchestratorv1.ProcessTransferUpdateRequest{
Event: &chainv1.TransferStatusChangedEvent{ Event: &chainv1.TransferStatusChangedEvent{
@@ -161,7 +156,7 @@ func TestProcessTransferUpdateHandler_Settled(t *testing.T) {
}, },
} }
reSP, err := gsresponse.Execute(ctx, svc.h.events.processTransferUpdate(ctx, req)) reSP, err := gsresponse.Execute(ctx, svc.processTransferUpdateHandler(ctx, req))
if err != nil { if err != nil {
t.Fatalf("handler returned error: %v", err) t.Fatalf("handler returned error: %v", err)
} }
@@ -194,7 +189,6 @@ func TestProcessDepositObservedHandler_MatchesPayment(t *testing.T) {
clock: testClock{now: time.Now()}, clock: testClock{now: time.Now()},
storage: &stubRepository{store: store}, storage: &stubRepository{store: store},
} }
svc.h.events = newPaymentEventHandler(svc.storage, svc.ensureRepository, svc.logger)
req := &orchestratorv1.ProcessDepositObservedRequest{ req := &orchestratorv1.ProcessDepositObservedRequest{
Event: &chainv1.WalletDepositObservedEvent{ Event: &chainv1.WalletDepositObservedEvent{
@@ -203,7 +197,7 @@ func TestProcessDepositObservedHandler_MatchesPayment(t *testing.T) {
}, },
} }
reSP, err := gsresponse.Execute(ctx, svc.h.events.processDepositObserved(ctx, req)) reSP, err := gsresponse.Execute(ctx, svc.processDepositObservedHandler(ctx, req))
if err != nil { if err != nil {
t.Fatalf("handler returned error: %v", err) t.Fatalf("handler returned error: %v", err)
} }

View File

@@ -11,7 +11,6 @@ import (
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
) )
// PaymentKind captures the orchestrator intent type. // PaymentKind captures the orchestrator intent type.
@@ -58,7 +57,6 @@ const (
EndpointTypeLedger PaymentEndpointType = "ledger" EndpointTypeLedger PaymentEndpointType = "ledger"
EndpointTypeManagedWallet PaymentEndpointType = "managed_wallet" EndpointTypeManagedWallet PaymentEndpointType = "managed_wallet"
EndpointTypeExternalChain PaymentEndpointType = "external_chain" EndpointTypeExternalChain PaymentEndpointType = "external_chain"
EndpointTypeCard PaymentEndpointType = "card"
) )
// LedgerEndpoint describes ledger routing. // LedgerEndpoint describes ledger routing.
@@ -80,36 +78,12 @@ type ExternalChainEndpoint struct {
Memo string `bson:"memo,omitempty" json:"memo,omitempty"` Memo string `bson:"memo,omitempty" json:"memo,omitempty"`
} }
// CardEndpoint describes a card payout destination.
type CardEndpoint struct {
Pan string `bson:"pan,omitempty" json:"pan,omitempty"`
Token string `bson:"token,omitempty" json:"token,omitempty"`
Cardholder string `bson:"cardholder,omitempty" json:"cardholder,omitempty"`
ExpMonth uint32 `bson:"expMonth,omitempty" json:"expMonth,omitempty"`
ExpYear uint32 `bson:"expYear,omitempty" json:"expYear,omitempty"`
Country string `bson:"country,omitempty" json:"country,omitempty"`
MaskedPan string `bson:"maskedPan,omitempty" json:"maskedPan,omitempty"`
}
// CardPayout stores gateway payout tracking info.
type CardPayout struct {
PayoutRef string `bson:"payoutRef,omitempty" json:"payoutRef,omitempty"`
ProviderPaymentID string `bson:"providerPaymentId,omitempty" json:"providerPaymentId,omitempty"`
Status string `bson:"status,omitempty" json:"status,omitempty"`
FailureReason string `bson:"failureReason,omitempty" json:"failureReason,omitempty"`
CardCountry string `bson:"cardCountry,omitempty" json:"cardCountry,omitempty"`
MaskedPan string `bson:"maskedPan,omitempty" json:"maskedPan,omitempty"`
ProviderCode string `bson:"providerCode,omitempty" json:"providerCode,omitempty"`
GatewayReference string `bson:"gatewayReference,omitempty" json:"gatewayReference,omitempty"`
}
// PaymentEndpoint is a polymorphic payment destination/source. // PaymentEndpoint is a polymorphic payment destination/source.
type PaymentEndpoint struct { type PaymentEndpoint struct {
Type PaymentEndpointType `bson:"type" json:"type"` Type PaymentEndpointType `bson:"type" json:"type"`
Ledger *LedgerEndpoint `bson:"ledger,omitempty" json:"ledger,omitempty"` Ledger *LedgerEndpoint `bson:"ledger,omitempty" json:"ledger,omitempty"`
ManagedWallet *ManagedWalletEndpoint `bson:"managedWallet,omitempty" json:"managedWallet,omitempty"` ManagedWallet *ManagedWalletEndpoint `bson:"managedWallet,omitempty" json:"managedWallet,omitempty"`
ExternalChain *ExternalChainEndpoint `bson:"externalChain,omitempty" json:"externalChain,omitempty"` ExternalChain *ExternalChainEndpoint `bson:"externalChain,omitempty" json:"externalChain,omitempty"`
Card *CardEndpoint `bson:"card,omitempty" json:"card,omitempty"`
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"` Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
} }
@@ -125,15 +99,14 @@ type FXIntent struct {
// PaymentIntent models the requested payment operation. // PaymentIntent models the requested payment operation.
type PaymentIntent struct { type PaymentIntent struct {
Kind PaymentKind `bson:"kind" json:"kind"` Kind PaymentKind `bson:"kind" json:"kind"`
Source PaymentEndpoint `bson:"source" json:"source"` Source PaymentEndpoint `bson:"source" json:"source"`
Destination PaymentEndpoint `bson:"destination" json:"destination"` Destination PaymentEndpoint `bson:"destination" json:"destination"`
Amount *moneyv1.Money `bson:"amount" json:"amount"` Amount *moneyv1.Money `bson:"amount" json:"amount"`
RequiresFX bool `bson:"requiresFx,omitempty" json:"requiresFx,omitempty"` RequiresFX bool `bson:"requiresFx,omitempty" json:"requiresFx,omitempty"`
FX *FXIntent `bson:"fx,omitempty" json:"fx,omitempty"` FX *FXIntent `bson:"fx,omitempty" json:"fx,omitempty"`
FeePolicy *feesv1.PolicyOverrides `bson:"feePolicy,omitempty" json:"feePolicy,omitempty"` FeePolicy *feesv1.PolicyOverrides `bson:"feePolicy,omitempty" json:"feePolicy,omitempty"`
SettlementMode orchestratorv1.SettlementMode `bson:"settlementMode,omitempty" json:"settlementMode,omitempty"` Attributes map[string]string `bson:"attributes,omitempty" json:"attributes,omitempty"`
Attributes map[string]string `bson:"attributes,omitempty" json:"attributes,omitempty"`
} }
// PaymentQuoteSnapshot stores the latest quote info. // PaymentQuoteSnapshot stores the latest quote info.
@@ -145,6 +118,7 @@ type PaymentQuoteSnapshot struct {
FeeRules []*feesv1.AppliedRule `bson:"feeRules,omitempty" json:"feeRules,omitempty"` FeeRules []*feesv1.AppliedRule `bson:"feeRules,omitempty" json:"feeRules,omitempty"`
FXQuote *oraclev1.Quote `bson:"fxQuote,omitempty" json:"fxQuote,omitempty"` FXQuote *oraclev1.Quote `bson:"fxQuote,omitempty" json:"fxQuote,omitempty"`
NetworkFee *chainv1.EstimateTransferFeeResponse `bson:"networkFee,omitempty" json:"networkFee,omitempty"` NetworkFee *chainv1.EstimateTransferFeeResponse `bson:"networkFee,omitempty" json:"networkFee,omitempty"`
FeeQuoteToken string `bson:"feeQuoteToken,omitempty" json:"feeQuoteToken,omitempty"`
QuoteRef string `bson:"quoteRef,omitempty" json:"quoteRef,omitempty"` QuoteRef string `bson:"quoteRef,omitempty" json:"quoteRef,omitempty"`
} }
@@ -154,8 +128,6 @@ type ExecutionRefs struct {
CreditEntryRef string `bson:"creditEntryRef,omitempty" json:"creditEntryRef,omitempty"` CreditEntryRef string `bson:"creditEntryRef,omitempty" json:"creditEntryRef,omitempty"`
FXEntryRef string `bson:"fxEntryRef,omitempty" json:"fxEntryRef,omitempty"` FXEntryRef string `bson:"fxEntryRef,omitempty" json:"fxEntryRef,omitempty"`
ChainTransferRef string `bson:"chainTransferRef,omitempty" json:"chainTransferRef,omitempty"` ChainTransferRef string `bson:"chainTransferRef,omitempty" json:"chainTransferRef,omitempty"`
CardPayoutRef string `bson:"cardPayoutRef,omitempty" json:"cardPayoutRef,omitempty"`
FeeTransferRef string `bson:"feeTransferRef,omitempty" json:"feeTransferRef,omitempty"`
} }
// Payment persists orchestrated payment lifecycle. // Payment persists orchestrated payment lifecycle.
@@ -172,7 +144,6 @@ type Payment struct {
LastQuote *PaymentQuoteSnapshot `bson:"lastQuote,omitempty" json:"lastQuote,omitempty"` LastQuote *PaymentQuoteSnapshot `bson:"lastQuote,omitempty" json:"lastQuote,omitempty"`
Execution *ExecutionRefs `bson:"execution,omitempty" json:"execution,omitempty"` Execution *ExecutionRefs `bson:"execution,omitempty" json:"execution,omitempty"`
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"` Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
CardPayout *CardPayout `bson:"cardPayout,omitempty" json:"cardPayout,omitempty"`
} }
// Collection implements storable.Storable. // Collection implements storable.Storable.
@@ -252,13 +223,5 @@ func normalizeEndpoint(ep *PaymentEndpoint) {
ep.ExternalChain.Asset.ContractAddress = strings.TrimSpace(strings.ToLower(ep.ExternalChain.Asset.ContractAddress)) ep.ExternalChain.Asset.ContractAddress = strings.TrimSpace(strings.ToLower(ep.ExternalChain.Asset.ContractAddress))
} }
} }
case EndpointTypeCard:
if ep.Card != nil {
ep.Card.Pan = strings.TrimSpace(ep.Card.Pan)
ep.Card.Token = strings.TrimSpace(ep.Card.Token)
ep.Card.Cardholder = strings.TrimSpace(ep.Card.Cardholder)
ep.Card.Country = strings.TrimSpace(ep.Card.Country)
ep.Card.MaskedPan = strings.TrimSpace(ep.Card.MaskedPan)
}
} }
} }

View File

@@ -12,12 +12,10 @@ type PaymentQuoteRecord struct {
storable.Base `bson:",inline" json:",inline"` storable.Base `bson:",inline" json:",inline"`
model.OrganizationBoundBase `bson:",inline" json:",inline"` model.OrganizationBoundBase `bson:",inline" json:",inline"`
QuoteRef string `bson:"quoteRef" json:"quoteRef"` QuoteRef string `bson:"quoteRef" json:"quoteRef"`
Intent PaymentIntent `bson:"intent,omitempty" json:"intent,omitempty"` Intent PaymentIntent `bson:"intent" json:"intent"`
Intents []PaymentIntent `bson:"intents,omitempty" json:"intents,omitempty"` Quote *PaymentQuoteSnapshot `bson:"quote" json:"quote"`
Quote *PaymentQuoteSnapshot `bson:"quote,omitempty" json:"quote,omitempty"` ExpiresAt time.Time `bson:"expiresAt" json:"expiresAt"`
Quotes []*PaymentQuoteSnapshot `bson:"quotes,omitempty" json:"quotes,omitempty"`
ExpiresAt time.Time `bson:"expiresAt" json:"expiresAt"`
} }
// Collection implements storable.Storable. // Collection implements storable.Storable.

View File

@@ -73,16 +73,6 @@ func (q *Quotes) Create(ctx context.Context, quote *model.PaymentQuoteRecord) er
quote.Intent.Attributes[k] = strings.TrimSpace(v) quote.Intent.Attributes[k] = strings.TrimSpace(v)
} }
} }
if len(quote.Intents) > 0 {
for i := range quote.Intents {
if quote.Intents[i].Attributes == nil {
continue
}
for k, v := range quote.Intents[i].Attributes {
quote.Intents[i].Attributes[k] = strings.TrimSpace(v)
}
}
}
quote.Update() quote.Update()
filter := repository.OrgFilter(quote.OrganizationRef).And( filter := repository.OrgFilter(quote.OrganizationRef).And(

View File

@@ -47,7 +47,12 @@ func Error[T any](logger mlogger.Logger, service mservice.Type, code codes.Code,
if err != nil { if err != nil {
fields = append(fields, zap.Error(err)) fields = append(fields, zap.Error(err))
} }
logger.Warn("gRPC request failed", fields...) logFn := logger.Warn
switch code {
case codes.Internal, codes.DataLoss, codes.Unavailable:
logFn = logger.Error
}
logFn("gRPC request failed", fields...)
msg := message(err) msg := message(err)
switch { switch {

View File

@@ -6,9 +6,9 @@ import (
"fmt" "fmt"
"testing" "testing"
"github.com/stretchr/testify/require"
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mservice" "github.com/tech/sendico/pkg/mservice"
"github.com/stretchr/testify/require"
"go.mongodb.org/mongo-driver/bson/primitive" "go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap" "go.uber.org/zap"
"google.golang.org/grpc/codes" "google.golang.org/grpc/codes"
@@ -32,7 +32,7 @@ func TestUnarySuccess(t *testing.T) {
return Success(resp) return Success(resp)
} }
unary := Unary(logger, mservice.Type("test"), handler) unary := Unary[testRequest, testResponse](logger, mservice.Type("test"), handler)
resp, err := unary(context.Background(), &testRequest{Value: "hello"}) resp, err := unary(context.Background(), &testRequest{Value: "hello"})
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, resp) require.NotNil(t, resp)

View File

@@ -37,9 +37,7 @@ func Create(logger mlogger.Logger, db *mongo.Database) (*RefreshTokenDB, error)
{Field: "clientId", Sort: ri.Asc}, {Field: "clientId", Sort: ri.Asc},
{Field: "deviceId", Sort: ri.Asc}, {Field: "deviceId", Sort: ri.Asc},
}, },
Unique: true, Unique: true,
Name: "unique_active_session",
PartialFilter: repository.Filter(IsRevokedField, false),
}); err != nil { }); err != nil {
p.Logger.Error("Failed to create unique account/client/device index", zap.Error(err)) p.Logger.Error("Failed to create unique account/client/device index", zap.Error(err))
return nil, err return nil, err

View File

@@ -10,29 +10,23 @@ import (
"testing" "testing"
"time" "time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tech/sendico/pkg/db/internal/mongo/refreshtokensdb" "github.com/tech/sendico/pkg/db/internal/mongo/refreshtokensdb"
"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"
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
factory "github.com/tech/sendico/pkg/mlogger/factory" factory "github.com/tech/sendico/pkg/mlogger/factory"
"github.com/tech/sendico/pkg/model" "github.com/tech/sendico/pkg/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/mongodb" "github.com/testcontainers/testcontainers-go/modules/mongodb"
"github.com/testcontainers/testcontainers-go/wait" "github.com/testcontainers/testcontainers-go/wait"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive" "go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo" "go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options" "go.mongodb.org/mongo-driver/mongo/options"
) )
func setupTestDB(t *testing.T) (*refreshtokensdb.RefreshTokenDB, func()) { func setupTestDB(t *testing.T) (*refreshtokensdb.RefreshTokenDB, func()) {
db, _, cleanup := setupTestDBWithMongo(t)
return db, cleanup
}
func setupTestDBWithMongo(t *testing.T) (*refreshtokensdb.RefreshTokenDB, *mongo.Database, func()) {
// mark as helper for better test failure reporting // mark as helper for better test failure reporting
t.Helper() t.Helper()
@@ -68,7 +62,7 @@ func setupTestDBWithMongo(t *testing.T) (*refreshtokensdb.RefreshTokenDB, *mongo
_ = mongoContainer.Terminate(termCtx) _ = mongoContainer.Terminate(termCtx)
} }
return db, database, cleanup return db, cleanup
} }
func createTestRefreshToken(accountRef primitive.ObjectID, clientID, deviceID, token string) *model.RefreshToken { func createTestRefreshToken(accountRef primitive.ObjectID, clientID, deviceID, token string) *model.RefreshToken {
@@ -338,63 +332,6 @@ func TestRefreshTokenDB_SessionReplacement(t *testing.T) {
_, err = db.GetByCRT(ctx, secondCRT) _, err = db.GetByCRT(ctx, secondCRT)
require.NoError(t, err) require.NoError(t, err)
}) })
t.Run("Create_After_GlobalRevocation_AllowsNewActive", func(t *testing.T) {
userID := primitive.NewObjectID()
clientID := "web-app"
deviceID := "user-laptop"
firstToken := createTestRefreshToken(userID, clientID, deviceID, "revoked_token_123")
err := db.Create(ctx, firstToken)
require.NoError(t, err)
require.NotNil(t, firstToken.GetID())
// Global revoke (deviceID empty) — all tokens should be revoked
err = db.RevokeAll(ctx, userID, "")
require.NoError(t, err)
var revoked model.RefreshToken
err = db.Get(ctx, *firstToken.GetID(), &revoked)
require.NoError(t, err)
assert.True(t, revoked.IsRevoked)
// Creating a new token for the same account/client/device must succeed and produce an active token
reissueToken := createTestRefreshToken(userID, clientID, deviceID, "new_token_after_revocation")
err = db.Create(ctx, reissueToken)
require.NoError(t, err)
newCRT := &model.ClientRefreshToken{
SessionIdentifier: model.SessionIdentifier{
ClientID: clientID,
DeviceID: deviceID,
},
RefreshToken: "new_token_after_revocation",
}
_, err = db.GetByCRT(ctx, newCRT)
require.NoError(t, err)
// Old token must remain unusable
oldCRT := &model.ClientRefreshToken{
SessionIdentifier: model.SessionIdentifier{
ClientID: clientID,
DeviceID: deviceID,
},
RefreshToken: "revoked_token_123",
}
_, err = db.GetByCRT(ctx, oldCRT)
assert.Error(t, err)
// Both records exist: revoked + new active
query := repository.Query().
Filter(repository.AccountField(), userID).
And(
repository.Query().Comparison(repository.Field("clientId"), builder.Eq, clientID),
repository.Query().Comparison(repository.Field("deviceId"), builder.Eq, deviceID),
)
ids, err := db.Repository.ListIDs(ctx, query)
require.NoError(t, err)
assert.Len(t, ids, 2)
})
} }
func TestRefreshTokenDB_ClientManagement(t *testing.T) { func TestRefreshTokenDB_ClientManagement(t *testing.T) {
@@ -700,29 +637,3 @@ func TestRefreshTokenDB_DatabaseIndexes(t *testing.T) {
assert.Len(t, ids, 5) // Should find 5 non-revoked tokens assert.Len(t, ids, 5) // Should find 5 non-revoked tokens
}) })
} }
func TestRefreshTokenDB_IndexPartialUniqueActiveSession(t *testing.T) {
db, database, cleanup := setupTestDBWithMongo(t)
defer cleanup()
ctx := context.Background()
cursor, err := database.Collection(db.Repository.Collection()).Indexes().List(ctx)
require.NoError(t, err)
defer cursor.Close(ctx)
found := false
for cursor.Next(ctx) {
var idx bson.M
require.NoError(t, cursor.Decode(&idx))
if idx["name"] == "unique_active_session" {
found = true
assert.Equal(t, true, idx["unique"])
partial, ok := idx["partialFilterExpression"].(bson.M)
require.True(t, ok)
assert.Equal(t, bson.M{"isRevoked": false}, partial)
}
}
assert.True(t, found, "unique_active_session index not found")
}

View File

@@ -41,9 +41,6 @@ func (r *MongoRepository) CreateIndex(def *ri.Definition) error {
if def.Name != "" { if def.Name != "" {
opts.SetName(def.Name) opts.SetName(def.Name)
} }
if def.PartialFilter != nil {
opts.SetPartialFilterExpression(def.PartialFilter.BuildQuery())
}
_, err := r.collection.Indexes().CreateOne( _, err := r.collection.Indexes().CreateOne(
context.Background(), context.Background(),

View File

@@ -1,83 +0,0 @@
//go:build integration
// +build integration
package repositoryimp_test
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tech/sendico/pkg/db/repository"
ri "github.com/tech/sendico/pkg/db/repository/index"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/mongodb"
"github.com/testcontainers/testcontainers-go/wait"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)
func TestCreateIndex_WithPartialFilter(t *testing.T) {
startCtx, startCancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer startCancel()
mongoContainer, err := mongodb.Run(startCtx,
"mongo:latest",
mongodb.WithUsername("root"),
mongodb.WithPassword("password"),
testcontainers.WithWaitStrategy(wait.ForListeningPort("27017/tcp").WithStartupTimeout(2*time.Minute)),
)
require.NoError(t, err)
mongoURI, err := mongoContainer.ConnectionString(startCtx)
require.NoError(t, err)
client, err := mongo.Connect(startCtx, options.Client().ApplyURI(mongoURI))
require.NoError(t, err)
defer client.Disconnect(context.Background())
database := client.Database("test_partial_index_" + t.Name())
defer database.Drop(context.Background())
repo := repository.CreateMongoRepository(database, "partial_index_items")
def := &ri.Definition{
Keys: []ri.Key{
{Field: "field", Sort: ri.Asc},
},
Unique: true,
Name: "partial_unique_field_true",
PartialFilter: repository.Filter("flag", treu),
}
require.NoError(t, repo.CreateIndex(def))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cursor, err := database.Collection(repo.Collection()).Indexes().List(ctx)
require.NoError(t, err)
defer cursor.Close(ctx)
found := false
for cursor.Next(ctx) {
var idx bson.M
require.NoError(t, cursor.Decode(&idx))
if idx["name"] == def.Name {
found = true
assert.Equal(t, true, idx["unique"])
assert.Equal(t, bson.M{"field": int32(1)}, idx["key"])
partial, ok := idx["partialFilterExpression"].(bson.M)
require.True(t, ok)
assert.Equal(t, bson.M{"flag": true}, partial)
}
}
assert.True(t, found, "partial unique index was not created")
termCtx, termCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer termCancel()
_ = mongoContainer.Terminate(termCtx)
}

View File

@@ -1,7 +1,5 @@
package repository package repository
import "github.com/tech/sendico/pkg/db/repository/builder"
type Sort int8 type Sort int8
const ( const (
@@ -16,9 +14,8 @@ type Key struct {
} }
type Definition struct { type Definition struct {
Keys []Key // mandatory, at least one element Keys []Key // mandatory, at least one element
Unique bool // unique constraint? Unique bool // unique constraint?
TTL *int32 // seconds; nil means “no TTL” TTL *int32 // seconds; nil means “no TTL”
Name string // optional explicit name Name string // optional explicit name
PartialFilter builder.Query // optional: partialFilterExpression for conditional indexes
} }

View File

@@ -9,7 +9,7 @@ require (
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/mattn/go-colorable v0.1.14 github.com/mattn/go-colorable v0.1.14
github.com/mitchellh/mapstructure v1.5.0 github.com/mitchellh/mapstructure v1.5.0
github.com/nats-io/nats.go v1.48.0 github.com/nats-io/nats.go v1.47.0
github.com/prometheus/client_golang v1.23.2 github.com/prometheus/client_golang v1.23.2
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
github.com/testcontainers/testcontainers-go v0.33.0 github.com/testcontainers/testcontainers-go v0.33.0
@@ -17,8 +17,8 @@ require (
go.mongodb.org/mongo-driver v1.17.6 go.mongodb.org/mongo-driver v1.17.6
go.uber.org/zap v1.27.1 go.uber.org/zap v1.27.1
golang.org/x/crypto v0.46.0 golang.org/x/crypto v0.46.0
google.golang.org/grpc v1.78.0 google.golang.org/grpc v1.77.0
google.golang.org/protobuf v1.36.11 google.golang.org/protobuf v1.36.10
) )
require ( require (
@@ -93,6 +93,6 @@ require (
golang.org/x/sys v0.39.0 // indirect golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect golang.org/x/text v0.32.0 // indirect
golang.org/x/time v0.5.0 // indirect golang.org/x/time v0.5.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )

View File

@@ -106,8 +106,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U= github.com/nats-io/nats.go v1.47.0 h1:YQdADw6J/UfGUd2Oy6tn4Hq6YHxCaJrVKayxxFqYrgM=
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= github.com/nats-io/nats.go v1.47.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc= github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg= github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
@@ -267,14 +267,14 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda h1:+2XxjfsAu6vqFxwGBRcHiMaDCuZiqXGDUDVWVtrFAnE= google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 h1:mepRgnBZa07I4TRuomDE4sTIYieg/osKmzIf4USdWS4=
google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo= google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

View File

@@ -8,10 +8,9 @@ import (
) )
type CryptoAddressPaymentData struct { type CryptoAddressPaymentData struct {
Currency Currency `bson:"currency" json:"currency"` Address string `bson:"address" json:"address"`
Address string `bson:"address" json:"address"` Network string `bson:"network" json:"network"`
Network string `bson:"network" json:"network"` DestinationTag *string `bson:"destinationTag,omitempty" json:"destinationTag,omitempty"`
DestinationTag *string `bson:"destinationTag,omitempty" json:"destinationTag,omitempty"`
} }
func (m *PaymentMethod) AsCryptoAddress() (*CryptoAddressPaymentData, error) { func (m *PaymentMethod) AsCryptoAddress() (*CryptoAddressPaymentData, error) {

View File

@@ -3,13 +3,12 @@ package model
type Currency string type Currency string
const ( const (
CurrencyEUR Currency = "EUR" // Euro CurrencyEUR Currency = "EUR" // Euro
CurrencyUSD Currency = "USD" // US Dollar CurrencyUSD Currency = "USD" // US Dollar
CurrencyRUB Currency = "RUB" // Russian Ruble CurrencyRUB Currency = "RUB" // Russian Ruble
CurrencyUAH Currency = "UAH" // Ukrainian Hryvnia CurrencyUAH Currency = "UAH" // Ukrainian Hryvnia
CurrencyPLN Currency = "PLN" // Polish Złoty CurrencyPLN Currency = "PLN" // Polish Złoty
CurrencyCZK Currency = "CZK" // Czech Koruna CurrencyCZK Currency = "CZK" // Czech Koruna
CurrencyUSDT Currency = "USDT" // Czech Koruna
) )
// All supported currencies // All supported currencies
@@ -20,7 +19,6 @@ var SupportedCurrencies = []Currency{
CurrencyUAH, CurrencyUAH,
CurrencyPLN, CurrencyPLN,
CurrencyCZK, CurrencyCZK,
CurrencyUSDT,
} }
type Amount struct { type Amount struct {

View File

@@ -1,6 +0,0 @@
package model
type Money struct {
Currency string `bson:"currency" json:"currency"`
Amount string `bson:"amount" json:"amount"`
}

Some files were not shown because too many files have changed in this diff Show More