payment quotation v2 + payment orchestration v2 draft
This commit is contained in:
224
api/payments/storage/model/gateway_eligibility.go
Normal file
224
api/payments/storage/model/gateway_eligibility.go
Normal file
@@ -0,0 +1,224 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
type GatewayDirection int
|
||||
|
||||
const (
|
||||
GatewayDirectionAny GatewayDirection = iota
|
||||
GatewayDirectionOut
|
||||
GatewayDirectionIn
|
||||
)
|
||||
|
||||
func (d GatewayDirection) String() string {
|
||||
switch d {
|
||||
case GatewayDirectionOut:
|
||||
return "out"
|
||||
case GatewayDirectionIn:
|
||||
return "in"
|
||||
default:
|
||||
return "any"
|
||||
}
|
||||
}
|
||||
|
||||
func NoEligibleGatewayMessage(network, currency string, action RailOperation, dir GatewayDirection) string {
|
||||
return fmt.Sprintf(
|
||||
"plan builder: no eligible gateway found for %s %s %s for direction %s",
|
||||
strings.ToUpper(strings.TrimSpace(network)),
|
||||
strings.ToUpper(strings.TrimSpace(currency)),
|
||||
ParseRailOperation(string(action)),
|
||||
dir.String(),
|
||||
)
|
||||
}
|
||||
|
||||
func IsGatewayEligible(
|
||||
gw *GatewayInstanceDescriptor,
|
||||
rail Rail,
|
||||
network, currency string,
|
||||
action RailOperation,
|
||||
dir GatewayDirection,
|
||||
amount decimal.Decimal,
|
||||
) error {
|
||||
if gw == nil {
|
||||
return gatewayIneligible(gw, "gateway instance is required")
|
||||
}
|
||||
if !gw.IsEnabled {
|
||||
return gatewayIneligible(gw, "gateway instance is disabled")
|
||||
}
|
||||
if gw.Rail != rail {
|
||||
return gatewayIneligible(gw, fmt.Sprintf("rail mismatch: want %s got %s", rail, gw.Rail))
|
||||
}
|
||||
if network != "" && gw.Network != "" && !strings.EqualFold(gw.Network, network) {
|
||||
return gatewayIneligible(gw, fmt.Sprintf("network mismatch: want %s got %s", network, gw.Network))
|
||||
}
|
||||
if currency != "" && len(gw.Currencies) > 0 {
|
||||
found := false
|
||||
for _, c := range gw.Currencies {
|
||||
if strings.EqualFold(c, currency) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return gatewayIneligible(gw, "currency not supported: "+currency)
|
||||
}
|
||||
}
|
||||
|
||||
if !gatewayAllowsAction(gw.Operations, gw.Capabilities, action, dir) {
|
||||
return gatewayIneligible(gw, fmt.Sprintf("gateway does not allow action=%s dir=%s", action, dir.String()))
|
||||
}
|
||||
|
||||
if currency != "" {
|
||||
if err := amountWithinLimits(gw, gw.Limits, currency, amount, action); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type gatewayIneligibleError struct {
|
||||
reason string
|
||||
}
|
||||
|
||||
func (e gatewayIneligibleError) Error() string {
|
||||
return e.reason
|
||||
}
|
||||
|
||||
func gatewayIneligible(gw *GatewayInstanceDescriptor, reason string) error {
|
||||
if strings.TrimSpace(reason) == "" {
|
||||
reason = "gateway instance is not eligible"
|
||||
}
|
||||
instanceID := ""
|
||||
if gw != nil {
|
||||
instanceID = gw.InstanceID
|
||||
}
|
||||
return gatewayIneligibleError{reason: fmt.Sprintf("gateway %s eligibility check error: %s", instanceID, reason)}
|
||||
}
|
||||
|
||||
func gatewayAllowsAction(operations []RailOperation, cap RailCapabilities, action RailOperation, dir GatewayDirection) bool {
|
||||
normalized := NormalizeRailOperations(operations)
|
||||
if len(normalized) > 0 {
|
||||
return operationsAllowAction(normalized, action, dir)
|
||||
}
|
||||
return capabilityAllowsAction(cap, action, dir)
|
||||
}
|
||||
|
||||
func capabilityAllowsAction(cap RailCapabilities, action RailOperation, dir GatewayDirection) bool {
|
||||
switch action {
|
||||
case RailOperationSend:
|
||||
switch dir {
|
||||
case GatewayDirectionOut:
|
||||
return cap.CanPayOut
|
||||
case GatewayDirectionIn:
|
||||
return cap.CanPayIn
|
||||
default:
|
||||
return cap.CanPayIn || cap.CanPayOut
|
||||
}
|
||||
case RailOperationExternalDebit, RailOperationExternalCredit:
|
||||
switch dir {
|
||||
case GatewayDirectionOut:
|
||||
return cap.CanPayOut
|
||||
case GatewayDirectionIn:
|
||||
return cap.CanPayIn
|
||||
default:
|
||||
return cap.CanPayIn || cap.CanPayOut
|
||||
}
|
||||
case RailOperationFee:
|
||||
return cap.CanSendFee
|
||||
case RailOperationObserveConfirm:
|
||||
return cap.RequiresObserveConfirm
|
||||
case RailOperationBlock:
|
||||
return cap.CanBlock
|
||||
case RailOperationRelease:
|
||||
return cap.CanRelease
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func operationsAllowAction(operations []RailOperation, action RailOperation, dir GatewayDirection) bool {
|
||||
action = ParseRailOperation(string(action))
|
||||
if action == RailOperationUnspecified {
|
||||
return false
|
||||
}
|
||||
|
||||
if HasRailOperation(operations, action) {
|
||||
return true
|
||||
}
|
||||
|
||||
switch action {
|
||||
case RailOperationSend:
|
||||
switch dir {
|
||||
case GatewayDirectionIn:
|
||||
return HasRailOperation(operations, RailOperationExternalDebit)
|
||||
case GatewayDirectionOut:
|
||||
return HasRailOperation(operations, RailOperationExternalCredit)
|
||||
default:
|
||||
return HasRailOperation(operations, RailOperationExternalDebit) ||
|
||||
HasRailOperation(operations, RailOperationExternalCredit)
|
||||
}
|
||||
case RailOperationExternalDebit:
|
||||
return HasRailOperation(operations, RailOperationSend)
|
||||
case RailOperationExternalCredit:
|
||||
return HasRailOperation(operations, RailOperationSend)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func amountWithinLimits(gw *GatewayInstanceDescriptor, limits Limits, currency string, amount decimal.Decimal, action RailOperation) error {
|
||||
min := firstLimitValue(limits.MinAmount, "")
|
||||
max := firstLimitValue(limits.MaxAmount, "")
|
||||
perTxMin := firstLimitValue(limits.PerTxMinAmount, "")
|
||||
perTxMax := firstLimitValue(limits.PerTxMaxAmount, "")
|
||||
maxFee := firstLimitValue(limits.PerTxMaxFee, "")
|
||||
|
||||
if override, ok := limits.CurrencyLimits[currency]; ok {
|
||||
min = firstLimitValue(override.MinAmount, min)
|
||||
max = firstLimitValue(override.MaxAmount, max)
|
||||
if action == RailOperationFee {
|
||||
maxFee = firstLimitValue(override.MaxFee, maxFee)
|
||||
}
|
||||
}
|
||||
|
||||
if min != "" {
|
||||
if val, err := decimal.NewFromString(min); err == nil && amount.LessThan(val) {
|
||||
return gatewayIneligible(gw, fmt.Sprintf("amount %s %s below min limit %s", amount.String(), currency, val.String()))
|
||||
}
|
||||
}
|
||||
if perTxMin != "" {
|
||||
if val, err := decimal.NewFromString(perTxMin); err == nil && amount.LessThan(val) {
|
||||
return gatewayIneligible(gw, fmt.Sprintf("amount %s %s below per-tx min limit %s", amount.String(), currency, val.String()))
|
||||
}
|
||||
}
|
||||
if max != "" {
|
||||
if val, err := decimal.NewFromString(max); err == nil && amount.GreaterThan(val) {
|
||||
return gatewayIneligible(gw, fmt.Sprintf("amount %s %s exceeds max limit %s", amount.String(), currency, val.String()))
|
||||
}
|
||||
}
|
||||
if perTxMax != "" {
|
||||
if val, err := decimal.NewFromString(perTxMax); err == nil && amount.GreaterThan(val) {
|
||||
return gatewayIneligible(gw, fmt.Sprintf("amount %s %s exceeds per-tx max limit %s", amount.String(), currency, val.String()))
|
||||
}
|
||||
}
|
||||
if action == RailOperationFee && maxFee != "" {
|
||||
if val, err := decimal.NewFromString(maxFee); err == nil && amount.GreaterThan(val) {
|
||||
return gatewayIneligible(gw, fmt.Sprintf("fee amount %s %s exceeds max fee limit %s", amount.String(), currency, val.String()))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func firstLimitValue(primary, fallback string) string {
|
||||
val := strings.TrimSpace(primary)
|
||||
if val != "" {
|
||||
return val
|
||||
}
|
||||
return strings.TrimSpace(fallback)
|
||||
}
|
||||
49
api/payments/storage/model/gateway_eligibility_test.go
Normal file
49
api/payments/storage/model/gateway_eligibility_test.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
func TestIsGatewayEligible_AllowsMatchingGateway(t *testing.T) {
|
||||
gw := &GatewayInstanceDescriptor{
|
||||
ID: "gw-1",
|
||||
InstanceID: "inst-1",
|
||||
Rail: RailCrypto,
|
||||
Network: "TRON",
|
||||
Currencies: []string{"USDT"},
|
||||
Operations: []RailOperation{RailOperationSend, RailOperationExternalCredit},
|
||||
IsEnabled: true,
|
||||
}
|
||||
|
||||
err := IsGatewayEligible(gw, RailCrypto, "TRON", "USDT", RailOperationSend, GatewayDirectionOut, decimal.RequireFromString("10"))
|
||||
if err != nil {
|
||||
t.Fatalf("expected gateway to be eligible, got err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsGatewayEligible_RejectsNetworkMismatch(t *testing.T) {
|
||||
gw := &GatewayInstanceDescriptor{
|
||||
ID: "gw-1",
|
||||
InstanceID: "inst-1",
|
||||
Rail: RailCrypto,
|
||||
Network: "ETH",
|
||||
Currencies: []string{"USDT"},
|
||||
Operations: []RailOperation{RailOperationSend},
|
||||
IsEnabled: true,
|
||||
}
|
||||
|
||||
err := IsGatewayEligible(gw, RailCrypto, "TRON", "USDT", RailOperationSend, GatewayDirectionOut, decimal.RequireFromString("10"))
|
||||
if err == nil {
|
||||
t.Fatalf("expected network mismatch error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoEligibleGatewayMessage(t *testing.T) {
|
||||
got := NoEligibleGatewayMessage("tron", "usdt", RailOperationSend, GatewayDirectionOut)
|
||||
want := "plan builder: no eligible gateway found for TRON USDT SEND for direction out"
|
||||
if got != want {
|
||||
t.Fatalf("unexpected message: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -139,6 +139,7 @@ type GatewayInstanceDescriptor struct {
|
||||
Network string `bson:"network,omitempty" json:"network,omitempty"`
|
||||
InvokeURI string `bson:"invokeUri,omitempty" json:"invokeUri,omitempty"`
|
||||
Currencies []string `bson:"currencies,omitempty" json:"currencies,omitempty"`
|
||||
Operations []RailOperation `bson:"operations,omitempty" json:"operations,omitempty"`
|
||||
Capabilities RailCapabilities `bson:"capabilities,omitempty" json:"capabilities,omitempty"`
|
||||
Limits Limits `bson:"limits,omitempty" json:"limits,omitempty"`
|
||||
Version string `bson:"version,omitempty" json:"version,omitempty"`
|
||||
@@ -305,18 +306,18 @@ type PaymentPlan struct {
|
||||
|
||||
// ExecutionStep describes a planned or executed payment step for reporting.
|
||||
type ExecutionStep struct {
|
||||
Code string `bson:"code,omitempty" json:"code,omitempty"`
|
||||
Description string `bson:"description,omitempty" json:"description,omitempty"`
|
||||
Amount *paymenttypes.Money `bson:"amount,omitempty" json:"amount,omitempty"`
|
||||
NetworkFee *paymenttypes.Money `bson:"networkFee,omitempty" json:"networkFee,omitempty"`
|
||||
SourceWalletRef string `bson:"sourceWalletRef,omitempty" json:"sourceWalletRef,omitempty"`
|
||||
DestinationRef string `bson:"destinationRef,omitempty" json:"destinationRef,omitempty"`
|
||||
TransferRef string `bson:"transferRef,omitempty" json:"transferRef,omitempty"`
|
||||
OperationRef string `bson:"operationRef,omitempty" json:"operationRef,omitempty"`
|
||||
ReportVisibility ReportVisibility `bson:"reportVisibility,omitempty" json:"reportVisibility,omitempty"`
|
||||
Error string `bson:"error,omitempty" json:"error,omitempty"`
|
||||
State OperationState `bson:"state,omitempty" json:"state,omitempty"`
|
||||
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
|
||||
Code string `bson:"code,omitempty" json:"code,omitempty"`
|
||||
Description string `bson:"description,omitempty" json:"description,omitempty"`
|
||||
Amount *paymenttypes.Money `bson:"amount,omitempty" json:"amount,omitempty"`
|
||||
NetworkFee *paymenttypes.Money `bson:"networkFee,omitempty" json:"networkFee,omitempty"`
|
||||
SourceWalletRef string `bson:"sourceWalletRef,omitempty" json:"sourceWalletRef,omitempty"`
|
||||
DestinationRef string `bson:"destinationRef,omitempty" json:"destinationRef,omitempty"`
|
||||
TransferRef string `bson:"transferRef,omitempty" json:"transferRef,omitempty"`
|
||||
OperationRef string `bson:"operationRef,omitempty" json:"operationRef,omitempty"`
|
||||
ReportVisibility ReportVisibility `bson:"reportVisibility,omitempty" json:"reportVisibility,omitempty"`
|
||||
Error string `bson:"error,omitempty" json:"error,omitempty"`
|
||||
State OperationState `bson:"state,omitempty" json:"state,omitempty"`
|
||||
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
func (s *ExecutionStep) IsTerminal() bool {
|
||||
|
||||
93
api/payments/storage/model/rail_operations.go
Normal file
93
api/payments/storage/model/rail_operations.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package model
|
||||
|
||||
import "strings"
|
||||
|
||||
var supportedRailOperations = map[RailOperation]struct{}{
|
||||
RailOperationDebit: {},
|
||||
RailOperationCredit: {},
|
||||
RailOperationExternalDebit: {},
|
||||
RailOperationExternalCredit: {},
|
||||
RailOperationMove: {},
|
||||
RailOperationSend: {},
|
||||
RailOperationFee: {},
|
||||
RailOperationObserveConfirm: {},
|
||||
RailOperationFXConvert: {},
|
||||
RailOperationBlock: {},
|
||||
RailOperationRelease: {},
|
||||
}
|
||||
|
||||
// ParseRailOperation canonicalizes string values into a RailOperation token.
|
||||
func ParseRailOperation(value string) RailOperation {
|
||||
clean := strings.ToUpper(strings.TrimSpace(value))
|
||||
if clean == "" {
|
||||
return RailOperationUnspecified
|
||||
}
|
||||
return RailOperation(clean)
|
||||
}
|
||||
|
||||
// IsSupportedRailOperation reports whether op is recognized by payment planning.
|
||||
func IsSupportedRailOperation(op RailOperation) bool {
|
||||
_, ok := supportedRailOperations[ParseRailOperation(string(op))]
|
||||
return ok
|
||||
}
|
||||
|
||||
// NormalizeRailOperations trims, uppercases, deduplicates, and filters unknown values.
|
||||
func NormalizeRailOperations(values []RailOperation) []RailOperation {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]RailOperation, 0, len(values))
|
||||
seen := map[RailOperation]bool{}
|
||||
for _, value := range values {
|
||||
op := ParseRailOperation(string(value))
|
||||
if op == RailOperationUnspecified || !IsSupportedRailOperation(op) || seen[op] {
|
||||
continue
|
||||
}
|
||||
seen[op] = true
|
||||
result = append(result, op)
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// NormalizeRailOperationStrings normalizes string operation values.
|
||||
func NormalizeRailOperationStrings(values []string) []RailOperation {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
ops := make([]RailOperation, 0, len(values))
|
||||
for _, value := range values {
|
||||
ops = append(ops, ParseRailOperation(value))
|
||||
}
|
||||
return NormalizeRailOperations(ops)
|
||||
}
|
||||
|
||||
// HasRailOperation checks whether ops includes action.
|
||||
func HasRailOperation(ops []RailOperation, action RailOperation) bool {
|
||||
want := ParseRailOperation(string(action))
|
||||
if want == RailOperationUnspecified {
|
||||
return false
|
||||
}
|
||||
for _, op := range ops {
|
||||
if ParseRailOperation(string(op)) == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// RailCapabilitiesFromOperations derives legacy capability flags from explicit operations.
|
||||
func RailCapabilitiesFromOperations(ops []RailOperation) RailCapabilities {
|
||||
normalized := NormalizeRailOperations(ops)
|
||||
return RailCapabilities{
|
||||
CanPayIn: HasRailOperation(normalized, RailOperationExternalDebit),
|
||||
CanPayOut: HasRailOperation(normalized, RailOperationSend) || HasRailOperation(normalized, RailOperationExternalCredit),
|
||||
CanReadBalance: false,
|
||||
CanSendFee: HasRailOperation(normalized, RailOperationFee),
|
||||
RequiresObserveConfirm: HasRailOperation(normalized, RailOperationObserveConfirm),
|
||||
CanBlock: HasRailOperation(normalized, RailOperationBlock),
|
||||
CanRelease: HasRailOperation(normalized, RailOperationRelease),
|
||||
}
|
||||
}
|
||||
65
api/payments/storage/model/rail_operations_test.go
Normal file
65
api/payments/storage/model/rail_operations_test.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package model
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeRailOperations(t *testing.T) {
|
||||
ops := NormalizeRailOperations([]RailOperation{
|
||||
"send",
|
||||
"SEND",
|
||||
" external_credit ",
|
||||
"unknown",
|
||||
"",
|
||||
})
|
||||
if len(ops) != 2 {
|
||||
t.Fatalf("unexpected operations count: got=%d want=2", len(ops))
|
||||
}
|
||||
if ops[0] != RailOperationSend {
|
||||
t.Fatalf("unexpected first operation: got=%q want=%q", ops[0], RailOperationSend)
|
||||
}
|
||||
if ops[1] != RailOperationExternalCredit {
|
||||
t.Fatalf("unexpected second operation: got=%q want=%q", ops[1], RailOperationExternalCredit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasRailOperation(t *testing.T) {
|
||||
ops := []RailOperation{RailOperationSend, RailOperationExternalCredit}
|
||||
if !HasRailOperation(ops, RailOperationSend) {
|
||||
t.Fatalf("expected send operation to be present")
|
||||
}
|
||||
if !HasRailOperation(ops, " external_credit ") {
|
||||
t.Fatalf("expected external credit operation to be present")
|
||||
}
|
||||
if HasRailOperation(ops, RailOperationObserveConfirm) {
|
||||
t.Fatalf("did not expect observe confirm operation to be present")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRailCapabilitiesFromOperations(t *testing.T) {
|
||||
cap := RailCapabilitiesFromOperations([]RailOperation{
|
||||
RailOperationExternalDebit,
|
||||
RailOperationExternalCredit,
|
||||
RailOperationFee,
|
||||
RailOperationObserveConfirm,
|
||||
RailOperationBlock,
|
||||
RailOperationRelease,
|
||||
})
|
||||
|
||||
if !cap.CanPayIn {
|
||||
t.Fatalf("expected can pay in to be true")
|
||||
}
|
||||
if !cap.CanPayOut {
|
||||
t.Fatalf("expected can pay out to be true")
|
||||
}
|
||||
if !cap.CanSendFee {
|
||||
t.Fatalf("expected can send fee to be true")
|
||||
}
|
||||
if !cap.RequiresObserveConfirm {
|
||||
t.Fatalf("expected requires observe confirm to be true")
|
||||
}
|
||||
if !cap.CanBlock {
|
||||
t.Fatalf("expected can block to be true")
|
||||
}
|
||||
if !cap.CanRelease {
|
||||
t.Fatalf("expected can release to be true")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user