payment quotation v2 + payment orchestration v2 draft

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

View File

@@ -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)
}

View 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)
}
}

View File

@@ -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 {

View 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),
}
}

View 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")
}
}