payment quotation v2 + payment orchestration v2 draft
This commit is contained in:
@@ -30,13 +30,14 @@ require (
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/nats-io/nats.go v1.48.0 // indirect
|
||||
github.com/nats-io/nats.go v1.49.0 // indirect
|
||||
github.com/nats-io/nkeys v0.4.15 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
github.com/prometheus/client_golang v1.23.2 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.5 // indirect
|
||||
github.com/prometheus/procfs v0.19.2 // indirect
|
||||
github.com/shopspring/decimal v1.4.0 // indirect
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||
github.com/xdg-go/scram v1.2.0 // indirect
|
||||
github.com/xdg-go/stringprep v1.0.4 // indirect
|
||||
@@ -48,5 +49,5 @@ require (
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc // indirect
|
||||
)
|
||||
|
||||
@@ -91,8 +91,8 @@ 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.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
|
||||
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
||||
github.com/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE=
|
||||
github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw=
|
||||
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
|
||||
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
|
||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||
@@ -121,6 +121,8 @@ github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/i
|
||||
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
|
||||
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
|
||||
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
|
||||
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
@@ -208,8 +210,8 @@ 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-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc h1:51Wupg8spF+5FC6D+iMKbOddFjMckETnNnEiZ+HX37s=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
||||
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
|
||||
@@ -8,8 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
orchestrationv1 "github.com/tech/sendico/pkg/proto/payments/orchestration/v1"
|
||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestration/v1"
|
||||
orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
@@ -17,33 +16,22 @@ import (
|
||||
|
||||
// Client exposes typed helpers around the payment orchestration and quotation gRPC APIs.
|
||||
type Client interface {
|
||||
InitiatePayments(ctx context.Context, req *orchestratorv1.InitiatePaymentsRequest) (*orchestratorv1.InitiatePaymentsResponse, 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)
|
||||
ExecutePayment(ctx context.Context, req *orchestrationv2.ExecutePaymentRequest) (*orchestrationv2.ExecutePaymentResponse, error)
|
||||
GetPayment(ctx context.Context, req *orchestrationv2.GetPaymentRequest) (*orchestrationv2.GetPaymentResponse, error)
|
||||
ListPayments(ctx context.Context, req *orchestrationv2.ListPaymentsRequest) (*orchestrationv2.ListPaymentsResponse, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
type grpcOrchestratorClient interface {
|
||||
InitiatePayments(ctx context.Context, in *orchestratorv1.InitiatePaymentsRequest, opts ...grpc.CallOption) (*orchestratorv1.InitiatePaymentsResponse, 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)
|
||||
ExecutePayment(ctx context.Context, in *orchestrationv2.ExecutePaymentRequest, opts ...grpc.CallOption) (*orchestrationv2.ExecutePaymentResponse, error)
|
||||
GetPayment(ctx context.Context, in *orchestrationv2.GetPaymentRequest, opts ...grpc.CallOption) (*orchestrationv2.GetPaymentResponse, error)
|
||||
ListPayments(ctx context.Context, in *orchestrationv2.ListPaymentsRequest, opts ...grpc.CallOption) (*orchestrationv2.ListPaymentsResponse, error)
|
||||
}
|
||||
|
||||
type orchestratorClient struct {
|
||||
cfg Config
|
||||
conn *grpc.ClientConn
|
||||
quoteConn *grpc.ClientConn
|
||||
client grpcOrchestratorClient
|
||||
cfg Config
|
||||
conn *grpc.ClientConn
|
||||
client grpcOrchestratorClient
|
||||
}
|
||||
|
||||
// New dials the payment orchestrator endpoint and returns a ready client.
|
||||
@@ -52,29 +40,16 @@ func New(ctx context.Context, cfg Config, opts ...grpc.DialOption) (Client, erro
|
||||
if strings.TrimSpace(cfg.Address) == "" {
|
||||
return nil, merrors.InvalidArgument("payment-orchestrator: address is required")
|
||||
}
|
||||
if strings.TrimSpace(cfg.QuoteAddress) == "" {
|
||||
cfg.QuoteAddress = cfg.Address
|
||||
}
|
||||
|
||||
conn, err := dial(ctx, cfg, cfg.Address, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
quoteConn := conn
|
||||
if cfg.QuoteAddress != cfg.Address {
|
||||
quoteConn, err = dial(ctx, cfg, cfg.QuoteAddress, opts...)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return &orchestratorClient{
|
||||
cfg: cfg,
|
||||
conn: conn,
|
||||
quoteConn: quoteConn,
|
||||
client: orchestrationv1.NewPaymentExecutionServiceClient(conn),
|
||||
cfg: cfg,
|
||||
conn: conn,
|
||||
client: orchestrationv2.NewPaymentOrchestratorServiceClient(conn),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -99,11 +74,6 @@ func dial(ctx context.Context, cfg Config, address string, opts ...grpc.DialOpti
|
||||
|
||||
// NewWithClient injects a pre-built orchestrator client (useful for tests).
|
||||
func NewWithClient(cfg Config, oc grpcOrchestratorClient) Client {
|
||||
return NewWithClients(cfg, oc)
|
||||
}
|
||||
|
||||
// NewWithClients injects pre-built orchestrator and quotation clients (useful for tests).
|
||||
func NewWithClients(cfg Config, oc grpcOrchestratorClient) Client {
|
||||
cfg.setDefaults()
|
||||
return &orchestratorClient{
|
||||
cfg: cfg,
|
||||
@@ -111,69 +81,36 @@ func NewWithClients(cfg Config, oc grpcOrchestratorClient) Client {
|
||||
}
|
||||
}
|
||||
|
||||
// NewWithClients injects pre-built orchestrator and quotation clients (useful for tests).
|
||||
func NewWithClients(cfg Config, oc grpcOrchestratorClient) Client {
|
||||
return NewWithClient(cfg, oc)
|
||||
}
|
||||
|
||||
func (c *orchestratorClient) Close() error {
|
||||
var firstErr error
|
||||
if c.quoteConn != nil && c.quoteConn != c.conn {
|
||||
if err := c.quoteConn.Close(); err != nil {
|
||||
firstErr = err
|
||||
}
|
||||
if c == nil || c.conn == nil {
|
||||
return nil
|
||||
}
|
||||
if c.conn != nil {
|
||||
if err := c.conn.Close(); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
return firstErr
|
||||
return c.conn.Close()
|
||||
}
|
||||
|
||||
func (c *orchestratorClient) InitiatePayments(ctx context.Context, req *orchestratorv1.InitiatePaymentsRequest) (*orchestratorv1.InitiatePaymentsResponse, error) {
|
||||
func (c *orchestratorClient) ExecutePayment(ctx context.Context, req *orchestrationv2.ExecutePaymentRequest) (*orchestrationv2.ExecutePaymentResponse, error) {
|
||||
ctx, cancel := c.callContext(ctx)
|
||||
defer cancel()
|
||||
return c.client.InitiatePayments(ctx, req)
|
||||
return c.client.ExecutePayment(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) {
|
||||
func (c *orchestratorClient) GetPayment(ctx context.Context, req *orchestrationv2.GetPaymentRequest) (*orchestrationv2.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) {
|
||||
func (c *orchestratorClient) ListPayments(ctx context.Context, req *orchestrationv2.ListPaymentsRequest) (*orchestrationv2.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 {
|
||||
|
||||
@@ -4,11 +4,10 @@ import "time"
|
||||
|
||||
// Config captures connection settings for the payment orchestrator gRPC service.
|
||||
type Config struct {
|
||||
Address string
|
||||
QuoteAddress string
|
||||
DialTimeout time.Duration
|
||||
CallTimeout time.Duration
|
||||
Insecure bool
|
||||
Address string
|
||||
DialTimeout time.Duration
|
||||
CallTimeout time.Duration
|
||||
Insecure bool
|
||||
}
|
||||
|
||||
func (c *Config) setDefaults() {
|
||||
|
||||
@@ -3,76 +3,36 @@ package client
|
||||
import (
|
||||
"context"
|
||||
|
||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestration/v1"
|
||||
orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2"
|
||||
)
|
||||
|
||||
// Fake implements Client for tests.
|
||||
type Fake struct {
|
||||
InitiatePaymentsFn func(ctx context.Context, req *orchestratorv1.InitiatePaymentsRequest) (*orchestratorv1.InitiatePaymentsResponse, 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
|
||||
ExecutePaymentFn func(ctx context.Context, req *orchestrationv2.ExecutePaymentRequest) (*orchestrationv2.ExecutePaymentResponse, error)
|
||||
GetPaymentFn func(ctx context.Context, req *orchestrationv2.GetPaymentRequest) (*orchestrationv2.GetPaymentResponse, error)
|
||||
ListPaymentsFn func(ctx context.Context, req *orchestrationv2.ListPaymentsRequest) (*orchestrationv2.ListPaymentsResponse, error)
|
||||
CloseFn func() error
|
||||
}
|
||||
|
||||
func (f *Fake) InitiatePayments(ctx context.Context, req *orchestratorv1.InitiatePaymentsRequest) (*orchestratorv1.InitiatePaymentsResponse, error) {
|
||||
if f.InitiatePaymentsFn != nil {
|
||||
return f.InitiatePaymentsFn(ctx, req)
|
||||
func (f *Fake) ExecutePayment(ctx context.Context, req *orchestrationv2.ExecutePaymentRequest) (*orchestrationv2.ExecutePaymentResponse, error) {
|
||||
if f.ExecutePaymentFn != nil {
|
||||
return f.ExecutePaymentFn(ctx, req)
|
||||
}
|
||||
return &orchestratorv1.InitiatePaymentsResponse{}, nil
|
||||
return &orchestrationv2.ExecutePaymentResponse{}, 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) {
|
||||
func (f *Fake) GetPayment(ctx context.Context, req *orchestrationv2.GetPaymentRequest) (*orchestrationv2.GetPaymentResponse, error) {
|
||||
if f.GetPaymentFn != nil {
|
||||
return f.GetPaymentFn(ctx, req)
|
||||
}
|
||||
return &orchestratorv1.GetPaymentResponse{}, nil
|
||||
return &orchestrationv2.GetPaymentResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *Fake) ListPayments(ctx context.Context, req *orchestratorv1.ListPaymentsRequest) (*orchestratorv1.ListPaymentsResponse, error) {
|
||||
func (f *Fake) ListPayments(ctx context.Context, req *orchestrationv2.ListPaymentsRequest) (*orchestrationv2.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
|
||||
return &orchestrationv2.ListPaymentsResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *Fake) Close() error {
|
||||
|
||||
@@ -18,7 +18,6 @@ replace github.com/tech/sendico/payments/storage => ../storage
|
||||
|
||||
require (
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/shopspring/decimal v1.4.0
|
||||
github.com/tech/sendico/fx/oracle v0.0.0-00010101000000-000000000000
|
||||
github.com/tech/sendico/gateway/chain v0.0.0-00010101000000-000000000000
|
||||
@@ -46,9 +45,10 @@ require (
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/nats-io/nats.go v1.48.0 // indirect
|
||||
github.com/nats-io/nats.go v1.49.0 // indirect
|
||||
github.com/nats-io/nkeys v0.4.15 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
github.com/prometheus/client_golang v1.23.2 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.5 // indirect
|
||||
github.com/prometheus/procfs v0.19.2 // indirect
|
||||
@@ -64,5 +64,5 @@ require (
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc // indirect
|
||||
)
|
||||
|
||||
@@ -91,8 +91,8 @@ 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.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
|
||||
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
||||
github.com/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE=
|
||||
github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw=
|
||||
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
|
||||
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
|
||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||
@@ -211,8 +211,8 @@ 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-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc h1:51Wupg8spF+5FC6D+iMKbOddFjMckETnNnEiZ+HX37s=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
||||
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
ledgerclient "github.com/tech/sendico/ledger/client"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrator"
|
||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||
quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1"
|
||||
)
|
||||
|
||||
type orchestratorDeps struct {
|
||||
@@ -14,7 +13,6 @@ type orchestratorDeps struct {
|
||||
ledgerClient ledgerclient.Client
|
||||
mntxClient mntxclient.Client
|
||||
oracleClient oracleclient.Client
|
||||
quotationClient quotationv1.QuotationServiceClient
|
||||
gatewayInvokeResolver orchestrator.GatewayInvokeResolver
|
||||
}
|
||||
|
||||
@@ -32,7 +30,6 @@ func (i *Imp) initDependencies(_ *config) *orchestratorDeps {
|
||||
deps.ledgerClient = &discoveryLedgerClient{resolver: i.discoveryClients}
|
||||
deps.oracleClient = &discoveryOracleClient{resolver: i.discoveryClients}
|
||||
deps.mntxClient = &discoveryMntxClient{resolver: i.discoveryClients}
|
||||
deps.quotationClient = &discoveryQuotationClient{resolver: i.discoveryClients}
|
||||
deps.gatewayInvokeResolver = discoveryGatewayInvokeResolver{resolver: i.discoveryClients}
|
||||
return deps
|
||||
}
|
||||
@@ -52,9 +49,6 @@ func (i *Imp) buildServiceOptions(cfg *config, deps *orchestratorDeps) []orchest
|
||||
opts = append(opts, orchestrator.WithMntxGateway(deps.mntxClient))
|
||||
}
|
||||
|
||||
if deps.quotationClient != nil {
|
||||
opts = append(opts, orchestrator.WithQuotationService(deps.quotationClient))
|
||||
}
|
||||
opts = append(opts, orchestrator.WithMaxFXQuoteTTLMillis(cfg.maxFXQuoteTTLMillis()))
|
||||
if deps.gatewayInvokeResolver != nil {
|
||||
opts = append(opts, orchestrator.WithGatewayInvokeResolver(deps.gatewayInvokeResolver))
|
||||
|
||||
@@ -19,7 +19,6 @@ import (
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||
quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1"
|
||||
"go.uber.org/zap"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials"
|
||||
@@ -33,7 +32,6 @@ var (
|
||||
ledgerServiceNames = []string{"LEDGER", string(mservice.Ledger)}
|
||||
oracleServiceNames = []string{"FX_ORACLE", string(mservice.FXOracle)}
|
||||
mntxServiceNames = []string{"CARD_PAYOUT_RAIL_GATEWAY", string(mservice.MntxGateway)}
|
||||
quoteServiceNames = []string{"PAYMENT_QUOTATION", "payment_quotation"}
|
||||
)
|
||||
|
||||
type discoveryEndpoint struct {
|
||||
@@ -55,9 +53,6 @@ type discoveryClientResolver struct {
|
||||
feesConn *grpc.ClientConn
|
||||
feesEndpoint discoveryEndpoint
|
||||
|
||||
quoteConn *grpc.ClientConn
|
||||
quoteEndpoint discoveryEndpoint
|
||||
|
||||
ledgerClient ledgerclient.Client
|
||||
ledgerEndpoint discoveryEndpoint
|
||||
|
||||
@@ -93,10 +88,6 @@ func (r *discoveryClientResolver) Close() {
|
||||
_ = r.feesConn.Close()
|
||||
r.feesConn = nil
|
||||
}
|
||||
if r.quoteConn != nil {
|
||||
_ = r.quoteConn.Close()
|
||||
r.quoteConn = nil
|
||||
}
|
||||
if r.ledgerClient != nil {
|
||||
_ = r.ledgerClient.Close()
|
||||
r.ledgerClient = nil
|
||||
@@ -137,11 +128,6 @@ func (r *discoveryClientResolver) MntxAvailable() bool {
|
||||
return ok
|
||||
}
|
||||
|
||||
func (r *discoveryClientResolver) QuotationAvailable() bool {
|
||||
_, ok := r.findEntry("quotation", quoteServiceNames, "", "")
|
||||
return ok
|
||||
}
|
||||
|
||||
func (r *discoveryClientResolver) FeesClient(ctx context.Context) (feesv1.FeeEngineClient, error) {
|
||||
entry, ok := r.findEntry("fees", feesServiceNames, "", "")
|
||||
if !ok {
|
||||
@@ -173,37 +159,6 @@ func (r *discoveryClientResolver) FeesClient(ctx context.Context) (feesv1.FeeEng
|
||||
return feesv1.NewFeeEngineClient(r.feesConn), nil
|
||||
}
|
||||
|
||||
func (r *discoveryClientResolver) QuotationClient(ctx context.Context) (quotationv1.QuotationServiceClient, error) {
|
||||
entry, ok := r.findEntry("quotation", quoteServiceNames, "", "")
|
||||
if !ok {
|
||||
return nil, merrors.NoData("discovery: quotation service unavailable")
|
||||
}
|
||||
endpoint, err := parseDiscoveryEndpoint(entry.InvokeURI)
|
||||
if err != nil {
|
||||
r.logMissing("quotation", "invalid quotation invoke uri", entry.InvokeURI, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if r.quoteConn == nil || r.quoteEndpoint.key() != endpoint.key() || r.quoteEndpoint.address != endpoint.address {
|
||||
if r.quoteConn != nil {
|
||||
_ = r.quoteConn.Close()
|
||||
r.quoteConn = nil
|
||||
}
|
||||
conn, dialErr := dialGrpc(ctx, endpoint)
|
||||
if dialErr != nil {
|
||||
r.logMissing("quotation", "failed to dial quotation service", endpoint.raw, dialErr)
|
||||
return nil, dialErr
|
||||
}
|
||||
r.quoteConn = conn
|
||||
r.quoteEndpoint = endpoint
|
||||
}
|
||||
|
||||
return quotationv1.NewQuotationServiceClient(r.quoteConn), nil
|
||||
}
|
||||
|
||||
func (r *discoveryClientResolver) LedgerClient(ctx context.Context) (ledgerclient.Client, error) {
|
||||
entry, ok := r.findEntry("ledger", ledgerServiceNames, "", "")
|
||||
if !ok {
|
||||
@@ -404,9 +359,6 @@ func (r *discoveryClientResolver) findEntry(key string, services []string, rail
|
||||
}
|
||||
|
||||
func (r *discoveryClientResolver) logSelection(key, entryKey string, entry discovery.RegistryEntry) {
|
||||
if r.logger == nil {
|
||||
return
|
||||
}
|
||||
r.mu.Lock()
|
||||
last := r.lastSelection[key]
|
||||
if last == entryKey {
|
||||
@@ -426,9 +378,6 @@ func (r *discoveryClientResolver) logSelection(key, entryKey string, entry disco
|
||||
}
|
||||
|
||||
func (r *discoveryClientResolver) logMissing(key, message, invokeURI string, err error) {
|
||||
if r.logger == nil {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
r.mu.Lock()
|
||||
last := r.lastMissing[key]
|
||||
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
|
||||
quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
@@ -52,33 +51,6 @@ func (c *discoveryFeeClient) ValidateFeeToken(ctx context.Context, req *feesv1.V
|
||||
return client.ValidateFeeToken(ctx, req, opts...)
|
||||
}
|
||||
|
||||
type discoveryQuotationClient struct {
|
||||
resolver *discoveryClientResolver
|
||||
}
|
||||
|
||||
func (c *discoveryQuotationClient) Available() bool {
|
||||
if c == nil || c.resolver == nil {
|
||||
return false
|
||||
}
|
||||
return c.resolver.QuotationAvailable()
|
||||
}
|
||||
|
||||
func (c *discoveryQuotationClient) QuotePayment(ctx context.Context, req *quotationv1.QuotePaymentRequest, opts ...grpc.CallOption) (*quotationv1.QuotePaymentResponse, error) {
|
||||
client, err := c.resolver.QuotationClient(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client.QuotePayment(ctx, req, opts...)
|
||||
}
|
||||
|
||||
func (c *discoveryQuotationClient) QuotePayments(ctx context.Context, req *quotationv1.QuotePaymentsRequest, opts ...grpc.CallOption) (*quotationv1.QuotePaymentsResponse, error) {
|
||||
client, err := c.resolver.QuotationClient(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client.QuotePayments(ctx, req, opts...)
|
||||
}
|
||||
|
||||
type discoveryLedgerClient struct {
|
||||
resolver *discoveryClientResolver
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ func (i *Imp) Start() error {
|
||||
return svc, nil
|
||||
}
|
||||
|
||||
app, err := grpcapp.NewApp(i.logger, "payments_orchestrator", cfg.Config, i.debug, repoFactory, serviceFactory)
|
||||
app, err := grpcapp.NewApp(i.logger, "payments.orchestrator", cfg.Config, i.debug, repoFactory, serviceFactory)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/db/storable"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
pm "github.com/tech/sendico/pkg/model"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
@@ -91,9 +92,19 @@ type Payment struct {
|
||||
StepExecutions []StepExecution
|
||||
}
|
||||
|
||||
func New() Factory {
|
||||
// Dependencies configures aggregate factory integrations.
|
||||
type Dependencies struct {
|
||||
Logger mlogger.Logger
|
||||
}
|
||||
|
||||
func New(deps ...Dependencies) Factory {
|
||||
var dep Dependencies
|
||||
if len(deps) > 0 {
|
||||
dep = deps[0]
|
||||
}
|
||||
return &svc{
|
||||
now: func() time.Time { return time.Now().UTC() },
|
||||
logger: dep.Logger.Named("agg"),
|
||||
now: func() time.Time { return time.Now().UTC() },
|
||||
newID: func() bson.ObjectID {
|
||||
return bson.NewObjectID()
|
||||
},
|
||||
|
||||
@@ -7,18 +7,45 @@ import (
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/db/storable"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
pm "github.com/tech/sendico/pkg/model"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const initialVersion uint64 = 1
|
||||
|
||||
type svc struct {
|
||||
now func() time.Time
|
||||
newID func() bson.ObjectID
|
||||
logger mlogger.Logger
|
||||
now func() time.Time
|
||||
newID func() bson.ObjectID
|
||||
}
|
||||
|
||||
func (s *svc) Create(in Input) (*Payment, error) {
|
||||
func (s *svc) Create(in Input) (payment *Payment, err error) {
|
||||
logger := s.logger
|
||||
logger.Debug("Starting Create",
|
||||
zap.String("organization_ref", in.OrganizationRef.Hex()),
|
||||
zap.String("quotation_ref", strings.TrimSpace(in.QuotationRef)),
|
||||
zap.Int("steps_count", len(in.Steps)),
|
||||
)
|
||||
defer func(start time.Time) {
|
||||
fields := []zap.Field{zap.Int64("duration_ms", time.Since(start).Milliseconds())}
|
||||
if err != nil {
|
||||
logger.Warn("Failed to create", append(fields, zap.Error(err))...)
|
||||
return
|
||||
}
|
||||
if payment == nil {
|
||||
logger.Debug("Completed Create", append(fields, zap.Bool("payment_nil", true))...)
|
||||
return
|
||||
}
|
||||
fields = append(fields,
|
||||
zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef)),
|
||||
zap.String("state", string(payment.State)),
|
||||
zap.Uint64("version", payment.Version),
|
||||
)
|
||||
logger.Debug("Completed Create", fields...)
|
||||
}(time.Now())
|
||||
|
||||
if in.OrganizationRef.IsZero() {
|
||||
return nil, merrors.InvalidArgument("organization_id is required")
|
||||
}
|
||||
@@ -67,7 +94,7 @@ func (s *svc) Create(in Input) (*Payment, error) {
|
||||
now := s.now().UTC()
|
||||
id := s.newID()
|
||||
|
||||
return &Payment{
|
||||
payment = &Payment{
|
||||
Base: storable.Base{
|
||||
ID: id,
|
||||
CreatedAt: now,
|
||||
@@ -85,7 +112,8 @@ func (s *svc) Create(in Input) (*Payment, error) {
|
||||
State: StateCreated,
|
||||
Version: initialVersion,
|
||||
StepExecutions: stepExecutions,
|
||||
}, nil
|
||||
}
|
||||
return payment, nil
|
||||
}
|
||||
|
||||
func buildInitialStepTelemetry(shell []StepShell) ([]StepExecution, error) {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package erecon
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrStepNotFound = errors.New("step execution not found")
|
||||
ErrAmbiguousStepMatch = errors.New("ambiguous step execution match")
|
||||
ErrStepTransitionInvalid = errors.New("step transition invalid")
|
||||
)
|
||||
@@ -0,0 +1,271 @@
|
||||
package erecon
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
)
|
||||
|
||||
type normalizedEvent struct {
|
||||
stepRef string
|
||||
matchRefs []agg.ExternalRef
|
||||
appendRefs []agg.ExternalRef
|
||||
targetState agg.StepState
|
||||
failureCode string
|
||||
failureMsg string
|
||||
occurredAt *time.Time
|
||||
forceAggregateFailed bool
|
||||
forceAggregateNeedsAttention bool
|
||||
}
|
||||
|
||||
func normalizeEvent(event Event) (*normalizedEvent, error) {
|
||||
if countPayloads(event) != 1 {
|
||||
return nil, merrors.InvalidArgument("exactly one event payload is required")
|
||||
}
|
||||
|
||||
if event.Gateway != nil {
|
||||
return normalizeGatewayEvent(*event.Gateway)
|
||||
}
|
||||
if event.Ledger != nil {
|
||||
return normalizeLedgerEvent(*event.Ledger)
|
||||
}
|
||||
return normalizeCardEvent(*event.Card)
|
||||
}
|
||||
|
||||
func countPayloads(event Event) int {
|
||||
count := 0
|
||||
if event.Gateway != nil {
|
||||
count++
|
||||
}
|
||||
if event.Ledger != nil {
|
||||
count++
|
||||
}
|
||||
if event.Card != nil {
|
||||
count++
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func normalizeGatewayEvent(src GatewayEvent) (*normalizedEvent, error) {
|
||||
status, ok := normalizeGatewayStatus(src.Status)
|
||||
if !ok {
|
||||
return nil, merrors.InvalidArgument("gateway status is invalid")
|
||||
}
|
||||
|
||||
target, needsAttention := mapFailureTarget(status, src.Retryable)
|
||||
ev := &normalizedEvent{
|
||||
stepRef: strings.TrimSpace(src.StepRef),
|
||||
targetState: target,
|
||||
failureCode: strings.TrimSpace(src.FailureCode),
|
||||
failureMsg: strings.TrimSpace(src.FailureMsg),
|
||||
occurredAt: normalizeTimePtr(src.OccurredAt),
|
||||
forceAggregateFailed: src.TerminalFailure,
|
||||
forceAggregateNeedsAttention: needsAttention,
|
||||
}
|
||||
ev.matchRefs = normalizeRefList([]agg.ExternalRef{
|
||||
{
|
||||
GatewayInstanceID: strings.TrimSpace(src.GatewayInstanceID),
|
||||
Kind: ExternalRefKindOperation,
|
||||
Ref: strings.TrimSpace(src.OperationRef),
|
||||
},
|
||||
{
|
||||
GatewayInstanceID: strings.TrimSpace(src.GatewayInstanceID),
|
||||
Kind: ExternalRefKindTransfer,
|
||||
Ref: strings.TrimSpace(src.TransferRef),
|
||||
},
|
||||
})
|
||||
ev.appendRefs = cloneRefs(ev.matchRefs)
|
||||
|
||||
if ev.stepRef == "" && len(ev.matchRefs) == 0 {
|
||||
return nil, merrors.InvalidArgument("gateway event must include step_ref or operation/transfer reference")
|
||||
}
|
||||
if ev.targetState == agg.StepStateFailed && ev.failureMsg == "" {
|
||||
ev.failureMsg = "gateway operation failed"
|
||||
}
|
||||
return ev, nil
|
||||
}
|
||||
|
||||
func normalizeLedgerEvent(src LedgerEvent) (*normalizedEvent, error) {
|
||||
status, ok := normalizeLedgerStatus(src.Status)
|
||||
if !ok {
|
||||
return nil, merrors.InvalidArgument("ledger status is invalid")
|
||||
}
|
||||
|
||||
target, needsAttention := mapFailureTarget(status, src.Retryable)
|
||||
ev := &normalizedEvent{
|
||||
stepRef: strings.TrimSpace(src.StepRef),
|
||||
targetState: target,
|
||||
failureCode: strings.TrimSpace(src.FailureCode),
|
||||
failureMsg: strings.TrimSpace(src.FailureMsg),
|
||||
occurredAt: normalizeTimePtr(src.OccurredAt),
|
||||
forceAggregateFailed: src.TerminalFailure,
|
||||
forceAggregateNeedsAttention: needsAttention,
|
||||
}
|
||||
ev.matchRefs = normalizeRefList([]agg.ExternalRef{
|
||||
{
|
||||
Kind: ExternalRefKindLedger,
|
||||
Ref: strings.TrimSpace(src.EntryRef),
|
||||
},
|
||||
})
|
||||
ev.appendRefs = cloneRefs(ev.matchRefs)
|
||||
|
||||
if ev.stepRef == "" && len(ev.matchRefs) == 0 {
|
||||
return nil, merrors.InvalidArgument("ledger event must include step_ref or entry_ref")
|
||||
}
|
||||
if ev.targetState == agg.StepStateFailed && ev.failureMsg == "" {
|
||||
ev.failureMsg = "ledger operation failed"
|
||||
}
|
||||
return ev, nil
|
||||
}
|
||||
|
||||
func normalizeCardEvent(src CardEvent) (*normalizedEvent, error) {
|
||||
status, ok := normalizeCardStatus(src.Status)
|
||||
if !ok {
|
||||
return nil, merrors.InvalidArgument("card status is invalid")
|
||||
}
|
||||
|
||||
target, needsAttention := mapFailureTarget(status, src.Retryable)
|
||||
ev := &normalizedEvent{
|
||||
stepRef: strings.TrimSpace(src.StepRef),
|
||||
targetState: target,
|
||||
failureCode: strings.TrimSpace(src.FailureCode),
|
||||
failureMsg: strings.TrimSpace(src.FailureMsg),
|
||||
occurredAt: normalizeTimePtr(src.OccurredAt),
|
||||
forceAggregateFailed: src.TerminalFailure,
|
||||
forceAggregateNeedsAttention: needsAttention,
|
||||
}
|
||||
ev.matchRefs = normalizeRefList([]agg.ExternalRef{
|
||||
{
|
||||
GatewayInstanceID: strings.TrimSpace(src.GatewayInstanceID),
|
||||
Kind: ExternalRefKindCardPayout,
|
||||
Ref: strings.TrimSpace(src.PayoutRef),
|
||||
},
|
||||
})
|
||||
ev.appendRefs = cloneRefs(ev.matchRefs)
|
||||
|
||||
if ev.stepRef == "" && len(ev.matchRefs) == 0 {
|
||||
return nil, merrors.InvalidArgument("card event must include step_ref or payout_ref")
|
||||
}
|
||||
if ev.targetState == agg.StepStateFailed && ev.failureMsg == "" {
|
||||
ev.failureMsg = "card payout failed"
|
||||
}
|
||||
return ev, nil
|
||||
}
|
||||
|
||||
func normalizeGatewayStatus(status GatewayStatus) (GatewayStatus, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(string(status))) {
|
||||
case string(GatewayStatusCreated):
|
||||
return GatewayStatusCreated, true
|
||||
case string(GatewayStatusProcessing):
|
||||
return GatewayStatusProcessing, true
|
||||
case string(GatewayStatusWaiting):
|
||||
return GatewayStatusWaiting, true
|
||||
case string(GatewayStatusSuccess):
|
||||
return GatewayStatusSuccess, true
|
||||
case string(GatewayStatusFailed):
|
||||
return GatewayStatusFailed, true
|
||||
case string(GatewayStatusCancelled):
|
||||
return GatewayStatusCancelled, true
|
||||
default:
|
||||
return GatewayStatusUnspecified, false
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeLedgerStatus(status LedgerStatus) (LedgerStatus, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(string(status))) {
|
||||
case string(LedgerStatusPending):
|
||||
return LedgerStatusPending, true
|
||||
case string(LedgerStatusProcessing):
|
||||
return LedgerStatusProcessing, true
|
||||
case string(LedgerStatusPosted):
|
||||
return LedgerStatusPosted, true
|
||||
case string(LedgerStatusFailed):
|
||||
return LedgerStatusFailed, true
|
||||
case string(LedgerStatusCancelled):
|
||||
return LedgerStatusCancelled, true
|
||||
default:
|
||||
return LedgerStatusUnspecified, false
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeCardStatus(status CardStatus) (CardStatus, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(string(status))) {
|
||||
case string(CardStatusCreated):
|
||||
return CardStatusCreated, true
|
||||
case string(CardStatusProcessing):
|
||||
return CardStatusProcessing, true
|
||||
case string(CardStatusWaiting):
|
||||
return CardStatusWaiting, true
|
||||
case string(CardStatusSuccess):
|
||||
return CardStatusSuccess, true
|
||||
case string(CardStatusFailed):
|
||||
return CardStatusFailed, true
|
||||
case string(CardStatusCancelled):
|
||||
return CardStatusCancelled, true
|
||||
default:
|
||||
return CardStatusUnspecified, false
|
||||
}
|
||||
}
|
||||
|
||||
func mapFailureTarget(status any, retryable *bool) (agg.StepState, bool) {
|
||||
switch status {
|
||||
case GatewayStatusCreated, GatewayStatusProcessing, GatewayStatusWaiting:
|
||||
return agg.StepStateRunning, false
|
||||
case LedgerStatusPending, LedgerStatusProcessing:
|
||||
return agg.StepStateRunning, false
|
||||
case CardStatusCreated, CardStatusProcessing, CardStatusWaiting:
|
||||
return agg.StepStateRunning, false
|
||||
case GatewayStatusSuccess, LedgerStatusPosted, CardStatusSuccess:
|
||||
return agg.StepStateCompleted, false
|
||||
case GatewayStatusFailed, GatewayStatusCancelled, LedgerStatusFailed, LedgerStatusCancelled, CardStatusFailed, CardStatusCancelled:
|
||||
if retryable != nil && !*retryable {
|
||||
return agg.StepStateNeedsAttention, true
|
||||
}
|
||||
return agg.StepStateFailed, false
|
||||
default:
|
||||
return agg.StepStateUnspecified, false
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeTimePtr(ts *time.Time) *time.Time {
|
||||
if ts == nil {
|
||||
return nil
|
||||
}
|
||||
val := ts.UTC()
|
||||
return &val
|
||||
}
|
||||
|
||||
func normalizeRefList(refs []agg.ExternalRef) []agg.ExternalRef {
|
||||
if len(refs) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]agg.ExternalRef, 0, len(refs))
|
||||
seen := map[string]struct{}{}
|
||||
for i := range refs {
|
||||
ref := refs[i]
|
||||
ref.GatewayInstanceID = strings.TrimSpace(ref.GatewayInstanceID)
|
||||
ref.Kind = strings.TrimSpace(ref.Kind)
|
||||
ref.Ref = strings.TrimSpace(ref.Ref)
|
||||
if ref.Kind == "" || ref.Ref == "" {
|
||||
continue
|
||||
}
|
||||
key := ref.GatewayInstanceID + "\x1f" + strings.ToLower(ref.Kind) + "\x1f" + strings.ToLower(ref.Ref)
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, ref)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneRefs(refs []agg.ExternalRef) []agg.ExternalRef {
|
||||
if len(refs) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]agg.ExternalRef, 0, len(refs))
|
||||
out = append(out, refs...)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package erecon
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xerr"
|
||||
)
|
||||
|
||||
func findStepIndex(payment *agg.Payment, event *normalizedEvent) (int, error) {
|
||||
if payment == nil {
|
||||
return -1, ErrStepNotFound
|
||||
}
|
||||
if event == nil {
|
||||
return -1, ErrStepNotFound
|
||||
}
|
||||
|
||||
if stepRef := strings.TrimSpace(event.stepRef); stepRef != "" {
|
||||
for i := range payment.StepExecutions {
|
||||
if strings.EqualFold(strings.TrimSpace(payment.StepExecutions[i].StepRef), stepRef) {
|
||||
return i, nil
|
||||
}
|
||||
}
|
||||
return -1, xerr.Wrapf(ErrStepNotFound, "step_ref=%s", stepRef)
|
||||
}
|
||||
|
||||
matches := make([]int, 0, 1)
|
||||
for i := range payment.StepExecutions {
|
||||
if stepMatchesAnyRef(payment.StepExecutions[i], event.matchRefs) {
|
||||
matches = append(matches, i)
|
||||
}
|
||||
}
|
||||
switch len(matches) {
|
||||
case 0:
|
||||
return -1, ErrStepNotFound
|
||||
case 1:
|
||||
return matches[0], nil
|
||||
default:
|
||||
return -1, ErrAmbiguousStepMatch
|
||||
}
|
||||
}
|
||||
|
||||
func stepMatchesAnyRef(step agg.StepExecution, refs []agg.ExternalRef) bool {
|
||||
if len(refs) == 0 || len(step.ExternalRefs) == 0 {
|
||||
return false
|
||||
}
|
||||
for i := range refs {
|
||||
if hasExternalRef(step.ExternalRefs, refs[i]) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasExternalRef(existing []agg.ExternalRef, ref agg.ExternalRef) bool {
|
||||
kind := strings.TrimSpace(ref.Kind)
|
||||
value := strings.TrimSpace(ref.Ref)
|
||||
gatewayID := strings.TrimSpace(ref.GatewayInstanceID)
|
||||
if kind == "" || value == "" {
|
||||
return false
|
||||
}
|
||||
for i := range existing {
|
||||
candidate := existing[i]
|
||||
if !strings.EqualFold(strings.TrimSpace(candidate.Kind), kind) {
|
||||
continue
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(candidate.Ref), value) {
|
||||
continue
|
||||
}
|
||||
if gatewayID != "" && strings.TrimSpace(candidate.GatewayInstanceID) != "" && !strings.EqualFold(strings.TrimSpace(candidate.GatewayInstanceID), gatewayID) {
|
||||
continue
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func mergeExternalRefs(existing []agg.ExternalRef, additions []agg.ExternalRef) ([]agg.ExternalRef, bool) {
|
||||
if len(additions) == 0 {
|
||||
return cloneRefs(existing), false
|
||||
}
|
||||
out := cloneRefs(existing)
|
||||
changed := false
|
||||
for i := range additions {
|
||||
ref := additions[i]
|
||||
if hasExternalRef(out, ref) {
|
||||
continue
|
||||
}
|
||||
out = append(out, agg.ExternalRef{
|
||||
GatewayInstanceID: strings.TrimSpace(ref.GatewayInstanceID),
|
||||
Kind: strings.TrimSpace(ref.Kind),
|
||||
Ref: strings.TrimSpace(ref.Ref),
|
||||
})
|
||||
changed = true
|
||||
}
|
||||
return out, changed
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package erecon
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
)
|
||||
|
||||
// Reconciler applies external async events to payment runtime state.
|
||||
type Reconciler interface {
|
||||
Reconcile(in Input) (*Output, error)
|
||||
}
|
||||
|
||||
// Input is the reconciliation payload.
|
||||
type Input struct {
|
||||
Payment *agg.Payment
|
||||
Event Event
|
||||
}
|
||||
|
||||
// Output is the reconciliation result.
|
||||
type Output struct {
|
||||
Payment *agg.Payment
|
||||
MatchedStepRef string
|
||||
StepChanged bool
|
||||
AggregateChanged bool
|
||||
}
|
||||
|
||||
// Event is one transport-agnostic external event envelope.
|
||||
// Exactly one payload must be set.
|
||||
type Event struct {
|
||||
Gateway *GatewayEvent
|
||||
Ledger *LedgerEvent
|
||||
Card *CardEvent
|
||||
}
|
||||
|
||||
// GatewayStatus is gateway operation lifecycle status.
|
||||
type GatewayStatus string
|
||||
|
||||
const (
|
||||
GatewayStatusUnspecified GatewayStatus = "unspecified"
|
||||
GatewayStatusCreated GatewayStatus = "created"
|
||||
GatewayStatusProcessing GatewayStatus = "processing"
|
||||
GatewayStatusWaiting GatewayStatus = "waiting"
|
||||
GatewayStatusSuccess GatewayStatus = "success"
|
||||
GatewayStatusFailed GatewayStatus = "failed"
|
||||
GatewayStatusCancelled GatewayStatus = "cancelled"
|
||||
)
|
||||
|
||||
// LedgerStatus is ledger operation lifecycle status.
|
||||
type LedgerStatus string
|
||||
|
||||
const (
|
||||
LedgerStatusUnspecified LedgerStatus = "unspecified"
|
||||
LedgerStatusPending LedgerStatus = "pending"
|
||||
LedgerStatusProcessing LedgerStatus = "processing"
|
||||
LedgerStatusPosted LedgerStatus = "posted"
|
||||
LedgerStatusFailed LedgerStatus = "failed"
|
||||
LedgerStatusCancelled LedgerStatus = "cancelled"
|
||||
)
|
||||
|
||||
// CardStatus is card payout lifecycle status.
|
||||
type CardStatus string
|
||||
|
||||
const (
|
||||
CardStatusUnspecified CardStatus = "unspecified"
|
||||
CardStatusCreated CardStatus = "created"
|
||||
CardStatusProcessing CardStatus = "processing"
|
||||
CardStatusWaiting CardStatus = "waiting"
|
||||
CardStatusSuccess CardStatus = "success"
|
||||
CardStatusFailed CardStatus = "failed"
|
||||
CardStatusCancelled CardStatus = "cancelled"
|
||||
)
|
||||
|
||||
// GatewayEvent is one async event from gateway execution flow.
|
||||
type GatewayEvent struct {
|
||||
StepRef string
|
||||
OperationRef string
|
||||
TransferRef string
|
||||
GatewayInstanceID string
|
||||
Status GatewayStatus
|
||||
FailureCode string
|
||||
FailureMsg string
|
||||
Retryable *bool
|
||||
TerminalFailure bool
|
||||
OccurredAt *time.Time
|
||||
}
|
||||
|
||||
// LedgerEvent is one async event from ledger flow.
|
||||
type LedgerEvent struct {
|
||||
StepRef string
|
||||
EntryRef string
|
||||
Status LedgerStatus
|
||||
FailureCode string
|
||||
FailureMsg string
|
||||
Retryable *bool
|
||||
TerminalFailure bool
|
||||
OccurredAt *time.Time
|
||||
}
|
||||
|
||||
// CardEvent is one async event from card payout flow.
|
||||
type CardEvent struct {
|
||||
StepRef string
|
||||
PayoutRef string
|
||||
GatewayInstanceID string
|
||||
Status CardStatus
|
||||
FailureCode string
|
||||
FailureMsg string
|
||||
Retryable *bool
|
||||
TerminalFailure bool
|
||||
OccurredAt *time.Time
|
||||
}
|
||||
|
||||
const (
|
||||
ExternalRefKindOperation = "operation_ref"
|
||||
ExternalRefKindTransfer = "transfer_ref"
|
||||
ExternalRefKindLedger = "ledger_entry_ref"
|
||||
ExternalRefKindCardPayout = "card_payout_ref"
|
||||
)
|
||||
|
||||
// Dependencies configures reconciliation service integrations.
|
||||
type Dependencies struct {
|
||||
Logger mlogger.Logger
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func New(deps ...Dependencies) Reconciler {
|
||||
var dep Dependencies
|
||||
if len(deps) > 0 {
|
||||
dep = deps[0]
|
||||
}
|
||||
now := dep.Now
|
||||
if now == nil {
|
||||
now = defaultNow
|
||||
}
|
||||
return &svc{
|
||||
logger: dep.Logger.Named("erecon"),
|
||||
now: now,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package erecon
|
||||
|
||||
import (
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/ostate"
|
||||
)
|
||||
|
||||
func reduceAggregateState(payment *agg.Payment, event *normalizedEvent, sm ostate.StateMachine) (bool, error) {
|
||||
if payment == nil || sm == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
target := deriveAggregateTarget(payment, event, sm)
|
||||
return applyAggregateTarget(payment, target, sm)
|
||||
}
|
||||
|
||||
func deriveAggregateTarget(payment *agg.Payment, event *normalizedEvent, sm ostate.StateMachine) agg.State {
|
||||
if payment == nil {
|
||||
return agg.StateUnspecified
|
||||
}
|
||||
if event != nil && event.forceAggregateFailed {
|
||||
return agg.StateFailed
|
||||
}
|
||||
|
||||
hasNeedsAttention := false
|
||||
hasWork := false
|
||||
allTerminalSuccessOrSkipped := len(payment.StepExecutions) > 0
|
||||
|
||||
for i := range payment.StepExecutions {
|
||||
state := payment.StepExecutions[i].State
|
||||
switch state {
|
||||
case agg.StepStateCompleted, agg.StepStateSkipped:
|
||||
hasWork = true
|
||||
case agg.StepStatePending, agg.StepStateUnspecified:
|
||||
allTerminalSuccessOrSkipped = false
|
||||
case agg.StepStateNeedsAttention:
|
||||
hasWork = true
|
||||
hasNeedsAttention = true
|
||||
allTerminalSuccessOrSkipped = false
|
||||
case agg.StepStateRunning, agg.StepStateFailed:
|
||||
hasWork = true
|
||||
allTerminalSuccessOrSkipped = false
|
||||
default:
|
||||
allTerminalSuccessOrSkipped = false
|
||||
}
|
||||
}
|
||||
|
||||
if allTerminalSuccessOrSkipped {
|
||||
return agg.StateSettled
|
||||
}
|
||||
if hasNeedsAttention || (event != nil && event.forceAggregateNeedsAttention) {
|
||||
return agg.StateNeedsAttention
|
||||
}
|
||||
if hasWork {
|
||||
return agg.StateExecuting
|
||||
}
|
||||
if sm.IsAggregateTerminal(payment.State) {
|
||||
return payment.State
|
||||
}
|
||||
return agg.StateCreated
|
||||
}
|
||||
|
||||
func applyAggregateTarget(payment *agg.Payment, target agg.State, sm ostate.StateMachine) (bool, error) {
|
||||
if payment == nil || sm == nil {
|
||||
return false, nil
|
||||
}
|
||||
current := payment.State
|
||||
if current == target {
|
||||
return false, nil
|
||||
}
|
||||
if sm.IsAggregateTerminal(current) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
original := current
|
||||
for i := 0; i < 6 && current != target; i++ {
|
||||
if sm.EnsureAggregateTransition(current, target) == nil {
|
||||
current = target
|
||||
break
|
||||
}
|
||||
next, ok := nextAggregateHop(current, target)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
if sm.EnsureAggregateTransition(current, next) != nil {
|
||||
break
|
||||
}
|
||||
current = next
|
||||
}
|
||||
if current != target {
|
||||
return false, nil
|
||||
}
|
||||
payment.State = current
|
||||
return payment.State != original, nil
|
||||
}
|
||||
|
||||
func nextAggregateHop(current, target agg.State) (agg.State, bool) {
|
||||
switch current {
|
||||
case agg.StateUnspecified:
|
||||
return agg.StateCreated, true
|
||||
case agg.StateCreated:
|
||||
if target == agg.StateFailed {
|
||||
return agg.StateFailed, true
|
||||
}
|
||||
if target == agg.StateExecuting {
|
||||
return agg.StateExecuting, true
|
||||
}
|
||||
return agg.StateExecuting, true
|
||||
case agg.StateExecuting:
|
||||
return target, true
|
||||
case agg.StateNeedsAttention:
|
||||
if target == agg.StateCreated {
|
||||
return agg.StateExecuting, true
|
||||
}
|
||||
return target, true
|
||||
default:
|
||||
return agg.StateUnspecified, false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
package erecon
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/ostate"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xerr"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type svc struct {
|
||||
logger mlogger.Logger
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func defaultNow() time.Time {
|
||||
return time.Now().UTC()
|
||||
}
|
||||
|
||||
func (s *svc) Reconcile(in Input) (out *Output, err error) {
|
||||
logger := s.logger
|
||||
paymentRef := ""
|
||||
if in.Payment != nil {
|
||||
paymentRef = strings.TrimSpace(in.Payment.PaymentRef)
|
||||
}
|
||||
logger.Debug("Starting Reconcile",
|
||||
zap.String("payment_ref", paymentRef),
|
||||
zap.String("event_source", eventSource(in.Event)),
|
||||
)
|
||||
defer func(start time.Time) {
|
||||
fields := []zap.Field{zap.Int64("duration_ms", time.Since(start).Milliseconds())}
|
||||
if out != nil {
|
||||
fields = append(fields,
|
||||
zap.String("matched_step_ref", strings.TrimSpace(out.MatchedStepRef)),
|
||||
zap.Bool("step_changed", out.StepChanged),
|
||||
zap.Bool("aggregate_changed", out.AggregateChanged),
|
||||
)
|
||||
if out.Payment != nil {
|
||||
fields = append(fields,
|
||||
zap.String("payment_state", string(out.Payment.State)),
|
||||
zap.Uint64("version", out.Payment.Version),
|
||||
)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
logger.Warn("Failed to reconcile", append(fields, zap.Error(err))...)
|
||||
return
|
||||
}
|
||||
logger.Debug("Completed Reconcile", fields...)
|
||||
}(time.Now())
|
||||
|
||||
if in.Payment == nil {
|
||||
return nil, merrors.InvalidArgument("payment is required")
|
||||
}
|
||||
if len(in.Payment.StepExecutions) == 0 {
|
||||
return nil, merrors.InvalidArgument("payment.step_executions are required")
|
||||
}
|
||||
|
||||
event, err := normalizeEvent(in.Event)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
payment, err := clonePayment(in.Payment)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
idx, err := findStepIndex(payment, event)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sm := ostate.New(ostate.Dependencies{Logger: logger.Named("ostate")})
|
||||
stepChanged, err := s.applyStepEvent(&payment.StepExecutions[idx], event, sm)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
aggregateChanged, err := reduceAggregateState(payment, event, sm)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if stepChanged || aggregateChanged {
|
||||
payment.Version++
|
||||
payment.UpdatedAt = s.now().UTC()
|
||||
}
|
||||
|
||||
out = &Output{
|
||||
Payment: payment,
|
||||
MatchedStepRef: payment.StepExecutions[idx].StepRef,
|
||||
StepChanged: stepChanged,
|
||||
AggregateChanged: aggregateChanged,
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *svc) applyStepEvent(step *agg.StepExecution, event *normalizedEvent, sm ostate.StateMachine) (bool, error) {
|
||||
if step == nil || event == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
changed := false
|
||||
out := *step
|
||||
|
||||
refs, refsChanged := mergeExternalRefs(out.ExternalRefs, event.appendRefs)
|
||||
if refsChanged {
|
||||
out.ExternalRefs = refs
|
||||
changed = true
|
||||
}
|
||||
|
||||
target := event.targetState
|
||||
if target == agg.StepStateUnspecified {
|
||||
*step = out
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
if out.State == target {
|
||||
changed = s.applyStepDiagnostics(&out, event) || changed
|
||||
*step = out
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
if sm.IsStepTerminal(out.State) {
|
||||
*step = out
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
next, transitionChanged, err := transitionStepState(out, target, sm)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
out = next
|
||||
changed = changed || transitionChanged
|
||||
changed = s.applyStepDiagnostics(&out, event) || changed
|
||||
|
||||
*step = out
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
func transitionStepState(step agg.StepExecution, target agg.StepState, sm ostate.StateMachine) (agg.StepExecution, bool, error) {
|
||||
if step.State == target {
|
||||
return step, false, nil
|
||||
}
|
||||
|
||||
if sm.EnsureStepTransition(step.State, target) == nil {
|
||||
step.State = target
|
||||
return step, true, nil
|
||||
}
|
||||
|
||||
original := step.State
|
||||
bridge := []agg.StepState{agg.StepStateRunning, target}
|
||||
for i := range bridge {
|
||||
next := bridge[i]
|
||||
if step.State == next {
|
||||
continue
|
||||
}
|
||||
if sm.EnsureStepTransition(step.State, next) != nil {
|
||||
return step, false, xerr.Wrapf(ErrStepTransitionInvalid, "%s -> %s", original, target)
|
||||
}
|
||||
step.State = next
|
||||
}
|
||||
return step, step.State != original, nil
|
||||
}
|
||||
|
||||
func (s *svc) applyStepDiagnostics(step *agg.StepExecution, event *normalizedEvent) bool {
|
||||
if step == nil || event == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
now := s.now().UTC()
|
||||
at := now
|
||||
if event.occurredAt != nil {
|
||||
at = event.occurredAt.UTC()
|
||||
}
|
||||
|
||||
changed := false
|
||||
switch step.State {
|
||||
case agg.StepStateRunning:
|
||||
if step.StartedAt == nil {
|
||||
step.StartedAt = &at
|
||||
changed = true
|
||||
}
|
||||
if step.CompletedAt != nil {
|
||||
step.CompletedAt = nil
|
||||
changed = true
|
||||
}
|
||||
if step.FailureCode != "" || step.FailureMsg != "" {
|
||||
step.FailureCode = ""
|
||||
step.FailureMsg = ""
|
||||
changed = true
|
||||
}
|
||||
|
||||
case agg.StepStateCompleted:
|
||||
if step.StartedAt == nil {
|
||||
step.StartedAt = &at
|
||||
changed = true
|
||||
}
|
||||
if step.CompletedAt == nil || !step.CompletedAt.Equal(at) {
|
||||
step.CompletedAt = &at
|
||||
changed = true
|
||||
}
|
||||
if step.FailureCode != "" || step.FailureMsg != "" {
|
||||
step.FailureCode = ""
|
||||
step.FailureMsg = ""
|
||||
changed = true
|
||||
}
|
||||
|
||||
case agg.StepStateFailed, agg.StepStateNeedsAttention:
|
||||
if step.StartedAt == nil {
|
||||
step.StartedAt = &at
|
||||
changed = true
|
||||
}
|
||||
if step.CompletedAt == nil || !step.CompletedAt.Equal(at) {
|
||||
step.CompletedAt = &at
|
||||
changed = true
|
||||
}
|
||||
fc := strings.TrimSpace(event.failureCode)
|
||||
fm := strings.TrimSpace(event.failureMsg)
|
||||
if step.FailureCode != fc || step.FailureMsg != fm {
|
||||
step.FailureCode = fc
|
||||
step.FailureMsg = fm
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
func clonePayment(payment *agg.Payment) (*agg.Payment, error) {
|
||||
data, err := bson.Marshal(payment)
|
||||
if err != nil {
|
||||
return nil, merrors.Internal("payment clone failed")
|
||||
}
|
||||
var out agg.Payment
|
||||
if err := bson.Unmarshal(data, &out); err != nil {
|
||||
return nil, merrors.Internal("payment clone failed")
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func eventSource(event Event) string {
|
||||
switch {
|
||||
case event.Gateway != nil:
|
||||
return "gateway"
|
||||
case event.Ledger != nil:
|
||||
return "ledger"
|
||||
case event.Card != nil:
|
||||
return "card"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
package erecon
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
)
|
||||
|
||||
func TestReconcile_GatewayWaiting_UpdatesRunningAndRefs(t *testing.T) {
|
||||
now := time.Date(2026, time.January, 10, 11, 12, 13, 0, time.UTC)
|
||||
reconciler := &svc{now: func() time.Time { return now }}
|
||||
|
||||
in := &agg.Payment{
|
||||
PaymentRef: "p1",
|
||||
State: agg.StateCreated,
|
||||
Version: 7,
|
||||
StepExecutions: []agg.StepExecution{
|
||||
{StepRef: "s1", StepCode: "send", State: agg.StepStatePending, Attempt: 1},
|
||||
},
|
||||
}
|
||||
|
||||
out, err := reconciler.Reconcile(Input{
|
||||
Payment: in,
|
||||
Event: Event{
|
||||
Gateway: &GatewayEvent{
|
||||
StepRef: "s1",
|
||||
OperationRef: "op-1",
|
||||
TransferRef: "tx-1",
|
||||
GatewayInstanceID: "gw-1",
|
||||
Status: GatewayStatusWaiting,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Reconcile returned error: %v", err)
|
||||
}
|
||||
if !out.StepChanged {
|
||||
t.Fatal("expected step_changed")
|
||||
}
|
||||
if !out.AggregateChanged {
|
||||
t.Fatal("expected aggregate_changed")
|
||||
}
|
||||
|
||||
got := out.Payment.StepExecutions[0]
|
||||
if got.State != agg.StepStateRunning {
|
||||
t.Fatalf("step state mismatch: got=%q want=%q", got.State, agg.StepStateRunning)
|
||||
}
|
||||
if got.StartedAt == nil || !got.StartedAt.Equal(now) {
|
||||
t.Fatalf("started_at mismatch: got=%v want=%v", got.StartedAt, now)
|
||||
}
|
||||
if got.CompletedAt != nil {
|
||||
t.Fatalf("expected nil completed_at, got %v", got.CompletedAt)
|
||||
}
|
||||
if !hasRef(got.ExternalRefs, agg.ExternalRef{GatewayInstanceID: "gw-1", Kind: ExternalRefKindOperation, Ref: "op-1"}) {
|
||||
t.Fatalf("expected operation_ref external reference")
|
||||
}
|
||||
if !hasRef(got.ExternalRefs, agg.ExternalRef{GatewayInstanceID: "gw-1", Kind: ExternalRefKindTransfer, Ref: "tx-1"}) {
|
||||
t.Fatalf("expected transfer_ref external reference")
|
||||
}
|
||||
if out.Payment.State != agg.StateExecuting {
|
||||
t.Fatalf("aggregate state mismatch: got=%q want=%q", out.Payment.State, agg.StateExecuting)
|
||||
}
|
||||
if out.Payment.Version != 8 {
|
||||
t.Fatalf("version mismatch: got=%d want=%d", out.Payment.Version, 8)
|
||||
}
|
||||
|
||||
if in.StepExecutions[0].State != agg.StepStatePending {
|
||||
t.Fatalf("input payment was mutated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcile_GatewaySuccess_SettlesPayment(t *testing.T) {
|
||||
now := time.Date(2026, time.January, 10, 11, 12, 13, 0, time.UTC)
|
||||
reconciler := &svc{now: func() time.Time { return now }}
|
||||
|
||||
out, err := reconciler.Reconcile(Input{
|
||||
Payment: &agg.Payment{
|
||||
PaymentRef: "p1",
|
||||
State: agg.StateCreated,
|
||||
Version: 1,
|
||||
StepExecutions: []agg.StepExecution{
|
||||
{StepRef: "s1", StepCode: "observe", State: agg.StepStatePending, Attempt: 1},
|
||||
},
|
||||
},
|
||||
Event: Event{
|
||||
Gateway: &GatewayEvent{
|
||||
StepRef: "s1",
|
||||
Status: GatewayStatusSuccess,
|
||||
OperationRef: "op-1",
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Reconcile returned error: %v", err)
|
||||
}
|
||||
step := out.Payment.StepExecutions[0]
|
||||
if step.State != agg.StepStateCompleted {
|
||||
t.Fatalf("step state mismatch: got=%q want=%q", step.State, agg.StepStateCompleted)
|
||||
}
|
||||
if step.CompletedAt == nil || !step.CompletedAt.Equal(now) {
|
||||
t.Fatalf("completed_at mismatch: got=%v want=%v", step.CompletedAt, now)
|
||||
}
|
||||
if out.Payment.State != agg.StateSettled {
|
||||
t.Fatalf("aggregate state mismatch: got=%q want=%q", out.Payment.State, agg.StateSettled)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcile_GatewayFailureMapping(t *testing.T) {
|
||||
now := time.Date(2026, time.January, 10, 11, 12, 13, 0, time.UTC)
|
||||
reconciler := &svc{now: func() time.Time { return now }}
|
||||
|
||||
retryable := true
|
||||
out, err := reconciler.Reconcile(Input{
|
||||
Payment: &agg.Payment{
|
||||
PaymentRef: "p1",
|
||||
State: agg.StateExecuting,
|
||||
StepExecutions: []agg.StepExecution{
|
||||
{StepRef: "s1", StepCode: "observe", State: agg.StepStateRunning, Attempt: 1},
|
||||
},
|
||||
},
|
||||
Event: Event{
|
||||
Gateway: &GatewayEvent{
|
||||
StepRef: "s1",
|
||||
Status: GatewayStatusFailed,
|
||||
Retryable: &retryable,
|
||||
FailureCode: "gw_timeout",
|
||||
FailureMsg: "timeout",
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Reconcile returned error: %v", err)
|
||||
}
|
||||
step := out.Payment.StepExecutions[0]
|
||||
if step.State != agg.StepStateFailed {
|
||||
t.Fatalf("step state mismatch: got=%q want=%q", step.State, agg.StepStateFailed)
|
||||
}
|
||||
if step.FailureCode != "gw_timeout" || step.FailureMsg != "timeout" {
|
||||
t.Fatalf("failure details mismatch: code=%q msg=%q", step.FailureCode, step.FailureMsg)
|
||||
}
|
||||
if out.Payment.State != agg.StateExecuting {
|
||||
t.Fatalf("aggregate state mismatch: got=%q want=%q", out.Payment.State, agg.StateExecuting)
|
||||
}
|
||||
|
||||
nonRetryable := false
|
||||
out, err = reconciler.Reconcile(Input{
|
||||
Payment: &agg.Payment{
|
||||
PaymentRef: "p2",
|
||||
State: agg.StateExecuting,
|
||||
StepExecutions: []agg.StepExecution{
|
||||
{StepRef: "s1", StepCode: "observe", State: agg.StepStateRunning, Attempt: 1},
|
||||
},
|
||||
},
|
||||
Event: Event{
|
||||
Gateway: &GatewayEvent{
|
||||
StepRef: "s1",
|
||||
Status: GatewayStatusFailed,
|
||||
Retryable: &nonRetryable,
|
||||
FailureCode: "gw_rejected",
|
||||
FailureMsg: "rejected",
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Reconcile returned error: %v", err)
|
||||
}
|
||||
step = out.Payment.StepExecutions[0]
|
||||
if step.State != agg.StepStateNeedsAttention {
|
||||
t.Fatalf("step state mismatch: got=%q want=%q", step.State, agg.StepStateNeedsAttention)
|
||||
}
|
||||
if out.Payment.State != agg.StateNeedsAttention {
|
||||
t.Fatalf("aggregate state mismatch: got=%q want=%q", out.Payment.State, agg.StateNeedsAttention)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcile_LedgerTerminalFailure_ForcesAggregateFailed(t *testing.T) {
|
||||
now := time.Date(2026, time.January, 10, 11, 12, 13, 0, time.UTC)
|
||||
reconciler := &svc{now: func() time.Time { return now }}
|
||||
|
||||
out, err := reconciler.Reconcile(Input{
|
||||
Payment: &agg.Payment{
|
||||
PaymentRef: "p1",
|
||||
State: agg.StateExecuting,
|
||||
StepExecutions: []agg.StepExecution{
|
||||
{
|
||||
StepRef: "s1", StepCode: "ledger.debit", State: agg.StepStateRunning, Attempt: 1,
|
||||
ExternalRefs: []agg.ExternalRef{{Kind: ExternalRefKindLedger, Ref: "entry-1"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
Event: Event{
|
||||
Ledger: &LedgerEvent{
|
||||
EntryRef: "entry-1",
|
||||
Status: LedgerStatusFailed,
|
||||
FailureCode: "ledger_declined",
|
||||
TerminalFailure: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Reconcile returned error: %v", err)
|
||||
}
|
||||
if out.Payment.StepExecutions[0].State != agg.StepStateFailed {
|
||||
t.Fatalf("step state mismatch: got=%q want=%q", out.Payment.StepExecutions[0].State, agg.StepStateFailed)
|
||||
}
|
||||
if out.Payment.State != agg.StateFailed {
|
||||
t.Fatalf("aggregate state mismatch: got=%q want=%q", out.Payment.State, agg.StateFailed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcile_CardMatchByExternalRef(t *testing.T) {
|
||||
reconciler := &svc{now: defaultNow}
|
||||
|
||||
out, err := reconciler.Reconcile(Input{
|
||||
Payment: &agg.Payment{
|
||||
PaymentRef: "p1",
|
||||
State: agg.StateExecuting,
|
||||
StepExecutions: []agg.StepExecution{
|
||||
{
|
||||
StepRef: "s1", StepCode: "card.observe", State: agg.StepStateRunning, Attempt: 1,
|
||||
ExternalRefs: []agg.ExternalRef{
|
||||
{Kind: ExternalRefKindCardPayout, Ref: "payout-1"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Event: Event{
|
||||
Card: &CardEvent{
|
||||
PayoutRef: "payout-1",
|
||||
Status: CardStatusSuccess,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Reconcile returned error: %v", err)
|
||||
}
|
||||
if out.MatchedStepRef != "s1" {
|
||||
t.Fatalf("matched step mismatch: got=%q want=%q", out.MatchedStepRef, "s1")
|
||||
}
|
||||
if out.Payment.StepExecutions[0].State != agg.StepStateCompleted {
|
||||
t.Fatalf("step state mismatch: got=%q want=%q", out.Payment.StepExecutions[0].State, agg.StepStateCompleted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcile_MatchingErrors(t *testing.T) {
|
||||
reconciler := &svc{now: defaultNow}
|
||||
|
||||
_, err := reconciler.Reconcile(Input{
|
||||
Payment: &agg.Payment{
|
||||
PaymentRef: "p1",
|
||||
State: agg.StateExecuting,
|
||||
StepExecutions: []agg.StepExecution{
|
||||
{
|
||||
StepRef: "s1", StepCode: "a", State: agg.StepStateRunning,
|
||||
ExternalRefs: []agg.ExternalRef{{Kind: ExternalRefKindTransfer, Ref: "tx-1"}},
|
||||
},
|
||||
{
|
||||
StepRef: "s2", StepCode: "b", State: agg.StepStateRunning,
|
||||
ExternalRefs: []agg.ExternalRef{{Kind: ExternalRefKindTransfer, Ref: "tx-1"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
Event: Event{
|
||||
Gateway: &GatewayEvent{
|
||||
TransferRef: "tx-1",
|
||||
Status: GatewayStatusSuccess,
|
||||
},
|
||||
},
|
||||
})
|
||||
if !errors.Is(err, ErrAmbiguousStepMatch) {
|
||||
t.Fatalf("expected ErrAmbiguousStepMatch, got %v", err)
|
||||
}
|
||||
|
||||
_, err = reconciler.Reconcile(Input{
|
||||
Payment: &agg.Payment{
|
||||
PaymentRef: "p1",
|
||||
State: agg.StateExecuting,
|
||||
StepExecutions: []agg.StepExecution{{StepRef: "s1", StepCode: "a", State: agg.StepStateRunning}},
|
||||
},
|
||||
Event: Event{
|
||||
Gateway: &GatewayEvent{
|
||||
TransferRef: "missing",
|
||||
Status: GatewayStatusSuccess,
|
||||
},
|
||||
},
|
||||
})
|
||||
if !errors.Is(err, ErrStepNotFound) {
|
||||
t.Fatalf("expected ErrStepNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcile_ValidationErrors(t *testing.T) {
|
||||
reconciler := &svc{now: defaultNow}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
in Input
|
||||
}{
|
||||
{
|
||||
name: "missing payment",
|
||||
in: Input{
|
||||
Event: Event{Gateway: &GatewayEvent{StepRef: "s1", Status: GatewayStatusSuccess}},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing step executions",
|
||||
in: Input{
|
||||
Payment: &agg.Payment{},
|
||||
Event: Event{Gateway: &GatewayEvent{StepRef: "s1", Status: GatewayStatusSuccess}},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple payloads",
|
||||
in: Input{
|
||||
Payment: &agg.Payment{
|
||||
StepExecutions: []agg.StepExecution{{StepRef: "s1", StepCode: "a", State: agg.StepStatePending}},
|
||||
},
|
||||
Event: Event{
|
||||
Gateway: &GatewayEvent{StepRef: "s1", Status: GatewayStatusSuccess},
|
||||
Ledger: &LedgerEvent{StepRef: "s1", Status: LedgerStatusPosted},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid status",
|
||||
in: Input{
|
||||
Payment: &agg.Payment{
|
||||
StepExecutions: []agg.StepExecution{{StepRef: "s1", StepCode: "a", State: agg.StepStatePending}},
|
||||
},
|
||||
Event: Event{
|
||||
Card: &CardEvent{StepRef: "s1", Status: CardStatus("bad")},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := reconciler.Reconcile(tt.in)
|
||||
if !errors.Is(err, merrors.ErrInvalidArg) {
|
||||
t.Fatalf("expected invalid argument error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func hasRef(refs []agg.ExternalRef, wanted agg.ExternalRef) bool {
|
||||
for i := range refs {
|
||||
ref := refs[i]
|
||||
if ref.Kind != wanted.Kind {
|
||||
continue
|
||||
}
|
||||
if ref.Ref != wanted.Ref {
|
||||
continue
|
||||
}
|
||||
if ref.GatewayInstanceID != wanted.GatewayInstanceID {
|
||||
continue
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package idem
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/tech/sendico/payments/storage"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
type fakeStore struct {
|
||||
createFn func(ctx context.Context, payment *model.Payment) error
|
||||
getByIdempotencyKeyFn func(ctx context.Context, orgRef bson.ObjectID, idempotencyKey string) (*model.Payment, error)
|
||||
}
|
||||
|
||||
func (f *fakeStore) Create(ctx context.Context, payment *model.Payment) error {
|
||||
if f.createFn == nil {
|
||||
return nil
|
||||
}
|
||||
return f.createFn(ctx, payment)
|
||||
}
|
||||
|
||||
func (f *fakeStore) GetByIdempotencyKey(ctx context.Context, orgRef bson.ObjectID, idempotencyKey string) (*model.Payment, error) {
|
||||
if f.getByIdempotencyKeyFn == nil {
|
||||
return nil, storage.ErrPaymentNotFound
|
||||
}
|
||||
return f.getByIdempotencyKeyFn(ctx, orgRef, idempotencyKey)
|
||||
}
|
||||
@@ -4,13 +4,31 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const hashSep = "\x1f"
|
||||
|
||||
func (s *svc) Fingerprint(in FPInput) (string, error) {
|
||||
func (s *svc) Fingerprint(in FPInput) (fingerprint string, err error) {
|
||||
logger := s.logger
|
||||
logger.Debug("Starting Fingerprint",
|
||||
zap.String("organization_ref", strings.ToLower(strings.TrimSpace(in.OrganizationRef))),
|
||||
zap.String("quotation_ref", strings.TrimSpace(in.QuotationRef)),
|
||||
zap.String("intent_ref", strings.TrimSpace(in.IntentRef)),
|
||||
zap.Bool("has_client_payment_ref", strings.TrimSpace(in.ClientPaymentRef) != ""),
|
||||
)
|
||||
defer func(start time.Time) {
|
||||
fields := []zap.Field{zap.Int64("duration_ms", time.Since(start).Milliseconds())}
|
||||
if err != nil {
|
||||
logger.Warn("Failed to fingerprint", append(fields, zap.Error(err))...)
|
||||
return
|
||||
}
|
||||
logger.Debug("Completed Fingerprint", append(fields, zap.Bool("generated", strings.TrimSpace(fingerprint) != ""))...)
|
||||
}(time.Now())
|
||||
|
||||
orgRef := strings.ToLower(strings.TrimSpace(in.OrganizationRef))
|
||||
if orgRef == "" {
|
||||
return "", merrors.InvalidArgument("organization_ref is required")
|
||||
@@ -29,7 +47,8 @@ func (s *svc) Fingerprint(in FPInput) (string, error) {
|
||||
"client=" + clientPaymentRef,
|
||||
}, hashSep)
|
||||
|
||||
return hashBytes([]byte(payload)), nil
|
||||
fingerprint = hashBytes([]byte(payload))
|
||||
return fingerprint, nil
|
||||
}
|
||||
|
||||
func hashBytes(data []byte) string {
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package idem
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFingerprint_StableAndTrimmed(t *testing.T) {
|
||||
svc := New()
|
||||
|
||||
a, err := svc.Fingerprint(FPInput{
|
||||
OrganizationRef: " 65f1a2c6f3c5e2e7a1b2c3d4 ",
|
||||
QuotationRef: " quote-1 ",
|
||||
IntentRef: " intent-1 ",
|
||||
ClientPaymentRef: " client-1 ",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Fingerprint returned error: %v", err)
|
||||
}
|
||||
b, err := svc.Fingerprint(FPInput{
|
||||
OrganizationRef: "65F1A2C6F3C5E2E7A1B2C3D4",
|
||||
QuotationRef: "quote-1",
|
||||
IntentRef: "intent-1",
|
||||
ClientPaymentRef: "client-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Fingerprint returned error: %v", err)
|
||||
}
|
||||
if a != b {
|
||||
t.Fatalf("expected deterministic fingerprint, got %q vs %q", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFingerprint_ChangesOnPayload(t *testing.T) {
|
||||
svc := New()
|
||||
|
||||
base, err := svc.Fingerprint(FPInput{
|
||||
OrganizationRef: "65f1a2c6f3c5e2e7a1b2c3d4",
|
||||
QuotationRef: "quote-1",
|
||||
IntentRef: "intent-1",
|
||||
ClientPaymentRef: "client-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Fingerprint returned error: %v", err)
|
||||
}
|
||||
|
||||
diffQuote, err := svc.Fingerprint(FPInput{
|
||||
OrganizationRef: "65f1a2c6f3c5e2e7a1b2c3d4",
|
||||
QuotationRef: "quote-2",
|
||||
IntentRef: "intent-1",
|
||||
ClientPaymentRef: "client-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Fingerprint returned error: %v", err)
|
||||
}
|
||||
if base == diffQuote {
|
||||
t.Fatalf("expected different fingerprint for different quotation_ref")
|
||||
}
|
||||
|
||||
diffClient, err := svc.Fingerprint(FPInput{
|
||||
OrganizationRef: "65f1a2c6f3c5e2e7a1b2c3d4",
|
||||
QuotationRef: "quote-1",
|
||||
IntentRef: "intent-1",
|
||||
ClientPaymentRef: "client-2",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Fingerprint returned error: %v", err)
|
||||
}
|
||||
if base == diffClient {
|
||||
t.Fatalf("expected different fingerprint for different client_payment_ref")
|
||||
}
|
||||
|
||||
diffIntent, err := svc.Fingerprint(FPInput{
|
||||
OrganizationRef: "65f1a2c6f3c5e2e7a1b2c3d4",
|
||||
QuotationRef: "quote-1",
|
||||
IntentRef: "intent-2",
|
||||
ClientPaymentRef: "client-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Fingerprint returned error: %v", err)
|
||||
}
|
||||
if base == diffIntent {
|
||||
t.Fatalf("expected different fingerprint for different intent_ref")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFingerprint_RequiresBusinessFields(t *testing.T) {
|
||||
svc := New()
|
||||
|
||||
if _, err := svc.Fingerprint(FPInput{
|
||||
QuotationRef: "quote-1",
|
||||
}); err == nil {
|
||||
t.Fatal("expected error for empty organization_ref")
|
||||
}
|
||||
|
||||
if _, err := svc.Fingerprint(FPInput{
|
||||
OrganizationRef: "65f1a2c6f3c5e2e7a1b2c3d4",
|
||||
}); err == nil {
|
||||
t.Fatal("expected error for empty quotation_ref")
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
@@ -41,6 +42,15 @@ type CreateInput struct {
|
||||
Reuse ReuseInput
|
||||
}
|
||||
|
||||
func New() Service {
|
||||
return &svc{}
|
||||
// Dependencies configures idempotency service integrations.
|
||||
type Dependencies struct {
|
||||
Logger mlogger.Logger
|
||||
}
|
||||
|
||||
func New(deps ...Dependencies) Service {
|
||||
var dep Dependencies
|
||||
if len(deps) > 0 {
|
||||
dep = deps[0]
|
||||
}
|
||||
return &svc{logger: dep.Logger.Named("idem")}
|
||||
}
|
||||
|
||||
@@ -10,101 +10,6 @@ import (
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
func TestFingerprint_StableAndTrimmed(t *testing.T) {
|
||||
svc := New()
|
||||
|
||||
a, err := svc.Fingerprint(FPInput{
|
||||
OrganizationRef: " 65f1a2c6f3c5e2e7a1b2c3d4 ",
|
||||
QuotationRef: " quote-1 ",
|
||||
IntentRef: " intent-1 ",
|
||||
ClientPaymentRef: " client-1 ",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Fingerprint returned error: %v", err)
|
||||
}
|
||||
b, err := svc.Fingerprint(FPInput{
|
||||
OrganizationRef: "65F1A2C6F3C5E2E7A1B2C3D4",
|
||||
QuotationRef: "quote-1",
|
||||
IntentRef: "intent-1",
|
||||
ClientPaymentRef: "client-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Fingerprint returned error: %v", err)
|
||||
}
|
||||
if a != b {
|
||||
t.Fatalf("expected deterministic fingerprint, got %q vs %q", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFingerprint_ChangesOnPayload(t *testing.T) {
|
||||
svc := New()
|
||||
|
||||
base, err := svc.Fingerprint(FPInput{
|
||||
OrganizationRef: "65f1a2c6f3c5e2e7a1b2c3d4",
|
||||
QuotationRef: "quote-1",
|
||||
IntentRef: "intent-1",
|
||||
ClientPaymentRef: "client-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Fingerprint returned error: %v", err)
|
||||
}
|
||||
|
||||
diffQuote, err := svc.Fingerprint(FPInput{
|
||||
OrganizationRef: "65f1a2c6f3c5e2e7a1b2c3d4",
|
||||
QuotationRef: "quote-2",
|
||||
IntentRef: "intent-1",
|
||||
ClientPaymentRef: "client-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Fingerprint returned error: %v", err)
|
||||
}
|
||||
if base == diffQuote {
|
||||
t.Fatalf("expected different fingerprint for different quotation_ref")
|
||||
}
|
||||
|
||||
diffClient, err := svc.Fingerprint(FPInput{
|
||||
OrganizationRef: "65f1a2c6f3c5e2e7a1b2c3d4",
|
||||
QuotationRef: "quote-1",
|
||||
IntentRef: "intent-1",
|
||||
ClientPaymentRef: "client-2",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Fingerprint returned error: %v", err)
|
||||
}
|
||||
if base == diffClient {
|
||||
t.Fatalf("expected different fingerprint for different client_payment_ref")
|
||||
}
|
||||
|
||||
diffIntent, err := svc.Fingerprint(FPInput{
|
||||
OrganizationRef: "65f1a2c6f3c5e2e7a1b2c3d4",
|
||||
QuotationRef: "quote-1",
|
||||
IntentRef: "intent-2",
|
||||
ClientPaymentRef: "client-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Fingerprint returned error: %v", err)
|
||||
}
|
||||
if base == diffIntent {
|
||||
t.Fatalf("expected different fingerprint for different intent_ref")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFingerprint_RequiresBusinessFields(t *testing.T) {
|
||||
svc := New()
|
||||
|
||||
if _, err := svc.Fingerprint(FPInput{
|
||||
QuotationRef: "quote-1",
|
||||
}); err == nil {
|
||||
t.Fatal("expected error for empty organization_ref")
|
||||
}
|
||||
|
||||
if _, err := svc.Fingerprint(FPInput{
|
||||
OrganizationRef: "65f1a2c6f3c5e2e7a1b2c3d4",
|
||||
}); err == nil {
|
||||
t.Fatal("expected error for empty quotation_ref")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTryReuse_NotFound(t *testing.T) {
|
||||
svc := New()
|
||||
store := &fakeStore{
|
||||
@@ -294,22 +199,3 @@ func TestCreateOrReuse_DuplicateWithoutReusableRecordReturnsDuplicate(t *testing
|
||||
t.Fatalf("expected ErrDuplicatePayment, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeStore struct {
|
||||
createFn func(ctx context.Context, payment *model.Payment) error
|
||||
getByIdempotencyKeyFn func(ctx context.Context, orgRef bson.ObjectID, idempotencyKey string) (*model.Payment, error)
|
||||
}
|
||||
|
||||
func (f *fakeStore) Create(ctx context.Context, payment *model.Payment) error {
|
||||
if f.createFn == nil {
|
||||
return nil
|
||||
}
|
||||
return f.createFn(ctx, payment)
|
||||
}
|
||||
|
||||
func (f *fakeStore) GetByIdempotencyKey(ctx context.Context, orgRef bson.ObjectID, idempotencyKey string) (*model.Payment, error) {
|
||||
if f.getByIdempotencyKeyFn == nil {
|
||||
return nil, storage.ErrPaymentNotFound
|
||||
}
|
||||
return f.getByIdempotencyKeyFn(ctx, orgRef, idempotencyKey)
|
||||
}
|
||||
@@ -4,21 +4,46 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/payments/storage"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const reqHashMetaKey = "_orchestrator_v2_req_hash"
|
||||
|
||||
type svc struct{}
|
||||
type svc struct {
|
||||
logger mlogger.Logger
|
||||
}
|
||||
|
||||
func (s *svc) TryReuse(
|
||||
ctx context.Context,
|
||||
store Store,
|
||||
in ReuseInput,
|
||||
) (*model.Payment, bool, error) {
|
||||
) (payment *model.Payment, reused bool, err error) {
|
||||
logger := s.logger
|
||||
logger.Debug("Starting Try reuse",
|
||||
zap.String("organization_ref", in.OrganizationID.Hex()),
|
||||
zap.Bool("has_idempotency_key", strings.TrimSpace(in.IdempotencyKey) != ""),
|
||||
)
|
||||
defer func(start time.Time) {
|
||||
fields := []zap.Field{
|
||||
zap.Int64("duration_ms", time.Since(start).Milliseconds()),
|
||||
zap.Bool("reused", reused),
|
||||
}
|
||||
if payment != nil {
|
||||
fields = append(fields, zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef)))
|
||||
}
|
||||
if err != nil {
|
||||
logger.Warn("Failed to try reuse", append(fields, zap.Error(err))...)
|
||||
return
|
||||
}
|
||||
logger.Debug("Completed Try reuse", fields...)
|
||||
}(time.Now())
|
||||
|
||||
if store == nil {
|
||||
return nil, false, merrors.InvalidArgument("payments store is required")
|
||||
}
|
||||
@@ -28,7 +53,7 @@ func (s *svc) TryReuse(
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
payment, err := store.GetByIdempotencyKey(ctx, in.OrganizationID, idempotencyKey)
|
||||
payment, err = store.GetByIdempotencyKey(ctx, in.OrganizationID, idempotencyKey)
|
||||
if err != nil {
|
||||
if errors.Is(err, storage.ErrPaymentNotFound) || errors.Is(err, merrors.ErrNoData) {
|
||||
return nil, false, nil
|
||||
@@ -50,7 +75,28 @@ func (s *svc) CreateOrReuse(
|
||||
ctx context.Context,
|
||||
store Store,
|
||||
in CreateInput,
|
||||
) (*model.Payment, bool, error) {
|
||||
) (payment *model.Payment, reused bool, err error) {
|
||||
logger := s.logger
|
||||
logger.Debug("Starting Create or reuse",
|
||||
zap.String("organization_ref", in.Reuse.OrganizationID.Hex()),
|
||||
zap.Bool("has_payment", in.Payment != nil),
|
||||
zap.Bool("has_idempotency_key", strings.TrimSpace(in.Reuse.IdempotencyKey) != ""),
|
||||
)
|
||||
defer func(start time.Time) {
|
||||
fields := []zap.Field{
|
||||
zap.Int64("duration_ms", time.Since(start).Milliseconds()),
|
||||
zap.Bool("reused", reused),
|
||||
}
|
||||
if payment != nil {
|
||||
fields = append(fields, zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef)))
|
||||
}
|
||||
if err != nil {
|
||||
logger.Warn("Failed to create or reuse", append(fields, zap.Error(err))...)
|
||||
return
|
||||
}
|
||||
logger.Debug("Completed Create or reuse", fields...)
|
||||
}(time.Now())
|
||||
|
||||
if store == nil {
|
||||
return nil, false, merrors.InvalidArgument("payments store is required")
|
||||
}
|
||||
@@ -64,19 +110,19 @@ func (s *svc) CreateOrReuse(
|
||||
}
|
||||
setPaymentReqHash(in.Payment, fingerprint)
|
||||
|
||||
if err := store.Create(ctx, in.Payment); err != nil {
|
||||
if !errors.Is(err, storage.ErrDuplicatePayment) {
|
||||
return nil, false, err
|
||||
if createErr := store.Create(ctx, in.Payment); createErr != nil {
|
||||
if !errors.Is(createErr, storage.ErrDuplicatePayment) {
|
||||
return nil, false, createErr
|
||||
}
|
||||
|
||||
payment, reused, reuseErr := s.TryReuse(ctx, store, in.Reuse)
|
||||
if reuseErr != nil {
|
||||
return nil, false, reuseErr
|
||||
payment, reused, err = s.TryReuse(ctx, store, in.Reuse)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if reused {
|
||||
return payment, true, nil
|
||||
}
|
||||
return nil, false, err
|
||||
return nil, false, createErr
|
||||
}
|
||||
|
||||
return in.Payment, false, nil
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package oobs
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
)
|
||||
|
||||
type memoryAuditStore struct {
|
||||
mu sync.RWMutex
|
||||
byPayment map[string][]TimelineEntry
|
||||
byStep map[string][]TimelineEntry
|
||||
}
|
||||
|
||||
func newMemoryAuditStore() AuditStore {
|
||||
return &memoryAuditStore{
|
||||
byPayment: map[string][]TimelineEntry{},
|
||||
byStep: map[string][]TimelineEntry{},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *memoryAuditStore) Append(entry TimelineEntry) error {
|
||||
paymentRef := strings.TrimSpace(entry.PaymentRef)
|
||||
if paymentRef == "" {
|
||||
return merrors.InvalidArgument("timeline.payment_ref is required")
|
||||
}
|
||||
stepRef := strings.TrimSpace(entry.StepRef)
|
||||
entry.PaymentRef = paymentRef
|
||||
entry.StepRef = stepRef
|
||||
entry.StepCode = strings.TrimSpace(entry.StepCode)
|
||||
entry.Event = strings.TrimSpace(entry.Event)
|
||||
entry.State = strings.TrimSpace(entry.State)
|
||||
entry.Message = strings.TrimSpace(entry.Message)
|
||||
entry.Fields = trimStringMap(entry.Fields)
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.byPayment[paymentRef] = append(s.byPayment[paymentRef], cloneTimelineEntry(entry))
|
||||
if stepRef == "" {
|
||||
return nil
|
||||
}
|
||||
key := stepAttemptKey(paymentRef, stepRef, entry.Attempt)
|
||||
s.byStep[key] = append(s.byStep[key], cloneTimelineEntry(entry))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *memoryAuditStore) ListByPayment(paymentRef string, limit int32, desc bool) ([]TimelineEntry, error) {
|
||||
ref := strings.TrimSpace(paymentRef)
|
||||
if ref == "" {
|
||||
return nil, merrors.InvalidArgument("payment_ref is required")
|
||||
}
|
||||
limit = normalizeLimit(limit)
|
||||
|
||||
s.mu.RLock()
|
||||
items := append([]TimelineEntry(nil), s.byPayment[ref]...)
|
||||
s.mu.RUnlock()
|
||||
|
||||
return paginateEntries(items, limit, desc), nil
|
||||
}
|
||||
|
||||
func (s *memoryAuditStore) ListByStepAttempt(
|
||||
paymentRef string,
|
||||
stepRef string,
|
||||
attempt uint32,
|
||||
limit int32,
|
||||
desc bool,
|
||||
) ([]TimelineEntry, error) {
|
||||
ref := strings.TrimSpace(paymentRef)
|
||||
if ref == "" {
|
||||
return nil, merrors.InvalidArgument("payment_ref is required")
|
||||
}
|
||||
step := strings.TrimSpace(stepRef)
|
||||
if step == "" {
|
||||
return nil, merrors.InvalidArgument("step_ref is required")
|
||||
}
|
||||
if attempt == 0 {
|
||||
return nil, merrors.InvalidArgument("attempt is required")
|
||||
}
|
||||
limit = normalizeLimit(limit)
|
||||
|
||||
key := stepAttemptKey(ref, step, attempt)
|
||||
s.mu.RLock()
|
||||
items := append([]TimelineEntry(nil), s.byStep[key]...)
|
||||
s.mu.RUnlock()
|
||||
|
||||
return paginateEntries(items, limit, desc), nil
|
||||
}
|
||||
|
||||
func paginateEntries(items []TimelineEntry, limit int32, desc bool) []TimelineEntry {
|
||||
if desc {
|
||||
items = reverseEntries(items)
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
if int32(len(items)) > limit {
|
||||
items = items[:limit]
|
||||
}
|
||||
out := make([]TimelineEntry, 0, len(items))
|
||||
for i := range items {
|
||||
out = append(out, cloneTimelineEntry(items[i]))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneTimelineEntry(entry TimelineEntry) TimelineEntry {
|
||||
entry.Fields = cloneStringMap(entry.Fields)
|
||||
return entry
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package oobs
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultTimelineLimit int32 = 100
|
||||
maxTimelineLimit int32 = 1000
|
||||
)
|
||||
|
||||
func normalizePaymentEvent(event PaymentEvent) (PaymentEvent, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(string(event))) {
|
||||
case string(PaymentEventCreated):
|
||||
return PaymentEventCreated, true
|
||||
case string(PaymentEventStateChanged):
|
||||
return PaymentEventStateChanged, true
|
||||
case string(PaymentEventNeedsAttention):
|
||||
return PaymentEventNeedsAttention, true
|
||||
case string(PaymentEventSettled):
|
||||
return PaymentEventSettled, true
|
||||
case string(PaymentEventFailed):
|
||||
return PaymentEventFailed, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeStepEvent(event StepEvent) (StepEvent, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(string(event))) {
|
||||
case string(StepEventScheduled):
|
||||
return StepEventScheduled, true
|
||||
case string(StepEventStarted):
|
||||
return StepEventStarted, true
|
||||
case string(StepEventCompleted):
|
||||
return StepEventCompleted, true
|
||||
case string(StepEventFailed):
|
||||
return StepEventFailed, true
|
||||
case string(StepEventSkipped):
|
||||
return StepEventSkipped, true
|
||||
case string(StepEventBlocked):
|
||||
return StepEventBlocked, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeExternalSource(source ExternalSource) (ExternalSource, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(string(source))) {
|
||||
case string(ExternalSourceGateway):
|
||||
return ExternalSourceGateway, true
|
||||
case string(ExternalSourceLedger):
|
||||
return ExternalSourceLedger, true
|
||||
case string(ExternalSourceCard):
|
||||
return ExternalSourceCard, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeAggregateState(state agg.State) (agg.State, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(string(state))) {
|
||||
case string(agg.StateCreated):
|
||||
return agg.StateCreated, true
|
||||
case string(agg.StateExecuting):
|
||||
return agg.StateExecuting, true
|
||||
case string(agg.StateNeedsAttention):
|
||||
return agg.StateNeedsAttention, true
|
||||
case string(agg.StateSettled):
|
||||
return agg.StateSettled, true
|
||||
case string(agg.StateFailed):
|
||||
return agg.StateFailed, true
|
||||
default:
|
||||
return agg.StateUnspecified, false
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeStepState(state agg.StepState) (agg.StepState, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(string(state))) {
|
||||
case string(agg.StepStatePending):
|
||||
return agg.StepStatePending, true
|
||||
case string(agg.StepStateRunning):
|
||||
return agg.StepStateRunning, true
|
||||
case string(agg.StepStateCompleted):
|
||||
return agg.StepStateCompleted, true
|
||||
case string(agg.StepStateFailed):
|
||||
return agg.StepStateFailed, true
|
||||
case string(agg.StepStateNeedsAttention):
|
||||
return agg.StepStateNeedsAttention, true
|
||||
case string(agg.StepStateSkipped):
|
||||
return agg.StepStateSkipped, true
|
||||
default:
|
||||
return agg.StepStateUnspecified, false
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeLimit(limit int32) int32 {
|
||||
if limit <= 0 {
|
||||
return defaultTimelineLimit
|
||||
}
|
||||
if limit > maxTimelineLimit {
|
||||
return maxTimelineLimit
|
||||
}
|
||||
return limit
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package oobs
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func nowUTC(nowFn func() time.Time) time.Time {
|
||||
if nowFn == nil {
|
||||
return time.Now().UTC()
|
||||
}
|
||||
return nowFn().UTC()
|
||||
}
|
||||
|
||||
func cloneStringMap(src map[string]string) map[string]string {
|
||||
if len(src) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(src))
|
||||
for key, value := range src {
|
||||
out[strings.TrimSpace(key)] = strings.TrimSpace(value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func trimStringMap(src map[string]string) map[string]string {
|
||||
if len(src) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(src))
|
||||
for key, value := range src {
|
||||
k := strings.TrimSpace(key)
|
||||
v := strings.TrimSpace(value)
|
||||
if k == "" && v == "" {
|
||||
continue
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mergeMaps(left map[string]string, right map[string]string) map[string]string {
|
||||
if len(left) == 0 && len(right) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(left)+len(right))
|
||||
for key, value := range left {
|
||||
out[key] = value
|
||||
}
|
||||
for key, value := range right {
|
||||
out[key] = value
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func stepAttemptKey(paymentRef string, stepRef string, attempt uint32) string {
|
||||
return paymentRef + "|" + stepRef + "|" + uint32String(attempt)
|
||||
}
|
||||
|
||||
func uint32String(v uint32) string {
|
||||
if v == 0 {
|
||||
return "0"
|
||||
}
|
||||
var buf [10]byte
|
||||
i := len(buf)
|
||||
for v > 0 {
|
||||
i--
|
||||
buf[i] = byte('0' + v%10)
|
||||
v /= 10
|
||||
}
|
||||
return string(buf[i:])
|
||||
}
|
||||
|
||||
func reverseEntries(items []TimelineEntry) []TimelineEntry {
|
||||
if len(items) <= 1 {
|
||||
return items
|
||||
}
|
||||
out := make([]TimelineEntry, 0, len(items))
|
||||
for i := len(items) - 1; i >= 0; i-- {
|
||||
out = append(out, items[i])
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package oobs
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||
)
|
||||
|
||||
type noopMetrics struct{}
|
||||
|
||||
func newNoopMetrics() Metrics {
|
||||
return noopMetrics{}
|
||||
}
|
||||
|
||||
func (noopMetrics) IncPaymentEvent(_ PaymentEvent, _ agg.State) {}
|
||||
|
||||
func (noopMetrics) IncStepEvent(_ StepEvent, _ string, _ agg.StepState) {}
|
||||
|
||||
func (noopMetrics) IncExternalEvent(_ ExternalSource, _ string) {}
|
||||
|
||||
func (noopMetrics) ObserveStepDuration(_ string, _ agg.StepState, _ time.Duration) {}
|
||||
@@ -0,0 +1,152 @@
|
||||
package oobs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
)
|
||||
|
||||
// Observer records orchestration-v2 telemetry and exposes audit timelines.
|
||||
type Observer interface {
|
||||
RecordPayment(ctx context.Context, in RecordPaymentInput) error
|
||||
RecordStep(ctx context.Context, in RecordStepInput) error
|
||||
RecordExternal(ctx context.Context, in RecordExternalInput) error
|
||||
|
||||
PaymentTimeline(ctx context.Context, in PaymentTimelineInput) (*TimelineOutput, error)
|
||||
StepTimeline(ctx context.Context, in StepTimelineInput) (*TimelineOutput, error)
|
||||
}
|
||||
|
||||
// PaymentEvent classifies aggregate-level observability events.
|
||||
type PaymentEvent string
|
||||
|
||||
const (
|
||||
PaymentEventCreated PaymentEvent = "created"
|
||||
PaymentEventStateChanged PaymentEvent = "state_changed"
|
||||
PaymentEventNeedsAttention PaymentEvent = "needs_attention"
|
||||
PaymentEventSettled PaymentEvent = "settled"
|
||||
PaymentEventFailed PaymentEvent = "failed"
|
||||
)
|
||||
|
||||
// StepEvent classifies step-attempt-level observability events.
|
||||
type StepEvent string
|
||||
|
||||
const (
|
||||
StepEventScheduled StepEvent = "scheduled"
|
||||
StepEventStarted StepEvent = "started"
|
||||
StepEventCompleted StepEvent = "completed"
|
||||
StepEventFailed StepEvent = "failed"
|
||||
StepEventSkipped StepEvent = "skipped"
|
||||
StepEventBlocked StepEvent = "blocked"
|
||||
)
|
||||
|
||||
// ExternalSource identifies asynchronous event origin.
|
||||
type ExternalSource string
|
||||
|
||||
const (
|
||||
ExternalSourceGateway ExternalSource = "gateway"
|
||||
ExternalSourceLedger ExternalSource = "ledger"
|
||||
ExternalSourceCard ExternalSource = "card"
|
||||
)
|
||||
|
||||
// TimelineScope classifies timeline item scope.
|
||||
type TimelineScope string
|
||||
|
||||
const (
|
||||
ScopePayment TimelineScope = "payment"
|
||||
ScopeStep TimelineScope = "step"
|
||||
)
|
||||
|
||||
// TimelineEntry is one immutable audit timeline item.
|
||||
type TimelineEntry struct {
|
||||
OccurredAt time.Time
|
||||
Scope TimelineScope
|
||||
PaymentRef string
|
||||
StepRef string
|
||||
StepCode string
|
||||
Attempt uint32
|
||||
Event string
|
||||
State string
|
||||
Message string
|
||||
Fields map[string]string
|
||||
}
|
||||
|
||||
// TimelineOutput is one timeline query result page.
|
||||
type TimelineOutput struct {
|
||||
Items []TimelineEntry
|
||||
}
|
||||
|
||||
// RecordPaymentInput is aggregate-level telemetry payload.
|
||||
type RecordPaymentInput struct {
|
||||
Payment *agg.Payment
|
||||
Event PaymentEvent
|
||||
Message string
|
||||
Fields map[string]string
|
||||
}
|
||||
|
||||
// RecordStepInput is step-attempt-level telemetry payload.
|
||||
type RecordStepInput struct {
|
||||
PaymentRef string
|
||||
Step agg.StepExecution
|
||||
Event StepEvent
|
||||
Message string
|
||||
Duration time.Duration
|
||||
Fields map[string]string
|
||||
}
|
||||
|
||||
// RecordExternalInput is external-event telemetry payload.
|
||||
type RecordExternalInput struct {
|
||||
PaymentRef string
|
||||
StepRef string
|
||||
Attempt uint32
|
||||
Source ExternalSource
|
||||
Status string
|
||||
RefKind string
|
||||
Ref string
|
||||
Message string
|
||||
Fields map[string]string
|
||||
}
|
||||
|
||||
// PaymentTimelineInput scopes payment-level timeline lookup.
|
||||
type PaymentTimelineInput struct {
|
||||
PaymentRef string
|
||||
Limit int32
|
||||
Desc bool
|
||||
}
|
||||
|
||||
// StepTimelineInput scopes step-attempt timeline lookup.
|
||||
type StepTimelineInput struct {
|
||||
PaymentRef string
|
||||
StepRef string
|
||||
Attempt uint32
|
||||
Limit int32
|
||||
Desc bool
|
||||
}
|
||||
|
||||
// Metrics captures counters and durations for orchestration telemetry.
|
||||
type Metrics interface {
|
||||
IncPaymentEvent(event PaymentEvent, state agg.State)
|
||||
IncStepEvent(event StepEvent, stepCode string, state agg.StepState)
|
||||
IncExternalEvent(source ExternalSource, status string)
|
||||
ObserveStepDuration(stepCode string, state agg.StepState, duration time.Duration)
|
||||
}
|
||||
|
||||
// AuditStore persists timeline events and serves timeline lookups.
|
||||
type AuditStore interface {
|
||||
Append(entry TimelineEntry) error
|
||||
ListByPayment(paymentRef string, limit int32, desc bool) ([]TimelineEntry, error)
|
||||
ListByStepAttempt(paymentRef string, stepRef string, attempt uint32, limit int32, desc bool) ([]TimelineEntry, error)
|
||||
}
|
||||
|
||||
// Dependencies configures observer integrations.
|
||||
type Dependencies struct {
|
||||
Logger mlogger.Logger
|
||||
Metrics Metrics
|
||||
Store AuditStore
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func New(deps Dependencies) (Observer, error) {
|
||||
return newService(deps)
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
package oobs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type svc struct {
|
||||
logger mlogger.Logger
|
||||
metrics Metrics
|
||||
store AuditStore
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func newService(deps Dependencies) (Observer, error) {
|
||||
store := deps.Store
|
||||
if store == nil {
|
||||
store = newMemoryAuditStore()
|
||||
}
|
||||
|
||||
logger := deps.Logger.Named("oobs")
|
||||
|
||||
metrics := deps.Metrics
|
||||
if metrics == nil {
|
||||
metrics = newNoopMetrics()
|
||||
}
|
||||
|
||||
return &svc{
|
||||
logger: logger,
|
||||
metrics: metrics,
|
||||
store: store,
|
||||
now: deps.Now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *svc) RecordPayment(_ context.Context, in RecordPaymentInput) (err error) {
|
||||
logger := s.logger
|
||||
paymentRef := ""
|
||||
if in.Payment != nil {
|
||||
paymentRef = strings.TrimSpace(in.Payment.PaymentRef)
|
||||
}
|
||||
logger.Debug("Starting Record payment",
|
||||
zap.String("payment_ref", paymentRef),
|
||||
zap.String("event", string(in.Event)),
|
||||
)
|
||||
defer func(start time.Time) {
|
||||
fields := []zap.Field{
|
||||
zap.Int64("duration_ms", time.Since(start).Milliseconds()),
|
||||
zap.String("payment_ref", paymentRef),
|
||||
zap.String("event", string(in.Event)),
|
||||
}
|
||||
if err != nil {
|
||||
logger.Warn("Failed to record payment", append(fields, zap.Error(err))...)
|
||||
return
|
||||
}
|
||||
logger.Debug("Completed Record payment", fields...)
|
||||
}(time.Now())
|
||||
|
||||
entry, payment, event, err := buildPaymentEntry(nowUTC(s.now), in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.store.Append(entry); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.metrics.IncPaymentEvent(event, payment.State)
|
||||
s.logPayment(entry, payment.State, payment.Version)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *svc) RecordStep(_ context.Context, in RecordStepInput) (err error) {
|
||||
logger := s.logger
|
||||
logger.Debug("Starting Record step",
|
||||
zap.String("payment_ref", strings.TrimSpace(in.PaymentRef)),
|
||||
zap.String("step_ref", strings.TrimSpace(in.Step.StepRef)),
|
||||
zap.Uint32("attempt", in.Step.Attempt),
|
||||
zap.String("event", string(in.Event)),
|
||||
)
|
||||
defer func(start time.Time) {
|
||||
fields := []zap.Field{
|
||||
zap.Int64("duration_ms", time.Since(start).Milliseconds()),
|
||||
zap.String("payment_ref", strings.TrimSpace(in.PaymentRef)),
|
||||
zap.String("step_ref", strings.TrimSpace(in.Step.StepRef)),
|
||||
zap.Uint32("attempt", in.Step.Attempt),
|
||||
zap.String("event", string(in.Event)),
|
||||
}
|
||||
if err != nil {
|
||||
logger.Warn("Failed to record step", append(fields, zap.Error(err))...)
|
||||
return
|
||||
}
|
||||
logger.Debug("Completed Record step", fields...)
|
||||
}(time.Now())
|
||||
|
||||
entry, step, event, duration, err := buildStepEntry(nowUTC(s.now), in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.store.Append(entry); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.metrics.IncStepEvent(event, step.StepCode, step.State)
|
||||
if duration > 0 {
|
||||
s.metrics.ObserveStepDuration(step.StepCode, step.State, duration)
|
||||
}
|
||||
s.logStep(entry, step.State, duration)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *svc) RecordExternal(_ context.Context, in RecordExternalInput) (err error) {
|
||||
logger := s.logger
|
||||
logger.Debug("Starting Record external",
|
||||
zap.String("payment_ref", strings.TrimSpace(in.PaymentRef)),
|
||||
zap.String("step_ref", strings.TrimSpace(in.StepRef)),
|
||||
zap.Uint32("attempt", in.Attempt),
|
||||
zap.String("source", string(in.Source)),
|
||||
zap.String("status", strings.TrimSpace(in.Status)),
|
||||
)
|
||||
defer func(start time.Time) {
|
||||
fields := []zap.Field{
|
||||
zap.Int64("duration_ms", time.Since(start).Milliseconds()),
|
||||
zap.String("payment_ref", strings.TrimSpace(in.PaymentRef)),
|
||||
zap.String("step_ref", strings.TrimSpace(in.StepRef)),
|
||||
zap.Uint32("attempt", in.Attempt),
|
||||
zap.String("source", string(in.Source)),
|
||||
zap.String("status", strings.TrimSpace(in.Status)),
|
||||
}
|
||||
if err != nil {
|
||||
logger.Warn("Failed to record external", append(fields, zap.Error(err))...)
|
||||
return
|
||||
}
|
||||
logger.Debug("Completed Record external", fields...)
|
||||
}(time.Now())
|
||||
|
||||
entry, source, status, err := buildExternalEntry(nowUTC(s.now), in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.store.Append(entry); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.metrics.IncExternalEvent(source, status)
|
||||
s.logExternal(entry, source, status)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *svc) PaymentTimeline(_ context.Context, in PaymentTimelineInput) (out *TimelineOutput, err error) {
|
||||
logger := s.logger
|
||||
logger.Debug("Starting Payment timeline",
|
||||
zap.String("payment_ref", strings.TrimSpace(in.PaymentRef)),
|
||||
zap.Int32("limit", in.Limit),
|
||||
zap.Bool("desc", in.Desc),
|
||||
)
|
||||
defer func(start time.Time) {
|
||||
fields := []zap.Field{zap.Int64("duration_ms", time.Since(start).Milliseconds())}
|
||||
if out != nil {
|
||||
fields = append(fields, zap.Int("items_count", len(out.Items)))
|
||||
}
|
||||
if err != nil {
|
||||
logger.Warn("Failed to payment timeline", append(fields, zap.Error(err))...)
|
||||
return
|
||||
}
|
||||
logger.Debug("Completed Payment timeline", fields...)
|
||||
}(time.Now())
|
||||
|
||||
paymentRef := strings.TrimSpace(in.PaymentRef)
|
||||
if paymentRef == "" {
|
||||
return nil, merrors.InvalidArgument("payment_ref is required")
|
||||
}
|
||||
items, err := s.store.ListByPayment(paymentRef, in.Limit, in.Desc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = &TimelineOutput{Items: items}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *svc) StepTimeline(_ context.Context, in StepTimelineInput) (out *TimelineOutput, err error) {
|
||||
logger := s.logger
|
||||
logger.Debug("Starting Step timeline",
|
||||
zap.String("payment_ref", strings.TrimSpace(in.PaymentRef)),
|
||||
zap.String("step_ref", strings.TrimSpace(in.StepRef)),
|
||||
zap.Uint32("attempt", in.Attempt),
|
||||
zap.Int32("limit", in.Limit),
|
||||
zap.Bool("desc", in.Desc),
|
||||
)
|
||||
defer func(start time.Time) {
|
||||
fields := []zap.Field{zap.Int64("duration_ms", time.Since(start).Milliseconds())}
|
||||
if out != nil {
|
||||
fields = append(fields, zap.Int("items_count", len(out.Items)))
|
||||
}
|
||||
if err != nil {
|
||||
logger.Warn("Failed to step timeline", append(fields, zap.Error(err))...)
|
||||
return
|
||||
}
|
||||
logger.Debug("Completed Step timeline", fields...)
|
||||
}(time.Now())
|
||||
|
||||
paymentRef := strings.TrimSpace(in.PaymentRef)
|
||||
if paymentRef == "" {
|
||||
return nil, merrors.InvalidArgument("payment_ref is required")
|
||||
}
|
||||
stepRef := strings.TrimSpace(in.StepRef)
|
||||
if stepRef == "" {
|
||||
return nil, merrors.InvalidArgument("step_ref is required")
|
||||
}
|
||||
if in.Attempt == 0 {
|
||||
return nil, merrors.InvalidArgument("attempt is required")
|
||||
}
|
||||
|
||||
items, err := s.store.ListByStepAttempt(paymentRef, stepRef, in.Attempt, in.Limit, in.Desc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = &TimelineOutput{Items: items}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func buildPaymentEntry(now time.Time, in RecordPaymentInput) (TimelineEntry, *agg.Payment, PaymentEvent, error) {
|
||||
payment := in.Payment
|
||||
if payment == nil {
|
||||
return TimelineEntry{}, nil, "", merrors.InvalidArgument("payment is required")
|
||||
}
|
||||
event, ok := normalizePaymentEvent(in.Event)
|
||||
if !ok {
|
||||
return TimelineEntry{}, nil, "", merrors.InvalidArgument("event is invalid")
|
||||
}
|
||||
state, ok := normalizeAggregateState(payment.State)
|
||||
if !ok {
|
||||
return TimelineEntry{}, nil, "", merrors.InvalidArgument("payment.state is invalid")
|
||||
}
|
||||
paymentRef := strings.TrimSpace(payment.PaymentRef)
|
||||
if paymentRef == "" {
|
||||
return TimelineEntry{}, nil, "", merrors.InvalidArgument("payment.payment_ref is required")
|
||||
}
|
||||
|
||||
entry := TimelineEntry{
|
||||
OccurredAt: now,
|
||||
Scope: ScopePayment,
|
||||
PaymentRef: paymentRef,
|
||||
Event: string(event),
|
||||
State: string(state),
|
||||
Message: strings.TrimSpace(in.Message),
|
||||
Fields: trimStringMap(in.Fields),
|
||||
}
|
||||
return entry, payment, event, nil
|
||||
}
|
||||
|
||||
func buildStepEntry(now time.Time, in RecordStepInput) (TimelineEntry, agg.StepExecution, StepEvent, time.Duration, error) {
|
||||
event, ok := normalizeStepEvent(in.Event)
|
||||
if !ok {
|
||||
return TimelineEntry{}, agg.StepExecution{}, "", 0, merrors.InvalidArgument("event is invalid")
|
||||
}
|
||||
paymentRef := strings.TrimSpace(in.PaymentRef)
|
||||
if paymentRef == "" {
|
||||
return TimelineEntry{}, agg.StepExecution{}, "", 0, merrors.InvalidArgument("payment_ref is required")
|
||||
}
|
||||
stepRef := strings.TrimSpace(in.Step.StepRef)
|
||||
if stepRef == "" {
|
||||
return TimelineEntry{}, agg.StepExecution{}, "", 0, merrors.InvalidArgument("step.step_ref is required")
|
||||
}
|
||||
stepState, ok := normalizeStepState(in.Step.State)
|
||||
if !ok {
|
||||
return TimelineEntry{}, agg.StepExecution{}, "", 0, merrors.InvalidArgument("step.state is invalid")
|
||||
}
|
||||
|
||||
attempt := in.Step.Attempt
|
||||
if attempt == 0 {
|
||||
attempt = 1
|
||||
}
|
||||
|
||||
entry := TimelineEntry{
|
||||
OccurredAt: now,
|
||||
Scope: ScopeStep,
|
||||
PaymentRef: paymentRef,
|
||||
StepRef: stepRef,
|
||||
StepCode: strings.TrimSpace(in.Step.StepCode),
|
||||
Attempt: attempt,
|
||||
Event: string(event),
|
||||
State: string(stepState),
|
||||
Message: strings.TrimSpace(in.Message),
|
||||
Fields: trimStringMap(in.Fields),
|
||||
}
|
||||
|
||||
step := in.Step
|
||||
step.State = stepState
|
||||
step.StepRef = stepRef
|
||||
step.StepCode = strings.TrimSpace(step.StepCode)
|
||||
step.Attempt = attempt
|
||||
|
||||
return entry, step, event, in.Duration, nil
|
||||
}
|
||||
|
||||
func buildExternalEntry(now time.Time, in RecordExternalInput) (TimelineEntry, ExternalSource, string, error) {
|
||||
source, ok := normalizeExternalSource(in.Source)
|
||||
if !ok {
|
||||
return TimelineEntry{}, "", "", merrors.InvalidArgument("source is invalid")
|
||||
}
|
||||
paymentRef := strings.TrimSpace(in.PaymentRef)
|
||||
if paymentRef == "" {
|
||||
return TimelineEntry{}, "", "", merrors.InvalidArgument("payment_ref is required")
|
||||
}
|
||||
stepRef := strings.TrimSpace(in.StepRef)
|
||||
if stepRef == "" {
|
||||
return TimelineEntry{}, "", "", merrors.InvalidArgument("step_ref is required")
|
||||
}
|
||||
if in.Attempt == 0 {
|
||||
return TimelineEntry{}, "", "", merrors.InvalidArgument("attempt is required")
|
||||
}
|
||||
|
||||
status := strings.ToLower(strings.TrimSpace(in.Status))
|
||||
if status == "" {
|
||||
status = "received"
|
||||
}
|
||||
fields := mergeMaps(trimStringMap(in.Fields), map[string]string{
|
||||
"source": string(source),
|
||||
"status": status,
|
||||
"refKind": strings.TrimSpace(in.RefKind),
|
||||
"ref": strings.TrimSpace(in.Ref),
|
||||
})
|
||||
entry := TimelineEntry{
|
||||
OccurredAt: now,
|
||||
Scope: ScopeStep,
|
||||
PaymentRef: paymentRef,
|
||||
StepRef: stepRef,
|
||||
Attempt: in.Attempt,
|
||||
Event: "external." + string(source) + "." + status,
|
||||
State: status,
|
||||
Message: strings.TrimSpace(in.Message),
|
||||
Fields: trimStringMap(fields),
|
||||
}
|
||||
return entry, source, status, nil
|
||||
}
|
||||
|
||||
func (s *svc) logPayment(entry TimelineEntry, state agg.State, version uint64) {
|
||||
logger := s.logger.With(
|
||||
zap.String("payment_ref", entry.PaymentRef),
|
||||
zap.String("event", entry.Event),
|
||||
zap.String("state", string(state)),
|
||||
zap.Uint64("version", version),
|
||||
zap.String("scope", string(entry.Scope)),
|
||||
)
|
||||
if entry.Message != "" {
|
||||
logger = logger.With(zap.String("message", entry.Message))
|
||||
}
|
||||
if len(entry.Fields) > 0 {
|
||||
logger = logger.With(zap.Any("fields", entry.Fields))
|
||||
}
|
||||
switch state {
|
||||
case agg.StateFailed, agg.StateNeedsAttention:
|
||||
logger.Warn("Orchestration payment event")
|
||||
default:
|
||||
logger.Info("Orchestration payment event")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *svc) logStep(entry TimelineEntry, state agg.StepState, duration time.Duration) {
|
||||
logger := s.logger.With(
|
||||
zap.String("payment_ref", entry.PaymentRef),
|
||||
zap.String("step_ref", entry.StepRef),
|
||||
zap.String("step_code", entry.StepCode),
|
||||
zap.Uint32("attempt", entry.Attempt),
|
||||
zap.String("event", entry.Event),
|
||||
zap.String("state", string(state)),
|
||||
zap.String("scope", string(entry.Scope)),
|
||||
)
|
||||
if duration > 0 {
|
||||
logger = logger.With(zap.Int64("duration_ms", duration.Milliseconds()))
|
||||
}
|
||||
if entry.Message != "" {
|
||||
logger = logger.With(zap.String("message", entry.Message))
|
||||
}
|
||||
if len(entry.Fields) > 0 {
|
||||
logger = logger.With(zap.Any("fields", entry.Fields))
|
||||
}
|
||||
switch state {
|
||||
case agg.StepStateFailed, agg.StepStateNeedsAttention:
|
||||
logger.Warn("Orchestration step event")
|
||||
default:
|
||||
logger.Info("Orchestration step event")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *svc) logExternal(entry TimelineEntry, source ExternalSource, status string) {
|
||||
logger := s.logger.With(
|
||||
zap.String("payment_ref", entry.PaymentRef),
|
||||
zap.String("step_ref", entry.StepRef),
|
||||
zap.Uint32("attempt", entry.Attempt),
|
||||
zap.String("source", string(source)),
|
||||
zap.String("status", status),
|
||||
zap.String("event", entry.Event),
|
||||
zap.String("scope", string(entry.Scope)),
|
||||
)
|
||||
if entry.Message != "" {
|
||||
logger = logger.With(zap.String("message", entry.Message))
|
||||
}
|
||||
if len(entry.Fields) > 0 {
|
||||
logger = logger.With(zap.Any("fields", entry.Fields))
|
||||
}
|
||||
if strings.Contains(status, "failed") || strings.Contains(status, "cancel") {
|
||||
logger.Warn("Orchestration external event")
|
||||
return
|
||||
}
|
||||
logger.Info("Orchestration external event")
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
package oobs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestRecordAndTimelineQueries(t *testing.T) {
|
||||
now := time.Date(2026, time.February, 21, 18, 0, 0, 0, time.UTC)
|
||||
metrics := &fakeMetrics{}
|
||||
observer := mustObserver(t, Dependencies{
|
||||
Logger: zap.NewNop(),
|
||||
Metrics: metrics,
|
||||
Now: func() time.Time { return now },
|
||||
})
|
||||
|
||||
payment := &agg.Payment{
|
||||
PaymentRef: "pay-1",
|
||||
State: agg.StateExecuting,
|
||||
Version: 4,
|
||||
}
|
||||
if err := observer.RecordPayment(context.Background(), RecordPaymentInput{
|
||||
Payment: payment,
|
||||
Event: PaymentEventStateChanged,
|
||||
Message: "execution started",
|
||||
Fields: map[string]string{
|
||||
"quotationRef": "quote-1",
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("RecordPayment returned error: %v", err)
|
||||
}
|
||||
|
||||
step := agg.StepExecution{
|
||||
StepRef: "s1",
|
||||
StepCode: "hop.10.crypto.send",
|
||||
State: agg.StepStateRunning,
|
||||
Attempt: 1,
|
||||
}
|
||||
if err := observer.RecordStep(context.Background(), RecordStepInput{
|
||||
PaymentRef: payment.PaymentRef,
|
||||
Step: step,
|
||||
Event: StepEventStarted,
|
||||
Duration: 1800 * time.Millisecond,
|
||||
}); err != nil {
|
||||
t.Fatalf("RecordStep(started) returned error: %v", err)
|
||||
}
|
||||
|
||||
if err := observer.RecordExternal(context.Background(), RecordExternalInput{
|
||||
PaymentRef: payment.PaymentRef,
|
||||
StepRef: step.StepRef,
|
||||
Attempt: 1,
|
||||
Source: ExternalSourceGateway,
|
||||
Status: "success",
|
||||
RefKind: "transfer_ref",
|
||||
Ref: "tr-1",
|
||||
}); err != nil {
|
||||
t.Fatalf("RecordExternal returned error: %v", err)
|
||||
}
|
||||
|
||||
step.State = agg.StepStateFailed
|
||||
if err := observer.RecordStep(context.Background(), RecordStepInput{
|
||||
PaymentRef: payment.PaymentRef,
|
||||
Step: step,
|
||||
Event: StepEventFailed,
|
||||
Message: "terminal failure",
|
||||
}); err != nil {
|
||||
t.Fatalf("RecordStep(failed) returned error: %v", err)
|
||||
}
|
||||
|
||||
paymentTimeline, err := observer.PaymentTimeline(context.Background(), PaymentTimelineInput{
|
||||
PaymentRef: payment.PaymentRef,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PaymentTimeline returned error: %v", err)
|
||||
}
|
||||
if len(paymentTimeline.Items) != 4 {
|
||||
t.Fatalf("payment timeline size mismatch: got=%d want=4", len(paymentTimeline.Items))
|
||||
}
|
||||
assertEventOrder(t, paymentTimeline.Items, []string{
|
||||
"state_changed",
|
||||
"started",
|
||||
"external.gateway.success",
|
||||
"failed",
|
||||
})
|
||||
|
||||
stepTimeline, err := observer.StepTimeline(context.Background(), StepTimelineInput{
|
||||
PaymentRef: payment.PaymentRef,
|
||||
StepRef: step.StepRef,
|
||||
Attempt: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StepTimeline returned error: %v", err)
|
||||
}
|
||||
if len(stepTimeline.Items) != 3 {
|
||||
t.Fatalf("step timeline size mismatch: got=%d want=3", len(stepTimeline.Items))
|
||||
}
|
||||
assertEventOrder(t, stepTimeline.Items, []string{
|
||||
"started",
|
||||
"external.gateway.success",
|
||||
"failed",
|
||||
})
|
||||
|
||||
descTimeline, err := observer.PaymentTimeline(context.Background(), PaymentTimelineInput{
|
||||
PaymentRef: payment.PaymentRef,
|
||||
Limit: 2,
|
||||
Desc: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PaymentTimeline(desc) returned error: %v", err)
|
||||
}
|
||||
if len(descTimeline.Items) != 2 {
|
||||
t.Fatalf("desc timeline size mismatch: got=%d want=2", len(descTimeline.Items))
|
||||
}
|
||||
assertEventOrder(t, descTimeline.Items, []string{"failed", "external.gateway.success"})
|
||||
|
||||
if metrics.paymentEvents != 1 {
|
||||
t.Fatalf("payment metric mismatch: got=%d want=1", metrics.paymentEvents)
|
||||
}
|
||||
if metrics.stepEvents != 2 {
|
||||
t.Fatalf("step metric mismatch: got=%d want=2", metrics.stepEvents)
|
||||
}
|
||||
if metrics.externalEvents != 1 {
|
||||
t.Fatalf("external metric mismatch: got=%d want=1", metrics.externalEvents)
|
||||
}
|
||||
if metrics.stepDurations != 1 {
|
||||
t.Fatalf("duration metric mismatch: got=%d want=1", metrics.stepDurations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStepTimeline_AttemptIsolation(t *testing.T) {
|
||||
observer := mustObserver(t, Dependencies{Logger: zap.NewNop()})
|
||||
ctx := context.Background()
|
||||
|
||||
for _, attempt := range []uint32{1, 2} {
|
||||
err := observer.RecordStep(ctx, RecordStepInput{
|
||||
PaymentRef: "pay-1",
|
||||
Step: agg.StepExecution{
|
||||
StepRef: "s1",
|
||||
StepCode: "hop.10.crypto.send",
|
||||
State: agg.StepStateCompleted,
|
||||
Attempt: attempt,
|
||||
},
|
||||
Event: StepEventCompleted,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RecordStep attempt=%d returned error: %v", attempt, err)
|
||||
}
|
||||
}
|
||||
|
||||
a1, err := observer.StepTimeline(ctx, StepTimelineInput{
|
||||
PaymentRef: "pay-1",
|
||||
StepRef: "s1",
|
||||
Attempt: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StepTimeline(attempt=1) returned error: %v", err)
|
||||
}
|
||||
if len(a1.Items) != 1 {
|
||||
t.Fatalf("attempt=1 timeline size mismatch: got=%d want=1", len(a1.Items))
|
||||
}
|
||||
if got, want := a1.Items[0].Attempt, uint32(1); got != want {
|
||||
t.Fatalf("attempt mismatch: got=%d want=%d", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidationErrors(t *testing.T) {
|
||||
observer := mustObserver(t, Dependencies{Logger: zap.NewNop()})
|
||||
ctx := context.Background()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
run func() error
|
||||
}{
|
||||
{
|
||||
name: "record payment missing payment",
|
||||
run: func() error {
|
||||
return observer.RecordPayment(ctx, RecordPaymentInput{
|
||||
Event: PaymentEventCreated,
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "record payment invalid event",
|
||||
run: func() error {
|
||||
return observer.RecordPayment(ctx, RecordPaymentInput{
|
||||
Payment: &agg.Payment{PaymentRef: "pay-1", State: agg.StateCreated},
|
||||
Event: PaymentEvent("bad"),
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "record step missing payment ref",
|
||||
run: func() error {
|
||||
return observer.RecordStep(ctx, RecordStepInput{
|
||||
Step: agg.StepExecution{
|
||||
StepRef: "s1",
|
||||
State: agg.StepStatePending,
|
||||
},
|
||||
Event: StepEventScheduled,
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "record step invalid state",
|
||||
run: func() error {
|
||||
return observer.RecordStep(ctx, RecordStepInput{
|
||||
PaymentRef: "pay-1",
|
||||
Step: agg.StepExecution{
|
||||
StepRef: "s1",
|
||||
State: agg.StepStateUnspecified,
|
||||
},
|
||||
Event: StepEventScheduled,
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "record external invalid source",
|
||||
run: func() error {
|
||||
return observer.RecordExternal(ctx, RecordExternalInput{
|
||||
PaymentRef: "pay-1",
|
||||
StepRef: "s1",
|
||||
Attempt: 1,
|
||||
Source: ExternalSource("bad"),
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "query step timeline missing attempt",
|
||||
run: func() error {
|
||||
_, err := observer.StepTimeline(ctx, StepTimelineInput{
|
||||
PaymentRef: "pay-1",
|
||||
StepRef: "s1",
|
||||
})
|
||||
return err
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if err := tt.run(); !errors.Is(err, merrors.ErrInvalidArg) {
|
||||
t.Fatalf("expected invalid argument error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertEventOrder(t *testing.T, items []TimelineEntry, expected []string) {
|
||||
t.Helper()
|
||||
if len(items) != len(expected) {
|
||||
t.Fatalf("event count mismatch: got=%d want=%d", len(items), len(expected))
|
||||
}
|
||||
for i := range expected {
|
||||
if got, want := items[i].Event, expected[i]; got != want {
|
||||
t.Fatalf("event[%d] mismatch: got=%q want=%q", i, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mustObserver(t *testing.T, deps Dependencies) Observer {
|
||||
t.Helper()
|
||||
out, err := New(deps)
|
||||
if err != nil {
|
||||
t.Fatalf("New returned error: %v", err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type fakeMetrics struct {
|
||||
paymentEvents int
|
||||
stepEvents int
|
||||
externalEvents int
|
||||
stepDurations int
|
||||
}
|
||||
|
||||
func (f *fakeMetrics) IncPaymentEvent(_ PaymentEvent, _ agg.State) {
|
||||
f.paymentEvents++
|
||||
}
|
||||
|
||||
func (f *fakeMetrics) IncStepEvent(_ StepEvent, _ string, _ agg.StepState) {
|
||||
f.stepEvents++
|
||||
}
|
||||
|
||||
func (f *fakeMetrics) IncExternalEvent(_ ExternalSource, _ string) {
|
||||
f.externalEvents++
|
||||
}
|
||||
|
||||
func (f *fakeMetrics) ObserveStepDuration(_ string, _ agg.StepState, _ time.Duration) {
|
||||
f.stepDurations++
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package opagg
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAggregate_GroupsCompatibleItemsByRecipient(t *testing.T) {
|
||||
aggregator := New()
|
||||
|
||||
in := Input{
|
||||
Items: []Item{
|
||||
{
|
||||
IntentSnapshot: sampleIntent("intent-a", "card-1", "100"),
|
||||
QuoteSnapshot: sampleQuote("quote-batch", "100", "9150", "1.8"),
|
||||
},
|
||||
{
|
||||
IntentSnapshot: sampleIntent("intent-b", "card-1", "125"),
|
||||
QuoteSnapshot: sampleQuote("quote-batch", "125", "11437.5", "1.8"),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
out, err := aggregator.Aggregate(in)
|
||||
if err != nil {
|
||||
t.Fatalf("Aggregate returned error: %v", err)
|
||||
}
|
||||
if out == nil {
|
||||
t.Fatal("expected output")
|
||||
}
|
||||
if got, want := len(out.Groups), 1; got != want {
|
||||
t.Fatalf("groups count mismatch: got=%d want=%d", got, want)
|
||||
}
|
||||
|
||||
group := out.Groups[0]
|
||||
if got, want := len(group.IntentRefs), 2; got != want {
|
||||
t.Fatalf("intent_refs count mismatch: got=%d want=%d", got, want)
|
||||
}
|
||||
if got, want := group.IntentRefs[0], "intent-a"; got != want {
|
||||
t.Fatalf("intent_refs[0] mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := group.IntentRefs[1], "intent-b"; got != want {
|
||||
t.Fatalf("intent_refs[1] mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := group.IntentSnapshot.Amount.Amount, "225"; got != want {
|
||||
t.Fatalf("intent amount mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if group.QuoteSnapshot == nil {
|
||||
t.Fatal("expected quote snapshot")
|
||||
}
|
||||
if got, want := group.QuoteSnapshot.DebitAmount.Amount, "225"; got != want {
|
||||
t.Fatalf("debit amount mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := group.QuoteSnapshot.ExpectedSettlementAmount.Amount, "20587.5"; got != want {
|
||||
t.Fatalf("settlement amount mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := group.QuoteSnapshot.ExpectedFeeTotal.Amount, "3.6"; got != want {
|
||||
t.Fatalf("fee total mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := group.QuoteSnapshot.TotalCost.Amount, "228.6"; got != want {
|
||||
t.Fatalf("total cost mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := len(group.QuoteSnapshot.FeeLines), 2; got != want {
|
||||
t.Fatalf("fee lines mismatch: got=%d want=%d", got, want)
|
||||
}
|
||||
if got, want := group.QuoteSnapshot.FeeLines[0].Money.Amount, "3"; got != want {
|
||||
t.Fatalf("platform fee mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := group.QuoteSnapshot.FeeLines[1].Money.Amount, "0.6"; got != want {
|
||||
t.Fatalf("tax fee mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if group.IntentSnapshot.Attributes[attrAggregatedByRecipient] != "true" {
|
||||
t.Fatalf("expected aggregated attribute %q=true", attrAggregatedByRecipient)
|
||||
}
|
||||
if got, want := group.IntentSnapshot.Attributes[attrAggregatedItems], "2"; got != want {
|
||||
t.Fatalf("aggregated items mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAggregate_DoesNotMergeDifferentRecipients(t *testing.T) {
|
||||
aggregator := New()
|
||||
|
||||
in := Input{
|
||||
Items: []Item{
|
||||
{
|
||||
IntentSnapshot: sampleIntent("intent-a", "card-1", "100"),
|
||||
QuoteSnapshot: sampleQuote("quote-batch", "100", "9150", "1.8"),
|
||||
},
|
||||
{
|
||||
IntentSnapshot: sampleIntent("intent-b", "card-2", "125"),
|
||||
QuoteSnapshot: sampleQuote("quote-batch", "125", "11437.5", "1.8"),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
out, err := aggregator.Aggregate(in)
|
||||
if err != nil {
|
||||
t.Fatalf("Aggregate returned error: %v", err)
|
||||
}
|
||||
if out == nil {
|
||||
t.Fatal("expected output")
|
||||
}
|
||||
if got, want := len(out.Groups), 2; got != want {
|
||||
t.Fatalf("groups count mismatch: got=%d want=%d", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAggregate_DoesNotMergeWhenBatchingIneligible(t *testing.T) {
|
||||
aggregator := New()
|
||||
|
||||
first := sampleQuote("quote-batch", "100", "9150", "1.8")
|
||||
first.ExecutionConditions.BatchingEligible = false
|
||||
second := sampleQuote("quote-batch", "125", "11437.5", "1.8")
|
||||
second.ExecutionConditions.BatchingEligible = false
|
||||
|
||||
in := Input{
|
||||
Items: []Item{
|
||||
{
|
||||
IntentSnapshot: sampleIntent("intent-a", "card-1", "100"),
|
||||
QuoteSnapshot: first,
|
||||
},
|
||||
{
|
||||
IntentSnapshot: sampleIntent("intent-b", "card-1", "125"),
|
||||
QuoteSnapshot: second,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
out, err := aggregator.Aggregate(in)
|
||||
if err != nil {
|
||||
t.Fatalf("Aggregate returned error: %v", err)
|
||||
}
|
||||
if out == nil {
|
||||
t.Fatal("expected output")
|
||||
}
|
||||
if got, want := len(out.Groups), 2; got != want {
|
||||
t.Fatalf("groups count mismatch: got=%d want=%d", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAggregate_UserBatchQuoteSampleCompactsToSingleRecipientOperation(t *testing.T) {
|
||||
aggregator := New()
|
||||
|
||||
in := Input{
|
||||
Items: []Item{
|
||||
{
|
||||
IntentSnapshot: sampleIntent("q-intent-1771599670962253000", "card-1", "100"),
|
||||
QuoteSnapshot: sampleQuote("quote-batch-usdt-rub", "100", "9150", "1.8"),
|
||||
},
|
||||
{
|
||||
IntentSnapshot: sampleIntent("q-intent-1771599670962255000", "card-1", "125"),
|
||||
QuoteSnapshot: sampleQuote("quote-batch-usdt-rub", "125", "11437.5", "1.8"),
|
||||
},
|
||||
{
|
||||
IntentSnapshot: sampleIntent("q-intent-1771599670962256000", "card-1", "80"),
|
||||
QuoteSnapshot: sampleQuote("quote-batch-usdt-rub", "80", "7320", "1.8"),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
out, err := aggregator.Aggregate(in)
|
||||
if err != nil {
|
||||
t.Fatalf("Aggregate returned error: %v", err)
|
||||
}
|
||||
if out == nil {
|
||||
t.Fatal("expected output")
|
||||
}
|
||||
if got, want := len(out.Groups), 1; got != want {
|
||||
t.Fatalf("groups count mismatch: got=%d want=%d", got, want)
|
||||
}
|
||||
|
||||
group := out.Groups[0]
|
||||
if got, want := len(group.IntentRefs), 3; got != want {
|
||||
t.Fatalf("intent_refs count mismatch: got=%d want=%d", got, want)
|
||||
}
|
||||
if got, want := group.IntentSnapshot.Amount.Amount, "305"; got != want {
|
||||
t.Fatalf("intent amount mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if group.QuoteSnapshot == nil {
|
||||
t.Fatal("expected quote snapshot")
|
||||
}
|
||||
if got, want := group.QuoteSnapshot.DebitAmount.Amount, "305"; got != want {
|
||||
t.Fatalf("debit amount mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := group.QuoteSnapshot.ExpectedSettlementAmount.Amount, "27907.5"; got != want {
|
||||
t.Fatalf("settlement amount mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := group.QuoteSnapshot.ExpectedFeeTotal.Amount, "5.4"; got != want {
|
||||
t.Fatalf("fee total mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := group.QuoteSnapshot.TotalCost.Amount, "310.4"; got != want {
|
||||
t.Fatalf("total cost mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if group.IntentSnapshot.Attributes[attrAggregatedByRecipient] != "true" {
|
||||
t.Fatalf("expected aggregated attribute %q=true", attrAggregatedByRecipient)
|
||||
}
|
||||
if got, want := group.IntentSnapshot.Attributes[attrAggregatedItems], "3"; got != want {
|
||||
t.Fatalf("aggregated items mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package opagg
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
func cloneMoney(src *paymenttypes.Money) *paymenttypes.Money {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
return &paymenttypes.Money{
|
||||
Amount: strings.TrimSpace(src.Amount),
|
||||
Currency: normalizeCurrency(src.Currency),
|
||||
}
|
||||
}
|
||||
|
||||
func cloneNetworkFee(src *paymenttypes.NetworkFeeEstimate) *paymenttypes.NetworkFeeEstimate {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
return &paymenttypes.NetworkFeeEstimate{
|
||||
NetworkFee: cloneMoney(src.NetworkFee),
|
||||
EstimationContext: strings.TrimSpace(src.EstimationContext),
|
||||
}
|
||||
}
|
||||
|
||||
func cloneFeeLines(src []*paymenttypes.FeeLine) []*paymenttypes.FeeLine {
|
||||
if len(src) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]*paymenttypes.FeeLine, 0, len(src))
|
||||
for _, line := range src {
|
||||
if line == nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, cloneFeeLine(line))
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneFeeLine(src *paymenttypes.FeeLine) *paymenttypes.FeeLine {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
return &paymenttypes.FeeLine{
|
||||
LedgerAccountRef: strings.TrimSpace(src.LedgerAccountRef),
|
||||
Money: cloneMoney(src.Money),
|
||||
LineType: src.LineType,
|
||||
Side: src.Side,
|
||||
Meta: cloneMetadata(src.Meta),
|
||||
}
|
||||
}
|
||||
|
||||
func cloneFeeRules(src []*paymenttypes.AppliedRule) []*paymenttypes.AppliedRule {
|
||||
if len(src) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]*paymenttypes.AppliedRule, 0, len(src))
|
||||
for _, rule := range src {
|
||||
if rule == nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, cloneFeeRule(rule))
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneFeeRule(src *paymenttypes.AppliedRule) *paymenttypes.AppliedRule {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
return &paymenttypes.AppliedRule{
|
||||
RuleID: strings.TrimSpace(src.RuleID),
|
||||
RuleVersion: strings.TrimSpace(src.RuleVersion),
|
||||
Formula: strings.TrimSpace(src.Formula),
|
||||
Rounding: src.Rounding,
|
||||
TaxCode: strings.TrimSpace(src.TaxCode),
|
||||
TaxRate: strings.TrimSpace(src.TaxRate),
|
||||
Parameters: cloneMetadata(src.Parameters),
|
||||
}
|
||||
}
|
||||
|
||||
func cloneFXQuote(src *paymenttypes.FXQuote) *paymenttypes.FXQuote {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
dst := &paymenttypes.FXQuote{
|
||||
QuoteRef: strings.TrimSpace(src.QuoteRef),
|
||||
Side: src.Side,
|
||||
ExpiresAtUnixMs: src.ExpiresAtUnixMs,
|
||||
PricedAtUnixMs: src.PricedAtUnixMs,
|
||||
Provider: strings.TrimSpace(src.Provider),
|
||||
RateRef: strings.TrimSpace(src.RateRef),
|
||||
Firm: src.Firm,
|
||||
BaseAmount: cloneMoney(src.BaseAmount),
|
||||
QuoteAmount: cloneMoney(src.QuoteAmount),
|
||||
}
|
||||
if src.Pair != nil {
|
||||
dst.Pair = &paymenttypes.CurrencyPair{
|
||||
Base: normalizeCurrency(src.Pair.Base),
|
||||
Quote: normalizeCurrency(src.Pair.Quote),
|
||||
}
|
||||
}
|
||||
if src.Price != nil {
|
||||
dst.Price = &paymenttypes.Decimal{Value: strings.TrimSpace(src.Price.Value)}
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
func cloneRoute(src *paymenttypes.QuoteRouteSpecification) *paymenttypes.QuoteRouteSpecification {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
dst := &paymenttypes.QuoteRouteSpecification{
|
||||
Rail: strings.TrimSpace(src.Rail),
|
||||
Provider: strings.TrimSpace(src.Provider),
|
||||
PayoutMethod: strings.TrimSpace(src.PayoutMethod),
|
||||
Network: strings.TrimSpace(src.Network),
|
||||
RouteRef: strings.TrimSpace(src.RouteRef),
|
||||
PricingProfileRef: strings.TrimSpace(src.PricingProfileRef),
|
||||
}
|
||||
if src.Settlement != nil {
|
||||
dst.Settlement = &paymenttypes.QuoteRouteSettlement{
|
||||
Model: strings.TrimSpace(src.Settlement.Model),
|
||||
Asset: cloneAsset(src.Settlement.Asset),
|
||||
}
|
||||
}
|
||||
if len(src.Hops) > 0 {
|
||||
dst.Hops = make([]*paymenttypes.QuoteRouteHop, 0, len(src.Hops))
|
||||
for _, hop := range src.Hops {
|
||||
if hop == nil {
|
||||
continue
|
||||
}
|
||||
dst.Hops = append(dst.Hops, &paymenttypes.QuoteRouteHop{
|
||||
Index: hop.Index,
|
||||
Rail: strings.TrimSpace(hop.Rail),
|
||||
Gateway: strings.TrimSpace(hop.Gateway),
|
||||
InstanceID: strings.TrimSpace(hop.InstanceID),
|
||||
Network: strings.TrimSpace(hop.Network),
|
||||
Role: hop.Role,
|
||||
})
|
||||
}
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
func cloneAsset(src *paymenttypes.Asset) *paymenttypes.Asset {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
return &paymenttypes.Asset{
|
||||
Chain: strings.TrimSpace(src.Chain),
|
||||
TokenSymbol: strings.TrimSpace(src.TokenSymbol),
|
||||
ContractAddress: strings.TrimSpace(src.ContractAddress),
|
||||
}
|
||||
}
|
||||
|
||||
func cloneExecutionConditions(src *paymenttypes.QuoteExecutionConditions) *paymenttypes.QuoteExecutionConditions {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
dst := &paymenttypes.QuoteExecutionConditions{
|
||||
Readiness: src.Readiness,
|
||||
BatchingEligible: src.BatchingEligible,
|
||||
PrefundingRequired: src.PrefundingRequired,
|
||||
PrefundingCostIncluded: src.PrefundingCostIncluded,
|
||||
LiquidityCheckRequiredAtExecution: src.LiquidityCheckRequiredAtExecution,
|
||||
LatencyHint: strings.TrimSpace(src.LatencyHint),
|
||||
}
|
||||
if len(src.Assumptions) > 0 {
|
||||
dst.Assumptions = cloneStringSlice(src.Assumptions)
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
func cloneMetadata(src map[string]string) map[string]string {
|
||||
if len(src) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(src))
|
||||
for key, value := range src {
|
||||
k := strings.TrimSpace(key)
|
||||
if k == "" {
|
||||
continue
|
||||
}
|
||||
out[k] = strings.TrimSpace(value)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneStringSlice(src []string) []string {
|
||||
if len(src) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(src))
|
||||
for _, item := range src {
|
||||
token := strings.TrimSpace(item)
|
||||
if token == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, token)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneIntentSnapshot(src model.PaymentIntent) (model.PaymentIntent, error) {
|
||||
var dst model.PaymentIntent
|
||||
if err := bsonClone(src, &dst); err != nil {
|
||||
return model.PaymentIntent{}, err
|
||||
}
|
||||
return dst, nil
|
||||
}
|
||||
|
||||
func cloneQuoteSnapshot(src *model.PaymentQuoteSnapshot) (*model.PaymentQuoteSnapshot, error) {
|
||||
if src == nil {
|
||||
return nil, nil
|
||||
}
|
||||
dst := &model.PaymentQuoteSnapshot{}
|
||||
if err := bsonClone(src, dst); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dst, nil
|
||||
}
|
||||
|
||||
func bsonClone(src any, dst any) error {
|
||||
data, err := bson.Marshal(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return bson.Unmarshal(data, dst)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package opagg
|
||||
|
||||
import (
|
||||
"github.com/shopspring/decimal"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
)
|
||||
|
||||
func sampleIntent(ref, cardToken, amount string) model.PaymentIntent {
|
||||
return model.PaymentIntent{
|
||||
Ref: ref,
|
||||
Kind: model.PaymentKindPayout,
|
||||
Source: model.PaymentEndpoint{
|
||||
Type: model.EndpointTypeManagedWallet,
|
||||
ManagedWallet: &model.ManagedWalletEndpoint{
|
||||
ManagedWalletRef: "src-wallet-1",
|
||||
Asset: &paymenttypes.Asset{Chain: "TRON", TokenSymbol: "USDT"},
|
||||
},
|
||||
},
|
||||
Destination: model.PaymentEndpoint{
|
||||
Type: model.EndpointTypeCard,
|
||||
Card: &model.CardEndpoint{
|
||||
Token: cardToken,
|
||||
Country: "RU",
|
||||
},
|
||||
},
|
||||
Amount: &paymenttypes.Money{
|
||||
Amount: amount,
|
||||
Currency: "USDT",
|
||||
},
|
||||
SettlementMode: model.SettlementModeFixSource,
|
||||
SettlementCurrency: "RUB",
|
||||
}
|
||||
}
|
||||
|
||||
func sampleQuote(quoteRef, debit, settlement, feeTotal string) *model.PaymentQuoteSnapshot {
|
||||
fee := "1.5"
|
||||
tax := "0.3"
|
||||
totalCost := addStrings(debit, feeTotal)
|
||||
|
||||
return &model.PaymentQuoteSnapshot{
|
||||
QuoteRef: quoteRef,
|
||||
DebitAmount: &paymenttypes.Money{
|
||||
Amount: debit,
|
||||
Currency: "USDT",
|
||||
},
|
||||
ExpectedSettlementAmount: &paymenttypes.Money{
|
||||
Amount: settlement,
|
||||
Currency: "RUB",
|
||||
},
|
||||
ExpectedFeeTotal: &paymenttypes.Money{
|
||||
Amount: feeTotal,
|
||||
Currency: "USDT",
|
||||
},
|
||||
TotalCost: &paymenttypes.Money{
|
||||
Amount: totalCost,
|
||||
Currency: "USDT",
|
||||
},
|
||||
FeeLines: []*paymenttypes.FeeLine{
|
||||
{
|
||||
LedgerAccountRef: "ledger:fees:usdt",
|
||||
Money: &paymenttypes.Money{Amount: fee, Currency: "USDT"},
|
||||
LineType: paymenttypes.PostingLineTypeFee,
|
||||
Side: paymenttypes.EntrySideDebit,
|
||||
Meta: map[string]string{"component": "platform_fee", "provider": "monetix"},
|
||||
},
|
||||
{
|
||||
LedgerAccountRef: "ledger:tax:usdt",
|
||||
Money: &paymenttypes.Money{Amount: tax, Currency: "USDT"},
|
||||
LineType: paymenttypes.PostingLineTypeTax,
|
||||
Side: paymenttypes.EntrySideDebit,
|
||||
Meta: map[string]string{"component": "vat", "provider": "monetix"},
|
||||
},
|
||||
},
|
||||
FeeRules: []*paymenttypes.AppliedRule{
|
||||
{
|
||||
RuleID: "rule.platform.usdt",
|
||||
RuleVersion: "2026-02-01",
|
||||
Formula: "flat(1.50)+tax(0.30)",
|
||||
TaxCode: "VAT",
|
||||
TaxRate: "0.20",
|
||||
Parameters: map[string]string{"country": "RU"},
|
||||
},
|
||||
},
|
||||
FXQuote: &paymenttypes.FXQuote{
|
||||
QuoteRef: "fx-usdt-rub",
|
||||
Provider: "test-oracle",
|
||||
RateRef: "rate-usdt-rub",
|
||||
Side: paymenttypes.FXSideSellBaseBuyQuote,
|
||||
Firm: true,
|
||||
Price: &paymenttypes.Decimal{Value: "91.5"},
|
||||
BaseAmount: &paymenttypes.Money{Amount: debit, Currency: "USDT"},
|
||||
QuoteAmount: &paymenttypes.Money{
|
||||
Amount: settlement,
|
||||
Currency: "RUB",
|
||||
},
|
||||
},
|
||||
Route: &paymenttypes.QuoteRouteSpecification{
|
||||
RouteRef: "rte-recipient-1",
|
||||
PricingProfileRef: "fee_profile_1",
|
||||
Hops: []*paymenttypes.QuoteRouteHop{
|
||||
{Index: 1, Rail: "CRYPTO", Role: paymenttypes.QuoteRouteHopRoleSource},
|
||||
{Index: 2, Rail: "LEDGER", Role: paymenttypes.QuoteRouteHopRoleTransit, Gateway: "internal"},
|
||||
{Index: 3, Rail: "CARD_PAYOUT", Role: paymenttypes.QuoteRouteHopRoleDestination, Gateway: "monetix"},
|
||||
},
|
||||
Settlement: &paymenttypes.QuoteRouteSettlement{
|
||||
Model: "fix_source",
|
||||
Asset: &paymenttypes.Asset{TokenSymbol: "USDT"},
|
||||
},
|
||||
},
|
||||
ExecutionConditions: &paymenttypes.QuoteExecutionConditions{
|
||||
Readiness: paymenttypes.QuoteExecutionReadinessLiquidityReady,
|
||||
BatchingEligible: true,
|
||||
LiquidityCheckRequiredAtExecution: true,
|
||||
LatencyHint: "instant",
|
||||
Assumptions: []string{"execution_time_liquidity_check"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func addStrings(left, right string) string {
|
||||
l, lErr := decimal.NewFromString(left)
|
||||
r, rErr := decimal.NewFromString(right)
|
||||
if lErr != nil || rErr != nil {
|
||||
return ""
|
||||
}
|
||||
return l.Add(r).String()
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package opagg
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
)
|
||||
|
||||
func isEmptyIntentSnapshot(intent model.PaymentIntent) bool {
|
||||
return intent.Amount == nil && (intent.Kind == "" || intent.Kind == model.PaymentKindUnspecified)
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func itoa(v int) string {
|
||||
if v <= 0 {
|
||||
return "0"
|
||||
}
|
||||
var buf [20]byte
|
||||
i := len(buf)
|
||||
for v > 0 {
|
||||
i--
|
||||
buf[i] = byte('0' + v%10)
|
||||
v /= 10
|
||||
}
|
||||
return string(buf[i:])
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package opagg
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
)
|
||||
|
||||
func endpointKey(ep model.PaymentEndpoint) (string, error) {
|
||||
endpointType := normalizeEndpointType(ep)
|
||||
if endpointType == model.EndpointTypeUnspecified {
|
||||
return "", merrors.InvalidArgument("endpoint type is required")
|
||||
}
|
||||
|
||||
parts := []string{
|
||||
"type=" + strings.ToLower(strings.TrimSpace(string(endpointType))),
|
||||
"instance=" + strings.TrimSpace(ep.InstanceID),
|
||||
}
|
||||
|
||||
switch endpointType {
|
||||
case model.EndpointTypeLedger:
|
||||
if ep.Ledger == nil {
|
||||
return "", merrors.InvalidArgument("ledger endpoint is required")
|
||||
}
|
||||
parts = append(parts,
|
||||
"account="+strings.TrimSpace(ep.Ledger.LedgerAccountRef),
|
||||
"contra="+strings.TrimSpace(ep.Ledger.ContraLedgerAccountRef),
|
||||
)
|
||||
case model.EndpointTypeManagedWallet:
|
||||
if ep.ManagedWallet == nil {
|
||||
return "", merrors.InvalidArgument("managed_wallet endpoint is required")
|
||||
}
|
||||
parts = append(parts,
|
||||
"wallet="+strings.TrimSpace(ep.ManagedWallet.ManagedWalletRef),
|
||||
"asset="+assetKey(ep.ManagedWallet.Asset),
|
||||
)
|
||||
case model.EndpointTypeExternalChain:
|
||||
if ep.ExternalChain == nil {
|
||||
return "", merrors.InvalidArgument("external_chain endpoint is required")
|
||||
}
|
||||
parts = append(parts,
|
||||
"address="+strings.TrimSpace(ep.ExternalChain.Address),
|
||||
"memo="+strings.TrimSpace(ep.ExternalChain.Memo),
|
||||
"asset="+assetKey(ep.ExternalChain.Asset),
|
||||
)
|
||||
case model.EndpointTypeCard:
|
||||
if ep.Card == nil {
|
||||
return "", merrors.InvalidArgument("card endpoint is required")
|
||||
}
|
||||
parts = append(parts,
|
||||
"token="+strings.TrimSpace(ep.Card.Token),
|
||||
"pan="+strings.TrimSpace(ep.Card.Pan),
|
||||
"masked="+strings.TrimSpace(ep.Card.MaskedPan),
|
||||
"country="+strings.TrimSpace(ep.Card.Country),
|
||||
"exp="+strconv.FormatUint(uint64(ep.Card.ExpMonth), 10)+"-"+strconv.FormatUint(uint64(ep.Card.ExpYear), 10),
|
||||
)
|
||||
default:
|
||||
return "", merrors.InvalidArgument("unsupported endpoint type")
|
||||
}
|
||||
|
||||
return strings.Join(parts, "|"), nil
|
||||
}
|
||||
|
||||
func normalizeEndpointType(ep model.PaymentEndpoint) model.PaymentEndpointType {
|
||||
if ep.Type != model.EndpointTypeUnspecified {
|
||||
return ep.Type
|
||||
}
|
||||
switch {
|
||||
case ep.Ledger != nil:
|
||||
return model.EndpointTypeLedger
|
||||
case ep.ManagedWallet != nil:
|
||||
return model.EndpointTypeManagedWallet
|
||||
case ep.ExternalChain != nil:
|
||||
return model.EndpointTypeExternalChain
|
||||
case ep.Card != nil:
|
||||
return model.EndpointTypeCard
|
||||
default:
|
||||
return model.EndpointTypeUnspecified
|
||||
}
|
||||
}
|
||||
|
||||
func routeSignature(route *paymenttypes.QuoteRouteSpecification) string {
|
||||
if route == nil {
|
||||
return "none"
|
||||
}
|
||||
parts := []string{
|
||||
"route_ref=" + strings.TrimSpace(route.RouteRef),
|
||||
"rail=" + strings.ToUpper(strings.TrimSpace(route.Rail)),
|
||||
"provider=" + strings.TrimSpace(route.Provider),
|
||||
"network=" + strings.TrimSpace(route.Network),
|
||||
"settlement=" + settlementKey(route.Settlement),
|
||||
}
|
||||
for i, hop := range route.Hops {
|
||||
if hop == nil {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf(
|
||||
"hop[%d]=%d:%s:%s:%s:%s:%s",
|
||||
i,
|
||||
hop.Index,
|
||||
strings.ToUpper(strings.TrimSpace(hop.Rail)),
|
||||
strings.TrimSpace(hop.Gateway),
|
||||
strings.TrimSpace(hop.InstanceID),
|
||||
strings.TrimSpace(hop.Network),
|
||||
strings.ToUpper(strings.TrimSpace(string(hop.Role))),
|
||||
))
|
||||
}
|
||||
return strings.Join(parts, "|")
|
||||
}
|
||||
|
||||
func settlementKey(s *paymenttypes.QuoteRouteSettlement) string {
|
||||
if s == nil {
|
||||
return "none"
|
||||
}
|
||||
return strings.Join([]string{
|
||||
"model=" + strings.TrimSpace(s.Model),
|
||||
"asset=" + assetKey(s.Asset),
|
||||
}, "|")
|
||||
}
|
||||
|
||||
func fxQuoteSignature(q *paymenttypes.FXQuote) string {
|
||||
if q == nil {
|
||||
return "none"
|
||||
}
|
||||
pair := "none"
|
||||
if q.Pair != nil {
|
||||
pair = strings.ToUpper(strings.TrimSpace(q.Pair.Base)) + "/" + strings.ToUpper(strings.TrimSpace(q.Pair.Quote))
|
||||
}
|
||||
price := ""
|
||||
if q.Price != nil {
|
||||
price = strings.TrimSpace(q.Price.Value)
|
||||
}
|
||||
return strings.Join([]string{
|
||||
"pair=" + pair,
|
||||
"side=" + strings.ToUpper(strings.TrimSpace(string(q.Side))),
|
||||
"price=" + price,
|
||||
"provider=" + strings.TrimSpace(q.Provider),
|
||||
"rate_ref=" + strings.TrimSpace(q.RateRef),
|
||||
"firm=" + strconv.FormatBool(q.Firm),
|
||||
}, "|")
|
||||
}
|
||||
|
||||
func feeLineKey(line *paymenttypes.FeeLine) string {
|
||||
if line == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.Join([]string{
|
||||
strings.TrimSpace(line.LedgerAccountRef),
|
||||
strings.ToUpper(strings.TrimSpace(string(line.LineType))),
|
||||
strings.ToUpper(strings.TrimSpace(string(line.Side))),
|
||||
moneyCurrency(line.Money),
|
||||
metadataSignature(line.Meta),
|
||||
}, "|")
|
||||
}
|
||||
|
||||
func feeRuleKey(rule *paymenttypes.AppliedRule) string {
|
||||
if rule == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.Join([]string{
|
||||
strings.TrimSpace(rule.RuleID),
|
||||
strings.TrimSpace(rule.RuleVersion),
|
||||
strings.TrimSpace(rule.Formula),
|
||||
strings.TrimSpace(rule.TaxCode),
|
||||
strings.TrimSpace(rule.TaxRate),
|
||||
metadataSignature(rule.Parameters),
|
||||
}, "|")
|
||||
}
|
||||
|
||||
func metadataSignature(meta map[string]string) string {
|
||||
if len(meta) == 0 {
|
||||
return ""
|
||||
}
|
||||
keys := make([]string, 0, len(meta))
|
||||
for key := range meta {
|
||||
k := strings.TrimSpace(key)
|
||||
if k == "" {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
if len(keys) == 0 {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
parts = append(parts, key+"="+strings.TrimSpace(meta[key]))
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
func moneyCurrency(m *paymenttypes.Money) string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
return normalizeCurrency(m.Currency)
|
||||
}
|
||||
|
||||
func normalizeCurrency(value string) string {
|
||||
return strings.ToUpper(strings.TrimSpace(value))
|
||||
}
|
||||
|
||||
func assetKey(asset *paymenttypes.Asset) string {
|
||||
if asset == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.Join([]string{
|
||||
strings.ToUpper(strings.TrimSpace(asset.Chain)),
|
||||
strings.ToUpper(strings.TrimSpace(asset.TokenSymbol)),
|
||||
strings.TrimSpace(asset.ContractAddress),
|
||||
}, ":")
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package opagg
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
)
|
||||
|
||||
func mergeIntentSnapshot(dst *model.PaymentIntent, src model.PaymentIntent) error {
|
||||
if dst == nil {
|
||||
return merrors.InvalidArgument("intent_snapshot is required")
|
||||
}
|
||||
|
||||
sum, err := mergeMoney(dst.Amount, src.Amount, "intent_snapshot.amount")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dst.Amount = sum
|
||||
|
||||
if dst.SettlementCurrency == "" {
|
||||
dst.SettlementCurrency = strings.TrimSpace(src.SettlementCurrency)
|
||||
} else if srcCurrency := strings.TrimSpace(src.SettlementCurrency); srcCurrency != "" && !strings.EqualFold(dst.SettlementCurrency, srcCurrency) {
|
||||
return merrors.InvalidArgument("intent_snapshot.settlement_currency mismatch")
|
||||
}
|
||||
|
||||
if dst.Attributes == nil {
|
||||
dst.Attributes = map[string]string{}
|
||||
}
|
||||
for key, value := range src.Attributes {
|
||||
k := strings.TrimSpace(key)
|
||||
if k == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := dst.Attributes[k]; exists {
|
||||
continue
|
||||
}
|
||||
dst.Attributes[k] = strings.TrimSpace(value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mergeQuoteSnapshot(dst *model.PaymentQuoteSnapshot, src *model.PaymentQuoteSnapshot) error {
|
||||
if dst == nil {
|
||||
return merrors.InvalidArgument("quote_snapshot is required")
|
||||
}
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var err error
|
||||
dst.DebitAmount, err = mergeMoney(dst.DebitAmount, src.DebitAmount, "quote_snapshot.debit_amount")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dst.DebitSettlementAmount, err = mergeMoney(dst.DebitSettlementAmount, src.DebitSettlementAmount, "quote_snapshot.debit_settlement_amount")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dst.ExpectedSettlementAmount, err = mergeMoney(dst.ExpectedSettlementAmount, src.ExpectedSettlementAmount, "quote_snapshot.expected_settlement_amount")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dst.ExpectedFeeTotal, err = mergeMoney(dst.ExpectedFeeTotal, src.ExpectedFeeTotal, "quote_snapshot.expected_fee_total")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dst.TotalCost, err = mergeMoney(dst.TotalCost, src.TotalCost, "quote_snapshot.total_cost")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dst.NetworkFee, err = mergeNetworkFee(dst.NetworkFee, src.NetworkFee)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dst.FeeLines, err = mergeFeeLines(dst.FeeLines, src.FeeLines)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dst.FeeRules = mergeFeeRules(dst.FeeRules, src.FeeRules)
|
||||
|
||||
dst.Route, err = mergeRoute(dst.Route, src.Route)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dst.ExecutionConditions = mergeExecutionConditions(dst.ExecutionConditions, src.ExecutionConditions)
|
||||
|
||||
dst.FXQuote, err = mergeFXQuote(dst.FXQuote, src.FXQuote)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if strings.TrimSpace(dst.QuoteRef) == "" {
|
||||
dst.QuoteRef = strings.TrimSpace(src.QuoteRef)
|
||||
} else if srcRef := strings.TrimSpace(src.QuoteRef); srcRef != "" && dst.QuoteRef != srcRef {
|
||||
return merrors.InvalidArgument("quote_snapshot.quote_ref mismatch")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func mergeMoney(dst, src *paymenttypes.Money, field string) (*paymenttypes.Money, error) {
|
||||
if dst == nil {
|
||||
return cloneMoney(src), nil
|
||||
}
|
||||
if src == nil {
|
||||
return dst, nil
|
||||
}
|
||||
|
||||
dstCurrency := normalizeCurrency(dst.Currency)
|
||||
srcCurrency := normalizeCurrency(src.Currency)
|
||||
if dstCurrency == "" || srcCurrency == "" {
|
||||
return nil, merrors.InvalidArgument(field + ": currency is required")
|
||||
}
|
||||
if dstCurrency != srcCurrency {
|
||||
return nil, merrors.InvalidArgument(field + ": currency mismatch")
|
||||
}
|
||||
|
||||
left, err := parseDecimal(dst.Amount)
|
||||
if err != nil {
|
||||
return nil, merrors.InvalidArgument(field + ": invalid amount")
|
||||
}
|
||||
right, err := parseDecimal(src.Amount)
|
||||
if err != nil {
|
||||
return nil, merrors.InvalidArgument(field + ": invalid amount")
|
||||
}
|
||||
dst.Amount = left.Add(right).String()
|
||||
dst.Currency = dstCurrency
|
||||
return dst, nil
|
||||
}
|
||||
|
||||
func parseDecimal(raw string) (decimal.Decimal, error) {
|
||||
return decimal.NewFromString(strings.TrimSpace(raw))
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package opagg
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
)
|
||||
|
||||
func mergeRoute(dst, src *paymenttypes.QuoteRouteSpecification) (*paymenttypes.QuoteRouteSpecification, error) {
|
||||
if dst == nil {
|
||||
return cloneRoute(src), nil
|
||||
}
|
||||
if src == nil {
|
||||
return dst, nil
|
||||
}
|
||||
if routeSignature(dst) != routeSignature(src) {
|
||||
return nil, merrors.InvalidArgument("quote_snapshot.route mismatch")
|
||||
}
|
||||
return dst, nil
|
||||
}
|
||||
|
||||
func mergeFXQuote(dst, src *paymenttypes.FXQuote) (*paymenttypes.FXQuote, error) {
|
||||
if dst == nil {
|
||||
return cloneFXQuote(src), nil
|
||||
}
|
||||
if src == nil {
|
||||
return dst, nil
|
||||
}
|
||||
if fxQuoteSignature(dst) != fxQuoteSignature(src) {
|
||||
return nil, merrors.InvalidArgument("quote_snapshot.fx_quote mismatch")
|
||||
}
|
||||
var err error
|
||||
dst.BaseAmount, err = mergeMoney(dst.BaseAmount, src.BaseAmount, "quote_snapshot.fx_quote.base_amount")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dst.QuoteAmount, err = mergeMoney(dst.QuoteAmount, src.QuoteAmount, "quote_snapshot.fx_quote.quote_amount")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if dst.ExpiresAtUnixMs == 0 || (src.ExpiresAtUnixMs > 0 && src.ExpiresAtUnixMs < dst.ExpiresAtUnixMs) {
|
||||
dst.ExpiresAtUnixMs = src.ExpiresAtUnixMs
|
||||
}
|
||||
if src.PricedAtUnixMs > dst.PricedAtUnixMs {
|
||||
dst.PricedAtUnixMs = src.PricedAtUnixMs
|
||||
}
|
||||
if dst.QuoteRef == "" {
|
||||
dst.QuoteRef = src.QuoteRef
|
||||
}
|
||||
return dst, nil
|
||||
}
|
||||
|
||||
func mergeExecutionConditions(dst, src *paymenttypes.QuoteExecutionConditions) *paymenttypes.QuoteExecutionConditions {
|
||||
if dst == nil {
|
||||
return cloneExecutionConditions(src)
|
||||
}
|
||||
if src == nil {
|
||||
return dst
|
||||
}
|
||||
|
||||
dst.Readiness = mergedReadiness(dst.Readiness, src.Readiness)
|
||||
dst.BatchingEligible = dst.BatchingEligible && src.BatchingEligible
|
||||
dst.PrefundingRequired = dst.PrefundingRequired || src.PrefundingRequired
|
||||
dst.PrefundingCostIncluded = dst.PrefundingCostIncluded || src.PrefundingCostIncluded
|
||||
dst.LiquidityCheckRequiredAtExecution = dst.LiquidityCheckRequiredAtExecution || src.LiquidityCheckRequiredAtExecution
|
||||
if dst.LatencyHint == "" {
|
||||
dst.LatencyHint = src.LatencyHint
|
||||
} else if srcHint := strings.TrimSpace(src.LatencyHint); srcHint != "" && !strings.EqualFold(dst.LatencyHint, srcHint) {
|
||||
dst.LatencyHint = "mixed"
|
||||
}
|
||||
dst.Assumptions = mergeAssumptions(dst.Assumptions, src.Assumptions)
|
||||
|
||||
return dst
|
||||
}
|
||||
|
||||
func mergedReadiness(a, b paymenttypes.QuoteExecutionReadiness) paymenttypes.QuoteExecutionReadiness {
|
||||
scoreA := readinessScore(a)
|
||||
scoreB := readinessScore(b)
|
||||
if scoreA <= scoreB {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func readinessScore(v paymenttypes.QuoteExecutionReadiness) int {
|
||||
switch v {
|
||||
case paymenttypes.QuoteExecutionReadinessIndicative:
|
||||
return 0
|
||||
case paymenttypes.QuoteExecutionReadinessLiquidityObtainable:
|
||||
return 1
|
||||
case paymenttypes.QuoteExecutionReadinessLiquidityReady:
|
||||
return 2
|
||||
default:
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
func mergeAssumptions(dst, src []string) []string {
|
||||
if len(dst) == 0 && len(src) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]struct{}, len(dst)+len(src))
|
||||
out := make([]string, 0, len(dst)+len(src))
|
||||
for _, item := range dst {
|
||||
key := strings.TrimSpace(item)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[key]; exists {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, key)
|
||||
}
|
||||
for _, item := range src {
|
||||
key := strings.TrimSpace(item)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[key]; exists {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, key)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mergeNetworkFee(dst, src *paymenttypes.NetworkFeeEstimate) (*paymenttypes.NetworkFeeEstimate, error) {
|
||||
if dst == nil {
|
||||
return cloneNetworkFee(src), nil
|
||||
}
|
||||
if src == nil {
|
||||
return dst, nil
|
||||
}
|
||||
sum, err := mergeMoney(dst.NetworkFee, src.NetworkFee, "quote_snapshot.network_fee.network_fee")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dst.NetworkFee = sum
|
||||
if dst.EstimationContext == "" {
|
||||
dst.EstimationContext = strings.TrimSpace(src.EstimationContext)
|
||||
} else if ctx := strings.TrimSpace(src.EstimationContext); ctx != "" && !strings.EqualFold(dst.EstimationContext, ctx) {
|
||||
dst.EstimationContext = "mixed"
|
||||
}
|
||||
return dst, nil
|
||||
}
|
||||
|
||||
func mergeFeeLines(dst, src []*paymenttypes.FeeLine) ([]*paymenttypes.FeeLine, error) {
|
||||
if len(dst) == 0 {
|
||||
return cloneFeeLines(src), nil
|
||||
}
|
||||
if len(src) == 0 {
|
||||
return dst, nil
|
||||
}
|
||||
|
||||
out := cloneFeeLines(dst)
|
||||
indexByKey := make(map[string]int, len(out))
|
||||
for i, line := range out {
|
||||
if line == nil {
|
||||
continue
|
||||
}
|
||||
indexByKey[feeLineKey(line)] = i
|
||||
}
|
||||
|
||||
for _, line := range src {
|
||||
if line == nil {
|
||||
continue
|
||||
}
|
||||
key := feeLineKey(line)
|
||||
if idx, exists := indexByKey[key]; exists {
|
||||
sum, err := mergeMoney(out[idx].Money, line.Money, "quote_snapshot.fee_lines["+key+"]")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[idx].Money = sum
|
||||
continue
|
||||
}
|
||||
cloned := cloneFeeLine(line)
|
||||
indexByKey[key] = len(out)
|
||||
out = append(out, cloned)
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func mergeFeeRules(dst, src []*paymenttypes.AppliedRule) []*paymenttypes.AppliedRule {
|
||||
if len(dst) == 0 {
|
||||
return cloneFeeRules(src)
|
||||
}
|
||||
if len(src) == 0 {
|
||||
return dst
|
||||
}
|
||||
|
||||
out := cloneFeeRules(dst)
|
||||
seen := make(map[string]struct{}, len(out))
|
||||
for _, rule := range out {
|
||||
if rule == nil {
|
||||
continue
|
||||
}
|
||||
seen[feeRuleKey(rule)] = struct{}{}
|
||||
}
|
||||
for _, rule := range src {
|
||||
if rule == nil {
|
||||
continue
|
||||
}
|
||||
key := feeRuleKey(rule)
|
||||
if _, exists := seen[key]; exists {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, cloneFeeRule(rule))
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package opagg
|
||||
|
||||
import (
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
)
|
||||
|
||||
// Aggregator compacts compatible quote items into recipient-level execution groups.
|
||||
type Aggregator interface {
|
||||
Aggregate(in Input) (*Output, error)
|
||||
}
|
||||
|
||||
// Input contains quote/intents selected for one execution request scope.
|
||||
type Input struct {
|
||||
Items []Item
|
||||
}
|
||||
|
||||
// Item is one quote-intent pair candidate for aggregation.
|
||||
type Item struct {
|
||||
IntentRef string
|
||||
IntentSnapshot model.PaymentIntent
|
||||
QuoteSnapshot *model.PaymentQuoteSnapshot
|
||||
}
|
||||
|
||||
// Group is one aggregated recipient operation group.
|
||||
type Group struct {
|
||||
RecipientKey string
|
||||
IntentRefs []string
|
||||
IntentSnapshot model.PaymentIntent
|
||||
QuoteSnapshot *model.PaymentQuoteSnapshot
|
||||
}
|
||||
|
||||
// Output is the aggregation result.
|
||||
type Output struct {
|
||||
Groups []Group
|
||||
}
|
||||
|
||||
// Dependencies configures operation aggregator integrations.
|
||||
type Dependencies struct {
|
||||
Logger mlogger.Logger
|
||||
}
|
||||
|
||||
func New(deps ...Dependencies) Aggregator {
|
||||
var dep Dependencies
|
||||
if len(deps) > 0 {
|
||||
dep = deps[0]
|
||||
}
|
||||
return &svc{logger: dep.Logger.Named("opagg")}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package opagg
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
keySep = "\x1f"
|
||||
|
||||
attrAggregatedByRecipient = "orchestrator.v2.aggregated_by_recipient"
|
||||
attrAggregatedItems = "orchestrator.v2.aggregated_items"
|
||||
)
|
||||
|
||||
type svc struct {
|
||||
logger mlogger.Logger
|
||||
}
|
||||
|
||||
type groupAccumulator struct {
|
||||
recipientKey string
|
||||
intentRefs []string
|
||||
intent model.PaymentIntent
|
||||
quote *model.PaymentQuoteSnapshot
|
||||
}
|
||||
|
||||
func (s *svc) Aggregate(in Input) (out *Output, err error) {
|
||||
logger := s.logger
|
||||
logger.Debug("Starting Aggregate", zap.Int("items_count", len(in.Items)))
|
||||
defer func(start time.Time) {
|
||||
fields := []zap.Field{zap.Int64("duration_ms", time.Since(start).Milliseconds())}
|
||||
if out != nil {
|
||||
fields = append(fields, zap.Int("groups_count", len(out.Groups)))
|
||||
}
|
||||
if err != nil {
|
||||
logger.Warn("Failed to aggregate", append(fields, zap.Error(err))...)
|
||||
return
|
||||
}
|
||||
logger.Debug("Completed Aggregate", fields...)
|
||||
}(time.Now())
|
||||
|
||||
if len(in.Items) == 0 {
|
||||
return nil, merrors.InvalidArgument("items are required")
|
||||
}
|
||||
|
||||
groups := make(map[string]*groupAccumulator, len(in.Items))
|
||||
order := make([]string, 0, len(in.Items))
|
||||
|
||||
for i := range in.Items {
|
||||
item := in.Items[i]
|
||||
if err := validateItem(item); err != nil {
|
||||
return nil, merrors.InvalidArgument("items[" + itoa(i) + "]: " + err.Error())
|
||||
}
|
||||
|
||||
key, recipientKey, err := groupingKey(item)
|
||||
if err != nil {
|
||||
return nil, merrors.InvalidArgument("items[" + itoa(i) + "]: " + err.Error())
|
||||
}
|
||||
if !isBatchingEligible(item.QuoteSnapshot) {
|
||||
key = key + keySep + "non_batching=" + itoa(i)
|
||||
}
|
||||
|
||||
intentRef := firstNonEmpty(
|
||||
strings.TrimSpace(item.IntentRef),
|
||||
strings.TrimSpace(item.IntentSnapshot.Ref),
|
||||
"intent-"+itoa(i+1),
|
||||
)
|
||||
|
||||
acc, exists := groups[key]
|
||||
if !exists {
|
||||
intentSnapshot, cloneErr := cloneIntentSnapshot(item.IntentSnapshot)
|
||||
if cloneErr != nil {
|
||||
return nil, cloneErr
|
||||
}
|
||||
quoteSnapshot, cloneErr := cloneQuoteSnapshot(item.QuoteSnapshot)
|
||||
if cloneErr != nil {
|
||||
return nil, cloneErr
|
||||
}
|
||||
if quoteSnapshot == nil {
|
||||
return nil, merrors.InvalidArgument("items[" + itoa(i) + "].quote_snapshot is required")
|
||||
}
|
||||
|
||||
groups[key] = &groupAccumulator{
|
||||
recipientKey: recipientKey,
|
||||
intentRefs: []string{intentRef},
|
||||
intent: intentSnapshot,
|
||||
quote: quoteSnapshot,
|
||||
}
|
||||
order = append(order, key)
|
||||
continue
|
||||
}
|
||||
|
||||
if err := mergeIntentSnapshot(&acc.intent, item.IntentSnapshot); err != nil {
|
||||
return nil, merrors.InvalidArgument("items[" + itoa(i) + "]: " + err.Error())
|
||||
}
|
||||
if err := mergeQuoteSnapshot(acc.quote, item.QuoteSnapshot); err != nil {
|
||||
return nil, merrors.InvalidArgument("items[" + itoa(i) + "]: " + err.Error())
|
||||
}
|
||||
acc.intentRefs = append(acc.intentRefs, intentRef)
|
||||
}
|
||||
|
||||
out = &Output{
|
||||
Groups: make([]Group, 0, len(order)),
|
||||
}
|
||||
for _, key := range order {
|
||||
acc := groups[key]
|
||||
if acc == nil || acc.quote == nil {
|
||||
continue
|
||||
}
|
||||
finalIntent, err := cloneIntentSnapshot(acc.intent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
finalQuote, err := cloneQuoteSnapshot(acc.quote)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(acc.intentRefs) > 1 {
|
||||
if finalIntent.Attributes == nil {
|
||||
finalIntent.Attributes = map[string]string{}
|
||||
}
|
||||
finalIntent.Attributes[attrAggregatedByRecipient] = "true"
|
||||
finalIntent.Attributes[attrAggregatedItems] = strconv.Itoa(len(acc.intentRefs))
|
||||
}
|
||||
|
||||
out.Groups = append(out.Groups, Group{
|
||||
RecipientKey: acc.recipientKey,
|
||||
IntentRefs: cloneStringSlice(acc.intentRefs),
|
||||
IntentSnapshot: finalIntent,
|
||||
QuoteSnapshot: finalQuote,
|
||||
})
|
||||
}
|
||||
|
||||
if len(out.Groups) == 0 {
|
||||
return nil, merrors.InvalidArgument("aggregation produced no groups")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func validateItem(item Item) error {
|
||||
if isEmptyIntentSnapshot(item.IntentSnapshot) {
|
||||
return merrors.InvalidArgument("intent_snapshot is required")
|
||||
}
|
||||
if item.QuoteSnapshot == nil {
|
||||
return merrors.InvalidArgument("quote_snapshot is required")
|
||||
}
|
||||
if item.IntentSnapshot.Amount == nil {
|
||||
return merrors.InvalidArgument("intent_snapshot.amount is required")
|
||||
}
|
||||
if strings.TrimSpace(item.IntentSnapshot.Amount.Currency) == "" {
|
||||
return merrors.InvalidArgument("intent_snapshot.amount.currency is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func groupingKey(item Item) (string, string, error) {
|
||||
sourceKey, err := endpointKey(item.IntentSnapshot.Source)
|
||||
if err != nil {
|
||||
return "", "", merrors.InvalidArgument("intent_snapshot.source: " + err.Error())
|
||||
}
|
||||
recipientKey, err := endpointKey(item.IntentSnapshot.Destination)
|
||||
if err != nil {
|
||||
return "", "", merrors.InvalidArgument("intent_snapshot.destination: " + err.Error())
|
||||
}
|
||||
|
||||
quote := item.QuoteSnapshot
|
||||
key := strings.Join([]string{
|
||||
"kind=" + strings.ToLower(strings.TrimSpace(string(item.IntentSnapshot.Kind))),
|
||||
"source=" + sourceKey,
|
||||
"recipient=" + recipientKey,
|
||||
"settlement_mode=" + strings.ToLower(strings.TrimSpace(string(item.IntentSnapshot.SettlementMode))),
|
||||
"settlement_currency=" + normalizeCurrency(item.IntentSnapshot.SettlementCurrency),
|
||||
"debit_currency=" + moneyCurrency(quote.DebitAmount),
|
||||
"settlement_amount_currency=" + moneyCurrency(quote.ExpectedSettlementAmount),
|
||||
"route=" + routeSignature(quote.Route),
|
||||
"fx=" + fxQuoteSignature(quote.FXQuote),
|
||||
}, keySep)
|
||||
|
||||
return key, recipientKey, nil
|
||||
}
|
||||
|
||||
func isBatchingEligible(quote *model.PaymentQuoteSnapshot) bool {
|
||||
if quote == nil || quote.ExecutionConditions == nil {
|
||||
return true
|
||||
}
|
||||
return quote.ExecutionConditions.BatchingEligible
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package ostate
|
||||
|
||||
import "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||
|
||||
var aggregateTransitions = map[agg.State]map[agg.State]struct{}{
|
||||
agg.StateUnspecified: {
|
||||
agg.StateCreated: {},
|
||||
},
|
||||
agg.StateCreated: {
|
||||
agg.StateExecuting: {},
|
||||
agg.StateFailed: {},
|
||||
},
|
||||
agg.StateExecuting: {
|
||||
agg.StateNeedsAttention: {},
|
||||
agg.StateSettled: {},
|
||||
agg.StateFailed: {},
|
||||
},
|
||||
agg.StateNeedsAttention: {
|
||||
agg.StateExecuting: {},
|
||||
agg.StateSettled: {},
|
||||
agg.StateFailed: {},
|
||||
},
|
||||
agg.StateSettled: {},
|
||||
agg.StateFailed: {},
|
||||
}
|
||||
|
||||
var aggregateTerminalStates = map[agg.State]struct{}{
|
||||
agg.StateSettled: {},
|
||||
agg.StateFailed: {},
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package ostate
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||
)
|
||||
|
||||
func TestAggregateTransitionMatrix(t *testing.T) {
|
||||
sm := New()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
from agg.State
|
||||
to agg.State
|
||||
wantOK bool
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "unspecified to created",
|
||||
from: agg.StateUnspecified,
|
||||
to: agg.StateCreated,
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "created to executing",
|
||||
from: agg.StateCreated,
|
||||
to: agg.StateExecuting,
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "executing to needs attention",
|
||||
from: agg.StateExecuting,
|
||||
to: agg.StateNeedsAttention,
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "executing to settled",
|
||||
from: agg.StateExecuting,
|
||||
to: agg.StateSettled,
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "needs attention back to executing",
|
||||
from: agg.StateNeedsAttention,
|
||||
to: agg.StateExecuting,
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "idempotent self transition",
|
||||
from: agg.StateFailed,
|
||||
to: agg.StateFailed,
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "created to settled denied",
|
||||
from: agg.StateCreated,
|
||||
to: agg.StateSettled,
|
||||
wantOK: false,
|
||||
wantErr: ErrAggregateTransitionNotAllowed,
|
||||
},
|
||||
{
|
||||
name: "settled to executing denied",
|
||||
from: agg.StateSettled,
|
||||
to: agg.StateExecuting,
|
||||
wantOK: false,
|
||||
wantErr: ErrAggregateTransitionNotAllowed,
|
||||
},
|
||||
{
|
||||
name: "unknown from state",
|
||||
from: agg.State("paused"),
|
||||
to: agg.StateExecuting,
|
||||
wantOK: false,
|
||||
wantErr: ErrUnknownAggregateState,
|
||||
},
|
||||
{
|
||||
name: "unknown to state",
|
||||
from: agg.StateExecuting,
|
||||
to: agg.State("paused"),
|
||||
wantOK: false,
|
||||
wantErr: ErrUnknownAggregateState,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := sm.CanTransitionAggregate(tt.from, tt.to); got != tt.wantOK {
|
||||
t.Fatalf("CanTransitionAggregate mismatch: got=%v want=%v", got, tt.wantOK)
|
||||
}
|
||||
|
||||
err := sm.EnsureAggregateTransition(tt.from, tt.to)
|
||||
if tt.wantOK {
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureAggregateTransition returned error: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !errors.Is(err, tt.wantErr) {
|
||||
t.Fatalf("expected error %v, got %v", tt.wantErr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAggregateTerminalStates(t *testing.T) {
|
||||
sm := New()
|
||||
|
||||
tests := []struct {
|
||||
state agg.State
|
||||
expect bool
|
||||
}{
|
||||
{state: agg.StateUnspecified, expect: false},
|
||||
{state: agg.StateCreated, expect: false},
|
||||
{state: agg.StateExecuting, expect: false},
|
||||
{state: agg.StateNeedsAttention, expect: false},
|
||||
{state: agg.StateSettled, expect: true},
|
||||
{state: agg.StateFailed, expect: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := sm.IsAggregateTerminal(tt.state); got != tt.expect {
|
||||
t.Fatalf("IsAggregateTerminal(%q) mismatch: got=%v want=%v", tt.state, got, tt.expect)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package ostate
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrUnknownAggregateState = errors.New("unknown aggregate state")
|
||||
ErrAggregateTransitionNotAllowed = errors.New("aggregate transition not allowed")
|
||||
ErrUnknownStepState = errors.New("unknown step state")
|
||||
ErrStepTransitionNotAllowed = errors.New("step transition not allowed")
|
||||
)
|
||||
@@ -0,0 +1,30 @@
|
||||
package ostate
|
||||
|
||||
import (
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
)
|
||||
|
||||
// StateMachine is the single source of truth for orchestration-v2 state transitions.
|
||||
type StateMachine interface {
|
||||
CanTransitionAggregate(from, to agg.State) bool
|
||||
CanTransitionStep(from, to agg.StepState) bool
|
||||
EnsureAggregateTransition(from, to agg.State) error
|
||||
EnsureStepTransition(from, to agg.StepState) error
|
||||
IsAggregateTerminal(state agg.State) bool
|
||||
IsStepTerminal(state agg.StepState) bool
|
||||
}
|
||||
|
||||
// Dependencies configures state-machine integrations.
|
||||
type Dependencies struct {
|
||||
Logger mlogger.Logger
|
||||
}
|
||||
|
||||
func New(deps ...Dependencies) StateMachine {
|
||||
var dep Dependencies
|
||||
if len(deps) > 0 {
|
||||
dep = deps[0]
|
||||
}
|
||||
return &svc{logger: dep.Logger.Named("ostate")}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package ostate
|
||||
|
||||
import (
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xerr"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type svc struct {
|
||||
logger mlogger.Logger
|
||||
}
|
||||
|
||||
func (s *svc) CanTransitionAggregate(from, to agg.State) bool {
|
||||
return canTransitionAggregate(from, to)
|
||||
}
|
||||
|
||||
func (s *svc) CanTransitionStep(from, to agg.StepState) bool {
|
||||
return canTransitionStep(from, to)
|
||||
}
|
||||
|
||||
func (s *svc) EnsureAggregateTransition(from, to agg.State) error {
|
||||
logger := s.logger
|
||||
logger.Debug("Starting Ensure aggregate transition",
|
||||
zap.String("from", string(from)),
|
||||
zap.String("to", string(to)),
|
||||
)
|
||||
if !isKnownAggregateState(from) {
|
||||
err := xerr.Wrapf(ErrUnknownAggregateState, "%q", from)
|
||||
logger.Warn("Failed to ensure aggregate transition", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
if !isKnownAggregateState(to) {
|
||||
err := xerr.Wrapf(ErrUnknownAggregateState, "%q", to)
|
||||
logger.Warn("Failed to ensure aggregate transition", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
if canTransitionAggregate(from, to) {
|
||||
logger.Debug("Completed Ensure aggregate transition", zap.Bool("allowed", true))
|
||||
return nil
|
||||
}
|
||||
err := xerr.Wrapf(ErrAggregateTransitionNotAllowed, "%s -> %s", from, to)
|
||||
logger.Warn("Failed to ensure aggregate transition", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *svc) EnsureStepTransition(from, to agg.StepState) error {
|
||||
logger := s.logger
|
||||
logger.Debug("Starting Ensure step transition",
|
||||
zap.String("from", string(from)),
|
||||
zap.String("to", string(to)),
|
||||
)
|
||||
if !isKnownStepState(from) {
|
||||
err := xerr.Wrapf(ErrUnknownStepState, "%q", from)
|
||||
logger.Warn("Failed to ensure step transition", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
if !isKnownStepState(to) {
|
||||
err := xerr.Wrapf(ErrUnknownStepState, "%q", to)
|
||||
logger.Warn("Failed to ensure step transition", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
if canTransitionStep(from, to) {
|
||||
logger.Debug("Completed Ensure step transition", zap.Bool("allowed", true))
|
||||
return nil
|
||||
}
|
||||
err := xerr.Wrapf(ErrStepTransitionNotAllowed, "%s -> %s", from, to)
|
||||
logger.Warn("Failed to ensure step transition", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *svc) IsAggregateTerminal(state agg.State) bool {
|
||||
_, ok := aggregateTerminalStates[state]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (s *svc) IsStepTerminal(state agg.StepState) bool {
|
||||
_, ok := stepTerminalStates[state]
|
||||
return ok
|
||||
}
|
||||
|
||||
func canTransitionAggregate(from, to agg.State) bool {
|
||||
if from == to {
|
||||
return isKnownAggregateState(from)
|
||||
}
|
||||
allowed, ok := aggregateTransitions[from]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
_, ok = allowed[to]
|
||||
return ok
|
||||
}
|
||||
|
||||
func canTransitionStep(from, to agg.StepState) bool {
|
||||
if from == to {
|
||||
return isKnownStepState(from)
|
||||
}
|
||||
allowed, ok := stepTransitions[from]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
_, ok = allowed[to]
|
||||
return ok
|
||||
}
|
||||
|
||||
func isKnownAggregateState(state agg.State) bool {
|
||||
_, ok := aggregateTransitions[state]
|
||||
return ok
|
||||
}
|
||||
|
||||
func isKnownStepState(state agg.StepState) bool {
|
||||
_, ok := stepTransitions[state]
|
||||
return ok
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package ostate
|
||||
|
||||
import "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||
|
||||
var stepTransitions = map[agg.StepState]map[agg.StepState]struct{}{
|
||||
agg.StepStateUnspecified: {
|
||||
agg.StepStatePending: {},
|
||||
},
|
||||
agg.StepStatePending: {
|
||||
agg.StepStateRunning: {},
|
||||
agg.StepStateFailed: {},
|
||||
agg.StepStateNeedsAttention: {},
|
||||
agg.StepStateSkipped: {},
|
||||
},
|
||||
agg.StepStateRunning: {
|
||||
agg.StepStateCompleted: {},
|
||||
agg.StepStateFailed: {},
|
||||
agg.StepStateNeedsAttention: {},
|
||||
},
|
||||
agg.StepStateCompleted: {},
|
||||
agg.StepStateFailed: {
|
||||
agg.StepStateRunning: {},
|
||||
agg.StepStateNeedsAttention: {},
|
||||
},
|
||||
agg.StepStateNeedsAttention: {
|
||||
agg.StepStateRunning: {},
|
||||
agg.StepStateFailed: {},
|
||||
agg.StepStateCompleted: {},
|
||||
agg.StepStateSkipped: {},
|
||||
},
|
||||
agg.StepStateSkipped: {},
|
||||
}
|
||||
|
||||
var stepTerminalStates = map[agg.StepState]struct{}{
|
||||
agg.StepStateCompleted: {},
|
||||
agg.StepStateSkipped: {},
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package ostate
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||
)
|
||||
|
||||
func TestStepTransitionMatrix(t *testing.T) {
|
||||
sm := New()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
from agg.StepState
|
||||
to agg.StepState
|
||||
wantOK bool
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "unspecified to pending",
|
||||
from: agg.StepStateUnspecified,
|
||||
to: agg.StepStatePending,
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "pending to running",
|
||||
from: agg.StepStatePending,
|
||||
to: agg.StepStateRunning,
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "running to completed",
|
||||
from: agg.StepStateRunning,
|
||||
to: agg.StepStateCompleted,
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "failed to running retry",
|
||||
from: agg.StepStateFailed,
|
||||
to: agg.StepStateRunning,
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "needs attention to completed",
|
||||
from: agg.StepStateNeedsAttention,
|
||||
to: agg.StepStateCompleted,
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "idempotent self transition",
|
||||
from: agg.StepStateSkipped,
|
||||
to: agg.StepStateSkipped,
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "pending to completed denied",
|
||||
from: agg.StepStatePending,
|
||||
to: agg.StepStateCompleted,
|
||||
wantOK: false,
|
||||
wantErr: ErrStepTransitionNotAllowed,
|
||||
},
|
||||
{
|
||||
name: "completed to running denied",
|
||||
from: agg.StepStateCompleted,
|
||||
to: agg.StepStateRunning,
|
||||
wantOK: false,
|
||||
wantErr: ErrStepTransitionNotAllowed,
|
||||
},
|
||||
{
|
||||
name: "unknown from state",
|
||||
from: agg.StepState("waiting"),
|
||||
to: agg.StepStateRunning,
|
||||
wantOK: false,
|
||||
wantErr: ErrUnknownStepState,
|
||||
},
|
||||
{
|
||||
name: "unknown to state",
|
||||
from: agg.StepStateRunning,
|
||||
to: agg.StepState("waiting"),
|
||||
wantOK: false,
|
||||
wantErr: ErrUnknownStepState,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := sm.CanTransitionStep(tt.from, tt.to); got != tt.wantOK {
|
||||
t.Fatalf("CanTransitionStep mismatch: got=%v want=%v", got, tt.wantOK)
|
||||
}
|
||||
|
||||
err := sm.EnsureStepTransition(tt.from, tt.to)
|
||||
if tt.wantOK {
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureStepTransition returned error: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !errors.Is(err, tt.wantErr) {
|
||||
t.Fatalf("expected error %v, got %v", tt.wantErr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStepTerminalStates(t *testing.T) {
|
||||
sm := New()
|
||||
|
||||
tests := []struct {
|
||||
state agg.StepState
|
||||
expect bool
|
||||
}{
|
||||
{state: agg.StepStateUnspecified, expect: false},
|
||||
{state: agg.StepStatePending, expect: false},
|
||||
{state: agg.StepStateRunning, expect: false},
|
||||
{state: agg.StepStateNeedsAttention, expect: false},
|
||||
{state: agg.StepStateFailed, expect: false},
|
||||
{state: agg.StepStateCompleted, expect: true},
|
||||
{state: agg.StepStateSkipped, expect: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := sm.IsStepTerminal(tt.state); got != tt.expect {
|
||||
t.Fatalf("IsStepTerminal(%q) mismatch: got=%v want=%v", tt.state, got, tt.expect)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package pquery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/prepo"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// Service provides read models for orchestration-v2 payments.
|
||||
type Service interface {
|
||||
GetPayment(ctx context.Context, in GetPaymentInput) (*agg.Payment, error)
|
||||
ListPayments(ctx context.Context, in ListPaymentsInput) (*ListPaymentsOutput, error)
|
||||
}
|
||||
|
||||
// GetPaymentInput scopes one payment lookup.
|
||||
type GetPaymentInput struct {
|
||||
OrganizationRef bson.ObjectID
|
||||
PaymentRef string
|
||||
}
|
||||
|
||||
// ListPaymentsInput scopes cursor-based listing.
|
||||
type ListPaymentsInput struct {
|
||||
OrganizationRef bson.ObjectID
|
||||
States []agg.State
|
||||
QuotationRef string
|
||||
CreatedFrom *time.Time
|
||||
CreatedTo *time.Time
|
||||
Cursor *prepo.ListCursor
|
||||
Limit int32
|
||||
}
|
||||
|
||||
// ListPaymentsOutput is one list page.
|
||||
type ListPaymentsOutput struct {
|
||||
Items []*agg.Payment
|
||||
NextCursor *prepo.ListCursor
|
||||
}
|
||||
|
||||
// Dependencies defines query service dependencies.
|
||||
type Dependencies struct {
|
||||
Repository prepo.Repository
|
||||
Logger mlogger.Logger
|
||||
}
|
||||
|
||||
func New(deps Dependencies) (Service, error) {
|
||||
return newService(deps)
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
package pquery
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/prepo"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultLimit int32 = 50
|
||||
maxLimit int32 = 200
|
||||
)
|
||||
|
||||
var allStates = []agg.State{
|
||||
agg.StateCreated,
|
||||
agg.StateExecuting,
|
||||
agg.StateNeedsAttention,
|
||||
agg.StateSettled,
|
||||
agg.StateFailed,
|
||||
}
|
||||
|
||||
type svc struct {
|
||||
logger mlogger.Logger
|
||||
repo prepo.Repository
|
||||
}
|
||||
|
||||
type normalizedInput struct {
|
||||
organizationRef bson.ObjectID
|
||||
quotationRef string
|
||||
states []agg.State
|
||||
createdFrom *time.Time
|
||||
createdTo *time.Time
|
||||
cursor *prepo.ListCursor
|
||||
limit int32
|
||||
}
|
||||
|
||||
func newService(deps Dependencies) (Service, error) {
|
||||
if deps.Repository == nil {
|
||||
return nil, merrors.InvalidArgument("payment repository v2 is required")
|
||||
}
|
||||
return &svc{
|
||||
logger: deps.Logger.Named("pquery"),
|
||||
repo: deps.Repository,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *svc) GetPayment(ctx context.Context, in GetPaymentInput) (payment *agg.Payment, err error) {
|
||||
logger := s.logger
|
||||
logger.Debug("Starting Get payment",
|
||||
zap.String("organization_ref", in.OrganizationRef.Hex()),
|
||||
zap.String("payment_ref", strings.TrimSpace(in.PaymentRef)),
|
||||
)
|
||||
defer func(start time.Time) {
|
||||
fields := []zap.Field{zap.Int64("duration_ms", time.Since(start).Milliseconds())}
|
||||
if payment != nil {
|
||||
fields = append(fields,
|
||||
zap.String("state", string(payment.State)),
|
||||
zap.Uint64("version", payment.Version),
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
logger.Warn("Failed to get payment", append(fields, zap.Error(err))...)
|
||||
return
|
||||
}
|
||||
logger.Debug("Completed Get payment", fields...)
|
||||
}(time.Now())
|
||||
|
||||
if in.OrganizationRef.IsZero() {
|
||||
return nil, merrors.InvalidArgument("organization_ref is required")
|
||||
}
|
||||
paymentRef := strings.TrimSpace(in.PaymentRef)
|
||||
if paymentRef == "" {
|
||||
return nil, merrors.InvalidArgument("payment_ref is required")
|
||||
}
|
||||
payment, err = s.repo.GetByPaymentRef(ctx, in.OrganizationRef, paymentRef)
|
||||
return payment, err
|
||||
}
|
||||
|
||||
func (s *svc) ListPayments(ctx context.Context, in ListPaymentsInput) (out *ListPaymentsOutput, err error) {
|
||||
logger := s.logger
|
||||
logger.Debug("Starting List payments",
|
||||
zap.String("organization_ref", in.OrganizationRef.Hex()),
|
||||
zap.String("quotation_ref", strings.TrimSpace(in.QuotationRef)),
|
||||
zap.Int("states_count", len(in.States)),
|
||||
zap.Int32("limit", in.Limit),
|
||||
)
|
||||
defer func(start time.Time) {
|
||||
fields := []zap.Field{zap.Int64("duration_ms", time.Since(start).Milliseconds())}
|
||||
if out != nil {
|
||||
fields = append(fields, zap.Int("items_count", len(out.Items)))
|
||||
if out.NextCursor != nil {
|
||||
fields = append(fields,
|
||||
zap.String("next_cursor_id", out.NextCursor.ID.Hex()),
|
||||
zap.Time("next_cursor_created_at", out.NextCursor.CreatedAt),
|
||||
)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
logger.Warn("Failed to list payments", append(fields, zap.Error(err))...)
|
||||
return
|
||||
}
|
||||
logger.Debug("Completed List payments", fields...)
|
||||
}(time.Now())
|
||||
|
||||
norm, err := normalizeInput(in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if norm.quotationRef != "" {
|
||||
out, err = s.listByQuotationRef(ctx, norm)
|
||||
return out, err
|
||||
}
|
||||
out, err = s.listByStates(ctx, norm)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func normalizeInput(in ListPaymentsInput) (*normalizedInput, error) {
|
||||
if in.OrganizationRef.IsZero() {
|
||||
return nil, merrors.InvalidArgument("organization_ref is required")
|
||||
}
|
||||
if in.CreatedFrom != nil && in.CreatedTo != nil {
|
||||
from := in.CreatedFrom.UTC()
|
||||
to := in.CreatedTo.UTC()
|
||||
if !from.Before(to) {
|
||||
return nil, merrors.InvalidArgument("created_from must be before created_to")
|
||||
}
|
||||
}
|
||||
states, err := normalizeStates(in.States)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var createdFrom *time.Time
|
||||
if in.CreatedFrom != nil {
|
||||
from := in.CreatedFrom.UTC()
|
||||
createdFrom = &from
|
||||
}
|
||||
var createdTo *time.Time
|
||||
if in.CreatedTo != nil {
|
||||
to := in.CreatedTo.UTC()
|
||||
createdTo = &to
|
||||
}
|
||||
|
||||
return &normalizedInput{
|
||||
organizationRef: in.OrganizationRef,
|
||||
quotationRef: strings.TrimSpace(in.QuotationRef),
|
||||
states: states,
|
||||
createdFrom: createdFrom,
|
||||
createdTo: createdTo,
|
||||
cursor: in.Cursor,
|
||||
limit: sanitizeLimit(in.Limit),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizeStates(src []agg.State) ([]agg.State, error) {
|
||||
if len(src) == 0 {
|
||||
return append([]agg.State(nil), allStates...), nil
|
||||
}
|
||||
out := make([]agg.State, 0, len(src))
|
||||
seen := map[agg.State]struct{}{}
|
||||
for i := range src {
|
||||
state, ok := normalizeState(src[i])
|
||||
if !ok {
|
||||
return nil, merrors.InvalidArgument("states contains invalid value")
|
||||
}
|
||||
if _, exists := seen[state]; exists {
|
||||
continue
|
||||
}
|
||||
seen[state] = struct{}{}
|
||||
out = append(out, state)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return append([]agg.State(nil), allStates...), nil
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func normalizeState(state agg.State) (agg.State, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(string(state))) {
|
||||
case string(agg.StateCreated):
|
||||
return agg.StateCreated, true
|
||||
case string(agg.StateExecuting):
|
||||
return agg.StateExecuting, true
|
||||
case string(agg.StateNeedsAttention):
|
||||
return agg.StateNeedsAttention, true
|
||||
case string(agg.StateSettled):
|
||||
return agg.StateSettled, true
|
||||
case string(agg.StateFailed):
|
||||
return agg.StateFailed, true
|
||||
default:
|
||||
return agg.StateUnspecified, false
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeLimit(limit int32) int32 {
|
||||
if limit <= 0 {
|
||||
return defaultLimit
|
||||
}
|
||||
if limit > maxLimit {
|
||||
return maxLimit
|
||||
}
|
||||
return limit
|
||||
}
|
||||
|
||||
func (s *svc) listByQuotationRef(ctx context.Context, in *normalizedInput) (*ListPaymentsOutput, error) {
|
||||
cursor := in.cursor
|
||||
out := make([]*agg.Payment, 0, in.limit)
|
||||
|
||||
for len(out) < int(in.limit) {
|
||||
page, err := s.repo.ListByQuotationRef(ctx, prepo.ListByQuotationRefInput{
|
||||
OrganizationRef: in.organizationRef,
|
||||
QuotationRef: in.quotationRef,
|
||||
Limit: in.limit,
|
||||
Cursor: cursor,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if page == nil || len(page.Items) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
for i := range page.Items {
|
||||
item := page.Items[i]
|
||||
if !matchesFilters(item, in) {
|
||||
continue
|
||||
}
|
||||
out = append(out, item)
|
||||
if len(out) == int(in.limit) {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(out) == int(in.limit) || page.NextCursor == nil {
|
||||
break
|
||||
}
|
||||
cursor = page.NextCursor
|
||||
}
|
||||
|
||||
return buildOutput(out, in.limit), nil
|
||||
}
|
||||
|
||||
func (s *svc) listByStates(ctx context.Context, in *normalizedInput) (*ListPaymentsOutput, error) {
|
||||
cursor := in.cursor
|
||||
out := make([]*agg.Payment, 0, in.limit)
|
||||
|
||||
for len(out) < int(in.limit) {
|
||||
merged, next, err := s.fetchStatesPage(ctx, in, cursor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(merged) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
for i := range merged {
|
||||
if !matchesFilters(merged[i], in) {
|
||||
continue
|
||||
}
|
||||
out = append(out, merged[i])
|
||||
if len(out) == int(in.limit) {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(out) == int(in.limit) || next == nil {
|
||||
break
|
||||
}
|
||||
cursor = next
|
||||
}
|
||||
|
||||
return buildOutput(out, in.limit), nil
|
||||
}
|
||||
|
||||
func (s *svc) fetchStatesPage(
|
||||
ctx context.Context,
|
||||
in *normalizedInput,
|
||||
cursor *prepo.ListCursor,
|
||||
) ([]*agg.Payment, *prepo.ListCursor, error) {
|
||||
batch := in.limit
|
||||
if batch < 20 {
|
||||
batch = 20
|
||||
}
|
||||
|
||||
merged := make([]*agg.Payment, 0, len(in.states)*int(batch))
|
||||
var next *prepo.ListCursor
|
||||
for i := range in.states {
|
||||
page, err := s.repo.ListByState(ctx, prepo.ListByStateInput{
|
||||
OrganizationRef: in.organizationRef,
|
||||
State: in.states[i],
|
||||
Limit: batch,
|
||||
Cursor: cursor,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if page == nil || len(page.Items) == 0 {
|
||||
continue
|
||||
}
|
||||
merged = append(merged, page.Items...)
|
||||
if next == nil || cursorLess(page.NextCursor, next) {
|
||||
next = page.NextCursor
|
||||
}
|
||||
}
|
||||
if len(merged) == 0 {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
sortPaymentsDesc(merged)
|
||||
merged = dedupeByPaymentRef(merged)
|
||||
if len(merged) == 0 {
|
||||
return nil, next, nil
|
||||
}
|
||||
oldest := cursorFromPayment(merged[len(merged)-1])
|
||||
if cursorLess(oldest, next) {
|
||||
next = oldest
|
||||
}
|
||||
return merged, next, nil
|
||||
}
|
||||
|
||||
func cursorFromPayment(payment *agg.Payment) *prepo.ListCursor {
|
||||
if payment == nil || payment.ID.IsZero() || payment.CreatedAt.IsZero() {
|
||||
return nil
|
||||
}
|
||||
return &prepo.ListCursor{
|
||||
CreatedAt: payment.CreatedAt.UTC(),
|
||||
ID: payment.ID,
|
||||
}
|
||||
}
|
||||
|
||||
func cursorLess(left *prepo.ListCursor, right *prepo.ListCursor) bool {
|
||||
if left == nil {
|
||||
return false
|
||||
}
|
||||
if right == nil {
|
||||
return true
|
||||
}
|
||||
if left.CreatedAt.Before(right.CreatedAt) {
|
||||
return true
|
||||
}
|
||||
if left.CreatedAt.After(right.CreatedAt) {
|
||||
return false
|
||||
}
|
||||
return bytes.Compare(left.ID[:], right.ID[:]) < 0
|
||||
}
|
||||
|
||||
func buildOutput(items []*agg.Payment, limit int32) *ListPaymentsOutput {
|
||||
if len(items) == 0 {
|
||||
return &ListPaymentsOutput{}
|
||||
}
|
||||
if int32(len(items)) > limit {
|
||||
items = items[:limit]
|
||||
}
|
||||
var nextCursor *prepo.ListCursor
|
||||
if int32(len(items)) == limit {
|
||||
nextCursor = cursorFromPayment(items[len(items)-1])
|
||||
}
|
||||
return &ListPaymentsOutput{
|
||||
Items: items,
|
||||
NextCursor: nextCursor,
|
||||
}
|
||||
}
|
||||
|
||||
func matchesFilters(payment *agg.Payment, in *normalizedInput) bool {
|
||||
if payment == nil {
|
||||
return false
|
||||
}
|
||||
if in.quotationRef != "" && !strings.EqualFold(strings.TrimSpace(payment.QuotationRef), in.quotationRef) {
|
||||
return false
|
||||
}
|
||||
if in.createdFrom != nil && payment.CreatedAt.Before(*in.createdFrom) {
|
||||
return false
|
||||
}
|
||||
if in.createdTo != nil && !payment.CreatedAt.Before(*in.createdTo) {
|
||||
return false
|
||||
}
|
||||
return containsState(in.states, payment.State)
|
||||
}
|
||||
|
||||
func containsState(states []agg.State, state agg.State) bool {
|
||||
for i := range states {
|
||||
if states[i] == state {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func sortPaymentsDesc(items []*agg.Payment) {
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
left := items[i]
|
||||
right := items[j]
|
||||
if !left.CreatedAt.Equal(right.CreatedAt) {
|
||||
return left.CreatedAt.After(right.CreatedAt)
|
||||
}
|
||||
return bytes.Compare(left.ID[:], right.ID[:]) > 0
|
||||
})
|
||||
}
|
||||
|
||||
func dedupeByPaymentRef(items []*agg.Payment) []*agg.Payment {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]*agg.Payment, 0, len(items))
|
||||
seen := map[string]struct{}{}
|
||||
for i := range items {
|
||||
ref := strings.TrimSpace(items[i].PaymentRef)
|
||||
if ref == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[ref]; ok {
|
||||
continue
|
||||
}
|
||||
seen[ref] = struct{}{}
|
||||
out = append(out, items[i])
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package prepo
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/db/storable"
|
||||
pm "github.com/tech/sendico/pkg/model"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
const paymentsV2Collection = "payments_v2"
|
||||
|
||||
type paymentDocument struct {
|
||||
storable.Base `bson:",inline"`
|
||||
pm.OrganizationBoundBase `bson:",inline"`
|
||||
PaymentRef string `bson:"paymentRef"`
|
||||
IdempotencyKey string `bson:"idempotencyKey"`
|
||||
QuotationRef string `bson:"quotationRef"`
|
||||
ClientPaymentRef string `bson:"clientPaymentRef,omitempty"`
|
||||
IntentSnapshot model.PaymentIntent `bson:"intentSnapshot"`
|
||||
QuoteSnapshot *model.PaymentQuoteSnapshot `bson:"quoteSnapshot"`
|
||||
State agg.State `bson:"state"`
|
||||
Version uint64 `bson:"version"`
|
||||
StepExecutions []agg.StepExecution `bson:"stepExecutions,omitempty"`
|
||||
}
|
||||
|
||||
func (*paymentDocument) Collection() string {
|
||||
return paymentsV2Collection
|
||||
}
|
||||
|
||||
func toDocument(payment *agg.Payment) (*paymentDocument, error) {
|
||||
if payment == nil {
|
||||
return nil, nil
|
||||
}
|
||||
doc := &paymentDocument{
|
||||
Base: payment.Base,
|
||||
OrganizationBoundBase: payment.OrganizationBoundBase,
|
||||
PaymentRef: strings.TrimSpace(payment.PaymentRef),
|
||||
IdempotencyKey: strings.TrimSpace(payment.IdempotencyKey),
|
||||
QuotationRef: strings.TrimSpace(payment.QuotationRef),
|
||||
ClientPaymentRef: strings.TrimSpace(payment.ClientPaymentRef),
|
||||
IntentSnapshot: payment.IntentSnapshot,
|
||||
QuoteSnapshot: payment.QuoteSnapshot,
|
||||
State: payment.State,
|
||||
Version: payment.Version,
|
||||
StepExecutions: cloneStepExecutions(payment.StepExecutions),
|
||||
}
|
||||
return cloneDocument(doc)
|
||||
}
|
||||
|
||||
func fromDocument(doc *paymentDocument) (*agg.Payment, error) {
|
||||
if doc == nil {
|
||||
return nil, nil
|
||||
}
|
||||
cloned, err := cloneDocument(doc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &agg.Payment{
|
||||
Base: cloned.Base,
|
||||
OrganizationBoundBase: cloned.OrganizationBoundBase,
|
||||
PaymentRef: cloned.PaymentRef,
|
||||
IdempotencyKey: cloned.IdempotencyKey,
|
||||
QuotationRef: cloned.QuotationRef,
|
||||
ClientPaymentRef: cloned.ClientPaymentRef,
|
||||
IntentSnapshot: cloned.IntentSnapshot,
|
||||
QuoteSnapshot: cloned.QuoteSnapshot,
|
||||
State: cloned.State,
|
||||
Version: cloned.Version,
|
||||
StepExecutions: cloneStepExecutions(cloned.StepExecutions),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func cloneDocument(doc *paymentDocument) (*paymentDocument, error) {
|
||||
if doc == nil {
|
||||
return nil, nil
|
||||
}
|
||||
data, err := bson.Marshal(doc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := &paymentDocument{}
|
||||
if err := bson.Unmarshal(data, out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func cloneStepExecutions(src []agg.StepExecution) []agg.StepExecution {
|
||||
if len(src) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]agg.StepExecution, 0, len(src))
|
||||
for i := range src {
|
||||
step := src[i]
|
||||
step.StepRef = strings.TrimSpace(step.StepRef)
|
||||
step.StepCode = strings.TrimSpace(step.StepCode)
|
||||
step.FailureCode = strings.TrimSpace(step.FailureCode)
|
||||
step.FailureMsg = strings.TrimSpace(step.FailureMsg)
|
||||
if step.Attempt == 0 {
|
||||
step.Attempt = 1
|
||||
}
|
||||
step.ExternalRefs = cloneExternalRefs(step.ExternalRefs)
|
||||
step.StartedAt = cloneTime(step.StartedAt)
|
||||
step.CompletedAt = cloneTime(step.CompletedAt)
|
||||
out = append(out, step)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneExternalRefs(refs []agg.ExternalRef) []agg.ExternalRef {
|
||||
if len(refs) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]agg.ExternalRef, 0, len(refs))
|
||||
for i := range refs {
|
||||
ref := refs[i]
|
||||
ref.GatewayInstanceID = strings.TrimSpace(ref.GatewayInstanceID)
|
||||
ref.Kind = strings.TrimSpace(ref.Kind)
|
||||
ref.Ref = strings.TrimSpace(ref.Ref)
|
||||
out = append(out, ref)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneTime(ts *time.Time) *time.Time {
|
||||
if ts == nil {
|
||||
return nil
|
||||
}
|
||||
val := ts.UTC()
|
||||
return &val
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package prepo
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrPaymentNotFound = errors.New("payment repository v2: payment not found")
|
||||
ErrDuplicatePayment = errors.New("payment repository v2: duplicate payment")
|
||||
ErrVersionConflict = errors.New("payment repository v2: version conflict")
|
||||
)
|
||||
@@ -0,0 +1,40 @@
|
||||
package prepo
|
||||
|
||||
import (
|
||||
ri "github.com/tech/sendico/pkg/db/repository/index"
|
||||
)
|
||||
|
||||
type indexDefinition = ri.Definition
|
||||
|
||||
func requiredIndexes() []*indexDefinition {
|
||||
return []*indexDefinition{
|
||||
{
|
||||
Keys: []ri.Key{
|
||||
{Field: "organizationRef", Sort: ri.Asc},
|
||||
{Field: "paymentRef", Sort: ri.Asc},
|
||||
},
|
||||
Unique: true,
|
||||
},
|
||||
{
|
||||
Keys: []ri.Key{
|
||||
{Field: "organizationRef", Sort: ri.Asc},
|
||||
{Field: "idempotencyKey", Sort: ri.Asc},
|
||||
},
|
||||
Unique: true,
|
||||
},
|
||||
{
|
||||
Keys: []ri.Key{
|
||||
{Field: "organizationRef", Sort: ri.Asc},
|
||||
{Field: "quotationRef", Sort: ri.Asc},
|
||||
{Field: "createdAt", Sort: ri.Desc},
|
||||
},
|
||||
},
|
||||
{
|
||||
Keys: []ri.Key{
|
||||
{Field: "organizationRef", Sort: ri.Asc},
|
||||
{Field: "state", Sort: ri.Asc},
|
||||
{Field: "createdAt", Sort: ri.Desc},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package prepo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
)
|
||||
|
||||
// Repository persists orchestration-v2 payment aggregates.
|
||||
type Repository interface {
|
||||
Create(ctx context.Context, payment *agg.Payment) error
|
||||
UpdateCAS(ctx context.Context, payment *agg.Payment, expectedVersion uint64) error
|
||||
GetByPaymentRef(ctx context.Context, orgRef bson.ObjectID, paymentRef string) (*agg.Payment, error)
|
||||
GetByIdempotencyKey(ctx context.Context, orgRef bson.ObjectID, idempotencyKey string) (*agg.Payment, error)
|
||||
ListByQuotationRef(ctx context.Context, in ListByQuotationRefInput) (*ListOutput, error)
|
||||
ListByState(ctx context.Context, in ListByStateInput) (*ListOutput, error)
|
||||
}
|
||||
|
||||
// ListCursor is a stable pagination cursor sorted by created_at desc then id desc.
|
||||
type ListCursor struct {
|
||||
CreatedAt time.Time
|
||||
ID bson.ObjectID
|
||||
}
|
||||
|
||||
// ListOutput is a page of payment aggregates.
|
||||
type ListOutput struct {
|
||||
Items []*agg.Payment
|
||||
NextCursor *ListCursor
|
||||
}
|
||||
|
||||
// ListByQuotationRefInput defines listing scope by quotation_ref.
|
||||
type ListByQuotationRefInput struct {
|
||||
OrganizationRef bson.ObjectID
|
||||
QuotationRef string
|
||||
Limit int32
|
||||
Cursor *ListCursor
|
||||
}
|
||||
|
||||
// ListByStateInput defines listing scope by aggregate state.
|
||||
type ListByStateInput struct {
|
||||
OrganizationRef bson.ObjectID
|
||||
State agg.State
|
||||
Limit int32
|
||||
Cursor *ListCursor
|
||||
}
|
||||
|
||||
// Dependencies configures repository integrations.
|
||||
type Dependencies struct {
|
||||
Logger mlogger.Logger
|
||||
}
|
||||
|
||||
// NewMongo constructs a Mongo-backed payment repository-v2.
|
||||
func NewMongo(collection *mongo.Collection, deps ...Dependencies) (Repository, error) {
|
||||
var dep Dependencies
|
||||
if len(deps) > 0 {
|
||||
dep = deps[0]
|
||||
}
|
||||
return newWithStoreLogger(newMongoStore(collection), dep.Logger)
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package prepo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
type mongoStore struct {
|
||||
collection *mongo.Collection
|
||||
}
|
||||
|
||||
func newMongoStore(collection *mongo.Collection) paymentStore {
|
||||
return &mongoStore{
|
||||
collection: collection,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *mongoStore) EnsureIndexes(defs []*indexDefinition) error {
|
||||
if s.collection == nil {
|
||||
return merrors.InvalidArgument("payment repository v2: mongo collection is required")
|
||||
}
|
||||
if len(defs) == 0 {
|
||||
return nil
|
||||
}
|
||||
models := make([]mongo.IndexModel, 0, len(defs))
|
||||
for i := range defs {
|
||||
def := defs[i]
|
||||
if def == nil || len(def.Keys) == 0 {
|
||||
continue
|
||||
}
|
||||
keys := bson.D{}
|
||||
for j := range def.Keys {
|
||||
key := def.Keys[j]
|
||||
name := strings.TrimSpace(key.Field)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
switch key.Type {
|
||||
case "":
|
||||
keys = append(keys, bson.E{Key: name, Value: int32(key.Sort)})
|
||||
default:
|
||||
keys = append(keys, bson.E{Key: name, Value: string(key.Type)})
|
||||
}
|
||||
}
|
||||
if len(keys) == 0 {
|
||||
continue
|
||||
}
|
||||
opt := options.Index()
|
||||
if def.Name != "" {
|
||||
opt.SetName(def.Name)
|
||||
}
|
||||
if def.Unique {
|
||||
opt.SetUnique(true)
|
||||
}
|
||||
if def.Sparse {
|
||||
opt.SetSparse(true)
|
||||
}
|
||||
if def.TTL != nil {
|
||||
opt.SetExpireAfterSeconds(int32(*def.TTL))
|
||||
}
|
||||
if def.PartialFilter != nil {
|
||||
opt.SetPartialFilterExpression(def.PartialFilter.BuildQuery())
|
||||
}
|
||||
models = append(models, mongo.IndexModel{
|
||||
Keys: keys,
|
||||
Options: opt,
|
||||
})
|
||||
}
|
||||
if len(models) == 0 {
|
||||
return nil
|
||||
}
|
||||
_, err := s.collection.Indexes().CreateMany(context.Background(), models)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *mongoStore) Create(ctx context.Context, doc *paymentDocument) error {
|
||||
if s.collection == nil {
|
||||
return merrors.InvalidArgument("payment repository v2: mongo collection is required")
|
||||
}
|
||||
if doc == nil {
|
||||
return merrors.InvalidArgument("payment repository v2: payment document is required")
|
||||
}
|
||||
_, err := s.collection.InsertOne(ctx, doc)
|
||||
if mongo.IsDuplicateKeyError(err) {
|
||||
return ErrDuplicatePayment
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *mongoStore) UpdateCAS(ctx context.Context, doc *paymentDocument, expectedVersion uint64) (bool, error) {
|
||||
if s.collection == nil {
|
||||
return false, merrors.InvalidArgument("payment repository v2: mongo collection is required")
|
||||
}
|
||||
if doc == nil {
|
||||
return false, merrors.InvalidArgument("payment repository v2: payment document is required")
|
||||
}
|
||||
filter := bson.D{
|
||||
{Key: "_id", Value: doc.ID},
|
||||
{Key: "organizationRef", Value: doc.OrganizationRef},
|
||||
{Key: "version", Value: expectedVersion},
|
||||
}
|
||||
result, err := s.collection.ReplaceOne(ctx, filter, doc)
|
||||
if mongo.IsDuplicateKeyError(err) {
|
||||
return false, ErrDuplicatePayment
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return result.MatchedCount > 0, nil
|
||||
}
|
||||
|
||||
func (s *mongoStore) GetByPaymentRef(ctx context.Context, orgRef bson.ObjectID, paymentRef string) (*paymentDocument, error) {
|
||||
return s.findOne(ctx, bson.D{
|
||||
{Key: "organizationRef", Value: orgRef},
|
||||
{Key: "paymentRef", Value: paymentRef},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *mongoStore) GetByIdempotencyKey(ctx context.Context, orgRef bson.ObjectID, idempotencyKey string) (*paymentDocument, error) {
|
||||
return s.findOne(ctx, bson.D{
|
||||
{Key: "organizationRef", Value: orgRef},
|
||||
{Key: "idempotencyKey", Value: idempotencyKey},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *mongoStore) GetByID(ctx context.Context, orgRef bson.ObjectID, id bson.ObjectID) (*paymentDocument, error) {
|
||||
return s.findOne(ctx, bson.D{
|
||||
{Key: "_id", Value: id},
|
||||
{Key: "organizationRef", Value: orgRef},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *mongoStore) ListByQuotationRef(ctx context.Context, orgRef bson.ObjectID, quotationRef string, cursor *listCursor, limit int64) ([]*paymentDocument, error) {
|
||||
filter := bson.D{
|
||||
{Key: "organizationRef", Value: orgRef},
|
||||
{Key: "quotationRef", Value: quotationRef},
|
||||
}
|
||||
return s.list(ctx, filter, cursor, limit)
|
||||
}
|
||||
|
||||
func (s *mongoStore) ListByState(ctx context.Context, orgRef bson.ObjectID, state agg.State, cursor *listCursor, limit int64) ([]*paymentDocument, error) {
|
||||
filter := bson.D{
|
||||
{Key: "organizationRef", Value: orgRef},
|
||||
{Key: "state", Value: state},
|
||||
}
|
||||
return s.list(ctx, filter, cursor, limit)
|
||||
}
|
||||
|
||||
func (s *mongoStore) findOne(ctx context.Context, filter bson.D) (*paymentDocument, error) {
|
||||
if s.collection == nil {
|
||||
return nil, merrors.InvalidArgument("payment repository v2: mongo collection is required")
|
||||
}
|
||||
doc := &paymentDocument{}
|
||||
err := s.collection.FindOne(ctx, filter).Decode(doc)
|
||||
if err == nil {
|
||||
return doc, nil
|
||||
}
|
||||
if err == mongo.ErrNoDocuments {
|
||||
return nil, ErrPaymentNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func (s *mongoStore) list(ctx context.Context, filter bson.D, cursor *listCursor, limit int64) ([]*paymentDocument, error) {
|
||||
if s.collection == nil {
|
||||
return nil, merrors.InvalidArgument("payment repository v2: mongo collection is required")
|
||||
}
|
||||
if cursor != nil {
|
||||
filter = append(filter, bson.E{
|
||||
Key: "$or",
|
||||
Value: bson.A{
|
||||
bson.D{{Key: "createdAt", Value: bson.D{{Key: "$lt", Value: cursor.CreatedAt}}}},
|
||||
bson.D{
|
||||
{Key: "createdAt", Value: cursor.CreatedAt},
|
||||
{Key: "_id", Value: bson.D{{Key: "$lt", Value: cursor.ID}}},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
opt := options.Find().
|
||||
SetSort(bson.D{
|
||||
{Key: "createdAt", Value: -1},
|
||||
{Key: "_id", Value: -1},
|
||||
}).
|
||||
SetLimit(limit)
|
||||
cur, err := s.collection.Find(ctx, filter, opt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cur.Close(ctx)
|
||||
|
||||
items := make([]*paymentDocument, 0)
|
||||
for cur.Next(ctx) {
|
||||
doc := &paymentDocument{}
|
||||
if err := cur.Decode(doc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, doc)
|
||||
}
|
||||
if err := cur.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
var _ paymentStore = (*mongoStore)(nil)
|
||||
@@ -0,0 +1,548 @@
|
||||
package prepo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultListLimit int64 = 50
|
||||
maxListLimit int64 = 200
|
||||
)
|
||||
|
||||
type listCursor struct {
|
||||
CreatedAt time.Time
|
||||
ID bson.ObjectID
|
||||
}
|
||||
|
||||
type paymentStore interface {
|
||||
EnsureIndexes(defs []*indexDefinition) error
|
||||
Create(ctx context.Context, doc *paymentDocument) error
|
||||
UpdateCAS(ctx context.Context, doc *paymentDocument, expectedVersion uint64) (bool, error)
|
||||
GetByPaymentRef(ctx context.Context, orgRef bson.ObjectID, paymentRef string) (*paymentDocument, error)
|
||||
GetByIdempotencyKey(ctx context.Context, orgRef bson.ObjectID, idempotencyKey string) (*paymentDocument, error)
|
||||
GetByID(ctx context.Context, orgRef bson.ObjectID, id bson.ObjectID) (*paymentDocument, error)
|
||||
ListByQuotationRef(ctx context.Context, orgRef bson.ObjectID, quotationRef string, cursor *listCursor, limit int64) ([]*paymentDocument, error)
|
||||
ListByState(ctx context.Context, orgRef bson.ObjectID, state agg.State, cursor *listCursor, limit int64) ([]*paymentDocument, error)
|
||||
}
|
||||
|
||||
type svc struct {
|
||||
logger mlogger.Logger
|
||||
store paymentStore
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func newWithStore(store paymentStore) (Repository, error) {
|
||||
return newWithStoreLogger(store, nil)
|
||||
}
|
||||
|
||||
func newWithStoreLogger(store paymentStore, logger mlogger.Logger) (Repository, error) {
|
||||
if store == nil {
|
||||
return nil, merrors.InvalidArgument("payment repository v2: store is required")
|
||||
}
|
||||
if err := store.EnsureIndexes(requiredIndexes()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &svc{
|
||||
logger: logger.Named("prepo"),
|
||||
store: store,
|
||||
now: func() time.Time {
|
||||
return time.Now().UTC()
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *svc) Create(ctx context.Context, payment *agg.Payment) (err error) {
|
||||
logger := s.logger
|
||||
paymentRef := ""
|
||||
if payment != nil {
|
||||
paymentRef = strings.TrimSpace(payment.PaymentRef)
|
||||
}
|
||||
logger.Debug("Starting Create", zap.String("payment_ref", paymentRef))
|
||||
defer func(start time.Time) {
|
||||
fields := []zap.Field{
|
||||
zap.Int64("duration_ms", time.Since(start).Milliseconds()),
|
||||
zap.String("payment_ref", paymentRef),
|
||||
}
|
||||
if payment != nil {
|
||||
fields = append(fields,
|
||||
zap.String("state", string(payment.State)),
|
||||
zap.Uint64("version", payment.Version),
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
logger.Warn("Failed to create", append(fields, zap.Error(err))...)
|
||||
return
|
||||
}
|
||||
logger.Debug("Completed Create", fields...)
|
||||
}(time.Now())
|
||||
|
||||
doc, err := prepareCreate(payment, s.now().UTC())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.store.Create(ctx, doc); err != nil {
|
||||
if isDuplicate(err) {
|
||||
return ErrDuplicatePayment
|
||||
}
|
||||
return err
|
||||
}
|
||||
out, err := fromDocument(doc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*payment = *out
|
||||
paymentRef = strings.TrimSpace(payment.PaymentRef)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *svc) UpdateCAS(ctx context.Context, payment *agg.Payment, expectedVersion uint64) (err error) {
|
||||
logger := s.logger
|
||||
paymentRef := ""
|
||||
if payment != nil {
|
||||
paymentRef = strings.TrimSpace(payment.PaymentRef)
|
||||
}
|
||||
logger.Debug("Starting Update cas",
|
||||
zap.String("payment_ref", paymentRef),
|
||||
zap.Uint64("expected_version", expectedVersion),
|
||||
)
|
||||
defer func(start time.Time) {
|
||||
fields := []zap.Field{
|
||||
zap.Int64("duration_ms", time.Since(start).Milliseconds()),
|
||||
zap.String("payment_ref", paymentRef),
|
||||
zap.Uint64("expected_version", expectedVersion),
|
||||
}
|
||||
if payment != nil {
|
||||
fields = append(fields,
|
||||
zap.String("state", string(payment.State)),
|
||||
zap.Uint64("version", payment.Version),
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
logger.Warn("Failed to update cas", append(fields, zap.Error(err))...)
|
||||
return
|
||||
}
|
||||
logger.Debug("Completed Update cas", fields...)
|
||||
}(time.Now())
|
||||
|
||||
doc, err := prepareUpdate(payment, expectedVersion, s.now().UTC())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
updated, err := s.store.UpdateCAS(ctx, doc, expectedVersion)
|
||||
if err != nil {
|
||||
if isDuplicate(err) {
|
||||
return ErrDuplicatePayment
|
||||
}
|
||||
return err
|
||||
}
|
||||
if !updated {
|
||||
if _, findErr := s.store.GetByID(ctx, doc.OrganizationRef, doc.ID); findErr != nil {
|
||||
if errors.Is(findErr, ErrPaymentNotFound) {
|
||||
return ErrPaymentNotFound
|
||||
}
|
||||
return findErr
|
||||
}
|
||||
return ErrVersionConflict
|
||||
}
|
||||
|
||||
out, err := fromDocument(doc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*payment = *out
|
||||
paymentRef = strings.TrimSpace(payment.PaymentRef)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *svc) GetByPaymentRef(ctx context.Context, orgRef bson.ObjectID, paymentRef string) (payment *agg.Payment, err error) {
|
||||
logger := s.logger
|
||||
requestPaymentRef := strings.TrimSpace(paymentRef)
|
||||
logger.Debug("Starting Get by payment ref",
|
||||
zap.String("organization_ref", orgRef.Hex()),
|
||||
zap.String("payment_ref", requestPaymentRef),
|
||||
)
|
||||
defer func(start time.Time) {
|
||||
fields := []zap.Field{
|
||||
zap.Int64("duration_ms", time.Since(start).Milliseconds()),
|
||||
zap.String("organization_ref", orgRef.Hex()),
|
||||
zap.String("payment_ref", requestPaymentRef),
|
||||
}
|
||||
if payment != nil {
|
||||
fields = append(fields,
|
||||
zap.String("state", string(payment.State)),
|
||||
zap.Uint64("version", payment.Version),
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
logger.Warn("Failed to get by payment ref", append(fields, zap.Error(err))...)
|
||||
return
|
||||
}
|
||||
logger.Debug("Completed Get by payment ref", fields...)
|
||||
}(time.Now())
|
||||
|
||||
if orgRef.IsZero() {
|
||||
return nil, merrors.InvalidArgument("organization_ref is required")
|
||||
}
|
||||
paymentRef = strings.TrimSpace(paymentRef)
|
||||
if paymentRef == "" {
|
||||
return nil, merrors.InvalidArgument("payment_ref is required")
|
||||
}
|
||||
doc, err := s.store.GetByPaymentRef(ctx, orgRef, paymentRef)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payment, err = fromDocument(doc)
|
||||
return payment, err
|
||||
}
|
||||
|
||||
func (s *svc) GetByIdempotencyKey(ctx context.Context, orgRef bson.ObjectID, idempotencyKey string) (payment *agg.Payment, err error) {
|
||||
logger := s.logger
|
||||
hasKey := strings.TrimSpace(idempotencyKey) != ""
|
||||
logger.Debug("Starting Get by idempotency key",
|
||||
zap.String("organization_ref", orgRef.Hex()),
|
||||
zap.Bool("has_idempotency_key", hasKey),
|
||||
)
|
||||
defer func(start time.Time) {
|
||||
fields := []zap.Field{
|
||||
zap.Int64("duration_ms", time.Since(start).Milliseconds()),
|
||||
zap.String("organization_ref", orgRef.Hex()),
|
||||
zap.Bool("has_idempotency_key", hasKey),
|
||||
}
|
||||
if payment != nil {
|
||||
fields = append(fields,
|
||||
zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef)),
|
||||
zap.String("state", string(payment.State)),
|
||||
zap.Uint64("version", payment.Version),
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
logger.Warn("Failed to get by idempotency key", append(fields, zap.Error(err))...)
|
||||
return
|
||||
}
|
||||
logger.Debug("Completed Get by idempotency key", fields...)
|
||||
}(time.Now())
|
||||
|
||||
if orgRef.IsZero() {
|
||||
return nil, merrors.InvalidArgument("organization_ref is required")
|
||||
}
|
||||
idempotencyKey = strings.TrimSpace(idempotencyKey)
|
||||
if idempotencyKey == "" {
|
||||
return nil, merrors.InvalidArgument("idempotency_key is required")
|
||||
}
|
||||
doc, err := s.store.GetByIdempotencyKey(ctx, orgRef, idempotencyKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payment, err = fromDocument(doc)
|
||||
return payment, err
|
||||
}
|
||||
|
||||
func (s *svc) ListByQuotationRef(ctx context.Context, in ListByQuotationRefInput) (out *ListOutput, err error) {
|
||||
logger := s.logger
|
||||
logger.Debug("Starting List by quotation ref",
|
||||
zap.String("organization_ref", in.OrganizationRef.Hex()),
|
||||
zap.String("quotation_ref", strings.TrimSpace(in.QuotationRef)),
|
||||
zap.Int32("limit", in.Limit),
|
||||
)
|
||||
defer func(start time.Time) {
|
||||
fields := []zap.Field{zap.Int64("duration_ms", time.Since(start).Milliseconds())}
|
||||
if out != nil {
|
||||
fields = append(fields, zap.Int("items_count", len(out.Items)))
|
||||
}
|
||||
if err != nil {
|
||||
logger.Warn("Failed to list by quotation ref", append(fields, zap.Error(err))...)
|
||||
return
|
||||
}
|
||||
logger.Debug("Completed List by quotation ref", fields...)
|
||||
}(time.Now())
|
||||
|
||||
if in.OrganizationRef.IsZero() {
|
||||
return nil, merrors.InvalidArgument("organization_ref is required")
|
||||
}
|
||||
in.QuotationRef = strings.TrimSpace(in.QuotationRef)
|
||||
if in.QuotationRef == "" {
|
||||
return nil, merrors.InvalidArgument("quotation_ref is required")
|
||||
}
|
||||
cursor, err := normalizeCursor(in.Cursor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out, err = s.list(ctx, listQuery{
|
||||
limit: sanitizeLimit(in.Limit),
|
||||
run: func(limit int64) ([]*paymentDocument, error) {
|
||||
return s.store.ListByQuotationRef(ctx, in.OrganizationRef, in.QuotationRef, cursor, limit)
|
||||
},
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (s *svc) ListByState(ctx context.Context, in ListByStateInput) (out *ListOutput, err error) {
|
||||
logger := s.logger
|
||||
logger.Debug("Starting List by state",
|
||||
zap.String("organization_ref", in.OrganizationRef.Hex()),
|
||||
zap.String("state", string(in.State)),
|
||||
zap.Int32("limit", in.Limit),
|
||||
)
|
||||
defer func(start time.Time) {
|
||||
fields := []zap.Field{zap.Int64("duration_ms", time.Since(start).Milliseconds())}
|
||||
if out != nil {
|
||||
fields = append(fields, zap.Int("items_count", len(out.Items)))
|
||||
}
|
||||
if err != nil {
|
||||
logger.Warn("Failed to list by state", append(fields, zap.Error(err))...)
|
||||
return
|
||||
}
|
||||
logger.Debug("Completed List by state", fields...)
|
||||
}(time.Now())
|
||||
|
||||
if in.OrganizationRef.IsZero() {
|
||||
return nil, merrors.InvalidArgument("organization_ref is required")
|
||||
}
|
||||
state, ok := normalizeAggregateState(in.State)
|
||||
if !ok {
|
||||
return nil, merrors.InvalidArgument("state is invalid")
|
||||
}
|
||||
cursor, err := normalizeCursor(in.Cursor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out, err = s.list(ctx, listQuery{
|
||||
limit: sanitizeLimit(in.Limit),
|
||||
run: func(limit int64) ([]*paymentDocument, error) {
|
||||
return s.store.ListByState(ctx, in.OrganizationRef, state, cursor, limit)
|
||||
},
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
type listQuery struct {
|
||||
limit int64
|
||||
run func(limit int64) ([]*paymentDocument, error)
|
||||
}
|
||||
|
||||
func (s *svc) list(_ context.Context, query listQuery) (*ListOutput, error) {
|
||||
fetchLimit := query.limit + 1
|
||||
docs, err := query.run(fetchLimit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(docs) == 0 {
|
||||
return &ListOutput{}, nil
|
||||
}
|
||||
|
||||
nextCursor := (*ListCursor)(nil)
|
||||
if int64(len(docs)) == fetchLimit {
|
||||
docs = docs[:len(docs)-1]
|
||||
last := docs[len(docs)-1]
|
||||
nextCursor = &ListCursor{
|
||||
CreatedAt: last.CreatedAt.UTC(),
|
||||
ID: last.ID,
|
||||
}
|
||||
}
|
||||
|
||||
items := make([]*agg.Payment, 0, len(docs))
|
||||
for i := range docs {
|
||||
entity, convErr := fromDocument(docs[i])
|
||||
if convErr != nil {
|
||||
return nil, convErr
|
||||
}
|
||||
items = append(items, entity)
|
||||
}
|
||||
return &ListOutput{
|
||||
Items: items,
|
||||
NextCursor: nextCursor,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func prepareCreate(payment *agg.Payment, now time.Time) (*paymentDocument, error) {
|
||||
doc, err := normalizePayment(payment, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if doc.ID.IsZero() {
|
||||
doc.ID = bson.NewObjectID()
|
||||
}
|
||||
if doc.PaymentRef == "" {
|
||||
doc.PaymentRef = doc.ID.Hex()
|
||||
}
|
||||
if doc.CreatedAt.IsZero() {
|
||||
doc.CreatedAt = now
|
||||
}
|
||||
doc.UpdatedAt = now
|
||||
if doc.Version == 0 {
|
||||
doc.Version = 1
|
||||
}
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
func prepareUpdate(payment *agg.Payment, expectedVersion uint64, now time.Time) (*paymentDocument, error) {
|
||||
if expectedVersion == 0 {
|
||||
return nil, merrors.InvalidArgument("expected_version is required")
|
||||
}
|
||||
doc, err := normalizePayment(payment, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if doc.ID.IsZero() {
|
||||
return nil, merrors.InvalidArgument("payment id is required")
|
||||
}
|
||||
if doc.CreatedAt.IsZero() {
|
||||
return nil, merrors.InvalidArgument("payment.created_at is required")
|
||||
}
|
||||
nextVersion := expectedVersion + 1
|
||||
if doc.Version != 0 && doc.Version != expectedVersion && doc.Version != nextVersion {
|
||||
return nil, merrors.InvalidArgument("payment.version must equal expected_version or expected_version + 1")
|
||||
}
|
||||
doc.Version = nextVersion
|
||||
doc.UpdatedAt = now
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
func normalizePayment(payment *agg.Payment, requirePaymentRef bool) (*paymentDocument, error) {
|
||||
doc, err := toDocument(payment)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if doc == nil {
|
||||
return nil, merrors.InvalidArgument("payment is required")
|
||||
}
|
||||
doc.PaymentRef = strings.TrimSpace(doc.PaymentRef)
|
||||
doc.IdempotencyKey = strings.TrimSpace(doc.IdempotencyKey)
|
||||
doc.QuotationRef = strings.TrimSpace(doc.QuotationRef)
|
||||
doc.ClientPaymentRef = strings.TrimSpace(doc.ClientPaymentRef)
|
||||
|
||||
if doc.OrganizationRef.IsZero() {
|
||||
return nil, merrors.InvalidArgument("organization_ref is required")
|
||||
}
|
||||
if requirePaymentRef && doc.PaymentRef == "" {
|
||||
return nil, merrors.InvalidArgument("payment_ref is required")
|
||||
}
|
||||
if doc.IdempotencyKey == "" {
|
||||
return nil, merrors.InvalidArgument("idempotency_key is required")
|
||||
}
|
||||
if doc.QuotationRef == "" {
|
||||
return nil, merrors.InvalidArgument("quotation_ref is required")
|
||||
}
|
||||
if doc.QuoteSnapshot == nil {
|
||||
return nil, merrors.InvalidArgument("quote_snapshot is required")
|
||||
}
|
||||
state, ok := normalizeAggregateState(doc.State)
|
||||
if !ok {
|
||||
return nil, merrors.InvalidArgument("state is invalid")
|
||||
}
|
||||
doc.State = state
|
||||
|
||||
for i := range doc.StepExecutions {
|
||||
step := &doc.StepExecutions[i]
|
||||
step.StepRef = strings.TrimSpace(step.StepRef)
|
||||
step.StepCode = strings.TrimSpace(step.StepCode)
|
||||
step.FailureCode = strings.TrimSpace(step.FailureCode)
|
||||
step.FailureMsg = strings.TrimSpace(step.FailureMsg)
|
||||
if step.StepRef == "" {
|
||||
return nil, merrors.InvalidArgument("step_executions[" + itoa(i) + "].step_ref is required")
|
||||
}
|
||||
if step.StepCode == "" {
|
||||
step.StepCode = step.StepRef
|
||||
}
|
||||
if step.Attempt == 0 {
|
||||
step.Attempt = 1
|
||||
}
|
||||
ss, ok := normalizeStepState(step.State)
|
||||
if !ok {
|
||||
return nil, merrors.InvalidArgument("step_executions[" + itoa(i) + "].state is invalid")
|
||||
}
|
||||
step.State = ss
|
||||
}
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
func normalizeAggregateState(state agg.State) (agg.State, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(string(state))) {
|
||||
case string(agg.StateCreated):
|
||||
return agg.StateCreated, true
|
||||
case string(agg.StateExecuting):
|
||||
return agg.StateExecuting, true
|
||||
case string(agg.StateNeedsAttention):
|
||||
return agg.StateNeedsAttention, true
|
||||
case string(agg.StateSettled):
|
||||
return agg.StateSettled, true
|
||||
case string(agg.StateFailed):
|
||||
return agg.StateFailed, true
|
||||
default:
|
||||
return agg.StateUnspecified, false
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeStepState(state agg.StepState) (agg.StepState, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(string(state))) {
|
||||
case string(agg.StepStatePending):
|
||||
return agg.StepStatePending, true
|
||||
case string(agg.StepStateRunning):
|
||||
return agg.StepStateRunning, true
|
||||
case string(agg.StepStateCompleted):
|
||||
return agg.StepStateCompleted, true
|
||||
case string(agg.StepStateFailed):
|
||||
return agg.StepStateFailed, true
|
||||
case string(agg.StepStateNeedsAttention):
|
||||
return agg.StepStateNeedsAttention, true
|
||||
case string(agg.StepStateSkipped):
|
||||
return agg.StepStateSkipped, true
|
||||
default:
|
||||
return agg.StepStateUnspecified, false
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeCursor(cursor *ListCursor) (*listCursor, error) {
|
||||
if cursor == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if cursor.ID.IsZero() {
|
||||
return nil, merrors.InvalidArgument("cursor.id is required")
|
||||
}
|
||||
if cursor.CreatedAt.IsZero() {
|
||||
return nil, merrors.InvalidArgument("cursor.created_at is required")
|
||||
}
|
||||
return &listCursor{
|
||||
CreatedAt: cursor.CreatedAt.UTC(),
|
||||
ID: cursor.ID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func sanitizeLimit(limit int32) int64 {
|
||||
if limit <= 0 {
|
||||
return defaultListLimit
|
||||
}
|
||||
if limit > int32(maxListLimit) {
|
||||
return maxListLimit
|
||||
}
|
||||
return int64(limit)
|
||||
}
|
||||
|
||||
func isDuplicate(err error) bool {
|
||||
return errors.Is(err, ErrDuplicatePayment)
|
||||
}
|
||||
|
||||
func itoa(v int) string {
|
||||
if v == 0 {
|
||||
return "0"
|
||||
}
|
||||
var buf [20]byte
|
||||
i := len(buf)
|
||||
for v > 0 {
|
||||
i--
|
||||
buf[i] = byte('0' + v%10)
|
||||
v /= 10
|
||||
}
|
||||
return string(buf[i:])
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
package prepo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"sort"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/db/storable"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
pm "github.com/tech/sendico/pkg/model"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
func TestNewWithStore_EnsuresRequiredIndexes(t *testing.T) {
|
||||
store := newFakeStore()
|
||||
_, err := newWithStore(store)
|
||||
if err != nil {
|
||||
t.Fatalf("newWithStore returned error: %v", err)
|
||||
}
|
||||
|
||||
if len(store.indexes) != 4 {
|
||||
t.Fatalf("index count mismatch: got=%d want=4", len(store.indexes))
|
||||
}
|
||||
|
||||
assertIndex(t, store.indexes[0], []string{"organizationRef", "paymentRef"}, true)
|
||||
assertIndex(t, store.indexes[1], []string{"organizationRef", "idempotencyKey"}, true)
|
||||
assertIndex(t, store.indexes[2], []string{"organizationRef", "quotationRef", "createdAt"}, false)
|
||||
assertIndex(t, store.indexes[3], []string{"organizationRef", "state", "createdAt"}, false)
|
||||
}
|
||||
|
||||
func TestCreateAndGet(t *testing.T) {
|
||||
now := time.Date(2026, time.January, 12, 10, 0, 0, 0, time.UTC)
|
||||
store := newFakeStore()
|
||||
repo, err := newWithStore(store)
|
||||
if err != nil {
|
||||
t.Fatalf("newWithStore returned error: %v", err)
|
||||
}
|
||||
repo.(*svc).now = func() time.Time { return now }
|
||||
|
||||
org := bson.NewObjectID()
|
||||
payment := &agg.Payment{
|
||||
OrganizationBoundBase: modelOrg(org),
|
||||
IdempotencyKey: "idem-1",
|
||||
QuotationRef: "quote-1",
|
||||
State: agg.StateCreated,
|
||||
IntentSnapshot: model.PaymentIntent{Kind: model.PaymentKindPayout, Amount: testMoney()},
|
||||
QuoteSnapshot: &model.PaymentQuoteSnapshot{QuoteRef: "quote-1"},
|
||||
StepExecutions: []agg.StepExecution{
|
||||
{StepRef: "s1", StepCode: "step-1", State: agg.StepStatePending, Attempt: 1},
|
||||
},
|
||||
}
|
||||
|
||||
if err := repo.Create(context.Background(), payment); err != nil {
|
||||
t.Fatalf("Create returned error: %v", err)
|
||||
}
|
||||
if payment.ID.IsZero() {
|
||||
t.Fatal("expected generated id")
|
||||
}
|
||||
if payment.PaymentRef == "" {
|
||||
t.Fatal("expected generated payment_ref")
|
||||
}
|
||||
if payment.Version != 1 {
|
||||
t.Fatalf("version mismatch: got=%d want=1", payment.Version)
|
||||
}
|
||||
if !payment.CreatedAt.Equal(now) || !payment.UpdatedAt.Equal(now) {
|
||||
t.Fatalf("timestamps mismatch: created=%v updated=%v", payment.CreatedAt, payment.UpdatedAt)
|
||||
}
|
||||
|
||||
gotByRef, err := repo.GetByPaymentRef(context.Background(), org, payment.PaymentRef)
|
||||
if err != nil {
|
||||
t.Fatalf("GetByPaymentRef returned error: %v", err)
|
||||
}
|
||||
if gotByRef.PaymentRef != payment.PaymentRef {
|
||||
t.Fatalf("payment_ref mismatch: got=%q want=%q", gotByRef.PaymentRef, payment.PaymentRef)
|
||||
}
|
||||
|
||||
gotByIdem, err := repo.GetByIdempotencyKey(context.Background(), org, payment.IdempotencyKey)
|
||||
if err != nil {
|
||||
t.Fatalf("GetByIdempotencyKey returned error: %v", err)
|
||||
}
|
||||
if gotByIdem.PaymentRef != payment.PaymentRef {
|
||||
t.Fatalf("idempotency lookup mismatch: got=%q want=%q", gotByIdem.PaymentRef, payment.PaymentRef)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreate_Duplicate(t *testing.T) {
|
||||
store := newFakeStore()
|
||||
repo, err := newWithStore(store)
|
||||
if err != nil {
|
||||
t.Fatalf("newWithStore returned error: %v", err)
|
||||
}
|
||||
|
||||
org := bson.NewObjectID()
|
||||
first := newPaymentFixture(org, "idem-1", "quote-1", "pay-1", agg.StateCreated, time.Now())
|
||||
second := newPaymentFixture(org, "idem-1", "quote-1", "pay-2", agg.StateCreated, time.Now())
|
||||
|
||||
if err := repo.Create(context.Background(), first); err != nil {
|
||||
t.Fatalf("Create(first) returned error: %v", err)
|
||||
}
|
||||
err = repo.Create(context.Background(), second)
|
||||
if !errors.Is(err, ErrDuplicatePayment) {
|
||||
t.Fatalf("expected ErrDuplicatePayment, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateCAS(t *testing.T) {
|
||||
now := time.Date(2026, time.January, 12, 10, 0, 0, 0, time.UTC)
|
||||
later := now.Add(2 * time.Minute)
|
||||
|
||||
store := newFakeStore()
|
||||
repoIface, err := newWithStore(store)
|
||||
if err != nil {
|
||||
t.Fatalf("newWithStore returned error: %v", err)
|
||||
}
|
||||
repo := repoIface.(*svc)
|
||||
repo.now = func() time.Time { return now }
|
||||
|
||||
org := bson.NewObjectID()
|
||||
payment := newPaymentFixture(org, "idem-1", "quote-1", "pay-1", agg.StateExecuting, now)
|
||||
if err := repo.Create(context.Background(), payment); err != nil {
|
||||
t.Fatalf("Create returned error: %v", err)
|
||||
}
|
||||
|
||||
repo.now = func() time.Time { return later }
|
||||
payment.State = agg.StateNeedsAttention
|
||||
payment.StepExecutions[0].State = agg.StepStateNeedsAttention
|
||||
if err := repo.UpdateCAS(context.Background(), payment, 1); err != nil {
|
||||
t.Fatalf("UpdateCAS returned error: %v", err)
|
||||
}
|
||||
if payment.Version != 2 {
|
||||
t.Fatalf("version mismatch: got=%d want=2", payment.Version)
|
||||
}
|
||||
if !payment.UpdatedAt.Equal(later) {
|
||||
t.Fatalf("updated_at mismatch: got=%v want=%v", payment.UpdatedAt, later)
|
||||
}
|
||||
|
||||
// stale version update
|
||||
payment.State = agg.StateExecuting
|
||||
err = repo.UpdateCAS(context.Background(), payment, 1)
|
||||
if !errors.Is(err, ErrVersionConflict) {
|
||||
t.Fatalf("expected ErrVersionConflict, got %v", err)
|
||||
}
|
||||
|
||||
// missing payment update
|
||||
missing := newPaymentFixture(org, "idem-x", "quote-x", "pay-x", agg.StateExecuting, now)
|
||||
missing.ID = bson.NewObjectID()
|
||||
missing.Version = 2
|
||||
err = repo.UpdateCAS(context.Background(), missing, 1)
|
||||
if !errors.Is(err, ErrPaymentNotFound) {
|
||||
t.Fatalf("expected ErrPaymentNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListByQuotationRefAndState(t *testing.T) {
|
||||
store := newFakeStore()
|
||||
repo, err := newWithStore(store)
|
||||
if err != nil {
|
||||
t.Fatalf("newWithStore returned error: %v", err)
|
||||
}
|
||||
|
||||
org := bson.NewObjectID()
|
||||
base := time.Date(2026, time.January, 12, 10, 0, 0, 0, time.UTC)
|
||||
|
||||
p1 := newPaymentFixture(org, "idem-1", "quote-1", "pay-1", agg.StateExecuting, base.Add(3*time.Minute))
|
||||
p2 := newPaymentFixture(org, "idem-2", "quote-1", "pay-2", agg.StateExecuting, base.Add(2*time.Minute))
|
||||
p3 := newPaymentFixture(org, "idem-3", "quote-1", "pay-3", agg.StateSettled, base.Add(1*time.Minute))
|
||||
p4 := newPaymentFixture(org, "idem-4", "quote-2", "pay-4", agg.StateExecuting, base.Add(4*time.Minute))
|
||||
|
||||
for _, p := range []*agg.Payment{p1, p2, p3, p4} {
|
||||
if err := repo.Create(context.Background(), p); err != nil {
|
||||
t.Fatalf("Create returned error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
page1, err := repo.ListByQuotationRef(context.Background(), ListByQuotationRefInput{
|
||||
OrganizationRef: org,
|
||||
QuotationRef: "quote-1",
|
||||
Limit: 2,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ListByQuotationRef(page1) returned error: %v", err)
|
||||
}
|
||||
if len(page1.Items) != 2 {
|
||||
t.Fatalf("page1 size mismatch: got=%d want=2", len(page1.Items))
|
||||
}
|
||||
if page1.Items[0].PaymentRef != p1.PaymentRef || page1.Items[1].PaymentRef != p2.PaymentRef {
|
||||
t.Fatalf("page1 order mismatch: got=%q,%q", page1.Items[0].PaymentRef, page1.Items[1].PaymentRef)
|
||||
}
|
||||
if page1.NextCursor == nil {
|
||||
t.Fatal("expected next cursor")
|
||||
}
|
||||
|
||||
page2, err := repo.ListByQuotationRef(context.Background(), ListByQuotationRefInput{
|
||||
OrganizationRef: org,
|
||||
QuotationRef: "quote-1",
|
||||
Limit: 2,
|
||||
Cursor: page1.NextCursor,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ListByQuotationRef(page2) returned error: %v", err)
|
||||
}
|
||||
if len(page2.Items) != 1 || page2.Items[0].PaymentRef != p3.PaymentRef {
|
||||
t.Fatalf("page2 mismatch")
|
||||
}
|
||||
if page2.NextCursor != nil {
|
||||
t.Fatalf("expected nil next cursor")
|
||||
}
|
||||
|
||||
statePage, err := repo.ListByState(context.Background(), ListByStateInput{
|
||||
OrganizationRef: org,
|
||||
State: agg.StateExecuting,
|
||||
Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ListByState returned error: %v", err)
|
||||
}
|
||||
if len(statePage.Items) != 3 {
|
||||
t.Fatalf("state page size mismatch: got=%d want=3", len(statePage.Items))
|
||||
}
|
||||
if statePage.Items[0].PaymentRef != p4.PaymentRef {
|
||||
t.Fatalf("state order mismatch: first=%q want=%q", statePage.Items[0].PaymentRef, p4.PaymentRef)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidationErrors(t *testing.T) {
|
||||
store := newFakeStore()
|
||||
repo, err := newWithStore(store)
|
||||
if err != nil {
|
||||
t.Fatalf("newWithStore returned error: %v", err)
|
||||
}
|
||||
org := bson.NewObjectID()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
run func() error
|
||||
}{
|
||||
{
|
||||
name: "create missing payment",
|
||||
run: func() error {
|
||||
return repo.Create(context.Background(), nil)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "create invalid state",
|
||||
run: func() error {
|
||||
p := newPaymentFixture(org, "idem-1", "quote-1", "pay-1", agg.State("bad"), time.Now())
|
||||
return repo.Create(context.Background(), p)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "update expected version missing",
|
||||
run: func() error {
|
||||
p := newPaymentFixture(org, "idem-1", "quote-1", "pay-1", agg.StateExecuting, time.Now())
|
||||
p.ID = bson.NewObjectID()
|
||||
return repo.UpdateCAS(context.Background(), p, 0)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list state invalid",
|
||||
run: func() error {
|
||||
_, err := repo.ListByState(context.Background(), ListByStateInput{
|
||||
OrganizationRef: org,
|
||||
State: agg.State("bad"),
|
||||
})
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list cursor invalid",
|
||||
run: func() error {
|
||||
_, err := repo.ListByQuotationRef(context.Background(), ListByQuotationRefInput{
|
||||
OrganizationRef: org,
|
||||
QuotationRef: "quote-1",
|
||||
Cursor: &ListCursor{CreatedAt: time.Now()},
|
||||
})
|
||||
return err
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if err := tt.run(); !errors.Is(err, merrors.ErrInvalidArg) {
|
||||
t.Fatalf("expected invalid argument error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type fakeStore struct {
|
||||
indexes []*indexDefinition
|
||||
docs map[bson.ObjectID]*paymentDocument
|
||||
}
|
||||
|
||||
func newFakeStore() *fakeStore {
|
||||
return &fakeStore{
|
||||
docs: map[bson.ObjectID]*paymentDocument{},
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeStore) EnsureIndexes(defs []*indexDefinition) error {
|
||||
f.indexes = defs
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) Create(_ context.Context, doc *paymentDocument) error {
|
||||
cloned, err := cloneDocument(doc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, existing := range f.docs {
|
||||
if existing.OrganizationRef == cloned.OrganizationRef && existing.PaymentRef == cloned.PaymentRef {
|
||||
return ErrDuplicatePayment
|
||||
}
|
||||
if existing.OrganizationRef == cloned.OrganizationRef && existing.IdempotencyKey == cloned.IdempotencyKey {
|
||||
return ErrDuplicatePayment
|
||||
}
|
||||
}
|
||||
f.docs[cloned.ID] = cloned
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) UpdateCAS(_ context.Context, doc *paymentDocument, expectedVersion uint64) (bool, error) {
|
||||
existing := f.docs[doc.ID]
|
||||
if existing == nil {
|
||||
return false, nil
|
||||
}
|
||||
if existing.OrganizationRef != doc.OrganizationRef {
|
||||
return false, nil
|
||||
}
|
||||
if existing.Version != expectedVersion {
|
||||
return false, nil
|
||||
}
|
||||
cloned, err := cloneDocument(doc)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
f.docs[doc.ID] = cloned
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) GetByPaymentRef(_ context.Context, orgRef bson.ObjectID, paymentRef string) (*paymentDocument, error) {
|
||||
for _, doc := range f.docs {
|
||||
if doc.OrganizationRef == orgRef && doc.PaymentRef == paymentRef {
|
||||
return cloneDocument(doc)
|
||||
}
|
||||
}
|
||||
return nil, ErrPaymentNotFound
|
||||
}
|
||||
|
||||
func (f *fakeStore) GetByIdempotencyKey(_ context.Context, orgRef bson.ObjectID, idempotencyKey string) (*paymentDocument, error) {
|
||||
for _, doc := range f.docs {
|
||||
if doc.OrganizationRef == orgRef && doc.IdempotencyKey == idempotencyKey {
|
||||
return cloneDocument(doc)
|
||||
}
|
||||
}
|
||||
return nil, ErrPaymentNotFound
|
||||
}
|
||||
|
||||
func (f *fakeStore) GetByID(_ context.Context, orgRef bson.ObjectID, id bson.ObjectID) (*paymentDocument, error) {
|
||||
doc := f.docs[id]
|
||||
if doc == nil || doc.OrganizationRef != orgRef {
|
||||
return nil, ErrPaymentNotFound
|
||||
}
|
||||
return cloneDocument(doc)
|
||||
}
|
||||
|
||||
func (f *fakeStore) ListByQuotationRef(_ context.Context, orgRef bson.ObjectID, quotationRef string, cursor *listCursor, limit int64) ([]*paymentDocument, error) {
|
||||
return f.list(func(doc *paymentDocument) bool {
|
||||
return doc.OrganizationRef == orgRef && doc.QuotationRef == quotationRef
|
||||
}, cursor, limit)
|
||||
}
|
||||
|
||||
func (f *fakeStore) ListByState(_ context.Context, orgRef bson.ObjectID, state agg.State, cursor *listCursor, limit int64) ([]*paymentDocument, error) {
|
||||
return f.list(func(doc *paymentDocument) bool {
|
||||
return doc.OrganizationRef == orgRef && doc.State == state
|
||||
}, cursor, limit)
|
||||
}
|
||||
|
||||
func (f *fakeStore) list(match func(*paymentDocument) bool, cursor *listCursor, limit int64) ([]*paymentDocument, error) {
|
||||
items := make([]*paymentDocument, 0)
|
||||
for _, doc := range f.docs {
|
||||
if !match(doc) {
|
||||
continue
|
||||
}
|
||||
if cursor != nil {
|
||||
if !isBeforeCursor(doc, *cursor) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
cloned, err := cloneDocument(doc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, cloned)
|
||||
}
|
||||
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
left := items[i]
|
||||
right := items[j]
|
||||
if !left.CreatedAt.Equal(right.CreatedAt) {
|
||||
return left.CreatedAt.After(right.CreatedAt)
|
||||
}
|
||||
return bytes.Compare(left.ID[:], right.ID[:]) > 0
|
||||
})
|
||||
|
||||
if int64(len(items)) > limit {
|
||||
items = items[:limit]
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func isBeforeCursor(doc *paymentDocument, cursor listCursor) bool {
|
||||
if doc.CreatedAt.Before(cursor.CreatedAt) {
|
||||
return true
|
||||
}
|
||||
if doc.CreatedAt.After(cursor.CreatedAt) {
|
||||
return false
|
||||
}
|
||||
return bytes.Compare(doc.ID[:], cursor.ID[:]) < 0
|
||||
}
|
||||
|
||||
func assertIndex(t *testing.T, def *indexDefinition, fields []string, unique bool) {
|
||||
t.Helper()
|
||||
if def == nil {
|
||||
t.Fatal("expected index definition")
|
||||
}
|
||||
if def.Unique != unique {
|
||||
t.Fatalf("index unique mismatch: got=%v want=%v", def.Unique, unique)
|
||||
}
|
||||
if len(def.Keys) != len(fields) {
|
||||
t.Fatalf("index key count mismatch: got=%d want=%d", len(def.Keys), len(fields))
|
||||
}
|
||||
for i := range fields {
|
||||
if def.Keys[i].Field != fields[i] {
|
||||
t.Fatalf("index key[%d] mismatch: got=%q want=%q", i, def.Keys[i].Field, fields[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newPaymentFixture(org bson.ObjectID, idem, quote, paymentRef string, state agg.State, createdAt time.Time) *agg.Payment {
|
||||
return &agg.Payment{
|
||||
Base: modelBase(createdAt),
|
||||
OrganizationBoundBase: modelOrg(org),
|
||||
PaymentRef: paymentRef,
|
||||
IdempotencyKey: idem,
|
||||
QuotationRef: quote,
|
||||
ClientPaymentRef: "client-" + paymentRef,
|
||||
IntentSnapshot: model.PaymentIntent{Kind: model.PaymentKindPayout, Amount: testMoney()},
|
||||
QuoteSnapshot: &model.PaymentQuoteSnapshot{QuoteRef: quote},
|
||||
State: state,
|
||||
Version: 1,
|
||||
StepExecutions: []agg.StepExecution{
|
||||
{StepRef: "s1", StepCode: "step-1", State: agg.StepStatePending, Attempt: 1},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func modelOrg(org bson.ObjectID) pm.OrganizationBoundBase {
|
||||
return pm.OrganizationBoundBase{OrganizationRef: org}
|
||||
}
|
||||
|
||||
func modelBase(createdAt time.Time) storable.Base {
|
||||
return storable.Base{
|
||||
ID: bson.NewObjectID(),
|
||||
CreatedAt: createdAt.UTC(),
|
||||
UpdatedAt: createdAt.UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
func testMoney() *paymenttypes.Money {
|
||||
return &paymenttypes.Money{Amount: "10", Currency: "USDT"}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package prmap
|
||||
|
||||
import "github.com/tech/sendico/pkg/merrors"
|
||||
|
||||
func invalidMissing(field string) error {
|
||||
return merrors.InvalidArgument(field + " is required")
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package prmap
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
func tsOrNil(value time.Time) *timestamppb.Timestamp {
|
||||
if value.IsZero() {
|
||||
return nil
|
||||
}
|
||||
return timestamppb.New(value.UTC())
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func itoa(v int) string {
|
||||
if v == 0 {
|
||||
return "0"
|
||||
}
|
||||
var buf [20]byte
|
||||
i := len(buf)
|
||||
for v > 0 {
|
||||
i--
|
||||
buf[i] = byte('0' + v%10)
|
||||
v /= 10
|
||||
}
|
||||
return string(buf[i:])
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package prmap
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
pkgmodel "github.com/tech/sendico/pkg/model"
|
||||
paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1"
|
||||
endpointv1 "github.com/tech/sendico/pkg/proto/payments/endpoint/v1"
|
||||
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
type ledgerMethodData struct {
|
||||
LedgerAccountRef string `bson:"ledgerAccountRef"`
|
||||
ContraLedgerAccountRef string `bson:"contraLedgerAccountRef,omitempty"`
|
||||
}
|
||||
|
||||
func mapIntentSnapshot(src model.PaymentIntent) (*quotationv2.QuoteIntent, error) {
|
||||
source, err := mapIntentEndpoint(src.Source, "intent_snapshot.source")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
destination, err := mapIntentEndpoint(src.Destination, "intent_snapshot.destination")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
settlementMode := settlementModeToProto(src.SettlementMode)
|
||||
return "ationv2.QuoteIntent{
|
||||
Source: source,
|
||||
Destination: destination,
|
||||
Amount: moneyToProto(src.Amount),
|
||||
SettlementMode: settlementMode,
|
||||
FeeTreatment: feeTreatmentForSettlementMode(settlementMode),
|
||||
SettlementCurrency: strings.ToUpper(strings.TrimSpace(src.SettlementCurrency)),
|
||||
Comment: strings.TrimSpace(src.Attributes["comment"]),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func mapIntentEndpoint(src model.PaymentEndpoint, field string) (*endpointv1.PaymentEndpoint, error) {
|
||||
switch src.Type {
|
||||
case model.EndpointTypeManagedWallet:
|
||||
if src.ManagedWallet == nil {
|
||||
return nil, invalidMissing(field + ".managed_wallet")
|
||||
}
|
||||
if strings.TrimSpace(src.ManagedWallet.ManagedWalletRef) == "" {
|
||||
return nil, invalidMissing(field + ".managed_wallet.managed_wallet_ref")
|
||||
}
|
||||
return endpointWithMethod(endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_WALLET, walletData(src.ManagedWallet))
|
||||
case model.EndpointTypeExternalChain:
|
||||
if src.ExternalChain == nil {
|
||||
return nil, invalidMissing(field + ".external_chain")
|
||||
}
|
||||
if strings.TrimSpace(src.ExternalChain.Address) == "" {
|
||||
return nil, invalidMissing(field + ".external_chain.address")
|
||||
}
|
||||
return endpointWithMethod(endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CRYPTO_ADDRESS, externalChainData(src.ExternalChain))
|
||||
case model.EndpointTypeCard:
|
||||
if src.Card == nil {
|
||||
return nil, invalidMissing(field + ".card")
|
||||
}
|
||||
if strings.TrimSpace(src.Card.Token) == "" && strings.TrimSpace(src.Card.Pan) == "" {
|
||||
return nil, invalidMissing(field + ".card.pan_or_token")
|
||||
}
|
||||
return endpointWithCard(src.Card)
|
||||
case model.EndpointTypeLedger:
|
||||
if src.Ledger == nil {
|
||||
return nil, invalidMissing(field + ".ledger")
|
||||
}
|
||||
if strings.TrimSpace(src.Ledger.LedgerAccountRef) == "" {
|
||||
return nil, invalidMissing(field + ".ledger.ledger_account_ref")
|
||||
}
|
||||
return endpointWithMethod(endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_LEDGER, ledgerData(src.Ledger))
|
||||
default:
|
||||
return nil, invalidMissing(field)
|
||||
}
|
||||
}
|
||||
|
||||
func endpointWithCard(card *model.CardEndpoint) (*endpointv1.PaymentEndpoint, error) {
|
||||
token := strings.TrimSpace(card.Token)
|
||||
if token != "" {
|
||||
return endpointWithMethod(endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CARD_TOKEN, tokenCardData(card))
|
||||
}
|
||||
return endpointWithMethod(endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CARD, rawCardData(card))
|
||||
}
|
||||
|
||||
func endpointWithMethod(
|
||||
methodType endpointv1.PaymentMethodType,
|
||||
methodData any,
|
||||
) (*endpointv1.PaymentEndpoint, error) {
|
||||
data, err := bson.Marshal(methodData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &endpointv1.PaymentEndpoint{
|
||||
Source: &endpointv1.PaymentEndpoint_PaymentMethod{
|
||||
PaymentMethod: &endpointv1.PaymentMethod{
|
||||
Type: methodType,
|
||||
Data: data,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func walletData(src *model.ManagedWalletEndpoint) pkgmodel.WalletPaymentData {
|
||||
walletID := ""
|
||||
if src != nil {
|
||||
walletID = strings.TrimSpace(src.ManagedWalletRef)
|
||||
}
|
||||
return pkgmodel.WalletPaymentData{
|
||||
WalletID: walletID,
|
||||
}
|
||||
}
|
||||
|
||||
func externalChainData(src *model.ExternalChainEndpoint) pkgmodel.CryptoAddressPaymentData {
|
||||
currency := pkgmodel.Currency("")
|
||||
network := ""
|
||||
address := ""
|
||||
if src != nil {
|
||||
if src.Asset != nil {
|
||||
currency = pkgmodel.Currency(strings.ToUpper(strings.TrimSpace(src.Asset.TokenSymbol)))
|
||||
network = strings.TrimSpace(src.Asset.Chain)
|
||||
}
|
||||
address = strings.TrimSpace(src.Address)
|
||||
}
|
||||
data := pkgmodel.CryptoAddressPaymentData{
|
||||
Currency: currency,
|
||||
Network: network,
|
||||
Address: address,
|
||||
}
|
||||
if src != nil && strings.TrimSpace(src.Memo) != "" {
|
||||
memo := strings.TrimSpace(src.Memo)
|
||||
data.DestinationTag = &memo
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func rawCardData(src *model.CardEndpoint) pkgmodel.CardPaymentData {
|
||||
if src == nil {
|
||||
return pkgmodel.CardPaymentData{}
|
||||
}
|
||||
return pkgmodel.CardPaymentData{
|
||||
Pan: strings.TrimSpace(src.Pan),
|
||||
FirstName: strings.TrimSpace(src.Cardholder),
|
||||
LastName: strings.TrimSpace(src.CardholderSurname),
|
||||
ExpMonth: uintToString(src.ExpMonth),
|
||||
ExpYear: uintToString(src.ExpYear),
|
||||
Country: strings.TrimSpace(src.Country),
|
||||
}
|
||||
}
|
||||
|
||||
func tokenCardData(src *model.CardEndpoint) pkgmodel.TokenPaymentData {
|
||||
if src == nil {
|
||||
return pkgmodel.TokenPaymentData{}
|
||||
}
|
||||
return pkgmodel.TokenPaymentData{
|
||||
Token: strings.TrimSpace(src.Token),
|
||||
Last4: strings.TrimSpace(src.MaskedPan),
|
||||
ExpMonth: uintToString(src.ExpMonth),
|
||||
ExpYear: uintToString(src.ExpYear),
|
||||
CardholderName: strings.TrimSpace(src.Cardholder),
|
||||
Country: strings.TrimSpace(src.Country),
|
||||
}
|
||||
}
|
||||
|
||||
func ledgerData(src *model.LedgerEndpoint) ledgerMethodData {
|
||||
if src == nil {
|
||||
return ledgerMethodData{}
|
||||
}
|
||||
return ledgerMethodData{
|
||||
LedgerAccountRef: strings.TrimSpace(src.LedgerAccountRef),
|
||||
ContraLedgerAccountRef: strings.TrimSpace(src.ContraLedgerAccountRef),
|
||||
}
|
||||
}
|
||||
|
||||
func settlementModeToProto(mode model.SettlementMode) paymentv1.SettlementMode {
|
||||
switch mode {
|
||||
case model.SettlementModeFixReceived:
|
||||
return paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED
|
||||
case model.SettlementModeFixSource:
|
||||
return paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE
|
||||
default:
|
||||
return paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE
|
||||
}
|
||||
}
|
||||
|
||||
func feeTreatmentForSettlementMode(mode paymentv1.SettlementMode) quotationv2.FeeTreatment {
|
||||
switch mode {
|
||||
case paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED:
|
||||
return quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION
|
||||
default:
|
||||
return quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE
|
||||
}
|
||||
}
|
||||
|
||||
func uintToString(value uint32) string {
|
||||
if value == 0 {
|
||||
return ""
|
||||
}
|
||||
return strconv.FormatUint(uint64(value), 10)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package prmap
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
)
|
||||
|
||||
func validateMapInput(in MapInput) error {
|
||||
if in.Payment == nil {
|
||||
return merrors.InvalidArgument("payment is required")
|
||||
}
|
||||
return validatePaymentInvariants(in.Payment)
|
||||
}
|
||||
|
||||
func validatePaymentInvariants(payment *agg.Payment) error {
|
||||
if payment == nil {
|
||||
return merrors.InvalidArgument("payment is required")
|
||||
}
|
||||
if strings.TrimSpace(payment.PaymentRef) == "" {
|
||||
return merrors.InvalidArgument("payment.payment_ref is required")
|
||||
}
|
||||
if strings.TrimSpace(payment.QuotationRef) == "" {
|
||||
return merrors.InvalidArgument("payment.quotation_ref is required")
|
||||
}
|
||||
if payment.IntentSnapshot.Amount == nil {
|
||||
return merrors.InvalidArgument("payment.intent_snapshot.amount is required")
|
||||
}
|
||||
if strings.TrimSpace(payment.IntentSnapshot.SettlementCurrency) == "" {
|
||||
return merrors.InvalidArgument("payment.intent_snapshot.settlement_currency is required")
|
||||
}
|
||||
if payment.QuoteSnapshot == nil {
|
||||
return merrors.InvalidArgument("payment.quote_snapshot is required")
|
||||
}
|
||||
if payment.QuoteSnapshot.DebitAmount == nil {
|
||||
return merrors.InvalidArgument("payment.quote_snapshot.debit_amount is required")
|
||||
}
|
||||
if _, ok := normalizeAggregateState(payment.State); !ok {
|
||||
return merrors.InvalidArgument("payment.state is invalid")
|
||||
}
|
||||
if payment.Version == 0 {
|
||||
return merrors.InvalidArgument("payment.version is required")
|
||||
}
|
||||
if payment.CreatedAt.IsZero() {
|
||||
return merrors.InvalidArgument("payment.created_at is required")
|
||||
}
|
||||
if payment.UpdatedAt.IsZero() {
|
||||
return merrors.InvalidArgument("payment.updated_at is required")
|
||||
}
|
||||
|
||||
for i := range payment.StepExecutions {
|
||||
if err := validateStepInvariants(payment.StepExecutions[i], i); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateStepInvariants(step agg.StepExecution, index int) error {
|
||||
if strings.TrimSpace(step.StepRef) == "" {
|
||||
return merrors.InvalidArgument("payment.step_executions[" + itoa(index) + "].step_ref is required")
|
||||
}
|
||||
if _, ok := normalizeStepState(step.State); !ok {
|
||||
return merrors.InvalidArgument("payment.step_executions[" + itoa(index) + "].state is invalid")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package prmap
|
||||
|
||||
import (
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2"
|
||||
)
|
||||
|
||||
// Mapper transforms orchestration-v2 runtime aggregate snapshots into API responses.
|
||||
type Mapper interface {
|
||||
Map(in MapInput) (*MapOutput, error)
|
||||
}
|
||||
|
||||
// MapInput is the mapper payload.
|
||||
type MapInput struct {
|
||||
Payment *agg.Payment
|
||||
}
|
||||
|
||||
// MapOutput is the mapper result.
|
||||
type MapOutput struct {
|
||||
Payment *orchestrationv2.Payment
|
||||
}
|
||||
|
||||
// Dependencies configures payment mapper integrations.
|
||||
type Dependencies struct {
|
||||
Logger mlogger.Logger
|
||||
}
|
||||
|
||||
func New(deps ...Dependencies) Mapper {
|
||||
var dep Dependencies
|
||||
if len(deps) > 0 {
|
||||
dep = deps[0]
|
||||
}
|
||||
return &svc{logger: dep.Logger.Named("prmap")}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
package prmap
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||
accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1"
|
||||
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1"
|
||||
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
|
||||
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
func mapQuoteSnapshot(
|
||||
src *model.PaymentQuoteSnapshot,
|
||||
fallbackQuoteRef string,
|
||||
intentRef string,
|
||||
) *quotationv2.PaymentQuote {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
resolvedSettlementMode := resolvedSettlementModeFromSnapshot(src)
|
||||
fxQuote := fxQuoteToProto(src.FXQuote)
|
||||
|
||||
return "ationv2.PaymentQuote{
|
||||
State: quotationv2.QuoteState_QUOTE_STATE_EXECUTABLE,
|
||||
BlockReason: quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED,
|
||||
TransferPrincipalAmount: moneyToProto(src.DebitAmount),
|
||||
DestinationAmount: moneyToProto(src.ExpectedSettlementAmount),
|
||||
FeeLines: feeLinesToProto(src.FeeLines),
|
||||
FeeRules: feeRulesToProto(src.FeeRules),
|
||||
FxQuote: fxQuote,
|
||||
QuoteRef: firstNonEmpty(src.QuoteRef, fallbackQuoteRef),
|
||||
ExpiresAt: quoteExpiryToProto(fxQuote),
|
||||
PricedAt: quotePricedAtToProto(fxQuote),
|
||||
Route: routeToProto(src.Route),
|
||||
ExecutionConditions: executionConditionsToProto(src.ExecutionConditions),
|
||||
PayerTotalDebitAmount: moneyToProto(src.TotalCost),
|
||||
ResolvedSettlementMode: resolvedSettlementMode,
|
||||
ResolvedFeeTreatment: feeTreatmentForSettlementMode(resolvedSettlementMode),
|
||||
IntentRef: strings.TrimSpace(intentRef),
|
||||
}
|
||||
}
|
||||
|
||||
func moneyToProto(src *paymenttypes.Money) *moneyv1.Money {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
return &moneyv1.Money{
|
||||
Amount: strings.TrimSpace(src.GetAmount()),
|
||||
Currency: strings.ToUpper(strings.TrimSpace(src.GetCurrency())),
|
||||
}
|
||||
}
|
||||
|
||||
func fxQuoteToProto(src *paymenttypes.FXQuote) *oraclev1.Quote {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
out := &oraclev1.Quote{
|
||||
QuoteRef: strings.TrimSpace(src.QuoteRef),
|
||||
Side: fxSideToProto(src.GetSide()),
|
||||
Price: &moneyv1.Decimal{Value: strings.TrimSpace(src.GetPrice().GetValue())},
|
||||
BaseAmount: moneyToProto(src.GetBaseAmount()),
|
||||
QuoteAmount: moneyToProto(src.GetQuoteAmount()),
|
||||
ExpiresAtUnixMs: src.GetExpiresAtUnixMs(),
|
||||
Provider: strings.TrimSpace(src.GetProvider()),
|
||||
RateRef: strings.TrimSpace(src.GetRateRef()),
|
||||
Firm: src.GetFirm(),
|
||||
}
|
||||
if pair := src.GetPair(); pair != nil {
|
||||
out.Pair = &fxv1.CurrencyPair{
|
||||
Base: strings.ToUpper(strings.TrimSpace(pair.GetBase())),
|
||||
Quote: strings.ToUpper(strings.TrimSpace(pair.GetQuote())),
|
||||
}
|
||||
}
|
||||
if src.GetPricedAtUnixMs() > 0 {
|
||||
out.PricedAt = tsOrNil(time.UnixMilli(src.GetPricedAtUnixMs()))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func routeToProto(src *paymenttypes.QuoteRouteSpecification) *quotationv2.RouteSpecification {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
out := "ationv2.RouteSpecification{
|
||||
Rail: strings.TrimSpace(src.Rail),
|
||||
Provider: strings.TrimSpace(src.Provider),
|
||||
PayoutMethod: strings.TrimSpace(src.PayoutMethod),
|
||||
Network: strings.TrimSpace(src.Network),
|
||||
RouteRef: strings.TrimSpace(src.RouteRef),
|
||||
PricingProfileRef: strings.TrimSpace(src.PricingProfileRef),
|
||||
Settlement: routeSettlementToProto(src.Settlement),
|
||||
}
|
||||
if len(src.Hops) > 0 {
|
||||
out.Hops = make([]*quotationv2.RouteHop, 0, len(src.Hops))
|
||||
for _, hop := range src.Hops {
|
||||
if hop == nil {
|
||||
continue
|
||||
}
|
||||
out.Hops = append(out.Hops, "ationv2.RouteHop{
|
||||
Index: hop.Index,
|
||||
Rail: strings.TrimSpace(hop.Rail),
|
||||
Gateway: strings.TrimSpace(hop.Gateway),
|
||||
InstanceId: strings.TrimSpace(hop.InstanceID),
|
||||
Network: strings.TrimSpace(hop.Network),
|
||||
Role: routeHopRoleToProto(hop.Role),
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func routeSettlementToProto(src *paymenttypes.QuoteRouteSettlement) *quotationv2.RouteSettlement {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
out := "ationv2.RouteSettlement{
|
||||
Model: strings.TrimSpace(src.Model),
|
||||
}
|
||||
if src.Asset != nil {
|
||||
out.Asset = &paymentv1.ChainAsset{
|
||||
Key: &paymentv1.ChainAssetKey{
|
||||
Chain: strings.ToUpper(strings.TrimSpace(src.Asset.Chain)),
|
||||
TokenSymbol: strings.ToUpper(strings.TrimSpace(src.Asset.TokenSymbol)),
|
||||
},
|
||||
}
|
||||
if contract := strings.TrimSpace(src.Asset.ContractAddress); contract != "" {
|
||||
out.Asset.ContractAddress = &contract
|
||||
}
|
||||
}
|
||||
if out.Asset == nil && out.Model == "" {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func executionConditionsToProto(src *paymenttypes.QuoteExecutionConditions) *quotationv2.ExecutionConditions {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
return "ationv2.ExecutionConditions{
|
||||
Readiness: readinessToProto(src.Readiness),
|
||||
BatchingEligible: src.BatchingEligible,
|
||||
PrefundingRequired: src.PrefundingRequired,
|
||||
PrefundingCostIncluded: src.PrefundingCostIncluded,
|
||||
LiquidityCheckRequiredAtExecution: src.LiquidityCheckRequiredAtExecution,
|
||||
LatencyHint: strings.TrimSpace(src.LatencyHint),
|
||||
Assumptions: cloneAssumptions(src.Assumptions),
|
||||
}
|
||||
}
|
||||
|
||||
func feeLinesToProto(lines []*paymenttypes.FeeLine) []*feesv1.DerivedPostingLine {
|
||||
if len(lines) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]*feesv1.DerivedPostingLine, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
if line == nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, &feesv1.DerivedPostingLine{
|
||||
LedgerAccountRef: strings.TrimSpace(line.GetLedgerAccountRef()),
|
||||
Money: moneyToProto(line.GetMoney()),
|
||||
LineType: postingLineTypeToProto(line.GetLineType()),
|
||||
Side: entrySideToProto(line.GetSide()),
|
||||
Meta: cloneStringMap(line.Meta),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func feeRulesToProto(rules []*paymenttypes.AppliedRule) []*feesv1.AppliedRule {
|
||||
if len(rules) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]*feesv1.AppliedRule, 0, len(rules))
|
||||
for _, rule := range rules {
|
||||
if rule == nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, &feesv1.AppliedRule{
|
||||
RuleId: strings.TrimSpace(rule.RuleID),
|
||||
RuleVersion: strings.TrimSpace(rule.RuleVersion),
|
||||
Formula: strings.TrimSpace(rule.Formula),
|
||||
Rounding: roundingModeToProto(rule.Rounding),
|
||||
TaxCode: strings.TrimSpace(rule.TaxCode),
|
||||
TaxRate: strings.TrimSpace(rule.TaxRate),
|
||||
Parameters: cloneStringMap(rule.Parameters),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func resolvedSettlementModeFromSnapshot(snapshot *model.PaymentQuoteSnapshot) paymentv1.SettlementMode {
|
||||
if snapshot == nil || snapshot.Route == nil || snapshot.Route.Settlement == nil {
|
||||
return paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE
|
||||
}
|
||||
switch strings.ToUpper(strings.TrimSpace(snapshot.Route.Settlement.Model)) {
|
||||
case "FIX_RECEIVED", "SETTLEMENT_FIX_RECEIVED", "SETTLEMENT_MODE_FIX_RECEIVED":
|
||||
return paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED
|
||||
default:
|
||||
return paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE
|
||||
}
|
||||
}
|
||||
|
||||
func quoteExpiryToProto(src *oraclev1.Quote) *timestamppb.Timestamp {
|
||||
if src == nil || src.GetExpiresAtUnixMs() <= 0 {
|
||||
return nil
|
||||
}
|
||||
return tsOrNil(time.UnixMilli(src.GetExpiresAtUnixMs()))
|
||||
}
|
||||
|
||||
func quotePricedAtToProto(src *oraclev1.Quote) *timestamppb.Timestamp {
|
||||
if src == nil || src.GetPricedAt() == nil {
|
||||
return nil
|
||||
}
|
||||
return timestamppb.New(src.GetPricedAt().AsTime().UTC())
|
||||
}
|
||||
|
||||
func cloneAssumptions(src []string) []string {
|
||||
if len(src) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(src))
|
||||
for _, item := range src {
|
||||
if trimmed := strings.TrimSpace(item); trimmed != "" {
|
||||
out = append(out, trimmed)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneStringMap(src map[string]string) map[string]string {
|
||||
if len(src) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(src))
|
||||
for k, v := range src {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func fxSideToProto(side paymenttypes.FXSide) fxv1.Side {
|
||||
switch side {
|
||||
case paymenttypes.FXSideBuyBaseSellQuote:
|
||||
return fxv1.Side_BUY_BASE_SELL_QUOTE
|
||||
case paymenttypes.FXSideSellBaseBuyQuote:
|
||||
return fxv1.Side_SELL_BASE_BUY_QUOTE
|
||||
default:
|
||||
return fxv1.Side_SIDE_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
func routeHopRoleToProto(role paymenttypes.QuoteRouteHopRole) quotationv2.RouteHopRole {
|
||||
switch role {
|
||||
case paymenttypes.QuoteRouteHopRoleSource:
|
||||
return quotationv2.RouteHopRole_ROUTE_HOP_ROLE_SOURCE
|
||||
case paymenttypes.QuoteRouteHopRoleTransit:
|
||||
return quotationv2.RouteHopRole_ROUTE_HOP_ROLE_TRANSIT
|
||||
case paymenttypes.QuoteRouteHopRoleDestination:
|
||||
return quotationv2.RouteHopRole_ROUTE_HOP_ROLE_DESTINATION
|
||||
default:
|
||||
return quotationv2.RouteHopRole_ROUTE_HOP_ROLE_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
func readinessToProto(readiness paymenttypes.QuoteExecutionReadiness) quotationv2.QuoteExecutionReadiness {
|
||||
switch readiness {
|
||||
case paymenttypes.QuoteExecutionReadinessLiquidityReady:
|
||||
return quotationv2.QuoteExecutionReadiness_QUOTE_EXECUTION_READINESS_LIQUIDITY_READY
|
||||
case paymenttypes.QuoteExecutionReadinessLiquidityObtainable:
|
||||
return quotationv2.QuoteExecutionReadiness_QUOTE_EXECUTION_READINESS_LIQUIDITY_OBTAINABLE
|
||||
case paymenttypes.QuoteExecutionReadinessIndicative:
|
||||
return quotationv2.QuoteExecutionReadiness_QUOTE_EXECUTION_READINESS_INDICATIVE
|
||||
default:
|
||||
return quotationv2.QuoteExecutionReadiness_QUOTE_EXECUTION_READINESS_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
func entrySideToProto(side paymenttypes.EntrySide) accountingv1.EntrySide {
|
||||
switch side {
|
||||
case paymenttypes.EntrySideDebit:
|
||||
return accountingv1.EntrySide_ENTRY_SIDE_DEBIT
|
||||
case paymenttypes.EntrySideCredit:
|
||||
return accountingv1.EntrySide_ENTRY_SIDE_CREDIT
|
||||
default:
|
||||
return accountingv1.EntrySide_ENTRY_SIDE_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
func postingLineTypeToProto(lineType paymenttypes.PostingLineType) accountingv1.PostingLineType {
|
||||
switch lineType {
|
||||
case paymenttypes.PostingLineTypeFee:
|
||||
return accountingv1.PostingLineType_POSTING_LINE_FEE
|
||||
case paymenttypes.PostingLineTypeTax:
|
||||
return accountingv1.PostingLineType_POSTING_LINE_TAX
|
||||
case paymenttypes.PostingLineTypeSpread:
|
||||
return accountingv1.PostingLineType_POSTING_LINE_SPREAD
|
||||
case paymenttypes.PostingLineTypeReversal:
|
||||
return accountingv1.PostingLineType_POSTING_LINE_REVERSAL
|
||||
default:
|
||||
return accountingv1.PostingLineType_POSTING_LINE_TYPE_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
func roundingModeToProto(mode paymenttypes.RoundingMode) moneyv1.RoundingMode {
|
||||
switch mode {
|
||||
case paymenttypes.RoundingModeHalfEven:
|
||||
return moneyv1.RoundingMode_ROUND_HALF_EVEN
|
||||
case paymenttypes.RoundingModeHalfUp:
|
||||
return moneyv1.RoundingMode_ROUND_HALF_UP
|
||||
case paymenttypes.RoundingModeDown:
|
||||
return moneyv1.RoundingMode_ROUND_DOWN
|
||||
default:
|
||||
return moneyv1.RoundingMode_ROUNDING_MODE_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package prmap
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type svc struct {
|
||||
logger mlogger.Logger
|
||||
}
|
||||
|
||||
func (s *svc) Map(in MapInput) (out *MapOutput, err error) {
|
||||
logger := s.logger
|
||||
paymentRef := ""
|
||||
if in.Payment != nil {
|
||||
paymentRef = strings.TrimSpace(in.Payment.PaymentRef)
|
||||
}
|
||||
logger.Debug("Starting Map", zap.String("payment_ref", paymentRef))
|
||||
defer func(start time.Time) {
|
||||
fields := []zap.Field{
|
||||
zap.Int64("duration_ms", time.Since(start).Milliseconds()),
|
||||
zap.String("payment_ref", paymentRef),
|
||||
}
|
||||
if out != nil && out.Payment != nil {
|
||||
fields = append(fields,
|
||||
zap.String("state", out.Payment.GetState().String()),
|
||||
zap.Uint64("version", out.Payment.GetVersion()),
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
logger.Warn("Failed to map", append(fields, zap.Error(err))...)
|
||||
return
|
||||
}
|
||||
logger.Debug("Completed Map", fields...)
|
||||
}(time.Now())
|
||||
|
||||
if err := validateMapInput(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
protoPayment, err := mapPayment(in.Payment)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = &MapOutput{Payment: protoPayment}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func mapPayment(src *agg.Payment) (*orchestrationv2.Payment, error) {
|
||||
if src == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
intentSnapshot, err := mapIntentSnapshot(src.IntentSnapshot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
quoteSnapshot := mapQuoteSnapshot(src.QuoteSnapshot, strings.TrimSpace(src.QuotationRef), strings.TrimSpace(src.IntentSnapshot.Ref))
|
||||
steps, err := mapStepExecutions(src.StepExecutions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &orchestrationv2.Payment{
|
||||
PaymentRef: strings.TrimSpace(src.PaymentRef),
|
||||
QuotationRef: strings.TrimSpace(src.QuotationRef),
|
||||
IntentSnapshot: intentSnapshot,
|
||||
QuoteSnapshot: quoteSnapshot,
|
||||
ClientPaymentRef: strings.TrimSpace(src.ClientPaymentRef),
|
||||
State: mapAggregateState(src.State),
|
||||
Version: src.Version,
|
||||
StepExecutions: steps,
|
||||
CreatedAt: tsOrNil(src.CreatedAt),
|
||||
UpdatedAt: tsOrNil(src.UpdatedAt),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
package prmap
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/db/storable"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
pkgmodel "github.com/tech/sendico/pkg/model"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1"
|
||||
paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1"
|
||||
endpointv1 "github.com/tech/sendico/pkg/proto/payments/endpoint/v1"
|
||||
orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2"
|
||||
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
|
||||
sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
func TestMap_Success(t *testing.T) {
|
||||
mapper := New()
|
||||
payment := newPaymentFixture()
|
||||
|
||||
out, err := mapper.Map(MapInput{Payment: payment})
|
||||
if err != nil {
|
||||
t.Fatalf("Map returned error: %v", err)
|
||||
}
|
||||
if out == nil || out.Payment == nil {
|
||||
t.Fatalf("expected mapped payment")
|
||||
}
|
||||
|
||||
protoPayment := out.Payment
|
||||
if got, want := protoPayment.GetPaymentRef(), payment.PaymentRef; got != want {
|
||||
t.Fatalf("payment_ref mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := protoPayment.GetState(), orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_EXECUTING; got != want {
|
||||
t.Fatalf("state mismatch: got=%s want=%s", got.String(), want.String())
|
||||
}
|
||||
if got, want := protoPayment.GetCreatedAt().AsTime().UTC(), payment.CreatedAt.UTC(); !got.Equal(want) {
|
||||
t.Fatalf("created_at mismatch: got=%v want=%v", got, want)
|
||||
}
|
||||
if got, want := protoPayment.GetUpdatedAt().AsTime().UTC(), payment.UpdatedAt.UTC(); !got.Equal(want) {
|
||||
t.Fatalf("updated_at mismatch: got=%v want=%v", got, want)
|
||||
}
|
||||
|
||||
intent := protoPayment.GetIntentSnapshot()
|
||||
if intent == nil {
|
||||
t.Fatalf("expected intent_snapshot")
|
||||
}
|
||||
if got, want := intent.GetSettlementMode(), paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE; got != want {
|
||||
t.Fatalf("settlement_mode mismatch: got=%s want=%s", got.String(), want.String())
|
||||
}
|
||||
if got, want := intent.GetFeeTreatment(), quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE; got != want {
|
||||
t.Fatalf("fee_treatment mismatch: got=%s want=%s", got.String(), want.String())
|
||||
}
|
||||
if got, want := intent.GetComment(), "invoice-7"; got != want {
|
||||
t.Fatalf("comment mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if source := intent.GetSource().GetPaymentMethod(); source == nil {
|
||||
t.Fatalf("expected source payment_method")
|
||||
} else {
|
||||
if got, want := source.GetType(), endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_WALLET; got != want {
|
||||
t.Fatalf("source method type mismatch: got=%s want=%s", got.String(), want.String())
|
||||
}
|
||||
var wallet pkgmodel.WalletPaymentData
|
||||
if err := bson.Unmarshal(source.GetData(), &wallet); err != nil {
|
||||
t.Fatalf("failed to decode wallet data: %v", err)
|
||||
}
|
||||
if got, want := wallet.WalletID, "mw-src"; got != want {
|
||||
t.Fatalf("wallet id mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
if destination := intent.GetDestination().GetPaymentMethod(); destination == nil {
|
||||
t.Fatalf("expected destination payment_method")
|
||||
} else if got, want := destination.GetType(), endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CARD_TOKEN; got != want {
|
||||
t.Fatalf("destination method type mismatch: got=%s want=%s", got.String(), want.String())
|
||||
}
|
||||
|
||||
quote := protoPayment.GetQuoteSnapshot()
|
||||
if quote == nil {
|
||||
t.Fatalf("expected quote_snapshot")
|
||||
}
|
||||
if got, want := quote.GetQuoteRef(), payment.QuotationRef; got != want {
|
||||
t.Fatalf("quote_ref mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := quote.GetState(), quotationv2.QuoteState_QUOTE_STATE_EXECUTABLE; got != want {
|
||||
t.Fatalf("quote state mismatch: got=%s want=%s", got.String(), want.String())
|
||||
}
|
||||
if got, want := quote.GetResolvedSettlementMode(), paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED; got != want {
|
||||
t.Fatalf("resolved_settlement_mode mismatch: got=%s want=%s", got.String(), want.String())
|
||||
}
|
||||
if got, want := quote.GetResolvedFeeTreatment(), quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION; got != want {
|
||||
t.Fatalf("resolved_fee_treatment mismatch: got=%s want=%s", got.String(), want.String())
|
||||
}
|
||||
if got, want := quote.GetIntentRef(), payment.IntentSnapshot.Ref; got != want {
|
||||
t.Fatalf("intent_ref mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
|
||||
steps := protoPayment.GetStepExecutions()
|
||||
if len(steps) != 2 {
|
||||
t.Fatalf("step count mismatch: got=%d want=2", len(steps))
|
||||
}
|
||||
if got, want := steps[0].GetAttempt(), uint32(1); got != want {
|
||||
t.Fatalf("attempt normalization mismatch: got=%d want=%d", got, want)
|
||||
}
|
||||
if got, want := steps[1].GetFailure().GetCategory(), sharedv1.PaymentFailureCode_FAILURE_BALANCE; got != want {
|
||||
t.Fatalf("failure category mismatch: got=%s want=%s", got.String(), want.String())
|
||||
}
|
||||
if got, want := steps[1].GetRefs()[0].GetRail(), gatewayv1.Rail_RAIL_LEDGER; got != want {
|
||||
t.Fatalf("external ref rail mismatch: got=%s want=%s", got.String(), want.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMap_InvalidArguments(t *testing.T) {
|
||||
mapper := New()
|
||||
now := time.Date(2026, time.January, 11, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
payment *agg.Payment
|
||||
}{
|
||||
{
|
||||
name: "nil payment",
|
||||
},
|
||||
{
|
||||
name: "missing payment ref",
|
||||
payment: func() *agg.Payment {
|
||||
p := newPaymentFixture()
|
||||
p.PaymentRef = ""
|
||||
return p
|
||||
}(),
|
||||
},
|
||||
{
|
||||
name: "missing quote snapshot",
|
||||
payment: func() *agg.Payment {
|
||||
p := newPaymentFixture()
|
||||
p.QuoteSnapshot = nil
|
||||
return p
|
||||
}(),
|
||||
},
|
||||
{
|
||||
name: "invalid aggregate state",
|
||||
payment: func() *agg.Payment {
|
||||
p := newPaymentFixture()
|
||||
p.State = agg.StateUnspecified
|
||||
return p
|
||||
}(),
|
||||
},
|
||||
{
|
||||
name: "invalid step state",
|
||||
payment: func() *agg.Payment {
|
||||
p := newPaymentFixture()
|
||||
p.StepExecutions[0].State = agg.StepStateUnspecified
|
||||
return p
|
||||
}(),
|
||||
},
|
||||
{
|
||||
name: "missing intent amount",
|
||||
payment: func() *agg.Payment {
|
||||
p := newPaymentFixture()
|
||||
p.IntentSnapshot.Amount = nil
|
||||
return p
|
||||
}(),
|
||||
},
|
||||
{
|
||||
name: "unsupported endpoint type",
|
||||
payment: func() *agg.Payment {
|
||||
p := newPaymentFixture()
|
||||
p.IntentSnapshot.Source.Type = model.EndpointTypeUnspecified
|
||||
return p
|
||||
}(),
|
||||
},
|
||||
{
|
||||
name: "missing timestamps",
|
||||
payment: func() *agg.Payment {
|
||||
p := newPaymentFixture()
|
||||
p.CreatedAt = now
|
||||
p.UpdatedAt = time.Time{}
|
||||
return p
|
||||
}(),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := mapper.Map(MapInput{Payment: tt.payment})
|
||||
if !errors.Is(err, merrors.ErrInvalidArg) {
|
||||
t.Fatalf("expected invalid argument error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func newPaymentFixture() *agg.Payment {
|
||||
createdAt := time.Date(2026, time.January, 11, 12, 0, 0, 0, time.UTC)
|
||||
startedAt := createdAt.Add(2 * time.Minute)
|
||||
|
||||
return &agg.Payment{
|
||||
Base: storable.Base{
|
||||
ID: bson.NewObjectID(),
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: createdAt.Add(5 * time.Minute),
|
||||
},
|
||||
OrganizationBoundBase: pkgmodel.OrganizationBoundBase{
|
||||
OrganizationRef: bson.NewObjectID(),
|
||||
},
|
||||
PaymentRef: "pay-1",
|
||||
IdempotencyKey: "idem-1",
|
||||
QuotationRef: "quote-1",
|
||||
ClientPaymentRef: "client-1",
|
||||
IntentSnapshot: model.PaymentIntent{
|
||||
Ref: "intent-1",
|
||||
Source: model.PaymentEndpoint{
|
||||
Type: model.EndpointTypeManagedWallet,
|
||||
ManagedWallet: &model.ManagedWalletEndpoint{
|
||||
ManagedWalletRef: "mw-src",
|
||||
Asset: &paymenttypes.Asset{
|
||||
Chain: "TRON",
|
||||
TokenSymbol: "USDT",
|
||||
},
|
||||
},
|
||||
},
|
||||
Destination: model.PaymentEndpoint{
|
||||
Type: model.EndpointTypeCard,
|
||||
Card: &model.CardEndpoint{
|
||||
Token: "tok_1",
|
||||
MaskedPan: "1234",
|
||||
Cardholder: "John",
|
||||
ExpMonth: 12,
|
||||
ExpYear: 2030,
|
||||
Country: "US",
|
||||
},
|
||||
},
|
||||
Amount: &paymenttypes.Money{
|
||||
Amount: "100",
|
||||
Currency: "USDT",
|
||||
},
|
||||
SettlementMode: model.SettlementModeFixSource,
|
||||
SettlementCurrency: "USD",
|
||||
Attributes: map[string]string{
|
||||
"comment": "invoice-7",
|
||||
},
|
||||
},
|
||||
QuoteSnapshot: &model.PaymentQuoteSnapshot{
|
||||
DebitAmount: &paymenttypes.Money{
|
||||
Amount: "100",
|
||||
Currency: "USDT",
|
||||
},
|
||||
ExpectedSettlementAmount: &paymenttypes.Money{
|
||||
Amount: "95",
|
||||
Currency: "USD",
|
||||
},
|
||||
TotalCost: &paymenttypes.Money{
|
||||
Amount: "101",
|
||||
Currency: "USDT",
|
||||
},
|
||||
FeeLines: []*paymenttypes.FeeLine{
|
||||
{
|
||||
LedgerAccountRef: "fees:1",
|
||||
Money: &paymenttypes.Money{
|
||||
Amount: "1",
|
||||
Currency: "USDT",
|
||||
},
|
||||
LineType: paymenttypes.PostingLineTypeFee,
|
||||
Side: paymenttypes.EntrySideDebit,
|
||||
Meta: map[string]string{"bucket": "service"},
|
||||
},
|
||||
},
|
||||
FeeRules: []*paymenttypes.AppliedRule{
|
||||
{
|
||||
RuleID: "rule-1",
|
||||
RuleVersion: "v1",
|
||||
Formula: "x*0.01",
|
||||
Rounding: paymenttypes.RoundingModeHalfUp,
|
||||
TaxCode: "VAT",
|
||||
TaxRate: "0.10",
|
||||
Parameters: map[string]string{"jurisdiction": "EU"},
|
||||
},
|
||||
},
|
||||
FXQuote: &paymenttypes.FXQuote{
|
||||
QuoteRef: "fx-1",
|
||||
Pair: &paymenttypes.CurrencyPair{
|
||||
Base: "USDT",
|
||||
Quote: "USD",
|
||||
},
|
||||
Side: paymenttypes.FXSideSellBaseBuyQuote,
|
||||
Price: &paymenttypes.Decimal{
|
||||
Value: "0.95",
|
||||
},
|
||||
BaseAmount: &paymenttypes.Money{
|
||||
Amount: "100",
|
||||
Currency: "USDT",
|
||||
},
|
||||
QuoteAmount: &paymenttypes.Money{
|
||||
Amount: "95",
|
||||
Currency: "USD",
|
||||
},
|
||||
ExpiresAtUnixMs: createdAt.Add(10 * time.Minute).UnixMilli(),
|
||||
PricedAtUnixMs: createdAt.Add(-1 * time.Minute).UnixMilli(),
|
||||
Provider: "oracle-1",
|
||||
RateRef: "rate-1",
|
||||
Firm: true,
|
||||
},
|
||||
Route: &paymenttypes.QuoteRouteSpecification{
|
||||
Rail: "CARD_PAYOUT",
|
||||
Provider: "provider-1",
|
||||
PayoutMethod: "CARD",
|
||||
Network: "VISA",
|
||||
RouteRef: "route-1",
|
||||
PricingProfileRef: "pricing-1",
|
||||
Settlement: &paymenttypes.QuoteRouteSettlement{
|
||||
Model: "FIX_RECEIVED",
|
||||
Asset: &paymenttypes.Asset{
|
||||
Chain: "TRON",
|
||||
TokenSymbol: "USDT",
|
||||
},
|
||||
},
|
||||
Hops: []*paymenttypes.QuoteRouteHop{
|
||||
{
|
||||
Index: 10,
|
||||
Rail: "LEDGER",
|
||||
Role: paymenttypes.QuoteRouteHopRoleSource,
|
||||
},
|
||||
{
|
||||
Index: 20,
|
||||
Rail: "CARD_PAYOUT",
|
||||
Gateway: "gw-card",
|
||||
InstanceID: "card-1",
|
||||
Role: paymenttypes.QuoteRouteHopRoleDestination,
|
||||
},
|
||||
},
|
||||
},
|
||||
ExecutionConditions: &paymenttypes.QuoteExecutionConditions{
|
||||
Readiness: paymenttypes.QuoteExecutionReadinessLiquidityReady,
|
||||
BatchingEligible: true,
|
||||
PrefundingRequired: false,
|
||||
PrefundingCostIncluded: true,
|
||||
LiquidityCheckRequiredAtExecution: true,
|
||||
LatencyHint: "fast",
|
||||
Assumptions: []string{"funds_ready"},
|
||||
},
|
||||
},
|
||||
State: agg.StateExecuting,
|
||||
Version: 3,
|
||||
StepExecutions: []agg.StepExecution{
|
||||
{
|
||||
StepRef: "s1",
|
||||
StepCode: "hop.20.card_payout.send",
|
||||
State: agg.StepStateRunning,
|
||||
Attempt: 0,
|
||||
StartedAt: &startedAt,
|
||||
},
|
||||
{
|
||||
StepRef: "s2",
|
||||
StepCode: "edge.10_20.ledger.debit",
|
||||
State: agg.StepStateFailed,
|
||||
Attempt: 2,
|
||||
FailureCode: "ledger_balance_low",
|
||||
FailureMsg: "insufficient balance",
|
||||
ExternalRefs: []agg.ExternalRef{
|
||||
{
|
||||
GatewayInstanceID: "ledger-1",
|
||||
Kind: "ledger_entry_ref",
|
||||
Ref: "entry-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package prmap
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||
gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1"
|
||||
orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2"
|
||||
sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1"
|
||||
)
|
||||
|
||||
func normalizeAggregateState(state agg.State) (agg.State, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(string(state))) {
|
||||
case string(agg.StateCreated):
|
||||
return agg.StateCreated, true
|
||||
case string(agg.StateExecuting):
|
||||
return agg.StateExecuting, true
|
||||
case string(agg.StateNeedsAttention):
|
||||
return agg.StateNeedsAttention, true
|
||||
case string(agg.StateSettled):
|
||||
return agg.StateSettled, true
|
||||
case string(agg.StateFailed):
|
||||
return agg.StateFailed, true
|
||||
default:
|
||||
return agg.StateUnspecified, false
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeStepState(state agg.StepState) (agg.StepState, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(string(state))) {
|
||||
case string(agg.StepStatePending):
|
||||
return agg.StepStatePending, true
|
||||
case string(agg.StepStateRunning):
|
||||
return agg.StepStateRunning, true
|
||||
case string(agg.StepStateCompleted):
|
||||
return agg.StepStateCompleted, true
|
||||
case string(agg.StepStateFailed):
|
||||
return agg.StepStateFailed, true
|
||||
case string(agg.StepStateNeedsAttention):
|
||||
return agg.StepStateNeedsAttention, true
|
||||
case string(agg.StepStateSkipped):
|
||||
return agg.StepStateSkipped, true
|
||||
default:
|
||||
return agg.StepStateUnspecified, false
|
||||
}
|
||||
}
|
||||
|
||||
func mapAggregateState(state agg.State) orchestrationv2.OrchestrationState {
|
||||
switch normalized, _ := normalizeAggregateState(state); normalized {
|
||||
case agg.StateCreated:
|
||||
return orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_CREATED
|
||||
case agg.StateExecuting:
|
||||
return orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_EXECUTING
|
||||
case agg.StateNeedsAttention:
|
||||
return orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_NEEDS_ATTENTION
|
||||
case agg.StateSettled:
|
||||
return orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_SETTLED
|
||||
case agg.StateFailed:
|
||||
return orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_FAILED
|
||||
default:
|
||||
return orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
func mapStepState(state agg.StepState) orchestrationv2.StepExecutionState {
|
||||
switch normalized, _ := normalizeStepState(state); normalized {
|
||||
case agg.StepStatePending:
|
||||
return orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_PENDING
|
||||
case agg.StepStateRunning:
|
||||
return orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_RUNNING
|
||||
case agg.StepStateCompleted:
|
||||
return orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_COMPLETED
|
||||
case agg.StepStateFailed:
|
||||
return orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_FAILED
|
||||
case agg.StepStateNeedsAttention:
|
||||
return orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_NEEDS_ATTENTION
|
||||
case agg.StepStateSkipped:
|
||||
return orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_SKIPPED
|
||||
default:
|
||||
return orchestrationv2.StepExecutionState_STEP_EXECUTION_STATE_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
func inferFailureCategory(failureCode string) sharedv1.PaymentFailureCode {
|
||||
code := strings.ToLower(strings.TrimSpace(failureCode))
|
||||
switch {
|
||||
case strings.Contains(code, "balance"), strings.Contains(code, "insufficient_funds"):
|
||||
return sharedv1.PaymentFailureCode_FAILURE_BALANCE
|
||||
case strings.Contains(code, "ledger"):
|
||||
return sharedv1.PaymentFailureCode_FAILURE_LEDGER
|
||||
case strings.Contains(code, "fx"):
|
||||
return sharedv1.PaymentFailureCode_FAILURE_FX
|
||||
case strings.Contains(code, "chain"), strings.Contains(code, "crypto"), strings.Contains(code, "provider"), strings.Contains(code, "card"):
|
||||
return sharedv1.PaymentFailureCode_FAILURE_CHAIN
|
||||
case strings.Contains(code, "fee"), strings.Contains(code, "charge"):
|
||||
return sharedv1.PaymentFailureCode_FAILURE_FEES
|
||||
case strings.Contains(code, "policy"), strings.Contains(code, "risk"), strings.Contains(code, "compliance"):
|
||||
return sharedv1.PaymentFailureCode_FAILURE_POLICY
|
||||
default:
|
||||
return sharedv1.PaymentFailureCode_FAILURE_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
func inferRail(kind string, stepCode string) gatewayv1.Rail {
|
||||
all := strings.ToLower(strings.TrimSpace(kind + " " + stepCode))
|
||||
switch {
|
||||
case strings.Contains(all, "ledger"):
|
||||
return gatewayv1.Rail_RAIL_LEDGER
|
||||
case strings.Contains(all, "card_payout"), strings.Contains(all, "card"):
|
||||
return gatewayv1.Rail_RAIL_CARD_PAYOUT
|
||||
case strings.Contains(all, "provider_settlement"), strings.Contains(all, "provider"):
|
||||
return gatewayv1.Rail_RAIL_PROVIDER_SETTLEMENT
|
||||
case strings.Contains(all, "fiat_onramp"), strings.Contains(all, "onramp"):
|
||||
return gatewayv1.Rail_RAIL_FIAT_ONRAMP
|
||||
case strings.Contains(all, "crypto"), strings.Contains(all, "chain"), strings.Contains(all, "tx"):
|
||||
return gatewayv1.Rail_RAIL_CRYPTO
|
||||
default:
|
||||
return gatewayv1.Rail_RAIL_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package prmap
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2"
|
||||
)
|
||||
|
||||
func mapStepExecutions(src []agg.StepExecution) ([]*orchestrationv2.StepExecution, error) {
|
||||
if len(src) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
out := make([]*orchestrationv2.StepExecution, 0, len(src))
|
||||
for i := range src {
|
||||
mapped, err := mapStepExecution(src[i], i)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, mapped)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func mapStepExecution(step agg.StepExecution, index int) (*orchestrationv2.StepExecution, error) {
|
||||
state, ok := normalizeStepState(step.State)
|
||||
if !ok {
|
||||
return nil, merrors.InvalidArgument("payment.step_executions[" + itoa(index) + "].state is invalid")
|
||||
}
|
||||
|
||||
attempt := step.Attempt
|
||||
if attempt == 0 {
|
||||
attempt = 1
|
||||
}
|
||||
|
||||
return &orchestrationv2.StepExecution{
|
||||
StepRef: strings.TrimSpace(step.StepRef),
|
||||
StepCode: strings.TrimSpace(step.StepCode),
|
||||
State: mapStepState(state),
|
||||
Attempt: attempt,
|
||||
StartedAt: tsOrNil(derefTime(step.StartedAt)),
|
||||
CompletedAt: tsOrNil(derefTime(step.CompletedAt)),
|
||||
Failure: mapStepFailure(step, state),
|
||||
Refs: mapExternalRefs(step.StepCode, step.ExternalRefs),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func mapStepFailure(step agg.StepExecution, state agg.StepState) *orchestrationv2.Failure {
|
||||
if state != agg.StepStateFailed && state != agg.StepStateNeedsAttention {
|
||||
return nil
|
||||
}
|
||||
code := strings.TrimSpace(step.FailureCode)
|
||||
msg := strings.TrimSpace(step.FailureMsg)
|
||||
if code == "" && msg == "" {
|
||||
return nil
|
||||
}
|
||||
return &orchestrationv2.Failure{
|
||||
Category: inferFailureCategory(code),
|
||||
Code: code,
|
||||
Message: msg,
|
||||
}
|
||||
}
|
||||
|
||||
func mapExternalRefs(stepCode string, refs []agg.ExternalRef) []*orchestrationv2.ExternalReference {
|
||||
if len(refs) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]*orchestrationv2.ExternalReference, 0, len(refs))
|
||||
for i := range refs {
|
||||
ref := refs[i]
|
||||
kind := strings.TrimSpace(ref.Kind)
|
||||
value := strings.TrimSpace(ref.Ref)
|
||||
gatewayInstanceID := strings.TrimSpace(ref.GatewayInstanceID)
|
||||
if kind == "" && value == "" && gatewayInstanceID == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, &orchestrationv2.ExternalReference{
|
||||
Rail: inferRail(kind, stepCode),
|
||||
GatewayInstanceId: gatewayInstanceID,
|
||||
Kind: kind,
|
||||
Ref: value,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func derefTime(value *time.Time) time.Time {
|
||||
if value == nil {
|
||||
return time.Time{}
|
||||
}
|
||||
return value.UTC()
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package psvc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/oobs"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (s *svc) recomputeAggregateState(ctx context.Context, payment *agg.Payment) (bool, error) {
|
||||
logger := s.logger
|
||||
if payment == nil {
|
||||
return false, nil
|
||||
}
|
||||
current := payment.State
|
||||
target := s.deriveAggregateTarget(payment)
|
||||
next, changed, err := s.transitionAggregateState(current, target)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !changed {
|
||||
return false, nil
|
||||
}
|
||||
payment.State = next
|
||||
logger.Debug("psvc.payment_state_changed",
|
||||
zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef)),
|
||||
zap.String("from_state", string(current)),
|
||||
zap.String("to_state", string(next)),
|
||||
zap.Uint64("version", payment.Version),
|
||||
)
|
||||
if next == agg.StateSettled || next == agg.StateNeedsAttention || next == agg.StateFailed {
|
||||
logger.Debug("psvc.payment_finalization_state",
|
||||
zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef)),
|
||||
zap.String("state", string(next)),
|
||||
zap.Uint64("version", payment.Version),
|
||||
)
|
||||
}
|
||||
return true, s.observer.RecordPayment(ctx, oobs.RecordPaymentInput{
|
||||
Payment: payment,
|
||||
Event: paymentEventForState(next),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *svc) deriveAggregateTarget(payment *agg.Payment) agg.State {
|
||||
if payment == nil {
|
||||
return agg.StateUnspecified
|
||||
}
|
||||
if s.state.IsAggregateTerminal(payment.State) {
|
||||
return payment.State
|
||||
}
|
||||
if len(payment.StepExecutions) == 0 {
|
||||
return agg.StateCreated
|
||||
}
|
||||
|
||||
allDone := true
|
||||
for i := range payment.StepExecutions {
|
||||
step := payment.StepExecutions[i]
|
||||
switch step.State {
|
||||
case agg.StepStateNeedsAttention:
|
||||
return agg.StateNeedsAttention
|
||||
case agg.StepStateFailed:
|
||||
if step.Attempt >= s.maxAttemptsForStep(step.StepRef) {
|
||||
return agg.StateNeedsAttention
|
||||
}
|
||||
allDone = false
|
||||
case agg.StepStateCompleted, agg.StepStateSkipped:
|
||||
default:
|
||||
allDone = false
|
||||
}
|
||||
}
|
||||
if allDone {
|
||||
return agg.StateSettled
|
||||
}
|
||||
return agg.StateExecuting
|
||||
}
|
||||
|
||||
func (s *svc) transitionAggregateState(current, target agg.State) (agg.State, bool, error) {
|
||||
if current == target {
|
||||
return current, false, nil
|
||||
}
|
||||
if s.state.IsAggregateTerminal(current) {
|
||||
return current, false, nil
|
||||
}
|
||||
if err := s.state.EnsureAggregateTransition(current, target); err == nil {
|
||||
return target, true, nil
|
||||
}
|
||||
if current == agg.StateCreated {
|
||||
if err := s.state.EnsureAggregateTransition(current, agg.StateExecuting); err == nil {
|
||||
current = agg.StateExecuting
|
||||
}
|
||||
}
|
||||
if current == agg.StateNeedsAttention && target == agg.StateCreated {
|
||||
if err := s.state.EnsureAggregateTransition(current, agg.StateExecuting); err == nil {
|
||||
current = agg.StateExecuting
|
||||
}
|
||||
}
|
||||
if current == target {
|
||||
return current, true, nil
|
||||
}
|
||||
if err := s.state.EnsureAggregateTransition(current, target); err != nil {
|
||||
return current, false, nil
|
||||
}
|
||||
return target, true, nil
|
||||
}
|
||||
|
||||
func paymentEventForState(state agg.State) oobs.PaymentEvent {
|
||||
switch state {
|
||||
case agg.StateNeedsAttention:
|
||||
return oobs.PaymentEventNeedsAttention
|
||||
case agg.StateSettled:
|
||||
return oobs.PaymentEventSettled
|
||||
case agg.StateFailed:
|
||||
return oobs.PaymentEventFailed
|
||||
default:
|
||||
return oobs.PaymentEventStateChanged
|
||||
}
|
||||
}
|
||||
|
||||
func (s *svc) maxAttemptsForStep(stepRef string) uint32 {
|
||||
if s.retryPolicy.MaxAttemptsByStepRef != nil {
|
||||
if maxAttempts := s.retryPolicy.MaxAttemptsByStepRef[stepRef]; maxAttempts > 0 {
|
||||
return maxAttempts
|
||||
}
|
||||
}
|
||||
if s.retryPolicy.MaxAttempts > 0 {
|
||||
return s.retryPolicy.MaxAttempts
|
||||
}
|
||||
return 1
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package psvc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/sexec"
|
||||
)
|
||||
|
||||
type defaultLedgerExecutor struct{}
|
||||
type defaultCryptoExecutor struct{}
|
||||
type defaultProviderSettlementExecutor struct{}
|
||||
type defaultCardPayoutExecutor struct{}
|
||||
type defaultObserveConfirmExecutor struct{}
|
||||
|
||||
func (defaultLedgerExecutor) ExecuteLedger(_ context.Context, req sexec.StepRequest) (*sexec.ExecuteOutput, error) {
|
||||
step := req.StepExecution
|
||||
step.State = agg.StepStateCompleted
|
||||
step.FailureCode = ""
|
||||
step.FailureMsg = ""
|
||||
return &sexec.ExecuteOutput{StepExecution: step}, nil
|
||||
}
|
||||
|
||||
func (defaultCryptoExecutor) ExecuteCrypto(_ context.Context, req sexec.StepRequest) (*sexec.ExecuteOutput, error) {
|
||||
return asyncOutput(req.StepExecution, "operation_ref", "crypto:"+req.Step.StepRef), nil
|
||||
}
|
||||
|
||||
func (defaultProviderSettlementExecutor) ExecuteProviderSettlement(_ context.Context, req sexec.StepRequest) (*sexec.ExecuteOutput, error) {
|
||||
return asyncOutput(req.StepExecution, "operation_ref", "provider:"+req.Step.StepRef), nil
|
||||
}
|
||||
|
||||
func (defaultCardPayoutExecutor) ExecuteCardPayout(_ context.Context, req sexec.StepRequest) (*sexec.ExecuteOutput, error) {
|
||||
return asyncOutput(req.StepExecution, "card_payout_ref", "card:"+req.Step.StepRef), nil
|
||||
}
|
||||
|
||||
func (defaultObserveConfirmExecutor) ExecuteObserveConfirm(_ context.Context, req sexec.StepRequest) (*sexec.ExecuteOutput, error) {
|
||||
return asyncOutput(req.StepExecution, "operation_ref", "observe:"+req.Step.StepRef), nil
|
||||
}
|
||||
|
||||
func asyncOutput(step agg.StepExecution, kind, ref string) *sexec.ExecuteOutput {
|
||||
step.State = agg.StepStateRunning
|
||||
step.ExternalRefs = append(step.ExternalRefs, agg.ExternalRef{
|
||||
Kind: strings.TrimSpace(kind),
|
||||
Ref: strings.TrimSpace(ref),
|
||||
})
|
||||
step.FailureCode = ""
|
||||
step.FailureMsg = ""
|
||||
return &sexec.ExecuteOutput{
|
||||
StepExecution: step,
|
||||
Async: true,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
package psvc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/idem"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/oobs"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/prepo"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/qsnap"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/reqval"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (s *svc) ExecutePayment(ctx context.Context, req *orchestrationv2.ExecutePaymentRequest) (resp *orchestrationv2.ExecutePaymentResponse, err error) {
|
||||
logger := s.logger
|
||||
orgRef := ""
|
||||
if req != nil && req.GetMeta() != nil {
|
||||
orgRef = strings.TrimSpace(req.GetMeta().GetOrganizationRef())
|
||||
}
|
||||
logger.Debug("Starting Execute payment",
|
||||
zap.String("organization_ref", orgRef),
|
||||
zap.String("quotation_ref", strings.TrimSpace(req.GetQuotationRef())),
|
||||
zap.String("intent_ref", strings.TrimSpace(req.GetIntentRef())),
|
||||
zap.Bool("has_client_payment_ref", strings.TrimSpace(req.GetClientPaymentRef()) != ""),
|
||||
)
|
||||
defer func(start time.Time) {
|
||||
fields := []zap.Field{zap.Int64("duration_ms", time.Since(start).Milliseconds())}
|
||||
if resp != nil && resp.Payment != nil {
|
||||
fields = append(fields,
|
||||
zap.String("payment_ref", strings.TrimSpace(resp.Payment.GetPaymentRef())),
|
||||
zap.String("state", resp.Payment.GetState().String()),
|
||||
zap.Uint64("version", resp.Payment.GetVersion()),
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
logger.Warn("Failed to execute payment", append(fields, zap.Error(err))...)
|
||||
return
|
||||
}
|
||||
logger.Debug("Completed Execute payment", fields...)
|
||||
}(time.Now())
|
||||
|
||||
requestCtx, fingerprint, err := s.prepareExecute(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
payment, reused, err := s.tryReuse(ctx, requestCtx, fingerprint)
|
||||
if err != nil {
|
||||
return nil, remapIdempotencyError(err)
|
||||
}
|
||||
if !reused {
|
||||
payment, err = s.createNewPayment(ctx, requestCtx)
|
||||
if err != nil {
|
||||
return nil, remapIdempotencyError(err)
|
||||
}
|
||||
}
|
||||
if payment != nil {
|
||||
logger.Debug("psvc.payment_started",
|
||||
zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef)),
|
||||
zap.Bool("reused", reused),
|
||||
zap.String("state", string(payment.State)),
|
||||
)
|
||||
}
|
||||
|
||||
payment, err = s.runRuntime(ctx, payment)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if payment != nil {
|
||||
logger.Debug("psvc.payment_execution_progressed",
|
||||
zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef)),
|
||||
zap.String("state", string(payment.State)),
|
||||
zap.Uint64("version", payment.Version),
|
||||
)
|
||||
}
|
||||
protoPayment, err := s.mapPayment(payment)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if payment != nil {
|
||||
logger.Debug("psvc.payment_finalized",
|
||||
zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef)),
|
||||
zap.String("state", string(payment.State)),
|
||||
zap.Uint64("version", payment.Version),
|
||||
)
|
||||
}
|
||||
resp = &orchestrationv2.ExecutePaymentResponse{Payment: protoPayment}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s *svc) prepareExecute(req *orchestrationv2.ExecutePaymentRequest) (*reqval.Ctx, string, error) {
|
||||
requestCtx, err := s.validator.Validate(mapExecuteReq(req))
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
fingerprint, err := s.idempotency.Fingerprint(idem.FPInput{
|
||||
OrganizationRef: requestCtx.OrganizationRef,
|
||||
QuotationRef: requestCtx.QuotationRef,
|
||||
IntentRef: requestCtx.IntentRef,
|
||||
ClientPaymentRef: requestCtx.ClientPaymentRef,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return requestCtx, fingerprint, nil
|
||||
}
|
||||
|
||||
func mapExecuteReq(req *orchestrationv2.ExecutePaymentRequest) *reqval.Req {
|
||||
if req == nil {
|
||||
return nil
|
||||
}
|
||||
out := &reqval.Req{
|
||||
QuotationRef: req.GetQuotationRef(),
|
||||
IntentRef: req.GetIntentRef(),
|
||||
ClientPaymentRef: req.GetClientPaymentRef(),
|
||||
}
|
||||
meta := req.GetMeta()
|
||||
if meta == nil {
|
||||
return out
|
||||
}
|
||||
out.Meta = &reqval.Meta{OrganizationRef: meta.GetOrganizationRef()}
|
||||
if meta.GetTrace() != nil {
|
||||
out.Meta.Trace = &reqval.Trace{IdempotencyKey: meta.GetTrace().GetIdempotencyKey()}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *svc) tryReuse(ctx context.Context, requestCtx *reqval.Ctx, requestFingerprint string) (*agg.Payment, bool, error) {
|
||||
existing, err := s.repository.GetByIdempotencyKey(ctx, requestCtx.OrganizationID, requestCtx.IdempotencyKey)
|
||||
if err != nil {
|
||||
if errors.Is(err, prepo.ErrPaymentNotFound) || errors.Is(err, merrors.ErrNoData) {
|
||||
return nil, false, nil
|
||||
}
|
||||
return nil, false, err
|
||||
}
|
||||
if existing == nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
existingFingerprint, err := s.idempotency.Fingerprint(idem.FPInput{
|
||||
OrganizationRef: requestCtx.OrganizationRef,
|
||||
QuotationRef: existing.QuotationRef,
|
||||
IntentRef: existing.IntentSnapshot.Ref,
|
||||
ClientPaymentRef: existing.ClientPaymentRef,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if strings.TrimSpace(existingFingerprint) != strings.TrimSpace(requestFingerprint) {
|
||||
return nil, false, idem.ErrIdempotencyParamMismatch
|
||||
}
|
||||
return existing, true, nil
|
||||
}
|
||||
|
||||
func (s *svc) createNewPayment(ctx context.Context, requestCtx *reqval.Ctx) (*agg.Payment, error) {
|
||||
resolved, graph, err := s.resolveAndPlan(ctx, requestCtx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payment, err := s.aggregate.Create(agg.Input{
|
||||
OrganizationRef: requestCtx.OrganizationID,
|
||||
IdempotencyKey: requestCtx.IdempotencyKey,
|
||||
QuotationRef: resolved.QuotationRef,
|
||||
ClientPaymentRef: requestCtx.ClientPaymentRef,
|
||||
IntentSnapshot: resolved.IntentSnapshot,
|
||||
QuoteSnapshot: resolved.QuoteSnapshot,
|
||||
Steps: toStepShells(graph),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.repository.Create(ctx, payment); err != nil {
|
||||
if !errors.Is(err, prepo.ErrDuplicatePayment) {
|
||||
return nil, err
|
||||
}
|
||||
reused, ok, reuseErr := s.tryReuse(ctx, requestCtx, mustFingerprint(s.idempotency, requestCtx))
|
||||
if reuseErr != nil {
|
||||
return nil, reuseErr
|
||||
}
|
||||
if ok {
|
||||
return reused, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.recordPaymentCreated(ctx, payment, graph); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return payment, nil
|
||||
}
|
||||
|
||||
func (s *svc) resolveAndPlan(ctx context.Context, requestCtx *reqval.Ctx) (*qsnap.Output, *xplan.Graph, error) {
|
||||
resolved, err := s.quote.Resolve(ctx, s.quoteStore, qsnap.Input{
|
||||
OrganizationID: requestCtx.OrganizationID,
|
||||
QuotationRef: requestCtx.QuotationRef,
|
||||
IntentRef: requestCtx.IntentRef,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
graph, err := s.planner.Compile(xplan.Input{
|
||||
IntentSnapshot: resolved.IntentSnapshot,
|
||||
QuoteSnapshot: resolved.QuoteSnapshot,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return resolved, graph, nil
|
||||
}
|
||||
|
||||
func toStepShells(graph *xplan.Graph) []agg.StepShell {
|
||||
if graph == nil || len(graph.Steps) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]agg.StepShell, 0, len(graph.Steps))
|
||||
for i := range graph.Steps {
|
||||
out = append(out, agg.StepShell{
|
||||
StepRef: graph.Steps[i].StepRef,
|
||||
StepCode: graph.Steps[i].StepCode,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *svc) recordPaymentCreated(ctx context.Context, payment *agg.Payment, graph *xplan.Graph) error {
|
||||
if err := s.observer.RecordPayment(ctx, oobs.RecordPaymentInput{
|
||||
Payment: payment,
|
||||
Event: oobs.PaymentEventCreated,
|
||||
Fields: map[string]string{
|
||||
"route_ref": graphRouteRef(graph),
|
||||
},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range payment.StepExecutions {
|
||||
if err := s.observer.RecordStep(ctx, oobs.RecordStepInput{
|
||||
PaymentRef: payment.PaymentRef,
|
||||
Step: payment.StepExecutions[i],
|
||||
Event: oobs.StepEventScheduled,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func graphRouteRef(graph *xplan.Graph) string {
|
||||
if graph == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(graph.RouteRef)
|
||||
}
|
||||
|
||||
func remapIdempotencyError(err error) error {
|
||||
if errors.Is(err, idem.ErrIdempotencyParamMismatch) {
|
||||
return merrors.InvalidArgument(err.Error())
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func mustFingerprint(idemSvc idem.Service, requestCtx *reqval.Ctx) string {
|
||||
if idemSvc == nil || requestCtx == nil {
|
||||
return ""
|
||||
}
|
||||
value, err := idemSvc.Fingerprint(idem.FPInput{
|
||||
OrganizationRef: requestCtx.OrganizationRef,
|
||||
QuotationRef: requestCtx.QuotationRef,
|
||||
IntentRef: requestCtx.IntentRef,
|
||||
ClientPaymentRef: requestCtx.ClientPaymentRef,
|
||||
})
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package psvc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/erecon"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/oobs"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/prepo"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (s *svc) ReconcileExternal(ctx context.Context, in ReconcileExternalInput) (out *ReconcileExternalOutput, err error) {
|
||||
logger := s.logger
|
||||
logger.Debug("Starting Reconcile external",
|
||||
zap.String("organization_ref", strings.TrimSpace(in.OrganizationRef)),
|
||||
zap.String("payment_ref", strings.TrimSpace(in.PaymentRef)),
|
||||
zap.String("event_source", externalEventSource(in.Event)),
|
||||
zap.String("event_status", externalEventStatus(in.Event)),
|
||||
)
|
||||
defer func(start time.Time) {
|
||||
fields := []zap.Field{zap.Int64("duration_ms", time.Since(start).Milliseconds())}
|
||||
if out != nil && out.Payment != nil {
|
||||
fields = append(fields,
|
||||
zap.String("state", out.Payment.GetState().String()),
|
||||
zap.Uint64("version", out.Payment.GetVersion()),
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
logger.Warn("Failed to reconcile external", append(fields, zap.Error(err))...)
|
||||
return
|
||||
}
|
||||
logger.Debug("Completed Reconcile external", fields...)
|
||||
}(time.Now())
|
||||
|
||||
orgRef := strings.TrimSpace(in.OrganizationRef)
|
||||
if orgRef == "" {
|
||||
return nil, merrors.InvalidArgument("organization_ref is required")
|
||||
}
|
||||
orgID, err := bson.ObjectIDFromHex(orgRef)
|
||||
if err != nil {
|
||||
return nil, merrors.InvalidArgument("organization_ref must be a valid objectID")
|
||||
}
|
||||
paymentRef, err := parsePaymentRef(in.PaymentRef)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
payment, err := s.repository.GetByPaymentRef(ctx, orgID, paymentRef)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if payment == nil {
|
||||
return nil, prepo.ErrPaymentNotFound
|
||||
}
|
||||
|
||||
reconOut, err := s.reconciler.Reconcile(erecon.Input{
|
||||
Payment: payment,
|
||||
Event: in.Event,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if reconOut == nil || reconOut.Payment == nil {
|
||||
return nil, merrors.Internal("reconciler returned nil payment")
|
||||
}
|
||||
|
||||
if err := s.recordExternal(ctx, reconOut.Payment, in.Event, reconOut.MatchedStepRef); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if reconOut.StepChanged || reconOut.AggregateChanged {
|
||||
if err := s.repository.UpdateCAS(ctx, reconOut.Payment, payment.Version); err != nil {
|
||||
if errors.Is(err, prepo.ErrVersionConflict) {
|
||||
fresh, reloadErr := s.repository.GetByPaymentRef(ctx, orgID, paymentRef)
|
||||
if reloadErr != nil {
|
||||
return nil, reloadErr
|
||||
}
|
||||
reconOut.Payment = fresh
|
||||
} else {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
advanced, err := s.runRuntime(ctx, reconOut.Payment)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mapped, err := s.mapPayment(advanced)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = &ReconcileExternalOutput{Payment: mapped}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *svc) recordExternal(ctx context.Context, payment *agg.Payment, event erecon.Event, matchedStepRef string) error {
|
||||
input, ok := buildExternalRecordInput(payment, event, matchedStepRef)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return s.observer.RecordExternal(ctx, input)
|
||||
}
|
||||
|
||||
func buildExternalRecordInput(payment *agg.Payment, event erecon.Event, matchedStepRef string) (oobs.RecordExternalInput, bool) {
|
||||
if payment == nil {
|
||||
return oobs.RecordExternalInput{}, false
|
||||
}
|
||||
stepRef := strings.TrimSpace(matchedStepRef)
|
||||
if stepRef == "" {
|
||||
if event.Gateway != nil {
|
||||
stepRef = strings.TrimSpace(event.Gateway.StepRef)
|
||||
} else if event.Ledger != nil {
|
||||
stepRef = strings.TrimSpace(event.Ledger.StepRef)
|
||||
} else if event.Card != nil {
|
||||
stepRef = strings.TrimSpace(event.Card.StepRef)
|
||||
}
|
||||
}
|
||||
if stepRef == "" {
|
||||
return oobs.RecordExternalInput{}, false
|
||||
}
|
||||
attempt := stepAttempt(payment.StepExecutions, stepRef)
|
||||
if attempt == 0 {
|
||||
attempt = 1
|
||||
}
|
||||
|
||||
in := oobs.RecordExternalInput{
|
||||
PaymentRef: payment.PaymentRef,
|
||||
StepRef: stepRef,
|
||||
Attempt: attempt,
|
||||
}
|
||||
switch {
|
||||
case event.Gateway != nil:
|
||||
in.Source = oobs.ExternalSourceGateway
|
||||
in.Status = strings.TrimSpace(string(event.Gateway.Status))
|
||||
in.RefKind = erecon.ExternalRefKindOperation
|
||||
in.Ref = firstNonEmpty(event.Gateway.OperationRef, event.Gateway.TransferRef)
|
||||
if strings.TrimSpace(event.Gateway.TransferRef) != "" {
|
||||
in.RefKind = erecon.ExternalRefKindTransfer
|
||||
}
|
||||
in.Message = strings.TrimSpace(event.Gateway.FailureMsg)
|
||||
case event.Ledger != nil:
|
||||
in.Source = oobs.ExternalSourceLedger
|
||||
in.Status = strings.TrimSpace(string(event.Ledger.Status))
|
||||
in.RefKind = erecon.ExternalRefKindLedger
|
||||
in.Ref = strings.TrimSpace(event.Ledger.EntryRef)
|
||||
in.Message = strings.TrimSpace(event.Ledger.FailureMsg)
|
||||
case event.Card != nil:
|
||||
in.Source = oobs.ExternalSourceCard
|
||||
in.Status = strings.TrimSpace(string(event.Card.Status))
|
||||
in.RefKind = erecon.ExternalRefKindCardPayout
|
||||
in.Ref = strings.TrimSpace(event.Card.PayoutRef)
|
||||
in.Message = strings.TrimSpace(event.Card.FailureMsg)
|
||||
default:
|
||||
return oobs.RecordExternalInput{}, false
|
||||
}
|
||||
return in, true
|
||||
}
|
||||
|
||||
func stepAttempt(steps []agg.StepExecution, stepRef string) uint32 {
|
||||
ref := strings.TrimSpace(stepRef)
|
||||
for i := range steps {
|
||||
if strings.TrimSpace(steps[i].StepRef) == ref {
|
||||
return steps[i].Attempt
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for i := range values {
|
||||
if val := strings.TrimSpace(values[i]); val != "" {
|
||||
return val
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func externalEventSource(event erecon.Event) string {
|
||||
switch {
|
||||
case event.Gateway != nil:
|
||||
return "gateway"
|
||||
case event.Ledger != nil:
|
||||
return "ledger"
|
||||
case event.Card != nil:
|
||||
return "card"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
func externalEventStatus(event erecon.Event) string {
|
||||
switch {
|
||||
case event.Gateway != nil:
|
||||
return strings.TrimSpace(string(event.Gateway.Status))
|
||||
case event.Ledger != nil:
|
||||
return strings.TrimSpace(string(event.Ledger.Status))
|
||||
case event.Card != nil:
|
||||
return strings.TrimSpace(string(event.Card.Status))
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package psvc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/erecon"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/idem"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/oobs"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/ostate"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/pquery"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/prepo"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/prmap"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/qsnap"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/reqval"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/sexec"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/ssched"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2"
|
||||
)
|
||||
|
||||
// Service orchestrates execute/query/reconcile payment runtime operations.
|
||||
type Service interface {
|
||||
ExecutePayment(ctx context.Context, req *orchestrationv2.ExecutePaymentRequest) (*orchestrationv2.ExecutePaymentResponse, error)
|
||||
GetPayment(ctx context.Context, req *orchestrationv2.GetPaymentRequest) (*orchestrationv2.GetPaymentResponse, error)
|
||||
ListPayments(ctx context.Context, req *orchestrationv2.ListPaymentsRequest) (*orchestrationv2.ListPaymentsResponse, error)
|
||||
|
||||
ReconcileExternal(ctx context.Context, in ReconcileExternalInput) (*ReconcileExternalOutput, error)
|
||||
}
|
||||
|
||||
// ReconcileExternalInput is one internal external-event payload.
|
||||
type ReconcileExternalInput struct {
|
||||
OrganizationRef string
|
||||
PaymentRef string
|
||||
Event erecon.Event
|
||||
}
|
||||
|
||||
// ReconcileExternalOutput is reconciliation result payload.
|
||||
type ReconcileExternalOutput struct {
|
||||
Payment *orchestrationv2.Payment
|
||||
}
|
||||
|
||||
// Dependencies configures orchestration-v2 runtime modules.
|
||||
type Dependencies struct {
|
||||
Logger mlogger.Logger
|
||||
|
||||
QuoteStore qsnap.Store
|
||||
|
||||
Validator reqval.Validator
|
||||
Idempotency idem.Service
|
||||
Quote qsnap.Resolver
|
||||
Aggregate agg.Factory
|
||||
Planner xplan.Compiler
|
||||
State ostate.StateMachine
|
||||
Scheduler ssched.Runtime
|
||||
Executors sexec.Registry
|
||||
Reconciler erecon.Reconciler
|
||||
Repository prepo.Repository
|
||||
Query pquery.Service
|
||||
Mapper prmap.Mapper
|
||||
Observer oobs.Observer
|
||||
|
||||
RetryPolicy ssched.RetryPolicy
|
||||
Now func() time.Time
|
||||
MaxTicks int
|
||||
}
|
||||
|
||||
func New(deps Dependencies) (Service, error) {
|
||||
return newService(deps)
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package psvc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/pquery"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/prmap"
|
||||
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
|
||||
orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (s *svc) GetPayment(ctx context.Context, req *orchestrationv2.GetPaymentRequest) (resp *orchestrationv2.GetPaymentResponse, err error) {
|
||||
logger := s.logger
|
||||
orgRef := ""
|
||||
if req != nil && req.GetMeta() != nil {
|
||||
orgRef = strings.TrimSpace(req.GetMeta().GetOrganizationRef())
|
||||
}
|
||||
logger.Debug("Starting Get payment",
|
||||
zap.String("organization_ref", orgRef),
|
||||
zap.String("payment_ref", strings.TrimSpace(req.GetPaymentRef())),
|
||||
)
|
||||
defer func(start time.Time) {
|
||||
fields := []zap.Field{zap.Int64("duration_ms", time.Since(start).Milliseconds())}
|
||||
if resp != nil && resp.Payment != nil {
|
||||
fields = append(fields,
|
||||
zap.String("state", resp.Payment.GetState().String()),
|
||||
zap.Uint64("version", resp.Payment.GetVersion()),
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
logger.Warn("Failed to get payment", append(fields, zap.Error(err))...)
|
||||
return
|
||||
}
|
||||
logger.Debug("Completed Get payment", fields...)
|
||||
}(time.Now())
|
||||
|
||||
_, orgID, err := parseOrganization(req.GetMeta())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
paymentRef, err := parsePaymentRef(req.GetPaymentRef())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
payment, err := s.query.GetPayment(ctx, pquery.GetPaymentInput{
|
||||
OrganizationRef: orgID,
|
||||
PaymentRef: paymentRef,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
protoPayment, err := s.mapPayment(payment)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp = &orchestrationv2.GetPaymentResponse{Payment: protoPayment}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s *svc) ListPayments(ctx context.Context, req *orchestrationv2.ListPaymentsRequest) (resp *orchestrationv2.ListPaymentsResponse, err error) {
|
||||
logger := s.logger
|
||||
orgRef := ""
|
||||
if req != nil && req.GetMeta() != nil {
|
||||
orgRef = strings.TrimSpace(req.GetMeta().GetOrganizationRef())
|
||||
}
|
||||
logger.Debug("Starting List payments",
|
||||
zap.String("organization_ref", orgRef),
|
||||
zap.String("quotation_ref", strings.TrimSpace(req.GetQuotationRef())),
|
||||
zap.Int("states_count", len(req.GetStates())),
|
||||
)
|
||||
defer func(start time.Time) {
|
||||
fields := []zap.Field{zap.Int64("duration_ms", time.Since(start).Milliseconds())}
|
||||
if resp != nil {
|
||||
fields = append(fields, zap.Int("payments_count", len(resp.GetPayments())))
|
||||
}
|
||||
if err != nil {
|
||||
logger.Warn("Failed to list payments", append(fields, zap.Error(err))...)
|
||||
return
|
||||
}
|
||||
logger.Debug("Completed List payments", fields...)
|
||||
}(time.Now())
|
||||
|
||||
_, orgID, err := parseOrganization(req.GetMeta())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
states, err := mapStates(req.GetStates())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
createdFrom, err := parseCreated(req.GetCreatedFrom(), "created_from")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
createdTo, err := parseCreated(req.GetCreatedTo(), "created_to")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cursor, err := parseCursor(req.GetPage().GetCursor())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
limit := int32(0)
|
||||
if req.GetPage() != nil {
|
||||
limit = req.GetPage().GetLimit()
|
||||
}
|
||||
|
||||
page, err := s.query.ListPayments(ctx, pquery.ListPaymentsInput{
|
||||
OrganizationRef: orgID,
|
||||
States: states,
|
||||
QuotationRef: strings.TrimSpace(req.GetQuotationRef()),
|
||||
CreatedFrom: createdFrom,
|
||||
CreatedTo: createdTo,
|
||||
Cursor: cursor,
|
||||
Limit: limit,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
items, err := s.mapPayments(page.Items)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nextCursor, err := formatCursor(page.NextCursor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp = &orchestrationv2.ListPaymentsResponse{
|
||||
Payments: items,
|
||||
Page: &paginationv1.CursorPageResponse{NextCursor: nextCursor},
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s *svc) mapPayments(items []*agg.Payment) ([]*orchestrationv2.Payment, error) {
|
||||
if len(items) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
out := make([]*orchestrationv2.Payment, 0, len(items))
|
||||
for i := range items {
|
||||
mapped, err := s.mapPayment(items[i])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, mapped)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *svc) mapPayment(payment *agg.Payment) (*orchestrationv2.Payment, error) {
|
||||
mapped, err := s.mapper.Map(prmap.MapInput{Payment: payment})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if mapped == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return mapped.Payment, nil
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package psvc
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/prepo"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2"
|
||||
sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
type listCursorPayload struct {
|
||||
CreatedAt string `json:"created_at"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
func parseOrganization(meta *sharedv1.RequestMeta) (string, bson.ObjectID, error) {
|
||||
if meta == nil {
|
||||
return "", bson.NilObjectID, merrors.InvalidArgument("meta is required")
|
||||
}
|
||||
orgRef := strings.TrimSpace(meta.GetOrganizationRef())
|
||||
if orgRef == "" {
|
||||
return "", bson.NilObjectID, merrors.InvalidArgument("meta.organization_ref is required")
|
||||
}
|
||||
orgID, err := bson.ObjectIDFromHex(orgRef)
|
||||
if err != nil {
|
||||
return "", bson.NilObjectID, merrors.InvalidArgument("meta.organization_ref must be a valid objectID")
|
||||
}
|
||||
return orgRef, orgID, nil
|
||||
}
|
||||
|
||||
func parsePaymentRef(value string) (string, error) {
|
||||
ref := strings.TrimSpace(value)
|
||||
if ref == "" {
|
||||
return "", merrors.InvalidArgument("payment_ref is required")
|
||||
}
|
||||
return ref, nil
|
||||
}
|
||||
|
||||
func parseCursor(value string) (*prepo.ListCursor, error) {
|
||||
raw := strings.TrimSpace(value)
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
data, err := base64.RawURLEncoding.DecodeString(raw)
|
||||
if err != nil {
|
||||
return nil, merrors.InvalidArgument("page.cursor is invalid")
|
||||
}
|
||||
var payload listCursorPayload
|
||||
if err := json.Unmarshal(data, &payload); err != nil {
|
||||
return nil, merrors.InvalidArgument("page.cursor is invalid")
|
||||
}
|
||||
id, err := bson.ObjectIDFromHex(strings.TrimSpace(payload.ID))
|
||||
if err != nil {
|
||||
return nil, merrors.InvalidArgument("page.cursor is invalid")
|
||||
}
|
||||
createdAt, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(payload.CreatedAt))
|
||||
if err != nil {
|
||||
return nil, merrors.InvalidArgument("page.cursor is invalid")
|
||||
}
|
||||
return &prepo.ListCursor{
|
||||
CreatedAt: createdAt.UTC(),
|
||||
ID: id,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func formatCursor(cursor *prepo.ListCursor) (string, error) {
|
||||
if cursor == nil || cursor.ID.IsZero() || cursor.CreatedAt.IsZero() {
|
||||
return "", nil
|
||||
}
|
||||
data, err := json.Marshal(listCursorPayload{
|
||||
CreatedAt: cursor.CreatedAt.UTC().Format(time.RFC3339Nano),
|
||||
ID: cursor.ID.Hex(),
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(data), nil
|
||||
}
|
||||
|
||||
func parseCreated(ts *timestamppb.Timestamp, field string) (*time.Time, error) {
|
||||
if ts == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if err := ts.CheckValid(); err != nil {
|
||||
return nil, merrors.InvalidArgument(field + " is invalid")
|
||||
}
|
||||
value := ts.AsTime().UTC()
|
||||
return &value, nil
|
||||
}
|
||||
|
||||
func mapStates(states []orchestrationv2.OrchestrationState) ([]agg.State, error) {
|
||||
if len(states) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
out := make([]agg.State, 0, len(states))
|
||||
for i := range states {
|
||||
state, ok := mapState(states[i])
|
||||
if !ok {
|
||||
return nil, merrors.InvalidArgument("states contains invalid value")
|
||||
}
|
||||
out = append(out, state)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func mapState(state orchestrationv2.OrchestrationState) (agg.State, bool) {
|
||||
switch state {
|
||||
case orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_CREATED:
|
||||
return agg.StateCreated, true
|
||||
case orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_EXECUTING:
|
||||
return agg.StateExecuting, true
|
||||
case orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_NEEDS_ATTENTION:
|
||||
return agg.StateNeedsAttention, true
|
||||
case orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_SETTLED:
|
||||
return agg.StateSettled, true
|
||||
case orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_FAILED:
|
||||
return agg.StateFailed, true
|
||||
default:
|
||||
return agg.StateUnspecified, false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
package psvc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/oobs"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/prepo"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/sexec"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/ssched"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (s *svc) runRuntime(ctx context.Context, payment *agg.Payment) (*agg.Payment, error) {
|
||||
logger := s.logger
|
||||
paymentRef := ""
|
||||
state := agg.StateUnspecified
|
||||
stepCount := 0
|
||||
if payment != nil {
|
||||
paymentRef = strings.TrimSpace(payment.PaymentRef)
|
||||
state = payment.State
|
||||
stepCount = len(payment.StepExecutions)
|
||||
}
|
||||
logger.Debug("Starting Run runtime",
|
||||
zap.String("payment_ref", paymentRef),
|
||||
zap.String("state", string(state)),
|
||||
zap.Int("steps_count", stepCount),
|
||||
)
|
||||
|
||||
if payment == nil {
|
||||
return nil, merrors.InvalidArgument("payment is required")
|
||||
}
|
||||
if s.state.IsAggregateTerminal(payment.State) {
|
||||
logger.Debug("psvc.run_runtime.terminal", zap.String("payment_ref", paymentRef), zap.String("state", string(payment.State)))
|
||||
return payment, nil
|
||||
}
|
||||
|
||||
current := payment
|
||||
for tick := 0; tick < s.maxTicks; tick++ {
|
||||
graph, err := s.compileGraph(current)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
updated, changed, waitOnly, err := s.runTick(ctx, current, graph)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
logger.Debug("psvc.run_runtime.tick",
|
||||
zap.String("payment_ref", paymentRef),
|
||||
zap.Int("tick", tick),
|
||||
zap.Bool("changed", changed),
|
||||
zap.Bool("wait_only", waitOnly),
|
||||
zap.String("state", string(updated.State)),
|
||||
zap.Uint64("version", updated.Version),
|
||||
)
|
||||
if !changed {
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
if s.state.IsAggregateTerminal(updated.State) {
|
||||
return updated, nil
|
||||
}
|
||||
if waitOnly {
|
||||
return updated, nil
|
||||
}
|
||||
current = updated
|
||||
}
|
||||
logger.Debug("psvc.run_runtime.max_ticks_reached",
|
||||
zap.String("payment_ref", paymentRef),
|
||||
zap.Int("max_ticks", s.maxTicks),
|
||||
zap.String("state", string(current.State)),
|
||||
zap.Uint64("version", current.Version),
|
||||
)
|
||||
return current, nil
|
||||
}
|
||||
|
||||
func (s *svc) runTick(ctx context.Context, payment *agg.Payment, graph *xplan.Graph) (*agg.Payment, bool, bool, error) {
|
||||
logger := s.logger
|
||||
expectedVersion := payment.Version
|
||||
|
||||
scheduled, err := s.scheduler.Schedule(ssched.Input{
|
||||
Steps: graph.Steps,
|
||||
StepExecutions: payment.StepExecutions,
|
||||
Retry: s.retryPolicy,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, false, false, err
|
||||
}
|
||||
logger.Debug("psvc.run_tick.scheduled",
|
||||
zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef)),
|
||||
zap.Int("runnable_count", len(scheduled.Runnable)),
|
||||
zap.Int("blocked_count", len(scheduled.Blocked)),
|
||||
zap.Int("skipped_count", len(scheduled.Skipped)),
|
||||
)
|
||||
|
||||
changed := mergeScheduledExecutions(payment, scheduled.StepExecutions)
|
||||
if changed {
|
||||
if err := s.recordScheduleTransitions(ctx, payment, scheduled.StepExecutions); err != nil {
|
||||
return nil, false, false, err
|
||||
}
|
||||
}
|
||||
|
||||
for i := range scheduled.Runnable {
|
||||
stepChanged, runErr := s.executeRunnable(ctx, payment, graph, scheduled.Runnable[i])
|
||||
if runErr != nil {
|
||||
return nil, false, false, runErr
|
||||
}
|
||||
changed = changed || stepChanged
|
||||
}
|
||||
|
||||
aggChanged, err := s.recomputeAggregateState(ctx, payment)
|
||||
if err != nil {
|
||||
return nil, false, false, err
|
||||
}
|
||||
changed = changed || aggChanged
|
||||
if !changed {
|
||||
return payment, false, len(scheduled.Runnable) == 0, nil
|
||||
}
|
||||
|
||||
if err := s.repository.UpdateCAS(ctx, payment, expectedVersion); err != nil {
|
||||
if errors.Is(err, prepo.ErrVersionConflict) {
|
||||
logger.Debug("psvc.run_tick.cas_conflict",
|
||||
zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef)),
|
||||
zap.Uint64("expected_version", expectedVersion),
|
||||
)
|
||||
fresh, reloadErr := s.repository.GetByPaymentRef(ctx, payment.OrganizationRef, payment.PaymentRef)
|
||||
if reloadErr != nil {
|
||||
return nil, false, false, reloadErr
|
||||
}
|
||||
return fresh, true, false, nil
|
||||
}
|
||||
return nil, false, false, err
|
||||
}
|
||||
logger.Debug("psvc.run_tick.persisted",
|
||||
zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef)),
|
||||
zap.Uint64("version", payment.Version),
|
||||
zap.String("state", string(payment.State)),
|
||||
)
|
||||
return payment, true, len(scheduled.Runnable) == 0, nil
|
||||
}
|
||||
|
||||
func (s *svc) compileGraph(payment *agg.Payment) (*xplan.Graph, error) {
|
||||
return s.planner.Compile(xplan.Input{
|
||||
IntentSnapshot: payment.IntentSnapshot,
|
||||
QuoteSnapshot: payment.QuoteSnapshot,
|
||||
})
|
||||
}
|
||||
|
||||
func mergeScheduledExecutions(payment *agg.Payment, updated []agg.StepExecution) bool {
|
||||
if payment == nil {
|
||||
return false
|
||||
}
|
||||
if len(updated) == 0 {
|
||||
return false
|
||||
}
|
||||
changed := false
|
||||
index := stepIndexByRef(payment.StepExecutions)
|
||||
for i := range updated {
|
||||
step := updated[i]
|
||||
idx, ok := index[strings.TrimSpace(step.StepRef)]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if !stepExecutionEqual(payment.StepExecutions[idx], step) {
|
||||
payment.StepExecutions[idx] = step
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
func (s *svc) recordScheduleTransitions(ctx context.Context, payment *agg.Payment, current []agg.StepExecution) error {
|
||||
_ = current
|
||||
for i := range payment.StepExecutions {
|
||||
step := payment.StepExecutions[i]
|
||||
if step.State != agg.StepStateSkipped && step.State != agg.StepStateNeedsAttention {
|
||||
continue
|
||||
}
|
||||
event := oobs.StepEventSkipped
|
||||
if step.State == agg.StepStateNeedsAttention {
|
||||
event = oobs.StepEventBlocked
|
||||
}
|
||||
if err := s.observer.RecordStep(ctx, oobs.RecordStepInput{
|
||||
PaymentRef: payment.PaymentRef,
|
||||
Step: step,
|
||||
Event: event,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *svc) executeRunnable(ctx context.Context, payment *agg.Payment, graph *xplan.Graph, runnable ssched.RunnableStep) (bool, error) {
|
||||
logger := s.logger
|
||||
logger.Debug("Starting Step execution",
|
||||
zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef)),
|
||||
zap.String("step_ref", strings.TrimSpace(runnable.StepRef)),
|
||||
zap.String("step_code", strings.TrimSpace(runnable.StepCode)),
|
||||
zap.Uint32("attempt", runnable.Attempt),
|
||||
)
|
||||
|
||||
idx, ok := findStepExecution(payment.StepExecutions, runnable.StepRef)
|
||||
if !ok {
|
||||
return false, merrors.InvalidArgument("step execution not found: " + runnable.StepRef)
|
||||
}
|
||||
stepExecution := payment.StepExecutions[idx]
|
||||
stepExecution.Attempt = runnable.Attempt
|
||||
|
||||
if stepExecution.State != agg.StepStateRunning {
|
||||
if err := s.state.EnsureStepTransition(stepExecution.State, agg.StepStateRunning); err != nil {
|
||||
stepExecution.State = agg.StepStateNeedsAttention
|
||||
stepExecution.FailureCode = "step.transition_invalid"
|
||||
stepExecution.FailureMsg = err.Error()
|
||||
} else {
|
||||
stepExecution.State = agg.StepStateRunning
|
||||
}
|
||||
now := s.nowUTC()
|
||||
stepExecution.StartedAt = &now
|
||||
stepExecution.CompletedAt = nil
|
||||
if err := s.observer.RecordStep(ctx, oobs.RecordStepInput{
|
||||
PaymentRef: payment.PaymentRef,
|
||||
Step: stepExecution,
|
||||
Event: oobs.StepEventStarted,
|
||||
}); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
step, ok := findGraphStep(graph, runnable.StepRef)
|
||||
if !ok {
|
||||
return false, merrors.InvalidArgument("graph step not found: " + runnable.StepRef)
|
||||
}
|
||||
out, err := s.executors.Execute(ctx, sexec.ExecuteInput{
|
||||
Payment: payment,
|
||||
Step: step,
|
||||
StepExecution: stepExecution,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warn("psvc.step_execution.executor_error",
|
||||
zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef)),
|
||||
zap.String("step_ref", strings.TrimSpace(runnable.StepRef)),
|
||||
zap.Uint32("attempt", runnable.Attempt),
|
||||
zap.Error(err),
|
||||
)
|
||||
failed := markStepFailed(stepExecution, "step.executor_error", err.Error(), s.nowUTC())
|
||||
payment.StepExecutions[idx] = failed
|
||||
if obsErr := s.observer.RecordStep(ctx, oobs.RecordStepInput{
|
||||
PaymentRef: payment.PaymentRef,
|
||||
Step: failed,
|
||||
Event: oobs.StepEventFailed,
|
||||
}); obsErr != nil {
|
||||
return false, obsErr
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
next := normalizeExecutorOutput(stepExecution, out, s.nowUTC())
|
||||
payment.StepExecutions[idx] = next
|
||||
logger.Debug("Completed Step execution",
|
||||
zap.String("payment_ref", strings.TrimSpace(payment.PaymentRef)),
|
||||
zap.String("step_ref", strings.TrimSpace(next.StepRef)),
|
||||
zap.String("state", string(next.State)),
|
||||
zap.Uint32("attempt", next.Attempt),
|
||||
)
|
||||
if next.State == agg.StepStateCompleted || next.State == agg.StepStateFailed || next.State == agg.StepStateNeedsAttention {
|
||||
event := oobs.StepEventCompleted
|
||||
if next.State != agg.StepStateCompleted {
|
||||
event = oobs.StepEventFailed
|
||||
}
|
||||
if err := s.observer.RecordStep(ctx, oobs.RecordStepInput{
|
||||
PaymentRef: payment.PaymentRef,
|
||||
Step: next,
|
||||
Event: event,
|
||||
Duration: stepDuration(next),
|
||||
}); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func normalizeExecutorOutput(current agg.StepExecution, out *sexec.ExecuteOutput, now time.Time) agg.StepExecution {
|
||||
if out == nil {
|
||||
next := current
|
||||
next.State = agg.StepStateCompleted
|
||||
next.CompletedAt = &now
|
||||
return next
|
||||
}
|
||||
next := current
|
||||
if out.StepExecution.StepRef != "" {
|
||||
next.StepRef = out.StepExecution.StepRef
|
||||
}
|
||||
if out.StepExecution.StepCode != "" {
|
||||
next.StepCode = out.StepExecution.StepCode
|
||||
}
|
||||
if out.StepExecution.Attempt != 0 {
|
||||
next.Attempt = out.StepExecution.Attempt
|
||||
}
|
||||
next.ExternalRefs = out.StepExecution.ExternalRefs
|
||||
next.FailureCode = strings.TrimSpace(out.StepExecution.FailureCode)
|
||||
next.FailureMsg = strings.TrimSpace(out.StepExecution.FailureMsg)
|
||||
|
||||
switch out.StepExecution.State {
|
||||
case agg.StepStateCompleted, agg.StepStateFailed, agg.StepStateNeedsAttention, agg.StepStateSkipped:
|
||||
next.State = out.StepExecution.State
|
||||
case agg.StepStateRunning:
|
||||
next.State = agg.StepStateRunning
|
||||
default:
|
||||
if out.Async {
|
||||
next.State = agg.StepStateRunning
|
||||
} else {
|
||||
next.State = agg.StepStateCompleted
|
||||
}
|
||||
}
|
||||
|
||||
if next.StartedAt == nil {
|
||||
next.StartedAt = &now
|
||||
}
|
||||
if next.State == agg.StepStateRunning {
|
||||
next.CompletedAt = nil
|
||||
} else if next.CompletedAt == nil {
|
||||
next.CompletedAt = &now
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
func markStepFailed(step agg.StepExecution, code, message string, now time.Time) agg.StepExecution {
|
||||
step.State = agg.StepStateFailed
|
||||
step.FailureCode = strings.TrimSpace(code)
|
||||
step.FailureMsg = strings.TrimSpace(message)
|
||||
if step.StartedAt == nil {
|
||||
step.StartedAt = &now
|
||||
}
|
||||
step.CompletedAt = &now
|
||||
return step
|
||||
}
|
||||
|
||||
func stepDuration(step agg.StepExecution) time.Duration {
|
||||
if step.StartedAt == nil || step.CompletedAt == nil {
|
||||
return 0
|
||||
}
|
||||
if step.CompletedAt.Before(*step.StartedAt) {
|
||||
return 0
|
||||
}
|
||||
return step.CompletedAt.Sub(*step.StartedAt)
|
||||
}
|
||||
|
||||
func stepIndexByRef(steps []agg.StepExecution) map[string]int {
|
||||
out := make(map[string]int, len(steps))
|
||||
for i := range steps {
|
||||
out[strings.TrimSpace(steps[i].StepRef)] = i
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func findStepExecution(steps []agg.StepExecution, stepRef string) (int, bool) {
|
||||
ref := strings.TrimSpace(stepRef)
|
||||
for i := range steps {
|
||||
if strings.TrimSpace(steps[i].StepRef) == ref {
|
||||
return i, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func findGraphStep(graph *xplan.Graph, stepRef string) (xplan.Step, bool) {
|
||||
if graph == nil {
|
||||
return xplan.Step{}, false
|
||||
}
|
||||
ref := strings.TrimSpace(stepRef)
|
||||
for i := range graph.Steps {
|
||||
if strings.TrimSpace(graph.Steps[i].StepRef) == ref {
|
||||
return graph.Steps[i], true
|
||||
}
|
||||
}
|
||||
return xplan.Step{}, false
|
||||
}
|
||||
|
||||
func stepExecutionEqual(left, right agg.StepExecution) bool {
|
||||
if left.StepRef != right.StepRef || left.StepCode != right.StepCode {
|
||||
return false
|
||||
}
|
||||
if left.State != right.State || left.Attempt != right.Attempt {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(left.FailureCode) != strings.TrimSpace(right.FailureCode) {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(left.FailureMsg) != strings.TrimSpace(right.FailureMsg) {
|
||||
return false
|
||||
}
|
||||
if !timePtrEqual(left.StartedAt, right.StartedAt) || !timePtrEqual(left.CompletedAt, right.CompletedAt) {
|
||||
return false
|
||||
}
|
||||
if len(left.ExternalRefs) != len(right.ExternalRefs) {
|
||||
return false
|
||||
}
|
||||
for i := range left.ExternalRefs {
|
||||
if left.ExternalRefs[i] != right.ExternalRefs[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func timePtrEqual(left *time.Time, right *time.Time) bool {
|
||||
if left == nil && right == nil {
|
||||
return true
|
||||
}
|
||||
if left == nil || right == nil {
|
||||
return false
|
||||
}
|
||||
return left.UTC().Equal(right.UTC())
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package psvc
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/erecon"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/idem"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/oobs"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/ostate"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/pquery"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/prepo"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/prmap"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/qsnap"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/reqval"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/sexec"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/ssched"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultMaxTicks = 32
|
||||
)
|
||||
|
||||
type svc struct {
|
||||
logger mlogger.Logger
|
||||
|
||||
quoteStore qsnap.Store
|
||||
|
||||
validator reqval.Validator
|
||||
idempotency idem.Service
|
||||
quote qsnap.Resolver
|
||||
aggregate agg.Factory
|
||||
planner xplan.Compiler
|
||||
state ostate.StateMachine
|
||||
scheduler ssched.Runtime
|
||||
executors sexec.Registry
|
||||
reconciler erecon.Reconciler
|
||||
repository prepo.Repository
|
||||
query pquery.Service
|
||||
mapper prmap.Mapper
|
||||
observer oobs.Observer
|
||||
|
||||
retryPolicy ssched.RetryPolicy
|
||||
now func() time.Time
|
||||
maxTicks int
|
||||
}
|
||||
|
||||
func newService(deps Dependencies) (Service, error) {
|
||||
if deps.QuoteStore == nil {
|
||||
return nil, merrors.InvalidArgument("quote store is required")
|
||||
}
|
||||
if deps.Repository == nil {
|
||||
return nil, merrors.InvalidArgument("payment repository v2 is required")
|
||||
}
|
||||
|
||||
logger := deps.Logger.Named("psvc")
|
||||
|
||||
observer := deps.Observer
|
||||
if observer == nil {
|
||||
var err error
|
||||
observer, err = oobs.New(oobs.Dependencies{Logger: logger.Named("oobs")})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
query := deps.Query
|
||||
if query == nil {
|
||||
var err error
|
||||
query, err = pquery.New(pquery.Dependencies{
|
||||
Repository: deps.Repository,
|
||||
Logger: logger.Named("pquery"),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
out := &svc{
|
||||
logger: logger,
|
||||
|
||||
quoteStore: deps.QuoteStore,
|
||||
|
||||
validator: firstValidator(deps.Validator, logger.Named("reqval")),
|
||||
idempotency: firstIdempotency(deps.Idempotency, logger.Named("idem")),
|
||||
quote: firstQuoteResolver(deps.Quote, logger.Named("qsnap")),
|
||||
aggregate: firstAggregateFactory(deps.Aggregate, logger.Named("agg")),
|
||||
planner: firstPlanCompiler(deps.Planner, logger.Named("xplan")),
|
||||
state: firstStateMachine(deps.State, logger.Named("ostate")),
|
||||
scheduler: firstScheduler(deps.Scheduler, logger.Named("ssched")),
|
||||
executors: firstExecutors(deps.Executors, logger.Named("sexec")),
|
||||
reconciler: firstReconciler(deps.Reconciler, logger.Named("erecon")),
|
||||
repository: deps.Repository,
|
||||
query: query,
|
||||
mapper: firstMapper(deps.Mapper, logger.Named("prmap")),
|
||||
observer: observer,
|
||||
|
||||
retryPolicy: deps.RetryPolicy,
|
||||
now: deps.Now,
|
||||
maxTicks: deps.MaxTicks,
|
||||
}
|
||||
if out.now == nil {
|
||||
out.now = func() time.Time { return time.Now().UTC() }
|
||||
}
|
||||
if out.maxTicks <= 0 {
|
||||
out.maxTicks = defaultMaxTicks
|
||||
}
|
||||
if out.retryPolicy.MaxAttempts == 0 {
|
||||
out.retryPolicy.MaxAttempts = 2
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func firstValidator(v reqval.Validator, logger mlogger.Logger) reqval.Validator {
|
||||
if v != nil {
|
||||
return v
|
||||
}
|
||||
return reqval.New(reqval.Dependencies{Logger: logger})
|
||||
}
|
||||
|
||||
func firstIdempotency(v idem.Service, logger mlogger.Logger) idem.Service {
|
||||
if v != nil {
|
||||
return v
|
||||
}
|
||||
return idem.New(idem.Dependencies{Logger: logger})
|
||||
}
|
||||
|
||||
func firstQuoteResolver(v qsnap.Resolver, logger mlogger.Logger) qsnap.Resolver {
|
||||
if v != nil {
|
||||
return v
|
||||
}
|
||||
return qsnap.New(qsnap.Dependencies{Logger: logger})
|
||||
}
|
||||
|
||||
func firstAggregateFactory(v agg.Factory, logger mlogger.Logger) agg.Factory {
|
||||
if v != nil {
|
||||
return v
|
||||
}
|
||||
return agg.New(agg.Dependencies{Logger: logger})
|
||||
}
|
||||
|
||||
func firstPlanCompiler(v xplan.Compiler, logger mlogger.Logger) xplan.Compiler {
|
||||
if v != nil {
|
||||
return v
|
||||
}
|
||||
return xplan.New(xplan.Dependencies{Logger: logger})
|
||||
}
|
||||
|
||||
func firstStateMachine(v ostate.StateMachine, logger mlogger.Logger) ostate.StateMachine {
|
||||
if v != nil {
|
||||
return v
|
||||
}
|
||||
return ostate.New(ostate.Dependencies{Logger: logger})
|
||||
}
|
||||
|
||||
func firstScheduler(v ssched.Runtime, logger mlogger.Logger) ssched.Runtime {
|
||||
if v != nil {
|
||||
return v
|
||||
}
|
||||
return ssched.New(ssched.Dependencies{Logger: logger})
|
||||
}
|
||||
|
||||
func firstExecutors(v sexec.Registry, logger mlogger.Logger) sexec.Registry {
|
||||
if v != nil {
|
||||
return v
|
||||
}
|
||||
return sexec.New(sexec.Dependencies{
|
||||
Logger: logger,
|
||||
Ledger: defaultLedgerExecutor{},
|
||||
Crypto: defaultCryptoExecutor{},
|
||||
ProviderSettlement: defaultProviderSettlementExecutor{},
|
||||
CardPayout: defaultCardPayoutExecutor{},
|
||||
ObserveConfirm: defaultObserveConfirmExecutor{},
|
||||
})
|
||||
}
|
||||
|
||||
func firstReconciler(v erecon.Reconciler, logger mlogger.Logger) erecon.Reconciler {
|
||||
if v != nil {
|
||||
return v
|
||||
}
|
||||
return erecon.New(erecon.Dependencies{Logger: logger})
|
||||
}
|
||||
|
||||
func firstMapper(v prmap.Mapper, logger mlogger.Logger) prmap.Mapper {
|
||||
if v != nil {
|
||||
return v
|
||||
}
|
||||
return prmap.New(prmap.Dependencies{Logger: logger})
|
||||
}
|
||||
|
||||
func (s *svc) nowUTC() time.Time {
|
||||
return s.now().UTC()
|
||||
}
|
||||
@@ -0,0 +1,670 @@
|
||||
package psvc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/erecon"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/oobs"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/prepo"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/sexec"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/ssched"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
quotestorage "github.com/tech/sendico/payments/storage/quote"
|
||||
"github.com/tech/sendico/pkg/db/storable"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
pm "github.com/tech/sendico/pkg/model"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
|
||||
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
|
||||
orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2"
|
||||
sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
func TestExecutePayment_EndToEndSyncSettled(t *testing.T) {
|
||||
env := newTestEnv(t, func(_ string, req sexec.StepRequest) (*sexec.ExecuteOutput, error) {
|
||||
step := req.StepExecution
|
||||
step.State = agg.StepStateCompleted
|
||||
return &sexec.ExecuteOutput{StepExecution: step}, nil
|
||||
})
|
||||
env.quotes.Put(newExecutableQuote(env.orgID, "quote-sync", "intent-sync", buildLedgerRoute()))
|
||||
|
||||
resp, err := env.svc.ExecutePayment(context.Background(), &orchestrationv2.ExecutePaymentRequest{
|
||||
Meta: testMeta(env.orgID, "idem-sync"),
|
||||
QuotationRef: "quote-sync",
|
||||
ClientPaymentRef: "client-1",
|
||||
IntentRef: "intent-sync",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ExecutePayment returned error: %v", err)
|
||||
}
|
||||
if resp.GetPayment() == nil {
|
||||
t.Fatal("expected payment in response")
|
||||
}
|
||||
if got, want := resp.GetPayment().GetState(), orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_SETTLED; got != want {
|
||||
t.Fatalf("state mismatch: got=%s want=%s", got, want)
|
||||
}
|
||||
|
||||
getResp, err := env.svc.GetPayment(context.Background(), &orchestrationv2.GetPaymentRequest{
|
||||
Meta: testMeta(env.orgID, ""),
|
||||
PaymentRef: resp.GetPayment().GetPaymentRef(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GetPayment returned error: %v", err)
|
||||
}
|
||||
if getResp.GetPayment() == nil {
|
||||
t.Fatal("expected payment from GetPayment")
|
||||
}
|
||||
if got, want := getResp.GetPayment().GetPaymentRef(), resp.GetPayment().GetPaymentRef(); got != want {
|
||||
t.Fatalf("payment_ref mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
|
||||
timeline, err := env.observer.PaymentTimeline(context.Background(), oobs.PaymentTimelineInput{
|
||||
PaymentRef: resp.GetPayment().GetPaymentRef(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PaymentTimeline returned error: %v", err)
|
||||
}
|
||||
assertTimelineHasEvent(t, timeline.Items, "created")
|
||||
assertTimelineHasEvent(t, timeline.Items, "settled")
|
||||
}
|
||||
|
||||
func TestExecutePayment_IdempotencyMismatch(t *testing.T) {
|
||||
env := newTestEnv(t, func(_ string, req sexec.StepRequest) (*sexec.ExecuteOutput, error) {
|
||||
step := req.StepExecution
|
||||
step.State = agg.StepStateCompleted
|
||||
return &sexec.ExecuteOutput{StepExecution: step}, nil
|
||||
})
|
||||
env.quotes.Put(newExecutableQuote(env.orgID, "quote-idem", "intent-idem", buildLedgerRoute()))
|
||||
|
||||
_, err := env.svc.ExecutePayment(context.Background(), &orchestrationv2.ExecutePaymentRequest{
|
||||
Meta: testMeta(env.orgID, "idem-shared"),
|
||||
QuotationRef: "quote-idem",
|
||||
ClientPaymentRef: "client-a",
|
||||
IntentRef: "intent-idem",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("first ExecutePayment returned error: %v", err)
|
||||
}
|
||||
|
||||
_, err = env.svc.ExecutePayment(context.Background(), &orchestrationv2.ExecutePaymentRequest{
|
||||
Meta: testMeta(env.orgID, "idem-shared"),
|
||||
QuotationRef: "quote-idem",
|
||||
ClientPaymentRef: "client-b",
|
||||
IntentRef: "intent-idem",
|
||||
})
|
||||
if !errors.Is(err, merrors.ErrInvalidArg) {
|
||||
t.Fatalf("expected invalid argument for mismatch, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutePayment_RetryThenSuccess(t *testing.T) {
|
||||
env := newTestEnv(t, func(_ string, req sexec.StepRequest) (*sexec.ExecuteOutput, error) {
|
||||
if req.StepExecution.Attempt == 1 {
|
||||
return nil, errors.New("temporary ledger failure")
|
||||
}
|
||||
step := req.StepExecution
|
||||
step.State = agg.StepStateCompleted
|
||||
return &sexec.ExecuteOutput{StepExecution: step}, nil
|
||||
})
|
||||
env.quotes.Put(newExecutableQuote(env.orgID, "quote-retry", "intent-retry", buildLedgerRoute()))
|
||||
|
||||
resp, err := env.svc.ExecutePayment(context.Background(), &orchestrationv2.ExecutePaymentRequest{
|
||||
Meta: testMeta(env.orgID, "idem-retry"),
|
||||
QuotationRef: "quote-retry",
|
||||
ClientPaymentRef: "client-retry",
|
||||
IntentRef: "intent-retry",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ExecutePayment returned error: %v", err)
|
||||
}
|
||||
if got, want := resp.GetPayment().GetState(), orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_SETTLED; got != want {
|
||||
t.Fatalf("state mismatch: got=%s want=%s", got, want)
|
||||
}
|
||||
if len(resp.GetPayment().GetStepExecutions()) != 1 {
|
||||
t.Fatalf("expected one step execution, got=%d", len(resp.GetPayment().GetStepExecutions()))
|
||||
}
|
||||
if got, want := resp.GetPayment().GetStepExecutions()[0].GetAttempt(), uint32(2); got != want {
|
||||
t.Fatalf("attempt mismatch: got=%d want=%d", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileExternal_AdvancesAsyncPaymentToSettled(t *testing.T) {
|
||||
var observeStepRef string
|
||||
env := newTestEnv(t, func(kind string, req sexec.StepRequest) (*sexec.ExecuteOutput, error) {
|
||||
step := req.StepExecution
|
||||
switch kind {
|
||||
case "card_payout":
|
||||
step.State = agg.StepStateCompleted
|
||||
return &sexec.ExecuteOutput{StepExecution: step}, nil
|
||||
case "observe_confirm":
|
||||
step.State = agg.StepStateRunning
|
||||
step.ExternalRefs = append(step.ExternalRefs, agg.ExternalRef{
|
||||
GatewayInstanceID: "gw-card",
|
||||
Kind: erecon.ExternalRefKindOperation,
|
||||
Ref: "op-1",
|
||||
})
|
||||
observeStepRef = step.StepRef
|
||||
return &sexec.ExecuteOutput{StepExecution: step, Async: true}, nil
|
||||
default:
|
||||
step.State = agg.StepStateCompleted
|
||||
return &sexec.ExecuteOutput{StepExecution: step}, nil
|
||||
}
|
||||
})
|
||||
env.quotes.Put(newExecutableQuote(env.orgID, "quote-async", "intent-async", buildCardRoute()))
|
||||
|
||||
resp, err := env.svc.ExecutePayment(context.Background(), &orchestrationv2.ExecutePaymentRequest{
|
||||
Meta: testMeta(env.orgID, "idem-async"),
|
||||
QuotationRef: "quote-async",
|
||||
ClientPaymentRef: "client-async",
|
||||
IntentRef: "intent-async",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ExecutePayment returned error: %v", err)
|
||||
}
|
||||
if got, want := resp.GetPayment().GetState(), orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_EXECUTING; got != want {
|
||||
t.Fatalf("expected executing before external reconcile, got=%s", got)
|
||||
}
|
||||
if observeStepRef == "" {
|
||||
t.Fatal("expected observe step ref to be captured")
|
||||
}
|
||||
|
||||
reconciled, err := env.svc.ReconcileExternal(context.Background(), ReconcileExternalInput{
|
||||
OrganizationRef: env.orgID.Hex(),
|
||||
PaymentRef: resp.GetPayment().GetPaymentRef(),
|
||||
Event: erecon.Event{
|
||||
Gateway: &erecon.GatewayEvent{
|
||||
StepRef: observeStepRef,
|
||||
OperationRef: "op-1",
|
||||
Status: erecon.GatewayStatusSuccess,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ReconcileExternal returned error: %v", err)
|
||||
}
|
||||
if reconciled == nil || reconciled.Payment == nil {
|
||||
t.Fatal("expected reconciled payment")
|
||||
}
|
||||
if got, want := reconciled.Payment.GetState(), orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_SETTLED; got != want {
|
||||
t.Fatalf("state mismatch after reconcile: got=%s want=%s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPayments_FiltersAndCursor(t *testing.T) {
|
||||
env := newTestEnv(t, func(_ string, req sexec.StepRequest) (*sexec.ExecuteOutput, error) {
|
||||
step := req.StepExecution
|
||||
step.State = agg.StepStateCompleted
|
||||
return &sexec.ExecuteOutput{StepExecution: step}, nil
|
||||
})
|
||||
base := time.Date(2026, time.February, 20, 10, 0, 0, 0, time.UTC)
|
||||
|
||||
mustCreatePayment(t, env.repo, testPayment(env.orgID, "p-1", "idem-1", "quote-list", agg.StateExecuting, base.Add(3*time.Minute)))
|
||||
mustCreatePayment(t, env.repo, testPayment(env.orgID, "p-2", "idem-2", "quote-list", agg.StateSettled, base.Add(2*time.Minute)))
|
||||
mustCreatePayment(t, env.repo, testPayment(env.orgID, "p-3", "idem-3", "quote-list", agg.StateFailed, base.Add(1*time.Minute)))
|
||||
mustCreatePayment(t, env.repo, testPayment(env.orgID, "p-4", "idem-4", "quote-other", agg.StateExecuting, base.Add(4*time.Minute)))
|
||||
|
||||
first, err := env.svc.ListPayments(context.Background(), &orchestrationv2.ListPaymentsRequest{
|
||||
Meta: testMeta(env.orgID, ""),
|
||||
States: []orchestrationv2.OrchestrationState{orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_EXECUTING, orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_SETTLED},
|
||||
QuotationRef: "quote-list",
|
||||
Page: &paginationv1.CursorPageRequest{Limit: 1},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ListPayments(first) returned error: %v", err)
|
||||
}
|
||||
if len(first.GetPayments()) != 1 {
|
||||
t.Fatalf("expected one payment in first page, got=%d", len(first.GetPayments()))
|
||||
}
|
||||
if got, want := first.GetPayments()[0].GetPaymentRef(), "p-1"; got != want {
|
||||
t.Fatalf("first page payment mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if strings.TrimSpace(first.GetPage().GetNextCursor()) == "" {
|
||||
t.Fatal("expected next cursor")
|
||||
}
|
||||
|
||||
second, err := env.svc.ListPayments(context.Background(), &orchestrationv2.ListPaymentsRequest{
|
||||
Meta: testMeta(env.orgID, ""),
|
||||
States: []orchestrationv2.OrchestrationState{orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_EXECUTING, orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_SETTLED},
|
||||
QuotationRef: "quote-list",
|
||||
Page: &paginationv1.CursorPageRequest{
|
||||
Limit: 2,
|
||||
Cursor: first.GetPage().GetNextCursor(),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ListPayments(second) returned error: %v", err)
|
||||
}
|
||||
if len(second.GetPayments()) != 1 {
|
||||
t.Fatalf("expected one payment in second page, got=%d", len(second.GetPayments()))
|
||||
}
|
||||
if got, want := second.GetPayments()[0].GetPaymentRef(), "p-2"; got != want {
|
||||
t.Fatalf("second page payment mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertTimelineHasEvent(t *testing.T, items []oobs.TimelineEntry, event string) {
|
||||
t.Helper()
|
||||
for i := range items {
|
||||
if items[i].Event == event {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("timeline missing event %q", event)
|
||||
}
|
||||
|
||||
type testEnv struct {
|
||||
svc Service
|
||||
repo *memoryRepo
|
||||
quotes *memoryQuoteStore
|
||||
observer oobs.Observer
|
||||
orgID bson.ObjectID
|
||||
}
|
||||
|
||||
func newTestEnv(t *testing.T, handler func(kind string, req sexec.StepRequest) (*sexec.ExecuteOutput, error)) *testEnv {
|
||||
t.Helper()
|
||||
repo := newMemoryRepo(func() time.Time {
|
||||
return time.Now().UTC()
|
||||
})
|
||||
quotes := newMemoryQuoteStore()
|
||||
observer, err := oobs.New(oobs.Dependencies{})
|
||||
if err != nil {
|
||||
t.Fatalf("oobs.New returned error: %v", err)
|
||||
}
|
||||
|
||||
script := &scriptedExecutors{handler: handler}
|
||||
registry := sexec.New(sexec.Dependencies{
|
||||
Ledger: script,
|
||||
Crypto: script,
|
||||
ProviderSettlement: script,
|
||||
CardPayout: script,
|
||||
ObserveConfirm: script,
|
||||
})
|
||||
|
||||
svc, err := New(Dependencies{
|
||||
QuoteStore: quotes,
|
||||
Repository: repo,
|
||||
Executors: registry,
|
||||
Observer: observer,
|
||||
RetryPolicy: ssched.RetryPolicy{MaxAttempts: 2},
|
||||
MaxTicks: 20,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New returned error: %v", err)
|
||||
}
|
||||
return &testEnv{
|
||||
svc: svc,
|
||||
repo: repo,
|
||||
quotes: quotes,
|
||||
observer: observer,
|
||||
orgID: bson.NewObjectID(),
|
||||
}
|
||||
}
|
||||
|
||||
type scriptedExecutors struct {
|
||||
handler func(kind string, req sexec.StepRequest) (*sexec.ExecuteOutput, error)
|
||||
}
|
||||
|
||||
func (s *scriptedExecutors) ExecuteLedger(_ context.Context, req sexec.StepRequest) (*sexec.ExecuteOutput, error) {
|
||||
return s.handler("ledger", req)
|
||||
}
|
||||
func (s *scriptedExecutors) ExecuteCrypto(_ context.Context, req sexec.StepRequest) (*sexec.ExecuteOutput, error) {
|
||||
return s.handler("crypto", req)
|
||||
}
|
||||
func (s *scriptedExecutors) ExecuteProviderSettlement(_ context.Context, req sexec.StepRequest) (*sexec.ExecuteOutput, error) {
|
||||
return s.handler("provider_settlement", req)
|
||||
}
|
||||
func (s *scriptedExecutors) ExecuteCardPayout(_ context.Context, req sexec.StepRequest) (*sexec.ExecuteOutput, error) {
|
||||
return s.handler("card_payout", req)
|
||||
}
|
||||
func (s *scriptedExecutors) ExecuteObserveConfirm(_ context.Context, req sexec.StepRequest) (*sexec.ExecuteOutput, error) {
|
||||
return s.handler("observe_confirm", req)
|
||||
}
|
||||
|
||||
type memoryQuoteStore struct {
|
||||
mu sync.Mutex
|
||||
data map[string]*model.PaymentQuoteRecord
|
||||
}
|
||||
|
||||
func newMemoryQuoteStore() *memoryQuoteStore {
|
||||
return &memoryQuoteStore{data: map[string]*model.PaymentQuoteRecord{}}
|
||||
}
|
||||
|
||||
func (s *memoryQuoteStore) Put(record *model.PaymentQuoteRecord) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.data[quoteKey(record.OrganizationRef, record.QuoteRef)] = cloneQuoteRecord(record)
|
||||
}
|
||||
|
||||
func (s *memoryQuoteStore) GetByRef(_ context.Context, orgRef bson.ObjectID, quoteRef string) (*model.PaymentQuoteRecord, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
record := s.data[quoteKey(orgRef, quoteRef)]
|
||||
if record == nil {
|
||||
return nil, quotestorage.ErrQuoteNotFound
|
||||
}
|
||||
return cloneQuoteRecord(record), nil
|
||||
}
|
||||
|
||||
func quoteKey(orgRef bson.ObjectID, quoteRef string) string {
|
||||
return orgRef.Hex() + "|" + strings.TrimSpace(quoteRef)
|
||||
}
|
||||
|
||||
func cloneQuoteRecord(in *model.PaymentQuoteRecord) *model.PaymentQuoteRecord {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
data, _ := bson.Marshal(in)
|
||||
out := &model.PaymentQuoteRecord{}
|
||||
_ = bson.Unmarshal(data, out)
|
||||
return out
|
||||
}
|
||||
|
||||
type memoryRepo struct {
|
||||
mu sync.Mutex
|
||||
now func() time.Time
|
||||
|
||||
byID map[bson.ObjectID]*agg.Payment
|
||||
byPaymentRef map[string]bson.ObjectID
|
||||
byIdempotency map[string]bson.ObjectID
|
||||
}
|
||||
|
||||
func newMemoryRepo(now func() time.Time) *memoryRepo {
|
||||
return &memoryRepo{
|
||||
now: now,
|
||||
byID: map[bson.ObjectID]*agg.Payment{},
|
||||
byPaymentRef: map[string]bson.ObjectID{},
|
||||
byIdempotency: map[string]bson.ObjectID{},
|
||||
}
|
||||
}
|
||||
|
||||
func (r *memoryRepo) Create(_ context.Context, payment *agg.Payment) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if payment == nil {
|
||||
return merrors.InvalidArgument("payment is required")
|
||||
}
|
||||
if payment.ID.IsZero() {
|
||||
payment.ID = bson.NewObjectID()
|
||||
}
|
||||
if strings.TrimSpace(payment.PaymentRef) == "" {
|
||||
payment.PaymentRef = payment.ID.Hex()
|
||||
}
|
||||
if payment.CreatedAt.IsZero() {
|
||||
payment.CreatedAt = r.now().UTC()
|
||||
}
|
||||
payment.UpdatedAt = payment.CreatedAt
|
||||
if payment.Version == 0 {
|
||||
payment.Version = 1
|
||||
}
|
||||
|
||||
refKey := repoPaymentRefKey(payment.OrganizationRef, payment.PaymentRef)
|
||||
if _, exists := r.byPaymentRef[refKey]; exists {
|
||||
return prepo.ErrDuplicatePayment
|
||||
}
|
||||
idemKey := repoIdemKey(payment.OrganizationRef, payment.IdempotencyKey)
|
||||
if _, exists := r.byIdempotency[idemKey]; exists {
|
||||
return prepo.ErrDuplicatePayment
|
||||
}
|
||||
cloned := clonePayment(payment)
|
||||
r.byID[cloned.ID] = cloned
|
||||
r.byPaymentRef[refKey] = cloned.ID
|
||||
r.byIdempotency[idemKey] = cloned.ID
|
||||
*payment = *clonePayment(cloned)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *memoryRepo) UpdateCAS(_ context.Context, payment *agg.Payment, expectedVersion uint64) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if payment == nil {
|
||||
return merrors.InvalidArgument("payment is required")
|
||||
}
|
||||
stored := r.byID[payment.ID]
|
||||
if stored == nil {
|
||||
return prepo.ErrPaymentNotFound
|
||||
}
|
||||
if stored.OrganizationRef != payment.OrganizationRef {
|
||||
return prepo.ErrPaymentNotFound
|
||||
}
|
||||
if stored.Version != expectedVersion {
|
||||
return prepo.ErrVersionConflict
|
||||
}
|
||||
next := clonePayment(payment)
|
||||
next.Version = expectedVersion + 1
|
||||
next.UpdatedAt = r.now().UTC()
|
||||
r.byID[next.ID] = next
|
||||
*payment = *clonePayment(next)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *memoryRepo) GetByPaymentRef(_ context.Context, orgRef bson.ObjectID, paymentRef string) (*agg.Payment, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
id, ok := r.byPaymentRef[repoPaymentRefKey(orgRef, paymentRef)]
|
||||
if !ok {
|
||||
return nil, prepo.ErrPaymentNotFound
|
||||
}
|
||||
return clonePayment(r.byID[id]), nil
|
||||
}
|
||||
|
||||
func (r *memoryRepo) GetByIdempotencyKey(_ context.Context, orgRef bson.ObjectID, idempotencyKey string) (*agg.Payment, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
id, ok := r.byIdempotency[repoIdemKey(orgRef, idempotencyKey)]
|
||||
if !ok {
|
||||
return nil, prepo.ErrPaymentNotFound
|
||||
}
|
||||
return clonePayment(r.byID[id]), nil
|
||||
}
|
||||
|
||||
func (r *memoryRepo) ListByQuotationRef(_ context.Context, in prepo.ListByQuotationRefInput) (*prepo.ListOutput, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
items := make([]*agg.Payment, 0)
|
||||
for _, payment := range r.byID {
|
||||
if payment.OrganizationRef != in.OrganizationRef {
|
||||
continue
|
||||
}
|
||||
if payment.QuotationRef != in.QuotationRef {
|
||||
continue
|
||||
}
|
||||
if !isBeforeCursor(payment, in.Cursor) {
|
||||
continue
|
||||
}
|
||||
items = append(items, clonePayment(payment))
|
||||
}
|
||||
return paginatePayments(items, in.Limit), nil
|
||||
}
|
||||
|
||||
func (r *memoryRepo) ListByState(_ context.Context, in prepo.ListByStateInput) (*prepo.ListOutput, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
items := make([]*agg.Payment, 0)
|
||||
for _, payment := range r.byID {
|
||||
if payment.OrganizationRef != in.OrganizationRef {
|
||||
continue
|
||||
}
|
||||
if payment.State != in.State {
|
||||
continue
|
||||
}
|
||||
if !isBeforeCursor(payment, in.Cursor) {
|
||||
continue
|
||||
}
|
||||
items = append(items, clonePayment(payment))
|
||||
}
|
||||
return paginatePayments(items, in.Limit), nil
|
||||
}
|
||||
|
||||
func repoPaymentRefKey(orgRef bson.ObjectID, paymentRef string) string {
|
||||
return orgRef.Hex() + "|" + strings.TrimSpace(paymentRef)
|
||||
}
|
||||
|
||||
func repoIdemKey(orgRef bson.ObjectID, key string) string {
|
||||
return orgRef.Hex() + "|" + strings.TrimSpace(key)
|
||||
}
|
||||
|
||||
func clonePayment(in *agg.Payment) *agg.Payment {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
data, _ := bson.Marshal(in)
|
||||
out := &agg.Payment{}
|
||||
_ = bson.Unmarshal(data, out)
|
||||
return out
|
||||
}
|
||||
|
||||
func isBeforeCursor(payment *agg.Payment, cursor *prepo.ListCursor) bool {
|
||||
if cursor == nil {
|
||||
return true
|
||||
}
|
||||
if payment.CreatedAt.Before(cursor.CreatedAt) {
|
||||
return true
|
||||
}
|
||||
if payment.CreatedAt.After(cursor.CreatedAt) {
|
||||
return false
|
||||
}
|
||||
return bytes.Compare(payment.ID[:], cursor.ID[:]) < 0
|
||||
}
|
||||
|
||||
func paginatePayments(items []*agg.Payment, limit int32) *prepo.ListOutput {
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
left := items[i]
|
||||
right := items[j]
|
||||
if !left.CreatedAt.Equal(right.CreatedAt) {
|
||||
return left.CreatedAt.After(right.CreatedAt)
|
||||
}
|
||||
return bytes.Compare(left.ID[:], right.ID[:]) > 0
|
||||
})
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
max := int(limit)
|
||||
if max > len(items) {
|
||||
max = len(items)
|
||||
}
|
||||
page := items[:max]
|
||||
out := &prepo.ListOutput{Items: page}
|
||||
if len(items) > max && max > 0 {
|
||||
last := page[len(page)-1]
|
||||
out.NextCursor = &prepo.ListCursor{
|
||||
CreatedAt: last.CreatedAt.UTC(),
|
||||
ID: last.ID,
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func newExecutableQuote(orgRef bson.ObjectID, quoteRef, intentRef string, route *paymenttypes.QuoteRouteSpecification) *model.PaymentQuoteRecord {
|
||||
now := time.Now().UTC()
|
||||
return &model.PaymentQuoteRecord{
|
||||
Base: modelBase(now),
|
||||
OrganizationBoundBase: pm.OrganizationBoundBase{
|
||||
OrganizationRef: orgRef,
|
||||
},
|
||||
QuoteRef: quoteRef,
|
||||
Intent: model.PaymentIntent{
|
||||
Ref: intentRef,
|
||||
Kind: model.PaymentKindPayout,
|
||||
Source: testLedgerEndpoint("ledger-src"),
|
||||
Destination: testLedgerEndpoint("ledger-dst"),
|
||||
Amount: &paymenttypes.Money{Amount: "10", Currency: "USD"},
|
||||
SettlementCurrency: "USD",
|
||||
},
|
||||
Quote: &model.PaymentQuoteSnapshot{
|
||||
QuoteRef: quoteRef,
|
||||
DebitAmount: &paymenttypes.Money{Amount: "10", Currency: "USD"},
|
||||
Route: route,
|
||||
},
|
||||
StatusV2: &model.QuoteStatusV2{State: model.QuoteStateExecutable},
|
||||
ExpiresAt: now.Add(1 * time.Hour),
|
||||
}
|
||||
}
|
||||
|
||||
func buildLedgerRoute() *paymenttypes.QuoteRouteSpecification {
|
||||
return &paymenttypes.QuoteRouteSpecification{
|
||||
Hops: []*paymenttypes.QuoteRouteHop{
|
||||
{Index: 10, Rail: "LEDGER"},
|
||||
{Index: 20, Rail: "LEDGER"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func buildCardRoute() *paymenttypes.QuoteRouteSpecification {
|
||||
return &paymenttypes.QuoteRouteSpecification{
|
||||
Rail: "CARD_PAYOUT",
|
||||
Provider: "gw-card",
|
||||
Network: "visa",
|
||||
}
|
||||
}
|
||||
|
||||
func testMeta(orgRef bson.ObjectID, idempotencyKey string) *sharedv1.RequestMeta {
|
||||
meta := &sharedv1.RequestMeta{OrganizationRef: orgRef.Hex()}
|
||||
if strings.TrimSpace(idempotencyKey) != "" {
|
||||
meta.Trace = &tracev1.TraceContext{IdempotencyKey: idempotencyKey}
|
||||
}
|
||||
return meta
|
||||
}
|
||||
|
||||
func modelBase(at time.Time) storable.Base {
|
||||
return storable.Base{
|
||||
ID: bson.NewObjectID(),
|
||||
CreatedAt: at.UTC(),
|
||||
UpdatedAt: at.UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
func testPayment(orgRef bson.ObjectID, paymentRef, idem, quoteRef string, state agg.State, createdAt time.Time) *agg.Payment {
|
||||
return &agg.Payment{
|
||||
Base: modelBase(createdAt),
|
||||
OrganizationBoundBase: pm.OrganizationBoundBase{
|
||||
OrganizationRef: orgRef,
|
||||
},
|
||||
PaymentRef: paymentRef,
|
||||
IdempotencyKey: idem,
|
||||
QuotationRef: quoteRef,
|
||||
ClientPaymentRef: "client-" + paymentRef,
|
||||
IntentSnapshot: model.PaymentIntent{
|
||||
Ref: "intent-" + paymentRef,
|
||||
Kind: model.PaymentKindPayout,
|
||||
Source: testLedgerEndpoint("ledger-src"),
|
||||
Destination: testLedgerEndpoint("ledger-dst"),
|
||||
Amount: &paymenttypes.Money{Amount: "10", Currency: "USD"},
|
||||
SettlementCurrency: "USD",
|
||||
},
|
||||
QuoteSnapshot: &model.PaymentQuoteSnapshot{
|
||||
QuoteRef: quoteRef,
|
||||
DebitAmount: &paymenttypes.Money{Amount: "10", Currency: "USD"},
|
||||
Route: buildLedgerRoute(),
|
||||
},
|
||||
State: state,
|
||||
Version: 1,
|
||||
StepExecutions: []agg.StepExecution{
|
||||
{StepRef: "step-1", StepCode: "edge.10_20.ledger.move", State: agg.StepStateCompleted, Attempt: 1},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func mustCreatePayment(t *testing.T, repo prepo.Repository, payment *agg.Payment) {
|
||||
t.Helper()
|
||||
if err := repo.Create(context.Background(), payment); err != nil {
|
||||
t.Fatalf("Create returned error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func testLedgerEndpoint(account string) model.PaymentEndpoint {
|
||||
return model.PaymentEndpoint{
|
||||
Type: model.EndpointTypeLedger,
|
||||
Ledger: &model.LedgerEndpoint{
|
||||
LedgerAccountRef: strings.TrimSpace(account),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package qsnap
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
quotestorage "github.com/tech/sendico/payments/storage/quote"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
type fakeStore struct {
|
||||
getByRefFn func(ctx context.Context, orgRef bson.ObjectID, quoteRef string) (*model.PaymentQuoteRecord, error)
|
||||
}
|
||||
|
||||
func (f *fakeStore) GetByRef(ctx context.Context, orgRef bson.ObjectID, quoteRef string) (*model.PaymentQuoteRecord, error) {
|
||||
if f.getByRefFn == nil {
|
||||
return nil, quotestorage.ErrQuoteNotFound
|
||||
}
|
||||
return f.getByRefFn(ctx, orgRef, quoteRef)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
@@ -33,8 +34,23 @@ type Output struct {
|
||||
QuoteSnapshot *model.PaymentQuoteSnapshot
|
||||
}
|
||||
|
||||
func New() Resolver {
|
||||
// Dependencies configures quote resolver integrations.
|
||||
type Dependencies struct {
|
||||
Logger mlogger.Logger
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func New(deps ...Dependencies) Resolver {
|
||||
var dep Dependencies
|
||||
if len(deps) > 0 {
|
||||
dep = deps[0]
|
||||
}
|
||||
now := dep.Now
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &svc{
|
||||
now: time.Now,
|
||||
logger: dep.Logger.Named("qsnap"),
|
||||
now: now,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
package qsnap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
quotestorage "github.com/tech/sendico/payments/storage/quote"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
func TestResolve_NotFound(t *testing.T) {
|
||||
resolver := New()
|
||||
|
||||
_, err := resolver.Resolve(context.Background(), &fakeStore{
|
||||
getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) {
|
||||
return nil, quotestorage.ErrQuoteNotFound
|
||||
},
|
||||
}, Input{
|
||||
OrganizationID: bson.NewObjectID(),
|
||||
QuotationRef: "quote-ref",
|
||||
})
|
||||
if !errors.Is(err, ErrQuoteNotFound) {
|
||||
t.Fatalf("expected ErrQuoteNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_Expired(t *testing.T) {
|
||||
now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC)
|
||||
resolver := &svc{
|
||||
now: func() time.Time { return now },
|
||||
}
|
||||
|
||||
_, err := resolver.Resolve(context.Background(), &fakeStore{
|
||||
getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) {
|
||||
return &model.PaymentQuoteRecord{
|
||||
QuoteRef: "quote-ref",
|
||||
Intent: model.PaymentIntent{
|
||||
Kind: model.PaymentKindPayout,
|
||||
},
|
||||
Quote: &model.PaymentQuoteSnapshot{},
|
||||
StatusV2: &model.QuoteStatusV2{
|
||||
State: model.QuoteStateExecutable,
|
||||
},
|
||||
ExpiresAt: now.Add(-time.Second),
|
||||
}, nil
|
||||
},
|
||||
}, Input{
|
||||
OrganizationID: bson.NewObjectID(),
|
||||
QuotationRef: "quote-ref",
|
||||
})
|
||||
if !errors.Is(err, ErrQuoteExpired) {
|
||||
t.Fatalf("expected ErrQuoteExpired, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_NotExecutableState(t *testing.T) {
|
||||
now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC)
|
||||
resolver := &svc{
|
||||
now: func() time.Time { return now },
|
||||
}
|
||||
|
||||
_, err := resolver.Resolve(context.Background(), &fakeStore{
|
||||
getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) {
|
||||
return &model.PaymentQuoteRecord{
|
||||
QuoteRef: "quote-ref",
|
||||
Intent: model.PaymentIntent{
|
||||
Kind: model.PaymentKindPayout,
|
||||
},
|
||||
Quote: &model.PaymentQuoteSnapshot{},
|
||||
StatusV2: &model.QuoteStatusV2{
|
||||
State: model.QuoteStateBlocked,
|
||||
BlockReason: model.QuoteBlockReasonRouteUnavailable,
|
||||
},
|
||||
ExpiresAt: now.Add(time.Minute),
|
||||
}, nil
|
||||
},
|
||||
}, Input{
|
||||
OrganizationID: bson.NewObjectID(),
|
||||
QuotationRef: "quote-ref",
|
||||
})
|
||||
if !errors.Is(err, ErrQuoteNotExecutable) {
|
||||
t.Fatalf("expected ErrQuoteNotExecutable, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_NotExecutableExecutionNote(t *testing.T) {
|
||||
now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC)
|
||||
resolver := &svc{
|
||||
now: func() time.Time { return now },
|
||||
}
|
||||
|
||||
_, err := resolver.Resolve(context.Background(), &fakeStore{
|
||||
getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) {
|
||||
return &model.PaymentQuoteRecord{
|
||||
QuoteRef: "quote-ref",
|
||||
Intent: model.PaymentIntent{
|
||||
Kind: model.PaymentKindPayout,
|
||||
},
|
||||
Quote: &model.PaymentQuoteSnapshot{},
|
||||
ExecutionNote: "quote will not be executed",
|
||||
ExpiresAt: now.Add(time.Minute),
|
||||
}, nil
|
||||
},
|
||||
}, Input{
|
||||
OrganizationID: bson.NewObjectID(),
|
||||
QuotationRef: "quote-ref",
|
||||
})
|
||||
if !errors.Is(err, ErrQuoteNotExecutable) {
|
||||
t.Fatalf("expected ErrQuoteNotExecutable, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_ShapeMismatch(t *testing.T) {
|
||||
now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC)
|
||||
resolver := &svc{
|
||||
now: func() time.Time { return now },
|
||||
}
|
||||
|
||||
_, err := resolver.Resolve(context.Background(), &fakeStore{
|
||||
getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) {
|
||||
return &model.PaymentQuoteRecord{
|
||||
QuoteRef: "quote-ref",
|
||||
Intents: []model.PaymentIntent{
|
||||
{Kind: model.PaymentKindPayout},
|
||||
{Kind: model.PaymentKindPayout},
|
||||
},
|
||||
Quotes: []*model.PaymentQuoteSnapshot{
|
||||
{},
|
||||
},
|
||||
ExpiresAt: now.Add(time.Minute),
|
||||
}, nil
|
||||
},
|
||||
}, Input{
|
||||
OrganizationID: bson.NewObjectID(),
|
||||
QuotationRef: "quote-ref",
|
||||
IntentRef: "intent-1",
|
||||
})
|
||||
if !errors.Is(err, ErrQuoteShapeMismatch) {
|
||||
t.Fatalf("expected ErrQuoteShapeMismatch, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_InputValidation(t *testing.T) {
|
||||
resolver := New()
|
||||
orgID := bson.NewObjectID()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
store Store
|
||||
in Input
|
||||
}{
|
||||
{
|
||||
name: "nil store",
|
||||
store: nil,
|
||||
in: Input{
|
||||
OrganizationID: orgID,
|
||||
QuotationRef: "quote-ref",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty org id",
|
||||
store: &fakeStore{},
|
||||
in: Input{
|
||||
QuotationRef: "quote-ref",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty quotation ref",
|
||||
store: &fakeStore{},
|
||||
in: Input{
|
||||
OrganizationID: orgID,
|
||||
QuotationRef: " ",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := resolver.Resolve(context.Background(), tt.store, tt.in)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
quotestorage "github.com/tech/sendico/payments/storage/quote"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
@@ -244,191 +243,3 @@ func TestResolve_MultiShapeIntentRefNotFound(t *testing.T) {
|
||||
t.Fatalf("expected ErrIntentRefNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_NotFound(t *testing.T) {
|
||||
resolver := New()
|
||||
|
||||
_, err := resolver.Resolve(context.Background(), &fakeStore{
|
||||
getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) {
|
||||
return nil, quotestorage.ErrQuoteNotFound
|
||||
},
|
||||
}, Input{
|
||||
OrganizationID: bson.NewObjectID(),
|
||||
QuotationRef: "quote-ref",
|
||||
})
|
||||
if !errors.Is(err, ErrQuoteNotFound) {
|
||||
t.Fatalf("expected ErrQuoteNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_Expired(t *testing.T) {
|
||||
now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC)
|
||||
resolver := &svc{
|
||||
now: func() time.Time { return now },
|
||||
}
|
||||
|
||||
_, err := resolver.Resolve(context.Background(), &fakeStore{
|
||||
getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) {
|
||||
return &model.PaymentQuoteRecord{
|
||||
QuoteRef: "quote-ref",
|
||||
Intent: model.PaymentIntent{
|
||||
Kind: model.PaymentKindPayout,
|
||||
},
|
||||
Quote: &model.PaymentQuoteSnapshot{},
|
||||
StatusV2: &model.QuoteStatusV2{
|
||||
State: model.QuoteStateExecutable,
|
||||
},
|
||||
ExpiresAt: now.Add(-time.Second),
|
||||
}, nil
|
||||
},
|
||||
}, Input{
|
||||
OrganizationID: bson.NewObjectID(),
|
||||
QuotationRef: "quote-ref",
|
||||
})
|
||||
if !errors.Is(err, ErrQuoteExpired) {
|
||||
t.Fatalf("expected ErrQuoteExpired, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_NotExecutableState(t *testing.T) {
|
||||
now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC)
|
||||
resolver := &svc{
|
||||
now: func() time.Time { return now },
|
||||
}
|
||||
|
||||
_, err := resolver.Resolve(context.Background(), &fakeStore{
|
||||
getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) {
|
||||
return &model.PaymentQuoteRecord{
|
||||
QuoteRef: "quote-ref",
|
||||
Intent: model.PaymentIntent{
|
||||
Kind: model.PaymentKindPayout,
|
||||
},
|
||||
Quote: &model.PaymentQuoteSnapshot{},
|
||||
StatusV2: &model.QuoteStatusV2{
|
||||
State: model.QuoteStateBlocked,
|
||||
BlockReason: model.QuoteBlockReasonRouteUnavailable,
|
||||
},
|
||||
ExpiresAt: now.Add(time.Minute),
|
||||
}, nil
|
||||
},
|
||||
}, Input{
|
||||
OrganizationID: bson.NewObjectID(),
|
||||
QuotationRef: "quote-ref",
|
||||
})
|
||||
if !errors.Is(err, ErrQuoteNotExecutable) {
|
||||
t.Fatalf("expected ErrQuoteNotExecutable, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_NotExecutableExecutionNote(t *testing.T) {
|
||||
now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC)
|
||||
resolver := &svc{
|
||||
now: func() time.Time { return now },
|
||||
}
|
||||
|
||||
_, err := resolver.Resolve(context.Background(), &fakeStore{
|
||||
getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) {
|
||||
return &model.PaymentQuoteRecord{
|
||||
QuoteRef: "quote-ref",
|
||||
Intent: model.PaymentIntent{
|
||||
Kind: model.PaymentKindPayout,
|
||||
},
|
||||
Quote: &model.PaymentQuoteSnapshot{},
|
||||
ExecutionNote: "quote will not be executed",
|
||||
ExpiresAt: now.Add(time.Minute),
|
||||
}, nil
|
||||
},
|
||||
}, Input{
|
||||
OrganizationID: bson.NewObjectID(),
|
||||
QuotationRef: "quote-ref",
|
||||
})
|
||||
if !errors.Is(err, ErrQuoteNotExecutable) {
|
||||
t.Fatalf("expected ErrQuoteNotExecutable, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_ShapeMismatch(t *testing.T) {
|
||||
now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC)
|
||||
resolver := &svc{
|
||||
now: func() time.Time { return now },
|
||||
}
|
||||
|
||||
_, err := resolver.Resolve(context.Background(), &fakeStore{
|
||||
getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) {
|
||||
return &model.PaymentQuoteRecord{
|
||||
QuoteRef: "quote-ref",
|
||||
Intents: []model.PaymentIntent{
|
||||
{Kind: model.PaymentKindPayout},
|
||||
{Kind: model.PaymentKindPayout},
|
||||
},
|
||||
Quotes: []*model.PaymentQuoteSnapshot{
|
||||
{},
|
||||
},
|
||||
ExpiresAt: now.Add(time.Minute),
|
||||
}, nil
|
||||
},
|
||||
}, Input{
|
||||
OrganizationID: bson.NewObjectID(),
|
||||
QuotationRef: "quote-ref",
|
||||
IntentRef: "intent-1",
|
||||
})
|
||||
if !errors.Is(err, ErrQuoteShapeMismatch) {
|
||||
t.Fatalf("expected ErrQuoteShapeMismatch, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_InputValidation(t *testing.T) {
|
||||
resolver := New()
|
||||
orgID := bson.NewObjectID()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
store Store
|
||||
in Input
|
||||
}{
|
||||
{
|
||||
name: "nil store",
|
||||
store: nil,
|
||||
in: Input{
|
||||
OrganizationID: orgID,
|
||||
QuotationRef: "quote-ref",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty org id",
|
||||
store: &fakeStore{},
|
||||
in: Input{
|
||||
QuotationRef: "quote-ref",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty quotation ref",
|
||||
store: &fakeStore{},
|
||||
in: Input{
|
||||
OrganizationID: orgID,
|
||||
QuotationRef: " ",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := resolver.Resolve(context.Background(), tt.store, tt.in)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type fakeStore struct {
|
||||
getByRefFn func(ctx context.Context, orgRef bson.ObjectID, quoteRef string) (*model.PaymentQuoteRecord, error)
|
||||
}
|
||||
|
||||
func (f *fakeStore) GetByRef(ctx context.Context, orgRef bson.ObjectID, quoteRef string) (*model.PaymentQuoteRecord, error) {
|
||||
if f.getByRefFn == nil {
|
||||
return nil, quotestorage.ErrQuoteNotFound
|
||||
}
|
||||
return f.getByRefFn(ctx, orgRef, quoteRef)
|
||||
}
|
||||
@@ -3,18 +3,21 @@ package qsnap
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xerr"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
quotestorage "github.com/tech/sendico/payments/storage/quote"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type svc struct {
|
||||
now func() time.Time
|
||||
logger mlogger.Logger
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
type resolvedQuoteItem struct {
|
||||
@@ -27,7 +30,28 @@ func (s *svc) Resolve(
|
||||
ctx context.Context,
|
||||
store Store,
|
||||
in Input,
|
||||
) (*Output, error) {
|
||||
) (out *Output, err error) {
|
||||
logger := s.logger
|
||||
logger.Debug("Starting Resolve",
|
||||
zap.String("organization_ref", in.OrganizationID.Hex()),
|
||||
zap.String("quotation_ref", strings.TrimSpace(in.QuotationRef)),
|
||||
zap.String("intent_ref", strings.TrimSpace(in.IntentRef)),
|
||||
)
|
||||
defer func(start time.Time) {
|
||||
fields := []zap.Field{zap.Int64("duration_ms", time.Since(start).Milliseconds())}
|
||||
if out != nil {
|
||||
fields = append(fields,
|
||||
zap.String("quotation_ref", strings.TrimSpace(out.QuotationRef)),
|
||||
zap.String("intent_ref", strings.TrimSpace(out.IntentRef)),
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
logger.Warn("Failed to resolve", append(fields, zap.Error(err))...)
|
||||
return
|
||||
}
|
||||
logger.Debug("Completed Resolve", fields...)
|
||||
}(time.Now())
|
||||
|
||||
if store == nil {
|
||||
return nil, merrors.InvalidArgument("quotes store is required")
|
||||
}
|
||||
@@ -69,12 +93,13 @@ func (s *svc) Resolve(
|
||||
item.Quote.QuoteRef = outputRef
|
||||
}
|
||||
|
||||
return &Output{
|
||||
out = &Output{
|
||||
QuotationRef: outputRef,
|
||||
IntentRef: firstNonEmpty(strings.TrimSpace(item.Intent.Ref), intentRef),
|
||||
IntentSnapshot: item.Intent,
|
||||
QuoteSnapshot: item.Quote,
|
||||
}, nil
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func ensureExecutable(
|
||||
@@ -90,7 +115,7 @@ func ensureExecutable(
|
||||
}
|
||||
|
||||
if note := strings.TrimSpace(record.ExecutionNote); note != "" {
|
||||
return fmt.Errorf("%w: %s", ErrQuoteNotExecutable, note)
|
||||
return xerr.Wrapf(ErrQuoteNotExecutable, "%s", note)
|
||||
}
|
||||
|
||||
if status == nil {
|
||||
@@ -106,23 +131,23 @@ func ensureExecutable(
|
||||
case model.QuoteStateBlocked:
|
||||
reason := strings.TrimSpace(string(status.BlockReason))
|
||||
if reason != "" && reason != string(model.QuoteBlockReasonUnspecified) {
|
||||
return fmt.Errorf("%w: blocked (%s)", ErrQuoteNotExecutable, reason)
|
||||
return xerr.Wrapf(ErrQuoteNotExecutable, "blocked (%s)", reason)
|
||||
}
|
||||
return fmt.Errorf("%w: blocked", ErrQuoteNotExecutable)
|
||||
return xerr.Wrapf(ErrQuoteNotExecutable, "blocked")
|
||||
case model.QuoteStateIndicative:
|
||||
return fmt.Errorf("%w: indicative", ErrQuoteNotExecutable)
|
||||
return xerr.Wrapf(ErrQuoteNotExecutable, "indicative")
|
||||
default:
|
||||
state := strings.TrimSpace(string(status.State))
|
||||
if state == "" {
|
||||
return fmt.Errorf("%w: unspecified status", ErrQuoteNotExecutable)
|
||||
return xerr.Wrapf(ErrQuoteNotExecutable, "unspecified status")
|
||||
}
|
||||
return fmt.Errorf("%w: state=%s", ErrQuoteNotExecutable, state)
|
||||
return xerr.Wrapf(ErrQuoteNotExecutable, "state=%s", state)
|
||||
}
|
||||
}
|
||||
|
||||
func resolveRecordItem(record *model.PaymentQuoteRecord, intentRef string) (*resolvedQuoteItem, error) {
|
||||
if record == nil {
|
||||
return nil, fmt.Errorf("%w: record is nil", ErrQuoteShapeMismatch)
|
||||
return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "record is nil")
|
||||
}
|
||||
|
||||
hasArrayShape := len(record.Intents) > 0 || len(record.Quotes) > 0 || len(record.StatusesV2) > 0
|
||||
@@ -134,19 +159,19 @@ func resolveRecordItem(record *model.PaymentQuoteRecord, intentRef string) (*res
|
||||
|
||||
func resolveSingleShapeItem(record *model.PaymentQuoteRecord, intentRef string) (*resolvedQuoteItem, error) {
|
||||
if record == nil {
|
||||
return nil, fmt.Errorf("%w: record is nil", ErrQuoteShapeMismatch)
|
||||
return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "record is nil")
|
||||
}
|
||||
|
||||
if record.Quote == nil {
|
||||
return nil, fmt.Errorf("%w: quote snapshot is empty", ErrQuoteShapeMismatch)
|
||||
return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "quote snapshot is empty")
|
||||
}
|
||||
if isEmptyIntentSnapshot(record.Intent) {
|
||||
return nil, fmt.Errorf("%w: intent snapshot is empty", ErrQuoteShapeMismatch)
|
||||
return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "intent snapshot is empty")
|
||||
}
|
||||
if intentRef != "" {
|
||||
recordIntentRef := strings.TrimSpace(record.Intent.Ref)
|
||||
if recordIntentRef == "" || recordIntentRef != intentRef {
|
||||
return nil, fmt.Errorf("%w: %s", ErrIntentRefNotFound, intentRef)
|
||||
return nil, xerr.Wrapf(ErrIntentRefNotFound, "%s", intentRef)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,16 +193,16 @@ func resolveSingleShapeItem(record *model.PaymentQuoteRecord, intentRef string)
|
||||
|
||||
func resolveArrayShapeItem(record *model.PaymentQuoteRecord, intentRef string) (*resolvedQuoteItem, error) {
|
||||
if len(record.Intents) == 0 {
|
||||
return nil, fmt.Errorf("%w: intents are empty", ErrQuoteShapeMismatch)
|
||||
return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "intents are empty")
|
||||
}
|
||||
if len(record.Quotes) == 0 {
|
||||
return nil, fmt.Errorf("%w: quotes are empty", ErrQuoteShapeMismatch)
|
||||
return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "quotes are empty")
|
||||
}
|
||||
if len(record.Intents) != len(record.Quotes) {
|
||||
return nil, fmt.Errorf("%w: intents and quotes count mismatch", ErrQuoteShapeMismatch)
|
||||
return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "intents and quotes count mismatch")
|
||||
}
|
||||
if len(record.StatusesV2) > 0 && len(record.StatusesV2) != len(record.Quotes) {
|
||||
return nil, fmt.Errorf("%w: statuses and quotes count mismatch", ErrQuoteShapeMismatch)
|
||||
return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "statuses and quotes count mismatch")
|
||||
}
|
||||
|
||||
index := 0
|
||||
@@ -187,18 +212,18 @@ func resolveArrayShapeItem(record *model.PaymentQuoteRecord, intentRef string) (
|
||||
}
|
||||
selected, found := findIntentIndex(record.Intents, intentRef)
|
||||
if !found {
|
||||
return nil, fmt.Errorf("%w: %s", ErrIntentRefNotFound, intentRef)
|
||||
return nil, xerr.Wrapf(ErrIntentRefNotFound, "%s", intentRef)
|
||||
}
|
||||
index = selected
|
||||
} else if intentRef != "" {
|
||||
if strings.TrimSpace(record.Intents[0].Ref) != intentRef {
|
||||
return nil, fmt.Errorf("%w: %s", ErrIntentRefNotFound, intentRef)
|
||||
return nil, xerr.Wrapf(ErrIntentRefNotFound, "%s", intentRef)
|
||||
}
|
||||
}
|
||||
|
||||
quoteSnapshot := record.Quotes[index]
|
||||
if quoteSnapshot == nil {
|
||||
return nil, fmt.Errorf("%w: quote snapshot is nil", ErrQuoteShapeMismatch)
|
||||
return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "quote snapshot is nil")
|
||||
}
|
||||
|
||||
intentSnapshot, err := cloneIntentSnapshot(record.Intents[index])
|
||||
@@ -213,7 +238,7 @@ func resolveArrayShapeItem(record *model.PaymentQuoteRecord, intentRef string) (
|
||||
var statusSnapshot *model.QuoteStatusV2
|
||||
if len(record.StatusesV2) > 0 {
|
||||
if record.StatusesV2[index] == nil {
|
||||
return nil, fmt.Errorf("%w: status is nil", ErrQuoteShapeMismatch)
|
||||
return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "status is nil")
|
||||
}
|
||||
statusSnapshot = record.StatusesV2[index]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package reqval
|
||||
|
||||
import "go.mongodb.org/mongo-driver/v2/bson"
|
||||
import (
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// Validator validates execute-payment inputs and returns a normalized context.
|
||||
type Validator interface {
|
||||
@@ -37,6 +40,15 @@ type Ctx struct {
|
||||
ClientPaymentRef string
|
||||
}
|
||||
|
||||
func New() Validator {
|
||||
return &svc{}
|
||||
// Dependencies configures request validator integrations.
|
||||
type Dependencies struct {
|
||||
Logger mlogger.Logger
|
||||
}
|
||||
|
||||
func New(deps ...Dependencies) Validator {
|
||||
var dep Dependencies
|
||||
if len(deps) > 0 {
|
||||
dep = deps[0]
|
||||
}
|
||||
return &svc{logger: dep.Logger.Named("reqval")}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,12 @@ package reqval
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -17,9 +20,39 @@ const (
|
||||
|
||||
var refTokenRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:/-]*$`)
|
||||
|
||||
type svc struct{}
|
||||
type svc struct {
|
||||
logger mlogger.Logger
|
||||
}
|
||||
|
||||
func (s *svc) Validate(req *Req) (out *Ctx, err error) {
|
||||
logger := s.logger
|
||||
orgRefIn := ""
|
||||
if req != nil && req.Meta != nil {
|
||||
orgRefIn = strings.TrimSpace(req.Meta.OrganizationRef)
|
||||
}
|
||||
logger.Debug("Starting Validate",
|
||||
zap.String("organization_ref", orgRefIn),
|
||||
zap.String("quotation_ref", strings.TrimSpace(valueOrEmpty(req, func(v *Req) string { return v.QuotationRef }))),
|
||||
zap.String("intent_ref", strings.TrimSpace(valueOrEmpty(req, func(v *Req) string { return v.IntentRef }))),
|
||||
zap.Bool("has_idempotency_key", strings.TrimSpace(traceKey(req)) != ""),
|
||||
)
|
||||
defer func(start time.Time) {
|
||||
fields := []zap.Field{zap.Int64("duration_ms", time.Since(start).Milliseconds())}
|
||||
if out != nil {
|
||||
fields = append(fields,
|
||||
zap.String("organization_ref", out.OrganizationRef),
|
||||
zap.String("quotation_ref", out.QuotationRef),
|
||||
zap.String("intent_ref", out.IntentRef),
|
||||
zap.Bool("has_client_payment_ref", out.ClientPaymentRef != ""),
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
logger.Warn("Failed to validate", append(fields, zap.Error(err))...)
|
||||
return
|
||||
}
|
||||
logger.Debug("Completed Validate", fields...)
|
||||
}(time.Now())
|
||||
|
||||
func (s *svc) Validate(req *Req) (*Ctx, error) {
|
||||
if req == nil {
|
||||
return nil, merrors.InvalidArgument("request is required")
|
||||
}
|
||||
@@ -60,14 +93,15 @@ func (s *svc) Validate(req *Req) (*Ctx, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Ctx{
|
||||
out = &Ctx{
|
||||
OrganizationRef: orgRef,
|
||||
OrganizationID: orgID,
|
||||
IdempotencyKey: idempotencyKey,
|
||||
QuotationRef: quotationRef,
|
||||
IntentRef: intentRef,
|
||||
ClientPaymentRef: clientPaymentRef,
|
||||
}, nil
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func validateRefToken(field, value string, maxLen int, required bool) (string, error) {
|
||||
@@ -86,3 +120,17 @@ func validateRefToken(field, value string, maxLen int, required bool) (string, e
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func valueOrEmpty(req *Req, getter func(*Req) string) string {
|
||||
if req == nil || getter == nil {
|
||||
return ""
|
||||
}
|
||||
return getter(req)
|
||||
}
|
||||
|
||||
func traceKey(req *Req) string {
|
||||
if req == nil || req.Meta == nil || req.Meta.Trace == nil {
|
||||
return ""
|
||||
}
|
||||
return req.Meta.Trace.IdempotencyKey
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package sexec
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrMissingExecutor = errors.New("missing executor")
|
||||
ErrUnsupportedStep = errors.New("unsupported step")
|
||||
)
|
||||
@@ -0,0 +1,77 @@
|
||||
package sexec
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
)
|
||||
|
||||
// Registry dispatches orchestration steps to rail/action-specific executors.
|
||||
type Registry interface {
|
||||
Execute(ctx context.Context, in ExecuteInput) (*ExecuteOutput, error)
|
||||
}
|
||||
|
||||
// ExecuteInput is the step-execution payload.
|
||||
type ExecuteInput struct {
|
||||
Payment *agg.Payment
|
||||
Step xplan.Step
|
||||
StepExecution agg.StepExecution
|
||||
}
|
||||
|
||||
// ExecuteOutput is the executor result for one step.
|
||||
type ExecuteOutput struct {
|
||||
StepExecution agg.StepExecution
|
||||
Async bool
|
||||
}
|
||||
|
||||
// StepRequest is the normalized request passed to concrete executors.
|
||||
type StepRequest struct {
|
||||
Payment *agg.Payment
|
||||
Step xplan.Step
|
||||
StepExecution agg.StepExecution
|
||||
}
|
||||
|
||||
// LedgerExecutor handles ledger-bound actions.
|
||||
type LedgerExecutor interface {
|
||||
ExecuteLedger(ctx context.Context, req StepRequest) (*ExecuteOutput, error)
|
||||
}
|
||||
|
||||
// CryptoExecutor handles crypto rail SEND/FEE actions.
|
||||
type CryptoExecutor interface {
|
||||
ExecuteCrypto(ctx context.Context, req StepRequest) (*ExecuteOutput, error)
|
||||
}
|
||||
|
||||
// ProviderSettlementExecutor handles provider settlement SEND actions.
|
||||
type ProviderSettlementExecutor interface {
|
||||
ExecuteProviderSettlement(ctx context.Context, req StepRequest) (*ExecuteOutput, error)
|
||||
}
|
||||
|
||||
// CardPayoutExecutor handles card payout SEND actions.
|
||||
type CardPayoutExecutor interface {
|
||||
ExecuteCardPayout(ctx context.Context, req StepRequest) (*ExecuteOutput, error)
|
||||
}
|
||||
|
||||
// ObserveConfirmExecutor handles OBSERVE_CONFIRM actions.
|
||||
type ObserveConfirmExecutor interface {
|
||||
ExecuteObserveConfirm(ctx context.Context, req StepRequest) (*ExecuteOutput, error)
|
||||
}
|
||||
|
||||
// Dependencies defines concrete executors used by the registry.
|
||||
type Dependencies struct {
|
||||
Logger mlogger.Logger
|
||||
Ledger LedgerExecutor
|
||||
Crypto CryptoExecutor
|
||||
ProviderSettlement ProviderSettlementExecutor
|
||||
CardPayout CardPayoutExecutor
|
||||
ObserveConfirm ObserveConfirmExecutor
|
||||
}
|
||||
|
||||
func New(deps Dependencies) Registry {
|
||||
return &svc{
|
||||
logger: deps.Logger.Named("sexec"),
|
||||
deps: deps,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package sexec
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
)
|
||||
|
||||
type route int
|
||||
|
||||
const (
|
||||
routeUnknown route = iota + 1
|
||||
routeLedger
|
||||
routeCrypto
|
||||
routeProviderSettlement
|
||||
routeCardPayout
|
||||
routeObserveConfirm
|
||||
)
|
||||
|
||||
func classifyRoute(step xplan.Step) route {
|
||||
action := normalizeAction(step.Action)
|
||||
rail := normalizeRail(step.Rail)
|
||||
|
||||
switch action {
|
||||
case model.RailOperationObserveConfirm:
|
||||
return routeObserveConfirm
|
||||
case model.RailOperationSend:
|
||||
switch rail {
|
||||
case model.RailCrypto:
|
||||
return routeCrypto
|
||||
case model.RailProviderSettlement:
|
||||
return routeProviderSettlement
|
||||
case model.RailCardPayout:
|
||||
return routeCardPayout
|
||||
default:
|
||||
return routeUnknown
|
||||
}
|
||||
case model.RailOperationFee:
|
||||
if rail == model.RailCrypto {
|
||||
return routeCrypto
|
||||
}
|
||||
return routeUnknown
|
||||
default:
|
||||
if isLedgerAction(action) {
|
||||
return routeLedger
|
||||
}
|
||||
return routeUnknown
|
||||
}
|
||||
}
|
||||
|
||||
func isLedgerAction(action model.RailOperation) bool {
|
||||
switch action {
|
||||
case model.RailOperationDebit,
|
||||
model.RailOperationCredit,
|
||||
model.RailOperationExternalDebit,
|
||||
model.RailOperationExternalCredit,
|
||||
model.RailOperationMove,
|
||||
model.RailOperationBlock,
|
||||
model.RailOperationRelease,
|
||||
model.RailOperationFXConvert:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeAction(action model.RailOperation) model.RailOperation {
|
||||
switch strings.ToUpper(strings.TrimSpace(string(action))) {
|
||||
case string(model.RailOperationDebit):
|
||||
return model.RailOperationDebit
|
||||
case string(model.RailOperationCredit):
|
||||
return model.RailOperationCredit
|
||||
case string(model.RailOperationExternalDebit):
|
||||
return model.RailOperationExternalDebit
|
||||
case string(model.RailOperationExternalCredit):
|
||||
return model.RailOperationExternalCredit
|
||||
case string(model.RailOperationMove):
|
||||
return model.RailOperationMove
|
||||
case string(model.RailOperationSend):
|
||||
return model.RailOperationSend
|
||||
case string(model.RailOperationFee):
|
||||
return model.RailOperationFee
|
||||
case string(model.RailOperationObserveConfirm):
|
||||
return model.RailOperationObserveConfirm
|
||||
case string(model.RailOperationFXConvert):
|
||||
return model.RailOperationFXConvert
|
||||
case string(model.RailOperationBlock):
|
||||
return model.RailOperationBlock
|
||||
case string(model.RailOperationRelease):
|
||||
return model.RailOperationRelease
|
||||
default:
|
||||
return model.RailOperationUnspecified
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeRail(rail model.Rail) model.Rail {
|
||||
switch strings.ToUpper(strings.TrimSpace(string(rail))) {
|
||||
case string(model.RailCrypto):
|
||||
return model.RailCrypto
|
||||
case string(model.RailProviderSettlement):
|
||||
return model.RailProviderSettlement
|
||||
case string(model.RailLedger):
|
||||
return model.RailLedger
|
||||
case string(model.RailCardPayout):
|
||||
return model.RailCardPayout
|
||||
case string(model.RailFiatOnRamp):
|
||||
return model.RailFiatOnRamp
|
||||
default:
|
||||
return model.RailUnspecified
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package sexec
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xerr"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type svc struct {
|
||||
logger mlogger.Logger
|
||||
deps Dependencies
|
||||
}
|
||||
|
||||
func (s *svc) Execute(ctx context.Context, in ExecuteInput) (out *ExecuteOutput, err error) {
|
||||
logger := s.logger
|
||||
logger.Debug("Starting Execute",
|
||||
zap.String("step_ref", strings.TrimSpace(in.Step.StepRef)),
|
||||
zap.String("step_code", strings.TrimSpace(in.Step.StepCode)),
|
||||
zap.String("action", strings.TrimSpace(string(in.Step.Action))),
|
||||
zap.String("rail", strings.TrimSpace(string(in.Step.Rail))),
|
||||
)
|
||||
defer func(start time.Time) {
|
||||
fields := []zap.Field{zap.Int64("duration_ms", time.Since(start).Milliseconds())}
|
||||
if out != nil {
|
||||
fields = append(fields,
|
||||
zap.String("result_state", string(out.StepExecution.State)),
|
||||
zap.Uint32("result_attempt", out.StepExecution.Attempt),
|
||||
zap.Bool("async", out.Async),
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
logger.Warn("Failed to execute", append(fields, zap.Error(err))...)
|
||||
return
|
||||
}
|
||||
logger.Debug("Completed Execute", fields...)
|
||||
}(time.Now())
|
||||
|
||||
req, err := validateInput(in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switch classifyRoute(req.Step) {
|
||||
case routeLedger:
|
||||
if s.deps.Ledger == nil {
|
||||
return nil, missingExecutorError("ledger")
|
||||
}
|
||||
out, err = s.deps.Ledger.ExecuteLedger(ctx, req)
|
||||
return out, err
|
||||
case routeCrypto:
|
||||
if s.deps.Crypto == nil {
|
||||
return nil, missingExecutorError("crypto")
|
||||
}
|
||||
out, err = s.deps.Crypto.ExecuteCrypto(ctx, req)
|
||||
return out, err
|
||||
case routeProviderSettlement:
|
||||
if s.deps.ProviderSettlement == nil {
|
||||
return nil, missingExecutorError("provider_settlement")
|
||||
}
|
||||
out, err = s.deps.ProviderSettlement.ExecuteProviderSettlement(ctx, req)
|
||||
return out, err
|
||||
case routeCardPayout:
|
||||
if s.deps.CardPayout == nil {
|
||||
return nil, missingExecutorError("card_payout")
|
||||
}
|
||||
out, err = s.deps.CardPayout.ExecuteCardPayout(ctx, req)
|
||||
return out, err
|
||||
case routeObserveConfirm:
|
||||
if s.deps.ObserveConfirm == nil {
|
||||
return nil, missingExecutorError("observe_confirm")
|
||||
}
|
||||
out, err = s.deps.ObserveConfirm.ExecuteObserveConfirm(ctx, req)
|
||||
return out, err
|
||||
default:
|
||||
return nil, unsupportedStepError(req.Step)
|
||||
}
|
||||
}
|
||||
|
||||
func validateInput(in ExecuteInput) (StepRequest, error) {
|
||||
if in.Payment == nil {
|
||||
return StepRequest{}, merrors.InvalidArgument("payment is required")
|
||||
}
|
||||
|
||||
step, err := normalizeStep(in.Step)
|
||||
if err != nil {
|
||||
return StepRequest{}, err
|
||||
}
|
||||
exec, err := normalizeStepExecution(in.StepExecution, step)
|
||||
if err != nil {
|
||||
return StepRequest{}, err
|
||||
}
|
||||
|
||||
return StepRequest{
|
||||
Payment: in.Payment,
|
||||
Step: step,
|
||||
StepExecution: exec,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizeStep(step xplan.Step) (xplan.Step, error) {
|
||||
step.StepRef = strings.TrimSpace(step.StepRef)
|
||||
step.StepCode = strings.TrimSpace(step.StepCode)
|
||||
if step.StepRef == "" {
|
||||
return xplan.Step{}, merrors.InvalidArgument("step.step_ref is required")
|
||||
}
|
||||
if step.StepCode == "" {
|
||||
step.StepCode = step.StepRef
|
||||
}
|
||||
return step, nil
|
||||
}
|
||||
|
||||
func normalizeStepExecution(exec agg.StepExecution, step xplan.Step) (agg.StepExecution, error) {
|
||||
exec.StepRef = strings.TrimSpace(exec.StepRef)
|
||||
exec.StepCode = strings.TrimSpace(exec.StepCode)
|
||||
if exec.StepRef == "" {
|
||||
exec.StepRef = step.StepRef
|
||||
}
|
||||
if exec.StepRef != step.StepRef {
|
||||
return agg.StepExecution{}, merrors.InvalidArgument("step_execution.step_ref must match step.step_ref")
|
||||
}
|
||||
if exec.StepCode == "" {
|
||||
exec.StepCode = step.StepCode
|
||||
}
|
||||
if exec.Attempt == 0 {
|
||||
exec.Attempt = 1
|
||||
}
|
||||
return exec, nil
|
||||
}
|
||||
|
||||
func missingExecutorError(kind string) error {
|
||||
return xerr.Wrapf(ErrMissingExecutor, "%s", strings.TrimSpace(kind))
|
||||
}
|
||||
|
||||
func unsupportedStepError(step xplan.Step) error {
|
||||
msg := "action=" + strings.TrimSpace(string(step.Action)) + " rail=" + strings.TrimSpace(string(step.Rail))
|
||||
return xerr.Wrapf(ErrUnsupportedStep, "%s", msg)
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package sexec
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
)
|
||||
|
||||
func TestExecute_DispatchLedger(t *testing.T) {
|
||||
ledger := &fakeLedgerExecutor{}
|
||||
registry := New(Dependencies{Ledger: ledger})
|
||||
|
||||
out, err := registry.Execute(context.Background(), ExecuteInput{
|
||||
Payment: &agg.Payment{PaymentRef: "p1"},
|
||||
Step: xplan.Step{StepRef: "s1", StepCode: "ledger.debit", Action: model.RailOperationDebit, Rail: model.RailLedger},
|
||||
StepExecution: agg.StepExecution{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Execute returned error: %v", err)
|
||||
}
|
||||
if out == nil {
|
||||
t.Fatal("expected output")
|
||||
}
|
||||
if ledger.calls != 1 {
|
||||
t.Fatalf("expected ledger executor to be called once, got %d", ledger.calls)
|
||||
}
|
||||
if got, want := ledger.lastReq.StepExecution.Attempt, uint32(1); got != want {
|
||||
t.Fatalf("attempt mismatch: got=%d want=%d", got, want)
|
||||
}
|
||||
if got, want := ledger.lastReq.StepExecution.StepRef, "s1"; got != want {
|
||||
t.Fatalf("step_ref mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecute_DispatchSendRailsAndObserve(t *testing.T) {
|
||||
crypto := &fakeCryptoExecutor{}
|
||||
provider := &fakeProviderSettlementExecutor{}
|
||||
card := &fakeCardPayoutExecutor{}
|
||||
observe := &fakeObserveConfirmExecutor{}
|
||||
registry := New(Dependencies{
|
||||
Crypto: crypto,
|
||||
ProviderSettlement: provider,
|
||||
CardPayout: card,
|
||||
ObserveConfirm: observe,
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
step xplan.Step
|
||||
wantCalls func(t *testing.T)
|
||||
}{
|
||||
{
|
||||
name: "send crypto",
|
||||
step: xplan.Step{
|
||||
StepRef: "s1", StepCode: "crypto.send", Action: model.RailOperationSend, Rail: model.RailCrypto,
|
||||
},
|
||||
wantCalls: func(t *testing.T) {
|
||||
t.Helper()
|
||||
if crypto.calls != 1 || provider.calls != 0 || card.calls != 0 || observe.calls != 0 {
|
||||
t.Fatalf("unexpected call counters crypto=%d provider=%d card=%d observe=%d", crypto.calls, provider.calls, card.calls, observe.calls)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "send provider settlement",
|
||||
step: xplan.Step{
|
||||
StepRef: "s2", StepCode: "provider.send", Action: model.RailOperationSend, Rail: model.RailProviderSettlement,
|
||||
},
|
||||
wantCalls: func(t *testing.T) {
|
||||
t.Helper()
|
||||
if crypto.calls != 1 || provider.calls != 1 || card.calls != 0 || observe.calls != 0 {
|
||||
t.Fatalf("unexpected call counters crypto=%d provider=%d card=%d observe=%d", crypto.calls, provider.calls, card.calls, observe.calls)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "send card payout",
|
||||
step: xplan.Step{
|
||||
StepRef: "s3", StepCode: "card.send", Action: model.RailOperationSend, Rail: model.RailCardPayout,
|
||||
},
|
||||
wantCalls: func(t *testing.T) {
|
||||
t.Helper()
|
||||
if crypto.calls != 1 || provider.calls != 1 || card.calls != 1 || observe.calls != 0 {
|
||||
t.Fatalf("unexpected call counters crypto=%d provider=%d card=%d observe=%d", crypto.calls, provider.calls, card.calls, observe.calls)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "observe confirm",
|
||||
step: xplan.Step{
|
||||
StepRef: "s4", StepCode: "observe", Action: model.RailOperationObserveConfirm, Rail: model.RailCardPayout,
|
||||
},
|
||||
wantCalls: func(t *testing.T) {
|
||||
t.Helper()
|
||||
if crypto.calls != 1 || provider.calls != 1 || card.calls != 1 || observe.calls != 1 {
|
||||
t.Fatalf("unexpected call counters crypto=%d provider=%d card=%d observe=%d", crypto.calls, provider.calls, card.calls, observe.calls)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "crypto fee",
|
||||
step: xplan.Step{
|
||||
StepRef: "s5", StepCode: "crypto.fee", Action: model.RailOperationFee, Rail: model.RailCrypto,
|
||||
},
|
||||
wantCalls: func(t *testing.T) {
|
||||
t.Helper()
|
||||
if crypto.calls != 2 || provider.calls != 1 || card.calls != 1 || observe.calls != 1 {
|
||||
t.Fatalf("unexpected call counters crypto=%d provider=%d card=%d observe=%d", crypto.calls, provider.calls, card.calls, observe.calls)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := registry.Execute(context.Background(), ExecuteInput{
|
||||
Payment: &agg.Payment{PaymentRef: "p1"},
|
||||
Step: tt.step,
|
||||
StepExecution: agg.StepExecution{StepRef: tt.step.StepRef, StepCode: tt.step.StepCode, Attempt: 1},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Execute returned error: %v", err)
|
||||
}
|
||||
tt.wantCalls(t)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecute_UnsupportedStep(t *testing.T) {
|
||||
registry := New(Dependencies{})
|
||||
|
||||
_, err := registry.Execute(context.Background(), ExecuteInput{
|
||||
Payment: &agg.Payment{PaymentRef: "p1"},
|
||||
Step: xplan.Step{StepRef: "s1", StepCode: "bad.send", Action: model.RailOperationSend, Rail: model.RailLedger},
|
||||
StepExecution: agg.StepExecution{StepRef: "s1", StepCode: "bad.send", Attempt: 1},
|
||||
})
|
||||
if !errors.Is(err, ErrUnsupportedStep) {
|
||||
t.Fatalf("expected ErrUnsupportedStep, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecute_MissingExecutor(t *testing.T) {
|
||||
registry := New(Dependencies{})
|
||||
|
||||
_, err := registry.Execute(context.Background(), ExecuteInput{
|
||||
Payment: &agg.Payment{PaymentRef: "p1"},
|
||||
Step: xplan.Step{StepRef: "s1", StepCode: "crypto.send", Action: model.RailOperationSend, Rail: model.RailCrypto},
|
||||
StepExecution: agg.StepExecution{StepRef: "s1", StepCode: "crypto.send", Attempt: 1},
|
||||
})
|
||||
if !errors.Is(err, ErrMissingExecutor) {
|
||||
t.Fatalf("expected ErrMissingExecutor, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecute_ValidationErrors(t *testing.T) {
|
||||
ledger := &fakeLedgerExecutor{}
|
||||
registry := New(Dependencies{Ledger: ledger})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
in ExecuteInput
|
||||
}{
|
||||
{
|
||||
name: "missing payment",
|
||||
in: ExecuteInput{
|
||||
Step: xplan.Step{StepRef: "s1", Action: model.RailOperationDebit},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing step ref",
|
||||
in: ExecuteInput{
|
||||
Payment: &agg.Payment{},
|
||||
Step: xplan.Step{StepRef: " ", Action: model.RailOperationDebit},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mismatched step execution ref",
|
||||
in: ExecuteInput{
|
||||
Payment: &agg.Payment{},
|
||||
Step: xplan.Step{StepRef: "s1", StepCode: "s1", Action: model.RailOperationDebit},
|
||||
StepExecution: agg.StepExecution{StepRef: "s2"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := registry.Execute(context.Background(), tt.in)
|
||||
if !errors.Is(err, merrors.ErrInvalidArg) {
|
||||
t.Fatalf("expected invalid argument error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type fakeLedgerExecutor struct {
|
||||
calls int
|
||||
lastReq StepRequest
|
||||
}
|
||||
|
||||
func (f *fakeLedgerExecutor) ExecuteLedger(_ context.Context, req StepRequest) (*ExecuteOutput, error) {
|
||||
f.calls++
|
||||
f.lastReq = req
|
||||
return &ExecuteOutput{
|
||||
StepExecution: req.StepExecution,
|
||||
Async: false,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type fakeCryptoExecutor struct {
|
||||
calls int
|
||||
lastReq StepRequest
|
||||
}
|
||||
|
||||
func (f *fakeCryptoExecutor) ExecuteCrypto(_ context.Context, req StepRequest) (*ExecuteOutput, error) {
|
||||
f.calls++
|
||||
f.lastReq = req
|
||||
return &ExecuteOutput{
|
||||
StepExecution: req.StepExecution,
|
||||
Async: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type fakeProviderSettlementExecutor struct {
|
||||
calls int
|
||||
lastReq StepRequest
|
||||
}
|
||||
|
||||
func (f *fakeProviderSettlementExecutor) ExecuteProviderSettlement(_ context.Context, req StepRequest) (*ExecuteOutput, error) {
|
||||
f.calls++
|
||||
f.lastReq = req
|
||||
return &ExecuteOutput{
|
||||
StepExecution: req.StepExecution,
|
||||
Async: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type fakeCardPayoutExecutor struct {
|
||||
calls int
|
||||
lastReq StepRequest
|
||||
}
|
||||
|
||||
func (f *fakeCardPayoutExecutor) ExecuteCardPayout(_ context.Context, req StepRequest) (*ExecuteOutput, error) {
|
||||
f.calls++
|
||||
f.lastReq = req
|
||||
return &ExecuteOutput{
|
||||
StepExecution: req.StepExecution,
|
||||
Async: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type fakeObserveConfirmExecutor struct {
|
||||
calls int
|
||||
lastReq StepRequest
|
||||
}
|
||||
|
||||
func (f *fakeObserveConfirmExecutor) ExecuteObserveConfirm(_ context.Context, req StepRequest) (*ExecuteOutput, error) {
|
||||
f.calls++
|
||||
f.lastReq = req
|
||||
return &ExecuteOutput{
|
||||
StepExecution: req.StepExecution,
|
||||
Async: true,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
package ssched
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
)
|
||||
|
||||
func (s *svc) prepareInput(in Input) (*preparedInput, error) {
|
||||
if len(in.Steps) == 0 {
|
||||
return nil, merrors.InvalidArgument("steps are required")
|
||||
}
|
||||
|
||||
stepsByRef := make(map[string]xplan.Step, len(in.Steps))
|
||||
order := make([]string, 0, len(in.Steps))
|
||||
|
||||
for i := range in.Steps {
|
||||
step, err := normalizeGraphStep(in.Steps[i], i)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, exists := stepsByRef[step.StepRef]; exists {
|
||||
return nil, merrors.InvalidArgument("steps[" + itoa(i) + "].step_ref must be unique")
|
||||
}
|
||||
stepsByRef[step.StepRef] = step
|
||||
order = append(order, step.StepRef)
|
||||
}
|
||||
|
||||
for i := range order {
|
||||
step := stepsByRef[order[i]]
|
||||
for _, dep := range step.DependsOn {
|
||||
if _, ok := stepsByRef[dep]; !ok {
|
||||
return nil, merrors.InvalidArgument("step dependency is unknown: " + dep)
|
||||
}
|
||||
}
|
||||
for _, dep := range step.CommitAfter {
|
||||
if _, ok := stepsByRef[dep]; !ok {
|
||||
return nil, merrors.InvalidArgument("step commit_after dependency is unknown: " + dep)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
maxAttemptsByRef := buildMaxAttemptsByRef(order, in.Retry)
|
||||
executionsByRef, err := s.normalizeStepExecutions(in.StepExecutions, stepsByRef, maxAttemptsByRef)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
seedMissingExecutions(order, stepsByRef, executionsByRef, maxAttemptsByRef)
|
||||
|
||||
return &preparedInput{
|
||||
stepsByRef: stepsByRef,
|
||||
order: order,
|
||||
executionsByRef: executionsByRef,
|
||||
maxAttemptsByRef: maxAttemptsByRef,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizeGraphStep(step xplan.Step, index int) (xplan.Step, error) {
|
||||
step.StepRef = strings.TrimSpace(step.StepRef)
|
||||
step.StepCode = strings.TrimSpace(step.StepCode)
|
||||
if step.StepRef == "" {
|
||||
return xplan.Step{}, merrors.InvalidArgument("steps[" + itoa(index) + "].step_ref is required")
|
||||
}
|
||||
if step.StepCode == "" {
|
||||
step.StepCode = step.StepRef
|
||||
}
|
||||
step.DependsOn = normalizeRefList(step.DependsOn)
|
||||
step.CommitAfter = normalizeRefList(step.CommitAfter)
|
||||
return step, nil
|
||||
}
|
||||
|
||||
func normalizeRefList(refs []string) []string {
|
||||
if len(refs) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(refs))
|
||||
seen := make(map[string]struct{}, len(refs))
|
||||
for i := range refs {
|
||||
ref := strings.TrimSpace(refs[i])
|
||||
if ref == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[ref]; ok {
|
||||
continue
|
||||
}
|
||||
seen[ref] = struct{}{}
|
||||
out = append(out, ref)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func buildMaxAttemptsByRef(order []string, retry RetryPolicy) map[string]uint32 {
|
||||
defaultMax := retry.MaxAttempts
|
||||
if defaultMax == 0 {
|
||||
defaultMax = 1
|
||||
}
|
||||
out := make(map[string]uint32, len(order))
|
||||
for i := range order {
|
||||
out[order[i]] = defaultMax
|
||||
}
|
||||
for stepRef, maxAttempts := range retry.MaxAttemptsByStepRef {
|
||||
stepRef = strings.TrimSpace(stepRef)
|
||||
if stepRef == "" || maxAttempts == 0 {
|
||||
continue
|
||||
}
|
||||
out[stepRef] = maxAttempts
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *svc) normalizeStepExecutions(
|
||||
steps []agg.StepExecution,
|
||||
stepsByRef map[string]xplan.Step,
|
||||
maxAttemptsByRef map[string]uint32,
|
||||
) (map[string]*agg.StepExecution, error) {
|
||||
if len(steps) == 0 {
|
||||
return map[string]*agg.StepExecution{}, nil
|
||||
}
|
||||
|
||||
out := make(map[string]*agg.StepExecution, len(steps))
|
||||
for i := range steps {
|
||||
exec, err := s.normalizeStepExecution(steps[i], i)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stepRef := exec.StepRef
|
||||
if _, ok := stepsByRef[stepRef]; !ok {
|
||||
return nil, merrors.InvalidArgument("step_executions[" + itoa(i) + "].step_ref is unknown: " + stepRef)
|
||||
}
|
||||
if _, exists := out[stepRef]; exists {
|
||||
return nil, merrors.InvalidArgument("step_executions[" + itoa(i) + "].step_ref must be unique")
|
||||
}
|
||||
if exec.Attempt == 0 {
|
||||
exec.Attempt = 1
|
||||
}
|
||||
if maxAttemptsByRef[stepRef] == 0 {
|
||||
maxAttemptsByRef[stepRef] = 1
|
||||
}
|
||||
stepCode := strings.TrimSpace(exec.StepCode)
|
||||
if stepCode == "" {
|
||||
stepCode = stepsByRef[stepRef].StepCode
|
||||
}
|
||||
exec.StepCode = stepCode
|
||||
cloned := cloneStepExecution(exec)
|
||||
out[stepRef] = &cloned
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *svc) normalizeStepExecution(exec agg.StepExecution, index int) (agg.StepExecution, error) {
|
||||
exec.StepRef = strings.TrimSpace(exec.StepRef)
|
||||
exec.StepCode = strings.TrimSpace(exec.StepCode)
|
||||
exec.FailureCode = strings.TrimSpace(exec.FailureCode)
|
||||
exec.FailureMsg = strings.TrimSpace(exec.FailureMsg)
|
||||
exec.ExternalRefs = cloneExternalRefs(exec.ExternalRefs)
|
||||
if exec.StepRef == "" {
|
||||
return agg.StepExecution{}, merrors.InvalidArgument("step_executions[" + itoa(index) + "].step_ref is required")
|
||||
}
|
||||
|
||||
state, ok := normalizeStepState(exec.State)
|
||||
if !ok {
|
||||
return agg.StepExecution{}, merrors.InvalidArgument("step_executions[" + itoa(index) + "].state is invalid")
|
||||
}
|
||||
exec.State = state
|
||||
if err := s.stateMachine.EnsureStepTransition(exec.State, exec.State); err != nil {
|
||||
return agg.StepExecution{}, merrors.InvalidArgument("step_executions[" + itoa(index) + "].state is invalid")
|
||||
}
|
||||
return exec, nil
|
||||
}
|
||||
|
||||
func seedMissingExecutions(
|
||||
order []string,
|
||||
stepsByRef map[string]xplan.Step,
|
||||
executionsByRef map[string]*agg.StepExecution,
|
||||
maxAttemptsByRef map[string]uint32,
|
||||
) {
|
||||
for i := range order {
|
||||
stepRef := order[i]
|
||||
if _, ok := executionsByRef[stepRef]; ok {
|
||||
continue
|
||||
}
|
||||
step := stepsByRef[stepRef]
|
||||
attempt := uint32(1)
|
||||
if maxAttemptsByRef[stepRef] == 0 {
|
||||
maxAttemptsByRef[stepRef] = 1
|
||||
}
|
||||
executionsByRef[stepRef] = &agg.StepExecution{
|
||||
StepRef: step.StepRef,
|
||||
StepCode: step.StepCode,
|
||||
State: agg.StepStatePending,
|
||||
Attempt: attempt,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeStepState(state agg.StepState) (agg.StepState, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(string(state))) {
|
||||
case "":
|
||||
return agg.StepStateUnspecified, true
|
||||
case string(agg.StepStateUnspecified):
|
||||
return agg.StepStateUnspecified, true
|
||||
case string(agg.StepStatePending):
|
||||
return agg.StepStatePending, true
|
||||
case string(agg.StepStateRunning):
|
||||
return agg.StepStateRunning, true
|
||||
case string(agg.StepStateCompleted):
|
||||
return agg.StepStateCompleted, true
|
||||
case string(agg.StepStateFailed):
|
||||
return agg.StepStateFailed, true
|
||||
case string(agg.StepStateNeedsAttention):
|
||||
return agg.StepStateNeedsAttention, true
|
||||
case string(agg.StepStateSkipped):
|
||||
return agg.StepStateSkipped, true
|
||||
default:
|
||||
return agg.StepStateUnspecified, false
|
||||
}
|
||||
}
|
||||
|
||||
func cloneStepExecution(exec agg.StepExecution) agg.StepExecution {
|
||||
out := exec
|
||||
out.ExternalRefs = cloneExternalRefs(exec.ExternalRefs)
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneExternalRefs(refs []agg.ExternalRef) []agg.ExternalRef {
|
||||
if len(refs) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]agg.ExternalRef, 0, len(refs))
|
||||
for i := range refs {
|
||||
ref := refs[i]
|
||||
ref.GatewayInstanceID = strings.TrimSpace(ref.GatewayInstanceID)
|
||||
ref.Kind = strings.TrimSpace(ref.Kind)
|
||||
ref.Ref = strings.TrimSpace(ref.Ref)
|
||||
out = append(out, ref)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for i := range values {
|
||||
val := strings.TrimSpace(values[i])
|
||||
if val != "" {
|
||||
return val
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func max(left, right uint32) uint32 {
|
||||
if left > right {
|
||||
return left
|
||||
}
|
||||
return right
|
||||
}
|
||||
|
||||
func itoa(v int) string {
|
||||
if v == 0 {
|
||||
return "0"
|
||||
}
|
||||
var buf [20]byte
|
||||
i := len(buf)
|
||||
for v > 0 {
|
||||
i--
|
||||
buf[i] = byte('0' + v%10)
|
||||
v /= 10
|
||||
}
|
||||
return string(buf[i:])
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package ssched
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/ostate"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
)
|
||||
|
||||
// Runtime selects runnable orchestration steps and reconciles step runtime states.
|
||||
type Runtime interface {
|
||||
Schedule(in Input) (*Output, error)
|
||||
}
|
||||
|
||||
// Input is the scheduler payload.
|
||||
type Input struct {
|
||||
Steps []xplan.Step
|
||||
StepExecutions []agg.StepExecution
|
||||
Retry RetryPolicy
|
||||
}
|
||||
|
||||
// RetryPolicy configures per-step retry limits.
|
||||
type RetryPolicy struct {
|
||||
MaxAttempts uint32
|
||||
MaxAttemptsByStepRef map[string]uint32
|
||||
}
|
||||
|
||||
// RunnableStep is a step selected for execution.
|
||||
type RunnableStep struct {
|
||||
StepRef string
|
||||
StepCode string
|
||||
Attempt uint32
|
||||
}
|
||||
|
||||
// BlockedReason classifies why a step is not runnable.
|
||||
type BlockedReason string
|
||||
|
||||
const (
|
||||
BlockedWaitingDependencies BlockedReason = "waiting_dependencies"
|
||||
BlockedInProgress BlockedReason = "in_progress"
|
||||
BlockedNeedsAttention BlockedReason = "needs_attention"
|
||||
BlockedRetryExhausted BlockedReason = "retry_exhausted"
|
||||
BlockedDependencyMismatch BlockedReason = "dependency_mismatch"
|
||||
)
|
||||
|
||||
// BlockedStep is a step that cannot run in the current scheduling tick.
|
||||
type BlockedStep struct {
|
||||
StepRef string
|
||||
StepCode string
|
||||
Reason BlockedReason
|
||||
}
|
||||
|
||||
// Output is the scheduler decision for one tick.
|
||||
type Output struct {
|
||||
StepExecutions []agg.StepExecution
|
||||
Runnable []RunnableStep
|
||||
Blocked []BlockedStep
|
||||
Skipped []string
|
||||
}
|
||||
|
||||
// Dependencies configures scheduler integrations.
|
||||
type Dependencies struct {
|
||||
Logger mlogger.Logger
|
||||
StateMachine ostate.StateMachine
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func New(deps ...Dependencies) Runtime {
|
||||
var dep Dependencies
|
||||
if len(deps) > 0 {
|
||||
dep = deps[0]
|
||||
}
|
||||
stateMachine := dep.StateMachine
|
||||
if stateMachine == nil {
|
||||
stateMachine = ostate.New(ostate.Dependencies{Logger: dep.Logger.Named("ssched.ostate")})
|
||||
}
|
||||
now := dep.Now
|
||||
if now == nil {
|
||||
now = func() time.Time {
|
||||
return time.Now().UTC()
|
||||
}
|
||||
}
|
||||
return &svc{
|
||||
logger: dep.Logger.Named("ssched"),
|
||||
stateMachine: stateMachine,
|
||||
now: now,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
package ssched
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/ostate"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type svc struct {
|
||||
logger mlogger.Logger
|
||||
stateMachine ostate.StateMachine
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
type preparedInput struct {
|
||||
stepsByRef map[string]xplan.Step
|
||||
order []string
|
||||
executionsByRef map[string]*agg.StepExecution
|
||||
maxAttemptsByRef map[string]uint32
|
||||
}
|
||||
|
||||
type gate int
|
||||
|
||||
const (
|
||||
gateReady gate = iota + 1
|
||||
gateWaiting
|
||||
gateImpossible
|
||||
)
|
||||
|
||||
type stepOutcome int
|
||||
|
||||
const (
|
||||
outcomeUnknown stepOutcome = iota + 1
|
||||
outcomeSuccess
|
||||
outcomeFailure
|
||||
outcomeSkipped
|
||||
)
|
||||
|
||||
func (s *svc) Schedule(in Input) (out *Output, err error) {
|
||||
logger := s.logger
|
||||
logger.Debug("Starting Schedule",
|
||||
zap.Int("steps_count", len(in.Steps)),
|
||||
zap.Int("step_executions_count", len(in.StepExecutions)),
|
||||
zap.Uint32("retry_max_attempts", in.Retry.MaxAttempts),
|
||||
)
|
||||
defer func(start time.Time) {
|
||||
fields := []zap.Field{zap.Int64("duration_ms", time.Since(start).Milliseconds())}
|
||||
if out != nil {
|
||||
fields = append(fields,
|
||||
zap.Int("runnable_count", len(out.Runnable)),
|
||||
zap.Int("blocked_count", len(out.Blocked)),
|
||||
zap.Int("skipped_count", len(out.Skipped)),
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
logger.Warn("Failed to schedule", append(fields, zap.Error(err))...)
|
||||
return
|
||||
}
|
||||
logger.Debug("Completed Schedule", fields...)
|
||||
}(time.Now())
|
||||
|
||||
prep, err := s.prepareInput(in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
skipped := map[string]struct{}{}
|
||||
s.reconcileStates(prep, skipped)
|
||||
|
||||
out = &Output{
|
||||
StepExecutions: make([]agg.StepExecution, 0, len(prep.order)),
|
||||
}
|
||||
for _, stepRef := range prep.order {
|
||||
step := prep.stepsByRef[stepRef]
|
||||
exec := prep.executionsByRef[stepRef]
|
||||
if exec == nil {
|
||||
return nil, merrors.InvalidArgument("execution is required for step_ref " + stepRef)
|
||||
}
|
||||
|
||||
switch exec.State {
|
||||
case agg.StepStatePending:
|
||||
switch evaluateGate(step, prep.executionsByRef, prep.maxAttemptsByRef) {
|
||||
case gateReady:
|
||||
out.Runnable = append(out.Runnable, RunnableStep{
|
||||
StepRef: exec.StepRef,
|
||||
StepCode: firstNonEmpty(exec.StepCode, step.StepCode),
|
||||
Attempt: max(exec.Attempt, 1),
|
||||
})
|
||||
case gateWaiting:
|
||||
out.Blocked = append(out.Blocked, blockedStep(exec, step, BlockedWaitingDependencies))
|
||||
default:
|
||||
out.Blocked = append(out.Blocked, blockedStep(exec, step, BlockedDependencyMismatch))
|
||||
}
|
||||
|
||||
case agg.StepStateFailed:
|
||||
maxAttempts := prep.maxAttemptsByRef[stepRef]
|
||||
if exec.Attempt >= maxAttempts {
|
||||
out.Blocked = append(out.Blocked, blockedStep(exec, step, BlockedRetryExhausted))
|
||||
break
|
||||
}
|
||||
switch evaluateGate(step, prep.executionsByRef, prep.maxAttemptsByRef) {
|
||||
case gateReady:
|
||||
out.Runnable = append(out.Runnable, RunnableStep{
|
||||
StepRef: exec.StepRef,
|
||||
StepCode: firstNonEmpty(exec.StepCode, step.StepCode),
|
||||
Attempt: exec.Attempt + 1,
|
||||
})
|
||||
case gateWaiting:
|
||||
out.Blocked = append(out.Blocked, blockedStep(exec, step, BlockedWaitingDependencies))
|
||||
default:
|
||||
out.Blocked = append(out.Blocked, blockedStep(exec, step, BlockedDependencyMismatch))
|
||||
}
|
||||
|
||||
case agg.StepStateNeedsAttention:
|
||||
out.Blocked = append(out.Blocked, blockedStep(exec, step, BlockedNeedsAttention))
|
||||
|
||||
case agg.StepStateRunning:
|
||||
out.Blocked = append(out.Blocked, blockedStep(exec, step, BlockedInProgress))
|
||||
}
|
||||
|
||||
out.StepExecutions = append(out.StepExecutions, cloneStepExecution(*exec))
|
||||
}
|
||||
|
||||
if len(skipped) > 0 {
|
||||
out.Skipped = make([]string, 0, len(skipped))
|
||||
for _, stepRef := range prep.order {
|
||||
if _, ok := skipped[stepRef]; ok {
|
||||
out.Skipped = append(out.Skipped, stepRef)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *svc) reconcileStates(prep *preparedInput, skipped map[string]struct{}) {
|
||||
for pass := 0; pass < len(prep.order)+1; pass++ {
|
||||
changed := false
|
||||
for _, stepRef := range prep.order {
|
||||
step := prep.stepsByRef[stepRef]
|
||||
exec := prep.executionsByRef[stepRef]
|
||||
if exec == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if s.promoteRetryExhaustedToNeedsAttention(exec, prep.maxAttemptsByRef[stepRef]) {
|
||||
changed = true
|
||||
}
|
||||
|
||||
if s.skipImpossiblePending(step, exec, prep, skipped) {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *svc) promoteRetryExhaustedToNeedsAttention(exec *agg.StepExecution, maxAttempts uint32) bool {
|
||||
if exec == nil || exec.State != agg.StepStateFailed {
|
||||
return false
|
||||
}
|
||||
if exec.Attempt < maxAttempts {
|
||||
return false
|
||||
}
|
||||
if err := s.stateMachine.EnsureStepTransition(exec.State, agg.StepStateNeedsAttention); err != nil {
|
||||
return false
|
||||
}
|
||||
exec.State = agg.StepStateNeedsAttention
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *svc) skipImpossiblePending(step xplan.Step, exec *agg.StepExecution, prep *preparedInput, skipped map[string]struct{}) bool {
|
||||
if exec == nil || exec.State != agg.StepStatePending {
|
||||
return false
|
||||
}
|
||||
if evaluateGate(step, prep.executionsByRef, prep.maxAttemptsByRef) != gateImpossible {
|
||||
return false
|
||||
}
|
||||
if err := s.stateMachine.EnsureStepTransition(exec.State, agg.StepStateSkipped); err != nil {
|
||||
return false
|
||||
}
|
||||
now := s.now().UTC()
|
||||
exec.State = agg.StepStateSkipped
|
||||
exec.FailureCode = ""
|
||||
exec.FailureMsg = ""
|
||||
exec.CompletedAt = &now
|
||||
skipped[exec.StepRef] = struct{}{}
|
||||
return true
|
||||
}
|
||||
|
||||
func evaluateGate(step xplan.Step, executionsByRef map[string]*agg.StepExecution, maxAttemptsByRef map[string]uint32) gate {
|
||||
depOutcomes := make(map[string]stepOutcome, len(step.DependsOn))
|
||||
for _, dep := range step.DependsOn {
|
||||
depExec := executionsByRef[dep]
|
||||
if depExec == nil {
|
||||
return gateWaiting
|
||||
}
|
||||
outcome := outcomeForStep(depExec, maxAttemptsByRef[dep])
|
||||
if outcome == outcomeUnknown {
|
||||
return gateWaiting
|
||||
}
|
||||
depOutcomes[dep] = outcome
|
||||
}
|
||||
|
||||
policy := normalizeCommitPolicy(step.CommitPolicy)
|
||||
switch policy {
|
||||
case model.CommitPolicyAfterSuccess:
|
||||
return evaluateAll(stepCommitTargets(step), executionsByRef, maxAttemptsByRef, outcomeSuccess)
|
||||
case model.CommitPolicyAfterFailure:
|
||||
return evaluateAll(stepCommitTargets(step), executionsByRef, maxAttemptsByRef, outcomeFailure)
|
||||
case model.CommitPolicyAfterCanceled:
|
||||
return evaluateTerminal(stepCommitTargets(step), executionsByRef, maxAttemptsByRef)
|
||||
default:
|
||||
for _, outcome := range depOutcomes {
|
||||
if outcome == outcomeFailure {
|
||||
return gateImpossible
|
||||
}
|
||||
}
|
||||
return gateReady
|
||||
}
|
||||
}
|
||||
|
||||
func evaluateAll(
|
||||
refs []string,
|
||||
executionsByRef map[string]*agg.StepExecution,
|
||||
maxAttemptsByRef map[string]uint32,
|
||||
want stepOutcome,
|
||||
) gate {
|
||||
for _, ref := range refs {
|
||||
exec := executionsByRef[ref]
|
||||
if exec == nil {
|
||||
return gateWaiting
|
||||
}
|
||||
outcome := outcomeForStep(exec, maxAttemptsByRef[ref])
|
||||
if outcome == outcomeUnknown {
|
||||
return gateWaiting
|
||||
}
|
||||
if outcome != want {
|
||||
return gateImpossible
|
||||
}
|
||||
}
|
||||
return gateReady
|
||||
}
|
||||
|
||||
func evaluateTerminal(refs []string, executionsByRef map[string]*agg.StepExecution, maxAttemptsByRef map[string]uint32) gate {
|
||||
for _, ref := range refs {
|
||||
exec := executionsByRef[ref]
|
||||
if exec == nil {
|
||||
return gateWaiting
|
||||
}
|
||||
if outcomeForStep(exec, maxAttemptsByRef[ref]) == outcomeUnknown {
|
||||
return gateWaiting
|
||||
}
|
||||
}
|
||||
return gateReady
|
||||
}
|
||||
|
||||
func outcomeForStep(exec *agg.StepExecution, maxAttempts uint32) stepOutcome {
|
||||
if exec == nil {
|
||||
return outcomeUnknown
|
||||
}
|
||||
if maxAttempts == 0 {
|
||||
maxAttempts = 1
|
||||
}
|
||||
switch exec.State {
|
||||
case agg.StepStateCompleted:
|
||||
return outcomeSuccess
|
||||
case agg.StepStateSkipped:
|
||||
return outcomeSkipped
|
||||
case agg.StepStateNeedsAttention:
|
||||
return outcomeFailure
|
||||
case agg.StepStateFailed:
|
||||
if exec.Attempt < maxAttempts {
|
||||
return outcomeUnknown
|
||||
}
|
||||
return outcomeFailure
|
||||
default:
|
||||
return outcomeUnknown
|
||||
}
|
||||
}
|
||||
|
||||
func stepCommitTargets(step xplan.Step) []string {
|
||||
if len(step.CommitAfter) > 0 {
|
||||
return step.CommitAfter
|
||||
}
|
||||
return step.DependsOn
|
||||
}
|
||||
|
||||
func normalizeCommitPolicy(policy model.CommitPolicy) model.CommitPolicy {
|
||||
switch strings.ToUpper(strings.TrimSpace(string(policy))) {
|
||||
case string(model.CommitPolicyImmediate):
|
||||
return model.CommitPolicyImmediate
|
||||
case string(model.CommitPolicyAfterSuccess):
|
||||
return model.CommitPolicyAfterSuccess
|
||||
case string(model.CommitPolicyAfterFailure):
|
||||
return model.CommitPolicyAfterFailure
|
||||
case string(model.CommitPolicyAfterCanceled):
|
||||
return model.CommitPolicyAfterCanceled
|
||||
default:
|
||||
return model.CommitPolicyUnspecified
|
||||
}
|
||||
}
|
||||
|
||||
func blockedStep(exec *agg.StepExecution, step xplan.Step, reason BlockedReason) BlockedStep {
|
||||
stepCode := step.StepCode
|
||||
if exec != nil && strings.TrimSpace(exec.StepCode) != "" {
|
||||
stepCode = exec.StepCode
|
||||
}
|
||||
return BlockedStep{
|
||||
StepRef: step.StepRef,
|
||||
StepCode: strings.TrimSpace(stepCode),
|
||||
Reason: reason,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
package ssched
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
)
|
||||
|
||||
func TestSchedule_LinearFlowPicksFirstRunnable(t *testing.T) {
|
||||
runtime := New()
|
||||
|
||||
out, err := runtime.Schedule(Input{
|
||||
Steps: []xplan.Step{
|
||||
step("a", nil),
|
||||
step("b", []string{"a"}),
|
||||
},
|
||||
StepExecutions: []agg.StepExecution{
|
||||
exec("a", agg.StepStatePending, 1),
|
||||
exec("b", agg.StepStatePending, 1),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Schedule returned error: %v", err)
|
||||
}
|
||||
|
||||
assertRunnableRefs(t, out, []string{"a"})
|
||||
assertRunnableAttempt(t, out, "a", 1)
|
||||
assertBlockedReason(t, out, "b", BlockedWaitingDependencies)
|
||||
if len(out.Skipped) != 0 {
|
||||
t.Fatalf("expected no skipped steps, got %v", out.Skipped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchedule_SuccessBranchSkipsFailureBranch(t *testing.T) {
|
||||
runtime := New()
|
||||
|
||||
out, err := runtime.Schedule(Input{
|
||||
Steps: []xplan.Step{
|
||||
step("observe", nil),
|
||||
successStep("debit", "observe"),
|
||||
failureStep("release", "observe"),
|
||||
},
|
||||
StepExecutions: []agg.StepExecution{
|
||||
exec("observe", agg.StepStateCompleted, 1),
|
||||
exec("debit", agg.StepStatePending, 1),
|
||||
exec("release", agg.StepStatePending, 1),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Schedule returned error: %v", err)
|
||||
}
|
||||
|
||||
assertRunnableRefs(t, out, []string{"debit"})
|
||||
assertSkippedRefs(t, out, []string{"release"})
|
||||
release := mustExecution(t, out, "release")
|
||||
if release.State != agg.StepStateSkipped {
|
||||
t.Fatalf("release state mismatch: got=%q want=%q", release.State, agg.StepStateSkipped)
|
||||
}
|
||||
if release.CompletedAt == nil {
|
||||
t.Fatal("expected skipped step to have completed_at")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchedule_AfterFailureWaitsWhenDependencyCanRetry(t *testing.T) {
|
||||
runtime := New()
|
||||
|
||||
out, err := runtime.Schedule(Input{
|
||||
Steps: []xplan.Step{
|
||||
step("observe", nil),
|
||||
failureStep("release", "observe"),
|
||||
},
|
||||
StepExecutions: []agg.StepExecution{
|
||||
exec("observe", agg.StepStateFailed, 1),
|
||||
exec("release", agg.StepStatePending, 1),
|
||||
},
|
||||
Retry: RetryPolicy{MaxAttempts: 2},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Schedule returned error: %v", err)
|
||||
}
|
||||
|
||||
assertRunnableRefs(t, out, []string{"observe"})
|
||||
assertRunnableAttempt(t, out, "observe", 2)
|
||||
assertBlockedReason(t, out, "release", BlockedWaitingDependencies)
|
||||
if len(out.Skipped) != 0 {
|
||||
t.Fatalf("expected no skipped steps, got %v", out.Skipped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchedule_AfterFailureRunsWhenDependencyExhausted(t *testing.T) {
|
||||
runtime := New()
|
||||
|
||||
out, err := runtime.Schedule(Input{
|
||||
Steps: []xplan.Step{
|
||||
step("observe", nil),
|
||||
successStep("debit", "observe"),
|
||||
failureStep("release", "observe"),
|
||||
},
|
||||
StepExecutions: []agg.StepExecution{
|
||||
exec("observe", agg.StepStateFailed, 2),
|
||||
exec("debit", agg.StepStatePending, 1),
|
||||
exec("release", agg.StepStatePending, 1),
|
||||
},
|
||||
Retry: RetryPolicy{MaxAttempts: 2},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Schedule returned error: %v", err)
|
||||
}
|
||||
|
||||
observe := mustExecution(t, out, "observe")
|
||||
if observe.State != agg.StepStateNeedsAttention {
|
||||
t.Fatalf("observe state mismatch: got=%q want=%q", observe.State, agg.StepStateNeedsAttention)
|
||||
}
|
||||
assertRunnableRefs(t, out, []string{"release"})
|
||||
assertSkippedRefs(t, out, []string{"debit"})
|
||||
assertBlockedReason(t, out, "observe", BlockedNeedsAttention)
|
||||
}
|
||||
|
||||
func TestSchedule_RetryExhaustedPromotesNeedsAttention(t *testing.T) {
|
||||
runtime := New()
|
||||
|
||||
out, err := runtime.Schedule(Input{
|
||||
Steps: []xplan.Step{
|
||||
step("single", nil),
|
||||
},
|
||||
StepExecutions: []agg.StepExecution{
|
||||
exec("single", agg.StepStateFailed, 1),
|
||||
},
|
||||
Retry: RetryPolicy{MaxAttempts: 1},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Schedule returned error: %v", err)
|
||||
}
|
||||
|
||||
single := mustExecution(t, out, "single")
|
||||
if single.State != agg.StepStateNeedsAttention {
|
||||
t.Fatalf("single state mismatch: got=%q want=%q", single.State, agg.StepStateNeedsAttention)
|
||||
}
|
||||
assertBlockedReason(t, out, "single", BlockedNeedsAttention)
|
||||
if len(out.Runnable) != 0 {
|
||||
t.Fatalf("expected no runnable steps, got %d", len(out.Runnable))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchedule_FailedDependencySkipsImmediateDependents(t *testing.T) {
|
||||
runtime := New()
|
||||
|
||||
out, err := runtime.Schedule(Input{
|
||||
Steps: []xplan.Step{
|
||||
step("a", nil),
|
||||
step("b", []string{"a"}),
|
||||
},
|
||||
StepExecutions: []agg.StepExecution{
|
||||
exec("a", agg.StepStateFailed, 1),
|
||||
exec("b", agg.StepStatePending, 1),
|
||||
},
|
||||
Retry: RetryPolicy{MaxAttempts: 1},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Schedule returned error: %v", err)
|
||||
}
|
||||
|
||||
a := mustExecution(t, out, "a")
|
||||
if a.State != agg.StepStateNeedsAttention {
|
||||
t.Fatalf("a state mismatch: got=%q want=%q", a.State, agg.StepStateNeedsAttention)
|
||||
}
|
||||
assertSkippedRefs(t, out, []string{"b"})
|
||||
assertBlockedReason(t, out, "a", BlockedNeedsAttention)
|
||||
}
|
||||
|
||||
func TestSchedule_ValidationErrors(t *testing.T) {
|
||||
runtime := New()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
in Input
|
||||
}{
|
||||
{
|
||||
name: "missing steps",
|
||||
in: Input{
|
||||
StepExecutions: []agg.StepExecution{exec("a", agg.StepStatePending, 1)},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "step dependency unknown",
|
||||
in: Input{
|
||||
Steps: []xplan.Step{
|
||||
step("a", []string{"missing"}),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unknown execution state",
|
||||
in: Input{
|
||||
Steps: []xplan.Step{
|
||||
step("a", nil),
|
||||
},
|
||||
StepExecutions: []agg.StepExecution{
|
||||
{StepRef: "a", StepCode: "a", State: agg.StepState("bad_state"), Attempt: 1},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "execution ref unknown in graph",
|
||||
in: Input{
|
||||
Steps: []xplan.Step{
|
||||
step("a", nil),
|
||||
},
|
||||
StepExecutions: []agg.StepExecution{
|
||||
exec("x", agg.StepStatePending, 1),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := runtime.Schedule(tt.in)
|
||||
if !errors.Is(err, merrors.ErrInvalidArg) {
|
||||
t.Fatalf("expected invalid argument error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func step(ref string, deps []string) xplan.Step {
|
||||
return xplan.Step{
|
||||
StepRef: ref,
|
||||
StepCode: ref,
|
||||
DependsOn: deps,
|
||||
}
|
||||
}
|
||||
|
||||
func successStep(ref, dep string) xplan.Step {
|
||||
return xplan.Step{
|
||||
StepRef: ref,
|
||||
StepCode: ref,
|
||||
DependsOn: []string{dep},
|
||||
CommitPolicy: model.CommitPolicyAfterSuccess,
|
||||
CommitAfter: []string{dep},
|
||||
}
|
||||
}
|
||||
|
||||
func failureStep(ref, dep string) xplan.Step {
|
||||
return xplan.Step{
|
||||
StepRef: ref,
|
||||
StepCode: ref,
|
||||
DependsOn: []string{dep},
|
||||
CommitPolicy: model.CommitPolicyAfterFailure,
|
||||
CommitAfter: []string{dep},
|
||||
}
|
||||
}
|
||||
|
||||
func exec(ref string, state agg.StepState, attempt uint32) agg.StepExecution {
|
||||
return agg.StepExecution{
|
||||
StepRef: ref,
|
||||
StepCode: ref,
|
||||
State: state,
|
||||
Attempt: attempt,
|
||||
}
|
||||
}
|
||||
|
||||
func mustExecution(t *testing.T, out *Output, stepRef string) agg.StepExecution {
|
||||
t.Helper()
|
||||
for i := range out.StepExecutions {
|
||||
if out.StepExecutions[i].StepRef == stepRef {
|
||||
return out.StepExecutions[i]
|
||||
}
|
||||
}
|
||||
t.Fatalf("missing execution for step_ref %q", stepRef)
|
||||
return agg.StepExecution{}
|
||||
}
|
||||
|
||||
func assertRunnableRefs(t *testing.T, out *Output, want []string) {
|
||||
t.Helper()
|
||||
if len(out.Runnable) != len(want) {
|
||||
t.Fatalf("runnable count mismatch: got=%d want=%d", len(out.Runnable), len(want))
|
||||
}
|
||||
for i := range want {
|
||||
if out.Runnable[i].StepRef != want[i] {
|
||||
t.Fatalf("runnable[%d] mismatch: got=%q want=%q", i, out.Runnable[i].StepRef, want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertRunnableAttempt(t *testing.T, out *Output, stepRef string, want uint32) {
|
||||
t.Helper()
|
||||
for i := range out.Runnable {
|
||||
if out.Runnable[i].StepRef == stepRef {
|
||||
if out.Runnable[i].Attempt != want {
|
||||
t.Fatalf("runnable attempt mismatch for %q: got=%d want=%d", stepRef, out.Runnable[i].Attempt, want)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("runnable step %q not found", stepRef)
|
||||
}
|
||||
|
||||
func assertBlockedReason(t *testing.T, out *Output, stepRef string, want BlockedReason) {
|
||||
t.Helper()
|
||||
for i := range out.Blocked {
|
||||
if out.Blocked[i].StepRef != stepRef {
|
||||
continue
|
||||
}
|
||||
if out.Blocked[i].Reason != want {
|
||||
t.Fatalf("blocked reason mismatch for %q: got=%q want=%q", stepRef, out.Blocked[i].Reason, want)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatalf("blocked step %q not found", stepRef)
|
||||
}
|
||||
|
||||
func assertSkippedRefs(t *testing.T, out *Output, want []string) {
|
||||
t.Helper()
|
||||
if len(out.Skipped) != len(want) {
|
||||
t.Fatalf("skipped count mismatch: got=%d want=%d", len(out.Skipped), len(want))
|
||||
}
|
||||
for i := range want {
|
||||
if out.Skipped[i] != want[i] {
|
||||
t.Fatalf("skipped[%d] mismatch: got=%q want=%q", i, out.Skipped[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package xerr
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type wrappedError struct {
|
||||
base error
|
||||
msg string
|
||||
}
|
||||
|
||||
func (e wrappedError) Error() string {
|
||||
msg := strings.TrimSpace(e.msg)
|
||||
if e.base == nil {
|
||||
return msg
|
||||
}
|
||||
if msg == "" {
|
||||
return e.base.Error()
|
||||
}
|
||||
return e.base.Error() + ": " + msg
|
||||
}
|
||||
|
||||
func (e wrappedError) Unwrap() error {
|
||||
return e.base
|
||||
}
|
||||
|
||||
func Wrap(base error, msg string) error {
|
||||
return wrappedError{
|
||||
base: base,
|
||||
msg: strings.TrimSpace(msg),
|
||||
}
|
||||
}
|
||||
|
||||
func Wrapf(base error, format string, args ...any) error {
|
||||
return Wrap(base, fmt.Sprintf(format, args...))
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
package xplan
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
)
|
||||
|
||||
@@ -233,261 +231,3 @@ func TestCompile_SingleExternalFallback(t *testing.T) {
|
||||
t.Fatalf("observe dependency mismatch: got=%v want=%v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompile_PolicyOverrideByRailPair(t *testing.T) {
|
||||
compiler := New()
|
||||
|
||||
cardRail := model.RailCardPayout
|
||||
ledgerRail := model.RailLedger
|
||||
|
||||
graph, err := compiler.Compile(Input{
|
||||
IntentSnapshot: testIntent(model.PaymentKindPayout),
|
||||
QuoteSnapshot: &model.PaymentQuoteSnapshot{
|
||||
Route: &paymenttypes.QuoteRouteSpecification{
|
||||
Hops: []*paymenttypes.QuoteRouteHop{
|
||||
{Index: 10, Rail: "CRYPTO", Role: paymenttypes.QuoteRouteHopRoleSource},
|
||||
{Index: 20, Rail: "CARD_PAYOUT", Role: paymenttypes.QuoteRouteHopRoleDestination},
|
||||
},
|
||||
},
|
||||
},
|
||||
Policies: []Policy{
|
||||
{
|
||||
ID: "crypto-to-card-override",
|
||||
Match: EdgeMatch{
|
||||
Source: EndpointMatch{Rail: railPtr(model.RailCrypto)},
|
||||
Target: EndpointMatch{Rail: railPtr(model.RailCardPayout)},
|
||||
},
|
||||
Steps: []PolicyStep{
|
||||
{Code: "custom.review", Action: model.RailOperationMove, Rail: &ledgerRail},
|
||||
{Code: "custom.submit", Action: model.RailOperationSend, Rail: &cardRail, Visibility: model.ReportVisibilityUser},
|
||||
},
|
||||
Success: []PolicyStep{
|
||||
{Code: "custom.finalize", Action: model.RailOperationDebit, Rail: &ledgerRail},
|
||||
},
|
||||
Failure: []PolicyStep{
|
||||
{Code: "custom.release", Action: model.RailOperationRelease, Rail: &ledgerRail},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Compile returned error: %v", err)
|
||||
}
|
||||
|
||||
if len(graph.Steps) != 4 {
|
||||
t.Fatalf("expected 4 steps, got %d", len(graph.Steps))
|
||||
}
|
||||
assertStep(t, graph.Steps[0], "custom.review", model.RailOperationMove, model.RailLedger, model.ReportVisibilityHidden)
|
||||
assertStep(t, graph.Steps[1], "custom.submit", model.RailOperationSend, model.RailCardPayout, model.ReportVisibilityUser)
|
||||
assertStep(t, graph.Steps[2], "custom.finalize", model.RailOperationDebit, model.RailLedger, model.ReportVisibilityHidden)
|
||||
assertStep(t, graph.Steps[3], "custom.release", model.RailOperationRelease, model.RailLedger, model.ReportVisibilityHidden)
|
||||
|
||||
if graph.Steps[2].CommitPolicy != model.CommitPolicyAfterSuccess {
|
||||
t.Fatalf("expected custom.finalize AFTER_SUCCESS, got %q", graph.Steps[2].CommitPolicy)
|
||||
}
|
||||
if graph.Steps[3].CommitPolicy != model.CommitPolicyAfterFailure {
|
||||
t.Fatalf("expected custom.release AFTER_FAILURE, got %q", graph.Steps[3].CommitPolicy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompile_PolicyPriorityAndCustodyMatching(t *testing.T) {
|
||||
compiler := New()
|
||||
cardRail := model.RailCardPayout
|
||||
|
||||
on := true
|
||||
external := CustodyExternal
|
||||
|
||||
graph, err := compiler.Compile(Input{
|
||||
IntentSnapshot: testIntent(model.PaymentKindPayout),
|
||||
QuoteSnapshot: &model.PaymentQuoteSnapshot{
|
||||
Route: &paymenttypes.QuoteRouteSpecification{
|
||||
Hops: []*paymenttypes.QuoteRouteHop{
|
||||
{Index: 10, Rail: "CRYPTO", Role: paymenttypes.QuoteRouteHopRoleSource},
|
||||
{Index: 20, Rail: "CARD_PAYOUT", Role: paymenttypes.QuoteRouteHopRoleDestination},
|
||||
},
|
||||
},
|
||||
},
|
||||
Policies: []Policy{
|
||||
{
|
||||
ID: "generic-external",
|
||||
Enabled: &on,
|
||||
Priority: 1,
|
||||
Match: EdgeMatch{
|
||||
Source: EndpointMatch{Custody: &external},
|
||||
Target: EndpointMatch{Custody: &external},
|
||||
},
|
||||
Steps: []PolicyStep{{Code: "generic.submit", Action: model.RailOperationSend, Rail: &cardRail}},
|
||||
},
|
||||
{
|
||||
ID: "specific-crypto-card",
|
||||
Enabled: &on,
|
||||
Priority: 10,
|
||||
Match: EdgeMatch{
|
||||
Source: EndpointMatch{Rail: railPtr(model.RailCrypto), Custody: &external},
|
||||
Target: EndpointMatch{Rail: railPtr(model.RailCardPayout), Custody: &external},
|
||||
},
|
||||
Steps: []PolicyStep{{Code: "specific.submit", Action: model.RailOperationSend, Rail: &cardRail}},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Compile returned error: %v", err)
|
||||
}
|
||||
|
||||
if len(graph.Steps) != 1 {
|
||||
t.Fatalf("expected 1 policy step, got %d", len(graph.Steps))
|
||||
}
|
||||
if got, want := graph.Steps[0].StepCode, "specific.submit"; got != want {
|
||||
t.Fatalf("expected high-priority specific policy, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompile_IndicativeRejected(t *testing.T) {
|
||||
compiler := New()
|
||||
|
||||
_, err := compiler.Compile(Input{
|
||||
IntentSnapshot: testIntent(model.PaymentKindPayout),
|
||||
QuoteSnapshot: &model.PaymentQuoteSnapshot{
|
||||
Route: &paymenttypes.QuoteRouteSpecification{
|
||||
Rail: "CRYPTO",
|
||||
},
|
||||
ExecutionConditions: &paymenttypes.QuoteExecutionConditions{
|
||||
Readiness: paymenttypes.QuoteExecutionReadinessIndicative,
|
||||
},
|
||||
},
|
||||
})
|
||||
if !errors.Is(err, ErrNotExecutable) {
|
||||
t.Fatalf("expected ErrNotExecutable, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompile_ValidationErrors(t *testing.T) {
|
||||
compiler := New()
|
||||
|
||||
enabled := true
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
in Input
|
||||
}{
|
||||
{
|
||||
name: "missing intent",
|
||||
in: Input{
|
||||
QuoteSnapshot: &model.PaymentQuoteSnapshot{
|
||||
Route: &paymenttypes.QuoteRouteSpecification{Rail: "CRYPTO"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing quote",
|
||||
in: Input{
|
||||
IntentSnapshot: testIntent(model.PaymentKindPayout),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing route",
|
||||
in: Input{
|
||||
IntentSnapshot: testIntent(model.PaymentKindPayout),
|
||||
QuoteSnapshot: &model.PaymentQuoteSnapshot{},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unknown hop rail",
|
||||
in: Input{
|
||||
IntentSnapshot: testIntent(model.PaymentKindPayout),
|
||||
QuoteSnapshot: &model.PaymentQuoteSnapshot{
|
||||
Route: &paymenttypes.QuoteRouteSpecification{
|
||||
Hops: []*paymenttypes.QuoteRouteHop{{Index: 1, Rail: "UNKNOWN"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid policy step action",
|
||||
in: Input{
|
||||
IntentSnapshot: testIntent(model.PaymentKindPayout),
|
||||
QuoteSnapshot: &model.PaymentQuoteSnapshot{
|
||||
Route: &paymenttypes.QuoteRouteSpecification{
|
||||
Hops: []*paymenttypes.QuoteRouteHop{
|
||||
{Index: 1, Rail: "LEDGER", Role: paymenttypes.QuoteRouteHopRoleSource},
|
||||
{Index: 2, Rail: "CARD_PAYOUT", Role: paymenttypes.QuoteRouteHopRoleDestination},
|
||||
},
|
||||
},
|
||||
},
|
||||
Policies: []Policy{
|
||||
{
|
||||
ID: "bad-policy",
|
||||
Enabled: &enabled,
|
||||
Priority: 1,
|
||||
Match: EdgeMatch{
|
||||
Source: EndpointMatch{Rail: railPtr(model.RailLedger)},
|
||||
Target: EndpointMatch{Rail: railPtr(model.RailCardPayout)},
|
||||
},
|
||||
Steps: []PolicyStep{
|
||||
{Code: "bad.step", Action: model.RailOperationUnspecified},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := compiler.Compile(tt.in)
|
||||
if !errors.Is(err, merrors.ErrInvalidArg) {
|
||||
t.Fatalf("expected invalid argument, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertStep(
|
||||
t *testing.T,
|
||||
step Step,
|
||||
code string,
|
||||
action model.RailOperation,
|
||||
rail model.Rail,
|
||||
visibility model.ReportVisibility,
|
||||
) {
|
||||
t.Helper()
|
||||
if got, want := step.StepCode, code; got != want {
|
||||
t.Fatalf("step code mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := step.Action, action; got != want {
|
||||
t.Fatalf("step action mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := step.Rail, rail; got != want {
|
||||
t.Fatalf("step rail mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := step.Visibility, visibility; got != want {
|
||||
t.Fatalf("step visibility mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func testIntent(kind model.PaymentKind) model.PaymentIntent {
|
||||
return model.PaymentIntent{
|
||||
Kind: kind,
|
||||
Amount: &paymenttypes.Money{
|
||||
Amount: "10",
|
||||
Currency: "USD",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func railPtr(v model.Rail) *model.Rail {
|
||||
return &v
|
||||
}
|
||||
|
||||
func equalStringSlice(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package xplan
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
)
|
||||
|
||||
func TestCompile_PolicyOverrideByRailPair(t *testing.T) {
|
||||
compiler := New()
|
||||
|
||||
cardRail := model.RailCardPayout
|
||||
ledgerRail := model.RailLedger
|
||||
|
||||
graph, err := compiler.Compile(Input{
|
||||
IntentSnapshot: testIntent(model.PaymentKindPayout),
|
||||
QuoteSnapshot: &model.PaymentQuoteSnapshot{
|
||||
Route: &paymenttypes.QuoteRouteSpecification{
|
||||
Hops: []*paymenttypes.QuoteRouteHop{
|
||||
{Index: 10, Rail: "CRYPTO", Role: paymenttypes.QuoteRouteHopRoleSource},
|
||||
{Index: 20, Rail: "CARD_PAYOUT", Role: paymenttypes.QuoteRouteHopRoleDestination},
|
||||
},
|
||||
},
|
||||
},
|
||||
Policies: []Policy{
|
||||
{
|
||||
ID: "crypto-to-card-override",
|
||||
Match: EdgeMatch{
|
||||
Source: EndpointMatch{Rail: railPtr(model.RailCrypto)},
|
||||
Target: EndpointMatch{Rail: railPtr(model.RailCardPayout)},
|
||||
},
|
||||
Steps: []PolicyStep{
|
||||
{Code: "custom.review", Action: model.RailOperationMove, Rail: &ledgerRail},
|
||||
{Code: "custom.submit", Action: model.RailOperationSend, Rail: &cardRail, Visibility: model.ReportVisibilityUser},
|
||||
},
|
||||
Success: []PolicyStep{
|
||||
{Code: "custom.finalize", Action: model.RailOperationDebit, Rail: &ledgerRail},
|
||||
},
|
||||
Failure: []PolicyStep{
|
||||
{Code: "custom.release", Action: model.RailOperationRelease, Rail: &ledgerRail},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Compile returned error: %v", err)
|
||||
}
|
||||
|
||||
if len(graph.Steps) != 4 {
|
||||
t.Fatalf("expected 4 steps, got %d", len(graph.Steps))
|
||||
}
|
||||
assertStep(t, graph.Steps[0], "custom.review", model.RailOperationMove, model.RailLedger, model.ReportVisibilityHidden)
|
||||
assertStep(t, graph.Steps[1], "custom.submit", model.RailOperationSend, model.RailCardPayout, model.ReportVisibilityUser)
|
||||
assertStep(t, graph.Steps[2], "custom.finalize", model.RailOperationDebit, model.RailLedger, model.ReportVisibilityHidden)
|
||||
assertStep(t, graph.Steps[3], "custom.release", model.RailOperationRelease, model.RailLedger, model.ReportVisibilityHidden)
|
||||
|
||||
if graph.Steps[2].CommitPolicy != model.CommitPolicyAfterSuccess {
|
||||
t.Fatalf("expected custom.finalize AFTER_SUCCESS, got %q", graph.Steps[2].CommitPolicy)
|
||||
}
|
||||
if graph.Steps[3].CommitPolicy != model.CommitPolicyAfterFailure {
|
||||
t.Fatalf("expected custom.release AFTER_FAILURE, got %q", graph.Steps[3].CommitPolicy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompile_PolicyPriorityAndCustodyMatching(t *testing.T) {
|
||||
compiler := New()
|
||||
cardRail := model.RailCardPayout
|
||||
|
||||
on := true
|
||||
external := CustodyExternal
|
||||
|
||||
graph, err := compiler.Compile(Input{
|
||||
IntentSnapshot: testIntent(model.PaymentKindPayout),
|
||||
QuoteSnapshot: &model.PaymentQuoteSnapshot{
|
||||
Route: &paymenttypes.QuoteRouteSpecification{
|
||||
Hops: []*paymenttypes.QuoteRouteHop{
|
||||
{Index: 10, Rail: "CRYPTO", Role: paymenttypes.QuoteRouteHopRoleSource},
|
||||
{Index: 20, Rail: "CARD_PAYOUT", Role: paymenttypes.QuoteRouteHopRoleDestination},
|
||||
},
|
||||
},
|
||||
},
|
||||
Policies: []Policy{
|
||||
{
|
||||
ID: "generic-external",
|
||||
Enabled: &on,
|
||||
Priority: 1,
|
||||
Match: EdgeMatch{
|
||||
Source: EndpointMatch{Custody: &external},
|
||||
Target: EndpointMatch{Custody: &external},
|
||||
},
|
||||
Steps: []PolicyStep{{Code: "generic.submit", Action: model.RailOperationSend, Rail: &cardRail}},
|
||||
},
|
||||
{
|
||||
ID: "specific-crypto-card",
|
||||
Enabled: &on,
|
||||
Priority: 10,
|
||||
Match: EdgeMatch{
|
||||
Source: EndpointMatch{Rail: railPtr(model.RailCrypto), Custody: &external},
|
||||
Target: EndpointMatch{Rail: railPtr(model.RailCardPayout), Custody: &external},
|
||||
},
|
||||
Steps: []PolicyStep{{Code: "specific.submit", Action: model.RailOperationSend, Rail: &cardRail}},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Compile returned error: %v", err)
|
||||
}
|
||||
|
||||
if len(graph.Steps) != 1 {
|
||||
t.Fatalf("expected 1 policy step, got %d", len(graph.Steps))
|
||||
}
|
||||
if got, want := graph.Steps[0].StepCode, "specific.submit"; got != want {
|
||||
t.Fatalf("expected high-priority specific policy, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompile_IndicativeRejected(t *testing.T) {
|
||||
compiler := New()
|
||||
|
||||
_, err := compiler.Compile(Input{
|
||||
IntentSnapshot: testIntent(model.PaymentKindPayout),
|
||||
QuoteSnapshot: &model.PaymentQuoteSnapshot{
|
||||
Route: &paymenttypes.QuoteRouteSpecification{
|
||||
Rail: "CRYPTO",
|
||||
},
|
||||
ExecutionConditions: &paymenttypes.QuoteExecutionConditions{
|
||||
Readiness: paymenttypes.QuoteExecutionReadinessIndicative,
|
||||
},
|
||||
},
|
||||
})
|
||||
if !errors.Is(err, ErrNotExecutable) {
|
||||
t.Fatalf("expected ErrNotExecutable, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompile_ValidationErrors(t *testing.T) {
|
||||
compiler := New()
|
||||
|
||||
enabled := true
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
in Input
|
||||
}{
|
||||
{
|
||||
name: "missing intent",
|
||||
in: Input{
|
||||
QuoteSnapshot: &model.PaymentQuoteSnapshot{
|
||||
Route: &paymenttypes.QuoteRouteSpecification{Rail: "CRYPTO"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing quote",
|
||||
in: Input{
|
||||
IntentSnapshot: testIntent(model.PaymentKindPayout),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing route",
|
||||
in: Input{
|
||||
IntentSnapshot: testIntent(model.PaymentKindPayout),
|
||||
QuoteSnapshot: &model.PaymentQuoteSnapshot{},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unknown hop rail",
|
||||
in: Input{
|
||||
IntentSnapshot: testIntent(model.PaymentKindPayout),
|
||||
QuoteSnapshot: &model.PaymentQuoteSnapshot{
|
||||
Route: &paymenttypes.QuoteRouteSpecification{
|
||||
Hops: []*paymenttypes.QuoteRouteHop{{Index: 1, Rail: "UNKNOWN"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid policy step action",
|
||||
in: Input{
|
||||
IntentSnapshot: testIntent(model.PaymentKindPayout),
|
||||
QuoteSnapshot: &model.PaymentQuoteSnapshot{
|
||||
Route: &paymenttypes.QuoteRouteSpecification{
|
||||
Hops: []*paymenttypes.QuoteRouteHop{
|
||||
{Index: 1, Rail: "LEDGER", Role: paymenttypes.QuoteRouteHopRoleSource},
|
||||
{Index: 2, Rail: "CARD_PAYOUT", Role: paymenttypes.QuoteRouteHopRoleDestination},
|
||||
},
|
||||
},
|
||||
},
|
||||
Policies: []Policy{
|
||||
{
|
||||
ID: "bad-policy",
|
||||
Enabled: &enabled,
|
||||
Priority: 1,
|
||||
Match: EdgeMatch{
|
||||
Source: EndpointMatch{Rail: railPtr(model.RailLedger)},
|
||||
Target: EndpointMatch{Rail: railPtr(model.RailCardPayout)},
|
||||
},
|
||||
Steps: []PolicyStep{
|
||||
{Code: "bad.step", Action: model.RailOperationUnspecified},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := compiler.Compile(tt.in)
|
||||
if !errors.Is(err, merrors.ErrInvalidArg) {
|
||||
t.Fatalf("expected invalid argument, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package xplan
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
)
|
||||
|
||||
func (e *expansion) appendMain(step Step) string {
|
||||
step = normalizeStep(step)
|
||||
if len(step.DependsOn) == 0 && strings.TrimSpace(e.lastMainRef) != "" {
|
||||
step.DependsOn = []string{e.lastMainRef}
|
||||
}
|
||||
if len(step.CommitAfter) == 0 && step.CommitPolicy != model.CommitPolicyUnspecified {
|
||||
step.CommitAfter = cloneStringSlice(step.DependsOn)
|
||||
}
|
||||
step.StepRef = e.nextRef(firstNonEmpty(step.StepRef, step.StepCode))
|
||||
if strings.TrimSpace(step.StepCode) == "" {
|
||||
step.StepCode = step.StepRef
|
||||
}
|
||||
e.steps = append(e.steps, step)
|
||||
e.lastMainRef = step.StepRef
|
||||
return step.StepRef
|
||||
}
|
||||
|
||||
func (e *expansion) appendBranch(step Step) string {
|
||||
step = normalizeStep(step)
|
||||
if len(step.CommitAfter) == 0 && step.CommitPolicy != model.CommitPolicyUnspecified {
|
||||
step.CommitAfter = cloneStringSlice(step.DependsOn)
|
||||
}
|
||||
step.StepRef = e.nextRef(firstNonEmpty(step.StepRef, step.StepCode))
|
||||
if strings.TrimSpace(step.StepCode) == "" {
|
||||
step.StepCode = step.StepRef
|
||||
}
|
||||
e.steps = append(e.steps, step)
|
||||
return step.StepRef
|
||||
}
|
||||
|
||||
func (e *expansion) nextRef(base string) string {
|
||||
token := sanitizeToken(base)
|
||||
if token == "" {
|
||||
token = "step"
|
||||
}
|
||||
count := e.refSeq[token]
|
||||
e.refSeq[token] = count + 1
|
||||
if count == 0 {
|
||||
return token
|
||||
}
|
||||
return token + "_" + itoa(count+1)
|
||||
}
|
||||
|
||||
func normalizeStep(step Step) Step {
|
||||
step.StepRef = strings.TrimSpace(step.StepRef)
|
||||
step.StepCode = strings.TrimSpace(step.StepCode)
|
||||
step.Gateway = strings.TrimSpace(step.Gateway)
|
||||
step.InstanceID = strings.TrimSpace(step.InstanceID)
|
||||
step.UserLabel = strings.TrimSpace(step.UserLabel)
|
||||
step.Visibility = model.NormalizeReportVisibility(step.Visibility)
|
||||
step.CommitPolicy = normalizeCommitPolicy(step.CommitPolicy)
|
||||
step.DependsOn = normalizeStringList(step.DependsOn)
|
||||
step.CommitAfter = normalizeStringList(step.CommitAfter)
|
||||
step.Metadata = normalizeMetadata(step.Metadata)
|
||||
return step
|
||||
}
|
||||
|
||||
func normalizeCommitPolicy(policy model.CommitPolicy) model.CommitPolicy {
|
||||
switch strings.ToUpper(strings.TrimSpace(string(policy))) {
|
||||
case string(model.CommitPolicyImmediate):
|
||||
return model.CommitPolicyImmediate
|
||||
case string(model.CommitPolicyAfterSuccess):
|
||||
return model.CommitPolicyAfterSuccess
|
||||
case string(model.CommitPolicyAfterFailure):
|
||||
return model.CommitPolicyAfterFailure
|
||||
case string(model.CommitPolicyAfterCanceled):
|
||||
return model.CommitPolicyAfterCanceled
|
||||
default:
|
||||
return model.CommitPolicyUnspecified
|
||||
}
|
||||
}
|
||||
|
||||
func defaultVisibilityForAction(action model.RailOperation, role paymenttypes.QuoteRouteHopRole) model.ReportVisibility {
|
||||
switch action {
|
||||
case model.RailOperationSend, model.RailOperationObserveConfirm:
|
||||
if role == paymenttypes.QuoteRouteHopRoleDestination {
|
||||
return model.ReportVisibilityUser
|
||||
}
|
||||
return model.ReportVisibilityBackoffice
|
||||
default:
|
||||
return model.ReportVisibilityHidden
|
||||
}
|
||||
}
|
||||
|
||||
func defaultUserLabel(
|
||||
action model.RailOperation,
|
||||
rail model.Rail,
|
||||
role paymenttypes.QuoteRouteHopRole,
|
||||
kind model.PaymentKind,
|
||||
) string {
|
||||
if role != paymenttypes.QuoteRouteHopRoleDestination {
|
||||
return ""
|
||||
}
|
||||
switch action {
|
||||
case model.RailOperationSend:
|
||||
if kind == model.PaymentKindPayout && rail == model.RailCardPayout {
|
||||
return "Card payout submitted"
|
||||
}
|
||||
return "Transfer submitted"
|
||||
case model.RailOperationObserveConfirm:
|
||||
if kind == model.PaymentKindPayout && rail == model.RailCardPayout {
|
||||
return "Card payout confirmed"
|
||||
}
|
||||
return "Transfer confirmed"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package xplan
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
)
|
||||
|
||||
func isEmptyIntentSnapshot(intent model.PaymentIntent) bool {
|
||||
return intent.Amount == nil && (intent.Kind == "" || intent.Kind == model.PaymentKindUnspecified)
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func sanitizeToken(value string) string {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
prevUnderscore := false
|
||||
for _, r := range value {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
||||
b.WriteRune(r)
|
||||
prevUnderscore = false
|
||||
continue
|
||||
}
|
||||
if !prevUnderscore {
|
||||
b.WriteByte('_')
|
||||
prevUnderscore = true
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Trim(b.String(), "_")
|
||||
}
|
||||
|
||||
func normalizeStringList(items []string) []string {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]struct{}, len(items))
|
||||
out := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
token := strings.TrimSpace(item)
|
||||
if token == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[token]; exists {
|
||||
continue
|
||||
}
|
||||
seen[token] = struct{}{}
|
||||
out = append(out, token)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeMetadata(input map[string]string) map[string]string {
|
||||
if len(input) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(input))
|
||||
for key, value := range input {
|
||||
k := strings.TrimSpace(key)
|
||||
if k == "" {
|
||||
continue
|
||||
}
|
||||
out[k] = strings.TrimSpace(value)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneMetadata(input map[string]string) map[string]string {
|
||||
if len(input) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(input))
|
||||
for key, value := range input {
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneStringSlice(values []string) []string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, len(values))
|
||||
copy(out, values)
|
||||
return out
|
||||
}
|
||||
|
||||
func itoa(v int) string {
|
||||
if v == 0 {
|
||||
return "0"
|
||||
}
|
||||
if v < 0 {
|
||||
return "0"
|
||||
}
|
||||
var buf [20]byte
|
||||
i := len(buf)
|
||||
for v > 0 {
|
||||
i--
|
||||
buf[i] = byte('0' + v%10)
|
||||
v /= 10
|
||||
}
|
||||
return string(buf[i:])
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package xplan
|
||||
|
||||
import (
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
)
|
||||
|
||||
@@ -105,6 +106,17 @@ type Policy struct {
|
||||
Failure []PolicyStep `json:"failure,omitempty" bson:"failure,omitempty"`
|
||||
}
|
||||
|
||||
func New() Compiler {
|
||||
return &svc{}
|
||||
// Dependencies configures execution graph compiler integrations.
|
||||
type Dependencies struct {
|
||||
Logger mlogger.Logger
|
||||
}
|
||||
|
||||
func New(deps ...Dependencies) Compiler {
|
||||
var dep Dependencies
|
||||
if len(deps) > 0 {
|
||||
dep = deps[0]
|
||||
}
|
||||
return &svc{
|
||||
logger: dep.Logger.Named("xplan"),
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user