payment quotation v2 + payment orchestration v2 draft
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user