fixed quotation currency inference

This commit is contained in:
Stephan D
2026-03-04 04:50:31 +01:00
parent 9b794a3065
commit de07b9a792
35 changed files with 928 additions and 182 deletions

View File

@@ -51,6 +51,12 @@ func (i *Imp) initDependencies(cfg *config) *clientDependencies {
i.discoveryClients = newDiscoveryClientResolver(i.logger, i.discoveryReg)
deps.gatewayResolver = discoveryChainGatewayResolver{resolver: i.discoveryClients}
deps.gatewayInvokeResolver = discoveryGatewayInvokeResolver{resolver: i.discoveryClients}
ledgerClient, ledgerErr := i.discoveryClients.LedgerClient(context.Background())
if ledgerErr != nil {
i.logger.Warn("Failed to initialise ledger client from discovery", zap.Error(ledgerErr))
} else {
deps.ledgerClient = ledgerClient
}
} else if i != nil && i.logger != nil {
i.logger.Warn("Discovery registry unavailable; chain gateway clients disabled")
}

View File

@@ -11,9 +11,11 @@ import (
"time"
chainclient "github.com/tech/sendico/gateway/chain/client"
ledgerclient "github.com/tech/sendico/ledger/client"
"github.com/tech/sendico/pkg/discovery"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
"go.uber.org/zap"
)
@@ -35,7 +37,9 @@ type discoveryClientResolver struct {
mu sync.Mutex
chainClients map[string]chainclient.Client
chainClients map[string]chainclient.Client
ledgerClient ledgerclient.Client
ledgerEndpoint discoveryEndpoint
lastSelection map[string]string
lastMissing map[string]time.Time
@@ -66,6 +70,10 @@ func (r *discoveryClientResolver) Close() {
}
delete(r.chainClients, key)
}
if r.ledgerClient != nil {
_ = r.ledgerClient.Close()
r.ledgerClient = nil
}
}
type discoveryGatewayInvokeResolver struct {
@@ -130,6 +138,43 @@ func (r *discoveryClientResolver) ChainClientByNetwork(ctx context.Context, netw
return r.ChainClientByInvokeURI(ctx, entry.InvokeURI)
}
func (r *discoveryClientResolver) LedgerClient(ctx context.Context) (ledgerclient.Client, error) {
entry, ok := r.findLedgerEntry()
if !ok {
return nil, merrors.NoData("discovery: ledger service unavailable")
}
endpoint, err := parseDiscoveryEndpoint(entry.InvokeURI)
if err != nil {
r.logMissing("ledger", "invalid ledger invoke uri", entry.InvokeURI, err)
return nil, err
}
if ctx == nil {
ctx = context.Background()
}
r.mu.Lock()
defer r.mu.Unlock()
if r.ledgerClient == nil || r.ledgerEndpoint.key() != endpoint.key() || r.ledgerEndpoint.address != endpoint.address {
if r.ledgerClient != nil {
_ = r.ledgerClient.Close()
r.ledgerClient = nil
}
client, dialErr := ledgerclient.New(ctx, ledgerclient.Config{
Address: endpoint.address,
Insecure: endpoint.insecure,
})
if dialErr != nil {
r.logMissing("ledger", "failed to dial ledger service", endpoint.raw, dialErr)
return nil, dialErr
}
r.ledgerClient = client
r.ledgerEndpoint = endpoint
}
return r.ledgerClient, nil
}
func (r *discoveryClientResolver) findChainEntry(network string) (*discovery.RegistryEntry, bool) {
if r == nil || r.registry == nil {
r.logMissing("chain", "discovery registry unavailable", "", nil)
@@ -172,6 +217,44 @@ func (r *discoveryClientResolver) findChainEntry(network string) (*discovery.Reg
return &entry, true
}
func (r *discoveryClientResolver) findLedgerEntry() (*discovery.RegistryEntry, bool) {
if r == nil || r.registry == nil {
r.logMissing("ledger", "discovery registry unavailable", "", nil)
return nil, false
}
entries := r.registry.List(time.Now(), true)
matches := make([]discovery.RegistryEntry, 0)
for _, entry := range entries {
if !strings.EqualFold(strings.TrimSpace(entry.Service), string(mservice.Ledger)) {
continue
}
if strings.TrimSpace(entry.InvokeURI) == "" {
continue
}
matches = append(matches, entry)
}
if len(matches) == 0 {
r.logMissing("ledger", "discovery ledger entry missing", "", nil)
return nil, false
}
sort.Slice(matches, func(i, j int) bool {
if matches[i].RoutingPriority != matches[j].RoutingPriority {
return matches[i].RoutingPriority > matches[j].RoutingPriority
}
if matches[i].ID != matches[j].ID {
return matches[i].ID < matches[j].ID
}
return matches[i].InstanceID < matches[j].InstanceID
})
entry := matches[0]
entryKey := discoveryEntryKey(entry)
r.logSelection("ledger", entryKey, entry)
return &entry, true
}
func (r *discoveryClientResolver) logSelection(key, entryKey string, entry discovery.RegistryEntry) {
if r == nil {
return

View File

@@ -51,6 +51,9 @@ func (i *Imp) Start() error {
if i.deps.oracleClient != nil {
opts = append(opts, quotesvc.WithOracleClient(i.deps.oracleClient))
}
if i.deps.ledgerClient != nil {
opts = append(opts, quotesvc.WithLedgerClient(i.deps.ledgerClient))
}
if i.deps.gatewayResolver != nil {
opts = append(opts, quotesvc.WithChainGatewayResolver(i.deps.gatewayResolver))
}

View File

@@ -2,6 +2,7 @@ package serverimp
import (
oracleclient "github.com/tech/sendico/fx/oracle/client"
ledgerclient "github.com/tech/sendico/ledger/client"
quotesvc "github.com/tech/sendico/payments/quotation/internal/service/quotation"
"github.com/tech/sendico/payments/storage"
"github.com/tech/sendico/pkg/discovery"
@@ -20,6 +21,7 @@ type clientDependencies struct {
feesConn *grpc.ClientConn
feesClient feesv1.FeeEngineClient
oracleClient oracleclient.Client
ledgerClient ledgerclient.Client
gatewayResolver quotesvc.ChainGatewayResolver
gatewayInvokeResolver quotesvc.GatewayInvokeResolver
}

View File

@@ -0,0 +1,90 @@
package quotation
import (
"context"
"strings"
"github.com/tech/sendico/pkg/merrors"
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
"go.uber.org/zap"
)
type ledgerAccountCurrencyResolver struct {
client ledgerClientForCurrency
logger *zap.Logger
}
type ledgerClientForCurrency interface {
GetBalance(ctx context.Context, req *ledgerv1.GetBalanceRequest) (*ledgerv1.BalanceResponse, error)
ListAccounts(ctx context.Context, req *ledgerv1.ListAccountsRequest) (*ledgerv1.ListAccountsResponse, error)
}
func newLedgerAccountCurrencyResolver(core *Service) *ledgerAccountCurrencyResolver {
if core == nil || core.deps.ledger == nil {
return nil
}
logger := core.logger
if logger == nil {
logger = zap.NewNop()
}
return &ledgerAccountCurrencyResolver{
client: core.deps.ledger,
logger: logger,
}
}
func (r *ledgerAccountCurrencyResolver) ResolveLedgerAccountCurrency(
ctx context.Context,
organizationRef string,
ledgerAccountRef string,
) (string, error) {
if r == nil || r.client == nil {
return "", merrors.NoData("ledger client unavailable")
}
accountRef := strings.TrimSpace(ledgerAccountRef)
if accountRef == "" {
return "", merrors.InvalidArgument("ledger_account_ref is required")
}
balanceResp, err := r.client.GetBalance(ctx, &ledgerv1.GetBalanceRequest{
LedgerAccountRef: accountRef,
})
if err != nil {
return "", err
}
if balance := balanceResp.GetBalance(); balance != nil {
if currency := strings.ToUpper(strings.TrimSpace(balance.GetCurrency())); currency != "" {
return currency, nil
}
}
orgRef := strings.TrimSpace(organizationRef)
if orgRef == "" {
return "", merrors.NoData("ledger account currency is missing")
}
listResp, err := r.client.ListAccounts(ctx, &ledgerv1.ListAccountsRequest{
OrganizationRef: orgRef,
})
if err != nil {
return "", err
}
for _, account := range listResp.GetAccounts() {
if strings.TrimSpace(account.GetLedgerAccountRef()) != accountRef {
continue
}
if currency := strings.ToUpper(strings.TrimSpace(account.GetCurrency())); currency != "" {
return currency, nil
}
break
}
if r.logger != nil {
r.logger.Warn("Failed to resolve ledger account currency",
zap.String("organization_ref", orgRef),
zap.String("ledger_account_ref", accountRef),
)
}
return "", merrors.NoData("ledger account currency is missing")
}

View File

@@ -159,9 +159,13 @@ func WithClock(clock clockpkg.Clock) Option {
}
}
// WithLedgerClient is retained for backward compatibility and is currently a no-op.
func WithLedgerClient(_ ledgerclient.Client) Option {
return func(*Service) {}
// WithLedgerClient wires the ledger client used for account-currency inference.
func WithLedgerClient(client ledgerclient.Client) Option {
return func(s *Service) {
if s != nil && client != nil {
s.deps.ledger = client
}
}
}
// WithProviderSettlementGatewayClient is retained for backward compatibility and is currently a no-op.

View File

@@ -9,6 +9,7 @@ import (
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
endpointv1 "github.com/tech/sendico/pkg/proto/payments/endpoint/v1"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
@@ -29,6 +30,9 @@ type quoteIntentLogSummary struct {
SettlementMode string `json:"settlementMode,omitempty"`
FeeTreatment string `json:"feeTreatment,omitempty"`
SettlementCurrency string `json:"settlementCurrency,omitempty"`
HasFX bool `json:"hasFx"`
FXPair string `json:"fxPair,omitempty"`
FXSide string `json:"fxSide,omitempty"`
HasComment bool `json:"hasComment"`
}
@@ -59,6 +63,7 @@ func summarizeQuoteIntent(intent *quotationv2.QuoteIntent) *quoteIntentLogSummar
if intent == nil {
return nil
}
fxPair, fxSide, hasFX := summarizeFXIntent(intent.GetFx())
return &quoteIntentLogSummary{
Source: summarizeEndpoint(intent.GetSource()),
Destination: summarizeEndpoint(intent.GetDestination()),
@@ -66,6 +71,9 @@ func summarizeQuoteIntent(intent *quotationv2.QuoteIntent) *quoteIntentLogSummar
SettlementMode: enumLogValue(intent.GetSettlementMode().String()),
FeeTreatment: enumLogValue(intent.GetFeeTreatment().String()),
SettlementCurrency: strings.ToUpper(strings.TrimSpace(intent.GetSettlementCurrency())),
HasFX: hasFX,
FXPair: fxPair,
FXSide: fxSide,
HasComment: strings.TrimSpace(intent.GetComment()) != "",
}
}
@@ -142,3 +150,15 @@ func moneyLogValue(m *moneyv1.Money) string {
func enumLogValue(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func summarizeFXIntent(fx *sharedv1.FXIntent) (string, string, bool) {
if fx == nil || fx.GetPair() == nil {
return "", "", false
}
base := strings.ToUpper(strings.TrimSpace(fx.GetPair().GetBase()))
quote := strings.ToUpper(strings.TrimSpace(fx.GetPair().GetQuote()))
if base == "" && quote == "" {
return "", enumLogValue(fx.GetSide().String()), false
}
return strings.TrimSpace(base + "/" + quote), enumLogValue(fx.GetSide().String()), true
}

View File

@@ -56,6 +56,9 @@ func newQuoteComputationService(core *Service) *quote_computation_service.QuoteC
if resolver := newManagedWalletNetworkResolver(core); resolver != nil {
opts = append(opts, quote_computation_service.WithManagedWalletNetworkResolver(resolver))
}
if resolver := newLedgerAccountCurrencyResolver(core); resolver != nil {
opts = append(opts, quote_computation_service.WithLedgerAccountCurrencyResolver(resolver))
}
if resolver := fundingProfileResolver(core); resolver != nil {
opts = append(opts, quote_computation_service.WithFundingProfileResolver(resolver))
}

View File

@@ -0,0 +1,87 @@
package quote_computation_service
import (
"context"
"strings"
"github.com/tech/sendico/payments/storage/model"
)
type endpointCurrencyInference struct {
SourceCurrency string
DestinationCurrency string
SourceInferred bool
DestinationInferred bool
}
func (s *QuoteComputationService) inferEndpointCurrencies(
ctx context.Context,
organizationRef string,
intent model.PaymentIntent,
ledgerCurrencyCache map[string]string,
) (endpointCurrencyInference, error) {
sourceCurrency, sourceInferred, err := s.inferEndpointCurrency(
ctx,
organizationRef,
intent.Source,
ledgerCurrencyCache,
)
if err != nil {
return endpointCurrencyInference{}, err
}
destinationCurrency, destinationInferred, err := s.inferEndpointCurrency(
ctx,
organizationRef,
intent.Destination,
ledgerCurrencyCache,
)
if err != nil {
return endpointCurrencyInference{}, err
}
return endpointCurrencyInference{
SourceCurrency: sourceCurrency,
DestinationCurrency: destinationCurrency,
SourceInferred: sourceInferred,
DestinationInferred: destinationInferred,
}, nil
}
func (s *QuoteComputationService) inferEndpointCurrency(
ctx context.Context,
organizationRef string,
endpoint model.PaymentEndpoint,
ledgerCurrencyCache map[string]string,
) (string, bool, error) {
if token := sourceAssetToken(endpoint); token != "" {
return token, true, nil
}
if endpoint.Ledger == nil {
return "", false, nil
}
ledgerAccountRef := strings.TrimSpace(endpoint.Ledger.LedgerAccountRef)
if ledgerAccountRef == "" {
return "", false, nil
}
if cached := normalizeAsset(ledgerCurrencyCache[ledgerAccountRef]); cached != "" {
return cached, true, nil
}
if s == nil || s.ledgerAccountCurrencyResolver == nil {
return "", false, nil
}
currency, err := s.ledgerAccountCurrencyResolver.ResolveLedgerAccountCurrency(
ctx,
strings.TrimSpace(organizationRef),
ledgerAccountRef,
)
if err != nil {
return "", false, err
}
currency = normalizeAsset(currency)
if currency == "" {
return "", false, nil
}
if ledgerCurrencyCache != nil {
ledgerCurrencyCache[ledgerAccountRef] = currency
}
return currency, true, nil
}

View File

@@ -0,0 +1,86 @@
package quote_computation_service
import (
"context"
"testing"
"github.com/tech/sendico/payments/storage/model"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
)
func TestInferEndpointCurrencies_UsesEndpointAssets(t *testing.T) {
svc := New(nil)
out, err := svc.inferEndpointCurrencies(context.Background(), "org-1", model.PaymentIntent{
Source: model.PaymentEndpoint{
Type: model.EndpointTypeManagedWallet,
ManagedWallet: &model.ManagedWalletEndpoint{
ManagedWalletRef: "mw-src",
Asset: &paymenttypes.Asset{TokenSymbol: "USDT"},
},
},
Destination: model.PaymentEndpoint{
Type: model.EndpointTypeExternalChain,
ExternalChain: &model.ExternalChainEndpoint{
Asset: &paymenttypes.Asset{TokenSymbol: "RUB"},
},
},
}, map[string]string{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got, want := out.SourceCurrency, "USDT"; got != want {
t.Fatalf("unexpected source currency: got=%q want=%q", got, want)
}
if got, want := out.DestinationCurrency, "RUB"; got != want {
t.Fatalf("unexpected destination currency: got=%q want=%q", got, want)
}
if !out.SourceInferred || !out.DestinationInferred {
t.Fatalf("expected both currencies inferred: %#v", out)
}
}
func TestInferEndpointCurrencies_UsesLedgerResolver(t *testing.T) {
resolver := &fakeLedgerCurrencyResolver{
currencies: map[string]string{
"ledger-src": "USDT",
"ledger-dst": "RUB",
},
}
svc := New(nil, WithLedgerAccountCurrencyResolver(resolver))
out, err := svc.inferEndpointCurrencies(context.Background(), "org-1", model.PaymentIntent{
Source: model.PaymentEndpoint{
Type: model.EndpointTypeLedger,
Ledger: &model.LedgerEndpoint{
LedgerAccountRef: "ledger-src",
},
},
Destination: model.PaymentEndpoint{
Type: model.EndpointTypeLedger,
Ledger: &model.LedgerEndpoint{
LedgerAccountRef: "ledger-dst",
},
},
}, map[string]string{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got, want := out.SourceCurrency, "USDT"; got != want {
t.Fatalf("unexpected source currency: got=%q want=%q", got, want)
}
if got, want := out.DestinationCurrency, "RUB"; got != want {
t.Fatalf("unexpected destination currency: got=%q want=%q", got, want)
}
if resolver.calls != 2 {
t.Fatalf("unexpected resolver calls: got=%d want=%d", resolver.calls, 2)
}
}
type fakeLedgerCurrencyResolver struct {
currencies map[string]string
calls int
}
func (f *fakeLedgerCurrencyResolver) ResolveLedgerAccountCurrency(_ context.Context, _ string, ledgerAccountRef string) (string, error) {
f.calls++
return f.currencies[ledgerAccountRef], nil
}

View File

@@ -8,6 +8,15 @@ import (
paymenttypes "github.com/tech/sendico/pkg/payments/types"
)
type fxDerivationResult struct {
InferredSourceCurrency string
InferredDestinationCurrency string
EffectiveSourceCurrency string
EffectiveDestinationCurrency string
ExplicitOverrideApplied bool
RequiresFXInferred bool
}
func modelIntentFromQuoteIntent(src *transfer_intent_hydrator.QuoteIntent) model.PaymentIntent {
if src == nil {
return model.PaymentIntent{}
@@ -33,18 +42,44 @@ func modelIntentFromQuoteIntent(src *transfer_intent_hydrator.QuoteIntent) model
}
func fxIntentFromHydratedIntent(src *transfer_intent_hydrator.QuoteIntent) *model.FXIntent {
if src == nil {
if src == nil || src.FX == nil {
return nil
}
if strings.TrimSpace(string(src.FXSide)) == "" || src.FXSide == paymenttypes.FXSideUnspecified {
result := &model.FXIntent{
Side: src.FX.Side,
Firm: src.FX.Firm,
TTLMillis: src.FX.TTLms,
PreferredProvider: strings.TrimSpace(src.FX.PreferredProvider),
MaxAgeMillis: src.FX.MaxAgeMs,
}
if src.FX.Pair != nil {
result.Pair = &paymenttypes.CurrencyPair{
Base: normalizeAsset(src.FX.Pair.GetBase()),
Quote: normalizeAsset(src.FX.Pair.GetQuote()),
}
}
if result.Side == paymenttypes.FXSideUnspecified &&
result.Pair == nil &&
!result.Firm &&
result.TTLMillis == 0 &&
result.PreferredProvider == "" &&
result.MaxAgeMillis == 0 {
return nil
}
return &model.FXIntent{Side: src.FXSide}
return result
}
func ensureDerivedFXIntent(intent *model.PaymentIntent) {
func ensureDerivedFXIntent(
intent *model.PaymentIntent,
inferredSourceCurrency string,
inferredDestinationCurrency string,
) fxDerivationResult {
result := fxDerivationResult{
InferredSourceCurrency: normalizeAsset(inferredSourceCurrency),
InferredDestinationCurrency: normalizeAsset(inferredDestinationCurrency),
}
if intent == nil {
return
return result
}
amountCurrency := ""
@@ -52,6 +87,9 @@ func ensureDerivedFXIntent(intent *model.PaymentIntent) {
amountCurrency = normalizeAsset(intent.Amount.GetCurrency())
}
settlementCurrency := normalizeAsset(intent.SettlementCurrency)
if result.InferredDestinationCurrency != "" {
settlementCurrency = result.InferredDestinationCurrency
}
if settlementCurrency == "" {
settlementCurrency = amountCurrency
}
@@ -59,42 +97,98 @@ func ensureDerivedFXIntent(intent *model.PaymentIntent) {
intent.SettlementCurrency = settlementCurrency
}
sourceCurrency := sourceAssetToken(intent.Source)
sourceCurrency := firstNonEmpty(result.InferredSourceCurrency, sourceAssetToken(intent.Source))
sourceCurrencyBeforeExplicit := sourceCurrency
settlementCurrencyBeforeExplicit := settlementCurrency
requiresFXBeforeExplicit := intent.RequiresFX
// For FIX_RECEIVED, destination amounts can be provided in payout currency.
// Derive FX necessity from source asset currency when available.
if !intent.RequiresFX &&
intent.SettlementMode == model.SettlementModeFixReceived &&
explicitSourceCurrency, explicitDestinationCurrency := fxTradeCurrencies(intent.FX)
if explicitSourceCurrency != "" && explicitDestinationCurrency != "" {
sourceCurrency = explicitSourceCurrency
settlementCurrency = explicitDestinationCurrency
intent.SettlementCurrency = settlementCurrency
intent.RequiresFX = true
if sourceCurrencyBeforeExplicit == "" ||
settlementCurrencyBeforeExplicit == "" ||
!strings.EqualFold(sourceCurrencyBeforeExplicit, explicitSourceCurrency) ||
!strings.EqualFold(settlementCurrencyBeforeExplicit, explicitDestinationCurrency) ||
!requiresFXBeforeExplicit {
result.ExplicitOverrideApplied = true
}
} else if !intent.RequiresFX &&
sourceCurrency != "" &&
settlementCurrency != "" &&
!strings.EqualFold(sourceCurrency, settlementCurrency) {
intent.RequiresFX = true
result.RequiresFXInferred = true
}
if !intent.RequiresFX {
return
result.EffectiveSourceCurrency = sourceCurrency
result.EffectiveDestinationCurrency = settlementCurrency
return result
}
baseCurrency := firstNonEmpty(sourceCurrency, amountCurrency)
quoteCurrency := settlementCurrency
if baseCurrency == "" || quoteCurrency == "" {
return
result.EffectiveSourceCurrency = sourceCurrency
result.EffectiveDestinationCurrency = settlementCurrency
return result
}
if intent.FX == nil {
intent.FX = &model.FXIntent{}
}
if strings.TrimSpace(string(intent.FX.Side)) == "" || intent.FX.Side == paymenttypes.FXSideUnspecified {
intent.FX.Side = paymenttypes.FXSideSellBaseBuyQuote
}
if intent.FX.Pair == nil {
intent.FX.Pair = &paymenttypes.CurrencyPair{}
}
desiredBase, desiredQuote := fxPairFromTradeCurrencies(intent.FX.Side, baseCurrency, quoteCurrency)
if normalizeAsset(intent.FX.Pair.Base) == "" {
intent.FX.Pair.Base = baseCurrency
intent.FX.Pair.Base = desiredBase
}
if normalizeAsset(intent.FX.Pair.Quote) == "" {
intent.FX.Pair.Quote = quoteCurrency
intent.FX.Pair.Quote = desiredQuote
}
if strings.TrimSpace(string(intent.FX.Side)) == "" || intent.FX.Side == paymenttypes.FXSideUnspecified {
intent.FX.Side = paymenttypes.FXSideSellBaseBuyQuote
result.EffectiveSourceCurrency, result.EffectiveDestinationCurrency = fxTradeCurrencies(intent.FX)
if result.EffectiveSourceCurrency == "" {
result.EffectiveSourceCurrency = sourceCurrency
}
if result.EffectiveDestinationCurrency == "" {
result.EffectiveDestinationCurrency = settlementCurrency
}
return result
}
func fxPairFromTradeCurrencies(side paymenttypes.FXSide, sourceCurrency, destinationCurrency string) (string, string) {
sourceCurrency = normalizeAsset(sourceCurrency)
destinationCurrency = normalizeAsset(destinationCurrency)
switch side {
case paymenttypes.FXSideBuyBaseSellQuote:
return destinationCurrency, sourceCurrency
default:
return sourceCurrency, destinationCurrency
}
}
func fxTradeCurrencies(fx *model.FXIntent) (string, string) {
if fx == nil || fx.Pair == nil {
return "", ""
}
base := normalizeAsset(fx.Pair.GetBase())
quote := normalizeAsset(fx.Pair.GetQuote())
if base == "" || quote == "" {
return "", ""
}
switch fx.Side {
case paymenttypes.FXSideBuyBaseSellQuote:
return quote, base
default:
return base, quote
}
}

View File

@@ -16,7 +16,7 @@ func TestEnsureDerivedFXIntent_DefaultsSideWhenEmpty(t *testing.T) {
FX: &model.FXIntent{},
}
ensureDerivedFXIntent(intent)
ensureDerivedFXIntent(intent, "", "")
if intent.FX == nil {
t.Fatal("expected fx intent")
@@ -34,7 +34,7 @@ func TestEnsureDerivedFXIntent_DefaultsSideWhenUnspecified(t *testing.T) {
FX: &model.FXIntent{Side: paymenttypes.FXSideUnspecified},
}
ensureDerivedFXIntent(intent)
ensureDerivedFXIntent(intent, "", "")
if intent.FX == nil {
t.Fatal("expected fx intent")
@@ -56,11 +56,17 @@ func TestEnsureDerivedFXIntent_PreservesExplicitSideFromHydratedIntent(t *testin
Amount: &paymenttypes.Money{Amount: "100", Currency: "USDT"},
SettlementCurrency: "RUB",
RequiresFX: true,
FXSide: paymenttypes.FXSideBuyBaseSellQuote,
FX: &transfer_intent_hydrator.QuoteFXIntent{
Pair: &paymenttypes.CurrencyPair{
Base: "RUB",
Quote: "USDT",
},
Side: paymenttypes.FXSideBuyBaseSellQuote,
},
}
intent := modelIntentFromQuoteIntent(hydrated)
ensureDerivedFXIntent(&intent)
ensureDerivedFXIntent(&intent, "", "")
if intent.FX == nil {
t.Fatal("expected fx intent")
@@ -69,3 +75,45 @@ func TestEnsureDerivedFXIntent_PreservesExplicitSideFromHydratedIntent(t *testin
t.Fatalf("unexpected side: got=%q want=%q", got, want)
}
}
func TestEnsureDerivedFXIntent_ExplicitFXOverridesInferredCurrencies(t *testing.T) {
intent := &model.PaymentIntent{
RequiresFX: false,
SettlementCurrency: "EUR",
Amount: &paymenttypes.Money{Amount: "10", Currency: "EUR"},
FX: &model.FXIntent{
Pair: &paymenttypes.CurrencyPair{
Base: "USDT",
Quote: "RUB",
},
Side: paymenttypes.FXSideSellBaseBuyQuote,
},
}
out := ensureDerivedFXIntent(intent, "BTC", "EUR")
if !out.ExplicitOverrideApplied {
t.Fatalf("expected explicit override flag")
}
if !intent.RequiresFX {
t.Fatalf("expected requires_fx=true")
}
if got, want := intent.SettlementCurrency, "RUB"; got != want {
t.Fatalf("unexpected settlement currency: got=%q want=%q", got, want)
}
if intent.FX == nil || intent.FX.Pair == nil {
t.Fatalf("expected fx pair")
}
if got, want := intent.FX.Pair.GetBase(), "USDT"; got != want {
t.Fatalf("unexpected base currency: got=%q want=%q", got, want)
}
if got, want := intent.FX.Pair.GetQuote(), "RUB"; got != want {
t.Fatalf("unexpected quote currency: got=%q want=%q", got, want)
}
if got, want := out.EffectiveSourceCurrency, "USDT"; got != want {
t.Fatalf("unexpected effective source currency: got=%q want=%q", got, want)
}
if got, want := out.EffectiveDestinationCurrency, "RUB"; got != want {
t.Fatalf("unexpected effective destination currency: got=%q want=%q", got, want)
}
}

View File

@@ -53,9 +53,10 @@ func (s *QuoteComputationService) BuildPlan(ctx context.Context, in ComputeInput
Items: make([]*QuoteComputationPlanItem, 0, len(in.Intents)),
}
managedWalletNetworks := map[string]string{}
ledgerAccountCurrencies := map[string]string{}
for i, intent := range in.Intents {
item, err := s.buildPlanItem(ctx, in, i, intent, managedWalletNetworks)
item, err := s.buildPlanItem(ctx, in, i, intent, managedWalletNetworks, ledgerAccountCurrencies)
if err != nil {
s.logger.Warn("Computation plan item build failed",
zap.String("org_ref", in.OrganizationRef),
@@ -84,6 +85,7 @@ func (s *QuoteComputationService) buildPlanItem(
index int,
intent *transfer_intent_hydrator.QuoteIntent,
managedWalletNetworks map[string]string,
ledgerAccountCurrencies map[string]string,
) (*QuoteComputationPlanItem, error) {
if intent == nil {
s.logger.Warn("Plan item build failed: intent is nil", zap.Int("index", index))
@@ -137,7 +139,59 @@ func (s *QuoteComputationService) buildPlanItem(
}
modelIntent.Source = clonePaymentEndpoint(source)
modelIntent.Destination = clonePaymentEndpoint(destination)
ensureDerivedFXIntent(&modelIntent)
currencyInference, err := s.inferEndpointCurrencies(
ctx,
strings.TrimSpace(in.OrganizationRef),
modelIntent,
ledgerAccountCurrencies,
)
if err != nil {
return nil, merrors.InternalWrap(err, "resolve endpoint currencies")
}
fxDecision := ensureDerivedFXIntent(
&modelIntent,
currencyInference.SourceCurrency,
currencyInference.DestinationCurrency,
)
if currencyInference.SourceInferred || currencyInference.DestinationInferred {
s.logger.Info("Resolved endpoint currencies for quote intent",
zap.Int("index", index),
zap.String("intent_ref", strings.TrimSpace(modelIntent.Ref)),
zap.String("inferred_source_currency", fxDecision.InferredSourceCurrency),
zap.String("inferred_destination_currency", fxDecision.InferredDestinationCurrency),
zap.String("effective_source_currency", fxDecision.EffectiveSourceCurrency),
zap.String("effective_destination_currency", fxDecision.EffectiveDestinationCurrency),
)
}
if fxDecision.ExplicitOverrideApplied {
fxBase, fxQuote, fxSide := "", "", ""
if modelIntent.FX != nil {
fxSide = strings.TrimSpace(string(modelIntent.FX.Side))
if modelIntent.FX.Pair != nil {
fxBase = strings.TrimSpace(modelIntent.FX.Pair.GetBase())
fxQuote = strings.TrimSpace(modelIntent.FX.Pair.GetQuote())
}
}
s.logger.Info("Applied explicit FX override to inferred endpoint currencies",
zap.Int("index", index),
zap.String("intent_ref", strings.TrimSpace(modelIntent.Ref)),
zap.String("inferred_source_currency", fxDecision.InferredSourceCurrency),
zap.String("inferred_destination_currency", fxDecision.InferredDestinationCurrency),
zap.String("effective_source_currency", fxDecision.EffectiveSourceCurrency),
zap.String("effective_destination_currency", fxDecision.EffectiveDestinationCurrency),
zap.String("fx_base_currency", fxBase),
zap.String("fx_quote_currency", fxQuote),
zap.String("fx_side", fxSide),
)
}
if fxDecision.RequiresFXInferred {
s.logger.Info("Inferred FX requirement from endpoint currencies",
zap.Int("index", index),
zap.String("intent_ref", strings.TrimSpace(modelIntent.Ref)),
zap.String("source_currency", fxDecision.EffectiveSourceCurrency),
zap.String("destination_currency", fxDecision.EffectiveDestinationCurrency),
)
}
sourceRail, sourceNetwork, err := plan.RailFromEndpoint(source, modelIntent.Attributes, true)
if err != nil {

View File

@@ -19,16 +19,21 @@ type ManagedWalletNetworkResolver interface {
ResolveManagedWalletNetwork(ctx context.Context, managedWalletRef string) (string, error)
}
type LedgerAccountCurrencyResolver interface {
ResolveLedgerAccountCurrency(ctx context.Context, organizationRef, ledgerAccountRef string) (string, error)
}
type Option func(*QuoteComputationService)
type QuoteComputationService struct {
core Core
fundingResolver gateway_funding_profile.FundingProfileResolver
gatewayRegistry plan.GatewayRegistry
managedWalletNetworkResolver ManagedWalletNetworkResolver
routeStore plan.RouteStore
pathFinder *graph_path_finder.GraphPathFinder
logger mlogger.Logger
core Core
fundingResolver gateway_funding_profile.FundingProfileResolver
gatewayRegistry plan.GatewayRegistry
managedWalletNetworkResolver ManagedWalletNetworkResolver
ledgerAccountCurrencyResolver LedgerAccountCurrencyResolver
routeStore plan.RouteStore
pathFinder *graph_path_finder.GraphPathFinder
logger mlogger.Logger
}
func New(core Core, opts ...Option) *QuoteComputationService {
@@ -69,6 +74,14 @@ func WithManagedWalletNetworkResolver(resolver ManagedWalletNetworkResolver) Opt
}
}
func WithLedgerAccountCurrencyResolver(resolver LedgerAccountCurrencyResolver) Option {
return func(svc *QuoteComputationService) {
if svc != nil {
svc.ledgerAccountCurrencyResolver = resolver
}
}
}
func WithRouteStore(store plan.RouteStore) Option {
return func(svc *QuoteComputationService) {
if svc != nil {

View File

@@ -1,6 +1,7 @@
package quotation
import (
ledgerclient "github.com/tech/sendico/ledger/client"
"github.com/tech/sendico/payments/storage"
clockpkg "github.com/tech/sendico/pkg/clock"
"github.com/tech/sendico/pkg/mlogger"
@@ -35,6 +36,7 @@ type serviceDependencies struct {
fees feesDependency
gateway gatewayDependency
oracle oracleDependency
ledger ledgerclient.Client
gatewayRegistry GatewayRegistry
gatewayInvokeResolver GatewayInvokeResolver
cardRoutes map[string]CardGatewayRoute

View File

@@ -13,6 +13,7 @@ import (
paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1"
methodsv1 "github.com/tech/sendico/pkg/proto/payments/methods/v1"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1"
"go.mongodb.org/mongo-driver/v2/bson"
"google.golang.org/grpc"
)
@@ -123,7 +124,16 @@ func (h *TransferIntentHydrator) HydrateOne(ctx context.Context, in HydrateOneIn
if settlementCurrency == "" {
settlementCurrency = strings.ToUpper(strings.TrimSpace(amount.Currency))
}
requiresFX := !strings.EqualFold(amount.Currency, settlementCurrency)
fxIntent := fxIntentFromProto(in.Intent.GetFx())
if settlementCurrency == "" {
settlementCurrency = settlementCurrencyFromFX(fxIntent)
}
requiresFX := false
if fxIntent != nil && fxIntent.Pair != nil {
requiresFX = true
} else {
requiresFX = !strings.EqualFold(amount.Currency, settlementCurrency)
}
intent := &QuoteIntent{
Ref: h.newRef(),
@@ -134,7 +144,7 @@ func (h *TransferIntentHydrator) HydrateOne(ctx context.Context, in HydrateOneIn
Comment: strings.TrimSpace(in.Intent.GetComment()),
SettlementMode: settlementMode,
FeeTreatment: feeTreatment,
FXSide: fxSideFromProto(in.Intent.GetFxSide()),
FX: fxIntent,
SettlementCurrency: settlementCurrency,
RequiresFX: requiresFX,
Attributes: map[string]string{
@@ -223,3 +233,55 @@ func fxSideFromProto(side fxv1.Side) paymenttypes.FXSide {
return paymenttypes.FXSideUnspecified
}
}
func fxIntentFromProto(src *sharedv1.FXIntent) *QuoteFXIntent {
if src == nil || src.GetPair() == nil {
return nil
}
base := strings.ToUpper(strings.TrimSpace(src.GetPair().GetBase()))
quote := strings.ToUpper(strings.TrimSpace(src.GetPair().GetQuote()))
if base == "" || quote == "" {
return nil
}
side := fxSideFromProto(src.GetSide())
if side == paymenttypes.FXSideUnspecified {
side = paymenttypes.FXSideSellBaseBuyQuote
}
return &QuoteFXIntent{
Pair: &paymenttypes.CurrencyPair{
Base: base,
Quote: quote,
},
Side: side,
Firm: src.GetFirm(),
TTLms: src.GetTtlMs(),
PreferredProvider: strings.TrimSpace(src.GetPreferredProvider()),
MaxAgeMs: src.GetMaxAgeMs(),
}
}
func settlementCurrencyFromFX(fx *QuoteFXIntent) string {
if fx == nil || fx.Pair == nil {
return ""
}
base := strings.ToUpper(strings.TrimSpace(fx.Pair.GetBase()))
quote := strings.ToUpper(strings.TrimSpace(fx.Pair.GetQuote()))
switch fx.Side {
case paymenttypes.FXSideBuyBaseSellQuote:
return firstNonEmpty(base, quote)
case paymenttypes.FXSideSellBaseBuyQuote:
return firstNonEmpty(quote, base)
default:
return firstNonEmpty(quote, base)
}
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed != "" {
return trimmed
}
}
return ""
}

View File

@@ -64,6 +64,15 @@ type QuoteCardEndpoint struct {
MaskedPan string
}
type QuoteFXIntent struct {
Pair *paymenttypes.CurrencyPair
Side paymenttypes.FXSide
Firm bool
TTLms int64
PreferredProvider string
MaxAgeMs int32
}
type QuoteEndpoint struct {
Type QuoteEndpointType
PaymentMethodRef string
@@ -84,7 +93,7 @@ type QuoteIntent struct {
Comment string
SettlementMode QuoteSettlementMode
FeeTreatment QuoteFeeTreatment
FXSide paymenttypes.FXSide
FX *QuoteFXIntent
SettlementCurrency string
RequiresFX bool
Attributes map[string]string

View File

@@ -16,6 +16,7 @@ import (
endpointv1 "github.com/tech/sendico/pkg/proto/payments/endpoint/v1"
methodsv1 "github.com/tech/sendico/pkg/proto/payments/methods/v1"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1"
"go.mongodb.org/mongo-driver/v2/bson"
"google.golang.org/grpc"
)
@@ -100,7 +101,7 @@ func TestHydrateOne_SuccessWithInlineMethods(t *testing.T) {
}
}
func TestHydrateOne_PropagatesFXSide(t *testing.T) {
func TestHydrateOne_PropagatesFXIntent(t *testing.T) {
h := New(nil, WithRefFactory(func() string { return "q-intent-fx-side" }))
intent := &quotationv2.QuoteIntent{
Source: &endpointv1.PaymentEndpoint{
@@ -126,7 +127,17 @@ func TestHydrateOne_PropagatesFXSide(t *testing.T) {
},
Amount: newMoney("10", "USDT"),
SettlementCurrency: "RUB",
FxSide: fxv1.Side_BUY_BASE_SELL_QUOTE,
Fx: &sharedv1.FXIntent{
Pair: &fxv1.CurrencyPair{
Base: "USDT",
Quote: "RUB",
},
Side: fxv1.Side_BUY_BASE_SELL_QUOTE,
Firm: true,
TtlMs: 12_000,
PreferredProvider: "bestfx",
MaxAgeMs: 1_000,
},
}
got, err := h.HydrateOne(context.Background(), HydrateOneInput{
@@ -140,8 +151,65 @@ func TestHydrateOne_PropagatesFXSide(t *testing.T) {
if got == nil {
t.Fatalf("expected hydrated intent")
}
if got.FXSide != paymenttypes.FXSideBuyBaseSellQuote {
t.Fatalf("unexpected fx side: got=%q", got.FXSide)
if got.FX == nil || got.FX.Pair == nil {
t.Fatalf("expected hydrated fx intent")
}
if got.FX.Side != paymenttypes.FXSideBuyBaseSellQuote {
t.Fatalf("unexpected fx side: got=%q", got.FX.Side)
}
if got.FX.Pair.GetBase() != "USDT" || got.FX.Pair.GetQuote() != "RUB" {
t.Fatalf("unexpected fx pair: got=%s/%s", got.FX.Pair.GetBase(), got.FX.Pair.GetQuote())
}
if !got.FX.Firm || got.FX.TTLms != 12_000 || got.FX.PreferredProvider != "bestfx" || got.FX.MaxAgeMs != 1_000 {
t.Fatalf("unexpected fx extras: %#v", got.FX)
}
}
func TestHydrateOne_RequiresFXWhenExplicitFXProvided(t *testing.T) {
h := New(nil, WithRefFactory(func() string { return "q-intent-fx-required" }))
intent := &quotationv2.QuoteIntent{
Source: &endpointv1.PaymentEndpoint{
Source: &endpointv1.PaymentEndpoint_PaymentMethod{
PaymentMethod: &endpointv1.PaymentMethod{
Type: endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_LEDGER,
Data: mustMarshalBSON(t, map[string]string{"ledgerAccountRef": "ledger-src"}),
},
},
},
Destination: &endpointv1.PaymentEndpoint{
Source: &endpointv1.PaymentEndpoint_PaymentMethod{
PaymentMethod: &endpointv1.PaymentMethod{
Type: endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CARD,
Data: mustMarshalBSON(t, pkgmodel.CardPaymentData{
Pan: "4111111111111111",
ExpMonth: "12",
ExpYear: "2030",
Country: "US",
}),
},
},
},
Amount: newMoney("10", "RUB"),
SettlementCurrency: "RUB",
Fx: &sharedv1.FXIntent{
Pair: &fxv1.CurrencyPair{Base: "USDT", Quote: "RUB"},
Side: fxv1.Side_SELL_BASE_BUY_QUOTE,
},
}
got, err := h.HydrateOne(context.Background(), HydrateOneInput{
OrganizationRef: bson.NewObjectID().Hex(),
InitiatorRef: bson.NewObjectID().Hex(),
Intent: intent,
})
if err != nil {
t.Fatalf("HydrateOne returned error: %v", err)
}
if got == nil {
t.Fatalf("expected hydrated intent")
}
if !got.RequiresFX {
t.Fatalf("expected requires_fx=true when explicit fx is supplied")
}
}