outbox for gateways

This commit is contained in:
Stephan D
2026-02-18 01:35:28 +01:00
parent 974caf286c
commit 69531cee73
221 changed files with 12172 additions and 782 deletions

View File

@@ -0,0 +1,62 @@
package quote_computation_service
import (
"context"
"fmt"
"github.com/tech/sendico/pkg/merrors"
)
func (s *QuoteComputationService) Compute(ctx context.Context, in ComputeInput) (*ComputeOutput, error) {
if s == nil || s.core == nil {
return nil, merrors.InvalidArgument("quote computation core is required")
}
planModel, err := s.BuildPlan(ctx, in)
if err != nil {
return nil, err
}
results := make([]*QuoteComputationResult, 0, len(planModel.Items))
for _, item := range planModel.Items {
computed, computeErr := s.computePlanItem(ctx, item)
if computeErr != nil {
if item == nil {
return nil, computeErr
}
return nil, fmt.Errorf("Item %d: %w", item.Index, computeErr)
}
results = append(results, computed)
}
return &ComputeOutput{
Plan: planModel,
Results: results,
}, nil
}
func (s *QuoteComputationService) computePlanItem(
ctx context.Context,
item *QuoteComputationPlanItem,
) (*QuoteComputationResult, error) {
if item == nil || item.QuoteInput.Intent.Amount == nil {
return nil, merrors.InvalidArgument("plan item is required")
}
quote, expiresAt, err := s.core.BuildQuote(ctx, item.QuoteInput)
if err != nil {
return nil, err
}
enrichedQuote := ensureComputedQuote(quote, item)
if bindErr := validateQuoteRouteBinding(enrichedQuote, item.QuoteInput); bindErr != nil {
return nil, bindErr
}
result := &QuoteComputationResult{
ItemIndex: item.Index,
Quote: enrichedQuote,
ExpiresAt: expiresAt,
BlockReason: item.BlockReason,
}
return result, nil
}

View File

