+source currency pick fix +fx side propagation
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
package quotation
|
||||
|
||||
import paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
|
||||
const (
|
||||
defaultCardGateway = "monetix"
|
||||
defaultCardGateway = paymenttypes.DefaultCardsGatewayID
|
||||
)
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
defaultCardGatewayKey = "monetix"
|
||||
defaultCardGatewayKey = paymenttypes.DefaultCardsGatewayID
|
||||
)
|
||||
|
||||
type CardGatewayFundingRoute struct {
|
||||
|
||||
@@ -14,13 +14,13 @@ func TestStaticFundingProfileResolver_ExplicitCardRoute(t *testing.T) {
|
||||
resolver := NewStaticFundingProfileResolver(StaticFundingProfileResolverInput{
|
||||
DefaultMode: model.FundingModeNone,
|
||||
CardRoutes: map[string]CardGatewayFundingRoute{
|
||||
"monetix": {
|
||||
paymenttypes.DefaultCardsGatewayID: {
|
||||
FundingAddress: "T-FUNDING",
|
||||
FeeWalletRef: "wallet-fee",
|
||||
},
|
||||
},
|
||||
FeeLedgerAccounts: map[string]string{
|
||||
"monetix": "ledger:fees",
|
||||
paymenttypes.DefaultCardsGatewayID: "ledger:fees",
|
||||
},
|
||||
})
|
||||
|
||||
@@ -47,7 +47,7 @@ func TestStaticFundingProfileResolver_ExplicitCardRoute(t *testing.T) {
|
||||
},
|
||||
},
|
||||
Attributes: map[string]string{
|
||||
"gateway": "monetix",
|
||||
"gateway": paymenttypes.DefaultCardsGatewayID,
|
||||
"initiator_ref": "usr-1",
|
||||
},
|
||||
})
|
||||
@@ -57,8 +57,8 @@ func TestStaticFundingProfileResolver_ExplicitCardRoute(t *testing.T) {
|
||||
if profile == nil {
|
||||
t.Fatalf("expected profile")
|
||||
}
|
||||
if profile.GatewayID != "monetix" {
|
||||
t.Fatalf("expected gateway monetix, got %q", profile.GatewayID)
|
||||
if profile.GatewayID != paymenttypes.DefaultCardsGatewayID {
|
||||
t.Fatalf("expected gateway %s, got %q", paymenttypes.DefaultCardsGatewayID, profile.GatewayID)
|
||||
}
|
||||
if profile.Mode != model.FundingModeNone {
|
||||
t.Fatalf("expected mode none, got %q", profile.Mode)
|
||||
@@ -149,7 +149,7 @@ func TestStaticFundingProfileResolver_EmptyInputReturnsNil(t *testing.T) {
|
||||
func TestStaticFundingProfileResolver_ConfiguredProfileCloned(t *testing.T) {
|
||||
resolver := NewStaticFundingProfileResolver(StaticFundingProfileResolverInput{
|
||||
Profiles: map[string]*GatewayFundingProfile{
|
||||
"monetix": {
|
||||
paymenttypes.DefaultCardsGatewayID: {
|
||||
Mode: model.FundingModeDepositObserved,
|
||||
DepositCheck: &model.DepositCheckPolicy{
|
||||
WalletRef: "wallet-deposit",
|
||||
@@ -164,7 +164,7 @@ func TestStaticFundingProfileResolver_ConfiguredProfileCloned(t *testing.T) {
|
||||
})
|
||||
|
||||
first, err := resolver.ResolveGatewayFundingProfile(context.Background(), FundingProfileRequest{
|
||||
GatewayID: "monetix",
|
||||
GatewayID: paymenttypes.DefaultCardsGatewayID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -175,7 +175,7 @@ func TestStaticFundingProfileResolver_ConfiguredProfileCloned(t *testing.T) {
|
||||
first.DepositCheck.WalletRef = "changed"
|
||||
|
||||
second, err := resolver.ResolveGatewayFundingProfile(context.Background(), FundingProfileRequest{
|
||||
GatewayID: "monetix",
|
||||
GatewayID: paymenttypes.DefaultCardsGatewayID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
|
||||
@@ -67,7 +67,21 @@ func fxIntentForQuote(intent *sharedv1.PaymentIntent) *sharedv1.FXIntent {
|
||||
return nil
|
||||
}
|
||||
if fx := intent.GetFx(); fx != nil && fx.GetPair() != nil {
|
||||
return fx
|
||||
side := fx.GetSide()
|
||||
if side == fxv1.Side_SIDE_UNSPECIFIED {
|
||||
side = fxv1.Side_SELL_BASE_BUY_QUOTE
|
||||
}
|
||||
return &sharedv1.FXIntent{
|
||||
Pair: &fxv1.CurrencyPair{
|
||||
Base: strings.TrimSpace(fx.GetPair().GetBase()),
|
||||
Quote: strings.TrimSpace(fx.GetPair().GetQuote()),
|
||||
},
|
||||
Side: side,
|
||||
Firm: fx.GetFirm(),
|
||||
TtlMs: fx.GetTtlMs(),
|
||||
PreferredProvider: strings.TrimSpace(fx.GetPreferredProvider()),
|
||||
MaxAgeMs: fx.GetMaxAgeMs(),
|
||||
}
|
||||
}
|
||||
amount := intent.GetAmount()
|
||||
if amount == nil {
|
||||
|
||||
@@ -114,3 +114,30 @@ func TestShouldRequestFX_UsesFXIntentOrCurrencyDifference(t *testing.T) {
|
||||
t.Fatalf("expected shouldRequestFX=true for derived FX from currency mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFXIntentForQuote_DefaultsUnspecifiedSideForExplicitPair(t *testing.T) {
|
||||
intent := &sharedv1.PaymentIntent{
|
||||
Fx: &sharedv1.FXIntent{
|
||||
Pair: &fxv1.CurrencyPair{Base: "USDT", Quote: "RUB"},
|
||||
Side: fxv1.Side_SIDE_UNSPECIFIED,
|
||||
Firm: true,
|
||||
TtlMs: 5000,
|
||||
PreferredProvider: "provider-a",
|
||||
MaxAgeMs: 750,
|
||||
},
|
||||
}
|
||||
|
||||
fx := fxIntentForQuote(intent)
|
||||
if fx == nil {
|
||||
t.Fatal("expected fx intent")
|
||||
}
|
||||
if fx.GetSide() != fxv1.Side_SELL_BASE_BUY_QUOTE {
|
||||
t.Fatalf("unexpected side: got=%s", fx.GetSide().String())
|
||||
}
|
||||
if fx.GetPair() == nil || fx.GetPair().GetBase() != "USDT" || fx.GetPair().GetQuote() != "RUB" {
|
||||
t.Fatalf("unexpected pair: %+v", fx.GetPair())
|
||||
}
|
||||
if !fx.GetFirm() || fx.GetTtlMs() != 5000 || fx.GetPreferredProvider() != "provider-a" || fx.GetMaxAgeMs() != 750 {
|
||||
t.Fatalf("unexpected fx options: %+v", fx)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
chainpkg "github.com/tech/sendico/pkg/chain"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||
"go.uber.org/zap"
|
||||
"google.golang.org/grpc/codes"
|
||||
@@ -22,6 +23,13 @@ type managedWalletNetworkResolver struct {
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
type discoveredGatewayCandidate struct {
|
||||
gatewayID string
|
||||
instanceID string
|
||||
network string
|
||||
invokeURI string
|
||||
}
|
||||
|
||||
func newManagedWalletNetworkResolver(core *Service) *managedWalletNetworkResolver {
|
||||
if core == nil {
|
||||
return nil
|
||||
@@ -39,23 +47,35 @@ func newManagedWalletNetworkResolver(core *Service) *managedWalletNetworkResolve
|
||||
}
|
||||
|
||||
func (r *managedWalletNetworkResolver) ResolveManagedWalletNetwork(ctx context.Context, managedWalletRef string) (string, error) {
|
||||
asset, err := r.ResolveManagedWalletAsset(ctx, managedWalletRef)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
network := strings.ToUpper(strings.TrimSpace(asset.GetChain()))
|
||||
if network == "" {
|
||||
return "", merrors.NoData("managed wallet network is missing")
|
||||
}
|
||||
return network, nil
|
||||
}
|
||||
|
||||
func (r *managedWalletNetworkResolver) ResolveManagedWalletAsset(ctx context.Context, managedWalletRef string) (*paymenttypes.Asset, error) {
|
||||
if r == nil {
|
||||
return "", merrors.NoData("chain gateway unavailable")
|
||||
return nil, merrors.NoData("chain gateway unavailable")
|
||||
}
|
||||
walletRef := strings.TrimSpace(managedWalletRef)
|
||||
if walletRef == "" {
|
||||
return "", merrors.InvalidArgument("managed_wallet_ref is required")
|
||||
return nil, merrors.InvalidArgument("managed_wallet_ref is required")
|
||||
}
|
||||
|
||||
var discoveryErr error
|
||||
if r.gatewayRegistry != nil && r.gatewayInvokeResolver != nil {
|
||||
network, err := r.resolveFromDiscoveredGateways(ctx, walletRef)
|
||||
asset, err := r.resolveAssetFromDiscoveredGateways(ctx, walletRef)
|
||||
if err == nil {
|
||||
return network, nil
|
||||
return asset, nil
|
||||
}
|
||||
discoveryErr = err
|
||||
if r.logger != nil {
|
||||
r.logger.Warn("Managed wallet network lookup via discovery failed",
|
||||
r.logger.Warn("Managed wallet asset lookup via discovery failed",
|
||||
zap.String("wallet_ref", walletRef),
|
||||
zap.Error(err),
|
||||
)
|
||||
@@ -64,72 +84,33 @@ func (r *managedWalletNetworkResolver) ResolveManagedWalletNetwork(ctx context.C
|
||||
|
||||
if r.resolver == nil {
|
||||
if discoveryErr != nil {
|
||||
return "", discoveryErr
|
||||
return nil, discoveryErr
|
||||
}
|
||||
return "", merrors.NoData("chain gateway unavailable")
|
||||
return nil, merrors.NoData("chain gateway unavailable")
|
||||
}
|
||||
|
||||
client, err := r.resolver.Resolve(ctx, "")
|
||||
if err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
if client == nil {
|
||||
return "", merrors.NoData("chain gateway unavailable")
|
||||
return nil, merrors.NoData("chain gateway unavailable")
|
||||
}
|
||||
resp, err := client.GetManagedWallet(ctx, &chainv1.GetManagedWalletRequest{WalletRef: walletRef})
|
||||
if err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
return managedWalletNetworkFromResponse(resp)
|
||||
return managedWalletAssetFromResponse(resp)
|
||||
}
|
||||
|
||||
func (r *managedWalletNetworkResolver) resolveFromDiscoveredGateways(ctx context.Context, walletRef string) (string, error) {
|
||||
entries, err := r.gatewayRegistry.List(ctx)
|
||||
func (r *managedWalletNetworkResolver) resolveAssetFromDiscoveredGateways(ctx context.Context, walletRef string) (*paymenttypes.Asset, error) {
|
||||
candidates, err := r.listDiscoveredGatewayCandidates(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
type candidate struct {
|
||||
gatewayID string
|
||||
instanceID string
|
||||
network string
|
||||
invokeURI string
|
||||
}
|
||||
candidates := make([]candidate, 0, len(entries))
|
||||
seenInvokeURI := map[string]struct{}{}
|
||||
for _, entry := range entries {
|
||||
if entry == nil || !entry.IsEnabled || entry.Rail != model.RailCrypto {
|
||||
continue
|
||||
}
|
||||
invokeURI := strings.TrimSpace(entry.InvokeURI)
|
||||
if invokeURI == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(invokeURI)
|
||||
if _, exists := seenInvokeURI[key]; exists {
|
||||
continue
|
||||
}
|
||||
seenInvokeURI[key] = struct{}{}
|
||||
candidates = append(candidates, candidate{
|
||||
gatewayID: strings.TrimSpace(entry.ID),
|
||||
instanceID: strings.TrimSpace(entry.InstanceID),
|
||||
network: strings.ToUpper(strings.TrimSpace(entry.Network)),
|
||||
invokeURI: invokeURI,
|
||||
})
|
||||
}
|
||||
|
||||
if len(candidates) == 0 {
|
||||
return "", merrors.NoData("chain gateway unavailable")
|
||||
return nil, merrors.NoData("chain gateway unavailable")
|
||||
}
|
||||
sort.Slice(candidates, func(i, j int) bool {
|
||||
if candidates[i].gatewayID != candidates[j].gatewayID {
|
||||
return candidates[i].gatewayID < candidates[j].gatewayID
|
||||
}
|
||||
if candidates[i].instanceID != candidates[j].instanceID {
|
||||
return candidates[i].instanceID < candidates[j].instanceID
|
||||
}
|
||||
return candidates[i].invokeURI < candidates[j].invokeURI
|
||||
})
|
||||
|
||||
var firstErr error
|
||||
for _, candidate := range candidates {
|
||||
@@ -150,7 +131,7 @@ func (r *managedWalletNetworkResolver) resolveFromDiscoveredGateways(ctx context
|
||||
}
|
||||
continue
|
||||
}
|
||||
network, extractErr := managedWalletNetworkFromResponse(resp)
|
||||
asset, extractErr := managedWalletAssetFromResponse(resp)
|
||||
if extractErr != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = extractErr
|
||||
@@ -163,30 +144,87 @@ func (r *managedWalletNetworkResolver) resolveFromDiscoveredGateways(ctx context
|
||||
zap.String("gateway_id", candidate.gatewayID),
|
||||
zap.String("instance_id", candidate.instanceID),
|
||||
zap.String("gateway_network", candidate.network),
|
||||
zap.String("resolved_network", network),
|
||||
zap.String("resolved_network", asset.GetChain()),
|
||||
)
|
||||
}
|
||||
return network, nil
|
||||
return asset, nil
|
||||
}
|
||||
|
||||
if firstErr != nil {
|
||||
return "", firstErr
|
||||
return nil, firstErr
|
||||
}
|
||||
return "", merrors.NoData("managed wallet not found in discovered gateways")
|
||||
return nil, merrors.NoData("managed wallet not found in discovered gateways")
|
||||
}
|
||||
|
||||
func (r *managedWalletNetworkResolver) listDiscoveredGatewayCandidates(ctx context.Context) ([]discoveredGatewayCandidate, error) {
|
||||
entries, err := r.gatewayRegistry.List(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
candidates := make([]discoveredGatewayCandidate, 0, len(entries))
|
||||
seenInvokeURI := map[string]struct{}{}
|
||||
for _, entry := range entries {
|
||||
if entry == nil || !entry.IsEnabled || entry.Rail != model.RailCrypto {
|
||||
continue
|
||||
}
|
||||
invokeURI := strings.TrimSpace(entry.InvokeURI)
|
||||
if invokeURI == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(invokeURI)
|
||||
if _, exists := seenInvokeURI[key]; exists {
|
||||
continue
|
||||
}
|
||||
seenInvokeURI[key] = struct{}{}
|
||||
candidates = append(candidates, discoveredGatewayCandidate{
|
||||
gatewayID: strings.TrimSpace(entry.ID),
|
||||
instanceID: strings.TrimSpace(entry.InstanceID),
|
||||
network: strings.ToUpper(strings.TrimSpace(entry.Network)),
|
||||
invokeURI: invokeURI,
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(candidates, func(i, j int) bool {
|
||||
if candidates[i].gatewayID != candidates[j].gatewayID {
|
||||
return candidates[i].gatewayID < candidates[j].gatewayID
|
||||
}
|
||||
if candidates[i].instanceID != candidates[j].instanceID {
|
||||
return candidates[i].instanceID < candidates[j].instanceID
|
||||
}
|
||||
return candidates[i].invokeURI < candidates[j].invokeURI
|
||||
})
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
func managedWalletNetworkFromResponse(resp *chainv1.GetManagedWalletResponse) (string, error) {
|
||||
wallet := resp.GetWallet()
|
||||
if wallet == nil || wallet.GetAsset() == nil {
|
||||
return "", merrors.NoData("managed wallet asset is missing")
|
||||
asset, err := managedWalletAssetFromResponse(resp)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
network := strings.ToUpper(strings.TrimSpace(chainpkg.NetworkAlias(wallet.GetAsset().GetChain())))
|
||||
network := strings.ToUpper(strings.TrimSpace(asset.GetChain()))
|
||||
if network == "" || network == "UNSPECIFIED" {
|
||||
return "", merrors.NoData("managed wallet network is missing")
|
||||
}
|
||||
return network, nil
|
||||
}
|
||||
|
||||
func managedWalletAssetFromResponse(resp *chainv1.GetManagedWalletResponse) (*paymenttypes.Asset, error) {
|
||||
wallet := resp.GetWallet()
|
||||
if wallet == nil || wallet.GetAsset() == nil {
|
||||
return nil, merrors.NoData("managed wallet asset is missing")
|
||||
}
|
||||
network := strings.ToUpper(strings.TrimSpace(chainpkg.NetworkAlias(wallet.GetAsset().GetChain())))
|
||||
if network == "" || network == "UNSPECIFIED" {
|
||||
return nil, merrors.NoData("managed wallet network is missing")
|
||||
}
|
||||
return &paymenttypes.Asset{
|
||||
Chain: network,
|
||||
TokenSymbol: strings.ToUpper(strings.TrimSpace(wallet.GetAsset().GetTokenSymbol())),
|
||||
ContractAddress: strings.TrimSpace(wallet.GetAsset().GetContractAddress()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func isManagedWalletNotFound(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
|
||||
@@ -169,18 +169,18 @@ func (s *QuotationServiceV2) ProcessQuotePayments(
|
||||
}
|
||||
|
||||
expires := make([]time.Time, 0, len(details))
|
||||
intents := make([]model.PaymentIntent, 0, len(details))
|
||||
snapshots := make([]*model.PaymentQuoteSnapshot, 0, len(details))
|
||||
statuses := make([]*quote_persistence_service.StatusInput, 0, len(details))
|
||||
items := make([]quote_persistence_service.PersistItemInput, 0, len(details))
|
||||
for _, detail := range details {
|
||||
if detail == nil || detail.Intent.Amount == nil || detail.Quote == nil {
|
||||
logger.Warn("ProcessQuotePayments contains incomplete detail")
|
||||
return nil, merrors.InvalidArgument("batch processing detail is incomplete")
|
||||
}
|
||||
expires = append(expires, detail.ExpiresAt)
|
||||
intents = append(intents, detail.Intent)
|
||||
snapshots = append(snapshots, quoteSnapshotFromComputed(detail.Quote))
|
||||
statuses = append(statuses, statusInputFromStatus(detail.Status))
|
||||
items = append(items, quote_persistence_service.PersistItemInput{
|
||||
Intent: pointerTo(detail.Intent),
|
||||
Quote: quoteSnapshotFromComputed(detail.Quote),
|
||||
Status: statusInputFromStatus(detail.Status),
|
||||
})
|
||||
}
|
||||
|
||||
expiresAt, ok := minExpiry(expires)
|
||||
@@ -195,9 +195,8 @@ func (s *QuotationServiceV2) ProcessQuotePayments(
|
||||
IdempotencyKey: requestCtx.IdempotencyKey,
|
||||
Hash: fingerprint,
|
||||
ExpiresAt: expiresAt,
|
||||
Intents: intents,
|
||||
Quotes: snapshots,
|
||||
Statuses: statuses,
|
||||
RequestShape: model.QuoteRequestShapeBatch,
|
||||
Items: items,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warn("ProcessQuotePayments failed to build persistence record", zap.Error(err))
|
||||
@@ -244,7 +243,7 @@ func (s *QuotationServiceV2) ProcessQuotePayments(
|
||||
result.Record = stored
|
||||
logger.Info("ProcessQuotePayments persisted quote batch",
|
||||
zap.String("quote_ref", strings.TrimSpace(stored.QuoteRef)),
|
||||
zap.Int("quotes_count", len(stored.Quotes)),
|
||||
zap.Int("quotes_count", len(stored.Items)),
|
||||
zap.Time("expires_at", stored.ExpiresAt),
|
||||
zap.Duration("elapsed", time.Since(startedAt)),
|
||||
)
|
||||
|
||||
@@ -181,9 +181,14 @@ func (s *QuotationServiceV2) ProcessQuotePayment(
|
||||
IdempotencyKey: requestCtx.IdempotencyKey,
|
||||
Hash: fingerprint,
|
||||
ExpiresAt: expiresAt,
|
||||
Intent: pointerTo(detail.Intent),
|
||||
Quote: quoteSnapshotFromComputed(detail.Quote),
|
||||
Status: statusInputFromStatus(detail.Status),
|
||||
RequestShape: model.QuoteRequestShapeSingle,
|
||||
Items: []quote_persistence_service.PersistItemInput{
|
||||
{
|
||||
Intent: pointerTo(detail.Intent),
|
||||
Quote: quoteSnapshotFromComputed(detail.Quote),
|
||||
Status: statusInputFromStatus(detail.Status),
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warn("ProcessQuotePayment failed to build persistence record", zap.Error(err))
|
||||
|
||||
@@ -13,28 +13,34 @@ func (s *QuotationServiceV2) singleResultFromRecord(record *model.PaymentQuoteRe
|
||||
if record == nil {
|
||||
return nil, merrors.InvalidArgument("record is required")
|
||||
}
|
||||
if record.Quote == nil {
|
||||
if record.RequestShape != model.QuoteRequestShapeSingle {
|
||||
return nil, merrors.InvalidArgument("record request shape is not single")
|
||||
}
|
||||
if len(record.Items) != 1 || record.Items[0] == nil {
|
||||
return nil, merrors.InvalidArgument("record single item is required")
|
||||
}
|
||||
item := record.Items[0]
|
||||
if item.Intent == nil {
|
||||
return nil, merrors.InvalidArgument("record intent is required")
|
||||
}
|
||||
if item.Quote == nil {
|
||||
return nil, merrors.InvalidArgument("record quote is required")
|
||||
}
|
||||
|
||||
status := statusFromStored(record.StatusV2)
|
||||
status := statusFromStored(item.Status)
|
||||
mapped, err := s.deps.ResponseMapper.Map(quote_response_mapper_v2.MapInput{
|
||||
Meta: quote_response_mapper_v2.QuoteMeta{
|
||||
ID: record.GetID().Hex(),
|
||||
CreatedAt: record.CreatedAt,
|
||||
UpdatedAt: record.UpdatedAt,
|
||||
},
|
||||
Quote: canonicalFromSnapshot(record.Quote, record.ExpiresAt, s.deps.Now().UTC(), strings.TrimSpace(record.QuoteRef)),
|
||||
Quote: canonicalFromSnapshot(item.Quote, record.ExpiresAt, s.deps.Now().UTC(), strings.TrimSpace(record.QuoteRef)),
|
||||
Status: status,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
intentRef := strings.TrimSpace(record.Intent.Ref)
|
||||
if len(record.Intents) == 1 {
|
||||
intentRef = firstNonEmpty(strings.TrimSpace(record.Intents[0].Ref), intentRef)
|
||||
}
|
||||
mapped.Quote.IntentRef = intentRef
|
||||
mapped.Quote.IntentRef = strings.TrimSpace(item.Intent.Ref)
|
||||
return &QuotePaymentResult{
|
||||
Response: "ationv2.QuotePaymentResponse{
|
||||
Quote: mapped.Quote,
|
||||
@@ -48,17 +54,22 @@ func (s *QuotationServiceV2) batchResultFromRecord(record *model.PaymentQuoteRec
|
||||
if record == nil {
|
||||
return nil, merrors.InvalidArgument("record is required")
|
||||
}
|
||||
if len(record.Quotes) == 0 {
|
||||
return nil, merrors.InvalidArgument("record quotes are required")
|
||||
if record.RequestShape != model.QuoteRequestShapeBatch {
|
||||
return nil, merrors.InvalidArgument("record request shape is not batch")
|
||||
}
|
||||
if len(record.Items) == 0 {
|
||||
return nil, merrors.InvalidArgument("record items are required")
|
||||
}
|
||||
|
||||
quotes := make([]*quotationv2.PaymentQuote, 0, len(record.Quotes))
|
||||
for idx, snapshot := range record.Quotes {
|
||||
var storedStatus *model.QuoteStatusV2
|
||||
if idx < len(record.StatusesV2) {
|
||||
storedStatus = record.StatusesV2[idx]
|
||||
quotes := make([]*quotationv2.PaymentQuote, 0, len(record.Items))
|
||||
for _, item := range record.Items {
|
||||
if item == nil {
|
||||
return nil, merrors.InvalidArgument("record item is required")
|
||||
}
|
||||
status := statusFromStored(storedStatus)
|
||||
if item.Quote == nil {
|
||||
return nil, merrors.InvalidArgument("record item quote is required")
|
||||
}
|
||||
status := statusFromStored(item.Status)
|
||||
|
||||
mapped, err := s.deps.ResponseMapper.Map(quote_response_mapper_v2.MapInput{
|
||||
Meta: quote_response_mapper_v2.QuoteMeta{
|
||||
@@ -66,14 +77,14 @@ func (s *QuotationServiceV2) batchResultFromRecord(record *model.PaymentQuoteRec
|
||||
CreatedAt: record.CreatedAt,
|
||||
UpdatedAt: record.UpdatedAt,
|
||||
},
|
||||
Quote: canonicalFromSnapshot(snapshot, record.ExpiresAt, s.deps.Now().UTC(), strings.TrimSpace(record.QuoteRef)),
|
||||
Quote: canonicalFromSnapshot(item.Quote, record.ExpiresAt, s.deps.Now().UTC(), strings.TrimSpace(record.QuoteRef)),
|
||||
Status: status,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if idx < len(record.Intents) {
|
||||
mapped.Quote.IntentRef = strings.TrimSpace(record.Intents[idx].Ref)
|
||||
if item.Intent != nil {
|
||||
mapped.Quote.IntentRef = strings.TrimSpace(item.Intent.Ref)
|
||||
}
|
||||
quotes = append(quotes, mapped.Quote)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
quotestorage "github.com/tech/sendico/payments/storage/quote"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
pkgmodel "github.com/tech/sendico/pkg/model"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||
accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1"
|
||||
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||
@@ -188,6 +189,131 @@ func TestQuotePayment_USDTToRUB_EndToEnd(t *testing.T) {
|
||||
t.Logf("single response:\n%s", mustProtoJSON(t, result.Response))
|
||||
}
|
||||
|
||||
func TestQuotePayment_FixReceivedRUB_ProducesUSDTDebit_EndToEnd(t *testing.T) {
|
||||
now := time.Unix(1_700_000_000, 0).UTC()
|
||||
orgID := bson.NewObjectID()
|
||||
|
||||
store := newInMemoryQuotesStore()
|
||||
core := &fakeQuoteCore{now: now}
|
||||
svc := New(Dependencies{
|
||||
Logger: zaptest.NewLogger(t),
|
||||
QuotesStore: store,
|
||||
Hydrator: transfer_intent_hydrator.New(nil, transfer_intent_hydrator.WithRefFactory(func() string {
|
||||
return "q-intent-fix-received"
|
||||
})),
|
||||
Computation: quote_computation_service.New(
|
||||
core,
|
||||
quote_computation_service.WithManagedWalletNetworkResolver(staticManagedWalletResolverForE2E{
|
||||
assetsByRef: map[string]*paymenttypes.Asset{
|
||||
"wallet-usdt-source": {
|
||||
Chain: "TRON_NILE",
|
||||
TokenSymbol: "USDT",
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
Now: func() time.Time { return now },
|
||||
NewRef: func() string { return "quote-fix-received-rub" },
|
||||
})
|
||||
|
||||
intent := makeTransferIntent(t, "5000", "RUB", "wallet-usdt-source", "4111111111111111", "RU")
|
||||
intent.SettlementMode = paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED
|
||||
intent.SettlementCurrency = "RUB"
|
||||
|
||||
req := "ationv2.QuotePaymentRequest{
|
||||
Meta: &sharedv1.RequestMeta{
|
||||
OrganizationRef: orgID.Hex(),
|
||||
},
|
||||
IdempotencyKey: "idem-fix-received-rub",
|
||||
InitiatorRef: "initiator-42",
|
||||
PreviewOnly: false,
|
||||
Intent: intent,
|
||||
}
|
||||
|
||||
result, err := svc.ProcessQuotePayment(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessQuotePayment returned error: %v", err)
|
||||
}
|
||||
if result == nil || result.Response == nil || result.Response.GetQuote() == nil {
|
||||
t.Fatalf("expected quote response")
|
||||
}
|
||||
quote := result.Response.GetQuote()
|
||||
|
||||
rate := decimal.RequireFromString("91.5")
|
||||
received := decimal.RequireFromString("5000")
|
||||
expectedPrincipal := received.Div(rate)
|
||||
|
||||
feeTotal := decimal.Zero
|
||||
for _, line := range quote.GetFeeLines() {
|
||||
if line == nil || line.GetMoney() == nil {
|
||||
continue
|
||||
}
|
||||
if !strings.EqualFold(line.GetMoney().GetCurrency(), "USDT") {
|
||||
continue
|
||||
}
|
||||
lineAmount := decimal.RequireFromString(line.GetMoney().GetAmount())
|
||||
switch line.GetSide() {
|
||||
case accountingv1.EntrySide_ENTRY_SIDE_CREDIT:
|
||||
feeTotal = feeTotal.Sub(lineAmount)
|
||||
default:
|
||||
feeTotal = feeTotal.Add(lineAmount)
|
||||
}
|
||||
}
|
||||
expectedTotalDebit := expectedPrincipal.Add(feeTotal)
|
||||
|
||||
if got, want := quote.GetTransferPrincipalAmount().GetAmount(), expectedPrincipal.String(); got != want {
|
||||
t.Fatalf("unexpected principal amount: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := quote.GetTransferPrincipalAmount().GetCurrency(), "USDT"; got != want {
|
||||
t.Fatalf("unexpected principal currency: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := quote.GetDestinationAmount().GetAmount(), "5000"; got != want {
|
||||
t.Fatalf("unexpected destination amount: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := quote.GetDestinationAmount().GetCurrency(), "RUB"; got != want {
|
||||
t.Fatalf("unexpected destination currency: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := quote.GetPayerTotalDebitAmount().GetAmount(), expectedTotalDebit.String(); got != want {
|
||||
t.Fatalf("unexpected payer_total_debit_amount: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := quote.GetPayerTotalDebitAmount().GetCurrency(), "USDT"; got != want {
|
||||
t.Fatalf("unexpected payer_total_debit_amount currency: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := quote.GetResolvedSettlementMode(), paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED; got != want {
|
||||
t.Fatalf("unexpected resolved_settlement_mode: got=%s want=%s", got.String(), want.String())
|
||||
}
|
||||
if quote.GetRoute() == nil || quote.GetRoute().GetSettlement() == nil {
|
||||
t.Fatalf("expected route settlement")
|
||||
}
|
||||
if got, want := quote.GetRoute().GetSettlement().GetModel(), "fix_received"; got != want {
|
||||
t.Fatalf("unexpected route settlement model: got=%q want=%q", got, want)
|
||||
}
|
||||
if quote.GetFxQuote() == nil {
|
||||
t.Fatalf("expected fx quote")
|
||||
}
|
||||
if got, want := quote.GetFxQuote().GetPair().GetBase(), "USDT"; got != want {
|
||||
t.Fatalf("unexpected fx base: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := quote.GetFxQuote().GetPair().GetQuote(), "RUB"; got != want {
|
||||
t.Fatalf("unexpected fx quote currency: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := quote.GetFxQuote().GetSide(), fxv1.Side_SELL_BASE_BUY_QUOTE; got != want {
|
||||
t.Fatalf("unexpected fx side: got=%s want=%s", got.String(), want.String())
|
||||
}
|
||||
if got, want := quote.GetFxQuote().GetQuoteAmount().GetAmount(), "5000"; got != want {
|
||||
t.Fatalf("unexpected fx quote amount: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := quote.GetFxQuote().GetQuoteAmount().GetCurrency(), "RUB"; got != want {
|
||||
t.Fatalf("unexpected fx quote amount currency: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := quote.GetFxQuote().GetBaseAmount().GetAmount(), expectedPrincipal.String(); got != want {
|
||||
t.Fatalf("unexpected fx base amount: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := quote.GetFxQuote().GetBaseAmount().GetCurrency(), "USDT"; got != want {
|
||||
t.Fatalf("unexpected fx base amount currency: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuotePayment_ClampsQuoteExpiryToFXQuoteExpiry(t *testing.T) {
|
||||
now := time.Unix(1_700_000_000, 0).UTC()
|
||||
orgID := bson.NewObjectID()
|
||||
@@ -605,29 +731,70 @@ func (f *fakeQuoteCore) BuildQuote(_ context.Context, in quote_computation_servi
|
||||
return nil, time.Time{}, fmt.Errorf("route hops are required for route-bound quote pricing")
|
||||
}
|
||||
|
||||
baseAmount := decimal.RequireFromString(in.Intent.Amount.GetAmount())
|
||||
intentAmount := decimal.RequireFromString(in.Intent.Amount.GetAmount())
|
||||
amountCurrency := strings.ToUpper(strings.TrimSpace(in.Intent.Amount.GetCurrency()))
|
||||
rate := decimal.RequireFromString("91.5")
|
||||
quoteAmount := baseAmount.Mul(rate)
|
||||
|
||||
baseCurrency := "USDT"
|
||||
quoteCurrency := "RUB"
|
||||
fxSide := fxv1.Side_SELL_BASE_BUY_QUOTE
|
||||
if in.Intent.FX != nil && in.Intent.FX.Pair != nil {
|
||||
if base := strings.ToUpper(strings.TrimSpace(in.Intent.FX.Pair.GetBase())); base != "" {
|
||||
baseCurrency = base
|
||||
}
|
||||
if quote := strings.ToUpper(strings.TrimSpace(in.Intent.FX.Pair.GetQuote())); quote != "" {
|
||||
quoteCurrency = quote
|
||||
}
|
||||
switch in.Intent.FX.Side {
|
||||
case paymenttypes.FXSideBuyBaseSellQuote:
|
||||
fxSide = fxv1.Side_BUY_BASE_SELL_QUOTE
|
||||
default:
|
||||
fxSide = fxv1.Side_SELL_BASE_BUY_QUOTE
|
||||
}
|
||||
}
|
||||
|
||||
baseAmount := intentAmount
|
||||
quoteAmount := intentAmount.Mul(rate)
|
||||
switch {
|
||||
case strings.EqualFold(amountCurrency, quoteCurrency):
|
||||
quoteAmount = intentAmount
|
||||
baseAmount = intentAmount.Div(rate)
|
||||
case strings.EqualFold(amountCurrency, baseCurrency):
|
||||
baseAmount = intentAmount
|
||||
quoteAmount = intentAmount.Mul(rate)
|
||||
}
|
||||
|
||||
payAmount := baseAmount
|
||||
payCurrency := baseCurrency
|
||||
settlementAmount := quoteAmount
|
||||
settlementCurrency := quoteCurrency
|
||||
if fxSide == fxv1.Side_BUY_BASE_SELL_QUOTE {
|
||||
payAmount = quoteAmount
|
||||
payCurrency = quoteCurrency
|
||||
settlementAmount = baseAmount
|
||||
settlementCurrency = baseCurrency
|
||||
}
|
||||
|
||||
feeAmount := decimal.RequireFromString("1.50")
|
||||
taxAmount := decimal.RequireFromString("0.30")
|
||||
if routeFeeClass(in.Route) != "card_payout:3_hops:monetix" {
|
||||
if routeFeeClass(in.Route) != "card_payout:3_hops:"+paymenttypes.DefaultCardsGatewayID {
|
||||
feeAmount = decimal.RequireFromString("2.00")
|
||||
taxAmount = decimal.RequireFromString("0.40")
|
||||
}
|
||||
|
||||
quote := "e_computation_service.ComputedQuote{
|
||||
DebitAmount: &moneyv1.Money{
|
||||
Amount: baseAmount.String(),
|
||||
Currency: "USDT",
|
||||
Amount: payAmount.String(),
|
||||
Currency: payCurrency,
|
||||
},
|
||||
CreditAmount: &moneyv1.Money{
|
||||
Amount: quoteAmount.String(),
|
||||
Currency: "RUB",
|
||||
Amount: settlementAmount.String(),
|
||||
Currency: settlementCurrency,
|
||||
},
|
||||
FeeLines: []*feesv1.DerivedPostingLine{
|
||||
{
|
||||
LedgerAccountRef: "ledger:fees:usdt",
|
||||
Money: &moneyv1.Money{Amount: feeAmount.StringFixed(2), Currency: "USDT"},
|
||||
Money: &moneyv1.Money{Amount: feeAmount.StringFixed(2), Currency: payCurrency},
|
||||
LineType: accountingv1.PostingLineType_POSTING_LINE_FEE,
|
||||
Side: accountingv1.EntrySide_ENTRY_SIDE_DEBIT,
|
||||
Meta: map[string]string{
|
||||
@@ -637,7 +804,7 @@ func (f *fakeQuoteCore) BuildQuote(_ context.Context, in quote_computation_servi
|
||||
},
|
||||
{
|
||||
LedgerAccountRef: "ledger:tax:usdt",
|
||||
Money: &moneyv1.Money{Amount: taxAmount.StringFixed(2), Currency: "USDT"},
|
||||
Money: &moneyv1.Money{Amount: taxAmount.StringFixed(2), Currency: payCurrency},
|
||||
LineType: accountingv1.PostingLineType_POSTING_LINE_TAX,
|
||||
Side: accountingv1.EntrySide_ENTRY_SIDE_DEBIT,
|
||||
Meta: map[string]string{
|
||||
@@ -664,13 +831,13 @@ func (f *fakeQuoteCore) BuildQuote(_ context.Context, in quote_computation_servi
|
||||
FXQuote: &oraclev1.Quote{
|
||||
QuoteRef: "fx-usdt-rub",
|
||||
Pair: &fxv1.CurrencyPair{
|
||||
Base: "USDT",
|
||||
Quote: "RUB",
|
||||
Base: baseCurrency,
|
||||
Quote: quoteCurrency,
|
||||
},
|
||||
Side: fxv1.Side_SELL_BASE_BUY_QUOTE,
|
||||
Side: fxSide,
|
||||
Price: &moneyv1.Decimal{Value: rate.String()},
|
||||
BaseAmount: &moneyv1.Money{Amount: baseAmount.String(), Currency: "USDT"},
|
||||
QuoteAmount: &moneyv1.Money{Amount: quoteAmount.String(), Currency: "RUB"},
|
||||
BaseAmount: &moneyv1.Money{Amount: baseAmount.String(), Currency: baseCurrency},
|
||||
QuoteAmount: &moneyv1.Money{Amount: quoteAmount.String(), Currency: quoteCurrency},
|
||||
ExpiresAtUnixMs: f.now.Add(f.fxTTLValue()).UnixMilli(),
|
||||
Provider: "test-oracle",
|
||||
RateRef: "rate-usdt-rub",
|
||||
@@ -815,6 +982,30 @@ type staticGatewayRegistryForE2E struct {
|
||||
items []*model.GatewayInstanceDescriptor
|
||||
}
|
||||
|
||||
type staticManagedWalletResolverForE2E struct {
|
||||
assetsByRef map[string]*paymenttypes.Asset
|
||||
}
|
||||
|
||||
func (r staticManagedWalletResolverForE2E) ResolveManagedWalletAsset(_ context.Context, managedWalletRef string) (*paymenttypes.Asset, error) {
|
||||
if len(r.assetsByRef) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
asset, ok := r.assetsByRef[strings.TrimSpace(managedWalletRef)]
|
||||
if !ok || asset == nil {
|
||||
return nil, nil
|
||||
}
|
||||
cloned := *asset
|
||||
return &cloned, nil
|
||||
}
|
||||
|
||||
func (r staticManagedWalletResolverForE2E) ResolveManagedWalletNetwork(ctx context.Context, managedWalletRef string) (string, error) {
|
||||
asset, err := r.ResolveManagedWalletAsset(ctx, managedWalletRef)
|
||||
if err != nil || asset == nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(asset.GetChain()), nil
|
||||
}
|
||||
|
||||
func (r staticGatewayRegistryForE2E) List(context.Context) ([]*model.GatewayInstanceDescriptor, error) {
|
||||
if len(r.items) == 0 {
|
||||
return nil, nil
|
||||
|
||||
@@ -23,7 +23,7 @@ import (
|
||||
func TestBuildPlan_BuildsStepsAndFundingGate(t *testing.T) {
|
||||
svc := New(nil, WithFundingProfileResolver(gateway_funding_profile.NewStaticFundingProfileResolver(gateway_funding_profile.StaticFundingProfileResolverInput{
|
||||
GatewayModes: map[string]model.FundingMode{
|
||||
"monetix": model.FundingModeBalanceReserve,
|
||||
paymenttypes.DefaultCardsGatewayID: model.FundingModeBalanceReserve,
|
||||
},
|
||||
})))
|
||||
|
||||
@@ -168,6 +168,69 @@ func TestBuildPlan_RequiresFXUsesSettlementCurrencyForDestinationStep(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPlan_UsesSourceAssetCurrencyForSourceStep(t *testing.T) {
|
||||
svc := New(nil)
|
||||
orgID := bson.NewObjectID()
|
||||
intent := sampleCryptoToCardQuoteIntent()
|
||||
intent.SettlementMode = transfer_intent_hydrator.QuoteSettlementModeFixReceived
|
||||
intent.Amount = &paymenttypes.Money{
|
||||
Amount: "5000",
|
||||
Currency: "RUB",
|
||||
}
|
||||
intent.SettlementCurrency = "RUB"
|
||||
intent.RequiresFX = false
|
||||
|
||||
planModel, err := svc.BuildPlan(context.Background(), ComputeInput{
|
||||
OrganizationRef: orgID.Hex(),
|
||||
OrganizationID: orgID,
|
||||
BaseIdempotencyKey: "idem-key",
|
||||
PreviewOnly: false,
|
||||
Intents: []*transfer_intent_hydrator.QuoteIntent{intent},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(planModel.Items) != 1 {
|
||||
t.Fatalf("expected one plan item")
|
||||
}
|
||||
item := planModel.Items[0]
|
||||
if item == nil {
|
||||
t.Fatalf("expected plan item")
|
||||
}
|
||||
if !item.QuoteInput.Intent.RequiresFX {
|
||||
t.Fatalf("expected derived FX requirement for fix_received cross-currency flow")
|
||||
}
|
||||
if item.QuoteInput.Intent.FX == nil || item.QuoteInput.Intent.FX.Pair == nil {
|
||||
t.Fatalf("expected derived FX pair")
|
||||
}
|
||||
if got, want := strings.TrimSpace(item.QuoteInput.Intent.FX.Pair.GetBase()), "USDT"; got != want {
|
||||
t.Fatalf("unexpected derived FX base currency: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := strings.TrimSpace(item.QuoteInput.Intent.FX.Pair.GetQuote()), "RUB"; got != want {
|
||||
t.Fatalf("unexpected derived FX quote currency: got=%q want=%q", got, want)
|
||||
}
|
||||
steps := item.Steps
|
||||
if got, want := len(steps), 4; got != want {
|
||||
t.Fatalf("unexpected step count: got=%d want=%d", got, want)
|
||||
}
|
||||
if steps[0] == nil || steps[0].Amount == nil {
|
||||
t.Fatalf("expected source step amount")
|
||||
}
|
||||
if got, want := strings.TrimSpace(steps[0].Amount.GetCurrency()), "USDT"; got != want {
|
||||
t.Fatalf("unexpected source step currency: got=%q want=%q", got, want)
|
||||
}
|
||||
last := steps[len(steps)-1]
|
||||
if last == nil || last.Amount == nil {
|
||||
t.Fatalf("expected destination step amount")
|
||||
}
|
||||
if got, want := strings.TrimSpace(last.Amount.GetCurrency()), "RUB"; got != want {
|
||||
t.Fatalf("unexpected destination step currency: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := steps[1].Operation, model.RailOperationFXConvert; got != want {
|
||||
t.Fatalf("unexpected middle operation: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPlan_ResolvesIndependentEconomicsKnobs(t *testing.T) {
|
||||
svc := New(nil)
|
||||
orgID := bson.NewObjectID()
|
||||
@@ -397,7 +460,7 @@ func TestCompute_EnrichesRouteConditionsAndTotalCost(t *testing.T) {
|
||||
}
|
||||
svc := New(core, WithFundingProfileResolver(gateway_funding_profile.NewStaticFundingProfileResolver(gateway_funding_profile.StaticFundingProfileResolverInput{
|
||||
GatewayModes: map[string]model.FundingMode{
|
||||
"monetix": model.FundingModeBalanceReserve,
|
||||
paymenttypes.DefaultCardsGatewayID: model.FundingModeBalanceReserve,
|
||||
},
|
||||
})))
|
||||
|
||||
@@ -459,7 +522,7 @@ func TestCompute_EnrichesRouteConditionsAndTotalCost(t *testing.T) {
|
||||
if got, want := len(hops), 2; got != want {
|
||||
t.Fatalf("unexpected route hops in build input: got=%d want=%d", got, want)
|
||||
}
|
||||
if got, want := hops[1].GetGateway(), "monetix"; got != want {
|
||||
if got, want := hops[1].GetGateway(), paymenttypes.DefaultCardsGatewayID; got != want {
|
||||
t.Fatalf("unexpected destination gateway in build input route: got=%q want=%q", got, want)
|
||||
}
|
||||
if core.lastQuoteIn.ExecutionConditions == nil {
|
||||
@@ -611,7 +674,7 @@ func sampleCardQuoteIntent() *transfer_intent_hydrator.QuoteIntent {
|
||||
},
|
||||
SettlementCurrency: "USD",
|
||||
Attributes: map[string]string{
|
||||
"gateway": "monetix",
|
||||
"gateway": paymenttypes.DefaultCardsGatewayID,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/tech/sendico/payments/quotation/internal/service/quotation/transfer_intent_hydrator"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
)
|
||||
|
||||
func modelIntentFromQuoteIntent(src *transfer_intent_hydrator.QuoteIntent) model.PaymentIntent {
|
||||
@@ -22,6 +23,7 @@ func modelIntentFromQuoteIntent(src *transfer_intent_hydrator.QuoteIntent) model
|
||||
Source: modelEndpointFromQuoteEndpoint(src.Source),
|
||||
Destination: modelEndpointFromQuoteEndpoint(src.Destination),
|
||||
Amount: cloneModelMoney(src.Amount),
|
||||
FX: fxIntentFromHydratedIntent(src),
|
||||
RequiresFX: src.RequiresFX,
|
||||
Attributes: cloneStringMap(src.Attributes),
|
||||
SettlementMode: modelSettlementMode(src.SettlementMode),
|
||||
@@ -30,6 +32,72 @@ func modelIntentFromQuoteIntent(src *transfer_intent_hydrator.QuoteIntent) model
|
||||
}
|
||||
}
|
||||
|
||||
func fxIntentFromHydratedIntent(src *transfer_intent_hydrator.QuoteIntent) *model.FXIntent {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(string(src.FXSide)) == "" || src.FXSide == paymenttypes.FXSideUnspecified {
|
||||
return nil
|
||||
}
|
||||
return &model.FXIntent{Side: src.FXSide}
|
||||
}
|
||||
|
||||
func ensureDerivedFXIntent(intent *model.PaymentIntent) {
|
||||
if intent == nil {
|
||||
return
|
||||
}
|
||||
|
||||
amountCurrency := ""
|
||||
if intent.Amount != nil {
|
||||
amountCurrency = normalizeAsset(intent.Amount.GetCurrency())
|
||||
}
|
||||
settlementCurrency := normalizeAsset(intent.SettlementCurrency)
|
||||
if settlementCurrency == "" {
|
||||
settlementCurrency = amountCurrency
|
||||
}
|
||||
if intent.SettlementCurrency == "" && settlementCurrency != "" {
|
||||
intent.SettlementCurrency = settlementCurrency
|
||||
}
|
||||
|
||||
sourceCurrency := sourceAssetToken(intent.Source)
|
||||
|
||||
// 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 &&
|
||||
sourceCurrency != "" &&
|
||||
settlementCurrency != "" &&
|
||||
!strings.EqualFold(sourceCurrency, settlementCurrency) {
|
||||
intent.RequiresFX = true
|
||||
}
|
||||
|
||||
if !intent.RequiresFX {
|
||||
return
|
||||
}
|
||||
|
||||
baseCurrency := firstNonEmpty(sourceCurrency, amountCurrency)
|
||||
quoteCurrency := settlementCurrency
|
||||
if baseCurrency == "" || quoteCurrency == "" {
|
||||
return
|
||||
}
|
||||
|
||||
if intent.FX == nil {
|
||||
intent.FX = &model.FXIntent{}
|
||||
}
|
||||
if intent.FX.Pair == nil {
|
||||
intent.FX.Pair = &paymenttypes.CurrencyPair{}
|
||||
}
|
||||
if normalizeAsset(intent.FX.Pair.Base) == "" {
|
||||
intent.FX.Pair.Base = baseCurrency
|
||||
}
|
||||
if normalizeAsset(intent.FX.Pair.Quote) == "" {
|
||||
intent.FX.Pair.Quote = quoteCurrency
|
||||
}
|
||||
if strings.TrimSpace(string(intent.FX.Side)) == "" || intent.FX.Side == paymenttypes.FXSideUnspecified {
|
||||
intent.FX.Side = paymenttypes.FXSideSellBaseBuyQuote
|
||||
}
|
||||
}
|
||||
|
||||
func modelEndpointFromQuoteEndpoint(src transfer_intent_hydrator.QuoteEndpoint) model.PaymentEndpoint {
|
||||
result := model.PaymentEndpoint{
|
||||
Type: model.EndpointTypeUnspecified,
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package quote_computation_service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/tech/sendico/payments/quotation/internal/service/quotation/transfer_intent_hydrator"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
)
|
||||
|
||||
func TestEnsureDerivedFXIntent_DefaultsSideWhenEmpty(t *testing.T) {
|
||||
intent := &model.PaymentIntent{
|
||||
RequiresFX: true,
|
||||
SettlementCurrency: "RUB",
|
||||
Amount: &paymenttypes.Money{Amount: "100", Currency: "USDT"},
|
||||
FX: &model.FXIntent{},
|
||||
}
|
||||
|
||||
ensureDerivedFXIntent(intent)
|
||||
|
||||
if intent.FX == nil {
|
||||
t.Fatal("expected fx intent")
|
||||
}
|
||||
if got, want := intent.FX.Side, paymenttypes.FXSideSellBaseBuyQuote; got != want {
|
||||
t.Fatalf("unexpected side: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureDerivedFXIntent_DefaultsSideWhenUnspecified(t *testing.T) {
|
||||
intent := &model.PaymentIntent{
|
||||
RequiresFX: true,
|
||||
SettlementCurrency: "RUB",
|
||||
Amount: &paymenttypes.Money{Amount: "100", Currency: "USDT"},
|
||||
FX: &model.FXIntent{Side: paymenttypes.FXSideUnspecified},
|
||||
}
|
||||
|
||||
ensureDerivedFXIntent(intent)
|
||||
|
||||
if intent.FX == nil {
|
||||
t.Fatal("expected fx intent")
|
||||
}
|
||||
if got, want := intent.FX.Side, paymenttypes.FXSideSellBaseBuyQuote; got != want {
|
||||
t.Fatalf("unexpected side: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureDerivedFXIntent_PreservesExplicitSideFromHydratedIntent(t *testing.T) {
|
||||
hydrated := &transfer_intent_hydrator.QuoteIntent{
|
||||
Source: transfer_intent_hydrator.QuoteEndpoint{
|
||||
Type: transfer_intent_hydrator.QuoteEndpointTypeManagedWallet,
|
||||
ManagedWallet: &transfer_intent_hydrator.QuoteManagedWalletEndpoint{
|
||||
ManagedWalletRef: "mw-src",
|
||||
Asset: &paymenttypes.Asset{TokenSymbol: "USDT"},
|
||||
},
|
||||
},
|
||||
Amount: &paymenttypes.Money{Amount: "100", Currency: "USDT"},
|
||||
SettlementCurrency: "RUB",
|
||||
RequiresFX: true,
|
||||
FXSide: paymenttypes.FXSideBuyBaseSellQuote,
|
||||
}
|
||||
|
||||
intent := modelIntentFromQuoteIntent(hydrated)
|
||||
ensureDerivedFXIntent(&intent)
|
||||
|
||||
if intent.FX == nil {
|
||||
t.Fatal("expected fx intent")
|
||||
}
|
||||
if got, want := intent.FX.Side, paymenttypes.FXSideBuyBaseSellQuote; got != want {
|
||||
t.Fatalf("unexpected side: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,10 @@ import (
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type managedWalletAssetResolver interface {
|
||||
ResolveManagedWalletAsset(ctx context.Context, managedWalletRef string) (*paymenttypes.Asset, error)
|
||||
}
|
||||
|
||||
func (s *QuoteComputationService) enrichManagedWalletEndpointNetwork(
|
||||
ctx context.Context,
|
||||
endpoint *model.PaymentEndpoint,
|
||||
@@ -25,7 +29,30 @@ func (s *QuoteComputationService) enrichManagedWalletEndpointNetwork(
|
||||
if walletRef == "" {
|
||||
return merrors.InvalidArgument("managed_wallet_ref is required")
|
||||
}
|
||||
if endpoint.ManagedWallet.Asset != nil && strings.TrimSpace(endpoint.ManagedWallet.Asset.GetChain()) != "" {
|
||||
asset := endpoint.ManagedWallet.Asset
|
||||
if asset == nil {
|
||||
asset = &paymenttypes.Asset{}
|
||||
endpoint.ManagedWallet.Asset = asset
|
||||
}
|
||||
if resolver, ok := s.managedWalletNetworkResolver.(managedWalletAssetResolver); ok && resolver != nil {
|
||||
if strings.TrimSpace(asset.GetChain()) == "" || strings.TrimSpace(asset.GetTokenSymbol()) == "" {
|
||||
if resolved, err := resolver.ResolveManagedWalletAsset(ctx, walletRef); err == nil && resolved != nil {
|
||||
if strings.TrimSpace(asset.GetChain()) == "" {
|
||||
asset.Chain = strings.ToUpper(strings.TrimSpace(resolved.GetChain()))
|
||||
}
|
||||
if strings.TrimSpace(asset.GetTokenSymbol()) == "" {
|
||||
asset.TokenSymbol = strings.ToUpper(strings.TrimSpace(resolved.GetTokenSymbol()))
|
||||
}
|
||||
if strings.TrimSpace(asset.GetContractAddress()) == "" {
|
||||
asset.ContractAddress = strings.TrimSpace(resolved.GetContractAddress())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(asset.GetChain()) != "" {
|
||||
asset.Chain = strings.ToUpper(strings.TrimSpace(asset.GetChain()))
|
||||
asset.TokenSymbol = strings.ToUpper(strings.TrimSpace(asset.GetTokenSymbol()))
|
||||
asset.ContractAddress = strings.TrimSpace(asset.GetContractAddress())
|
||||
return nil
|
||||
}
|
||||
if s.managedWalletNetworkResolver == nil {
|
||||
@@ -57,11 +84,6 @@ func (s *QuoteComputationService) enrichManagedWalletEndpointNetwork(
|
||||
)
|
||||
}
|
||||
|
||||
asset := endpoint.ManagedWallet.Asset
|
||||
if asset == nil {
|
||||
asset = &paymenttypes.Asset{}
|
||||
endpoint.ManagedWallet.Asset = asset
|
||||
}
|
||||
asset.Chain = network
|
||||
asset.TokenSymbol = strings.ToUpper(strings.TrimSpace(asset.TokenSymbol))
|
||||
asset.ContractAddress = strings.TrimSpace(asset.ContractAddress)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/tech/sendico/payments/quotation/internal/service/quotation/transfer_intent_hydrator"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
@@ -147,6 +148,93 @@ func TestBuildPlan_ManagedWalletNetworkResolverCachesByWalletRef(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPlan_ResolvesManagedWalletAssetTokenForSourceCurrency(t *testing.T) {
|
||||
resolver := &fakeManagedWalletNetworkResolver{
|
||||
networks: map[string]string{
|
||||
"wallet-usdt-source": "TRON_NILE",
|
||||
},
|
||||
assets: map[string]*paymenttypes.Asset{
|
||||
"wallet-usdt-source": {
|
||||
Chain: "TRON_NILE",
|
||||
TokenSymbol: "USDT",
|
||||
},
|
||||
},
|
||||
}
|
||||
svc := New(nil,
|
||||
WithManagedWalletNetworkResolver(resolver),
|
||||
WithGatewayRegistry(staticGatewayRegistry{
|
||||
items: []*model.GatewayInstanceDescriptor{
|
||||
{
|
||||
ID: "crypto-tron",
|
||||
InstanceID: "crypto-tron",
|
||||
Rail: model.RailCrypto,
|
||||
Network: "TRON_NILE",
|
||||
Currencies: []string{"USDT"},
|
||||
Capabilities: model.RailCapabilities{
|
||||
CanPayOut: true,
|
||||
},
|
||||
IsEnabled: true,
|
||||
},
|
||||
{
|
||||
ID: "fx-tron",
|
||||
InstanceID: "fx-tron",
|
||||
Rail: model.RailProviderSettlement,
|
||||
Network: "TRON_NILE",
|
||||
Currencies: []string{"USDT", "RUB"},
|
||||
Operations: []model.RailOperation{model.RailOperationFXConvert},
|
||||
IsEnabled: true,
|
||||
},
|
||||
{
|
||||
ID: "card-gw",
|
||||
InstanceID: "card-gw",
|
||||
Rail: model.RailCardPayout,
|
||||
Currencies: []string{"RUB"},
|
||||
Capabilities: model.RailCapabilities{
|
||||
CanPayOut: true,
|
||||
},
|
||||
IsEnabled: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
intent := sampleCryptoToCardQuoteIntent()
|
||||
intent.Source.ManagedWallet.Asset = nil
|
||||
intent.Amount = &paymenttypes.Money{
|
||||
Amount: "5000",
|
||||
Currency: "RUB",
|
||||
}
|
||||
intent.SettlementMode = transfer_intent_hydrator.QuoteSettlementModeFixReceived
|
||||
intent.SettlementCurrency = "RUB"
|
||||
intent.RequiresFX = false
|
||||
orgID := bson.NewObjectID()
|
||||
planModel, err := svc.BuildPlan(context.Background(), ComputeInput{
|
||||
OrganizationRef: orgID.Hex(),
|
||||
OrganizationID: orgID,
|
||||
BaseIdempotencyKey: "idem-wallet-asset",
|
||||
Intents: []*transfer_intent_hydrator.QuoteIntent{intent},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if planModel == nil || len(planModel.Items) != 1 || planModel.Items[0] == nil {
|
||||
t.Fatalf("expected one plan item")
|
||||
}
|
||||
item := planModel.Items[0]
|
||||
if got, want := item.Steps[0].GatewayID, "crypto-tron"; got != want {
|
||||
t.Fatalf("unexpected source gateway: got=%q want=%q", got, want)
|
||||
}
|
||||
if item.Steps[0] == nil || item.Steps[0].Amount == nil {
|
||||
t.Fatalf("expected source step amount")
|
||||
}
|
||||
if got, want := item.Steps[0].Amount.GetCurrency(), "USDT"; got != want {
|
||||
t.Fatalf("unexpected source step currency: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := resolver.assetCalls, 1; got != want {
|
||||
t.Fatalf("unexpected asset resolver calls: got=%d want=%d", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPlan_FailsWhenManagedWalletNetworkResolutionFails(t *testing.T) {
|
||||
resolver := &fakeManagedWalletNetworkResolver{
|
||||
err: merrors.NoData("wallet not found"),
|
||||
@@ -168,9 +256,12 @@ func TestBuildPlan_FailsWhenManagedWalletNetworkResolutionFails(t *testing.T) {
|
||||
}
|
||||
|
||||
type fakeManagedWalletNetworkResolver struct {
|
||||
networks map[string]string
|
||||
err error
|
||||
calls int
|
||||
networks map[string]string
|
||||
assets map[string]*paymenttypes.Asset
|
||||
err error
|
||||
assetErr error
|
||||
calls int
|
||||
assetCalls int
|
||||
}
|
||||
|
||||
func (f *fakeManagedWalletNetworkResolver) ResolveManagedWalletNetwork(_ context.Context, managedWalletRef string) (string, error) {
|
||||
@@ -183,3 +274,22 @@ func (f *fakeManagedWalletNetworkResolver) ResolveManagedWalletNetwork(_ context
|
||||
}
|
||||
return f.networks[managedWalletRef], nil
|
||||
}
|
||||
|
||||
func (f *fakeManagedWalletNetworkResolver) ResolveManagedWalletAsset(_ context.Context, managedWalletRef string) (*paymenttypes.Asset, error) {
|
||||
f.assetCalls++
|
||||
if f.assetErr != nil {
|
||||
return nil, f.assetErr
|
||||
}
|
||||
if f.assets == nil {
|
||||
return nil, nil
|
||||
}
|
||||
src := f.assets[managedWalletRef]
|
||||
if src == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return &paymenttypes.Asset{
|
||||
Chain: src.GetChain(),
|
||||
TokenSymbol: src.GetTokenSymbol(),
|
||||
ContractAddress: src.GetContractAddress(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -136,6 +136,7 @@ func (s *QuoteComputationService) buildPlanItem(
|
||||
}
|
||||
modelIntent.Source = clonePaymentEndpoint(source)
|
||||
modelIntent.Destination = clonePaymentEndpoint(destination)
|
||||
ensureDerivedFXIntent(&modelIntent)
|
||||
|
||||
sourceRail, sourceNetwork, err := plan.RailFromEndpoint(source, modelIntent.Attributes, true)
|
||||
if err != nil {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
)
|
||||
|
||||
@@ -20,6 +21,7 @@ func buildComputationSteps(
|
||||
|
||||
attrs := intent.Attributes
|
||||
amount := protoMoneyFromModel(intent.Amount)
|
||||
sourceAmount := sourceStepAmount(intent, amount)
|
||||
destinationAmount := destinationStepAmount(intent, amount)
|
||||
sourceRail := sourceRailForIntent(intent)
|
||||
destinationRail := destinationRailForIntent(intent)
|
||||
@@ -45,7 +47,7 @@ func buildComputationSteps(
|
||||
Operation: sourceOperationForRail(rails[0]),
|
||||
GatewayID: sourceGatewayID,
|
||||
InstanceID: sourceInstanceID,
|
||||
Amount: cloneProtoMoney(amount),
|
||||
Amount: cloneProtoMoney(sourceAmount),
|
||||
Optional: false,
|
||||
IncludeInAggregate: false,
|
||||
},
|
||||
@@ -63,7 +65,7 @@ func buildComputationSteps(
|
||||
Rail: model.RailProviderSettlement,
|
||||
Operation: model.RailOperationFXConvert,
|
||||
DependsOn: []string{sourceStepID},
|
||||
Amount: cloneProtoMoney(amount),
|
||||
Amount: cloneProtoMoney(sourceAmount),
|
||||
Optional: false,
|
||||
IncludeInAggregate: false,
|
||||
})
|
||||
@@ -82,12 +84,16 @@ func buildComputationSteps(
|
||||
operation = model.RailOperationFXConvert
|
||||
fxAssigned = true
|
||||
}
|
||||
stepAmount := amount
|
||||
if operation == model.RailOperationFXConvert {
|
||||
stepAmount = sourceAmount
|
||||
}
|
||||
steps = append(steps, &QuoteComputationStep{
|
||||
StepID: stepID,
|
||||
Rail: rail,
|
||||
Operation: operation,
|
||||
DependsOn: []string{lastStepID},
|
||||
Amount: cloneProtoMoney(amount),
|
||||
Amount: cloneProtoMoney(stepAmount),
|
||||
Optional: false,
|
||||
IncludeInAggregate: false,
|
||||
})
|
||||
@@ -209,11 +215,78 @@ func destinationStepAmount(intent model.PaymentIntent, sourceAmount *moneyv1.Mon
|
||||
}
|
||||
|
||||
settlementCurrency := strings.ToUpper(strings.TrimSpace(intent.SettlementCurrency))
|
||||
if settlementCurrency == "" && intent.FX != nil && intent.FX.Pair != nil {
|
||||
settlementCurrency = strings.ToUpper(strings.TrimSpace(intent.FX.Pair.Quote))
|
||||
if settlementCurrency == "" {
|
||||
settlementCurrency = settlementCurrencyFromFX(intent.FX)
|
||||
}
|
||||
if settlementCurrency != "" {
|
||||
amount.Currency = settlementCurrency
|
||||
}
|
||||
return amount
|
||||
}
|
||||
|
||||
func settlementCurrencyFromFX(fx *model.FXIntent) string {
|
||||
if fx == nil || fx.Pair == nil {
|
||||
return ""
|
||||
}
|
||||
base := normalizeAsset(fx.Pair.GetBase())
|
||||
quote := normalizeAsset(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 sourceStepAmount(intent model.PaymentIntent, amount *moneyv1.Money) *moneyv1.Money {
|
||||
result := cloneProtoMoney(amount)
|
||||
if result == nil {
|
||||
return nil
|
||||
}
|
||||
if currency := sourceStepCurrency(intent, result.GetCurrency()); currency != "" {
|
||||
result.Currency = currency
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func sourceStepCurrency(intent model.PaymentIntent, fallback string) string {
|
||||
if currency := sourceCurrencyFromFX(intent.FX); currency != "" {
|
||||
return currency
|
||||
}
|
||||
if currency := sourceAssetToken(intent.Source); currency != "" {
|
||||
return currency
|
||||
}
|
||||
return normalizeAsset(fallback)
|
||||
}
|
||||
|
||||
func sourceCurrencyFromFX(fx *model.FXIntent) string {
|
||||
if fx == nil || fx.Pair == nil {
|
||||
return ""
|
||||
}
|
||||
base := normalizeAsset(fx.Pair.GetBase())
|
||||
quote := normalizeAsset(fx.Pair.GetQuote())
|
||||
switch fx.Side {
|
||||
case paymenttypes.FXSideBuyBaseSellQuote:
|
||||
return quote
|
||||
case paymenttypes.FXSideSellBaseBuyQuote:
|
||||
return base
|
||||
default:
|
||||
return firstNonEmpty(base, quote)
|
||||
}
|
||||
}
|
||||
|
||||
func sourceAssetToken(endpoint model.PaymentEndpoint) string {
|
||||
if endpoint.ManagedWallet != nil && endpoint.ManagedWallet.Asset != nil {
|
||||
if token := normalizeAsset(endpoint.ManagedWallet.Asset.GetTokenSymbol()); token != "" {
|
||||
return token
|
||||
}
|
||||
}
|
||||
if endpoint.ExternalChain != nil && endpoint.ExternalChain.Asset != nil {
|
||||
if token := normalizeAsset(endpoint.ExternalChain.Asset.GetTokenSymbol()); token != "" {
|
||||
return token
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package quote_computation_service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
)
|
||||
|
||||
func TestDestinationStepAmount_UsesSideAwareCurrencyFallback(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
side paymenttypes.FXSide
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "buy_base_sell_quote uses base settlement currency",
|
||||
side: paymenttypes.FXSideBuyBaseSellQuote,
|
||||
want: "RUB",
|
||||
},
|
||||
{
|
||||
name: "sell_base_buy_quote uses quote settlement currency",
|
||||
side: paymenttypes.FXSideSellBaseBuyQuote,
|
||||
want: "USDT",
|
||||
},
|
||||
{
|
||||
name: "unspecified defaults to quote settlement currency",
|
||||
side: paymenttypes.FXSideUnspecified,
|
||||
want: "USDT",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
intent := model.PaymentIntent{
|
||||
RequiresFX: true,
|
||||
FX: &model.FXIntent{
|
||||
Pair: &paymenttypes.CurrencyPair{
|
||||
Base: "RUB",
|
||||
Quote: "USDT",
|
||||
},
|
||||
Side: tt.side,
|
||||
},
|
||||
}
|
||||
|
||||
got := destinationStepAmount(intent, &moneyv1.Money{
|
||||
Amount: "100",
|
||||
Currency: "EUR",
|
||||
})
|
||||
if got == nil {
|
||||
t.Fatal("expected destination amount")
|
||||
}
|
||||
if got.GetCurrency() != tt.want {
|
||||
t.Fatalf("unexpected destination currency: got=%q want=%q", got.GetCurrency(), tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceCurrencyFromFX_RespectsSide(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
side paymenttypes.FXSide
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "buy_base_sell_quote debits quote currency",
|
||||
side: paymenttypes.FXSideBuyBaseSellQuote,
|
||||
want: "USDT",
|
||||
},
|
||||
{
|
||||
name: "sell_base_buy_quote debits base currency",
|
||||
side: paymenttypes.FXSideSellBaseBuyQuote,
|
||||
want: "RUB",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := sourceCurrencyFromFX(&model.FXIntent{
|
||||
Pair: &paymenttypes.CurrencyPair{
|
||||
Base: "RUB",
|
||||
Quote: "USDT",
|
||||
},
|
||||
Side: tt.side,
|
||||
})
|
||||
if got != tt.want {
|
||||
t.Fatalf("unexpected source currency: got=%q want=%q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -104,10 +104,10 @@ func shapeMatches(record *model.PaymentQuoteRecord, shape QuoteShape) bool {
|
||||
|
||||
switch shape {
|
||||
case QuoteShapeSingle:
|
||||
return len(record.Quotes) == 0
|
||||
return record.RequestShape == model.QuoteRequestShapeSingle && len(record.Items) == 1
|
||||
case QuoteShapeBatch:
|
||||
return len(record.Quotes) > 0
|
||||
return record.RequestShape == model.QuoteRequestShapeBatch && len(record.Items) > 0
|
||||
default:
|
||||
return true
|
||||
return record.RequestShape != model.QuoteRequestShapeUnspecified
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,11 +39,7 @@ func TestTryReuse_ParamMismatch(t *testing.T) {
|
||||
svc := New()
|
||||
store := &fakeQuotesStore{
|
||||
getByIdempotencyKeyFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) {
|
||||
return &model.PaymentQuoteRecord{
|
||||
IdempotencyKey: "idem-1",
|
||||
Hash: "stored-hash",
|
||||
Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q1"},
|
||||
}, nil
|
||||
return testSingleRecord("stored-hash", "q1"), nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -62,11 +58,7 @@ func TestTryReuse_ShapeMismatch(t *testing.T) {
|
||||
svc := New()
|
||||
store := &fakeQuotesStore{
|
||||
getByIdempotencyKeyFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) {
|
||||
return &model.PaymentQuoteRecord{
|
||||
IdempotencyKey: "idem-1",
|
||||
Hash: "hash-1",
|
||||
Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q1"},
|
||||
}, nil
|
||||
return testSingleRecord("hash-1", "q1"), nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -85,13 +77,7 @@ func TestTryReuse_ShapeMismatchSingle(t *testing.T) {
|
||||
svc := New()
|
||||
store := &fakeQuotesStore{
|
||||
getByIdempotencyKeyFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) {
|
||||
return &model.PaymentQuoteRecord{
|
||||
IdempotencyKey: "idem-1",
|
||||
Hash: "hash-1",
|
||||
Quotes: []*model.PaymentQuoteSnapshot{
|
||||
{QuoteRef: "q1"},
|
||||
},
|
||||
}, nil
|
||||
return testBatchRecord("hash-1", "q1"), nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -108,11 +94,7 @@ func TestTryReuse_ShapeMismatchSingle(t *testing.T) {
|
||||
|
||||
func TestTryReuse_Success(t *testing.T) {
|
||||
svc := New()
|
||||
existing := &model.PaymentQuoteRecord{
|
||||
IdempotencyKey: "idem-1",
|
||||
Hash: "hash-1",
|
||||
Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q1"},
|
||||
}
|
||||
existing := testSingleRecord("hash-1", "q1")
|
||||
store := &fakeQuotesStore{
|
||||
getByIdempotencyKeyFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) {
|
||||
return existing, nil
|
||||
@@ -141,11 +123,7 @@ func TestCreateOrReuse_CreateSuccess(t *testing.T) {
|
||||
store := &fakeQuotesStore{
|
||||
createFn: func(context.Context, *model.PaymentQuoteRecord) error { return nil },
|
||||
}
|
||||
record := &model.PaymentQuoteRecord{
|
||||
IdempotencyKey: "idem-1",
|
||||
Hash: "hash-1",
|
||||
Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q1"},
|
||||
}
|
||||
record := testSingleRecord("hash-1", "q1")
|
||||
|
||||
got, reused, err := svc.CreateOrReuse(context.Background(), store, CreateInput{
|
||||
Record: record,
|
||||
@@ -169,22 +147,14 @@ func TestCreateOrReuse_CreateSuccess(t *testing.T) {
|
||||
|
||||
func TestCreateOrReuse_DuplicateReturnsExisting(t *testing.T) {
|
||||
svc := New()
|
||||
existing := &model.PaymentQuoteRecord{
|
||||
IdempotencyKey: "idem-1",
|
||||
Hash: "hash-1",
|
||||
Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q1"},
|
||||
}
|
||||
existing := testSingleRecord("hash-1", "q1")
|
||||
store := &fakeQuotesStore{
|
||||
createFn: func(context.Context, *model.PaymentQuoteRecord) error { return quotestorage.ErrDuplicateQuote },
|
||||
getByIdempotencyKeyFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) {
|
||||
return existing, nil
|
||||
},
|
||||
}
|
||||
record := &model.PaymentQuoteRecord{
|
||||
IdempotencyKey: "idem-1",
|
||||
Hash: "hash-1",
|
||||
Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q2"},
|
||||
}
|
||||
record := testSingleRecord("hash-1", "q2")
|
||||
|
||||
got, reused, err := svc.CreateOrReuse(context.Background(), store, CreateInput{
|
||||
Record: record,
|
||||
@@ -211,20 +181,12 @@ func TestCreateOrReuse_DuplicateParamMismatch(t *testing.T) {
|
||||
store := &fakeQuotesStore{
|
||||
createFn: func(context.Context, *model.PaymentQuoteRecord) error { return quotestorage.ErrDuplicateQuote },
|
||||
getByIdempotencyKeyFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) {
|
||||
return &model.PaymentQuoteRecord{
|
||||
IdempotencyKey: "idem-1",
|
||||
Hash: "stored-hash",
|
||||
Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q1"},
|
||||
}, nil
|
||||
return testSingleRecord("stored-hash", "q1"), nil
|
||||
},
|
||||
}
|
||||
|
||||
_, _, err := svc.CreateOrReuse(context.Background(), store, CreateInput{
|
||||
Record: &model.PaymentQuoteRecord{
|
||||
IdempotencyKey: "idem-1",
|
||||
Hash: "new-hash",
|
||||
Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q2"},
|
||||
},
|
||||
Record: testSingleRecord("new-hash", "q2"),
|
||||
Reuse: ReuseInput{
|
||||
OrganizationID: bson.NewObjectID(),
|
||||
IdempotencyKey: "idem-1",
|
||||
@@ -247,11 +209,7 @@ func TestCreateOrReuse_DuplicateWithoutReusableRecordReturnsDuplicate(t *testing
|
||||
}
|
||||
|
||||
_, _, err := svc.CreateOrReuse(context.Background(), store, CreateInput{
|
||||
Record: &model.PaymentQuoteRecord{
|
||||
IdempotencyKey: "idem-1",
|
||||
Hash: "hash-1",
|
||||
Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q2"},
|
||||
},
|
||||
Record: testSingleRecord("hash-1", "q2"),
|
||||
Reuse: ReuseInput{
|
||||
OrganizationID: bson.NewObjectID(),
|
||||
IdempotencyKey: "idem-1",
|
||||
@@ -286,3 +244,33 @@ func (f *fakeQuotesStore) GetByIdempotencyKey(ctx context.Context, orgRef bson.O
|
||||
}
|
||||
return f.getByIdempotencyKeyFn(ctx, orgRef, idempotencyKey)
|
||||
}
|
||||
|
||||
func testSingleRecord(hash, quoteRef string) *model.PaymentQuoteRecord {
|
||||
return &model.PaymentQuoteRecord{
|
||||
IdempotencyKey: "idem-1",
|
||||
Hash: hash,
|
||||
RequestShape: model.QuoteRequestShapeSingle,
|
||||
Items: []*model.PaymentQuoteItemV2{
|
||||
{
|
||||
Intent: &model.PaymentIntent{Ref: "intent-1"},
|
||||
Quote: &model.PaymentQuoteSnapshot{QuoteRef: quoteRef},
|
||||
Status: &model.QuoteStatusV2{State: model.QuoteStateExecutable},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func testBatchRecord(hash, quoteRef string) *model.PaymentQuoteRecord {
|
||||
return &model.PaymentQuoteRecord{
|
||||
IdempotencyKey: "idem-1",
|
||||
Hash: hash,
|
||||
RequestShape: model.QuoteRequestShapeBatch,
|
||||
Items: []*model.PaymentQuoteItemV2{
|
||||
{
|
||||
Intent: &model.PaymentIntent{Ref: "intent-1"},
|
||||
Quote: &model.PaymentQuoteSnapshot{QuoteRef: quoteRef},
|
||||
Status: &model.QuoteStatusV2{State: model.QuoteStateExecutable},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,19 +13,18 @@ type StatusInput struct {
|
||||
BlockReason quotationv2.QuoteBlockReason
|
||||
}
|
||||
|
||||
type PersistItemInput struct {
|
||||
Intent *model.PaymentIntent
|
||||
Quote *model.PaymentQuoteSnapshot
|
||||
Status *StatusInput
|
||||
}
|
||||
|
||||
type PersistInput struct {
|
||||
OrganizationID bson.ObjectID
|
||||
QuoteRef string
|
||||
IdempotencyKey string
|
||||
Hash string
|
||||
ExpiresAt time.Time
|
||||
|
||||
Intent *model.PaymentIntent
|
||||
Intents []model.PaymentIntent
|
||||
|
||||
Quote *model.PaymentQuoteSnapshot
|
||||
Quotes []*model.PaymentQuoteSnapshot
|
||||
|
||||
Status *StatusInput
|
||||
Statuses []*StatusInput
|
||||
RequestShape model.QuoteRequestShape
|
||||
Items []PersistItemInput
|
||||
}
|
||||
|
||||
@@ -52,53 +52,46 @@ func (s *QuotePersistenceService) BuildRecord(in PersistInput) (*model.PaymentQu
|
||||
if in.ExpiresAt.IsZero() {
|
||||
return nil, merrors.InvalidArgument("expires_at is required")
|
||||
}
|
||||
|
||||
isSingle := in.Quote != nil
|
||||
isBatch := len(in.Quotes) > 0
|
||||
|
||||
if isSingle == isBatch {
|
||||
return nil, merrors.InvalidArgument("exactly one quote shape is required")
|
||||
switch in.RequestShape {
|
||||
case model.QuoteRequestShapeSingle:
|
||||
if len(in.Items) != 1 {
|
||||
return nil, merrors.InvalidArgument("single shape requires exactly one item")
|
||||
}
|
||||
case model.QuoteRequestShapeBatch:
|
||||
if len(in.Items) == 0 {
|
||||
return nil, merrors.InvalidArgument("batch shape requires at least one item")
|
||||
}
|
||||
default:
|
||||
return nil, merrors.InvalidArgument("request_shape is required")
|
||||
}
|
||||
|
||||
record := &model.PaymentQuoteRecord{
|
||||
QuoteRef: strings.TrimSpace(in.QuoteRef),
|
||||
IdempotencyKey: strings.TrimSpace(in.IdempotencyKey),
|
||||
RequestShape: in.RequestShape,
|
||||
Hash: strings.TrimSpace(in.Hash),
|
||||
ExpiresAt: in.ExpiresAt,
|
||||
Items: make([]*model.PaymentQuoteItemV2, 0, len(in.Items)),
|
||||
}
|
||||
record.SetID(bson.NewObjectID())
|
||||
record.SetOrganizationRef(in.OrganizationID)
|
||||
|
||||
if isSingle {
|
||||
if in.Intent == nil {
|
||||
return nil, merrors.InvalidArgument("intent is required")
|
||||
for idx, item := range in.Items {
|
||||
if item.Intent == nil {
|
||||
return nil, merrors.InvalidArgument("items[" + itoa(idx) + "].intent is required")
|
||||
}
|
||||
status, err := mapStatusInput(in.Status)
|
||||
if item.Quote == nil {
|
||||
return nil, merrors.InvalidArgument("items[" + itoa(idx) + "].quote is required")
|
||||
}
|
||||
status, err := mapStatusInput(item.Status)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, merrors.InvalidArgument("items[" + itoa(idx) + "]." + err.Error())
|
||||
}
|
||||
record.Intent = *in.Intent
|
||||
record.Quote = in.Quote
|
||||
record.StatusV2 = status
|
||||
return record, nil
|
||||
record.Items = append(record.Items, &model.PaymentQuoteItemV2{
|
||||
Intent: item.Intent,
|
||||
Quote: item.Quote,
|
||||
Status: status,
|
||||
})
|
||||
}
|
||||
|
||||
if len(in.Intents) == 0 {
|
||||
return nil, merrors.InvalidArgument("intents are required")
|
||||
}
|
||||
if len(in.Intents) != len(in.Quotes) {
|
||||
return nil, merrors.InvalidArgument("intents and quotes count mismatch")
|
||||
}
|
||||
statuses, err := mapStatusInputs(in.Statuses)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(statuses) != len(in.Quotes) {
|
||||
return nil, merrors.InvalidArgument("statuses and quotes count mismatch")
|
||||
}
|
||||
|
||||
record.Intents = in.Intents
|
||||
record.Quotes = in.Quotes
|
||||
record.StatusesV2 = statuses
|
||||
return record, nil
|
||||
}
|
||||
|
||||
@@ -24,12 +24,17 @@ func TestPersistSingle(t *testing.T) {
|
||||
IdempotencyKey: "idem-1",
|
||||
Hash: "hash-1",
|
||||
ExpiresAt: time.Now().Add(time.Minute),
|
||||
Intent: &model.PaymentIntent{Ref: "intent-1"},
|
||||
Quote: &model.PaymentQuoteSnapshot{
|
||||
QuoteRef: "quote-1",
|
||||
},
|
||||
Status: &StatusInput{
|
||||
State: quotationv2.QuoteState_QUOTE_STATE_EXECUTABLE,
|
||||
RequestShape: model.QuoteRequestShapeSingle,
|
||||
Items: []PersistItemInput{
|
||||
{
|
||||
Intent: &model.PaymentIntent{Ref: "intent-1"},
|
||||
Quote: &model.PaymentQuoteSnapshot{
|
||||
QuoteRef: "quote-1",
|
||||
},
|
||||
Status: &StatusInput{
|
||||
State: quotationv2.QuoteState_QUOTE_STATE_EXECUTABLE,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
@@ -41,17 +46,20 @@ func TestPersistSingle(t *testing.T) {
|
||||
if store.created == nil {
|
||||
t.Fatalf("expected record to be created")
|
||||
}
|
||||
if store.created.ExecutionNote != "" {
|
||||
t.Fatalf("expected no legacy execution note, got %q", store.created.ExecutionNote)
|
||||
if store.created.RequestShape != model.QuoteRequestShapeSingle {
|
||||
t.Fatalf("unexpected request shape: %q", store.created.RequestShape)
|
||||
}
|
||||
if store.created.StatusV2 == nil {
|
||||
t.Fatalf("expected v2 status metadata")
|
||||
if len(store.created.Items) != 1 || store.created.Items[0] == nil {
|
||||
t.Fatalf("expected single persisted item")
|
||||
}
|
||||
if store.created.StatusV2.State != model.QuoteStateExecutable {
|
||||
t.Fatalf("unexpected state: %q", store.created.StatusV2.State)
|
||||
if store.created.Items[0].Status == nil {
|
||||
t.Fatalf("expected item status metadata")
|
||||
}
|
||||
if store.created.StatusV2.BlockReason != model.QuoteBlockReasonUnspecified {
|
||||
t.Fatalf("unexpected block_reason: %q", store.created.StatusV2.BlockReason)
|
||||
if store.created.Items[0].Status.State != model.QuoteStateExecutable {
|
||||
t.Fatalf("unexpected state: %q", store.created.Items[0].Status.State)
|
||||
}
|
||||
if store.created.Items[0].Status.BlockReason != model.QuoteBlockReasonUnspecified {
|
||||
t.Fatalf("unexpected block_reason: %q", store.created.Items[0].Status.BlockReason)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,21 +74,22 @@ func TestPersistBatch(t *testing.T) {
|
||||
IdempotencyKey: "idem-batch-1",
|
||||
Hash: "hash-batch-1",
|
||||
ExpiresAt: time.Now().Add(time.Minute),
|
||||
Intents: []model.PaymentIntent{
|
||||
{Ref: "i1"},
|
||||
{Ref: "i2"},
|
||||
},
|
||||
Quotes: []*model.PaymentQuoteSnapshot{
|
||||
{QuoteRef: "q1"},
|
||||
{QuoteRef: "q2"},
|
||||
},
|
||||
Statuses: []*StatusInput{
|
||||
RequestShape: model.QuoteRequestShapeBatch,
|
||||
Items: []PersistItemInput{
|
||||
{
|
||||
State: quotationv2.QuoteState_QUOTE_STATE_BLOCKED,
|
||||
BlockReason: quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_ROUTE_UNAVAILABLE,
|
||||
Intent: &model.PaymentIntent{Ref: "i1"},
|
||||
Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q1"},
|
||||
Status: &StatusInput{
|
||||
State: quotationv2.QuoteState_QUOTE_STATE_BLOCKED,
|
||||
BlockReason: quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_ROUTE_UNAVAILABLE,
|
||||
},
|
||||
},
|
||||
{
|
||||
State: quotationv2.QuoteState_QUOTE_STATE_INDICATIVE,
|
||||
Intent: &model.PaymentIntent{Ref: "i2"},
|
||||
Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q2"},
|
||||
Status: &StatusInput{
|
||||
State: quotationv2.QuoteState_QUOTE_STATE_INDICATIVE,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -90,11 +99,14 @@ func TestPersistBatch(t *testing.T) {
|
||||
if record == nil {
|
||||
t.Fatalf("expected record")
|
||||
}
|
||||
if len(record.StatusesV2) != 2 {
|
||||
t.Fatalf("expected 2 statuses, got %d", len(record.StatusesV2))
|
||||
if record.RequestShape != model.QuoteRequestShapeBatch {
|
||||
t.Fatalf("unexpected request shape: %q", record.RequestShape)
|
||||
}
|
||||
if record.StatusesV2[0].BlockReason != model.QuoteBlockReasonRouteUnavailable {
|
||||
t.Fatalf("unexpected first status block reason: %q", record.StatusesV2[0].BlockReason)
|
||||
if len(record.Items) != 2 {
|
||||
t.Fatalf("expected 2 items, got %d", len(record.Items))
|
||||
}
|
||||
if record.Items[0].Status == nil || record.Items[0].Status.BlockReason != model.QuoteBlockReasonRouteUnavailable {
|
||||
t.Fatalf("unexpected first status block reason")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,11 +126,16 @@ func TestPersistValidation(t *testing.T) {
|
||||
IdempotencyKey: "i",
|
||||
Hash: "h",
|
||||
ExpiresAt: time.Now().Add(time.Minute),
|
||||
Intent: &model.PaymentIntent{Ref: "intent"},
|
||||
Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q"},
|
||||
Status: &StatusInput{
|
||||
State: quotationv2.QuoteState_QUOTE_STATE_BLOCKED,
|
||||
BlockReason: quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED,
|
||||
RequestShape: model.QuoteRequestShapeSingle,
|
||||
Items: []PersistItemInput{
|
||||
{
|
||||
Intent: &model.PaymentIntent{Ref: "intent"},
|
||||
Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q"},
|
||||
Status: &StatusInput{
|
||||
State: quotationv2.QuoteState_QUOTE_STATE_BLOCKED,
|
||||
BlockReason: quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if !errors.Is(err, merrors.ErrInvalidArg) {
|
||||
@@ -131,16 +148,22 @@ func TestPersistValidation(t *testing.T) {
|
||||
IdempotencyKey: "i",
|
||||
Hash: "h",
|
||||
ExpiresAt: time.Now().Add(time.Minute),
|
||||
Intents: []model.PaymentIntent{
|
||||
{Ref: "i1"},
|
||||
RequestShape: model.QuoteRequestShapeSingle,
|
||||
Items: []PersistItemInput{
|
||||
{
|
||||
Intent: &model.PaymentIntent{Ref: "i1"},
|
||||
Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q1"},
|
||||
Status: &StatusInput{State: quotationv2.QuoteState_QUOTE_STATE_EXECUTABLE},
|
||||
},
|
||||
{
|
||||
Intent: &model.PaymentIntent{Ref: "i2"},
|
||||
Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q2"},
|
||||
Status: &StatusInput{State: quotationv2.QuoteState_QUOTE_STATE_EXECUTABLE},
|
||||
},
|
||||
},
|
||||
Quotes: []*model.PaymentQuoteSnapshot{
|
||||
{QuoteRef: "q1"},
|
||||
},
|
||||
Statuses: []*StatusInput{},
|
||||
})
|
||||
if !errors.Is(err, merrors.ErrInvalidArg) {
|
||||
t.Fatalf("expected invalid argument for statuses mismatch, got %v", err)
|
||||
t.Fatalf("expected invalid argument for single shape with multiple items, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,22 +28,6 @@ func mapStatusInput(input *StatusInput) (*model.QuoteStatusV2, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func mapStatusInputs(inputs []*StatusInput) ([]*model.QuoteStatusV2, error) {
|
||||
if len(inputs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
result := make([]*model.QuoteStatusV2, 0, len(inputs))
|
||||
for i, item := range inputs {
|
||||
mapped, err := mapStatusInput(item)
|
||||
if err != nil {
|
||||
return nil, merrors.InvalidArgument("statuses[" + itoa(i) + "]: " + err.Error())
|
||||
}
|
||||
result = append(result, mapped)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func mapQuoteState(state quotationv2.QuoteState) model.QuoteState {
|
||||
switch state {
|
||||
case quotationv2.QuoteState_QUOTE_STATE_INDICATIVE:
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1"
|
||||
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
|
||||
@@ -41,7 +42,7 @@ func TestMap_ExecutableQuote(t *testing.T) {
|
||||
},
|
||||
Route: "ationv2.RouteSpecification{
|
||||
Rail: "CARD",
|
||||
Provider: "monetix",
|
||||
Provider: paymenttypes.DefaultCardsGatewayID,
|
||||
PayoutMethod: "CARD",
|
||||
Settlement: "ationv2.RouteSettlement{
|
||||
Asset: &paymentv1.ChainAsset{
|
||||
@@ -100,7 +101,7 @@ func TestMap_ExecutableQuote(t *testing.T) {
|
||||
if got := out.Quote.GetPricedAt().AsTime(); !got.Equal(pricedAt) {
|
||||
t.Fatalf("unexpected priced_at: %v", got)
|
||||
}
|
||||
if got, want := out.Quote.GetRoute().GetProvider(), "monetix"; got != want {
|
||||
if got, want := out.Quote.GetRoute().GetProvider(), paymenttypes.DefaultCardsGatewayID; got != want {
|
||||
t.Fatalf("unexpected route provider: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := out.Quote.GetRoute().GetSettlement().GetAsset().GetKey().GetTokenSymbol(), "USD"; got != want {
|
||||
|
||||
@@ -139,7 +139,6 @@ Converts plan/build errors to QuoteBlockReason.
|
||||
Produces quote state (executable/blocked/indicative + block_reason when blocked).
|
||||
QuotePersistenceService
|
||||
Persists quote record with v2 status metadata.
|
||||
Keeps legacy ExecutionNote for backward compatibility.
|
||||
QuoteResponseMapperV2
|
||||
Maps canonical quote + status to quotationv2.PaymentQuote.
|
||||
Enforces your lifecycle/execution invariants.
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
payecon "github.com/tech/sendico/pkg/payments/economics"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||
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"
|
||||
@@ -133,6 +134,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()),
|
||||
SettlementCurrency: settlementCurrency,
|
||||
RequiresFX: requiresFX,
|
||||
Attributes: map[string]string{
|
||||
@@ -210,3 +212,14 @@ func feeTreatmentFromProto(value quotationv2.FeeTreatment) QuoteFeeTreatment {
|
||||
return QuoteFeeTreatmentUnspecified
|
||||
}
|
||||
}
|
||||
|
||||
func fxSideFromProto(side fxv1.Side) paymenttypes.FXSide {
|
||||
switch side {
|
||||
case fxv1.Side_BUY_BASE_SELL_QUOTE:
|
||||
return paymenttypes.FXSideBuyBaseSellQuote
|
||||
case fxv1.Side_SELL_BASE_BUY_QUOTE:
|
||||
return paymenttypes.FXSideSellBaseBuyQuote
|
||||
default:
|
||||
return paymenttypes.FXSideUnspecified
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +84,7 @@ type QuoteIntent struct {
|
||||
Comment string
|
||||
SettlementMode QuoteSettlementMode
|
||||
FeeTreatment QuoteFeeTreatment
|
||||
FXSide paymenttypes.FXSide
|
||||
SettlementCurrency string
|
||||
RequiresFX bool
|
||||
Attributes map[string]string
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"testing"
|
||||
|
||||
pkgmodel "github.com/tech/sendico/pkg/model"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1"
|
||||
pboundv1 "github.com/tech/sendico/pkg/proto/common/permission_bound/v1"
|
||||
@@ -98,6 +100,51 @@ func TestHydrateOne_SuccessWithInlineMethods(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHydrateOne_PropagatesFXSide(t *testing.T) {
|
||||
h := New(nil, WithRefFactory(func() string { return "q-intent-fx-side" }))
|
||||
intent := "ationv2.QuoteIntent{
|
||||
Source: &endpointv1.PaymentEndpoint{
|
||||
Source: &endpointv1.PaymentEndpoint_PaymentMethod{
|
||||
PaymentMethod: &endpointv1.PaymentMethod{
|
||||
Type: endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_WALLET,
|
||||
Data: mustMarshalBSON(t, pkgmodel.WalletPaymentData{WalletID: "mw-src-1"}),
|
||||
},
|
||||
},
|
||||
},
|
||||
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", "USDT"),
|
||||
SettlementCurrency: "RUB",
|
||||
FxSide: fxv1.Side_BUY_BASE_SELL_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.FXSide != paymenttypes.FXSideBuyBaseSellQuote {
|
||||
t.Fatalf("unexpected fx side: got=%q", got.FXSide)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHydrateOne_ResolvesPaymentMethodRefViaPrivateMethod(t *testing.T) {
|
||||
orgRef := bson.NewObjectID().Hex()
|
||||
methodRef := bson.NewObjectID().Hex()
|
||||
@@ -681,14 +728,6 @@ func newMoney(amount, currency string) *moneyv1.Money {
|
||||
}
|
||||
}
|
||||
|
||||
func endpointWithMethodRef(methodRef string) *endpointv1.PaymentEndpoint {
|
||||
return &endpointv1.PaymentEndpoint{
|
||||
Source: &endpointv1.PaymentEndpoint_PaymentMethodRef{
|
||||
PaymentMethodRef: methodRef,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func mustMarshalBSON(t *testing.T, value any) []byte {
|
||||
t.Helper()
|
||||
data, err := bson.Marshal(value)
|
||||
|
||||
Reference in New Issue
Block a user