quotation service fixed

This commit is contained in:
Stephan D
2026-02-24 16:14:09 +01:00
parent 6444813f38
commit 2fe90347a8
76 changed files with 769 additions and 230 deletions

View File

@@ -64,8 +64,8 @@ func networkPriority(edgeNetwork, requested string) int {
}
func normalizeRail(value model.Rail) model.Rail {
normalized := model.Rail(strings.ToUpper(strings.TrimSpace(string(value))))
if normalized == "" {
normalized := model.ParseRail(string(value))
if normalized == model.RailUnspecified {
return model.RailUnspecified
}
return normalized

View File

@@ -23,7 +23,7 @@ func TestFind_NetworkFiltersEdges(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
assertPathRails(t, path, []string{"CRYPTO", "LEDGER", "CARD_PAYOUT"})
assertPathRails(t, path, []string{"CRYPTO", "LEDGER", "CARD"})
if got, want := path.Edges[0].Network, "TRON"; got != want {
t.Fatalf("unexpected first edge network: got=%q want=%q", got, want)
}
@@ -47,7 +47,7 @@ func TestFind_PrefersExactNetworkOverWildcard(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
assertPathRails(t, path, []string{"CRYPTO", "PROVIDER_SETTLEMENT", "CARD_PAYOUT"})
assertPathRails(t, path, []string{"CRYPTO", "SETTLEMENT", "CARD"})
}
func TestFind_DeterministicTieBreak(t *testing.T) {
@@ -68,7 +68,7 @@ func TestFind_DeterministicTieBreak(t *testing.T) {
}
// Both routes have equal length; lexical tie-break chooses LEDGER branch.
assertPathRails(t, path, []string{"CRYPTO", "LEDGER", "CARD_PAYOUT"})
assertPathRails(t, path, []string{"CRYPTO", "LEDGER", "CARD"})
}
func TestFind_IgnoresInvalidEdges(t *testing.T) {
@@ -88,5 +88,5 @@ func TestFind_IgnoresInvalidEdges(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
assertPathRails(t, path, []string{"CRYPTO", "LEDGER", "CARD_PAYOUT"})
assertPathRails(t, path, []string{"CRYPTO", "LEDGER", "CARD"})
}

View File

@@ -62,7 +62,7 @@ func TestFind_FindsIndirectPath(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
assertPathRails(t, path, []string{"CRYPTO", "LEDGER", "CARD_PAYOUT"})
assertPathRails(t, path, []string{"CRYPTO", "LEDGER", "CARD"})
if got, want := len(path.Edges), 2; got != want {
t.Fatalf("unexpected edge count: got=%d want=%d", got, want)
}
@@ -84,7 +84,7 @@ func TestFind_PrefersShortestPath(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
assertPathRails(t, path, []string{"CRYPTO", "CARD_PAYOUT"})
assertPathRails(t, path, []string{"CRYPTO", "CARD"})
}
func TestFind_HandlesCycles(t *testing.T) {
@@ -103,7 +103,7 @@ func TestFind_HandlesCycles(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
assertPathRails(t, path, []string{"CRYPTO", "LEDGER", "CARD_PAYOUT"})
assertPathRails(t, path, []string{"CRYPTO", "LEDGER", "CARD"})
}
func TestFind_ReturnsErrorWhenPathUnavailable(t *testing.T) {

View File

@@ -131,6 +131,42 @@ func TestBuildPlan_RequiresFXAddsMiddleStep(t *testing.T) {
}
}
func TestBuildPlan_RequiresFXUsesSettlementCurrencyForDestinationStep(t *testing.T) {
svc := New(nil)
orgID := bson.NewObjectID()
intent := sampleCryptoToCardQuoteIntent()
intent.RequiresFX = true
intent.SettlementCurrency = "RUB"
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")
}
steps := planModel.Items[0].Steps
if got, want := len(steps), 4; got != want {
t.Fatalf("unexpected step count: got=%d want=%d", 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 := last.Operation, model.RailOperationSend; got != want {
t.Fatalf("unexpected destination operation: got=%q want=%q", got, want)
}
}
func TestBuildPlan_SelectsGatewaysAndIgnoresIrrelevant(t *testing.T) {
svc := New(nil, WithGatewayRegistry(staticGatewayRegistry{
items: []*model.GatewayInstanceDescriptor{
@@ -406,7 +442,7 @@ func TestCompute_FailsWhenCoreReturnsDifferentRoute(t *testing.T) {
quote: &ComputedQuote{
DebitAmount: &moneyv1.Money{Amount: "100", Currency: "USD"},
Route: &quotationv2.RouteSpecification{
Rail: "CARD_PAYOUT",
Rail: "CARD",
Provider: "other-provider",
PayoutMethod: "CARD",
},

View File

@@ -16,6 +16,9 @@ func (s *QuoteComputationService) resolveRouteRails(
destinationRail model.Rail,
network string,
) ([]model.Rail, error) {
sourceRail = model.ParseRail(string(sourceRail))
destinationRail = model.ParseRail(string(destinationRail))
s.logger.Debug("Resolving route rails",
zap.String("source_rail", string(sourceRail)),
zap.String("dest_rail", string(destinationRail)),
@@ -179,8 +182,8 @@ func (s *QuoteComputationService) routeGraphEdges(ctx context.Context) ([]graph_
continue
}
from := model.Rail(strings.ToUpper(strings.TrimSpace(string(route.FromRail))))
to := model.Rail(strings.ToUpper(strings.TrimSpace(string(route.ToRail))))
from := model.ParseRail(string(route.FromRail))
to := model.ParseRail(string(route.ToRail))
if from == model.RailUnspecified || to == model.RailUnspecified {
continue

View File

@@ -74,7 +74,7 @@ func TestBuildPlan_UsesRouteGraphPath(t *testing.T) {
if got, want := string(item.Steps[1].Rail), string(model.RailProviderSettlement); got != want {
t.Fatalf("unexpected transit rail: got=%q want=%q", got, want)
}
if got := strings.ToUpper(strings.TrimSpace(item.Route.GetHops()[1].GetRail())); got != "PROVIDER_SETTLEMENT" {
if got := strings.ToUpper(strings.TrimSpace(item.Route.GetHops()[1].GetRail())); got != "SETTLEMENT" {
t.Fatalf("unexpected route transit hop rail: %q", got)
}
}

View File

@@ -5,6 +5,7 @@ import (
"strings"
"github.com/tech/sendico/payments/storage/model"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
)
func buildComputationSteps(
@@ -19,6 +20,7 @@ func buildComputationSteps(
attrs := intent.Attributes
amount := protoMoneyFromModel(intent.Amount)
destinationAmount := destinationStepAmount(intent, amount)
sourceRail := sourceRailForIntent(intent)
destinationRail := destinationRailForIntent(intent)
rails := normalizeRouteRails(sourceRail, destinationRail, routeRails)
@@ -101,7 +103,7 @@ func buildComputationSteps(
GatewayID: destinationGatewayID,
InstanceID: destinationInstanceID,
DependsOn: []string{lastStepID},
Amount: cloneProtoMoney(amount),
Amount: destinationAmount,
Optional: false,
IncludeInAggregate: true,
})
@@ -196,3 +198,22 @@ func destinationOperationForRail(rail model.Rail) model.RailOperation {
return model.RailOperationExternalCredit
}
}
func destinationStepAmount(intent model.PaymentIntent, sourceAmount *moneyv1.Money) *moneyv1.Money {
amount := cloneProtoMoney(sourceAmount)
if amount == nil {
return nil
}
if !intent.RequiresFX {
return amount
}
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 != "" {
amount.Currency = settlementCurrency
}
return amount
}

View File

@@ -3,6 +3,7 @@ package quote_computation_service
import (
"strings"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/merrors"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
)
@@ -59,6 +60,9 @@ func normalizeSettlementParts(src *quotationv2.RouteSettlement) (chain, token, c
}
func normalizeRail(value string) string {
if rail := model.ParseRail(value); rail != model.RailUnspecified {
return string(rail)
}
return strings.ToUpper(strings.TrimSpace(value))
}

View File

@@ -40,7 +40,7 @@ func TestMap_ExecutableQuote(t *testing.T) {
Currency: "USD",
},
Route: &quotationv2.RouteSpecification{
Rail: "CARD_PAYOUT",
Rail: "CARD",
Provider: "monetix",
PayoutMethod: "CARD",
Settlement: &quotationv2.RouteSettlement{