@@ -0,0 +1,501 @@
package quote_computation_service
import (
"context"
"errors"
"testing"
"time"
"github.com/tech/sendico/payments/quotation/internal/service/quotation/gateway_funding_profile"
"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"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1"
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"
)
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,
},
})))
orgID := bson.NewObjectID()
intent := sampleCardQuoteIntent()
intent.Attributes["ledger_block_account_ref"] = "ledger:block"
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 planModel == nil || len(planModel.Items) != 1 {
t.Fatalf("expected single plan item")
}
item := planModel.Items[0]
if item == nil {
t.Fatalf("expected plan item")
}
if item.IdempotencyKey != "idem-key" {
t.Fatalf("expected item idempotency key idem-key, got %q", item.IdempotencyKey)
}
if item.QuoteInput.IdempotencyKey != "idem-key" {
t.Fatalf("expected quote input idempotency key idem-key, got %q", item.QuoteInput.IdempotencyKey)
}
if len(item.Steps) != 2 {
t.Fatalf("expected 2 steps, got %d", len(item.Steps))
}
if item.Steps[0].Operation != model.RailOperationMove {
t.Fatalf("expected source operation MOVE, got %q", item.Steps[0].Operation)
}
if item.Steps[1].Operation != model.RailOperationSend {
t.Fatalf("expected destination operation SEND, got %q", item.Steps[1].Operation)
}
if item.Funding == nil {
t.Fatalf("expected funding gate")
}
if item.Funding.Mode != model.FundingModeBalanceReserve {
t.Fatalf("expected funding mode balance_reserve, got %q", item.Funding.Mode)
}
if item.Route == nil {
t.Fatalf("expected route specification")
}
if got, want := item.Route.GetRail(), "CARD_PAYOUT"; got != want {
t.Fatalf("unexpected route rail: got=%q want=%q", got, want)
}
if got := item.Route.GetRouteRef(); got == "" {
t.Fatalf("expected route_ref")
}
if got := item.Route.GetPricingProfileRef(); got == "" {
t.Fatalf("expected pricing_profile_ref")
}
if got, want := len(item.Route.GetHops()), 2; got != want {
t.Fatalf("unexpected route hops count: got=%d want=%d", got, want)
}
if item.ExecutionConditions == nil {
t.Fatalf("expected execution conditions")
}
if !item.ExecutionConditions.GetPrefundingRequired() {
t.Fatalf("expected prefunding required for balance reserve mode")
}
}
func TestBuildPlan_RequiresFXAddsMiddleStep(t *testing.T) {
svc := New(nil)
orgID := bson.NewObjectID()
intent := sampleCardQuoteIntent()
intent.RequiresFX = true
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 || len(planModel.Items[0].Steps) != 3 {
t.Fatalf("expected 3 steps for FX intent")
}
if got := planModel.Items[0].Steps[1].Operation; got != model.RailOperationFXConvert {
t.Fatalf("expected middle step FX_CONVERT, got %q", got)
}
if planModel.Items[0].Route == nil {
t.Fatalf("expected route specification")
}
if got, want := len(planModel.Items[0].Route.GetHops()), 3; got != want {
t.Fatalf("unexpected route hops count: got=%d want=%d", got, want)
}
if got, want := planModel.Items[0].Route.GetHops()[1].GetRole(), quotationv2.RouteHopRole_ROUTE_HOP_ROLE_TRANSIT; got != want {
t.Fatalf("unexpected middle hop role: got=%s want=%s", got.String(), want.String())
}
}
func TestBuildPlan_SelectsGatewaysAndIgnoresIrrelevant(t *testing.T) {
svc := New(nil, WithGatewayRegistry(staticGatewayRegistry{
items: []*model.GatewayInstanceDescriptor{
{
ID: "crypto-disabled",
InstanceID: "crypto-disabled",
Rail: model.RailCrypto,
Network: "TRON",
Currencies: []string{"USDT"},
Capabilities: model.RailCapabilities{
CanPayOut: true,
},
IsEnabled: false,
},
{
ID: "crypto-network-mismatch",
InstanceID: "crypto-network-mismatch",
Rail: model.RailCrypto,
Network: "ETH",
Currencies: []string{"USDT"},
Capabilities: model.RailCapabilities{
CanPayOut: true,
},
IsEnabled: true,
},
{
ID: "crypto-currency-mismatch",
InstanceID: "crypto-currency-mismatch",
Rail: model.RailCrypto,
Network: "TRON",
Currencies: []string{"EUR"},
Capabilities: model.RailCapabilities{
CanPayOut: true,
},
IsEnabled: true,
},
{
ID: "crypto-gw-1",
InstanceID: "crypto-gw-1",
Rail: model.RailCrypto,
Network: "TRON",
Currencies: []string{"USDT"},
Capabilities: model.RailCapabilities{
CanPayOut: true,
},
IsEnabled: true,
},
{
ID: "payout-disabled",
InstanceID: "payout-disabled",
Rail: model.RailCardPayout,
Currencies: []string{"USDT"},
Capabilities: model.RailCapabilities{
CanPayOut: true,
},
IsEnabled: false,
},
{
ID: "payout-currency-mismatch",
InstanceID: "payout-currency-mismatch",
Rail: model.RailCardPayout,
Currencies: []string{"EUR"},
Capabilities: model.RailCapabilities{
CanPayOut: true,
},
IsEnabled: true,
},
{
ID: "payout-gw-1",
InstanceID: "payout-gw-1",
Rail: model.RailCardPayout,
Currencies: []string{"USDT"},
Capabilities: model.RailCapabilities{
CanPayOut: true,
},
IsEnabled: true,
},
{
ID: "provider-ignored",
InstanceID: "provider-ignored",
Rail: model.RailProviderSettlement,
Network: "TRON",
Currencies: []string{"USDT"},
IsEnabled: true,
},
},
}))
orgID := bson.NewObjectID()
planModel, err := svc.BuildPlan(context.Background(), ComputeInput{
OrganizationRef: orgID.Hex(),
OrganizationID: orgID,
BaseIdempotencyKey: "idem-key",
PreviewOnly: false,
Intents: []*transfer_intent_hydrator.QuoteIntent{sampleCryptoToCardQuoteIntent()},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if planModel == nil || len(planModel.Items) != 1 {
t.Fatalf("expected one plan item")
}
item := planModel.Items[0]
if item == nil {
t.Fatalf("expected non-nil plan item")
}
if got, want := len(item.Steps), 3; got != want {
t.Fatalf("unexpected step count: got=%d want=%d", got, want)
}
if got, want := item.Steps[0].GatewayID, "crypto-gw-1"; got != want {
t.Fatalf("unexpected source gateway: got=%q want=%q", got, want)
}
if got, want := item.Steps[1].GatewayID, "internal"; got != want {
t.Fatalf("unexpected bridge gateway: got=%q want=%q", got, want)
}
if got, want := item.Steps[2].GatewayID, "payout-gw-1"; got != want {
t.Fatalf("unexpected destination gateway: got=%q want=%q", got, want)
}
if item.Route == nil {
t.Fatalf("expected route")
}
if got, want := item.Route.GetProvider(), "payout-gw-1"; got != want {
t.Fatalf("unexpected selected provider: got=%q want=%q", got, want)
}
if got, want := len(item.Route.GetHops()), 3; got != want {
t.Fatalf("unexpected route hop count: got=%d want=%d", got, want)
}
if got, want := item.Route.GetHops()[1].GetRail(), "LEDGER"; got != want {
t.Fatalf("unexpected bridge rail: got=%q want=%q", got, want)
}
if got, want := item.Route.GetHops()[1].GetRole(), quotationv2.RouteHopRole_ROUTE_HOP_ROLE_TRANSIT; got != want {
t.Fatalf("unexpected bridge role: got=%s want=%s", got.String(), want.String())
}
if got := item.Route.GetRouteRef(); got == "" {
t.Fatalf("expected route_ref")
}
if got := item.Route.GetPricingProfileRef(); got == "" {
t.Fatalf("expected pricing_profile_ref")
}
}
func TestCompute_EnrichesRouteConditionsAndTotalCost(t *testing.T) {
core := &fakeCore{
quote: &ComputedQuote{
DebitAmount: &moneyv1.Money{Amount: "100", Currency: "USD"},
FeeLines: []*feesv1.DerivedPostingLine{
{
Money: &moneyv1.Money{Amount: "1.50", Currency: "USD"},
Side: accountingv1.EntrySide_ENTRY_SIDE_DEBIT,
LineType: accountingv1.PostingLineType_POSTING_LINE_FEE,
},
},
},
expiresAt: time.Unix(1000, 0),
}
svc := New(core, WithFundingProfileResolver(gateway_funding_profile.NewStaticFundingProfileResolver(gateway_funding_profile.StaticFundingProfileResolverInput{
GatewayModes: map[string]model.FundingMode{
"monetix": model.FundingModeBalanceReserve,
},
})))
orgID := bson.NewObjectID()
output, err := svc.Compute(context.Background(), ComputeInput{
OrganizationRef: orgID.Hex(),
OrganizationID: orgID,
BaseIdempotencyKey: "idem-key",
PreviewOnly: false,
Intents: []*transfer_intent_hydrator.QuoteIntent{sampleCardQuoteIntent()},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if output == nil || len(output.Results) != 1 {
t.Fatalf("expected single result")
}
result := output.Results[0]
if result == nil || result.Quote == nil {
t.Fatalf("expected result quote")
}
if result.Quote.Route == nil {
t.Fatalf("expected route specification")
}
if got, want := result.Quote.Route.GetPayoutMethod(), "CARD"; got != want {
t.Fatalf("unexpected payout method: got=%q want=%q", got, want)
}
if got := result.Quote.Route.GetRouteRef(); got == "" {
t.Fatalf("expected route_ref")
}
if got := result.Quote.Route.GetPricingProfileRef(); got == "" {
t.Fatalf("expected pricing_profile_ref")
}
if got, want := len(result.Quote.Route.GetHops()), 2; got != want {
t.Fatalf("unexpected route hops count: got=%d want=%d", got, want)
}
if result.Quote.ExecutionConditions == nil {
t.Fatalf("expected execution conditions")
}
if got := result.Quote.ExecutionConditions.GetReadiness(); got != quotationv2.QuoteExecutionReadiness_QUOTE_EXECUTION_READINESS_LIQUIDITY_OBTAINABLE {
t.Fatalf("unexpected readiness: %s", got.String())
}
if result.Quote.TotalCost == nil {
t.Fatalf("expected total cost")
}
if got, want := result.Quote.TotalCost.GetAmount(), "101.5"; got != want {
t.Fatalf("unexpected total cost amount: got=%q want=%q", got, want)
}
if core.lastQuoteIn.Route == nil {
t.Fatalf("expected selected route to be passed into build quote input")
}
if got, want := core.lastQuoteIn.Route.GetProvider(), "monetix"; got != want {
t.Fatalf("unexpected selected route provider in build input: got=%q want=%q", got, want)
}
if core.lastQuoteIn.ExecutionConditions == nil {
t.Fatalf("expected execution conditions to be passed into build quote input")
}
if got := core.lastQuoteIn.ExecutionConditions.GetPrefundingRequired(); !got {
t.Fatalf("expected prefunding_required in build quote input for reserve mode")
}
}
func TestCompute_PreviewMarksIndicativeReadiness(t *testing.T) {
core := &fakeCore{
quote: &ComputedQuote{
DebitAmount: &moneyv1.Money{Amount: "100", Currency: "USD"},
},
expiresAt: time.Unix(1000, 0),
}
svc := New(core)
orgID := bson.NewObjectID()
output, err := svc.Compute(context.Background(), ComputeInput{
OrganizationRef: orgID.Hex(),
OrganizationID: orgID,
PreviewOnly: true,
Intents: []*transfer_intent_hydrator.QuoteIntent{sampleCardQuoteIntent()},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if output == nil || len(output.Results) != 1 {
t.Fatalf("expected single result")
}
if output.Results[0].Quote == nil || output.Results[0].Quote.ExecutionConditions == nil {
t.Fatalf("expected execution conditions")
}
if got := output.Results[0].Quote.ExecutionConditions.GetReadiness(); got != quotationv2.QuoteExecutionReadiness_QUOTE_EXECUTION_READINESS_INDICATIVE {
t.Fatalf("unexpected readiness: %s", got.String())
}
}
func TestCompute_FailsWhenCoreReturnsDifferentRoute(t *testing.T) {
core := &fakeCore{
quote: &ComputedQuote{
DebitAmount: &moneyv1.Money{Amount: "100", Currency: "USD"},
Route: &quotationv2.RouteSpecification{
Rail: "CARD_PAYOUT",
Provider: "other-provider",
PayoutMethod: "CARD",
},
},
expiresAt: time.Unix(1000, 0),
}
svc := New(core)
orgID := bson.NewObjectID()
_, err := svc.Compute(context.Background(), ComputeInput{
OrganizationRef: orgID.Hex(),
OrganizationID: orgID,
BaseIdempotencyKey: "idem-key",
PreviewOnly: false,
Intents: []*transfer_intent_hydrator.QuoteIntent{sampleCardQuoteIntent()},
})
if !errors.Is(err, merrors.ErrInvalidArg) {
t.Fatalf("expected invalid argument for route mismatch, got %v", err)
}
}
type fakeCore struct {
quote *ComputedQuote
expiresAt time.Time
quoteErr error
quoteCalls int
lastQuoteIn BuildQuoteInput
}
func (f *fakeCore) BuildQuote(_ context.Context, in BuildQuoteInput) (*ComputedQuote, time.Time, error) {
f.quoteCalls++
f.lastQuoteIn = in
if f.quoteErr != nil {
return nil, time.Time{}, f.quoteErr
}
return f.quote, f.expiresAt, nil
}
func sampleCardQuoteIntent() *transfer_intent_hydrator.QuoteIntent {
return &transfer_intent_hydrator.QuoteIntent{
Ref: "intent-1",
Kind: transfer_intent_hydrator.QuoteIntentKindPayout,
SettlementMode: transfer_intent_hydrator.QuoteSettlementModeFixSource,
Source: transfer_intent_hydrator.QuoteEndpoint{
Type: transfer_intent_hydrator.QuoteEndpointTypeLedger,
Ledger: &transfer_intent_hydrator.QuoteLedgerEndpoint{
LedgerAccountRef: "ledger:src",
ContraLedgerAccountRef: "ledger:contra",
},
},
Destination: transfer_intent_hydrator.QuoteEndpoint{
Type: transfer_intent_hydrator.QuoteEndpointTypeCard,
Card: &transfer_intent_hydrator.QuoteCardEndpoint{
Token: "tok_1",
},
},
Amount: &paymenttypes.Money{
Amount: "100",
Currency: "USD",
},
SettlementCurrency: "USD",
Attributes: map[string]string{
"gateway": "monetix",
},
}
}
func sampleCryptoToCardQuoteIntent() *transfer_intent_hydrator.QuoteIntent {
return &transfer_intent_hydrator.QuoteIntent{
Ref: "intent-crypto-card",
Kind: transfer_intent_hydrator.QuoteIntentKindPayout,
SettlementMode: transfer_intent_hydrator.QuoteSettlementModeFixSource,
Source: transfer_intent_hydrator.QuoteEndpoint{
Type: transfer_intent_hydrator.QuoteEndpointTypeManagedWallet,
ManagedWallet: &transfer_intent_hydrator.QuoteManagedWalletEndpoint{
ManagedWalletRef: "wallet-usdt-source",
Asset: &paymenttypes.Asset{
Chain: "TRON",
TokenSymbol: "USDT",
},
},
},
Destination: transfer_intent_hydrator.QuoteEndpoint{
Type: transfer_intent_hydrator.QuoteEndpointTypeCard,
Card: &transfer_intent_hydrator.QuoteCardEndpoint{
Token: "tok_1",
},
},
Amount: &paymenttypes.Money{
Amount: "100",
Currency: "USDT",
},
SettlementCurrency: "USDT",
Attributes: map[string]string{},
}
}
type staticGatewayRegistry struct {
items []*model.GatewayInstanceDescriptor
}
func (r staticGatewayRegistry) List(context.Context) ([]*model.GatewayInstanceDescriptor, error) {
if len(r.items) == 0 {
return nil, nil
}
out := make([]*model.GatewayInstanceDescriptor, 0, len(r.items))
for _, item := range r.items {
if item == nil {
continue
}
cloned := *item
if item.Currencies != nil {
cloned.Currencies = append([]string(nil), item.Currencies...)
}
out = append(out, &cloned)
}
return out, nil
}

View File

@@ -0,0 +1,136 @@
package quote_computation_service
import (
"strings"
"github.com/shopspring/decimal"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
)
func ensureComputedQuote(src *ComputedQuote, item *QuoteComputationPlanItem) *ComputedQuote {
if src == nil {
src = &ComputedQuote{}
}
if item == nil {
return src
}
if src.Route == nil {
src.Route = cloneRouteSpecification(item.Route)
}
if src.ExecutionConditions == nil {
src.ExecutionConditions = cloneExecutionConditions(item.ExecutionConditions)
}
if src.TotalCost == nil {
src.TotalCost = deriveTotalCost(src.DebitAmount, src.FeeLines)
}
return src
}
func deriveTotalCost(
debitAmount *moneyv1.Money,
feeLines []*feesv1.DerivedPostingLine,
) *moneyv1.Money {
if debitAmount == nil {
return nil
}
currency := strings.ToUpper(strings.TrimSpace(debitAmount.GetCurrency()))
baseValue, err := decimal.NewFromString(strings.TrimSpace(debitAmount.GetAmount()))
if err != nil {
return nil
}
total := baseValue
for _, line := range feeLines {
if line == nil || line.GetMoney() == nil {
continue
}
lineCurrency := strings.ToUpper(strings.TrimSpace(line.GetMoney().GetCurrency()))
if lineCurrency == "" || lineCurrency != currency {
continue
}
lineAmount, convErr := decimal.NewFromString(strings.TrimSpace(line.GetMoney().GetAmount()))
if convErr != nil {
continue
}
switch line.GetSide() {
case accountingv1.EntrySide_ENTRY_SIDE_DEBIT:
total = total.Add(lineAmount)
case accountingv1.EntrySide_ENTRY_SIDE_CREDIT:
total = total.Sub(lineAmount)
}
}
return &moneyv1.Money{
Amount: total.String(),
Currency: currency,
}
}
func cloneRouteSpecification(src *quotationv2.RouteSpecification) *quotationv2.RouteSpecification {
if src == nil {
return nil
}
result := &quotationv2.RouteSpecification{
Rail: strings.TrimSpace(src.GetRail()),
Provider: strings.TrimSpace(src.GetProvider()),
PayoutMethod: strings.TrimSpace(src.GetPayoutMethod()),
SettlementAsset: strings.ToUpper(strings.TrimSpace(src.GetSettlementAsset())),
SettlementModel: strings.TrimSpace(src.GetSettlementModel()),
Network: strings.TrimSpace(src.GetNetwork()),
RouteRef: strings.TrimSpace(src.GetRouteRef()),
PricingProfileRef: strings.TrimSpace(src.GetPricingProfileRef()),
}
if hops := src.GetHops(); len(hops) > 0 {
result.Hops = make([]*quotationv2.RouteHop, 0, len(hops))
for _, hop := range hops {
if cloned := cloneRouteHop(hop); cloned != nil {
result.Hops = append(result.Hops, cloned)
}
}
if len(result.Hops) == 0 {
result.Hops = nil
}
}
return result
}
func cloneExecutionConditions(src *quotationv2.ExecutionConditions) *quotationv2.ExecutionConditions {
if src == nil {
return nil
}
result := &quotationv2.ExecutionConditions{
Readiness: src.GetReadiness(),
BatchingEligible: src.GetBatchingEligible(),
PrefundingRequired: src.GetPrefundingRequired(),
PrefundingCostIncluded: src.GetPrefundingCostIncluded(),
LiquidityCheckRequiredAtExecution: src.GetLiquidityCheckRequiredAtExecution(),
LatencyHint: strings.TrimSpace(src.GetLatencyHint()),
}
for _, assumption := range src.GetAssumptions() {
value := strings.TrimSpace(assumption)
if value == "" {
continue
}
result.Assumptions = append(result.Assumptions, value)
}
if len(result.Assumptions) == 0 {
result.Assumptions = nil
}
return result
}
func cloneRouteHop(src *quotationv2.RouteHop) *quotationv2.RouteHop {
if src == nil {
return nil
}
return &quotationv2.RouteHop{
Index: src.GetIndex(),
Rail: strings.TrimSpace(src.GetRail()),
Gateway: strings.TrimSpace(src.GetGateway()),
InstanceId: strings.TrimSpace(src.GetInstanceId()),
Network: strings.TrimSpace(src.GetNetwork()),
Role: src.GetRole(),
}
}

View File

@@ -0,0 +1,176 @@
package quote_computation_service
import (
"context"
"fmt"
"sort"
"strings"
"github.com/shopspring/decimal"
"github.com/tech/sendico/payments/quotation/internal/service/plan"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/merrors"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
)
func (s *QuoteComputationService) resolveStepGateways(
ctx context.Context,
steps []*QuoteComputationStep,
routeNetwork string,
) error {
if s == nil || s.gatewayRegistry == nil {
return nil
}
gateways, err := s.gatewayRegistry.List(ctx)
if err != nil {
return err
}
if len(gateways) == 0 {
return merrors.InvalidArgument("gateway registry has no entries")
}
sorted := make([]*model.GatewayInstanceDescriptor, 0, len(gateways))
for _, gw := range gateways {
if gw != nil {
sorted = append(sorted, gw)
}
}
sort.Slice(sorted, func(i, j int) bool {
return strings.TrimSpace(sorted[i].ID) < strings.TrimSpace(sorted[j].ID)
})
for idx, step := range steps {
if step == nil {
continue
}
if strings.TrimSpace(step.GatewayID) != "" {
continue
}
if step.Rail == model.RailLedger {
step.GatewayID = "internal"
continue
}
selected, selectErr := selectGatewayForStep(sorted, step, routeNetwork)
if selectErr != nil {
return fmt.Errorf("Step[%d] %s: %w", idx, strings.TrimSpace(step.StepID), selectErr)
}
step.GatewayID = strings.TrimSpace(selected.ID)
if strings.TrimSpace(step.InstanceID) == "" {
step.InstanceID = strings.TrimSpace(selected.InstanceID)
}
}
return nil
}
func selectGatewayForStep(
gateways []*model.GatewayInstanceDescriptor,
step *QuoteComputationStep,
routeNetwork string,
) (*model.GatewayInstanceDescriptor, error) {
if step == nil {
return nil, merrors.InvalidArgument("step is required")
}
if len(gateways) == 0 {
return nil, merrors.InvalidArgument("gateway list is empty")
}
currency := ""
amount := decimal.Zero
if step.Amount != nil {
currency = strings.ToUpper(strings.TrimSpace(step.Amount.GetCurrency()))
if parsed, err := parseDecimalAmount(step.Amount); err == nil {
amount = parsed
}
}
action := gatewayEligibilityOperation(step.Operation)
direction := plan.SendDirectionForRail(step.Rail)
network := networkForGatewaySelection(step.Rail, routeNetwork)
var lastErr error
for _, gw := range gateways {
if gw == nil {
continue
}
if strings.TrimSpace(step.InstanceID) != "" &&
!strings.EqualFold(strings.TrimSpace(gw.InstanceID), strings.TrimSpace(step.InstanceID)) {
continue
}
if err := plan.IsGatewayEligible(gw, step.Rail, network, currency, action, direction, amount); err != nil {
lastErr = err
continue
}
return gw, nil
}
if lastErr != nil {
return nil, merrors.InvalidArgument("no eligible gateway: " + lastErr.Error())
}
return nil, merrors.InvalidArgument("no eligible gateway")
}
func parseDecimalAmount(m *moneyv1.Money) (decimal.Decimal, error) {
if m == nil {
return decimal.Zero, nil
}
value := strings.TrimSpace(m.GetAmount())
if value == "" {
return decimal.Zero, nil
}
parsed, err := decimal.NewFromString(value)
if err != nil {
return decimal.Zero, err
}
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:
return strings.ToUpper(strings.TrimSpace(routeNetwork))
default:
return ""
}
}
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
}
last := steps[len(steps)-1]
if last == nil {
return
}
last.GatewayID = ""
}
func destinationGatewayFromSteps(steps []*QuoteComputationStep) string {
for i := len(steps) - 1; i >= 0; i-- {
step := steps[i]
if step == nil {
continue
}
if gateway := normalizeGatewayKey(step.GatewayID); gateway != "" {
return gateway
}
}
return ""
}

View File

@@ -0,0 +1,167 @@
package quote_computation_service
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"
)
const defaultCardGateway = "monetix"
func cloneProtoMoney(src *moneyv1.Money) *moneyv1.Money {
if src == nil {
return nil
}
return &moneyv1.Money{
Amount: strings.TrimSpace(src.GetAmount()),
Currency: strings.TrimSpace(src.GetCurrency()),
}
}
func protoMoneyFromModel(src *paymenttypes.Money) *moneyv1.Money {
if src == nil {
return nil
}
return &moneyv1.Money{
Amount: strings.TrimSpace(src.GetAmount()),
Currency: strings.ToUpper(strings.TrimSpace(src.GetCurrency())),
}
}
func cloneStringMap(src map[string]string) map[string]string {
if len(src) == 0 {
return nil
}
out := make(map[string]string, len(src))
for k, v := range src {
key := strings.TrimSpace(k)
if key == "" {
continue
}
out[key] = strings.TrimSpace(v)
}
if len(out) == 0 {
return nil
}
return out
}
func cloneAsset(src *paymenttypes.Asset) *paymenttypes.Asset {
if src == nil {
return nil
}
return &paymenttypes.Asset{
Chain: strings.TrimSpace(src.Chain),
TokenSymbol: strings.TrimSpace(src.TokenSymbol),
ContractAddress: strings.TrimSpace(src.ContractAddress),
}
}
func cloneModelMoney(src *paymenttypes.Money) *paymenttypes.Money {
if src == nil {
return nil
}
return &paymenttypes.Money{
Amount: strings.TrimSpace(src.GetAmount()),
Currency: strings.ToUpper(strings.TrimSpace(src.GetCurrency())),
}
}
func clonePaymentIntent(src model.PaymentIntent) model.PaymentIntent {
out := model.PaymentIntent{
Ref: strings.TrimSpace(src.Ref),
Kind: src.Kind,
Source: clonePaymentEndpoint(src.Source),
Destination: clonePaymentEndpoint(src.Destination),
Amount: cloneModelMoney(src.Amount),
RequiresFX: src.RequiresFX,
FX: nil,
FeePolicy: src.FeePolicy,
SettlementMode: src.SettlementMode,
SettlementCurrency: strings.ToUpper(strings.TrimSpace(src.SettlementCurrency)),
Attributes: cloneStringMap(src.Attributes),
Customer: src.Customer,
}
if src.FX != nil {
fx := *src.FX
out.FX = &fx
}
return out
}
func clonePaymentEndpoint(src model.PaymentEndpoint) model.PaymentEndpoint {
out := model.PaymentEndpoint{
Type: src.Type,
InstanceID: strings.TrimSpace(src.InstanceID),
Metadata: nil,
Ledger: nil,
ManagedWallet: nil,
ExternalChain: nil,
Card: nil,
}
if src.Ledger != nil {
out.Ledger = &model.LedgerEndpoint{
LedgerAccountRef: strings.TrimSpace(src.Ledger.LedgerAccountRef),
ContraLedgerAccountRef: strings.TrimSpace(src.Ledger.ContraLedgerAccountRef),
}
}
if src.ManagedWallet != nil {
out.ManagedWallet = &model.ManagedWalletEndpoint{
ManagedWalletRef: strings.TrimSpace(src.ManagedWallet.ManagedWalletRef),
Asset: cloneAsset(src.ManagedWallet.Asset),
}
}
if src.ExternalChain != nil {
out.ExternalChain = &model.ExternalChainEndpoint{
Asset: cloneAsset(src.ExternalChain.Asset),
Address: strings.TrimSpace(src.ExternalChain.Address),
Memo: strings.TrimSpace(src.ExternalChain.Memo),
}
}
if src.Card != nil {
out.Card = &model.CardEndpoint{
Pan: strings.TrimSpace(src.Card.Pan),
Token: strings.TrimSpace(src.Card.Token),
Cardholder: strings.TrimSpace(src.Card.Cardholder),
CardholderSurname: strings.TrimSpace(src.Card.CardholderSurname),
ExpMonth: src.Card.ExpMonth,
ExpYear: src.Card.ExpYear,
Country: strings.TrimSpace(src.Card.Country),
MaskedPan: strings.TrimSpace(src.Card.MaskedPan),
}
}
if len(src.Metadata) > 0 {
out.Metadata = cloneStringMap(src.Metadata)
}
return out
}
func lookupAttr(attrs map[string]string, keys ...string) string {
if len(attrs) == 0 {
return ""
}
for _, key := range keys {
if key == "" {
continue
}
if val := strings.TrimSpace(attrs[key]); val != "" {
return val
}
}
return ""
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func normalizeGatewayKey(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}

View File

@@ -0,0 +1,19 @@
package quote_computation_service
import (
"github.com/tech/sendico/payments/quotation/internal/service/quotation/transfer_intent_hydrator"
"go.mongodb.org/mongo-driver/v2/bson"
)
type ComputeInput struct {
OrganizationRef string
OrganizationID bson.ObjectID
BaseIdempotencyKey string
PreviewOnly bool
Intents []*transfer_intent_hydrator.QuoteIntent
}
type ComputeOutput struct {
Plan *QuoteComputationPlan
Results []*QuoteComputationResult
}

View File

@@ -0,0 +1,108 @@
package quote_computation_service
import (
"strings"
"github.com/tech/sendico/payments/quotation/internal/service/quotation/transfer_intent_hydrator"
"github.com/tech/sendico/payments/storage/model"
)
func modelIntentFromQuoteIntent(src *transfer_intent_hydrator.QuoteIntent) model.PaymentIntent {
if src == nil {
return model.PaymentIntent{}
}
settlementCurrency := strings.ToUpper(strings.TrimSpace(src.SettlementCurrency))
if settlementCurrency == "" && src.Amount != nil {
settlementCurrency = strings.ToUpper(strings.TrimSpace(src.Amount.Currency))
}
return model.PaymentIntent{
Ref: strings.TrimSpace(src.Ref),
Kind: modelPaymentKind(src.Kind, src.Destination),
Source: modelEndpointFromQuoteEndpoint(src.Source),
Destination: modelEndpointFromQuoteEndpoint(src.Destination),
Amount: cloneModelMoney(src.Amount),
RequiresFX: src.RequiresFX,
Attributes: cloneStringMap(src.Attributes),
SettlementMode: modelSettlementMode(src.SettlementMode),
SettlementCurrency: settlementCurrency,
}
}
func modelEndpointFromQuoteEndpoint(src transfer_intent_hydrator.QuoteEndpoint) model.PaymentEndpoint {
result := model.PaymentEndpoint{
Type: model.EndpointTypeUnspecified,
}
switch src.Type {
case transfer_intent_hydrator.QuoteEndpointTypeLedger:
result.Type = model.EndpointTypeLedger
if src.Ledger != nil {
result.Ledger = &model.LedgerEndpoint{
LedgerAccountRef: strings.TrimSpace(src.Ledger.LedgerAccountRef),
ContraLedgerAccountRef: strings.TrimSpace(src.Ledger.ContraLedgerAccountRef),
}
}
case transfer_intent_hydrator.QuoteEndpointTypeManagedWallet:
result.Type = model.EndpointTypeManagedWallet
if src.ManagedWallet != nil {
result.ManagedWallet = &model.ManagedWalletEndpoint{
ManagedWalletRef: strings.TrimSpace(src.ManagedWallet.ManagedWalletRef),
Asset: cloneAsset(src.ManagedWallet.Asset),
}
}
case transfer_intent_hydrator.QuoteEndpointTypeExternalChain:
result.Type = model.EndpointTypeExternalChain
if src.ExternalChain != nil {
result.ExternalChain = &model.ExternalChainEndpoint{
Asset: cloneAsset(src.ExternalChain.Asset),
Address: strings.TrimSpace(src.ExternalChain.Address),
Memo: strings.TrimSpace(src.ExternalChain.Memo),
}
}
case transfer_intent_hydrator.QuoteEndpointTypeCard:
result.Type = model.EndpointTypeCard
if src.Card != nil {
result.Card = &model.CardEndpoint{
Pan: strings.TrimSpace(src.Card.Pan),
Token: strings.TrimSpace(src.Card.Token),
Cardholder: strings.TrimSpace(src.Card.Cardholder),
CardholderSurname: strings.TrimSpace(src.Card.CardholderSurname),
ExpMonth: src.Card.ExpMonth,
ExpYear: src.Card.ExpYear,
Country: strings.TrimSpace(src.Card.Country),
MaskedPan: strings.TrimSpace(src.Card.MaskedPan),
}
}
}
return result
}
func modelPaymentKind(kind transfer_intent_hydrator.QuoteIntentKind, destination transfer_intent_hydrator.QuoteEndpoint) model.PaymentKind {
switch kind {
case transfer_intent_hydrator.QuoteIntentKindPayout:
return model.PaymentKindPayout
case transfer_intent_hydrator.QuoteIntentKindInternalTransfer:
return model.PaymentKindInternalTransfer
case transfer_intent_hydrator.QuoteIntentKindFXConversion:
return model.PaymentKindFXConversion
}
switch destination.Type {
case transfer_intent_hydrator.QuoteEndpointTypeExternalChain, transfer_intent_hydrator.QuoteEndpointTypeCard:
return model.PaymentKindPayout
default:
return model.PaymentKindInternalTransfer
}
}
func modelSettlementMode(mode transfer_intent_hydrator.QuoteSettlementMode) model.SettlementMode {
switch mode {
case transfer_intent_hydrator.QuoteSettlementModeFixSource:
return model.SettlementModeFixSource
case transfer_intent_hydrator.QuoteSettlementModeFixReceived:
return model.SettlementModeFixReceived
default:
return model.SettlementModeUnspecified
}
}

View File

@@ -0,0 +1,95 @@
package quote_computation_service
import (
"time"
"github.com/tech/sendico/payments/quotation/internal/service/quotation/gateway_funding_profile"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/model/account_role"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
"go.mongodb.org/mongo-driver/v2/bson"
)
// PlanMode defines whether the computation plan is for one intent or many.
type PlanMode string
const (
PlanModeUnspecified PlanMode = "unspecified"
PlanModeSingle PlanMode = "single"
PlanModeBatch PlanMode = "batch"
)
// BuildQuoteInput is the domain input for one quote computation request.
type BuildQuoteInput struct {
OrganizationRef string
IdempotencyKey string
PreviewOnly bool
Intent model.PaymentIntent
Route *quotationv2.RouteSpecification
ExecutionConditions *quotationv2.ExecutionConditions
}
// ComputedQuote is a canonical quote payload for v2 processing.
type ComputedQuote struct {
QuoteRef string
DebitAmount *moneyv1.Money
CreditAmount *moneyv1.Money
TotalCost *moneyv1.Money
FeeLines []*feesv1.DerivedPostingLine
FeeRules []*feesv1.AppliedRule
FXQuote *oraclev1.Quote
Route *quotationv2.RouteSpecification
ExecutionConditions *quotationv2.ExecutionConditions
}
// QuoteComputationPlan is an orchestration plan for quote computations.
// It is intentionally separate from executable payment plans.
type QuoteComputationPlan struct {
Mode PlanMode
OrganizationRef string
OrganizationID bson.ObjectID
PreviewOnly bool
BaseIdempotencyKey string
Items []*QuoteComputationPlanItem
}
// QuoteComputationPlanItem is one quote-computation unit.
type QuoteComputationPlanItem struct {
Index int
IdempotencyKey string
IntentRef string
Intent model.PaymentIntent
QuoteInput BuildQuoteInput
Steps []*QuoteComputationStep
Funding *gateway_funding_profile.QuoteFundingGate
Route *quotationv2.RouteSpecification
ExecutionConditions *quotationv2.ExecutionConditions
BlockReason quotationv2.QuoteBlockReason
}
// QuoteComputationStep is one planner step in a generic execution graph.
// The planner should use rail+operation instead of custom per-case leg kinds.
type QuoteComputationStep struct {
StepID string
Rail model.Rail
Operation model.RailOperation
GatewayID string
InstanceID string
DependsOn []string
Amount *moneyv1.Money
FromRole *account_role.AccountRole
ToRole *account_role.AccountRole
Optional bool
IncludeInAggregate bool
}
// QuoteComputationResult is the computed output for one planned item.
type QuoteComputationResult struct {
ItemIndex int
Quote *ComputedQuote
ExpiresAt time.Time
BlockReason quotationv2.QuoteBlockReason
}

View File

@@ -0,0 +1,244 @@
package quote_computation_service
import (
"context"
"fmt"
"strings"
"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/transfer_intent_hydrator"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/merrors"
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"
)
func (s *QuoteComputationService) BuildPlan(ctx context.Context, in ComputeInput) (*QuoteComputationPlan, error) {
if strings.TrimSpace(in.OrganizationRef) == "" {
return nil, merrors.InvalidArgument("organization_ref is required")
}
if in.OrganizationID == bson.NilObjectID {
return nil, merrors.InvalidArgument("organization_id is required")
}
if len(in.Intents) == 0 {
return nil, merrors.InvalidArgument("intents are required")
}
if !in.PreviewOnly && strings.TrimSpace(in.BaseIdempotencyKey) == "" {
return nil, merrors.InvalidArgument("idempotency_key is required")
}
mode := PlanModeSingle
if len(in.Intents) > 1 {
mode = PlanModeBatch
}
planModel := &QuoteComputationPlan{
Mode: mode,
OrganizationRef: strings.TrimSpace(in.OrganizationRef),
OrganizationID: in.OrganizationID,
PreviewOnly: in.PreviewOnly,
BaseIdempotencyKey: strings.TrimSpace(in.BaseIdempotencyKey),
Items: make([]*QuoteComputationPlanItem, 0, len(in.Intents)),
}
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)
}
planModel.Items = append(planModel.Items, item)
}
return planModel, nil
}
func (s *QuoteComputationService) buildPlanItem(
ctx context.Context,
in ComputeInput,
index int,
intent *transfer_intent_hydrator.QuoteIntent,
) (*QuoteComputationPlanItem, error) {
if intent == nil {
return nil, merrors.InvalidArgument("intent is required")
}
modelIntent := modelIntentFromQuoteIntent(intent)
if modelIntent.Amount == nil {
return nil, merrors.InvalidArgument("intent.amount is required")
}
if modelIntent.Source.Type == model.EndpointTypeUnspecified {
return nil, merrors.InvalidArgument("intent.source is required")
}
if modelIntent.Destination.Type == model.EndpointTypeUnspecified {
return nil, merrors.InvalidArgument("intent.destination is required")
}
itemIdempotencyKey := deriveItemIdempotencyKey(strings.TrimSpace(in.BaseIdempotencyKey), len(in.Intents), index)
source := clonePaymentEndpoint(modelIntent.Source)
destination := clonePaymentEndpoint(modelIntent.Destination)
_, sourceNetwork, err := plan.RailFromEndpoint(source, modelIntent.Attributes, true)
if err != nil {
return nil, err
}
destRail, destNetwork, err := plan.RailFromEndpoint(destination, modelIntent.Attributes, false)
if err != nil {
return nil, err
}
routeNetwork, err := plan.ResolveRouteNetwork(modelIntent.Attributes, sourceNetwork, destNetwork)
if err != nil {
return nil, err
}
steps := buildComputationSteps(index, modelIntent, destination)
if modelIntent.Destination.Type == model.EndpointTypeCard &&
s.gatewayRegistry != nil &&
!hasExplicitDestinationGateway(modelIntent.Attributes) {
// Avoid sticky default provider when registry-driven selection is available.
clearImplicitDestinationGateway(steps)
}
if err := s.resolveStepGateways(
ctx,
steps,
firstNonEmpty(routeNetwork, destNetwork, sourceNetwork),
); err != nil {
return nil, err
}
provider := firstNonEmpty(
destinationGatewayFromSteps(steps),
gatewayKeyForFunding(modelIntent.Attributes, destination),
)
if provider == "" && destRail == model.RailLedger {
provider = "internal"
}
funding, err := s.resolveFundingGate(ctx, resolveFundingGateInput{
OrganizationRef: strings.TrimSpace(in.OrganizationRef),
Rail: destRail,
Network: firstNonEmpty(routeNetwork, destNetwork, sourceNetwork),
Amount: protoMoneyFromModel(modelIntent.Amount),
Source: source,
Destination: destination,
Attributes: modelIntent.Attributes,
Currency: firstNonEmpty(
strings.TrimSpace(modelIntent.SettlementCurrency),
strings.TrimSpace(modelIntent.Amount.GetCurrency()),
),
GatewayID: provider,
InstanceID: instanceIDForFunding(modelIntent.Attributes),
})
if err != nil {
return nil, err
}
route := buildRouteSpecification(
modelIntent,
destination,
destRail,
firstNonEmpty(routeNetwork, destNetwork, sourceNetwork),
provider,
steps,
)
conditions, blockReason := buildExecutionConditions(in.PreviewOnly, steps, funding)
if route == nil || strings.TrimSpace(route.GetRail()) == "" || route.GetRail() == string(model.RailUnspecified) {
blockReason = quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_ROUTE_UNAVAILABLE
}
quoteInput := BuildQuoteInput{
OrganizationRef: strings.TrimSpace(in.OrganizationRef),
IdempotencyKey: itemIdempotencyKey,
Intent: clonePaymentIntent(modelIntent),
PreviewOnly: in.PreviewOnly,
Route: cloneRouteSpecification(route),
ExecutionConditions: cloneExecutionConditions(conditions),
}
intentRef := strings.TrimSpace(modelIntent.Ref)
if intentRef == "" {
intentRef = fmt.Sprintf("intent-%d", index)
}
return &QuoteComputationPlanItem{
Index: index,
IdempotencyKey: itemIdempotencyKey,
IntentRef: intentRef,
Intent: modelIntent,
QuoteInput: quoteInput,
Steps: steps,
Funding: funding,
Route: route,
ExecutionConditions: conditions,
BlockReason: blockReason,
}, nil
}
func deriveItemIdempotencyKey(base string, total, index int) string {
base = strings.TrimSpace(base)
if base == "" {
return ""
}
if total <= 1 {
return base
}
return fmt.Sprintf("%s:%d", base, index+1)
}
func gatewayKeyForFunding(attrs map[string]string, destination 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)
}
func instanceIDForFunding(attrs map[string]string) string {
return strings.TrimSpace(lookupAttr(attrs,
"instance_id",
"instanceId",
"destination_instance_id",
"destinationInstanceId",
))
}
type resolveFundingGateInput struct {
OrganizationRef string
GatewayID string
InstanceID string
Rail model.Rail
Network string
Currency string
Amount *moneyv1.Money
Source model.PaymentEndpoint
Destination model.PaymentEndpoint
Attributes map[string]string
}
func (s *QuoteComputationService) resolveFundingGate(
ctx context.Context,
in resolveFundingGateInput,
) (*gateway_funding_profile.QuoteFundingGate, error) {
if s == nil || s.fundingResolver == nil {
return nil, nil
}
profile, err := s.fundingResolver.ResolveGatewayFundingProfile(ctx, gateway_funding_profile.FundingProfileRequest{
OrganizationRef: strings.TrimSpace(in.OrganizationRef),
GatewayID: normalizeGatewayKey(in.GatewayID),
InstanceID: strings.TrimSpace(in.InstanceID),
Rail: in.Rail,
Network: strings.TrimSpace(in.Network),
Currency: strings.ToUpper(strings.TrimSpace(in.Currency)),
Amount: in.Amount,
Source: &in.Source,
Destination: &in.Destination,
Attributes: in.Attributes,
})
if err != nil {
return nil, err
}
if profile == nil {
return nil, nil
}
return gateway_funding_profile.BuildFundingGateFromProfile(profile, in.Amount)
}

