service backend
All checks were successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful

This commit is contained in:
Stephan D
2025-11-07 18:35:26 +01:00
parent 20e8f9acc4
commit 62a6631b9a
537 changed files with 48453 additions and 0 deletions

3
api/payments/orchestrator/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
internal/generated
.gocache
app

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

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

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

View File

@@ -0,0 +1 @@
.env.api

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

View 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=

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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