payment quotation v2 + payment orchestration v2 draft

This commit is contained in:
Stephan D
2026-02-24 13:01:35 +01:00
parent 0646f55189
commit 6444813f38
289 changed files with 17005 additions and 16065 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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:])
}

View File

@@ -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),
}, ":")
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: {},
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: {},
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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:])
}

View File

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

View File

@@ -0,0 +1,7 @@
package prmap
import "github.com/tech/sendico/pkg/merrors"
func invalidMissing(field string) error {
return merrors.InvalidArgument(field + " is required")
}

View File

@@ -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:])
}

View File

@@ -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 &quotationv2.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)
}

View File

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

View File

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

View File

@@ -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 &quotationv2.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 := &quotationv2.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, &quotationv2.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 := &quotationv2.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 &quotationv2.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
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,8 @@
package sexec
import "errors"
var (
ErrMissingExecutor = errors.New("missing executor")
ErrUnsupportedStep = errors.New("unsupported step")
)

View File

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

View File

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

View File

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

View File

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

View File

@@ -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:])
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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:])
}

View File

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