View File

@@ -0,0 +1,147 @@
package quote_computation_service
import (
"fmt"
"strings"
"github.com/tech/sendico/payments/storage/model"
)
func buildComputationSteps(
index int,
intent model.PaymentIntent,
destination model.PaymentEndpoint,
) []*QuoteComputationStep {
if intent.Amount == nil {
return nil
}
attrs := intent.Attributes
amount := protoMoneyFromModel(intent.Amount)
sourceRail := sourceRailForIntent(intent)
destinationRail := destinationRailForIntent(intent)
sourceGatewayID := strings.TrimSpace(lookupAttr(attrs,
"source_gateway",
"sourceGateway",
"source_gateway_id",
"sourceGatewayId",
))
sourceInstanceID := strings.TrimSpace(lookupAttr(attrs, "source_instance_id", "sourceInstanceId"))
destinationGatewayID := gatewayKeyForFunding(attrs, destination)
destinationInstanceID := firstNonEmpty(
strings.TrimSpace(lookupAttr(attrs, "destination_instance_id", "destinationInstanceId")),
strings.TrimSpace(lookupAttr(attrs, "instance_id", "instanceId")),
)
sourceStepID := fmt.Sprintf("i%d.source", index)
steps := []*QuoteComputationStep{
{
StepID: sourceStepID,
Rail: sourceRail,
Operation: sourceOperationForRail(sourceRail),
GatewayID: sourceGatewayID,
InstanceID: sourceInstanceID,
Amount: cloneProtoMoney(amount),
Optional: false,
IncludeInAggregate: false,
},
}
lastStepID := sourceStepID
if intent.RequiresFX {
fxStepID := fmt.Sprintf("i%d.fx", index)
steps = append(steps, &QuoteComputationStep{
StepID: fxStepID,
Rail: model.RailProviderSettlement,
Operation: model.RailOperationFXConvert,
DependsOn: []string{sourceStepID},
Amount: cloneProtoMoney(amount),
Optional: false,
IncludeInAggregate: false,
})
lastStepID = fxStepID
}
if requiresTransitBridgeStep(sourceRail, destinationRail) {
bridgeStepID := fmt.Sprintf("i%d.bridge", index)
steps = append(steps, &QuoteComputationStep{
StepID: bridgeStepID,
Rail: model.RailLedger,
Operation: model.RailOperationMove,
DependsOn: []string{lastStepID},
Amount: cloneProtoMoney(amount),
Optional: false,
IncludeInAggregate: false,
})
lastStepID = bridgeStepID
}
destinationStepID := fmt.Sprintf("i%d.destination", index)
steps = append(steps, &QuoteComputationStep{
StepID: destinationStepID,
Rail: destinationRail,
Operation: destinationOperationForRail(destinationRail),
GatewayID: destinationGatewayID,
InstanceID: destinationInstanceID,
DependsOn: []string{lastStepID},
Amount: cloneProtoMoney(amount),
Optional: false,
IncludeInAggregate: true,
})
return steps
}
func requiresTransitBridgeStep(sourceRail, destinationRail model.Rail) bool {
if sourceRail == model.RailUnspecified || destinationRail == model.RailUnspecified {
return false
}
if sourceRail == destinationRail {
return false
}
if sourceRail == model.RailLedger || destinationRail == model.RailLedger {
return false
}
return true
}
func sourceRailForIntent(intent model.PaymentIntent) model.Rail {
if intent.Source.Type == model.EndpointTypeLedger {
return model.RailLedger
}
if intent.Source.Type == model.EndpointTypeManagedWallet || intent.Source.Type == model.EndpointTypeExternalChain {
return model.RailCrypto
}
return model.RailLedger
}
func destinationRailForIntent(intent model.PaymentIntent) model.Rail {
switch intent.Destination.Type {
case model.EndpointTypeCard:
return model.RailCardPayout
case model.EndpointTypeManagedWallet, model.EndpointTypeExternalChain:
return model.RailCrypto
case model.EndpointTypeLedger:
return model.RailLedger
default:
return model.RailProviderSettlement
}
}
func sourceOperationForRail(rail model.Rail) model.RailOperation {
if rail == model.RailLedger {
return model.RailOperationMove
}
return model.RailOperationExternalDebit
}
func destinationOperationForRail(rail model.Rail) model.RailOperation {
switch rail {
case model.RailLedger:
return model.RailOperationMove
case model.RailCardPayout:
return model.RailOperationSend
default:
return model.RailOperationExternalCredit
}
}

