Fully separated payment quotation and orchestration flows
This commit is contained in:
@@ -8,7 +8,9 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
orchestrationv1 "github.com/tech/sendico/pkg/proto/payments/orchestration/v1"
|
||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||
quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
@@ -81,8 +83,8 @@ func New(ctx context.Context, cfg Config, opts ...grpc.DialOption) (Client, erro
|
||||
cfg: cfg,
|
||||
conn: conn,
|
||||
quoteConn: quoteConn,
|
||||
client: orchestratorv1.NewPaymentOrchestratorClient(conn),
|
||||
quoteClient: orchestratorv1.NewPaymentQuotationClient(quoteConn),
|
||||
client: orchestrationv1.NewPaymentExecutionServiceClient(conn),
|
||||
quoteClient: quotationv1.NewQuotationServiceClient(quoteConn),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -26,8 +26,6 @@ require (
|
||||
github.com/tech/sendico/ledger v0.0.0-00010101000000-000000000000
|
||||
github.com/tech/sendico/payments/storage v0.0.0-00010101000000-000000000000
|
||||
github.com/tech/sendico/pkg v0.1.0
|
||||
github.com/testcontainers/testcontainers-go v0.33.0
|
||||
github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0
|
||||
go.uber.org/zap v1.27.1
|
||||
google.golang.org/grpc v1.78.0
|
||||
@@ -36,72 +34,31 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
dario.cat/mergo v1.0.1 // indirect
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect
|
||||
github.com/casbin/casbin/v2 v2.135.0 // indirect
|
||||
github.com/casbin/govaluate v1.10.0 // indirect
|
||||
github.com/casbin/mongodb-adapter/v4 v4.3.0 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/containerd/log v0.1.0 // indirect
|
||||
github.com/containerd/platforms v0.2.1 // indirect
|
||||
github.com/cpuguy83/dockercfg v0.3.2 // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/docker/docker v27.3.1+incompatible // indirect
|
||||
github.com/docker/go-connections v0.5.0 // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/go-chi/chi/v5 v5.2.5 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/klauspost/compress v1.18.4 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54 // indirect
|
||||
github.com/magiconair/properties v1.8.7 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/patternmatcher v0.6.0 // indirect
|
||||
github.com/moby/sys/sequential v0.6.0 // indirect
|
||||
github.com/moby/sys/user v0.3.0 // indirect
|
||||
github.com/moby/sys/userns v0.1.0 // indirect
|
||||
github.com/moby/term v0.5.0 // indirect
|
||||
github.com/montanaflynn/stats v0.7.1 // indirect
|
||||
github.com/morikuni/aec v1.0.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/nats-io/nats.go v1.48.0 // indirect
|
||||
github.com/nats-io/nkeys v0.4.15 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.0 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.5 // indirect
|
||||
github.com/prometheus/procfs v0.19.2 // indirect
|
||||
github.com/shirou/gopsutil/v3 v3.24.5 // indirect
|
||||
github.com/shoenig/go-m1cpu v0.1.6 // indirect
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.16 // indirect
|
||||
github.com/tklauser/numcpus v0.11.0 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||
github.com/xdg-go/scram v1.2.0 // indirect
|
||||
github.com/xdg-go/stringprep v1.0.4 // indirect
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 // indirect
|
||||
go.opentelemetry.io/otel v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.39.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
|
||||
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU=
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
@@ -28,9 +26,6 @@ github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpS
|
||||
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
|
||||
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
|
||||
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
||||
github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
|
||||
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
@@ -45,12 +40,10 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
@@ -60,16 +53,10 @@ github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
|
||||
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
|
||||
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
||||
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
@@ -100,8 +87,6 @@ github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g
|
||||
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
|
||||
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
||||
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
||||
github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
|
||||
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
|
||||
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
@@ -132,20 +117,17 @@ github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4
|
||||
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI=
|
||||
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
|
||||
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/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
|
||||
github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
|
||||
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.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/testcontainers/testcontainers-go v0.33.0 h1:zJS9PfXYT5O0ZFXM2xxXfk4J5UMw/kRiISng037Gxdw=
|
||||
@@ -164,13 +146,9 @@ github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6
|
||||
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
go.mongodb.org/mongo-driver v1.17.8 h1:BDP3+U3Y8K0vTrpqDJIRaXNhb/bKyoVeg6tIJsW5EhM=
|
||||
go.mongodb.org/mongo-driver v1.17.8/go.mod h1:LlOhpH5NUEfhxcAwG0UEkMqwYcc4JU18gtCdGudk/tQ=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
@@ -179,10 +157,6 @@ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 h1:UP6IpuH
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM=
|
||||
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
||||
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0/go.mod h1:vnakAaFckOMiMtOIhFI2MNH4FYrZzXCYxmb1LlhoGz8=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU=
|
||||
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
|
||||
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
|
||||
@@ -191,8 +165,6 @@ go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2W
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
||||
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
||||
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
||||
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
|
||||
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
@@ -202,70 +174,43 @@ go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda h1:+2XxjfsAu6vqFxwGBRcHiMaDCuZiqXGDUDVWVtrFAnE=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||
@@ -275,8 +220,5 @@ google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU=
|
||||
gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
ledgerclient "github.com/tech/sendico/ledger/client"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrator"
|
||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||
quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1"
|
||||
)
|
||||
|
||||
type orchestratorDeps struct {
|
||||
@@ -13,6 +14,7 @@ type orchestratorDeps struct {
|
||||
ledgerClient ledgerclient.Client
|
||||
mntxClient mntxclient.Client
|
||||
oracleClient oracleclient.Client
|
||||
quotationClient quotationv1.QuotationServiceClient
|
||||
gatewayInvokeResolver orchestrator.GatewayInvokeResolver
|
||||
}
|
||||
|
||||
@@ -30,6 +32,7 @@ func (i *Imp) initDependencies(_ *config) *orchestratorDeps {
|
||||
deps.ledgerClient = &discoveryLedgerClient{resolver: i.discoveryClients}
|
||||
deps.oracleClient = &discoveryOracleClient{resolver: i.discoveryClients}
|
||||
deps.mntxClient = &discoveryMntxClient{resolver: i.discoveryClients}
|
||||
deps.quotationClient = &discoveryQuotationClient{resolver: i.discoveryClients}
|
||||
deps.gatewayInvokeResolver = discoveryGatewayInvokeResolver{resolver: i.discoveryClients}
|
||||
return deps
|
||||
}
|
||||
@@ -48,8 +51,9 @@ func (i *Imp) buildServiceOptions(cfg *config, deps *orchestratorDeps) []orchest
|
||||
if deps.mntxClient != nil {
|
||||
opts = append(opts, orchestrator.WithMntxGateway(deps.mntxClient))
|
||||
}
|
||||
if deps.oracleClient != nil {
|
||||
opts = append(opts, orchestrator.WithOracleClient(deps.oracleClient))
|
||||
|
||||
if deps.quotationClient != nil {
|
||||
opts = append(opts, orchestrator.WithQuotationService(deps.quotationClient))
|
||||
}
|
||||
if deps.gatewayInvokeResolver != nil {
|
||||
opts = append(opts, orchestrator.WithGatewayInvokeResolver(deps.gatewayInvokeResolver))
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||
quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1"
|
||||
"go.uber.org/zap"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials"
|
||||
@@ -32,6 +33,7 @@ var (
|
||||
ledgerServiceNames = []string{"LEDGER", string(mservice.Ledger)}
|
||||
oracleServiceNames = []string{"FX_ORACLE", string(mservice.FXOracle)}
|
||||
mntxServiceNames = []string{"CARD_PAYOUT_RAIL_GATEWAY", string(mservice.MntxGateway)}
|
||||
quoteServiceNames = []string{"PAYMENT_QUOTATION", "payment_quotation"}
|
||||
)
|
||||
|
||||
type discoveryEndpoint struct {
|
||||
@@ -53,6 +55,9 @@ type discoveryClientResolver struct {
|
||||
feesConn *grpc.ClientConn
|
||||
feesEndpoint discoveryEndpoint
|
||||
|
||||
quoteConn *grpc.ClientConn
|
||||
quoteEndpoint discoveryEndpoint
|
||||
|
||||
ledgerClient ledgerclient.Client
|
||||
ledgerEndpoint discoveryEndpoint
|
||||
|
||||
@@ -88,6 +93,10 @@ func (r *discoveryClientResolver) Close() {
|
||||
_ = r.feesConn.Close()
|
||||
r.feesConn = nil
|
||||
}
|
||||
if r.quoteConn != nil {
|
||||
_ = r.quoteConn.Close()
|
||||
r.quoteConn = nil
|
||||
}
|
||||
if r.ledgerClient != nil {
|
||||
_ = r.ledgerClient.Close()
|
||||
r.ledgerClient = nil
|
||||
@@ -128,6 +137,11 @@ func (r *discoveryClientResolver) MntxAvailable() bool {
|
||||
return ok
|
||||
}
|
||||
|
||||
func (r *discoveryClientResolver) QuotationAvailable() bool {
|
||||
_, ok := r.findEntry("quotation", quoteServiceNames, "", "")
|
||||
return ok
|
||||
}
|
||||
|
||||
func (r *discoveryClientResolver) FeesClient(ctx context.Context) (feesv1.FeeEngineClient, error) {
|
||||
entry, ok := r.findEntry("fees", feesServiceNames, "", "")
|
||||
if !ok {
|
||||
@@ -159,6 +173,37 @@ func (r *discoveryClientResolver) FeesClient(ctx context.Context) (feesv1.FeeEng
|
||||
return feesv1.NewFeeEngineClient(r.feesConn), nil
|
||||
}
|
||||
|
||||
func (r *discoveryClientResolver) QuotationClient(ctx context.Context) (quotationv1.QuotationServiceClient, error) {
|
||||
entry, ok := r.findEntry("quotation", quoteServiceNames, "", "")
|
||||
if !ok {
|
||||
return nil, merrors.NoData("discovery: quotation service unavailable")
|
||||
}
|
||||
endpoint, err := parseDiscoveryEndpoint(entry.InvokeURI)
|
||||
if err != nil {
|
||||
r.logMissing("quotation", "invalid quotation invoke uri", entry.InvokeURI, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if r.quoteConn == nil || r.quoteEndpoint.key() != endpoint.key() || r.quoteEndpoint.address != endpoint.address {
|
||||
if r.quoteConn != nil {
|
||||
_ = r.quoteConn.Close()
|
||||
r.quoteConn = nil
|
||||
}
|
||||
conn, dialErr := dialGrpc(ctx, endpoint)
|
||||
if dialErr != nil {
|
||||
r.logMissing("quotation", "failed to dial quotation service", endpoint.raw, dialErr)
|
||||
return nil, dialErr
|
||||
}
|
||||
r.quoteConn = conn
|
||||
r.quoteEndpoint = endpoint
|
||||
}
|
||||
|
||||
return quotationv1.NewQuotationServiceClient(r.quoteConn), nil
|
||||
}
|
||||
|
||||
func (r *discoveryClientResolver) LedgerClient(ctx context.Context) (ledgerclient.Client, error) {
|
||||
entry, ok := r.findEntry("ledger", ledgerServiceNames, "", "")
|
||||
if !ok {
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
|
||||
quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
@@ -51,6 +52,33 @@ func (c *discoveryFeeClient) ValidateFeeToken(ctx context.Context, req *feesv1.V
|
||||
return client.ValidateFeeToken(ctx, req, opts...)
|
||||
}
|
||||
|
||||
type discoveryQuotationClient struct {
|
||||
resolver *discoveryClientResolver
|
||||
}
|
||||
|
||||
func (c *discoveryQuotationClient) Available() bool {
|
||||
if c == nil || c.resolver == nil {
|
||||
return false
|
||||
}
|
||||
return c.resolver.QuotationAvailable()
|
||||
}
|
||||
|
||||
func (c *discoveryQuotationClient) QuotePayment(ctx context.Context, req *quotationv1.QuotePaymentRequest, opts ...grpc.CallOption) (*quotationv1.QuotePaymentResponse, error) {
|
||||
client, err := c.resolver.QuotationClient(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client.QuotePayment(ctx, req, opts...)
|
||||
}
|
||||
|
||||
func (c *discoveryQuotationClient) QuotePayments(ctx context.Context, req *quotationv1.QuotePaymentsRequest, opts ...grpc.CallOption) (*quotationv1.QuotePaymentsResponse, error) {
|
||||
client, err := c.resolver.QuotationClient(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client.QuotePayments(ctx, req, opts...)
|
||||
}
|
||||
|
||||
type discoveryLedgerClient struct {
|
||||
resolver *discoveryClientResolver
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package orchestrator
|
||||
package execution
|
||||
|
||||
import (
|
||||
"strings"
|
||||
@@ -11,8 +11,9 @@ const (
|
||||
executionStepMetadataRole = "role"
|
||||
executionStepMetadataStatus = "status"
|
||||
|
||||
executionStepRoleSource = "source"
|
||||
executionStepRoleConsumer = "consumer"
|
||||
executionStepRoleSource = "source"
|
||||
executionStepRoleConsumer = "consumer"
|
||||
executionStepCodeCardPayout = "card_payout"
|
||||
)
|
||||
|
||||
func setExecutionStepRole(step *model.ExecutionStep, role string) {
|
||||
@@ -32,7 +33,7 @@ func executionStepRole(step *model.ExecutionStep) string {
|
||||
if role := strings.TrimSpace(step.Metadata[executionStepMetadataRole]); role != "" {
|
||||
return strings.ToLower(role)
|
||||
}
|
||||
if strings.EqualFold(step.Code, stepCodeCardPayout) {
|
||||
if strings.EqualFold(step.Code, executionStepCodeCardPayout) {
|
||||
return executionStepRoleConsumer
|
||||
}
|
||||
return executionStepRoleSource
|
||||
123
api/payments/orchestrator/internal/service/execution/export.go
Normal file
123
api/payments/orchestrator/internal/service/execution/export.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package execution
|
||||
|
||||
import (
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/model/account_role"
|
||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||
)
|
||||
|
||||
const (
|
||||
ExecutionStepRoleSource = executionStepRoleSource
|
||||
ExecutionStepRoleConsumer = executionStepRoleConsumer
|
||||
)
|
||||
|
||||
func SetExecutionStepRole(step *model.ExecutionStep, role string) {
|
||||
setExecutionStepRole(step, role)
|
||||
}
|
||||
|
||||
func SetExecutionStepStatus(step *model.ExecutionStep, state model.OperationState) {
|
||||
setExecutionStepStatus(step, state)
|
||||
}
|
||||
|
||||
func ExecutionStepRole(step *model.ExecutionStep) string {
|
||||
return executionStepRole(step)
|
||||
}
|
||||
|
||||
func IsSourceExecutionStep(step *model.ExecutionStep) bool {
|
||||
return isSourceExecutionStep(step)
|
||||
}
|
||||
|
||||
func SourceStepsConfirmed(plan *model.ExecutionPlan) bool {
|
||||
return sourceStepsConfirmed(plan)
|
||||
}
|
||||
|
||||
func FindExecutionStepByTransferRef(plan *model.ExecutionPlan, transferRef string) *model.ExecutionStep {
|
||||
return findExecutionStepByTransferRef(plan, transferRef)
|
||||
}
|
||||
|
||||
func UpdateExecutionStepFromTransfer(plan *model.ExecutionPlan, event *chainv1.TransferStatusChangedEvent) *model.ExecutionStep {
|
||||
return updateExecutionStepFromTransfer(plan, event)
|
||||
}
|
||||
|
||||
func ExecutionStepStatusFromTransferStatus(status chainv1.TransferStatus) model.OperationState {
|
||||
return executionStepStatusFromTransferStatus(status)
|
||||
}
|
||||
|
||||
func SetExecutionStepMetadata(step *model.ExecutionStep, key, value string) {
|
||||
setExecutionStepMetadata(step, key, value)
|
||||
}
|
||||
|
||||
func EnsureExecutionRefs(payment *model.Payment) *model.ExecutionRefs {
|
||||
return ensureExecutionRefs(payment)
|
||||
}
|
||||
|
||||
func ExecutionQuote(
|
||||
payment *model.Payment,
|
||||
quote *orchestratorv1.PaymentQuote,
|
||||
quoteFromSnapshot func(*model.PaymentQuoteSnapshot) *orchestratorv1.PaymentQuote,
|
||||
) *orchestratorv1.PaymentQuote {
|
||||
return executionQuote(payment, quote, quoteFromSnapshot)
|
||||
}
|
||||
|
||||
func EnsureExecutionPlanForPlan(payment *model.Payment, plan *model.PaymentPlan) *model.ExecutionPlan {
|
||||
return ensureExecutionPlanForPlan(payment, plan)
|
||||
}
|
||||
|
||||
func ExecutionPlanComplete(plan *model.ExecutionPlan) bool {
|
||||
return executionPlanComplete(plan)
|
||||
}
|
||||
|
||||
func BlockStepConfirmed(plan *model.PaymentPlan, execPlan *model.ExecutionPlan) bool {
|
||||
return blockStepConfirmed(plan, execPlan)
|
||||
}
|
||||
|
||||
func RoleHintsForStep(plan *model.PaymentPlan, idx int) (*account_role.AccountRole, *account_role.AccountRole) {
|
||||
return roleHintsForStep(plan, idx)
|
||||
}
|
||||
|
||||
func LinkRailObservation(payment *model.Payment, rail model.Rail, referenceID, dependsOn string) {
|
||||
linkRailObservation(payment, rail, referenceID, dependsOn)
|
||||
}
|
||||
|
||||
func PlanStepID(step *model.PaymentStep, idx int) string {
|
||||
return planStepID(step, idx)
|
||||
}
|
||||
|
||||
func DescribePlanStep(step *model.PaymentStep) string {
|
||||
return describePlanStep(step)
|
||||
}
|
||||
|
||||
func PlanStepIdempotencyKey(payment *model.Payment, idx int, step *model.PaymentStep) string {
|
||||
return planStepIdempotencyKey(payment, idx, step)
|
||||
}
|
||||
|
||||
func FailureCodeForStep(step *model.PaymentStep) model.PaymentFailureCode {
|
||||
return failureCodeForStep(step)
|
||||
}
|
||||
|
||||
func ExecutionStepsByCode(plan *model.ExecutionPlan) map[string]*model.ExecutionStep {
|
||||
return executionStepsByCode(plan)
|
||||
}
|
||||
|
||||
func PlanStepsByID(plan *model.PaymentPlan) map[string]*model.PaymentStep {
|
||||
return planStepsByID(plan)
|
||||
}
|
||||
|
||||
func StepDependenciesReady(
|
||||
step *model.PaymentStep,
|
||||
execSteps map[string]*model.ExecutionStep,
|
||||
planSteps map[string]*model.PaymentStep,
|
||||
requireSuccess bool,
|
||||
) (ready bool, waiting bool, blocked bool, err error) {
|
||||
return stepDependenciesReady(step, execSteps, planSteps, requireSuccess)
|
||||
}
|
||||
|
||||
func CardPayoutDependenciesConfirmed(plan *model.PaymentPlan, execPlan *model.ExecutionPlan) bool {
|
||||
return cardPayoutDependenciesConfirmed(plan, execPlan)
|
||||
}
|
||||
|
||||
func AnalyzeExecutionPlan(logger mlogger.Logger, payment *model.Payment) (bool, bool, error) {
|
||||
return analyzeExecutionPlan(logger, payment)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package orchestrator
|
||||
package execution
|
||||
|
||||
import (
|
||||
"errors"
|
||||
@@ -1,4 +1,4 @@
|
||||
package orchestrator
|
||||
package execution
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -17,12 +17,16 @@ func ensureExecutionRefs(payment *model.Payment) *model.ExecutionRefs {
|
||||
return payment.Execution
|
||||
}
|
||||
|
||||
func executionQuote(payment *model.Payment, quote *orchestratorv1.PaymentQuote) *orchestratorv1.PaymentQuote {
|
||||
func executionQuote(
|
||||
payment *model.Payment,
|
||||
quote *orchestratorv1.PaymentQuote,
|
||||
quoteFromSnapshot func(*model.PaymentQuoteSnapshot) *orchestratorv1.PaymentQuote,
|
||||
) *orchestratorv1.PaymentQuote {
|
||||
if quote != nil {
|
||||
return quote
|
||||
}
|
||||
if payment != nil && payment.LastQuote != nil {
|
||||
return modelQuoteToProto(payment.LastQuote)
|
||||
if payment != nil && payment.LastQuote != nil && quoteFromSnapshot != nil {
|
||||
return quoteFromSnapshot(payment.LastQuote)
|
||||
}
|
||||
return &orchestratorv1.PaymentQuote{}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package orchestrator
|
||||
package execution
|
||||
|
||||
import (
|
||||
"strings"
|
||||
@@ -2,7 +2,6 @@ package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/payments/storage"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
@@ -12,8 +11,7 @@ import (
|
||||
|
||||
type paymentEngine interface {
|
||||
EnsureRepository(ctx context.Context) error
|
||||
BuildPaymentQuote(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.PaymentQuote, time.Time, error)
|
||||
ResolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, *orchestratorv1.PaymentIntent, error)
|
||||
ResolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, *orchestratorv1.PaymentIntent, *model.PaymentPlan, error)
|
||||
ExecutePayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, quote *orchestratorv1.PaymentQuote) error
|
||||
Repository() storage.Repository
|
||||
}
|
||||
@@ -26,11 +24,7 @@ func (e defaultPaymentEngine) EnsureRepository(ctx context.Context) error {
|
||||
return e.svc.ensureRepository(ctx)
|
||||
}
|
||||
|
||||
func (e defaultPaymentEngine) BuildPaymentQuote(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.PaymentQuote, time.Time, error) {
|
||||
return e.svc.buildPaymentQuote(ctx, orgRef, req)
|
||||
}
|
||||
|
||||
func (e defaultPaymentEngine) ResolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, *orchestratorv1.PaymentIntent, error) {
|
||||
func (e defaultPaymentEngine) ResolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, *orchestratorv1.PaymentIntent, *model.PaymentPlan, error) {
|
||||
return e.svc.resolvePaymentQuote(ctx, in)
|
||||
}
|
||||
|
||||
@@ -54,20 +48,6 @@ func newPaymentCommandFactory(engine paymentEngine, logger mlogger.Logger) *paym
|
||||
}
|
||||
}
|
||||
|
||||
func (f *paymentCommandFactory) QuotePayment() *quotePaymentCommand {
|
||||
return "ePaymentCommand{
|
||||
engine: f.engine,
|
||||
logger: f.logger.Named("quote_payment"),
|
||||
}
|
||||
}
|
||||
|
||||
func (f *paymentCommandFactory) QuotePayments() *quotePaymentsCommand {
|
||||
return "ePaymentsCommand{
|
||||
engine: f.engine,
|
||||
logger: f.logger.Named("quote_payments"),
|
||||
}
|
||||
}
|
||||
|
||||
func (f *paymentCommandFactory) InitiatePayment() *initiatePaymentCommand {
|
||||
return &initiatePaymentCommand{
|
||||
engine: f.engine,
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/execution"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/model/account_role"
|
||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||
)
|
||||
|
||||
type Liveness = execution.Liveness
|
||||
|
||||
const (
|
||||
StepFinal Liveness = execution.StepFinal
|
||||
StepRunnable Liveness = execution.StepRunnable
|
||||
StepBlocked Liveness = execution.StepBlocked
|
||||
StepDead Liveness = execution.StepDead
|
||||
|
||||
executionStepRoleSource = execution.ExecutionStepRoleSource
|
||||
executionStepRoleConsumer = execution.ExecutionStepRoleConsumer
|
||||
)
|
||||
|
||||
func setExecutionStepRole(step *model.ExecutionStep, role string) {
|
||||
execution.SetExecutionStepRole(step, role)
|
||||
}
|
||||
|
||||
func setExecutionStepStatus(step *model.ExecutionStep, state model.OperationState) {
|
||||
execution.SetExecutionStepStatus(step, state)
|
||||
}
|
||||
|
||||
func findExecutionStepByTransferRef(plan *model.ExecutionPlan, transferRef string) *model.ExecutionStep {
|
||||
return execution.FindExecutionStepByTransferRef(plan, transferRef)
|
||||
}
|
||||
|
||||
func updateExecutionStepFromTransfer(plan *model.ExecutionPlan, event *chainv1.TransferStatusChangedEvent) *model.ExecutionStep {
|
||||
return execution.UpdateExecutionStepFromTransfer(plan, event)
|
||||
}
|
||||
|
||||
func ensureExecutionRefs(payment *model.Payment) *model.ExecutionRefs {
|
||||
return execution.EnsureExecutionRefs(payment)
|
||||
}
|
||||
|
||||
func executionQuote(payment *model.Payment, quote *orchestratorv1.PaymentQuote) *orchestratorv1.PaymentQuote {
|
||||
return execution.ExecutionQuote(payment, quote, modelQuoteToProto)
|
||||
}
|
||||
|
||||
func ensureExecutionPlanForPlan(payment *model.Payment, plan *model.PaymentPlan) *model.ExecutionPlan {
|
||||
return execution.EnsureExecutionPlanForPlan(payment, plan)
|
||||
}
|
||||
|
||||
func executionPlanComplete(plan *model.ExecutionPlan) bool {
|
||||
return execution.ExecutionPlanComplete(plan)
|
||||
}
|
||||
|
||||
func blockStepConfirmed(plan *model.PaymentPlan, execPlan *model.ExecutionPlan) bool {
|
||||
return execution.BlockStepConfirmed(plan, execPlan)
|
||||
}
|
||||
|
||||
func roleHintsForStep(plan *model.PaymentPlan, idx int) (*account_role.AccountRole, *account_role.AccountRole) {
|
||||
return execution.RoleHintsForStep(plan, idx)
|
||||
}
|
||||
|
||||
func linkRailObservation(payment *model.Payment, rail model.Rail, referenceID, dependsOn string) {
|
||||
execution.LinkRailObservation(payment, rail, referenceID, dependsOn)
|
||||
}
|
||||
|
||||
func planStepID(step *model.PaymentStep, idx int) string {
|
||||
return execution.PlanStepID(step, idx)
|
||||
}
|
||||
|
||||
func describePlanStep(step *model.PaymentStep) string {
|
||||
return execution.DescribePlanStep(step)
|
||||
}
|
||||
|
||||
func planStepIdempotencyKey(payment *model.Payment, idx int, step *model.PaymentStep) string {
|
||||
return execution.PlanStepIdempotencyKey(payment, idx, step)
|
||||
}
|
||||
|
||||
func executionStepsByCode(plan *model.ExecutionPlan) map[string]*model.ExecutionStep {
|
||||
return execution.ExecutionStepsByCode(plan)
|
||||
}
|
||||
|
||||
func planStepsByID(plan *model.PaymentPlan) map[string]*model.PaymentStep {
|
||||
return execution.PlanStepsByID(plan)
|
||||
}
|
||||
|
||||
func stepDependenciesReady(
|
||||
step *model.PaymentStep,
|
||||
execSteps map[string]*model.ExecutionStep,
|
||||
planSteps map[string]*model.PaymentStep,
|
||||
requireSuccess bool,
|
||||
) (ready bool, waiting bool, blocked bool, err error) {
|
||||
return execution.StepDependenciesReady(step, execSteps, planSteps, requireSuccess)
|
||||
}
|
||||
|
||||
func cardPayoutDependenciesConfirmed(plan *model.PaymentPlan, execPlan *model.ExecutionPlan) bool {
|
||||
return execution.CardPayoutDependenciesConfirmed(plan, execPlan)
|
||||
}
|
||||
|
||||
func analyzeExecutionPlan(logger mlogger.Logger, payment *model.Payment) (bool, bool, error) {
|
||||
return execution.AnalyzeExecutionPlan(logger, payment)
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
)
|
||||
|
||||
type sendDirection int
|
||||
|
||||
const (
|
||||
sendDirectionAny sendDirection = iota
|
||||
sendDirectionOut
|
||||
sendDirectionIn
|
||||
)
|
||||
|
||||
func sendDirectionForRail(rail model.Rail) sendDirection {
|
||||
switch rail {
|
||||
case model.RailFiatOnRamp:
|
||||
return sendDirectionIn
|
||||
default:
|
||||
return sendDirectionOut
|
||||
}
|
||||
}
|
||||
|
||||
func isGatewayEligible(gw *model.GatewayInstanceDescriptor, rail model.Rail, network, currency string, action model.RailOperation, dir sendDirection, 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 !capabilityAllowsAction(gw.Capabilities, action, dir) {
|
||||
return gatewayIneligible(gw, fmt.Sprintf("capability does not allow action=%s dir=%s", action, sendDirectionLabel(dir)))
|
||||
}
|
||||
|
||||
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 *model.GatewayInstanceDescriptor, reason string) error {
|
||||
if strings.TrimSpace(reason) == "" {
|
||||
reason = "gateway instance is not eligible"
|
||||
}
|
||||
return gatewayIneligibleError{reason: fmt.Sprintf("gateway %s eligibility check error: %s", gw.InstanceID, reason)}
|
||||
}
|
||||
|
||||
func sendDirectionLabel(dir sendDirection) string {
|
||||
switch dir {
|
||||
case sendDirectionOut:
|
||||
return "out"
|
||||
case sendDirectionIn:
|
||||
return "in"
|
||||
default:
|
||||
return "any"
|
||||
}
|
||||
}
|
||||
|
||||
func capabilityAllowsAction(cap model.RailCapabilities, action model.RailOperation, dir sendDirection) bool {
|
||||
switch action {
|
||||
case model.RailOperationSend:
|
||||
switch dir {
|
||||
case sendDirectionOut:
|
||||
return cap.CanPayOut
|
||||
case sendDirectionIn:
|
||||
return cap.CanPayIn
|
||||
default:
|
||||
return cap.CanPayIn || cap.CanPayOut
|
||||
}
|
||||
case model.RailOperationFee:
|
||||
return cap.CanSendFee
|
||||
case model.RailOperationObserveConfirm:
|
||||
return cap.RequiresObserveConfirm
|
||||
case model.RailOperationBlock:
|
||||
return cap.CanBlock
|
||||
case model.RailOperationRelease:
|
||||
return cap.CanRelease
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func amountWithinLimits(gw *model.GatewayInstanceDescriptor, limits model.Limits, currency string, amount decimal.Decimal, action model.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 == model.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 == model.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)
|
||||
}
|
||||
|
||||
func parseRailValue(value string) model.Rail {
|
||||
val := strings.ToUpper(strings.TrimSpace(value))
|
||||
switch val {
|
||||
case string(model.RailCrypto):
|
||||
return model.RailCrypto
|
||||
case string(model.RailProviderSettlement):
|
||||
return model.RailProviderSettlement
|
||||
case string(model.RailLedger):
|
||||
return model.RailLedger
|
||||
case string(model.RailCardPayout):
|
||||
return model.RailCardPayout
|
||||
case string(model.RailFiatOnRamp):
|
||||
return model.RailFiatOnRamp
|
||||
default:
|
||||
return model.RailUnspecified
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,11 @@ package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/shared"
|
||||
"github.com/tech/sendico/payments/storage"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||
@@ -18,561 +15,9 @@ import (
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
"github.com/tech/sendico/pkg/mutil/mzap"
|
||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
type quotePaymentCommand struct {
|
||||
engine paymentEngine
|
||||
logger mlogger.Logger
|
||||
}
|
||||
|
||||
var (
|
||||
errIdempotencyRequired = errors.New("idempotency key is required")
|
||||
errPreviewWithIdempotency = errors.New("preview requests must not use idempotency key")
|
||||
errIdempotencyParamMismatch = errors.New("idempotency key reuse with different parameters")
|
||||
)
|
||||
|
||||
type quoteCtx struct {
|
||||
orgID string
|
||||
orgRef bson.ObjectID
|
||||
intent *orchestratorv1.PaymentIntent
|
||||
previewOnly bool
|
||||
idempotencyKey string
|
||||
hash string
|
||||
}
|
||||
|
||||
func (h *quotePaymentCommand) Execute(
|
||||
ctx context.Context,
|
||||
req *orchestratorv1.QuotePaymentRequest,
|
||||
) gsresponse.Responder[orchestratorv1.QuotePaymentResponse] {
|
||||
|
||||
if err := h.engine.EnsureRepository(ctx); err != nil {
|
||||
return gsresponse.Unavailable[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
if req == nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
|
||||
}
|
||||
|
||||
qc, err := h.prepareQuoteCtx(req)
|
||||
if err != nil {
|
||||
return h.mapQuoteErr(err)
|
||||
}
|
||||
|
||||
quotesStore, err := ensureQuotesStore(h.engine.Repository())
|
||||
if err != nil {
|
||||
return gsresponse.Unavailable[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
|
||||
quoteProto, err := h.quotePayment(ctx, quotesStore, qc, req)
|
||||
if err != nil {
|
||||
return h.mapQuoteErr(err)
|
||||
}
|
||||
|
||||
return gsresponse.Success(&orchestratorv1.QuotePaymentResponse{
|
||||
IdempotencyKey: req.GetIdempotencyKey(),
|
||||
Quote: quoteProto,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *quotePaymentCommand) prepareQuoteCtx(req *orchestratorv1.QuotePaymentRequest) (*quoteCtx, error) {
|
||||
orgRef, orgID, err := validateMetaAndOrgRef(req.GetMeta())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := requireNonNilIntent(req.GetIntent()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
intent := req.GetIntent()
|
||||
preview := req.GetPreviewOnly()
|
||||
idem := strings.TrimSpace(req.GetIdempotencyKey())
|
||||
|
||||
if preview && idem != "" {
|
||||
return nil, errPreviewWithIdempotency
|
||||
}
|
||||
if !preview && idem == "" {
|
||||
return nil, errIdempotencyRequired
|
||||
}
|
||||
|
||||
return "eCtx{
|
||||
orgID: orgRef,
|
||||
orgRef: orgID,
|
||||
intent: intent,
|
||||
previewOnly: preview,
|
||||
idempotencyKey: idem,
|
||||
hash: hashQuoteRequest(req),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *quotePaymentCommand) quotePayment(
|
||||
ctx context.Context,
|
||||
quotesStore storage.QuotesStore,
|
||||
qc *quoteCtx,
|
||||
req *orchestratorv1.QuotePaymentRequest,
|
||||
) (*orchestratorv1.PaymentQuote, error) {
|
||||
|
||||
if qc.previewOnly {
|
||||
quote, _, err := h.engine.BuildPaymentQuote(ctx, qc.orgID, req)
|
||||
if err != nil {
|
||||
h.logger.Warn("Failed to build preview payment quote", zap.Error(err), zap.String("org_ref", qc.orgID))
|
||||
return nil, err
|
||||
}
|
||||
quote.QuoteRef = bson.NewObjectID().Hex()
|
||||
return quote, nil
|
||||
}
|
||||
|
||||
existing, err := quotesStore.GetByIdempotencyKey(ctx, qc.orgRef, qc.idempotencyKey)
|
||||
if err != nil && !errors.Is(err, storage.ErrQuoteNotFound) && !errors.Is(err, merrors.ErrNoData) {
|
||||
h.logger.Warn("Failed to lookup quote by idempotency key", zap.Error(err),
|
||||
mzap.ObjRef("org_ref", qc.orgRef), zap.String("idempotency_key", qc.idempotencyKey),
|
||||
)
|
||||
return nil, err
|
||||
}
|
||||
if existing != nil {
|
||||
if existing.Hash != qc.hash {
|
||||
return nil, errIdempotencyParamMismatch
|
||||
}
|
||||
h.logger.Debug(
|
||||
"Idempotent quote reused",
|
||||
mzap.ObjRef("org_ref", qc.orgRef),
|
||||
zap.String("idempotency_key", qc.idempotencyKey),
|
||||
zap.String("quote_ref", existing.QuoteRef),
|
||||
)
|
||||
return modelQuoteToProto(existing.Quote), nil
|
||||
}
|
||||
|
||||
quote, expiresAt, err := h.engine.BuildPaymentQuote(ctx, qc.orgID, req)
|
||||
if err != nil {
|
||||
h.logger.Warn(
|
||||
"Failed to build payment quote",
|
||||
zap.Error(err),
|
||||
mzap.ObjRef("org_ref", qc.orgRef),
|
||||
zap.String("idempotency_key", qc.idempotencyKey),
|
||||
)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
quoteRef := bson.NewObjectID().Hex()
|
||||
quote.QuoteRef = quoteRef
|
||||
|
||||
record := &model.PaymentQuoteRecord{
|
||||
QuoteRef: quoteRef,
|
||||
IdempotencyKey: qc.idempotencyKey,
|
||||
Hash: qc.hash,
|
||||
Intent: intentFromProto(qc.intent),
|
||||
Quote: quoteSnapshotToModel(quote),
|
||||
ExpiresAt: expiresAt,
|
||||
}
|
||||
record.SetID(bson.NewObjectID())
|
||||
record.SetOrganizationRef(qc.orgRef)
|
||||
|
||||
if err := quotesStore.Create(ctx, record); err != nil {
|
||||
if errors.Is(err, storage.ErrDuplicateQuote) {
|
||||
existing, getErr := quotesStore.GetByIdempotencyKey(ctx, qc.orgRef, qc.idempotencyKey)
|
||||
if getErr == nil && existing != nil {
|
||||
if existing.Hash != qc.hash {
|
||||
return nil, errIdempotencyParamMismatch
|
||||
}
|
||||
return modelQuoteToProto(existing.Quote), nil
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
h.logger.Info(
|
||||
"Stored payment quote",
|
||||
zap.String("quote_ref", quoteRef),
|
||||
mzap.ObjRef("org_ref", qc.orgRef),
|
||||
zap.String("idempotency_key", qc.idempotencyKey),
|
||||
zap.String("kind", qc.intent.GetKind().String()),
|
||||
)
|
||||
|
||||
return quote, nil
|
||||
}
|
||||
|
||||
func (h *quotePaymentCommand) mapQuoteErr(err error) gsresponse.Responder[orchestratorv1.QuotePaymentResponse] {
|
||||
if errors.Is(err, errIdempotencyRequired) ||
|
||||
errors.Is(err, errPreviewWithIdempotency) ||
|
||||
errors.Is(err, errIdempotencyParamMismatch) {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
return gsresponse.Auto[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
|
||||
// TODO: temprorarary hashing function, replace with a proper solution later
|
||||
func hashQuoteRequest(req *orchestratorv1.QuotePaymentRequest) string {
|
||||
cloned := proto.Clone(req).(*orchestratorv1.QuotePaymentRequest)
|
||||
cloned.Meta = nil
|
||||
cloned.IdempotencyKey = ""
|
||||
cloned.PreviewOnly = false
|
||||
|
||||
b, err := proto.MarshalOptions{Deterministic: true}.Marshal(cloned)
|
||||
if err != nil {
|
||||
sum := sha256.Sum256([]byte("marshal_error"))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
sum := sha256.Sum256(b)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
type quotePaymentsCommand struct {
|
||||
engine paymentEngine
|
||||
logger mlogger.Logger
|
||||
}
|
||||
|
||||
var (
|
||||
errBatchIdempotencyRequired = errors.New("idempotency key is required")
|
||||
errBatchPreviewWithIdempotency = errors.New("preview requests must not use idempotency key")
|
||||
errBatchIdempotencyParamMismatch = errors.New("idempotency key reuse with different parameters")
|
||||
errBatchIdempotencyShapeMismatch = errors.New("idempotency key already used for a different quote shape")
|
||||
)
|
||||
|
||||
type quotePaymentsCtx struct {
|
||||
orgID string
|
||||
orgRef bson.ObjectID
|
||||
previewOnly bool
|
||||
idempotencyKey string
|
||||
hash string
|
||||
intentCount int
|
||||
}
|
||||
|
||||
func (h *quotePaymentsCommand) Execute(
|
||||
ctx context.Context,
|
||||
req *orchestratorv1.QuotePaymentsRequest,
|
||||
) gsresponse.Responder[orchestratorv1.QuotePaymentsResponse] {
|
||||
|
||||
if err := h.engine.EnsureRepository(ctx); err != nil {
|
||||
return gsresponse.Unavailable[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
if req == nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
|
||||
}
|
||||
|
||||
qc, intents, err := h.prepare(req)
|
||||
if err != nil {
|
||||
return h.mapErr(err)
|
||||
}
|
||||
|
||||
quotesStore, err := ensureQuotesStore(h.engine.Repository())
|
||||
if err != nil {
|
||||
return gsresponse.Unavailable[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
|
||||
if qc.previewOnly {
|
||||
quotes, expires, err := h.buildQuotes(ctx, req.GetMeta(), qc.idempotencyKey, intents, true)
|
||||
if err != nil {
|
||||
return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
aggregate, expiresAt, err := h.aggregate(quotes, expires)
|
||||
if err != nil {
|
||||
return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
_ = expiresAt
|
||||
return gsresponse.Success(&orchestratorv1.QuotePaymentsResponse{
|
||||
QuoteRef: "",
|
||||
Aggregate: aggregate,
|
||||
Quotes: quotes,
|
||||
})
|
||||
}
|
||||
|
||||
if rec, ok, err := h.tryReuse(ctx, quotesStore, qc); err != nil {
|
||||
return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
} else if ok {
|
||||
return gsresponse.Success(h.responseFromRecord(rec))
|
||||
}
|
||||
|
||||
quotes, expires, err := h.buildQuotes(ctx, req.GetMeta(), qc.idempotencyKey, intents, false)
|
||||
if err != nil {
|
||||
return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
|
||||
aggregate, expiresAt, err := h.aggregate(quotes, expires)
|
||||
if err != nil {
|
||||
return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
|
||||
quoteRef := bson.NewObjectID().Hex()
|
||||
for _, q := range quotes {
|
||||
if q != nil {
|
||||
q.QuoteRef = quoteRef
|
||||
}
|
||||
}
|
||||
|
||||
rec, err := h.storeBatch(ctx, quotesStore, qc, quoteRef, intents, quotes, expiresAt)
|
||||
if err != nil {
|
||||
return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
|
||||
if rec != nil {
|
||||
return gsresponse.Success(h.responseFromRecord(rec))
|
||||
}
|
||||
|
||||
h.logger.Info(
|
||||
"Stored payment quotes",
|
||||
h.logFields(qc, quoteRef, expiresAt, len(quotes))...,
|
||||
)
|
||||
|
||||
return gsresponse.Success(&orchestratorv1.QuotePaymentsResponse{
|
||||
IdempotencyKey: req.GetIdempotencyKey(),
|
||||
QuoteRef: quoteRef,
|
||||
Aggregate: aggregate,
|
||||
Quotes: quotes,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *quotePaymentsCommand) prepare(req *orchestratorv1.QuotePaymentsRequest) (*quotePaymentsCtx, []*orchestratorv1.PaymentIntent, error) {
|
||||
orgRefStr, orgID, err := validateMetaAndOrgRef(req.GetMeta())
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
intents := req.GetIntents()
|
||||
if len(intents) == 0 {
|
||||
return nil, nil, merrors.InvalidArgument("intents are required")
|
||||
}
|
||||
for _, intent := range intents {
|
||||
if err := requireNonNilIntent(intent); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
preview := req.GetPreviewOnly()
|
||||
idem := strings.TrimSpace(req.GetIdempotencyKey())
|
||||
|
||||
if preview && idem != "" {
|
||||
return nil, nil, errBatchPreviewWithIdempotency
|
||||
}
|
||||
if !preview && idem == "" {
|
||||
return nil, nil, errBatchIdempotencyRequired
|
||||
}
|
||||
|
||||
hash, err := hashQuotePaymentsIntents(intents)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return "ePaymentsCtx{
|
||||
orgID: orgRefStr,
|
||||
orgRef: orgID,
|
||||
previewOnly: preview,
|
||||
idempotencyKey: idem,
|
||||
hash: hash,
|
||||
intentCount: len(intents),
|
||||
}, intents, nil
|
||||
}
|
||||
|
||||
func (h *quotePaymentsCommand) tryReuse(
|
||||
ctx context.Context,
|
||||
quotesStore storage.QuotesStore,
|
||||
qc *quotePaymentsCtx,
|
||||
) (*model.PaymentQuoteRecord, bool, error) {
|
||||
|
||||
rec, err := quotesStore.GetByIdempotencyKey(ctx, qc.orgRef, qc.idempotencyKey)
|
||||
if err != nil {
|
||||
if errors.Is(err, storage.ErrQuoteNotFound) {
|
||||
return nil, false, nil
|
||||
}
|
||||
h.logger.Warn(
|
||||
"Failed to lookup payment quotes by idempotency key",
|
||||
h.logFields(qc, "", time.Time{}, 0)...,
|
||||
)
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
if len(rec.Quotes) == 0 {
|
||||
return nil, false, errBatchIdempotencyShapeMismatch
|
||||
}
|
||||
if rec.Hash != qc.hash {
|
||||
return nil, false, errBatchIdempotencyParamMismatch
|
||||
}
|
||||
|
||||
h.logger.Debug(
|
||||
"Idempotent payment quotes reused",
|
||||
h.logFields(qc, rec.QuoteRef, rec.ExpiresAt, len(rec.Quotes))...,
|
||||
)
|
||||
|
||||
return rec, true, nil
|
||||
}
|
||||
|
||||
func (h *quotePaymentsCommand) buildQuotes(
|
||||
ctx context.Context,
|
||||
meta *orchestratorv1.RequestMeta,
|
||||
baseKey string,
|
||||
intents []*orchestratorv1.PaymentIntent,
|
||||
preview bool,
|
||||
) ([]*orchestratorv1.PaymentQuote, []time.Time, error) {
|
||||
|
||||
quotes := make([]*orchestratorv1.PaymentQuote, 0, len(intents))
|
||||
expires := make([]time.Time, 0, len(intents))
|
||||
|
||||
for i, intent := range intents {
|
||||
req := &orchestratorv1.QuotePaymentRequest{
|
||||
Meta: meta,
|
||||
IdempotencyKey: perIntentIdempotencyKey(baseKey, i, len(intents)),
|
||||
Intent: intent,
|
||||
PreviewOnly: preview,
|
||||
}
|
||||
q, exp, err := h.engine.BuildPaymentQuote(ctx, meta.GetOrganizationRef(), req)
|
||||
if err != nil {
|
||||
h.logger.Warn(
|
||||
"Failed to build payment quote (batch item)",
|
||||
zap.Int("idx", i),
|
||||
zap.Error(err),
|
||||
)
|
||||
return nil, nil, err
|
||||
}
|
||||
quotes = append(quotes, q)
|
||||
expires = append(expires, exp)
|
||||
}
|
||||
|
||||
return quotes, expires, nil
|
||||
}
|
||||
|
||||
func (h *quotePaymentsCommand) aggregate(
|
||||
quotes []*orchestratorv1.PaymentQuote,
|
||||
expires []time.Time,
|
||||
) (*orchestratorv1.PaymentQuoteAggregate, time.Time, error) {
|
||||
|
||||
agg, err := aggregatePaymentQuotes(quotes)
|
||||
if err != nil {
|
||||
return nil, time.Time{}, merrors.InternalWrap(err, "quote aggregation failed")
|
||||
}
|
||||
|
||||
expiresAt, ok := minQuoteExpiry(expires)
|
||||
if !ok {
|
||||
return nil, time.Time{}, merrors.Internal("quote expiry missing")
|
||||
}
|
||||
|
||||
return agg, expiresAt, nil
|
||||
}
|
||||
|
||||
func (h *quotePaymentsCommand) storeBatch(
|
||||
ctx context.Context,
|
||||
quotesStore storage.QuotesStore,
|
||||
qc *quotePaymentsCtx,
|
||||
quoteRef string,
|
||||
intents []*orchestratorv1.PaymentIntent,
|
||||
quotes []*orchestratorv1.PaymentQuote,
|
||||
expiresAt time.Time,
|
||||
) (*model.PaymentQuoteRecord, error) {
|
||||
|
||||
record := &model.PaymentQuoteRecord{
|
||||
QuoteRef: quoteRef,
|
||||
IdempotencyKey: qc.idempotencyKey,
|
||||
Hash: qc.hash,
|
||||
Intents: intentsFromProto(intents),
|
||||
Quotes: quoteSnapshotsFromProto(quotes),
|
||||
ExpiresAt: expiresAt,
|
||||
}
|
||||
record.SetID(bson.NewObjectID())
|
||||
record.SetOrganizationRef(qc.orgRef)
|
||||
|
||||
if err := quotesStore.Create(ctx, record); err != nil {
|
||||
if errors.Is(err, storage.ErrDuplicateQuote) {
|
||||
rec, ok, reuseErr := h.tryReuse(ctx, quotesStore, qc)
|
||||
if reuseErr != nil {
|
||||
return nil, reuseErr
|
||||
}
|
||||
if ok {
|
||||
return rec, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (h *quotePaymentsCommand) responseFromRecord(rec *model.PaymentQuoteRecord) *orchestratorv1.QuotePaymentsResponse {
|
||||
quotes := modelQuotesToProto(rec.Quotes)
|
||||
for _, q := range quotes {
|
||||
if q != nil {
|
||||
q.QuoteRef = rec.QuoteRef
|
||||
}
|
||||
}
|
||||
aggregate, _ := aggregatePaymentQuotes(quotes)
|
||||
|
||||
return &orchestratorv1.QuotePaymentsResponse{
|
||||
QuoteRef: rec.QuoteRef,
|
||||
Aggregate: aggregate,
|
||||
Quotes: quotes,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *quotePaymentsCommand) logFields(qc *quotePaymentsCtx, quoteRef string, expiresAt time.Time, quoteCount int) []zap.Field {
|
||||
fields := []zap.Field{
|
||||
mzap.ObjRef("org_ref", qc.orgRef),
|
||||
zap.String("org_ref_str", qc.orgID),
|
||||
zap.String("idempotency_key", qc.idempotencyKey),
|
||||
zap.String("hash", qc.hash),
|
||||
zap.Bool("preview_only", qc.previewOnly),
|
||||
zap.Int("intent_count", qc.intentCount),
|
||||
}
|
||||
if quoteRef != "" {
|
||||
fields = append(fields, zap.String("quote_ref", quoteRef))
|
||||
}
|
||||
if !expiresAt.IsZero() {
|
||||
fields = append(fields, zap.Time("expires_at", expiresAt))
|
||||
}
|
||||
if quoteCount > 0 {
|
||||
fields = append(fields, zap.Int("quote_count", quoteCount))
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func (h *quotePaymentsCommand) mapErr(err error) gsresponse.Responder[orchestratorv1.QuotePaymentsResponse] {
|
||||
if errors.Is(err, errBatchIdempotencyRequired) ||
|
||||
errors.Is(err, errBatchPreviewWithIdempotency) ||
|
||||
errors.Is(err, errBatchIdempotencyParamMismatch) ||
|
||||
errors.Is(err, errBatchIdempotencyShapeMismatch) {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
|
||||
func modelQuotesToProto(snaps []*model.PaymentQuoteSnapshot) []*orchestratorv1.PaymentQuote {
|
||||
if len(snaps) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]*orchestratorv1.PaymentQuote, 0, len(snaps))
|
||||
for _, s := range snaps {
|
||||
out = append(out, modelQuoteToProto(s))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func hashQuotePaymentsIntents(intents []*orchestratorv1.PaymentIntent) (string, error) {
|
||||
type item struct {
|
||||
Idx int
|
||||
H [32]byte
|
||||
}
|
||||
items := make([]item, 0, len(intents))
|
||||
|
||||
for i, intent := range intents {
|
||||
b, err := proto.MarshalOptions{Deterministic: true}.Marshal(intent)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
items = append(items, item{Idx: i, H: sha256.Sum256(b)})
|
||||
}
|
||||
|
||||
sort.Slice(items, func(i, j int) bool { return items[i].Idx < items[j].Idx })
|
||||
|
||||
h := sha256.New()
|
||||
h.Write([]byte("quote-payments-fp/v1"))
|
||||
h.Write([]byte{0})
|
||||
for _, it := range items {
|
||||
h.Write(it.H[:])
|
||||
h.Write([]byte{0})
|
||||
}
|
||||
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
type initiatePaymentsCommand struct {
|
||||
engine paymentEngine
|
||||
logger mlogger.Logger
|
||||
@@ -612,15 +57,22 @@ func (h *initiatePaymentsCommand) Execute(ctx context.Context, req *orchestrator
|
||||
|
||||
intents := record.Intents
|
||||
quotes := record.Quotes
|
||||
plans := record.Plans
|
||||
if len(intents) == 0 && record.Intent.Kind != "" && record.Intent.Kind != model.PaymentKindUnspecified {
|
||||
intents = []model.PaymentIntent{record.Intent}
|
||||
}
|
||||
if len(quotes) == 0 && record.Quote != nil {
|
||||
quotes = []*model.PaymentQuoteSnapshot{record.Quote}
|
||||
}
|
||||
if len(plans) == 0 && record.Plan != nil {
|
||||
plans = []*model.PaymentPlan{record.Plan}
|
||||
}
|
||||
if len(intents) == 0 || len(quotes) == 0 || len(intents) != len(quotes) {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("stored quote payload is incomplete"))
|
||||
}
|
||||
if len(plans) > 0 && len(plans) != len(intents) {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("stored payment plans are incomplete"))
|
||||
}
|
||||
|
||||
store, err := ensurePaymentsStore(h.engine.Repository())
|
||||
if err != nil {
|
||||
@@ -639,7 +91,7 @@ func (h *initiatePaymentsCommand) Execute(ctx context.Context, req *orchestrator
|
||||
}
|
||||
quoteProto.QuoteRef = quoteRef
|
||||
|
||||
perKey := perIntentIdempotencyKey(idempotencyKey, i, len(intents))
|
||||
perKey := shared.PerIntentIdempotencyKey(idempotencyKey, i, len(intents))
|
||||
if existing, err := getPaymentByIdempotencyKey(ctx, store, orgRef, perKey); err == nil && existing != nil {
|
||||
payments = append(payments, toProtoPayment(existing))
|
||||
continue
|
||||
@@ -648,6 +100,14 @@ func (h *initiatePaymentsCommand) Execute(ctx context.Context, req *orchestrator
|
||||
}
|
||||
|
||||
entity := newPayment(orgRef, intentProto, perKey, req.GetMetadata(), quoteProto)
|
||||
var plan *model.PaymentPlan
|
||||
if i < len(plans) {
|
||||
plan = plans[i]
|
||||
}
|
||||
if plan == nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("stored payment plans are incomplete"))
|
||||
}
|
||||
attachStoredPlan(entity, plan, perKey)
|
||||
if err = store.Create(ctx, entity); err != nil {
|
||||
if errors.Is(err, storage.ErrDuplicatePayment) {
|
||||
return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.DataConflict("payment already exists"))
|
||||
@@ -733,7 +193,7 @@ func (h *initiatePaymentCommand) Execute(ctx context.Context, req *orchestratorv
|
||||
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
|
||||
quoteSnapshot, resolvedIntent, err := h.engine.ResolvePaymentQuote(ctx, quoteResolutionInput{
|
||||
quoteSnapshot, resolvedIntent, plan, err := h.engine.ResolvePaymentQuote(ctx, quoteResolutionInput{
|
||||
OrgRef: orgRef,
|
||||
OrgID: orgID,
|
||||
Meta: req.GetMeta(),
|
||||
@@ -770,6 +230,10 @@ func (h *initiatePaymentCommand) Execute(ctx context.Context, req *orchestratorv
|
||||
)
|
||||
|
||||
entity := newPayment(orgID, resolvedIntent, idempotencyKey, req.GetMetadata(), quoteSnapshot)
|
||||
if plan == nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("stored payment plan is required"))
|
||||
}
|
||||
attachStoredPlan(entity, plan, idempotencyKey)
|
||||
|
||||
if err = store.Create(ctx, entity); err != nil {
|
||||
if errors.Is(err, storage.ErrDuplicatePayment) {
|
||||
@@ -845,7 +309,7 @@ func (h *initiateConversionCommand) Execute(ctx context.Context, req *orchestrat
|
||||
if req == nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
|
||||
}
|
||||
orgRef, orgID, err := validateMetaAndOrgRef(req.GetMeta())
|
||||
_, orgID, err := validateMetaAndOrgRef(req.GetMeta())
|
||||
if err != nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
@@ -893,16 +357,28 @@ func (h *initiateConversionCommand) Execute(ctx context.Context, req *orchestrat
|
||||
SettlementCurrency: strings.TrimSpace(amount.GetCurrency()),
|
||||
}
|
||||
|
||||
quote, _, err := h.engine.BuildPaymentQuote(ctx, orgRef, &orchestratorv1.QuotePaymentRequest{
|
||||
quote, resolvedIntent, plan, err := h.engine.ResolvePaymentQuote(ctx, quoteResolutionInput{
|
||||
OrgRef: req.GetMeta().GetOrganizationRef(),
|
||||
OrgID: orgID,
|
||||
Meta: req.GetMeta(),
|
||||
IdempotencyKey: req.GetIdempotencyKey(),
|
||||
Intent: intentProto,
|
||||
IdempotencyKey: req.GetIdempotencyKey(),
|
||||
})
|
||||
if err != nil {
|
||||
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
if quote == nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("quote is required"))
|
||||
}
|
||||
if resolvedIntent == nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("intent is required"))
|
||||
}
|
||||
if plan == nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("stored payment plan is required"))
|
||||
}
|
||||
|
||||
entity := newPayment(orgID, intentProto, idempotencyKey, req.GetMetadata(), quote)
|
||||
entity := newPayment(orgID, resolvedIntent, idempotencyKey, req.GetMetadata(), quote)
|
||||
attachStoredPlan(entity, plan, idempotencyKey)
|
||||
|
||||
if err = store.Create(ctx, entity); err != nil {
|
||||
if errors.Is(err, storage.ErrDuplicatePayment) {
|
||||
|
||||
@@ -2,16 +2,13 @@ package orchestrator
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
|
||||
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
|
||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||
"google.golang.org/protobuf/proto"
|
||||
|
||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||
accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1"
|
||||
@@ -25,10 +22,8 @@ type moneyGetter interface {
|
||||
}
|
||||
|
||||
const (
|
||||
feeLineMetaTarget = "fee_target"
|
||||
feeLineTargetWallet = "wallet"
|
||||
feeLineMetaWalletRef = "fee_wallet_ref"
|
||||
feeLineMetaWalletType = "fee_wallet_type"
|
||||
feeLineMetaTarget = "fee_target"
|
||||
feeLineTargetWallet = "wallet"
|
||||
)
|
||||
|
||||
func cloneProtoMoney(input *moneyv1.Money) *moneyv1.Money {
|
||||
@@ -70,76 +65,6 @@ func cloneStringList(values []string) []string {
|
||||
return result
|
||||
}
|
||||
|
||||
func cloneFeeLines(lines []*feesv1.DerivedPostingLine) []*feesv1.DerivedPostingLine {
|
||||
if len(lines) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]*feesv1.DerivedPostingLine, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
if line == nil {
|
||||
continue
|
||||
}
|
||||
if cloned, ok := proto.Clone(line).(*feesv1.DerivedPostingLine); ok {
|
||||
out = append(out, cloned)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneFeeRules(rules []*feesv1.AppliedRule) []*feesv1.AppliedRule {
|
||||
if len(rules) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]*feesv1.AppliedRule, 0, len(rules))
|
||||
for _, rule := range rules {
|
||||
if rule == nil {
|
||||
continue
|
||||
}
|
||||
if cloned, ok := proto.Clone(rule).(*feesv1.AppliedRule); ok {
|
||||
out = append(out, cloned)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func extractFeeTotal(lines []*feesv1.DerivedPostingLine, currency string) *moneyv1.Money {
|
||||
if len(lines) == 0 || currency == "" {
|
||||
return nil
|
||||
}
|
||||
total := decimal.Zero
|
||||
for _, line := range lines {
|
||||
if line == nil || line.GetMoney() == nil {
|
||||
continue
|
||||
}
|
||||
if !strings.EqualFold(line.GetMoney().GetCurrency(), currency) {
|
||||
continue
|
||||
}
|
||||
amount, err := decimal.NewFromString(line.GetMoney().GetAmount())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
switch line.GetSide() {
|
||||
case accountingv1.EntrySide_ENTRY_SIDE_CREDIT:
|
||||
total = total.Sub(amount.Abs())
|
||||
default:
|
||||
total = total.Add(amount.Abs())
|
||||
}
|
||||
}
|
||||
if total.IsZero() {
|
||||
return nil
|
||||
}
|
||||
return &moneyv1.Money{
|
||||
Currency: currency,
|
||||
Amount: total.String(),
|
||||
}
|
||||
}
|
||||
|
||||
func resolveTradeAmounts(intentAmount *moneyv1.Money, fxQuote *oraclev1.Quote, side fxv1.Side) (*moneyv1.Money, *moneyv1.Money) {
|
||||
if fxQuote == nil {
|
||||
return cloneProtoMoney(intentAmount), cloneProtoMoney(intentAmount)
|
||||
@@ -291,43 +216,6 @@ func convertWithQuote(m *moneyv1.Money, quote *oraclev1.Quote, targetCurrency st
|
||||
}
|
||||
}
|
||||
|
||||
func quoteToProto(src *oracleclient.Quote) *oraclev1.Quote {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
return &oraclev1.Quote{
|
||||
QuoteRef: src.QuoteRef,
|
||||
Pair: src.Pair,
|
||||
Side: src.Side,
|
||||
Price: &moneyv1.Decimal{Value: src.Price},
|
||||
BaseAmount: cloneProtoMoney(src.BaseAmount),
|
||||
QuoteAmount: cloneProtoMoney(src.QuoteAmount),
|
||||
ExpiresAtUnixMs: src.ExpiresAt.UnixMilli(),
|
||||
Provider: src.Provider,
|
||||
RateRef: src.RateRef,
|
||||
Firm: src.Firm,
|
||||
}
|
||||
}
|
||||
|
||||
func setFeeLineTarget(lines []*feesv1.DerivedPostingLine, target string) {
|
||||
target = strings.TrimSpace(target)
|
||||
if target == "" || len(lines) == 0 {
|
||||
return
|
||||
}
|
||||
for _, line := range lines {
|
||||
if line == nil {
|
||||
continue
|
||||
}
|
||||
if line.Meta == nil {
|
||||
line.Meta = map[string]string{}
|
||||
}
|
||||
line.Meta[feeLineMetaTarget] = target
|
||||
if strings.EqualFold(target, feeLineTargetWallet) {
|
||||
line.LedgerAccountRef = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func feeLineTarget(line *feesv1.DerivedPostingLine) string {
|
||||
if line == nil {
|
||||
return ""
|
||||
@@ -339,26 +227,6 @@ func isWalletTargetFeeLine(line *feesv1.DerivedPostingLine) bool {
|
||||
return strings.EqualFold(feeLineTarget(line), feeLineTargetWallet)
|
||||
}
|
||||
|
||||
func setFeeLineWalletRef(lines []*feesv1.DerivedPostingLine, walletRef, walletType string) {
|
||||
walletRef = strings.TrimSpace(walletRef)
|
||||
walletType = strings.TrimSpace(walletType)
|
||||
if walletRef == "" || len(lines) == 0 {
|
||||
return
|
||||
}
|
||||
for _, line := range lines {
|
||||
if line == nil {
|
||||
continue
|
||||
}
|
||||
if line.Meta == nil {
|
||||
line.Meta = map[string]string{}
|
||||
}
|
||||
line.Meta[feeLineMetaWalletRef] = walletRef
|
||||
if walletType != "" {
|
||||
line.Meta[feeLineMetaWalletType] = walletType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func ledgerChargesFromFeeLines(lines []*feesv1.DerivedPostingLine) []*ledgerv1.PostingLine {
|
||||
if len(lines) == 0 {
|
||||
return nil
|
||||
@@ -398,39 +266,6 @@ func ledgerLineTypeFromAccounting(lineType accountingv1.PostingLineType) ledgerv
|
||||
}
|
||||
}
|
||||
|
||||
func quoteExpiry(now time.Time, feeQuote *feesv1.PrecomputeFeesResponse, fxQuote *oraclev1.Quote) time.Time {
|
||||
expiry := time.Time{}
|
||||
if feeQuote != nil && feeQuote.GetExpiresAt() != nil {
|
||||
expiry = feeQuote.GetExpiresAt().AsTime()
|
||||
}
|
||||
if expiry.IsZero() {
|
||||
expiry = now.Add(time.Duration(defaultFeeQuoteTTLMillis) * time.Millisecond)
|
||||
}
|
||||
if fxQuote != nil && fxQuote.GetExpiresAtUnixMs() > 0 {
|
||||
fxExpiry := time.UnixMilli(fxQuote.GetExpiresAtUnixMs()).UTC()
|
||||
if fxExpiry.Before(expiry) {
|
||||
expiry = fxExpiry
|
||||
}
|
||||
}
|
||||
return expiry
|
||||
}
|
||||
|
||||
func assignLedgerAccounts(lines []*feesv1.DerivedPostingLine, account string) []*feesv1.DerivedPostingLine {
|
||||
if account == "" || len(lines) == 0 {
|
||||
return lines
|
||||
}
|
||||
for _, line := range lines {
|
||||
if line == nil || isWalletTargetFeeLine(line) {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(line.GetLedgerAccountRef()) != "" {
|
||||
continue
|
||||
}
|
||||
line.LedgerAccountRef = account
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func moneyEquals(a, b moneyGetter) bool {
|
||||
if a == nil || b == nil {
|
||||
return false
|
||||
|
||||
@@ -2,14 +2,10 @@ package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||
)
|
||||
@@ -21,13 +17,6 @@ func (s *Service) ensureRepository(ctx context.Context) error {
|
||||
return s.storage.Ping(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) withTimeout(ctx context.Context, d time.Duration) (context.Context, context.CancelFunc) {
|
||||
if d <= 0 {
|
||||
return context.WithCancel(ctx)
|
||||
}
|
||||
return context.WithTimeout(ctx, d)
|
||||
}
|
||||
|
||||
func executeUnary[TReq any, TResp any](ctx context.Context, svc *Service, method string, handler func(context.Context, *TReq) gsresponse.Responder[TResp], req *TReq) (*TResp, error) {
|
||||
start := svc.clock.Now()
|
||||
resp, err := gsresponse.Unary(svc.logger, mservice.PaymentOrchestrator, handler)(ctx, req)
|
||||
@@ -35,22 +24,6 @@ func executeUnary[TReq any, TResp any](ctx context.Context, svc *Service, method
|
||||
return resp, err
|
||||
}
|
||||
|
||||
func triggerFromKind(kind orchestratorv1.PaymentKind, requiresFX bool) feesv1.Trigger {
|
||||
switch kind {
|
||||
case orchestratorv1.PaymentKind_PAYMENT_KIND_PAYOUT:
|
||||
return feesv1.Trigger_TRIGGER_PAYOUT
|
||||
case orchestratorv1.PaymentKind_PAYMENT_KIND_INTERNAL_TRANSFER:
|
||||
return feesv1.Trigger_TRIGGER_CAPTURE
|
||||
case orchestratorv1.PaymentKind_PAYMENT_KIND_FX_CONVERSION:
|
||||
return feesv1.Trigger_TRIGGER_FX_CONVERSION
|
||||
default:
|
||||
if requiresFX {
|
||||
return feesv1.Trigger_TRIGGER_FX_CONVERSION
|
||||
}
|
||||
return feesv1.Trigger_TRIGGER_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
func shouldEstimateNetworkFee(intent *orchestratorv1.PaymentIntent) bool {
|
||||
if intent == nil {
|
||||
return false
|
||||
@@ -71,43 +44,6 @@ func shouldEstimateNetworkFee(intent *orchestratorv1.PaymentIntent) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func shouldRequestFX(intent *orchestratorv1.PaymentIntent) bool {
|
||||
if intent == nil {
|
||||
return false
|
||||
}
|
||||
if fxIntentForQuote(intent) != nil {
|
||||
return true
|
||||
}
|
||||
return intent.GetRequiresFx()
|
||||
}
|
||||
|
||||
func fxIntentForQuote(intent *orchestratorv1.PaymentIntent) *orchestratorv1.FXIntent {
|
||||
if intent == nil {
|
||||
return nil
|
||||
}
|
||||
if fx := intent.GetFx(); fx != nil && fx.GetPair() != nil {
|
||||
return fx
|
||||
}
|
||||
amount := intent.GetAmount()
|
||||
if amount == nil {
|
||||
return nil
|
||||
}
|
||||
settlementCurrency := strings.TrimSpace(intent.GetSettlementCurrency())
|
||||
if settlementCurrency == "" {
|
||||
return nil
|
||||
}
|
||||
if strings.EqualFold(amount.GetCurrency(), settlementCurrency) {
|
||||
return nil
|
||||
}
|
||||
return &orchestratorv1.FXIntent{
|
||||
Pair: &fxv1.CurrencyPair{
|
||||
Base: strings.TrimSpace(amount.GetCurrency()),
|
||||
Quote: settlementCurrency,
|
||||
},
|
||||
Side: fxv1.Side_SELL_BASE_BUY_QUOTE,
|
||||
}
|
||||
}
|
||||
|
||||
func mapMntxStatusToState(status mntxv1.PayoutStatus) model.PaymentState {
|
||||
switch status {
|
||||
|
||||
|
||||
@@ -7,10 +7,10 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
||||
chainclient "github.com/tech/sendico/gateway/chain/client"
|
||||
mntxclient "github.com/tech/sendico/gateway/mntx/client"
|
||||
ledgerclient "github.com/tech/sendico/ledger/client"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/plan_builder"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
clockpkg "github.com/tech/sendico/pkg/clock"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/payments/rail"
|
||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||
quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
@@ -34,21 +35,25 @@ type ChainGatewayResolver interface {
|
||||
Resolve(ctx context.Context, network string) (chainclient.Client, error)
|
||||
}
|
||||
|
||||
type feesDependency struct {
|
||||
client feesv1.FeeEngineClient
|
||||
timeout time.Duration
|
||||
type quotationDependency struct {
|
||||
client quotationv1.QuotationServiceClient
|
||||
}
|
||||
|
||||
func (f feesDependency) available() bool {
|
||||
if f.client == nil {
|
||||
func (q quotationDependency) available() bool {
|
||||
if q.client == nil {
|
||||
return false
|
||||
}
|
||||
if checker, ok := f.client.(interface{ Available() bool }); ok {
|
||||
if checker, ok := q.client.(interface{ Available() bool }); ok {
|
||||
return checker.Available()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type feesDependency struct {
|
||||
client feesv1.FeeEngineClient
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
type ledgerDependency struct {
|
||||
client ledgerclient.Client
|
||||
internal rail.InternalLedger
|
||||
@@ -200,20 +205,6 @@ func (g railGatewayDependency) resolveDynamic(ctx context.Context, step *model.P
|
||||
}
|
||||
}
|
||||
|
||||
type oracleDependency struct {
|
||||
client oracleclient.Client
|
||||
}
|
||||
|
||||
func (o oracleDependency) available() bool {
|
||||
if o.client == nil {
|
||||
return false
|
||||
}
|
||||
if checker, ok := o.client.(interface{ Available() bool }); ok {
|
||||
return checker.Available()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type mntxDependency struct {
|
||||
client mntxclient.Client
|
||||
}
|
||||
@@ -268,6 +259,13 @@ func WithPaymentGatewayBroker(broker mb.Broker) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// WithQuotationService wires the quotation gRPC client.
|
||||
func WithQuotationService(client quotationv1.QuotationServiceClient) Option {
|
||||
return func(s *Service) {
|
||||
s.deps.quotation = quotationDependency{client: client}
|
||||
}
|
||||
}
|
||||
|
||||
// WithLedgerClient wires the ledger client.
|
||||
func WithLedgerClient(client ledgerclient.Client) Option {
|
||||
return func(s *Service) {
|
||||
@@ -332,13 +330,6 @@ func WithRailGateways(gateways map[string]rail.RailGateway) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// WithOracleClient wires the FX oracle client.
|
||||
func WithOracleClient(client oracleclient.Client) Option {
|
||||
return func(s *Service) {
|
||||
s.deps.oracle = oracleDependency{client: client}
|
||||
}
|
||||
}
|
||||
|
||||
// WithMntxGateway wires the Monetix gateway client.
|
||||
func WithMntxGateway(client mntxclient.Client) Option {
|
||||
return func(s *Service) {
|
||||
@@ -377,17 +368,8 @@ func WithFeeLedgerAccounts(routes map[string]string) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// WithPlanBuilder wires a payment plan builder implementation.
|
||||
func WithPlanBuilder(builder PlanBuilder) Option {
|
||||
return func(s *Service) {
|
||||
if builder != nil {
|
||||
s.deps.planBuilder = builder
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithGatewayRegistry wires a registry of gateway instances for routing.
|
||||
func WithGatewayRegistry(registry GatewayRegistry) Option {
|
||||
func WithGatewayRegistry(registry plan_builder.GatewayRegistry) Option {
|
||||
return func(s *Service) {
|
||||
if registry != nil {
|
||||
s.deps.gatewayRegistry = registry
|
||||
@@ -395,9 +377,6 @@ func WithGatewayRegistry(registry GatewayRegistry) Option {
|
||||
s.deps.railGateways.chainResolver = s.deps.gatewayInvokeResolver
|
||||
s.deps.railGateways.providerResolver = s.deps.gatewayInvokeResolver
|
||||
s.deps.railGateways.logger = s.logger.Named("rail_gateways")
|
||||
if s.deps.planBuilder == nil {
|
||||
s.deps.planBuilder = newDefaultPlanBuilder(s.logger)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,86 +29,22 @@ func (p *paymentExecutor) executePayment(ctx context.Context, store storage.Paym
|
||||
if store == nil {
|
||||
return errStorageUnavailable
|
||||
}
|
||||
if p.svc == nil {
|
||||
return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, "service_unavailable", errStorageUnavailable)
|
||||
if payment == nil {
|
||||
return merrors.InvalidArgument("payment is required")
|
||||
}
|
||||
if p.svc.storage == nil {
|
||||
return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, "routes_store_unavailable", errStorageUnavailable)
|
||||
if payment.PaymentPlan == nil || len(payment.PaymentPlan.Steps) == 0 {
|
||||
return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, "payment_plan_missing", merrors.InvalidArgument("payment plan is required"))
|
||||
}
|
||||
routeStore := p.svc.storage.Routes()
|
||||
if routeStore == nil {
|
||||
return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, "routes_store_unavailable", errStorageUnavailable)
|
||||
if strings.TrimSpace(payment.PaymentPlan.ID) == "" {
|
||||
payment.PaymentPlan.ID = payment.PaymentRef
|
||||
}
|
||||
planTemplates := p.svc.storage.PlanTemplates()
|
||||
if planTemplates == nil {
|
||||
return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, "plan_templates_store_unavailable", errStorageUnavailable)
|
||||
if strings.TrimSpace(payment.PaymentPlan.IdempotencyKey) == "" {
|
||||
payment.PaymentPlan.IdempotencyKey = payment.IdempotencyKey
|
||||
}
|
||||
builder := p.svc.deps.planBuilder
|
||||
if builder == nil {
|
||||
builder = newDefaultPlanBuilder(p.logger)
|
||||
}
|
||||
plan, err := builder.Build(ctx, payment, quote, routeStore, planTemplates, p.svc.deps.gatewayRegistry)
|
||||
if err != nil {
|
||||
p.logPlanBuilderFailure(payment, err)
|
||||
return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, strings.TrimSpace(err.Error()), err)
|
||||
}
|
||||
if plan == nil || len(plan.Steps) == 0 {
|
||||
return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, "payment_plan_empty", merrors.InvalidArgument("payment plan is required"))
|
||||
}
|
||||
payment.PaymentPlan = plan
|
||||
|
||||
return p.executePaymentPlan(ctx, store, payment, quote)
|
||||
}
|
||||
|
||||
func (p *paymentExecutor) logPlanBuilderFailure(payment *model.Payment, err error) {
|
||||
if p == nil || payment == nil {
|
||||
return
|
||||
}
|
||||
intent := payment.Intent
|
||||
sourceRail, sourceNetwork, sourceErr := railFromEndpoint(intent.Source, intent.Attributes, true)
|
||||
destRail, destNetwork, destErr := railFromEndpoint(intent.Destination, intent.Attributes, false)
|
||||
|
||||
fields := []zap.Field{
|
||||
zap.Error(err),
|
||||
zap.String("payment_ref", payment.PaymentRef),
|
||||
zap.String("org_ref", payment.OrganizationRef.Hex()),
|
||||
zap.String("idempotency_key", payment.IdempotencyKey),
|
||||
zap.String("source_rail", string(sourceRail)),
|
||||
zap.String("destination_rail", string(destRail)),
|
||||
zap.String("source_network", sourceNetwork),
|
||||
zap.String("destination_network", destNetwork),
|
||||
zap.String("source_endpoint_type", string(intent.Source.Type)),
|
||||
zap.String("destination_endpoint_type", string(intent.Destination.Type)),
|
||||
}
|
||||
|
||||
missing := make([]string, 0, 2)
|
||||
if sourceErr != nil || sourceRail == model.RailUnspecified {
|
||||
missing = append(missing, "source")
|
||||
if sourceErr != nil {
|
||||
fields = append(fields, zap.String("source_rail_error", sourceErr.Error()))
|
||||
}
|
||||
}
|
||||
if destErr != nil || destRail == model.RailUnspecified {
|
||||
missing = append(missing, "destination")
|
||||
if destErr != nil {
|
||||
fields = append(fields, zap.String("destination_rail_error", destErr.Error()))
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
fields = append(fields, zap.String("missing_rails", strings.Join(missing, ",")))
|
||||
p.logger.Warn("Payment rail resolution failed", fields...)
|
||||
return
|
||||
}
|
||||
|
||||
routeNetwork, routeErr := resolveRouteNetwork(intent.Attributes, sourceNetwork, destNetwork)
|
||||
if routeErr != nil {
|
||||
fields = append(fields, zap.String("route_network_error", routeErr.Error()))
|
||||
} else if routeNetwork != "" {
|
||||
fields = append(fields, zap.String("route_network", routeNetwork))
|
||||
}
|
||||
p.logger.Warn("Payment route missing for rails", fields...)
|
||||
}
|
||||
|
||||
func (p *paymentExecutor) applyFX(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote, charges []*ledgerv1.PostingLine, description string, metadata map[string]string, exec *model.ExecutionRefs) error {
|
||||
intent := payment.Intent
|
||||
source := intent.Source.Ledger
|
||||
@@ -169,7 +105,7 @@ func (p *paymentExecutor) failPayment(ctx context.Context, store storage.Payment
|
||||
payment.FailureReason = strings.TrimSpace(reason)
|
||||
if store != nil {
|
||||
if updateErr := store.Update(ctx, payment); updateErr != nil {
|
||||
p.logger.Error("failed to persist payment failure", zap.Error(updateErr), zap.String("payment_ref", payment.PaymentRef))
|
||||
p.logger.Warn("Failed to persist payment failure", zap.Error(updateErr), zap.String("payment_ref", payment.PaymentRef))
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
|
||||
@@ -29,14 +29,6 @@ func isPlanComplete(payment *model.Payment) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isStepFinal(step *model.ExecutionStep) bool {
|
||||
if (step.State == model.OperationStateFailed) || (step.State == model.OperationStateSuccess) || (step.State == model.OperationStateCancelled) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (p *paymentExecutor) pickIndependentSteps(
|
||||
ctx context.Context,
|
||||
l *zap.Logger,
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/model/account_role"
|
||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||
)
|
||||
|
||||
func ensureExecutionRefs(payment *model.Payment) *model.ExecutionRefs {
|
||||
if payment.Execution == nil {
|
||||
payment.Execution = &model.ExecutionRefs{}
|
||||
}
|
||||
return payment.Execution
|
||||
}
|
||||
|
||||
func executionQuote(payment *model.Payment, quote *orchestratorv1.PaymentQuote) *orchestratorv1.PaymentQuote {
|
||||
if quote != nil {
|
||||
return quote
|
||||
}
|
||||
if payment != nil && payment.LastQuote != nil {
|
||||
return modelQuoteToProto(payment.LastQuote)
|
||||
}
|
||||
return &orchestratorv1.PaymentQuote{}
|
||||
}
|
||||
|
||||
func ensureExecutionPlanForPlan(
|
||||
payment *model.Payment,
|
||||
plan *model.PaymentPlan,
|
||||
) *model.ExecutionPlan {
|
||||
|
||||
if payment.ExecutionPlan != nil {
|
||||
return payment.ExecutionPlan
|
||||
}
|
||||
|
||||
exec := &model.ExecutionPlan{
|
||||
Steps: make([]*model.ExecutionStep, 0, len(plan.Steps)),
|
||||
}
|
||||
|
||||
for _, step := range plan.Steps {
|
||||
if step == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
exec.Steps = append(exec.Steps, &model.ExecutionStep{
|
||||
Code: step.StepID,
|
||||
State: model.OperationStatePlanned,
|
||||
OperationRef: uuid.New().String(),
|
||||
})
|
||||
}
|
||||
|
||||
return exec
|
||||
}
|
||||
|
||||
func executionPlanComplete(plan *model.ExecutionPlan) bool {
|
||||
if plan == nil || len(plan.Steps) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, step := range plan.Steps {
|
||||
if step == nil {
|
||||
continue
|
||||
}
|
||||
if step.State == model.OperationStateSkipped {
|
||||
continue
|
||||
}
|
||||
if step.State != model.OperationStateSuccess {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func blockStepConfirmed(plan *model.PaymentPlan, execPlan *model.ExecutionPlan) bool {
|
||||
if plan == nil || execPlan == nil || len(plan.Steps) == 0 {
|
||||
return false
|
||||
}
|
||||
execSteps := executionStepsByCode(execPlan)
|
||||
for idx, step := range plan.Steps {
|
||||
if step == nil || step.Action != model.RailOperationBlock {
|
||||
continue
|
||||
}
|
||||
execStep := execSteps[planStepID(step, idx)]
|
||||
if execStep == nil {
|
||||
continue
|
||||
}
|
||||
if execStep.State == model.OperationStateSuccess {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func roleHintsForStep(plan *model.PaymentPlan, idx int) (*account_role.AccountRole, *account_role.AccountRole) {
|
||||
if plan == nil || idx <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
for i := idx - 1; i >= 0; i-- {
|
||||
step := plan.Steps[i]
|
||||
if step == nil {
|
||||
continue
|
||||
}
|
||||
if step.Rail != model.RailLedger || step.Action != model.RailOperationMove {
|
||||
continue
|
||||
}
|
||||
if step.ToRole != nil && strings.TrimSpace(string(*step.ToRole)) != "" {
|
||||
role := *step.ToRole
|
||||
return &role, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func linkRailObservation(payment *model.Payment, rail model.Rail, referenceID, dependsOn string) {
|
||||
if payment == nil || payment.PaymentPlan == nil {
|
||||
return
|
||||
}
|
||||
ref := strings.TrimSpace(referenceID)
|
||||
if ref == "" {
|
||||
return
|
||||
}
|
||||
plan := payment.PaymentPlan
|
||||
execPlan := ensureExecutionPlanForPlan(payment, plan)
|
||||
if execPlan == nil {
|
||||
return
|
||||
}
|
||||
dep := strings.TrimSpace(dependsOn)
|
||||
for idx, planStep := range plan.Steps {
|
||||
if planStep == nil {
|
||||
continue
|
||||
}
|
||||
if planStep.Rail != rail || planStep.Action != model.RailOperationObserveConfirm {
|
||||
continue
|
||||
}
|
||||
if dep != "" {
|
||||
matched := false
|
||||
for _, entry := range planStep.DependsOn {
|
||||
if strings.EqualFold(strings.TrimSpace(entry), dep) {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if idx >= len(execPlan.Steps) {
|
||||
continue
|
||||
}
|
||||
execStep := execPlan.Steps[idx]
|
||||
if execStep == nil {
|
||||
execStep = &model.ExecutionStep{Code: planStepID(planStep, idx), Description: describePlanStep(planStep)}
|
||||
execPlan.Steps[idx] = execStep
|
||||
}
|
||||
if execStep.TransferRef == "" {
|
||||
execStep.TransferRef = ref
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func planStepID(step *model.PaymentStep, idx int) string {
|
||||
if step != nil {
|
||||
if val := strings.TrimSpace(step.StepID); val != "" {
|
||||
return val
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("plan_step_%d", idx)
|
||||
}
|
||||
|
||||
func describePlanStep(step *model.PaymentStep) string {
|
||||
if step == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(fmt.Sprintf("%s %s", step.Rail, step.Action))
|
||||
}
|
||||
|
||||
func planStepIdempotencyKey(payment *model.Payment, idx int, step *model.PaymentStep) string {
|
||||
base := ""
|
||||
if payment != nil {
|
||||
base = strings.TrimSpace(payment.IdempotencyKey)
|
||||
if base == "" {
|
||||
base = strings.TrimSpace(payment.PaymentRef)
|
||||
}
|
||||
}
|
||||
if base == "" {
|
||||
base = "payment"
|
||||
}
|
||||
if step == nil {
|
||||
return fmt.Sprintf("%s:plan:%d", base, idx)
|
||||
}
|
||||
stepID := strings.TrimSpace(step.StepID)
|
||||
if stepID == "" {
|
||||
stepID = fmt.Sprintf("%d", idx)
|
||||
}
|
||||
return fmt.Sprintf("%s:plan:%s:%s:%s", base, stepID, strings.ToLower(string(step.Rail)), strings.ToLower(string(step.Action)))
|
||||
}
|
||||
|
||||
func failureCodeForStep(step *model.PaymentStep) model.PaymentFailureCode {
|
||||
if step == nil {
|
||||
return model.PaymentFailureCodePolicy
|
||||
}
|
||||
switch step.Rail {
|
||||
case model.RailLedger:
|
||||
if step.Action == model.RailOperationFXConvert {
|
||||
return model.PaymentFailureCodeFX
|
||||
}
|
||||
return model.PaymentFailureCodeLedger
|
||||
case model.RailCrypto:
|
||||
return model.PaymentFailureCodeChain
|
||||
default:
|
||||
return model.PaymentFailureCodePolicy
|
||||
}
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
)
|
||||
|
||||
func executionStepsByCode(plan *model.ExecutionPlan) map[string]*model.ExecutionStep {
|
||||
result := map[string]*model.ExecutionStep{}
|
||||
if plan == nil {
|
||||
return result
|
||||
}
|
||||
for _, step := range plan.Steps {
|
||||
if step == nil {
|
||||
continue
|
||||
}
|
||||
if code := strings.TrimSpace(step.Code); code != "" {
|
||||
result[code] = step
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func planStepsByID(plan *model.PaymentPlan) map[string]*model.PaymentStep {
|
||||
result := map[string]*model.PaymentStep{}
|
||||
if plan == nil {
|
||||
return result
|
||||
}
|
||||
for idx, step := range plan.Steps {
|
||||
if step == nil {
|
||||
continue
|
||||
}
|
||||
id := planStepID(step, idx)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
result[id] = step
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func stepDependenciesReady(
|
||||
step *model.PaymentStep,
|
||||
execSteps map[string]*model.ExecutionStep,
|
||||
planSteps map[string]*model.PaymentStep,
|
||||
requireSuccess bool,
|
||||
) (ready bool, waiting bool, blocked bool, err error) {
|
||||
|
||||
if step == nil {
|
||||
return false, false, false,
|
||||
merrors.InvalidArgument("payment plan: step is required")
|
||||
}
|
||||
|
||||
for _, dep := range step.DependsOn {
|
||||
key := strings.TrimSpace(dep)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
execStep := execSteps[key]
|
||||
if execStep == nil {
|
||||
// step has not been started
|
||||
return false, true, false, nil
|
||||
}
|
||||
|
||||
if execStep.State == model.OperationStateFailed ||
|
||||
execStep.State == model.OperationStateCancelled {
|
||||
// dependency dead, step is impossible
|
||||
return false, false, true, nil
|
||||
}
|
||||
|
||||
if !execStep.ReadyForNext() {
|
||||
// step is processed
|
||||
return false, true, false, nil
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Commit policies
|
||||
// ------------------------------------------------------------
|
||||
switch step.CommitPolicy {
|
||||
|
||||
case model.CommitPolicyImmediate, model.CommitPolicyUnspecified:
|
||||
return true, false, false, nil
|
||||
|
||||
case model.CommitPolicyAfterSuccess:
|
||||
commitAfter := step.CommitAfter
|
||||
if len(commitAfter) == 0 {
|
||||
commitAfter = step.DependsOn
|
||||
}
|
||||
|
||||
for _, dep := range commitAfter {
|
||||
key := strings.TrimSpace(dep)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
execStep := execSteps[key]
|
||||
if execStep == nil {
|
||||
return false, true, false,
|
||||
merrors.InvalidArgument("commit dependency missing")
|
||||
}
|
||||
|
||||
if execStep.State == model.OperationStateFailed ||
|
||||
execStep.State == model.OperationStateCancelled {
|
||||
return false, false, true, nil
|
||||
}
|
||||
|
||||
if !execStep.IsSuccess() {
|
||||
return false, true, false, nil
|
||||
}
|
||||
}
|
||||
|
||||
return true, false, false, nil
|
||||
|
||||
case model.CommitPolicyAfterFailure:
|
||||
commitAfter := step.CommitAfter
|
||||
if len(commitAfter) == 0 {
|
||||
commitAfter = step.DependsOn
|
||||
}
|
||||
|
||||
for _, dep := range commitAfter {
|
||||
key := strings.TrimSpace(dep)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
execStep := execSteps[key]
|
||||
if execStep == nil {
|
||||
return false, true, false,
|
||||
merrors.InvalidArgument("commit dependency missing")
|
||||
}
|
||||
|
||||
if execStep.State == model.OperationStateFailed {
|
||||
continue
|
||||
}
|
||||
|
||||
if execStep.IsTerminal() {
|
||||
// complete with fail, block
|
||||
return false, false, true, nil
|
||||
}
|
||||
|
||||
// still exexuting, wait
|
||||
return false, true, false, nil
|
||||
}
|
||||
|
||||
return true, false, false, nil
|
||||
|
||||
case model.CommitPolicyAfterCanceled:
|
||||
commitAfter := step.CommitAfter
|
||||
if len(commitAfter) == 0 {
|
||||
commitAfter = step.DependsOn
|
||||
}
|
||||
|
||||
for _, dep := range commitAfter {
|
||||
key := strings.TrimSpace(dep)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
execStep := execSteps[key]
|
||||
if execStep == nil {
|
||||
return false, true, false,
|
||||
merrors.InvalidArgument("commit dependency missing")
|
||||
}
|
||||
|
||||
if !execStep.IsTerminal() {
|
||||
return false, true, false, nil
|
||||
}
|
||||
}
|
||||
|
||||
return true, false, false, nil
|
||||
|
||||
default:
|
||||
return true, false, false, nil
|
||||
}
|
||||
}
|
||||
|
||||
func cardPayoutDependenciesConfirmed(
|
||||
plan *model.PaymentPlan,
|
||||
execPlan *model.ExecutionPlan,
|
||||
) bool {
|
||||
|
||||
if execPlan == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if plan == nil || len(plan.Steps) == 0 {
|
||||
return sourceStepsConfirmed(execPlan)
|
||||
}
|
||||
|
||||
execSteps := executionStepsByCode(execPlan)
|
||||
planSteps := planStepsByID(plan)
|
||||
|
||||
for _, step := range plan.Steps {
|
||||
if step == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if step.Rail != model.RailCardPayout ||
|
||||
step.Action != model.RailOperationSend {
|
||||
continue
|
||||
}
|
||||
|
||||
ready, waiting, blocked, err :=
|
||||
stepDependenciesReady(step, execSteps, planSteps, true)
|
||||
|
||||
if err != nil || blocked {
|
||||
// payout definitely cannot run
|
||||
return false
|
||||
}
|
||||
|
||||
if waiting {
|
||||
// dependencies exist but are not finished yet
|
||||
// payout must NOT run
|
||||
return false
|
||||
}
|
||||
|
||||
// only true when dependencies are REALLY satisfied
|
||||
return ready
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -2,8 +2,6 @@ package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
@@ -141,23 +139,6 @@ func (p *paymentExecutor) executePlanStep(
|
||||
}
|
||||
}
|
||||
|
||||
func sub(a, b string) (string, error) {
|
||||
ra, ok := new(big.Rat).SetString(a)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("invalid number: %s", a)
|
||||
}
|
||||
|
||||
rb, ok := new(big.Rat).SetString(b)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("invalid number: %s", b)
|
||||
}
|
||||
|
||||
ra.Sub(ra, rb)
|
||||
|
||||
// 2 знака после запятой (как у тебя)
|
||||
return ra.FloatString(2), nil
|
||||
}
|
||||
|
||||
func (p *paymentExecutor) executeSendStep(
|
||||
ctx context.Context,
|
||||
payment *model.Payment,
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
)
|
||||
|
||||
func attachStoredPlan(payment *model.Payment, plan *model.PaymentPlan, idempotencyKey string) {
|
||||
if payment == nil || plan == nil {
|
||||
return
|
||||
}
|
||||
cloned := cloneStoredPaymentPlan(plan)
|
||||
if cloned == nil {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(cloned.ID) == "" {
|
||||
cloned.ID = strings.TrimSpace(payment.PaymentRef)
|
||||
}
|
||||
if strings.TrimSpace(cloned.IdempotencyKey) == "" {
|
||||
cloned.IdempotencyKey = strings.TrimSpace(idempotencyKey)
|
||||
}
|
||||
payment.PaymentPlan = cloned
|
||||
}
|
||||
|
||||
func cloneStoredPaymentPlan(src *model.PaymentPlan) *model.PaymentPlan {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
clone := &model.PaymentPlan{
|
||||
ID: strings.TrimSpace(src.ID),
|
||||
IdempotencyKey: strings.TrimSpace(src.IdempotencyKey),
|
||||
CreatedAt: src.CreatedAt,
|
||||
FXQuote: cloneStoredFXQuote(src.FXQuote),
|
||||
Fees: cloneStoredFeeLines(src.Fees),
|
||||
}
|
||||
if len(src.Steps) > 0 {
|
||||
clone.Steps = make([]*model.PaymentStep, 0, len(src.Steps))
|
||||
for _, step := range src.Steps {
|
||||
if step == nil {
|
||||
clone.Steps = append(clone.Steps, nil)
|
||||
continue
|
||||
}
|
||||
stepClone := &model.PaymentStep{
|
||||
StepID: strings.TrimSpace(step.StepID),
|
||||
Rail: step.Rail,
|
||||
GatewayID: strings.TrimSpace(step.GatewayID),
|
||||
InstanceID: strings.TrimSpace(step.InstanceID),
|
||||
Action: step.Action,
|
||||
DependsOn: cloneStringList(step.DependsOn),
|
||||
CommitPolicy: step.CommitPolicy,
|
||||
CommitAfter: cloneStringList(step.CommitAfter),
|
||||
Amount: cloneMoney(step.Amount),
|
||||
FromRole: cloneAccountRole(step.FromRole),
|
||||
ToRole: cloneAccountRole(step.ToRole),
|
||||
}
|
||||
clone.Steps = append(clone.Steps, stepClone)
|
||||
}
|
||||
}
|
||||
return clone
|
||||
}
|
||||
|
||||
func cloneStoredFXQuote(src *paymenttypes.FXQuote) *paymenttypes.FXQuote {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
result := &paymenttypes.FXQuote{
|
||||
QuoteRef: strings.TrimSpace(src.QuoteRef),
|
||||
Side: src.Side,
|
||||
ExpiresAtUnixMs: src.ExpiresAtUnixMs,
|
||||
Provider: strings.TrimSpace(src.Provider),
|
||||
RateRef: strings.TrimSpace(src.RateRef),
|
||||
Firm: src.Firm,
|
||||
BaseAmount: cloneMoney(src.BaseAmount),
|
||||
QuoteAmount: cloneMoney(src.QuoteAmount),
|
||||
}
|
||||
if src.Pair != nil {
|
||||
result.Pair = &paymenttypes.CurrencyPair{
|
||||
Base: strings.TrimSpace(src.Pair.Base),
|
||||
Quote: strings.TrimSpace(src.Pair.Quote),
|
||||
}
|
||||
}
|
||||
if src.Price != nil {
|
||||
result.Price = &paymenttypes.Decimal{Value: strings.TrimSpace(src.Price.Value)}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func cloneStoredFeeLines(lines []*paymenttypes.FeeLine) []*paymenttypes.FeeLine {
|
||||
if len(lines) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]*paymenttypes.FeeLine, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
if line == nil {
|
||||
result = append(result, nil)
|
||||
continue
|
||||
}
|
||||
result = append(result, &paymenttypes.FeeLine{
|
||||
LedgerAccountRef: strings.TrimSpace(line.LedgerAccountRef),
|
||||
Money: cloneMoney(line.Money),
|
||||
LineType: line.LineType,
|
||||
Side: line.Side,
|
||||
Meta: cloneMetadata(line.Meta),
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -1,28 +1,7 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
import "github.com/tech/sendico/payments/orchestrator/internal/service/plan_builder"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||
)
|
||||
|
||||
// RouteStore exposes routing definitions for plan construction.
|
||||
type RouteStore interface {
|
||||
List(ctx context.Context, filter *model.PaymentRouteFilter) (*model.PaymentRouteList, error)
|
||||
}
|
||||
|
||||
// PlanTemplateStore exposes orchestration plan templates for plan construction.
|
||||
type PlanTemplateStore interface {
|
||||
List(ctx context.Context, filter *model.PaymentPlanTemplateFilter) (*model.PaymentPlanTemplateList, error)
|
||||
}
|
||||
|
||||
// GatewayRegistry exposes gateway instances for capability-based selection.
|
||||
type GatewayRegistry interface {
|
||||
List(ctx context.Context) ([]*model.GatewayInstanceDescriptor, error)
|
||||
}
|
||||
|
||||
// PlanBuilder constructs ordered payment plans from intents, quotes, and routing policy.
|
||||
type PlanBuilder interface {
|
||||
Build(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote, routes RouteStore, templates PlanTemplateStore, gateways GatewayRegistry) (*model.PaymentPlan, error)
|
||||
}
|
||||
// GatewayRegistry re-exports the plan_builder.GatewayRegistry interface for use
|
||||
// within the orchestrator package (gateway_registry.go, options.go, etc.).
|
||||
type GatewayRegistry = plan_builder.GatewayRegistry
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||
)
|
||||
|
||||
func perIntentIdempotencyKey(base string, index int, total int) string {
|
||||
base = strings.TrimSpace(base)
|
||||
if base == "" {
|
||||
return ""
|
||||
}
|
||||
if total <= 1 {
|
||||
return base
|
||||
}
|
||||
return fmt.Sprintf("%s:%d", base, index+1)
|
||||
}
|
||||
|
||||
func minQuoteExpiry(expires []time.Time) (time.Time, bool) {
|
||||
var min time.Time
|
||||
for _, exp := range expires {
|
||||
if exp.IsZero() {
|
||||
continue
|
||||
}
|
||||
if min.IsZero() || exp.Before(min) {
|
||||
min = exp
|
||||
}
|
||||
}
|
||||
if min.IsZero() {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return min, true
|
||||
}
|
||||
|
||||
func aggregatePaymentQuotes(quotes []*orchestratorv1.PaymentQuote) (*orchestratorv1.PaymentQuoteAggregate, error) {
|
||||
if len(quotes) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
debitTotals := map[string]decimal.Decimal{}
|
||||
settlementTotals := map[string]decimal.Decimal{}
|
||||
feeTotals := map[string]decimal.Decimal{}
|
||||
networkTotals := map[string]decimal.Decimal{}
|
||||
|
||||
for _, quote := range quotes {
|
||||
if quote == nil {
|
||||
continue
|
||||
}
|
||||
if err := accumulateMoney(debitTotals, quote.GetDebitAmount()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := accumulateMoney(settlementTotals, quote.GetExpectedSettlementAmount()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := accumulateMoney(feeTotals, quote.GetExpectedFeeTotal()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if nf := quote.GetNetworkFee(); nf != nil {
|
||||
if err := accumulateMoney(networkTotals, nf.GetNetworkFee()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &orchestratorv1.PaymentQuoteAggregate{
|
||||
DebitAmounts: totalsToMoney(debitTotals),
|
||||
ExpectedSettlementAmounts: totalsToMoney(settlementTotals),
|
||||
ExpectedFeeTotals: totalsToMoney(feeTotals),
|
||||
NetworkFeeTotals: totalsToMoney(networkTotals),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func accumulateMoney(totals map[string]decimal.Decimal, money *moneyv1.Money) error {
|
||||
if money == nil {
|
||||
return nil
|
||||
}
|
||||
currency := strings.TrimSpace(money.GetCurrency())
|
||||
if currency == "" {
|
||||
return nil
|
||||
}
|
||||
amount, err := decimal.NewFromString(money.GetAmount())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if current, ok := totals[currency]; ok {
|
||||
totals[currency] = current.Add(amount)
|
||||
return nil
|
||||
}
|
||||
totals[currency] = amount
|
||||
return nil
|
||||
}
|
||||
|
||||
func totalsToMoney(totals map[string]decimal.Decimal) []*moneyv1.Money {
|
||||
if len(totals) == 0 {
|
||||
return nil
|
||||
}
|
||||
currencies := make([]string, 0, len(totals))
|
||||
for currency := range totals {
|
||||
currencies = append(currencies, currency)
|
||||
}
|
||||
sort.Strings(currencies)
|
||||
|
||||
result := make([]*moneyv1.Money, 0, len(currencies))
|
||||
for _, currency := range currencies {
|
||||
amount := totals[currency]
|
||||
result = append(result, &moneyv1.Money{
|
||||
Amount: amount.String(),
|
||||
Currency: currency,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func intentsFromProto(intents []*orchestratorv1.PaymentIntent) []model.PaymentIntent {
|
||||
if len(intents) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]model.PaymentIntent, 0, len(intents))
|
||||
for _, intent := range intents {
|
||||
result = append(result, intentFromProto(intent))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func quoteSnapshotsFromProto(quotes []*orchestratorv1.PaymentQuote) []*model.PaymentQuoteSnapshot {
|
||||
if len(quotes) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]*model.PaymentQuoteSnapshot, 0, len(quotes))
|
||||
for _, quote := range quotes {
|
||||
if quote == nil {
|
||||
continue
|
||||
}
|
||||
if snapshot := quoteSnapshotToModel(quote); snapshot != nil {
|
||||
result = append(result, snapshot)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||
)
|
||||
|
||||
func TestAggregatePaymentQuotes(t *testing.T) {
|
||||
quotes := []*orchestratorv1.PaymentQuote{
|
||||
{
|
||||
DebitAmount: &moneyv1.Money{Amount: "10", Currency: "USD"},
|
||||
ExpectedSettlementAmount: &moneyv1.Money{Amount: "8", Currency: "EUR"},
|
||||
ExpectedFeeTotal: &moneyv1.Money{Amount: "1", Currency: "USD"},
|
||||
NetworkFee: &chainv1.EstimateTransferFeeResponse{
|
||||
NetworkFee: &moneyv1.Money{Amount: "0.5", Currency: "USD"},
|
||||
},
|
||||
},
|
||||
{
|
||||
DebitAmount: &moneyv1.Money{Amount: "5", Currency: "USD"},
|
||||
ExpectedSettlementAmount: &moneyv1.Money{Amount: "1000", Currency: "NGN"},
|
||||
ExpectedFeeTotal: &moneyv1.Money{Amount: "2", Currency: "USD"},
|
||||
NetworkFee: &chainv1.EstimateTransferFeeResponse{
|
||||
NetworkFee: &moneyv1.Money{Amount: "100", Currency: "NGN"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
agg, err := aggregatePaymentQuotes(quotes)
|
||||
if err != nil {
|
||||
t.Fatalf("aggregatePaymentQuotes returned error: %v", err)
|
||||
}
|
||||
|
||||
assertMoneyTotals(t, agg.GetDebitAmounts(), map[string]string{"USD": "15"})
|
||||
assertMoneyTotals(t, agg.GetExpectedSettlementAmounts(), map[string]string{"EUR": "8", "NGN": "1000"})
|
||||
assertMoneyTotals(t, agg.GetExpectedFeeTotals(), map[string]string{"USD": "3"})
|
||||
assertMoneyTotals(t, agg.GetNetworkFeeTotals(), map[string]string{"USD": "0.5", "NGN": "100"})
|
||||
}
|
||||
|
||||
func TestAggregatePaymentQuotesInvalidAmount(t *testing.T) {
|
||||
quotes := []*orchestratorv1.PaymentQuote{
|
||||
{
|
||||
DebitAmount: &moneyv1.Money{Amount: "bad", Currency: "USD"},
|
||||
},
|
||||
}
|
||||
if _, err := aggregatePaymentQuotes(quotes); err == nil {
|
||||
t.Fatal("expected error for invalid amount")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMinQuoteExpiry(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
later := now.Add(10 * time.Minute)
|
||||
earliest := now.Add(5 * time.Minute)
|
||||
|
||||
min, ok := minQuoteExpiry([]time.Time{later, {}, earliest})
|
||||
if !ok {
|
||||
t.Fatal("expected min expiry to be set")
|
||||
}
|
||||
if !min.Equal(earliest) {
|
||||
t.Fatalf("expected min expiry %v, got %v", earliest, min)
|
||||
}
|
||||
|
||||
if _, ok := minQuoteExpiry([]time.Time{{}}); ok {
|
||||
t.Fatal("expected min expiry to be unset")
|
||||
}
|
||||
}
|
||||
|
||||
func assertMoneyTotals(t *testing.T, list []*moneyv1.Money, expected map[string]string) {
|
||||
t.Helper()
|
||||
got := make(map[string]decimal.Decimal, len(list))
|
||||
for _, item := range list {
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
val, err := decimal.NewFromString(item.GetAmount())
|
||||
if err != nil {
|
||||
t.Fatalf("invalid money amount %q: %v", item.GetAmount(), err)
|
||||
}
|
||||
got[item.GetCurrency()] = val
|
||||
}
|
||||
if len(got) != len(expected) {
|
||||
t.Fatalf("expected %d totals, got %d", len(expected), len(got))
|
||||
}
|
||||
for currency, amount := range expected {
|
||||
val, err := decimal.NewFromString(amount)
|
||||
if err != nil {
|
||||
t.Fatalf("invalid expected amount %q: %v", amount, err)
|
||||
}
|
||||
gotVal, ok := got[currency]
|
||||
if !ok {
|
||||
t.Fatalf("missing currency %s", currency)
|
||||
}
|
||||
if !gotVal.Equal(val) {
|
||||
t.Fatalf("expected %s %s, got %s", amount, currency, gotVal.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,579 +0,0 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
chainpkg "github.com/tech/sendico/pkg/chain"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
|
||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||
"go.uber.org/zap"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
func (s *Service) buildPaymentQuote(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.PaymentQuote, time.Time, error) {
|
||||
intent := req.GetIntent()
|
||||
amount := intent.GetAmount()
|
||||
fxSide := fxv1.Side_SIDE_UNSPECIFIED
|
||||
if fxIntent := fxIntentForQuote(intent); fxIntent != nil {
|
||||
fxSide = fxIntent.GetSide()
|
||||
}
|
||||
|
||||
var fxQuote *oraclev1.Quote
|
||||
var err error
|
||||
if shouldRequestFX(intent) {
|
||||
fxQuote, err = s.requestFXQuote(ctx, orgRef, req)
|
||||
if err != nil {
|
||||
return nil, time.Time{}, err
|
||||
}
|
||||
s.logger.Debug("fx quote attached to payment quote", zap.String("org_ref", orgRef))
|
||||
}
|
||||
|
||||
payAmount, settlementAmountBeforeFees := resolveTradeAmounts(amount, fxQuote, fxSide)
|
||||
|
||||
feeBaseAmount := payAmount
|
||||
if feeBaseAmount == nil {
|
||||
feeBaseAmount = cloneProtoMoney(amount)
|
||||
}
|
||||
|
||||
intentModel := intentFromProto(intent)
|
||||
sourceRail, _, err := railFromEndpoint(intentModel.Source, intentModel.Attributes, true)
|
||||
if err != nil {
|
||||
return nil, time.Time{}, err
|
||||
}
|
||||
destRail, _, err := railFromEndpoint(intentModel.Destination, intentModel.Attributes, false)
|
||||
if err != nil {
|
||||
return nil, time.Time{}, err
|
||||
}
|
||||
feeRequired := feesRequiredForRails(sourceRail, destRail)
|
||||
feeQuote := &feesv1.PrecomputeFeesResponse{}
|
||||
if feeRequired {
|
||||
feeQuote, err = s.quoteFees(ctx, orgRef, req, feeBaseAmount)
|
||||
if err != nil {
|
||||
return nil, time.Time{}, err
|
||||
}
|
||||
}
|
||||
conversionFeeQuote := &feesv1.PrecomputeFeesResponse{}
|
||||
if s.shouldQuoteConversionFee(ctx, req.GetIntent()) {
|
||||
conversionFeeQuote, err = s.quoteConversionFees(ctx, orgRef, req, feeBaseAmount)
|
||||
if err != nil {
|
||||
return nil, time.Time{}, err
|
||||
}
|
||||
}
|
||||
feeCurrency := ""
|
||||
if feeBaseAmount != nil {
|
||||
feeCurrency = feeBaseAmount.GetCurrency()
|
||||
} else if amount != nil {
|
||||
feeCurrency = amount.GetCurrency()
|
||||
}
|
||||
feeLines := cloneFeeLines(feeQuote.GetLines())
|
||||
if conversionFeeQuote != nil {
|
||||
feeLines = append(feeLines, cloneFeeLines(conversionFeeQuote.GetLines())...)
|
||||
}
|
||||
s.assignFeeLedgerAccounts(intent, feeLines)
|
||||
feeTotal := extractFeeTotal(feeLines, feeCurrency)
|
||||
|
||||
var networkFee *chainv1.EstimateTransferFeeResponse
|
||||
if shouldEstimateNetworkFee(intent) {
|
||||
networkFee, err = s.estimateNetworkFee(ctx, intent)
|
||||
if err != nil {
|
||||
return nil, time.Time{}, err
|
||||
}
|
||||
s.logger.Debug("Network fee estimated", zap.String("org_ref", orgRef))
|
||||
}
|
||||
|
||||
debitAmount, settlementAmount := computeAggregates(payAmount, settlementAmountBeforeFees, feeTotal, networkFee, fxQuote, intent.GetSettlementMode())
|
||||
|
||||
quote := &orchestratorv1.PaymentQuote{
|
||||
DebitAmount: debitAmount,
|
||||
DebitSettlementAmount: payAmount,
|
||||
ExpectedSettlementAmount: settlementAmount,
|
||||
ExpectedFeeTotal: feeTotal,
|
||||
FeeLines: feeLines,
|
||||
FeeRules: mergeFeeRules(feeQuote, conversionFeeQuote),
|
||||
FxQuote: fxQuote,
|
||||
NetworkFee: networkFee,
|
||||
}
|
||||
|
||||
expiresAt := quoteExpiry(s.clock.Now(), feeQuote, fxQuote)
|
||||
if conversionFeeQuote != nil {
|
||||
convExpiry := quoteExpiry(s.clock.Now(), conversionFeeQuote, fxQuote)
|
||||
if convExpiry.Before(expiresAt) {
|
||||
expiresAt = convExpiry
|
||||
}
|
||||
}
|
||||
|
||||
return quote, expiresAt, nil
|
||||
}
|
||||
|
||||
func (s *Service) quoteFees(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest, baseAmount *moneyv1.Money) (*feesv1.PrecomputeFeesResponse, error) {
|
||||
if !s.deps.fees.available() {
|
||||
return &feesv1.PrecomputeFeesResponse{}, nil
|
||||
}
|
||||
intent := req.GetIntent()
|
||||
amount := cloneProtoMoney(baseAmount)
|
||||
if amount == nil {
|
||||
amount = cloneProtoMoney(intent.GetAmount())
|
||||
}
|
||||
attrs := ensureFeeAttributes(intent, amount, cloneMetadata(intent.GetAttributes()))
|
||||
feeIntent := &feesv1.Intent{
|
||||
Trigger: feeTriggerForIntent(intent),
|
||||
BaseAmount: amount,
|
||||
BookedAt: timestamppb.New(s.clock.Now()),
|
||||
OriginType: "payments.orchestrator.quote",
|
||||
OriginRef: strings.TrimSpace(req.GetIdempotencyKey()),
|
||||
Attributes: attrs,
|
||||
}
|
||||
timeout := req.GetMeta().GetTrace()
|
||||
ctxTimeout, cancel := s.withTimeout(ctx, s.deps.fees.timeout)
|
||||
defer cancel()
|
||||
resp, err := s.deps.fees.client.PrecomputeFees(ctxTimeout, &feesv1.PrecomputeFeesRequest{
|
||||
Meta: &feesv1.RequestMeta{
|
||||
OrganizationRef: orgRef,
|
||||
Trace: timeout,
|
||||
},
|
||||
Intent: feeIntent,
|
||||
TtlMs: defaultFeeQuoteTTLMillis,
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Warn("Fees precompute failed", zap.Error(err))
|
||||
return nil, merrors.Internal("fees_precompute_failed")
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s *Service) quoteConversionFees(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest, baseAmount *moneyv1.Money) (*feesv1.PrecomputeFeesResponse, error) {
|
||||
if !s.deps.fees.available() {
|
||||
return &feesv1.PrecomputeFeesResponse{}, nil
|
||||
}
|
||||
intent := req.GetIntent()
|
||||
amount := cloneProtoMoney(baseAmount)
|
||||
if amount == nil {
|
||||
amount = cloneProtoMoney(intent.GetAmount())
|
||||
}
|
||||
attrs := ensureFeeAttributes(intent, amount, cloneMetadata(intent.GetAttributes()))
|
||||
attrs["product"] = "wallet"
|
||||
attrs["source_type"] = "managed_wallet"
|
||||
attrs["destination_type"] = "ledger"
|
||||
|
||||
feeIntent := &feesv1.Intent{
|
||||
Trigger: feesv1.Trigger_TRIGGER_CAPTURE,
|
||||
BaseAmount: amount,
|
||||
BookedAt: timestamppb.New(s.clock.Now()),
|
||||
OriginType: "payments.orchestrator.conversion_quote",
|
||||
OriginRef: strings.TrimSpace(req.GetIdempotencyKey()),
|
||||
Attributes: attrs,
|
||||
}
|
||||
timeout := req.GetMeta().GetTrace()
|
||||
ctxTimeout, cancel := s.withTimeout(ctx, s.deps.fees.timeout)
|
||||
defer cancel()
|
||||
resp, err := s.deps.fees.client.PrecomputeFees(ctxTimeout, &feesv1.PrecomputeFeesRequest{
|
||||
Meta: &feesv1.RequestMeta{
|
||||
OrganizationRef: orgRef,
|
||||
Trace: timeout,
|
||||
},
|
||||
Intent: feeIntent,
|
||||
TtlMs: defaultFeeQuoteTTLMillis,
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Warn("Conversion fee precompute failed", zap.Error(err))
|
||||
return nil, merrors.Internal("fees_precompute_failed")
|
||||
}
|
||||
setFeeLineTarget(resp.GetLines(), feeLineTargetWallet)
|
||||
if src := intent.GetSource().GetManagedWallet(); src != nil {
|
||||
setFeeLineWalletRef(resp.GetLines(), src.GetManagedWalletRef(), "managed_wallet")
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s *Service) shouldQuoteConversionFee(ctx context.Context, intent *orchestratorv1.PaymentIntent) bool {
|
||||
if intent == nil {
|
||||
return false
|
||||
}
|
||||
if !isManagedWalletEndpoint(intent.GetSource()) {
|
||||
return false
|
||||
}
|
||||
if isLedgerEndpoint(intent.GetDestination()) {
|
||||
return false
|
||||
}
|
||||
if s.storage == nil {
|
||||
return false
|
||||
}
|
||||
templates := s.storage.PlanTemplates()
|
||||
if templates == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
intentModel := intentFromProto(intent)
|
||||
sourceRail, sourceNetwork, err := railFromEndpoint(intentModel.Source, intentModel.Attributes, true)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
destRail, destNetwork, err := railFromEndpoint(intentModel.Destination, intentModel.Attributes, false)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
network, err := resolveRouteNetwork(intentModel.Attributes, sourceNetwork, destNetwork)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
template, err := selectPlanTemplate(ctx, s.logger.Named("quote_payment"), templates, sourceRail, destRail, network)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return templateHasLedgerMove(template)
|
||||
}
|
||||
|
||||
func templateHasLedgerMove(template *model.PaymentPlanTemplate) bool {
|
||||
if template == nil {
|
||||
return false
|
||||
}
|
||||
for _, step := range template.Steps {
|
||||
if step.Rail != model.RailLedger {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(step.Operation), "ledger.move") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func mergeFeeRules(primary, secondary *feesv1.PrecomputeFeesResponse) []*feesv1.AppliedRule {
|
||||
rules := cloneFeeRules(nil)
|
||||
if primary != nil {
|
||||
rules = append(rules, cloneFeeRules(primary.GetApplied())...)
|
||||
}
|
||||
if secondary != nil {
|
||||
rules = append(rules, cloneFeeRules(secondary.GetApplied())...)
|
||||
}
|
||||
if len(rules) == 0 {
|
||||
return nil
|
||||
}
|
||||
return rules
|
||||
}
|
||||
|
||||
func ensureFeeAttributes(intent *orchestratorv1.PaymentIntent, baseAmount *moneyv1.Money, attrs map[string]string) map[string]string {
|
||||
if attrs == nil {
|
||||
attrs = map[string]string{}
|
||||
}
|
||||
if intent == nil {
|
||||
return attrs
|
||||
}
|
||||
setFeeAttributeIfMissing(attrs, "product", "wallet")
|
||||
if op := feeOperationFromKind(intent.GetKind()); op != "" {
|
||||
setFeeAttributeIfMissing(attrs, "operation", op)
|
||||
}
|
||||
if currency := feeCurrencyFromAmount(baseAmount, intent.GetAmount()); currency != "" {
|
||||
setFeeAttributeIfMissing(attrs, "currency", currency)
|
||||
}
|
||||
if srcType := endpointTypeFromProto(intent.GetSource()); srcType != "" {
|
||||
setFeeAttributeIfMissing(attrs, "source_type", srcType)
|
||||
}
|
||||
if dstType := endpointTypeFromProto(intent.GetDestination()); dstType != "" {
|
||||
setFeeAttributeIfMissing(attrs, "destination_type", dstType)
|
||||
}
|
||||
if asset := assetFromIntent(intent); asset != nil {
|
||||
if token := strings.TrimSpace(asset.GetTokenSymbol()); token != "" {
|
||||
setFeeAttributeIfMissing(attrs, "asset", token)
|
||||
}
|
||||
if chain := asset.GetChain(); chain != chainv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED {
|
||||
if network := strings.TrimSpace(chainpkg.NetworkAlias(chain)); network != "" {
|
||||
setFeeAttributeIfMissing(attrs, "network", network)
|
||||
}
|
||||
}
|
||||
}
|
||||
return attrs
|
||||
}
|
||||
|
||||
func feeTriggerForIntent(intent *orchestratorv1.PaymentIntent) feesv1.Trigger {
|
||||
if intent == nil {
|
||||
return feesv1.Trigger_TRIGGER_UNSPECIFIED
|
||||
}
|
||||
trigger := triggerFromKind(intent.GetKind(), intent.GetRequiresFx())
|
||||
if trigger != feesv1.Trigger_TRIGGER_FX_CONVERSION && isManagedWalletEndpoint(intent.GetSource()) && isLedgerEndpoint(intent.GetDestination()) {
|
||||
return feesv1.Trigger_TRIGGER_CAPTURE
|
||||
}
|
||||
return trigger
|
||||
}
|
||||
|
||||
func isManagedWalletEndpoint(endpoint *orchestratorv1.PaymentEndpoint) bool {
|
||||
return endpoint != nil && endpoint.GetManagedWallet() != nil
|
||||
}
|
||||
|
||||
func isLedgerEndpoint(endpoint *orchestratorv1.PaymentEndpoint) bool {
|
||||
return endpoint != nil && endpoint.GetLedger() != nil
|
||||
}
|
||||
|
||||
func setFeeAttributeIfMissing(attrs map[string]string, key, value string) {
|
||||
if attrs == nil {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(key) == "" {
|
||||
return
|
||||
}
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return
|
||||
}
|
||||
if _, exists := attrs[key]; exists {
|
||||
return
|
||||
}
|
||||
attrs[key] = value
|
||||
}
|
||||
|
||||
func feeOperationFromKind(kind orchestratorv1.PaymentKind) string {
|
||||
switch kind {
|
||||
case orchestratorv1.PaymentKind_PAYMENT_KIND_PAYOUT:
|
||||
return "payout"
|
||||
case orchestratorv1.PaymentKind_PAYMENT_KIND_INTERNAL_TRANSFER:
|
||||
return "internal_transfer"
|
||||
case orchestratorv1.PaymentKind_PAYMENT_KIND_FX_CONVERSION:
|
||||
return "fx_conversion"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func feeCurrencyFromAmount(baseAmount, intentAmount *moneyv1.Money) string {
|
||||
if baseAmount != nil {
|
||||
if currency := strings.TrimSpace(baseAmount.GetCurrency()); currency != "" {
|
||||
return currency
|
||||
}
|
||||
}
|
||||
if intentAmount != nil {
|
||||
return strings.TrimSpace(intentAmount.GetCurrency())
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func endpointTypeFromProto(endpoint *orchestratorv1.PaymentEndpoint) string {
|
||||
if endpoint == nil {
|
||||
return ""
|
||||
}
|
||||
switch {
|
||||
case endpoint.GetLedger() != nil:
|
||||
return "ledger"
|
||||
case endpoint.GetManagedWallet() != nil:
|
||||
return "managed_wallet"
|
||||
case endpoint.GetExternalChain() != nil:
|
||||
return "external_chain"
|
||||
case endpoint.GetCard() != nil:
|
||||
return "card"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func assetFromIntent(intent *orchestratorv1.PaymentIntent) *chainv1.Asset {
|
||||
if intent == nil {
|
||||
return nil
|
||||
}
|
||||
if asset := assetFromEndpoint(intent.GetDestination()); asset != nil {
|
||||
return asset
|
||||
}
|
||||
return assetFromEndpoint(intent.GetSource())
|
||||
}
|
||||
|
||||
func assetFromEndpoint(endpoint *orchestratorv1.PaymentEndpoint) *chainv1.Asset {
|
||||
if endpoint == nil {
|
||||
return nil
|
||||
}
|
||||
if wallet := endpoint.GetManagedWallet(); wallet != nil {
|
||||
return wallet.GetAsset()
|
||||
}
|
||||
if external := endpoint.GetExternalChain(); external != nil {
|
||||
return external.GetAsset()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) estimateNetworkFee(ctx context.Context, intent *orchestratorv1.PaymentIntent) (*chainv1.EstimateTransferFeeResponse, error) {
|
||||
req := &chainv1.EstimateTransferFeeRequest{
|
||||
Amount: cloneProtoMoney(intent.GetAmount()),
|
||||
}
|
||||
if src := intent.GetSource().GetManagedWallet(); src != nil {
|
||||
req.SourceWalletRef = strings.TrimSpace(src.GetManagedWalletRef())
|
||||
}
|
||||
if dst := intent.GetDestination().GetManagedWallet(); dst != nil {
|
||||
req.Destination = &chainv1.TransferDestination{
|
||||
Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: strings.TrimSpace(dst.GetManagedWalletRef())},
|
||||
}
|
||||
}
|
||||
if dst := intent.GetDestination().GetExternalChain(); dst != nil {
|
||||
req.Destination = &chainv1.TransferDestination{
|
||||
Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: strings.TrimSpace(dst.GetAddress())},
|
||||
Memo: strings.TrimSpace(dst.GetMemo()),
|
||||
}
|
||||
req.Asset = dst.GetAsset()
|
||||
}
|
||||
if req.Asset == nil {
|
||||
if src := intent.GetSource().GetManagedWallet(); src != nil {
|
||||
req.Asset = src.GetAsset()
|
||||
}
|
||||
}
|
||||
|
||||
network := ""
|
||||
if req.Asset != nil {
|
||||
network = chainpkg.NetworkName(req.Asset.GetChain())
|
||||
}
|
||||
instanceID := strings.TrimSpace(intent.GetSource().GetInstanceId())
|
||||
if instanceID == "" {
|
||||
instanceID = strings.TrimSpace(intent.GetDestination().GetInstanceId())
|
||||
}
|
||||
client, _, err := s.resolveChainGatewayClient(ctx, network, moneyFromProto(req.Amount), []model.RailOperation{model.RailOperationSend}, instanceID, "")
|
||||
if err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
s.logger.Debug("Network fee estimation skipped: gateway unavailable", zap.Error(err))
|
||||
return nil, nil
|
||||
}
|
||||
s.logger.Warn("Chain gateway resolution failed", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
if client == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
resp, err := client.EstimateTransferFee(ctx, req)
|
||||
if err != nil {
|
||||
s.logger.Warn("chain gateway fee estimation failed", zap.Error(err))
|
||||
return nil, merrors.Internal("chain_gateway_fee_estimation_failed")
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s *Service) requestFXQuote(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest) (*oraclev1.Quote, error) {
|
||||
if !s.deps.oracle.available() {
|
||||
if req.GetIntent().GetRequiresFx() {
|
||||
return nil, merrors.Internal("fx_oracle_unavailable")
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
intent := req.GetIntent()
|
||||
meta := req.GetMeta()
|
||||
fxIntent := fxIntentForQuote(intent)
|
||||
if fxIntent == nil {
|
||||
if intent.GetRequiresFx() {
|
||||
return nil, merrors.InvalidArgument("fx intent missing")
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
ttl := fxIntent.GetTtlMs()
|
||||
if ttl <= 0 {
|
||||
ttl = defaultOracleTTLMillis
|
||||
}
|
||||
|
||||
params := oracleclient.GetQuoteParams{
|
||||
Meta: oracleclient.RequestMeta{
|
||||
OrganizationRef: orgRef,
|
||||
Trace: meta.GetTrace(),
|
||||
},
|
||||
Pair: fxIntent.GetPair(),
|
||||
Side: fxIntent.GetSide(),
|
||||
Firm: fxIntent.GetFirm(),
|
||||
TTL: time.Duration(ttl) * time.Millisecond,
|
||||
PreferredProvider: strings.TrimSpace(fxIntent.GetPreferredProvider()),
|
||||
}
|
||||
|
||||
if fxIntent.GetMaxAgeMs() > 0 {
|
||||
params.MaxAge = time.Duration(fxIntent.GetMaxAgeMs()) * time.Millisecond
|
||||
}
|
||||
|
||||
if amount := intent.GetAmount(); amount != nil {
|
||||
pair := fxIntent.GetPair()
|
||||
if pair != nil {
|
||||
switch {
|
||||
case strings.EqualFold(amount.GetCurrency(), pair.GetBase()):
|
||||
params.BaseAmount = cloneProtoMoney(amount)
|
||||
case strings.EqualFold(amount.GetCurrency(), pair.GetQuote()):
|
||||
params.QuoteAmount = cloneProtoMoney(amount)
|
||||
default:
|
||||
params.BaseAmount = cloneProtoMoney(amount)
|
||||
}
|
||||
} else {
|
||||
params.BaseAmount = cloneProtoMoney(amount)
|
||||
}
|
||||
}
|
||||
|
||||
quote, err := s.deps.oracle.client.GetQuote(ctx, params)
|
||||
if err != nil {
|
||||
s.logger.Warn("fx oracle quote failed", zap.Error(err))
|
||||
return nil, merrors.Internal(fmt.Sprintf("orchestrator: fx quote failed, %s", err.Error()))
|
||||
}
|
||||
if quote == nil {
|
||||
if intent.GetRequiresFx() {
|
||||
return nil, merrors.Internal("orchestrator: fx quote missing")
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
return quoteToProto(quote), nil
|
||||
}
|
||||
|
||||
func feesRequiredForRails(sourceRail, destRail model.Rail) bool {
|
||||
if sourceRail == model.RailLedger && destRail == model.RailLedger {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Service) feeLedgerAccountForIntent(intent *orchestratorv1.PaymentIntent) string {
|
||||
if intent == nil || len(s.deps.feeLedgerAccounts) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
key := s.gatewayKeyFromIntent(intent)
|
||||
if key == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(s.deps.feeLedgerAccounts[key])
|
||||
}
|
||||
|
||||
func (s *Service) assignFeeLedgerAccounts(intent *orchestratorv1.PaymentIntent, lines []*feesv1.DerivedPostingLine) {
|
||||
account := s.feeLedgerAccountForIntent(intent)
|
||||
key := s.gatewayKeyFromIntent(intent)
|
||||
|
||||
missing := 0
|
||||
for _, line := range lines {
|
||||
if line == nil {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(line.GetLedgerAccountRef()) == "" {
|
||||
missing++
|
||||
}
|
||||
}
|
||||
if missing == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
if account == "" {
|
||||
s.logger.Debug("No fee ledger account mapping found", zap.String("gateway", key), zap.Int("missing_lines", missing))
|
||||
return
|
||||
}
|
||||
assignLedgerAccounts(lines, account)
|
||||
s.logger.Debug("Applied fee ledger account mapping", zap.String("gateway", key), zap.String("ledger_account", account), zap.Int("lines", missing))
|
||||
}
|
||||
|
||||
func (s *Service) gatewayKeyFromIntent(intent *orchestratorv1.PaymentIntent) string {
|
||||
if intent == nil {
|
||||
return ""
|
||||
}
|
||||
key := strings.TrimSpace(intent.GetAttributes()["gateway"])
|
||||
if key == "" {
|
||||
if dest := intent.GetDestination(); dest != nil && dest.GetCard() != nil {
|
||||
key = defaultCardGateway
|
||||
}
|
||||
}
|
||||
return strings.ToLower(key)
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||
"go.uber.org/zap"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
type feeEngineFake struct {
|
||||
precomputeCalls int
|
||||
}
|
||||
|
||||
func (f *feeEngineFake) QuoteFees(ctx context.Context, in *feesv1.QuoteFeesRequest, opts ...grpc.CallOption) (*feesv1.QuoteFeesResponse, error) {
|
||||
return &feesv1.QuoteFeesResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *feeEngineFake) PrecomputeFees(ctx context.Context, in *feesv1.PrecomputeFeesRequest, opts ...grpc.CallOption) (*feesv1.PrecomputeFeesResponse, error) {
|
||||
f.precomputeCalls++
|
||||
return &feesv1.PrecomputeFeesResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *feeEngineFake) ValidateFeeToken(ctx context.Context, in *feesv1.ValidateFeeTokenRequest, opts ...grpc.CallOption) (*feesv1.ValidateFeeTokenResponse, error) {
|
||||
return &feesv1.ValidateFeeTokenResponse{}, nil
|
||||
}
|
||||
|
||||
func TestBuildPaymentQuote_RequestsFXWhenSettlementDiffers(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
calls := 0
|
||||
|
||||
svc := &Service{
|
||||
logger: zap.NewNop(),
|
||||
clock: testClock{now: time.Now()},
|
||||
deps: serviceDependencies{
|
||||
oracle: oracleDependency{
|
||||
client: &oracleclient.Fake{
|
||||
GetQuoteFn: func(ctx context.Context, params oracleclient.GetQuoteParams) (*oracleclient.Quote, error) {
|
||||
calls++
|
||||
return &oracleclient.Quote{
|
||||
QuoteRef: "q1",
|
||||
Pair: params.Pair,
|
||||
Side: params.Side,
|
||||
Price: "1.1",
|
||||
BaseAmount: params.BaseAmount,
|
||||
QuoteAmount: params.QuoteAmount,
|
||||
ExpiresAt: time.Now().Add(time.Minute),
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
req := &orchestratorv1.QuotePaymentRequest{
|
||||
Meta: &orchestratorv1.RequestMeta{OrganizationRef: "org"},
|
||||
Intent: &orchestratorv1.PaymentIntent{
|
||||
Ref: "ref-1",
|
||||
Source: &orchestratorv1.PaymentEndpoint{
|
||||
Endpoint: &orchestratorv1.PaymentEndpoint_Ledger{Ledger: &orchestratorv1.LedgerEndpoint{LedgerAccountRef: "ledger:source"}},
|
||||
},
|
||||
Destination: &orchestratorv1.PaymentEndpoint{
|
||||
Endpoint: &orchestratorv1.PaymentEndpoint_Ledger{Ledger: &orchestratorv1.LedgerEndpoint{LedgerAccountRef: "ledger:dest"}},
|
||||
},
|
||||
Amount: &moneyv1.Money{Currency: "USD", Amount: "100"},
|
||||
SettlementCurrency: "EUR",
|
||||
},
|
||||
}
|
||||
|
||||
if _, _, err := svc.buildPaymentQuote(ctx, "org", req); err != nil {
|
||||
t.Fatalf("buildPaymentQuote returned error: %v", err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("expected 1 fx quote call, got %d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPaymentQuote_FeesRequestedForExternalRails(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
feeFake := &feeEngineFake{}
|
||||
|
||||
svc := &Service{
|
||||
logger: zap.NewNop(),
|
||||
clock: testClock{now: time.Now()},
|
||||
deps: serviceDependencies{
|
||||
fees: feesDependency{client: feeFake},
|
||||
},
|
||||
}
|
||||
|
||||
req := &orchestratorv1.QuotePaymentRequest{
|
||||
Meta: &orchestratorv1.RequestMeta{OrganizationRef: "org"},
|
||||
Intent: &orchestratorv1.PaymentIntent{
|
||||
Ref: "ref-1",
|
||||
Source: &orchestratorv1.PaymentEndpoint{
|
||||
Endpoint: &orchestratorv1.PaymentEndpoint_Ledger{Ledger: &orchestratorv1.LedgerEndpoint{LedgerAccountRef: "ledger:source"}},
|
||||
},
|
||||
Destination: &orchestratorv1.PaymentEndpoint{
|
||||
Endpoint: &orchestratorv1.PaymentEndpoint_Card{Card: &orchestratorv1.CardEndpoint{}},
|
||||
},
|
||||
Amount: &moneyv1.Money{Currency: "USD", Amount: "100"},
|
||||
SettlementCurrency: "USD",
|
||||
},
|
||||
}
|
||||
|
||||
if _, _, err := svc.buildPaymentQuote(ctx, "org", req); err != nil {
|
||||
t.Fatalf("buildPaymentQuote returned error: %v", err)
|
||||
}
|
||||
if feeFake.precomputeCalls != 1 {
|
||||
t.Fatalf("expected 1 fee precompute call, got %d", feeFake.precomputeCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPaymentQuote_FeesSkippedForLedgerTransfer(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
feeFake := &feeEngineFake{}
|
||||
|
||||
svc := &Service{
|
||||
logger: zap.NewNop(),
|
||||
clock: testClock{now: time.Now()},
|
||||
deps: serviceDependencies{
|
||||
fees: feesDependency{client: feeFake},
|
||||
},
|
||||
}
|
||||
|
||||
req := &orchestratorv1.QuotePaymentRequest{
|
||||
Meta: &orchestratorv1.RequestMeta{OrganizationRef: "org"},
|
||||
Intent: &orchestratorv1.PaymentIntent{
|
||||
Ref: "ref-1",
|
||||
Source: &orchestratorv1.PaymentEndpoint{
|
||||
Endpoint: &orchestratorv1.PaymentEndpoint_Ledger{Ledger: &orchestratorv1.LedgerEndpoint{LedgerAccountRef: "ledger:source"}},
|
||||
},
|
||||
Destination: &orchestratorv1.PaymentEndpoint{
|
||||
Endpoint: &orchestratorv1.PaymentEndpoint_Ledger{Ledger: &orchestratorv1.LedgerEndpoint{LedgerAccountRef: "ledger:dest"}},
|
||||
},
|
||||
Amount: &moneyv1.Money{Currency: "USD", Amount: "100"},
|
||||
SettlementCurrency: "USD",
|
||||
},
|
||||
}
|
||||
|
||||
if _, _, err := svc.buildPaymentQuote(ctx, "org", req); err != nil {
|
||||
t.Fatalf("buildPaymentQuote returned error: %v", err)
|
||||
}
|
||||
if feeFake.precomputeCalls != 0 {
|
||||
t.Fatalf("expected fee precompute to be skipped, got %d", feeFake.precomputeCalls)
|
||||
}
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
//go:build integration
|
||||
// +build integration
|
||||
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
storagemongo "github.com/tech/sendico/payments/storage/mongo"
|
||||
"github.com/tech/sendico/pkg/db/repository"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||
"github.com/testcontainers/testcontainers-go"
|
||||
"github.com/testcontainers/testcontainers-go/modules/mongodb"
|
||||
"github.com/testcontainers/testcontainers-go/wait"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/readpref"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func terminateMongo(ctx context.Context, t *testing.T, container *mongodb.MongoDBContainer) {
|
||||
t.Helper()
|
||||
if err := container.Terminate(ctx); err != nil {
|
||||
t.Fatalf("failed to terminate MongoDB container: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func disconnectMongo(ctx context.Context, t *testing.T, client *mongo.Client) {
|
||||
t.Helper()
|
||||
if err := client.Disconnect(ctx); err != nil {
|
||||
t.Fatalf("failed to disconnect from MongoDB: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuotePayment_IdempotencyReuseAfterExpiry(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
mongoContainer, err := mongodb.Run(ctx,
|
||||
"mongo:latest",
|
||||
mongodb.WithUsername("root"),
|
||||
mongodb.WithPassword("password"),
|
||||
testcontainers.WithWaitStrategy(wait.ForLog("Waiting for connections")),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to start MongoDB container: %v", err)
|
||||
}
|
||||
defer terminateMongo(ctx, t, mongoContainer)
|
||||
|
||||
mongoURI, err := mongoContainer.ConnectionString(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get MongoDB connection string: %v", err)
|
||||
}
|
||||
|
||||
client, err := mongo.Connect(ctx, options.Client().ApplyURI(mongoURI))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to connect to MongoDB: %v", err)
|
||||
}
|
||||
defer disconnectMongo(ctx, t, client)
|
||||
|
||||
db := client.Database("test_" + strings.ReplaceAll(t.Name(), "/", "_"))
|
||||
paymentsRepo := repository.CreateMongoRepository(db, (&model.Payment{}).Collection())
|
||||
quotesRepo := repository.CreateMongoRepository(db, (&model.PaymentQuoteRecord{}).Collection())
|
||||
routesRepo := repository.CreateMongoRepository(db, (&model.PaymentRoute{}).Collection())
|
||||
plansRepo := repository.CreateMongoRepository(db, (&model.PaymentPlanTemplate{}).Collection())
|
||||
|
||||
ping := func(ctx context.Context) error { return client.Ping(ctx, readpref.Primary()) }
|
||||
store, err := storagemongo.NewWithRepository(zap.NewNop(), ping, paymentsRepo, quotesRepo, routesRepo, plansRepo)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create payments repository: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
svc := NewService(zap.NewNop(), store, WithClock(testClock{now: now}))
|
||||
|
||||
orgID := bson.NewObjectID()
|
||||
req := &orchestratorv1.QuotePaymentRequest{
|
||||
Meta: &orchestratorv1.RequestMeta{OrganizationRef: orgID.Hex()},
|
||||
IdempotencyKey: "idem-expired-quote",
|
||||
Ref: "ref-expired",
|
||||
Intent: &orchestratorv1.PaymentIntent{
|
||||
Ref: "intent-1",
|
||||
Source: &orchestratorv1.PaymentEndpoint{
|
||||
Endpoint: &orchestratorv1.PaymentEndpoint_Ledger{
|
||||
Ledger: &orchestratorv1.LedgerEndpoint{LedgerAccountRef: "ledger:source"},
|
||||
},
|
||||
},
|
||||
Destination: &orchestratorv1.PaymentEndpoint{
|
||||
Endpoint: &orchestratorv1.PaymentEndpoint_Ledger{
|
||||
Ledger: &orchestratorv1.LedgerEndpoint{LedgerAccountRef: "ledger:dest"},
|
||||
},
|
||||
},
|
||||
Amount: &moneyv1.Money{Currency: "USDT", Amount: "1"},
|
||||
SettlementCurrency: "USDT",
|
||||
},
|
||||
}
|
||||
|
||||
resp1, err := svc.QuotePayment(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("first quote returned error: %v", err)
|
||||
}
|
||||
firstRef := resp1.GetQuote().GetQuoteRef()
|
||||
if firstRef == "" {
|
||||
t.Fatal("expected first quote ref to be populated")
|
||||
}
|
||||
|
||||
quotesColl := db.Collection((&model.PaymentQuoteRecord{}).Collection())
|
||||
update := bson.M{"$set": bson.M{"expiresAt": time.Now().Add(-time.Minute)}}
|
||||
result, err := quotesColl.UpdateOne(ctx, bson.M{
|
||||
"organizationRef": orgID,
|
||||
"idempotencyKey": req.GetIdempotencyKey(),
|
||||
}, update)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to expire quote: %v", err)
|
||||
}
|
||||
if result.MatchedCount == 0 {
|
||||
t.Fatal("expected expired quote to be updated")
|
||||
}
|
||||
|
||||
resp2, err := svc.QuotePayment(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("second quote returned error: %v", err)
|
||||
}
|
||||
secondRef := resp2.GetQuote().GetQuoteRef()
|
||||
if secondRef == "" {
|
||||
t.Fatal("expected second quote ref to be populated")
|
||||
}
|
||||
if secondRef == firstRef {
|
||||
t.Fatal("expected a new quote to be generated after expiry")
|
||||
}
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestRequestFXQuoteUsesQuoteAmountWhenCurrencyMatchesQuote(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var captured oracleclient.GetQuoteParams
|
||||
|
||||
svc := &Service{
|
||||
logger: zap.NewNop(),
|
||||
clock: testClock{now: time.Now()},
|
||||
deps: serviceDependencies{
|
||||
oracle: oracleDependency{
|
||||
client: &oracleclient.Fake{
|
||||
GetQuoteFn: func(ctx context.Context, params oracleclient.GetQuoteParams) (*oracleclient.Quote, error) {
|
||||
captured = params
|
||||
return &oracleclient.Quote{
|
||||
QuoteRef: "q",
|
||||
Pair: params.Pair,
|
||||
Side: params.Side,
|
||||
Price: "1.1",
|
||||
BaseAmount: params.BaseAmount,
|
||||
QuoteAmount: params.QuoteAmount,
|
||||
ExpiresAt: time.Now(),
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
req := &orchestratorv1.QuotePaymentRequest{
|
||||
Meta: &orchestratorv1.RequestMeta{OrganizationRef: "org"},
|
||||
Intent: &orchestratorv1.PaymentIntent{
|
||||
Ref: "ref-1",
|
||||
Amount: &moneyv1.Money{Currency: "USD", Amount: "100"},
|
||||
SettlementCurrency: "USD",
|
||||
Fx: &orchestratorv1.FXIntent{
|
||||
Pair: &fxv1.CurrencyPair{Base: "EUR", Quote: "USD"},
|
||||
Side: fxv1.Side_BUY_BASE_SELL_QUOTE,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if _, err := svc.requestFXQuote(ctx, "org", req); err != nil {
|
||||
t.Fatalf("requestFXQuote returned error: %v", err)
|
||||
}
|
||||
|
||||
if captured.QuoteAmount == nil {
|
||||
t.Fatal("expected quote amount to be populated")
|
||||
}
|
||||
if captured.BaseAmount != nil {
|
||||
t.Fatal("expected base amount to be nil when using quote amount input")
|
||||
}
|
||||
if captured.QuoteAmount.GetCurrency() != "USD" {
|
||||
t.Fatalf("expected quote amount currency USD, got %s", captured.QuoteAmount.GetCurrency())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestFXQuoteFailsWhenRequiredAndOracleUnavailable(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
svc := &Service{
|
||||
logger: zap.NewNop(),
|
||||
clock: testClock{now: time.Now()},
|
||||
deps: serviceDependencies{
|
||||
oracle: oracleDependency{},
|
||||
},
|
||||
}
|
||||
|
||||
req := &orchestratorv1.QuotePaymentRequest{
|
||||
Meta: &orchestratorv1.RequestMeta{OrganizationRef: "org"},
|
||||
Intent: &orchestratorv1.PaymentIntent{
|
||||
Ref: "ref-1",
|
||||
RequiresFx: true,
|
||||
Amount: &moneyv1.Money{Currency: "USDT", Amount: "1"},
|
||||
SettlementCurrency: "RUB",
|
||||
Fx: &orchestratorv1.FXIntent{
|
||||
Pair: &fxv1.CurrencyPair{Base: "USDT", Quote: "RUB"},
|
||||
Side: fxv1.Side_SELL_BASE_BUY_QUOTE,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if _, err := svc.requestFXQuote(ctx, "org", req); err == nil {
|
||||
t.Fatal("expected error when FX is required and oracle is unavailable")
|
||||
} else {
|
||||
if !errors.Is(err, merrors.ErrInternal) {
|
||||
t.Fatalf("expected internal error, got %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "fx_oracle_unavailable") {
|
||||
t.Fatalf("expected fx_oracle_unavailable error, got %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestFXQuoteFailsWhenRequiredAndQuoteMissing(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
svc := &Service{
|
||||
logger: zap.NewNop(),
|
||||
clock: testClock{now: time.Now()},
|
||||
deps: serviceDependencies{
|
||||
oracle: oracleDependency{
|
||||
client: &oracleclient.Fake{
|
||||
GetQuoteFn: func(ctx context.Context, params oracleclient.GetQuoteParams) (*oracleclient.Quote, error) {
|
||||
return nil, nil
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
req := &orchestratorv1.QuotePaymentRequest{
|
||||
Meta: &orchestratorv1.RequestMeta{OrganizationRef: "org"},
|
||||
Intent: &orchestratorv1.PaymentIntent{
|
||||
Ref: "ref-1",
|
||||
RequiresFx: true,
|
||||
Amount: &moneyv1.Money{Currency: "USDT", Amount: "1"},
|
||||
SettlementCurrency: "RUB",
|
||||
Fx: &orchestratorv1.FXIntent{
|
||||
Pair: &fxv1.CurrencyPair{Base: "USDT", Quote: "RUB"},
|
||||
Side: fxv1.Side_SELL_BASE_BUY_QUOTE,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if _, err := svc.requestFXQuote(ctx, "org", req); err == nil {
|
||||
t.Fatal("expected error when FX quote is missing")
|
||||
} else {
|
||||
if !errors.Is(err, merrors.ErrInternal) {
|
||||
t.Fatalf("expected internal error, got %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "orchestrator: fx quote missing") {
|
||||
t.Fatalf("expected 'orchestrator: fx quote missing' error, got %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/model/account_role"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||
)
|
||||
|
||||
func railFromEndpoint(endpoint model.PaymentEndpoint, attrs map[string]string, isSource bool) (model.Rail, string, error) {
|
||||
override := railOverrideFromAttributes(attrs, isSource)
|
||||
if override != model.RailUnspecified {
|
||||
return override, networkFromEndpoint(endpoint), nil
|
||||
}
|
||||
switch endpoint.Type {
|
||||
case model.EndpointTypeLedger:
|
||||
return model.RailLedger, "", nil
|
||||
case model.EndpointTypeManagedWallet, model.EndpointTypeExternalChain:
|
||||
return model.RailCrypto, networkFromEndpoint(endpoint), nil
|
||||
case model.EndpointTypeCard:
|
||||
return model.RailCardPayout, "", nil
|
||||
default:
|
||||
return model.RailUnspecified, "", merrors.InvalidArgument("plan builder: unsupported payment endpoint")
|
||||
}
|
||||
}
|
||||
|
||||
func railOverrideFromAttributes(attrs map[string]string, isSource bool) model.Rail {
|
||||
if len(attrs) == 0 {
|
||||
return model.RailUnspecified
|
||||
}
|
||||
keys := []string{"source_rail", "sourceRail"}
|
||||
if !isSource {
|
||||
keys = []string{"destination_rail", "destinationRail"}
|
||||
}
|
||||
lookup := map[string]struct{}{}
|
||||
for _, key := range keys {
|
||||
lookup[strings.ToLower(key)] = struct{}{}
|
||||
}
|
||||
for key, value := range attrs {
|
||||
if _, ok := lookup[strings.ToLower(strings.TrimSpace(key))]; !ok {
|
||||
continue
|
||||
}
|
||||
rail := parseRailValue(value)
|
||||
if rail != model.RailUnspecified {
|
||||
return rail
|
||||
}
|
||||
}
|
||||
return model.RailUnspecified
|
||||
}
|
||||
|
||||
func cloneAccountRole(role *account_role.AccountRole) *account_role.AccountRole {
|
||||
if role == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := *role
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func resolveFeeAmount(payment *model.Payment, quote *orchestratorv1.PaymentQuote) *paymenttypes.Money {
|
||||
if quote != nil && quote.GetExpectedFeeTotal() != nil {
|
||||
return moneyFromProto(quote.GetExpectedFeeTotal())
|
||||
}
|
||||
if payment != nil && payment.LastQuote != nil {
|
||||
return cloneMoney(payment.LastQuote.ExpectedFeeTotal)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func planTimestamp(payment *model.Payment) time.Time {
|
||||
if payment != nil && !payment.CreatedAt.IsZero() {
|
||||
return payment.CreatedAt.UTC()
|
||||
}
|
||||
return time.Now().UTC()
|
||||
}
|
||||
|
||||
func requireMoney(amount *paymenttypes.Money, label string) (*paymenttypes.Money, error) {
|
||||
if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" {
|
||||
return nil, merrors.InvalidArgument("plan builder: " + label + " is required")
|
||||
}
|
||||
return amount, nil
|
||||
}
|
||||
|
||||
func networkFromEndpoint(endpoint model.PaymentEndpoint) string {
|
||||
switch endpoint.Type {
|
||||
case model.EndpointTypeManagedWallet:
|
||||
if endpoint.ManagedWallet != nil && endpoint.ManagedWallet.Asset != nil {
|
||||
return strings.ToUpper(strings.TrimSpace(endpoint.ManagedWallet.Asset.GetChain()))
|
||||
}
|
||||
case model.EndpointTypeExternalChain:
|
||||
if endpoint.ExternalChain != nil && endpoint.ExternalChain.Asset != nil {
|
||||
return strings.ToUpper(strings.TrimSpace(endpoint.ExternalChain.Asset.GetChain()))
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package orchestrator
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/plan_builder"
|
||||
"github.com/tech/sendico/payments/storage"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/api/routers"
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
msg "github.com/tech/sendico/pkg/messaging"
|
||||
mb "github.com/tech/sendico/pkg/messaging/broker"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
orchestrationv1 "github.com/tech/sendico/pkg/proto/payments/orchestration/v1"
|
||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
@@ -20,13 +22,9 @@ func (e serviceError) Error() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
const (
|
||||
defaultFeeQuoteTTLMillis int64 = 120000
|
||||
defaultOracleTTLMillis int64 = 60000
|
||||
)
|
||||
|
||||
var (
|
||||
errStorageUnavailable = serviceError("payments.orchestrator: storage not initialised")
|
||||
errStorageUnavailable = serviceError("payments.orchestrator: storage not initialised")
|
||||
errQuotationUnavailable = serviceError("payments.orchestrator: quotation service not configured")
|
||||
)
|
||||
|
||||
// Service orchestrates payments across ledger, billing, FX, and chain domains.
|
||||
@@ -42,22 +40,22 @@ type Service struct {
|
||||
gatewayBroker mb.Broker
|
||||
gatewayConsumers []msg.Consumer
|
||||
|
||||
orchestratorv1.UnimplementedPaymentOrchestratorServer
|
||||
orchestrationv1.UnimplementedPaymentExecutionServiceServer
|
||||
}
|
||||
|
||||
type serviceDependencies struct {
|
||||
fees feesDependency
|
||||
ledger ledgerDependency
|
||||
gateway gatewayDependency
|
||||
railGateways railGatewayDependency
|
||||
providerGateway providerGatewayDependency
|
||||
oracle oracleDependency
|
||||
quotation quotationDependency
|
||||
fees feesDependency
|
||||
ledger ledgerDependency
|
||||
gateway gatewayDependency
|
||||
railGateways railGatewayDependency
|
||||
providerGateway providerGatewayDependency
|
||||
|
||||
mntx mntxDependency
|
||||
gatewayRegistry GatewayRegistry
|
||||
gatewayRegistry plan_builder.GatewayRegistry
|
||||
gatewayInvokeResolver GatewayInvokeResolver
|
||||
cardRoutes map[string]CardGatewayRoute
|
||||
feeLedgerAccounts map[string]string
|
||||
planBuilder PlanBuilder
|
||||
}
|
||||
|
||||
type handlerSet struct {
|
||||
@@ -118,22 +116,10 @@ func (s *Service) ensureHandlers() {
|
||||
// Register attaches the service to the supplied gRPC router.
|
||||
func (s *Service) Register(router routers.GRPC) error {
|
||||
return router.Register(func(reg grpc.ServiceRegistrar) {
|
||||
orchestratorv1.RegisterPaymentOrchestratorServer(reg, s)
|
||||
orchestrationv1.RegisterPaymentExecutionServiceServer(reg, s)
|
||||
})
|
||||
}
|
||||
|
||||
// QuotePayment aggregates downstream quotes.
|
||||
func (s *Service) QuotePayment(ctx context.Context, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.QuotePaymentResponse, error) {
|
||||
s.ensureHandlers()
|
||||
return executeUnary(ctx, s, "QuotePayment", s.h.commands.QuotePayment().Execute, req)
|
||||
}
|
||||
|
||||
// QuotePayments aggregates downstream quotes for multiple intents.
|
||||
func (s *Service) QuotePayments(ctx context.Context, req *orchestratorv1.QuotePaymentsRequest) (*orchestratorv1.QuotePaymentsResponse, error) {
|
||||
s.ensureHandlers()
|
||||
return executeUnary(ctx, s, "QuotePayments", s.h.commands.QuotePayments().Execute, req)
|
||||
}
|
||||
|
||||
// InitiatePayment captures a payment intent and reserves funds orchestration.
|
||||
func (s *Service) InitiatePayment(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error) {
|
||||
s.ensureHandlers()
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||
quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
@@ -106,51 +107,74 @@ type quoteResolutionError struct {
|
||||
|
||||
func (e quoteResolutionError) Error() string { return e.err.Error() }
|
||||
|
||||
func (s *Service) resolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, *orchestratorv1.PaymentIntent, error) {
|
||||
func (s *Service) resolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, *orchestratorv1.PaymentIntent, *model.PaymentPlan, error) {
|
||||
if ref := strings.TrimSpace(in.QuoteRef); ref != "" {
|
||||
quotesStore, err := ensureQuotesStore(s.storage)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
record, err := quotesStore.GetByRef(ctx, in.OrgID, ref)
|
||||
if err != nil {
|
||||
if errors.Is(err, storage.ErrQuoteNotFound) {
|
||||
return nil, nil, quoteResolutionError{code: "quote_not_found", err: merrors.InvalidArgument("quote_ref not found or expired")}
|
||||
return nil, nil, nil, quoteResolutionError{code: "quote_not_found", err: merrors.InvalidArgument("quote_ref not found or expired")}
|
||||
}
|
||||
return nil, nil, err
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
if !record.ExpiresAt.IsZero() && s.clock.Now().After(record.ExpiresAt) {
|
||||
return nil, nil, quoteResolutionError{code: "quote_expired", err: merrors.InvalidArgument("quote_ref expired")}
|
||||
return nil, nil, nil, quoteResolutionError{code: "quote_expired", err: merrors.InvalidArgument("quote_ref expired")}
|
||||
}
|
||||
intent, err := recordIntentFromQuote(record)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
if in.Intent != nil && !proto.Equal(intent, in.Intent) {
|
||||
return nil, nil, quoteResolutionError{code: "quote_intent_mismatch", err: merrors.InvalidArgument("quote_ref does not match intent")}
|
||||
return nil, nil, nil, quoteResolutionError{code: "quote_intent_mismatch", err: merrors.InvalidArgument("quote_ref does not match intent")}
|
||||
}
|
||||
quote, err := recordQuoteFromQuote(record)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
quote.QuoteRef = ref
|
||||
return quote, intent, nil
|
||||
plan, err := recordPlanFromQuote(record)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
return quote, intent, plan, nil
|
||||
}
|
||||
|
||||
if in.Intent == nil {
|
||||
return nil, nil, merrors.InvalidArgument("intent is required")
|
||||
return nil, nil, nil, merrors.InvalidArgument("intent is required")
|
||||
}
|
||||
req := &orchestratorv1.QuotePaymentRequest{
|
||||
req := "ationv1.QuotePaymentRequest{
|
||||
Meta: in.Meta,
|
||||
IdempotencyKey: in.IdempotencyKey,
|
||||
Intent: in.Intent,
|
||||
PreviewOnly: false,
|
||||
}
|
||||
quote, _, err := s.buildPaymentQuote(ctx, in.OrgRef, req)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
if !s.deps.quotation.available() {
|
||||
return nil, nil, nil, errQuotationUnavailable
|
||||
}
|
||||
return quote, in.Intent, nil
|
||||
quoteResp, err := s.deps.quotation.client.QuotePayment(ctx, req)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
quote := quoteResp.GetQuote()
|
||||
if quote == nil {
|
||||
return nil, nil, nil, merrors.InvalidArgument("stored quote is empty")
|
||||
}
|
||||
ref := strings.TrimSpace(quote.GetQuoteRef())
|
||||
if ref == "" {
|
||||
return nil, nil, nil, merrors.InvalidArgument("quotation response does not include quote_ref")
|
||||
}
|
||||
|
||||
return s.resolvePaymentQuote(ctx, quoteResolutionInput{
|
||||
OrgRef: in.OrgRef,
|
||||
OrgID: in.OrgID,
|
||||
Meta: in.Meta,
|
||||
Intent: in.Intent,
|
||||
QuoteRef: ref,
|
||||
IdempotencyKey: in.IdempotencyKey,
|
||||
})
|
||||
}
|
||||
|
||||
func recordIntentFromQuote(record *model.PaymentQuoteRecord) (*orchestratorv1.PaymentIntent, error) {
|
||||
@@ -185,6 +209,22 @@ func recordQuoteFromQuote(record *model.PaymentQuoteRecord) (*orchestratorv1.Pay
|
||||
return nil, merrors.InvalidArgument("stored quote is empty")
|
||||
}
|
||||
|
||||
func recordPlanFromQuote(record *model.PaymentQuoteRecord) (*model.PaymentPlan, error) {
|
||||
if record == nil {
|
||||
return nil, merrors.InvalidArgument("stored quote payload is incomplete")
|
||||
}
|
||||
if len(record.Plans) > 0 {
|
||||
if len(record.Plans) != 1 {
|
||||
return nil, merrors.InvalidArgument("stored quote payload is incomplete")
|
||||
}
|
||||
return cloneStoredPaymentPlan(record.Plans[0]), nil
|
||||
}
|
||||
if record.Plan != nil {
|
||||
return cloneStoredPaymentPlan(record.Plan), nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func newPayment(orgID bson.ObjectID, intent *orchestratorv1.PaymentIntent, idempotencyKey string, metadata map[string]string, quote *orchestratorv1.PaymentQuote) *model.Payment {
|
||||
entity := &model.Payment{}
|
||||
entity.SetID(bson.NewObjectID())
|
||||
|
||||
@@ -5,18 +5,20 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
||||
ledgerclient "github.com/tech/sendico/ledger/client"
|
||||
"github.com/tech/sendico/payments/storage"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
clockpkg "github.com/tech/sendico/pkg/clock"
|
||||
mloggerfactory "github.com/tech/sendico/pkg/mlogger/factory"
|
||||
"github.com/tech/sendico/pkg/model/account_role"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
|
||||
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
|
||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||
quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/protobuf/types/known/structpb"
|
||||
)
|
||||
|
||||
@@ -81,7 +83,7 @@ func TestResolvePaymentQuote_NotFound(t *testing.T) {
|
||||
storage: stubRepo{quotes: &helperQuotesStore{}},
|
||||
clock: clockpkg.NewSystem(),
|
||||
}
|
||||
_, _, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{
|
||||
_, _, _, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{
|
||||
OrgRef: org.Hex(),
|
||||
OrgID: org,
|
||||
Meta: &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()},
|
||||
@@ -110,7 +112,7 @@ func TestResolvePaymentQuote_Expired(t *testing.T) {
|
||||
storage: stubRepo{quotes: &helperQuotesStore{records: map[string]*model.PaymentQuoteRecord{"q1": record}}},
|
||||
clock: clockpkg.NewSystem(),
|
||||
}
|
||||
_, _, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{
|
||||
_, _, _, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{
|
||||
OrgRef: org.Hex(),
|
||||
OrgID: org,
|
||||
Meta: &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()},
|
||||
@@ -136,7 +138,7 @@ func TestResolvePaymentQuote_QuoteRefUsesStoredIntent(t *testing.T) {
|
||||
storage: stubRepo{quotes: &helperQuotesStore{records: map[string]*model.PaymentQuoteRecord{"q1": record}}},
|
||||
clock: clockpkg.NewSystem(),
|
||||
}
|
||||
quote, resolvedIntent, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{
|
||||
quote, resolvedIntent, _, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{
|
||||
OrgRef: org.Hex(),
|
||||
OrgID: org,
|
||||
Meta: &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()},
|
||||
@@ -166,23 +168,12 @@ func TestResolvePaymentQuote_QuoteRefSkipsQuoteRecompute(t *testing.T) {
|
||||
Quote: &model.PaymentQuoteSnapshot{},
|
||||
}
|
||||
|
||||
feeFake := &feeEngineFake{}
|
||||
oracleCalls := 0
|
||||
svc := &Service{
|
||||
storage: stubRepo{quotes: &helperQuotesStore{records: map[string]*model.PaymentQuoteRecord{"q1": record}}},
|
||||
clock: clockpkg.NewSystem(),
|
||||
deps: serviceDependencies{
|
||||
fees: feesDependency{client: feeFake},
|
||||
oracle: oracleDependency{client: &oracleclient.Fake{
|
||||
GetQuoteFn: func(ctx context.Context, params oracleclient.GetQuoteParams) (*oracleclient.Quote, error) {
|
||||
oracleCalls++
|
||||
return &oracleclient.Quote{QuoteRef: "q1", ExpiresAt: time.Now()}, nil
|
||||
},
|
||||
}},
|
||||
},
|
||||
}
|
||||
|
||||
_, _, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{
|
||||
_, _, _, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{
|
||||
OrgRef: org.Hex(),
|
||||
OrgID: org,
|
||||
Meta: &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()},
|
||||
@@ -191,18 +182,44 @@ func TestResolvePaymentQuote_QuoteRefSkipsQuoteRecompute(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if feeFake.precomputeCalls != 0 {
|
||||
t.Fatalf("expected no fee recompute, got %d", feeFake.precomputeCalls)
|
||||
}
|
||||
if oracleCalls != 0 {
|
||||
t.Fatalf("expected no fx recompute, got %d", oracleCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitiatePaymentIdempotency(t *testing.T) {
|
||||
logger := mloggerfactory.NewLogger(false)
|
||||
org := bson.NewObjectID()
|
||||
store := newHelperPaymentStore()
|
||||
intent := &orchestratorv1.PaymentIntent{
|
||||
Ref: "ref-1",
|
||||
Source: &orchestratorv1.PaymentEndpoint{
|
||||
Endpoint: &orchestratorv1.PaymentEndpoint_Ledger{Ledger: &orchestratorv1.LedgerEndpoint{LedgerAccountRef: "ledger:source"}},
|
||||
},
|
||||
Destination: &orchestratorv1.PaymentEndpoint{
|
||||
Endpoint: &orchestratorv1.PaymentEndpoint_Ledger{Ledger: &orchestratorv1.LedgerEndpoint{LedgerAccountRef: "ledger:dest"}},
|
||||
},
|
||||
Amount: &moneyv1.Money{Currency: "USD", Amount: "1"},
|
||||
SettlementCurrency: "USD",
|
||||
}
|
||||
record := &model.PaymentQuoteRecord{
|
||||
QuoteRef: "q1",
|
||||
Intent: intentFromProto(intent),
|
||||
Quote: &model.PaymentQuoteSnapshot{
|
||||
DebitAmount: &paymenttypes.Money{Currency: "USD", Amount: "1"},
|
||||
ExpectedSettlementAmount: &paymenttypes.Money{Currency: "USD", Amount: "1"},
|
||||
QuoteRef: "q1",
|
||||
},
|
||||
Plan: &model.PaymentPlan{
|
||||
Steps: []*model.PaymentStep{
|
||||
{
|
||||
StepID: "ledger_move",
|
||||
Rail: model.RailLedger,
|
||||
Action: model.RailOperationMove,
|
||||
Amount: &paymenttypes.Money{Currency: "USD", Amount: "1"},
|
||||
FromRole: rolePtr(account_role.AccountRoleOperating),
|
||||
ToRole: rolePtr(account_role.AccountRoleTransit),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
ledgerFake := &ledgerclient.Fake{
|
||||
ListConnectorAccountsFn: func(ctx context.Context, req *connectorv1.ListAccountsRequest) (*connectorv1.ListAccountsResponse, error) {
|
||||
operatingDetails, _ := structpb.NewStruct(map[string]interface{}{"role": "ACCOUNT_ROLE_OPERATING"})
|
||||
@@ -218,41 +235,23 @@ func TestInitiatePaymentIdempotency(t *testing.T) {
|
||||
return &ledgerv1.PostResponse{JournalEntryRef: "move-1"}, nil
|
||||
},
|
||||
}
|
||||
routes := &stubRoutesStore{
|
||||
routes: []*model.PaymentRoute{
|
||||
{FromRail: model.RailLedger, ToRail: model.RailLedger, IsEnabled: true},
|
||||
},
|
||||
}
|
||||
plans := &stubPlanTemplatesStore{
|
||||
templates: []*model.PaymentPlanTemplate{
|
||||
{
|
||||
FromRail: model.RailLedger,
|
||||
ToRail: model.RailLedger,
|
||||
IsEnabled: true,
|
||||
Steps: []model.OrchestrationStep{
|
||||
{StepID: "ledger_move", Rail: model.RailLedger, Operation: "ledger.move", FromRole: rolePtr(account_role.AccountRoleOperating), ToRole: rolePtr(account_role.AccountRoleTransit)},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
svc := NewService(logger, stubRepo{
|
||||
payments: store,
|
||||
routes: routes,
|
||||
plans: plans,
|
||||
}, WithClock(clockpkg.NewSystem()), WithLedgerClient(ledgerFake))
|
||||
quotes: &helperQuotesStore{records: map[string]*model.PaymentQuoteRecord{"q1": record}},
|
||||
}, WithClock(clockpkg.NewSystem()), WithLedgerClient(ledgerFake), WithQuotationService(&helperQuotationClient{
|
||||
quotePaymentFn: func(ctx context.Context, req *quotationv1.QuotePaymentRequest, opts ...grpc.CallOption) (*quotationv1.QuotePaymentResponse, error) {
|
||||
amount := req.GetIntent().GetAmount()
|
||||
return "ationv1.QuotePaymentResponse{
|
||||
Quote: &orchestratorv1.PaymentQuote{
|
||||
QuoteRef: "q1",
|
||||
DebitAmount: amount,
|
||||
ExpectedSettlementAmount: amount,
|
||||
},
|
||||
IdempotencyKey: req.GetIdempotencyKey(),
|
||||
}, nil
|
||||
},
|
||||
}))
|
||||
svc.ensureHandlers()
|
||||
|
||||
intent := &orchestratorv1.PaymentIntent{
|
||||
Ref: "ref-1",
|
||||
Source: &orchestratorv1.PaymentEndpoint{
|
||||
Endpoint: &orchestratorv1.PaymentEndpoint_Ledger{Ledger: &orchestratorv1.LedgerEndpoint{LedgerAccountRef: "ledger:source"}},
|
||||
},
|
||||
Destination: &orchestratorv1.PaymentEndpoint{
|
||||
Endpoint: &orchestratorv1.PaymentEndpoint_Ledger{Ledger: &orchestratorv1.LedgerEndpoint{LedgerAccountRef: "ledger:dest"}},
|
||||
},
|
||||
Amount: &moneyv1.Money{Currency: "USD", Amount: "1"},
|
||||
SettlementCurrency: "USD",
|
||||
}
|
||||
req := &orchestratorv1.InitiatePaymentRequest{
|
||||
Meta: &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()},
|
||||
Intent: intent,
|
||||
@@ -289,7 +288,19 @@ func TestInitiatePaymentByQuoteRef(t *testing.T) {
|
||||
record := &model.PaymentQuoteRecord{
|
||||
QuoteRef: "q1",
|
||||
Intent: intentFromProto(intent),
|
||||
Quote: &model.PaymentQuoteSnapshot{},
|
||||
Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q1"},
|
||||
Plan: &model.PaymentPlan{
|
||||
Steps: []*model.PaymentStep{
|
||||
{
|
||||
StepID: "ledger_move",
|
||||
Rail: model.RailLedger,
|
||||
Action: model.RailOperationMove,
|
||||
Amount: &paymenttypes.Money{Currency: "USD", Amount: "1"},
|
||||
FromRole: rolePtr(account_role.AccountRoleOperating),
|
||||
ToRole: rolePtr(account_role.AccountRoleTransit),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
ledgerFake := &ledgerclient.Fake{
|
||||
ListConnectorAccountsFn: func(ctx context.Context, req *connectorv1.ListAccountsRequest) (*connectorv1.ListAccountsResponse, error) {
|
||||
@@ -306,28 +317,9 @@ func TestInitiatePaymentByQuoteRef(t *testing.T) {
|
||||
return &ledgerv1.PostResponse{JournalEntryRef: "move-1"}, nil
|
||||
},
|
||||
}
|
||||
routes := &stubRoutesStore{
|
||||
routes: []*model.PaymentRoute{
|
||||
{FromRail: model.RailLedger, ToRail: model.RailLedger, IsEnabled: true},
|
||||
},
|
||||
}
|
||||
plans := &stubPlanTemplatesStore{
|
||||
templates: []*model.PaymentPlanTemplate{
|
||||
{
|
||||
FromRail: model.RailLedger,
|
||||
ToRail: model.RailLedger,
|
||||
IsEnabled: true,
|
||||
Steps: []model.OrchestrationStep{
|
||||
{StepID: "ledger_move", Rail: model.RailLedger, Operation: "ledger.move", FromRole: rolePtr(account_role.AccountRoleOperating), ToRole: rolePtr(account_role.AccountRoleTransit)},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
svc := NewService(logger, stubRepo{
|
||||
payments: store,
|
||||
quotes: &helperQuotesStore{records: map[string]*model.PaymentQuoteRecord{"q1": record}},
|
||||
routes: routes,
|
||||
plans: plans,
|
||||
}, WithClock(clockpkg.NewSystem()), WithLedgerClient(ledgerFake))
|
||||
svc.ensureHandlers()
|
||||
|
||||
@@ -470,6 +462,33 @@ func (s *helperQuotesStore) GetByIdempotencyKey(_ context.Context, orgRef bson.O
|
||||
return nil, storage.ErrQuoteNotFound
|
||||
}
|
||||
|
||||
type helperQuotationClient struct {
|
||||
quotePaymentFn func(ctx context.Context, req *quotationv1.QuotePaymentRequest, opts ...grpc.CallOption) (*quotationv1.QuotePaymentResponse, error)
|
||||
quotePaymentsFn func(ctx context.Context, req *quotationv1.QuotePaymentsRequest, opts ...grpc.CallOption) (*quotationv1.QuotePaymentsResponse, error)
|
||||
}
|
||||
|
||||
func (c *helperQuotationClient) QuotePayment(ctx context.Context, req *quotationv1.QuotePaymentRequest, opts ...grpc.CallOption) (*quotationv1.QuotePaymentResponse, error) {
|
||||
if c.quotePaymentFn != nil {
|
||||
return c.quotePaymentFn(ctx, req, opts...)
|
||||
}
|
||||
return "ationv1.QuotePaymentResponse{}, nil
|
||||
}
|
||||
|
||||
func (c *helperQuotationClient) QuotePayments(ctx context.Context, req *quotationv1.QuotePaymentsRequest, opts ...grpc.CallOption) (*quotationv1.QuotePaymentsResponse, error) {
|
||||
if c.quotePaymentsFn != nil {
|
||||
return c.quotePaymentsFn(ctx, req, opts...)
|
||||
}
|
||||
return "ationv1.QuotePaymentsResponse{}, nil
|
||||
}
|
||||
|
||||
func rolePtr(role account_role.AccountRole) *account_role.AccountRole {
|
||||
return &role
|
||||
}
|
||||
|
||||
type stubGatewayRegistry struct {
|
||||
items []*model.GatewayInstanceDescriptor
|
||||
}
|
||||
|
||||
func (s *stubGatewayRegistry) List(_ context.Context) ([]*model.GatewayInstanceDescriptor, error) {
|
||||
return s.items, nil
|
||||
}
|
||||
|
||||
@@ -64,7 +64,8 @@ func TestExecutePayment_FXConversionSettled(t *testing.T) {
|
||||
Type: model.EndpointTypeLedger,
|
||||
Ledger: &model.LedgerEndpoint{LedgerAccountRef: "ledger:dest"},
|
||||
},
|
||||
Amount: &paymenttypes.Money{Currency: "USD", Amount: "100"},
|
||||
Amount: &paymenttypes.Money{Currency: "USD", Amount: "100"},
|
||||
SettlementCurrency: "USD",
|
||||
},
|
||||
}
|
||||
store.payments[payment.PaymentRef] = payment
|
||||
@@ -77,6 +78,15 @@ func TestExecutePayment_FXConversionSettled(t *testing.T) {
|
||||
Price: &moneyv1.Decimal{Value: "0.9"},
|
||||
},
|
||||
}
|
||||
attachStoredPlan(payment, &model.PaymentPlan{
|
||||
Steps: []*model.PaymentStep{
|
||||
{
|
||||
StepID: "fx_convert",
|
||||
Rail: model.RailLedger,
|
||||
Action: model.RailOperationFXConvert,
|
||||
},
|
||||
},
|
||||
}, payment.IdempotencyKey)
|
||||
if err := svc.executePayment(ctx, store, payment, quote); err != nil {
|
||||
t.Fatalf("executePayment returned error: %v", err)
|
||||
}
|
||||
@@ -174,11 +184,22 @@ func TestExecutePayment_ChainFailure(t *testing.T) {
|
||||
Type: model.EndpointTypeLedger,
|
||||
Ledger: &model.LedgerEndpoint{LedgerAccountRef: "ledger:dest"},
|
||||
},
|
||||
Amount: &paymenttypes.Money{Currency: "USDT", Amount: "50"},
|
||||
Amount: &paymenttypes.Money{Currency: "USDT", Amount: "50"},
|
||||
SettlementCurrency: "USDT",
|
||||
},
|
||||
}
|
||||
store.payments[payment.PaymentRef] = payment
|
||||
|
||||
attachStoredPlan(payment, &model.PaymentPlan{
|
||||
Steps: []*model.PaymentStep{
|
||||
{
|
||||
StepID: "crypto_send",
|
||||
Rail: model.RailCrypto,
|
||||
Action: model.RailOperationSend,
|
||||
Amount: &paymenttypes.Money{Currency: "USDT", Amount: "50"},
|
||||
},
|
||||
},
|
||||
}, payment.IdempotencyKey)
|
||||
err := svc.executePayment(ctx, store, payment, &orchestratorv1.PaymentQuote{})
|
||||
if err == nil || err.Error() != "chain failure" {
|
||||
t.Fatalf("expected chain failure error, got %v", err)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package orchestrator
|
||||
package plan_builder
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -15,7 +15,8 @@ type defaultPlanBuilder struct {
|
||||
logger mlogger.Logger
|
||||
}
|
||||
|
||||
func newDefaultPlanBuilder(logger mlogger.Logger) *defaultPlanBuilder {
|
||||
// New constructs the default plan builder.
|
||||
func New(logger mlogger.Logger) *defaultPlanBuilder {
|
||||
return &defaultPlanBuilder{
|
||||
logger: logger.Named("plan_builder"),
|
||||
}
|
||||
@@ -77,7 +78,7 @@ func (b *defaultPlanBuilder) Build(ctx context.Context, payment *model.Payment,
|
||||
return nil, merrors.InvalidArgument("plan builder: unsupported same-rail payment")
|
||||
}
|
||||
|
||||
network, err := resolveRouteNetwork(intent.Attributes, sourceNetwork, destNetwork)
|
||||
network, err := ResolveRouteNetwork(intent.Attributes, sourceNetwork, destNetwork)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to resolve route network", zap.Error(err))
|
||||
return nil, err
|
||||
@@ -91,7 +92,7 @@ func (b *defaultPlanBuilder) Build(ctx context.Context, payment *model.Payment,
|
||||
}
|
||||
logger.Debug("Route selected", mzap.StorableRef(route))
|
||||
|
||||
template, err := selectPlanTemplate(ctx, logger, templates, sourceRail, destRail, network)
|
||||
template, err := SelectPlanTemplate(ctx, logger, templates, sourceRail, destRail, network)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to select plan template", zap.Error(err))
|
||||
return nil, err
|
||||
@@ -1,4 +1,4 @@
|
||||
package orchestrator
|
||||
package plan_builder
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
|
||||
func TestDefaultPlanBuilder_BuildsPlanFromRoutes_CryptoToCard(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
builder := newDefaultPlanBuilder(mloggerfactory.NewLogger(false))
|
||||
builder := New(mloggerfactory.NewLogger(false))
|
||||
|
||||
payment := &model.Payment{
|
||||
PaymentRef: "pay-1",
|
||||
@@ -138,7 +138,7 @@ func TestDefaultPlanBuilder_BuildsPlanFromRoutes_CryptoToCard(t *testing.T) {
|
||||
|
||||
func TestDefaultPlanBuilder_ErrorsWhenRouteMissing(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
builder := newDefaultPlanBuilder(mloggerfactory.NewLogger(false))
|
||||
builder := New(mloggerfactory.NewLogger(false))
|
||||
|
||||
payment := &model.Payment{
|
||||
PaymentRef: "pay-1",
|
||||
@@ -173,7 +173,7 @@ func TestDefaultPlanBuilder_ErrorsWhenRouteMissing(t *testing.T) {
|
||||
|
||||
func TestBuildPlanFromTemplate_ProviderSettlementUsesNetAmountWhenFixReceived(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
builder := newDefaultPlanBuilder(mloggerfactory.NewLogger(false))
|
||||
builder := New(mloggerfactory.NewLogger(false))
|
||||
|
||||
payment := &model.Payment{
|
||||
PaymentRef: "pay-settle-1",
|
||||
@@ -237,7 +237,7 @@ func TestBuildPlanFromTemplate_ProviderSettlementUsesNetAmountWhenFixReceived(t
|
||||
|
||||
func TestDefaultPlanBuilder_UsesSourceCurrencyForCryptoSendWithFX(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
builder := newDefaultPlanBuilder(mloggerfactory.NewLogger(false))
|
||||
builder := New(mloggerfactory.NewLogger(false))
|
||||
|
||||
payment := &model.Payment{
|
||||
PaymentRef: "pay-2",
|
||||
@@ -423,6 +423,10 @@ func (s *stubGatewayRegistry) List(_ context.Context) ([]*model.GatewayInstanceD
|
||||
return s.items, nil
|
||||
}
|
||||
|
||||
func rolePtr(role account_role.AccountRole) *account_role.AccountRole {
|
||||
return &role
|
||||
}
|
||||
|
||||
func assertPlanStep(t *testing.T, step *model.PaymentStep, stepID string, rail model.Rail, action model.RailOperation, gatewayID, instanceID, currency, amount string) {
|
||||
t.Helper()
|
||||
if step == nil {
|
||||
@@ -1,4 +1,4 @@
|
||||
package orchestrator
|
||||
package plan_builder
|
||||
|
||||
import (
|
||||
"strings"
|
||||
@@ -1,4 +1,4 @@
|
||||
package orchestrator
|
||||
package plan_builder
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -0,0 +1,451 @@
|
||||
package plan_builder
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/model/account_role"
|
||||
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"
|
||||
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
|
||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||
)
|
||||
|
||||
type moneyGetter interface {
|
||||
GetAmount() string
|
||||
GetCurrency() string
|
||||
}
|
||||
|
||||
func cloneMoney(input *paymenttypes.Money) *paymenttypes.Money {
|
||||
if input == nil {
|
||||
return nil
|
||||
}
|
||||
return &paymenttypes.Money{
|
||||
Currency: input.GetCurrency(),
|
||||
Amount: input.GetAmount(),
|
||||
}
|
||||
}
|
||||
|
||||
func cloneStringList(values []string) []string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
clean := strings.TrimSpace(value)
|
||||
if clean == "" {
|
||||
continue
|
||||
}
|
||||
result = append(result, clean)
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func cloneMetadata(input map[string]string) map[string]string {
|
||||
if len(input) == 0 {
|
||||
return nil
|
||||
}
|
||||
clone := make(map[string]string, len(input))
|
||||
for k, v := range input {
|
||||
clone[k] = v
|
||||
}
|
||||
return clone
|
||||
}
|
||||
|
||||
func cloneAccountRole(role *account_role.AccountRole) *account_role.AccountRole {
|
||||
if role == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := *role
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func decimalFromMoney(m moneyGetter) (decimal.Decimal, error) {
|
||||
if m == nil {
|
||||
return decimal.Zero, nil
|
||||
}
|
||||
return decimal.NewFromString(m.GetAmount())
|
||||
}
|
||||
|
||||
func moneyFromProto(m *moneyv1.Money) *paymenttypes.Money {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
return &paymenttypes.Money{
|
||||
Currency: m.GetCurrency(),
|
||||
Amount: m.GetAmount(),
|
||||
}
|
||||
}
|
||||
|
||||
func protoMoney(m *paymenttypes.Money) *moneyv1.Money {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
return &moneyv1.Money{
|
||||
Currency: m.GetCurrency(),
|
||||
Amount: m.GetAmount(),
|
||||
}
|
||||
}
|
||||
|
||||
func cloneProtoMoney(input *moneyv1.Money) *moneyv1.Money {
|
||||
if input == nil {
|
||||
return nil
|
||||
}
|
||||
return &moneyv1.Money{
|
||||
Currency: input.GetCurrency(),
|
||||
Amount: input.GetAmount(),
|
||||
}
|
||||
}
|
||||
|
||||
func makeMoney(currency string, value decimal.Decimal) *moneyv1.Money {
|
||||
return &moneyv1.Money{
|
||||
Currency: currency,
|
||||
Amount: value.String(),
|
||||
}
|
||||
}
|
||||
|
||||
func ensureCurrency(m *moneyv1.Money, targetCurrency string, quote *oraclev1.Quote) (*moneyv1.Money, error) {
|
||||
if m == nil || strings.TrimSpace(targetCurrency) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if strings.EqualFold(m.GetCurrency(), targetCurrency) {
|
||||
return cloneProtoMoney(m), nil
|
||||
}
|
||||
return convertWithQuote(m, quote, targetCurrency)
|
||||
}
|
||||
|
||||
func convertWithQuote(m *moneyv1.Money, quote *oraclev1.Quote, targetCurrency string) (*moneyv1.Money, error) {
|
||||
if m == nil || quote == nil || quote.GetPair() == nil || quote.GetPrice() == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
base := strings.TrimSpace(quote.GetPair().GetBase())
|
||||
qt := strings.TrimSpace(quote.GetPair().GetQuote())
|
||||
if base == "" || qt == "" || strings.TrimSpace(targetCurrency) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
price, err := decimal.NewFromString(quote.GetPrice().GetValue())
|
||||
if err != nil || price.IsZero() {
|
||||
return nil, err
|
||||
}
|
||||
value, err := decimalFromMoney(m)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.EqualFold(m.GetCurrency(), base) && strings.EqualFold(targetCurrency, qt):
|
||||
return makeMoney(targetCurrency, value.Mul(price)), nil
|
||||
case strings.EqualFold(m.GetCurrency(), qt) && strings.EqualFold(targetCurrency, base):
|
||||
return makeMoney(targetCurrency, value.Div(price)), nil
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
func attributeLookup(attrs map[string]string, keys ...string) string {
|
||||
if len(keys) == 0 {
|
||||
return ""
|
||||
}
|
||||
for _, key := range keys {
|
||||
if key == "" || attrs == nil {
|
||||
continue
|
||||
}
|
||||
if val := strings.TrimSpace(attrs[key]); val != "" {
|
||||
return val
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func cardPayoutAmount(payment *model.Payment) (*paymenttypes.Money, error) {
|
||||
if payment == nil {
|
||||
return nil, merrors.InvalidArgument("payment is required")
|
||||
}
|
||||
amount := cloneMoney(payment.Intent.Amount)
|
||||
if payment.LastQuote != nil {
|
||||
settlement := payment.LastQuote.ExpectedSettlementAmount
|
||||
if settlement != nil && strings.TrimSpace(settlement.GetAmount()) != "" && strings.TrimSpace(settlement.GetCurrency()) != "" {
|
||||
amount = cloneMoney(settlement)
|
||||
}
|
||||
}
|
||||
if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" {
|
||||
return nil, merrors.InvalidArgument("card payout: amount is required")
|
||||
}
|
||||
return amount, nil
|
||||
}
|
||||
|
||||
func executionQuote(payment *model.Payment, quote *orchestratorv1.PaymentQuote) *orchestratorv1.PaymentQuote {
|
||||
if quote != nil {
|
||||
return quote
|
||||
}
|
||||
if payment != nil && payment.LastQuote != nil {
|
||||
return modelQuoteToProto(payment.LastQuote)
|
||||
}
|
||||
return &orchestratorv1.PaymentQuote{}
|
||||
}
|
||||
|
||||
func modelQuoteToProto(src *model.PaymentQuoteSnapshot) *orchestratorv1.PaymentQuote {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
return &orchestratorv1.PaymentQuote{
|
||||
DebitAmount: protoMoney(src.DebitAmount),
|
||||
DebitSettlementAmount: protoMoney(src.DebitSettlementAmount),
|
||||
ExpectedSettlementAmount: protoMoney(src.ExpectedSettlementAmount),
|
||||
ExpectedFeeTotal: protoMoney(src.ExpectedFeeTotal),
|
||||
FeeLines: feeLinesToProto(src.FeeLines),
|
||||
FeeRules: feeRulesToProto(src.FeeRules),
|
||||
FxQuote: fxQuoteToProto(src.FXQuote),
|
||||
NetworkFee: networkFeeToProto(src.NetworkFee),
|
||||
QuoteRef: strings.TrimSpace(src.QuoteRef),
|
||||
}
|
||||
}
|
||||
|
||||
func networkFeeToProto(resp *paymenttypes.NetworkFeeEstimate) *chainv1.EstimateTransferFeeResponse {
|
||||
if resp == nil {
|
||||
return nil
|
||||
}
|
||||
return &chainv1.EstimateTransferFeeResponse{
|
||||
NetworkFee: protoMoney(resp.NetworkFee),
|
||||
EstimationContext: strings.TrimSpace(resp.EstimationContext),
|
||||
}
|
||||
}
|
||||
|
||||
func fxQuoteFromProto(quote *oraclev1.Quote) *paymenttypes.FXQuote {
|
||||
if quote == nil {
|
||||
return nil
|
||||
}
|
||||
return &paymenttypes.FXQuote{
|
||||
QuoteRef: strings.TrimSpace(quote.GetQuoteRef()),
|
||||
Pair: pairFromProto(quote.GetPair()),
|
||||
Side: fxSideFromProto(quote.GetSide()),
|
||||
Price: decimalFromProto(quote.GetPrice()),
|
||||
BaseAmount: moneyFromProto(quote.GetBaseAmount()),
|
||||
QuoteAmount: moneyFromProto(quote.GetQuoteAmount()),
|
||||
ExpiresAtUnixMs: quote.GetExpiresAtUnixMs(),
|
||||
Provider: strings.TrimSpace(quote.GetProvider()),
|
||||
RateRef: strings.TrimSpace(quote.GetRateRef()),
|
||||
Firm: quote.GetFirm(),
|
||||
}
|
||||
}
|
||||
|
||||
func fxQuoteToProto(quote *paymenttypes.FXQuote) *oraclev1.Quote {
|
||||
if quote == nil {
|
||||
return nil
|
||||
}
|
||||
return &oraclev1.Quote{
|
||||
QuoteRef: strings.TrimSpace(quote.QuoteRef),
|
||||
Pair: pairToProto(quote.Pair),
|
||||
Side: fxSideToProto(quote.Side),
|
||||
Price: decimalToProto(quote.Price),
|
||||
BaseAmount: protoMoney(quote.BaseAmount),
|
||||
QuoteAmount: protoMoney(quote.QuoteAmount),
|
||||
ExpiresAtUnixMs: quote.ExpiresAtUnixMs,
|
||||
Provider: strings.TrimSpace(quote.Provider),
|
||||
RateRef: strings.TrimSpace(quote.RateRef),
|
||||
Firm: quote.Firm,
|
||||
}
|
||||
}
|
||||
|
||||
func feeLinesFromProto(lines []*feesv1.DerivedPostingLine) []*paymenttypes.FeeLine {
|
||||
if len(lines) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]*paymenttypes.FeeLine, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
if line == nil {
|
||||
continue
|
||||
}
|
||||
result = append(result, &paymenttypes.FeeLine{
|
||||
LedgerAccountRef: strings.TrimSpace(line.GetLedgerAccountRef()),
|
||||
Money: moneyFromProto(line.GetMoney()),
|
||||
LineType: postingLineTypeFromProto(line.GetLineType()),
|
||||
Side: entrySideFromProto(line.GetSide()),
|
||||
Meta: cloneMetadata(line.GetMeta()),
|
||||
})
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func feeLinesToProto(lines []*paymenttypes.FeeLine) []*feesv1.DerivedPostingLine {
|
||||
if len(lines) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]*feesv1.DerivedPostingLine, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
if line == nil {
|
||||
continue
|
||||
}
|
||||
result = append(result, &feesv1.DerivedPostingLine{
|
||||
LedgerAccountRef: strings.TrimSpace(line.LedgerAccountRef),
|
||||
Money: protoMoney(line.Money),
|
||||
LineType: postingLineTypeToProto(line.LineType),
|
||||
Side: entrySideToProto(line.Side),
|
||||
Meta: cloneMetadata(line.Meta),
|
||||
})
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func feeRulesToProto(rules []*paymenttypes.AppliedRule) []*feesv1.AppliedRule {
|
||||
if len(rules) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]*feesv1.AppliedRule, 0, len(rules))
|
||||
for _, rule := range rules {
|
||||
if rule == nil {
|
||||
continue
|
||||
}
|
||||
result = append(result, &feesv1.AppliedRule{
|
||||
RuleId: strings.TrimSpace(rule.RuleID),
|
||||
RuleVersion: strings.TrimSpace(rule.RuleVersion),
|
||||
Formula: strings.TrimSpace(rule.Formula),
|
||||
Rounding: roundingModeToProto(rule.Rounding),
|
||||
TaxCode: strings.TrimSpace(rule.TaxCode),
|
||||
TaxRate: strings.TrimSpace(rule.TaxRate),
|
||||
Parameters: cloneMetadata(rule.Parameters),
|
||||
})
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func pairFromProto(pair *fxv1.CurrencyPair) *paymenttypes.CurrencyPair {
|
||||
if pair == nil {
|
||||
return nil
|
||||
}
|
||||
return &paymenttypes.CurrencyPair{
|
||||
Base: pair.GetBase(),
|
||||
Quote: pair.GetQuote(),
|
||||
}
|
||||
}
|
||||
|
||||
func pairToProto(pair *paymenttypes.CurrencyPair) *fxv1.CurrencyPair {
|
||||
if pair == nil {
|
||||
return nil
|
||||
}
|
||||
return &fxv1.CurrencyPair{
|
||||
Base: pair.GetBase(),
|
||||
Quote: pair.GetQuote(),
|
||||
}
|
||||
}
|
||||
|
||||
func fxSideFromProto(side fxv1.Side) paymenttypes.FXSide {
|
||||
switch side {
|
||||
case fxv1.Side_BUY_BASE_SELL_QUOTE:
|
||||
return paymenttypes.FXSideBuyBaseSellQuote
|
||||
case fxv1.Side_SELL_BASE_BUY_QUOTE:
|
||||
return paymenttypes.FXSideSellBaseBuyQuote
|
||||
default:
|
||||
return paymenttypes.FXSideUnspecified
|
||||
}
|
||||
}
|
||||
|
||||
func fxSideToProto(side paymenttypes.FXSide) fxv1.Side {
|
||||
switch side {
|
||||
case paymenttypes.FXSideBuyBaseSellQuote:
|
||||
return fxv1.Side_BUY_BASE_SELL_QUOTE
|
||||
case paymenttypes.FXSideSellBaseBuyQuote:
|
||||
return fxv1.Side_SELL_BASE_BUY_QUOTE
|
||||
default:
|
||||
return fxv1.Side_SIDE_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
func decimalFromProto(value *moneyv1.Decimal) *paymenttypes.Decimal {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return &paymenttypes.Decimal{Value: value.GetValue()}
|
||||
}
|
||||
|
||||
func decimalToProto(value *paymenttypes.Decimal) *moneyv1.Decimal {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return &moneyv1.Decimal{Value: value.GetValue()}
|
||||
}
|
||||
|
||||
func postingLineTypeFromProto(lineType accountingv1.PostingLineType) paymenttypes.PostingLineType {
|
||||
switch lineType {
|
||||
case accountingv1.PostingLineType_POSTING_LINE_FEE:
|
||||
return paymenttypes.PostingLineTypeFee
|
||||
case accountingv1.PostingLineType_POSTING_LINE_TAX:
|
||||
return paymenttypes.PostingLineTypeTax
|
||||
case accountingv1.PostingLineType_POSTING_LINE_SPREAD:
|
||||
return paymenttypes.PostingLineTypeSpread
|
||||
case accountingv1.PostingLineType_POSTING_LINE_REVERSAL:
|
||||
return paymenttypes.PostingLineTypeReversal
|
||||
default:
|
||||
return paymenttypes.PostingLineTypeUnspecified
|
||||
}
|
||||
}
|
||||
|
||||
func postingLineTypeToProto(lineType paymenttypes.PostingLineType) accountingv1.PostingLineType {
|
||||
switch lineType {
|
||||
case paymenttypes.PostingLineTypeFee:
|
||||
return accountingv1.PostingLineType_POSTING_LINE_FEE
|
||||
case paymenttypes.PostingLineTypeTax:
|
||||
return accountingv1.PostingLineType_POSTING_LINE_TAX
|
||||
case paymenttypes.PostingLineTypeSpread:
|
||||
return accountingv1.PostingLineType_POSTING_LINE_SPREAD
|
||||
case paymenttypes.PostingLineTypeReversal:
|
||||
return accountingv1.PostingLineType_POSTING_LINE_REVERSAL
|
||||
default:
|
||||
return accountingv1.PostingLineType_POSTING_LINE_TYPE_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
func entrySideFromProto(side accountingv1.EntrySide) paymenttypes.EntrySide {
|
||||
switch side {
|
||||
case accountingv1.EntrySide_ENTRY_SIDE_DEBIT:
|
||||
return paymenttypes.EntrySideDebit
|
||||
case accountingv1.EntrySide_ENTRY_SIDE_CREDIT:
|
||||
return paymenttypes.EntrySideCredit
|
||||
default:
|
||||
return paymenttypes.EntrySideUnspecified
|
||||
}
|
||||
}
|
||||
|
||||
func entrySideToProto(side paymenttypes.EntrySide) accountingv1.EntrySide {
|
||||
switch side {
|
||||
case paymenttypes.EntrySideDebit:
|
||||
return accountingv1.EntrySide_ENTRY_SIDE_DEBIT
|
||||
case paymenttypes.EntrySideCredit:
|
||||
return accountingv1.EntrySide_ENTRY_SIDE_CREDIT
|
||||
default:
|
||||
return accountingv1.EntrySide_ENTRY_SIDE_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
func roundingModeToProto(mode paymenttypes.RoundingMode) moneyv1.RoundingMode {
|
||||
switch mode {
|
||||
case paymenttypes.RoundingModeHalfEven:
|
||||
return moneyv1.RoundingMode_ROUND_HALF_EVEN
|
||||
case paymenttypes.RoundingModeHalfUp:
|
||||
return moneyv1.RoundingMode_ROUND_HALF_UP
|
||||
case paymenttypes.RoundingModeDown:
|
||||
return moneyv1.RoundingMode_ROUND_DOWN
|
||||
default:
|
||||
return moneyv1.RoundingMode_ROUNDING_MODE_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package plan_builder
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
)
|
||||
|
||||
// RouteStore exposes routing definitions for plan construction.
|
||||
type RouteStore interface {
|
||||
List(ctx context.Context, filter *model.PaymentRouteFilter) (*model.PaymentRouteList, error)
|
||||
}
|
||||
|
||||
// PlanTemplateStore exposes orchestration plan templates for plan construction.
|
||||
type PlanTemplateStore interface {
|
||||
List(ctx context.Context, filter *model.PaymentPlanTemplateFilter) (*model.PaymentPlanTemplateList, error)
|
||||
}
|
||||
|
||||
// GatewayRegistry exposes gateway instances for capability-based selection.
|
||||
type GatewayRegistry interface {
|
||||
List(ctx context.Context) ([]*model.GatewayInstanceDescriptor, error)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package orchestrator
|
||||
package plan_builder
|
||||
|
||||
import (
|
||||
"time"
|
||||
@@ -1,4 +1,4 @@
|
||||
package orchestrator
|
||||
package plan_builder
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -9,7 +9,9 @@ import (
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
)
|
||||
|
||||
func resolveRouteNetwork(attrs map[string]string, sourceNetwork, destNetwork string) (string, error) {
|
||||
// ResolveRouteNetwork determines the network for route selection from source/destination
|
||||
// networks and attribute overrides.
|
||||
func ResolveRouteNetwork(attrs map[string]string, sourceNetwork, destNetwork string) (string, error) {
|
||||
src := strings.ToUpper(strings.TrimSpace(sourceNetwork))
|
||||
dst := strings.ToUpper(strings.TrimSpace(destNetwork))
|
||||
if src != "" && dst != "" && !strings.EqualFold(src, dst) {
|
||||
@@ -1,4 +1,4 @@
|
||||
package orchestrator
|
||||
package plan_builder
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/model/account_role"
|
||||
"github.com/tech/sendico/pkg/mutil/mzap"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
|
||||
@@ -353,14 +352,6 @@ func observeAmountForRail(rail model.Rail, source, settlement, payout *paymentty
|
||||
return source
|
||||
}
|
||||
|
||||
func cloneAccountRole(role *account_role.AccountRole) *account_role.AccountRole {
|
||||
if role == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := *role
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func netSourceAmount(sourceAmount, feeAmount *paymenttypes.Money, quote *orchestratorv1.PaymentQuote) (*paymenttypes.Money, error) {
|
||||
if sourceAmount == nil {
|
||||
return nil, merrors.InvalidArgument("plan builder: source amount is required")
|
||||
@@ -1,4 +1,4 @@
|
||||
package orchestrator
|
||||
package plan_builder
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -12,7 +12,8 @@ import (
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func selectPlanTemplate(ctx context.Context, logger mlogger.Logger, templates PlanTemplateStore, sourceRail, destRail model.Rail, network string) (*model.PaymentPlanTemplate, error) {
|
||||
// SelectPlanTemplate selects the best plan template matching the given rails and network.
|
||||
func SelectPlanTemplate(ctx context.Context, logger mlogger.Logger, templates PlanTemplateStore, sourceRail, destRail model.Rail, network string) (*model.PaymentPlanTemplate, error) {
|
||||
if templates == nil {
|
||||
return nil, merrors.InvalidArgument("plan builder: plan templates store is required")
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func PerIntentIdempotencyKey(base string, index int, total int) string {
|
||||
base = strings.TrimSpace(base)
|
||||
if base == "" {
|
||||
return ""
|
||||
}
|
||||
if total <= 1 {
|
||||
return base
|
||||
}
|
||||
return fmt.Sprintf("%s:%d", base, index+1)
|
||||
}
|
||||
@@ -17,12 +17,10 @@ replace github.com/tech/sendico/ledger => ../../ledger
|
||||
replace github.com/tech/sendico/payments/storage => ../storage
|
||||
|
||||
require (
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/shopspring/decimal v1.4.0
|
||||
github.com/tech/sendico/fx/oracle v0.0.0-00010101000000-000000000000
|
||||
github.com/tech/sendico/gateway/chain v0.0.0-00010101000000-000000000000
|
||||
github.com/tech/sendico/gateway/mntx v0.0.0-00010101000000-000000000000
|
||||
github.com/tech/sendico/ledger v0.0.0-00010101000000-000000000000
|
||||
github.com/tech/sendico/payments/storage v0.0.0-00010101000000-000000000000
|
||||
github.com/tech/sendico/pkg v0.1.0
|
||||
@@ -41,6 +39,7 @@ require (
|
||||
github.com/casbin/mongodb-adapter/v4 v4.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/go-chi/chi/v5 v5.2.5 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/klauspost/compress v1.18.4 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
@@ -52,6 +51,7 @@ require (
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.5 // indirect
|
||||
github.com/prometheus/procfs v0.19.2 // indirect
|
||||
github.com/rogpeppe/go-internal v1.12.0 // indirect
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||
github.com/xdg-go/scram v1.2.0 // indirect
|
||||
github.com/xdg-go/stringprep v1.0.4 // indirect
|
||||
|
||||
@@ -11,6 +11,34 @@ import (
|
||||
|
||||
const quotationDiscoverySender = "payment_quotation"
|
||||
|
||||
func (i *Imp) initDiscovery(cfg *config) {
|
||||
if i == nil || cfg == nil || cfg.Messaging == nil || cfg.Messaging.Driver == "" {
|
||||
return
|
||||
}
|
||||
|
||||
logger := i.logger.Named("discovery")
|
||||
broker, err := msg.CreateMessagingBroker(logger.Named("bus"), cfg.Messaging)
|
||||
if err != nil {
|
||||
i.logger.Warn("Failed to initialise discovery broker", zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
registry := discovery.NewRegistry()
|
||||
watcher, err := discovery.NewRegistryWatcher(logger, broker, registry)
|
||||
if err != nil {
|
||||
i.logger.Warn("Failed to initialise discovery registry watcher", zap.Error(err))
|
||||
return
|
||||
}
|
||||
if err := watcher.Start(); err != nil {
|
||||
i.logger.Warn("Failed to start discovery registry watcher", zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
i.discoveryWatcher = watcher
|
||||
i.discoveryReg = registry
|
||||
i.logger.Info("Discovery registry watcher started")
|
||||
}
|
||||
|
||||
func (i *Imp) startDiscoveryAnnouncer(cfg *config, producer msg.Producer) {
|
||||
if i == nil || cfg == nil || producer == nil || cfg.GRPC == nil {
|
||||
return
|
||||
@@ -43,3 +71,15 @@ func (i *Imp) stopDiscoveryAnnouncer() {
|
||||
i.discoveryAnnouncer.Stop()
|
||||
i.discoveryAnnouncer = nil
|
||||
}
|
||||
|
||||
func (i *Imp) stopDiscovery() {
|
||||
if i == nil {
|
||||
return
|
||||
}
|
||||
i.stopDiscoveryAnnouncer()
|
||||
if i.discoveryWatcher != nil {
|
||||
i.discoveryWatcher.Stop()
|
||||
i.discoveryWatcher = nil
|
||||
}
|
||||
i.discoveryReg = nil
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package serverimp
|
||||
|
||||
import (
|
||||
quotesvc "github.com/tech/sendico/payments/quotation/internal/service/orchestrator"
|
||||
quotesvc "github.com/tech/sendico/payments/quotation/internal/service/quotation"
|
||||
"github.com/tech/sendico/payments/storage"
|
||||
mongostorage "github.com/tech/sendico/payments/storage/mongo"
|
||||
"github.com/tech/sendico/pkg/db"
|
||||
@@ -19,7 +19,7 @@ func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) {
|
||||
}
|
||||
|
||||
func (i *Imp) Shutdown() {
|
||||
i.stopDiscoveryAnnouncer()
|
||||
i.stopDiscovery()
|
||||
if i.service != nil {
|
||||
i.service.Shutdown()
|
||||
}
|
||||
@@ -34,6 +34,7 @@ func (i *Imp) Start() error {
|
||||
}
|
||||
i.config = cfg
|
||||
|
||||
i.initDiscovery(cfg)
|
||||
i.deps = i.initDependencies(cfg)
|
||||
|
||||
quoteRetention := cfg.quoteRetention()
|
||||
@@ -54,6 +55,9 @@ func (i *Imp) Start() error {
|
||||
opts = append(opts, quotesvc.WithChainGatewayClient(i.deps.gatewayClient))
|
||||
}
|
||||
}
|
||||
if registry := quotesvc.NewDiscoveryGatewayRegistry(logger, i.discoveryReg); registry != nil {
|
||||
opts = append(opts, quotesvc.WithGatewayRegistry(registry))
|
||||
}
|
||||
i.startDiscoveryAnnouncer(cfg, producer)
|
||||
svc := quotesvc.NewQuotationService(logger, repo, opts...)
|
||||
i.service = svc
|
||||
|
||||
@@ -33,5 +33,7 @@ type Imp struct {
|
||||
service quoteService
|
||||
deps *clientDependencies
|
||||
|
||||
discoveryWatcher *discovery.RegistryWatcher
|
||||
discoveryReg *discovery.Registry
|
||||
discoveryAnnouncer *discovery.Announcer
|
||||
}
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
package orchestrator
|
||||
|
||||
const (
|
||||
defaultCardGateway = "monetix"
|
||||
|
||||
stepCodeGasTopUp = "gas_top_up"
|
||||
stepCodeFundingTransfer = "funding_transfer"
|
||||
stepCodeCardPayout = "card_payout"
|
||||
stepCodeFeeTransfer = "fee_transfer"
|
||||
)
|
||||
@@ -1,367 +0,0 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
chainclient "github.com/tech/sendico/gateway/chain/client"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (s *Service) submitCardFundingTransfers(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote) error {
|
||||
if payment == nil {
|
||||
return merrors.InvalidArgument("payment is required")
|
||||
}
|
||||
intent := payment.Intent
|
||||
source := intent.Source.ManagedWallet
|
||||
if source == nil || strings.TrimSpace(source.ManagedWalletRef) == "" {
|
||||
return merrors.InvalidArgument("card funding: source managed wallet is required")
|
||||
}
|
||||
route, err := s.cardRoute(defaultCardGateway)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sourceWalletRef := strings.TrimSpace(source.ManagedWalletRef)
|
||||
fundingAddress := strings.TrimSpace(route.FundingAddress)
|
||||
feeWalletRef := strings.TrimSpace(route.FeeWalletRef)
|
||||
|
||||
intentAmount := cloneMoney(intent.Amount)
|
||||
if intentAmount == nil || strings.TrimSpace(intentAmount.GetAmount()) == "" || strings.TrimSpace(intentAmount.GetCurrency()) == "" {
|
||||
return merrors.InvalidArgument("card funding: amount is required")
|
||||
}
|
||||
intentAmountProto := protoMoney(intentAmount)
|
||||
|
||||
payoutAmount, err := cardPayoutAmount(payment)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var feeAmount *paymenttypes.Money
|
||||
if quote != nil {
|
||||
feeAmount = moneyFromProto(quote.GetExpectedFeeTotal())
|
||||
}
|
||||
if feeAmount == nil && payment.LastQuote != nil {
|
||||
feeAmount = cloneMoney(payment.LastQuote.ExpectedFeeTotal)
|
||||
}
|
||||
feeDecimal := decimal.Zero
|
||||
if feeAmount != nil && strings.TrimSpace(feeAmount.GetAmount()) != "" {
|
||||
if strings.TrimSpace(feeAmount.GetCurrency()) == "" {
|
||||
return merrors.InvalidArgument("card funding: fee currency is required")
|
||||
}
|
||||
feeDecimal, err = decimalFromMoney(feeAmount)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
feeRequired := feeDecimal.IsPositive()
|
||||
feeAmountProto := protoMoney(feeAmount)
|
||||
|
||||
network := networkFromEndpoint(intent.Source)
|
||||
instanceID := strings.TrimSpace(intent.Source.InstanceID)
|
||||
actions := []model.RailOperation{model.RailOperationSend}
|
||||
if feeRequired {
|
||||
actions = append(actions, model.RailOperationFee)
|
||||
}
|
||||
chainClient, _, err := s.resolveChainGatewayClient(ctx, network, intentAmount, actions, instanceID, payment.PaymentRef)
|
||||
if err != nil {
|
||||
s.logger.Warn("card funding gateway resolution failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef))
|
||||
return err
|
||||
}
|
||||
|
||||
fundingDest := &chainv1.TransferDestination{
|
||||
Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: fundingAddress},
|
||||
}
|
||||
fundingFee, err := s.estimateTransferNetworkFee(ctx, chainClient, sourceWalletRef, fundingDest, intentAmountProto)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var feeTransferFee *moneyv1.Money
|
||||
if feeRequired {
|
||||
if feeWalletRef == "" {
|
||||
return merrors.InvalidArgument("card funding: fee wallet ref is required when fee exists")
|
||||
}
|
||||
feeDest := &chainv1.TransferDestination{
|
||||
Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: feeWalletRef},
|
||||
}
|
||||
feeTransferFee, err = s.estimateTransferNetworkFee(ctx, chainClient, sourceWalletRef, feeDest, feeAmountProto)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
totalFee, gasCurrency, err := sumNetworkFees(fundingFee, feeTransferFee)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var estimatedTotalFee *moneyv1.Money
|
||||
if gasCurrency != "" && !totalFee.IsNegative() {
|
||||
estimatedTotalFee = makeMoney(gasCurrency, totalFee)
|
||||
}
|
||||
|
||||
var topUpMoney *moneyv1.Money
|
||||
var topUpFee *moneyv1.Money
|
||||
topUpPositive := false
|
||||
if estimatedTotalFee != nil {
|
||||
computeResp, err := chainClient.ComputeGasTopUp(ctx, &chainv1.ComputeGasTopUpRequest{
|
||||
WalletRef: sourceWalletRef,
|
||||
EstimatedTotalFee: estimatedTotalFee,
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Warn("card funding gas top-up compute failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef))
|
||||
return err
|
||||
}
|
||||
if computeResp != nil {
|
||||
topUpMoney = computeResp.GetTopupAmount()
|
||||
}
|
||||
if topUpMoney != nil && strings.TrimSpace(topUpMoney.GetAmount()) != "" {
|
||||
amountDec, err := decimalFromMoney(topUpMoney)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
topUpPositive = amountDec.IsPositive()
|
||||
}
|
||||
if topUpMoney != nil && topUpPositive {
|
||||
if strings.TrimSpace(topUpMoney.GetCurrency()) == "" {
|
||||
return merrors.InvalidArgument("card funding: gas top-up currency is required")
|
||||
}
|
||||
if feeWalletRef == "" {
|
||||
return merrors.InvalidArgument("card funding: fee wallet ref is required for gas top-up")
|
||||
}
|
||||
topUpDest := &chainv1.TransferDestination{
|
||||
Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: sourceWalletRef},
|
||||
}
|
||||
topUpFee, err = s.estimateTransferNetworkFee(ctx, chainClient, feeWalletRef, topUpDest, topUpMoney)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
plan := ensureExecutionPlan(payment)
|
||||
var gasStep *model.ExecutionStep
|
||||
var feeStep *model.ExecutionStep
|
||||
if topUpMoney != nil && topUpPositive {
|
||||
gasStep = ensureExecutionStep(plan, stepCodeGasTopUp)
|
||||
setExecutionStepRole(gasStep, executionStepRoleSource)
|
||||
setExecutionStepStatus(gasStep, model.OperationStatePlanned)
|
||||
gasStep.Description = "Top up native gas from fee wallet"
|
||||
gasStep.Amount = moneyFromProto(topUpMoney)
|
||||
gasStep.NetworkFee = moneyFromProto(topUpFee)
|
||||
gasStep.SourceWalletRef = feeWalletRef
|
||||
gasStep.DestinationRef = sourceWalletRef
|
||||
}
|
||||
|
||||
fundStep := ensureExecutionStep(plan, stepCodeFundingTransfer)
|
||||
setExecutionStepRole(fundStep, executionStepRoleSource)
|
||||
setExecutionStepStatus(fundStep, model.OperationStatePlanned)
|
||||
fundStep.Description = "Transfer payout amount to card funding wallet"
|
||||
fundStep.Amount = cloneMoney(intentAmount)
|
||||
fundStep.NetworkFee = moneyFromProto(fundingFee)
|
||||
fundStep.SourceWalletRef = sourceWalletRef
|
||||
fundStep.DestinationRef = fundingAddress
|
||||
|
||||
if feeRequired {
|
||||
feeStep = ensureExecutionStep(plan, stepCodeFeeTransfer)
|
||||
setExecutionStepRole(feeStep, executionStepRoleSource)
|
||||
setExecutionStepStatus(feeStep, model.OperationStatePlanned)
|
||||
feeStep.Description = "Transfer fee to fee wallet"
|
||||
feeStep.Amount = cloneMoney(feeAmount)
|
||||
feeStep.NetworkFee = moneyFromProto(feeTransferFee)
|
||||
feeStep.SourceWalletRef = sourceWalletRef
|
||||
feeStep.DestinationRef = feeWalletRef
|
||||
}
|
||||
|
||||
cardStep := ensureExecutionStep(plan, stepCodeCardPayout)
|
||||
setExecutionStepRole(cardStep, executionStepRoleConsumer)
|
||||
setExecutionStepStatus(cardStep, model.OperationStatePlanned)
|
||||
cardStep.Description = "Submit card payout"
|
||||
cardStep.Amount = cloneMoney(payoutAmount)
|
||||
if card := intent.Destination.Card; card != nil {
|
||||
if masked := strings.TrimSpace(card.MaskedPan); masked != "" {
|
||||
cardStep.DestinationRef = masked
|
||||
}
|
||||
}
|
||||
|
||||
updateExecutionPlanTotalNetworkFee(plan)
|
||||
|
||||
exec := payment.Execution
|
||||
if exec == nil {
|
||||
exec = &model.ExecutionRefs{}
|
||||
}
|
||||
|
||||
if topUpMoney != nil && topUpPositive {
|
||||
ensureResp, gasErr := chainClient.EnsureGasTopUp(ctx, &chainv1.EnsureGasTopUpRequest{
|
||||
IdempotencyKey: payment.IdempotencyKey + ":card:gas",
|
||||
OrganizationRef: payment.OrganizationRef.Hex(),
|
||||
IntentRef: strings.TrimSpace(payment.Intent.Ref),
|
||||
OperationRef: strings.TrimSpace(cardStep.OperationRef),
|
||||
SourceWalletRef: feeWalletRef,
|
||||
TargetWalletRef: sourceWalletRef,
|
||||
EstimatedTotalFee: estimatedTotalFee,
|
||||
Metadata: cloneMetadata(payment.Metadata),
|
||||
PaymentRef: payment.PaymentRef,
|
||||
})
|
||||
if gasErr != nil {
|
||||
s.logger.Warn("card gas top-up transfer failed", zap.Error(gasErr), zap.String("payment_ref", payment.PaymentRef))
|
||||
return gasErr
|
||||
}
|
||||
if gasStep != nil {
|
||||
actual := (*moneyv1.Money)(nil)
|
||||
if ensureResp != nil {
|
||||
actual = ensureResp.GetTopupAmount()
|
||||
if transfer := ensureResp.GetTransfer(); transfer != nil {
|
||||
gasStep.TransferRef = strings.TrimSpace(transfer.GetTransferRef())
|
||||
}
|
||||
}
|
||||
actualPositive := false
|
||||
if actual != nil && strings.TrimSpace(actual.GetAmount()) != "" {
|
||||
actualDec, err := decimalFromMoney(actual)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
actualPositive = actualDec.IsPositive()
|
||||
}
|
||||
if actual != nil && actualPositive {
|
||||
gasStep.Amount = moneyFromProto(actual)
|
||||
if strings.TrimSpace(actual.GetCurrency()) == "" {
|
||||
return merrors.InvalidArgument("card funding: gas top-up currency is required")
|
||||
}
|
||||
topUpDest := &chainv1.TransferDestination{
|
||||
Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: sourceWalletRef},
|
||||
}
|
||||
topUpFee, err = s.estimateTransferNetworkFee(ctx, chainClient, feeWalletRef, topUpDest, actual)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
gasStep.NetworkFee = moneyFromProto(topUpFee)
|
||||
setExecutionStepStatus(gasStep, model.OperationStateWaiting)
|
||||
} else {
|
||||
gasStep.Amount = nil
|
||||
gasStep.NetworkFee = nil
|
||||
gasStep.TransferRef = ""
|
||||
setExecutionStepStatus(gasStep, model.OperationStateSkipped)
|
||||
}
|
||||
}
|
||||
if gasStep != nil {
|
||||
s.logger.Info("card gas top-up transfer submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", gasStep.TransferRef))
|
||||
}
|
||||
updateExecutionPlanTotalNetworkFee(plan)
|
||||
}
|
||||
|
||||
fundResp, err := chainClient.SubmitTransfer(ctx, &chainv1.SubmitTransferRequest{
|
||||
IdempotencyKey: payment.IdempotencyKey + ":card:fund",
|
||||
OrganizationRef: payment.OrganizationRef.Hex(),
|
||||
SourceWalletRef: sourceWalletRef,
|
||||
Destination: fundingDest,
|
||||
Amount: cloneProtoMoney(intentAmountProto),
|
||||
Metadata: cloneMetadata(payment.Metadata),
|
||||
PaymentRef: payment.PaymentRef,
|
||||
IntentRef: strings.TrimSpace(intent.Ref),
|
||||
OperationRef: strings.TrimSpace(cardStep.OperationRef),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if fundResp != nil && fundResp.GetTransfer() != nil {
|
||||
exec.ChainTransferRef = strings.TrimSpace(fundResp.GetTransfer().GetTransferRef())
|
||||
fundStep.TransferRef = exec.ChainTransferRef
|
||||
}
|
||||
setExecutionStepStatus(fundStep, model.OperationStateWaiting)
|
||||
updateExecutionPlanTotalNetworkFee(plan)
|
||||
|
||||
if feeRequired {
|
||||
feeResp, err := chainClient.SubmitTransfer(ctx, &chainv1.SubmitTransferRequest{
|
||||
IntentRef: intent.Ref,
|
||||
OperationRef: feeStep.OperationRef,
|
||||
IdempotencyKey: payment.IdempotencyKey + ":card:fee",
|
||||
OrganizationRef: payment.OrganizationRef.Hex(),
|
||||
SourceWalletRef: sourceWalletRef,
|
||||
Destination: &chainv1.TransferDestination{
|
||||
Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: feeWalletRef},
|
||||
},
|
||||
Amount: cloneProtoMoney(feeAmountProto),
|
||||
Metadata: cloneMetadata(payment.Metadata),
|
||||
PaymentRef: payment.PaymentRef,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if feeResp != nil && feeResp.GetTransfer() != nil {
|
||||
exec.FeeTransferRef = strings.TrimSpace(feeResp.GetTransfer().GetTransferRef())
|
||||
feeStep.TransferRef = exec.FeeTransferRef
|
||||
}
|
||||
setExecutionStepStatus(feeStep, model.OperationStateWaiting)
|
||||
s.logger.Info("card fee transfer submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", exec.FeeTransferRef))
|
||||
}
|
||||
|
||||
payment.Execution = exec
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) estimateTransferNetworkFee(ctx context.Context, client chainclient.Client, sourceWalletRef string, destination *chainv1.TransferDestination, amount *moneyv1.Money) (*moneyv1.Money, error) {
|
||||
if client == nil {
|
||||
return nil, merrors.InvalidArgument("chain gateway unavailable")
|
||||
}
|
||||
sourceWalletRef = strings.TrimSpace(sourceWalletRef)
|
||||
if sourceWalletRef == "" {
|
||||
return nil, merrors.InvalidArgument("source wallet ref is required")
|
||||
}
|
||||
if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" {
|
||||
return nil, merrors.InvalidArgument("amount is required")
|
||||
}
|
||||
|
||||
resp, err := client.EstimateTransferFee(ctx, &chainv1.EstimateTransferFeeRequest{
|
||||
SourceWalletRef: sourceWalletRef,
|
||||
Destination: destination,
|
||||
Amount: cloneProtoMoney(amount),
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Warn("chain gateway fee estimation failed", zap.Error(err), zap.String("source_wallet_ref", sourceWalletRef))
|
||||
return nil, merrors.Internal("chain_gateway_fee_estimation_failed")
|
||||
}
|
||||
if resp == nil {
|
||||
s.logger.Warn("chain gateway fee estimation returned empty response", zap.String("source_wallet_ref", sourceWalletRef))
|
||||
return nil, merrors.Internal("chain_gateway_fee_estimation_failed")
|
||||
}
|
||||
fee := resp.GetNetworkFee()
|
||||
if fee == nil || strings.TrimSpace(fee.GetAmount()) == "" || strings.TrimSpace(fee.GetCurrency()) == "" {
|
||||
s.logger.Warn("chain gateway fee estimation missing network fee", zap.String("source_wallet_ref", sourceWalletRef))
|
||||
return nil, merrors.Internal("chain_gateway_fee_estimation_failed")
|
||||
}
|
||||
return cloneProtoMoney(fee), nil
|
||||
}
|
||||
|
||||
func sumNetworkFees(fees ...*moneyv1.Money) (decimal.Decimal, string, error) {
|
||||
total := decimal.Zero
|
||||
currency := ""
|
||||
for _, fee := range fees {
|
||||
if fee == nil {
|
||||
continue
|
||||
}
|
||||
amount := strings.TrimSpace(fee.GetAmount())
|
||||
feeCurrency := strings.TrimSpace(fee.GetCurrency())
|
||||
if amount == "" || feeCurrency == "" {
|
||||
return decimal.Zero, "", merrors.InvalidArgument("network fee is required")
|
||||
}
|
||||
value, err := decimalFromMoney(fee)
|
||||
if err != nil {
|
||||
return decimal.Zero, "", err
|
||||
}
|
||||
if currency == "" {
|
||||
currency = feeCurrency
|
||||
} else if !strings.EqualFold(currency, feeCurrency) {
|
||||
return decimal.Zero, "", merrors.InvalidArgument("network fee currency mismatch")
|
||||
}
|
||||
total = total.Add(value)
|
||||
}
|
||||
return total, currency, nil
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
)
|
||||
|
||||
func ensureExecutionPlan(payment *model.Payment) *model.ExecutionPlan {
|
||||
if payment == nil {
|
||||
return nil
|
||||
}
|
||||
if payment.ExecutionPlan == nil {
|
||||
payment.ExecutionPlan = &model.ExecutionPlan{}
|
||||
}
|
||||
return payment.ExecutionPlan
|
||||
}
|
||||
|
||||
func ensureExecutionStep(plan *model.ExecutionPlan, code string) *model.ExecutionStep {
|
||||
if plan == nil {
|
||||
return nil
|
||||
}
|
||||
code = strings.TrimSpace(code)
|
||||
if code == "" {
|
||||
return nil
|
||||
}
|
||||
for _, step := range plan.Steps {
|
||||
if step == nil {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(step.Code, code) {
|
||||
if step.Code == "" {
|
||||
step.Code = code
|
||||
}
|
||||
return step
|
||||
}
|
||||
}
|
||||
step := &model.ExecutionStep{Code: code}
|
||||
plan.Steps = append(plan.Steps, step)
|
||||
return step
|
||||
}
|
||||
|
||||
func updateExecutionPlanTotalNetworkFee(plan *model.ExecutionPlan) {
|
||||
if plan == nil {
|
||||
return
|
||||
}
|
||||
total := decimal.Zero
|
||||
currency := ""
|
||||
hasFee := false
|
||||
for _, step := range plan.Steps {
|
||||
if step == nil || step.NetworkFee == nil {
|
||||
continue
|
||||
}
|
||||
fee := step.NetworkFee
|
||||
if strings.TrimSpace(fee.GetAmount()) == "" || strings.TrimSpace(fee.GetCurrency()) == "" {
|
||||
continue
|
||||
}
|
||||
if currency == "" {
|
||||
currency = strings.TrimSpace(fee.GetCurrency())
|
||||
} else if !strings.EqualFold(currency, fee.GetCurrency()) {
|
||||
continue
|
||||
}
|
||||
value, err := decimalFromMoney(fee)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
total = total.Add(value)
|
||||
hasFee = true
|
||||
}
|
||||
if !hasFee || currency == "" {
|
||||
plan.TotalNetworkFee = nil
|
||||
return
|
||||
}
|
||||
plan.TotalNetworkFee = &paymenttypes.Money{
|
||||
Currency: currency,
|
||||
Amount: total.String(),
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (s *Service) cardRoute(gateway string) (CardGatewayRoute, error) {
|
||||
if len(s.deps.cardRoutes) == 0 {
|
||||
s.logger.Warn("card routing not configured", zap.String("gateway", gateway))
|
||||
return CardGatewayRoute{}, merrors.InvalidArgument("card routing not configured")
|
||||
}
|
||||
key := strings.ToLower(strings.TrimSpace(gateway))
|
||||
if key == "" {
|
||||
key = defaultCardGateway
|
||||
}
|
||||
route, ok := s.deps.cardRoutes[key]
|
||||
if !ok {
|
||||
s.logger.Warn("card routing missing for gateway", zap.String("gateway", key))
|
||||
return CardGatewayRoute{}, merrors.InvalidArgument("card routing missing for gateway " + key)
|
||||
}
|
||||
if strings.TrimSpace(route.FundingAddress) == "" {
|
||||
s.logger.Warn("card routing missing funding address", zap.String("gateway", key))
|
||||
return CardGatewayRoute{}, merrors.InvalidArgument("card funding address is required for gateway " + key)
|
||||
}
|
||||
return route, nil
|
||||
}
|
||||
@@ -1,351 +0,0 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/shopspring/decimal"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (s *Service) submitCardPayout(ctx context.Context, operationRef string, payment *model.Payment) error {
|
||||
if payment == nil {
|
||||
return merrors.InvalidArgument("payment is required")
|
||||
}
|
||||
if payment.Execution != nil && strings.TrimSpace(payment.Execution.CardPayoutRef) != "" {
|
||||
return nil
|
||||
}
|
||||
intent := payment.Intent
|
||||
card := intent.Destination.Card
|
||||
if card == nil {
|
||||
return merrors.InvalidArgument("card payout: card endpoint is required")
|
||||
}
|
||||
amount, err := cardPayoutAmount(payment)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
amtDec, err := decimalFromMoney(amount)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
minor := amtDec.Mul(decimal.NewFromInt(100)).IntPart()
|
||||
|
||||
payoutID := payment.PaymentRef
|
||||
currency := strings.TrimSpace(amount.GetCurrency())
|
||||
holder := strings.TrimSpace(card.Cardholder)
|
||||
meta := cloneMetadata(payment.Metadata)
|
||||
customer := intent.Customer
|
||||
customerID := ""
|
||||
customerFirstName := ""
|
||||
customerMiddleName := ""
|
||||
customerLastName := ""
|
||||
customerIP := ""
|
||||
customerZip := ""
|
||||
customerCountry := ""
|
||||
customerState := ""
|
||||
customerCity := ""
|
||||
customerAddress := ""
|
||||
if customer != nil {
|
||||
customerID = strings.TrimSpace(customer.ID)
|
||||
customerFirstName = strings.TrimSpace(customer.FirstName)
|
||||
customerMiddleName = strings.TrimSpace(customer.MiddleName)
|
||||
customerLastName = strings.TrimSpace(customer.LastName)
|
||||
customerIP = strings.TrimSpace(customer.IP)
|
||||
customerZip = strings.TrimSpace(customer.Zip)
|
||||
customerCountry = strings.TrimSpace(customer.Country)
|
||||
customerState = strings.TrimSpace(customer.State)
|
||||
customerCity = strings.TrimSpace(customer.City)
|
||||
customerAddress = strings.TrimSpace(customer.Address)
|
||||
}
|
||||
if customerFirstName == "" {
|
||||
customerFirstName = strings.TrimSpace(card.Cardholder)
|
||||
}
|
||||
if customerLastName == "" {
|
||||
customerLastName = strings.TrimSpace(card.CardholderSurname)
|
||||
}
|
||||
if customerID == "" {
|
||||
return merrors.InvalidArgument("card payout: customer id is required")
|
||||
}
|
||||
if customerFirstName == "" {
|
||||
return merrors.InvalidArgument("card payout: customer first name is required")
|
||||
}
|
||||
if customerLastName == "" {
|
||||
return merrors.InvalidArgument("card payout: customer last name is required")
|
||||
}
|
||||
if customerIP == "" {
|
||||
return merrors.InvalidArgument("card payout: customer ip is required")
|
||||
}
|
||||
|
||||
var (
|
||||
state *mntxv1.CardPayoutState
|
||||
)
|
||||
|
||||
if token := strings.TrimSpace(card.Token); token != "" {
|
||||
req := &mntxv1.CardTokenPayoutRequest{
|
||||
PayoutId: payoutID,
|
||||
IdempotencyKey: payment.IdempotencyKey,
|
||||
CustomerId: customerID,
|
||||
CustomerFirstName: customerFirstName,
|
||||
CustomerMiddleName: customerMiddleName,
|
||||
CustomerLastName: customerLastName,
|
||||
CustomerIp: customerIP,
|
||||
CustomerZip: customerZip,
|
||||
CustomerCountry: customerCountry,
|
||||
CustomerState: customerState,
|
||||
CustomerCity: customerCity,
|
||||
CustomerAddress: customerAddress,
|
||||
AmountMinor: minor,
|
||||
Currency: currency,
|
||||
CardToken: token,
|
||||
CardHolder: holder,
|
||||
MaskedPan: strings.TrimSpace(card.MaskedPan),
|
||||
Metadata: meta,
|
||||
}
|
||||
resp, err := s.deps.mntx.client.CreateCardTokenPayout(ctx, req)
|
||||
if err != nil {
|
||||
s.logger.Warn("card token payout failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef))
|
||||
return err
|
||||
}
|
||||
state = resp.GetPayout()
|
||||
} else if pan := strings.TrimSpace(card.Pan); pan != "" {
|
||||
req := &mntxv1.CardPayoutRequest{
|
||||
PayoutId: payoutID,
|
||||
IdempotencyKey: payment.IdempotencyKey,
|
||||
CustomerId: customerID,
|
||||
CustomerFirstName: customerFirstName,
|
||||
CustomerMiddleName: customerMiddleName,
|
||||
CustomerLastName: customerLastName,
|
||||
CustomerIp: customerIP,
|
||||
CustomerZip: customerZip,
|
||||
CustomerCountry: customerCountry,
|
||||
CustomerState: customerState,
|
||||
CustomerCity: customerCity,
|
||||
CustomerAddress: customerAddress,
|
||||
AmountMinor: minor,
|
||||
Currency: currency,
|
||||
CardPan: pan,
|
||||
CardExpYear: card.ExpYear,
|
||||
CardExpMonth: card.ExpMonth,
|
||||
CardHolder: holder,
|
||||
Metadata: meta,
|
||||
IntentRef: payment.Intent.Ref,
|
||||
OperationRef: operationRef,
|
||||
}
|
||||
resp, err := s.deps.mntx.client.CreateCardPayout(ctx, req)
|
||||
if err != nil {
|
||||
s.logger.Warn("card payout failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef))
|
||||
return err
|
||||
}
|
||||
state = resp.GetPayout()
|
||||
} else {
|
||||
return merrors.InvalidArgument("card payout: either token or pan must be provided")
|
||||
}
|
||||
|
||||
if state == nil {
|
||||
return merrors.Internal("card payout: missing payout state")
|
||||
}
|
||||
recordCardPayoutState(payment, state)
|
||||
exec := payment.Execution
|
||||
if exec == nil {
|
||||
exec = &model.ExecutionRefs{}
|
||||
}
|
||||
if exec.CardPayoutRef == "" {
|
||||
exec.CardPayoutRef = strings.TrimSpace(state.GetPayoutId())
|
||||
}
|
||||
payment.Execution = exec
|
||||
|
||||
plan := ensureExecutionPlan(payment)
|
||||
if plan != nil {
|
||||
step := ensureExecutionStep(plan, stepCodeCardPayout)
|
||||
setExecutionStepRole(step, executionStepRoleConsumer)
|
||||
step.Description = "Submit card payout"
|
||||
step.Amount = cloneMoney(amount)
|
||||
if masked := strings.TrimSpace(card.MaskedPan); masked != "" {
|
||||
step.DestinationRef = masked
|
||||
}
|
||||
if exec.CardPayoutRef != "" {
|
||||
step.TransferRef = exec.CardPayoutRef
|
||||
}
|
||||
setExecutionStepStatus(step, model.OperationStateWaiting)
|
||||
updateExecutionPlanTotalNetworkFee(plan)
|
||||
}
|
||||
|
||||
s.logger.Info("card payout submitted", zap.String("payment_ref", payment.PaymentRef),
|
||||
zap.String("payout_id", exec.CardPayoutRef), zap.String("operation_ref", state.OperationRef))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func recordCardPayoutState(payment *model.Payment, state *mntxv1.CardPayoutState) {
|
||||
if payment == nil || state == nil {
|
||||
return
|
||||
}
|
||||
if payment.CardPayout == nil {
|
||||
payment.CardPayout = &model.CardPayout{}
|
||||
}
|
||||
payment.CardPayout.PayoutRef = strings.TrimSpace(state.GetPayoutId())
|
||||
payment.CardPayout.ProviderPaymentID = strings.TrimSpace(state.GetProviderPaymentId())
|
||||
payment.CardPayout.Status = state.GetStatus().String()
|
||||
payment.CardPayout.FailureReason = strings.TrimSpace(state.GetProviderMessage())
|
||||
payment.CardPayout.ProviderCode = strings.TrimSpace(state.GetProviderCode())
|
||||
if payment.CardPayout.CardCountry == "" && payment.Intent.Destination.Card != nil {
|
||||
payment.CardPayout.CardCountry = strings.TrimSpace(payment.Intent.Destination.Card.Country)
|
||||
}
|
||||
if payment.CardPayout.MaskedPan == "" && payment.Intent.Destination.Card != nil {
|
||||
payment.CardPayout.MaskedPan = strings.TrimSpace(payment.Intent.Destination.Card.MaskedPan)
|
||||
}
|
||||
payment.CardPayout.GatewayReference = strings.TrimSpace(state.GetPayoutId())
|
||||
}
|
||||
|
||||
func updateCardPayoutPlanSteps(payment *model.Payment, payout *mntxv1.CardPayoutState) bool {
|
||||
if payment == nil || payout == nil || payment.PaymentPlan == nil {
|
||||
return false
|
||||
}
|
||||
plan := ensureExecutionPlanForPlan(payment, payment.PaymentPlan)
|
||||
if plan == nil {
|
||||
return false
|
||||
}
|
||||
payoutID := strings.TrimSpace(payout.GetPayoutId())
|
||||
if payoutID == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
updated := false
|
||||
for idx, planStep := range payment.PaymentPlan.Steps {
|
||||
if planStep == nil {
|
||||
continue
|
||||
}
|
||||
if planStep.Rail != model.RailCardPayout {
|
||||
continue
|
||||
}
|
||||
if planStep.Action != model.RailOperationSend && planStep.Action != model.RailOperationObserveConfirm {
|
||||
continue
|
||||
}
|
||||
if idx >= len(plan.Steps) {
|
||||
continue
|
||||
}
|
||||
execStep := plan.Steps[idx]
|
||||
if execStep == nil {
|
||||
execStep = &model.ExecutionStep{
|
||||
Code: planStepID(planStep, idx),
|
||||
Description: describePlanStep(planStep),
|
||||
OperationRef: uuid.New().String(),
|
||||
State: model.OperationStateCreated,
|
||||
}
|
||||
plan.Steps[idx] = execStep
|
||||
}
|
||||
if execStep.TransferRef == "" {
|
||||
execStep.TransferRef = payoutID
|
||||
}
|
||||
switch payout.GetStatus() {
|
||||
|
||||
case mntxv1.PayoutStatus_PAYOUT_STATUS_CREATED:
|
||||
setExecutionStepStatus(execStep, model.OperationStateCreated)
|
||||
|
||||
case mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING:
|
||||
setExecutionStepStatus(execStep, model.OperationStateWaiting)
|
||||
|
||||
case mntxv1.PayoutStatus_PAYOUT_STATUS_SUCCESS:
|
||||
setExecutionStepStatus(execStep, model.OperationStateSuccess)
|
||||
|
||||
case mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED:
|
||||
setExecutionStepStatus(execStep, model.OperationStateFailed)
|
||||
|
||||
case mntxv1.PayoutStatus_PAYOUT_STATUS_CANCELLED:
|
||||
setExecutionStepStatus(execStep, model.OperationStateCancelled)
|
||||
|
||||
default:
|
||||
setExecutionStepStatus(execStep, model.OperationStatePlanned)
|
||||
}
|
||||
updated = true
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
func applyCardPayoutUpdate(payment *model.Payment, payout *mntxv1.CardPayoutState) {
|
||||
if payment == nil || payout == nil {
|
||||
return
|
||||
}
|
||||
recordCardPayoutState(payment, payout)
|
||||
|
||||
if payment.Execution == nil {
|
||||
payment.Execution = &model.ExecutionRefs{}
|
||||
}
|
||||
if payment.Execution.CardPayoutRef == "" {
|
||||
payment.Execution.CardPayoutRef = strings.TrimSpace(payout.GetPayoutId())
|
||||
}
|
||||
|
||||
updated := updateCardPayoutPlanSteps(payment, payout)
|
||||
plan := ensureExecutionPlan(payment)
|
||||
if plan != nil && !updated {
|
||||
step := findExecutionStepByTransferRef(plan, strings.TrimSpace(payout.GetPayoutId()))
|
||||
if step == nil {
|
||||
step = ensureExecutionStep(plan, stepCodeCardPayout)
|
||||
setExecutionStepRole(step, executionStepRoleConsumer)
|
||||
if step.TransferRef == "" {
|
||||
step.TransferRef = payment.Execution.CardPayoutRef
|
||||
}
|
||||
}
|
||||
switch payout.GetStatus() {
|
||||
|
||||
case mntxv1.PayoutStatus_PAYOUT_STATUS_CREATED:
|
||||
setExecutionStepStatus(step, model.OperationStatePlanned)
|
||||
|
||||
case mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING:
|
||||
setExecutionStepStatus(step, model.OperationStateWaiting)
|
||||
|
||||
case mntxv1.PayoutStatus_PAYOUT_STATUS_SUCCESS:
|
||||
setExecutionStepStatus(step, model.OperationStateSuccess)
|
||||
|
||||
case mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED:
|
||||
setExecutionStepStatus(step, model.OperationStateFailed)
|
||||
|
||||
case mntxv1.PayoutStatus_PAYOUT_STATUS_CANCELLED:
|
||||
setExecutionStepStatus(step, model.OperationStateCancelled)
|
||||
|
||||
default:
|
||||
setExecutionStepStatus(step, model.OperationStatePlanned)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
switch payout.GetStatus() {
|
||||
|
||||
case mntxv1.PayoutStatus_PAYOUT_STATUS_SUCCESS:
|
||||
payment.FailureCode = model.PaymentFailureCodeUnspecified
|
||||
payment.FailureReason = ""
|
||||
|
||||
case mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED:
|
||||
payment.FailureCode = model.PaymentFailureCodePolicy
|
||||
payment.FailureReason = strings.TrimSpace(payout.GetProviderMessage())
|
||||
|
||||
case mntxv1.PayoutStatus_PAYOUT_STATUS_CANCELLED:
|
||||
payment.FailureCode = model.PaymentFailureCodePolicy
|
||||
payment.FailureReason = "payout cancelled"
|
||||
|
||||
default:
|
||||
// CREATED / WAITING — keep as is
|
||||
}
|
||||
}
|
||||
|
||||
func cardPayoutAmount(payment *model.Payment) (*paymenttypes.Money, error) {
|
||||
if payment == nil {
|
||||
return nil, merrors.InvalidArgument("payment is required")
|
||||
}
|
||||
amount := cloneMoney(payment.Intent.Amount)
|
||||
if payment.LastQuote != nil {
|
||||
settlement := payment.LastQuote.ExpectedSettlementAmount
|
||||
if settlement != nil && strings.TrimSpace(settlement.GetAmount()) != "" && strings.TrimSpace(settlement.GetCurrency()) != "" {
|
||||
amount = cloneMoney(settlement)
|
||||
}
|
||||
}
|
||||
if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" {
|
||||
return nil, merrors.InvalidArgument("card payout: amount is required")
|
||||
}
|
||||
return amount, nil
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||
)
|
||||
|
||||
const (
|
||||
executionStepMetadataRole = "role"
|
||||
executionStepMetadataStatus = "status"
|
||||
|
||||
executionStepRoleSource = "source"
|
||||
executionStepRoleConsumer = "consumer"
|
||||
)
|
||||
|
||||
func setExecutionStepRole(step *model.ExecutionStep, role string) {
|
||||
role = strings.ToLower(strings.TrimSpace(role))
|
||||
setExecutionStepMetadata(step, executionStepMetadataRole, role)
|
||||
}
|
||||
|
||||
func setExecutionStepStatus(step *model.ExecutionStep, state model.OperationState) {
|
||||
step.State = state
|
||||
setExecutionStepMetadata(step, executionStepMetadataStatus, string(state))
|
||||
}
|
||||
|
||||
func executionStepRole(step *model.ExecutionStep) string {
|
||||
if step == nil {
|
||||
return ""
|
||||
}
|
||||
if role := strings.TrimSpace(step.Metadata[executionStepMetadataRole]); role != "" {
|
||||
return strings.ToLower(role)
|
||||
}
|
||||
if strings.EqualFold(step.Code, stepCodeCardPayout) {
|
||||
return executionStepRoleConsumer
|
||||
}
|
||||
return executionStepRoleSource
|
||||
}
|
||||
|
||||
func isSourceExecutionStep(step *model.ExecutionStep) bool {
|
||||
return executionStepRole(step) == executionStepRoleSource
|
||||
}
|
||||
|
||||
func sourceStepsConfirmed(plan *model.ExecutionPlan) bool {
|
||||
if plan == nil || len(plan.Steps) == 0 {
|
||||
return false
|
||||
}
|
||||
hasSource := false
|
||||
for _, step := range plan.Steps {
|
||||
if step == nil || !isSourceExecutionStep(step) {
|
||||
continue
|
||||
}
|
||||
if step.State == model.OperationStateSkipped {
|
||||
continue
|
||||
}
|
||||
hasSource = true
|
||||
if step.State != model.OperationStateSuccess {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return hasSource
|
||||
}
|
||||
|
||||
func findExecutionStepByTransferRef(plan *model.ExecutionPlan, transferRef string) *model.ExecutionStep {
|
||||
if plan == nil {
|
||||
return nil
|
||||
}
|
||||
transferRef = strings.TrimSpace(transferRef)
|
||||
if transferRef == "" {
|
||||
return nil
|
||||
}
|
||||
for _, step := range plan.Steps {
|
||||
if step == nil {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(step.TransferRef), transferRef) {
|
||||
return step
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateExecutionStepFromTransfer(plan *model.ExecutionPlan, event *chainv1.TransferStatusChangedEvent) *model.ExecutionStep {
|
||||
if plan == nil || event == nil || event.GetTransfer() == nil {
|
||||
return nil
|
||||
}
|
||||
transfer := event.GetTransfer()
|
||||
transferRef := strings.TrimSpace(transfer.GetTransferRef())
|
||||
if transferRef == "" {
|
||||
return nil
|
||||
}
|
||||
if status := executionStepStatusFromTransferStatus(transfer.GetStatus()); status != "" {
|
||||
var updated *model.ExecutionStep
|
||||
for _, step := range plan.Steps {
|
||||
if step == nil {
|
||||
continue
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(step.TransferRef), transferRef) {
|
||||
continue
|
||||
}
|
||||
if step.TransferRef == "" {
|
||||
step.TransferRef = transferRef
|
||||
}
|
||||
setExecutionStepStatus(step, status)
|
||||
if updated == nil {
|
||||
updated = step
|
||||
}
|
||||
}
|
||||
return updated
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func executionStepStatusFromTransferStatus(status chainv1.TransferStatus) model.OperationState {
|
||||
switch status {
|
||||
|
||||
case chainv1.TransferStatus_TRANSFER_CREATED:
|
||||
return model.OperationStatePlanned
|
||||
|
||||
case chainv1.TransferStatus_TRANSFER_PROCESSING:
|
||||
return model.OperationStateProcessing
|
||||
|
||||
case chainv1.TransferStatus_TRANSFER_WAITING:
|
||||
return model.OperationStateWaiting
|
||||
|
||||
case chainv1.TransferStatus_TRANSFER_SUCCESS:
|
||||
return model.OperationStateSuccess
|
||||
|
||||
case chainv1.TransferStatus_TRANSFER_FAILED:
|
||||
return model.OperationStateFailed
|
||||
|
||||
case chainv1.TransferStatus_TRANSFER_CANCELLED:
|
||||
return model.OperationStateCancelled
|
||||
|
||||
default:
|
||||
return model.OperationStatePlanned
|
||||
}
|
||||
}
|
||||
|
||||
func setExecutionStepMetadata(step *model.ExecutionStep, key, value string) {
|
||||
if step == nil {
|
||||
return
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return
|
||||
}
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
if step.Metadata != nil {
|
||||
delete(step.Metadata, key)
|
||||
if len(step.Metadata) == 0 {
|
||||
step.Metadata = nil
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if step.Metadata == nil {
|
||||
step.Metadata = map[string]string{}
|
||||
}
|
||||
step.Metadata[key] = value
|
||||
}
|
||||
@@ -1,295 +0,0 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
paymodel "github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
cons "github.com/tech/sendico/pkg/messaging/consumer"
|
||||
paymentgateway "github.com/tech/sendico/pkg/messaging/notifications/paymentgateway"
|
||||
np "github.com/tech/sendico/pkg/messaging/notifications/processor"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/pkg/payments/rail"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (s *Service) startGatewayConsumers() {
|
||||
if s == nil || s.gatewayBroker == nil {
|
||||
s.logger.Warn("Missing broker. Gateway feedback consumer has NOT started")
|
||||
return
|
||||
}
|
||||
s.logger.Info("Gateway feedback consumer started")
|
||||
processor := paymentgateway.NewPaymentGatewayExecutionProcessor(s.logger, s.onGatewayExecution)
|
||||
s.consumeGatewayProcessor(processor)
|
||||
}
|
||||
|
||||
func (s *Service) consumeGatewayProcessor(processor np.EnvelopeProcessor) {
|
||||
consumer, err := cons.NewConsumer(s.logger, s.gatewayBroker, processor.GetSubject())
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to create payment gateway consumer", zap.Error(err), zap.String("event", processor.GetSubject().ToString()))
|
||||
return
|
||||
}
|
||||
s.gatewayConsumers = append(s.gatewayConsumers, consumer)
|
||||
go func() {
|
||||
if err := consumer.ConsumeMessages(processor.Process); err != nil {
|
||||
s.logger.Warn("Payment gateway consumer stopped", zap.Error(err), zap.String("event", processor.GetSubject().ToString()))
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func executionPlanSucceeded(plan *paymodel.ExecutionPlan) bool {
|
||||
for _, s := range plan.Steps {
|
||||
if !s.IsTerminal() {
|
||||
return false
|
||||
}
|
||||
if s.State != paymodel.OperationStateSuccess {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func executionPlanFailed(plan *paymodel.ExecutionPlan) bool {
|
||||
hasFailed := false
|
||||
|
||||
for _, s := range plan.Steps {
|
||||
if !s.IsTerminal() {
|
||||
return false
|
||||
}
|
||||
if s.State == paymodel.OperationStateFailed {
|
||||
hasFailed = true
|
||||
}
|
||||
}
|
||||
|
||||
return hasFailed
|
||||
}
|
||||
|
||||
func (s *Service) onGatewayExecution(ctx context.Context, exec *model.PaymentGatewayExecution) error {
|
||||
if exec == nil {
|
||||
return merrors.InvalidArgument("payment gateway execution is nil", "execution")
|
||||
}
|
||||
|
||||
paymentRef := strings.TrimSpace(exec.PaymentRef)
|
||||
if paymentRef == "" {
|
||||
return merrors.InvalidArgument("payment_ref is required", "payment_ref")
|
||||
}
|
||||
|
||||
store := s.storage.Payments()
|
||||
|
||||
payment, err := store.GetByPaymentRef(ctx, paymentRef)
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to fetch payment from database", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
// --- metadata
|
||||
if payment.Metadata == nil {
|
||||
payment.Metadata = map[string]string{}
|
||||
}
|
||||
payment.Metadata["gateway_operation_result"] = string(exec.Status)
|
||||
payment.Metadata["gateway_operation_ref"] = exec.OperationRef
|
||||
payment.Metadata["gateway_request_idempotency"] = exec.IdempotencyKey
|
||||
|
||||
// --- update exactly ONE step
|
||||
|
||||
if payment.State, err = updateExecutionStepsFromGatewayExecution(s.logger, payment, exec); err != nil {
|
||||
s.logger.Warn("No execution step matched gateway result",
|
||||
zap.String("payment_ref", paymentRef),
|
||||
zap.String("operation_ref", exec.OperationRef),
|
||||
zap.String("idempotency", exec.IdempotencyKey),
|
||||
)
|
||||
}
|
||||
|
||||
if err := store.Update(ctx, payment); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// reload unified state
|
||||
payment, err = store.GetByPaymentRef(ctx, paymentRef)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// --- if plan can continue — continue
|
||||
if payment.ExecutionPlan != nil && !executionPlanComplete(payment.ExecutionPlan) {
|
||||
return s.resumePaymentPlan(ctx, store, payment)
|
||||
}
|
||||
|
||||
// --- plan is terminal: decide payment fate by aggregation
|
||||
if payment.ExecutionPlan != nil && executionPlanComplete(payment.ExecutionPlan) {
|
||||
switch {
|
||||
case executionPlanSucceeded(payment.ExecutionPlan):
|
||||
payment.State = paymodel.PaymentStateSettled
|
||||
|
||||
case executionPlanFailed(payment.ExecutionPlan):
|
||||
payment.State = paymodel.PaymentStateFailed
|
||||
payment.FailureReason = "execution_plan_failed"
|
||||
}
|
||||
|
||||
return store.Update(ctx, payment)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateExecutionStepsFromGatewayExecution(
|
||||
logger mlogger.Logger,
|
||||
payment *paymodel.Payment,
|
||||
exec *model.PaymentGatewayExecution,
|
||||
) (paymodel.PaymentState, error) {
|
||||
|
||||
log := logger.With(
|
||||
zap.String("payment_ref", payment.PaymentRef),
|
||||
zap.String("operation_ref", strings.TrimSpace(exec.OperationRef)),
|
||||
zap.String("gateway_status", string(exec.Status)),
|
||||
)
|
||||
|
||||
log.Debug("gateway execution received")
|
||||
|
||||
if payment == nil || payment.PaymentPlan == nil || exec == nil {
|
||||
log.Warn("invalid input: payment/plan/exec is nil")
|
||||
return paymodel.PaymentStateSubmitted,
|
||||
merrors.DataConflict("payment is missing plan or execution step")
|
||||
}
|
||||
|
||||
operationRef := strings.TrimSpace(exec.OperationRef)
|
||||
if operationRef == "" {
|
||||
log.Warn("empty operation_ref from gateway")
|
||||
return paymodel.PaymentStateSubmitted,
|
||||
merrors.InvalidArgument("no operation reference provided")
|
||||
}
|
||||
|
||||
execPlan := ensureExecutionPlanForPlan(payment, payment.PaymentPlan)
|
||||
if execPlan == nil {
|
||||
log.Warn("Execution plan missing")
|
||||
return paymodel.PaymentStateSubmitted, merrors.InvalidArgument("execution plan missing")
|
||||
}
|
||||
|
||||
status := executionStepStatusFromGatewayStatus(exec.Status)
|
||||
if status == "" {
|
||||
log.Warn("Unknown gateway status")
|
||||
return paymodel.PaymentStateSubmitted,
|
||||
merrors.DataConflict(fmt.Sprintf("unknown gateway status: %s", exec.Status))
|
||||
}
|
||||
|
||||
var matched bool
|
||||
|
||||
for idx, execStep := range execPlan.Steps {
|
||||
if execStep == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.EqualFold(strings.TrimSpace(execStep.OperationRef), operationRef) {
|
||||
|
||||
log.Debug("Execution step matched",
|
||||
zap.Int("step_index", idx),
|
||||
zap.String("step_code", execStep.Code),
|
||||
zap.String("prev_state", string(execStep.State)),
|
||||
)
|
||||
|
||||
if execStep.TransferRef == "" && exec.TransferRef != "" {
|
||||
execStep.TransferRef = strings.TrimSpace(exec.TransferRef)
|
||||
log.Debug("Transfer_ref attached to step", zap.String("transfer_ref", execStep.TransferRef))
|
||||
}
|
||||
|
||||
setExecutionStepStatus(execStep, status)
|
||||
if exec.Error != "" && execStep.Error == "" {
|
||||
execStep.Error = strings.TrimSpace(exec.Error)
|
||||
}
|
||||
|
||||
log.Debug("Execution step state updated",
|
||||
zap.Int("step_index", idx),
|
||||
zap.String("step_code", execStep.Code),
|
||||
zap.String("new_state", string(execStep.State)),
|
||||
)
|
||||
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !matched {
|
||||
log.Warn("No execution step found for operation_ref")
|
||||
return paymodel.PaymentStateSubmitted,
|
||||
merrors.InvalidArgument(
|
||||
fmt.Sprintf("execution step not found for operation reference: %s", operationRef),
|
||||
)
|
||||
}
|
||||
|
||||
// -------- GLOBAL REDUCTION --------
|
||||
|
||||
var (
|
||||
hasSuccess bool
|
||||
allDone = true
|
||||
)
|
||||
|
||||
for idx, step := range execPlan.Steps {
|
||||
if step == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
log.Debug("Evaluating step for payment state",
|
||||
zap.Int("step_index", idx),
|
||||
zap.String("step_code", step.Code),
|
||||
zap.String("step_state", string(step.State)),
|
||||
)
|
||||
|
||||
switch step.State {
|
||||
case paymodel.OperationStateFailed:
|
||||
payment.FailureReason = step.Error
|
||||
log.Info("Payment marked as FAILED due to step failure",
|
||||
zap.String("failed_step_code", step.Code),
|
||||
zap.String("error", step.Error),
|
||||
)
|
||||
return paymodel.PaymentStateFailed, nil
|
||||
|
||||
case paymodel.OperationStateSuccess:
|
||||
hasSuccess = true
|
||||
|
||||
case paymodel.OperationStateSkipped:
|
||||
// ok
|
||||
|
||||
default:
|
||||
allDone = false
|
||||
}
|
||||
}
|
||||
|
||||
if hasSuccess && allDone {
|
||||
log.Info("Payment marked as SUCCESS (all steps completed)")
|
||||
return paymodel.PaymentStateSuccess, nil
|
||||
}
|
||||
|
||||
log.Info("Payment still PROCESSING (steps not finished)")
|
||||
return paymodel.PaymentStateSubmitted, nil
|
||||
}
|
||||
|
||||
func executionStepStatusFromGatewayStatus(status rail.OperationResult) paymodel.OperationState {
|
||||
switch status {
|
||||
|
||||
case rail.OperationResultSuccess:
|
||||
return paymodel.OperationStateSuccess
|
||||
|
||||
case rail.OperationResultFailed:
|
||||
return paymodel.OperationStateFailed
|
||||
|
||||
case rail.OperationResultCancelled:
|
||||
return paymodel.OperationStateCancelled
|
||||
|
||||
default:
|
||||
return paymodel.OperationStateFailed
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) Shutdown() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
for _, consumer := range s.gatewayConsumers {
|
||||
if consumer != nil {
|
||||
consumer.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,922 +0,0 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/tech/sendico/payments/storage"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
"github.com/tech/sendico/pkg/mutil/mzap"
|
||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
type quotePaymentCommand struct {
|
||||
engine paymentEngine
|
||||
logger mlogger.Logger
|
||||
}
|
||||
|
||||
var (
|
||||
errIdempotencyRequired = errors.New("idempotency key is required")
|
||||
errPreviewWithIdempotency = errors.New("preview requests must not use idempotency key")
|
||||
errIdempotencyParamMismatch = errors.New("idempotency key reuse with different parameters")
|
||||
)
|
||||
|
||||
type quoteCtx struct {
|
||||
orgID string
|
||||
orgRef bson.ObjectID
|
||||
intent *orchestratorv1.PaymentIntent
|
||||
previewOnly bool
|
||||
idempotencyKey string
|
||||
hash string
|
||||
}
|
||||
|
||||
func (h *quotePaymentCommand) Execute(
|
||||
ctx context.Context,
|
||||
req *orchestratorv1.QuotePaymentRequest,
|
||||
) gsresponse.Responder[orchestratorv1.QuotePaymentResponse] {
|
||||
|
||||
if err := h.engine.EnsureRepository(ctx); err != nil {
|
||||
return gsresponse.Unavailable[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
if req == nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
|
||||
}
|
||||
|
||||
qc, err := h.prepareQuoteCtx(req)
|
||||
if err != nil {
|
||||
return h.mapQuoteErr(err)
|
||||
}
|
||||
|
||||
quotesStore, err := ensureQuotesStore(h.engine.Repository())
|
||||
if err != nil {
|
||||
return gsresponse.Unavailable[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
|
||||
quoteProto, err := h.quotePayment(ctx, quotesStore, qc, req)
|
||||
if err != nil {
|
||||
return h.mapQuoteErr(err)
|
||||
}
|
||||
|
||||
return gsresponse.Success(&orchestratorv1.QuotePaymentResponse{
|
||||
IdempotencyKey: req.GetIdempotencyKey(),
|
||||
Quote: quoteProto,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *quotePaymentCommand) prepareQuoteCtx(req *orchestratorv1.QuotePaymentRequest) (*quoteCtx, error) {
|
||||
orgRef, orgID, err := validateMetaAndOrgRef(req.GetMeta())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := requireNonNilIntent(req.GetIntent()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
intent := req.GetIntent()
|
||||
preview := req.GetPreviewOnly()
|
||||
idem := strings.TrimSpace(req.GetIdempotencyKey())
|
||||
|
||||
if preview && idem != "" {
|
||||
return nil, errPreviewWithIdempotency
|
||||
}
|
||||
if !preview && idem == "" {
|
||||
return nil, errIdempotencyRequired
|
||||
}
|
||||
|
||||
return "eCtx{
|
||||
orgID: orgRef,
|
||||
orgRef: orgID,
|
||||
intent: intent,
|
||||
previewOnly: preview,
|
||||
idempotencyKey: idem,
|
||||
hash: hashQuoteRequest(req),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *quotePaymentCommand) quotePayment(
|
||||
ctx context.Context,
|
||||
quotesStore storage.QuotesStore,
|
||||
qc *quoteCtx,
|
||||
req *orchestratorv1.QuotePaymentRequest,
|
||||
) (*orchestratorv1.PaymentQuote, error) {
|
||||
|
||||
if qc.previewOnly {
|
||||
quote, _, err := h.engine.BuildPaymentQuote(ctx, qc.orgID, req)
|
||||
if err != nil {
|
||||
h.logger.Warn("Failed to build preview payment quote", zap.Error(err), zap.String("org_ref", qc.orgID))
|
||||
return nil, err
|
||||
}
|
||||
quote.QuoteRef = bson.NewObjectID().Hex()
|
||||
return quote, nil
|
||||
}
|
||||
|
||||
existing, err := quotesStore.GetByIdempotencyKey(ctx, qc.orgRef, qc.idempotencyKey)
|
||||
if err != nil && !errors.Is(err, storage.ErrQuoteNotFound) && !errors.Is(err, merrors.ErrNoData) {
|
||||
h.logger.Warn("Failed to lookup quote by idempotency key", zap.Error(err),
|
||||
mzap.ObjRef("org_ref", qc.orgRef), zap.String("idempotency_key", qc.idempotencyKey),
|
||||
)
|
||||
return nil, err
|
||||
}
|
||||
if existing != nil {
|
||||
if existing.Hash != qc.hash {
|
||||
return nil, errIdempotencyParamMismatch
|
||||
}
|
||||
h.logger.Debug(
|
||||
"Idempotent quote reused",
|
||||
mzap.ObjRef("org_ref", qc.orgRef),
|
||||
zap.String("idempotency_key", qc.idempotencyKey),
|
||||
zap.String("quote_ref", existing.QuoteRef),
|
||||
)
|
||||
return modelQuoteToProto(existing.Quote), nil
|
||||
}
|
||||
|
||||
quote, expiresAt, err := h.engine.BuildPaymentQuote(ctx, qc.orgID, req)
|
||||
if err != nil {
|
||||
h.logger.Warn(
|
||||
"Failed to build payment quote",
|
||||
zap.Error(err),
|
||||
mzap.ObjRef("org_ref", qc.orgRef),
|
||||
zap.String("idempotency_key", qc.idempotencyKey),
|
||||
)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
quoteRef := bson.NewObjectID().Hex()
|
||||
quote.QuoteRef = quoteRef
|
||||
|
||||
record := &model.PaymentQuoteRecord{
|
||||
QuoteRef: quoteRef,
|
||||
IdempotencyKey: qc.idempotencyKey,
|
||||
Hash: qc.hash,
|
||||
Intent: intentFromProto(qc.intent),
|
||||
Quote: quoteSnapshotToModel(quote),
|
||||
ExpiresAt: expiresAt,
|
||||
}
|
||||
record.SetID(bson.NewObjectID())
|
||||
record.SetOrganizationRef(qc.orgRef)
|
||||
|
||||
if err := quotesStore.Create(ctx, record); err != nil {
|
||||
if errors.Is(err, storage.ErrDuplicateQuote) {
|
||||
existing, getErr := quotesStore.GetByIdempotencyKey(ctx, qc.orgRef, qc.idempotencyKey)
|
||||
if getErr == nil && existing != nil {
|
||||
if existing.Hash != qc.hash {
|
||||
return nil, errIdempotencyParamMismatch
|
||||
}
|
||||
return modelQuoteToProto(existing.Quote), nil
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
h.logger.Info(
|
||||
"Stored payment quote",
|
||||
zap.String("quote_ref", quoteRef),
|
||||
mzap.ObjRef("org_ref", qc.orgRef),
|
||||
zap.String("idempotency_key", qc.idempotencyKey),
|
||||
zap.String("kind", qc.intent.GetKind().String()),
|
||||
)
|
||||
|
||||
return quote, nil
|
||||
}
|
||||
|
||||
func (h *quotePaymentCommand) mapQuoteErr(err error) gsresponse.Responder[orchestratorv1.QuotePaymentResponse] {
|
||||
if errors.Is(err, errIdempotencyRequired) ||
|
||||
errors.Is(err, errPreviewWithIdempotency) ||
|
||||
errors.Is(err, errIdempotencyParamMismatch) {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
return gsresponse.Auto[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
|
||||
// TODO: temprorarary hashing function, replace with a proper solution later
|
||||
func hashQuoteRequest(req *orchestratorv1.QuotePaymentRequest) string {
|
||||
cloned := proto.Clone(req).(*orchestratorv1.QuotePaymentRequest)
|
||||
cloned.Meta = nil
|
||||
cloned.IdempotencyKey = ""
|
||||
cloned.PreviewOnly = false
|
||||
|
||||
b, err := proto.MarshalOptions{Deterministic: true}.Marshal(cloned)
|
||||
if err != nil {
|
||||
sum := sha256.Sum256([]byte("marshal_error"))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
sum := sha256.Sum256(b)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
type quotePaymentsCommand struct {
|
||||
engine paymentEngine
|
||||
logger mlogger.Logger
|
||||
}
|
||||
|
||||
var (
|
||||
errBatchIdempotencyRequired = errors.New("idempotency key is required")
|
||||
errBatchPreviewWithIdempotency = errors.New("preview requests must not use idempotency key")
|
||||
errBatchIdempotencyParamMismatch = errors.New("idempotency key reuse with different parameters")
|
||||
errBatchIdempotencyShapeMismatch = errors.New("idempotency key already used for a different quote shape")
|
||||
)
|
||||
|
||||
type quotePaymentsCtx struct {
|
||||
orgID string
|
||||
orgRef bson.ObjectID
|
||||
previewOnly bool
|
||||
idempotencyKey string
|
||||
hash string
|
||||
intentCount int
|
||||
}
|
||||
|
||||
func (h *quotePaymentsCommand) Execute(
|
||||
ctx context.Context,
|
||||
req *orchestratorv1.QuotePaymentsRequest,
|
||||
) gsresponse.Responder[orchestratorv1.QuotePaymentsResponse] {
|
||||
|
||||
if err := h.engine.EnsureRepository(ctx); err != nil {
|
||||
return gsresponse.Unavailable[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
if req == nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
|
||||
}
|
||||
|
||||
qc, intents, err := h.prepare(req)
|
||||
if err != nil {
|
||||
return h.mapErr(err)
|
||||
}
|
||||
|
||||
quotesStore, err := ensureQuotesStore(h.engine.Repository())
|
||||
if err != nil {
|
||||
return gsresponse.Unavailable[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
|
||||
if qc.previewOnly {
|
||||
quotes, expires, err := h.buildQuotes(ctx, req.GetMeta(), qc.idempotencyKey, intents, true)
|
||||
if err != nil {
|
||||
return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
aggregate, expiresAt, err := h.aggregate(quotes, expires)
|
||||
if err != nil {
|
||||
return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
_ = expiresAt
|
||||
return gsresponse.Success(&orchestratorv1.QuotePaymentsResponse{
|
||||
QuoteRef: "",
|
||||
Aggregate: aggregate,
|
||||
Quotes: quotes,
|
||||
})
|
||||
}
|
||||
|
||||
if rec, ok, err := h.tryReuse(ctx, quotesStore, qc); err != nil {
|
||||
return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
} else if ok {
|
||||
return gsresponse.Success(h.responseFromRecord(rec))
|
||||
}
|
||||
|
||||
quotes, expires, err := h.buildQuotes(ctx, req.GetMeta(), qc.idempotencyKey, intents, false)
|
||||
if err != nil {
|
||||
return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
|
||||
aggregate, expiresAt, err := h.aggregate(quotes, expires)
|
||||
if err != nil {
|
||||
return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
|
||||
quoteRef := bson.NewObjectID().Hex()
|
||||
for _, q := range quotes {
|
||||
if q != nil {
|
||||
q.QuoteRef = quoteRef
|
||||
}
|
||||
}
|
||||
|
||||
rec, err := h.storeBatch(ctx, quotesStore, qc, quoteRef, intents, quotes, expiresAt)
|
||||
if err != nil {
|
||||
return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
|
||||
if rec != nil {
|
||||
return gsresponse.Success(h.responseFromRecord(rec))
|
||||
}
|
||||
|
||||
h.logger.Info(
|
||||
"Stored payment quotes",
|
||||
h.logFields(qc, quoteRef, expiresAt, len(quotes))...,
|
||||
)
|
||||
|
||||
return gsresponse.Success(&orchestratorv1.QuotePaymentsResponse{
|
||||
IdempotencyKey: req.GetIdempotencyKey(),
|
||||
QuoteRef: quoteRef,
|
||||
Aggregate: aggregate,
|
||||
Quotes: quotes,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *quotePaymentsCommand) prepare(req *orchestratorv1.QuotePaymentsRequest) (*quotePaymentsCtx, []*orchestratorv1.PaymentIntent, error) {
|
||||
orgRefStr, orgID, err := validateMetaAndOrgRef(req.GetMeta())
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
intents := req.GetIntents()
|
||||
if len(intents) == 0 {
|
||||
return nil, nil, merrors.InvalidArgument("intents are required")
|
||||
}
|
||||
for _, intent := range intents {
|
||||
if err := requireNonNilIntent(intent); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
preview := req.GetPreviewOnly()
|
||||
idem := strings.TrimSpace(req.GetIdempotencyKey())
|
||||
|
||||
if preview && idem != "" {
|
||||
return nil, nil, errBatchPreviewWithIdempotency
|
||||
}
|
||||
if !preview && idem == "" {
|
||||
return nil, nil, errBatchIdempotencyRequired
|
||||
}
|
||||
|
||||
hash, err := hashQuotePaymentsIntents(intents)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return "ePaymentsCtx{
|
||||
orgID: orgRefStr,
|
||||
orgRef: orgID,
|
||||
previewOnly: preview,
|
||||
idempotencyKey: idem,
|
||||
hash: hash,
|
||||
intentCount: len(intents),
|
||||
}, intents, nil
|
||||
}
|
||||
|
||||
func (h *quotePaymentsCommand) tryReuse(
|
||||
ctx context.Context,
|
||||
quotesStore storage.QuotesStore,
|
||||
qc *quotePaymentsCtx,
|
||||
) (*model.PaymentQuoteRecord, bool, error) {
|
||||
|
||||
rec, err := quotesStore.GetByIdempotencyKey(ctx, qc.orgRef, qc.idempotencyKey)
|
||||
if err != nil {
|
||||
if errors.Is(err, storage.ErrQuoteNotFound) {
|
||||
return nil, false, nil
|
||||
}
|
||||
h.logger.Warn(
|
||||
"Failed to lookup payment quotes by idempotency key",
|
||||
h.logFields(qc, "", time.Time{}, 0)...,
|
||||
)
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
if len(rec.Quotes) == 0 {
|
||||
return nil, false, errBatchIdempotencyShapeMismatch
|
||||
}
|
||||
if rec.Hash != qc.hash {
|
||||
return nil, false, errBatchIdempotencyParamMismatch
|
||||
}
|
||||
|
||||
h.logger.Debug(
|
||||
"Idempotent payment quotes reused",
|
||||
h.logFields(qc, rec.QuoteRef, rec.ExpiresAt, len(rec.Quotes))...,
|
||||
)
|
||||
|
||||
return rec, true, nil
|
||||
}
|
||||
|
||||
func (h *quotePaymentsCommand) buildQuotes(
|
||||
ctx context.Context,
|
||||
meta *orchestratorv1.RequestMeta,
|
||||
baseKey string,
|
||||
intents []*orchestratorv1.PaymentIntent,
|
||||
preview bool,
|
||||
) ([]*orchestratorv1.PaymentQuote, []time.Time, error) {
|
||||
|
||||
quotes := make([]*orchestratorv1.PaymentQuote, 0, len(intents))
|
||||
expires := make([]time.Time, 0, len(intents))
|
||||
|
||||
for i, intent := range intents {
|
||||
req := &orchestratorv1.QuotePaymentRequest{
|
||||
Meta: meta,
|
||||
IdempotencyKey: perIntentIdempotencyKey(baseKey, i, len(intents)),
|
||||
Intent: intent,
|
||||
PreviewOnly: preview,
|
||||
}
|
||||
q, exp, err := h.engine.BuildPaymentQuote(ctx, meta.GetOrganizationRef(), req)
|
||||
if err != nil {
|
||||
h.logger.Warn(
|
||||
"Failed to build payment quote (batch item)",
|
||||
zap.Int("idx", i),
|
||||
zap.Error(err),
|
||||
)
|
||||
return nil, nil, err
|
||||
}
|
||||
quotes = append(quotes, q)
|
||||
expires = append(expires, exp)
|
||||
}
|
||||
|
||||
return quotes, expires, nil
|
||||
}
|
||||
|
||||
func (h *quotePaymentsCommand) aggregate(
|
||||
quotes []*orchestratorv1.PaymentQuote,
|
||||
expires []time.Time,
|
||||
) (*orchestratorv1.PaymentQuoteAggregate, time.Time, error) {
|
||||
|
||||
agg, err := aggregatePaymentQuotes(quotes)
|
||||
if err != nil {
|
||||
return nil, time.Time{}, merrors.InternalWrap(err, "quote aggregation failed")
|
||||
}
|
||||
|
||||
expiresAt, ok := minQuoteExpiry(expires)
|
||||
if !ok {
|
||||
return nil, time.Time{}, merrors.Internal("quote expiry missing")
|
||||
}
|
||||
|
||||
return agg, expiresAt, nil
|
||||
}
|
||||
|
||||
func (h *quotePaymentsCommand) storeBatch(
|
||||
ctx context.Context,
|
||||
quotesStore storage.QuotesStore,
|
||||
qc *quotePaymentsCtx,
|
||||
quoteRef string,
|
||||
intents []*orchestratorv1.PaymentIntent,
|
||||
quotes []*orchestratorv1.PaymentQuote,
|
||||
expiresAt time.Time,
|
||||
) (*model.PaymentQuoteRecord, error) {
|
||||
|
||||
record := &model.PaymentQuoteRecord{
|
||||
QuoteRef: quoteRef,
|
||||
IdempotencyKey: qc.idempotencyKey,
|
||||
Hash: qc.hash,
|
||||
Intents: intentsFromProto(intents),
|
||||
Quotes: quoteSnapshotsFromProto(quotes),
|
||||
ExpiresAt: expiresAt,
|
||||
}
|
||||
record.SetID(bson.NewObjectID())
|
||||
record.SetOrganizationRef(qc.orgRef)
|
||||
|
||||
if err := quotesStore.Create(ctx, record); err != nil {
|
||||
if errors.Is(err, storage.ErrDuplicateQuote) {
|
||||
rec, ok, reuseErr := h.tryReuse(ctx, quotesStore, qc)
|
||||
if reuseErr != nil {
|
||||
return nil, reuseErr
|
||||
}
|
||||
if ok {
|
||||
return rec, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (h *quotePaymentsCommand) responseFromRecord(rec *model.PaymentQuoteRecord) *orchestratorv1.QuotePaymentsResponse {
|
||||
quotes := modelQuotesToProto(rec.Quotes)
|
||||
for _, q := range quotes {
|
||||
if q != nil {
|
||||
q.QuoteRef = rec.QuoteRef
|
||||
}
|
||||
}
|
||||
aggregate, _ := aggregatePaymentQuotes(quotes)
|
||||
|
||||
return &orchestratorv1.QuotePaymentsResponse{
|
||||
QuoteRef: rec.QuoteRef,
|
||||
Aggregate: aggregate,
|
||||
Quotes: quotes,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *quotePaymentsCommand) logFields(qc *quotePaymentsCtx, quoteRef string, expiresAt time.Time, quoteCount int) []zap.Field {
|
||||
fields := []zap.Field{
|
||||
mzap.ObjRef("org_ref", qc.orgRef),
|
||||
zap.String("org_ref_str", qc.orgID),
|
||||
zap.String("idempotency_key", qc.idempotencyKey),
|
||||
zap.String("hash", qc.hash),
|
||||
zap.Bool("preview_only", qc.previewOnly),
|
||||
zap.Int("intent_count", qc.intentCount),
|
||||
}
|
||||
if quoteRef != "" {
|
||||
fields = append(fields, zap.String("quote_ref", quoteRef))
|
||||
}
|
||||
if !expiresAt.IsZero() {
|
||||
fields = append(fields, zap.Time("expires_at", expiresAt))
|
||||
}
|
||||
if quoteCount > 0 {
|
||||
fields = append(fields, zap.Int("quote_count", quoteCount))
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func (h *quotePaymentsCommand) mapErr(err error) gsresponse.Responder[orchestratorv1.QuotePaymentsResponse] {
|
||||
if errors.Is(err, errBatchIdempotencyRequired) ||
|
||||
errors.Is(err, errBatchPreviewWithIdempotency) ||
|
||||
errors.Is(err, errBatchIdempotencyParamMismatch) ||
|
||||
errors.Is(err, errBatchIdempotencyShapeMismatch) {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
|
||||
func modelQuotesToProto(snaps []*model.PaymentQuoteSnapshot) []*orchestratorv1.PaymentQuote {
|
||||
if len(snaps) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]*orchestratorv1.PaymentQuote, 0, len(snaps))
|
||||
for _, s := range snaps {
|
||||
out = append(out, modelQuoteToProto(s))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func hashQuotePaymentsIntents(intents []*orchestratorv1.PaymentIntent) (string, error) {
|
||||
type item struct {
|
||||
Idx int
|
||||
H [32]byte
|
||||
}
|
||||
items := make([]item, 0, len(intents))
|
||||
|
||||
for i, intent := range intents {
|
||||
b, err := proto.MarshalOptions{Deterministic: true}.Marshal(intent)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
items = append(items, item{Idx: i, H: sha256.Sum256(b)})
|
||||
}
|
||||
|
||||
sort.Slice(items, func(i, j int) bool { return items[i].Idx < items[j].Idx })
|
||||
|
||||
h := sha256.New()
|
||||
h.Write([]byte("quote-payments-fp/v1"))
|
||||
h.Write([]byte{0})
|
||||
for _, it := range items {
|
||||
h.Write(it.H[:])
|
||||
h.Write([]byte{0})
|
||||
}
|
||||
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
type initiatePaymentsCommand struct {
|
||||
engine paymentEngine
|
||||
logger mlogger.Logger
|
||||
}
|
||||
|
||||
func (h *initiatePaymentsCommand) Execute(ctx context.Context, req *orchestratorv1.InitiatePaymentsRequest) gsresponse.Responder[orchestratorv1.InitiatePaymentsResponse] {
|
||||
if err := h.engine.EnsureRepository(ctx); err != nil {
|
||||
return gsresponse.Unavailable[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
if req == nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
|
||||
}
|
||||
_, orgRef, err := validateMetaAndOrgRef(req.GetMeta())
|
||||
if err != nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
idempotencyKey, err := requireIdempotencyKey(req.GetIdempotencyKey())
|
||||
if err != nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
quoteRef := strings.TrimSpace(req.GetQuoteRef())
|
||||
if quoteRef == "" {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("quote_ref is required"))
|
||||
}
|
||||
|
||||
quotesStore, err := ensureQuotesStore(h.engine.Repository())
|
||||
if err != nil {
|
||||
return gsresponse.Unavailable[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
record, err := quotesStore.GetByRef(ctx, orgRef, quoteRef)
|
||||
if err != nil {
|
||||
if errors.Is(err, storage.ErrQuoteNotFound) {
|
||||
return gsresponse.FailedPrecondition[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, "quote_not_found", merrors.InvalidArgument("quote_ref not found or expired"))
|
||||
}
|
||||
return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
|
||||
intents := record.Intents
|
||||
quotes := record.Quotes
|
||||
if len(intents) == 0 && record.Intent.Kind != "" && record.Intent.Kind != model.PaymentKindUnspecified {
|
||||
intents = []model.PaymentIntent{record.Intent}
|
||||
}
|
||||
if len(quotes) == 0 && record.Quote != nil {
|
||||
quotes = []*model.PaymentQuoteSnapshot{record.Quote}
|
||||
}
|
||||
if len(intents) == 0 || len(quotes) == 0 || len(intents) != len(quotes) {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("stored quote payload is incomplete"))
|
||||
}
|
||||
|
||||
store, err := ensurePaymentsStore(h.engine.Repository())
|
||||
if err != nil {
|
||||
return gsresponse.Unavailable[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
|
||||
payments := make([]*orchestratorv1.Payment, 0, len(intents))
|
||||
for i := range intents {
|
||||
intentProto := protoIntentFromModel(intents[i])
|
||||
if err := requireNonNilIntent(intentProto); err != nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
quoteProto := modelQuoteToProto(quotes[i])
|
||||
if quoteProto == nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("stored quote is empty"))
|
||||
}
|
||||
quoteProto.QuoteRef = quoteRef
|
||||
|
||||
perKey := perIntentIdempotencyKey(idempotencyKey, i, len(intents))
|
||||
if existing, err := getPaymentByIdempotencyKey(ctx, store, orgRef, perKey); err == nil && existing != nil {
|
||||
payments = append(payments, toProtoPayment(existing))
|
||||
continue
|
||||
} else if err != nil && !errors.Is(err, storage.ErrPaymentNotFound) {
|
||||
return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
|
||||
entity := newPayment(orgRef, intentProto, perKey, req.GetMetadata(), quoteProto)
|
||||
if err = store.Create(ctx, entity); err != nil {
|
||||
if errors.Is(err, storage.ErrDuplicatePayment) {
|
||||
return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.DataConflict("payment already exists"))
|
||||
}
|
||||
return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
|
||||
if err := h.engine.ExecutePayment(ctx, store, entity, quoteProto); err != nil {
|
||||
return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
|
||||
payments = append(payments, toProtoPayment(entity))
|
||||
}
|
||||
|
||||
h.logger.Info(
|
||||
"Payments initiated",
|
||||
mzap.ObjRef("org_ref", orgRef),
|
||||
zap.String("quote_ref", quoteRef),
|
||||
zap.String("idempotency_key", idempotencyKey),
|
||||
zap.Int("payment_count", len(payments)),
|
||||
)
|
||||
return gsresponse.Success(&orchestratorv1.InitiatePaymentsResponse{Payments: payments})
|
||||
}
|
||||
|
||||
type initiatePaymentCommand struct {
|
||||
engine paymentEngine
|
||||
logger mlogger.Logger
|
||||
}
|
||||
|
||||
func (h *initiatePaymentCommand) Execute(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) gsresponse.Responder[orchestratorv1.InitiatePaymentResponse] {
|
||||
if err := h.engine.EnsureRepository(ctx); err != nil {
|
||||
return gsresponse.Unavailable[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
if req == nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
|
||||
}
|
||||
orgRef, orgID, err := validateMetaAndOrgRef(req.GetMeta())
|
||||
if err != nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
intent := req.GetIntent()
|
||||
quoteRef := strings.TrimSpace(req.GetQuoteRef())
|
||||
hasIntent := intent != nil
|
||||
hasQuote := quoteRef != ""
|
||||
switch {
|
||||
case !hasIntent && !hasQuote:
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("intent or quote_ref is required"))
|
||||
case hasIntent && hasQuote:
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("intent and quote_ref are mutually exclusive"))
|
||||
}
|
||||
if hasIntent {
|
||||
if err := requireNonNilIntent(intent); err != nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
}
|
||||
idempotencyKey, err := requireIdempotencyKey(req.GetIdempotencyKey())
|
||||
if err != nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
h.logger.Debug(
|
||||
"Initiate payment request accepted",
|
||||
mzap.ObjRef("org_ref", orgID),
|
||||
zap.String("idempotency_key", idempotencyKey),
|
||||
zap.String("quote_ref", quoteRef),
|
||||
zap.Bool("has_intent", hasIntent),
|
||||
)
|
||||
|
||||
store, err := ensurePaymentsStore(h.engine.Repository())
|
||||
if err != nil {
|
||||
return gsresponse.Unavailable[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
|
||||
if existing, err := getPaymentByIdempotencyKey(ctx, store, orgID, idempotencyKey); err == nil && existing != nil {
|
||||
h.logger.Debug(
|
||||
"idempotent payment request reused",
|
||||
zap.String("payment_ref", existing.PaymentRef),
|
||||
mzap.ObjRef("org_ref", orgID),
|
||||
zap.String("idempotency_key", idempotencyKey),
|
||||
zap.String("quote_ref", quoteRef),
|
||||
)
|
||||
return gsresponse.Success(&orchestratorv1.InitiatePaymentResponse{Payment: toProtoPayment(existing)})
|
||||
} else if err != nil && !errors.Is(err, storage.ErrPaymentNotFound) {
|
||||
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
|
||||
quoteSnapshot, resolvedIntent, err := h.engine.ResolvePaymentQuote(ctx, quoteResolutionInput{
|
||||
OrgRef: orgRef,
|
||||
OrgID: orgID,
|
||||
Meta: req.GetMeta(),
|
||||
Intent: intent,
|
||||
QuoteRef: quoteRef,
|
||||
IdempotencyKey: req.GetIdempotencyKey(),
|
||||
})
|
||||
if err != nil {
|
||||
if qerr, ok := err.(quoteResolutionError); ok {
|
||||
switch qerr.code {
|
||||
case "quote_not_found":
|
||||
return gsresponse.FailedPrecondition[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, qerr.code, qerr.err)
|
||||
case "quote_expired":
|
||||
return gsresponse.FailedPrecondition[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, qerr.code, qerr.err)
|
||||
case "quote_intent_mismatch":
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, qerr.err)
|
||||
default:
|
||||
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, qerr.err)
|
||||
}
|
||||
}
|
||||
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
if quoteSnapshot == nil {
|
||||
quoteSnapshot = &orchestratorv1.PaymentQuote{}
|
||||
}
|
||||
if err := requireNonNilIntent(resolvedIntent); err != nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
h.logger.Debug(
|
||||
"Payment quote resolved",
|
||||
mzap.ObjRef("org_ref", orgID),
|
||||
zap.String("quote_ref", quoteRef),
|
||||
zap.Bool("quote_ref_used", quoteRef != ""),
|
||||
)
|
||||
|
||||
entity := newPayment(orgID, resolvedIntent, idempotencyKey, req.GetMetadata(), quoteSnapshot)
|
||||
|
||||
if err = store.Create(ctx, entity); err != nil {
|
||||
if errors.Is(err, storage.ErrDuplicatePayment) {
|
||||
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.DataConflict("payment already exists"))
|
||||
}
|
||||
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
|
||||
if err := h.engine.ExecutePayment(ctx, store, entity, quoteSnapshot); err != nil {
|
||||
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
|
||||
h.logger.Info(
|
||||
"Payment initiated",
|
||||
zap.String("payment_ref", entity.PaymentRef),
|
||||
mzap.ObjRef("org_ref", orgID),
|
||||
zap.String("kind", resolvedIntent.GetKind().String()),
|
||||
zap.String("quote_ref", quoteSnapshot.GetQuoteRef()),
|
||||
zap.String("idempotency_key", idempotencyKey),
|
||||
)
|
||||
return gsresponse.Success(&orchestratorv1.InitiatePaymentResponse{
|
||||
Payment: toProtoPayment(entity),
|
||||
})
|
||||
}
|
||||
|
||||
type cancelPaymentCommand struct {
|
||||
engine paymentEngine
|
||||
logger mlogger.Logger
|
||||
}
|
||||
|
||||
func (h *cancelPaymentCommand) Execute(ctx context.Context, req *orchestratorv1.CancelPaymentRequest) gsresponse.Responder[orchestratorv1.CancelPaymentResponse] {
|
||||
if err := h.engine.EnsureRepository(ctx); err != nil {
|
||||
return gsresponse.Unavailable[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
if req == nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
|
||||
}
|
||||
paymentRef, err := requirePaymentRef(req.GetPaymentRef())
|
||||
if err != nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
store, err := ensurePaymentsStore(h.engine.Repository())
|
||||
if err != nil {
|
||||
return gsresponse.Unavailable[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
payment, err := store.GetByPaymentRef(ctx, paymentRef)
|
||||
if err != nil {
|
||||
return paymentNotFoundResponder[orchestratorv1.CancelPaymentResponse](mservice.PaymentOrchestrator, h.logger, err)
|
||||
}
|
||||
if payment.State != model.PaymentStateAccepted {
|
||||
reason := merrors.InvalidArgument("payment cannot be cancelled in current state")
|
||||
return gsresponse.FailedPrecondition[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, "payment_not_cancellable", reason)
|
||||
}
|
||||
payment.State = model.PaymentStateCancelled
|
||||
payment.FailureCode = model.PaymentFailureCodePolicy
|
||||
payment.FailureReason = strings.TrimSpace(req.GetReason())
|
||||
if err := store.Update(ctx, payment); err != nil {
|
||||
return gsresponse.Auto[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
h.logger.Info("Payment cancelled", zap.String("payment_ref", payment.PaymentRef), zap.String("org_ref", payment.OrganizationRef.Hex()))
|
||||
return gsresponse.Success(&orchestratorv1.CancelPaymentResponse{Payment: toProtoPayment(payment)})
|
||||
}
|
||||
|
||||
type initiateConversionCommand struct {
|
||||
engine paymentEngine
|
||||
logger mlogger.Logger
|
||||
}
|
||||
|
||||
func (h *initiateConversionCommand) Execute(ctx context.Context, req *orchestratorv1.InitiateConversionRequest) gsresponse.Responder[orchestratorv1.InitiateConversionResponse] {
|
||||
if err := h.engine.EnsureRepository(ctx); err != nil {
|
||||
return gsresponse.Unavailable[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
if req == nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
|
||||
}
|
||||
orgRef, orgID, err := validateMetaAndOrgRef(req.GetMeta())
|
||||
if err != nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
idempotencyKey, err := requireIdempotencyKey(req.GetIdempotencyKey())
|
||||
if err != nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
if req.GetSource() == nil || req.GetSource().GetLedger() == nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("source ledger endpoint is required"))
|
||||
}
|
||||
if req.GetDestination() == nil || req.GetDestination().GetLedger() == nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("destination ledger endpoint is required"))
|
||||
}
|
||||
fxIntent := req.GetFx()
|
||||
if fxIntent == nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("fx intent is required"))
|
||||
}
|
||||
|
||||
store, err := ensurePaymentsStore(h.engine.Repository())
|
||||
if err != nil {
|
||||
return gsresponse.Unavailable[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
|
||||
if existing, err := getPaymentByIdempotencyKey(ctx, store, orgID, idempotencyKey); err == nil && existing != nil {
|
||||
h.logger.Debug("Idempotent conversion request reused", zap.String("payment_ref", existing.PaymentRef), mzap.ObjRef("org_ref", orgID))
|
||||
return gsresponse.Success(&orchestratorv1.InitiateConversionResponse{Conversion: toProtoPayment(existing)})
|
||||
} else if err != nil && !errors.Is(err, storage.ErrPaymentNotFound) {
|
||||
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
|
||||
amount, err := conversionAmountFromMetadata(req.GetMetadata(), fxIntent)
|
||||
if err != nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
|
||||
intentProto := &orchestratorv1.PaymentIntent{
|
||||
Ref: uuid.New().String(),
|
||||
Kind: orchestratorv1.PaymentKind_PAYMENT_KIND_FX_CONVERSION,
|
||||
Source: req.GetSource(),
|
||||
Destination: req.GetDestination(),
|
||||
Amount: amount,
|
||||
RequiresFx: true,
|
||||
Fx: fxIntent,
|
||||
FeePolicy: req.GetFeePolicy(),
|
||||
SettlementCurrency: strings.TrimSpace(amount.GetCurrency()),
|
||||
}
|
||||
|
||||
quote, _, err := h.engine.BuildPaymentQuote(ctx, orgRef, &orchestratorv1.QuotePaymentRequest{
|
||||
Meta: req.GetMeta(),
|
||||
IdempotencyKey: req.GetIdempotencyKey(),
|
||||
Intent: intentProto,
|
||||
})
|
||||
if err != nil {
|
||||
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
|
||||
entity := newPayment(orgID, intentProto, idempotencyKey, req.GetMetadata(), quote)
|
||||
|
||||
if err = store.Create(ctx, entity); err != nil {
|
||||
if errors.Is(err, storage.ErrDuplicatePayment) {
|
||||
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, merrors.DataConflict("payment already exists"))
|
||||
}
|
||||
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
|
||||
if err := h.engine.ExecutePayment(ctx, store, entity, quote); err != nil {
|
||||
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
|
||||
h.logger.Info("Conversion initiated", zap.String("payment_ref", entity.PaymentRef), mzap.ObjRef("org_ref", orgID))
|
||||
return gsresponse.Success(&orchestratorv1.InitiateConversionResponse{
|
||||
Conversion: toProtoPayment(entity),
|
||||
})
|
||||
}
|
||||
@@ -1,318 +0,0 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/payments/storage"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type paymentEventHandler struct {
|
||||
repo storage.Repository
|
||||
ensureRepo func(ctx context.Context) error
|
||||
logger mlogger.Logger
|
||||
submitCardPayout func(ctx context.Context, operationRef string, payment *model.Payment) error
|
||||
resumePlan func(ctx context.Context, store storage.PaymentsStore, payment *model.Payment) error
|
||||
releaseHold func(ctx context.Context, store storage.PaymentsStore, payment *model.Payment) error
|
||||
}
|
||||
|
||||
func newPaymentEventHandler(repo storage.Repository, ensure func(ctx context.Context) error, logger mlogger.Logger, submitCardPayout func(ctx context.Context, operationRef string, payment *model.Payment) error, resumePlan func(ctx context.Context, store storage.PaymentsStore, payment *model.Payment) error, releaseHold func(ctx context.Context, store storage.PaymentsStore, payment *model.Payment) error) *paymentEventHandler {
|
||||
return &paymentEventHandler{
|
||||
repo: repo,
|
||||
ensureRepo: ensure,
|
||||
logger: logger,
|
||||
submitCardPayout: submitCardPayout,
|
||||
resumePlan: resumePlan,
|
||||
releaseHold: releaseHold,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *paymentEventHandler) processTransferUpdate(ctx context.Context, req *orchestratorv1.ProcessTransferUpdateRequest) gsresponse.Responder[orchestratorv1.ProcessTransferUpdateResponse] {
|
||||
if err := h.ensureRepo(ctx); err != nil {
|
||||
return gsresponse.Unavailable[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
if req == nil || req.GetEvent() == nil || req.GetEvent().GetTransfer() == nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("transfer event is required"))
|
||||
}
|
||||
transfer := req.GetEvent().GetTransfer()
|
||||
transferRef := strings.TrimSpace(transfer.GetTransferRef())
|
||||
if transferRef == "" {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("transfer_ref is required"))
|
||||
}
|
||||
store := h.repo.Payments()
|
||||
if store == nil {
|
||||
return gsresponse.Unavailable[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
|
||||
}
|
||||
payment, err := store.GetByChainTransferRef(ctx, transferRef)
|
||||
if err != nil {
|
||||
return paymentNotFoundResponder[orchestratorv1.ProcessTransferUpdateResponse](mservice.PaymentOrchestrator, h.logger, err)
|
||||
}
|
||||
if payment.PaymentPlan != nil && len(payment.PaymentPlan.Steps) > 0 {
|
||||
if payment.ExecutionPlan == nil || len(payment.ExecutionPlan.Steps) != len(payment.PaymentPlan.Steps) {
|
||||
ensureExecutionPlanForPlan(payment, payment.PaymentPlan)
|
||||
}
|
||||
updateExecutionStepFromTransfer(payment.ExecutionPlan, req.GetEvent())
|
||||
if payment.Execution == nil {
|
||||
payment.Execution = &model.ExecutionRefs{}
|
||||
}
|
||||
if payment.Execution.ChainTransferRef == "" {
|
||||
payment.Execution.ChainTransferRef = transferRef
|
||||
}
|
||||
reason := transferFailureReason(req.GetEvent())
|
||||
switch transfer.GetStatus() {
|
||||
case chainv1.TransferStatus_TRANSFER_FAILED:
|
||||
payment.State = model.PaymentStateFailed
|
||||
payment.FailureCode = model.PaymentFailureCodeChain
|
||||
payment.FailureReason = reason
|
||||
if err := store.Update(ctx, payment); err != nil {
|
||||
return gsresponse.Auto[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
return gsresponse.Success(&orchestratorv1.ProcessTransferUpdateResponse{Payment: toProtoPayment(payment)})
|
||||
case chainv1.TransferStatus_TRANSFER_CANCELLED:
|
||||
payment.State = model.PaymentStateCancelled
|
||||
payment.FailureCode = model.PaymentFailureCodePolicy
|
||||
payment.FailureReason = reason
|
||||
if err := store.Update(ctx, payment); err != nil {
|
||||
return gsresponse.Auto[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
return gsresponse.Success(&orchestratorv1.ProcessTransferUpdateResponse{Payment: toProtoPayment(payment)})
|
||||
case chainv1.TransferStatus_TRANSFER_SUCCESS:
|
||||
if h.resumePlan != nil {
|
||||
if err := h.resumePlan(ctx, store, payment); err != nil {
|
||||
return gsresponse.Auto[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
}
|
||||
return gsresponse.Success(&orchestratorv1.ProcessTransferUpdateResponse{Payment: toProtoPayment(payment)})
|
||||
case chainv1.TransferStatus_TRANSFER_WAITING:
|
||||
return gsresponse.Success(&orchestratorv1.ProcessTransferUpdateResponse{Payment: toProtoPayment(payment)})
|
||||
default:
|
||||
if err := store.Update(ctx, payment); err != nil {
|
||||
return gsresponse.Auto[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
return gsresponse.Success(&orchestratorv1.ProcessTransferUpdateResponse{Payment: toProtoPayment(payment)})
|
||||
}
|
||||
}
|
||||
|
||||
updateExecutionStepFromTransfer(payment.ExecutionPlan, req.GetEvent())
|
||||
if payment.Intent.Destination.Type == model.EndpointTypeCard {
|
||||
if payment.Execution == nil {
|
||||
payment.Execution = &model.ExecutionRefs{}
|
||||
}
|
||||
if payment.Execution.ChainTransferRef == "" {
|
||||
payment.Execution.ChainTransferRef = transferRef
|
||||
}
|
||||
reason := transferFailureReason(req.GetEvent())
|
||||
switch transfer.GetStatus() {
|
||||
case chainv1.TransferStatus_TRANSFER_FAILED:
|
||||
payment.State = model.PaymentStateFailed
|
||||
payment.FailureCode = model.PaymentFailureCodeChain
|
||||
payment.FailureReason = reason
|
||||
case chainv1.TransferStatus_TRANSFER_CANCELLED:
|
||||
payment.State = model.PaymentStateCancelled
|
||||
payment.FailureCode = model.PaymentFailureCodePolicy
|
||||
payment.FailureReason = reason
|
||||
case chainv1.TransferStatus_TRANSFER_SUCCESS:
|
||||
if payment.State != model.PaymentStateFailed && payment.State != model.PaymentStateCancelled && payment.State != model.PaymentStateSettled {
|
||||
if cardPayoutDependenciesConfirmed(payment.PaymentPlan, payment.ExecutionPlan) {
|
||||
if payment.Execution.CardPayoutRef == "" {
|
||||
payment.State = model.PaymentStateFundsReserved
|
||||
if h.submitCardPayout == nil {
|
||||
h.logger.Warn("card payout execution skipped", zap.String("payment_ref", payment.PaymentRef))
|
||||
} else if err := h.submitCardPayout(ctx, transfer.GetOperationRef(), payment); err != nil {
|
||||
payment.State = model.PaymentStateFailed
|
||||
payment.FailureCode = model.PaymentFailureCodePolicy
|
||||
payment.FailureReason = strings.TrimSpace(err.Error())
|
||||
h.logger.Warn("card payout execution failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
case chainv1.TransferStatus_TRANSFER_WAITING:
|
||||
default:
|
||||
// keep current state
|
||||
}
|
||||
if err := store.Update(ctx, payment); err != nil {
|
||||
return gsresponse.Auto[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
h.logger.Info("transfer update applied", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", transferRef), zap.Any("state", payment.State))
|
||||
return gsresponse.Success(&orchestratorv1.ProcessTransferUpdateResponse{Payment: toProtoPayment(payment)})
|
||||
}
|
||||
|
||||
applyTransferStatus(req.GetEvent(), payment)
|
||||
if err := store.Update(ctx, payment); err != nil {
|
||||
return gsresponse.Auto[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
h.logger.Info("transfer update applied", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", transferRef), zap.Any("state", payment.State))
|
||||
return gsresponse.Success(&orchestratorv1.ProcessTransferUpdateResponse{Payment: toProtoPayment(payment)})
|
||||
}
|
||||
|
||||
func (h *paymentEventHandler) processDepositObserved(ctx context.Context, req *orchestratorv1.ProcessDepositObservedRequest) gsresponse.Responder[orchestratorv1.ProcessDepositObservedResponse] {
|
||||
if err := h.ensureRepo(ctx); err != nil {
|
||||
return gsresponse.Unavailable[orchestratorv1.ProcessDepositObservedResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
if req == nil || req.GetEvent() == nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.ProcessDepositObservedResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("deposit event is required"))
|
||||
}
|
||||
event := req.GetEvent()
|
||||
walletRef := strings.TrimSpace(event.GetWalletRef())
|
||||
if walletRef == "" {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.ProcessDepositObservedResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("wallet_ref is required"))
|
||||
}
|
||||
store := h.repo.Payments()
|
||||
if store == nil {
|
||||
return gsresponse.Unavailable[orchestratorv1.ProcessDepositObservedResponse](h.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
|
||||
}
|
||||
filter := &model.PaymentFilter{
|
||||
States: []model.PaymentState{model.PaymentStateSubmitted, model.PaymentStateFundsReserved},
|
||||
DestinationRef: walletRef,
|
||||
}
|
||||
result, err := store.List(ctx, filter)
|
||||
if err != nil {
|
||||
return gsresponse.Auto[orchestratorv1.ProcessDepositObservedResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
for _, payment := range result.Items {
|
||||
if payment.Intent.Destination.Type != model.EndpointTypeManagedWallet {
|
||||
continue
|
||||
}
|
||||
if !moneyEquals(payment.Intent.Amount, event.GetAmount()) {
|
||||
continue
|
||||
}
|
||||
payment.State = model.PaymentStateSettled
|
||||
payment.FailureCode = model.PaymentFailureCodeUnspecified
|
||||
payment.FailureReason = ""
|
||||
if payment.Execution == nil {
|
||||
payment.Execution = &model.ExecutionRefs{}
|
||||
}
|
||||
if payment.Execution.ChainTransferRef == "" {
|
||||
payment.Execution.ChainTransferRef = strings.TrimSpace(event.GetTransactionHash())
|
||||
}
|
||||
if err := store.Update(ctx, payment); err != nil {
|
||||
return gsresponse.Auto[orchestratorv1.ProcessDepositObservedResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
h.logger.Info("deposit observed matched payment", zap.String("payment_ref", payment.PaymentRef), zap.String("wallet_ref", walletRef))
|
||||
return gsresponse.Success(&orchestratorv1.ProcessDepositObservedResponse{Payment: toProtoPayment(payment)})
|
||||
}
|
||||
return gsresponse.Success(&orchestratorv1.ProcessDepositObservedResponse{})
|
||||
}
|
||||
|
||||
func (h *paymentEventHandler) processCardPayoutUpdate(ctx context.Context, req *orchestratorv1.ProcessCardPayoutUpdateRequest) gsresponse.Responder[orchestratorv1.ProcessCardPayoutUpdateResponse] {
|
||||
if err := h.ensureRepo(ctx); err != nil {
|
||||
return gsresponse.Unavailable[orchestratorv1.ProcessCardPayoutUpdateResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
if req == nil || req.GetEvent() == nil || req.GetEvent().GetPayout() == nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.ProcessCardPayoutUpdateResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("event is required"))
|
||||
}
|
||||
payout := req.GetEvent().GetPayout()
|
||||
paymentRef := strings.TrimSpace(payout.GetPayoutId())
|
||||
if paymentRef == "" {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.ProcessCardPayoutUpdateResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("payout_id is required"))
|
||||
}
|
||||
|
||||
store := h.repo.Payments()
|
||||
if store == nil {
|
||||
return gsresponse.Unavailable[orchestratorv1.ProcessCardPayoutUpdateResponse](h.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
|
||||
}
|
||||
payment, err := store.GetByPaymentRef(ctx, paymentRef)
|
||||
if err != nil {
|
||||
return paymentNotFoundResponder[orchestratorv1.ProcessCardPayoutUpdateResponse](mservice.PaymentOrchestrator, h.logger, err)
|
||||
}
|
||||
|
||||
applyCardPayoutUpdate(payment, payout)
|
||||
|
||||
switch payout.GetStatus() {
|
||||
|
||||
case mntxv1.PayoutStatus_PAYOUT_STATUS_SUCCESS:
|
||||
h.logger.Info("card payout success received",
|
||||
zap.String("payment_ref", payment.PaymentRef),
|
||||
zap.String("payout_ref", payout.GetPayoutId()),
|
||||
zap.String("payment_state_before", string(payment.State)),
|
||||
zap.Bool("has_plan", payment.PaymentPlan != nil && len(payment.PaymentPlan.Steps) > 0),
|
||||
zap.Bool("resume_plan_present", h.resumePlan != nil),
|
||||
)
|
||||
|
||||
if h.resumePlan != nil && payment.PaymentPlan != nil && len(payment.PaymentPlan.Steps) > 0 {
|
||||
if err := h.resumePlan(ctx, store, payment); err != nil {
|
||||
h.logger.Error("resumePlan failed after payout success",
|
||||
zap.String("payment_ref", payment.PaymentRef),
|
||||
zap.String("payout_ref", payout.GetPayoutId()),
|
||||
zap.Error(err),
|
||||
)
|
||||
return gsresponse.Auto[orchestratorv1.ProcessCardPayoutUpdateResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
h.logger.Info("resumePlan executed after payout success",
|
||||
zap.String("payment_ref", payment.PaymentRef),
|
||||
zap.String("payout_ref", payout.GetPayoutId()),
|
||||
)
|
||||
} else {
|
||||
h.logger.Warn("payout success but plan cannot be resumed",
|
||||
zap.String("payment_ref", payment.PaymentRef),
|
||||
zap.String("payout_ref", payout.GetPayoutId()),
|
||||
zap.Bool("resume_plan_present", h.resumePlan != nil),
|
||||
zap.Bool("has_plan", payment.PaymentPlan != nil && len(payment.PaymentPlan.Steps) > 0),
|
||||
)
|
||||
}
|
||||
|
||||
case mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED:
|
||||
h.logger.Warn("card payout failed",
|
||||
zap.String("payment_ref", payment.PaymentRef),
|
||||
zap.String("payout_ref", payout.GetPayoutId()),
|
||||
zap.String("provider_message", payout.GetProviderMessage()),
|
||||
)
|
||||
|
||||
payment.State = model.PaymentStateFailed
|
||||
payment.FailureCode = model.PaymentFailureCodePolicy
|
||||
payment.FailureReason = strings.TrimSpace(payout.GetProviderMessage())
|
||||
|
||||
if h.releaseHold != nil && payment.PaymentPlan != nil && len(payment.PaymentPlan.Steps) > 0 {
|
||||
h.logger.Info("releasing hold after payout failure",
|
||||
zap.String("payment_ref", payment.PaymentRef),
|
||||
zap.String("payout_ref", payout.GetPayoutId()),
|
||||
)
|
||||
|
||||
if err := h.releaseHold(ctx, store, payment); err != nil {
|
||||
h.logger.Error("releaseHold failed after payout failure",
|
||||
zap.String("payment_ref", payment.PaymentRef),
|
||||
zap.String("payout_ref", payout.GetPayoutId()),
|
||||
zap.Error(err),
|
||||
)
|
||||
return gsresponse.Auto[orchestratorv1.ProcessCardPayoutUpdateResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
} else {
|
||||
h.logger.Warn("payout failed but hold cannot be released",
|
||||
zap.String("payment_ref", payment.PaymentRef),
|
||||
zap.String("payout_ref", payout.GetPayoutId()),
|
||||
zap.Bool("release_hold_present", h.releaseHold != nil),
|
||||
zap.Bool("has_plan", payment.PaymentPlan != nil && len(payment.PaymentPlan.Steps) > 0),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if err := store.Update(ctx, payment); err != nil {
|
||||
return gsresponse.Auto[orchestratorv1.ProcessCardPayoutUpdateResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
|
||||
h.logger.Info("card payout update applied", zap.String("payment_ref", payment.PaymentRef), zap.String("payout_id", paymentRef), zap.Any("state", payment.State))
|
||||
return gsresponse.Success(&orchestratorv1.ProcessCardPayoutUpdateResponse{
|
||||
Payment: toProtoPayment(payment),
|
||||
})
|
||||
}
|
||||
|
||||
func transferFailureReason(event *chainv1.TransferStatusChangedEvent) string {
|
||||
if event == nil || event.GetTransfer() == nil {
|
||||
return ""
|
||||
}
|
||||
reason := strings.TrimSpace(event.GetReason())
|
||||
if reason != "" {
|
||||
return reason
|
||||
}
|
||||
return strings.TrimSpace(event.GetTransfer().GetFailureReason())
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/tech/sendico/payments/storage"
|
||||
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
|
||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type paymentQueryHandler struct {
|
||||
repo storage.Repository
|
||||
ensureRepo func(ctx context.Context) error
|
||||
logger mlogger.Logger
|
||||
}
|
||||
|
||||
func newPaymentQueryHandler(repo storage.Repository, ensure func(ctx context.Context) error, logger mlogger.Logger) *paymentQueryHandler {
|
||||
return &paymentQueryHandler{
|
||||
repo: repo,
|
||||
ensureRepo: ensure,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *paymentQueryHandler) getPayment(ctx context.Context, req *orchestratorv1.GetPaymentRequest) gsresponse.Responder[orchestratorv1.GetPaymentResponse] {
|
||||
if err := h.ensureRepo(ctx); err != nil {
|
||||
return gsresponse.Unavailable[orchestratorv1.GetPaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
if req == nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.GetPaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
|
||||
}
|
||||
paymentRef, err := requirePaymentRef(req.GetPaymentRef())
|
||||
if err != nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.GetPaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
store, err := ensurePaymentsStore(h.repo)
|
||||
if err != nil {
|
||||
return gsresponse.Unavailable[orchestratorv1.GetPaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
entity, err := store.GetByPaymentRef(ctx, paymentRef)
|
||||
if err != nil {
|
||||
return paymentNotFoundResponder[orchestratorv1.GetPaymentResponse](mservice.PaymentOrchestrator, h.logger, err)
|
||||
}
|
||||
h.logger.Debug("payment fetched", zap.String("payment_ref", paymentRef))
|
||||
return gsresponse.Success(&orchestratorv1.GetPaymentResponse{Payment: toProtoPayment(entity)})
|
||||
}
|
||||
|
||||
func (h *paymentQueryHandler) listPayments(ctx context.Context, req *orchestratorv1.ListPaymentsRequest) gsresponse.Responder[orchestratorv1.ListPaymentsResponse] {
|
||||
if err := h.ensureRepo(ctx); err != nil {
|
||||
return gsresponse.Unavailable[orchestratorv1.ListPaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
if req == nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.ListPaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
|
||||
}
|
||||
store, err := ensurePaymentsStore(h.repo)
|
||||
if err != nil {
|
||||
return gsresponse.Unavailable[orchestratorv1.ListPaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
filter := filterFromProto(req)
|
||||
result, err := store.List(ctx, filter)
|
||||
if err != nil {
|
||||
return gsresponse.Auto[orchestratorv1.ListPaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
resp := &orchestratorv1.ListPaymentsResponse{
|
||||
Page: &paginationv1.CursorPageResponse{
|
||||
NextCursor: result.NextCursor,
|
||||
},
|
||||
}
|
||||
resp.Payments = make([]*orchestratorv1.Payment, 0, len(result.Items))
|
||||
for _, item := range result.Items {
|
||||
resp.Payments = append(resp.Payments, toProtoPayment(item))
|
||||
}
|
||||
h.logger.Debug("payments listed", zap.Int("count", len(resp.Payments)), zap.String("next_cursor", resp.GetPage().GetNextCursor()))
|
||||
return gsresponse.Success(resp)
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/payments/storage"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
|
||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type paymentExecutor struct {
|
||||
deps *serviceDependencies
|
||||
logger mlogger.Logger
|
||||
svc *Service
|
||||
}
|
||||
|
||||
func newPaymentExecutor(deps *serviceDependencies, logger mlogger.Logger, svc *Service) *paymentExecutor {
|
||||
return &paymentExecutor{deps: deps, logger: logger, svc: svc}
|
||||
}
|
||||
|
||||
func (p *paymentExecutor) executePayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, quote *orchestratorv1.PaymentQuote) error {
|
||||
if store == nil {
|
||||
return errStorageUnavailable
|
||||
}
|
||||
if p.svc == nil {
|
||||
return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, "service_unavailable", errStorageUnavailable)
|
||||
}
|
||||
if p.svc.storage == nil {
|
||||
return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, "routes_store_unavailable", errStorageUnavailable)
|
||||
}
|
||||
routeStore := p.svc.storage.Routes()
|
||||
if routeStore == nil {
|
||||
return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, "routes_store_unavailable", errStorageUnavailable)
|
||||
}
|
||||
planTemplates := p.svc.storage.PlanTemplates()
|
||||
if planTemplates == nil {
|
||||
return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, "plan_templates_store_unavailable", errStorageUnavailable)
|
||||
}
|
||||
builder := p.svc.deps.planBuilder
|
||||
if builder == nil {
|
||||
builder = newDefaultPlanBuilder(p.logger)
|
||||
}
|
||||
plan, err := builder.Build(ctx, payment, quote, routeStore, planTemplates, p.svc.deps.gatewayRegistry)
|
||||
if err != nil {
|
||||
p.logPlanBuilderFailure(payment, err)
|
||||
return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, strings.TrimSpace(err.Error()), err)
|
||||
}
|
||||
if plan == nil || len(plan.Steps) == 0 {
|
||||
return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, "payment_plan_empty", merrors.InvalidArgument("payment plan is required"))
|
||||
}
|
||||
payment.PaymentPlan = plan
|
||||
|
||||
return p.executePaymentPlan(ctx, store, payment, quote)
|
||||
}
|
||||
|
||||
func (p *paymentExecutor) logPlanBuilderFailure(payment *model.Payment, err error) {
|
||||
if p == nil || payment == nil {
|
||||
return
|
||||
}
|
||||
intent := payment.Intent
|
||||
sourceRail, sourceNetwork, sourceErr := railFromEndpoint(intent.Source, intent.Attributes, true)
|
||||
destRail, destNetwork, destErr := railFromEndpoint(intent.Destination, intent.Attributes, false)
|
||||
|
||||
fields := []zap.Field{
|
||||
zap.Error(err),
|
||||
zap.String("payment_ref", payment.PaymentRef),
|
||||
zap.String("org_ref", payment.OrganizationRef.Hex()),
|
||||
zap.String("idempotency_key", payment.IdempotencyKey),
|
||||
zap.String("source_rail", string(sourceRail)),
|
||||
zap.String("destination_rail", string(destRail)),
|
||||
zap.String("source_network", sourceNetwork),
|
||||
zap.String("destination_network", destNetwork),
|
||||
zap.String("source_endpoint_type", string(intent.Source.Type)),
|
||||
zap.String("destination_endpoint_type", string(intent.Destination.Type)),
|
||||
}
|
||||
|
||||
missing := make([]string, 0, 2)
|
||||
if sourceErr != nil || sourceRail == model.RailUnspecified {
|
||||
missing = append(missing, "source")
|
||||
if sourceErr != nil {
|
||||
fields = append(fields, zap.String("source_rail_error", sourceErr.Error()))
|
||||
}
|
||||
}
|
||||
if destErr != nil || destRail == model.RailUnspecified {
|
||||
missing = append(missing, "destination")
|
||||
if destErr != nil {
|
||||
fields = append(fields, zap.String("destination_rail_error", destErr.Error()))
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
fields = append(fields, zap.String("missing_rails", strings.Join(missing, ",")))
|
||||
p.logger.Warn("Payment rail resolution failed", fields...)
|
||||
return
|
||||
}
|
||||
|
||||
routeNetwork, routeErr := resolveRouteNetwork(intent.Attributes, sourceNetwork, destNetwork)
|
||||
if routeErr != nil {
|
||||
fields = append(fields, zap.String("route_network_error", routeErr.Error()))
|
||||
} else if routeNetwork != "" {
|
||||
fields = append(fields, zap.String("route_network", routeNetwork))
|
||||
}
|
||||
p.logger.Warn("Payment route missing for rails", fields...)
|
||||
}
|
||||
|
||||
func (p *paymentExecutor) applyFX(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote, charges []*ledgerv1.PostingLine, description string, metadata map[string]string, exec *model.ExecutionRefs) error {
|
||||
intent := payment.Intent
|
||||
source := intent.Source.Ledger
|
||||
destination := intent.Destination.Ledger
|
||||
if source == nil || destination == nil {
|
||||
return merrors.InvalidArgument("ledger: fx conversion requires ledger source and destination")
|
||||
}
|
||||
fq := quote.GetFxQuote()
|
||||
if fq == nil {
|
||||
return merrors.InvalidArgument("ledger: fx quote missing")
|
||||
}
|
||||
fxSide := fxv1.Side_SIDE_UNSPECIFIED
|
||||
if intent.FX != nil {
|
||||
fxSide = fxSideToProto(intent.FX.Side)
|
||||
}
|
||||
fromMoney, toMoney := resolveTradeAmounts(protoMoney(intent.Amount), fq, fxSide)
|
||||
if fromMoney == nil {
|
||||
fromMoney = protoMoney(intent.Amount)
|
||||
}
|
||||
if toMoney == nil {
|
||||
toMoney = cloneProtoMoney(quote.GetExpectedSettlementAmount())
|
||||
}
|
||||
rate := ""
|
||||
if fq.GetPrice() != nil {
|
||||
rate = fq.GetPrice().GetValue()
|
||||
}
|
||||
req := &ledgerv1.FXRequest{
|
||||
IdempotencyKey: payment.IdempotencyKey,
|
||||
OrganizationRef: payment.OrganizationRef.Hex(),
|
||||
FromLedgerAccountRef: strings.TrimSpace(source.LedgerAccountRef),
|
||||
ToLedgerAccountRef: strings.TrimSpace(destination.LedgerAccountRef),
|
||||
FromMoney: fromMoney,
|
||||
ToMoney: toMoney,
|
||||
Rate: rate,
|
||||
Description: description,
|
||||
Charges: charges,
|
||||
Metadata: metadata,
|
||||
}
|
||||
resp, err := p.deps.ledger.client.ApplyFXWithCharges(ctx, req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
exec.FXEntryRef = strings.TrimSpace(resp.GetJournalEntryRef())
|
||||
payment.Execution = exec
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *paymentExecutor) persistPayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment) error {
|
||||
if store == nil {
|
||||
return errStorageUnavailable
|
||||
}
|
||||
return store.Update(ctx, payment)
|
||||
}
|
||||
|
||||
func (p *paymentExecutor) failPayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, code model.PaymentFailureCode, reason string, err error) error {
|
||||
payment.State = model.PaymentStateFailed
|
||||
payment.FailureCode = code
|
||||
payment.FailureReason = strings.TrimSpace(reason)
|
||||
if store != nil {
|
||||
if updateErr := store.Update(ctx, payment); updateErr != nil {
|
||||
p.logger.Error("failed to persist payment failure", zap.Error(updateErr), zap.String("payment_ref", payment.PaymentRef))
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return merrors.Internal(reason)
|
||||
}
|
||||
|
||||
func paymentDescription(payment *model.Payment) string {
|
||||
if payment == nil {
|
||||
return ""
|
||||
}
|
||||
if val := strings.TrimSpace(payment.Intent.Attributes["description"]); val != "" {
|
||||
return val
|
||||
}
|
||||
if payment.Metadata != nil {
|
||||
if val := strings.TrimSpace(payment.Metadata["description"]); val != "" {
|
||||
return val
|
||||
}
|
||||
}
|
||||
return payment.PaymentRef
|
||||
}
|
||||
|
||||
func applyTransferStatus(event *chainv1.TransferStatusChangedEvent, payment *model.Payment) {
|
||||
if payment.Execution == nil {
|
||||
payment.Execution = &model.ExecutionRefs{}
|
||||
}
|
||||
if event == nil || event.GetTransfer() == nil {
|
||||
return
|
||||
}
|
||||
transfer := event.GetTransfer()
|
||||
payment.Execution.ChainTransferRef = strings.TrimSpace(transfer.GetTransferRef())
|
||||
reason := strings.TrimSpace(event.GetReason())
|
||||
if reason == "" {
|
||||
reason = strings.TrimSpace(transfer.GetFailureReason())
|
||||
}
|
||||
switch transfer.GetStatus() {
|
||||
|
||||
case chainv1.TransferStatus_TRANSFER_SUCCESS:
|
||||
payment.State = model.PaymentStateSettled
|
||||
payment.FailureCode = model.PaymentFailureCodeUnspecified
|
||||
payment.FailureReason = ""
|
||||
|
||||
case chainv1.TransferStatus_TRANSFER_FAILED:
|
||||
payment.State = model.PaymentStateFailed
|
||||
payment.FailureCode = model.PaymentFailureCodeChain
|
||||
payment.FailureReason = reason
|
||||
|
||||
case chainv1.TransferStatus_TRANSFER_CANCELLED:
|
||||
payment.State = model.PaymentStateCancelled
|
||||
payment.FailureCode = model.PaymentFailureCodePolicy
|
||||
payment.FailureReason = reason
|
||||
|
||||
case chainv1.TransferStatus_TRANSFER_WAITING:
|
||||
payment.State = model.PaymentStateSubmitted
|
||||
|
||||
case chainv1.TransferStatus_TRANSFER_CREATED,
|
||||
chainv1.TransferStatus_TRANSFER_PROCESSING:
|
||||
// do nothing, retain previous state
|
||||
|
||||
default:
|
||||
// retain previous state
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type Liveness string
|
||||
|
||||
const (
|
||||
StepFinal Liveness = "final"
|
||||
StepRunnable Liveness = "runnable"
|
||||
StepBlocked Liveness = "blocked"
|
||||
StepDead Liveness = "dead"
|
||||
)
|
||||
|
||||
func buildPaymentStepIndex(plan *model.PaymentPlan) map[string]*model.PaymentStep {
|
||||
idx := make(map[string]*model.PaymentStep, len(plan.Steps))
|
||||
for _, s := range plan.Steps {
|
||||
idx[s.StepID] = s
|
||||
}
|
||||
return idx
|
||||
}
|
||||
|
||||
func buildExecutionStepIndex(plan *model.ExecutionPlan) map[string]*model.ExecutionStep {
|
||||
index := make(map[string]*model.ExecutionStep, len(plan.Steps))
|
||||
for _, s := range plan.Steps {
|
||||
if s == nil {
|
||||
continue
|
||||
}
|
||||
index[s.Code] = s
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
func stepLiveness(
|
||||
logger mlogger.Logger,
|
||||
step *model.ExecutionStep,
|
||||
pStepIdx map[string]*model.PaymentStep,
|
||||
eStepIdx map[string]*model.ExecutionStep,
|
||||
) Liveness {
|
||||
|
||||
if step.IsTerminal() {
|
||||
return StepFinal
|
||||
}
|
||||
|
||||
pStep, ok := pStepIdx[step.Code]
|
||||
if !ok {
|
||||
logger.Error("step missing in payment plan",
|
||||
zap.String("step_id", step.Code),
|
||||
)
|
||||
return StepDead
|
||||
}
|
||||
|
||||
for _, depID := range pStep.DependsOn {
|
||||
dep := eStepIdx[depID]
|
||||
if dep == nil {
|
||||
logger.Warn("dependency missing in execution plan",
|
||||
zap.String("step_id", step.Code),
|
||||
zap.String("dep_id", depID),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
switch dep.State {
|
||||
case model.OperationStateFailed:
|
||||
return StepDead
|
||||
}
|
||||
}
|
||||
|
||||
allSuccess := true
|
||||
for _, depID := range pStep.DependsOn {
|
||||
dep := eStepIdx[depID]
|
||||
if dep == nil || dep.State != model.OperationStateSuccess {
|
||||
allSuccess = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if allSuccess {
|
||||
return StepRunnable
|
||||
}
|
||||
|
||||
return StepBlocked
|
||||
}
|
||||
|
||||
func analyzeExecutionPlan(
|
||||
logger mlogger.Logger,
|
||||
payment *model.Payment,
|
||||
) (bool, bool, error) {
|
||||
|
||||
if payment == nil || payment.ExecutionPlan == nil {
|
||||
return true, false, nil
|
||||
}
|
||||
|
||||
eIdx := buildExecutionStepIndex(payment.ExecutionPlan)
|
||||
pIdx := buildPaymentStepIndex(payment.PaymentPlan)
|
||||
|
||||
hasRunnable := false
|
||||
hasFailed := false
|
||||
var rootErr error
|
||||
|
||||
for _, s := range payment.ExecutionPlan.Steps {
|
||||
live := stepLiveness(logger, s, pIdx, eIdx)
|
||||
|
||||
if live == StepRunnable {
|
||||
hasRunnable = true
|
||||
}
|
||||
|
||||
if s.State == model.OperationStateFailed {
|
||||
hasFailed = true
|
||||
if rootErr == nil && s.Error != "" {
|
||||
rootErr = errors.New(s.Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
done := !hasRunnable
|
||||
return done, hasFailed, rootErr
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/model/account_role"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||
)
|
||||
|
||||
func (p *paymentExecutor) submitCardPayoutPlan(ctx context.Context, payment *model.Payment, operationRef string, amount *moneyv1.Money, fromRole, toRole *account_role.AccountRole) (string, error) {
|
||||
if payment == nil {
|
||||
return "", merrors.InvalidArgument("payment is required")
|
||||
}
|
||||
if !p.deps.mntx.available() {
|
||||
return "", merrors.Internal("card_gateway_unavailable")
|
||||
}
|
||||
intent := payment.Intent
|
||||
card := intent.Destination.Card
|
||||
if card == nil {
|
||||
return "", merrors.InvalidArgument("card payout: card endpoint is required")
|
||||
}
|
||||
if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" {
|
||||
return "", merrors.InvalidArgument("card payout: amount is required")
|
||||
}
|
||||
|
||||
amtDec, err := decimalFromMoney(amount)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
minor := amtDec.Mul(decimal.NewFromInt(100)).IntPart()
|
||||
|
||||
payoutID := payment.PaymentRef
|
||||
currency := strings.TrimSpace(amount.GetCurrency())
|
||||
holder := strings.TrimSpace(card.Cardholder)
|
||||
meta := cloneMetadata(payment.Metadata)
|
||||
if strings.TrimSpace(string(mergeAccountRole(fromRole))) != "" {
|
||||
if meta == nil {
|
||||
meta = map[string]string{}
|
||||
}
|
||||
meta[account_role.MetadataKeyFromRole] = strings.TrimSpace(string(mergeAccountRole(fromRole)))
|
||||
}
|
||||
if strings.TrimSpace(string(mergeAccountRole(toRole))) != "" {
|
||||
if meta == nil {
|
||||
meta = map[string]string{}
|
||||
}
|
||||
meta[account_role.MetadataKeyToRole] = strings.TrimSpace(string(mergeAccountRole(toRole)))
|
||||
}
|
||||
customer := intent.Customer
|
||||
customerID := ""
|
||||
customerFirstName := ""
|
||||
customerMiddleName := ""
|
||||
customerLastName := ""
|
||||
customerIP := ""
|
||||
customerZip := ""
|
||||
customerCountry := ""
|
||||
customerState := ""
|
||||
customerCity := ""
|
||||
customerAddress := ""
|
||||
if customer != nil {
|
||||
customerID = strings.TrimSpace(customer.ID)
|
||||
customerFirstName = strings.TrimSpace(customer.FirstName)
|
||||
customerMiddleName = strings.TrimSpace(customer.MiddleName)
|
||||
customerLastName = strings.TrimSpace(customer.LastName)
|
||||
customerIP = strings.TrimSpace(customer.IP)
|
||||
customerZip = strings.TrimSpace(customer.Zip)
|
||||
customerCountry = strings.TrimSpace(customer.Country)
|
||||
customerState = strings.TrimSpace(customer.State)
|
||||
customerCity = strings.TrimSpace(customer.City)
|
||||
customerAddress = strings.TrimSpace(customer.Address)
|
||||
}
|
||||
if customerFirstName == "" {
|
||||
customerFirstName = strings.TrimSpace(card.Cardholder)
|
||||
}
|
||||
if customerLastName == "" {
|
||||
customerLastName = strings.TrimSpace(card.CardholderSurname)
|
||||
}
|
||||
if customerID == "" {
|
||||
return "", merrors.InvalidArgument("card payout: customer id is required")
|
||||
}
|
||||
if customerFirstName == "" {
|
||||
return "", merrors.InvalidArgument("card payout: customer first name is required")
|
||||
}
|
||||
if customerLastName == "" {
|
||||
return "", merrors.InvalidArgument("card payout: customer last name is required")
|
||||
}
|
||||
if customerIP == "" {
|
||||
return "", merrors.InvalidArgument("card payout: customer ip is required")
|
||||
}
|
||||
|
||||
var state *mntxv1.CardPayoutState
|
||||
if token := strings.TrimSpace(card.Token); token != "" {
|
||||
req := &mntxv1.CardTokenPayoutRequest{
|
||||
PayoutId: payoutID,
|
||||
CustomerId: customerID,
|
||||
CustomerFirstName: customerFirstName,
|
||||
CustomerMiddleName: customerMiddleName,
|
||||
CustomerLastName: customerLastName,
|
||||
CustomerIp: customerIP,
|
||||
CustomerZip: customerZip,
|
||||
CustomerCountry: customerCountry,
|
||||
CustomerState: customerState,
|
||||
CustomerCity: customerCity,
|
||||
CustomerAddress: customerAddress,
|
||||
AmountMinor: minor,
|
||||
Currency: currency,
|
||||
CardToken: token,
|
||||
CardHolder: holder,
|
||||
MaskedPan: strings.TrimSpace(card.MaskedPan),
|
||||
Metadata: meta,
|
||||
OperationRef: operationRef,
|
||||
IntentRef: payment.Intent.Ref,
|
||||
IdempotencyKey: payment.IdempotencyKey,
|
||||
}
|
||||
resp, err := p.deps.mntx.client.CreateCardTokenPayout(ctx, req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
state = resp.GetPayout()
|
||||
} else if pan := strings.TrimSpace(card.Pan); pan != "" {
|
||||
req := &mntxv1.CardPayoutRequest{
|
||||
PayoutId: payoutID,
|
||||
CustomerId: customerID,
|
||||
CustomerFirstName: customerFirstName,
|
||||
CustomerMiddleName: customerMiddleName,
|
||||
CustomerLastName: customerLastName,
|
||||
CustomerIp: customerIP,
|
||||
CustomerZip: customerZip,
|
||||
CustomerCountry: customerCountry,
|
||||
CustomerState: customerState,
|
||||
CustomerCity: customerCity,
|
||||
CustomerAddress: customerAddress,
|
||||
AmountMinor: minor,
|
||||
Currency: currency,
|
||||
CardPan: pan,
|
||||
CardExpYear: card.ExpYear,
|
||||
CardExpMonth: card.ExpMonth,
|
||||
CardHolder: holder,
|
||||
Metadata: meta,
|
||||
OperationRef: operationRef,
|
||||
IntentRef: payment.Intent.Ref,
|
||||
IdempotencyKey: payment.IdempotencyKey,
|
||||
}
|
||||
resp, err := p.deps.mntx.client.CreateCardPayout(ctx, req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
state = resp.GetPayout()
|
||||
} else {
|
||||
return "", merrors.InvalidArgument("card payout: either token or pan must be provided")
|
||||
}
|
||||
|
||||
if state == nil {
|
||||
return "", merrors.Internal("card payout: missing payout state")
|
||||
}
|
||||
recordCardPayoutState(payment, state)
|
||||
exec := ensureExecutionRefs(payment)
|
||||
if exec.CardPayoutRef == "" {
|
||||
exec.CardPayoutRef = strings.TrimSpace(state.GetPayoutId())
|
||||
}
|
||||
return exec.CardPayoutRef, nil
|
||||
}
|
||||
|
||||
func mergeAccountRole(role *account_role.AccountRole) account_role.AccountRole {
|
||||
if role == nil {
|
||||
return ""
|
||||
}
|
||||
return account_role.AccountRole(strings.TrimSpace(string(*role)))
|
||||
}
|
||||
|
||||
func (p *paymentExecutor) resolveCardRoute(intent model.PaymentIntent) (CardGatewayRoute, error) {
|
||||
if p.svc != nil {
|
||||
return p.svc.cardRoute(p.gatewayKeyFromIntent(intent))
|
||||
}
|
||||
key := p.gatewayKeyFromIntent(intent)
|
||||
route, ok := p.deps.cardRoutes[key]
|
||||
if !ok {
|
||||
return CardGatewayRoute{}, merrors.InvalidArgument("card routing missing for gateway " + key)
|
||||
}
|
||||
if strings.TrimSpace(route.FundingAddress) == "" {
|
||||
return CardGatewayRoute{}, merrors.InvalidArgument("card funding address is required for gateway " + key)
|
||||
}
|
||||
return route, nil
|
||||
}
|
||||
|
||||
func (p *paymentExecutor) gatewayKeyFromIntent(intent model.PaymentIntent) string {
|
||||
key := strings.TrimSpace(intent.Attributes["gateway"])
|
||||
if key == "" && intent.Destination.Card != nil {
|
||||
key = defaultCardGateway
|
||||
}
|
||||
return strings.ToLower(key)
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/model/account_role"
|
||||
"github.com/tech/sendico/pkg/payments/rail"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||
)
|
||||
|
||||
func (p *paymentExecutor) buildCryptoTransferRequest(payment *model.Payment, amount *paymenttypes.Money, action model.RailOperation, idempotencyKey, operationRef string, quote *orchestratorv1.PaymentQuote, fromRole, toRole *account_role.AccountRole) (rail.TransferRequest, error) {
|
||||
if payment == nil {
|
||||
return rail.TransferRequest{}, merrors.InvalidArgument("chain: payment is required")
|
||||
}
|
||||
if amount == nil {
|
||||
return rail.TransferRequest{}, merrors.InvalidArgument("chain: amount is required")
|
||||
}
|
||||
source := payment.Intent.Source.ManagedWallet
|
||||
if source == nil || strings.TrimSpace(source.ManagedWalletRef) == "" {
|
||||
return rail.TransferRequest{}, merrors.InvalidArgument("chain: source managed wallet is required")
|
||||
}
|
||||
destRef, memo, err := p.resolveCryptoDestination(payment, action)
|
||||
if err != nil {
|
||||
return rail.TransferRequest{}, err
|
||||
}
|
||||
paymentRef := strings.TrimSpace(payment.PaymentRef)
|
||||
if paymentRef == "" {
|
||||
return rail.TransferRequest{}, merrors.InvalidArgument("chain: payment reference is required")
|
||||
}
|
||||
req := rail.TransferRequest{
|
||||
IntentRef: strings.TrimSpace(payment.Intent.Ref),
|
||||
OperationRef: strings.TrimSpace(operationRef),
|
||||
OrganizationRef: payment.OrganizationRef.Hex(),
|
||||
PaymentRef: strings.TrimSpace(payment.PaymentRef),
|
||||
FromAccountID: strings.TrimSpace(source.ManagedWalletRef),
|
||||
ToAccountID: strings.TrimSpace(destRef),
|
||||
Currency: strings.TrimSpace(amount.GetCurrency()),
|
||||
Network: strings.TrimSpace(cryptoNetworkForPayment(payment)),
|
||||
Amount: strings.TrimSpace(amount.GetAmount()),
|
||||
IdempotencyKey: strings.TrimSpace(idempotencyKey),
|
||||
Metadata: cloneMetadata(payment.Metadata),
|
||||
DestinationMemo: memo,
|
||||
}
|
||||
if fromRole != nil {
|
||||
req.FromRole = *fromRole
|
||||
}
|
||||
if toRole != nil {
|
||||
req.ToRole = *toRole
|
||||
}
|
||||
if req.Currency == "" || req.Amount == "" {
|
||||
return rail.TransferRequest{}, merrors.InvalidArgument("chain: amount is required")
|
||||
}
|
||||
if req.IdempotencyKey == "" {
|
||||
return rail.TransferRequest{}, merrors.InvalidArgument("chain: idempotency_key is required")
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func (p *paymentExecutor) resolveCryptoDestination(payment *model.Payment, action model.RailOperation) (string, string, error) {
|
||||
if payment == nil {
|
||||
return "", "", merrors.InvalidArgument("chain: payment is required")
|
||||
}
|
||||
intent := payment.Intent
|
||||
switch intent.Destination.Type {
|
||||
case model.EndpointTypeManagedWallet:
|
||||
if action == model.RailOperationSend {
|
||||
if intent.Destination.ManagedWallet == nil || strings.TrimSpace(intent.Destination.ManagedWallet.ManagedWalletRef) == "" {
|
||||
return "", "", merrors.InvalidArgument("chain: destination managed wallet is required")
|
||||
}
|
||||
return strings.TrimSpace(intent.Destination.ManagedWallet.ManagedWalletRef), "", nil
|
||||
}
|
||||
case model.EndpointTypeExternalChain:
|
||||
if action == model.RailOperationSend {
|
||||
if intent.Destination.ExternalChain == nil || strings.TrimSpace(intent.Destination.ExternalChain.Address) == "" {
|
||||
return "", "", merrors.InvalidArgument("chain: external address is required")
|
||||
}
|
||||
return strings.TrimSpace(intent.Destination.ExternalChain.Address), strings.TrimSpace(intent.Destination.ExternalChain.Memo), nil
|
||||
}
|
||||
}
|
||||
route, err := p.resolveCardRoute(intent)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
switch action {
|
||||
case model.RailOperationSend:
|
||||
address := strings.TrimSpace(route.FundingAddress)
|
||||
if address == "" {
|
||||
return "", "", merrors.InvalidArgument("chain: funding address is required")
|
||||
}
|
||||
return address, "", nil
|
||||
case model.RailOperationFee:
|
||||
if walletRef := strings.TrimSpace(route.FeeWalletRef); walletRef != "" {
|
||||
return walletRef, "", nil
|
||||
}
|
||||
if address := strings.TrimSpace(route.FeeAddress); address != "" {
|
||||
return address, "", nil
|
||||
}
|
||||
return "", "", merrors.InvalidArgument("chain: fee destination is required")
|
||||
default:
|
||||
return "", "", merrors.InvalidArgument("chain: unsupported action")
|
||||
}
|
||||
}
|
||||
|
||||
func cryptoNetworkForPayment(payment *model.Payment) string {
|
||||
if payment == nil {
|
||||
return ""
|
||||
}
|
||||
network := networkFromEndpoint(payment.Intent.Source)
|
||||
if network != "" {
|
||||
return network
|
||||
}
|
||||
return networkFromEndpoint(payment.Intent.Destination)
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/tech/sendico/payments/storage"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func buildStepIndex(plan *model.PaymentPlan) map[string]int {
|
||||
m := make(map[string]int, len(plan.Steps))
|
||||
for i, s := range plan.Steps {
|
||||
if s == nil {
|
||||
continue
|
||||
}
|
||||
m[s.StepID] = i
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func isPlanComplete(payment *model.Payment) bool {
|
||||
if (payment.State == model.PaymentStateCancelled) ||
|
||||
(payment.State == model.PaymentStateSettled) ||
|
||||
(payment.State == model.PaymentStateFailed) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isStepFinal(step *model.ExecutionStep) bool {
|
||||
if (step.State == model.OperationStateFailed) || (step.State == model.OperationStateSuccess) || (step.State == model.OperationStateCancelled) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (p *paymentExecutor) pickIndependentSteps(
|
||||
ctx context.Context,
|
||||
l *zap.Logger,
|
||||
store storage.PaymentsStore,
|
||||
waiting []*model.ExecutionStep,
|
||||
payment *model.Payment,
|
||||
quote *orchestratorv1.PaymentQuote,
|
||||
) error {
|
||||
|
||||
logger := l.With(zap.Int("waiting_steps", len(waiting)))
|
||||
logger.Debug("Selecting independent steps for execution")
|
||||
|
||||
execSteps := executionStepsByCode(payment.ExecutionPlan)
|
||||
planSteps := planStepsByID(payment.PaymentPlan)
|
||||
execQuote := executionQuote(payment, quote)
|
||||
charges := ledgerChargesFromFeeLines(execQuote.GetFeeLines())
|
||||
stepIdx := buildStepIndex(payment.PaymentPlan)
|
||||
|
||||
for _, execStep := range waiting {
|
||||
if execStep == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
lg := logger.With(
|
||||
zap.String("step_code", execStep.Code),
|
||||
zap.String("step_state", string(execStep.State)),
|
||||
)
|
||||
|
||||
planStep := planSteps[execStep.Code]
|
||||
if planStep == nil {
|
||||
lg.Warn("Plan step not found")
|
||||
continue
|
||||
}
|
||||
|
||||
ready, waitingDep, blocked, err :=
|
||||
stepDependenciesReady(planStep, execSteps, planSteps, true)
|
||||
|
||||
if err != nil {
|
||||
lg.Warn("Dependency evaluation failed", zap.Error(err))
|
||||
continue
|
||||
}
|
||||
|
||||
if blocked {
|
||||
lg.Debug("Step permanently blocked by dependency failure")
|
||||
setExecutionStepStatus(execStep, model.OperationStateCancelled)
|
||||
continue
|
||||
}
|
||||
|
||||
if waitingDep {
|
||||
lg.Debug("Step waiting for dependencies")
|
||||
continue
|
||||
}
|
||||
|
||||
if !ready {
|
||||
continue
|
||||
}
|
||||
|
||||
lg.Debug("Executing independent step")
|
||||
idx := stepIdx[execStep.Code]
|
||||
|
||||
async, err := p.executePlanStep(
|
||||
ctx,
|
||||
payment,
|
||||
planStep,
|
||||
execStep,
|
||||
quote,
|
||||
charges,
|
||||
idx,
|
||||
)
|
||||
if err != nil {
|
||||
lg.Warn("Step execution failed", zap.Error(err), zap.Bool("async", async))
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *paymentExecutor) pickWaitingSteps(
|
||||
ctx context.Context,
|
||||
l *zap.Logger,
|
||||
store storage.PaymentsStore,
|
||||
payment *model.Payment,
|
||||
quote *orchestratorv1.PaymentQuote,
|
||||
) error {
|
||||
if payment == nil || payment.ExecutionPlan == nil {
|
||||
l.Debug("No execution plan")
|
||||
return nil
|
||||
}
|
||||
|
||||
logger := l.With(zap.Int("total_steps", len(payment.ExecutionPlan.Steps)))
|
||||
logger.Debug("Collecting waiting steps")
|
||||
|
||||
waitingSteps := make([]*model.ExecutionStep, 0, len(payment.ExecutionPlan.Steps))
|
||||
for _, step := range payment.ExecutionPlan.Steps {
|
||||
if step == nil {
|
||||
continue
|
||||
}
|
||||
if step.State != model.OperationStatePlanned {
|
||||
continue
|
||||
}
|
||||
waitingSteps = append(waitingSteps, step)
|
||||
}
|
||||
|
||||
if len(waitingSteps) == 0 {
|
||||
logger.Debug("No waiting steps to process")
|
||||
return nil
|
||||
}
|
||||
|
||||
return p.pickIndependentSteps(ctx, logger, store, waitingSteps, payment, quote)
|
||||
}
|
||||
|
||||
func (p *paymentExecutor) executePaymentPlan(
|
||||
ctx context.Context,
|
||||
store storage.PaymentsStore,
|
||||
payment *model.Payment,
|
||||
quote *orchestratorv1.PaymentQuote,
|
||||
) error {
|
||||
|
||||
if payment == nil {
|
||||
return merrors.InvalidArgument("plan must be provided")
|
||||
}
|
||||
|
||||
logger := p.logger.With(zap.String("payment_ref", payment.PaymentRef))
|
||||
logger.Debug("Starting plan execution")
|
||||
|
||||
if isPlanComplete(payment) {
|
||||
logger.Debug("Plan already completed")
|
||||
return nil
|
||||
}
|
||||
|
||||
if payment.ExecutionPlan == nil {
|
||||
logger.Debug("Initializing execution plan from payment plan")
|
||||
payment.ExecutionPlan = ensureExecutionPlanForPlan(payment, payment.PaymentPlan)
|
||||
if err := store.Update(ctx, payment); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Execute steps
|
||||
if err := p.pickWaitingSteps(ctx, logger, store, payment, quote); err != nil {
|
||||
logger.Warn("Step execution returned infrastructure error", zap.Error(err))
|
||||
}
|
||||
|
||||
if err := store.Update(ctx, payment); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
done, failed, rootErr := analyzeExecutionPlan(logger, payment)
|
||||
if !done {
|
||||
return nil
|
||||
}
|
||||
|
||||
if failed {
|
||||
payment.State = model.PaymentStateFailed
|
||||
} else {
|
||||
payment.State = model.PaymentStateSettled
|
||||
}
|
||||
|
||||
if err := store.Update(ctx, payment); err != nil {
|
||||
logger.Warn("Failed to update final payment state", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
if failed && rootErr != nil {
|
||||
return rootErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,596 +0,0 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/ledgerconv"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/model/account_role"
|
||||
"github.com/tech/sendico/pkg/payments/rail"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
|
||||
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
|
||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (p *paymentExecutor) postLedgerDebit(ctx context.Context, payment *model.Payment, amount *moneyv1.Money, charges []*ledgerv1.PostingLine, idempotencyKey string, idx int, action model.RailOperation, quote *orchestratorv1.PaymentQuote) (string, error) {
|
||||
paymentRef := ""
|
||||
if payment != nil {
|
||||
paymentRef = strings.TrimSpace(payment.PaymentRef)
|
||||
}
|
||||
if p.deps.ledger.internal == nil {
|
||||
p.logger.Error("Ledger client unavailable", zap.String("action", "debit"), zap.String("payment_ref", paymentRef))
|
||||
return "", merrors.Internal("ledger_client_unavailable")
|
||||
}
|
||||
tx, err := p.ledgerTxForAction(ctx, payment, amount, charges, idempotencyKey, idx, action, quote)
|
||||
if err != nil {
|
||||
p.logger.Warn("Ledger debit preparation failed", zap.String("payment_ref", paymentRef), zap.Int("step_index", idx), zap.Error(err))
|
||||
return "", err
|
||||
}
|
||||
ref, err := p.deps.ledger.internal.CreateTransaction(ctx, tx)
|
||||
if err != nil {
|
||||
p.logger.Warn("Ledger debit failed", zap.String("payment_ref", paymentRef), zap.Int("step_index", idx), zap.Error(err))
|
||||
return "", err
|
||||
}
|
||||
p.logger.Info("Ledger debit posted",
|
||||
zap.String("payment_ref", paymentRef),
|
||||
zap.Int("step_index", idx),
|
||||
zap.String("action", string(action)),
|
||||
zap.String("entry_ref", strings.TrimSpace(ref)))
|
||||
return ref, nil
|
||||
}
|
||||
|
||||
func (p *paymentExecutor) postLedgerCredit(ctx context.Context, payment *model.Payment, amount *moneyv1.Money, idempotencyKey string, idx int, action model.RailOperation, quote *orchestratorv1.PaymentQuote) (string, error) {
|
||||
paymentRef := ""
|
||||
if payment != nil {
|
||||
paymentRef = strings.TrimSpace(payment.PaymentRef)
|
||||
}
|
||||
if p.deps.ledger.internal == nil {
|
||||
p.logger.Error("Ledger client unavailable", zap.String("action", "credit"), zap.String("payment_ref", paymentRef))
|
||||
return "", merrors.Internal("ledger_client_unavailable")
|
||||
}
|
||||
tx, err := p.ledgerTxForAction(ctx, payment, amount, nil, idempotencyKey, idx, action, quote)
|
||||
if err != nil {
|
||||
p.logger.Warn("Ledger credit preparation failed", zap.String("payment_ref", paymentRef), zap.Int("step_index", idx), zap.Error(err))
|
||||
return "", err
|
||||
}
|
||||
ref, err := p.deps.ledger.internal.CreateTransaction(ctx, tx)
|
||||
if err != nil {
|
||||
p.logger.Warn("Ledger credit failed", zap.String("payment_ref", paymentRef), zap.Int("step_index", idx), zap.Error(err))
|
||||
return "", err
|
||||
}
|
||||
p.logger.Info("Ledger credit posted",
|
||||
zap.String("payment_ref", paymentRef),
|
||||
zap.Int("step_index", idx),
|
||||
zap.String("action", string(action)),
|
||||
zap.String("entry_ref", strings.TrimSpace(ref)))
|
||||
return ref, nil
|
||||
}
|
||||
|
||||
func (p *paymentExecutor) postLedgerMove(ctx context.Context, payment *model.Payment, step *model.PaymentStep, amount *moneyv1.Money, idempotencyKey string, idx int) (string, error) {
|
||||
paymentRef := ""
|
||||
if payment != nil {
|
||||
paymentRef = strings.TrimSpace(payment.PaymentRef)
|
||||
}
|
||||
if p.deps.ledger.internal == nil {
|
||||
p.logger.Error("Ledger client unavailable", zap.String("action", "move"), zap.String("payment_ref", paymentRef))
|
||||
return "", merrors.Internal("ledger_client_unavailable")
|
||||
}
|
||||
if payment == nil {
|
||||
return "", merrors.InvalidArgument("ledger: payment is required")
|
||||
}
|
||||
if payment.OrganizationRef == bson.NilObjectID {
|
||||
return "", merrors.InvalidArgument("ledger: organization_ref is required")
|
||||
}
|
||||
if step == nil {
|
||||
return "", merrors.InvalidArgument("ledger: step is required")
|
||||
}
|
||||
if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" {
|
||||
return "", merrors.InvalidArgument("ledger: amount is required")
|
||||
}
|
||||
fromRole, toRole, err := ledgerMoveRoles(step)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
currency := strings.TrimSpace(amount.GetCurrency())
|
||||
fromAccount, err := p.resolveAccount(ctx, payment.OrganizationRef, currency, model.RailLedger, fromRole)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
toAccount, err := p.resolveAccount(ctx, payment.OrganizationRef, currency, model.RailLedger, toRole)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resp, err := p.deps.ledger.internal.TransferInternal(ctx, &ledgerv1.TransferRequest{
|
||||
IdempotencyKey: strings.TrimSpace(idempotencyKey),
|
||||
OrganizationRef: payment.OrganizationRef.Hex(),
|
||||
FromLedgerAccountRef: strings.TrimSpace(fromAccount),
|
||||
ToLedgerAccountRef: strings.TrimSpace(toAccount),
|
||||
Money: cloneProtoMoney(amount),
|
||||
Description: paymentDescription(payment),
|
||||
Metadata: cloneMetadata(payment.Metadata),
|
||||
FromRole: ledgerRoleFromAccountRole(fromRole),
|
||||
ToRole: ledgerRoleFromAccountRole(toRole),
|
||||
})
|
||||
if err != nil {
|
||||
p.logger.Warn("Ledger move failed",
|
||||
zap.String("payment_ref", paymentRef),
|
||||
zap.Int("step_index", idx),
|
||||
zap.String("from_role", string(fromRole)),
|
||||
zap.String("to_role", string(toRole)),
|
||||
zap.String("from_account", strings.TrimSpace(fromAccount)),
|
||||
zap.String("to_account", strings.TrimSpace(toAccount)),
|
||||
zap.String("amount", strings.TrimSpace(amount.GetAmount())),
|
||||
zap.String("currency", currency),
|
||||
zap.Error(err))
|
||||
return "", err
|
||||
}
|
||||
entryRef := strings.TrimSpace(resp.GetJournalEntryRef())
|
||||
p.logger.Info("Ledger move posted",
|
||||
zap.String("payment_ref", paymentRef),
|
||||
zap.Int("step_index", idx),
|
||||
zap.String("entry_ref", entryRef),
|
||||
zap.String("from_role", string(fromRole)),
|
||||
zap.String("to_role", string(toRole)),
|
||||
zap.String("from_account", strings.TrimSpace(fromAccount)),
|
||||
zap.String("to_account", strings.TrimSpace(toAccount)),
|
||||
zap.String("amount", strings.TrimSpace(amount.GetAmount())),
|
||||
zap.String("currency", currency))
|
||||
return entryRef, nil
|
||||
}
|
||||
|
||||
func (p *paymentExecutor) ledgerTxForAction(ctx context.Context, payment *model.Payment, amount *moneyv1.Money, charges []*ledgerv1.PostingLine, idempotencyKey string, idx int, action model.RailOperation, quote *orchestratorv1.PaymentQuote) (rail.LedgerTx, error) {
|
||||
if payment == nil {
|
||||
return rail.LedgerTx{}, merrors.InvalidArgument("ledger: payment is required")
|
||||
}
|
||||
if payment.OrganizationRef == bson.NilObjectID {
|
||||
return rail.LedgerTx{}, merrors.InvalidArgument("ledger: organization_ref is required")
|
||||
}
|
||||
if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" {
|
||||
return rail.LedgerTx{}, merrors.InvalidArgument("ledger: amount is required")
|
||||
}
|
||||
|
||||
sourceRail, _, err := railFromEndpoint(payment.Intent.Source, payment.Intent.Attributes, true)
|
||||
if err != nil {
|
||||
sourceRail = model.RailUnspecified
|
||||
}
|
||||
destRail, _, err := railFromEndpoint(payment.Intent.Destination, payment.Intent.Attributes, false)
|
||||
if err != nil {
|
||||
destRail = model.RailUnspecified
|
||||
}
|
||||
|
||||
fromRail := model.RailUnspecified
|
||||
toRail := model.RailUnspecified
|
||||
accountRef := ""
|
||||
contraRef := ""
|
||||
externalRef := ""
|
||||
operation := ""
|
||||
|
||||
switch action {
|
||||
case model.RailOperationDebit, model.RailOperationExternalDebit:
|
||||
fromRail = model.RailLedger
|
||||
toRail = ledgerStepToRail(payment.PaymentPlan, idx, destRail)
|
||||
accountRef, contraRef, err = ledgerDebitAccount(payment)
|
||||
if err != nil {
|
||||
accountRef, contraRef, err = p.resolveLedgerAccountRef(ctx, payment, amount, action)
|
||||
}
|
||||
if err == nil {
|
||||
if blockRef := ledgerBlockAccountIfConfirmed(payment); blockRef != "" {
|
||||
accountRef = blockRef
|
||||
contraRef = ""
|
||||
}
|
||||
}
|
||||
if action == model.RailOperationExternalDebit {
|
||||
operation = "external.debit"
|
||||
}
|
||||
case model.RailOperationCredit, model.RailOperationExternalCredit:
|
||||
fromRail = ledgerStepFromRail(payment.PaymentPlan, idx, sourceRail)
|
||||
toRail = model.RailLedger
|
||||
accountRef, contraRef, err = ledgerCreditAccount(payment)
|
||||
if err != nil {
|
||||
accountRef, contraRef, err = p.resolveLedgerAccountRef(ctx, payment, amount, action)
|
||||
}
|
||||
externalRef = ledgerExternalReference(payment.ExecutionPlan, idx)
|
||||
if action == model.RailOperationExternalCredit {
|
||||
operation = "external.credit"
|
||||
}
|
||||
default:
|
||||
return rail.LedgerTx{}, merrors.InvalidArgument("ledger: unsupported action")
|
||||
}
|
||||
if err != nil {
|
||||
return rail.LedgerTx{}, err
|
||||
}
|
||||
isDebit := action == model.RailOperationDebit || action == model.RailOperationExternalDebit
|
||||
isCredit := action == model.RailOperationCredit || action == model.RailOperationExternalCredit
|
||||
if isCredit && strings.TrimSpace(accountRef) != "" {
|
||||
setLedgerAccountAttributes(payment, accountRef)
|
||||
}
|
||||
if isDebit && toRail == model.RailLedger {
|
||||
toRail = model.RailUnspecified
|
||||
}
|
||||
if isCredit && fromRail == model.RailLedger {
|
||||
fromRail = model.RailUnspecified
|
||||
}
|
||||
|
||||
planID := payment.PaymentRef
|
||||
if payment.PaymentPlan != nil && strings.TrimSpace(payment.PaymentPlan.ID) != "" {
|
||||
planID = strings.TrimSpace(payment.PaymentPlan.ID)
|
||||
}
|
||||
|
||||
feeAmount := ""
|
||||
if isDebit {
|
||||
if feeMoney := resolveFeeAmount(payment, quote); feeMoney != nil {
|
||||
feeAmount = strings.TrimSpace(feeMoney.GetAmount())
|
||||
}
|
||||
}
|
||||
|
||||
fxRate := ""
|
||||
if quote != nil && quote.GetFxQuote() != nil && quote.GetFxQuote().GetPrice() != nil {
|
||||
fxRate = strings.TrimSpace(quote.GetFxQuote().GetPrice().GetValue())
|
||||
}
|
||||
|
||||
return rail.LedgerTx{
|
||||
PaymentPlanID: planID,
|
||||
Currency: strings.TrimSpace(amount.GetCurrency()),
|
||||
Amount: strings.TrimSpace(amount.GetAmount()),
|
||||
FeeAmount: feeAmount,
|
||||
FromRail: ledgerRailValue(fromRail),
|
||||
ToRail: ledgerRailValue(toRail),
|
||||
ExternalReferenceID: externalRef,
|
||||
Operation: operation,
|
||||
FXRateUsed: fxRate,
|
||||
IdempotencyKey: strings.TrimSpace(idempotencyKey),
|
||||
CreatedAt: planTimestamp(payment),
|
||||
OrganizationRef: payment.OrganizationRef.Hex(),
|
||||
LedgerAccountRef: strings.TrimSpace(accountRef),
|
||||
ContraLedgerAccountRef: strings.TrimSpace(contraRef),
|
||||
Description: paymentDescription(payment),
|
||||
Charges: charges,
|
||||
Metadata: cloneMetadata(payment.Metadata),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func ledgerRailValue(railValue model.Rail) string {
|
||||
if railValue == model.RailUnspecified || strings.TrimSpace(string(railValue)) == "" {
|
||||
return ""
|
||||
}
|
||||
return string(railValue)
|
||||
}
|
||||
|
||||
func ledgerStepFromRail(plan *model.PaymentPlan, idx int, fallback model.Rail) model.Rail {
|
||||
if plan == nil || idx <= 0 {
|
||||
return fallback
|
||||
}
|
||||
for i := idx - 1; i >= 0; i-- {
|
||||
step := plan.Steps[i]
|
||||
if step == nil {
|
||||
continue
|
||||
}
|
||||
if step.Rail != model.RailLedger && step.Rail != model.RailUnspecified {
|
||||
return step.Rail
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func ledgerStepToRail(plan *model.PaymentPlan, idx int, fallback model.Rail) model.Rail {
|
||||
if plan == nil || idx < 0 {
|
||||
return fallback
|
||||
}
|
||||
for i := idx + 1; i < len(plan.Steps); i++ {
|
||||
step := plan.Steps[i]
|
||||
if step == nil {
|
||||
continue
|
||||
}
|
||||
if step.Rail != model.RailLedger && step.Rail != model.RailUnspecified {
|
||||
return step.Rail
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func ledgerExternalReference(plan *model.ExecutionPlan, idx int) string {
|
||||
if plan == nil || idx <= 0 {
|
||||
return ""
|
||||
}
|
||||
for i := idx - 1; i >= 0; i-- {
|
||||
step := plan.Steps[i]
|
||||
if step == nil {
|
||||
continue
|
||||
}
|
||||
if ref := strings.TrimSpace(step.TransferRef); ref != "" {
|
||||
return ref
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func ledgerMoveRoles(step *model.PaymentStep) (account_role.AccountRole, account_role.AccountRole, error) {
|
||||
if step == nil {
|
||||
return "", "", merrors.InvalidArgument("ledger: step is required")
|
||||
}
|
||||
if step.FromRole == nil || strings.TrimSpace(string(*step.FromRole)) == "" {
|
||||
return "", "", merrors.InvalidArgument("ledger: from_role is required")
|
||||
}
|
||||
if step.ToRole == nil || strings.TrimSpace(string(*step.ToRole)) == "" {
|
||||
return "", "", merrors.InvalidArgument("ledger: to_role is required")
|
||||
}
|
||||
from := strings.ToLower(strings.TrimSpace(string(*step.FromRole)))
|
||||
to := strings.ToLower(strings.TrimSpace(string(*step.ToRole)))
|
||||
if from == "" || to == "" || strings.EqualFold(from, to) {
|
||||
return "", "", merrors.InvalidArgument("ledger: from_role and to_role must differ")
|
||||
}
|
||||
return account_role.AccountRole(from), account_role.AccountRole(to), nil
|
||||
}
|
||||
|
||||
func ledgerRoleFromAccountRole(role account_role.AccountRole) ledgerv1.AccountRole {
|
||||
if strings.TrimSpace(string(role)) == "" {
|
||||
return ledgerv1.AccountRole_ACCOUNT_ROLE_UNSPECIFIED
|
||||
}
|
||||
if parsed, ok := ledgerconv.ParseAccountRole(string(role)); ok {
|
||||
return parsed
|
||||
}
|
||||
return ledgerv1.AccountRole_ACCOUNT_ROLE_UNSPECIFIED
|
||||
}
|
||||
|
||||
func (p *paymentExecutor) resolveAccount(ctx context.Context, orgRef bson.ObjectID, asset string, rail model.Rail, role account_role.AccountRole) (string, error) {
|
||||
switch rail {
|
||||
case model.RailLedger:
|
||||
return p.resolveLedgerAccountByRole(ctx, orgRef, asset, role)
|
||||
default:
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
|
||||
func (p *paymentExecutor) resolveLedgerAccountByRole(ctx context.Context, orgRef bson.ObjectID, asset string, role account_role.AccountRole) (string, error) {
|
||||
if p == nil || p.deps == nil || p.deps.ledger.client == nil {
|
||||
return "", merrors.Internal("ledger_client_unavailable")
|
||||
}
|
||||
if orgRef == bson.NilObjectID {
|
||||
return "", merrors.InvalidArgument("ledger: organization_ref is required")
|
||||
}
|
||||
currency := strings.TrimSpace(asset)
|
||||
if currency == "" {
|
||||
return "", merrors.InvalidArgument("ledger: asset is required")
|
||||
}
|
||||
if strings.TrimSpace(string(role)) == "" {
|
||||
return "", merrors.InvalidArgument("ledger: role is required")
|
||||
}
|
||||
|
||||
resp, err := p.deps.ledger.client.ListConnectorAccounts(ctx, &connectorv1.ListAccountsRequest{
|
||||
OrganizationRef: orgRef.Hex(),
|
||||
Kind: connectorv1.AccountKind_LEDGER_ACCOUNT,
|
||||
Asset: currency,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
expectedRole := strings.ToLower(strings.TrimSpace(string(role)))
|
||||
for _, account := range resp.GetAccounts() {
|
||||
if account == nil {
|
||||
continue
|
||||
}
|
||||
if account.GetKind() != connectorv1.AccountKind_LEDGER_ACCOUNT {
|
||||
continue
|
||||
}
|
||||
if asset := strings.TrimSpace(account.GetAsset()); asset == "" || !strings.EqualFold(asset, currency) {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(account.GetOwnerRef()) != "" {
|
||||
continue
|
||||
}
|
||||
accRole := strings.ToLower(strings.TrimSpace(string(connectorAccountRole(account))))
|
||||
if accRole == "" || !strings.EqualFold(accRole, expectedRole) {
|
||||
continue
|
||||
}
|
||||
if ref := account.GetRef(); ref != nil {
|
||||
if accountID := strings.TrimSpace(ref.GetAccountId()); accountID != "" {
|
||||
return accountID, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", merrors.InvalidArgument("ledger: account role not found")
|
||||
}
|
||||
|
||||
func (p *paymentExecutor) resolveLedgerAccountRef(ctx context.Context, payment *model.Payment, amount *moneyv1.Money, action model.RailOperation) (string, string, error) {
|
||||
if payment == nil {
|
||||
return "", "", merrors.InvalidArgument("ledger: payment is required")
|
||||
}
|
||||
if amount == nil || strings.TrimSpace(amount.GetCurrency()) == "" {
|
||||
return "", "", merrors.InvalidArgument("ledger: amount is required")
|
||||
}
|
||||
switch action {
|
||||
case model.RailOperationCredit, model.RailOperationExternalCredit:
|
||||
if account, _, err := ledgerDebitAccount(payment); err == nil && strings.TrimSpace(account) != "" {
|
||||
setLedgerAccountAttributes(payment, account)
|
||||
return account, "", nil
|
||||
}
|
||||
case model.RailOperationDebit, model.RailOperationExternalDebit:
|
||||
if account, _, err := ledgerCreditAccount(payment); err == nil && strings.TrimSpace(account) != "" {
|
||||
setLedgerAccountAttributes(payment, account)
|
||||
return account, "", nil
|
||||
}
|
||||
}
|
||||
account, err := p.resolveOrgOwnedLedgerAccount(ctx, payment, amount)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
setLedgerAccountAttributes(payment, account)
|
||||
return account, "", nil
|
||||
}
|
||||
|
||||
func (p *paymentExecutor) resolveOrgOwnedLedgerAccount(ctx context.Context, payment *model.Payment, amount *moneyv1.Money) (string, error) {
|
||||
if payment == nil {
|
||||
return "", merrors.InvalidArgument("ledger: payment is required")
|
||||
}
|
||||
if payment.OrganizationRef == bson.NilObjectID {
|
||||
return "", merrors.InvalidArgument("ledger: organization_ref is required")
|
||||
}
|
||||
if amount == nil || strings.TrimSpace(amount.GetCurrency()) == "" {
|
||||
return "", merrors.InvalidArgument("ledger: amount is required")
|
||||
}
|
||||
if p == nil || p.deps == nil || p.deps.ledger.client == nil {
|
||||
return "", merrors.Internal("ledger_client_unavailable")
|
||||
}
|
||||
|
||||
currency := strings.TrimSpace(amount.GetCurrency())
|
||||
resp, err := p.deps.ledger.client.ListConnectorAccounts(ctx, &connectorv1.ListAccountsRequest{
|
||||
OrganizationRef: payment.OrganizationRef.Hex(),
|
||||
Kind: connectorv1.AccountKind_LEDGER_ACCOUNT,
|
||||
Asset: currency,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, account := range resp.GetAccounts() {
|
||||
if account == nil {
|
||||
continue
|
||||
}
|
||||
if account.GetKind() != connectorv1.AccountKind_LEDGER_ACCOUNT {
|
||||
continue
|
||||
}
|
||||
asset := strings.TrimSpace(account.GetAsset())
|
||||
if asset == "" || !strings.EqualFold(asset, currency) {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(account.GetOwnerRef()) != "" {
|
||||
continue
|
||||
}
|
||||
if connectorAccountIsSettlement(account) {
|
||||
continue
|
||||
}
|
||||
if ref := account.GetRef(); ref != nil {
|
||||
if accountID := strings.TrimSpace(ref.GetAccountId()); accountID != "" {
|
||||
return accountID, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", merrors.InvalidArgument("ledger: org-owned account not found")
|
||||
}
|
||||
|
||||
func connectorAccountIsSettlement(account *connectorv1.Account) bool {
|
||||
return connectorAccountRole(account) == account_role.AccountRoleSettlement
|
||||
}
|
||||
|
||||
func connectorAccountRole(account *connectorv1.Account) account_role.AccountRole {
|
||||
if account == nil || account.GetProviderDetails() == nil {
|
||||
return ""
|
||||
}
|
||||
details := account.GetProviderDetails().AsMap()
|
||||
if value := strings.TrimSpace(fmt.Sprint(details["role"])); value != "" {
|
||||
if role, ok := account_role.Parse(value); ok {
|
||||
return role
|
||||
}
|
||||
}
|
||||
switch v := details["is_settlement"].(type) {
|
||||
case bool:
|
||||
if v {
|
||||
return account_role.AccountRoleSettlement
|
||||
}
|
||||
case string:
|
||||
if strings.EqualFold(strings.TrimSpace(v), "true") {
|
||||
return account_role.AccountRoleSettlement
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func setLedgerAccountAttributes(payment *model.Payment, accountRef string) {
|
||||
if payment == nil || strings.TrimSpace(accountRef) == "" {
|
||||
return
|
||||
}
|
||||
if payment.Intent.Attributes == nil {
|
||||
payment.Intent.Attributes = map[string]string{}
|
||||
}
|
||||
if attributeLookup(payment.Intent.Attributes, "ledger_debit_account_ref", "ledgerDebitAccountRef") == "" {
|
||||
payment.Intent.Attributes["ledger_debit_account_ref"] = accountRef
|
||||
}
|
||||
if attributeLookup(payment.Intent.Attributes, "ledger_credit_account_ref", "ledgerCreditAccountRef") == "" {
|
||||
payment.Intent.Attributes["ledger_credit_account_ref"] = accountRef
|
||||
}
|
||||
}
|
||||
|
||||
func ledgerDebitAccount(payment *model.Payment) (string, string, error) {
|
||||
if payment == nil {
|
||||
return "", "", merrors.InvalidArgument("ledger: payment is required")
|
||||
}
|
||||
intent := payment.Intent
|
||||
if intent.Source.Ledger != nil && strings.TrimSpace(intent.Source.Ledger.LedgerAccountRef) != "" {
|
||||
return strings.TrimSpace(intent.Source.Ledger.LedgerAccountRef), strings.TrimSpace(intent.Source.Ledger.ContraLedgerAccountRef), nil
|
||||
}
|
||||
if ref := attributeLookup(intent.Attributes, "ledger_debit_account_ref", "ledgerDebitAccountRef"); ref != "" {
|
||||
return ref, strings.TrimSpace(attributeLookup(intent.Attributes, "ledger_debit_contra_account_ref", "ledgerDebitContraAccountRef")), nil
|
||||
}
|
||||
return "", "", merrors.InvalidArgument("ledger: source account is required")
|
||||
}
|
||||
|
||||
func ledgerBlockAccount(payment *model.Payment) (string, error) {
|
||||
if payment == nil {
|
||||
return "", merrors.InvalidArgument("ledger: payment is required")
|
||||
}
|
||||
intent := payment.Intent
|
||||
if intent.Source.Ledger != nil {
|
||||
if ref := strings.TrimSpace(intent.Source.Ledger.ContraLedgerAccountRef); ref != "" {
|
||||
return ref, nil
|
||||
}
|
||||
}
|
||||
if ref := attributeLookup(intent.Attributes,
|
||||
"ledger_block_account_ref",
|
||||
"ledgerBlockAccountRef",
|
||||
"ledger_hold_account_ref",
|
||||
"ledgerHoldAccountRef",
|
||||
"ledger_debit_contra_account_ref",
|
||||
"ledgerDebitContraAccountRef",
|
||||
); ref != "" {
|
||||
return ref, nil
|
||||
}
|
||||
return "", merrors.InvalidArgument("ledger: block account is required")
|
||||
}
|
||||
|
||||
func ledgerBlockAccountIfConfirmed(payment *model.Payment) string {
|
||||
if payment == nil {
|
||||
return ""
|
||||
}
|
||||
if !blockStepConfirmed(payment.PaymentPlan, payment.ExecutionPlan) {
|
||||
return ""
|
||||
}
|
||||
ref, err := ledgerBlockAccount(payment)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return ref
|
||||
}
|
||||
|
||||
func ledgerCreditAccount(payment *model.Payment) (string, string, error) {
|
||||
if payment == nil {
|
||||
return "", "", merrors.InvalidArgument("ledger: payment is required")
|
||||
}
|
||||
intent := payment.Intent
|
||||
if intent.Destination.Ledger != nil && strings.TrimSpace(intent.Destination.Ledger.LedgerAccountRef) != "" {
|
||||
return strings.TrimSpace(intent.Destination.Ledger.LedgerAccountRef), strings.TrimSpace(intent.Destination.Ledger.ContraLedgerAccountRef), nil
|
||||
}
|
||||
if ref := attributeLookup(intent.Attributes, "ledger_credit_account_ref", "ledgerCreditAccountRef"); ref != "" {
|
||||
return ref, strings.TrimSpace(attributeLookup(intent.Attributes, "ledger_credit_contra_account_ref", "ledgerCreditContraAccountRef")), nil
|
||||
}
|
||||
return "", "", merrors.InvalidArgument("ledger: destination account is required")
|
||||
}
|
||||
|
||||
func attributeLookup(attrs map[string]string, keys ...string) string {
|
||||
if len(keys) == 0 {
|
||||
return ""
|
||||
}
|
||||
for _, key := range keys {
|
||||
if key == "" || attrs == nil {
|
||||
continue
|
||||
}
|
||||
if val := strings.TrimSpace(attrs[key]); val != "" {
|
||||
return val
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/tech/sendico/payments/storage"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (p *paymentExecutor) releasePaymentHold(ctx context.Context, store storage.PaymentsStore, payment *model.Payment) error {
|
||||
if store == nil {
|
||||
return errStorageUnavailable
|
||||
}
|
||||
if payment == nil || payment.PaymentPlan == nil || len(payment.PaymentPlan.Steps) == 0 {
|
||||
return nil
|
||||
}
|
||||
execPlan := ensureExecutionPlanForPlan(payment, payment.PaymentPlan)
|
||||
if execPlan == nil || !blockStepConfirmed(payment.PaymentPlan, execPlan) {
|
||||
return nil
|
||||
}
|
||||
execSteps := executionStepsByCode(execPlan)
|
||||
execQuote := executionQuote(payment, nil)
|
||||
|
||||
for idx, step := range payment.PaymentPlan.Steps {
|
||||
if step == nil || step.Action != model.RailOperationRelease {
|
||||
continue
|
||||
}
|
||||
stepID := planStepID(step, idx)
|
||||
execStep := execSteps[stepID]
|
||||
if execStep == nil {
|
||||
execStep = &model.ExecutionStep{Code: stepID}
|
||||
execSteps[stepID] = execStep
|
||||
if idx < len(execPlan.Steps) {
|
||||
execPlan.Steps[idx] = execStep
|
||||
}
|
||||
}
|
||||
if execStep.State == model.OperationStateSuccess {
|
||||
p.logger.Debug("Payment step already confirmed, skipping", zap.String("step_id", stepID), zap.String("quutation", execQuote.QuoteRef))
|
||||
continue
|
||||
}
|
||||
if _, err := p.executePlanStep(ctx, payment, step, execStep, execQuote, nil, idx); err != nil {
|
||||
p.logger.Warn("Failed to execute payment step", zap.Error(err),
|
||||
zap.String("step_id", stepID), zap.String("quutation", execQuote.QuoteRef))
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return p.persistPayment(ctx, store, payment)
|
||||
}
|
||||
@@ -1,446 +0,0 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
|
||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (p *paymentExecutor) executePlanStep(
|
||||
ctx context.Context,
|
||||
payment *model.Payment,
|
||||
step *model.PaymentStep,
|
||||
execStep *model.ExecutionStep,
|
||||
quote *orchestratorv1.PaymentQuote,
|
||||
charges []*ledgerv1.PostingLine,
|
||||
idx int,
|
||||
) (bool, error) {
|
||||
|
||||
if payment == nil || step == nil || execStep == nil {
|
||||
return false, merrors.InvalidArgument("payment plan: step is required")
|
||||
}
|
||||
|
||||
stepID := execStep.Code
|
||||
logger := p.logger.With(
|
||||
zap.String("payment_ref", payment.PaymentRef),
|
||||
zap.String("step_id", stepID),
|
||||
zap.String("rail", string(step.Rail)),
|
||||
zap.String("action", string(step.Action)),
|
||||
zap.Int("idx", idx),
|
||||
)
|
||||
|
||||
logger.Debug("Executing payment plan step")
|
||||
|
||||
if execStep.IsTerminal() {
|
||||
logger.Debug("Step already in terminal state, skipping execution",
|
||||
zap.String("state", string(execStep.State)),
|
||||
)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
switch step.Action {
|
||||
|
||||
case model.RailOperationMove:
|
||||
logger.Debug("Posting ledger move")
|
||||
amount, err := requireMoney(cloneMoney(step.Amount), "ledger move amount")
|
||||
if err != nil {
|
||||
logger.Warn("Ledger move amount invalid", zap.Error(err))
|
||||
return false, err
|
||||
}
|
||||
ref, err := p.postLedgerMove(ctx, payment, step, protoMoney(amount), planStepIdempotencyKey(payment, idx, step), idx)
|
||||
if err != nil {
|
||||
logger.Warn("Ledger move failed", zap.Error(err))
|
||||
return false, err
|
||||
}
|
||||
execStep.TransferRef = strings.TrimSpace(ref)
|
||||
setExecutionStepStatus(execStep, model.OperationStateSuccess)
|
||||
logger.Info("Ledger move completed", zap.String("journal_ref", ref))
|
||||
return false, nil
|
||||
|
||||
case model.RailOperationDebit, model.RailOperationExternalDebit:
|
||||
logger.Debug("Posting ledger debit")
|
||||
amount, err := requireMoney(cloneMoney(step.Amount), "ledger debit amount")
|
||||
if err != nil {
|
||||
logger.Warn("Ledger debit amount invalid", zap.Error(err))
|
||||
return false, err
|
||||
}
|
||||
ref, err := p.postLedgerDebit(ctx, payment, protoMoney(amount), charges, planStepIdempotencyKey(payment, idx, step), idx, step.Action, quote)
|
||||
if err != nil {
|
||||
logger.Warn("Ledger debit failed", zap.Error(err))
|
||||
return false, err
|
||||
}
|
||||
ensureExecutionRefs(payment).DebitEntryRef = ref
|
||||
setExecutionStepStatus(execStep, model.OperationStateSuccess)
|
||||
logger.Info("Ledger debit completed", zap.String("journal_ref", ref))
|
||||
return false, nil
|
||||
|
||||
case model.RailOperationCredit, model.RailOperationExternalCredit:
|
||||
logger.Debug("Posting ledger credit")
|
||||
amount, err := requireMoney(cloneMoney(step.Amount), "ledger credit amount")
|
||||
if err != nil {
|
||||
logger.Warn("Ledger credit amount invalid", zap.Error(err))
|
||||
return false, err
|
||||
}
|
||||
ref, err := p.postLedgerCredit(ctx, payment, protoMoney(amount), planStepIdempotencyKey(payment, idx, step), idx, step.Action, quote)
|
||||
if err != nil {
|
||||
logger.Warn("Ledger credit failed", zap.Error(err))
|
||||
return false, err
|
||||
}
|
||||
ensureExecutionRefs(payment).CreditEntryRef = ref
|
||||
setExecutionStepStatus(execStep, model.OperationStateSuccess)
|
||||
logger.Info("Ledger credit completed", zap.String("journal_ref", ref))
|
||||
return false, nil
|
||||
|
||||
case model.RailOperationFXConvert:
|
||||
logger.Debug("Applying FX conversion")
|
||||
if err := p.applyFX(ctx, payment, quote, charges, paymentDescription(payment), cloneMetadata(payment.Metadata), ensureExecutionRefs(payment)); err != nil {
|
||||
logger.Warn("FX conversion failed", zap.Error(err))
|
||||
return false, err
|
||||
}
|
||||
setExecutionStepStatus(execStep, model.OperationStateSuccess)
|
||||
logger.Info("FX conversion completed")
|
||||
return false, nil
|
||||
|
||||
case model.RailOperationObserveConfirm:
|
||||
setExecutionStepStatus(execStep, model.OperationStateWaiting)
|
||||
logger.Info("ObserveConfirm step set to waiting for external confirmation")
|
||||
return true, nil
|
||||
|
||||
case model.RailOperationSend:
|
||||
logger.Debug("Executing send step")
|
||||
async, err := p.executeSendStep(ctx, payment, step, execStep, quote, idx)
|
||||
if err != nil {
|
||||
setExecutionStepStatus(execStep, model.OperationStateFailed)
|
||||
execStep.Error = err.Error()
|
||||
logger.Warn("Send step failed", zap.Error(err))
|
||||
return false, err
|
||||
}
|
||||
|
||||
return async, nil
|
||||
|
||||
case model.RailOperationFee:
|
||||
logger.Debug("Executing fee step")
|
||||
async, err := p.executeFeeStep(ctx, payment, step, execStep, idx)
|
||||
if err != nil {
|
||||
logger.Warn("Fee step failed", zap.Error(err))
|
||||
return false, err
|
||||
}
|
||||
logger.Info("Fee step submitted")
|
||||
return async, nil
|
||||
|
||||
default:
|
||||
logger.Warn("Unsupported payment plan action")
|
||||
return false, merrors.InvalidArgument("payment plan: unsupported action")
|
||||
}
|
||||
}
|
||||
|
||||
func sub(a, b string) (string, error) {
|
||||
ra, ok := new(big.Rat).SetString(a)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("invalid number: %s", a)
|
||||
}
|
||||
|
||||
rb, ok := new(big.Rat).SetString(b)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("invalid number: %s", b)
|
||||
}
|
||||
|
||||
ra.Sub(ra, rb)
|
||||
|
||||
// 2 знака после запятой (как у тебя)
|
||||
return ra.FloatString(2), nil
|
||||
}
|
||||
|
||||
func (p *paymentExecutor) executeSendStep(
|
||||
ctx context.Context,
|
||||
payment *model.Payment,
|
||||
step *model.PaymentStep,
|
||||
execStep *model.ExecutionStep,
|
||||
quote *orchestratorv1.PaymentQuote,
|
||||
idx int,
|
||||
) (bool, error) {
|
||||
|
||||
stepID := execStep.Code
|
||||
logger := p.logger.With(
|
||||
zap.String("payment_ref", payment.PaymentRef),
|
||||
zap.String("step_id", stepID),
|
||||
zap.String("rail", string(step.Rail)),
|
||||
zap.String("action", string(step.Action)),
|
||||
zap.Int("idx", idx),
|
||||
)
|
||||
|
||||
logger.Debug("Executing send step")
|
||||
|
||||
switch step.Rail {
|
||||
|
||||
case model.RailCrypto:
|
||||
logger.Debug("Preparing crypto transfer")
|
||||
|
||||
amount, err := requireMoney(cloneMoney(step.Amount), "crypto send amount")
|
||||
if err != nil {
|
||||
logger.Warn("Invalid crypto amount", zap.Error(err))
|
||||
return false, err
|
||||
}
|
||||
|
||||
if !p.deps.railGateways.available() {
|
||||
logger.Warn("Rail gateway unavailable")
|
||||
return false, merrors.Internal("rail gateway unavailable")
|
||||
}
|
||||
|
||||
fromRole, toRole := roleHintsForStep(payment.PaymentPlan, idx)
|
||||
req, err := p.buildCryptoTransferRequest(
|
||||
payment,
|
||||
amount,
|
||||
model.RailOperationSend,
|
||||
planStepIdempotencyKey(payment, idx, step),
|
||||
execStep.OperationRef,
|
||||
quote,
|
||||
fromRole, toRole,
|
||||
)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to build crypto transfer request", zap.Error(err))
|
||||
return false, err
|
||||
}
|
||||
|
||||
gw, err := p.deps.railGateways.resolve(ctx, step)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to resolve rail gateway", zap.Error(err))
|
||||
return false, err
|
||||
}
|
||||
|
||||
logger.Debug("Sending crypto transfer",
|
||||
zap.String("idempotency", req.IdempotencyKey), zap.String("intent_ref", req.IntentRef),
|
||||
zap.String("operation_ref", req.OperationRef),
|
||||
)
|
||||
|
||||
result, err := gw.Send(ctx, req)
|
||||
if err != nil {
|
||||
execStep.Error = strings.TrimSpace(err.Error())
|
||||
setExecutionStepStatus(execStep, model.OperationStateFailed)
|
||||
|
||||
payment.State = model.PaymentStateFailed
|
||||
payment.FailureCode = model.PaymentFailureCodeChain
|
||||
|
||||
logger.Warn("Send failed; step marked as failed", zap.Error(err))
|
||||
return false, nil
|
||||
}
|
||||
|
||||
execStep.TransferRef = strings.TrimSpace(result.ReferenceID)
|
||||
logger.Info("Crypto transfer submitted",
|
||||
zap.String("transfer_ref", execStep.TransferRef),
|
||||
)
|
||||
|
||||
exec := ensureExecutionRefs(payment)
|
||||
if exec.ChainTransferRef == "" && execStep.TransferRef != "" {
|
||||
exec.ChainTransferRef = execStep.TransferRef
|
||||
}
|
||||
|
||||
if execStep.TransferRef != "" {
|
||||
linkRailObservation(payment, step.Rail, execStep.TransferRef, stepID)
|
||||
}
|
||||
|
||||
setExecutionStepStatus(execStep, model.OperationStateWaiting)
|
||||
return true, nil
|
||||
|
||||
case model.RailCardPayout:
|
||||
logger.Debug("Submitting card payout")
|
||||
|
||||
amount, err := requireMoney(cloneMoney(step.Amount), "card payout amount")
|
||||
if err != nil {
|
||||
logger.Warn("Invalid card payout amount", zap.Error(err))
|
||||
return false, err
|
||||
}
|
||||
|
||||
fromRole, toRole := roleHintsForStep(payment.PaymentPlan, idx)
|
||||
ref, err := p.submitCardPayoutPlan(
|
||||
ctx,
|
||||
payment,
|
||||
execStep.OperationRef,
|
||||
protoMoney(amount),
|
||||
fromRole, toRole,
|
||||
)
|
||||
if err != nil {
|
||||
logger.Warn("Card payout submission failed", zap.Error(err))
|
||||
return false, err
|
||||
}
|
||||
|
||||
execStep.TransferRef = ref
|
||||
ensureExecutionRefs(payment).CardPayoutRef = ref
|
||||
|
||||
logger.Info("Card payout submitted", zap.String("payout_ref", ref))
|
||||
|
||||
setExecutionStepStatus(execStep, model.OperationStateWaiting)
|
||||
return true, nil
|
||||
|
||||
case model.RailProviderSettlement:
|
||||
logger.Debug("Preparing provider settlement transfer")
|
||||
|
||||
amount, err := requireMoney(cloneMoney(payment.LastQuote.DebitSettlementAmount), "provider settlement amount")
|
||||
if err != nil {
|
||||
logger.Warn("Invalid provider settlement amount", zap.Error(err), zap.Any("settlement", payment.LastQuote.DebitSettlementAmount))
|
||||
return false, err
|
||||
}
|
||||
logger.Debug("Expected settlement amount", zap.String("amount", amount.Amount), zap.String("currency", amount.Currency))
|
||||
fee, err := requireMoney(cloneMoney(payment.LastQuote.ExpectedFeeTotal), "provider settlement amount")
|
||||
if err != nil {
|
||||
logger.Warn("Invalid fee settlement amount", zap.Error(err))
|
||||
return false, err
|
||||
}
|
||||
if fee.Currency != amount.Currency {
|
||||
logger.Warn("Fee and amount currencies do not match",
|
||||
zap.String("amount_currency", amount.Currency), zap.String("fee_currency", fee.Currency),
|
||||
)
|
||||
return false, merrors.DataConflict("settlement payment: currencies mismatch")
|
||||
}
|
||||
|
||||
if !p.deps.railGateways.available() {
|
||||
logger.Warn("Rail gateway unavailable")
|
||||
return false, merrors.Internal("rail gateway unavailable")
|
||||
}
|
||||
|
||||
fromRole, toRole := roleHintsForStep(payment.PaymentPlan, idx)
|
||||
req, err := p.buildProviderSettlementTransferRequest(
|
||||
payment,
|
||||
step,
|
||||
execStep.OperationRef,
|
||||
amount,
|
||||
quote,
|
||||
idx,
|
||||
fromRole, toRole)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to build provider settlement request", zap.Error(err))
|
||||
return false, err
|
||||
}
|
||||
|
||||
gw, err := p.deps.railGateways.resolve(ctx, step)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to resolve rail gateway", zap.Error(err))
|
||||
return false, err
|
||||
}
|
||||
|
||||
logger.Info("Sending provider settlement transfer",
|
||||
zap.String("idempotency", req.IdempotencyKey), zap.String("intent_ref", req.IntentRef),
|
||||
)
|
||||
|
||||
result, err := gw.Send(ctx, req)
|
||||
if err != nil {
|
||||
execStep.Error = strings.TrimSpace(err.Error())
|
||||
setExecutionStepStatus(execStep, model.OperationStateFailed)
|
||||
|
||||
payment.State = model.PaymentStateFailed
|
||||
payment.FailureCode = model.PaymentFailureCodeSettlement
|
||||
|
||||
logger.Warn("Send failed; step marked as failed", zap.Error(err))
|
||||
return false, nil
|
||||
}
|
||||
|
||||
execStep.TransferRef = strings.TrimSpace(result.ReferenceID)
|
||||
if execStep.TransferRef == "" {
|
||||
execStep.TransferRef = strings.TrimSpace(req.IdempotencyKey)
|
||||
}
|
||||
|
||||
logger.Info("Provider settlement submitted",
|
||||
zap.String("transfer_ref", execStep.TransferRef),
|
||||
)
|
||||
|
||||
linkProviderSettlementObservation(payment, execStep.TransferRef)
|
||||
setExecutionStepStatus(execStep, model.OperationStateWaiting)
|
||||
return true, nil
|
||||
|
||||
case model.RailFiatOnRamp:
|
||||
logger.Warn("Fiat on-ramp not implemented")
|
||||
return false, merrors.InvalidArgument("payment plan: fiat on-ramp execution not implemented")
|
||||
|
||||
default:
|
||||
logger.Warn("Unsupported send rail")
|
||||
return false, merrors.InvalidArgument("payment plan: unsupported send rail")
|
||||
}
|
||||
}
|
||||
|
||||
func (p *paymentExecutor) executeFeeStep(
|
||||
ctx context.Context,
|
||||
payment *model.Payment,
|
||||
step *model.PaymentStep,
|
||||
execStep *model.ExecutionStep,
|
||||
idx int,
|
||||
) (bool, error) {
|
||||
|
||||
if payment == nil || step == nil || execStep == nil {
|
||||
return false, merrors.InvalidArgument("payment plan: fee step is required")
|
||||
}
|
||||
|
||||
switch step.Rail {
|
||||
|
||||
case model.RailCrypto:
|
||||
amount, err := requireMoney(cloneMoney(step.Amount), "crypto fee amount")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if !p.deps.railGateways.available() {
|
||||
return false, merrors.Internal("rail gateway unavailable")
|
||||
}
|
||||
|
||||
fromRole, toRole := roleHintsForStep(payment.PaymentPlan, idx)
|
||||
|
||||
req, err := p.buildCryptoTransferRequest(
|
||||
payment,
|
||||
amount,
|
||||
model.RailOperationFee,
|
||||
planStepIdempotencyKey(payment, idx, step),
|
||||
execStep.OperationRef,
|
||||
nil,
|
||||
fromRole,
|
||||
toRole,
|
||||
)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
gw, err := p.deps.railGateways.resolve(ctx, step)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
p.logger.Debug("Executing crypto fee transfer",
|
||||
zap.String("payment_ref", payment.PaymentRef),
|
||||
zap.String("step_id", planStepID(step, idx)),
|
||||
zap.String("amount", amount.GetAmount()),
|
||||
zap.String("currency", amount.GetCurrency()),
|
||||
)
|
||||
|
||||
result, err := gw.Send(ctx, req)
|
||||
if err != nil {
|
||||
p.logger.Warn("Crypto fee transfer failed to submit", zap.Error(err),
|
||||
zap.String("payment_ref", payment.PaymentRef),
|
||||
)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
execStep.TransferRef = strings.TrimSpace(result.ReferenceID)
|
||||
|
||||
if execStep.TransferRef != "" {
|
||||
ensureExecutionRefs(payment).FeeTransferRef = execStep.TransferRef
|
||||
}
|
||||
|
||||
// ВАЖНО: больше не Submitted
|
||||
setExecutionStepStatus(execStep, model.OperationStateWaiting)
|
||||
|
||||
p.logger.Info("Crypto fee transfer submitted, waiting confirmation",
|
||||
zap.String("payment_ref", payment.PaymentRef),
|
||||
zap.String("transfer_ref", execStep.TransferRef),
|
||||
)
|
||||
|
||||
return true, nil
|
||||
|
||||
default:
|
||||
return false, merrors.InvalidArgument("payment plan: unsupported fee rail")
|
||||
}
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/model/account_role"
|
||||
"github.com/tech/sendico/pkg/payments/rail"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||
)
|
||||
|
||||
const (
|
||||
providerSettlementMetaPaymentIntentID = "payment_ref"
|
||||
providerSettlementMetaQuoteRef = "quote_ref"
|
||||
providerSettlementMetaTargetChatID = "target_chat_id"
|
||||
providerSettlementMetaOutgoingLeg = "outgoing_leg"
|
||||
providerSettlementMetaSourceAmount = "source_amount"
|
||||
providerSettlementMetaSourceCurrency = "source_currency"
|
||||
)
|
||||
|
||||
func (p *paymentExecutor) buildProviderSettlementTransferRequest(payment *model.Payment, step *model.PaymentStep, operationRef string, amount *paymenttypes.Money, quote *orchestratorv1.PaymentQuote, idx int, fromRole, toRole *account_role.AccountRole) (rail.TransferRequest, error) {
|
||||
if payment == nil || step == nil {
|
||||
return rail.TransferRequest{}, merrors.InvalidArgument("provider settlement: payment and step are required")
|
||||
}
|
||||
if amount == nil {
|
||||
return rail.TransferRequest{}, merrors.InvalidArgument("provider settlement: amount is required")
|
||||
}
|
||||
requestID := planStepIdempotencyKey(payment, idx, step)
|
||||
if requestID == "" {
|
||||
return rail.TransferRequest{}, merrors.InvalidArgument("provider settlement: idempotency key is required")
|
||||
}
|
||||
intentRef := strings.TrimSpace(payment.Intent.Ref)
|
||||
if intentRef == "" {
|
||||
return rail.TransferRequest{}, merrors.InvalidArgument("provider settlement: intention ref is required")
|
||||
}
|
||||
paymentRef := strings.TrimSpace(payment.PaymentRef)
|
||||
if paymentRef == "" {
|
||||
return rail.TransferRequest{}, merrors.InvalidArgument("provider settlement: payment_ref is required")
|
||||
}
|
||||
metadata := cloneMetadata(payment.Metadata)
|
||||
if metadata == nil {
|
||||
metadata = map[string]string{}
|
||||
}
|
||||
metadata[providerSettlementMetaPaymentIntentID] = paymentRef
|
||||
if quoteRef := paymentGatewayQuoteRef(payment, quote); quoteRef != "" {
|
||||
metadata[providerSettlementMetaQuoteRef] = quoteRef
|
||||
}
|
||||
if chatID := paymentGatewayTargetChatID(payment); chatID != "" {
|
||||
metadata[providerSettlementMetaTargetChatID] = chatID
|
||||
}
|
||||
if strings.TrimSpace(metadata[providerSettlementMetaOutgoingLeg]) == "" {
|
||||
metadata[providerSettlementMetaOutgoingLeg] = strings.ToLower(strings.TrimSpace(string(step.Rail)))
|
||||
}
|
||||
if strings.TrimSpace(metadata[providerSettlementMetaSourceAmount]) == "" {
|
||||
metadata[providerSettlementMetaSourceAmount] = strings.TrimSpace(amount.Amount)
|
||||
}
|
||||
if strings.TrimSpace(metadata[providerSettlementMetaSourceCurrency]) == "" {
|
||||
metadata[providerSettlementMetaSourceCurrency] = strings.TrimSpace(amount.Currency)
|
||||
}
|
||||
|
||||
sourceWalletRef := ""
|
||||
if payment.Intent.Source.ManagedWallet != nil {
|
||||
sourceWalletRef = strings.TrimSpace(payment.Intent.Source.ManagedWallet.ManagedWalletRef)
|
||||
}
|
||||
if sourceWalletRef == "" {
|
||||
return rail.TransferRequest{}, merrors.InvalidArgument("provider settlement: source managed wallet is required")
|
||||
}
|
||||
|
||||
destRef := ""
|
||||
if payment.Intent.Destination.Type == model.EndpointTypeCard {
|
||||
if route, err := p.resolveCardRoute(payment.Intent); err == nil {
|
||||
destRef = strings.TrimSpace(route.FundingAddress)
|
||||
}
|
||||
}
|
||||
if destRef == "" {
|
||||
destRef = paymentRef
|
||||
}
|
||||
|
||||
req := rail.TransferRequest{
|
||||
OrganizationRef: payment.OrganizationRef.Hex(),
|
||||
FromAccountID: sourceWalletRef,
|
||||
ToAccountID: destRef,
|
||||
Currency: strings.TrimSpace(amount.GetCurrency()),
|
||||
Amount: strings.TrimSpace(amount.GetAmount()),
|
||||
IdempotencyKey: requestID,
|
||||
DestinationMemo: paymentRef,
|
||||
Metadata: metadata,
|
||||
PaymentRef: paymentRef,
|
||||
OperationRef: operationRef,
|
||||
IntentRef: intentRef,
|
||||
}
|
||||
if fromRole != nil {
|
||||
req.FromRole = *fromRole
|
||||
}
|
||||
if toRole != nil {
|
||||
req.ToRole = *toRole
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func paymentGatewayQuoteRef(payment *model.Payment, quote *orchestratorv1.PaymentQuote) string {
|
||||
if quote != nil {
|
||||
if ref := strings.TrimSpace(quote.GetQuoteRef()); ref != "" {
|
||||
return ref
|
||||
}
|
||||
}
|
||||
if payment != nil && payment.LastQuote != nil {
|
||||
return strings.TrimSpace(payment.LastQuote.QuoteRef)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func paymentGatewayTargetChatID(payment *model.Payment) string {
|
||||
if payment == nil {
|
||||
return ""
|
||||
}
|
||||
if payment.Intent.Attributes != nil {
|
||||
if chatID := strings.TrimSpace(payment.Intent.Attributes["target_chat_id"]); chatID != "" {
|
||||
return chatID
|
||||
}
|
||||
}
|
||||
if payment.Metadata != nil {
|
||||
return strings.TrimSpace(payment.Metadata["target_chat_id"])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func linkProviderSettlementObservation(payment *model.Payment, requestID string) {
|
||||
linkRailObservation(payment, model.RailProviderSettlement, requestID, "")
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/tech/sendico/payments/storage"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/api/routers"
|
||||
clockpkg "github.com/tech/sendico/pkg/clock"
|
||||
msg "github.com/tech/sendico/pkg/messaging"
|
||||
mb "github.com/tech/sendico/pkg/messaging/broker"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
type serviceError string
|
||||
|
||||
func (e serviceError) Error() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
const (
|
||||
defaultFeeQuoteTTLMillis int64 = 120000
|
||||
defaultOracleTTLMillis int64 = 60000
|
||||
)
|
||||
|
||||
var (
|
||||
errStorageUnavailable = serviceError("payments.orchestrator: storage not initialised")
|
||||
)
|
||||
|
||||
// Service orchestrates payments across ledger, billing, FX, and chain domains.
|
||||
type Service struct {
|
||||
logger mlogger.Logger
|
||||
storage storage.Repository
|
||||
clock clockpkg.Clock
|
||||
|
||||
deps serviceDependencies
|
||||
h handlerSet
|
||||
comp componentSet
|
||||
|
||||
gatewayBroker mb.Broker
|
||||
gatewayConsumers []msg.Consumer
|
||||
|
||||
orchestratorv1.UnimplementedPaymentOrchestratorServer
|
||||
}
|
||||
|
||||
type serviceDependencies struct {
|
||||
fees feesDependency
|
||||
ledger ledgerDependency
|
||||
gateway gatewayDependency
|
||||
railGateways railGatewayDependency
|
||||
providerGateway providerGatewayDependency
|
||||
oracle oracleDependency
|
||||
mntx mntxDependency
|
||||
gatewayRegistry GatewayRegistry
|
||||
gatewayInvokeResolver GatewayInvokeResolver
|
||||
cardRoutes map[string]CardGatewayRoute
|
||||
feeLedgerAccounts map[string]string
|
||||
planBuilder PlanBuilder
|
||||
}
|
||||
|
||||
type handlerSet struct {
|
||||
commands *paymentCommandFactory
|
||||
queries *paymentQueryHandler
|
||||
events *paymentEventHandler
|
||||
}
|
||||
|
||||
type componentSet struct {
|
||||
executor *paymentExecutor
|
||||
}
|
||||
|
||||
// NewService constructs a payment orchestrator service.
|
||||
func NewService(logger mlogger.Logger, repo storage.Repository, opts ...Option) *Service {
|
||||
svc := &Service{
|
||||
logger: logger.Named("payment_orchestrator"),
|
||||
storage: repo,
|
||||
clock: clockpkg.NewSystem(),
|
||||
}
|
||||
|
||||
initMetrics()
|
||||
|
||||
for _, opt := range opts {
|
||||
if opt != nil {
|
||||
opt(svc)
|
||||
}
|
||||
}
|
||||
|
||||
if svc.clock == nil {
|
||||
svc.clock = clockpkg.NewSystem()
|
||||
}
|
||||
|
||||
engine := defaultPaymentEngine{svc: svc}
|
||||
svc.h.commands = newPaymentCommandFactory(engine, svc.logger)
|
||||
svc.h.queries = newPaymentQueryHandler(svc.storage, svc.ensureRepository, svc.logger.Named("queries"))
|
||||
svc.h.events = newPaymentEventHandler(svc.storage, svc.ensureRepository, svc.logger.Named("events"), svc.submitCardPayout, svc.resumePaymentPlan, svc.releasePaymentHold)
|
||||
svc.comp.executor = newPaymentExecutor(&svc.deps, svc.logger.Named("payment_executor"), svc)
|
||||
svc.startGatewayConsumers()
|
||||
|
||||
return svc
|
||||
}
|
||||
|
||||
func (s *Service) ensureHandlers() {
|
||||
if s.h.commands == nil {
|
||||
s.h.commands = newPaymentCommandFactory(defaultPaymentEngine{svc: s}, s.logger)
|
||||
}
|
||||
if s.h.queries == nil {
|
||||
s.h.queries = newPaymentQueryHandler(s.storage, s.ensureRepository, s.logger.Named("queries"))
|
||||
}
|
||||
if s.h.events == nil {
|
||||
s.h.events = newPaymentEventHandler(s.storage, s.ensureRepository, s.logger.Named("events"), s.submitCardPayout, s.resumePaymentPlan, s.releasePaymentHold)
|
||||
}
|
||||
if s.comp.executor == nil {
|
||||
s.comp.executor = newPaymentExecutor(&s.deps, s.logger.Named("payment_executor"), s)
|
||||
}
|
||||
}
|
||||
|
||||
// Register attaches the service to the supplied gRPC router.
|
||||
func (s *Service) Register(router routers.GRPC) error {
|
||||
return router.Register(func(reg grpc.ServiceRegistrar) {
|
||||
orchestratorv1.RegisterPaymentOrchestratorServer(reg, s)
|
||||
})
|
||||
}
|
||||
|
||||
// QuotePayment aggregates downstream quotes.
|
||||
func (s *Service) QuotePayment(ctx context.Context, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.QuotePaymentResponse, error) {
|
||||
s.ensureHandlers()
|
||||
return executeUnary(ctx, s, "QuotePayment", s.h.commands.QuotePayment().Execute, req)
|
||||
}
|
||||
|
||||
// QuotePayments aggregates downstream quotes for multiple intents.
|
||||
func (s *Service) QuotePayments(ctx context.Context, req *orchestratorv1.QuotePaymentsRequest) (*orchestratorv1.QuotePaymentsResponse, error) {
|
||||
s.ensureHandlers()
|
||||
return executeUnary(ctx, s, "QuotePayments", s.h.commands.QuotePayments().Execute, req)
|
||||
}
|
||||
|
||||
// InitiatePayment captures a payment intent and reserves funds orchestration.
|
||||
func (s *Service) InitiatePayment(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error) {
|
||||
s.ensureHandlers()
|
||||
return executeUnary(ctx, s, "InitiatePayment", s.h.commands.InitiatePayment().Execute, req)
|
||||
}
|
||||
|
||||
// InitiatePayments executes multiple payments using a stored quote reference.
|
||||
func (s *Service) InitiatePayments(ctx context.Context, req *orchestratorv1.InitiatePaymentsRequest) (*orchestratorv1.InitiatePaymentsResponse, error) {
|
||||
s.ensureHandlers()
|
||||
return executeUnary(ctx, s, "InitiatePayments", s.h.commands.InitiatePayments().Execute, req)
|
||||
}
|
||||
|
||||
// CancelPayment attempts to cancel an in-flight payment.
|
||||
func (s *Service) CancelPayment(ctx context.Context, req *orchestratorv1.CancelPaymentRequest) (*orchestratorv1.CancelPaymentResponse, error) {
|
||||
s.ensureHandlers()
|
||||
return executeUnary(ctx, s, "CancelPayment", s.h.commands.CancelPayment().Execute, req)
|
||||
}
|
||||
|
||||
// GetPayment returns a stored payment record.
|
||||
func (s *Service) GetPayment(ctx context.Context, req *orchestratorv1.GetPaymentRequest) (*orchestratorv1.GetPaymentResponse, error) {
|
||||
s.ensureHandlers()
|
||||
return executeUnary(ctx, s, "GetPayment", s.h.queries.getPayment, req)
|
||||
}
|
||||
|
||||
// ListPayments lists stored payment records.
|
||||
func (s *Service) ListPayments(ctx context.Context, req *orchestratorv1.ListPaymentsRequest) (*orchestratorv1.ListPaymentsResponse, error) {
|
||||
s.ensureHandlers()
|
||||
return executeUnary(ctx, s, "ListPayments", s.h.queries.listPayments, req)
|
||||
}
|
||||
|
||||
// InitiateConversion orchestrates standalone FX conversions.
|
||||
func (s *Service) InitiateConversion(ctx context.Context, req *orchestratorv1.InitiateConversionRequest) (*orchestratorv1.InitiateConversionResponse, error) {
|
||||
s.ensureHandlers()
|
||||
return executeUnary(ctx, s, "InitiateConversion", s.h.commands.InitiateConversion().Execute, req)
|
||||
}
|
||||
|
||||
// ProcessTransferUpdate reconciles chain events back into payment state.
|
||||
func (s *Service) ProcessTransferUpdate(ctx context.Context, req *orchestratorv1.ProcessTransferUpdateRequest) (*orchestratorv1.ProcessTransferUpdateResponse, error) {
|
||||
s.ensureHandlers()
|
||||
return executeUnary(ctx, s, "ProcessTransferUpdate", s.h.events.processTransferUpdate, req)
|
||||
}
|
||||
|
||||
// ProcessDepositObserved reconciles deposit events to ledger.
|
||||
func (s *Service) ProcessDepositObserved(ctx context.Context, req *orchestratorv1.ProcessDepositObservedRequest) (*orchestratorv1.ProcessDepositObservedResponse, error) {
|
||||
s.ensureHandlers()
|
||||
return executeUnary(ctx, s, "ProcessDepositObserved", s.h.events.processDepositObserved, req)
|
||||
}
|
||||
|
||||
// ProcessCardPayoutUpdate reconciles card payout events back into payment state.
|
||||
func (s *Service) ProcessCardPayoutUpdate(ctx context.Context, req *orchestratorv1.ProcessCardPayoutUpdateRequest) (*orchestratorv1.ProcessCardPayoutUpdateResponse, error) {
|
||||
s.ensureHandlers()
|
||||
return executeUnary(ctx, s, "ProcessCardPayoutUpdate", s.h.events.processCardPayoutUpdate, req)
|
||||
}
|
||||
|
||||
func (s *Service) executePayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, quote *orchestratorv1.PaymentQuote) error {
|
||||
s.ensureHandlers()
|
||||
return s.comp.executor.executePayment(ctx, store, payment, quote)
|
||||
}
|
||||
|
||||
func (s *Service) resumePaymentPlan(ctx context.Context, store storage.PaymentsStore, payment *model.Payment) error {
|
||||
if payment == nil || payment.PaymentPlan == nil || len(payment.PaymentPlan.Steps) == 0 {
|
||||
return nil
|
||||
}
|
||||
s.ensureHandlers()
|
||||
return s.comp.executor.executePaymentPlan(ctx, store, payment, nil)
|
||||
}
|
||||
|
||||
func (s *Service) releasePaymentHold(ctx context.Context, store storage.PaymentsStore, payment *model.Payment) error {
|
||||
if payment == nil || payment.PaymentPlan == nil || len(payment.PaymentPlan.Steps) == 0 {
|
||||
return nil
|
||||
}
|
||||
s.ensureHandlers()
|
||||
return s.comp.executor.releasePaymentHold(ctx, store, payment)
|
||||
}
|
||||
70
api/payments/quotation/internal/service/plan/builder.go
Normal file
70
api/payments/quotation/internal/service/plan/builder.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package plan
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||
)
|
||||
|
||||
// RouteStore exposes routing definitions for plan construction.
|
||||
type RouteStore interface {
|
||||
List(ctx context.Context, filter *model.PaymentRouteFilter) (*model.PaymentRouteList, error)
|
||||
}
|
||||
|
||||
// PlanTemplateStore exposes plan templates for plan construction.
|
||||
type PlanTemplateStore interface {
|
||||
List(ctx context.Context, filter *model.PaymentPlanTemplateFilter) (*model.PaymentPlanTemplateList, error)
|
||||
}
|
||||
|
||||
// GatewayRegistry exposes gateway instances for capability-based selection.
|
||||
type GatewayRegistry interface {
|
||||
List(ctx context.Context) ([]*model.GatewayInstanceDescriptor, error)
|
||||
}
|
||||
|
||||
// Builder constructs ordered payment plans from intents, quotes, and routing policy.
|
||||
type Builder interface {
|
||||
Build(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote, routes RouteStore, templates PlanTemplateStore, gateways GatewayRegistry) (*model.PaymentPlan, error)
|
||||
}
|
||||
|
||||
type SendDirection = sendDirection
|
||||
|
||||
const (
|
||||
SendDirectionAny SendDirection = sendDirectionAny
|
||||
SendDirectionOut SendDirection = sendDirectionOut
|
||||
SendDirectionIn SendDirection = sendDirectionIn
|
||||
)
|
||||
|
||||
func NewDefaultBuilder(logger mlogger.Logger) Builder {
|
||||
return newDefaultBuilder(logger)
|
||||
}
|
||||
|
||||
func RailFromEndpoint(endpoint model.PaymentEndpoint, attrs map[string]string, isSource bool) (model.Rail, string, error) {
|
||||
return railFromEndpoint(endpoint, attrs, isSource)
|
||||
}
|
||||
|
||||
func ResolveRouteNetwork(attrs map[string]string, sourceNetwork, destNetwork string) (string, error) {
|
||||
return resolveRouteNetwork(attrs, sourceNetwork, destNetwork)
|
||||
}
|
||||
|
||||
func SelectTemplate(ctx context.Context, logger mlogger.Logger, templates PlanTemplateStore, sourceRail, destRail model.Rail, network string) (*model.PaymentPlanTemplate, error) {
|
||||
return selectPlanTemplate(ctx, logger, templates, sourceRail, destRail, network)
|
||||
}
|
||||
|
||||
func SendDirectionForRail(rail model.Rail) SendDirection {
|
||||
return sendDirectionForRail(rail)
|
||||
}
|
||||
|
||||
func IsGatewayEligible(gw *model.GatewayInstanceDescriptor, rail model.Rail, network, currency string, action model.RailOperation, dir SendDirection, amount decimal.Decimal) error {
|
||||
return isGatewayEligible(gw, rail, network, currency, action, sendDirection(dir), amount)
|
||||
}
|
||||
|
||||
func ParseRailValue(value string) model.Rail {
|
||||
return parseRailValue(value)
|
||||
}
|
||||
|
||||
func NetworkFromEndpoint(endpoint model.PaymentEndpoint) string {
|
||||
return networkFromEndpoint(endpoint)
|
||||
}
|
||||
365
api/payments/quotation/internal/service/plan/helpers.go
Normal file
365
api/payments/quotation/internal/service/plan/helpers.go
Normal file
@@ -0,0 +1,365 @@
|
||||
package plan
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
"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"
|
||||
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
|
||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||
)
|
||||
|
||||
type moneyGetter interface {
|
||||
GetAmount() string
|
||||
GetCurrency() string
|
||||
}
|
||||
|
||||
func cloneMoney(input *paymenttypes.Money) *paymenttypes.Money {
|
||||
if input == nil {
|
||||
return nil
|
||||
}
|
||||
return &paymenttypes.Money{Currency: input.GetCurrency(), Amount: input.GetAmount()}
|
||||
}
|
||||
|
||||
func cloneStringList(values []string) []string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
clean := strings.TrimSpace(value)
|
||||
if clean == "" {
|
||||
continue
|
||||
}
|
||||
result = append(result, clean)
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func cloneMetadata(input map[string]string) map[string]string {
|
||||
if len(input) == 0 {
|
||||
return nil
|
||||
}
|
||||
clone := make(map[string]string, len(input))
|
||||
for k, v := range input {
|
||||
clone[k] = strings.TrimSpace(v)
|
||||
}
|
||||
return clone
|
||||
}
|
||||
|
||||
func attributeLookup(attrs map[string]string, keys ...string) string {
|
||||
if len(attrs) == 0 || len(keys) == 0 {
|
||||
return ""
|
||||
}
|
||||
for _, key := range keys {
|
||||
needle := strings.ToLower(strings.TrimSpace(key))
|
||||
if needle == "" {
|
||||
continue
|
||||
}
|
||||
for attrKey, value := range attrs {
|
||||
if strings.EqualFold(strings.TrimSpace(attrKey), needle) {
|
||||
if val := strings.TrimSpace(value); val != "" {
|
||||
return val
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func decimalFromMoney(m moneyGetter) (decimal.Decimal, error) {
|
||||
if m == nil {
|
||||
return decimal.Zero, nil
|
||||
}
|
||||
return decimal.NewFromString(m.GetAmount())
|
||||
}
|
||||
|
||||
func moneyFromProto(m *moneyv1.Money) *paymenttypes.Money {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
return &paymenttypes.Money{Currency: m.GetCurrency(), Amount: m.GetAmount()}
|
||||
}
|
||||
|
||||
func protoMoney(m *paymenttypes.Money) *moneyv1.Money {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
return &moneyv1.Money{Currency: m.GetCurrency(), Amount: m.GetAmount()}
|
||||
}
|
||||
|
||||
func cloneProtoMoney(input *moneyv1.Money) *moneyv1.Money {
|
||||
if input == nil {
|
||||
return nil
|
||||
}
|
||||
return &moneyv1.Money{Currency: input.GetCurrency(), Amount: input.GetAmount()}
|
||||
}
|
||||
|
||||
func decimalFromProto(value *moneyv1.Decimal) *paymenttypes.Decimal {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return &paymenttypes.Decimal{Value: value.GetValue()}
|
||||
}
|
||||
|
||||
func decimalToProto(value *paymenttypes.Decimal) *moneyv1.Decimal {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return &moneyv1.Decimal{Value: value.GetValue()}
|
||||
}
|
||||
|
||||
func pairFromProto(pair *fxv1.CurrencyPair) *paymenttypes.CurrencyPair {
|
||||
if pair == nil {
|
||||
return nil
|
||||
}
|
||||
return &paymenttypes.CurrencyPair{Base: pair.GetBase(), Quote: pair.GetQuote()}
|
||||
}
|
||||
|
||||
func pairToProto(pair *paymenttypes.CurrencyPair) *fxv1.CurrencyPair {
|
||||
if pair == nil {
|
||||
return nil
|
||||
}
|
||||
return &fxv1.CurrencyPair{Base: pair.GetBase(), Quote: pair.GetQuote()}
|
||||
}
|
||||
|
||||
func fxSideFromProto(side fxv1.Side) paymenttypes.FXSide {
|
||||
switch side {
|
||||
case fxv1.Side_BUY_BASE_SELL_QUOTE:
|
||||
return paymenttypes.FXSideBuyBaseSellQuote
|
||||
case fxv1.Side_SELL_BASE_BUY_QUOTE:
|
||||
return paymenttypes.FXSideSellBaseBuyQuote
|
||||
default:
|
||||
return paymenttypes.FXSideUnspecified
|
||||
}
|
||||
}
|
||||
|
||||
func fxSideToProto(side paymenttypes.FXSide) fxv1.Side {
|
||||
switch side {
|
||||
case paymenttypes.FXSideBuyBaseSellQuote:
|
||||
return fxv1.Side_BUY_BASE_SELL_QUOTE
|
||||
case paymenttypes.FXSideSellBaseBuyQuote:
|
||||
return fxv1.Side_SELL_BASE_BUY_QUOTE
|
||||
default:
|
||||
return fxv1.Side_SIDE_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
func fxQuoteFromProto(quote *oraclev1.Quote) *paymenttypes.FXQuote {
|
||||
if quote == nil {
|
||||
return nil
|
||||
}
|
||||
return &paymenttypes.FXQuote{
|
||||
QuoteRef: strings.TrimSpace(quote.GetQuoteRef()),
|
||||
Pair: pairFromProto(quote.GetPair()),
|
||||
Side: fxSideFromProto(quote.GetSide()),
|
||||
Price: decimalFromProto(quote.GetPrice()),
|
||||
BaseAmount: moneyFromProto(quote.GetBaseAmount()),
|
||||
QuoteAmount: moneyFromProto(quote.GetQuoteAmount()),
|
||||
ExpiresAtUnixMs: quote.GetExpiresAtUnixMs(),
|
||||
Provider: strings.TrimSpace(quote.GetProvider()),
|
||||
RateRef: strings.TrimSpace(quote.GetRateRef()),
|
||||
Firm: quote.GetFirm(),
|
||||
}
|
||||
}
|
||||
|
||||
func fxQuoteToProto(quote *paymenttypes.FXQuote) *oraclev1.Quote {
|
||||
if quote == nil {
|
||||
return nil
|
||||
}
|
||||
return &oraclev1.Quote{
|
||||
QuoteRef: strings.TrimSpace(quote.QuoteRef),
|
||||
Pair: pairToProto(quote.Pair),
|
||||
Side: fxSideToProto(quote.Side),
|
||||
Price: decimalToProto(quote.Price),
|
||||
BaseAmount: protoMoney(quote.BaseAmount),
|
||||
QuoteAmount: protoMoney(quote.QuoteAmount),
|
||||
ExpiresAtUnixMs: quote.ExpiresAtUnixMs,
|
||||
Provider: strings.TrimSpace(quote.Provider),
|
||||
RateRef: strings.TrimSpace(quote.RateRef),
|
||||
Firm: quote.Firm,
|
||||
}
|
||||
}
|
||||
|
||||
func entrySideFromProto(side accountingv1.EntrySide) paymenttypes.EntrySide {
|
||||
switch side {
|
||||
case accountingv1.EntrySide_ENTRY_SIDE_DEBIT:
|
||||
return paymenttypes.EntrySideDebit
|
||||
case accountingv1.EntrySide_ENTRY_SIDE_CREDIT:
|
||||
return paymenttypes.EntrySideCredit
|
||||
default:
|
||||
return paymenttypes.EntrySideUnspecified
|
||||
}
|
||||
}
|
||||
|
||||
func entrySideToProto(side paymenttypes.EntrySide) accountingv1.EntrySide {
|
||||
switch side {
|
||||
case paymenttypes.EntrySideDebit:
|
||||
return accountingv1.EntrySide_ENTRY_SIDE_DEBIT
|
||||
case paymenttypes.EntrySideCredit:
|
||||
return accountingv1.EntrySide_ENTRY_SIDE_CREDIT
|
||||
default:
|
||||
return accountingv1.EntrySide_ENTRY_SIDE_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
func postingLineTypeFromProto(lineType accountingv1.PostingLineType) paymenttypes.PostingLineType {
|
||||
switch lineType {
|
||||
case accountingv1.PostingLineType_POSTING_LINE_FEE:
|
||||
return paymenttypes.PostingLineTypeFee
|
||||
case accountingv1.PostingLineType_POSTING_LINE_TAX:
|
||||
return paymenttypes.PostingLineTypeTax
|
||||
case accountingv1.PostingLineType_POSTING_LINE_SPREAD:
|
||||
return paymenttypes.PostingLineTypeSpread
|
||||
case accountingv1.PostingLineType_POSTING_LINE_REVERSAL:
|
||||
return paymenttypes.PostingLineTypeReversal
|
||||
default:
|
||||
return paymenttypes.PostingLineTypeUnspecified
|
||||
}
|
||||
}
|
||||
|
||||
func postingLineTypeToProto(lineType paymenttypes.PostingLineType) accountingv1.PostingLineType {
|
||||
switch lineType {
|
||||
case paymenttypes.PostingLineTypeFee:
|
||||
return accountingv1.PostingLineType_POSTING_LINE_FEE
|
||||
case paymenttypes.PostingLineTypeTax:
|
||||
return accountingv1.PostingLineType_POSTING_LINE_TAX
|
||||
case paymenttypes.PostingLineTypeSpread:
|
||||
return accountingv1.PostingLineType_POSTING_LINE_SPREAD
|
||||
case paymenttypes.PostingLineTypeReversal:
|
||||
return accountingv1.PostingLineType_POSTING_LINE_REVERSAL
|
||||
default:
|
||||
return accountingv1.PostingLineType_POSTING_LINE_TYPE_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
func feeLinesFromProto(lines []*feesv1.DerivedPostingLine) []*paymenttypes.FeeLine {
|
||||
if len(lines) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]*paymenttypes.FeeLine, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
if line == nil {
|
||||
continue
|
||||
}
|
||||
result = append(result, &paymenttypes.FeeLine{
|
||||
LedgerAccountRef: strings.TrimSpace(line.GetLedgerAccountRef()),
|
||||
Money: moneyFromProto(line.GetMoney()),
|
||||
LineType: postingLineTypeFromProto(line.GetLineType()),
|
||||
Side: entrySideFromProto(line.GetSide()),
|
||||
Meta: cloneMetadata(line.GetMeta()),
|
||||
})
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func feeLinesToProto(lines []*paymenttypes.FeeLine) []*feesv1.DerivedPostingLine {
|
||||
if len(lines) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]*feesv1.DerivedPostingLine, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
if line == nil {
|
||||
continue
|
||||
}
|
||||
result = append(result, &feesv1.DerivedPostingLine{
|
||||
LedgerAccountRef: strings.TrimSpace(line.LedgerAccountRef),
|
||||
Money: protoMoney(line.Money),
|
||||
LineType: postingLineTypeToProto(line.LineType),
|
||||
Side: entrySideToProto(line.Side),
|
||||
Meta: cloneMetadata(line.Meta),
|
||||
})
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func executionQuote(payment *model.Payment, quote *orchestratorv1.PaymentQuote) *orchestratorv1.PaymentQuote {
|
||||
if quote != nil {
|
||||
return quote
|
||||
}
|
||||
if payment != nil && payment.LastQuote != nil {
|
||||
return &orchestratorv1.PaymentQuote{
|
||||
DebitAmount: protoMoney(payment.LastQuote.DebitAmount),
|
||||
DebitSettlementAmount: protoMoney(payment.LastQuote.DebitSettlementAmount),
|
||||
ExpectedSettlementAmount: protoMoney(payment.LastQuote.ExpectedSettlementAmount),
|
||||
ExpectedFeeTotal: protoMoney(payment.LastQuote.ExpectedFeeTotal),
|
||||
FeeLines: feeLinesToProto(payment.LastQuote.FeeLines),
|
||||
FxQuote: fxQuoteToProto(payment.LastQuote.FXQuote),
|
||||
QuoteRef: strings.TrimSpace(payment.LastQuote.QuoteRef),
|
||||
}
|
||||
}
|
||||
return &orchestratorv1.PaymentQuote{}
|
||||
}
|
||||
|
||||
func makeMoney(currency string, value decimal.Decimal) *moneyv1.Money {
|
||||
return &moneyv1.Money{Currency: currency, Amount: value.String()}
|
||||
}
|
||||
|
||||
func ensureCurrency(m *moneyv1.Money, targetCurrency string, quote *oraclev1.Quote) (*moneyv1.Money, error) {
|
||||
if m == nil || strings.TrimSpace(targetCurrency) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if strings.EqualFold(m.GetCurrency(), targetCurrency) {
|
||||
return cloneProtoMoney(m), nil
|
||||
}
|
||||
return convertWithQuote(m, quote, targetCurrency)
|
||||
}
|
||||
|
||||
func convertWithQuote(m *moneyv1.Money, quote *oraclev1.Quote, targetCurrency string) (*moneyv1.Money, error) {
|
||||
if m == nil || quote == nil || quote.GetPair() == nil || quote.GetPrice() == nil {
|
||||
return nil, nil
|
||||
}
|
||||
base := strings.TrimSpace(quote.GetPair().GetBase())
|
||||
qt := strings.TrimSpace(quote.GetPair().GetQuote())
|
||||
if base == "" || qt == "" || strings.TrimSpace(targetCurrency) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
price, err := decimal.NewFromString(quote.GetPrice().GetValue())
|
||||
if err != nil || price.IsZero() {
|
||||
return nil, err
|
||||
}
|
||||
value, err := decimalFromMoney(m)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch {
|
||||
case strings.EqualFold(m.GetCurrency(), base) && strings.EqualFold(targetCurrency, qt):
|
||||
return makeMoney(targetCurrency, value.Mul(price)), nil
|
||||
case strings.EqualFold(m.GetCurrency(), qt) && strings.EqualFold(targetCurrency, base):
|
||||
return makeMoney(targetCurrency, value.Div(price)), nil
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
func cardPayoutAmount(payment *model.Payment) (*paymenttypes.Money, error) {
|
||||
if payment == nil {
|
||||
return nil, merrors.InvalidArgument("payment is required")
|
||||
}
|
||||
amount := cloneMoney(payment.Intent.Amount)
|
||||
if payment.LastQuote != nil {
|
||||
settlement := payment.LastQuote.ExpectedSettlementAmount
|
||||
if settlement != nil && strings.TrimSpace(settlement.GetAmount()) != "" && strings.TrimSpace(settlement.GetCurrency()) != "" {
|
||||
amount = cloneMoney(settlement)
|
||||
}
|
||||
}
|
||||
if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" {
|
||||
return nil, merrors.InvalidArgument("card payout: amount is required")
|
||||
}
|
||||
return amount, nil
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package orchestrator
|
||||
package plan
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -11,17 +11,17 @@ import (
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type defaultPlanBuilder struct {
|
||||
type defaultBuilder struct {
|
||||
logger mlogger.Logger
|
||||
}
|
||||
|
||||
func newDefaultPlanBuilder(logger mlogger.Logger) *defaultPlanBuilder {
|
||||
return &defaultPlanBuilder{
|
||||
func newDefaultBuilder(logger mlogger.Logger) *defaultBuilder {
|
||||
return &defaultBuilder{
|
||||
logger: logger.Named("plan_builder"),
|
||||
}
|
||||
}
|
||||
|
||||
func (b *defaultPlanBuilder) Build(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote, routes RouteStore, templates PlanTemplateStore, gateways GatewayRegistry) (*model.PaymentPlan, error) {
|
||||
func (b *defaultBuilder) Build(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote, routes RouteStore, templates PlanTemplateStore, gateways GatewayRegistry) (*model.PaymentPlan, error) {
|
||||
if payment == nil {
|
||||
return nil, merrors.InvalidArgument("plan builder: payment is required")
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package orchestrator
|
||||
package plan
|
||||
|
||||
import (
|
||||
"strings"
|
||||
@@ -1,4 +1,4 @@
|
||||
package orchestrator
|
||||
package plan
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,4 +1,4 @@
|
||||
package orchestrator
|
||||
package plan
|
||||
|
||||
import (
|
||||
"time"
|
||||
@@ -1,4 +1,4 @@
|
||||
package orchestrator
|
||||
package plan
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,12 +1,12 @@
|
||||
package orchestrator
|
||||
package plan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/payments/quotation/internal/service/shared"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/model/account_role"
|
||||
"github.com/tech/sendico/pkg/mutil/mzap"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (b *defaultPlanBuilder) buildPlanFromTemplate(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote, template *model.PaymentPlanTemplate, sourceRail, destRail model.Rail, sourceNetwork, destNetwork string, gateways GatewayRegistry) (*model.PaymentPlan, error) {
|
||||
func (b *defaultBuilder) buildPlanFromTemplate(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote, template *model.PaymentPlanTemplate, sourceRail, destRail model.Rail, sourceNetwork, destNetwork string, gateways GatewayRegistry) (*model.PaymentPlan, error) {
|
||||
if template == nil {
|
||||
return nil, merrors.InvalidArgument("plan builder: plan template is required")
|
||||
}
|
||||
@@ -136,8 +136,8 @@ func (b *defaultPlanBuilder) buildPlanFromTemplate(ctx context.Context, payment
|
||||
CommitPolicy: policy,
|
||||
CommitAfter: cloneStringList(tpl.CommitAfter),
|
||||
Amount: cloneMoney(amount),
|
||||
FromRole: cloneAccountRole(tpl.FromRole),
|
||||
ToRole: cloneAccountRole(tpl.ToRole),
|
||||
FromRole: shared.CloneAccountRole(tpl.FromRole),
|
||||
ToRole: shared.CloneAccountRole(tpl.ToRole),
|
||||
}
|
||||
|
||||
needsGateway := action == model.RailOperationSend || action == model.RailOperationFee || action == model.RailOperationObserveConfirm
|
||||
@@ -353,14 +353,6 @@ func observeAmountForRail(rail model.Rail, source, settlement, payout *paymentty
|
||||
return source
|
||||
}
|
||||
|
||||
func cloneAccountRole(role *account_role.AccountRole) *account_role.AccountRole {
|
||||
if role == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := *role
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func netSourceAmount(sourceAmount, feeAmount *paymenttypes.Money, quote *orchestratorv1.PaymentQuote) (*paymenttypes.Money, error) {
|
||||
if sourceAmount == nil {
|
||||
return nil, merrors.InvalidArgument("plan builder: source amount is required")
|
||||
@@ -1,4 +1,4 @@
|
||||
package orchestrator
|
||||
package plan
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -0,0 +1,5 @@
|
||||
package quotation
|
||||
|
||||
const (
|
||||
defaultCardGateway = "monetix"
|
||||
)
|
||||
@@ -1,4 +1,4 @@
|
||||
package orchestrator
|
||||
package quotation
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -8,13 +8,14 @@ import (
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
type paymentEngine interface {
|
||||
EnsureRepository(ctx context.Context) error
|
||||
BuildPaymentQuote(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.PaymentQuote, time.Time, error)
|
||||
ResolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, *orchestratorv1.PaymentIntent, error)
|
||||
ExecutePayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, quote *orchestratorv1.PaymentQuote) error
|
||||
BuildPaymentPlan(ctx context.Context, orgID bson.ObjectID, intent *orchestratorv1.PaymentIntent, idempotencyKey string, quote *orchestratorv1.PaymentQuote) (*model.PaymentPlan, error)
|
||||
ResolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, *orchestratorv1.PaymentIntent, *model.PaymentPlan, error)
|
||||
Repository() storage.Repository
|
||||
}
|
||||
|
||||
@@ -30,12 +31,12 @@ func (e defaultPaymentEngine) BuildPaymentQuote(ctx context.Context, orgRef stri
|
||||
return e.svc.buildPaymentQuote(ctx, orgRef, req)
|
||||
}
|
||||
|
||||
func (e defaultPaymentEngine) ResolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, *orchestratorv1.PaymentIntent, error) {
|
||||
return e.svc.resolvePaymentQuote(ctx, in)
|
||||
func (e defaultPaymentEngine) BuildPaymentPlan(ctx context.Context, orgID bson.ObjectID, intent *orchestratorv1.PaymentIntent, idempotencyKey string, quote *orchestratorv1.PaymentQuote) (*model.PaymentPlan, error) {
|
||||
return e.svc.buildPaymentPlan(ctx, orgID, intent, idempotencyKey, quote)
|
||||
}
|
||||
|
||||
func (e defaultPaymentEngine) ExecutePayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, quote *orchestratorv1.PaymentQuote) error {
|
||||
return e.svc.executePayment(ctx, store, payment, quote)
|
||||
func (e defaultPaymentEngine) ResolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, *orchestratorv1.PaymentIntent, *model.PaymentPlan, error) {
|
||||
return e.svc.resolvePaymentQuote(ctx, in)
|
||||
}
|
||||
|
||||
func (e defaultPaymentEngine) Repository() storage.Repository {
|
||||
@@ -57,41 +58,13 @@ func newPaymentCommandFactory(engine paymentEngine, logger mlogger.Logger) *paym
|
||||
func (f *paymentCommandFactory) QuotePayment() *quotePaymentCommand {
|
||||
return "ePaymentCommand{
|
||||
engine: f.engine,
|
||||
logger: f.logger.Named("quote_payment"),
|
||||
logger: f.logger.Named("quote.payment"),
|
||||
}
|
||||
}
|
||||
|
||||
func (f *paymentCommandFactory) QuotePayments() *quotePaymentsCommand {
|
||||
return "ePaymentsCommand{
|
||||
engine: f.engine,
|
||||
logger: f.logger.Named("quote_payments"),
|
||||
}
|
||||
}
|
||||
|
||||
func (f *paymentCommandFactory) InitiatePayment() *initiatePaymentCommand {
|
||||
return &initiatePaymentCommand{
|
||||
engine: f.engine,
|
||||
logger: f.logger.Named("initiate_payment"),
|
||||
}
|
||||
}
|
||||
|
||||
func (f *paymentCommandFactory) InitiatePayments() *initiatePaymentsCommand {
|
||||
return &initiatePaymentsCommand{
|
||||
engine: f.engine,
|
||||
logger: f.logger.Named("initiate_payments"),
|
||||
}
|
||||
}
|
||||
|
||||
func (f *paymentCommandFactory) CancelPayment() *cancelPaymentCommand {
|
||||
return &cancelPaymentCommand{
|
||||
engine: f.engine,
|
||||
logger: f.logger.Named("cancel_payment"),
|
||||
}
|
||||
}
|
||||
|
||||
func (f *paymentCommandFactory) InitiateConversion() *initiateConversionCommand {
|
||||
return &initiateConversionCommand{
|
||||
engine: f.engine,
|
||||
logger: f.logger.Named("initiate_conversion"),
|
||||
logger: f.logger.Named("quote.payments"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package quotation
|
||||
|
||||
const (
|
||||
providerSettlementMetaPaymentIntentID = "payment_ref"
|
||||
providerSettlementMetaOutgoingLeg = "outgoing_leg"
|
||||
)
|
||||
@@ -1,4 +1,4 @@
|
||||
package orchestrator
|
||||
package quotation
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,8 +1,7 @@
|
||||
package orchestrator
|
||||
package quotation
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
chainasset "github.com/tech/sendico/pkg/chain"
|
||||
@@ -10,12 +9,10 @@ import (
|
||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||
accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1"
|
||||
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||
gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
|
||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
func intentFromProto(src *orchestratorv1.PaymentIntent) model.PaymentIntent {
|
||||
@@ -123,46 +120,6 @@ func quoteSnapshotToModel(src *orchestratorv1.PaymentQuote) *model.PaymentQuoteS
|
||||
}
|
||||
}
|
||||
|
||||
func toProtoPayment(src *model.Payment) *orchestratorv1.Payment {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
payment := &orchestratorv1.Payment{
|
||||
PaymentRef: src.PaymentRef,
|
||||
IdempotencyKey: src.IdempotencyKey,
|
||||
Intent: protoIntentFromModel(src.Intent),
|
||||
State: protoStateFromModel(src.State),
|
||||
FailureCode: protoFailureFromModel(src.FailureCode),
|
||||
FailureReason: src.FailureReason,
|
||||
LastQuote: modelQuoteToProto(src.LastQuote),
|
||||
Execution: protoExecutionFromModel(src.Execution),
|
||||
ExecutionPlan: protoExecutionPlanFromModel(src.ExecutionPlan),
|
||||
PaymentPlan: protoPaymentPlanFromModel(src.PaymentPlan),
|
||||
Metadata: cloneMetadata(src.Metadata),
|
||||
}
|
||||
if src.CardPayout != nil {
|
||||
payment.CardPayout = &orchestratorv1.CardPayout{
|
||||
PayoutRef: src.CardPayout.PayoutRef,
|
||||
ProviderPaymentId: src.CardPayout.ProviderPaymentID,
|
||||
Status: src.CardPayout.Status,
|
||||
FailureReason: src.CardPayout.FailureReason,
|
||||
CardCountry: src.CardPayout.CardCountry,
|
||||
MaskedPan: src.CardPayout.MaskedPan,
|
||||
ProviderCode: src.CardPayout.ProviderCode,
|
||||
GatewayReference: src.CardPayout.GatewayReference,
|
||||
}
|
||||
}
|
||||
if src.CreatedAt.IsZero() {
|
||||
payment.CreatedAt = timestamppb.New(time.Now().UTC())
|
||||
} else {
|
||||
payment.CreatedAt = timestamppb.New(src.CreatedAt.UTC())
|
||||
}
|
||||
if src.UpdatedAt != (time.Time{}) {
|
||||
payment.UpdatedAt = timestamppb.New(src.UpdatedAt.UTC())
|
||||
}
|
||||
return payment
|
||||
}
|
||||
|
||||
func protoIntentFromModel(src model.PaymentIntent) *orchestratorv1.PaymentIntent {
|
||||
intent := &orchestratorv1.PaymentIntent{
|
||||
Ref: src.Ref,
|
||||
@@ -291,99 +248,6 @@ func protoFXIntentFromModel(src *model.FXIntent) *orchestratorv1.FXIntent {
|
||||
}
|
||||
}
|
||||
|
||||
func protoExecutionFromModel(src *model.ExecutionRefs) *orchestratorv1.ExecutionRefs {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
return &orchestratorv1.ExecutionRefs{
|
||||
DebitEntryRef: src.DebitEntryRef,
|
||||
CreditEntryRef: src.CreditEntryRef,
|
||||
FxEntryRef: src.FXEntryRef,
|
||||
ChainTransferRef: src.ChainTransferRef,
|
||||
CardPayoutRef: src.CardPayoutRef,
|
||||
FeeTransferRef: src.FeeTransferRef,
|
||||
}
|
||||
}
|
||||
|
||||
func protoExecutionStepFromModel(src *model.ExecutionStep) *orchestratorv1.ExecutionStep {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
return &orchestratorv1.ExecutionStep{
|
||||
Code: src.Code,
|
||||
Description: src.Description,
|
||||
Amount: protoMoney(src.Amount),
|
||||
NetworkFee: protoMoney(src.NetworkFee),
|
||||
SourceWalletRef: src.SourceWalletRef,
|
||||
DestinationRef: src.DestinationRef,
|
||||
TransferRef: src.TransferRef,
|
||||
Metadata: cloneMetadata(src.Metadata),
|
||||
OperationRef: src.OperationRef,
|
||||
}
|
||||
}
|
||||
|
||||
func protoExecutionPlanFromModel(src *model.ExecutionPlan) *orchestratorv1.ExecutionPlan {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
steps := make([]*orchestratorv1.ExecutionStep, 0, len(src.Steps))
|
||||
for _, step := range src.Steps {
|
||||
if protoStep := protoExecutionStepFromModel(step); protoStep != nil {
|
||||
steps = append(steps, protoStep)
|
||||
}
|
||||
}
|
||||
if len(steps) == 0 {
|
||||
steps = nil
|
||||
}
|
||||
return &orchestratorv1.ExecutionPlan{
|
||||
Steps: steps,
|
||||
TotalNetworkFee: protoMoney(src.TotalNetworkFee),
|
||||
}
|
||||
}
|
||||
|
||||
func protoPaymentStepFromModel(src *model.PaymentStep) *orchestratorv1.PaymentStep {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
return &orchestratorv1.PaymentStep{
|
||||
Rail: protoRailFromModel(src.Rail),
|
||||
GatewayId: strings.TrimSpace(src.GatewayID),
|
||||
Action: protoRailOperationFromModel(src.Action),
|
||||
Amount: protoMoney(src.Amount),
|
||||
StepId: strings.TrimSpace(src.StepID),
|
||||
InstanceId: strings.TrimSpace(src.InstanceID),
|
||||
DependsOn: cloneStringList(src.DependsOn),
|
||||
CommitPolicy: strings.TrimSpace(string(src.CommitPolicy)),
|
||||
CommitAfter: cloneStringList(src.CommitAfter),
|
||||
}
|
||||
}
|
||||
|
||||
func protoPaymentPlanFromModel(src *model.PaymentPlan) *orchestratorv1.PaymentPlan {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
steps := make([]*orchestratorv1.PaymentStep, 0, len(src.Steps))
|
||||
for _, step := range src.Steps {
|
||||
if protoStep := protoPaymentStepFromModel(step); protoStep != nil {
|
||||
steps = append(steps, protoStep)
|
||||
}
|
||||
}
|
||||
if len(steps) == 0 {
|
||||
steps = nil
|
||||
}
|
||||
plan := &orchestratorv1.PaymentPlan{
|
||||
Id: strings.TrimSpace(src.ID),
|
||||
Steps: steps,
|
||||
IdempotencyKey: strings.TrimSpace(src.IdempotencyKey),
|
||||
FxQuote: fxQuoteToProto(src.FXQuote),
|
||||
Fees: feeLinesToProto(src.Fees),
|
||||
}
|
||||
if !src.CreatedAt.IsZero() {
|
||||
plan.CreatedAt = timestamppb.New(src.CreatedAt.UTC())
|
||||
}
|
||||
return plan
|
||||
}
|
||||
|
||||
func modelQuoteToProto(src *model.PaymentQuoteSnapshot) *orchestratorv1.PaymentQuote {
|
||||
if src == nil {
|
||||
return nil
|
||||
@@ -401,28 +265,6 @@ func modelQuoteToProto(src *model.PaymentQuoteSnapshot) *orchestratorv1.PaymentQ
|
||||
}
|
||||
}
|
||||
|
||||
func filterFromProto(req *orchestratorv1.ListPaymentsRequest) *model.PaymentFilter {
|
||||
if req == nil {
|
||||
return &model.PaymentFilter{}
|
||||
}
|
||||
filter := &model.PaymentFilter{
|
||||
SourceRef: strings.TrimSpace(req.GetSourceRef()),
|
||||
DestinationRef: strings.TrimSpace(req.GetDestinationRef()),
|
||||
OrganizationRef: strings.TrimSpace(req.GetOrganizationRef()),
|
||||
}
|
||||
if req.GetPage() != nil {
|
||||
filter.Cursor = strings.TrimSpace(req.GetPage().GetCursor())
|
||||
filter.Limit = req.GetPage().GetLimit()
|
||||
}
|
||||
if len(req.GetFilterStates()) > 0 {
|
||||
filter.States = make([]model.PaymentState, 0, len(req.GetFilterStates()))
|
||||
for _, st := range req.GetFilterStates() {
|
||||
filter.States = append(filter.States, modelStateFromProto(st))
|
||||
}
|
||||
}
|
||||
return filter
|
||||
}
|
||||
|
||||
func protoKindFromModel(kind model.PaymentKind) orchestratorv1.PaymentKind {
|
||||
switch kind {
|
||||
case model.PaymentKindPayout:
|
||||
@@ -449,109 +291,6 @@ func modelKindFromProto(kind orchestratorv1.PaymentKind) model.PaymentKind {
|
||||
}
|
||||
}
|
||||
|
||||
func protoRailFromModel(rail model.Rail) gatewayv1.Rail {
|
||||
switch strings.ToUpper(strings.TrimSpace(string(rail))) {
|
||||
case string(model.RailCrypto):
|
||||
return gatewayv1.Rail_RAIL_CRYPTO
|
||||
case string(model.RailProviderSettlement):
|
||||
return gatewayv1.Rail_RAIL_PROVIDER_SETTLEMENT
|
||||
case string(model.RailLedger):
|
||||
return gatewayv1.Rail_RAIL_LEDGER
|
||||
case string(model.RailCardPayout):
|
||||
return gatewayv1.Rail_RAIL_CARD_PAYOUT
|
||||
case string(model.RailFiatOnRamp):
|
||||
return gatewayv1.Rail_RAIL_FIAT_ONRAMP
|
||||
default:
|
||||
return gatewayv1.Rail_RAIL_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
func protoRailOperationFromModel(action model.RailOperation) gatewayv1.RailOperation {
|
||||
switch strings.ToUpper(strings.TrimSpace(string(action))) {
|
||||
case string(model.RailOperationDebit):
|
||||
return gatewayv1.RailOperation_RAIL_OPERATION_DEBIT
|
||||
case string(model.RailOperationCredit):
|
||||
return gatewayv1.RailOperation_RAIL_OPERATION_CREDIT
|
||||
case string(model.RailOperationExternalDebit):
|
||||
return gatewayv1.RailOperation_RAIL_OPERATION_DEBIT
|
||||
case string(model.RailOperationExternalCredit):
|
||||
return gatewayv1.RailOperation_RAIL_OPERATION_CREDIT
|
||||
case string(model.RailOperationMove):
|
||||
return gatewayv1.RailOperation_RAIL_OPERATION_MOVE
|
||||
case string(model.RailOperationSend):
|
||||
return gatewayv1.RailOperation_RAIL_OPERATION_SEND
|
||||
case string(model.RailOperationFee):
|
||||
return gatewayv1.RailOperation_RAIL_OPERATION_FEE
|
||||
case string(model.RailOperationObserveConfirm):
|
||||
return gatewayv1.RailOperation_RAIL_OPERATION_OBSERVE_CONFIRM
|
||||
case string(model.RailOperationFXConvert):
|
||||
return gatewayv1.RailOperation_RAIL_OPERATION_FX_CONVERT
|
||||
case string(model.RailOperationBlock):
|
||||
return gatewayv1.RailOperation_RAIL_OPERATION_BLOCK
|
||||
case string(model.RailOperationRelease):
|
||||
return gatewayv1.RailOperation_RAIL_OPERATION_RELEASE
|
||||
default:
|
||||
return gatewayv1.RailOperation_RAIL_OPERATION_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
func protoStateFromModel(state model.PaymentState) orchestratorv1.PaymentState {
|
||||
switch state {
|
||||
case model.PaymentStateAccepted:
|
||||
return orchestratorv1.PaymentState_PAYMENT_STATE_ACCEPTED
|
||||
case model.PaymentStateFundsReserved:
|
||||
return orchestratorv1.PaymentState_PAYMENT_STATE_FUNDS_RESERVED
|
||||
case model.PaymentStateSubmitted:
|
||||
return orchestratorv1.PaymentState_PAYMENT_STATE_SUBMITTED
|
||||
case model.PaymentStateSettled:
|
||||
return orchestratorv1.PaymentState_PAYMENT_STATE_SETTLED
|
||||
case model.PaymentStateFailed:
|
||||
return orchestratorv1.PaymentState_PAYMENT_STATE_FAILED
|
||||
case model.PaymentStateCancelled:
|
||||
return orchestratorv1.PaymentState_PAYMENT_STATE_CANCELLED
|
||||
default:
|
||||
return orchestratorv1.PaymentState_PAYMENT_STATE_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
func modelStateFromProto(state orchestratorv1.PaymentState) model.PaymentState {
|
||||
switch state {
|
||||
case orchestratorv1.PaymentState_PAYMENT_STATE_ACCEPTED:
|
||||
return model.PaymentStateAccepted
|
||||
case orchestratorv1.PaymentState_PAYMENT_STATE_FUNDS_RESERVED:
|
||||
return model.PaymentStateFundsReserved
|
||||
case orchestratorv1.PaymentState_PAYMENT_STATE_SUBMITTED:
|
||||
return model.PaymentStateSubmitted
|
||||
case orchestratorv1.PaymentState_PAYMENT_STATE_SETTLED:
|
||||
return model.PaymentStateSettled
|
||||
case orchestratorv1.PaymentState_PAYMENT_STATE_FAILED:
|
||||
return model.PaymentStateFailed
|
||||
case orchestratorv1.PaymentState_PAYMENT_STATE_CANCELLED:
|
||||
return model.PaymentStateCancelled
|
||||
default:
|
||||
return model.PaymentStateUnspecified
|
||||
}
|
||||
}
|
||||
|
||||
func protoFailureFromModel(code model.PaymentFailureCode) orchestratorv1.PaymentFailureCode {
|
||||
switch code {
|
||||
case model.PaymentFailureCodeBalance:
|
||||
return orchestratorv1.PaymentFailureCode_FAILURE_BALANCE
|
||||
case model.PaymentFailureCodeLedger:
|
||||
return orchestratorv1.PaymentFailureCode_FAILURE_LEDGER
|
||||
case model.PaymentFailureCodeFX:
|
||||
return orchestratorv1.PaymentFailureCode_FAILURE_FX
|
||||
case model.PaymentFailureCodeChain:
|
||||
return orchestratorv1.PaymentFailureCode_FAILURE_CHAIN
|
||||
case model.PaymentFailureCodeFees:
|
||||
return orchestratorv1.PaymentFailureCode_FAILURE_FEES
|
||||
case model.PaymentFailureCodePolicy:
|
||||
return orchestratorv1.PaymentFailureCode_FAILURE_POLICY
|
||||
default:
|
||||
return orchestratorv1.PaymentFailureCode_FAILURE_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
func settlementModeFromProto(mode orchestratorv1.SettlementMode) model.SettlementMode {
|
||||
switch mode {
|
||||
case orchestratorv1.SettlementMode_SETTLEMENT_FIX_SOURCE:
|
||||
@@ -1,4 +1,4 @@
|
||||
package orchestrator
|
||||
package quotation
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -0,0 +1,12 @@
|
||||
package quotation
|
||||
|
||||
func (s *Service) Shutdown() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
for _, consumer := range s.gatewayConsumers {
|
||||
if consumer != nil {
|
||||
consumer.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package orchestrator
|
||||
package quotation
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,4 +1,4 @@
|
||||
package orchestrator
|
||||
package quotation
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
chainclient "github.com/tech/sendico/gateway/chain/client"
|
||||
"github.com/tech/sendico/payments/quotation/internal/service/plan"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
@@ -55,7 +56,7 @@ func (s *Service) resolveChainGatewayClient(ctx context.Context, network string,
|
||||
return nil, nil, merrors.NoData("chain gateway unavailable")
|
||||
}
|
||||
|
||||
func selectGatewayForActions(ctx context.Context, registry GatewayRegistry, rail model.Rail, network string, amount *paymenttypes.Money, actions []model.RailOperation, instanceID string, dir sendDirection) (*model.GatewayInstanceDescriptor, error) {
|
||||
func selectGatewayForActions(ctx context.Context, registry GatewayRegistry, rail model.Rail, network string, amount *paymenttypes.Money, actions []model.RailOperation, instanceID string, dir plan.SendDirection) (*model.GatewayInstanceDescriptor, error) {
|
||||
if registry == nil {
|
||||
return nil, merrors.NoData("gateway registry unavailable")
|
||||
}
|
||||
@@ -0,0 +1,602 @@
|
||||
package quotation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/payments/storage"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
"github.com/tech/sendico/pkg/mutil/mzap"
|
||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
type quotePaymentCommand struct {
|
||||
engine paymentEngine
|
||||
logger mlogger.Logger
|
||||
}
|
||||
|
||||
var (
|
||||
errIdempotencyRequired = errors.New("idempotency key is required")
|
||||
errPreviewWithIdempotency = errors.New("preview requests must not use idempotency key")
|
||||
errIdempotencyParamMismatch = errors.New("idempotency key reuse with different parameters")
|
||||
)
|
||||
|
||||
type quoteCtx struct {
|
||||
orgID string
|
||||
orgRef bson.ObjectID
|
||||
intent *orchestratorv1.PaymentIntent
|
||||
previewOnly bool
|
||||
idempotencyKey string
|
||||
hash string
|
||||
}
|
||||
|
||||
func (h *quotePaymentCommand) Execute(
|
||||
ctx context.Context,
|
||||
req *orchestratorv1.QuotePaymentRequest,
|
||||
) gsresponse.Responder[orchestratorv1.QuotePaymentResponse] {
|
||||
|
||||
if err := h.engine.EnsureRepository(ctx); err != nil {
|
||||
return gsresponse.Unavailable[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
if req == nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
|
||||
}
|
||||
|
||||
qc, err := h.prepareQuoteCtx(req)
|
||||
if err != nil {
|
||||
return h.mapQuoteErr(err)
|
||||
}
|
||||
|
||||
quotesStore, err := ensureQuotesStore(h.engine.Repository())
|
||||
if err != nil {
|
||||
return gsresponse.Unavailable[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
|
||||
quoteProto, err := h.quotePayment(ctx, quotesStore, qc, req)
|
||||
if err != nil {
|
||||
return h.mapQuoteErr(err)
|
||||
}
|
||||
|
||||
return gsresponse.Success(&orchestratorv1.QuotePaymentResponse{
|
||||
IdempotencyKey: req.GetIdempotencyKey(),
|
||||
Quote: quoteProto,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *quotePaymentCommand) prepareQuoteCtx(req *orchestratorv1.QuotePaymentRequest) (*quoteCtx, error) {
|
||||
orgRef, orgID, err := validateMetaAndOrgRef(req.GetMeta())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := requireNonNilIntent(req.GetIntent()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
intent := req.GetIntent()
|
||||
preview := req.GetPreviewOnly()
|
||||
idem := strings.TrimSpace(req.GetIdempotencyKey())
|
||||
|
||||
if preview && idem != "" {
|
||||
return nil, errPreviewWithIdempotency
|
||||
}
|
||||
if !preview && idem == "" {
|
||||
return nil, errIdempotencyRequired
|
||||
}
|
||||
|
||||
return "eCtx{
|
||||
orgID: orgRef,
|
||||
orgRef: orgID,
|
||||
intent: intent,
|
||||
previewOnly: preview,
|
||||
idempotencyKey: idem,
|
||||
hash: hashQuoteRequest(req),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *quotePaymentCommand) quotePayment(
|
||||
ctx context.Context,
|
||||
quotesStore storage.QuotesStore,
|
||||
qc *quoteCtx,
|
||||
req *orchestratorv1.QuotePaymentRequest,
|
||||
) (*orchestratorv1.PaymentQuote, error) {
|
||||
|
||||
if qc.previewOnly {
|
||||
quote, _, err := h.engine.BuildPaymentQuote(ctx, qc.orgID, req)
|
||||
if err != nil {
|
||||
h.logger.Warn("Failed to build preview payment quote", zap.Error(err), zap.String("org_ref", qc.orgID))
|
||||
return nil, err
|
||||
}
|
||||
quote.QuoteRef = bson.NewObjectID().Hex()
|
||||
return quote, nil
|
||||
}
|
||||
|
||||
existing, err := quotesStore.GetByIdempotencyKey(ctx, qc.orgRef, qc.idempotencyKey)
|
||||
if err != nil && !errors.Is(err, storage.ErrQuoteNotFound) && !errors.Is(err, merrors.ErrNoData) {
|
||||
h.logger.Warn("Failed to lookup quote by idempotency key", zap.Error(err),
|
||||
mzap.ObjRef("org_ref", qc.orgRef), zap.String("idempotency_key", qc.idempotencyKey),
|
||||
)
|
||||
return nil, err
|
||||
}
|
||||
if existing != nil {
|
||||
if existing.Hash != qc.hash {
|
||||
return nil, errIdempotencyParamMismatch
|
||||
}
|
||||
h.logger.Debug(
|
||||
"Idempotent quote reused",
|
||||
mzap.ObjRef("org_ref", qc.orgRef),
|
||||
zap.String("idempotency_key", qc.idempotencyKey),
|
||||
zap.String("quote_ref", existing.QuoteRef),
|
||||
)
|
||||
return modelQuoteToProto(existing.Quote), nil
|
||||
}
|
||||
|
||||
quote, expiresAt, err := h.engine.BuildPaymentQuote(ctx, qc.orgID, req)
|
||||
if err != nil {
|
||||
h.logger.Warn(
|
||||
"Failed to build payment quote",
|
||||
zap.Error(err),
|
||||
mzap.ObjRef("org_ref", qc.orgRef),
|
||||
zap.String("idempotency_key", qc.idempotencyKey),
|
||||
)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
quoteRef := bson.NewObjectID().Hex()
|
||||
quote.QuoteRef = quoteRef
|
||||
|
||||
plan, err := h.engine.BuildPaymentPlan(ctx, qc.orgRef, qc.intent, qc.idempotencyKey, quote)
|
||||
if err != nil {
|
||||
h.logger.Warn(
|
||||
"Failed to build payment plan",
|
||||
zap.Error(err),
|
||||
mzap.ObjRef("org_ref", qc.orgRef),
|
||||
zap.String("idempotency_key", qc.idempotencyKey),
|
||||
)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
record := &model.PaymentQuoteRecord{
|
||||
QuoteRef: quoteRef,
|
||||
IdempotencyKey: qc.idempotencyKey,
|
||||
Hash: qc.hash,
|
||||
Intent: intentFromProto(qc.intent),
|
||||
Quote: quoteSnapshotToModel(quote),
|
||||
Plan: cloneStoredPaymentPlan(plan),
|
||||
ExpiresAt: expiresAt,
|
||||
}
|
||||
record.SetID(bson.NewObjectID())
|
||||
record.SetOrganizationRef(qc.orgRef)
|
||||
|
||||
if err := quotesStore.Create(ctx, record); err != nil {
|
||||
if errors.Is(err, storage.ErrDuplicateQuote) {
|
||||
existing, getErr := quotesStore.GetByIdempotencyKey(ctx, qc.orgRef, qc.idempotencyKey)
|
||||
if getErr == nil && existing != nil {
|
||||
if existing.Hash != qc.hash {
|
||||
return nil, errIdempotencyParamMismatch
|
||||
}
|
||||
return modelQuoteToProto(existing.Quote), nil
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
h.logger.Info(
|
||||
"Stored payment quote",
|
||||
zap.String("quote_ref", quoteRef),
|
||||
mzap.ObjRef("org_ref", qc.orgRef),
|
||||
zap.String("idempotency_key", qc.idempotencyKey),
|
||||
zap.String("kind", qc.intent.GetKind().String()),
|
||||
)
|
||||
|
||||
return quote, nil
|
||||
}
|
||||
|
||||
func (h *quotePaymentCommand) mapQuoteErr(err error) gsresponse.Responder[orchestratorv1.QuotePaymentResponse] {
|
||||
if errors.Is(err, errIdempotencyRequired) ||
|
||||
errors.Is(err, errPreviewWithIdempotency) ||
|
||||
errors.Is(err, errIdempotencyParamMismatch) {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
return gsresponse.Auto[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
|
||||
// TODO: temprorarary hashing function, replace with a proper solution later
|
||||
func hashQuoteRequest(req *orchestratorv1.QuotePaymentRequest) string {
|
||||
cloned := proto.Clone(req).(*orchestratorv1.QuotePaymentRequest)
|
||||
cloned.Meta = nil
|
||||
cloned.IdempotencyKey = ""
|
||||
cloned.PreviewOnly = false
|
||||
|
||||
b, err := proto.MarshalOptions{Deterministic: true}.Marshal(cloned)
|
||||
if err != nil {
|
||||
sum := sha256.Sum256([]byte("marshal_error"))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
sum := sha256.Sum256(b)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
type quotePaymentsCommand struct {
|
||||
engine paymentEngine
|
||||
logger mlogger.Logger
|
||||
}
|
||||
|
||||
var (
|
||||
errBatchIdempotencyRequired = errors.New("idempotency key is required")
|
||||
errBatchPreviewWithIdempotency = errors.New("preview requests must not use idempotency key")
|
||||
errBatchIdempotencyParamMismatch = errors.New("idempotency key reuse with different parameters")
|
||||
errBatchIdempotencyShapeMismatch = errors.New("idempotency key already used for a different quote shape")
|
||||
)
|
||||
|
||||
type quotePaymentsCtx struct {
|
||||
orgID string
|
||||
orgRef bson.ObjectID
|
||||
previewOnly bool
|
||||
idempotencyKey string
|
||||
hash string
|
||||
intentCount int
|
||||
}
|
||||
|
||||
func (h *quotePaymentsCommand) Execute(
|
||||
ctx context.Context,
|
||||
req *orchestratorv1.QuotePaymentsRequest,
|
||||
) gsresponse.Responder[orchestratorv1.QuotePaymentsResponse] {
|
||||
|
||||
if err := h.engine.EnsureRepository(ctx); err != nil {
|
||||
return gsresponse.Unavailable[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
if req == nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
|
||||
}
|
||||
|
||||
qc, intents, err := h.prepare(req)
|
||||
if err != nil {
|
||||
return h.mapErr(err)
|
||||
}
|
||||
|
||||
quotesStore, err := ensureQuotesStore(h.engine.Repository())
|
||||
if err != nil {
|
||||
return gsresponse.Unavailable[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
|
||||
if qc.previewOnly {
|
||||
quotes, _, expires, err := h.buildQuotes(ctx, req.GetMeta(), qc.orgRef, qc.idempotencyKey, intents, true)
|
||||
if err != nil {
|
||||
return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
aggregate, expiresAt, err := h.aggregate(quotes, expires)
|
||||
if err != nil {
|
||||
return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
_ = expiresAt
|
||||
return gsresponse.Success(&orchestratorv1.QuotePaymentsResponse{
|
||||
QuoteRef: "",
|
||||
Aggregate: aggregate,
|
||||
Quotes: quotes,
|
||||
})
|
||||
}
|
||||
|
||||
if rec, ok, err := h.tryReuse(ctx, quotesStore, qc); err != nil {
|
||||
return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
} else if ok {
|
||||
return gsresponse.Success(h.responseFromRecord(rec))
|
||||
}
|
||||
|
||||
quotes, plans, expires, err := h.buildQuotes(ctx, req.GetMeta(), qc.orgRef, qc.idempotencyKey, intents, false)
|
||||
if err != nil {
|
||||
return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
|
||||
aggregate, expiresAt, err := h.aggregate(quotes, expires)
|
||||
if err != nil {
|
||||
return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
|
||||
quoteRef := bson.NewObjectID().Hex()
|
||||
for _, q := range quotes {
|
||||
if q != nil {
|
||||
q.QuoteRef = quoteRef
|
||||
}
|
||||
}
|
||||
|
||||
rec, err := h.storeBatch(ctx, quotesStore, qc, quoteRef, intents, quotes, plans, expiresAt)
|
||||
if err != nil {
|
||||
return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
|
||||
if rec != nil {
|
||||
return gsresponse.Success(h.responseFromRecord(rec))
|
||||
}
|
||||
|
||||
h.logger.Info(
|
||||
"Stored payment quotes",
|
||||
h.logFields(qc, quoteRef, expiresAt, len(quotes))...,
|
||||
)
|
||||
|
||||
return gsresponse.Success(&orchestratorv1.QuotePaymentsResponse{
|
||||
IdempotencyKey: req.GetIdempotencyKey(),
|
||||
QuoteRef: quoteRef,
|
||||
Aggregate: aggregate,
|
||||
Quotes: quotes,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *quotePaymentsCommand) prepare(req *orchestratorv1.QuotePaymentsRequest) (*quotePaymentsCtx, []*orchestratorv1.PaymentIntent, error) {
|
||||
orgRefStr, orgID, err := validateMetaAndOrgRef(req.GetMeta())
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
intents := req.GetIntents()
|
||||
if len(intents) == 0 {
|
||||
return nil, nil, merrors.InvalidArgument("intents are required")
|
||||
}
|
||||
for _, intent := range intents {
|
||||
if err := requireNonNilIntent(intent); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
preview := req.GetPreviewOnly()
|
||||
idem := strings.TrimSpace(req.GetIdempotencyKey())
|
||||
|
||||
if preview && idem != "" {
|
||||
return nil, nil, errBatchPreviewWithIdempotency
|
||||
}
|
||||
if !preview && idem == "" {
|
||||
return nil, nil, errBatchIdempotencyRequired
|
||||
}
|
||||
|
||||
hash, err := hashQuotePaymentsIntents(intents)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return "ePaymentsCtx{
|
||||
orgID: orgRefStr,
|
||||
orgRef: orgID,
|
||||
previewOnly: preview,
|
||||
idempotencyKey: idem,
|
||||
hash: hash,
|
||||
intentCount: len(intents),
|
||||
}, intents, nil
|
||||
}
|
||||
|
||||
func (h *quotePaymentsCommand) tryReuse(
|
||||
ctx context.Context,
|
||||
quotesStore storage.QuotesStore,
|
||||
qc *quotePaymentsCtx,
|
||||
) (*model.PaymentQuoteRecord, bool, error) {
|
||||
|
||||
rec, err := quotesStore.GetByIdempotencyKey(ctx, qc.orgRef, qc.idempotencyKey)
|
||||
if err != nil {
|
||||
if errors.Is(err, storage.ErrQuoteNotFound) {
|
||||
return nil, false, nil
|
||||
}
|
||||
h.logger.Warn(
|
||||
"Failed to lookup payment quotes by idempotency key",
|
||||
h.logFields(qc, "", time.Time{}, 0)...,
|
||||
)
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
if len(rec.Quotes) == 0 {
|
||||
return nil, false, errBatchIdempotencyShapeMismatch
|
||||
}
|
||||
if rec.Hash != qc.hash {
|
||||
return nil, false, errBatchIdempotencyParamMismatch
|
||||
}
|
||||
|
||||
h.logger.Debug(
|
||||
"Idempotent payment quotes reused",
|
||||
h.logFields(qc, rec.QuoteRef, rec.ExpiresAt, len(rec.Quotes))...,
|
||||
)
|
||||
|
||||
return rec, true, nil
|
||||
}
|
||||
|
||||
func (h *quotePaymentsCommand) buildQuotes(
|
||||
ctx context.Context,
|
||||
meta *orchestratorv1.RequestMeta,
|
||||
orgRef bson.ObjectID,
|
||||
baseKey string,
|
||||
intents []*orchestratorv1.PaymentIntent,
|
||||
preview bool,
|
||||
) ([]*orchestratorv1.PaymentQuote, []*model.PaymentPlan, []time.Time, error) {
|
||||
|
||||
quotes := make([]*orchestratorv1.PaymentQuote, 0, len(intents))
|
||||
plans := make([]*model.PaymentPlan, 0, len(intents))
|
||||
expires := make([]time.Time, 0, len(intents))
|
||||
|
||||
for i, intent := range intents {
|
||||
perKey := perIntentIdempotencyKey(baseKey, i, len(intents))
|
||||
req := &orchestratorv1.QuotePaymentRequest{
|
||||
Meta: meta,
|
||||
IdempotencyKey: perKey,
|
||||
Intent: intent,
|
||||
PreviewOnly: preview,
|
||||
}
|
||||
q, exp, err := h.engine.BuildPaymentQuote(ctx, meta.GetOrganizationRef(), req)
|
||||
if err != nil {
|
||||
h.logger.Warn(
|
||||
"Failed to build payment quote (batch item)",
|
||||
zap.Int("idx", i),
|
||||
zap.Error(err),
|
||||
)
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
if !preview {
|
||||
plan, err := h.engine.BuildPaymentPlan(ctx, orgRef, intent, perKey, q)
|
||||
if err != nil {
|
||||
h.logger.Warn(
|
||||
"Failed to build payment plan (batch item)",
|
||||
zap.Int("idx", i),
|
||||
zap.Error(err),
|
||||
)
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
plans = append(plans, cloneStoredPaymentPlan(plan))
|
||||
}
|
||||
quotes = append(quotes, q)
|
||||
expires = append(expires, exp)
|
||||
}
|
||||
|
||||
return quotes, plans, expires, nil
|
||||
}
|
||||
|
||||
func (h *quotePaymentsCommand) aggregate(
|
||||
quotes []*orchestratorv1.PaymentQuote,
|
||||
expires []time.Time,
|
||||
) (*orchestratorv1.PaymentQuoteAggregate, time.Time, error) {
|
||||
|
||||
agg, err := aggregatePaymentQuotes(quotes)
|
||||
if err != nil {
|
||||
return nil, time.Time{}, merrors.InternalWrap(err, "quote aggregation failed")
|
||||
}
|
||||
|
||||
expiresAt, ok := minQuoteExpiry(expires)
|
||||
if !ok {
|
||||
return nil, time.Time{}, merrors.Internal("quote expiry missing")
|
||||
}
|
||||
|
||||
return agg, expiresAt, nil
|
||||
}
|
||||
|
||||
func (h *quotePaymentsCommand) storeBatch(
|
||||
ctx context.Context,
|
||||
quotesStore storage.QuotesStore,
|
||||
qc *quotePaymentsCtx,
|
||||
quoteRef string,
|
||||
intents []*orchestratorv1.PaymentIntent,
|
||||
quotes []*orchestratorv1.PaymentQuote,
|
||||
plans []*model.PaymentPlan,
|
||||
expiresAt time.Time,
|
||||
) (*model.PaymentQuoteRecord, error) {
|
||||
|
||||
record := &model.PaymentQuoteRecord{
|
||||
QuoteRef: quoteRef,
|
||||
IdempotencyKey: qc.idempotencyKey,
|
||||
Hash: qc.hash,
|
||||
Intents: intentsFromProto(intents),
|
||||
Quotes: quoteSnapshotsFromProto(quotes),
|
||||
Plans: cloneStoredPaymentPlans(plans),
|
||||
ExpiresAt: expiresAt,
|
||||
}
|
||||
record.SetID(bson.NewObjectID())
|
||||
record.SetOrganizationRef(qc.orgRef)
|
||||
|
||||
if err := quotesStore.Create(ctx, record); err != nil {
|
||||
if errors.Is(err, storage.ErrDuplicateQuote) {
|
||||
rec, ok, reuseErr := h.tryReuse(ctx, quotesStore, qc)
|
||||
if reuseErr != nil {
|
||||
return nil, reuseErr
|
||||
}
|
||||
if ok {
|
||||
return rec, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (h *quotePaymentsCommand) responseFromRecord(rec *model.PaymentQuoteRecord) *orchestratorv1.QuotePaymentsResponse {
|
||||
quotes := modelQuotesToProto(rec.Quotes)
|
||||
for _, q := range quotes {
|
||||
if q != nil {
|
||||
q.QuoteRef = rec.QuoteRef
|
||||
}
|
||||
}
|
||||
aggregate, _ := aggregatePaymentQuotes(quotes)
|
||||
|
||||
return &orchestratorv1.QuotePaymentsResponse{
|
||||
QuoteRef: rec.QuoteRef,
|
||||
Aggregate: aggregate,
|
||||
Quotes: quotes,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *quotePaymentsCommand) logFields(qc *quotePaymentsCtx, quoteRef string, expiresAt time.Time, quoteCount int) []zap.Field {
|
||||
fields := []zap.Field{
|
||||
mzap.ObjRef("org_ref", qc.orgRef),
|
||||
zap.String("org_ref_str", qc.orgID),
|
||||
zap.String("idempotency_key", qc.idempotencyKey),
|
||||
zap.String("hash", qc.hash),
|
||||
zap.Bool("preview_only", qc.previewOnly),
|
||||
zap.Int("intent_count", qc.intentCount),
|
||||
}
|
||||
if quoteRef != "" {
|
||||
fields = append(fields, zap.String("quote_ref", quoteRef))
|
||||
}
|
||||
if !expiresAt.IsZero() {
|
||||
fields = append(fields, zap.Time("expires_at", expiresAt))
|
||||
}
|
||||
if quoteCount > 0 {
|
||||
fields = append(fields, zap.Int("quote_count", quoteCount))
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func (h *quotePaymentsCommand) mapErr(err error) gsresponse.Responder[orchestratorv1.QuotePaymentsResponse] {
|
||||
if errors.Is(err, errBatchIdempotencyRequired) ||
|
||||
errors.Is(err, errBatchPreviewWithIdempotency) ||
|
||||
errors.Is(err, errBatchIdempotencyParamMismatch) ||
|
||||
errors.Is(err, errBatchIdempotencyShapeMismatch) {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
|
||||
func modelQuotesToProto(snaps []*model.PaymentQuoteSnapshot) []*orchestratorv1.PaymentQuote {
|
||||
if len(snaps) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]*orchestratorv1.PaymentQuote, 0, len(snaps))
|
||||
for _, s := range snaps {
|
||||
out = append(out, modelQuoteToProto(s))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func hashQuotePaymentsIntents(intents []*orchestratorv1.PaymentIntent) (string, error) {
|
||||
type item struct {
|
||||
Idx int
|
||||
H [32]byte
|
||||
}
|
||||
items := make([]item, 0, len(intents))
|
||||
|
||||
for i, intent := range intents {
|
||||
b, err := proto.MarshalOptions{Deterministic: true}.Marshal(intent)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
items = append(items, item{Idx: i, H: sha256.Sum256(b)})
|
||||
}
|
||||
|
||||
sort.Slice(items, func(i, j int) bool { return items[i].Idx < items[j].Idx })
|
||||
|
||||
h := sha256.New()
|
||||
h.Write([]byte("quote-payments-fp/v1"))
|
||||
h.Write([]byte{0})
|
||||
for _, it := range items {
|
||||
h.Write(it.H[:])
|
||||
h.Write([]byte{0})
|
||||
}
|
||||
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package orchestrator
|
||||
package quotation
|
||||
|
||||
import (
|
||||
"strings"
|
||||
@@ -6,9 +6,7 @@ import (
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
|
||||
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
|
||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||
"google.golang.org/protobuf/proto"
|
||||
@@ -359,45 +357,6 @@ func setFeeLineWalletRef(lines []*feesv1.DerivedPostingLine, walletRef, walletTy
|
||||
}
|
||||
}
|
||||
|
||||
func ledgerChargesFromFeeLines(lines []*feesv1.DerivedPostingLine) []*ledgerv1.PostingLine {
|
||||
if len(lines) == 0 {
|
||||
return nil
|
||||
}
|
||||
charges := make([]*ledgerv1.PostingLine, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
if line == nil || isWalletTargetFeeLine(line) || strings.TrimSpace(line.GetLedgerAccountRef()) == "" {
|
||||
continue
|
||||
}
|
||||
money := cloneProtoMoney(line.GetMoney())
|
||||
if money == nil {
|
||||
continue
|
||||
}
|
||||
charges = append(charges, &ledgerv1.PostingLine{
|
||||
LedgerAccountRef: strings.TrimSpace(line.GetLedgerAccountRef()),
|
||||
Money: money,
|
||||
LineType: ledgerLineTypeFromAccounting(line.GetLineType()),
|
||||
})
|
||||
}
|
||||
if len(charges) == 0 {
|
||||
return nil
|
||||
}
|
||||
return charges
|
||||
}
|
||||
|
||||
func ledgerLineTypeFromAccounting(lineType accountingv1.PostingLineType) ledgerv1.LineType {
|
||||
switch lineType {
|
||||
case accountingv1.PostingLineType_POSTING_LINE_SPREAD:
|
||||
return ledgerv1.LineType_LINE_SPREAD
|
||||
case accountingv1.PostingLineType_POSTING_LINE_REVERSAL:
|
||||
return ledgerv1.LineType_LINE_REVERSAL
|
||||
case accountingv1.PostingLineType_POSTING_LINE_FEE,
|
||||
accountingv1.PostingLineType_POSTING_LINE_TAX:
|
||||
return ledgerv1.LineType_LINE_FEE
|
||||
default:
|
||||
return ledgerv1.LineType_LINE_MAIN
|
||||
}
|
||||
}
|
||||
|
||||
func quoteExpiry(now time.Time, feeQuote *feesv1.PrecomputeFeesResponse, fxQuote *oraclev1.Quote) time.Time {
|
||||
expiry := time.Time{}
|
||||
if feeQuote != nil && feeQuote.GetExpiresAt() != nil {
|
||||
@@ -430,34 +389,3 @@ func assignLedgerAccounts(lines []*feesv1.DerivedPostingLine, account string) []
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func moneyEquals(a, b moneyGetter) bool {
|
||||
if a == nil || b == nil {
|
||||
return false
|
||||
}
|
||||
if !strings.EqualFold(a.GetCurrency(), b.GetCurrency()) {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(a.GetAmount()) == strings.TrimSpace(b.GetAmount())
|
||||
}
|
||||
|
||||
func conversionAmountFromMetadata(meta map[string]string, fx *orchestratorv1.FXIntent) (*moneyv1.Money, error) {
|
||||
if meta == nil {
|
||||
meta = map[string]string{}
|
||||
}
|
||||
amount := strings.TrimSpace(meta["amount"])
|
||||
if amount == "" {
|
||||
return nil, merrors.InvalidArgument("conversion amount metadata is required")
|
||||
}
|
||||
currency := strings.TrimSpace(meta["currency"])
|
||||
if currency == "" && fx != nil && fx.GetPair() != nil {
|
||||
currency = strings.TrimSpace(fx.GetPair().GetBase())
|
||||
}
|
||||
if currency == "" {
|
||||
return nil, merrors.InvalidArgument("conversion currency metadata is required")
|
||||
}
|
||||
return &moneyv1.Money{
|
||||
Currency: currency,
|
||||
Amount: amount,
|
||||
}, nil
|
||||
}
|
||||
@@ -1,16 +1,14 @@
|
||||
package orchestrator
|
||||
package quotation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||
)
|
||||
|
||||
@@ -107,26 +105,3 @@ func fxIntentForQuote(intent *orchestratorv1.PaymentIntent) *orchestratorv1.FXIn
|
||||
Side: fxv1.Side_SELL_BASE_BUY_QUOTE,
|
||||
}
|
||||
}
|
||||
|
||||
func mapMntxStatusToState(status mntxv1.PayoutStatus) model.PaymentState {
|
||||
switch status {
|
||||
|
||||
case mntxv1.PayoutStatus_PAYOUT_STATUS_CREATED:
|
||||
return model.PaymentStateFundsReserved
|
||||
|
||||
case mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING:
|
||||
return model.PaymentStateSubmitted
|
||||
|
||||
case mntxv1.PayoutStatus_PAYOUT_STATUS_SUCCESS:
|
||||
return model.PaymentStateSettled
|
||||
|
||||
case mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED:
|
||||
return model.PaymentStateFailed
|
||||
|
||||
case mntxv1.PayoutStatus_PAYOUT_STATUS_CANCELLED:
|
||||
return model.PaymentStateCancelled
|
||||
|
||||
default:
|
||||
return model.PaymentStateUnspecified
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package orchestrator
|
||||
package quotation
|
||||
|
||||
import (
|
||||
"errors"
|
||||
@@ -1,4 +1,4 @@
|
||||
package orchestrator
|
||||
package quotation
|
||||
|
||||
import paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package orchestrator
|
||||
package quotation
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -6,10 +6,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
||||
chainclient "github.com/tech/sendico/gateway/chain/client"
|
||||
mntxclient "github.com/tech/sendico/gateway/mntx/client"
|
||||
ledgerclient "github.com/tech/sendico/ledger/client"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
clockpkg "github.com/tech/sendico/pkg/clock"
|
||||
@@ -18,7 +16,6 @@ import (
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/payments/rail"
|
||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Option configures service dependencies.
|
||||
@@ -67,139 +64,6 @@ type railGatewayDependency struct {
|
||||
logger mlogger.Logger
|
||||
}
|
||||
|
||||
func (g railGatewayDependency) available() bool {
|
||||
return len(g.byID) > 0 || len(g.byRail) > 0 || (g.registry != nil && (g.chainResolver != nil || g.providerResolver != nil))
|
||||
}
|
||||
|
||||
func (g railGatewayDependency) resolve(ctx context.Context, step *model.PaymentStep) (rail.RailGateway, error) {
|
||||
if step == nil {
|
||||
return nil, merrors.InvalidArgument("rail gateway: step is required")
|
||||
}
|
||||
if id := strings.TrimSpace(step.GatewayID); id != "" {
|
||||
if gw, ok := g.byID[id]; ok {
|
||||
return gw, nil
|
||||
}
|
||||
return g.resolveDynamic(ctx, step)
|
||||
}
|
||||
if len(g.byRail) == 0 {
|
||||
return g.resolveDynamic(ctx, step)
|
||||
}
|
||||
list := g.byRail[step.Rail]
|
||||
if len(list) == 0 {
|
||||
return g.resolveDynamic(ctx, step)
|
||||
}
|
||||
return list[0], nil
|
||||
}
|
||||
|
||||
func (g railGatewayDependency) resolveDynamic(ctx context.Context, step *model.PaymentStep) (rail.RailGateway, error) {
|
||||
if g.registry == nil {
|
||||
return nil, merrors.InvalidArgument("rail gateway: registry is required")
|
||||
}
|
||||
if g.chainResolver == nil && g.providerResolver == nil {
|
||||
return nil, merrors.InvalidArgument("rail gateway: gateway resolver is required")
|
||||
}
|
||||
items, err := g.registry.List(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return nil, merrors.InvalidArgument("rail gateway: no gateway instances available")
|
||||
}
|
||||
|
||||
currency := ""
|
||||
amount := decimal.Zero
|
||||
if step.Amount != nil && strings.TrimSpace(step.Amount.GetAmount()) != "" {
|
||||
value, err := decimalFromMoney(step.Amount)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
amount = value
|
||||
currency = strings.ToUpper(strings.TrimSpace(step.Amount.GetCurrency()))
|
||||
}
|
||||
|
||||
candidates := make([]*model.GatewayInstanceDescriptor, 0)
|
||||
var lastErr error
|
||||
for _, entry := range items {
|
||||
if entry == nil || !entry.IsEnabled {
|
||||
continue
|
||||
}
|
||||
if entry.Rail != step.Rail {
|
||||
continue
|
||||
}
|
||||
if step.GatewayID != "" && entry.ID != step.GatewayID {
|
||||
continue
|
||||
}
|
||||
if step.InstanceID != "" && !strings.EqualFold(strings.TrimSpace(entry.InstanceID), strings.TrimSpace(step.InstanceID)) {
|
||||
continue
|
||||
}
|
||||
if step.Action != model.RailOperationUnspecified {
|
||||
if err := isGatewayEligible(entry, step.Rail, "", currency, step.Action, sendDirectionForRail(step.Rail), amount); err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
}
|
||||
candidates = append(candidates, entry)
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
if lastErr != nil {
|
||||
return nil, merrors.InvalidArgument("rail gateway: missing gateway for rail: " + lastErr.Error())
|
||||
}
|
||||
return nil, merrors.InvalidArgument("rail gateway: missing gateway for rail")
|
||||
}
|
||||
sort.Slice(candidates, func(i, j int) bool {
|
||||
return candidates[i].ID < candidates[j].ID
|
||||
})
|
||||
entry := candidates[0]
|
||||
invokeURI := strings.TrimSpace(entry.InvokeURI)
|
||||
if invokeURI == "" {
|
||||
return nil, merrors.InvalidArgument("rail gateway: invoke uri is required")
|
||||
}
|
||||
|
||||
cfg := chainclient.RailGatewayConfig{
|
||||
Rail: string(entry.Rail),
|
||||
Network: entry.Network,
|
||||
Capabilities: rail.RailCapabilities{
|
||||
CanPayIn: entry.Capabilities.CanPayIn,
|
||||
CanPayOut: entry.Capabilities.CanPayOut,
|
||||
CanReadBalance: entry.Capabilities.CanReadBalance,
|
||||
CanSendFee: entry.Capabilities.CanSendFee,
|
||||
RequiresObserveConfirm: entry.Capabilities.RequiresObserveConfirm,
|
||||
CanBlock: entry.Capabilities.CanBlock,
|
||||
CanRelease: entry.Capabilities.CanRelease,
|
||||
},
|
||||
}
|
||||
|
||||
g.logger.Info("Rail gateway resolved",
|
||||
zap.String("step_id", strings.TrimSpace(step.StepID)),
|
||||
zap.String("action", string(step.Action)),
|
||||
zap.String("gateway_id", entry.ID),
|
||||
zap.String("instance_id", entry.InstanceID),
|
||||
zap.String("rail", string(entry.Rail)),
|
||||
zap.String("network", entry.Network),
|
||||
zap.String("invoke_uri", invokeURI))
|
||||
|
||||
switch entry.Rail {
|
||||
case model.RailProviderSettlement:
|
||||
if g.providerResolver == nil {
|
||||
return nil, merrors.InvalidArgument("rail gateway: provider settlement resolver required")
|
||||
}
|
||||
client, err := g.providerResolver.Resolve(ctx, invokeURI)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewProviderSettlementGateway(client, cfg), nil
|
||||
default:
|
||||
if g.chainResolver == nil {
|
||||
return nil, merrors.InvalidArgument("rail gateway: chain gateway resolver required")
|
||||
}
|
||||
client, err := g.chainResolver.Resolve(ctx, invokeURI)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return chainclient.NewRailGateway(client, cfg), nil
|
||||
}
|
||||
}
|
||||
|
||||
type oracleDependency struct {
|
||||
client oracleclient.Client
|
||||
}
|
||||
@@ -214,20 +78,6 @@ func (o oracleDependency) available() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
type mntxDependency struct {
|
||||
client mntxclient.Client
|
||||
}
|
||||
|
||||
func (m mntxDependency) available() bool {
|
||||
if m.client == nil {
|
||||
return false
|
||||
}
|
||||
if checker, ok := m.client.(interface{ Available() bool }); ok {
|
||||
return checker.Available()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type providerGatewayDependency struct {
|
||||
resolver ChainGatewayResolver
|
||||
}
|
||||
@@ -339,13 +189,6 @@ func WithOracleClient(client oracleclient.Client) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// WithMntxGateway wires the Monetix gateway client.
|
||||
func WithMntxGateway(client mntxclient.Client) Option {
|
||||
return func(s *Service) {
|
||||
s.deps.mntx = mntxDependency{client: client}
|
||||
}
|
||||
}
|
||||
|
||||
// WithCardGatewayRoutes configures funding/fee wallet routing per gateway.
|
||||
func WithCardGatewayRoutes(routes map[string]CardGatewayRoute) Option {
|
||||
return func(s *Service) {
|
||||
@@ -0,0 +1,159 @@
|
||||
package quotation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/payments/quotation/internal/service/shared"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
func (s *Service) buildPaymentPlan(
|
||||
ctx context.Context,
|
||||
orgID bson.ObjectID,
|
||||
intent *orchestratorv1.PaymentIntent,
|
||||
idempotencyKey string,
|
||||
quote *orchestratorv1.PaymentQuote,
|
||||
) (*model.PaymentPlan, error) {
|
||||
if s == nil || s.storage == nil {
|
||||
return nil, errStorageUnavailable
|
||||
}
|
||||
if err := requireNonNilIntent(intent); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
routeStore := s.storage.Routes()
|
||||
if routeStore == nil {
|
||||
return nil, merrors.InvalidArgument("routes store is required")
|
||||
}
|
||||
planTemplates := s.storage.PlanTemplates()
|
||||
if planTemplates == nil {
|
||||
return nil, merrors.InvalidArgument("plan templates store is required")
|
||||
}
|
||||
|
||||
builder := s.deps.planBuilder
|
||||
if builder == nil {
|
||||
builder = newDefaultPlanBuilder(s.logger.Named("plan_builder"))
|
||||
}
|
||||
|
||||
planQuote := quote
|
||||
if planQuote == nil {
|
||||
planQuote = &orchestratorv1.PaymentQuote{}
|
||||
}
|
||||
payment := newPayment(orgID, intent, strings.TrimSpace(idempotencyKey), nil, planQuote)
|
||||
if ref := strings.TrimSpace(planQuote.GetQuoteRef()); ref != "" {
|
||||
payment.PaymentRef = ref
|
||||
}
|
||||
|
||||
plan, err := builder.Build(ctx, payment, planQuote, routeStore, planTemplates, s.deps.gatewayRegistry)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if plan == nil || len(plan.Steps) == 0 {
|
||||
return nil, merrors.InvalidArgument("payment plan is required")
|
||||
}
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
func cloneStoredPaymentPlans(plans []*model.PaymentPlan) []*model.PaymentPlan {
|
||||
if len(plans) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]*model.PaymentPlan, 0, len(plans))
|
||||
for _, p := range plans {
|
||||
if p == nil {
|
||||
out = append(out, nil)
|
||||
continue
|
||||
}
|
||||
out = append(out, cloneStoredPaymentPlan(p))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneStoredPaymentPlan(src *model.PaymentPlan) *model.PaymentPlan {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
clone := &model.PaymentPlan{
|
||||
ID: strings.TrimSpace(src.ID),
|
||||
IdempotencyKey: strings.TrimSpace(src.IdempotencyKey),
|
||||
CreatedAt: src.CreatedAt,
|
||||
FXQuote: cloneStoredFXQuote(src.FXQuote),
|
||||
Fees: cloneStoredFeeLines(src.Fees),
|
||||
}
|
||||
if len(src.Steps) > 0 {
|
||||
clone.Steps = make([]*model.PaymentStep, 0, len(src.Steps))
|
||||
for _, step := range src.Steps {
|
||||
if step == nil {
|
||||
clone.Steps = append(clone.Steps, nil)
|
||||
continue
|
||||
}
|
||||
stepClone := &model.PaymentStep{
|
||||
StepID: strings.TrimSpace(step.StepID),
|
||||
Rail: step.Rail,
|
||||
GatewayID: strings.TrimSpace(step.GatewayID),
|
||||
InstanceID: strings.TrimSpace(step.InstanceID),
|
||||
Action: step.Action,
|
||||
DependsOn: cloneStringList(step.DependsOn),
|
||||
CommitPolicy: step.CommitPolicy,
|
||||
CommitAfter: cloneStringList(step.CommitAfter),
|
||||
Amount: cloneMoney(step.Amount),
|
||||
FromRole: shared.CloneAccountRole(step.FromRole),
|
||||
ToRole: shared.CloneAccountRole(step.ToRole),
|
||||
}
|
||||
clone.Steps = append(clone.Steps, stepClone)
|
||||
}
|
||||
}
|
||||
return clone
|
||||
}
|
||||
|
||||
func cloneStoredFXQuote(src *paymenttypes.FXQuote) *paymenttypes.FXQuote {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
result := &paymenttypes.FXQuote{
|
||||
QuoteRef: strings.TrimSpace(src.QuoteRef),
|
||||
Side: src.Side,
|
||||
ExpiresAtUnixMs: src.ExpiresAtUnixMs,
|
||||
Provider: strings.TrimSpace(src.Provider),
|
||||
RateRef: strings.TrimSpace(src.RateRef),
|
||||
Firm: src.Firm,
|
||||
BaseAmount: cloneMoney(src.BaseAmount),
|
||||
QuoteAmount: cloneMoney(src.QuoteAmount),
|
||||
}
|
||||
if src.Pair != nil {
|
||||
result.Pair = &paymenttypes.CurrencyPair{
|
||||
Base: strings.TrimSpace(src.Pair.Base),
|
||||
Quote: strings.TrimSpace(src.Pair.Quote),
|
||||
}
|
||||
}
|
||||
if src.Price != nil {
|
||||
result.Price = &paymenttypes.Decimal{Value: strings.TrimSpace(src.Price.Value)}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func cloneStoredFeeLines(lines []*paymenttypes.FeeLine) []*paymenttypes.FeeLine {
|
||||
if len(lines) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]*paymenttypes.FeeLine, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
if line == nil {
|
||||
result = append(result, nil)
|
||||
continue
|
||||
}
|
||||
result = append(result, &paymenttypes.FeeLine{
|
||||
LedgerAccountRef: strings.TrimSpace(line.LedgerAccountRef),
|
||||
Money: cloneMoney(line.Money),
|
||||
LineType: line.LineType,
|
||||
Side: line.Side,
|
||||
Meta: cloneMetadata(line.Meta),
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package orchestrator
|
||||
package quotation
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -0,0 +1,34 @@
|
||||
package quotation
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/tech/sendico/payments/quotation/internal/service/plan"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||
)
|
||||
|
||||
type defaultPlanBuilder struct {
|
||||
inner plan.Builder
|
||||
}
|
||||
|
||||
func newDefaultPlanBuilder(logger mlogger.Logger) PlanBuilder {
|
||||
return &defaultPlanBuilder{inner: plan.NewDefaultBuilder(logger)}
|
||||
}
|
||||
|
||||
func (b *defaultPlanBuilder) Build(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote, routes RouteStore, templates PlanTemplateStore, gateways GatewayRegistry) (*model.PaymentPlan, error) {
|
||||
return b.inner.Build(ctx, payment, quote, routes, templates, gateways)
|
||||
}
|
||||
|
||||
func railFromEndpoint(endpoint model.PaymentEndpoint, attrs map[string]string, isSource bool) (model.Rail, string, error) {
|
||||
return plan.RailFromEndpoint(endpoint, attrs, isSource)
|
||||
}
|
||||
|
||||
func resolveRouteNetwork(attrs map[string]string, sourceNetwork, destNetwork string) (string, error) {
|
||||
return plan.ResolveRouteNetwork(attrs, sourceNetwork, destNetwork)
|
||||
}
|
||||
|
||||
func selectPlanTemplate(ctx context.Context, logger mlogger.Logger, templates PlanTemplateStore, sourceRail, destRail model.Rail, network string) (*model.PaymentPlanTemplate, error) {
|
||||
return plan.SelectTemplate(ctx, logger, templates, sourceRail, destRail, network)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package quotation
|
||||
|
||||
import (
|
||||
"github.com/shopspring/decimal"
|
||||
"github.com/tech/sendico/payments/quotation/internal/service/plan"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
)
|
||||
|
||||
func sendDirectionForRail(rail model.Rail) plan.SendDirection {
|
||||
return plan.SendDirectionForRail(rail)
|
||||
}
|
||||
|
||||
func isGatewayEligible(gw *model.GatewayInstanceDescriptor, rail model.Rail, network, currency string, action model.RailOperation, dir plan.SendDirection, amount decimal.Decimal) error {
|
||||
return plan.IsGatewayEligible(gw, rail, network, currency, action, dir, amount)
|
||||
}
|
||||
|
||||
func parseRailValue(value string) model.Rail {
|
||||
return plan.ParseRailValue(value)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package orchestrator
|
||||
package quotation
|
||||
|
||||
import (
|
||||
"context"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user