outbox for gateways
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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: "ationv2.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
|
||||
}
|
||||
@@ -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 := "ationv2.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 := "ationv2.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 "ationv2.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(),
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 := "ationv2.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 := "ationv2.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 := "ationv2.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, "|")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user