View File

@@ -0,0 +1,95 @@
package quote_computation_service
import (
"strings"
"github.com/tech/sendico/pkg/merrors"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
)
func validateQuoteRouteBinding(quote *ComputedQuote, input BuildQuoteInput) error {
if quote == nil {
return merrors.InvalidArgument("computed quote is required")
}
if input.Route == nil {
return merrors.InvalidArgument("build_quote_input.route is required")
}
if quote.Route == nil {
return merrors.InvalidArgument("computed quote route is required")
}
if !sameRouteSpecification(quote.Route, input.Route) {
return merrors.InvalidArgument("computed quote route must match selected route")
}
return nil
}
func sameRouteSpecification(left, right *quotationv2.RouteSpecification) bool {
if left == nil || right == nil {
return left == right
}
return normalizeRail(left.GetRail()) == normalizeRail(right.GetRail()) &&
normalizeProvider(left.GetProvider()) == normalizeProvider(right.GetProvider()) &&
normalizePayoutMethod(left.GetPayoutMethod()) == normalizePayoutMethod(right.GetPayoutMethod()) &&
normalizeAsset(left.GetSettlementAsset()) == normalizeAsset(right.GetSettlementAsset()) &&
normalizeSettlementModel(left.GetSettlementModel()) == normalizeSettlementModel(right.GetSettlementModel()) &&
normalizeNetwork(left.GetNetwork()) == normalizeNetwork(right.GetNetwork()) &&
sameRouteReference(left.GetRouteRef(), right.GetRouteRef()) &&
samePricingProfileReference(left.GetPricingProfileRef(), right.GetPricingProfileRef()) &&
sameRouteHops(left.GetHops(), right.GetHops())
}
func normalizeRail(value string) string {
return strings.ToUpper(strings.TrimSpace(value))
}
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))
}
func normalizeSettlementModel(value string) string {
return strings.ToUpper(strings.TrimSpace(value))
}
func normalizeNetwork(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func sameRouteReference(left, right string) bool {
return strings.TrimSpace(left) == strings.TrimSpace(right)
}
func samePricingProfileReference(left, right string) bool {
return strings.TrimSpace(left) == strings.TrimSpace(right)
}
func sameRouteHops(left, right []*quotationv2.RouteHop) bool {
if len(left) != len(right) {
return false
}
for i := range left {
if !sameRouteHop(left[i], right[i]) {
return false
}
}
return true
}
func sameRouteHop(left, right *quotationv2.RouteHop) bool {
if left == nil || right == nil {
return left == right
}
return left.GetIndex() == right.GetIndex() &&
normalizeRail(left.GetRail()) == normalizeRail(right.GetRail()) &&
normalizeProvider(left.GetGateway()) == normalizeProvider(right.GetGateway()) &&
strings.TrimSpace(left.GetInstanceId()) == strings.TrimSpace(right.GetInstanceId()) &&
normalizeNetwork(left.GetNetwork()) == normalizeNetwork(right.GetNetwork()) &&
left.GetRole() == right.GetRole()
}

View File

@@ -0,0 +1,236 @@
package quote_computation_service
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"sort"
"strings"
"github.com/tech/sendico/payments/quotation/internal/service/quotation/gateway_funding_profile"
"github.com/tech/sendico/payments/storage/model"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
)
func buildRouteSpecification(
intent model.PaymentIntent,
destination model.PaymentEndpoint,
destinationRail model.Rail,
network string,
provider string,
steps []*QuoteComputationStep,
) *quotationv2.RouteSpecification {
hops := buildRouteHops(steps, network)
if strings.TrimSpace(provider) == "" {
provider = providerFromHops(hops)
}
route := &quotationv2.RouteSpecification{
Rail: normalizeRail(string(destinationRail)),
Provider: normalizeProvider(provider),
PayoutMethod: normalizePayoutMethod(payoutMethodFromEndpoint(destination)),
SettlementAsset: normalizeAsset(intent.SettlementCurrency),
SettlementModel: normalizeSettlementModel(settlementModelString(intent.SettlementMode)),
Network: normalizeNetwork(network),
Hops: hops,
}
if route.SettlementAsset == "" && intent.Amount != nil {
route.SettlementAsset = normalizeAsset(intent.Amount.GetCurrency())
}
route.RouteRef = buildRouteReference(route)
route.PricingProfileRef = buildPricingProfileReference(route)
return route
}
func buildExecutionConditions(
previewOnly bool,
steps []*QuoteComputationStep,
funding *gateway_funding_profile.QuoteFundingGate,
) (*quotationv2.ExecutionConditions, quotationv2.QuoteBlockReason) {
blockReason := quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED
conditions := &quotationv2.ExecutionConditions{
Readiness: quotationv2.QuoteExecutionReadiness_QUOTE_EXECUTION_READINESS_LIQUIDITY_READY,
BatchingEligible: isBatchingEligible(steps),
PrefundingRequired: false,
PrefundingCostIncluded: false,
LiquidityCheckRequiredAtExecution: true,
LatencyHint: "instant",
Assumptions: []string{
"execution_time_liquidity_check",
"execution_time_provider_limits",
},
}
if previewOnly {
conditions.Readiness = quotationv2.QuoteExecutionReadiness_QUOTE_EXECUTION_READINESS_INDICATIVE
conditions.LatencyHint = "indicative"
}
if funding != nil {
switch funding.Mode {
case model.FundingModeBalanceReserve:
conditions.PrefundingRequired = true
conditions.LatencyHint = "reserve_before_payout"
case model.FundingModeDepositObserved:
conditions.PrefundingRequired = true
conditions.LatencyHint = "deposit_confirmation_required"
}
}
if !previewOnly && conditions.PrefundingRequired {
conditions.Readiness = quotationv2.QuoteExecutionReadiness_QUOTE_EXECUTION_READINESS_LIQUIDITY_OBTAINABLE
conditions.Assumptions = append(conditions.Assumptions, "prefunding_may_be_required_at_execution")
}
if !previewOnly && !conditions.PrefundingRequired {
conditions.Assumptions = append(conditions.Assumptions, "liquidity_expected_available_now")
}
return conditions, blockReason
}
func payoutMethodFromEndpoint(endpoint model.PaymentEndpoint) string {
switch endpoint.Type {
case model.EndpointTypeCard:
return "CARD"
case model.EndpointTypeExternalChain:
return "CRYPTO_ADDRESS"
case model.EndpointTypeManagedWallet:
return "MANAGED_WALLET"
case model.EndpointTypeLedger:
return "LEDGER"
default:
return "UNSPECIFIED"
}
}
func settlementModelString(mode model.SettlementMode) string {
switch mode {
case model.SettlementModeFixSource:
return "FIX_SOURCE"
case model.SettlementModeFixReceived:
return "FIX_RECEIVED"
default:
return "UNSPECIFIED"
}
}
func isBatchingEligible(steps []*QuoteComputationStep) bool {
for _, step := range steps {
if step != nil && step.IncludeInAggregate {
return true
}
}
return false
}
func buildRouteHops(steps []*QuoteComputationStep, fallbackNetwork string) []*quotationv2.RouteHop {
filtered := make([]*QuoteComputationStep, 0, len(steps))
for _, step := range steps {
if step == nil {
continue
}
filtered = append(filtered, step)
}
if len(filtered) == 0 {
return nil
}
result := make([]*quotationv2.RouteHop, 0, len(filtered))
lastIndex := len(filtered) - 1
for i, step := range filtered {
hop := &quotationv2.RouteHop{
Index: uint32(i + 1),
Rail: normalizeRail(string(step.Rail)),
Gateway: normalizeProvider(step.GatewayID),
InstanceId: strings.TrimSpace(step.InstanceID),
Network: normalizeNetwork(firstNonEmpty(fallbackNetwork)),
Role: roleForHopIndex(i, lastIndex),
}
if hop.Gateway == "" && hop.Rail == normalizeRail(string(model.RailLedger)) {
hop.Gateway = "internal"
}
result = append(result, hop)
}
return result
}
func roleForHopIndex(index, last int) quotationv2.RouteHopRole {
switch {
case index <= 0:
return quotationv2.RouteHopRole_ROUTE_HOP_ROLE_SOURCE
case index >= last:
return quotationv2.RouteHopRole_ROUTE_HOP_ROLE_DESTINATION
default:
return quotationv2.RouteHopRole_ROUTE_HOP_ROLE_TRANSIT
}
}
func providerFromHops(hops []*quotationv2.RouteHop) string {
for i := len(hops) - 1; i >= 0; i-- {
if hops[i] == nil {
continue
}
if gateway := normalizeProvider(hops[i].GetGateway()); gateway != "" {
return gateway
}
}
return ""
}
func buildRouteReference(route *quotationv2.RouteSpecification) string {
signature := routeTopologySignature(route, true)
if signature == "" {
return ""
}
sum := sha256.Sum256([]byte(signature))
return "rte_" + hex.EncodeToString(sum[:12])
}
func buildPricingProfileReference(route *quotationv2.RouteSpecification) string {
signature := routeTopologySignature(route, false)
if signature == "" {
return ""
}
sum := sha256.Sum256([]byte(signature))
return "fee_" + hex.EncodeToString(sum[:10])
}
func routeTopologySignature(route *quotationv2.RouteSpecification, includeInstances bool) string {
if route == nil {
return ""
}
parts := []string{
normalizeRail(route.GetRail()),
normalizeProvider(route.GetProvider()),
normalizePayoutMethod(route.GetPayoutMethod()),
normalizeAsset(route.GetSettlementAsset()),
normalizeSettlementModel(route.GetSettlementModel()),
normalizeNetwork(route.GetNetwork()),
}
hops := route.GetHops()
if len(hops) > 0 {
copied := make([]*quotationv2.RouteHop, 0, len(hops))
for _, hop := range hops {
if hop != nil {
copied = append(copied, hop)
}
}
sort.Slice(copied, func(i, j int) bool {
return copied[i].GetIndex() < copied[j].GetIndex()
})
for _, hop := range copied {
hopParts := []string{
fmt.Sprintf("%d", hop.GetIndex()),
normalizeRail(hop.GetRail()),
normalizeProvider(hop.GetGateway()),
normalizeNetwork(hop.GetNetwork()),
fmt.Sprintf("%d", hop.GetRole()),
}
if includeInstances {
hopParts = append(hopParts, strings.TrimSpace(hop.GetInstanceId()))
}
parts = append(parts, strings.Join(hopParts, ":"))
}
}
return strings.Join(parts, "|")
}

View File

@@ -0,0 +1,49 @@
package quote_computation_service
import (
"context"
"time"
"github.com/tech/sendico/payments/quotation/internal/service/plan"
"github.com/tech/sendico/payments/quotation/internal/service/quotation/gateway_funding_profile"
)
type Core interface {
BuildQuote(ctx context.Context, in BuildQuoteInput) (*ComputedQuote, time.Time, error)
}
type Option func(*QuoteComputationService)
type QuoteComputationService struct {
core Core
fundingResolver gateway_funding_profile.FundingProfileResolver
gatewayRegistry plan.GatewayRegistry
}
func New(core Core, opts ...Option) *QuoteComputationService {
svc := &QuoteComputationService{
core: core,
}
for _, opt := range opts {
if opt != nil {
opt(svc)
}
}
return svc
}
func WithFundingProfileResolver(resolver gateway_funding_profile.FundingProfileResolver) Option {
return func(svc *QuoteComputationService) {
if svc != nil {
svc.fundingResolver = resolver
}
}
}
func WithGatewayRegistry(registry plan.GatewayRegistry) Option {
return func(svc *QuoteComputationService) {
if svc != nil {
svc.gatewayRegistry = registry
}
}
}