fixed quotation currency inference
This commit is contained in:
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 "eIntentLogSummary{
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 := "ationv2.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 := "ationv2.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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user