payment quotation v2 + payment orchestration v2 draft

This commit is contained in:
Stephan D
2026-02-24 13:01:35 +01:00
parent 0646f55189
commit 6444813f38
289 changed files with 17005 additions and 16065 deletions

View File

@@ -2,9 +2,9 @@ package quote_computation_service
import (
"context"
"fmt"
"github.com/tech/sendico/pkg/merrors"
"go.uber.org/zap"
)
func (s *QuoteComputationService) Compute(ctx context.Context, in ComputeInput) (*ComputeOutput, error) {
@@ -12,8 +12,19 @@ func (s *QuoteComputationService) Compute(ctx context.Context, in ComputeInput)
return nil, merrors.InvalidArgument("quote computation core is required")
}
s.logger.Debug("Computing quotes",
zap.String("org_ref", in.OrganizationRef),
zap.Int("intent_count", len(in.Intents)),
zap.Bool("preview_only", in.PreviewOnly),
)
planModel, err := s.BuildPlan(ctx, in)
if err != nil {
s.logger.Warn("Quote plan build failed",
zap.String("org_ref", in.OrganizationRef),
zap.Error(err),
)
return nil, err
}
@@ -24,11 +35,26 @@ func (s *QuoteComputationService) Compute(ctx context.Context, in ComputeInput)
if item == nil {
return nil, computeErr
}
return nil, fmt.Errorf("Item %d: %w", item.Index, computeErr)
s.logger.Warn("Quote item computation failed",
zap.String("org_ref", in.OrganizationRef),
zap.Int("item_index", item.Index),
zap.String("intent_ref", item.IntentRef),
zap.Error(computeErr),
)
return nil, wrapIndexedError(computeErr, "Item %d", item.Index)
}
results = append(results, computed)
}
s.logger.Debug("Quote computation completed",
zap.String("org_ref", in.OrganizationRef),
zap.String("plan_mode", string(planModel.Mode)),
zap.Int("item_count", len(results)),
)
return &ComputeOutput{
Plan: planModel,
Results: results,
@@ -45,18 +71,38 @@ func (s *QuoteComputationService) computePlanItem(
quote, expiresAt, err := s.core.BuildQuote(ctx, item.QuoteInput)
if err != nil {
s.logger.Warn("Quote build failed",
zap.Int("item_index", item.Index),
zap.String("intent_ref", item.IntentRef),
zap.Error(err),
)
return nil, err
}
enrichedQuote := ensureComputedQuote(quote, item)
if bindErr := validateQuoteRouteBinding(enrichedQuote, item.QuoteInput); bindErr != nil {
s.logger.Warn("Quote route binding validation failed",
zap.Int("item_index", item.Index),
zap.String("intent_ref", item.IntentRef),
zap.Error(bindErr),
)
return nil, bindErr
}
result := &QuoteComputationResult{
s.logger.Debug("Quote item computed",
zap.Int("item_index", item.Index),
zap.String("intent_ref", item.IntentRef),
zap.String("quote_ref", enrichedQuote.QuoteRef),
zap.Time("expires_at", expiresAt),
zap.String("block_reason", item.BlockReason.String()),
)
return &QuoteComputationResult{
ItemIndex: item.Index,
Quote: enrichedQuote,
ExpiresAt: expiresAt,
BlockReason: item.BlockReason,
}
return result, nil
}, nil
}

View File

@@ -1,8 +1,6 @@
package quote_computation_service
import (
"strings"
"github.com/tech/sendico/payments/storage/model"
paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
@@ -19,17 +17,6 @@ func resolvedSettlementModeFromModel(mode model.SettlementMode) paymentv1.Settle
}
}
func resolvedSettlementModeFromRouteModelValue(value string) paymentv1.SettlementMode {
switch strings.ToUpper(strings.TrimSpace(value)) {
case "FIX_RECEIVED", "SETTLEMENT_FIX_RECEIVED", "SETTLEMENT_MODE_FIX_RECEIVED":
return paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED
case "FIX_SOURCE", "SETTLEMENT_FIX_SOURCE", "SETTLEMENT_MODE_FIX_SOURCE":
return paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE
default:
return paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE
}
}
func resolvedFeeTreatmentForSettlementMode(mode paymentv1.SettlementMode) quotationv2.FeeTreatment {
switch mode {
case paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED:

View File

@@ -0,0 +1,16 @@
package quote_computation_service
import (
"errors"
"fmt"
"github.com/tech/sendico/pkg/merrors"
)
func wrapIndexedError(err error, format string, args ...any) error {
msg := fmt.Sprintf(format, args...)
if errors.Is(err, merrors.ErrInvalidArg) {
return merrors.InvalidArgumentWrap(err, msg)
}
return merrors.InternalWrap(err, msg)
}

View File

@@ -2,7 +2,6 @@ package quote_computation_service
import (
"context"
"fmt"
"sort"
"strings"
@@ -11,6 +10,7 @@ import (
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/merrors"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
"go.uber.org/zap"
)
func (s *QuoteComputationService) resolveStepGateways(
@@ -19,14 +19,28 @@ func (s *QuoteComputationService) resolveStepGateways(
routeNetwork string,
) error {
if s == nil || s.gatewayRegistry == nil {
s.logger.Debug("Step gateway resolution skipped: no gateway registry configured")
return nil
}
s.logger.Debug("Loading gateway registry",
zap.Int("step_count", len(steps)),
zap.String("route_network", routeNetwork),
)
gateways, err := s.gatewayRegistry.List(ctx)
if err != nil {
s.logger.Warn("Step gateway resolution failed: gateway registry list error",
zap.Error(err),
)
return err
}
if len(gateways) == 0 {
s.logger.Warn("Step gateway resolution failed: gateway registry has no entries")
return merrors.InvalidArgument("gateway registry has no entries")
}
@@ -40,29 +54,54 @@ func (s *QuoteComputationService) resolveStepGateways(
return model.LessGatewayDescriptor(sorted[i], sorted[j])
})
s.logger.Debug("Gateway registry loaded", zap.Int("gateway_count", len(sorted)))
for idx, step := range steps {
if step == nil {
continue
}
if step.Rail == model.RailLedger {
step.GatewayID = "internal"
step.GatewayInvokeURI = ""
s.logger.Debug("Step gateway assigned: ledger rail uses internal gateway",
zap.String("step_id", strings.TrimSpace(step.StepID)),
zap.Int("step_index", idx),
)
continue
}
selected, selectErr := selectGatewayForStep(sorted, step, routeNetwork)
selected, selectErr := s.selectGatewayForStep(sorted, step, routeNetwork)
if selectErr != nil {
return fmt.Errorf("Step[%d] %s: %w", idx, strings.TrimSpace(step.StepID), selectErr)
s.logger.Warn("Step gateway resolution failed: no eligible gateway for step",
zap.String("step_id", strings.TrimSpace(step.StepID)),
zap.Int("step_index", idx),
zap.String("rail", string(step.Rail)),
zap.String("route_network", routeNetwork),
zap.Error(selectErr),
)
return selectErr
}
step.GatewayID = strings.TrimSpace(selected.ID)
step.InstanceID = strings.TrimSpace(selected.InstanceID)
step.GatewayInvokeURI = strings.TrimSpace(selected.InvokeURI)
s.logger.Debug("Gateway selected for step",
zap.String("step_id", strings.TrimSpace(step.StepID)),
zap.String("rail", string(step.Rail)),
zap.String("gateway_id", step.GatewayID),
zap.String("instance_id", step.InstanceID),
)
}
return nil
}
func selectGatewayForStep(
func (s *QuoteComputationService) selectGatewayForStep(
gateways []*model.GatewayInstanceDescriptor,
step *QuoteComputationStep,
routeNetwork string,
@@ -82,39 +121,67 @@ func selectGatewayForStep(
amount = parsed
}
}
action := gatewayEligibilityOperation(step.Operation)
action := step.Operation
direction := plan.SendDirectionForRail(step.Rail)
network := networkForGatewaySelection(step.Rail, routeNetwork)
s.logger.Debug("Selecting gateway for step",
zap.String("step_id", strings.TrimSpace(step.StepID)),
zap.String("rail", string(step.Rail)),
zap.String("network", network),
zap.String("currency", currency),
zap.String("action", string(action)),
zap.String("preferred_gateway", step.GatewayID),
)
eligible := make([]*model.GatewayInstanceDescriptor, 0, len(gateways))
var lastErr error
for _, gw := range gateways {
for i, gw := range gateways {
if gw == nil {
s.logger.Warn("Nil gateway found", zap.Int("gateway_index", i))
continue
}
if err := plan.IsGatewayEligible(gw, step.Rail, network, currency, action, direction, amount); err != nil {
lastErr = err
eligErr := plan.IsGatewayEligible(gw, step.Rail, network, currency, action, direction, amount)
s.logger.Debug("Gateway eligibility check",
zap.String("step_id", strings.TrimSpace(step.StepID)),
zap.String("gateway_id", strings.TrimSpace(gw.ID)),
zap.String("instance_id", strings.TrimSpace(gw.InstanceID)),
zap.Bool("eligible", eligErr == nil),
zap.Error(eligErr),
)
if eligErr != nil {
continue
}
eligible = append(eligible, gw)
}
if selected, _ := model.SelectGatewayByPreference(
eligible,
step.GatewayID,
step.InstanceID,
step.GatewayInvokeURI,
); selected != nil {
s.logger.Debug("Gateway eligibility evaluated",
zap.String("step_id", strings.TrimSpace(step.StepID)),
zap.Int("eligible_count", len(eligible)),
zap.Int("total_count", len(gateways)),
)
selected, _ := model.SelectGatewayByPreference(eligible, step.GatewayID, step.InstanceID, step.GatewayInvokeURI)
if selected == nil && len(eligible) > 0 {
selected = eligible[0]
}
if selected != nil {
s.logger.Debug("Gateway selected",
zap.String("step_id", strings.TrimSpace(step.StepID)),
zap.String("gateway_id", strings.TrimSpace(selected.ID)),
zap.String("instance_id", strings.TrimSpace(selected.InstanceID)),
)
return selected, nil
}
if len(eligible) > 0 {
return eligible[0], nil
}
if lastErr != nil {
return nil, merrors.InvalidArgument("no eligible gateway: " + lastErr.Error())
}
return nil, merrors.InvalidArgument("no eligible gateway")
s.logger.Warn("No eligible gateway found for step",
zap.String("step_id", strings.TrimSpace(step.StepID)),
zap.String("rail", string(step.Rail)),
zap.String("network", network),
zap.String("currency", currency),
)
return nil, merrors.NoData(model.NoEligibleGatewayMessage(network, currency, action, toGatewayDirection(direction)))
}
func parseDecimalAmount(m *moneyv1.Money) (decimal.Decimal, error) {
@@ -132,15 +199,6 @@ func parseDecimalAmount(m *moneyv1.Money) (decimal.Decimal, error) {
return parsed, nil
}
func gatewayEligibilityOperation(op model.RailOperation) model.RailOperation {
switch op {
case model.RailOperationExternalDebit, model.RailOperationExternalCredit:
return model.RailOperationSend
default:
return op
}
}
func networkForGatewaySelection(rail model.Rail, routeNetwork string) string {
switch rail {
case model.RailCrypto, model.RailProviderSettlement, model.RailFiatOnRamp:
@@ -150,23 +208,15 @@ func networkForGatewaySelection(rail model.Rail, routeNetwork string) string {
}
}
func hasExplicitDestinationGateway(attrs map[string]string) bool {
return strings.TrimSpace(firstNonEmpty(
lookupAttr(attrs, "gateway", "gateway_id", "gatewayId"),
lookupAttr(attrs, "destination_gateway", "destinationGateway"),
)) != ""
}
func clearImplicitDestinationGateway(steps []*QuoteComputationStep) {
if len(steps) == 0 {
return
func toGatewayDirection(dir plan.SendDirection) model.GatewayDirection {
switch dir {
case plan.SendDirectionOut:
return model.GatewayDirectionOut
case plan.SendDirectionIn:
return model.GatewayDirectionIn
default:
return model.GatewayDirectionAny
}
last := steps[len(steps)-1]
if last == nil {
return
}
last.GatewayID = ""
last.GatewayInvokeURI = ""
}
func destinationGatewayFromSteps(steps []*QuoteComputationStep) string {

View File

@@ -8,8 +8,6 @@ import (
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
)
const defaultCardGateway = "monetix"
func cloneProtoMoney(src *moneyv1.Money) *moneyv1.Money {
if src == nil {
return nil

View File

@@ -13,6 +13,7 @@ import (
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
func (s *QuoteComputationService) BuildPlan(ctx context.Context, in ComputeInput) (*QuoteComputationPlan, error) {
@@ -33,6 +34,14 @@ func (s *QuoteComputationService) BuildPlan(ctx context.Context, in ComputeInput
if len(in.Intents) > 1 {
mode = PlanModeBatch
}
s.logger.Debug("Building computation plan",
zap.String("org_ref", in.OrganizationRef),
zap.String("plan_mode", string(mode)),
zap.Int("intent_count", len(in.Intents)),
zap.Bool("preview_only", in.PreviewOnly),
)
planModel := &QuoteComputationPlan{
Mode: mode,
OrganizationRef: strings.TrimSpace(in.OrganizationRef),
@@ -45,11 +54,24 @@ func (s *QuoteComputationService) BuildPlan(ctx context.Context, in ComputeInput
for i, intent := range in.Intents {
item, err := s.buildPlanItem(ctx, in, i, intent)
if err != nil {
return nil, fmt.Errorf("intents[%d]: %w", i, err)
s.logger.Warn("Computation plan item build failed",
zap.String("org_ref", in.OrganizationRef),
zap.Int("intent_index", i),
zap.Error(err),
)
return nil, wrapIndexedError(err, "intents[%d]", i)
}
planModel.Items = append(planModel.Items, item)
}
s.logger.Debug("Computation plan built",
zap.String("org_ref", in.OrganizationRef),
zap.String("plan_mode", string(planModel.Mode)),
zap.Int("item_count", len(planModel.Items)),
)
return planModel, nil
}
@@ -60,56 +82,117 @@ func (s *QuoteComputationService) buildPlanItem(
intent *transfer_intent_hydrator.QuoteIntent,
) (*QuoteComputationPlanItem, error) {
if intent == nil {
s.logger.Warn("Plan item build failed: intent is nil", zap.Int("index", index))
return nil, merrors.InvalidArgument("intent is required")
}
modelIntent := modelIntentFromQuoteIntent(intent)
if modelIntent.Amount == nil {
s.logger.Warn("Plan item build failed: intent amount is nil", zap.Int("index", index))
return nil, merrors.InvalidArgument("intent.amount is required")
}
if modelIntent.Source.Type == model.EndpointTypeUnspecified {
s.logger.Warn("Plan item build failed: intent source is unspecified", zap.Int("index", index))
return nil, merrors.InvalidArgument("intent.source is required")
}
if modelIntent.Destination.Type == model.EndpointTypeUnspecified {
s.logger.Warn("Plan item build failed: intent destination is unspecified", zap.Int("index", index))
return nil, merrors.InvalidArgument("intent.destination is required")
}
s.logger.Debug("Plan item intent validated",
zap.Int("index", index),
zap.String("source_type", string(modelIntent.Source.Type)),
zap.String("dest_type", string(modelIntent.Destination.Type)),
zap.String("amount_currency", modelIntent.Amount.GetCurrency()),
)
itemIdempotencyKey := deriveItemIdempotencyKey(strings.TrimSpace(in.BaseIdempotencyKey), len(in.Intents), index)
source := clonePaymentEndpoint(modelIntent.Source)
destination := clonePaymentEndpoint(modelIntent.Destination)
sourceRail, sourceNetwork, err := plan.RailFromEndpoint(source, modelIntent.Attributes, true)
if err != nil {
s.logger.Warn("Plan item build failed: source rail resolution error",
zap.Int("index", index),
zap.Error(err),
)
return nil, err
}
destRail, destNetwork, err := plan.RailFromEndpoint(destination, modelIntent.Attributes, false)
if err != nil {
s.logger.Warn("Plan item build failed: destination rail resolution error",
zap.Int("index", index),
zap.Error(err),
)
return nil, err
}
routeNetwork, err := plan.ResolveRouteNetwork(modelIntent.Attributes, sourceNetwork, destNetwork)
if err != nil {
s.logger.Warn("Plan item build failed: route network resolution error",
zap.Int("index", index),
zap.String("source_network", sourceNetwork),
zap.String("dest_network", destNetwork),
zap.Error(err),
)
return nil, err
}
s.logger.Debug("Plan item rails resolved",
zap.Int("index", index),
zap.String("source_rail", string(sourceRail)),
zap.String("dest_rail", string(destRail)),
zap.String("route_network", firstNonEmpty(routeNetwork, destNetwork, sourceNetwork)),
)
routeRails, err := s.resolveRouteRails(ctx, sourceRail, destRail, firstNonEmpty(routeNetwork, destNetwork, sourceNetwork))
if err != nil {
s.logger.Warn("Plan item build failed: route rails resolution error",
zap.Int("index", index),
zap.String("source_rail", string(sourceRail)),
zap.String("dest_rail", string(destRail)),
zap.Error(err),
)
return nil, err
}
s.logger.Debug("Plan item route rails resolved",
zap.Int("index", index),
zap.Int("route_rails_count", len(routeRails)),
)
steps := buildComputationSteps(index, modelIntent, destination, routeRails)
if modelIntent.Destination.Type == model.EndpointTypeCard &&
s.gatewayRegistry != nil &&
!hasExplicitDestinationGateway(modelIntent.Attributes) {
// Avoid sticky default provider when registry-driven selection is available.
clearImplicitDestinationGateway(steps)
}
s.logger.Debug("Plan item steps built", zap.Int("index", index), zap.Int("step_count", len(steps)))
if err := s.resolveStepGateways(
ctx,
steps,
firstNonEmpty(routeNetwork, destNetwork, sourceNetwork),
); err != nil {
s.logger.Warn("Plan item build failed: step gateway resolution error",
zap.Int("index", index),
zap.Error(err),
)
return nil, err
}
s.logger.Debug("Plan item step gateways resolved", zap.Int("index", index))
provider := firstNonEmpty(
destinationGatewayFromSteps(steps),
gatewayKeyForFunding(modelIntent.Attributes, destination),
@@ -117,6 +200,7 @@ func (s *QuoteComputationService) buildPlanItem(
if provider == "" && destRail == model.RailLedger {
provider = "internal"
}
funding, err := s.resolveFundingGate(ctx, resolveFundingGateInput{
OrganizationRef: strings.TrimSpace(in.OrganizationRef),
Rail: destRail,
@@ -133,17 +217,36 @@ func (s *QuoteComputationService) buildPlanItem(
InstanceID: instanceIDForFunding(modelIntent.Attributes),
})
if err != nil {
s.logger.Warn("Plan item build failed: funding gate resolution error",
zap.Int("index", index),
zap.String("provider", provider),
zap.Error(err),
)
return nil, err
}
s.logger.Debug("Plan item funding gate resolved",
zap.Int("index", index),
zap.String("provider", provider),
zap.Bool("has_funding", funding != nil),
)
route := buildRouteSpecification(
modelIntent,
firstNonEmpty(routeNetwork, destNetwork, sourceNetwork),
steps,
)
conditions, blockReason := buildExecutionConditions(in.PreviewOnly, steps, funding)
if route == nil || len(route.GetHops()) == 0 {
blockReason = quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_ROUTE_UNAVAILABLE
s.logger.Debug("Plan item route unavailable, item will be blocked",
zap.Int("index", index),
)
}
quoteInput := BuildQuoteInput{
OrganizationRef: strings.TrimSpace(in.OrganizationRef),
IdempotencyKey: itemIdempotencyKey,
@@ -160,6 +263,15 @@ func (s *QuoteComputationService) buildPlanItem(
intentRef = fmt.Sprintf("intent-%d", index)
}
s.logger.Debug("Computation plan item built",
zap.Int("index", index),
zap.String("intent_ref", intentRef),
zap.Int("step_count", len(steps)),
zap.String("block_reason", blockReason.String()),
zap.Bool("has_funding", funding != nil),
zap.Bool("preview_only", quoteInput.PreviewOnly),
)
return &QuoteComputationPlanItem{
Index: index,
IdempotencyKey: itemIdempotencyKey,
@@ -187,14 +299,11 @@ func deriveItemIdempotencyKey(base string, total, index int) string {
return fmt.Sprintf("%s:%d", base, index+1)
}
func gatewayKeyForFunding(attrs map[string]string, destination model.PaymentEndpoint) string {
func gatewayKeyForFunding(attrs map[string]string, _ model.PaymentEndpoint) string {
key := firstNonEmpty(
lookupAttr(attrs, "gateway", "gateway_id", "gatewayId"),
lookupAttr(attrs, "destination_gateway", "destinationGateway"),
)
if key == "" && destination.Card != nil {
return defaultCardGateway
}
return normalizeGatewayKey(key)
}
@@ -225,9 +334,23 @@ func (s *QuoteComputationService) resolveFundingGate(
in resolveFundingGateInput,
) (*gateway_funding_profile.QuoteFundingGate, error) {
if s == nil || s.fundingResolver == nil {
s.logger.Debug("Funding gate resolution skipped: no funding resolver configured",
zap.String("gateway_id", in.GatewayID),
zap.String("rail", string(in.Rail)),
)
return nil, nil
}
s.logger.Debug("Resolving funding gate",
zap.String("org_ref", in.OrganizationRef),
zap.String("gateway_id", in.GatewayID),
zap.String("instance_id", in.InstanceID),
zap.String("rail", string(in.Rail)),
zap.String("network", in.Network),
zap.String("currency", in.Currency),
)
profile, err := s.fundingResolver.ResolveGatewayFundingProfile(ctx, gateway_funding_profile.FundingProfileRequest{
OrganizationRef: strings.TrimSpace(in.OrganizationRef),
GatewayID: normalizeGatewayKey(in.GatewayID),
@@ -241,10 +364,40 @@ func (s *QuoteComputationService) resolveFundingGate(
Attributes: in.Attributes,
})
if err != nil {
s.logger.Warn("Funding gate resolution failed",
zap.String("org_ref", in.OrganizationRef),
zap.String("gateway_id", in.GatewayID),
zap.String("rail", string(in.Rail)),
zap.Error(err),
)
return nil, err
}
if profile == nil {
s.logger.Debug("Funding gate resolution returned no profile",
zap.String("gateway_id", in.GatewayID),
zap.String("rail", string(in.Rail)),
)
return nil, nil
}
return gateway_funding_profile.BuildFundingGateFromProfile(profile, in.Amount)
gate, err := gateway_funding_profile.BuildFundingGateFromProfile(profile, in.Amount)
if err != nil {
s.logger.Warn("Funding gate build from profile failed",
zap.String("gateway_id", in.GatewayID),
zap.Error(err),
)
return nil, err
}
s.logger.Debug("Funding gate resolved",
zap.String("gateway_id", in.GatewayID),
zap.String("rail", string(in.Rail)),
zap.Bool("has_gate", gate != nil),
)
return gate, nil
}

View File

@@ -7,6 +7,7 @@ import (
"github.com/tech/sendico/payments/quotation/internal/service/quotation/graph_path_finder"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/merrors"
"go.uber.org/zap"
)
func (s *QuoteComputationService) resolveRouteRails(
@@ -15,26 +16,69 @@ func (s *QuoteComputationService) resolveRouteRails(
destinationRail model.Rail,
network string,
) ([]model.Rail, error) {
s.logger.Debug("Resolving route rails",
zap.String("source_rail", string(sourceRail)),
zap.String("dest_rail", string(destinationRail)),
zap.String("network", network),
)
if sourceRail == model.RailUnspecified {
s.logger.Warn("Route rails resolution failed: source rail is unspecified")
return nil, merrors.InvalidArgument("source rail is required")
}
if destinationRail == model.RailUnspecified {
s.logger.Warn("Route rails resolution failed: destination rail is unspecified")
return nil, merrors.InvalidArgument("destination rail is required")
}
if sourceRail == destinationRail {
s.logger.Debug("Route rails resolved: same rail, no path finding needed",
zap.String("rail", string(sourceRail)),
)
return []model.Rail{sourceRail}, nil
}
strictGraph := s != nil && s.routeStore != nil
s.logger.Debug("Loading route graph edges",
zap.Bool("strict_graph", strictGraph),
)
edges, err := s.routeGraphEdges(ctx)
if err != nil {
s.logger.Warn("Route rails resolution failed: route graph edges load error",
zap.String("source_rail", string(sourceRail)),
zap.String("dest_rail", string(destinationRail)),
zap.Error(err),
)
return nil, err
}
s.logger.Debug("Route graph edges loaded",
zap.Int("edge_count", len(edges)),
zap.Bool("strict_graph", strictGraph),
)
if len(edges) == 0 {
if strictGraph {
s.logger.Warn("Route rails resolution failed: route graph has no edges",
zap.String("source_rail", string(sourceRail)),
zap.String("dest_rail", string(destinationRail)),
)
return nil, merrors.InvalidArgument("route graph has no edges")
}
s.logger.Debug("Route graph has no edges, using fallback path",
zap.String("source_rail", string(sourceRail)),
zap.String("dest_rail", string(destinationRail)),
)
return fallbackRouteRails(sourceRail, destinationRail), nil
}
@@ -43,6 +87,12 @@ func (s *QuoteComputationService) resolveRouteRails(
pathFinder = graph_path_finder.New()
}
s.logger.Debug("Finding route path",
zap.String("source_rail", string(sourceRail)),
zap.String("dest_rail", string(destinationRail)),
zap.String("network", network),
)
path, findErr := pathFinder.Find(graph_path_finder.FindInput{
SourceRail: sourceRail,
DestinationRail: destinationRail,
@@ -51,31 +101,75 @@ func (s *QuoteComputationService) resolveRouteRails(
})
if findErr != nil {
if strictGraph {
s.logger.Warn("Route rails resolution failed: path finding error",
zap.String("source_rail", string(sourceRail)),
zap.String("dest_rail", string(destinationRail)),
zap.String("network", network),
zap.Error(findErr),
)
return nil, findErr
}
s.logger.Debug("Route path finding failed, using fallback path",
zap.String("source_rail", string(sourceRail)),
zap.String("dest_rail", string(destinationRail)),
zap.String("network", network),
zap.Error(findErr),
)
return fallbackRouteRails(sourceRail, destinationRail), nil
}
if path == nil || len(path.Rails) == 0 {
if strictGraph {
s.logger.Warn("Route rails resolution failed: path is empty",
zap.String("source_rail", string(sourceRail)),
zap.String("dest_rail", string(destinationRail)),
zap.String("network", network),
)
return nil, merrors.InvalidArgument("route path is empty")
}
s.logger.Debug("Route path is empty, using fallback path",
zap.String("source_rail", string(sourceRail)),
zap.String("dest_rail", string(destinationRail)),
zap.String("network", network),
)
return fallbackRouteRails(sourceRail, destinationRail), nil
}
s.logger.Debug("Route rails resolved",
zap.String("source_rail", string(sourceRail)),
zap.String("dest_rail", string(destinationRail)),
zap.Int("rail_count", len(path.Rails)),
)
return append([]model.Rail(nil), path.Rails...), nil
}
func (s *QuoteComputationService) routeGraphEdges(ctx context.Context) ([]graph_path_finder.Edge, error) {
if s == nil || s.routeStore == nil {
s.logger.Debug("Route graph edges skipped: no route store configured")
return nil, nil
}
enabled := true
routes, err := s.routeStore.List(ctx, &model.PaymentRouteFilter{IsEnabled: &enabled})
if err != nil {
s.logger.Warn("Route graph edges load failed",
zap.Error(err),
)
return nil, err
}
if routes == nil || len(routes.Items) == 0 {
s.logger.Debug("Route graph edges: no routes found")
return nil, nil
}
@@ -84,17 +178,26 @@ func (s *QuoteComputationService) routeGraphEdges(ctx context.Context) ([]graph_
if route == nil || !route.IsEnabled {
continue
}
from := model.Rail(strings.ToUpper(strings.TrimSpace(string(route.FromRail))))
to := model.Rail(strings.ToUpper(strings.TrimSpace(string(route.ToRail))))
if from == model.RailUnspecified || to == model.RailUnspecified {
continue
}
edges = append(edges, graph_path_finder.Edge{
FromRail: from,
ToRail: to,
Network: strings.ToUpper(strings.TrimSpace(route.Network)),
})
}
s.logger.Debug("Route graph edges built",
zap.Int("route_count", len(routes.Items)),
zap.Int("edge_count", len(edges)),
)
return edges, nil
}
@@ -102,8 +205,10 @@ func fallbackRouteRails(sourceRail, destinationRail model.Rail) []model.Rail {
if sourceRail == destinationRail {
return []model.Rail{sourceRail}
}
if requiresTransitBridgeStep(sourceRail, destinationRail) {
return []model.Rail{sourceRail, model.RailLedger, destinationRail}
}
return []model.Rail{sourceRail, destinationRail}
}

View File

@@ -66,10 +66,6 @@ func normalizeProvider(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func normalizePayoutMethod(value string) string {
return strings.ToUpper(strings.TrimSpace(value))
}
func normalizeAsset(value string) string {
return strings.ToUpper(strings.TrimSpace(value))
}

View File

@@ -7,6 +7,8 @@ import (
"github.com/tech/sendico/payments/quotation/internal/service/plan"
"github.com/tech/sendico/payments/quotation/internal/service/quotation/gateway_funding_profile"
"github.com/tech/sendico/payments/quotation/internal/service/quotation/graph_path_finder"
"github.com/tech/sendico/pkg/mlogger"
"go.uber.org/zap"
)
type Core interface {
@@ -21,12 +23,14 @@ type QuoteComputationService struct {
gatewayRegistry plan.GatewayRegistry
routeStore plan.RouteStore
pathFinder *graph_path_finder.GraphPathFinder
logger mlogger.Logger
}
func New(core Core, opts ...Option) *QuoteComputationService {
svc := &QuoteComputationService{
core: core,
pathFinder: graph_path_finder.New(),
logger: zap.NewNop(),
}
for _, opt := range opts {
if opt != nil {
@@ -67,3 +71,11 @@ func WithPathFinder(pathFinder *graph_path_finder.GraphPathFinder) Option {
}
}
}
func WithLogger(logger mlogger.Logger) Option {
return func(svc *QuoteComputationService) {
if svc != nil && logger != nil {
svc.logger = logger.Named("computation")
}
}
}