service backend
This commit is contained in:
3
api/payments/orchestrator/.gitignore
vendored
Normal file
3
api/payments/orchestrator/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
internal/generated
|
||||
.gocache
|
||||
app
|
||||
148
api/payments/orchestrator/client/client.go
Normal file
148
api/payments/orchestrator/client/client.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
)
|
||||
|
||||
// Client exposes typed helpers around the payment orchestrator gRPC API.
|
||||
type Client interface {
|
||||
QuotePayment(ctx context.Context, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.QuotePaymentResponse, error)
|
||||
InitiatePayment(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error)
|
||||
CancelPayment(ctx context.Context, req *orchestratorv1.CancelPaymentRequest) (*orchestratorv1.CancelPaymentResponse, error)
|
||||
GetPayment(ctx context.Context, req *orchestratorv1.GetPaymentRequest) (*orchestratorv1.GetPaymentResponse, error)
|
||||
ListPayments(ctx context.Context, req *orchestratorv1.ListPaymentsRequest) (*orchestratorv1.ListPaymentsResponse, error)
|
||||
InitiateConversion(ctx context.Context, req *orchestratorv1.InitiateConversionRequest) (*orchestratorv1.InitiateConversionResponse, error)
|
||||
ProcessTransferUpdate(ctx context.Context, req *orchestratorv1.ProcessTransferUpdateRequest) (*orchestratorv1.ProcessTransferUpdateResponse, error)
|
||||
ProcessDepositObserved(ctx context.Context, req *orchestratorv1.ProcessDepositObservedRequest) (*orchestratorv1.ProcessDepositObservedResponse, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
type grpcOrchestratorClient interface {
|
||||
QuotePayment(ctx context.Context, in *orchestratorv1.QuotePaymentRequest, opts ...grpc.CallOption) (*orchestratorv1.QuotePaymentResponse, error)
|
||||
InitiatePayment(ctx context.Context, in *orchestratorv1.InitiatePaymentRequest, opts ...grpc.CallOption) (*orchestratorv1.InitiatePaymentResponse, error)
|
||||
CancelPayment(ctx context.Context, in *orchestratorv1.CancelPaymentRequest, opts ...grpc.CallOption) (*orchestratorv1.CancelPaymentResponse, error)
|
||||
GetPayment(ctx context.Context, in *orchestratorv1.GetPaymentRequest, opts ...grpc.CallOption) (*orchestratorv1.GetPaymentResponse, error)
|
||||
ListPayments(ctx context.Context, in *orchestratorv1.ListPaymentsRequest, opts ...grpc.CallOption) (*orchestratorv1.ListPaymentsResponse, error)
|
||||
InitiateConversion(ctx context.Context, in *orchestratorv1.InitiateConversionRequest, opts ...grpc.CallOption) (*orchestratorv1.InitiateConversionResponse, error)
|
||||
ProcessTransferUpdate(ctx context.Context, in *orchestratorv1.ProcessTransferUpdateRequest, opts ...grpc.CallOption) (*orchestratorv1.ProcessTransferUpdateResponse, error)
|
||||
ProcessDepositObserved(ctx context.Context, in *orchestratorv1.ProcessDepositObservedRequest, opts ...grpc.CallOption) (*orchestratorv1.ProcessDepositObservedResponse, error)
|
||||
}
|
||||
|
||||
type orchestratorClient struct {
|
||||
cfg Config
|
||||
conn *grpc.ClientConn
|
||||
client grpcOrchestratorClient
|
||||
}
|
||||
|
||||
// New dials the payment orchestrator endpoint and returns a ready client.
|
||||
func New(ctx context.Context, cfg Config, opts ...grpc.DialOption) (Client, error) {
|
||||
cfg.setDefaults()
|
||||
if strings.TrimSpace(cfg.Address) == "" {
|
||||
return nil, errors.New("payment-orchestrator: address is required")
|
||||
}
|
||||
|
||||
dialCtx, cancel := context.WithTimeout(ctx, cfg.DialTimeout)
|
||||
defer cancel()
|
||||
|
||||
dialOpts := make([]grpc.DialOption, 0, len(opts)+1)
|
||||
dialOpts = append(dialOpts, opts...)
|
||||
|
||||
if cfg.Insecure {
|
||||
dialOpts = append(dialOpts, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
} else {
|
||||
dialOpts = append(dialOpts, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{})))
|
||||
}
|
||||
|
||||
conn, err := grpc.DialContext(dialCtx, cfg.Address, dialOpts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("payment-orchestrator: dial %s: %w", cfg.Address, err)
|
||||
}
|
||||
|
||||
return &orchestratorClient{
|
||||
cfg: cfg,
|
||||
conn: conn,
|
||||
client: orchestratorv1.NewPaymentOrchestratorClient(conn),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewWithClient injects a pre-built orchestrator client (useful for tests).
|
||||
func NewWithClient(cfg Config, oc grpcOrchestratorClient) Client {
|
||||
cfg.setDefaults()
|
||||
return &orchestratorClient{
|
||||
cfg: cfg,
|
||||
client: oc,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *orchestratorClient) Close() error {
|
||||
if c.conn != nil {
|
||||
return c.conn.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *orchestratorClient) QuotePayment(ctx context.Context, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.QuotePaymentResponse, error) {
|
||||
ctx, cancel := c.callContext(ctx)
|
||||
defer cancel()
|
||||
return c.client.QuotePayment(ctx, req)
|
||||
}
|
||||
|
||||
func (c *orchestratorClient) InitiatePayment(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error) {
|
||||
ctx, cancel := c.callContext(ctx)
|
||||
defer cancel()
|
||||
return c.client.InitiatePayment(ctx, req)
|
||||
}
|
||||
|
||||
func (c *orchestratorClient) CancelPayment(ctx context.Context, req *orchestratorv1.CancelPaymentRequest) (*orchestratorv1.CancelPaymentResponse, error) {
|
||||
ctx, cancel := c.callContext(ctx)
|
||||
defer cancel()
|
||||
return c.client.CancelPayment(ctx, req)
|
||||
}
|
||||
|
||||
func (c *orchestratorClient) GetPayment(ctx context.Context, req *orchestratorv1.GetPaymentRequest) (*orchestratorv1.GetPaymentResponse, error) {
|
||||
ctx, cancel := c.callContext(ctx)
|
||||
defer cancel()
|
||||
return c.client.GetPayment(ctx, req)
|
||||
}
|
||||
|
||||
func (c *orchestratorClient) ListPayments(ctx context.Context, req *orchestratorv1.ListPaymentsRequest) (*orchestratorv1.ListPaymentsResponse, error) {
|
||||
ctx, cancel := c.callContext(ctx)
|
||||
defer cancel()
|
||||
return c.client.ListPayments(ctx, req)
|
||||
}
|
||||
|
||||
func (c *orchestratorClient) InitiateConversion(ctx context.Context, req *orchestratorv1.InitiateConversionRequest) (*orchestratorv1.InitiateConversionResponse, error) {
|
||||
ctx, cancel := c.callContext(ctx)
|
||||
defer cancel()
|
||||
return c.client.InitiateConversion(ctx, req)
|
||||
}
|
||||
|
||||
func (c *orchestratorClient) ProcessTransferUpdate(ctx context.Context, req *orchestratorv1.ProcessTransferUpdateRequest) (*orchestratorv1.ProcessTransferUpdateResponse, error) {
|
||||
ctx, cancel := c.callContext(ctx)
|
||||
defer cancel()
|
||||
return c.client.ProcessTransferUpdate(ctx, req)
|
||||
}
|
||||
|
||||
func (c *orchestratorClient) ProcessDepositObserved(ctx context.Context, req *orchestratorv1.ProcessDepositObservedRequest) (*orchestratorv1.ProcessDepositObservedResponse, error) {
|
||||
ctx, cancel := c.callContext(ctx)
|
||||
defer cancel()
|
||||
return c.client.ProcessDepositObserved(ctx, req)
|
||||
}
|
||||
|
||||
func (c *orchestratorClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
timeout := c.cfg.CallTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = 3 * time.Second
|
||||
}
|
||||
return context.WithTimeout(ctx, timeout)
|
||||
}
|
||||
20
api/payments/orchestrator/client/config.go
Normal file
20
api/payments/orchestrator/client/config.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package client
|
||||
|
||||
import "time"
|
||||
|
||||
// Config captures connection settings for the payment orchestrator gRPC service.
|
||||
type Config struct {
|
||||
Address string
|
||||
DialTimeout time.Duration
|
||||
CallTimeout time.Duration
|
||||
Insecure bool
|
||||
}
|
||||
|
||||
func (c *Config) setDefaults() {
|
||||
if c.DialTimeout <= 0 {
|
||||
c.DialTimeout = 5 * time.Second
|
||||
}
|
||||
if c.CallTimeout <= 0 {
|
||||
c.CallTimeout = 3 * time.Second
|
||||
}
|
||||
}
|
||||
83
api/payments/orchestrator/client/fake.go
Normal file
83
api/payments/orchestrator/client/fake.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||
)
|
||||
|
||||
// Fake implements Client for tests.
|
||||
type Fake struct {
|
||||
QuotePaymentFn func(ctx context.Context, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.QuotePaymentResponse, error)
|
||||
InitiatePaymentFn func(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error)
|
||||
CancelPaymentFn func(ctx context.Context, req *orchestratorv1.CancelPaymentRequest) (*orchestratorv1.CancelPaymentResponse, error)
|
||||
GetPaymentFn func(ctx context.Context, req *orchestratorv1.GetPaymentRequest) (*orchestratorv1.GetPaymentResponse, error)
|
||||
ListPaymentsFn func(ctx context.Context, req *orchestratorv1.ListPaymentsRequest) (*orchestratorv1.ListPaymentsResponse, error)
|
||||
InitiateConversionFn func(ctx context.Context, req *orchestratorv1.InitiateConversionRequest) (*orchestratorv1.InitiateConversionResponse, error)
|
||||
ProcessTransferUpdateFn func(ctx context.Context, req *orchestratorv1.ProcessTransferUpdateRequest) (*orchestratorv1.ProcessTransferUpdateResponse, error)
|
||||
ProcessDepositObservedFn func(ctx context.Context, req *orchestratorv1.ProcessDepositObservedRequest) (*orchestratorv1.ProcessDepositObservedResponse, error)
|
||||
CloseFn func() error
|
||||
}
|
||||
|
||||
func (f *Fake) QuotePayment(ctx context.Context, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.QuotePaymentResponse, error) {
|
||||
if f.QuotePaymentFn != nil {
|
||||
return f.QuotePaymentFn(ctx, req)
|
||||
}
|
||||
return &orchestratorv1.QuotePaymentResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *Fake) InitiatePayment(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error) {
|
||||
if f.InitiatePaymentFn != nil {
|
||||
return f.InitiatePaymentFn(ctx, req)
|
||||
}
|
||||
return &orchestratorv1.InitiatePaymentResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *Fake) CancelPayment(ctx context.Context, req *orchestratorv1.CancelPaymentRequest) (*orchestratorv1.CancelPaymentResponse, error) {
|
||||
if f.CancelPaymentFn != nil {
|
||||
return f.CancelPaymentFn(ctx, req)
|
||||
}
|
||||
return &orchestratorv1.CancelPaymentResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *Fake) GetPayment(ctx context.Context, req *orchestratorv1.GetPaymentRequest) (*orchestratorv1.GetPaymentResponse, error) {
|
||||
if f.GetPaymentFn != nil {
|
||||
return f.GetPaymentFn(ctx, req)
|
||||
}
|
||||
return &orchestratorv1.GetPaymentResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *Fake) ListPayments(ctx context.Context, req *orchestratorv1.ListPaymentsRequest) (*orchestratorv1.ListPaymentsResponse, error) {
|
||||
if f.ListPaymentsFn != nil {
|
||||
return f.ListPaymentsFn(ctx, req)
|
||||
}
|
||||
return &orchestratorv1.ListPaymentsResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *Fake) InitiateConversion(ctx context.Context, req *orchestratorv1.InitiateConversionRequest) (*orchestratorv1.InitiateConversionResponse, error) {
|
||||
if f.InitiateConversionFn != nil {
|
||||
return f.InitiateConversionFn(ctx, req)
|
||||
}
|
||||
return &orchestratorv1.InitiateConversionResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *Fake) ProcessTransferUpdate(ctx context.Context, req *orchestratorv1.ProcessTransferUpdateRequest) (*orchestratorv1.ProcessTransferUpdateResponse, error) {
|
||||
if f.ProcessTransferUpdateFn != nil {
|
||||
return f.ProcessTransferUpdateFn(ctx, req)
|
||||
}
|
||||
return &orchestratorv1.ProcessTransferUpdateResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *Fake) ProcessDepositObserved(ctx context.Context, req *orchestratorv1.ProcessDepositObservedRequest) (*orchestratorv1.ProcessDepositObservedResponse, error) {
|
||||
if f.ProcessDepositObservedFn != nil {
|
||||
return f.ProcessDepositObservedFn(ctx, req)
|
||||
}
|
||||
return &orchestratorv1.ProcessDepositObservedResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *Fake) Close() error {
|
||||
if f.CloseFn != nil {
|
||||
return f.CloseFn()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
1
api/payments/orchestrator/env/.gitignore
vendored
Normal file
1
api/payments/orchestrator/env/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.env.api
|
||||
60
api/payments/orchestrator/go.mod
Normal file
60
api/payments/orchestrator/go.mod
Normal file
@@ -0,0 +1,60 @@
|
||||
module github.com/tech/sendico/payments/orchestrator
|
||||
|
||||
go 1.25.3
|
||||
|
||||
replace github.com/tech/sendico/pkg => ../../pkg
|
||||
|
||||
replace github.com/tech/sendico/billing/fees => ../../billing/fees
|
||||
|
||||
replace github.com/tech/sendico/chain/gateway => ../../chain/gateway
|
||||
|
||||
replace github.com/tech/sendico/fx/oracle => ../../fx/oracle
|
||||
|
||||
replace github.com/tech/sendico/ledger => ../../ledger
|
||||
|
||||
require (
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/shopspring/decimal v1.4.0
|
||||
github.com/tech/sendico/chain/gateway v0.0.0-00010101000000-000000000000
|
||||
github.com/tech/sendico/fx/oracle v0.0.0-00010101000000-000000000000
|
||||
github.com/tech/sendico/ledger v0.0.0-00010101000000-000000000000
|
||||
github.com/tech/sendico/pkg v0.1.0
|
||||
go.mongodb.org/mongo-driver v1.17.6
|
||||
go.uber.org/zap v1.27.0
|
||||
google.golang.org/grpc v1.76.0
|
||||
google.golang.org/protobuf v1.36.10
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
|
||||
github.com/casbin/casbin/v2 v2.132.0 // indirect
|
||||
github.com/casbin/govaluate v1.10.0 // indirect
|
||||
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/go-chi/chi/v5 v5.2.3 // indirect
|
||||
github.com/golang/snappy v1.0.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/klauspost/compress v1.18.1 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/montanaflynn/stats v0.7.1 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/nats-io/nats.go v1.47.0 // indirect
|
||||
github.com/nats-io/nkeys v0.4.11 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.2 // indirect
|
||||
github.com/prometheus/procfs v0.19.2 // indirect
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||
github.com/xdg-go/scram v1.1.2 // indirect
|
||||
github.com/xdg-go/stringprep v1.0.4 // indirect
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
golang.org/x/crypto v0.43.0 // indirect
|
||||
golang.org/x/net v0.46.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/sys v0.37.0 // indirect
|
||||
golang.org/x/text v0.30.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101 // indirect
|
||||
)
|
||||
225
api/payments/orchestrator/go.sum
Normal file
225
api/payments/orchestrator/go.sum
Normal file
@@ -0,0 +1,225 @@
|
||||
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/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=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
|
||||
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||
github.com/casbin/casbin/v2 v2.132.0 h1:73hGmOszGSL3hTVquwkAi98XLl3gPJ+BxB6D7G9Fxtk=
|
||||
github.com/casbin/casbin/v2 v2.132.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
|
||||
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
|
||||
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||
github.com/casbin/mongodb-adapter/v3 v3.7.0 h1:w9c3bea1BGK4eZTAmk17JkY52yv/xSZDSHKji8q+z6E=
|
||||
github.com/casbin/mongodb-adapter/v3 v3.7.0/go.mod h1:F1mu4ojoJVE/8VhIMxMedhjfwRDdIXgANYs6Sd0MgVA=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
||||
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
|
||||
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/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=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/docker/docker v27.3.1+incompatible h1:KttF0XoteNTicmUtBO0L2tP+J7FGRFTjaEF4k6WdhfI=
|
||||
github.com/docker/docker v27.3.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
|
||||
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
|
||||
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||
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.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=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
|
||||
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/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
|
||||
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54 h1:mFWunSatvkQQDhpdyuFAYwyAan3hzCuma+Pz8sqvOfg=
|
||||
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
||||
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
|
||||
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
||||
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
|
||||
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
|
||||
github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo=
|
||||
github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
|
||||
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=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/nats-io/nats.go v1.47.0 h1:YQdADw6J/UfGUd2Oy6tn4Hq6YHxCaJrVKayxxFqYrgM=
|
||||
github.com/nats-io/nats.go v1.47.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
||||
github.com/nats-io/nkeys v0.4.11 h1:q44qGV008kYd9W1b1nEBkNzvnWxtRSQ7A8BoqRrcfa0=
|
||||
github.com/nats-io/nkeys v0.4.11/go.mod h1:szDimtgmfOi9n25JpfIdGw12tZFYXqhGxjhVxsatHVE=
|
||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
|
||||
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8=
|
||||
github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko=
|
||||
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
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/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
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=
|
||||
github.com/testcontainers/testcontainers-go v0.33.0/go.mod h1:W80YpTa8D5C3Yy16icheD01UTDu+LmXIA2Keo+jWtT8=
|
||||
github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0 h1:iXVA84s5hKMS5gn01GWOYHE3ymy/2b+0YkpFeTxB2XY=
|
||||
github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0/go.mod h1:R6tMjTojRiaoo89fh/hf7tOmfzohdqSU17R9DwSVSog=
|
||||
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
|
||||
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
|
||||
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
|
||||
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||
github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
|
||||
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
|
||||
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
|
||||
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.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.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUpsss=
|
||||
go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 h1:UP6IpuHFkUgOQL9FFQFrZ+5LiwhhYRbi7VZSIx6Nj5s=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM=
|
||||
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
|
||||
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
|
||||
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||
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=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||
go.uber.org/zap v1.27.0/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-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||
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-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
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.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
||||
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/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.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.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-20201119102817-f84b799fce68/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-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||
golang.org/x/sys v0.37.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/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.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||
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.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/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/rpc v0.0.0-20251103181224-f26f9409b101 h1:tRPGkdGHuewF4UisLzzHHr1spKw92qLM98nIzxbC0wY=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
|
||||
google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,426 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
gatewayv1 "github.com/tech/sendico/pkg/proto/chain/gateway/v1"
|
||||
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/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"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
func intentFromProto(src *orchestratorv1.PaymentIntent) model.PaymentIntent {
|
||||
if src == nil {
|
||||
return model.PaymentIntent{}
|
||||
}
|
||||
intent := model.PaymentIntent{
|
||||
Kind: modelKindFromProto(src.GetKind()),
|
||||
Source: endpointFromProto(src.GetSource()),
|
||||
Destination: endpointFromProto(src.GetDestination()),
|
||||
Amount: cloneMoney(src.GetAmount()),
|
||||
RequiresFX: src.GetRequiresFx(),
|
||||
FeePolicy: src.GetFeePolicy(),
|
||||
Attributes: cloneMetadata(src.GetAttributes()),
|
||||
}
|
||||
if src.GetFx() != nil {
|
||||
intent.FX = fxIntentFromProto(src.GetFx())
|
||||
}
|
||||
return intent
|
||||
}
|
||||
|
||||
func endpointFromProto(src *orchestratorv1.PaymentEndpoint) model.PaymentEndpoint {
|
||||
if src == nil {
|
||||
return model.PaymentEndpoint{Type: model.EndpointTypeUnspecified}
|
||||
}
|
||||
result := model.PaymentEndpoint{
|
||||
Type: model.EndpointTypeUnspecified,
|
||||
Metadata: cloneMetadata(src.GetMetadata()),
|
||||
}
|
||||
if ledger := src.GetLedger(); ledger != nil {
|
||||
result.Type = model.EndpointTypeLedger
|
||||
result.Ledger = &model.LedgerEndpoint{
|
||||
LedgerAccountRef: strings.TrimSpace(ledger.GetLedgerAccountRef()),
|
||||
ContraLedgerAccountRef: strings.TrimSpace(ledger.GetContraLedgerAccountRef()),
|
||||
}
|
||||
return result
|
||||
}
|
||||
if managed := src.GetManagedWallet(); managed != nil {
|
||||
result.Type = model.EndpointTypeManagedWallet
|
||||
result.ManagedWallet = &model.ManagedWalletEndpoint{
|
||||
ManagedWalletRef: strings.TrimSpace(managed.GetManagedWalletRef()),
|
||||
Asset: cloneAsset(managed.GetAsset()),
|
||||
}
|
||||
return result
|
||||
}
|
||||
if external := src.GetExternalChain(); external != nil {
|
||||
result.Type = model.EndpointTypeExternalChain
|
||||
result.ExternalChain = &model.ExternalChainEndpoint{
|
||||
Asset: cloneAsset(external.GetAsset()),
|
||||
Address: strings.TrimSpace(external.GetAddress()),
|
||||
Memo: strings.TrimSpace(external.GetMemo()),
|
||||
}
|
||||
return result
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func fxIntentFromProto(src *orchestratorv1.FXIntent) *model.FXIntent {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
return &model.FXIntent{
|
||||
Pair: clonePair(src.GetPair()),
|
||||
Side: src.GetSide(),
|
||||
Firm: src.GetFirm(),
|
||||
TTLMillis: src.GetTtlMs(),
|
||||
PreferredProvider: strings.TrimSpace(src.GetPreferredProvider()),
|
||||
MaxAgeMillis: src.GetMaxAgeMs(),
|
||||
}
|
||||
}
|
||||
|
||||
func quoteSnapshotToModel(src *orchestratorv1.PaymentQuote) *model.PaymentQuoteSnapshot {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
return &model.PaymentQuoteSnapshot{
|
||||
DebitAmount: cloneMoney(src.GetDebitAmount()),
|
||||
ExpectedSettlementAmount: cloneMoney(src.GetExpectedSettlementAmount()),
|
||||
ExpectedFeeTotal: cloneMoney(src.GetExpectedFeeTotal()),
|
||||
FeeLines: cloneFeeLines(src.GetFeeLines()),
|
||||
FeeRules: cloneFeeRules(src.GetFeeRules()),
|
||||
FXQuote: cloneFXQuote(src.GetFxQuote()),
|
||||
NetworkFee: cloneNetworkEstimate(src.GetNetworkFee()),
|
||||
FeeQuoteToken: strings.TrimSpace(src.GetFeeQuoteToken()),
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
Metadata: cloneMetadata(src.Metadata),
|
||||
}
|
||||
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{
|
||||
Kind: protoKindFromModel(src.Kind),
|
||||
Source: protoEndpointFromModel(src.Source),
|
||||
Destination: protoEndpointFromModel(src.Destination),
|
||||
Amount: cloneMoney(src.Amount),
|
||||
RequiresFx: src.RequiresFX,
|
||||
FeePolicy: src.FeePolicy,
|
||||
Attributes: cloneMetadata(src.Attributes),
|
||||
}
|
||||
if src.FX != nil {
|
||||
intent.Fx = protoFXIntentFromModel(src.FX)
|
||||
}
|
||||
return intent
|
||||
}
|
||||
|
||||
func protoEndpointFromModel(src model.PaymentEndpoint) *orchestratorv1.PaymentEndpoint {
|
||||
endpoint := &orchestratorv1.PaymentEndpoint{
|
||||
Metadata: cloneMetadata(src.Metadata),
|
||||
}
|
||||
switch src.Type {
|
||||
case model.EndpointTypeLedger:
|
||||
if src.Ledger != nil {
|
||||
endpoint.Endpoint = &orchestratorv1.PaymentEndpoint_Ledger{
|
||||
Ledger: &orchestratorv1.LedgerEndpoint{
|
||||
LedgerAccountRef: src.Ledger.LedgerAccountRef,
|
||||
ContraLedgerAccountRef: src.Ledger.ContraLedgerAccountRef,
|
||||
},
|
||||
}
|
||||
}
|
||||
case model.EndpointTypeManagedWallet:
|
||||
if src.ManagedWallet != nil {
|
||||
endpoint.Endpoint = &orchestratorv1.PaymentEndpoint_ManagedWallet{
|
||||
ManagedWallet: &orchestratorv1.ManagedWalletEndpoint{
|
||||
ManagedWalletRef: src.ManagedWallet.ManagedWalletRef,
|
||||
Asset: cloneAsset(src.ManagedWallet.Asset),
|
||||
},
|
||||
}
|
||||
}
|
||||
case model.EndpointTypeExternalChain:
|
||||
if src.ExternalChain != nil {
|
||||
endpoint.Endpoint = &orchestratorv1.PaymentEndpoint_ExternalChain{
|
||||
ExternalChain: &orchestratorv1.ExternalChainEndpoint{
|
||||
Asset: cloneAsset(src.ExternalChain.Asset),
|
||||
Address: src.ExternalChain.Address,
|
||||
Memo: src.ExternalChain.Memo,
|
||||
},
|
||||
}
|
||||
}
|
||||
default:
|
||||
// leave unspecified
|
||||
}
|
||||
return endpoint
|
||||
}
|
||||
|
||||
func protoFXIntentFromModel(src *model.FXIntent) *orchestratorv1.FXIntent {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
return &orchestratorv1.FXIntent{
|
||||
Pair: clonePair(src.Pair),
|
||||
Side: src.Side,
|
||||
Firm: src.Firm,
|
||||
TtlMs: src.TTLMillis,
|
||||
PreferredProvider: src.PreferredProvider,
|
||||
MaxAgeMs: src.MaxAgeMillis,
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
func modelQuoteToProto(src *model.PaymentQuoteSnapshot) *orchestratorv1.PaymentQuote {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
return &orchestratorv1.PaymentQuote{
|
||||
DebitAmount: cloneMoney(src.DebitAmount),
|
||||
ExpectedSettlementAmount: cloneMoney(src.ExpectedSettlementAmount),
|
||||
ExpectedFeeTotal: cloneMoney(src.ExpectedFeeTotal),
|
||||
FeeLines: cloneFeeLines(src.FeeLines),
|
||||
FeeRules: cloneFeeRules(src.FeeRules),
|
||||
FxQuote: cloneFXQuote(src.FXQuote),
|
||||
NetworkFee: cloneNetworkEstimate(src.NetworkFee),
|
||||
FeeQuoteToken: src.FeeQuoteToken,
|
||||
}
|
||||
}
|
||||
|
||||
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()),
|
||||
}
|
||||
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:
|
||||
return orchestratorv1.PaymentKind_PAYMENT_KIND_PAYOUT
|
||||
case model.PaymentKindInternalTransfer:
|
||||
return orchestratorv1.PaymentKind_PAYMENT_KIND_INTERNAL_TRANSFER
|
||||
case model.PaymentKindFXConversion:
|
||||
return orchestratorv1.PaymentKind_PAYMENT_KIND_FX_CONVERSION
|
||||
default:
|
||||
return orchestratorv1.PaymentKind_PAYMENT_KIND_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
func modelKindFromProto(kind orchestratorv1.PaymentKind) model.PaymentKind {
|
||||
switch kind {
|
||||
case orchestratorv1.PaymentKind_PAYMENT_KIND_PAYOUT:
|
||||
return model.PaymentKindPayout
|
||||
case orchestratorv1.PaymentKind_PAYMENT_KIND_INTERNAL_TRANSFER:
|
||||
return model.PaymentKindInternalTransfer
|
||||
case orchestratorv1.PaymentKind_PAYMENT_KIND_FX_CONVERSION:
|
||||
return model.PaymentKindFXConversion
|
||||
default:
|
||||
return model.PaymentKindUnspecified
|
||||
}
|
||||
}
|
||||
|
||||
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_PAYMENT_FAILURE_CODE_BALANCE
|
||||
case model.PaymentFailureCodeLedger:
|
||||
return orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_LEDGER
|
||||
case model.PaymentFailureCodeFX:
|
||||
return orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_FX
|
||||
case model.PaymentFailureCodeChain:
|
||||
return orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_CHAIN
|
||||
case model.PaymentFailureCodeFees:
|
||||
return orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_FEES
|
||||
case model.PaymentFailureCodePolicy:
|
||||
return orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_POLICY
|
||||
default:
|
||||
return orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
func cloneAsset(asset *gatewayv1.Asset) *gatewayv1.Asset {
|
||||
if asset == nil {
|
||||
return nil
|
||||
}
|
||||
return &gatewayv1.Asset{
|
||||
Chain: asset.GetChain(),
|
||||
TokenSymbol: asset.GetTokenSymbol(),
|
||||
ContractAddress: asset.GetContractAddress(),
|
||||
}
|
||||
}
|
||||
|
||||
func clonePair(pair *fxv1.CurrencyPair) *fxv1.CurrencyPair {
|
||||
if pair == nil {
|
||||
return nil
|
||||
}
|
||||
return &fxv1.CurrencyPair{
|
||||
Base: pair.GetBase(),
|
||||
Quote: pair.GetQuote(),
|
||||
}
|
||||
}
|
||||
|
||||
func cloneFXQuote(quote *oraclev1.Quote) *oraclev1.Quote {
|
||||
if quote == nil {
|
||||
return nil
|
||||
}
|
||||
if cloned, ok := proto.Clone(quote).(*oraclev1.Quote); ok {
|
||||
return cloned
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cloneNetworkEstimate(resp *gatewayv1.EstimateTransferFeeResponse) *gatewayv1.EstimateTransferFeeResponse {
|
||||
if resp == nil {
|
||||
return nil
|
||||
}
|
||||
if cloned, ok := proto.Clone(resp).(*gatewayv1.EstimateTransferFeeResponse); ok {
|
||||
return cloned
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func protoFailureToModel(code orchestratorv1.PaymentFailureCode) model.PaymentFailureCode {
|
||||
switch code {
|
||||
case orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_BALANCE:
|
||||
return model.PaymentFailureCodeBalance
|
||||
case orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_LEDGER:
|
||||
return model.PaymentFailureCodeLedger
|
||||
case orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_FX:
|
||||
return model.PaymentFailureCodeFX
|
||||
case orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_CHAIN:
|
||||
return model.PaymentFailureCodeChain
|
||||
case orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_FEES:
|
||||
return model.PaymentFailureCodeFees
|
||||
case orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_POLICY:
|
||||
return model.PaymentFailureCodePolicy
|
||||
default:
|
||||
return model.PaymentFailureCodeUnspecified
|
||||
}
|
||||
}
|
||||
|
||||
func applyProtoPaymentToModel(src *orchestratorv1.Payment, dst *model.Payment) error {
|
||||
if src == nil || dst == nil {
|
||||
return merrors.InvalidArgument("payment payload is required")
|
||||
}
|
||||
dst.PaymentRef = strings.TrimSpace(src.GetPaymentRef())
|
||||
dst.IdempotencyKey = strings.TrimSpace(src.GetIdempotencyKey())
|
||||
dst.Intent = intentFromProto(src.GetIntent())
|
||||
dst.State = modelStateFromProto(src.GetState())
|
||||
dst.FailureCode = protoFailureToModel(src.GetFailureCode())
|
||||
dst.FailureReason = strings.TrimSpace(src.GetFailureReason())
|
||||
dst.Metadata = cloneMetadata(src.GetMetadata())
|
||||
dst.LastQuote = quoteSnapshotToModel(src.GetLastQuote())
|
||||
dst.Execution = executionFromProto(src.GetExecution())
|
||||
return nil
|
||||
}
|
||||
|
||||
func executionFromProto(src *orchestratorv1.ExecutionRefs) *model.ExecutionRefs {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
return &model.ExecutionRefs{
|
||||
DebitEntryRef: strings.TrimSpace(src.GetDebitEntryRef()),
|
||||
CreditEntryRef: strings.TrimSpace(src.GetCreditEntryRef()),
|
||||
FXEntryRef: strings.TrimSpace(src.GetFxEntryRef()),
|
||||
ChainTransferRef: strings.TrimSpace(src.GetChainTransferRef()),
|
||||
}
|
||||
}
|
||||
|
||||
func ensurePageRequest(req *orchestratorv1.ListPaymentsRequest) *paginationv1.CursorPageRequest {
|
||||
if req == nil {
|
||||
return &paginationv1.CursorPageRequest{}
|
||||
}
|
||||
if req.GetPage() == nil {
|
||||
return &paginationv1.CursorPageRequest{}
|
||||
}
|
||||
return req.GetPage()
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
||||
"github.com/tech/sendico/payments/orchestrator/storage"
|
||||
"github.com/tech/sendico/payments/orchestrator/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||
gatewayv1 "github.com/tech/sendico/pkg/proto/chain/gateway/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"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
"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, error) {
|
||||
intent := req.GetIntent()
|
||||
amount := intent.GetAmount()
|
||||
baseAmount := cloneMoney(amount)
|
||||
feeQuote, err := s.quoteFees(ctx, orgRef, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
feeTotal := extractFeeTotal(feeQuote.GetLines(), amount.GetCurrency())
|
||||
|
||||
var networkFee *gatewayv1.EstimateTransferFeeResponse
|
||||
if shouldEstimateNetworkFee(intent) {
|
||||
networkFee, err = s.estimateNetworkFee(ctx, intent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
var fxQuote *oraclev1.Quote
|
||||
if shouldRequestFX(intent) {
|
||||
fxQuote, err = s.requestFXQuote(ctx, orgRef, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
debitAmount, settlementAmount := computeAggregates(baseAmount, feeTotal, networkFee)
|
||||
|
||||
return &orchestratorv1.PaymentQuote{
|
||||
DebitAmount: debitAmount,
|
||||
ExpectedSettlementAmount: settlementAmount,
|
||||
ExpectedFeeTotal: feeTotal,
|
||||
FeeLines: cloneFeeLines(feeQuote.GetLines()),
|
||||
FeeRules: cloneFeeRules(feeQuote.GetApplied()),
|
||||
FxQuote: fxQuote,
|
||||
NetworkFee: networkFee,
|
||||
FeeQuoteToken: feeQuote.GetFeeQuoteToken(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) quoteFees(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest) (*feesv1.PrecomputeFeesResponse, error) {
|
||||
if !s.fees.available() {
|
||||
return &feesv1.PrecomputeFeesResponse{}, nil
|
||||
}
|
||||
intent := req.GetIntent()
|
||||
feeIntent := &feesv1.Intent{
|
||||
Trigger: triggerFromKind(intent.GetKind(), intent.GetRequiresFx()),
|
||||
BaseAmount: cloneMoney(intent.GetAmount()),
|
||||
BookedAt: timestamppb.New(s.clock.Now()),
|
||||
OriginType: "payments.orchestrator.quote",
|
||||
OriginRef: strings.TrimSpace(req.GetIdempotencyKey()),
|
||||
Attributes: cloneMetadata(intent.GetAttributes()),
|
||||
}
|
||||
timeout := req.GetMeta().GetTrace()
|
||||
ctxTimeout, cancel := s.withTimeout(ctx, s.fees.timeout)
|
||||
defer cancel()
|
||||
resp, err := s.fees.client.PrecomputeFees(ctxTimeout, &feesv1.PrecomputeFeesRequest{
|
||||
Meta: &feesv1.RequestMeta{
|
||||
OrganizationRef: orgRef,
|
||||
Trace: timeout,
|
||||
},
|
||||
Intent: feeIntent,
|
||||
TtlMs: defaultFeeQuoteTTLMillis,
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Error("fees precompute failed", zap.Error(err))
|
||||
return nil, merrors.Internal("fees_precompute_failed")
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s *Service) estimateNetworkFee(ctx context.Context, intent *orchestratorv1.PaymentIntent) (*gatewayv1.EstimateTransferFeeResponse, error) {
|
||||
if !s.gateway.available() {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
req := &gatewayv1.EstimateTransferFeeRequest{
|
||||
Amount: cloneMoney(intent.GetAmount()),
|
||||
}
|
||||
if src := intent.GetSource().GetManagedWallet(); src != nil {
|
||||
req.SourceWalletRef = strings.TrimSpace(src.GetManagedWalletRef())
|
||||
}
|
||||
if dst := intent.GetDestination().GetManagedWallet(); dst != nil {
|
||||
req.Destination = &gatewayv1.TransferDestination{
|
||||
Destination: &gatewayv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: strings.TrimSpace(dst.GetManagedWalletRef())},
|
||||
}
|
||||
}
|
||||
if dst := intent.GetDestination().GetExternalChain(); dst != nil {
|
||||
req.Destination = &gatewayv1.TransferDestination{
|
||||
Destination: &gatewayv1.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()
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := s.gateway.client.EstimateTransferFee(ctx, req)
|
||||
if err != nil {
|
||||
s.logger.Error("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.oracle.available() {
|
||||
return nil, nil
|
||||
}
|
||||
intent := req.GetIntent()
|
||||
meta := req.GetMeta()
|
||||
fxIntent := intent.GetFx()
|
||||
if fxIntent == nil {
|
||||
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 {
|
||||
params.BaseAmount = cloneMoney(amount)
|
||||
}
|
||||
|
||||
quote, err := s.oracle.client.GetQuote(ctx, params)
|
||||
if err != nil {
|
||||
s.logger.Error("fx oracle quote failed", zap.Error(err))
|
||||
return nil, merrors.Internal("fx_quote_failed")
|
||||
}
|
||||
return quoteToProto(quote), nil
|
||||
}
|
||||
|
||||
func (s *Service) executePayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, quote *orchestratorv1.PaymentQuote) error {
|
||||
if store == nil {
|
||||
return errStorageUnavailable
|
||||
}
|
||||
|
||||
charges := ledgerChargesFromFeeLines(quote.GetFeeLines())
|
||||
ledgerNeeded := requiresLedger(payment)
|
||||
chainNeeded := requiresChain(payment)
|
||||
|
||||
exec := payment.Execution
|
||||
if exec == nil {
|
||||
exec = &model.ExecutionRefs{}
|
||||
}
|
||||
|
||||
if ledgerNeeded {
|
||||
if !s.ledger.available() {
|
||||
return s.failPayment(ctx, store, payment, model.PaymentFailureCodeLedger, "ledger_client_unavailable", merrors.Internal("ledger_client_unavailable"))
|
||||
}
|
||||
if err := s.performLedgerOperation(ctx, payment, quote, charges); err != nil {
|
||||
return s.failPayment(ctx, store, payment, model.PaymentFailureCodeLedger, strings.TrimSpace(err.Error()), err)
|
||||
}
|
||||
payment.State = model.PaymentStateFundsReserved
|
||||
if err := s.persistPayment(ctx, store, payment); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if chainNeeded {
|
||||
if !s.gateway.available() {
|
||||
return s.failPayment(ctx, store, payment, model.PaymentFailureCodeChain, "chain_client_unavailable", merrors.Internal("chain_client_unavailable"))
|
||||
}
|
||||
resp, err := s.submitChainTransfer(ctx, payment, quote)
|
||||
if err != nil {
|
||||
return s.failPayment(ctx, store, payment, model.PaymentFailureCodeChain, strings.TrimSpace(err.Error()), err)
|
||||
}
|
||||
exec = payment.Execution
|
||||
if exec == nil {
|
||||
exec = &model.ExecutionRefs{}
|
||||
}
|
||||
if resp != nil && resp.GetTransfer() != nil {
|
||||
exec.ChainTransferRef = strings.TrimSpace(resp.GetTransfer().GetTransferRef())
|
||||
}
|
||||
payment.Execution = exec
|
||||
payment.State = model.PaymentStateSubmitted
|
||||
if err := s.persistPayment(ctx, store, payment); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
payment.State = model.PaymentStateSettled
|
||||
return s.persistPayment(ctx, store, payment)
|
||||
}
|
||||
|
||||
func (s *Service) performLedgerOperation(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote, charges []*ledgerv1.PostingLine) error {
|
||||
intent := payment.Intent
|
||||
if payment.OrganizationRef == primitive.NilObjectID {
|
||||
return merrors.InvalidArgument("ledger: organization_ref is required")
|
||||
}
|
||||
|
||||
amount := cloneMoney(intent.Amount)
|
||||
if amount == nil {
|
||||
return merrors.InvalidArgument("ledger: amount is required")
|
||||
}
|
||||
|
||||
description := paymentDescription(payment)
|
||||
metadata := cloneMetadata(payment.Metadata)
|
||||
exec := payment.Execution
|
||||
if exec == nil {
|
||||
exec = &model.ExecutionRefs{}
|
||||
}
|
||||
|
||||
switch intent.Kind {
|
||||
case model.PaymentKindFXConversion:
|
||||
if err := s.applyFX(ctx, payment, quote, charges, description, metadata, exec); err != nil {
|
||||
return err
|
||||
}
|
||||
case model.PaymentKindInternalTransfer, model.PaymentKindPayout, model.PaymentKindUnspecified:
|
||||
from, to, err := resolveLedgerAccounts(intent)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req := &ledgerv1.TransferRequest{
|
||||
IdempotencyKey: payment.IdempotencyKey,
|
||||
OrganizationRef: payment.OrganizationRef.Hex(),
|
||||
FromLedgerAccountRef: from,
|
||||
ToLedgerAccountRef: to,
|
||||
Money: amount,
|
||||
Description: description,
|
||||
Charges: charges,
|
||||
Metadata: metadata,
|
||||
}
|
||||
resp, err := s.ledger.client.TransferInternal(ctx, req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
exec.DebitEntryRef = strings.TrimSpace(resp.GetJournalEntryRef())
|
||||
payment.Execution = exec
|
||||
default:
|
||||
return merrors.InvalidArgument("ledger: unsupported payment kind")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) 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")
|
||||
}
|
||||
fromMoney := cloneMoney(fq.GetBaseAmount())
|
||||
if fromMoney == nil {
|
||||
fromMoney = cloneMoney(intent.Amount)
|
||||
}
|
||||
toMoney := cloneMoney(fq.GetQuoteAmount())
|
||||
if toMoney == nil {
|
||||
toMoney = cloneMoney(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 := s.ledger.client.ApplyFXWithCharges(ctx, req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
exec.FXEntryRef = strings.TrimSpace(resp.GetJournalEntryRef())
|
||||
payment.Execution = exec
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) submitChainTransfer(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote) (*gatewayv1.SubmitTransferResponse, error) {
|
||||
intent := payment.Intent
|
||||
source := intent.Source.ManagedWallet
|
||||
destination := intent.Destination
|
||||
if source == nil || strings.TrimSpace(source.ManagedWalletRef) == "" {
|
||||
return nil, merrors.InvalidArgument("chain: source managed wallet is required")
|
||||
}
|
||||
dest, err := toGatewayDestination(destination)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
amount := cloneMoney(intent.Amount)
|
||||
if amount == nil {
|
||||
return nil, merrors.InvalidArgument("chain: amount is required")
|
||||
}
|
||||
fees := feeBreakdownFromQuote(quote)
|
||||
req := &gatewayv1.SubmitTransferRequest{
|
||||
IdempotencyKey: payment.IdempotencyKey,
|
||||
OrganizationRef: payment.OrganizationRef.Hex(),
|
||||
SourceWalletRef: strings.TrimSpace(source.ManagedWalletRef),
|
||||
Destination: dest,
|
||||
Amount: amount,
|
||||
Fees: fees,
|
||||
Metadata: cloneMetadata(payment.Metadata),
|
||||
ClientReference: payment.PaymentRef,
|
||||
}
|
||||
return s.gateway.client.SubmitTransfer(ctx, req)
|
||||
}
|
||||
|
||||
func (s *Service) persistPayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment) error {
|
||||
if store == nil {
|
||||
return errStorageUnavailable
|
||||
}
|
||||
return store.Update(ctx, payment)
|
||||
}
|
||||
|
||||
func (s *Service) 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 {
|
||||
s.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 resolveLedgerAccounts(intent model.PaymentIntent) (string, string, error) {
|
||||
source := intent.Source.Ledger
|
||||
destination := intent.Destination.Ledger
|
||||
if source == nil || strings.TrimSpace(source.LedgerAccountRef) == "" {
|
||||
return "", "", merrors.InvalidArgument("ledger: source account is required")
|
||||
}
|
||||
to := ""
|
||||
if destination != nil && strings.TrimSpace(destination.LedgerAccountRef) != "" {
|
||||
to = strings.TrimSpace(destination.LedgerAccountRef)
|
||||
} else if strings.TrimSpace(source.ContraLedgerAccountRef) != "" {
|
||||
to = strings.TrimSpace(source.ContraLedgerAccountRef)
|
||||
}
|
||||
if to == "" {
|
||||
return "", "", merrors.InvalidArgument("ledger: destination account is required")
|
||||
}
|
||||
return strings.TrimSpace(source.LedgerAccountRef), to, nil
|
||||
}
|
||||
|
||||
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 requiresLedger(payment *model.Payment) bool {
|
||||
if payment == nil {
|
||||
return false
|
||||
}
|
||||
if payment.Intent.Kind == model.PaymentKindFXConversion {
|
||||
return true
|
||||
}
|
||||
return hasLedgerEndpoint(payment.Intent.Source) || hasLedgerEndpoint(payment.Intent.Destination)
|
||||
}
|
||||
|
||||
func requiresChain(payment *model.Payment) bool {
|
||||
if payment == nil {
|
||||
return false
|
||||
}
|
||||
if !hasManagedWallet(payment.Intent.Source) {
|
||||
return false
|
||||
}
|
||||
switch payment.Intent.Destination.Type {
|
||||
case model.EndpointTypeManagedWallet, model.EndpointTypeExternalChain:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func hasLedgerEndpoint(endpoint model.PaymentEndpoint) bool {
|
||||
return endpoint.Type == model.EndpointTypeLedger && endpoint.Ledger != nil && strings.TrimSpace(endpoint.Ledger.LedgerAccountRef) != ""
|
||||
}
|
||||
|
||||
func hasManagedWallet(endpoint model.PaymentEndpoint) bool {
|
||||
return endpoint.Type == model.EndpointTypeManagedWallet && endpoint.ManagedWallet != nil && strings.TrimSpace(endpoint.ManagedWallet.ManagedWalletRef) != ""
|
||||
}
|
||||
|
||||
func toGatewayDestination(endpoint model.PaymentEndpoint) (*gatewayv1.TransferDestination, error) {
|
||||
switch endpoint.Type {
|
||||
case model.EndpointTypeManagedWallet:
|
||||
if endpoint.ManagedWallet == nil || strings.TrimSpace(endpoint.ManagedWallet.ManagedWalletRef) == "" {
|
||||
return nil, merrors.InvalidArgument("chain: destination managed wallet is required")
|
||||
}
|
||||
return &gatewayv1.TransferDestination{
|
||||
Destination: &gatewayv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: strings.TrimSpace(endpoint.ManagedWallet.ManagedWalletRef)},
|
||||
}, nil
|
||||
case model.EndpointTypeExternalChain:
|
||||
if endpoint.ExternalChain == nil || strings.TrimSpace(endpoint.ExternalChain.Address) == "" {
|
||||
return nil, merrors.InvalidArgument("chain: external address is required")
|
||||
}
|
||||
return &gatewayv1.TransferDestination{
|
||||
Destination: &gatewayv1.TransferDestination_ExternalAddress{ExternalAddress: strings.TrimSpace(endpoint.ExternalChain.Address)},
|
||||
Memo: strings.TrimSpace(endpoint.ExternalChain.Memo),
|
||||
}, nil
|
||||
default:
|
||||
return nil, merrors.InvalidArgument("chain: unsupported destination type")
|
||||
}
|
||||
}
|
||||
|
||||
func applyTransferStatus(event *gatewayv1.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 gatewayv1.TransferStatus_TRANSFER_CONFIRMED:
|
||||
payment.State = model.PaymentStateSettled
|
||||
payment.FailureCode = model.PaymentFailureCodeUnspecified
|
||||
payment.FailureReason = ""
|
||||
case gatewayv1.TransferStatus_TRANSFER_FAILED:
|
||||
payment.State = model.PaymentStateFailed
|
||||
payment.FailureCode = model.PaymentFailureCodeChain
|
||||
payment.FailureReason = reason
|
||||
case gatewayv1.TransferStatus_TRANSFER_CANCELLED:
|
||||
payment.State = model.PaymentStateCancelled
|
||||
payment.FailureCode = model.PaymentFailureCodePolicy
|
||||
payment.FailureReason = reason
|
||||
case gatewayv1.TransferStatus_TRANSFER_SIGNING,
|
||||
gatewayv1.TransferStatus_TRANSFER_PENDING,
|
||||
gatewayv1.TransferStatus_TRANSFER_SUBMITTED:
|
||||
payment.State = model.PaymentStateSubmitted
|
||||
default:
|
||||
// retain previous state
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
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"
|
||||
"github.com/shopspring/decimal"
|
||||
"google.golang.org/protobuf/proto"
|
||||
|
||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||
gatewayv1 "github.com/tech/sendico/pkg/proto/chain/gateway/v1"
|
||||
accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
)
|
||||
|
||||
func cloneMoney(input *moneyv1.Money) *moneyv1.Money {
|
||||
if input == nil {
|
||||
return nil
|
||||
}
|
||||
return &moneyv1.Money{
|
||||
Currency: input.GetCurrency(),
|
||||
Amount: input.GetAmount(),
|
||||
}
|
||||
}
|
||||
|
||||
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 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 computeAggregates(base, fee *moneyv1.Money, network *gatewayv1.EstimateTransferFeeResponse) (*moneyv1.Money, *moneyv1.Money) {
|
||||
if base == nil {
|
||||
return nil, nil
|
||||
}
|
||||
baseDecimal, err := decimalFromMoney(base)
|
||||
if err != nil {
|
||||
return cloneMoney(base), cloneMoney(base)
|
||||
}
|
||||
debit := baseDecimal
|
||||
settlement := baseDecimal
|
||||
|
||||
if feeDecimal, err := decimalFromMoneyMatching(base, fee); err == nil && feeDecimal != nil {
|
||||
debit = debit.Add(*feeDecimal)
|
||||
settlement = settlement.Sub(*feeDecimal)
|
||||
}
|
||||
|
||||
if network != nil && network.GetNetworkFee() != nil {
|
||||
if networkDecimal, err := decimalFromMoneyMatching(base, network.GetNetworkFee()); err == nil && networkDecimal != nil {
|
||||
debit = debit.Add(*networkDecimal)
|
||||
settlement = settlement.Sub(*networkDecimal)
|
||||
}
|
||||
}
|
||||
|
||||
return makeMoney(base.GetCurrency(), debit), makeMoney(base.GetCurrency(), settlement)
|
||||
}
|
||||
|
||||
func decimalFromMoney(m *moneyv1.Money) (decimal.Decimal, error) {
|
||||
if m == nil {
|
||||
return decimal.Zero, nil
|
||||
}
|
||||
return decimal.NewFromString(m.GetAmount())
|
||||
}
|
||||
|
||||
func decimalFromMoneyMatching(reference, candidate *moneyv1.Money) (*decimal.Decimal, error) {
|
||||
if reference == nil || candidate == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if !strings.EqualFold(reference.GetCurrency(), candidate.GetCurrency()) {
|
||||
return nil, nil
|
||||
}
|
||||
value, err := decimal.NewFromString(candidate.GetAmount())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &value, nil
|
||||
}
|
||||
|
||||
func makeMoney(currency string, value decimal.Decimal) *moneyv1.Money {
|
||||
return &moneyv1.Money{
|
||||
Currency: currency,
|
||||
Amount: value.String(),
|
||||
}
|
||||
}
|
||||
|
||||
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: cloneMoney(src.BaseAmount),
|
||||
QuoteAmount: cloneMoney(src.QuoteAmount),
|
||||
ExpiresAtUnixMs: src.ExpiresAt.UnixMilli(),
|
||||
Provider: src.Provider,
|
||||
RateRef: src.RateRef,
|
||||
Firm: src.Firm,
|
||||
}
|
||||
}
|
||||
|
||||
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 || strings.TrimSpace(line.GetLedgerAccountRef()) == "" {
|
||||
continue
|
||||
}
|
||||
money := cloneMoney(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 feeBreakdownFromQuote(quote *orchestratorv1.PaymentQuote) []*gatewayv1.ServiceFeeBreakdown {
|
||||
if quote == nil {
|
||||
return nil
|
||||
}
|
||||
lines := quote.GetFeeLines()
|
||||
breakdown := make([]*gatewayv1.ServiceFeeBreakdown, 0, len(lines)+1)
|
||||
for _, line := range lines {
|
||||
if line == nil {
|
||||
continue
|
||||
}
|
||||
amount := cloneMoney(line.GetMoney())
|
||||
if amount == nil {
|
||||
continue
|
||||
}
|
||||
code := strings.TrimSpace(line.GetMeta()["fee_code"])
|
||||
if code == "" {
|
||||
code = strings.TrimSpace(line.GetMeta()["fee_rule_id"])
|
||||
}
|
||||
if code == "" {
|
||||
code = line.GetLineType().String()
|
||||
}
|
||||
desc := strings.TrimSpace(line.GetMeta()["description"])
|
||||
breakdown = append(breakdown, &gatewayv1.ServiceFeeBreakdown{
|
||||
FeeCode: code,
|
||||
Amount: amount,
|
||||
Description: desc,
|
||||
})
|
||||
}
|
||||
if quote.GetNetworkFee() != nil && quote.GetNetworkFee().GetNetworkFee() != nil {
|
||||
networkAmount := cloneMoney(quote.GetNetworkFee().GetNetworkFee())
|
||||
if networkAmount != nil {
|
||||
breakdown = append(breakdown, &gatewayv1.ServiceFeeBreakdown{
|
||||
FeeCode: "network_fee",
|
||||
Amount: networkAmount,
|
||||
Description: strings.TrimSpace(quote.GetNetworkFee().GetEstimationContext()),
|
||||
})
|
||||
}
|
||||
}
|
||||
if len(breakdown) == 0 {
|
||||
return nil
|
||||
}
|
||||
return breakdown
|
||||
}
|
||||
|
||||
func moneyEquals(a, b *moneyv1.Money) 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
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||
)
|
||||
|
||||
func (s *Service) ensureRepository(ctx context.Context) error {
|
||||
if s.storage == nil {
|
||||
return errStorageUnavailable
|
||||
}
|
||||
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)
|
||||
observeRPC(method, err, svc.clock.Now().Sub(start))
|
||||
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
|
||||
}
|
||||
if intent.GetKind() == orchestratorv1.PaymentKind_PAYMENT_KIND_PAYOUT {
|
||||
return true
|
||||
}
|
||||
if intent.GetDestination().GetManagedWallet() != nil || intent.GetDestination().GetExternalChain() != nil {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func shouldRequestFX(intent *orchestratorv1.PaymentIntent) bool {
|
||||
if intent == nil {
|
||||
return false
|
||||
}
|
||||
if intent.GetRequiresFx() {
|
||||
return true
|
||||
}
|
||||
return intent.GetFx() != nil && intent.GetFx().GetPair() != nil
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
)
|
||||
|
||||
var (
|
||||
metricsOnce sync.Once
|
||||
|
||||
rpcLatency *prometheus.HistogramVec
|
||||
rpcStatus *prometheus.CounterVec
|
||||
)
|
||||
|
||||
func initMetrics() {
|
||||
metricsOnce.Do(func() {
|
||||
rpcLatency = promauto.NewHistogramVec(prometheus.HistogramOpts{
|
||||
Namespace: "sendico",
|
||||
Subsystem: "payment_orchestrator",
|
||||
Name: "rpc_latency_seconds",
|
||||
Help: "Latency distribution for payment orchestrator RPC handlers.",
|
||||
Buckets: prometheus.DefBuckets,
|
||||
}, []string{"method"})
|
||||
|
||||
rpcStatus = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||
Namespace: "sendico",
|
||||
Subsystem: "payment_orchestrator",
|
||||
Name: "rpc_requests_total",
|
||||
Help: "Total number of RPC invocations grouped by method and status.",
|
||||
}, []string{"method", "status"})
|
||||
})
|
||||
}
|
||||
|
||||
func observeRPC(method string, err error, duration time.Duration) {
|
||||
if rpcLatency != nil {
|
||||
rpcLatency.WithLabelValues(method).Observe(duration.Seconds())
|
||||
}
|
||||
if rpcStatus != nil {
|
||||
rpcStatus.WithLabelValues(method, statusLabel(err)).Inc()
|
||||
}
|
||||
}
|
||||
|
||||
func statusLabel(err error) string {
|
||||
switch {
|
||||
case err == nil:
|
||||
return "ok"
|
||||
case errors.Is(err, merrors.ErrInvalidArg):
|
||||
return "invalid_argument"
|
||||
case errors.Is(err, merrors.ErrNoData):
|
||||
return "not_found"
|
||||
case errors.Is(err, merrors.ErrDataConflict):
|
||||
return "conflict"
|
||||
case errors.Is(err, merrors.ErrAccessDenied):
|
||||
return "denied"
|
||||
case errors.Is(err, merrors.ErrInternal):
|
||||
return "internal"
|
||||
default:
|
||||
return "error"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
chainclient "github.com/tech/sendico/chain/gateway/client"
|
||||
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
||||
ledgerclient "github.com/tech/sendico/ledger/client"
|
||||
clockpkg "github.com/tech/sendico/pkg/clock"
|
||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||
)
|
||||
|
||||
// Option configures service dependencies.
|
||||
type Option func(*Service)
|
||||
|
||||
type feesDependency struct {
|
||||
client feesv1.FeeEngineClient
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
func (f feesDependency) available() bool {
|
||||
return f.client != nil
|
||||
}
|
||||
|
||||
type ledgerDependency struct {
|
||||
client ledgerclient.Client
|
||||
}
|
||||
|
||||
func (l ledgerDependency) available() bool {
|
||||
return l.client != nil
|
||||
}
|
||||
|
||||
type gatewayDependency struct {
|
||||
client chainclient.Client
|
||||
}
|
||||
|
||||
func (g gatewayDependency) available() bool {
|
||||
return g.client != nil
|
||||
}
|
||||
|
||||
type oracleDependency struct {
|
||||
client oracleclient.Client
|
||||
}
|
||||
|
||||
func (o oracleDependency) available() bool {
|
||||
return o.client != nil
|
||||
}
|
||||
|
||||
// WithFeeEngine wires the fee engine client.
|
||||
func WithFeeEngine(client feesv1.FeeEngineClient, timeout time.Duration) Option {
|
||||
return func(s *Service) {
|
||||
s.fees = feesDependency{
|
||||
client: client,
|
||||
timeout: timeout,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithLedgerClient wires the ledger client.
|
||||
func WithLedgerClient(client ledgerclient.Client) Option {
|
||||
return func(s *Service) {
|
||||
s.ledger = ledgerDependency{client: client}
|
||||
}
|
||||
}
|
||||
|
||||
// WithChainGatewayClient wires the chain gateway client.
|
||||
func WithChainGatewayClient(client chainclient.Client) Option {
|
||||
return func(s *Service) {
|
||||
s.gateway = gatewayDependency{client: client}
|
||||
}
|
||||
}
|
||||
|
||||
// WithOracleClient wires the FX oracle client.
|
||||
func WithOracleClient(client oracleclient.Client) Option {
|
||||
return func(s *Service) {
|
||||
s.oracle = oracleDependency{client: client}
|
||||
}
|
||||
}
|
||||
|
||||
// WithClock overrides the default clock.
|
||||
func WithClock(clock clockpkg.Clock) Option {
|
||||
return func(s *Service) {
|
||||
if clock != nil {
|
||||
s.clock = clock
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/storage"
|
||||
"github.com/tech/sendico/payments/orchestrator/storage/model"
|
||||
"github.com/tech/sendico/pkg/api/routers"
|
||||
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||
clockpkg "github.com/tech/sendico/pkg/clock"
|
||||
"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.mongodb.org/mongo-driver/bson/primitive"
|
||||
"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
|
||||
|
||||
fees feesDependency
|
||||
ledger ledgerDependency
|
||||
gateway gatewayDependency
|
||||
oracle oracleDependency
|
||||
|
||||
orchestratorv1.UnimplementedPaymentOrchestratorServer
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
|
||||
return svc
|
||||
}
|
||||
|
||||
// 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) {
|
||||
return executeUnary(ctx, s, "QuotePayment", s.quotePaymentHandler, req)
|
||||
}
|
||||
|
||||
// InitiatePayment captures a payment intent and reserves funds orchestration.
|
||||
func (s *Service) InitiatePayment(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error) {
|
||||
return executeUnary(ctx, s, "InitiatePayment", s.initiatePaymentHandler, req)
|
||||
}
|
||||
|
||||
// CancelPayment attempts to cancel an in-flight payment.
|
||||
func (s *Service) CancelPayment(ctx context.Context, req *orchestratorv1.CancelPaymentRequest) (*orchestratorv1.CancelPaymentResponse, error) {
|
||||
return executeUnary(ctx, s, "CancelPayment", s.cancelPaymentHandler, req)
|
||||
}
|
||||
|
||||
// GetPayment returns a stored payment record.
|
||||
func (s *Service) GetPayment(ctx context.Context, req *orchestratorv1.GetPaymentRequest) (*orchestratorv1.GetPaymentResponse, error) {
|
||||
return executeUnary(ctx, s, "GetPayment", s.getPaymentHandler, req)
|
||||
}
|
||||
|
||||
// ListPayments lists stored payment records.
|
||||
func (s *Service) ListPayments(ctx context.Context, req *orchestratorv1.ListPaymentsRequest) (*orchestratorv1.ListPaymentsResponse, error) {
|
||||
return executeUnary(ctx, s, "ListPayments", s.listPaymentsHandler, req)
|
||||
}
|
||||
|
||||
// InitiateConversion orchestrates standalone FX conversions.
|
||||
func (s *Service) InitiateConversion(ctx context.Context, req *orchestratorv1.InitiateConversionRequest) (*orchestratorv1.InitiateConversionResponse, error) {
|
||||
return executeUnary(ctx, s, "InitiateConversion", s.initiateConversionHandler, req)
|
||||
}
|
||||
|
||||
// ProcessTransferUpdate reconciles chain events back into payment state.
|
||||
func (s *Service) ProcessTransferUpdate(ctx context.Context, req *orchestratorv1.ProcessTransferUpdateRequest) (*orchestratorv1.ProcessTransferUpdateResponse, error) {
|
||||
return executeUnary(ctx, s, "ProcessTransferUpdate", s.processTransferUpdateHandler, req)
|
||||
}
|
||||
|
||||
// ProcessDepositObserved reconciles deposit events to ledger.
|
||||
func (s *Service) ProcessDepositObserved(ctx context.Context, req *orchestratorv1.ProcessDepositObservedRequest) (*orchestratorv1.ProcessDepositObservedResponse, error) {
|
||||
return executeUnary(ctx, s, "ProcessDepositObserved", s.processDepositObservedHandler, req)
|
||||
}
|
||||
|
||||
func (s *Service) quotePaymentHandler(ctx context.Context, req *orchestratorv1.QuotePaymentRequest) gsresponse.Responder[orchestratorv1.QuotePaymentResponse] {
|
||||
if err := s.ensureRepository(ctx); err != nil {
|
||||
return gsresponse.Unavailable[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
if req == nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
|
||||
}
|
||||
meta := req.GetMeta()
|
||||
if meta == nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("meta is required"))
|
||||
}
|
||||
orgRef := strings.TrimSpace(meta.GetOrganizationRef())
|
||||
if orgRef == "" {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("organization_ref is required"))
|
||||
}
|
||||
intent := req.GetIntent()
|
||||
if intent == nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("intent is required"))
|
||||
}
|
||||
if intent.GetAmount() == nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("intent.amount is required"))
|
||||
}
|
||||
|
||||
quote, err := s.buildPaymentQuote(ctx, orgRef, req)
|
||||
if err != nil {
|
||||
return gsresponse.Auto[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
|
||||
return gsresponse.Success(&orchestratorv1.QuotePaymentResponse{Quote: quote})
|
||||
}
|
||||
|
||||
func (s *Service) initiatePaymentHandler(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) gsresponse.Responder[orchestratorv1.InitiatePaymentResponse] {
|
||||
if err := s.ensureRepository(ctx); err != nil {
|
||||
return gsresponse.Unavailable[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
if req == nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
|
||||
}
|
||||
meta := req.GetMeta()
|
||||
if meta == nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("meta is required"))
|
||||
}
|
||||
orgRef := strings.TrimSpace(meta.GetOrganizationRef())
|
||||
if orgRef == "" {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("organization_ref is required"))
|
||||
}
|
||||
orgObjectID, parseErr := primitive.ObjectIDFromHex(orgRef)
|
||||
if parseErr != nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("organization_ref must be a valid objectID"))
|
||||
}
|
||||
intent := req.GetIntent()
|
||||
if intent == nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("intent is required"))
|
||||
}
|
||||
if intent.GetAmount() == nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("intent.amount is required"))
|
||||
}
|
||||
idempotencyKey := strings.TrimSpace(req.GetIdempotencyKey())
|
||||
if idempotencyKey == "" {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("idempotency_key is required"))
|
||||
}
|
||||
|
||||
store := s.storage.Payments()
|
||||
if store == nil {
|
||||
return gsresponse.Unavailable[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
|
||||
}
|
||||
|
||||
existing, err := store.GetByIdempotencyKey(ctx, orgObjectID, idempotencyKey)
|
||||
if err == nil && existing != nil {
|
||||
return gsresponse.Success(&orchestratorv1.InitiatePaymentResponse{
|
||||
Payment: toProtoPayment(existing),
|
||||
})
|
||||
}
|
||||
if err != nil && err != storage.ErrPaymentNotFound {
|
||||
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
|
||||
quote := req.GetFeeQuoteToken()
|
||||
var quoteSnapshot *orchestratorv1.PaymentQuote
|
||||
if quote == "" {
|
||||
quoteSnapshot, err = s.buildPaymentQuote(ctx, orgRef, &orchestratorv1.QuotePaymentRequest{
|
||||
Meta: req.GetMeta(),
|
||||
IdempotencyKey: req.GetIdempotencyKey(),
|
||||
Intent: req.GetIntent(),
|
||||
PreviewOnly: false,
|
||||
})
|
||||
if err != nil {
|
||||
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
} else {
|
||||
quoteSnapshot = &orchestratorv1.PaymentQuote{FeeQuoteToken: quote}
|
||||
}
|
||||
|
||||
entity := &model.Payment{}
|
||||
entity.SetID(primitive.NewObjectID())
|
||||
entity.SetOrganizationRef(orgObjectID)
|
||||
entity.PaymentRef = entity.GetID().Hex()
|
||||
entity.IdempotencyKey = idempotencyKey
|
||||
entity.State = model.PaymentStateAccepted
|
||||
entity.Intent = intentFromProto(intent)
|
||||
entity.Metadata = cloneMetadata(req.GetMetadata())
|
||||
entity.LastQuote = quoteSnapshotToModel(quoteSnapshot)
|
||||
entity.Normalize()
|
||||
|
||||
if err = store.Create(ctx, entity); err != nil {
|
||||
if err == storage.ErrDuplicatePayment {
|
||||
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.DataConflict("payment already exists"))
|
||||
}
|
||||
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
|
||||
if quoteSnapshot == nil {
|
||||
quoteSnapshot = &orchestratorv1.PaymentQuote{}
|
||||
}
|
||||
|
||||
if err := s.executePayment(ctx, store, entity, quoteSnapshot); err != nil {
|
||||
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
|
||||
return gsresponse.Success(&orchestratorv1.InitiatePaymentResponse{
|
||||
Payment: toProtoPayment(entity),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) cancelPaymentHandler(ctx context.Context, req *orchestratorv1.CancelPaymentRequest) gsresponse.Responder[orchestratorv1.CancelPaymentResponse] {
|
||||
if err := s.ensureRepository(ctx); err != nil {
|
||||
return gsresponse.Unavailable[orchestratorv1.CancelPaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
if req == nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.CancelPaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
|
||||
}
|
||||
paymentRef := strings.TrimSpace(req.GetPaymentRef())
|
||||
if paymentRef == "" {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.CancelPaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("payment_ref is required"))
|
||||
}
|
||||
store := s.storage.Payments()
|
||||
if store == nil {
|
||||
return gsresponse.Unavailable[orchestratorv1.CancelPaymentResponse](s.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
|
||||
}
|
||||
payment, err := store.GetByPaymentRef(ctx, paymentRef)
|
||||
if err != nil {
|
||||
if err == storage.ErrPaymentNotFound {
|
||||
return gsresponse.NotFound[orchestratorv1.CancelPaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
return gsresponse.Auto[orchestratorv1.CancelPaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
if payment.State != model.PaymentStateAccepted {
|
||||
reason := merrors.InvalidArgument("payment cannot be cancelled in current state")
|
||||
return gsresponse.FailedPrecondition[orchestratorv1.CancelPaymentResponse](s.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](s.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
return gsresponse.Success(&orchestratorv1.CancelPaymentResponse{Payment: toProtoPayment(payment)})
|
||||
}
|
||||
|
||||
func (s *Service) getPaymentHandler(ctx context.Context, req *orchestratorv1.GetPaymentRequest) gsresponse.Responder[orchestratorv1.GetPaymentResponse] {
|
||||
if err := s.ensureRepository(ctx); err != nil {
|
||||
return gsresponse.Unavailable[orchestratorv1.GetPaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
if req == nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.GetPaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
|
||||
}
|
||||
paymentRef := strings.TrimSpace(req.GetPaymentRef())
|
||||
if paymentRef == "" {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.GetPaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("payment_ref is required"))
|
||||
}
|
||||
store := s.storage.Payments()
|
||||
if store == nil {
|
||||
return gsresponse.Unavailable[orchestratorv1.GetPaymentResponse](s.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
|
||||
}
|
||||
entity, err := store.GetByPaymentRef(ctx, paymentRef)
|
||||
if err != nil {
|
||||
if err == storage.ErrPaymentNotFound {
|
||||
return gsresponse.NotFound[orchestratorv1.GetPaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
return gsresponse.Auto[orchestratorv1.GetPaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
return gsresponse.Success(&orchestratorv1.GetPaymentResponse{Payment: toProtoPayment(entity)})
|
||||
}
|
||||
|
||||
func (s *Service) listPaymentsHandler(ctx context.Context, req *orchestratorv1.ListPaymentsRequest) gsresponse.Responder[orchestratorv1.ListPaymentsResponse] {
|
||||
if err := s.ensureRepository(ctx); err != nil {
|
||||
return gsresponse.Unavailable[orchestratorv1.ListPaymentsResponse](s.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
if req == nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.ListPaymentsResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
|
||||
}
|
||||
store := s.storage.Payments()
|
||||
if store == nil {
|
||||
return gsresponse.Unavailable[orchestratorv1.ListPaymentsResponse](s.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
|
||||
}
|
||||
filter := filterFromProto(req)
|
||||
result, err := store.List(ctx, filter)
|
||||
if err != nil {
|
||||
return gsresponse.Auto[orchestratorv1.ListPaymentsResponse](s.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))
|
||||
}
|
||||
return gsresponse.Success(resp)
|
||||
}
|
||||
|
||||
func (s *Service) initiateConversionHandler(ctx context.Context, req *orchestratorv1.InitiateConversionRequest) gsresponse.Responder[orchestratorv1.InitiateConversionResponse] {
|
||||
if err := s.ensureRepository(ctx); err != nil {
|
||||
return gsresponse.Unavailable[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
if req == nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
|
||||
}
|
||||
meta := req.GetMeta()
|
||||
if meta == nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("meta is required"))
|
||||
}
|
||||
orgRef := strings.TrimSpace(meta.GetOrganizationRef())
|
||||
if orgRef == "" {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("organization_ref is required"))
|
||||
}
|
||||
orgObjectID, parseErr := primitive.ObjectIDFromHex(orgRef)
|
||||
if parseErr != nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("organization_ref must be a valid objectID"))
|
||||
}
|
||||
idempotencyKey := strings.TrimSpace(req.GetIdempotencyKey())
|
||||
if idempotencyKey == "" {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("idempotency_key is required"))
|
||||
}
|
||||
if req.GetSource() == nil || req.GetSource().GetLedger() == nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("source ledger endpoint is required"))
|
||||
}
|
||||
if req.GetDestination() == nil || req.GetDestination().GetLedger() == nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("destination ledger endpoint is required"))
|
||||
}
|
||||
fxIntent := req.GetFx()
|
||||
if fxIntent == nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("fx intent is required"))
|
||||
}
|
||||
|
||||
store := s.storage.Payments()
|
||||
if store == nil {
|
||||
return gsresponse.Unavailable[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
|
||||
}
|
||||
|
||||
if existing, err := store.GetByIdempotencyKey(ctx, orgObjectID, idempotencyKey); err == nil && existing != nil {
|
||||
return gsresponse.Success(&orchestratorv1.InitiateConversionResponse{Conversion: toProtoPayment(existing)})
|
||||
} else if err != nil && err != storage.ErrPaymentNotFound {
|
||||
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
|
||||
amount, err := conversionAmountFromMetadata(req.GetMetadata(), fxIntent)
|
||||
if err != nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
|
||||
intentProto := &orchestratorv1.PaymentIntent{
|
||||
Kind: orchestratorv1.PaymentKind_PAYMENT_KIND_FX_CONVERSION,
|
||||
Source: req.GetSource(),
|
||||
Destination: req.GetDestination(),
|
||||
Amount: amount,
|
||||
RequiresFx: true,
|
||||
Fx: fxIntent,
|
||||
FeePolicy: req.GetFeePolicy(),
|
||||
}
|
||||
|
||||
quote, err := s.buildPaymentQuote(ctx, orgRef, &orchestratorv1.QuotePaymentRequest{
|
||||
Meta: req.GetMeta(),
|
||||
IdempotencyKey: req.GetIdempotencyKey(),
|
||||
Intent: intentProto,
|
||||
})
|
||||
if err != nil {
|
||||
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
|
||||
entity := &model.Payment{}
|
||||
entity.SetID(primitive.NewObjectID())
|
||||
entity.SetOrganizationRef(orgObjectID)
|
||||
entity.PaymentRef = entity.GetID().Hex()
|
||||
entity.IdempotencyKey = idempotencyKey
|
||||
entity.State = model.PaymentStateAccepted
|
||||
entity.Intent = intentFromProto(intentProto)
|
||||
entity.Metadata = cloneMetadata(req.GetMetadata())
|
||||
entity.LastQuote = quoteSnapshotToModel(quote)
|
||||
entity.Normalize()
|
||||
|
||||
if err = store.Create(ctx, entity); err != nil {
|
||||
if err == storage.ErrDuplicatePayment {
|
||||
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, merrors.DataConflict("payment already exists"))
|
||||
}
|
||||
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
|
||||
if err := s.executePayment(ctx, store, entity, quote); err != nil {
|
||||
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
|
||||
return gsresponse.Success(&orchestratorv1.InitiateConversionResponse{
|
||||
Conversion: toProtoPayment(entity),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) processTransferUpdateHandler(ctx context.Context, req *orchestratorv1.ProcessTransferUpdateRequest) gsresponse.Responder[orchestratorv1.ProcessTransferUpdateResponse] {
|
||||
if err := s.ensureRepository(ctx); err != nil {
|
||||
return gsresponse.Unavailable[orchestratorv1.ProcessTransferUpdateResponse](s.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
if req == nil || req.GetEvent() == nil || req.GetEvent().GetTransfer() == nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.ProcessTransferUpdateResponse](s.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](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("transfer_ref is required"))
|
||||
}
|
||||
store := s.storage.Payments()
|
||||
if store == nil {
|
||||
return gsresponse.Unavailable[orchestratorv1.ProcessTransferUpdateResponse](s.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
|
||||
}
|
||||
payment, err := store.GetByChainTransferRef(ctx, transferRef)
|
||||
if err != nil {
|
||||
if err == storage.ErrPaymentNotFound {
|
||||
return gsresponse.NotFound[orchestratorv1.ProcessTransferUpdateResponse](s.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
return gsresponse.Auto[orchestratorv1.ProcessTransferUpdateResponse](s.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
applyTransferStatus(req.GetEvent(), payment)
|
||||
if err := store.Update(ctx, payment); err != nil {
|
||||
return gsresponse.Auto[orchestratorv1.ProcessTransferUpdateResponse](s.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
return gsresponse.Success(&orchestratorv1.ProcessTransferUpdateResponse{Payment: toProtoPayment(payment)})
|
||||
}
|
||||
|
||||
func (s *Service) processDepositObservedHandler(ctx context.Context, req *orchestratorv1.ProcessDepositObservedRequest) gsresponse.Responder[orchestratorv1.ProcessDepositObservedResponse] {
|
||||
if err := s.ensureRepository(ctx); err != nil {
|
||||
return gsresponse.Unavailable[orchestratorv1.ProcessDepositObservedResponse](s.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
if req == nil || req.GetEvent() == nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.ProcessDepositObservedResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("deposit event is required"))
|
||||
}
|
||||
event := req.GetEvent()
|
||||
walletRef := strings.TrimSpace(event.GetWalletRef())
|
||||
if walletRef == "" {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.ProcessDepositObservedResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("wallet_ref is required"))
|
||||
}
|
||||
store := s.storage.Payments()
|
||||
if store == nil {
|
||||
return gsresponse.Unavailable[orchestratorv1.ProcessDepositObservedResponse](s.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](s.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](s.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
return gsresponse.Success(&orchestratorv1.ProcessDepositObservedResponse{Payment: toProtoPayment(payment)})
|
||||
}
|
||||
return gsresponse.Success(&orchestratorv1.ProcessDepositObservedResponse{})
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
chainclient "github.com/tech/sendico/chain/gateway/client"
|
||||
ledgerclient "github.com/tech/sendico/ledger/client"
|
||||
"github.com/tech/sendico/payments/orchestrator/storage"
|
||||
"github.com/tech/sendico/payments/orchestrator/storage/model"
|
||||
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||
mo "github.com/tech/sendico/pkg/model"
|
||||
gatewayv1 "github.com/tech/sendico/pkg/proto/chain/gateway/v1"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/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"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestExecutePayment_FXConversionSettled(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
store := newStubPaymentsStore()
|
||||
repo := &stubRepository{store: store}
|
||||
svc := &Service{
|
||||
logger: zap.NewNop(),
|
||||
clock: testClock{now: time.Now()},
|
||||
storage: repo,
|
||||
ledger: ledgerDependency{client: &ledgerclient.Fake{
|
||||
ApplyFXWithChargesFn: func(ctx context.Context, req *ledgerv1.FXRequest) (*ledgerv1.PostResponse, error) {
|
||||
return &ledgerv1.PostResponse{JournalEntryRef: "fx-entry"}, nil
|
||||
},
|
||||
}},
|
||||
}
|
||||
|
||||
payment := &model.Payment{
|
||||
PaymentRef: "fx-1",
|
||||
IdempotencyKey: "fx-1",
|
||||
OrganizationBoundBase: mo.OrganizationBoundBase{OrganizationRef: primitive.NewObjectID()},
|
||||
Intent: model.PaymentIntent{
|
||||
Kind: model.PaymentKindFXConversion,
|
||||
Source: model.PaymentEndpoint{
|
||||
Type: model.EndpointTypeLedger,
|
||||
Ledger: &model.LedgerEndpoint{LedgerAccountRef: "ledger:source"},
|
||||
},
|
||||
Destination: model.PaymentEndpoint{
|
||||
Type: model.EndpointTypeLedger,
|
||||
Ledger: &model.LedgerEndpoint{LedgerAccountRef: "ledger:dest"},
|
||||
},
|
||||
Amount: &moneyv1.Money{Currency: "USD", Amount: "100"},
|
||||
},
|
||||
}
|
||||
store.payments[payment.PaymentRef] = payment
|
||||
|
||||
quote := &orchestratorv1.PaymentQuote{
|
||||
FxQuote: &oraclev1.Quote{
|
||||
QuoteRef: "quote-1",
|
||||
BaseAmount: &moneyv1.Money{Currency: "USD", Amount: "100"},
|
||||
QuoteAmount: &moneyv1.Money{Currency: "EUR", Amount: "90"},
|
||||
Price: &moneyv1.Decimal{Value: "0.9"},
|
||||
},
|
||||
}
|
||||
|
||||
if err := svc.executePayment(ctx, store, payment, quote); err != nil {
|
||||
t.Fatalf("executePayment returned error: %v", err)
|
||||
}
|
||||
|
||||
if payment.State != model.PaymentStateSettled {
|
||||
t.Fatalf("expected payment settled, got %s", payment.State)
|
||||
}
|
||||
if payment.Execution == nil || payment.Execution.FXEntryRef == "" {
|
||||
t.Fatal("expected FX entry ref set on payment execution")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutePayment_ChainFailure(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
store := newStubPaymentsStore()
|
||||
repo := &stubRepository{store: store}
|
||||
svc := &Service{
|
||||
logger: zap.NewNop(),
|
||||
clock: testClock{now: time.Now()},
|
||||
storage: repo,
|
||||
gateway: gatewayDependency{client: &chainclient.Fake{
|
||||
SubmitTransferFn: func(ctx context.Context, req *gatewayv1.SubmitTransferRequest) (*gatewayv1.SubmitTransferResponse, error) {
|
||||
return nil, errors.New("chain failure")
|
||||
},
|
||||
}},
|
||||
}
|
||||
|
||||
payment := &model.Payment{
|
||||
PaymentRef: "chain-1",
|
||||
IdempotencyKey: "chain-1",
|
||||
OrganizationBoundBase: mo.OrganizationBoundBase{OrganizationRef: primitive.NewObjectID()},
|
||||
Intent: model.PaymentIntent{
|
||||
Kind: model.PaymentKindPayout,
|
||||
Source: model.PaymentEndpoint{
|
||||
Type: model.EndpointTypeManagedWallet,
|
||||
ManagedWallet: &model.ManagedWalletEndpoint{
|
||||
ManagedWalletRef: "wallet-src",
|
||||
},
|
||||
},
|
||||
Destination: model.PaymentEndpoint{
|
||||
Type: model.EndpointTypeManagedWallet,
|
||||
ManagedWallet: &model.ManagedWalletEndpoint{
|
||||
ManagedWalletRef: "wallet-dst",
|
||||
},
|
||||
},
|
||||
Amount: &moneyv1.Money{Currency: "USD", Amount: "50"},
|
||||
},
|
||||
}
|
||||
store.payments[payment.PaymentRef] = payment
|
||||
|
||||
err := svc.executePayment(ctx, store, payment, &orchestratorv1.PaymentQuote{})
|
||||
if err == nil || err.Error() != "chain failure" {
|
||||
t.Fatalf("expected chain failure error, got %v", err)
|
||||
}
|
||||
if payment.State != model.PaymentStateFailed {
|
||||
t.Fatalf("expected payment failed, got %s", payment.State)
|
||||
}
|
||||
if payment.FailureCode != model.PaymentFailureCodeChain {
|
||||
t.Fatalf("expected failure code chain, got %s", payment.FailureCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessTransferUpdateHandler_Settled(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
payment := &model.Payment{
|
||||
PaymentRef: "pay-1",
|
||||
State: model.PaymentStateSubmitted,
|
||||
Execution: &model.ExecutionRefs{ChainTransferRef: "transfer-1"},
|
||||
}
|
||||
store := newStubPaymentsStore()
|
||||
store.payments[payment.PaymentRef] = payment
|
||||
store.byChain["transfer-1"] = payment
|
||||
|
||||
svc := &Service{
|
||||
logger: zap.NewNop(),
|
||||
clock: testClock{now: time.Now()},
|
||||
storage: &stubRepository{store: store},
|
||||
}
|
||||
|
||||
req := &orchestratorv1.ProcessTransferUpdateRequest{
|
||||
Event: &gatewayv1.TransferStatusChangedEvent{
|
||||
Transfer: &gatewayv1.Transfer{
|
||||
TransferRef: "transfer-1",
|
||||
Status: gatewayv1.TransferStatus_TRANSFER_CONFIRMED,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
reSP, err := gsresponse.Execute(ctx, svc.processTransferUpdateHandler(ctx, req))
|
||||
if err != nil {
|
||||
t.Fatalf("handler returned error: %v", err)
|
||||
}
|
||||
if reSP.GetPayment().GetState() != orchestratorv1.PaymentState_PAYMENT_STATE_SETTLED {
|
||||
t.Fatalf("expected settled state, got %s", reSP.GetPayment().GetState())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessDepositObservedHandler_MatchesPayment(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
payment := &model.Payment{
|
||||
PaymentRef: "pay-2",
|
||||
State: model.PaymentStateSubmitted,
|
||||
Intent: model.PaymentIntent{
|
||||
Destination: model.PaymentEndpoint{
|
||||
Type: model.EndpointTypeManagedWallet,
|
||||
ManagedWallet: &model.ManagedWalletEndpoint{
|
||||
ManagedWalletRef: "wallet-dst",
|
||||
},
|
||||
},
|
||||
Amount: &moneyv1.Money{Currency: "USD", Amount: "40"},
|
||||
},
|
||||
}
|
||||
store := newStubPaymentsStore()
|
||||
store.listResp = &model.PaymentList{Items: []*model.Payment{payment}}
|
||||
store.payments[payment.PaymentRef] = payment
|
||||
|
||||
svc := &Service{
|
||||
logger: zap.NewNop(),
|
||||
clock: testClock{now: time.Now()},
|
||||
storage: &stubRepository{store: store},
|
||||
}
|
||||
|
||||
req := &orchestratorv1.ProcessDepositObservedRequest{
|
||||
Event: &gatewayv1.WalletDepositObservedEvent{
|
||||
WalletRef: "wallet-dst",
|
||||
Amount: &moneyv1.Money{Currency: "USD", Amount: "40"},
|
||||
},
|
||||
}
|
||||
|
||||
reSP, err := gsresponse.Execute(ctx, svc.processDepositObservedHandler(ctx, req))
|
||||
if err != nil {
|
||||
t.Fatalf("handler returned error: %v", err)
|
||||
}
|
||||
if reSP.GetPayment().GetState() != orchestratorv1.PaymentState_PAYMENT_STATE_SETTLED {
|
||||
t.Fatalf("expected settled state, got %s", reSP.GetPayment().GetState())
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type stubRepository struct {
|
||||
store *stubPaymentsStore
|
||||
}
|
||||
|
||||
func (r *stubRepository) Ping(context.Context) error { return nil }
|
||||
func (r *stubRepository) Payments() storage.PaymentsStore { return r.store }
|
||||
|
||||
type stubPaymentsStore struct {
|
||||
payments map[string]*model.Payment
|
||||
byChain map[string]*model.Payment
|
||||
listResp *model.PaymentList
|
||||
}
|
||||
|
||||
func newStubPaymentsStore() *stubPaymentsStore {
|
||||
return &stubPaymentsStore{
|
||||
payments: map[string]*model.Payment{},
|
||||
byChain: map[string]*model.Payment{},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *stubPaymentsStore) Create(ctx context.Context, payment *model.Payment) error {
|
||||
if _, exists := s.payments[payment.PaymentRef]; exists {
|
||||
return storage.ErrDuplicatePayment
|
||||
}
|
||||
s.payments[payment.PaymentRef] = payment
|
||||
if payment.Execution != nil && payment.Execution.ChainTransferRef != "" {
|
||||
s.byChain[payment.Execution.ChainTransferRef] = payment
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubPaymentsStore) Update(ctx context.Context, payment *model.Payment) error {
|
||||
if _, exists := s.payments[payment.PaymentRef]; !exists {
|
||||
return storage.ErrPaymentNotFound
|
||||
}
|
||||
s.payments[payment.PaymentRef] = payment
|
||||
if payment.Execution != nil && payment.Execution.ChainTransferRef != "" {
|
||||
s.byChain[payment.Execution.ChainTransferRef] = payment
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubPaymentsStore) GetByPaymentRef(ctx context.Context, paymentRef string) (*model.Payment, error) {
|
||||
if p, ok := s.payments[paymentRef]; ok {
|
||||
return p, nil
|
||||
}
|
||||
return nil, storage.ErrPaymentNotFound
|
||||
}
|
||||
|
||||
func (s *stubPaymentsStore) GetByIdempotencyKey(ctx context.Context, orgRef primitive.ObjectID, key string) (*model.Payment, error) {
|
||||
for _, p := range s.payments {
|
||||
if p.OrganizationRef == orgRef && strings.TrimSpace(p.IdempotencyKey) == key {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
return nil, storage.ErrPaymentNotFound
|
||||
}
|
||||
|
||||
func (s *stubPaymentsStore) GetByChainTransferRef(ctx context.Context, transferRef string) (*model.Payment, error) {
|
||||
if p, ok := s.byChain[transferRef]; ok {
|
||||
return p, nil
|
||||
}
|
||||
return nil, storage.ErrPaymentNotFound
|
||||
}
|
||||
|
||||
func (s *stubPaymentsStore) List(ctx context.Context, filter *model.PaymentFilter) (*model.PaymentList, error) {
|
||||
if s.listResp != nil {
|
||||
return s.listResp, nil
|
||||
}
|
||||
return &model.PaymentList{}, nil
|
||||
}
|
||||
|
||||
var _ storage.PaymentsStore = (*stubPaymentsStore)(nil)
|
||||
|
||||
// testClock satisfies clock.Clock
|
||||
|
||||
type testClock struct {
|
||||
now time.Time
|
||||
}
|
||||
|
||||
func (c testClock) Now() time.Time { return c.now }
|
||||
226
api/payments/orchestrator/storage/model/payment.go
Normal file
226
api/payments/orchestrator/storage/model/payment.go
Normal file
@@ -0,0 +1,226 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/pkg/db/storable"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||
gatewayv1 "github.com/tech/sendico/pkg/proto/chain/gateway/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"
|
||||
)
|
||||
|
||||
// PaymentKind captures the orchestrator intent type.
|
||||
type PaymentKind string
|
||||
|
||||
const (
|
||||
PaymentKindUnspecified PaymentKind = "unspecified"
|
||||
PaymentKindPayout PaymentKind = "payout"
|
||||
PaymentKindInternalTransfer PaymentKind = "internal_transfer"
|
||||
PaymentKindFXConversion PaymentKind = "fx_conversion"
|
||||
)
|
||||
|
||||
// PaymentState enumerates lifecycle phases.
|
||||
type PaymentState string
|
||||
|
||||
const (
|
||||
PaymentStateUnspecified PaymentState = "unspecified"
|
||||
PaymentStateAccepted PaymentState = "accepted"
|
||||
PaymentStateFundsReserved PaymentState = "funds_reserved"
|
||||
PaymentStateSubmitted PaymentState = "submitted"
|
||||
PaymentStateSettled PaymentState = "settled"
|
||||
PaymentStateFailed PaymentState = "failed"
|
||||
PaymentStateCancelled PaymentState = "cancelled"
|
||||
)
|
||||
|
||||
// PaymentFailureCode captures terminal reasons.
|
||||
type PaymentFailureCode string
|
||||
|
||||
const (
|
||||
PaymentFailureCodeUnspecified PaymentFailureCode = "unspecified"
|
||||
PaymentFailureCodeBalance PaymentFailureCode = "balance"
|
||||
PaymentFailureCodeLedger PaymentFailureCode = "ledger"
|
||||
PaymentFailureCodeFX PaymentFailureCode = "fx"
|
||||
PaymentFailureCodeChain PaymentFailureCode = "chain"
|
||||
PaymentFailureCodeFees PaymentFailureCode = "fees"
|
||||
PaymentFailureCodePolicy PaymentFailureCode = "policy"
|
||||
)
|
||||
|
||||
// PaymentEndpointType indicates how value should be routed.
|
||||
type PaymentEndpointType string
|
||||
|
||||
const (
|
||||
EndpointTypeUnspecified PaymentEndpointType = "unspecified"
|
||||
EndpointTypeLedger PaymentEndpointType = "ledger"
|
||||
EndpointTypeManagedWallet PaymentEndpointType = "managed_wallet"
|
||||
EndpointTypeExternalChain PaymentEndpointType = "external_chain"
|
||||
)
|
||||
|
||||
// LedgerEndpoint describes ledger routing.
|
||||
type LedgerEndpoint struct {
|
||||
LedgerAccountRef string `bson:"ledgerAccountRef" json:"ledgerAccountRef"`
|
||||
ContraLedgerAccountRef string `bson:"contraLedgerAccountRef,omitempty" json:"contraLedgerAccountRef,omitempty"`
|
||||
}
|
||||
|
||||
// ManagedWalletEndpoint describes managed wallet routing.
|
||||
type ManagedWalletEndpoint struct {
|
||||
ManagedWalletRef string `bson:"managedWalletRef" json:"managedWalletRef"`
|
||||
Asset *gatewayv1.Asset `bson:"asset,omitempty" json:"asset,omitempty"`
|
||||
}
|
||||
|
||||
// ExternalChainEndpoint describes an external address.
|
||||
type ExternalChainEndpoint struct {
|
||||
Asset *gatewayv1.Asset `bson:"asset,omitempty" json:"asset,omitempty"`
|
||||
Address string `bson:"address" json:"address"`
|
||||
Memo string `bson:"memo,omitempty" json:"memo,omitempty"`
|
||||
}
|
||||
|
||||
// PaymentEndpoint is a polymorphic payment destination/source.
|
||||
type PaymentEndpoint struct {
|
||||
Type PaymentEndpointType `bson:"type" json:"type"`
|
||||
Ledger *LedgerEndpoint `bson:"ledger,omitempty" json:"ledger,omitempty"`
|
||||
ManagedWallet *ManagedWalletEndpoint `bson:"managedWallet,omitempty" json:"managedWallet,omitempty"`
|
||||
ExternalChain *ExternalChainEndpoint `bson:"externalChain,omitempty" json:"externalChain,omitempty"`
|
||||
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// FXIntent captures FX conversion preferences.
|
||||
type FXIntent struct {
|
||||
Pair *fxv1.CurrencyPair `bson:"pair,omitempty" json:"pair,omitempty"`
|
||||
Side fxv1.Side `bson:"side,omitempty" json:"side,omitempty"`
|
||||
Firm bool `bson:"firm,omitempty" json:"firm,omitempty"`
|
||||
TTLMillis int64 `bson:"ttlMillis,omitempty" json:"ttlMillis,omitempty"`
|
||||
PreferredProvider string `bson:"preferredProvider,omitempty" json:"preferredProvider,omitempty"`
|
||||
MaxAgeMillis int32 `bson:"maxAgeMillis,omitempty" json:"maxAgeMillis,omitempty"`
|
||||
}
|
||||
|
||||
// PaymentIntent models the requested payment operation.
|
||||
type PaymentIntent struct {
|
||||
Kind PaymentKind `bson:"kind" json:"kind"`
|
||||
Source PaymentEndpoint `bson:"source" json:"source"`
|
||||
Destination PaymentEndpoint `bson:"destination" json:"destination"`
|
||||
Amount *moneyv1.Money `bson:"amount" json:"amount"`
|
||||
RequiresFX bool `bson:"requiresFx,omitempty" json:"requiresFx,omitempty"`
|
||||
FX *FXIntent `bson:"fx,omitempty" json:"fx,omitempty"`
|
||||
FeePolicy *feesv1.PolicyOverrides `bson:"feePolicy,omitempty" json:"feePolicy,omitempty"`
|
||||
Attributes map[string]string `bson:"attributes,omitempty" json:"attributes,omitempty"`
|
||||
}
|
||||
|
||||
// PaymentQuoteSnapshot stores the latest quote info.
|
||||
type PaymentQuoteSnapshot struct {
|
||||
DebitAmount *moneyv1.Money `bson:"debitAmount,omitempty" json:"debitAmount,omitempty"`
|
||||
ExpectedSettlementAmount *moneyv1.Money `bson:"expectedSettlementAmount,omitempty" json:"expectedSettlementAmount,omitempty"`
|
||||
ExpectedFeeTotal *moneyv1.Money `bson:"expectedFeeTotal,omitempty" json:"expectedFeeTotal,omitempty"`
|
||||
FeeLines []*feesv1.DerivedPostingLine `bson:"feeLines,omitempty" json:"feeLines,omitempty"`
|
||||
FeeRules []*feesv1.AppliedRule `bson:"feeRules,omitempty" json:"feeRules,omitempty"`
|
||||
FXQuote *oraclev1.Quote `bson:"fxQuote,omitempty" json:"fxQuote,omitempty"`
|
||||
NetworkFee *gatewayv1.EstimateTransferFeeResponse `bson:"networkFee,omitempty" json:"networkFee,omitempty"`
|
||||
FeeQuoteToken string `bson:"feeQuoteToken,omitempty" json:"feeQuoteToken,omitempty"`
|
||||
}
|
||||
|
||||
// ExecutionRefs links to downstream systems.
|
||||
type ExecutionRefs struct {
|
||||
DebitEntryRef string `bson:"debitEntryRef,omitempty" json:"debitEntryRef,omitempty"`
|
||||
CreditEntryRef string `bson:"creditEntryRef,omitempty" json:"creditEntryRef,omitempty"`
|
||||
FXEntryRef string `bson:"fxEntryRef,omitempty" json:"fxEntryRef,omitempty"`
|
||||
ChainTransferRef string `bson:"chainTransferRef,omitempty" json:"chainTransferRef,omitempty"`
|
||||
}
|
||||
|
||||
// Payment persists orchestrated payment lifecycle.
|
||||
type Payment struct {
|
||||
storable.Base `bson:",inline" json:",inline"`
|
||||
model.OrganizationBoundBase `bson:",inline" json:",inline"`
|
||||
|
||||
PaymentRef string `bson:"paymentRef" json:"paymentRef"`
|
||||
IdempotencyKey string `bson:"idempotencyKey" json:"idempotencyKey"`
|
||||
Intent PaymentIntent `bson:"intent" json:"intent"`
|
||||
State PaymentState `bson:"state" json:"state"`
|
||||
FailureCode PaymentFailureCode `bson:"failureCode,omitempty" json:"failureCode,omitempty"`
|
||||
FailureReason string `bson:"failureReason,omitempty" json:"failureReason,omitempty"`
|
||||
LastQuote *PaymentQuoteSnapshot `bson:"lastQuote,omitempty" json:"lastQuote,omitempty"`
|
||||
Execution *ExecutionRefs `bson:"execution,omitempty" json:"execution,omitempty"`
|
||||
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// Collection implements storable.Storable.
|
||||
func (*Payment) Collection() string {
|
||||
return mservice.Payments
|
||||
}
|
||||
|
||||
// PaymentFilter enables filtered queries.
|
||||
type PaymentFilter struct {
|
||||
States []PaymentState
|
||||
SourceRef string
|
||||
DestinationRef string
|
||||
Cursor string
|
||||
Limit int32
|
||||
}
|
||||
|
||||
// PaymentList contains paginated results.
|
||||
type PaymentList struct {
|
||||
Items []*Payment
|
||||
NextCursor string
|
||||
}
|
||||
|
||||
// Normalize harmonises string fields for indexing and comparisons.
|
||||
func (p *Payment) Normalize() {
|
||||
p.PaymentRef = strings.TrimSpace(p.PaymentRef)
|
||||
p.IdempotencyKey = strings.TrimSpace(p.IdempotencyKey)
|
||||
p.FailureReason = strings.TrimSpace(p.FailureReason)
|
||||
if p.Metadata != nil {
|
||||
for k, v := range p.Metadata {
|
||||
p.Metadata[k] = strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
normalizeEndpoint(&p.Intent.Source)
|
||||
normalizeEndpoint(&p.Intent.Destination)
|
||||
if p.Intent.Attributes != nil {
|
||||
for k, v := range p.Intent.Attributes {
|
||||
p.Intent.Attributes[k] = strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
if p.Execution != nil {
|
||||
p.Execution.DebitEntryRef = strings.TrimSpace(p.Execution.DebitEntryRef)
|
||||
p.Execution.CreditEntryRef = strings.TrimSpace(p.Execution.CreditEntryRef)
|
||||
p.Execution.FXEntryRef = strings.TrimSpace(p.Execution.FXEntryRef)
|
||||
p.Execution.ChainTransferRef = strings.TrimSpace(p.Execution.ChainTransferRef)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeEndpoint(ep *PaymentEndpoint) {
|
||||
if ep == nil {
|
||||
return
|
||||
}
|
||||
if ep.Metadata != nil {
|
||||
for k, v := range ep.Metadata {
|
||||
ep.Metadata[k] = strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
switch ep.Type {
|
||||
case EndpointTypeLedger:
|
||||
if ep.Ledger != nil {
|
||||
ep.Ledger.LedgerAccountRef = strings.TrimSpace(ep.Ledger.LedgerAccountRef)
|
||||
ep.Ledger.ContraLedgerAccountRef = strings.TrimSpace(ep.Ledger.ContraLedgerAccountRef)
|
||||
}
|
||||
case EndpointTypeManagedWallet:
|
||||
if ep.ManagedWallet != nil {
|
||||
ep.ManagedWallet.ManagedWalletRef = strings.TrimSpace(ep.ManagedWallet.ManagedWalletRef)
|
||||
if ep.ManagedWallet.Asset != nil {
|
||||
ep.ManagedWallet.Asset.TokenSymbol = strings.TrimSpace(strings.ToUpper(ep.ManagedWallet.Asset.TokenSymbol))
|
||||
ep.ManagedWallet.Asset.ContractAddress = strings.TrimSpace(strings.ToLower(ep.ManagedWallet.Asset.ContractAddress))
|
||||
}
|
||||
}
|
||||
case EndpointTypeExternalChain:
|
||||
if ep.ExternalChain != nil {
|
||||
ep.ExternalChain.Address = strings.TrimSpace(strings.ToLower(ep.ExternalChain.Address))
|
||||
ep.ExternalChain.Memo = strings.TrimSpace(ep.ExternalChain.Memo)
|
||||
if ep.ExternalChain.Asset != nil {
|
||||
ep.ExternalChain.Asset.TokenSymbol = strings.TrimSpace(strings.ToUpper(ep.ExternalChain.Asset.TokenSymbol))
|
||||
ep.ExternalChain.Asset.ContractAddress = strings.TrimSpace(strings.ToLower(ep.ExternalChain.Asset.ContractAddress))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
68
api/payments/orchestrator/storage/mongo/repository.go
Normal file
68
api/payments/orchestrator/storage/mongo/repository.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package mongo
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/storage"
|
||||
"github.com/tech/sendico/payments/orchestrator/storage/model"
|
||||
"github.com/tech/sendico/payments/orchestrator/storage/mongo/store"
|
||||
"github.com/tech/sendico/pkg/db"
|
||||
"github.com/tech/sendico/pkg/db/repository"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
)
|
||||
|
||||
// Store implements storage.Repository backed by MongoDB.
|
||||
type Store struct {
|
||||
logger mlogger.Logger
|
||||
ping func(context.Context) error
|
||||
|
||||
payments storage.PaymentsStore
|
||||
}
|
||||
|
||||
// New constructs a Mongo-backed payments repository from a Mongo connection.
|
||||
func New(logger mlogger.Logger, conn *db.MongoConnection) (*Store, error) {
|
||||
if conn == nil {
|
||||
return nil, merrors.InvalidArgument("payments.storage.mongo: connection is nil")
|
||||
}
|
||||
repo := repository.CreateMongoRepository(conn.Database(), (&model.Payment{}).Collection())
|
||||
return NewWithRepository(logger, conn.Ping, repo)
|
||||
}
|
||||
|
||||
// NewWithRepository constructs a payments repository using the provided primitives.
|
||||
func NewWithRepository(logger mlogger.Logger, ping func(context.Context) error, paymentsRepo repository.Repository) (*Store, error) {
|
||||
if ping == nil {
|
||||
return nil, merrors.InvalidArgument("payments.storage.mongo: ping func is nil")
|
||||
}
|
||||
if paymentsRepo == nil {
|
||||
return nil, merrors.InvalidArgument("payments.storage.mongo: payments repository is nil")
|
||||
}
|
||||
|
||||
childLogger := logger.Named("storage").Named("mongo")
|
||||
paymentsStore, err := store.NewPayments(childLogger, paymentsRepo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := &Store{
|
||||
logger: childLogger,
|
||||
ping: ping,
|
||||
payments: paymentsStore,
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Ping verifies connectivity with the backing database.
|
||||
func (s *Store) Ping(ctx context.Context) error {
|
||||
if s.ping == nil {
|
||||
return merrors.InvalidArgument("payments.storage.mongo: ping func is nil")
|
||||
}
|
||||
return s.ping(ctx)
|
||||
}
|
||||
|
||||
// Payments returns the payments store.
|
||||
func (s *Store) Payments() storage.PaymentsStore {
|
||||
return s.payments
|
||||
}
|
||||
|
||||
var _ storage.Repository = (*Store)(nil)
|
||||
266
api/payments/orchestrator/storage/mongo/store/payments.go
Normal file
266
api/payments/orchestrator/storage/mongo/store/payments.go
Normal file
@@ -0,0 +1,266 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/storage"
|
||||
"github.com/tech/sendico/payments/orchestrator/storage/model"
|
||||
"github.com/tech/sendico/pkg/db/repository"
|
||||
"github.com/tech/sendico/pkg/db/repository/builder"
|
||||
ri "github.com/tech/sendico/pkg/db/repository/index"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultPaymentPageSize int64 = 50
|
||||
maxPaymentPageSize int64 = 200
|
||||
)
|
||||
|
||||
type Payments struct {
|
||||
logger mlogger.Logger
|
||||
repo repository.Repository
|
||||
}
|
||||
|
||||
// NewPayments constructs a Mongo-backed payments store.
|
||||
func NewPayments(logger mlogger.Logger, repo repository.Repository) (*Payments, error) {
|
||||
if repo == nil {
|
||||
return nil, merrors.InvalidArgument("paymentsStore: repository is nil")
|
||||
}
|
||||
|
||||
indexes := []*ri.Definition{
|
||||
{
|
||||
Keys: []ri.Key{{Field: "paymentRef", Sort: ri.Asc}},
|
||||
Unique: true,
|
||||
},
|
||||
{
|
||||
Keys: []ri.Key{{Field: "idempotencyKey", Sort: ri.Asc}, {Field: "organizationRef", Sort: ri.Asc}},
|
||||
Unique: true,
|
||||
},
|
||||
{
|
||||
Keys: []ri.Key{{Field: "state", Sort: ri.Asc}},
|
||||
},
|
||||
{
|
||||
Keys: []ri.Key{{Field: "intent.source.managedWallet.managedWalletRef", Sort: ri.Asc}},
|
||||
},
|
||||
{
|
||||
Keys: []ri.Key{{Field: "intent.destination.managedWallet.managedWalletRef", Sort: ri.Asc}},
|
||||
},
|
||||
{
|
||||
Keys: []ri.Key{{Field: "execution.chainTransferRef", Sort: ri.Asc}},
|
||||
},
|
||||
}
|
||||
|
||||
for _, def := range indexes {
|
||||
if err := repo.CreateIndex(def); err != nil {
|
||||
logger.Error("failed to ensure payments index", zap.Error(err), zap.String("collection", repo.Collection()))
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
childLogger := logger.Named("payments")
|
||||
childLogger.Debug("payments store initialised")
|
||||
|
||||
return &Payments{
|
||||
logger: childLogger,
|
||||
repo: repo,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Payments) Create(ctx context.Context, payment *model.Payment) error {
|
||||
if payment == nil {
|
||||
return merrors.InvalidArgument("paymentsStore: nil payment")
|
||||
}
|
||||
payment.Normalize()
|
||||
if payment.PaymentRef == "" {
|
||||
return merrors.InvalidArgument("paymentsStore: empty paymentRef")
|
||||
}
|
||||
if strings.TrimSpace(payment.IdempotencyKey) == "" {
|
||||
return merrors.InvalidArgument("paymentsStore: empty idempotencyKey")
|
||||
}
|
||||
if payment.OrganizationRef == primitive.NilObjectID {
|
||||
return merrors.InvalidArgument("paymentsStore: organization_ref is required")
|
||||
}
|
||||
|
||||
payment.Update()
|
||||
filter := repository.OrgFilter(payment.OrganizationRef).And(
|
||||
repository.Filter("idempotencyKey", payment.IdempotencyKey),
|
||||
)
|
||||
|
||||
if err := p.repo.Insert(ctx, payment, filter); err != nil {
|
||||
if errors.Is(err, merrors.ErrDataConflict) {
|
||||
return storage.ErrDuplicatePayment
|
||||
}
|
||||
return err
|
||||
}
|
||||
p.logger.Debug("payment created", zap.String("payment_ref", payment.PaymentRef))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Payments) Update(ctx context.Context, payment *model.Payment) error {
|
||||
if payment == nil {
|
||||
return merrors.InvalidArgument("paymentsStore: nil payment")
|
||||
}
|
||||
if payment.ID.IsZero() {
|
||||
return merrors.InvalidArgument("paymentsStore: missing payment id")
|
||||
}
|
||||
payment.Normalize()
|
||||
payment.Update()
|
||||
if err := p.repo.Update(ctx, payment); err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
return storage.ErrPaymentNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Payments) GetByPaymentRef(ctx context.Context, paymentRef string) (*model.Payment, error) {
|
||||
paymentRef = strings.TrimSpace(paymentRef)
|
||||
if paymentRef == "" {
|
||||
return nil, merrors.InvalidArgument("paymentsStore: empty paymentRef")
|
||||
}
|
||||
entity := &model.Payment{}
|
||||
if err := p.repo.FindOneByFilter(ctx, repository.Filter("paymentRef", paymentRef), entity); err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
return nil, storage.ErrPaymentNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return entity, nil
|
||||
}
|
||||
|
||||
func (p *Payments) GetByIdempotencyKey(ctx context.Context, orgRef primitive.ObjectID, idempotencyKey string) (*model.Payment, error) {
|
||||
idempotencyKey = strings.TrimSpace(idempotencyKey)
|
||||
if orgRef == primitive.NilObjectID {
|
||||
return nil, merrors.InvalidArgument("paymentsStore: organization_ref is required")
|
||||
}
|
||||
if idempotencyKey == "" {
|
||||
return nil, merrors.InvalidArgument("paymentsStore: empty idempotencyKey")
|
||||
}
|
||||
entity := &model.Payment{}
|
||||
query := repository.OrgFilter(orgRef).And(repository.Filter("idempotencyKey", idempotencyKey))
|
||||
if err := p.repo.FindOneByFilter(ctx, query, entity); err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
return nil, storage.ErrPaymentNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return entity, nil
|
||||
}
|
||||
|
||||
func (p *Payments) GetByChainTransferRef(ctx context.Context, transferRef string) (*model.Payment, error) {
|
||||
transferRef = strings.TrimSpace(transferRef)
|
||||
if transferRef == "" {
|
||||
return nil, merrors.InvalidArgument("paymentsStore: empty chain transfer reference")
|
||||
}
|
||||
entity := &model.Payment{}
|
||||
if err := p.repo.FindOneByFilter(ctx, repository.Filter("execution.chainTransferRef", transferRef), entity); err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
return nil, storage.ErrPaymentNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return entity, nil
|
||||
}
|
||||
|
||||
func (p *Payments) List(ctx context.Context, filter *model.PaymentFilter) (*model.PaymentList, error) {
|
||||
if filter == nil {
|
||||
filter = &model.PaymentFilter{}
|
||||
}
|
||||
|
||||
query := repository.Query()
|
||||
|
||||
if len(filter.States) > 0 {
|
||||
states := make([]string, 0, len(filter.States))
|
||||
for _, state := range filter.States {
|
||||
if trimmed := strings.TrimSpace(string(state)); trimmed != "" {
|
||||
states = append(states, trimmed)
|
||||
}
|
||||
}
|
||||
if len(states) > 0 {
|
||||
query = query.Comparison(repository.Field("state"), builder.In, states)
|
||||
}
|
||||
}
|
||||
|
||||
if ref := strings.TrimSpace(filter.SourceRef); ref != "" {
|
||||
if endpointFilter := endpointQuery("intent.source", ref); endpointFilter != nil {
|
||||
query = query.And(endpointFilter)
|
||||
}
|
||||
}
|
||||
|
||||
if ref := strings.TrimSpace(filter.DestinationRef); ref != "" {
|
||||
if endpointFilter := endpointQuery("intent.destination", ref); endpointFilter != nil {
|
||||
query = query.And(endpointFilter)
|
||||
}
|
||||
}
|
||||
|
||||
if cursor := strings.TrimSpace(filter.Cursor); cursor != "" {
|
||||
if oid, err := primitive.ObjectIDFromHex(cursor); err == nil {
|
||||
query = query.Comparison(repository.IDField(), builder.Gt, oid)
|
||||
} else {
|
||||
p.logger.Warn("ignoring invalid payments cursor", zap.String("cursor", cursor), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
limit := sanitizePaymentLimit(filter.Limit)
|
||||
fetchLimit := limit + 1
|
||||
query = query.Sort(repository.IDField(), true).Limit(&fetchLimit)
|
||||
|
||||
payments := make([]*model.Payment, 0, fetchLimit)
|
||||
decoder := func(cur *mongo.Cursor) error {
|
||||
item := &model.Payment{}
|
||||
if err := cur.Decode(item); err != nil {
|
||||
return err
|
||||
}
|
||||
payments = append(payments, item)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := p.repo.FindManyByFilter(ctx, query, decoder); err != nil && !errors.Is(err, merrors.ErrNoData) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nextCursor := ""
|
||||
if int64(len(payments)) == fetchLimit {
|
||||
last := payments[len(payments)-1]
|
||||
nextCursor = last.ID.Hex()
|
||||
payments = payments[:len(payments)-1]
|
||||
}
|
||||
|
||||
return &model.PaymentList{
|
||||
Items: payments,
|
||||
NextCursor: nextCursor,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func endpointQuery(prefix, ref string) builder.Query {
|
||||
trimmed := strings.TrimSpace(ref)
|
||||
if trimmed == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
lower := strings.ToLower(trimmed)
|
||||
filters := []builder.Query{
|
||||
repository.Filter(prefix+".ledger.ledgerAccountRef", trimmed),
|
||||
repository.Filter(prefix+".managedWallet.managedWalletRef", trimmed),
|
||||
repository.Filter(prefix+".externalChain.address", lower),
|
||||
}
|
||||
|
||||
return repository.Query().Or(filters...)
|
||||
}
|
||||
|
||||
func sanitizePaymentLimit(requested int32) int64 {
|
||||
if requested <= 0 {
|
||||
return defaultPaymentPageSize
|
||||
}
|
||||
if requested > int32(maxPaymentPageSize) {
|
||||
return maxPaymentPageSize
|
||||
}
|
||||
return int64(requested)
|
||||
}
|
||||
37
api/payments/orchestrator/storage/storage.go
Normal file
37
api/payments/orchestrator/storage/storage.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/storage/model"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
)
|
||||
|
||||
type storageError string
|
||||
|
||||
func (e storageError) Error() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
var (
|
||||
// ErrPaymentNotFound signals that a payment record does not exist.
|
||||
ErrPaymentNotFound = storageError("payments.orchestrator.storage: payment not found")
|
||||
// ErrDuplicatePayment signals that idempotency constraints were violated.
|
||||
ErrDuplicatePayment = storageError("payments.orchestrator.storage: duplicate payment")
|
||||
)
|
||||
|
||||
// Repository exposes persistence primitives for the orchestrator domain.
|
||||
type Repository interface {
|
||||
Ping(ctx context.Context) error
|
||||
Payments() PaymentsStore
|
||||
}
|
||||
|
||||
// PaymentsStore manages payment lifecycle state.
|
||||
type PaymentsStore interface {
|
||||
Create(ctx context.Context, payment *model.Payment) error
|
||||
Update(ctx context.Context, payment *model.Payment) error
|
||||
GetByPaymentRef(ctx context.Context, paymentRef string) (*model.Payment, error)
|
||||
GetByIdempotencyKey(ctx context.Context, orgRef primitive.ObjectID, idempotencyKey string) (*model.Payment, error)
|
||||
GetByChainTransferRef(ctx context.Context, transferRef string) (*model.Payment, error)
|
||||
List(ctx context.Context, filter *model.PaymentFilter) (*model.PaymentList, error)
|
||||
}
|
||||
Reference in New Issue
Block a user