fixed linting config

This commit is contained in:
Stephan D
2026-02-12 13:00:37 +01:00
parent 97395acd8f
commit 7cbcbb4b3c
42 changed files with 1813 additions and 237 deletions

View File

@@ -51,7 +51,7 @@ func (r *discoveryGatewayRegistry) List(_ context.Context) ([]*model.GatewayInst
InvokeURI: strings.TrimSpace(entry.InvokeURI),
Currencies: normalizeCurrencies(entry.Currencies),
Capabilities: capabilitiesFromOps(entry.Operations),
Limits: limitsFromDiscovery(entry.Limits),
Limits: limitsFromDiscovery(entry.Limits, entry.CurrencyMeta),
Version: entry.Version,
IsEnabled: entry.Healthy,
})
@@ -102,36 +102,111 @@ func capabilitiesFromOps(ops []string) model.RailCapabilities {
return cap
}
func limitsFromDiscovery(src *discovery.Limits) model.Limits {
if src == nil {
return model.Limits{}
}
func limitsFromDiscovery(src *discovery.Limits, currencies []discovery.CurrencyAnnouncement) model.Limits {
limits := model.Limits{
MinAmount: strings.TrimSpace(src.MinAmount),
MaxAmount: strings.TrimSpace(src.MaxAmount),
VolumeLimit: map[string]string{},
VelocityLimit: map[string]int{},
VolumeLimit: map[string]string{},
VelocityLimit: map[string]int{},
CurrencyLimits: map[string]model.LimitsOverride{},
}
for key, value := range src.VolumeLimit {
k := strings.TrimSpace(key)
v := strings.TrimSpace(value)
if k == "" || v == "" {
continue
if src != nil {
limits.MinAmount = strings.TrimSpace(src.MinAmount)
limits.MaxAmount = strings.TrimSpace(src.MaxAmount)
for key, value := range src.VolumeLimit {
k := strings.TrimSpace(key)
v := strings.TrimSpace(value)
if k == "" || v == "" {
continue
}
limits.VolumeLimit[k] = v
}
limits.VolumeLimit[k] = v
}
for key, value := range src.VelocityLimit {
k := strings.TrimSpace(key)
if k == "" {
continue
for key, value := range src.VelocityLimit {
k := strings.TrimSpace(key)
if k == "" {
continue
}
limits.VelocityLimit[k] = value
}
limits.VelocityLimit[k] = value
}
applyCurrencyTransferLimits(&limits, currencies)
if len(limits.VolumeLimit) == 0 {
limits.VolumeLimit = nil
}
if len(limits.VelocityLimit) == 0 {
limits.VelocityLimit = nil
}
if len(limits.CurrencyLimits) == 0 {
limits.CurrencyLimits = nil
}
return limits
}
func applyCurrencyTransferLimits(dst *model.Limits, currencies []discovery.CurrencyAnnouncement) {
if dst == nil || len(currencies) == 0 {
return
}
var (
commonMin string
commonMax string
commonMinInit bool
commonMaxInit bool
commonMinConsistent = true
commonMaxConsistent = true
)
for _, currency := range currencies {
code := strings.ToUpper(strings.TrimSpace(currency.Currency))
if code == "" || currency.Limits == nil || currency.Limits.Amount == nil {
commonMinConsistent = false
commonMaxConsistent = false
continue
}
min := strings.TrimSpace(currency.Limits.Amount.Min)
max := strings.TrimSpace(currency.Limits.Amount.Max)
if min != "" || max != "" {
override := dst.CurrencyLimits[code]
if min != "" {
override.MinAmount = min
}
if max != "" {
override.MaxAmount = max
}
if override.MinAmount != "" || override.MaxAmount != "" || override.MaxFee != "" || override.MaxOps > 0 || override.MaxVolume != "" {
dst.CurrencyLimits[code] = override
}
}
if min == "" {
commonMinConsistent = false
} else if !commonMinInit {
commonMin = min
commonMinInit = true
} else if commonMin != min {
commonMinConsistent = false
}
if max == "" {
commonMaxConsistent = false
} else if !commonMaxInit {
commonMax = max
commonMaxInit = true
} else if commonMax != max {
commonMaxConsistent = false
}
}
if commonMinInit && commonMinConsistent {
dst.PerTxMinAmount = firstLimitValue(dst.PerTxMinAmount, commonMin)
}
if commonMaxInit && commonMaxConsistent {
dst.PerTxMaxAmount = firstLimitValue(dst.PerTxMaxAmount, commonMax)
}
}
func firstLimitValue(primary, fallback string) string {
primary = strings.TrimSpace(primary)
if primary != "" {
return primary
}
return strings.TrimSpace(fallback)
}

View File

@@ -0,0 +1,62 @@
package quotation
import (
"testing"
"github.com/tech/sendico/pkg/discovery"
)
func TestLimitsFromDiscovery_MapsPerTxMinimumFromCurrencyMeta(t *testing.T) {
limits := limitsFromDiscovery(nil, []discovery.CurrencyAnnouncement{
{
Currency: "RUB",
Limits: &discovery.CurrencyLimits{
Amount: &discovery.CurrencyAmount{
Min: "100.00",
Max: "10000.00",
},
},
},
})
if limits.PerTxMinAmount != "100.00" {
t.Fatalf("expected per tx min 100.00, got %q", limits.PerTxMinAmount)
}
if limits.PerTxMaxAmount != "10000.00" {
t.Fatalf("expected per tx max 10000.00, got %q", limits.PerTxMaxAmount)
}
override, ok := limits.CurrencyLimits["RUB"]
if !ok {
t.Fatalf("expected RUB currency override")
}
if override.MinAmount != "100.00" {
t.Fatalf("expected RUB min override 100.00, got %q", override.MinAmount)
}
}
func TestLimitsFromDiscovery_DropsCommonPerTxMinimumWhenCurrenciesDiffer(t *testing.T) {
limits := limitsFromDiscovery(nil, []discovery.CurrencyAnnouncement{
{
Currency: "USD",
Limits: &discovery.CurrencyLimits{
Amount: &discovery.CurrencyAmount{Min: "10.00"},
},
},
{
Currency: "EUR",
Limits: &discovery.CurrencyLimits{
Amount: &discovery.CurrencyAmount{Min: "20.00"},
},
},
})
if limits.PerTxMinAmount != "" {
t.Fatalf("expected empty common per tx min, got %q", limits.PerTxMinAmount)
}
if limits.CurrencyLimits["USD"].MinAmount != "10.00" {
t.Fatalf("expected USD min override 10.00, got %q", limits.CurrencyLimits["USD"].MinAmount)
}
if limits.CurrencyLimits["EUR"].MinAmount != "20.00" {
t.Fatalf("expected EUR min override 20.00, got %q", limits.CurrencyLimits["EUR"].MinAmount)
}
}

View File

@@ -43,6 +43,11 @@ type quoteCtx struct {
hash string
}
type quotePaymentResult struct {
quote *sharedv1.PaymentQuote
executionNote string
}
func (h *quotePaymentCommand) Execute(
ctx context.Context,
req *quotationv1.QuotePaymentRequest,
@@ -65,14 +70,15 @@ func (h *quotePaymentCommand) Execute(
return gsresponse.Unavailable[quotationv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
quoteProto, err := h.quotePayment(ctx, quotesStore, qc, req)
result, err := h.quotePayment(ctx, quotesStore, qc, req)
if err != nil {
return h.mapQuoteErr(err)
}
return gsresponse.Success(&quotationv1.QuotePaymentResponse{
IdempotencyKey: req.GetIdempotencyKey(),
Quote: quoteProto,
Quote: result.quote,
ExecutionNote: result.executionNote,
})
}
@@ -111,7 +117,7 @@ func (h *quotePaymentCommand) quotePayment(
quotesStore storage.QuotesStore,
qc *quoteCtx,
req *quotationv1.QuotePaymentRequest,
) (*sharedv1.PaymentQuote, error) {
) (*quotePaymentResult, error) {
if qc.previewOnly {
quote, _, err := h.engine.BuildPaymentQuote(ctx, qc.orgID, req)
@@ -120,7 +126,7 @@ func (h *quotePaymentCommand) quotePayment(
return nil, err
}
quote.QuoteRef = bson.NewObjectID().Hex()
return quote, nil
return &quotePaymentResult{quote: quote}, nil
}
existing, err := quotesStore.GetByIdempotencyKey(ctx, qc.orgRef, qc.idempotencyKey)
@@ -140,7 +146,10 @@ func (h *quotePaymentCommand) quotePayment(
zap.String("idempotency_key", qc.idempotencyKey),
zap.String("quote_ref", existing.QuoteRef),
)
return modelQuoteToProto(existing.Quote), nil
return &quotePaymentResult{
quote: modelQuoteToProto(existing.Quote),
executionNote: strings.TrimSpace(existing.ExecutionNote),
}, nil
}
quote, expiresAt, err := h.engine.BuildPaymentQuote(ctx, qc.orgID, req)
@@ -157,17 +166,28 @@ func (h *quotePaymentCommand) quotePayment(
quoteRef := bson.NewObjectID().Hex()
quote.QuoteRef = quoteRef
executionNote := ""
plan, err := h.engine.BuildPaymentPlan(ctx, qc.orgRef, qc.intent, qc.idempotencyKey, quote)
if err != nil {
h.logger.Warn(
"Failed to build payment plan",
zap.Error(err),
mzap.ObjRef("org_ref", qc.orgRef),
zap.String("idempotency_key", qc.idempotencyKey),
)
return nil, err
if errors.Is(err, merrors.ErrInvalidArg) {
executionNote = quoteNonExecutableNote(err)
h.logger.Info(
"Payment quote marked as non-executable",
mzap.ObjRef("org_ref", qc.orgRef),
zap.String("idempotency_key", qc.idempotencyKey),
zap.String("quote_ref", quoteRef),
zap.String("execution_note", executionNote),
)
} else {
h.logger.Warn(
"Failed to build payment plan",
zap.Error(err),
mzap.ObjRef("org_ref", qc.orgRef),
zap.String("idempotency_key", qc.idempotencyKey),
)
return nil, err
}
}
record := &model.PaymentQuoteRecord{
QuoteRef: quoteRef,
IdempotencyKey: qc.idempotencyKey,
@@ -175,6 +195,7 @@ func (h *quotePaymentCommand) quotePayment(
Intent: intentFromProto(qc.intent),
Quote: quoteSnapshotToModel(quote),
Plan: cloneStoredPaymentPlan(plan),
ExecutionNote: executionNote,
ExpiresAt: expiresAt,
}
record.SetID(bson.NewObjectID())
@@ -187,7 +208,10 @@ func (h *quotePaymentCommand) quotePayment(
if existing.Hash != qc.hash {
return nil, errIdempotencyParamMismatch
}
return modelQuoteToProto(existing.Quote), nil
return &quotePaymentResult{
quote: modelQuoteToProto(existing.Quote),
executionNote: strings.TrimSpace(existing.ExecutionNote),
}, nil
}
}
return nil, err
@@ -201,7 +225,10 @@ func (h *quotePaymentCommand) quotePayment(
zap.String("kind", qc.intent.GetKind().String()),
)
return quote, nil
return &quotePaymentResult{
quote: quote,
executionNote: executionNote,
}, nil
}
func (h *quotePaymentCommand) mapQuoteErr(err error) gsresponse.Responder[quotationv1.QuotePaymentResponse] {
@@ -213,6 +240,16 @@ func (h *quotePaymentCommand) mapQuoteErr(err error) gsresponse.Responder[quotat
return gsresponse.Auto[quotationv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
func quoteNonExecutableNote(err error) string {
reason := strings.TrimSpace(err.Error())
reason = strings.TrimPrefix(reason, merrors.ErrInvalidArg.Error()+":")
reason = strings.TrimSpace(reason)
if reason == "" {
return "quote will not be executed"
}
return "quote will not be executed: " + reason
}
// TODO: temprorarary hashing function, replace with a proper solution later
func hashQuoteRequest(req *quotationv1.QuotePaymentRequest) string {
cloned := proto.Clone(req).(*quotationv1.QuotePaymentRequest)

View File

@@ -0,0 +1,193 @@
package quotation
import (
"context"
"strings"
"testing"
"time"
"github.com/tech/sendico/payments/storage"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/merrors"
mloggerfactory "github.com/tech/sendico/pkg/mlogger/factory"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1"
sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1"
"go.mongodb.org/mongo-driver/v2/bson"
)
func TestQuotePaymentStoresNonExecutableQuoteWhenPlanInvalid(t *testing.T) {
org := bson.NewObjectID()
req := &quotationv1.QuotePaymentRequest{
Meta: &sharedv1.RequestMeta{OrganizationRef: org.Hex()},
IdempotencyKey: "idem-1",
Intent: &sharedv1.PaymentIntent{
Kind: sharedv1.PaymentKind_PAYMENT_KIND_PAYOUT,
Amount: &moneyv1.Money{Currency: "USD", Amount: "1"},
SettlementCurrency: "USD",
},
}
quotesStore := &quoteCommandTestQuotesStore{
byID: make(map[string]*model.PaymentQuoteRecord),
}
engine := &quoteCommandTestEngine{
repo: quoteCommandTestRepo{quotes: quotesStore},
buildQuoteFn: func(context.Context, string, *quotationv1.QuotePaymentRequest) (*sharedv1.PaymentQuote, time.Time, error) {
return &sharedv1.PaymentQuote{
DebitAmount: &moneyv1.Money{Currency: "USD", Amount: "1"},
}, time.Now().Add(time.Hour), nil
},
buildPlanFn: func(context.Context, bson.ObjectID, *sharedv1.PaymentIntent, string, *sharedv1.PaymentQuote) (*model.PaymentPlan, error) {
return nil, merrors.InvalidArgument("plan builder: no eligible gateway instance found, last error: gateway mntx eligibility check error: amount 1 USD below per-tx min limit 10")
},
}
cmd := &quotePaymentCommand{
engine: engine,
logger: mloggerfactory.NewLogger(false),
}
resp, err := cmd.Execute(context.Background(), req)(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp == nil || resp.GetQuote() == nil {
t.Fatalf("expected quote response, got %#v", resp)
}
if note := resp.GetExecutionNote(); !strings.Contains(note, "quote will not be executed") {
t.Fatalf("expected non-executable note, got %q", note)
}
stored := quotesStore.byID[req.GetIdempotencyKey()]
if stored == nil {
t.Fatalf("expected stored quote record")
}
if stored.Plan != nil {
t.Fatalf("expected no stored payment plan for non-executable quote")
}
if stored.ExecutionNote != resp.GetExecutionNote() {
t.Fatalf("expected stored execution note %q, got %q", resp.GetExecutionNote(), stored.ExecutionNote)
}
}
func TestQuotePaymentReuseReturnsStoredExecutionNote(t *testing.T) {
org := bson.NewObjectID()
req := &quotationv1.QuotePaymentRequest{
Meta: &sharedv1.RequestMeta{OrganizationRef: org.Hex()},
IdempotencyKey: "idem-1",
Intent: &sharedv1.PaymentIntent{
Kind: sharedv1.PaymentKind_PAYMENT_KIND_PAYOUT,
Amount: &moneyv1.Money{Currency: "USD", Amount: "1"},
SettlementCurrency: "USD",
},
}
existing := &model.PaymentQuoteRecord{
QuoteRef: "q1",
IdempotencyKey: req.GetIdempotencyKey(),
Hash: hashQuoteRequest(req),
Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q1"},
ExecutionNote: "quote will not be executed: amount 1 USD below per-tx min limit 10",
}
quotesStore := &quoteCommandTestQuotesStore{
byID: map[string]*model.PaymentQuoteRecord{
req.GetIdempotencyKey(): existing,
},
}
engine := &quoteCommandTestEngine{
repo: quoteCommandTestRepo{quotes: quotesStore},
buildQuoteFn: func(context.Context, string, *quotationv1.QuotePaymentRequest) (*sharedv1.PaymentQuote, time.Time, error) {
t.Fatalf("build quote should not be called on idempotent reuse")
return nil, time.Time{}, nil
},
buildPlanFn: func(context.Context, bson.ObjectID, *sharedv1.PaymentIntent, string, *sharedv1.PaymentQuote) (*model.PaymentPlan, error) {
t.Fatalf("build plan should not be called on idempotent reuse")
return nil, nil
},
}
cmd := &quotePaymentCommand{
engine: engine,
logger: mloggerfactory.NewLogger(false),
}
resp, err := cmd.Execute(context.Background(), req)(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp == nil {
t.Fatalf("expected response")
}
if got, want := resp.GetExecutionNote(), existing.ExecutionNote; got != want {
t.Fatalf("expected execution note %q, got %q", want, got)
}
if resp.GetQuote().GetQuoteRef() != "q1" {
t.Fatalf("expected quote_ref q1, got %q", resp.GetQuote().GetQuoteRef())
}
}
type quoteCommandTestEngine struct {
repo storage.Repository
ensureErr error
buildQuoteFn func(ctx context.Context, orgRef string, req *quotationv1.QuotePaymentRequest) (*sharedv1.PaymentQuote, time.Time, error)
buildPlanFn func(ctx context.Context, orgID bson.ObjectID, intent *sharedv1.PaymentIntent, idempotencyKey string, quote *sharedv1.PaymentQuote) (*model.PaymentPlan, error)
}
func (e *quoteCommandTestEngine) EnsureRepository(context.Context) error { return e.ensureErr }
func (e *quoteCommandTestEngine) BuildPaymentQuote(ctx context.Context, orgRef string, req *quotationv1.QuotePaymentRequest) (*sharedv1.PaymentQuote, time.Time, error) {
if e.buildQuoteFn == nil {
return nil, time.Time{}, nil
}
return e.buildQuoteFn(ctx, orgRef, req)
}
func (e *quoteCommandTestEngine) BuildPaymentPlan(ctx context.Context, orgID bson.ObjectID, intent *sharedv1.PaymentIntent, idempotencyKey string, quote *sharedv1.PaymentQuote) (*model.PaymentPlan, error) {
if e.buildPlanFn == nil {
return nil, nil
}
return e.buildPlanFn(ctx, orgID, intent, idempotencyKey, quote)
}
func (e *quoteCommandTestEngine) ResolvePaymentQuote(context.Context, quoteResolutionInput) (*sharedv1.PaymentQuote, *sharedv1.PaymentIntent, *model.PaymentPlan, error) {
return nil, nil, nil, nil
}
func (e *quoteCommandTestEngine) Repository() storage.Repository { return e.repo }
type quoteCommandTestRepo struct {
quotes storage.QuotesStore
}
func (r quoteCommandTestRepo) Ping(context.Context) error { return nil }
func (r quoteCommandTestRepo) Payments() storage.PaymentsStore { return nil }
func (r quoteCommandTestRepo) Quotes() storage.QuotesStore { return r.quotes }
func (r quoteCommandTestRepo) Routes() storage.RoutesStore { return nil }
func (r quoteCommandTestRepo) PlanTemplates() storage.PlanTemplatesStore { return nil }
type quoteCommandTestQuotesStore struct {
byID map[string]*model.PaymentQuoteRecord
}
func (s *quoteCommandTestQuotesStore) Create(_ context.Context, rec *model.PaymentQuoteRecord) error {
if s.byID == nil {
s.byID = make(map[string]*model.PaymentQuoteRecord)
}
s.byID[rec.IdempotencyKey] = rec
return nil
}
func (s *quoteCommandTestQuotesStore) GetByRef(_ context.Context, _ bson.ObjectID, quoteRef string) (*model.PaymentQuoteRecord, error) {
for _, rec := range s.byID {
if rec != nil && rec.QuoteRef == quoteRef {
return rec, nil
}
}
return nil, storage.ErrQuoteNotFound
}
func (s *quoteCommandTestQuotesStore) GetByIdempotencyKey(_ context.Context, _ bson.ObjectID, idempotencyKey string) (*model.PaymentQuoteRecord, error) {
if rec, ok := s.byID[idempotencyKey]; ok {
return rec, nil
}
return nil, storage.ErrQuoteNotFound
}

View File

@@ -85,6 +85,9 @@ func (s *Service) resolvePaymentQuote(ctx context.Context, in quoteResolutionInp
if !record.ExpiresAt.IsZero() && s.clock.Now().After(record.ExpiresAt) {
return nil, nil, nil, quoteResolutionError{code: "quote_expired", err: merrors.InvalidArgument("quote_ref expired")}
}
if note := strings.TrimSpace(record.ExecutionNote); note != "" {
return nil, nil, nil, quoteResolutionError{code: "quote_not_executable", err: merrors.InvalidArgument(note)}
}
intent, err := recordIntentFromQuote(record)
if err != nil {
return nil, nil, nil, err