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

@@ -5,6 +5,7 @@ go 1.25.7
replace github.com/tech/sendico/pkg => ../../pkg
require (
github.com/shopspring/decimal v1.4.0
github.com/tech/sendico/pkg v0.1.0
go.mongodb.org/mongo-driver/v2 v2.5.0
go.uber.org/zap v1.27.1

View File

@@ -91,6 +91,8 @@ github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/i
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=

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

View File

@@ -16,6 +16,7 @@ import (
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
// Store implements storage.Repository backed by MongoDB.
@@ -23,6 +24,7 @@ type Store struct {
logger mlogger.Logger
ping func(context.Context) error
database *mongo.Database
payments storage.PaymentsStore
methods storage.PaymentMethodsStore
quotes quotestorage.QuotesStore
@@ -71,17 +73,18 @@ func New(logger mlogger.Logger, conn *db.MongoConnection, opts ...Option) (*Stor
plansRepo := repository.CreateMongoRepository(conn.Database(), (&model.PaymentPlanTemplate{}).Collection())
methodsRepo := repository.CreateMongoRepository(conn.Database(), mservice.PaymentMethods)
return newWithRepository(logger, conn.Ping, paymentsRepo, methodsRepo, quotesRepo, routesRepo, plansRepo, opts...)
return newWithRepository(logger, conn.Ping, conn.Database(), paymentsRepo, methodsRepo, quotesRepo, routesRepo, plansRepo, opts...)
}
// NewWithRepository constructs a payments repository using the provided primitives.
func NewWithRepository(logger mlogger.Logger, ping func(context.Context) error, paymentsRepo repository.Repository, quotesRepo repository.Repository, routesRepo repository.Repository, plansRepo repository.Repository, opts ...Option) (*Store, error) {
return newWithRepository(logger, ping, paymentsRepo, nil, quotesRepo, routesRepo, plansRepo, opts...)
return newWithRepository(logger, ping, nil, paymentsRepo, nil, quotesRepo, routesRepo, plansRepo, opts...)
}
func newWithRepository(
logger mlogger.Logger,
ping func(context.Context) error,
database *mongo.Database,
paymentsRepo, methodsRepo, quotesRepo, routesRepo, plansRepo repository.Repository,
opts ...Option,
) (*Store, error) {
@@ -147,6 +150,7 @@ func newWithRepository(
result := &Store{
logger: childLogger,
ping: ping,
database: database,
payments: paymentsStore,
methods: methodsStore,
quotes: quotesRepoStore.Quotes(),
@@ -190,4 +194,12 @@ func (s *Store) PlanTemplates() storage.PlanTemplatesStore {
return s.plans
}
// MongoDatabase returns underlying Mongo database when available.
func (s *Store) MongoDatabase() *mongo.Database {
if s == nil {
return nil
}
return s.database
}
var _ storage.Repository = (*Store)(nil)