Fixes + stable gateway ids
This commit is contained in:
195
api/billing/documents/.golangci.yml
Normal file
195
api/billing/documents/.golangci.yml
Normal file
@@ -0,0 +1,195 @@
|
||||
# See the dedicated "version" documentation section.
|
||||
version: "2"
|
||||
linters:
|
||||
# Default set of linters.
|
||||
# The value can be:
|
||||
# - `standard`: https://golangci-lint.run/docs/linters/#enabled-by-default
|
||||
# - `all`: enables all linters by default.
|
||||
# - `none`: disables all linters by default.
|
||||
# - `fast`: enables only linters considered as "fast" (`golangci-lint help linters --json | jq '[ .[] | select(.fast==true) ] | map(.name)'`).
|
||||
# Default: standard
|
||||
default: all
|
||||
# Enable specific linter.
|
||||
enable:
|
||||
- arangolint
|
||||
- asasalint
|
||||
- asciicheck
|
||||
- bidichk
|
||||
- bodyclose
|
||||
- canonicalheader
|
||||
- containedctx
|
||||
- contextcheck
|
||||
- copyloopvar
|
||||
- cyclop
|
||||
- decorder
|
||||
- dogsled
|
||||
- dupl
|
||||
- dupword
|
||||
- durationcheck
|
||||
- embeddedstructfieldcheck
|
||||
- err113
|
||||
- errcheck
|
||||
- errchkjson
|
||||
- errname
|
||||
- errorlint
|
||||
- exhaustive
|
||||
- exptostd
|
||||
- fatcontext
|
||||
- forbidigo
|
||||
- forcetypeassert
|
||||
- funcorder
|
||||
- funlen
|
||||
- ginkgolinter
|
||||
- gocheckcompilerdirectives
|
||||
- gochecknoglobals
|
||||
- gochecknoinits
|
||||
- gochecksumtype
|
||||
- gocognit
|
||||
- goconst
|
||||
- gocritic
|
||||
- gocyclo
|
||||
- godoclint
|
||||
- godot
|
||||
- godox
|
||||
- goheader
|
||||
- gomodguard
|
||||
- goprintffuncname
|
||||
- gosec
|
||||
- gosmopolitan
|
||||
- govet
|
||||
- grouper
|
||||
- iface
|
||||
- importas
|
||||
- inamedparam
|
||||
- ineffassign
|
||||
- interfacebloat
|
||||
- intrange
|
||||
- iotamixing
|
||||
- ireturn
|
||||
- lll
|
||||
- loggercheck
|
||||
- maintidx
|
||||
- makezero
|
||||
- mirror
|
||||
- misspell
|
||||
- mnd
|
||||
- modernize
|
||||
- musttag
|
||||
- nakedret
|
||||
- nestif
|
||||
- nilerr
|
||||
- nilnesserr
|
||||
- nilnil
|
||||
- nlreturn
|
||||
- noctx
|
||||
- noinlineerr
|
||||
- nolintlint
|
||||
- nonamedreturns
|
||||
- nosprintfhostport
|
||||
- paralleltest
|
||||
- perfsprint
|
||||
- prealloc
|
||||
- predeclared
|
||||
- promlinter
|
||||
- protogetter
|
||||
- reassign
|
||||
- recvcheck
|
||||
- revive
|
||||
- rowserrcheck
|
||||
- sloglint
|
||||
- spancheck
|
||||
- sqlclosecheck
|
||||
- staticcheck
|
||||
- tagalign
|
||||
- tagliatelle
|
||||
- testableexamples
|
||||
- testifylint
|
||||
- testpackage
|
||||
- thelper
|
||||
- tparallel
|
||||
- unconvert
|
||||
- unparam
|
||||
- unqueryvet
|
||||
- unused
|
||||
- usestdlibvars
|
||||
- usetesting
|
||||
- varnamelen
|
||||
- wastedassign
|
||||
- whitespace
|
||||
- wsl_v5
|
||||
- zerologlint
|
||||
# Disable specific linters.
|
||||
disable:
|
||||
- depguard
|
||||
- exhaustruct
|
||||
- gochecknoglobals
|
||||
- gomoddirectives
|
||||
- wsl
|
||||
- wrapcheck
|
||||
# All available settings of specific linters.
|
||||
# See the dedicated "linters.settings" documentation section.
|
||||
settings:
|
||||
wsl_v5:
|
||||
allow-first-in-block: true
|
||||
allow-whole-block: false
|
||||
branch-max-lines: 2
|
||||
|
||||
# Defines a set of rules to ignore issues.
|
||||
# It does not skip the analysis, and so does not ignore "typecheck" errors.
|
||||
exclusions:
|
||||
# Mode of the generated files analysis.
|
||||
#
|
||||
# - `strict`: sources are excluded by strictly following the Go generated file convention.
|
||||
# Source files that have lines matching only the following regular expression will be excluded: `^// Code generated .* DO NOT EDIT\.$`
|
||||
# This line must appear before the first non-comment, non-blank text in the file.
|
||||
# https://go.dev/s/generatedcode
|
||||
# - `lax`: sources are excluded if they contain lines like `autogenerated file`, `code generated`, `do not edit`, etc.
|
||||
# - `disable`: disable the generated files exclusion.
|
||||
#
|
||||
# Default: strict
|
||||
generated: lax
|
||||
# Log a warning if an exclusion rule is unused.
|
||||
# Default: false
|
||||
warn-unused: true
|
||||
# Predefined exclusion rules.
|
||||
# Default: []
|
||||
presets:
|
||||
- comments
|
||||
- std-error-handling
|
||||
- common-false-positives
|
||||
- legacy
|
||||
# Excluding configuration per-path, per-linter, per-text and per-source.
|
||||
rules:
|
||||
# Exclude some linters from running on tests files.
|
||||
- path: _test\.go
|
||||
linters:
|
||||
- gocyclo
|
||||
- errcheck
|
||||
- dupl
|
||||
- gosec
|
||||
# Run some linter only for test files by excluding its issues for everything else.
|
||||
- path-except: _test\.go
|
||||
linters:
|
||||
- forbidigo
|
||||
# Exclude known linters from partially hard-vendored code,
|
||||
# which is impossible to exclude via `nolint` comments.
|
||||
# `/` will be replaced by the current OS file path separator to properly work on Windows.
|
||||
- path: internal/hmac/
|
||||
text: "weak cryptographic primitive"
|
||||
linters:
|
||||
- gosec
|
||||
# Exclude some `staticcheck` messages.
|
||||
- linters:
|
||||
- staticcheck
|
||||
text: "SA9003:"
|
||||
# Exclude `lll` issues for long lines with `go:generate`.
|
||||
- linters:
|
||||
- lll
|
||||
source: "^//go:generate "
|
||||
# Which file paths to exclude: they will be analyzed, but issues from them won't be reported.
|
||||
# "/" will be replaced by the current OS file path separator to properly work on Windows.
|
||||
# Default: []
|
||||
paths: []
|
||||
# Which file paths to not exclude.
|
||||
# Default: []
|
||||
paths-except: []
|
||||
@@ -24,5 +24,6 @@ func Create() version.Printer {
|
||||
BuildDate: BuildDate,
|
||||
Version: Version,
|
||||
}
|
||||
|
||||
return vf.Create(&info)
|
||||
}
|
||||
|
||||
@@ -21,11 +21,13 @@ func NewLocalStore(logger mlogger.Logger, cfg LocalConfig) (*LocalStore, error)
|
||||
if root == "" {
|
||||
return nil, merrors.InvalidArgument("docstore: local root_path is empty")
|
||||
}
|
||||
|
||||
store := &LocalStore{
|
||||
logger: logger.Named("docstore").Named("local"),
|
||||
rootPath: root,
|
||||
}
|
||||
store.logger.Info("Document storage initialised", zap.String("root_path", root))
|
||||
|
||||
return store, nil
|
||||
}
|
||||
|
||||
@@ -33,15 +35,19 @@ func (s *LocalStore) Save(ctx context.Context, key string, data []byte) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
path := filepath.Join(s.rootPath, filepath.Clean(key))
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
s.logger.Warn("Failed to create document directory", zap.Error(err), zap.String("path", path))
|
||||
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
s.logger.Warn("Failed to write document file", zap.Error(err), zap.String("path", path))
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -49,12 +55,16 @@ func (s *LocalStore) Load(ctx context.Context, key string) ([]byte, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
path := filepath.Join(s.rootPath, filepath.Clean(key))
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to read document file", zap.Error(err), zap.String("path", path))
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ func NewS3Store(logger mlogger.Logger, cfg S3Config) (*S3Store, error) {
|
||||
if accessKey == "" && cfg.AccessKeyEnv != "" {
|
||||
accessKey = strings.TrimSpace(os.Getenv(cfg.AccessKeyEnv))
|
||||
}
|
||||
|
||||
secretKey := strings.TrimSpace(cfg.SecretAccessKey)
|
||||
if secretKey == "" && cfg.SecretKeyEnv != "" {
|
||||
secretKey = strings.TrimSpace(os.Getenv(cfg.SecretKeyEnv))
|
||||
@@ -62,23 +63,21 @@ func NewS3Store(logger mlogger.Logger, cfg S3Config) (*S3Store, error) {
|
||||
endpoint = "http://" + endpoint
|
||||
}
|
||||
}
|
||||
resolver := aws.EndpointResolverWithOptionsFunc(func(service, region string, _ ...interface{}) (aws.Endpoint, error) {
|
||||
if service == s3.ServiceID {
|
||||
return aws.Endpoint{URL: endpoint, SigningRegion: region, HostnameImmutable: true}, nil
|
||||
}
|
||||
return aws.Endpoint{}, &aws.EndpointNotFoundError{}
|
||||
})
|
||||
loadOpts = append(loadOpts, config.WithEndpointResolverWithOptions(resolver))
|
||||
}
|
||||
|
||||
awsCfg, err := config.LoadDefaultConfig(context.Background(), loadOpts...)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to create AWS config", zap.Error(err), zap.String("bucket", bucket))
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := s3.NewFromConfig(awsCfg, func(opts *s3.Options) {
|
||||
opts.UsePathStyle = cfg.ForcePathStyle
|
||||
|
||||
if endpoint != "" {
|
||||
opts.BaseEndpoint = aws.String(endpoint)
|
||||
}
|
||||
})
|
||||
|
||||
store := &S3Store{
|
||||
@@ -87,6 +86,7 @@ func NewS3Store(logger mlogger.Logger, cfg S3Config) (*S3Store, error) {
|
||||
bucket: bucket,
|
||||
}
|
||||
store.logger.Info("Document storage initialised", zap.String("bucket", bucket), zap.String("endpoint", endpoint))
|
||||
|
||||
return store, nil
|
||||
}
|
||||
|
||||
@@ -94,6 +94,7 @@ func (s *S3Store) Save(ctx context.Context, key string, data []byte) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err := s.client.PutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: aws.String(s.bucket),
|
||||
Key: aws.String(key),
|
||||
@@ -101,8 +102,10 @@ func (s *S3Store) Save(ctx context.Context, key string, data []byte) error {
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to upload document", zap.Error(err), zap.String("key", key))
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -110,15 +113,19 @@ func (s *S3Store) Load(ctx context.Context, key string) ([]byte, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
obj, err := s.client.GetObject(ctx, &s3.GetObjectInput{
|
||||
Bucket: aws.String(s.bucket),
|
||||
Key: aws.String(key),
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to fetch document", zap.Error(err), zap.String("key", key))
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer obj.Body.Close()
|
||||
|
||||
return io.ReadAll(obj.Body)
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ type S3Config struct {
|
||||
Bucket string `yaml:"bucket"`
|
||||
AccessKeyEnv string `yaml:"access_key_env"`
|
||||
SecretKeyEnv string `yaml:"secret_access_key_env"`
|
||||
AccessKey string `yaml:"access_key"`
|
||||
AccessKey string `yaml:"access_key"` //nolint:gosec // config field, not a hardcoded secret
|
||||
SecretAccessKey string `yaml:"secret_access_key"`
|
||||
UseSSL bool `yaml:"use_ssl"`
|
||||
ForcePathStyle bool `yaml:"force_path_style"`
|
||||
@@ -55,11 +55,13 @@ func New(logger mlogger.Logger, cfg Config) (Store, error) {
|
||||
if cfg.Local == nil {
|
||||
return nil, merrors.InvalidArgument("docstore: local config missing")
|
||||
}
|
||||
|
||||
return NewLocalStore(logger, *cfg.Local)
|
||||
case string(DriverS3), string(DriverMinio):
|
||||
if cfg.S3 == nil {
|
||||
return nil, merrors.InvalidArgument("docstore: s3 config missing")
|
||||
}
|
||||
|
||||
return NewS3Store(logger, *cfg.S3)
|
||||
default:
|
||||
return nil, merrors.InvalidArgument("docstore: unsupported driver")
|
||||
|
||||
@@ -29,7 +29,8 @@ type Imp struct {
|
||||
|
||||
type config struct {
|
||||
*grpcapp.Config `yaml:",inline"`
|
||||
Documents documents.Config `yaml:"documents"`
|
||||
|
||||
Documents documents.Config `yaml:"documents"`
|
||||
}
|
||||
|
||||
// Create initialises the billing documents server implementation.
|
||||
@@ -46,6 +47,7 @@ func (i *Imp) Shutdown() {
|
||||
if i.service != nil {
|
||||
i.service.Shutdown()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -68,6 +70,7 @@ func (i *Imp) Start() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
i.config = cfg
|
||||
|
||||
repoFactory := func(logger mlogger.Logger, conn *db.MongoConnection) (storage.Repository, error) {
|
||||
@@ -77,20 +80,23 @@ func (i *Imp) Start() error {
|
||||
docStore, err := docstore.New(i.logger, cfg.Documents.Storage)
|
||||
if err != nil {
|
||||
i.logger.Error("Failed to initialise document storage", zap.Error(err))
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
serviceFactory := func(logger mlogger.Logger, repo storage.Repository, producer msg.Producer) (grpcapp.Service, error) {
|
||||
serviceFactory := func(logger mlogger.Logger, repo storage.Repository, producer msg.Producer) (grpcapp.Service, error) { //nolint:lll // factory signature dictated by grpcapp
|
||||
invokeURI := ""
|
||||
if cfg.GRPC != nil {
|
||||
invokeURI = cfg.GRPC.DiscoveryInvokeURI()
|
||||
}
|
||||
|
||||
svc := documents.NewService(logger, repo, producer,
|
||||
documents.WithDiscoveryInvokeURI(invokeURI),
|
||||
documents.WithConfig(cfg.Documents),
|
||||
documents.WithDocumentStore(docStore),
|
||||
)
|
||||
i.service = svc
|
||||
|
||||
return svc, nil
|
||||
}
|
||||
|
||||
@@ -98,6 +104,7 @@ func (i *Imp) Start() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
i.app = app
|
||||
|
||||
return i.app.Start()
|
||||
@@ -107,12 +114,14 @@ func (i *Imp) loadConfig() (*config, error) {
|
||||
data, err := os.ReadFile(i.file)
|
||||
if err != nil {
|
||||
i.logger.Error("Could not read configuration file", zap.String("config_file", i.file), zap.Error(err))
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cfg := &config{Config: &grpcapp.Config{}}
|
||||
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||
i.logger.Error("Failed to parse configuration", zap.Error(err))
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
@@ -29,5 +29,6 @@ func (c Config) AcceptanceTemplatePath() string {
|
||||
if strings.TrimSpace(c.Templates.AcceptancePath) == "" {
|
||||
return "templates/acceptance.tpl"
|
||||
}
|
||||
|
||||
return c.Templates.AcceptancePath
|
||||
}
|
||||
|
||||
@@ -85,14 +85,18 @@ func statusFromError(err error) string {
|
||||
if err == nil {
|
||||
return "success"
|
||||
}
|
||||
|
||||
st, ok := status.FromError(err)
|
||||
if !ok {
|
||||
return "error"
|
||||
}
|
||||
|
||||
code := st.Code()
|
||||
|
||||
if code == codes.OK {
|
||||
return "success"
|
||||
}
|
||||
|
||||
return strings.ToLower(code.String())
|
||||
}
|
||||
|
||||
@@ -101,5 +105,6 @@ func docTypeLabel(docType documentsv1.DocumentType) string {
|
||||
if label == "" {
|
||||
return "DOCUMENT_TYPE_UNSPECIFIED"
|
||||
}
|
||||
|
||||
return label
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ func WithDiscoveryInvokeURI(uri string) Option {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
|
||||
s.invokeURI = strings.TrimSpace(uri)
|
||||
}
|
||||
}
|
||||
@@ -51,6 +52,7 @@ func WithProducer(producer msg.Producer) Option {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
|
||||
s.producer = producer
|
||||
}
|
||||
}
|
||||
@@ -61,6 +63,7 @@ func WithConfig(cfg Config) Option {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
|
||||
s.config = cfg
|
||||
}
|
||||
}
|
||||
@@ -71,6 +74,7 @@ func WithDocumentStore(store docstore.Store) Option {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
|
||||
s.docStore = store
|
||||
}
|
||||
}
|
||||
@@ -81,12 +85,15 @@ func WithTemplateRenderer(renderer TemplateRenderer) Option {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
|
||||
s.template = renderer
|
||||
}
|
||||
}
|
||||
|
||||
// Service provides billing document metadata and retrieval endpoints.
|
||||
type Service struct {
|
||||
documentsv1.UnimplementedDocumentServiceServer
|
||||
|
||||
logger mlogger.Logger
|
||||
storage storage.Repository
|
||||
docStore docstore.Store
|
||||
@@ -95,12 +102,12 @@ type Service struct {
|
||||
invokeURI string
|
||||
config Config
|
||||
template TemplateRenderer
|
||||
documentsv1.UnimplementedDocumentServiceServer
|
||||
}
|
||||
|
||||
// NewService constructs a documents service with optional configuration.
|
||||
func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Producer, opts ...Option) *Service {
|
||||
initMetrics()
|
||||
|
||||
svc := &Service{
|
||||
logger: logger.Named("documents"),
|
||||
storage: repo,
|
||||
@@ -109,6 +116,7 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro
|
||||
for _, opt := range opts {
|
||||
opt(svc)
|
||||
}
|
||||
|
||||
if svc.template == nil {
|
||||
if tmpl, err := newTemplateRenderer(svc.config.AcceptanceTemplatePath()); err != nil {
|
||||
svc.logger.Warn("Failed to load acceptance template", zap.Error(err))
|
||||
@@ -116,7 +124,9 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro
|
||||
svc.template = tmpl
|
||||
}
|
||||
}
|
||||
|
||||
svc.startDiscoveryAnnouncer()
|
||||
|
||||
return svc
|
||||
}
|
||||
|
||||
@@ -130,32 +140,22 @@ func (s *Service) Shutdown() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if s.announcer != nil {
|
||||
s.announcer.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) startDiscoveryAnnouncer() {
|
||||
if s == nil || s.producer == nil {
|
||||
return
|
||||
}
|
||||
announce := discovery.Announcement{
|
||||
Service: "BILLING_DOCUMENTS",
|
||||
Operations: []string{"documents.batch_resolve", "documents.get"},
|
||||
InvokeURI: s.invokeURI,
|
||||
Version: appversion.Create().Short(),
|
||||
}
|
||||
s.announcer = discovery.NewAnnouncer(s.logger, s.producer, string(mservice.BillingDocuments), announce)
|
||||
s.announcer.Start()
|
||||
}
|
||||
|
||||
func (s *Service) BatchResolveDocuments(ctx context.Context, req *documentsv1.BatchResolveDocumentsRequest) (resp *documentsv1.BatchResolveDocumentsResponse, err error) {
|
||||
start := time.Now()
|
||||
|
||||
var paymentRefs []string
|
||||
if req != nil {
|
||||
paymentRefs = req.GetPaymentRefs()
|
||||
}
|
||||
|
||||
logger := s.logger.With(zap.Int("payment_refs", len(paymentRefs)))
|
||||
|
||||
defer func() {
|
||||
statusLabel := statusFromError(err)
|
||||
observeRequest("batch_resolve", documentsv1.DocumentType_DOCUMENT_TYPE_UNSPECIFIED, statusLabel, time.Since(start))
|
||||
@@ -165,38 +165,48 @@ func (s *Service) BatchResolveDocuments(ctx context.Context, req *documentsv1.Ba
|
||||
if resp != nil {
|
||||
itemsCount = len(resp.GetItems())
|
||||
}
|
||||
|
||||
fields := []zap.Field{
|
||||
zap.String("status", statusLabel),
|
||||
zap.Duration("duration", time.Since(start)),
|
||||
zap.Int("items", itemsCount),
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
logger.Warn("BatchResolveDocuments failed", append(fields, zap.Error(err))...)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
logger.Info("BatchResolveDocuments finished", fields...)
|
||||
}()
|
||||
|
||||
if len(paymentRefs) == 0 {
|
||||
resp = &documentsv1.BatchResolveDocumentsResponse{}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
if s.storage == nil {
|
||||
err = status.Error(codes.Unavailable, errStorageUnavailable.Error())
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
refs := make([]string, 0, len(paymentRefs))
|
||||
for _, ref := range paymentRefs {
|
||||
clean := strings.TrimSpace(ref)
|
||||
|
||||
if clean == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
refs = append(refs, clean)
|
||||
}
|
||||
|
||||
if len(refs) == 0 {
|
||||
resp = &documentsv1.BatchResolveDocumentsResponse{}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
@@ -206,10 +216,12 @@ func (s *Service) BatchResolveDocuments(ctx context.Context, req *documentsv1.Ba
|
||||
}
|
||||
|
||||
recordByRef := map[string]*model.DocumentRecord{}
|
||||
|
||||
for _, record := range records {
|
||||
if record == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
recordByRef[record.PaymentRef] = record
|
||||
}
|
||||
|
||||
@@ -218,18 +230,23 @@ func (s *Service) BatchResolveDocuments(ctx context.Context, req *documentsv1.Ba
|
||||
meta := &documentsv1.DocumentMeta{PaymentRef: ref}
|
||||
if record := recordByRef[ref]; record != nil {
|
||||
record.Normalize()
|
||||
|
||||
available := []model.DocumentType{model.DocumentTypeAct}
|
||||
|
||||
ready := make([]model.DocumentType, 0, 1)
|
||||
if path, ok := record.StoragePaths[model.DocumentTypeAct]; ok && path != "" {
|
||||
ready = append(ready, model.DocumentTypeAct)
|
||||
}
|
||||
|
||||
meta.AvailableTypes = toProtoTypes(available)
|
||||
meta.ReadyTypes = toProtoTypes(ready)
|
||||
}
|
||||
|
||||
items = append(items, meta)
|
||||
}
|
||||
|
||||
resp = &documentsv1.BatchResolveDocumentsResponse{Items: items}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
@@ -237,10 +254,12 @@ func (s *Service) GetDocument(ctx context.Context, req *documentsv1.GetDocumentR
|
||||
start := time.Now()
|
||||
docType := documentsv1.DocumentType_DOCUMENT_TYPE_UNSPECIFIED
|
||||
paymentRef := ""
|
||||
|
||||
if req != nil {
|
||||
docType = req.GetType()
|
||||
paymentRef = strings.TrimSpace(req.GetPaymentRef())
|
||||
}
|
||||
|
||||
logger := s.logger.With(
|
||||
zap.String("payment_ref", paymentRef),
|
||||
zap.String("document_type", docTypeLabel(docType)),
|
||||
@@ -249,6 +268,7 @@ func (s *Service) GetDocument(ctx context.Context, req *documentsv1.GetDocumentR
|
||||
defer func() {
|
||||
statusLabel := statusFromError(err)
|
||||
observeRequest("get_document", docType, statusLabel, time.Since(start))
|
||||
|
||||
if resp != nil {
|
||||
observeDocumentBytes(docType, len(resp.GetContent()))
|
||||
}
|
||||
@@ -257,36 +277,49 @@ func (s *Service) GetDocument(ctx context.Context, req *documentsv1.GetDocumentR
|
||||
if resp != nil {
|
||||
contentBytes = len(resp.GetContent())
|
||||
}
|
||||
|
||||
fields := []zap.Field{
|
||||
zap.String("status", statusLabel),
|
||||
zap.Duration("duration", time.Since(start)),
|
||||
zap.Int("content_bytes", contentBytes),
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
logger.Warn("GetDocument failed", append(fields, zap.Error(err))...)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
logger.Info("GetDocument finished", fields...)
|
||||
}()
|
||||
|
||||
if paymentRef == "" {
|
||||
err = status.Error(codes.InvalidArgument, "payment_ref is required")
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if docType == documentsv1.DocumentType_DOCUMENT_TYPE_UNSPECIFIED {
|
||||
err = status.Error(codes.InvalidArgument, "document type is required")
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if s.storage == nil {
|
||||
err = status.Error(codes.Unavailable, errStorageUnavailable.Error())
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if s.docStore == nil {
|
||||
err = status.Error(codes.Unavailable, errDocStoreUnavailable.Error())
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if s.template == nil {
|
||||
err = status.Error(codes.FailedPrecondition, errTemplateUnavailable.Error())
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -295,8 +328,10 @@ func (s *Service) GetDocument(ctx context.Context, req *documentsv1.GetDocumentR
|
||||
if errors.Is(err, storage.ErrDocumentNotFound) {
|
||||
return nil, status.Error(codes.NotFound, "document record not found")
|
||||
}
|
||||
|
||||
return nil, status.Error(codes.Internal, err.Error())
|
||||
}
|
||||
|
||||
record.Normalize()
|
||||
|
||||
targetType := model.DocumentTypeFromProto(docType)
|
||||
@@ -310,6 +345,7 @@ func (s *Service) GetDocument(ctx context.Context, req *documentsv1.GetDocumentR
|
||||
if loadErr != nil {
|
||||
return nil, status.Error(codes.Internal, loadErr.Error())
|
||||
}
|
||||
|
||||
return &documentsv1.GetDocumentResponse{
|
||||
Content: content,
|
||||
Filename: documentFilename(docType, paymentRef),
|
||||
@@ -320,19 +356,23 @@ func (s *Service) GetDocument(ctx context.Context, req *documentsv1.GetDocumentR
|
||||
content, hash, genErr := s.generateActPDF(record.Snapshot)
|
||||
if genErr != nil {
|
||||
logger.Warn("Failed to generate document", zap.Error(genErr))
|
||||
|
||||
return nil, status.Error(codes.Internal, genErr.Error())
|
||||
}
|
||||
|
||||
path := documentStoragePath(paymentRef, docType)
|
||||
if saveErr := s.docStore.Save(ctx, path, content); saveErr != nil {
|
||||
logger.Warn("Failed to store document", zap.Error(saveErr))
|
||||
|
||||
return nil, status.Error(codes.Internal, saveErr.Error())
|
||||
}
|
||||
|
||||
record.StoragePaths[targetType] = path
|
||||
record.Hashes[targetType] = hash
|
||||
|
||||
if updateErr := s.storage.Documents().Update(ctx, record); updateErr != nil {
|
||||
logger.Warn("Failed to update document record", zap.Error(updateErr))
|
||||
|
||||
return nil, status.Error(codes.Internal, updateErr.Error())
|
||||
}
|
||||
|
||||
@@ -341,9 +381,25 @@ func (s *Service) GetDocument(ctx context.Context, req *documentsv1.GetDocumentR
|
||||
Filename: documentFilename(docType, paymentRef),
|
||||
MimeType: "application/pdf",
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s *Service) startDiscoveryAnnouncer() {
|
||||
if s == nil || s.producer == nil {
|
||||
return
|
||||
}
|
||||
|
||||
announce := discovery.Announcement{
|
||||
Service: "BILLING_DOCUMENTS",
|
||||
Operations: []string{"documents.batch_resolve", "documents.get"},
|
||||
InvokeURI: s.invokeURI,
|
||||
Version: appversion.Create().Short(),
|
||||
}
|
||||
s.announcer = discovery.NewAnnouncer(s.logger, s.producer, mservice.BillingDocuments, announce)
|
||||
s.announcer.Start()
|
||||
}
|
||||
|
||||
type serviceError string
|
||||
|
||||
func (e serviceError) Error() string {
|
||||
@@ -361,15 +417,18 @@ func (s *Service) generateActPDF(snapshot model.ActSnapshot) ([]byte, string, er
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
generated := renderer.Renderer{
|
||||
Issuer: s.config.Issuer,
|
||||
OwnerPassword: s.config.Protection.OwnerPassword,
|
||||
}
|
||||
placeholder := strings.Repeat("0", 64)
|
||||
|
||||
firstPass, err := generated.Render(blocks, placeholder)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
footerHash := sha256.Sum256(firstPass)
|
||||
footerHex := hex.EncodeToString(footerHash[:])
|
||||
|
||||
@@ -377,6 +436,7 @@ func (s *Service) generateActPDF(snapshot model.ActSnapshot) ([]byte, string, er
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
return finalBytes, footerHex, nil
|
||||
}
|
||||
|
||||
@@ -384,15 +444,18 @@ func toProtoTypes(types []model.DocumentType) []documentsv1.DocumentType {
|
||||
if len(types) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := make([]documentsv1.DocumentType, 0, len(types))
|
||||
for _, t := range types {
|
||||
result = append(result, t.Proto())
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func documentStoragePath(paymentRef string, docType documentsv1.DocumentType) string {
|
||||
suffix := "document.pdf"
|
||||
|
||||
switch docType {
|
||||
case documentsv1.DocumentType_DOCUMENT_TYPE_ACT:
|
||||
suffix = "act.pdf"
|
||||
@@ -400,12 +463,16 @@ func documentStoragePath(paymentRef string, docType documentsv1.DocumentType) st
|
||||
suffix = "invoice.pdf"
|
||||
case documentsv1.DocumentType_DOCUMENT_TYPE_RECEIPT:
|
||||
suffix = "receipt.pdf"
|
||||
case documentsv1.DocumentType_DOCUMENT_TYPE_UNSPECIFIED:
|
||||
// default suffix used
|
||||
}
|
||||
|
||||
return filepath.ToSlash(filepath.Join("documents", paymentRef, suffix))
|
||||
}
|
||||
|
||||
func documentFilename(docType documentsv1.DocumentType, paymentRef string) string {
|
||||
name := "document"
|
||||
|
||||
switch docType {
|
||||
case documentsv1.DocumentType_DOCUMENT_TYPE_ACT:
|
||||
name = "act"
|
||||
@@ -413,6 +480,9 @@ func documentFilename(docType documentsv1.DocumentType, paymentRef string) strin
|
||||
name = "invoice"
|
||||
case documentsv1.DocumentType_DOCUMENT_TYPE_RECEIPT:
|
||||
name = "receipt"
|
||||
case documentsv1.DocumentType_DOCUMENT_TYPE_UNSPECIFIED:
|
||||
// default name used
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s_%s.pdf", name, paymentRef)
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ type stubRepo struct {
|
||||
store storage.DocumentsStore
|
||||
}
|
||||
|
||||
func (s *stubRepo) Ping(ctx context.Context) error { return nil }
|
||||
func (s *stubRepo) Ping(_ context.Context) error { return nil }
|
||||
func (s *stubRepo) Documents() storage.DocumentsStore { return s.store }
|
||||
|
||||
var _ storage.Repository = (*stubRepo)(nil)
|
||||
@@ -28,22 +28,24 @@ type stubDocumentsStore struct {
|
||||
updateCalls int
|
||||
}
|
||||
|
||||
func (s *stubDocumentsStore) Create(ctx context.Context, record *model.DocumentRecord) error {
|
||||
func (s *stubDocumentsStore) Create(_ context.Context, record *model.DocumentRecord) error {
|
||||
s.record = record
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubDocumentsStore) Update(ctx context.Context, record *model.DocumentRecord) error {
|
||||
func (s *stubDocumentsStore) Update(_ context.Context, record *model.DocumentRecord) error {
|
||||
s.record = record
|
||||
s.updateCalls++
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubDocumentsStore) GetByPaymentRef(ctx context.Context, paymentRef string) (*model.DocumentRecord, error) {
|
||||
func (s *stubDocumentsStore) GetByPaymentRef(_ context.Context, _ string) (*model.DocumentRecord, error) {
|
||||
return s.record, nil
|
||||
}
|
||||
|
||||
func (s *stubDocumentsStore) ListByPaymentRefs(ctx context.Context, paymentRefs []string) ([]*model.DocumentRecord, error) {
|
||||
func (s *stubDocumentsStore) ListByPaymentRefs(_ context.Context, _ []string) ([]*model.DocumentRecord, error) {
|
||||
return []*model.DocumentRecord{s.record}, nil
|
||||
}
|
||||
|
||||
@@ -59,19 +61,21 @@ func newMemDocStore() *memDocStore {
|
||||
return &memDocStore{data: map[string][]byte{}}
|
||||
}
|
||||
|
||||
func (m *memDocStore) Save(ctx context.Context, key string, data []byte) error {
|
||||
func (m *memDocStore) Save(_ context.Context, key string, data []byte) error {
|
||||
m.saveCount++
|
||||
copyData := make([]byte, len(data))
|
||||
copy(copyData, data)
|
||||
m.data[key] = copyData
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memDocStore) Load(ctx context.Context, key string) ([]byte, error) {
|
||||
func (m *memDocStore) Load(_ context.Context, key string) ([]byte, error) {
|
||||
m.loadCount++
|
||||
data := m.data[key]
|
||||
copyData := make([]byte, len(data))
|
||||
copy(copyData, data)
|
||||
|
||||
return copyData, nil
|
||||
}
|
||||
|
||||
@@ -84,8 +88,9 @@ type stubTemplate struct {
|
||||
calls int
|
||||
}
|
||||
|
||||
func (s *stubTemplate) Render(snapshot model.ActSnapshot) ([]renderer.Block, error) {
|
||||
func (s *stubTemplate) Render(_ model.ActSnapshot) ([]renderer.Block, error) {
|
||||
s.calls++
|
||||
|
||||
return s.blocks, nil
|
||||
}
|
||||
|
||||
@@ -135,18 +140,23 @@ func TestGetDocument_IdempotentAndHashed(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("GetDocument first call: %v", err)
|
||||
}
|
||||
if len(resp1.Content) == 0 {
|
||||
|
||||
if len(resp1.GetContent()) == 0 {
|
||||
t.Fatalf("expected content on first call")
|
||||
}
|
||||
|
||||
stored := record.Hashes[model.DocumentTypeAct]
|
||||
|
||||
if stored == "" {
|
||||
t.Fatalf("expected stored hash")
|
||||
}
|
||||
footerHash := extractFooterHash(resp1.Content)
|
||||
|
||||
footerHash := extractFooterHash(resp1.GetContent())
|
||||
|
||||
if footerHash == "" {
|
||||
t.Fatalf("expected footer hash in PDF")
|
||||
}
|
||||
|
||||
if stored != footerHash {
|
||||
t.Fatalf("stored hash mismatch: got %s", stored)
|
||||
}
|
||||
@@ -158,16 +168,19 @@ func TestGetDocument_IdempotentAndHashed(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("GetDocument second call: %v", err)
|
||||
}
|
||||
if !bytes.Equal(resp1.Content, resp2.Content) {
|
||||
|
||||
if !bytes.Equal(resp1.GetContent(), resp2.GetContent()) {
|
||||
t.Fatalf("expected identical PDF bytes on second call")
|
||||
}
|
||||
|
||||
if tmpl.calls != 1 {
|
||||
t.Fatalf("expected template to be rendered once, got %d", tmpl.calls)
|
||||
}
|
||||
|
||||
if store.saveCount != 1 {
|
||||
t.Fatalf("expected document save once, got %d", store.saveCount)
|
||||
}
|
||||
|
||||
if store.loadCount == 0 {
|
||||
t.Fatalf("expected document load on second call")
|
||||
}
|
||||
@@ -176,17 +189,23 @@ func TestGetDocument_IdempotentAndHashed(t *testing.T) {
|
||||
func extractFooterHash(pdf []byte) string {
|
||||
prefix := []byte("Document integrity hash: ")
|
||||
idx := bytes.Index(pdf, prefix)
|
||||
|
||||
if idx == -1 {
|
||||
return ""
|
||||
}
|
||||
|
||||
start := idx + len(prefix)
|
||||
|
||||
end := start
|
||||
|
||||
for end < len(pdf) && isHexDigit(pdf[end]) {
|
||||
end++
|
||||
}
|
||||
|
||||
if end-start != 64 {
|
||||
return ""
|
||||
}
|
||||
|
||||
return string(pdf[start:end])
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ type templateRenderer struct {
|
||||
func newTemplateRenderer(path string) (*templateRenderer, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Read template: %w", err)
|
||||
return nil, fmt.Errorf("read template: %w", err)
|
||||
}
|
||||
|
||||
funcs := template.FuncMap{
|
||||
@@ -30,7 +30,7 @@ func newTemplateRenderer(path string) (*templateRenderer, error) {
|
||||
|
||||
tpl, err := template.New("acceptance").Funcs(funcs).Option("missingkey=error").Parse(string(data))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Parse template: %w", err)
|
||||
return nil, fmt.Errorf("parse template: %w", err)
|
||||
}
|
||||
|
||||
return &templateRenderer{tpl: tpl}, nil
|
||||
@@ -39,8 +39,9 @@ func newTemplateRenderer(path string) (*templateRenderer, error) {
|
||||
func (r *templateRenderer) Render(snapshot model.ActSnapshot) ([]renderer.Block, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := r.tpl.Execute(&buf, snapshot); err != nil {
|
||||
return nil, fmt.Errorf("Execute template: %w", err)
|
||||
return nil, fmt.Errorf("execute template: %w", err)
|
||||
}
|
||||
|
||||
return renderer.ParseBlocks(buf.String())
|
||||
}
|
||||
|
||||
@@ -49,6 +50,7 @@ func formatMoney(amount decimal.Decimal, currency string) string {
|
||||
if currency == "" {
|
||||
return amount.String()
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s %s", amount.String(), currency)
|
||||
}
|
||||
|
||||
@@ -56,5 +58,6 @@ func formatDate(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
|
||||
return t.Format("2006-01-02")
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package documents
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
|
||||
func TestTemplateRenderer_Render(t *testing.T) {
|
||||
path := filepath.Join("..", "..", "..", "templates", "acceptance.tpl")
|
||||
|
||||
tmpl, err := newTemplateRenderer(path)
|
||||
if err != nil {
|
||||
t.Fatalf("newTemplateRenderer: %v", err)
|
||||
@@ -29,22 +31,18 @@ func TestTemplateRenderer_Render(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Render: %v", err)
|
||||
}
|
||||
|
||||
if len(blocks) == 0 {
|
||||
t.Fatalf("expected blocks, got none")
|
||||
}
|
||||
|
||||
title := findBlock(blocks, renderer.TagTitle)
|
||||
|
||||
if title == nil {
|
||||
t.Fatalf("expected title block")
|
||||
}
|
||||
foundTitle := false
|
||||
for _, line := range title.Lines {
|
||||
if line == "ACT OF ACCEPTANCE OF SERVICES" {
|
||||
foundTitle = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundTitle {
|
||||
|
||||
if !slices.Contains(title.Lines, "ACT OF ACCEPTANCE OF SERVICES") {
|
||||
t.Fatalf("expected title content not found")
|
||||
}
|
||||
|
||||
@@ -52,13 +50,17 @@ func TestTemplateRenderer_Render(t *testing.T) {
|
||||
if kv == nil {
|
||||
t.Fatalf("expected kv block")
|
||||
}
|
||||
|
||||
foundExecutor := false
|
||||
|
||||
for _, row := range kv.Rows {
|
||||
if len(row) >= 2 && row[0] == "Executor" && row[1] == snapshot.ExecutorFullName {
|
||||
foundExecutor = true
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !foundExecutor {
|
||||
t.Fatalf("expected executor name in kv block")
|
||||
}
|
||||
@@ -67,13 +69,17 @@ func TestTemplateRenderer_Render(t *testing.T) {
|
||||
if table == nil {
|
||||
t.Fatalf("expected table block")
|
||||
}
|
||||
|
||||
foundAmount := false
|
||||
|
||||
for _, row := range table.Rows {
|
||||
if len(row) >= 2 && row[1] == "123.45 USD" {
|
||||
foundAmount = true
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !foundAmount {
|
||||
t.Fatalf("expected amount in table block")
|
||||
}
|
||||
@@ -85,5 +91,6 @@ func findBlock(blocks []renderer.Block, tag renderer.Tag) *renderer.Block {
|
||||
return &blocks[i]
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ func drawHeader(pdf *gofpdf.Fpdf, issuer Issuer, marginLeft, marginTop float64)
|
||||
if logoWidth > 0 {
|
||||
textX = startX + logoWidth + 6
|
||||
}
|
||||
|
||||
pdf.SetXY(textX, startY)
|
||||
pdf.SetFont("Helvetica", "B", 12)
|
||||
pdf.CellFormat(0, 5, issuer.LegalName, "", 1, "L", false, 0, "")
|
||||
@@ -39,6 +40,7 @@ func drawHeader(pdf *gofpdf.Fpdf, issuer Issuer, marginLeft, marginTop float64)
|
||||
}
|
||||
|
||||
currentY := pdf.GetY()
|
||||
|
||||
if logoWidth > 0 {
|
||||
logoBottom := startY + logoWidth
|
||||
if logoBottom > currentY {
|
||||
|
||||
@@ -2,7 +2,6 @@ package renderer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
@@ -39,18 +38,22 @@ func (r Renderer) Render(blocks []Block, footerHash string) ([]byte, error) {
|
||||
pdf.SetFooterFunc(func() {
|
||||
pdf.SetY(-15)
|
||||
pdf.SetFont("Helvetica", "", 8)
|
||||
footer := fmt.Sprintf("Document integrity hash: %s", footerHash)
|
||||
|
||||
footer := "Document integrity hash: " + footerHash
|
||||
pdf.CellFormat(0, 5, footer, "", 0, "L", false, 0, "")
|
||||
})
|
||||
|
||||
pdf.AddPage()
|
||||
|
||||
if _, err := drawHeader(pdf, r.Issuer, pageMarginLeft, pageMarginTop); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pdf.Ln(6)
|
||||
|
||||
for _, block := range blocks {
|
||||
renderBlock(pdf, block)
|
||||
|
||||
if pdf.Error() != nil {
|
||||
return nil, pdf.Error()
|
||||
}
|
||||
@@ -60,6 +63,7 @@ func (r Renderer) Render(blocks []Block, footerHash string) ([]byte, error) {
|
||||
if err := pdf.Output(buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
@@ -69,47 +73,64 @@ func renderBlock(pdf *gofpdf.Fpdf, block Block) {
|
||||
pdf.Ln(6)
|
||||
case TagTitle:
|
||||
pdf.SetFont("Helvetica", "B", 14)
|
||||
|
||||
for _, line := range block.Lines {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
pdf.Ln(4)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
pdf.CellFormat(0, 7, line, "", 1, "C", false, 0, "")
|
||||
}
|
||||
|
||||
pdf.Ln(2)
|
||||
case TagSubtitle:
|
||||
pdf.SetFont("Helvetica", "", 11)
|
||||
|
||||
for _, line := range block.Lines {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
pdf.Ln(3)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
pdf.CellFormat(0, 6, line, "", 1, "C", false, 0, "")
|
||||
}
|
||||
|
||||
pdf.Ln(2)
|
||||
case TagMeta:
|
||||
pdf.SetFont("Helvetica", "", 9)
|
||||
|
||||
for _, line := range block.Lines {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
pdf.Ln(2)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
pdf.CellFormat(0, 4.5, line, "", 1, "R", false, 0, "")
|
||||
}
|
||||
|
||||
pdf.Ln(2)
|
||||
case TagSection:
|
||||
pdf.Ln(2)
|
||||
pdf.SetFont("Helvetica", "B", 11)
|
||||
|
||||
for _, line := range block.Lines {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
pdf.Ln(3)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
pdf.CellFormat(0, 6, line, "", 1, "L", false, 0, "")
|
||||
}
|
||||
|
||||
pdf.Ln(1)
|
||||
case TagText:
|
||||
pdf.SetFont("Helvetica", "", 10)
|
||||
|
||||
text := strings.Join(block.Lines, "\n")
|
||||
pdf.MultiCell(0, 5, text, "", "L", false)
|
||||
pdf.Ln(1)
|
||||
@@ -119,12 +140,14 @@ func renderBlock(pdf *gofpdf.Fpdf, block Block) {
|
||||
renderTable(pdf, block)
|
||||
case TagSign:
|
||||
pdf.SetFont("Helvetica", "", 10)
|
||||
|
||||
text := strings.Join(block.Lines, "\n")
|
||||
pdf.MultiCell(0, 6, text, "", "L", false)
|
||||
pdf.Ln(2)
|
||||
default:
|
||||
// Unknown tag: treat as plain text for resilience.
|
||||
pdf.SetFont("Helvetica", "", 10)
|
||||
|
||||
text := strings.Join(block.Lines, "\n")
|
||||
pdf.MultiCell(0, 5, text, "", "L", false)
|
||||
pdf.Ln(1)
|
||||
@@ -133,6 +156,7 @@ func renderBlock(pdf *gofpdf.Fpdf, block Block) {
|
||||
|
||||
func renderKeyValue(pdf *gofpdf.Fpdf, block Block) {
|
||||
pdf.SetFont("Helvetica", "", 10)
|
||||
|
||||
usable := usableWidth(pdf)
|
||||
keyWidth := math.Round(usable * 0.35)
|
||||
valueWidth := usable - keyWidth
|
||||
@@ -142,11 +166,14 @@ func renderKeyValue(pdf *gofpdf.Fpdf, block Block) {
|
||||
if len(row) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
key := row[0]
|
||||
|
||||
value := ""
|
||||
if len(row) > 1 {
|
||||
value = row[1]
|
||||
}
|
||||
|
||||
x := pdf.GetX()
|
||||
y := pdf.GetY()
|
||||
|
||||
@@ -162,6 +189,7 @@ func renderKeyValue(pdf *gofpdf.Fpdf, block Block) {
|
||||
|
||||
pdf.SetY(maxFloat(leftY, rightY))
|
||||
}
|
||||
|
||||
pdf.Ln(1)
|
||||
}
|
||||
|
||||
@@ -169,6 +197,7 @@ func renderTable(pdf *gofpdf.Fpdf, block Block) {
|
||||
if len(block.Rows) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
usable := usableWidth(pdf)
|
||||
col1 := math.Round(usable * 0.7)
|
||||
col2 := usable - col1
|
||||
@@ -176,9 +205,11 @@ func renderTable(pdf *gofpdf.Fpdf, block Block) {
|
||||
|
||||
header := block.Rows[0]
|
||||
pdf.SetFont("Helvetica", "B", 10)
|
||||
|
||||
if len(header) > 0 {
|
||||
pdf.CellFormat(col1, lineHeight, header[0], "1", 0, "L", false, 0, "")
|
||||
}
|
||||
|
||||
if len(header) > 1 {
|
||||
pdf.CellFormat(col2, lineHeight, header[1], "1", 1, "R", false, 0, "")
|
||||
} else {
|
||||
@@ -186,15 +217,19 @@ func renderTable(pdf *gofpdf.Fpdf, block Block) {
|
||||
}
|
||||
|
||||
pdf.SetFont("Helvetica", "", 10)
|
||||
|
||||
for _, row := range block.Rows[1:] {
|
||||
colA := ""
|
||||
colB := ""
|
||||
|
||||
if len(row) > 0 {
|
||||
colA = row[0]
|
||||
}
|
||||
|
||||
if len(row) > 1 {
|
||||
colB = row[1]
|
||||
}
|
||||
|
||||
x := pdf.GetX()
|
||||
y := pdf.GetY()
|
||||
pdf.MultiCell(col1, lineHeight, colA, "1", "L", false)
|
||||
@@ -204,12 +239,14 @@ func renderTable(pdf *gofpdf.Fpdf, block Block) {
|
||||
rightY := pdf.GetY()
|
||||
pdf.SetY(maxFloat(leftY, rightY))
|
||||
}
|
||||
|
||||
pdf.Ln(2)
|
||||
}
|
||||
|
||||
func usableWidth(pdf *gofpdf.Fpdf) float64 {
|
||||
pageW, _ := pdf.GetPageSize()
|
||||
left, _, right, _ := pdf.GetMargins()
|
||||
|
||||
return pageW - left - right
|
||||
}
|
||||
|
||||
@@ -217,5 +254,6 @@ func maxFloat(a, b float64) float64 {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
@@ -26,11 +26,13 @@ func TestRenderer_RenderContainsText(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Render: %v", err)
|
||||
}
|
||||
|
||||
if len(pdfBytes) == 0 {
|
||||
t.Fatalf("expected PDF bytes")
|
||||
}
|
||||
|
||||
checks := []string{"Sendico Ltd", "Jane Doe", "100 USD", "Document integrity hash"}
|
||||
|
||||
for _, token := range checks {
|
||||
if !containsPDFText(pdfBytes, token) {
|
||||
t.Fatalf("expected PDF to contain %q", token)
|
||||
@@ -42,22 +44,29 @@ func containsPDFText(pdfBytes []byte, text string) bool {
|
||||
if bytes.Contains(pdfBytes, []byte(text)) {
|
||||
return true
|
||||
}
|
||||
|
||||
hexText := hex.EncodeToString([]byte(text))
|
||||
|
||||
if bytes.Contains(pdfBytes, []byte(strings.ToUpper(hexText))) {
|
||||
return true
|
||||
}
|
||||
|
||||
if bytes.Contains(pdfBytes, []byte(strings.ToLower(hexText))) {
|
||||
return true
|
||||
}
|
||||
|
||||
utf16Bytes := encodeUTF16BE(text, false)
|
||||
|
||||
if bytes.Contains(pdfBytes, utf16Bytes) {
|
||||
return true
|
||||
}
|
||||
|
||||
utf16Hex := hex.EncodeToString(utf16Bytes)
|
||||
|
||||
if bytes.Contains(pdfBytes, []byte(strings.ToUpper(utf16Hex))) {
|
||||
return true
|
||||
}
|
||||
|
||||
if bytes.Contains(pdfBytes, []byte(strings.ToLower(utf16Hex))) {
|
||||
return true
|
||||
}
|
||||
@@ -66,25 +75,33 @@ func containsPDFText(pdfBytes []byte, text string) bool {
|
||||
if bytes.Contains(pdfBytes, utf16BytesBOM) {
|
||||
return true
|
||||
}
|
||||
|
||||
utf16HexBOM := hex.EncodeToString(utf16BytesBOM)
|
||||
|
||||
if bytes.Contains(pdfBytes, []byte(strings.ToUpper(utf16HexBOM))) {
|
||||
return true
|
||||
}
|
||||
|
||||
return bytes.Contains(pdfBytes, []byte(strings.ToLower(utf16HexBOM)))
|
||||
}
|
||||
|
||||
func encodeUTF16BE(text string, withBOM bool) []byte {
|
||||
encoded := utf16.Encode([]rune(text))
|
||||
length := len(encoded) * 2
|
||||
|
||||
if withBOM {
|
||||
length += 2
|
||||
}
|
||||
|
||||
out := make([]byte, 0, length)
|
||||
|
||||
if withBOM {
|
||||
out = append(out, 0xFE, 0xFF)
|
||||
}
|
||||
|
||||
for _, v := range encoded {
|
||||
out = append(out, byte(v>>8), byte(v))
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ type Block struct {
|
||||
func ParseBlocks(input string) ([]Block, error) {
|
||||
scanner := bufio.NewScanner(strings.NewReader(input))
|
||||
blocks := make([]Block, 0)
|
||||
|
||||
var current *Block
|
||||
|
||||
flush := func() {
|
||||
@@ -44,17 +45,24 @@ func ParseBlocks(input string) ([]Block, error) {
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimRight(scanner.Text(), "\r")
|
||||
trimmed := strings.TrimSpace(line)
|
||||
|
||||
if strings.HasPrefix(trimmed, "#") {
|
||||
flush()
|
||||
|
||||
tag := Tag(strings.TrimSpace(strings.TrimPrefix(trimmed, "#")))
|
||||
|
||||
if tag == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if tag == TagSpacer {
|
||||
blocks = append(blocks, Block{Tag: TagSpacer})
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
current = &Block{Tag: tag}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -62,16 +70,19 @@ func ParseBlocks(input string) ([]Block, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
switch current.Tag {
|
||||
switch current.Tag { //nolint:exhaustive // only KV and Table need row parsing
|
||||
case TagKV, TagTable:
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.Split(line, "|")
|
||||
|
||||
row := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
row = append(row, strings.TrimSpace(part))
|
||||
}
|
||||
|
||||
current.Rows = append(current.Rows, row)
|
||||
default:
|
||||
current.Lines = append(current.Lines, line)
|
||||
@@ -79,9 +90,10 @@ func ParseBlocks(input string) ([]Block, error) {
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf("Parse blocks: %w", err)
|
||||
return nil, fmt.Errorf("parse blocks: %w", err)
|
||||
}
|
||||
|
||||
flush()
|
||||
|
||||
return blocks, nil
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ func DocumentTypeFromProto(t documentsv1.DocumentType) DocumentType {
|
||||
if name, ok := documentsv1.DocumentType_name[int32(t)]; ok {
|
||||
return DocumentType(name)
|
||||
}
|
||||
|
||||
return DocumentTypeUnspecified
|
||||
}
|
||||
|
||||
@@ -36,22 +37,24 @@ func (t DocumentType) Proto() documentsv1.DocumentType {
|
||||
if value, ok := documentsv1.DocumentType_value[string(t)]; ok {
|
||||
return documentsv1.DocumentType(value)
|
||||
}
|
||||
|
||||
return documentsv1.DocumentType_DOCUMENT_TYPE_UNSPECIFIED
|
||||
}
|
||||
|
||||
// ActSnapshot captures the immutable data needed to generate an acceptance act.
|
||||
type ActSnapshot struct {
|
||||
PaymentID string `bson:"paymentId" json:"paymentId"`
|
||||
Date time.Time `bson:"date" json:"date"`
|
||||
PaymentID string `bson:"paymentId" json:"paymentId"`
|
||||
Date time.Time `bson:"date" json:"date"`
|
||||
ExecutorFullName string `bson:"executorFullName" json:"executorFullName"`
|
||||
Amount decimal.Decimal `bson:"amount" json:"amount"`
|
||||
Currency string `bson:"currency" json:"currency"`
|
||||
Amount decimal.Decimal `bson:"amount" json:"amount"`
|
||||
Currency string `bson:"currency" json:"currency"`
|
||||
}
|
||||
|
||||
func (s *ActSnapshot) Normalize() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
|
||||
s.PaymentID = strings.TrimSpace(s.PaymentID)
|
||||
s.ExecutorFullName = strings.TrimSpace(s.ExecutorFullName)
|
||||
s.Currency = strings.TrimSpace(s.Currency)
|
||||
@@ -60,21 +63,25 @@ func (s *ActSnapshot) Normalize() {
|
||||
// DocumentRecord stores document metadata and cached artefacts for a payment.
|
||||
type DocumentRecord struct {
|
||||
storable.Base `bson:",inline" json:",inline"`
|
||||
PaymentRef string `bson:"paymentRef" json:"paymentRef"`
|
||||
Snapshot ActSnapshot `bson:"snapshot" json:"snapshot"`
|
||||
StoragePaths map[DocumentType]string `bson:"storagePaths,omitempty" json:"storagePaths,omitempty"`
|
||||
Hashes map[DocumentType]string `bson:"hashes,omitempty" json:"hashes,omitempty"`
|
||||
|
||||
PaymentRef string `bson:"paymentRef" json:"paymentRef"`
|
||||
Snapshot ActSnapshot `bson:"snapshot" json:"snapshot"`
|
||||
StoragePaths map[DocumentType]string `bson:"storagePaths,omitempty" json:"storagePaths,omitempty"`
|
||||
Hashes map[DocumentType]string `bson:"hashes,omitempty" json:"hashes,omitempty"`
|
||||
}
|
||||
|
||||
func (r *DocumentRecord) Normalize() {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
|
||||
r.PaymentRef = strings.TrimSpace(r.PaymentRef)
|
||||
r.Snapshot.Normalize()
|
||||
|
||||
if r.StoragePaths == nil {
|
||||
r.StoragePaths = map[DocumentType]string{}
|
||||
}
|
||||
|
||||
if r.Hashes == nil {
|
||||
r.Hashes = map[DocumentType]string{}
|
||||
}
|
||||
|
||||
@@ -43,17 +43,21 @@ func New(logger mlogger.Logger, conn *db.MongoConnection) (*Store, error) {
|
||||
|
||||
if err := result.Ping(ctx); err != nil {
|
||||
result.logger.Error("Mongo ping failed during store init", zap.Error(err))
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
documentsStore, err := store.NewDocuments(result.logger, database)
|
||||
if err != nil {
|
||||
result.logger.Error("Failed to initialise documents store", zap.Error(err))
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result.documents = documentsStore
|
||||
|
||||
result.logger.Info("Billing documents MongoDB storage initialised")
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ func NewDocuments(logger mlogger.Logger, db *mongo.Database) (*Documents, error)
|
||||
for _, def := range indexes {
|
||||
if err := repo.CreateIndex(def); err != nil {
|
||||
logger.Error("Failed to ensure documents index", zap.Error(err), zap.String("collection", repo.Collection()))
|
||||
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
@@ -56,7 +57,9 @@ func (d *Documents) Create(ctx context.Context, record *model.DocumentRecord) er
|
||||
if record == nil {
|
||||
return merrors.InvalidArgument("documentsStore: nil record")
|
||||
}
|
||||
|
||||
record.Normalize()
|
||||
|
||||
if record.PaymentRef == "" {
|
||||
return merrors.InvalidArgument("documentsStore: empty paymentRef")
|
||||
}
|
||||
@@ -66,9 +69,12 @@ func (d *Documents) Create(ctx context.Context, record *model.DocumentRecord) er
|
||||
if errors.Is(err, merrors.ErrDataConflict) {
|
||||
return storage.ErrDuplicateDocument
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
d.logger.Debug("Document record created", zap.String("payment_ref", record.PaymentRef))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -76,17 +82,21 @@ func (d *Documents) Update(ctx context.Context, record *model.DocumentRecord) er
|
||||
if record == nil {
|
||||
return merrors.InvalidArgument("documentsStore: nil record")
|
||||
}
|
||||
|
||||
if record.ID.IsZero() {
|
||||
return merrors.InvalidArgument("documentsStore: missing record id")
|
||||
}
|
||||
|
||||
record.Normalize()
|
||||
record.Update()
|
||||
if err := d.repo.Update(ctx, record); err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
return storage.ErrDocumentNotFound
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -101,8 +111,10 @@ func (d *Documents) GetByPaymentRef(ctx context.Context, paymentRef string) (*mo
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
return nil, storage.ErrDocumentNotFound
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return entity, nil
|
||||
}
|
||||
|
||||
@@ -113,26 +125,34 @@ func (d *Documents) ListByPaymentRefs(ctx context.Context, paymentRefs []string)
|
||||
if clean == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
refs = append(refs, clean)
|
||||
}
|
||||
|
||||
if len(refs) == 0 {
|
||||
return []*model.DocumentRecord{}, nil
|
||||
}
|
||||
|
||||
query := repository.Query().Comparison(repository.Field("paymentRef"), builder.In, refs)
|
||||
records := make([]*model.DocumentRecord, 0)
|
||||
|
||||
decoder := func(cur *mongo.Cursor) error {
|
||||
var rec model.DocumentRecord
|
||||
if err := cur.Decode(&rec); err != nil {
|
||||
d.logger.Warn("Failed to decode document record", zap.Error(err))
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
records = append(records, &rec)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := d.repo.FindManyByFilter(ctx, query, decoder); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Package appversion exposes build-time version information for the discovery service.
|
||||
package appversion
|
||||
|
||||
import (
|
||||
@@ -14,6 +15,7 @@ var (
|
||||
BuildDate string
|
||||
)
|
||||
|
||||
// Create returns a version printer populated with the compile-time build metadata.
|
||||
func Create() version.Printer { //nolint:ireturn // factory returns interface by design
|
||||
info := version.Info{
|
||||
Program: "Sendico Discovery Service",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Package serverimp contains the concrete discovery server implementation.
|
||||
package serverimp
|
||||
|
||||
import (
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
const defaultShutdownTimeout = 15 * time.Second
|
||||
|
||||
// Create returns a new server implementation configured with the given logger, config file, and debug flag.
|
||||
func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) {
|
||||
return &Imp{
|
||||
logger: logger.Named("server"),
|
||||
@@ -20,6 +21,7 @@ func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Start loads configuration, starts metrics and the discovery registry, then blocks until stopped.
|
||||
func (i *Imp) Start() error {
|
||||
i.initStopChannels()
|
||||
defer i.closeDone()
|
||||
@@ -73,6 +75,7 @@ func (i *Imp) Start() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Shutdown gracefully stops the discovery service and its metrics server.
|
||||
func (i *Imp) Shutdown() {
|
||||
timeout := i.shutdownTimeout()
|
||||
i.logger.Info("Stopping discovery service", zap.Duration("timeout", timeout))
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
)
|
||||
|
||||
// Imp is the concrete implementation of the discovery server application.
|
||||
type Imp struct {
|
||||
logger mlogger.Logger
|
||||
file string
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Package server provides the discovery service application factory.
|
||||
package server
|
||||
|
||||
import (
|
||||
@@ -6,6 +7,8 @@ import (
|
||||
"github.com/tech/sendico/pkg/server"
|
||||
)
|
||||
|
||||
// Create initialises and returns a new discovery server application.
|
||||
//
|
||||
//nolint:ireturn // factory returns interface by design
|
||||
func Create(logger mlogger.Logger, file string, debug bool) (server.Application, error) {
|
||||
return serverimp.Create(logger, file, debug) //nolint:wrapcheck
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Package main is the entry point for the discovery service.
|
||||
package main
|
||||
|
||||
import (
|
||||
|
||||
@@ -25,7 +25,7 @@ require (
|
||||
|
||||
require (
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260215031811-a0ab0b218a81 // indirect
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260218040609-6f1c0c95351b // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bits-and-blooms/bitset v1.24.4 // indirect
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect
|
||||
|
||||
@@ -6,8 +6,8 @@ github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ=
|
||||
github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260215031811-a0ab0b218a81 h1:TBzelXBdnzDy+HCrBMcomEnhrmigkWOI1/mIPCi2u4M=
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260215031811-a0ab0b218a81/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260218040609-6f1c0c95351b h1:RVnS+OZmBJbbNeqejAksq3Mxc73y0IEzyTUHPPWZuj8=
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260218040609-6f1c0c95351b/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
|
||||
github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0=
|
||||
github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
|
||||
@@ -222,6 +222,7 @@ func (s *Service) startDiscoveryAnnouncers() {
|
||||
}
|
||||
}
|
||||
announce := discovery.Announcement{
|
||||
ID: discovery.StableCryptoRailGatewayID(string(network.Name)),
|
||||
Service: "CRYPTO_RAIL_GATEWAY",
|
||||
Rail: "CRYPTO",
|
||||
Operations: []string{"balance.read", "payin.crypto", "payout.crypto", "fee.send", "observe.confirm"},
|
||||
|
||||
@@ -163,6 +163,9 @@ func (s *Service) startDiscoveryAnnouncer() {
|
||||
}
|
||||
announce.Currencies = currenciesFromDescriptor(s.gatewayDescriptor)
|
||||
}
|
||||
if strings.TrimSpace(announce.ID) == "" {
|
||||
announce.ID = "card_payout_rail_gateway"
|
||||
}
|
||||
s.announcer = discovery.NewAnnouncer(s.logger, s.producer, string(mservice.MntxGateway), announce)
|
||||
s.announcer.Start()
|
||||
}
|
||||
|
||||
@@ -508,6 +508,7 @@ func (s *Service) startAnnouncer() {
|
||||
caps = append(caps, "confirmations."+strings.ToLower(string(mservice.PaymentGateway))+"."+strings.ToLower(s.rail))
|
||||
}
|
||||
announce := discovery.Announcement{
|
||||
ID: discovery.StablePaymentGatewayID(s.rail),
|
||||
Service: string(mservice.PaymentGateway),
|
||||
Rail: s.rail,
|
||||
Operations: caps,
|
||||
|
||||
@@ -27,7 +27,7 @@ require (
|
||||
|
||||
require (
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260215031811-a0ab0b218a81 // indirect
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260218040609-6f1c0c95351b // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bits-and-blooms/bitset v1.24.4 // indirect
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect
|
||||
|
||||
@@ -6,8 +6,8 @@ github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ=
|
||||
github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260215031811-a0ab0b218a81 h1:TBzelXBdnzDy+HCrBMcomEnhrmigkWOI1/mIPCi2u4M=
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260215031811-a0ab0b218a81/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260218040609-6f1c0c95351b h1:RVnS+OZmBJbbNeqejAksq3Mxc73y0IEzyTUHPPWZuj8=
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260218040609-6f1c0c95351b/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
|
||||
github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0=
|
||||
github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
|
||||
@@ -227,6 +227,7 @@ func (s *Service) startDiscoveryAnnouncers() {
|
||||
}
|
||||
}
|
||||
announce := discovery.Announcement{
|
||||
ID: discovery.StableCryptoRailGatewayID(network.Name.String()),
|
||||
Service: "CRYPTO_RAIL_GATEWAY",
|
||||
Rail: "CRYPTO",
|
||||
Operations: []string{"balance.read", "payin.crypto", "payout.crypto", "fee.send", "observe.confirm"},
|
||||
|
||||
@@ -97,7 +97,6 @@ func (o *outboxStore) ListPending(ctx context.Context, limit int) ([]*model.Outb
|
||||
return nil, err
|
||||
}
|
||||
|
||||
o.logger.Debug("Listed pending outbox events", zap.Int("count", len(events)))
|
||||
return events, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -155,16 +155,16 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 h1:UP6IpuHFkUgOQL9FFQFrZ+5LiwhhYRbi7VZSIx6Nj5s=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM=
|
||||
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
||||
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
||||
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
|
||||
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
|
||||
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
|
||||
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
|
||||
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
|
||||
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
||||
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
||||
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
||||
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
|
||||
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
|
||||
@@ -47,10 +47,11 @@ func (r *compositeGatewayRegistry) List(ctx context.Context) ([]*model.GatewayIn
|
||||
continue
|
||||
}
|
||||
for _, entry := range list {
|
||||
if entry == nil || entry.ID == "" {
|
||||
key := model.GatewayDescriptorIdentityKey(entry)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
items[entry.ID] = entry
|
||||
items[key] = entry
|
||||
}
|
||||
}
|
||||
result := make([]*model.GatewayInstanceDescriptor, 0, len(items))
|
||||
@@ -58,7 +59,7 @@ func (r *compositeGatewayRegistry) List(ctx context.Context) ([]*model.GatewayIn
|
||||
result = append(result, entry)
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
return result[i].ID < result[j].ID
|
||||
return model.LessGatewayDescriptor(result[i], result[j])
|
||||
})
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ func (r *discoveryGatewayRegistry) List(_ context.Context) ([]*model.GatewayInst
|
||||
})
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
return items[i].ID < items[j].ID
|
||||
return model.LessGatewayDescriptor(items[i], items[j])
|
||||
})
|
||||
return items, nil
|
||||
}
|
||||
|
||||
@@ -31,14 +31,11 @@ func NewGatewayRegistry(logger mlogger.Logger, static []*model.GatewayInstanceDe
|
||||
func (r *gatewayRegistry) List(ctx context.Context) ([]*model.GatewayInstanceDescriptor, error) {
|
||||
items := map[string]*model.GatewayInstanceDescriptor{}
|
||||
for _, gw := range r.static {
|
||||
if gw == nil {
|
||||
key := model.GatewayDescriptorIdentityKey(gw)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
id := strings.TrimSpace(gw.ID)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
items[id] = cloneGatewayDescriptor(gw)
|
||||
items[key] = cloneGatewayDescriptor(gw)
|
||||
}
|
||||
|
||||
result := make([]*model.GatewayInstanceDescriptor, 0, len(items))
|
||||
@@ -46,7 +43,7 @@ func (r *gatewayRegistry) List(ctx context.Context) ([]*model.GatewayInstanceDes
|
||||
result = append(result, gw)
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
return result[i].ID < result[j].ID
|
||||
return model.LessGatewayDescriptor(result[i], result[j])
|
||||
})
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
)
|
||||
|
||||
type identityGatewayRegistryStub struct {
|
||||
items []*model.GatewayInstanceDescriptor
|
||||
}
|
||||
|
||||
func (s identityGatewayRegistryStub) List(context.Context) ([]*model.GatewayInstanceDescriptor, error) {
|
||||
return s.items, nil
|
||||
}
|
||||
|
||||
func TestGatewayRegistry_ListKeepsDistinctInstancesPerGatewayID(t *testing.T) {
|
||||
registry := NewGatewayRegistry(nil, []*model.GatewayInstanceDescriptor{
|
||||
{ID: "crypto_rail_gateway_tron", InstanceID: "inst-b", InvokeURI: "grpc://b"},
|
||||
{ID: "crypto_rail_gateway_tron", InstanceID: "inst-a", InvokeURI: "grpc://a"},
|
||||
{ID: "crypto_rail_gateway_tron", InstanceID: "inst-a", InvokeURI: "grpc://a-new"},
|
||||
})
|
||||
if registry == nil {
|
||||
t.Fatalf("expected registry to be created")
|
||||
}
|
||||
|
||||
items, err := registry.List(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got, want := len(items), 2; got != want {
|
||||
t.Fatalf("unexpected items count: got=%d want=%d", got, want)
|
||||
}
|
||||
if got, want := items[0].InstanceID, "inst-a"; got != want {
|
||||
t.Fatalf("unexpected first instance id: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := items[0].InvokeURI, "grpc://a-new"; got != want {
|
||||
t.Fatalf("expected latest duplicate to win for same gateway+instance: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := items[1].InstanceID, "inst-b"; got != want {
|
||||
t.Fatalf("unexpected second instance id: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompositeGatewayRegistry_ListKeepsDistinctInstancesPerGatewayID(t *testing.T) {
|
||||
registry := NewCompositeGatewayRegistry(nil,
|
||||
identityGatewayRegistryStub{items: []*model.GatewayInstanceDescriptor{
|
||||
{ID: "crypto_rail_gateway_tron", InstanceID: "inst-b", InvokeURI: "grpc://b"},
|
||||
}},
|
||||
identityGatewayRegistryStub{items: []*model.GatewayInstanceDescriptor{
|
||||
{ID: "crypto_rail_gateway_tron", InstanceID: "inst-a", InvokeURI: "grpc://a"},
|
||||
}},
|
||||
)
|
||||
if registry == nil {
|
||||
t.Fatalf("expected registry to be created")
|
||||
}
|
||||
|
||||
items, err := registry.List(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got, want := len(items), 2; got != want {
|
||||
t.Fatalf("unexpected items count: got=%d want=%d", got, want)
|
||||
}
|
||||
if got, want := items[0].InstanceID, "inst-a"; got != want {
|
||||
t.Fatalf("unexpected first instance id: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := items[1].InstanceID, "inst-b"; got != want {
|
||||
t.Fatalf("unexpected second instance id: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -90,9 +90,6 @@ func selectGatewayForActions(ctx context.Context, registry GatewayRegistry, rail
|
||||
if entry.Rail != rail {
|
||||
continue
|
||||
}
|
||||
if instanceID != "" && !strings.EqualFold(strings.TrimSpace(entry.InstanceID), strings.TrimSpace(instanceID)) {
|
||||
continue
|
||||
}
|
||||
ok := true
|
||||
for _, action := range actions {
|
||||
if err := isGatewayEligible(entry, rail, network, currency, action, dir, amt); err != nil {
|
||||
@@ -116,6 +113,13 @@ func selectGatewayForActions(ctx context.Context, registry GatewayRegistry, rail
|
||||
sort.Slice(eligible, func(i, j int) bool {
|
||||
return eligible[i].ID < eligible[j].ID
|
||||
})
|
||||
if instanceID != "" {
|
||||
for _, entry := range eligible {
|
||||
if strings.EqualFold(strings.TrimSpace(entry.InstanceID), strings.TrimSpace(instanceID)) {
|
||||
return entry, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return eligible[0], nil
|
||||
}
|
||||
|
||||
|
||||
@@ -131,12 +131,6 @@ func (g railGatewayDependency) resolveDynamic(ctx context.Context, step *model.P
|
||||
if entry.Rail != step.Rail {
|
||||
continue
|
||||
}
|
||||
if step.GatewayID != "" && entry.ID != step.GatewayID {
|
||||
continue
|
||||
}
|
||||
if step.InstanceID != "" && !strings.EqualFold(strings.TrimSpace(entry.InstanceID), strings.TrimSpace(step.InstanceID)) {
|
||||
continue
|
||||
}
|
||||
if step.Action != model.RailOperationUnspecified {
|
||||
if err := isGatewayEligible(entry, step.Rail, "", currency, step.Action, sendDirectionForRail(step.Rail), amount); err != nil {
|
||||
lastErr = err
|
||||
@@ -152,13 +146,38 @@ func (g railGatewayDependency) resolveDynamic(ctx context.Context, step *model.P
|
||||
return nil, merrors.InvalidArgument("rail gateway: missing gateway for rail")
|
||||
}
|
||||
sort.Slice(candidates, func(i, j int) bool {
|
||||
return candidates[i].ID < candidates[j].ID
|
||||
return model.LessGatewayDescriptor(candidates[i], candidates[j])
|
||||
})
|
||||
entry := candidates[0]
|
||||
entry, selectionMode := model.SelectGatewayByPreference(
|
||||
candidates,
|
||||
step.GatewayID,
|
||||
step.InstanceID,
|
||||
step.GatewayInvokeURI,
|
||||
)
|
||||
if entry == nil {
|
||||
entry = candidates[0]
|
||||
selectionMode = "rail_fallback"
|
||||
}
|
||||
invokeURI := strings.TrimSpace(entry.InvokeURI)
|
||||
if invokeURI == "" {
|
||||
return nil, merrors.InvalidArgument("rail gateway: invoke uri is required")
|
||||
}
|
||||
originalGatewayID := strings.TrimSpace(step.GatewayID)
|
||||
originalInstanceID := strings.TrimSpace(step.InstanceID)
|
||||
originalInvokeURI := strings.TrimSpace(step.GatewayInvokeURI)
|
||||
step.GatewayID = strings.TrimSpace(entry.ID)
|
||||
step.InstanceID = strings.TrimSpace(entry.InstanceID)
|
||||
step.GatewayInvokeURI = invokeURI
|
||||
g.logger.Debug("Rail gateway candidate selected",
|
||||
zap.String("step_id", strings.TrimSpace(step.StepID)),
|
||||
zap.String("selection_mode", selectionMode),
|
||||
zap.String("requested_gateway_id", originalGatewayID),
|
||||
zap.String("requested_instance_id", originalInstanceID),
|
||||
zap.String("requested_invoke_uri", originalInvokeURI),
|
||||
zap.String("resolved_gateway_id", step.GatewayID),
|
||||
zap.String("resolved_instance_id", step.InstanceID),
|
||||
zap.String("resolved_invoke_uri", step.GatewayInvokeURI),
|
||||
)
|
||||
|
||||
cfg := chainclient.RailGatewayConfig{
|
||||
Rail: string(entry.Rail),
|
||||
@@ -174,9 +193,22 @@ func (g railGatewayDependency) resolveDynamic(ctx context.Context, step *model.P
|
||||
},
|
||||
}
|
||||
|
||||
if selectionMode != "exact" && (originalGatewayID != "" || originalInstanceID != "" || originalInvokeURI != "") {
|
||||
g.logger.Warn("Rail gateway identity fallback applied",
|
||||
zap.String("step_id", strings.TrimSpace(step.StepID)),
|
||||
zap.String("selection_mode", selectionMode),
|
||||
zap.String("requested_gateway_id", originalGatewayID),
|
||||
zap.String("requested_instance_id", originalInstanceID),
|
||||
zap.String("requested_invoke_uri", originalInvokeURI),
|
||||
zap.String("resolved_gateway_id", step.GatewayID),
|
||||
zap.String("resolved_instance_id", step.InstanceID),
|
||||
zap.String("resolved_invoke_uri", step.GatewayInvokeURI),
|
||||
)
|
||||
}
|
||||
g.logger.Info("Rail gateway resolved",
|
||||
zap.String("step_id", strings.TrimSpace(step.StepID)),
|
||||
zap.String("action", string(step.Action)),
|
||||
zap.String("selection_mode", selectionMode),
|
||||
zap.String("gateway_id", entry.ID),
|
||||
zap.String("instance_id", entry.InstanceID),
|
||||
zap.String("rail", string(entry.Rail)),
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
chainclient "github.com/tech/sendico/gateway/chain/client"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type optionsGatewayRegistryStub struct {
|
||||
items []*model.GatewayInstanceDescriptor
|
||||
}
|
||||
|
||||
func (s optionsGatewayRegistryStub) List(context.Context) ([]*model.GatewayInstanceDescriptor, error) {
|
||||
return s.items, nil
|
||||
}
|
||||
|
||||
type optionsInvokeResolverStub struct {
|
||||
uris []string
|
||||
}
|
||||
|
||||
func (s *optionsInvokeResolverStub) Resolve(_ context.Context, invokeURI string) (chainclient.Client, error) {
|
||||
s.uris = append(s.uris, invokeURI)
|
||||
return &chainclient.Fake{}, nil
|
||||
}
|
||||
|
||||
func TestResolveDynamicGateway_FallsBackToInvokeURI(t *testing.T) {
|
||||
resolver := &optionsInvokeResolverStub{}
|
||||
deps := railGatewayDependency{
|
||||
registry: optionsGatewayRegistryStub{items: []*model.GatewayInstanceDescriptor{
|
||||
{
|
||||
ID: "aaa",
|
||||
InstanceID: "inst-a",
|
||||
Rail: model.RailCrypto,
|
||||
Network: "TRON",
|
||||
InvokeURI: "grpc://gw-a:50051",
|
||||
Currencies: []string{"USDT"},
|
||||
Capabilities: model.RailCapabilities{
|
||||
CanPayOut: true,
|
||||
},
|
||||
IsEnabled: true,
|
||||
},
|
||||
{
|
||||
ID: "bbb",
|
||||
InstanceID: "inst-b",
|
||||
Rail: model.RailCrypto,
|
||||
Network: "TRON",
|
||||
InvokeURI: "grpc://gw-b:50051",
|
||||
Currencies: []string{"USDT"},
|
||||
Capabilities: model.RailCapabilities{
|
||||
CanPayOut: true,
|
||||
},
|
||||
IsEnabled: true,
|
||||
},
|
||||
}},
|
||||
chainResolver: resolver,
|
||||
logger: zap.NewNop(),
|
||||
}
|
||||
step := &model.PaymentStep{
|
||||
StepID: "crypto.send",
|
||||
Rail: model.RailCrypto,
|
||||
Action: model.RailOperationSend,
|
||||
GatewayID: "legacy-id",
|
||||
InstanceID: "legacy-instance",
|
||||
GatewayInvokeURI: "grpc://gw-b:50051",
|
||||
Amount: &paymenttypes.Money{Currency: "USDT", Amount: "1"},
|
||||
}
|
||||
|
||||
if _, err := deps.resolveDynamic(context.Background(), step); err != nil {
|
||||
t.Fatalf("resolveDynamic returned error: %v", err)
|
||||
}
|
||||
if got, want := step.GatewayID, "bbb"; got != want {
|
||||
t.Fatalf("unexpected gateway_id: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := step.InstanceID, "inst-b"; got != want {
|
||||
t.Fatalf("unexpected instance_id: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := step.GatewayInvokeURI, "grpc://gw-b:50051"; got != want {
|
||||
t.Fatalf("unexpected gateway_invoke_uri: got=%q want=%q", got, want)
|
||||
}
|
||||
if len(resolver.uris) != 1 || resolver.uris[0] != "grpc://gw-b:50051" {
|
||||
t.Fatalf("unexpected resolver invocations: %#v", resolver.uris)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDynamicGateway_FallsBackToGatewayIDWhenInstanceChanges(t *testing.T) {
|
||||
resolver := &optionsInvokeResolverStub{}
|
||||
deps := railGatewayDependency{
|
||||
registry: optionsGatewayRegistryStub{items: []*model.GatewayInstanceDescriptor{
|
||||
{
|
||||
ID: "aaa",
|
||||
InstanceID: "inst-a",
|
||||
Rail: model.RailCrypto,
|
||||
Network: "TRON",
|
||||
InvokeURI: "grpc://gw-a:50051",
|
||||
Currencies: []string{"USDT"},
|
||||
Capabilities: model.RailCapabilities{
|
||||
CanPayOut: true,
|
||||
},
|
||||
IsEnabled: true,
|
||||
},
|
||||
{
|
||||
ID: "crypto_rail_gateway_tron",
|
||||
InstanceID: "inst-new",
|
||||
Rail: model.RailCrypto,
|
||||
Network: "TRON",
|
||||
InvokeURI: "grpc://gw-tron:50051",
|
||||
Currencies: []string{"USDT"},
|
||||
Capabilities: model.RailCapabilities{
|
||||
CanPayOut: true,
|
||||
},
|
||||
IsEnabled: true,
|
||||
},
|
||||
}},
|
||||
chainResolver: resolver,
|
||||
logger: zap.NewNop(),
|
||||
}
|
||||
step := &model.PaymentStep{
|
||||
StepID: "crypto.send",
|
||||
Rail: model.RailCrypto,
|
||||
Action: model.RailOperationSend,
|
||||
GatewayID: "crypto_rail_gateway_tron",
|
||||
InstanceID: "inst-old",
|
||||
Amount: &paymenttypes.Money{Currency: "USDT", Amount: "1"},
|
||||
}
|
||||
|
||||
if _, err := deps.resolveDynamic(context.Background(), step); err != nil {
|
||||
t.Fatalf("resolveDynamic returned error: %v", err)
|
||||
}
|
||||
if got, want := step.GatewayID, "crypto_rail_gateway_tron"; got != want {
|
||||
t.Fatalf("unexpected gateway_id: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := step.InstanceID, "inst-new"; got != want {
|
||||
t.Fatalf("unexpected instance_id: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := step.GatewayInvokeURI, "grpc://gw-tron:50051"; got != want {
|
||||
t.Fatalf("unexpected gateway_invoke_uri: got=%q want=%q", got, want)
|
||||
}
|
||||
if len(resolver.uris) != 1 || resolver.uris[0] != "grpc://gw-tron:50051" {
|
||||
t.Fatalf("unexpected resolver invocations: %#v", resolver.uris)
|
||||
}
|
||||
}
|
||||
@@ -43,17 +43,19 @@ func cloneStoredPaymentPlan(src *model.PaymentPlan) *model.PaymentPlan {
|
||||
continue
|
||||
}
|
||||
stepClone := &model.PaymentStep{
|
||||
StepID: strings.TrimSpace(step.StepID),
|
||||
Rail: step.Rail,
|
||||
GatewayID: strings.TrimSpace(step.GatewayID),
|
||||
InstanceID: strings.TrimSpace(step.InstanceID),
|
||||
Action: step.Action,
|
||||
DependsOn: cloneStringList(step.DependsOn),
|
||||
CommitPolicy: step.CommitPolicy,
|
||||
CommitAfter: cloneStringList(step.CommitAfter),
|
||||
Amount: cloneMoney(step.Amount),
|
||||
FromRole: cloneAccountRole(step.FromRole),
|
||||
ToRole: cloneAccountRole(step.ToRole),
|
||||
StepID: strings.TrimSpace(step.StepID),
|
||||
Rail: step.Rail,
|
||||
GatewayID: strings.TrimSpace(step.GatewayID),
|
||||
InstanceID: strings.TrimSpace(step.InstanceID),
|
||||
GatewayInvokeURI: strings.TrimSpace(step.GatewayInvokeURI),
|
||||
Action: step.Action,
|
||||
ReportVisibility: step.ReportVisibility,
|
||||
DependsOn: cloneStringList(step.DependsOn),
|
||||
CommitPolicy: step.CommitPolicy,
|
||||
CommitAfter: cloneStringList(step.CommitAfter),
|
||||
Amount: cloneMoney(step.Amount),
|
||||
FromRole: cloneAccountRole(step.FromRole),
|
||||
ToRole: cloneAccountRole(step.ToRole),
|
||||
}
|
||||
clone.Steps = append(clone.Steps, stepClone)
|
||||
}
|
||||
|
||||
@@ -107,9 +107,6 @@ func selectGateway(ctx context.Context, registry GatewayRegistry, rail model.Rai
|
||||
eligible := make([]*model.GatewayInstanceDescriptor, 0)
|
||||
var lastErr error
|
||||
for _, gw := range all {
|
||||
if instanceID != "" && !strings.EqualFold(strings.TrimSpace(gw.InstanceID), instanceID) {
|
||||
continue
|
||||
}
|
||||
if err := isGatewayEligible(gw, rail, network, currency, action, dir, amt); err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
@@ -125,6 +122,13 @@ func selectGateway(ctx context.Context, registry GatewayRegistry, rail model.Rai
|
||||
sort.Slice(eligible, func(i, j int) bool {
|
||||
return eligible[i].ID < eligible[j].ID
|
||||
})
|
||||
if instanceID != "" {
|
||||
for _, gw := range eligible {
|
||||
if strings.EqualFold(strings.TrimSpace(gw.InstanceID), instanceID) {
|
||||
return gw, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return eligible[0], nil
|
||||
}
|
||||
|
||||
|
||||
@@ -14,11 +14,12 @@ func buildFXConversionPlan(payment *model.Payment) (*model.PaymentPlan, error) {
|
||||
return nil, merrors.InvalidArgument("plan builder: payment is required")
|
||||
}
|
||||
step := &model.PaymentStep{
|
||||
StepID: "fx_convert",
|
||||
Rail: model.RailLedger,
|
||||
Action: model.RailOperationFXConvert,
|
||||
CommitPolicy: model.CommitPolicyImmediate,
|
||||
Amount: cloneMoney(payment.Intent.Amount),
|
||||
StepID: "fx_convert",
|
||||
Rail: model.RailLedger,
|
||||
Action: model.RailOperationFXConvert,
|
||||
ReportVisibility: model.ReportVisibilityUser,
|
||||
CommitPolicy: model.CommitPolicyImmediate,
|
||||
Amount: cloneMoney(payment.Intent.Amount),
|
||||
}
|
||||
return &model.PaymentPlan{
|
||||
ID: payment.PaymentRef,
|
||||
|
||||
@@ -131,6 +131,7 @@ func (b *defaultPlanBuilder) buildPlanFromTemplate(ctx context.Context, payment
|
||||
StepID: stepID,
|
||||
Rail: tpl.Rail,
|
||||
Action: action,
|
||||
ReportVisibility: tpl.ReportVisibility,
|
||||
DependsOn: cloneStringList(tpl.DependsOn),
|
||||
CommitPolicy: policy,
|
||||
CommitAfter: cloneStringList(tpl.CommitAfter),
|
||||
@@ -178,6 +179,7 @@ func (b *defaultPlanBuilder) buildPlanFromTemplate(ctx context.Context, payment
|
||||
}
|
||||
step.GatewayID = strings.TrimSpace(gw.ID)
|
||||
step.InstanceID = strings.TrimSpace(gw.InstanceID)
|
||||
step.GatewayInvokeURI = strings.TrimSpace(gw.InvokeURI)
|
||||
}
|
||||
|
||||
logger.Debug("Plan step added",
|
||||
|
||||
@@ -155,6 +155,12 @@ func validatePlanTemplate(logger mlogger.Logger, template *model.PaymentPlanTemp
|
||||
zap.Int("step_index", idx))
|
||||
return merrors.InvalidArgument("plan builder: plan template operation is required")
|
||||
}
|
||||
if !model.IsValidReportVisibility(step.ReportVisibility) {
|
||||
logger.Warn("Plan template step has invalid report visibility",
|
||||
zap.String("step_id", id),
|
||||
zap.String("report_visibility", string(step.ReportVisibility)))
|
||||
return merrors.InvalidArgument("plan builder: plan template report visibility is invalid")
|
||||
}
|
||||
action, err := actionForOperation(step.Operation)
|
||||
if err != nil {
|
||||
logger.Warn("Plan template step has invalid operation", zap.String("step_id", id),
|
||||
|
||||
@@ -155,16 +155,16 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 h1:UP6IpuHFkUgOQL9FFQFrZ+5LiwhhYRbi7VZSIx6Nj5s=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM=
|
||||
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
||||
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
||||
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
|
||||
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
|
||||
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
|
||||
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
|
||||
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
|
||||
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
||||
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
||||
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
||||
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
|
||||
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
|
||||
@@ -107,9 +107,6 @@ func selectGateway(ctx context.Context, registry GatewayRegistry, rail model.Rai
|
||||
eligible := make([]*model.GatewayInstanceDescriptor, 0)
|
||||
var lastErr error
|
||||
for _, gw := range all {
|
||||
if instanceID != "" && !strings.EqualFold(strings.TrimSpace(gw.InstanceID), instanceID) {
|
||||
continue
|
||||
}
|
||||
if err := isGatewayEligible(gw, rail, network, currency, action, dir, amt); err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
@@ -125,6 +122,13 @@ func selectGateway(ctx context.Context, registry GatewayRegistry, rail model.Rai
|
||||
sort.Slice(eligible, func(i, j int) bool {
|
||||
return eligible[i].ID < eligible[j].ID
|
||||
})
|
||||
if instanceID != "" {
|
||||
for _, gw := range eligible {
|
||||
if strings.EqualFold(strings.TrimSpace(gw.InstanceID), instanceID) {
|
||||
return gw, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return eligible[0], nil
|
||||
}
|
||||
|
||||
|
||||
@@ -14,11 +14,12 @@ func buildFXConversionPlan(payment *model.Payment) (*model.PaymentPlan, error) {
|
||||
return nil, merrors.InvalidArgument("plan builder: payment is required")
|
||||
}
|
||||
step := &model.PaymentStep{
|
||||
StepID: "fx_convert",
|
||||
Rail: model.RailLedger,
|
||||
Action: model.RailOperationFXConvert,
|
||||
CommitPolicy: model.CommitPolicyImmediate,
|
||||
Amount: cloneMoney(payment.Intent.Amount),
|
||||
StepID: "fx_convert",
|
||||
Rail: model.RailLedger,
|
||||
Action: model.RailOperationFXConvert,
|
||||
ReportVisibility: model.ReportVisibilityUser,
|
||||
CommitPolicy: model.CommitPolicyImmediate,
|
||||
Amount: cloneMoney(payment.Intent.Amount),
|
||||
}
|
||||
return &model.PaymentPlan{
|
||||
ID: payment.PaymentRef,
|
||||
|
||||
@@ -132,6 +132,7 @@ func (b *defaultBuilder) buildPlanFromTemplate(ctx context.Context, payment *mod
|
||||
StepID: stepID,
|
||||
Rail: tpl.Rail,
|
||||
Action: action,
|
||||
ReportVisibility: tpl.ReportVisibility,
|
||||
DependsOn: cloneStringList(tpl.DependsOn),
|
||||
CommitPolicy: policy,
|
||||
CommitAfter: cloneStringList(tpl.CommitAfter),
|
||||
@@ -179,6 +180,7 @@ func (b *defaultBuilder) buildPlanFromTemplate(ctx context.Context, payment *mod
|
||||
}
|
||||
step.GatewayID = strings.TrimSpace(gw.ID)
|
||||
step.InstanceID = strings.TrimSpace(gw.InstanceID)
|
||||
step.GatewayInvokeURI = strings.TrimSpace(gw.InvokeURI)
|
||||
}
|
||||
|
||||
logger.Debug("Plan step added",
|
||||
|
||||
@@ -154,6 +154,12 @@ func validatePlanTemplate(logger mlogger.Logger, template *model.PaymentPlanTemp
|
||||
zap.Int("step_index", idx))
|
||||
return merrors.InvalidArgument("plan builder: plan template operation is required")
|
||||
}
|
||||
if !model.IsValidReportVisibility(step.ReportVisibility) {
|
||||
logger.Warn("Plan template step has invalid report visibility",
|
||||
zap.String("step_id", id),
|
||||
zap.String("report_visibility", string(step.ReportVisibility)))
|
||||
return merrors.InvalidArgument("plan builder: plan template report visibility is invalid")
|
||||
}
|
||||
action, err := actionForOperation(step.Operation)
|
||||
if err != nil {
|
||||
logger.Warn("Plan template step has invalid operation", zap.String("step_id", id),
|
||||
|
||||
@@ -47,10 +47,11 @@ func (r *compositeGatewayRegistry) List(ctx context.Context) ([]*model.GatewayIn
|
||||
continue
|
||||
}
|
||||
for _, entry := range list {
|
||||
if entry == nil || entry.ID == "" {
|
||||
key := model.GatewayDescriptorIdentityKey(entry)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
items[entry.ID] = entry
|
||||
items[key] = entry
|
||||
}
|
||||
}
|
||||
result := make([]*model.GatewayInstanceDescriptor, 0, len(items))
|
||||
@@ -58,7 +59,7 @@ func (r *compositeGatewayRegistry) List(ctx context.Context) ([]*model.GatewayIn
|
||||
result = append(result, entry)
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
return result[i].ID < result[j].ID
|
||||
return model.LessGatewayDescriptor(result[i], result[j])
|
||||
})
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ func (r *discoveryGatewayRegistry) List(_ context.Context) ([]*model.GatewayInst
|
||||
})
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
return items[i].ID < items[j].ID
|
||||
return model.LessGatewayDescriptor(items[i], items[j])
|
||||
})
|
||||
return items, nil
|
||||
}
|
||||
|
||||
@@ -31,14 +31,11 @@ func NewGatewayRegistry(logger mlogger.Logger, static []*model.GatewayInstanceDe
|
||||
func (r *gatewayRegistry) List(ctx context.Context) ([]*model.GatewayInstanceDescriptor, error) {
|
||||
items := map[string]*model.GatewayInstanceDescriptor{}
|
||||
for _, gw := range r.static {
|
||||
if gw == nil {
|
||||
key := model.GatewayDescriptorIdentityKey(gw)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
id := strings.TrimSpace(gw.ID)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
items[id] = cloneGatewayDescriptor(gw)
|
||||
items[key] = cloneGatewayDescriptor(gw)
|
||||
}
|
||||
|
||||
result := make([]*model.GatewayInstanceDescriptor, 0, len(items))
|
||||
@@ -46,7 +43,7 @@ func (r *gatewayRegistry) List(ctx context.Context) ([]*model.GatewayInstanceDes
|
||||
result = append(result, gw)
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
return result[i].ID < result[j].ID
|
||||
return model.LessGatewayDescriptor(result[i], result[j])
|
||||
})
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package quotation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
)
|
||||
|
||||
type identityGatewayRegistryStub struct {
|
||||
items []*model.GatewayInstanceDescriptor
|
||||
}
|
||||
|
||||
func (s identityGatewayRegistryStub) List(context.Context) ([]*model.GatewayInstanceDescriptor, error) {
|
||||
return s.items, nil
|
||||
}
|
||||
|
||||
func TestGatewayRegistry_ListKeepsDistinctInstancesPerGatewayID(t *testing.T) {
|
||||
registry := NewGatewayRegistry(nil, []*model.GatewayInstanceDescriptor{
|
||||
{ID: "crypto_rail_gateway_tron", InstanceID: "inst-b", InvokeURI: "grpc://b"},
|
||||
{ID: "crypto_rail_gateway_tron", InstanceID: "inst-a", InvokeURI: "grpc://a"},
|
||||
{ID: "crypto_rail_gateway_tron", InstanceID: "inst-a", InvokeURI: "grpc://a-new"},
|
||||
})
|
||||
if registry == nil {
|
||||
t.Fatalf("expected registry to be created")
|
||||
}
|
||||
|
||||
items, err := registry.List(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got, want := len(items), 2; got != want {
|
||||
t.Fatalf("unexpected items count: got=%d want=%d", got, want)
|
||||
}
|
||||
if got, want := items[0].InstanceID, "inst-a"; got != want {
|
||||
t.Fatalf("unexpected first instance id: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := items[0].InvokeURI, "grpc://a-new"; got != want {
|
||||
t.Fatalf("expected latest duplicate to win for same gateway+instance: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := items[1].InstanceID, "inst-b"; got != want {
|
||||
t.Fatalf("unexpected second instance id: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompositeGatewayRegistry_ListKeepsDistinctInstancesPerGatewayID(t *testing.T) {
|
||||
registry := NewCompositeGatewayRegistry(nil,
|
||||
identityGatewayRegistryStub{items: []*model.GatewayInstanceDescriptor{
|
||||
{ID: "crypto_rail_gateway_tron", InstanceID: "inst-b", InvokeURI: "grpc://b"},
|
||||
}},
|
||||
identityGatewayRegistryStub{items: []*model.GatewayInstanceDescriptor{
|
||||
{ID: "crypto_rail_gateway_tron", InstanceID: "inst-a", InvokeURI: "grpc://a"},
|
||||
}},
|
||||
)
|
||||
if registry == nil {
|
||||
t.Fatalf("expected registry to be created")
|
||||
}
|
||||
|
||||
items, err := registry.List(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got, want := len(items), 2; got != want {
|
||||
t.Fatalf("unexpected items count: got=%d want=%d", got, want)
|
||||
}
|
||||
if got, want := items[0].InstanceID, "inst-a"; got != want {
|
||||
t.Fatalf("unexpected first instance id: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := items[1].InstanceID, "inst-b"; got != want {
|
||||
t.Fatalf("unexpected second instance id: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -91,9 +91,6 @@ func selectGatewayForActions(ctx context.Context, registry GatewayRegistry, rail
|
||||
if entry.Rail != rail {
|
||||
continue
|
||||
}
|
||||
if instanceID != "" && !strings.EqualFold(strings.TrimSpace(entry.InstanceID), strings.TrimSpace(instanceID)) {
|
||||
continue
|
||||
}
|
||||
ok := true
|
||||
for _, action := range actions {
|
||||
if err := isGatewayEligible(entry, rail, network, currency, action, dir, amt); err != nil {
|
||||
@@ -117,6 +114,13 @@ func selectGatewayForActions(ctx context.Context, registry GatewayRegistry, rail
|
||||
sort.Slice(eligible, func(i, j int) bool {
|
||||
return eligible[i].ID < eligible[j].ID
|
||||
})
|
||||
if instanceID != "" {
|
||||
for _, entry := range eligible {
|
||||
if strings.EqualFold(strings.TrimSpace(entry.InstanceID), strings.TrimSpace(instanceID)) {
|
||||
return entry, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return eligible[0], nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package graph_path_finder
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
)
|
||||
|
||||
func buildAdjacency(edges []Edge, network string) map[model.Rail][]normalizedEdge {
|
||||
adjacency := map[model.Rail][]normalizedEdge{}
|
||||
for _, edge := range edges {
|
||||
from := normalizeRail(edge.FromRail)
|
||||
to := normalizeRail(edge.ToRail)
|
||||
if from == model.RailUnspecified || to == model.RailUnspecified {
|
||||
continue
|
||||
}
|
||||
en := normalizeNetwork(edge.Network)
|
||||
if !matchesNetwork(en, network) {
|
||||
continue
|
||||
}
|
||||
adjacency[from] = append(adjacency[from], normalizedEdge{
|
||||
from: from,
|
||||
to: to,
|
||||
network: en,
|
||||
})
|
||||
}
|
||||
for from := range adjacency {
|
||||
sort.Slice(adjacency[from], func(i, j int) bool {
|
||||
left := adjacency[from][i]
|
||||
right := adjacency[from][j]
|
||||
if left.to != right.to {
|
||||
return left.to < right.to
|
||||
}
|
||||
lp := networkPriority(left.network, network)
|
||||
rp := networkPriority(right.network, network)
|
||||
if lp != rp {
|
||||
return lp < rp
|
||||
}
|
||||
return left.network < right.network
|
||||
})
|
||||
}
|
||||
return adjacency
|
||||
}
|
||||
|
||||
func matchesNetwork(edgeNetwork, requested string) bool {
|
||||
if requested == "" {
|
||||
return true
|
||||
}
|
||||
if edgeNetwork == "" {
|
||||
return true
|
||||
}
|
||||
return edgeNetwork == requested
|
||||
}
|
||||
|
||||
func networkPriority(edgeNetwork, requested string) int {
|
||||
if requested != "" && edgeNetwork == requested {
|
||||
return 0
|
||||
}
|
||||
if edgeNetwork == "" {
|
||||
return 1
|
||||
}
|
||||
return 2
|
||||
}
|
||||
|
||||
func normalizeRail(value model.Rail) model.Rail {
|
||||
normalized := model.Rail(strings.ToUpper(strings.TrimSpace(string(value))))
|
||||
if normalized == "" {
|
||||
return model.RailUnspecified
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func normalizeNetwork(value string) string {
|
||||
return strings.ToUpper(strings.TrimSpace(value))
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package graph_path_finder
|
||||
|
||||
import "github.com/tech/sendico/payments/storage/model"
|
||||
|
||||
// Edge describes a directed route transition between rails.
|
||||
type Edge struct {
|
||||
FromRail model.Rail
|
||||
ToRail model.Rail
|
||||
Network string
|
||||
}
|
||||
|
||||
// FindInput defines the graph query for path discovery.
|
||||
type FindInput struct {
|
||||
SourceRail model.Rail
|
||||
DestinationRail model.Rail
|
||||
Network string
|
||||
Edges []Edge
|
||||
}
|
||||
|
||||
// Path is an ordered list of rails and transitions selected by path finder.
|
||||
type Path struct {
|
||||
Rails []model.Rail
|
||||
Edges []Edge
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package graph_path_finder
|
||||
|
||||
import "github.com/tech/sendico/payments/storage/model"
|
||||
|
||||
func shortestPath(
|
||||
source model.Rail,
|
||||
destination model.Rail,
|
||||
network string,
|
||||
adjacency map[model.Rail][]normalizedEdge,
|
||||
) (*Path, bool) {
|
||||
best := map[model.Rail]score{
|
||||
source: {hops: 0, wildcardHops: 0, signature: string(source)},
|
||||
}
|
||||
parent := map[model.Rail]model.Rail{}
|
||||
parentEdge := map[model.Rail]normalizedEdge{}
|
||||
visited := map[model.Rail]bool{}
|
||||
|
||||
for {
|
||||
current, ok := nextRail(best, visited)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
if current == destination {
|
||||
break
|
||||
}
|
||||
visited[current] = true
|
||||
|
||||
currentScore := best[current]
|
||||
for _, edge := range adjacency[current] {
|
||||
candidate := score{
|
||||
hops: currentScore.hops + 1,
|
||||
wildcardHops: currentScore.wildcardHops,
|
||||
signature: currentScore.signature + ">" + string(edge.to),
|
||||
}
|
||||
if network != "" && edge.network == "" {
|
||||
candidate.wildcardHops++
|
||||
}
|
||||
|
||||
prev, seen := best[edge.to]
|
||||
if !seen || better(candidate, prev) {
|
||||
best[edge.to] = candidate
|
||||
parent[edge.to] = current
|
||||
parentEdge[edge.to] = edge
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if _, ok := best[destination]; !ok {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
rails := []model.Rail{destination}
|
||||
edges := make([]Edge, 0, len(rails))
|
||||
for cursor := destination; cursor != source; {
|
||||
prev, hasParent := parent[cursor]
|
||||
edge, hasEdge := parentEdge[cursor]
|
||||
if !hasParent || !hasEdge {
|
||||
return nil, false
|
||||
}
|
||||
rails = append(rails, prev)
|
||||
edges = append(edges, Edge{
|
||||
FromRail: edge.from,
|
||||
ToRail: edge.to,
|
||||
Network: edge.network,
|
||||
})
|
||||
cursor = prev
|
||||
}
|
||||
reverseRails(rails)
|
||||
reverseEdges(edges)
|
||||
|
||||
return &Path{
|
||||
Rails: rails,
|
||||
Edges: edges,
|
||||
}, true
|
||||
}
|
||||
|
||||
func nextRail(best map[model.Rail]score, visited map[model.Rail]bool) (model.Rail, bool) {
|
||||
selected := model.RailUnspecified
|
||||
selectedScore := score{}
|
||||
found := false
|
||||
for rail, railScore := range best {
|
||||
if visited[rail] {
|
||||
continue
|
||||
}
|
||||
if !found || better(railScore, selectedScore) || (equal(railScore, selectedScore) && rail < selected) {
|
||||
found = true
|
||||
selected = rail
|
||||
selectedScore = railScore
|
||||
}
|
||||
}
|
||||
return selected, found
|
||||
}
|
||||
|
||||
func better(left, right score) bool {
|
||||
if left.hops != right.hops {
|
||||
return left.hops < right.hops
|
||||
}
|
||||
if left.wildcardHops != right.wildcardHops {
|
||||
return left.wildcardHops < right.wildcardHops
|
||||
}
|
||||
return left.signature < right.signature
|
||||
}
|
||||
|
||||
func equal(left, right score) bool {
|
||||
return left.hops == right.hops &&
|
||||
left.wildcardHops == right.wildcardHops &&
|
||||
left.signature == right.signature
|
||||
}
|
||||
|
||||
func reverseRails(items []model.Rail) {
|
||||
for left, right := 0, len(items)-1; left < right; left, right = left+1, right-1 {
|
||||
items[left], items[right] = items[right], items[left]
|
||||
}
|
||||
}
|
||||
|
||||
func reverseEdges(items []Edge) {
|
||||
for left, right := 0, len(items)-1; left < right; left, right = left+1, right-1 {
|
||||
items[left], items[right] = items[right], items[left]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package graph_path_finder
|
||||
|
||||
import (
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
)
|
||||
|
||||
type GraphPathFinder struct{}
|
||||
|
||||
func New() *GraphPathFinder {
|
||||
return &GraphPathFinder{}
|
||||
}
|
||||
|
||||
type normalizedEdge struct {
|
||||
from model.Rail
|
||||
to model.Rail
|
||||
network string
|
||||
}
|
||||
|
||||
type score struct {
|
||||
hops int
|
||||
wildcardHops int
|
||||
signature string
|
||||
}
|
||||
|
||||
func (f *GraphPathFinder) Find(in FindInput) (*Path, error) {
|
||||
source := normalizeRail(in.SourceRail)
|
||||
destination := normalizeRail(in.DestinationRail)
|
||||
if source == model.RailUnspecified {
|
||||
return nil, merrors.InvalidArgument("source_rail is required")
|
||||
}
|
||||
if destination == model.RailUnspecified {
|
||||
return nil, merrors.InvalidArgument("destination_rail is required")
|
||||
}
|
||||
if source == destination {
|
||||
return &Path{Rails: []model.Rail{source}}, nil
|
||||
}
|
||||
|
||||
network := normalizeNetwork(in.Network)
|
||||
adjacency := buildAdjacency(in.Edges, network)
|
||||
if len(adjacency) == 0 {
|
||||
return nil, merrors.InvalidArgument("route graph has no usable edges")
|
||||
}
|
||||
|
||||
path, ok := shortestPath(source, destination, network, adjacency)
|
||||
if !ok {
|
||||
return nil, merrors.InvalidArgument("route path is unavailable")
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package graph_path_finder
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
)
|
||||
|
||||
func TestFind_NetworkFiltersEdges(t *testing.T) {
|
||||
finder := New()
|
||||
|
||||
path, err := finder.Find(FindInput{
|
||||
SourceRail: model.RailCrypto,
|
||||
DestinationRail: model.RailCardPayout,
|
||||
Network: "TRON",
|
||||
Edges: []Edge{
|
||||
{FromRail: model.RailCrypto, ToRail: model.RailLedger, Network: "ETH"},
|
||||
{FromRail: model.RailCrypto, ToRail: model.RailLedger, Network: "TRON"},
|
||||
{FromRail: model.RailLedger, ToRail: model.RailCardPayout, Network: "TRON"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
assertPathRails(t, path, []string{"CRYPTO", "LEDGER", "CARD_PAYOUT"})
|
||||
if got, want := path.Edges[0].Network, "TRON"; got != want {
|
||||
t.Fatalf("unexpected first edge network: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFind_PrefersExactNetworkOverWildcard(t *testing.T) {
|
||||
finder := New()
|
||||
|
||||
path, err := finder.Find(FindInput{
|
||||
SourceRail: model.RailCrypto,
|
||||
DestinationRail: model.RailCardPayout,
|
||||
Network: "TRON",
|
||||
Edges: []Edge{
|
||||
{FromRail: model.RailCrypto, ToRail: model.RailLedger, Network: ""},
|
||||
{FromRail: model.RailLedger, ToRail: model.RailCardPayout, Network: ""},
|
||||
{FromRail: model.RailCrypto, ToRail: model.RailProviderSettlement, Network: "TRON"},
|
||||
{FromRail: model.RailProviderSettlement, ToRail: model.RailCardPayout, Network: "TRON"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
assertPathRails(t, path, []string{"CRYPTO", "PROVIDER_SETTLEMENT", "CARD_PAYOUT"})
|
||||
}
|
||||
|
||||
func TestFind_DeterministicTieBreak(t *testing.T) {
|
||||
finder := New()
|
||||
|
||||
path, err := finder.Find(FindInput{
|
||||
SourceRail: model.RailCrypto,
|
||||
DestinationRail: model.RailCardPayout,
|
||||
Edges: []Edge{
|
||||
{FromRail: model.RailCrypto, ToRail: model.RailLedger},
|
||||
{FromRail: model.RailLedger, ToRail: model.RailCardPayout},
|
||||
{FromRail: model.RailCrypto, ToRail: model.RailProviderSettlement},
|
||||
{FromRail: model.RailProviderSettlement, ToRail: model.RailCardPayout},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Both routes have equal length; lexical tie-break chooses LEDGER branch.
|
||||
assertPathRails(t, path, []string{"CRYPTO", "LEDGER", "CARD_PAYOUT"})
|
||||
}
|
||||
|
||||
func TestFind_IgnoresInvalidEdges(t *testing.T) {
|
||||
finder := New()
|
||||
|
||||
path, err := finder.Find(FindInput{
|
||||
SourceRail: model.RailCrypto,
|
||||
DestinationRail: model.RailCardPayout,
|
||||
Edges: []Edge{
|
||||
{FromRail: model.RailUnspecified, ToRail: model.RailLedger},
|
||||
{FromRail: model.RailCrypto, ToRail: model.RailUnspecified},
|
||||
{FromRail: model.RailCrypto, ToRail: model.RailLedger},
|
||||
{FromRail: model.RailLedger, ToRail: model.RailCardPayout},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
assertPathRails(t, path, []string{"CRYPTO", "LEDGER", "CARD_PAYOUT"})
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package graph_path_finder
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
)
|
||||
|
||||
func TestFind_ValidatesInput(t *testing.T) {
|
||||
finder := New()
|
||||
|
||||
_, err := finder.Find(FindInput{
|
||||
DestinationRail: model.RailCardPayout,
|
||||
})
|
||||
if !errors.Is(err, merrors.ErrInvalidArg) {
|
||||
t.Fatalf("expected invalid argument for missing source rail, got %v", err)
|
||||
}
|
||||
|
||||
_, err = finder.Find(FindInput{
|
||||
SourceRail: model.RailCrypto,
|
||||
})
|
||||
if !errors.Is(err, merrors.ErrInvalidArg) {
|
||||
t.Fatalf("expected invalid argument for missing destination rail, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFind_SourceEqualsDestination(t *testing.T) {
|
||||
finder := New()
|
||||
|
||||
path, err := finder.Find(FindInput{
|
||||
SourceRail: model.RailCrypto,
|
||||
DestinationRail: model.RailCrypto,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if path == nil {
|
||||
t.Fatalf("expected path")
|
||||
}
|
||||
if got, want := railsToStrings(path.Rails), []string{"CRYPTO"}; !equalStrings(got, want) {
|
||||
t.Fatalf("unexpected rails: got=%v want=%v", got, want)
|
||||
}
|
||||
if len(path.Edges) != 0 {
|
||||
t.Fatalf("expected no edges for same source and destination")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFind_FindsIndirectPath(t *testing.T) {
|
||||
finder := New()
|
||||
|
||||
path, err := finder.Find(FindInput{
|
||||
SourceRail: model.RailCrypto,
|
||||
DestinationRail: model.RailCardPayout,
|
||||
Edges: []Edge{
|
||||
{FromRail: model.RailCrypto, ToRail: model.RailLedger},
|
||||
{FromRail: model.RailLedger, ToRail: model.RailCardPayout},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
assertPathRails(t, path, []string{"CRYPTO", "LEDGER", "CARD_PAYOUT"})
|
||||
if got, want := len(path.Edges), 2; got != want {
|
||||
t.Fatalf("unexpected edge count: got=%d want=%d", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFind_PrefersShortestPath(t *testing.T) {
|
||||
finder := New()
|
||||
|
||||
path, err := finder.Find(FindInput{
|
||||
SourceRail: model.RailCrypto,
|
||||
DestinationRail: model.RailCardPayout,
|
||||
Edges: []Edge{
|
||||
{FromRail: model.RailCrypto, ToRail: model.RailCardPayout},
|
||||
{FromRail: model.RailCrypto, ToRail: model.RailLedger},
|
||||
{FromRail: model.RailLedger, ToRail: model.RailCardPayout},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
assertPathRails(t, path, []string{"CRYPTO", "CARD_PAYOUT"})
|
||||
}
|
||||
|
||||
func TestFind_HandlesCycles(t *testing.T) {
|
||||
finder := New()
|
||||
|
||||
path, err := finder.Find(FindInput{
|
||||
SourceRail: model.RailCrypto,
|
||||
DestinationRail: model.RailCardPayout,
|
||||
Edges: []Edge{
|
||||
{FromRail: model.RailCrypto, ToRail: model.RailLedger},
|
||||
{FromRail: model.RailLedger, ToRail: model.RailCrypto},
|
||||
{FromRail: model.RailLedger, ToRail: model.RailCardPayout},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
assertPathRails(t, path, []string{"CRYPTO", "LEDGER", "CARD_PAYOUT"})
|
||||
}
|
||||
|
||||
func TestFind_ReturnsErrorWhenPathUnavailable(t *testing.T) {
|
||||
finder := New()
|
||||
|
||||
_, err := finder.Find(FindInput{
|
||||
SourceRail: model.RailCrypto,
|
||||
DestinationRail: model.RailCardPayout,
|
||||
Edges: []Edge{
|
||||
{FromRail: model.RailCrypto, ToRail: model.RailLedger},
|
||||
},
|
||||
})
|
||||
if !errors.Is(err, merrors.ErrInvalidArg) {
|
||||
t.Fatalf("expected invalid argument for unavailable path, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertPathRails(t *testing.T, path *Path, want []string) {
|
||||
t.Helper()
|
||||
if path == nil {
|
||||
t.Fatalf("expected non-nil path")
|
||||
}
|
||||
got := railsToStrings(path.Rails)
|
||||
if !equalStrings(got, want) {
|
||||
t.Fatalf("unexpected rails: got=%v want=%v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func railsToStrings(rails []model.Rail) []string {
|
||||
result := make([]string, 0, len(rails))
|
||||
for _, rail := range rails {
|
||||
result = append(result, string(rail))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func equalStrings(got, want []string) bool {
|
||||
if len(got) != len(want) {
|
||||
return false
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != want[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -93,17 +93,18 @@ func cloneStoredPaymentPlan(src *model.PaymentPlan) *model.PaymentPlan {
|
||||
continue
|
||||
}
|
||||
stepClone := &model.PaymentStep{
|
||||
StepID: strings.TrimSpace(step.StepID),
|
||||
Rail: step.Rail,
|
||||
GatewayID: strings.TrimSpace(step.GatewayID),
|
||||
InstanceID: strings.TrimSpace(step.InstanceID),
|
||||
Action: step.Action,
|
||||
DependsOn: cloneStringList(step.DependsOn),
|
||||
CommitPolicy: step.CommitPolicy,
|
||||
CommitAfter: cloneStringList(step.CommitAfter),
|
||||
Amount: cloneMoney(step.Amount),
|
||||
FromRole: shared.CloneAccountRole(step.FromRole),
|
||||
ToRole: shared.CloneAccountRole(step.ToRole),
|
||||
StepID: strings.TrimSpace(step.StepID),
|
||||
Rail: step.Rail,
|
||||
GatewayID: strings.TrimSpace(step.GatewayID),
|
||||
InstanceID: strings.TrimSpace(step.InstanceID),
|
||||
GatewayInvokeURI: strings.TrimSpace(step.GatewayInvokeURI),
|
||||
Action: step.Action,
|
||||
DependsOn: cloneStringList(step.DependsOn),
|
||||
CommitPolicy: step.CommitPolicy,
|
||||
CommitAfter: cloneStringList(step.CommitAfter),
|
||||
Amount: cloneMoney(step.Amount),
|
||||
FromRole: shared.CloneAccountRole(step.FromRole),
|
||||
ToRole: shared.CloneAccountRole(step.ToRole),
|
||||
}
|
||||
clone.Steps = append(clone.Steps, stepClone)
|
||||
}
|
||||
|
||||
@@ -18,34 +18,27 @@ import (
|
||||
|
||||
func statusInputFromStatus(status quote_response_mapper_v2.QuoteStatus) *quote_persistence_service.StatusInput {
|
||||
return "e_persistence_service.StatusInput{
|
||||
Kind: status.Kind,
|
||||
Lifecycle: status.Lifecycle,
|
||||
Executable: cloneBool(status.Executable),
|
||||
State: status.State,
|
||||
BlockReason: status.BlockReason,
|
||||
}
|
||||
}
|
||||
|
||||
func statusFromStored(input *model.QuoteStatusV2) quote_response_mapper_v2.QuoteStatus {
|
||||
if input == nil {
|
||||
status := quote_response_mapper_v2.QuoteStatus{
|
||||
Kind: quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE,
|
||||
Lifecycle: quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE,
|
||||
return quote_response_mapper_v2.QuoteStatus{
|
||||
State: quotationv2.QuoteState_QUOTE_STATE_EXECUTABLE,
|
||||
}
|
||||
status.Executable = boolPtr(true)
|
||||
return status
|
||||
}
|
||||
|
||||
status := quote_response_mapper_v2.QuoteStatus{
|
||||
Kind: quoteKindToProto(input.Kind),
|
||||
Lifecycle: quoteLifecycleToProto(input.Lifecycle),
|
||||
Executable: cloneBool(input.Executable),
|
||||
State: quoteStateToProto(input.State),
|
||||
BlockReason: quoteBlockReasonToProto(input.BlockReason),
|
||||
}
|
||||
if status.Kind == quotationv2.QuoteKind_QUOTE_KIND_UNSPECIFIED {
|
||||
status.Kind = quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE
|
||||
if status.State == quotationv2.QuoteState_QUOTE_STATE_UNSPECIFIED {
|
||||
status.State = quotationv2.QuoteState_QUOTE_STATE_EXECUTABLE
|
||||
}
|
||||
if status.Lifecycle == quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_UNSPECIFIED {
|
||||
status.Lifecycle = quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE
|
||||
if status.State != quotationv2.QuoteState_QUOTE_STATE_BLOCKED {
|
||||
status.BlockReason = quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED
|
||||
}
|
||||
return status
|
||||
}
|
||||
@@ -191,25 +184,18 @@ func sideToProto(side paymenttypes.FXSide) fxv1.Side {
|
||||
}
|
||||
}
|
||||
|
||||
func quoteKindToProto(kind model.QuoteKind) quotationv2.QuoteKind {
|
||||
switch kind {
|
||||
case model.QuoteKindExecutable:
|
||||
return quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE
|
||||
case model.QuoteKindIndicative:
|
||||
return quotationv2.QuoteKind_QUOTE_KIND_INDICATIVE
|
||||
func quoteStateToProto(state model.QuoteState) quotationv2.QuoteState {
|
||||
switch state {
|
||||
case model.QuoteStateIndicative:
|
||||
return quotationv2.QuoteState_QUOTE_STATE_INDICATIVE
|
||||
case model.QuoteStateExecutable:
|
||||
return quotationv2.QuoteState_QUOTE_STATE_EXECUTABLE
|
||||
case model.QuoteStateBlocked:
|
||||
return quotationv2.QuoteState_QUOTE_STATE_BLOCKED
|
||||
case model.QuoteStateExpired:
|
||||
return quotationv2.QuoteState_QUOTE_STATE_EXPIRED
|
||||
default:
|
||||
return quotationv2.QuoteKind_QUOTE_KIND_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
func quoteLifecycleToProto(lifecycle model.QuoteLifecycle) quotationv2.QuoteLifecycle {
|
||||
switch lifecycle {
|
||||
case model.QuoteLifecycleActive:
|
||||
return quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE
|
||||
case model.QuoteLifecycleExpired:
|
||||
return quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_EXPIRED
|
||||
default:
|
||||
return quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_UNSPECIFIED
|
||||
return quotationv2.QuoteState_QUOTE_STATE_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,11 +219,3 @@ func quoteBlockReasonToProto(reason model.QuoteBlockReason) quotationv2.QuoteBlo
|
||||
return quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
func cloneBool(src *bool) *bool {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
value := *src
|
||||
return &value
|
||||
}
|
||||
|
||||
@@ -7,11 +7,6 @@ import (
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
)
|
||||
|
||||
func boolPtr(value bool) *bool {
|
||||
v := value
|
||||
return &v
|
||||
}
|
||||
|
||||
func minExpiry(values []time.Time) (time.Time, bool) {
|
||||
var min time.Time
|
||||
for _, value := range values {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"strings"
|
||||
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1"
|
||||
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
|
||||
)
|
||||
|
||||
@@ -15,8 +16,7 @@ func modelRouteFromProto(src *quotationv2.RouteSpecification) *paymenttypes.Quot
|
||||
Rail: strings.TrimSpace(src.GetRail()),
|
||||
Provider: strings.TrimSpace(src.GetProvider()),
|
||||
PayoutMethod: strings.TrimSpace(src.GetPayoutMethod()),
|
||||
SettlementAsset: strings.ToUpper(strings.TrimSpace(src.GetSettlementAsset())),
|
||||
SettlementModel: strings.TrimSpace(src.GetSettlementModel()),
|
||||
Settlement: modelSettlementFromProto(src.GetSettlement()),
|
||||
Network: strings.TrimSpace(src.GetNetwork()),
|
||||
RouteRef: strings.TrimSpace(src.GetRouteRef()),
|
||||
PricingProfileRef: strings.TrimSpace(src.GetPricingProfileRef()),
|
||||
@@ -51,11 +51,10 @@ func protoRouteFromModel(src *paymenttypes.QuoteRouteSpecification) *quotationv2
|
||||
Rail: strings.TrimSpace(src.Rail),
|
||||
Provider: strings.TrimSpace(src.Provider),
|
||||
PayoutMethod: strings.TrimSpace(src.PayoutMethod),
|
||||
SettlementAsset: strings.ToUpper(strings.TrimSpace(src.SettlementAsset)),
|
||||
SettlementModel: strings.TrimSpace(src.SettlementModel),
|
||||
Network: strings.TrimSpace(src.Network),
|
||||
RouteRef: strings.TrimSpace(src.RouteRef),
|
||||
PricingProfileRef: strings.TrimSpace(src.PricingProfileRef),
|
||||
Settlement: protoSettlementFromModel(src.Settlement),
|
||||
}
|
||||
if len(src.Hops) > 0 {
|
||||
result.Hops = make([]*quotationv2.RouteHop, 0, len(src.Hops))
|
||||
@@ -79,6 +78,52 @@ func protoRouteFromModel(src *paymenttypes.QuoteRouteSpecification) *quotationv2
|
||||
return result
|
||||
}
|
||||
|
||||
func modelSettlementFromProto(src *quotationv2.RouteSettlement) *paymenttypes.QuoteRouteSettlement {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := &paymenttypes.QuoteRouteSettlement{
|
||||
Model: strings.TrimSpace(src.GetModel()),
|
||||
}
|
||||
if asset := src.GetAsset(); asset != nil {
|
||||
key := asset.GetKey()
|
||||
result.Asset = &paymenttypes.Asset{
|
||||
Chain: strings.ToUpper(strings.TrimSpace(key.GetChain())),
|
||||
TokenSymbol: strings.ToUpper(strings.TrimSpace(key.GetTokenSymbol())),
|
||||
ContractAddress: strings.TrimSpace(asset.GetContractAddress()),
|
||||
}
|
||||
}
|
||||
if result.Asset == nil && result.Model == "" {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func protoSettlementFromModel(src *paymenttypes.QuoteRouteSettlement) *quotationv2.RouteSettlement {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
result := "ationv2.RouteSettlement{
|
||||
Model: strings.TrimSpace(src.Model),
|
||||
}
|
||||
if src.Asset != nil {
|
||||
result.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 != "" {
|
||||
result.Asset.ContractAddress = &contract
|
||||
}
|
||||
}
|
||||
if result.Asset == nil && result.Model == "" {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func modelExecutionConditionsFromProto(src *quotationv2.ExecutionConditions) *paymenttypes.QuoteExecutionConditions {
|
||||
if src == nil {
|
||||
return nil
|
||||
|
||||
@@ -115,13 +115,6 @@ func (s *QuotationServiceV2) validateDependencies() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func quoteKindForPreview(previewOnly bool) quotationv2.QuoteKind {
|
||||
if previewOnly {
|
||||
return quotationv2.QuoteKind_QUOTE_KIND_INDICATIVE
|
||||
}
|
||||
return quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE
|
||||
}
|
||||
|
||||
func normalizeQuoteRef(value string) string {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
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"
|
||||
endpointv1 "github.com/tech/sendico/pkg/proto/payments/endpoint/v1"
|
||||
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
|
||||
@@ -67,14 +68,11 @@ func TestQuotePayment_USDTToRUB_EndToEnd(t *testing.T) {
|
||||
if got, want := quote.GetQuoteRef(), "quote-single-usdt-rub"; got != want {
|
||||
t.Fatalf("unexpected quote_ref: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := quote.GetKind(), quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE; got != want {
|
||||
t.Fatalf("unexpected kind: got=%s want=%s", got.String(), want.String())
|
||||
if got, want := quote.GetState(), quotationv2.QuoteState_QUOTE_STATE_EXECUTABLE; got != want {
|
||||
t.Fatalf("unexpected state: got=%s want=%s", got.String(), want.String())
|
||||
}
|
||||
if got, want := quote.GetLifecycle(), quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE; got != want {
|
||||
t.Fatalf("unexpected lifecycle: got=%s want=%s", got.String(), want.String())
|
||||
}
|
||||
if !quote.GetExecutable() {
|
||||
t.Fatalf("expected executable=true")
|
||||
if got := quote.GetBlockReason(); got != quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED {
|
||||
t.Fatalf("expected empty block reason, got=%s", got.String())
|
||||
}
|
||||
if got, want := quote.GetDebitAmount().GetAmount(), "100"; got != want {
|
||||
t.Fatalf("unexpected debit amount: got=%q want=%q", got, want)
|
||||
@@ -118,11 +116,11 @@ func TestQuotePayment_USDTToRUB_EndToEnd(t *testing.T) {
|
||||
if quote.GetRoute() == nil {
|
||||
t.Fatalf("expected route specification")
|
||||
}
|
||||
if got, want := quote.GetRoute().GetRail(), "CARD_PAYOUT"; got != want {
|
||||
t.Fatalf("unexpected route rail: got=%q want=%q", got, want)
|
||||
if got := strings.TrimSpace(quote.GetRoute().GetRail()); got != "" {
|
||||
t.Fatalf("expected route rail header to be empty, got=%q", got)
|
||||
}
|
||||
if got, want := quote.GetRoute().GetProvider(), "monetix"; got != want {
|
||||
t.Fatalf("unexpected route provider: got=%q want=%q", got, want)
|
||||
if got := strings.TrimSpace(quote.GetRoute().GetProvider()); got != "" {
|
||||
t.Fatalf("expected route provider header to be empty, got=%q", got)
|
||||
}
|
||||
if got := strings.TrimSpace(quote.GetRoute().GetRouteRef()); got == "" {
|
||||
t.Fatalf("expected route_ref")
|
||||
@@ -133,6 +131,12 @@ func TestQuotePayment_USDTToRUB_EndToEnd(t *testing.T) {
|
||||
if got, want := len(quote.GetRoute().GetHops()), 3; got != want {
|
||||
t.Fatalf("unexpected route hops count: got=%d want=%d", got, want)
|
||||
}
|
||||
if quote.GetRoute().GetSettlement() == nil || quote.GetRoute().GetSettlement().GetAsset() == nil {
|
||||
t.Fatalf("expected route settlement asset")
|
||||
}
|
||||
if got, want := quote.GetRoute().GetSettlement().GetAsset().GetKey().GetTokenSymbol(), "USDT"; got != want {
|
||||
t.Fatalf("unexpected route settlement token: got=%q want=%q", got, want)
|
||||
}
|
||||
if quote.GetExecutionConditions() == nil {
|
||||
t.Fatalf("expected execution conditions")
|
||||
}
|
||||
@@ -157,8 +161,8 @@ func TestQuotePayment_USDTToRUB_EndToEnd(t *testing.T) {
|
||||
if got, want := len(reused.Response.GetQuote().GetFeeRules()), 1; got != want {
|
||||
t.Fatalf("unexpected idempotent fee rules count: got=%d want=%d", got, want)
|
||||
}
|
||||
if got, want := reused.Response.GetQuote().GetRoute().GetProvider(), "monetix"; got != want {
|
||||
t.Fatalf("unexpected idempotent route provider: got=%q want=%q", got, want)
|
||||
if got := strings.TrimSpace(reused.Response.GetQuote().GetRoute().GetProvider()); got != "" {
|
||||
t.Fatalf("expected idempotent route provider header to be empty, got=%q", got)
|
||||
}
|
||||
|
||||
t.Logf("single request:\n%s", mustProtoJSON(t, req))
|
||||
@@ -217,8 +221,8 @@ func TestQuotePayments_USDTToRUB_ThreeItems_EndToEnd(t *testing.T) {
|
||||
if quote.GetQuoteRef() != "quote-batch-usdt-rub" {
|
||||
t.Fatalf("unexpected quote_ref for item %d: %q", i, quote.GetQuoteRef())
|
||||
}
|
||||
if !quote.GetExecutable() {
|
||||
t.Fatalf("expected executable quote for item %d", i)
|
||||
if got, want := quote.GetState(), quotationv2.QuoteState_QUOTE_STATE_EXECUTABLE; got != want {
|
||||
t.Fatalf("unexpected quote state for item %d: got=%s want=%s", i, got.String(), want.String())
|
||||
}
|
||||
if quote.GetDebitAmount().GetCurrency() != "USDT" {
|
||||
t.Fatalf("unexpected debit currency for item %d: %q", i, quote.GetDebitAmount().GetCurrency())
|
||||
@@ -229,8 +233,8 @@ func TestQuotePayments_USDTToRUB_ThreeItems_EndToEnd(t *testing.T) {
|
||||
if quote.GetRoute() == nil {
|
||||
t.Fatalf("expected route for item %d", i)
|
||||
}
|
||||
if got, want := quote.GetRoute().GetRail(), "CARD_PAYOUT"; got != want {
|
||||
t.Fatalf("unexpected route rail for item %d: got=%q want=%q", i, got, want)
|
||||
if got := strings.TrimSpace(quote.GetRoute().GetRail()); got != "" {
|
||||
t.Fatalf("expected route rail header for item %d to be empty, got=%q", i, got)
|
||||
}
|
||||
if got := strings.TrimSpace(quote.GetRoute().GetRouteRef()); got == "" {
|
||||
t.Fatalf("expected route_ref for item %d", i)
|
||||
@@ -241,6 +245,12 @@ func TestQuotePayments_USDTToRUB_ThreeItems_EndToEnd(t *testing.T) {
|
||||
if got, want := len(quote.GetRoute().GetHops()), 3; got != want {
|
||||
t.Fatalf("unexpected route hops count for item %d: got=%d want=%d", i, got, want)
|
||||
}
|
||||
if quote.GetRoute().GetSettlement() == nil || quote.GetRoute().GetSettlement().GetAsset() == nil {
|
||||
t.Fatalf("expected route settlement asset for item %d", i)
|
||||
}
|
||||
if got, want := quote.GetRoute().GetSettlement().GetAsset().GetKey().GetTokenSymbol(), "USDT"; got != want {
|
||||
t.Fatalf("unexpected route settlement token for item %d: got=%q want=%q", i, got, want)
|
||||
}
|
||||
if quote.GetExecutionConditions() == nil {
|
||||
t.Fatalf("expected execution conditions for item %d", i)
|
||||
}
|
||||
@@ -384,8 +394,8 @@ func TestQuotePayment_SelectsEligibleGatewaysAndIgnoresIrrelevant(t *testing.T)
|
||||
if quote.GetRoute() == nil {
|
||||
t.Fatalf("expected route")
|
||||
}
|
||||
if got, want := quote.GetRoute().GetProvider(), "payout-gw-1"; got != want {
|
||||
t.Fatalf("unexpected selected provider: got=%q want=%q", got, want)
|
||||
if got := strings.TrimSpace(quote.GetRoute().GetProvider()); got != "" {
|
||||
t.Fatalf("expected route provider header to be empty, got=%q", got)
|
||||
}
|
||||
if got, want := len(quote.GetRoute().GetHops()), 3; got != want {
|
||||
t.Fatalf("unexpected hops count: got=%d want=%d", got, want)
|
||||
@@ -399,6 +409,12 @@ func TestQuotePayment_SelectsEligibleGatewaysAndIgnoresIrrelevant(t *testing.T)
|
||||
if got, want := quote.GetRoute().GetHops()[2].GetGateway(), "payout-gw-1"; got != want {
|
||||
t.Fatalf("unexpected destination hop gateway: got=%q want=%q", got, want)
|
||||
}
|
||||
if quote.GetRoute().GetSettlement() == nil || quote.GetRoute().GetSettlement().GetAsset() == nil {
|
||||
t.Fatalf("expected route settlement asset")
|
||||
}
|
||||
if got, want := quote.GetRoute().GetSettlement().GetAsset().GetKey().GetTokenSymbol(), "USDT"; got != want {
|
||||
t.Fatalf("unexpected route settlement token: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := quote.GetTotalCost().GetAmount(), "102.4"; got != want {
|
||||
t.Fatalf("unexpected total_cost amount: got=%q want=%q", got, want)
|
||||
}
|
||||
@@ -513,7 +529,7 @@ func (f *fakeQuoteCore) BuildQuote(_ context.Context, in quote_computation_servi
|
||||
Side: accountingv1.EntrySide_ENTRY_SIDE_DEBIT,
|
||||
Meta: map[string]string{
|
||||
"component": "platform_fee",
|
||||
"provider": strings.TrimSpace(in.Route.GetProvider()),
|
||||
"provider": routeDestinationGateway(in.Route),
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -523,7 +539,7 @@ func (f *fakeQuoteCore) BuildQuote(_ context.Context, in quote_computation_servi
|
||||
Side: accountingv1.EntrySide_ENTRY_SIDE_DEBIT,
|
||||
Meta: map[string]string{
|
||||
"component": "vat",
|
||||
"provider": strings.TrimSpace(in.Route.GetProvider()),
|
||||
"provider": routeDestinationGateway(in.Route),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -567,14 +583,9 @@ func cloneRouteSpecForTest(src *quotationv2.RouteSpecification) *quotationv2.Rou
|
||||
return nil
|
||||
}
|
||||
result := "ationv2.RouteSpecification{
|
||||
Rail: strings.TrimSpace(src.GetRail()),
|
||||
Provider: strings.TrimSpace(src.GetProvider()),
|
||||
PayoutMethod: strings.TrimSpace(src.GetPayoutMethod()),
|
||||
SettlementAsset: strings.ToUpper(strings.TrimSpace(src.GetSettlementAsset())),
|
||||
SettlementModel: strings.TrimSpace(src.GetSettlementModel()),
|
||||
Network: strings.TrimSpace(src.GetNetwork()),
|
||||
RouteRef: strings.TrimSpace(src.GetRouteRef()),
|
||||
PricingProfileRef: strings.TrimSpace(src.GetPricingProfileRef()),
|
||||
Settlement: cloneRouteSettlementForTest(src.GetSettlement()),
|
||||
}
|
||||
if hops := src.GetHops(); len(hops) > 0 {
|
||||
result.Hops = make([]*quotationv2.RouteHop, 0, len(hops))
|
||||
@@ -598,6 +609,31 @@ func cloneRouteSpecForTest(src *quotationv2.RouteSpecification) *quotationv2.Rou
|
||||
return result
|
||||
}
|
||||
|
||||
func cloneRouteSettlementForTest(src *quotationv2.RouteSettlement) *quotationv2.RouteSettlement {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
result := "ationv2.RouteSettlement{
|
||||
Model: strings.TrimSpace(src.GetModel()),
|
||||
}
|
||||
if asset := src.GetAsset(); asset != nil {
|
||||
key := asset.GetKey()
|
||||
result.Asset = &paymentv1.ChainAsset{
|
||||
Key: &paymentv1.ChainAssetKey{
|
||||
Chain: strings.ToUpper(strings.TrimSpace(key.GetChain())),
|
||||
TokenSymbol: strings.ToUpper(strings.TrimSpace(key.GetTokenSymbol())),
|
||||
},
|
||||
}
|
||||
if contract := strings.TrimSpace(asset.GetContractAddress()); contract != "" {
|
||||
result.Asset.ContractAddress = &contract
|
||||
}
|
||||
}
|
||||
if result.Asset == nil && result.Model == "" {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func cloneExecutionConditionsForTest(src *quotationv2.ExecutionConditions) *quotationv2.ExecutionConditions {
|
||||
if src == nil {
|
||||
return nil
|
||||
@@ -631,15 +667,28 @@ func routeFeeClass(route *quotationv2.RouteSpecification) string {
|
||||
return ""
|
||||
}
|
||||
hops := route.GetHops()
|
||||
destRail := ""
|
||||
destGateway := ""
|
||||
if n := len(hops); n > 0 && hops[n-1] != nil {
|
||||
destRail = strings.ToLower(strings.TrimSpace(hops[n-1].GetRail()))
|
||||
destGateway = strings.ToLower(strings.TrimSpace(hops[n-1].GetGateway()))
|
||||
}
|
||||
return strings.ToLower(strings.TrimSpace(route.GetRail())) +
|
||||
return destRail +
|
||||
":" + fmt.Sprintf("%d_hops", len(hops)) +
|
||||
":" + destGateway
|
||||
}
|
||||
|
||||
func routeDestinationGateway(route *quotationv2.RouteSpecification) string {
|
||||
if route == nil {
|
||||
return ""
|
||||
}
|
||||
hops := route.GetHops()
|
||||
if n := len(hops); n > 0 && hops[n-1] != nil {
|
||||
return strings.TrimSpace(hops[n-1].GetGateway())
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type inMemoryQuotesStore struct {
|
||||
byRef map[string]*model.PaymentQuoteRecord
|
||||
byKey map[string]*model.PaymentQuoteRecord
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"github.com/tech/sendico/payments/quotation/internal/service/quotation/transfer_intent_hydrator"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
|
||||
)
|
||||
|
||||
type itemProcessDetail struct {
|
||||
@@ -128,19 +127,10 @@ func (p *singleIntentProcessorV2) Process(
|
||||
return nil, merrors.InvalidArgument("incomplete computation output")
|
||||
}
|
||||
|
||||
kind := quoteKindForPreview(in.Context.PreviewOnly)
|
||||
lifecycle := quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE
|
||||
execution := p.classifier.BuildExecutionStatus(kind, lifecycle, result.BlockReason)
|
||||
state := p.classifier.BuildState(in.Context.PreviewOnly, result.BlockReason)
|
||||
status := quote_response_mapper_v2.QuoteStatus{
|
||||
Kind: kind,
|
||||
Lifecycle: lifecycle,
|
||||
}
|
||||
if execution.IsSet() {
|
||||
if execution.IsExecutable() {
|
||||
status.Executable = boolPtr(true)
|
||||
} else {
|
||||
status.BlockReason = execution.BlockReason()
|
||||
}
|
||||
State: state.State(),
|
||||
BlockReason: state.BlockReason(),
|
||||
}
|
||||
|
||||
canonical := quote_response_mapper_v2.CanonicalQuote{
|
||||
|
||||
@@ -3,6 +3,7 @@ package quote_computation_service
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -71,8 +72,8 @@ func TestBuildPlan_BuildsStepsAndFundingGate(t *testing.T) {
|
||||
if item.Route == nil {
|
||||
t.Fatalf("expected route specification")
|
||||
}
|
||||
if got, want := item.Route.GetRail(), "CARD_PAYOUT"; got != want {
|
||||
t.Fatalf("unexpected route rail: got=%q want=%q", got, want)
|
||||
if got := strings.TrimSpace(item.Route.GetRail()); got != "" {
|
||||
t.Fatalf("expected route rail header to be empty, got %q", got)
|
||||
}
|
||||
if got := item.Route.GetRouteRef(); got == "" {
|
||||
t.Fatalf("expected route_ref")
|
||||
@@ -83,6 +84,12 @@ func TestBuildPlan_BuildsStepsAndFundingGate(t *testing.T) {
|
||||
if got, want := len(item.Route.GetHops()), 2; got != want {
|
||||
t.Fatalf("unexpected route hops count: got=%d want=%d", got, want)
|
||||
}
|
||||
if item.Route.GetSettlement() == nil || item.Route.GetSettlement().GetAsset() == nil {
|
||||
t.Fatalf("expected route settlement asset")
|
||||
}
|
||||
if got, want := item.Route.GetSettlement().GetAsset().GetKey().GetTokenSymbol(), "USD"; got != want {
|
||||
t.Fatalf("unexpected settlement asset token: got=%q want=%q", got, want)
|
||||
}
|
||||
if item.ExecutionConditions == nil {
|
||||
t.Fatalf("expected execution conditions")
|
||||
}
|
||||
@@ -246,8 +253,8 @@ func TestBuildPlan_SelectsGatewaysAndIgnoresIrrelevant(t *testing.T) {
|
||||
if item.Route == nil {
|
||||
t.Fatalf("expected route")
|
||||
}
|
||||
if got, want := item.Route.GetProvider(), "payout-gw-1"; got != want {
|
||||
t.Fatalf("unexpected selected provider: got=%q want=%q", got, want)
|
||||
if got := strings.TrimSpace(item.Route.GetProvider()); got != "" {
|
||||
t.Fatalf("expected route provider header to be empty, got %q", got)
|
||||
}
|
||||
if got, want := len(item.Route.GetHops()), 3; got != want {
|
||||
t.Fatalf("unexpected route hop count: got=%d want=%d", got, want)
|
||||
@@ -258,6 +265,15 @@ func TestBuildPlan_SelectsGatewaysAndIgnoresIrrelevant(t *testing.T) {
|
||||
if got, want := item.Route.GetHops()[1].GetRole(), quotationv2.RouteHopRole_ROUTE_HOP_ROLE_TRANSIT; got != want {
|
||||
t.Fatalf("unexpected bridge role: got=%s want=%s", got.String(), want.String())
|
||||
}
|
||||
if item.Route.GetSettlement() == nil || item.Route.GetSettlement().GetAsset() == nil {
|
||||
t.Fatalf("expected route settlement asset")
|
||||
}
|
||||
if got, want := item.Route.GetSettlement().GetAsset().GetKey().GetChain(), "TRON"; got != want {
|
||||
t.Fatalf("unexpected settlement asset chain: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := item.Route.GetSettlement().GetAsset().GetKey().GetTokenSymbol(), "USDT"; got != want {
|
||||
t.Fatalf("unexpected settlement asset token: got=%q want=%q", got, want)
|
||||
}
|
||||
if got := item.Route.GetRouteRef(); got == "" {
|
||||
t.Fatalf("expected route_ref")
|
||||
}
|
||||
@@ -307,8 +323,8 @@ func TestCompute_EnrichesRouteConditionsAndTotalCost(t *testing.T) {
|
||||
if result.Quote.Route == nil {
|
||||
t.Fatalf("expected route specification")
|
||||
}
|
||||
if got, want := result.Quote.Route.GetPayoutMethod(), "CARD"; got != want {
|
||||
t.Fatalf("unexpected payout method: got=%q want=%q", got, want)
|
||||
if got := strings.TrimSpace(result.Quote.Route.GetPayoutMethod()); got != "" {
|
||||
t.Fatalf("expected payout method header to be empty, got %q", got)
|
||||
}
|
||||
if got := result.Quote.Route.GetRouteRef(); got == "" {
|
||||
t.Fatalf("expected route_ref")
|
||||
@@ -319,6 +335,12 @@ func TestCompute_EnrichesRouteConditionsAndTotalCost(t *testing.T) {
|
||||
if got, want := len(result.Quote.Route.GetHops()), 2; got != want {
|
||||
t.Fatalf("unexpected route hops count: got=%d want=%d", got, want)
|
||||
}
|
||||
if result.Quote.Route.GetSettlement() == nil || result.Quote.Route.GetSettlement().GetAsset() == nil {
|
||||
t.Fatalf("expected route settlement asset")
|
||||
}
|
||||
if got, want := result.Quote.Route.GetSettlement().GetAsset().GetKey().GetTokenSymbol(), "USD"; got != want {
|
||||
t.Fatalf("unexpected route settlement token: got=%q want=%q", got, want)
|
||||
}
|
||||
if result.Quote.ExecutionConditions == nil {
|
||||
t.Fatalf("expected execution conditions")
|
||||
}
|
||||
@@ -334,8 +356,12 @@ func TestCompute_EnrichesRouteConditionsAndTotalCost(t *testing.T) {
|
||||
if core.lastQuoteIn.Route == nil {
|
||||
t.Fatalf("expected selected route to be passed into build quote input")
|
||||
}
|
||||
if got, want := core.lastQuoteIn.Route.GetProvider(), "monetix"; got != want {
|
||||
t.Fatalf("unexpected selected route provider in build input: got=%q want=%q", got, want)
|
||||
hops := core.lastQuoteIn.Route.GetHops()
|
||||
if got, want := len(hops), 2; got != want {
|
||||
t.Fatalf("unexpected route hops in build input: got=%d want=%d", got, want)
|
||||
}
|
||||
if got, want := hops[1].GetGateway(), "monetix"; got != want {
|
||||
t.Fatalf("unexpected destination gateway in build input route: got=%q want=%q", got, want)
|
||||
}
|
||||
if core.lastQuoteIn.ExecutionConditions == nil {
|
||||
t.Fatalf("expected execution conditions to be passed into build quote input")
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||
accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1"
|
||||
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
|
||||
)
|
||||
|
||||
@@ -76,11 +77,10 @@ func cloneRouteSpecification(src *quotationv2.RouteSpecification) *quotationv2.R
|
||||
Rail: strings.TrimSpace(src.GetRail()),
|
||||
Provider: strings.TrimSpace(src.GetProvider()),
|
||||
PayoutMethod: strings.TrimSpace(src.GetPayoutMethod()),
|
||||
SettlementAsset: strings.ToUpper(strings.TrimSpace(src.GetSettlementAsset())),
|
||||
SettlementModel: strings.TrimSpace(src.GetSettlementModel()),
|
||||
Network: strings.TrimSpace(src.GetNetwork()),
|
||||
RouteRef: strings.TrimSpace(src.GetRouteRef()),
|
||||
PricingProfileRef: strings.TrimSpace(src.GetPricingProfileRef()),
|
||||
Settlement: cloneRouteSettlement(src.GetSettlement()),
|
||||
}
|
||||
if hops := src.GetHops(); len(hops) > 0 {
|
||||
result.Hops = make([]*quotationv2.RouteHop, 0, len(hops))
|
||||
@@ -96,6 +96,31 @@ func cloneRouteSpecification(src *quotationv2.RouteSpecification) *quotationv2.R
|
||||
return result
|
||||
}
|
||||
|
||||
func cloneRouteSettlement(src *quotationv2.RouteSettlement) *quotationv2.RouteSettlement {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
result := "ationv2.RouteSettlement{
|
||||
Model: strings.TrimSpace(src.GetModel()),
|
||||
}
|
||||
if asset := src.GetAsset(); asset != nil {
|
||||
key := asset.GetKey()
|
||||
result.Asset = &paymentv1.ChainAsset{
|
||||
Key: &paymentv1.ChainAssetKey{
|
||||
Chain: strings.ToUpper(strings.TrimSpace(key.GetChain())),
|
||||
TokenSymbol: strings.ToUpper(strings.TrimSpace(key.GetTokenSymbol())),
|
||||
},
|
||||
}
|
||||
if contract := strings.TrimSpace(asset.GetContractAddress()); contract != "" {
|
||||
result.Asset.ContractAddress = &contract
|
||||
}
|
||||
}
|
||||
if result.Asset == nil && result.Model == "" {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func cloneExecutionConditions(src *quotationv2.ExecutionConditions) *quotationv2.ExecutionConditions {
|
||||
if src == nil {
|
||||
return nil
|
||||
|
||||
@@ -37,18 +37,16 @@ func (s *QuoteComputationService) resolveStepGateways(
|
||||
}
|
||||
}
|
||||
sort.Slice(sorted, func(i, j int) bool {
|
||||
return strings.TrimSpace(sorted[i].ID) < strings.TrimSpace(sorted[j].ID)
|
||||
return model.LessGatewayDescriptor(sorted[i], sorted[j])
|
||||
})
|
||||
|
||||
for idx, step := range steps {
|
||||
if step == nil {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(step.GatewayID) != "" {
|
||||
continue
|
||||
}
|
||||
if step.Rail == model.RailLedger {
|
||||
step.GatewayID = "internal"
|
||||
step.GatewayInvokeURI = ""
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -57,9 +55,8 @@ func (s *QuoteComputationService) resolveStepGateways(
|
||||
return fmt.Errorf("Step[%d] %s: %w", idx, strings.TrimSpace(step.StepID), selectErr)
|
||||
}
|
||||
step.GatewayID = strings.TrimSpace(selected.ID)
|
||||
if strings.TrimSpace(step.InstanceID) == "" {
|
||||
step.InstanceID = strings.TrimSpace(selected.InstanceID)
|
||||
}
|
||||
step.InstanceID = strings.TrimSpace(selected.InstanceID)
|
||||
step.GatewayInvokeURI = strings.TrimSpace(selected.InvokeURI)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -89,20 +86,29 @@ func selectGatewayForStep(
|
||||
direction := plan.SendDirectionForRail(step.Rail)
|
||||
network := networkForGatewaySelection(step.Rail, routeNetwork)
|
||||
|
||||
eligible := make([]*model.GatewayInstanceDescriptor, 0, len(gateways))
|
||||
var lastErr error
|
||||
for _, gw := range gateways {
|
||||
if gw == nil {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(step.InstanceID) != "" &&
|
||||
!strings.EqualFold(strings.TrimSpace(gw.InstanceID), strings.TrimSpace(step.InstanceID)) {
|
||||
continue
|
||||
}
|
||||
if err := plan.IsGatewayEligible(gw, step.Rail, network, currency, action, direction, amount); err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
return gw, nil
|
||||
eligible = append(eligible, gw)
|
||||
}
|
||||
|
||||
if selected, _ := model.SelectGatewayByPreference(
|
||||
eligible,
|
||||
step.GatewayID,
|
||||
step.InstanceID,
|
||||
step.GatewayInvokeURI,
|
||||
); selected != nil {
|
||||
return selected, nil
|
||||
}
|
||||
if len(eligible) > 0 {
|
||||
return eligible[0], nil
|
||||
}
|
||||
|
||||
if lastErr != nil {
|
||||
@@ -160,6 +166,7 @@ func clearImplicitDestinationGateway(steps []*QuoteComputationStep) {
|
||||
return
|
||||
}
|
||||
last.GatewayID = ""
|
||||
last.GatewayInvokeURI = ""
|
||||
}
|
||||
|
||||
func destinationGatewayFromSteps(steps []*QuoteComputationStep) string {
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package quote_computation_service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
)
|
||||
|
||||
type selectorGatewayRegistry struct {
|
||||
items []*model.GatewayInstanceDescriptor
|
||||
}
|
||||
|
||||
func (s selectorGatewayRegistry) List(context.Context) ([]*model.GatewayInstanceDescriptor, error) {
|
||||
return s.items, nil
|
||||
}
|
||||
|
||||
func TestResolveStepGateways_FallsBackToInvokeURI(t *testing.T) {
|
||||
svc := New(nil, WithGatewayRegistry(selectorGatewayRegistry{
|
||||
items: []*model.GatewayInstanceDescriptor{
|
||||
{
|
||||
ID: "aaa",
|
||||
InstanceID: "inst-a",
|
||||
InvokeURI: "grpc://gw-a:50051",
|
||||
Rail: model.RailCrypto,
|
||||
Network: "TRON",
|
||||
Currencies: []string{"USDT"},
|
||||
Capabilities: model.RailCapabilities{
|
||||
CanPayOut: true,
|
||||
},
|
||||
IsEnabled: true,
|
||||
},
|
||||
{
|
||||
ID: "bbb",
|
||||
InstanceID: "inst-b",
|
||||
InvokeURI: "grpc://gw-b:50051",
|
||||
Rail: model.RailCrypto,
|
||||
Network: "TRON",
|
||||
Currencies: []string{"USDT"},
|
||||
Capabilities: model.RailCapabilities{
|
||||
CanPayOut: true,
|
||||
},
|
||||
IsEnabled: true,
|
||||
},
|
||||
},
|
||||
}))
|
||||
steps := []*QuoteComputationStep{
|
||||
{
|
||||
StepID: "i0.destination",
|
||||
Rail: model.RailCrypto,
|
||||
Operation: model.RailOperationExternalCredit,
|
||||
GatewayID: "legacy-id",
|
||||
InstanceID: "legacy-instance",
|
||||
GatewayInvokeURI: "grpc://gw-b:50051",
|
||||
Amount: &moneyv1.Money{Currency: "USDT", Amount: "10"},
|
||||
},
|
||||
}
|
||||
|
||||
if err := svc.resolveStepGateways(context.Background(), steps, "TRON"); err != nil {
|
||||
t.Fatalf("resolveStepGateways returned error: %v", err)
|
||||
}
|
||||
if got, want := steps[0].GatewayID, "bbb"; got != want {
|
||||
t.Fatalf("unexpected gateway_id: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := steps[0].InstanceID, "inst-b"; got != want {
|
||||
t.Fatalf("unexpected instance_id: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := steps[0].GatewayInvokeURI, "grpc://gw-b:50051"; got != want {
|
||||
t.Fatalf("unexpected gateway_invoke_uri: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveStepGateways_FallsBackToGatewayIDWhenInstanceChanges(t *testing.T) {
|
||||
svc := New(nil, WithGatewayRegistry(selectorGatewayRegistry{
|
||||
items: []*model.GatewayInstanceDescriptor{
|
||||
{
|
||||
ID: "aaa",
|
||||
InstanceID: "inst-a",
|
||||
InvokeURI: "grpc://gw-a:50051",
|
||||
Rail: model.RailCrypto,
|
||||
Network: "TRON",
|
||||
Currencies: []string{"USDT"},
|
||||
Capabilities: model.RailCapabilities{
|
||||
CanPayOut: true,
|
||||
},
|
||||
IsEnabled: true,
|
||||
},
|
||||
{
|
||||
ID: "crypto_rail_gateway_tron",
|
||||
InstanceID: "inst-new",
|
||||
InvokeURI: "grpc://gw-tron:50051",
|
||||
Rail: model.RailCrypto,
|
||||
Network: "TRON",
|
||||
Currencies: []string{"USDT"},
|
||||
Capabilities: model.RailCapabilities{
|
||||
CanPayOut: true,
|
||||
},
|
||||
IsEnabled: true,
|
||||
},
|
||||
},
|
||||
}))
|
||||
steps := []*QuoteComputationStep{
|
||||
{
|
||||
StepID: "i0.destination",
|
||||
Rail: model.RailCrypto,
|
||||
Operation: model.RailOperationExternalCredit,
|
||||
GatewayID: "crypto_rail_gateway_tron",
|
||||
InstanceID: "inst-old",
|
||||
Amount: &moneyv1.Money{Currency: "USDT", Amount: "10"},
|
||||
},
|
||||
}
|
||||
|
||||
if err := svc.resolveStepGateways(context.Background(), steps, "TRON"); err != nil {
|
||||
t.Fatalf("resolveStepGateways returned error: %v", err)
|
||||
}
|
||||
if got, want := steps[0].GatewayID, "crypto_rail_gateway_tron"; got != want {
|
||||
t.Fatalf("unexpected gateway_id: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := steps[0].InstanceID, "inst-new"; got != want {
|
||||
t.Fatalf("unexpected instance_id: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := steps[0].GatewayInvokeURI, "grpc://gw-tron:50051"; got != want {
|
||||
t.Fatalf("unexpected gateway_invoke_uri: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,7 @@ type QuoteComputationStep struct {
|
||||
Operation model.RailOperation
|
||||
GatewayID string
|
||||
InstanceID string
|
||||
GatewayInvokeURI string
|
||||
DependsOn []string
|
||||
Amount *moneyv1.Money
|
||||
FromRole *account_role.AccountRole
|
||||
|
||||
@@ -78,7 +78,7 @@ func (s *QuoteComputationService) buildPlanItem(
|
||||
|
||||
source := clonePaymentEndpoint(modelIntent.Source)
|
||||
destination := clonePaymentEndpoint(modelIntent.Destination)
|
||||
_, sourceNetwork, err := plan.RailFromEndpoint(source, modelIntent.Attributes, true)
|
||||
sourceRail, sourceNetwork, err := plan.RailFromEndpoint(source, modelIntent.Attributes, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -91,7 +91,12 @@ func (s *QuoteComputationService) buildPlanItem(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
steps := buildComputationSteps(index, modelIntent, destination)
|
||||
routeRails, err := s.resolveRouteRails(ctx, sourceRail, destRail, firstNonEmpty(routeNetwork, destNetwork, sourceNetwork))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
steps := buildComputationSteps(index, modelIntent, destination, routeRails)
|
||||
if modelIntent.Destination.Type == model.EndpointTypeCard &&
|
||||
s.gatewayRegistry != nil &&
|
||||
!hasExplicitDestinationGateway(modelIntent.Attributes) {
|
||||
@@ -132,14 +137,11 @@ func (s *QuoteComputationService) buildPlanItem(
|
||||
}
|
||||
route := buildRouteSpecification(
|
||||
modelIntent,
|
||||
destination,
|
||||
destRail,
|
||||
firstNonEmpty(routeNetwork, destNetwork, sourceNetwork),
|
||||
provider,
|
||||
steps,
|
||||
)
|
||||
conditions, blockReason := buildExecutionConditions(in.PreviewOnly, steps, funding)
|
||||
if route == nil || strings.TrimSpace(route.GetRail()) == "" || route.GetRail() == string(model.RailUnspecified) {
|
||||
if route == nil || len(route.GetHops()) == 0 {
|
||||
blockReason = quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_ROUTE_UNAVAILABLE
|
||||
}
|
||||
quoteInput := BuildQuoteInput{
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package quote_computation_service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/payments/quotation/internal/service/quotation/graph_path_finder"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
)
|
||||
|
||||
func (s *QuoteComputationService) resolveRouteRails(
|
||||
ctx context.Context,
|
||||
sourceRail model.Rail,
|
||||
destinationRail model.Rail,
|
||||
network string,
|
||||
) ([]model.Rail, error) {
|
||||
if sourceRail == model.RailUnspecified {
|
||||
return nil, merrors.InvalidArgument("source rail is required")
|
||||
}
|
||||
if destinationRail == model.RailUnspecified {
|
||||
return nil, merrors.InvalidArgument("destination rail is required")
|
||||
}
|
||||
if sourceRail == destinationRail {
|
||||
return []model.Rail{sourceRail}, nil
|
||||
}
|
||||
|
||||
strictGraph := s != nil && s.routeStore != nil
|
||||
edges, err := s.routeGraphEdges(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(edges) == 0 {
|
||||
if strictGraph {
|
||||
return nil, merrors.InvalidArgument("route graph has no edges")
|
||||
}
|
||||
return fallbackRouteRails(sourceRail, destinationRail), nil
|
||||
}
|
||||
|
||||
pathFinder := s.pathFinder
|
||||
if pathFinder == nil {
|
||||
pathFinder = graph_path_finder.New()
|
||||
}
|
||||
|
||||
path, findErr := pathFinder.Find(graph_path_finder.FindInput{
|
||||
SourceRail: sourceRail,
|
||||
DestinationRail: destinationRail,
|
||||
Network: strings.ToUpper(strings.TrimSpace(network)),
|
||||
Edges: edges,
|
||||
})
|
||||
if findErr != nil {
|
||||
if strictGraph {
|
||||
return nil, findErr
|
||||
}
|
||||
return fallbackRouteRails(sourceRail, destinationRail), nil
|
||||
}
|
||||
|
||||
if path == nil || len(path.Rails) == 0 {
|
||||
if strictGraph {
|
||||
return nil, merrors.InvalidArgument("route path is empty")
|
||||
}
|
||||
return fallbackRouteRails(sourceRail, destinationRail), nil
|
||||
}
|
||||
return append([]model.Rail(nil), path.Rails...), nil
|
||||
}
|
||||
|
||||
func (s *QuoteComputationService) routeGraphEdges(ctx context.Context) ([]graph_path_finder.Edge, error) {
|
||||
if s == nil || s.routeStore == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
enabled := true
|
||||
routes, err := s.routeStore.List(ctx, &model.PaymentRouteFilter{IsEnabled: &enabled})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if routes == nil || len(routes.Items) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
edges := make([]graph_path_finder.Edge, 0, len(routes.Items))
|
||||
for _, route := range routes.Items {
|
||||
if route == nil || !route.IsEnabled {
|
||||
continue
|
||||
}
|
||||
from := model.Rail(strings.ToUpper(strings.TrimSpace(string(route.FromRail))))
|
||||
to := model.Rail(strings.ToUpper(strings.TrimSpace(string(route.ToRail))))
|
||||
if from == model.RailUnspecified || to == model.RailUnspecified {
|
||||
continue
|
||||
}
|
||||
edges = append(edges, graph_path_finder.Edge{
|
||||
FromRail: from,
|
||||
ToRail: to,
|
||||
Network: strings.ToUpper(strings.TrimSpace(route.Network)),
|
||||
})
|
||||
}
|
||||
return edges, nil
|
||||
}
|
||||
|
||||
func fallbackRouteRails(sourceRail, destinationRail model.Rail) []model.Rail {
|
||||
if sourceRail == destinationRail {
|
||||
return []model.Rail{sourceRail}
|
||||
}
|
||||
if requiresTransitBridgeStep(sourceRail, destinationRail) {
|
||||
return []model.Rail{sourceRail, model.RailLedger, destinationRail}
|
||||
}
|
||||
return []model.Rail{sourceRail, destinationRail}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package quote_computation_service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/tech/sendico/payments/quotation/internal/service/quotation/transfer_intent_hydrator"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
func TestBuildPlan_UsesRouteGraphPath(t *testing.T) {
|
||||
svc := New(nil,
|
||||
WithRouteStore(staticRouteStore{items: []*model.PaymentRoute{
|
||||
{FromRail: model.RailCrypto, ToRail: model.RailProviderSettlement, Network: "TRON", IsEnabled: true},
|
||||
{FromRail: model.RailProviderSettlement, ToRail: model.RailCardPayout, Network: "TRON", IsEnabled: true},
|
||||
}}),
|
||||
WithGatewayRegistry(staticGatewayRegistry{
|
||||
items: []*model.GatewayInstanceDescriptor{
|
||||
{
|
||||
ID: "crypto-gw",
|
||||
InstanceID: "crypto-gw",
|
||||
Rail: model.RailCrypto,
|
||||
Network: "TRON",
|
||||
Currencies: []string{"USDT"},
|
||||
Capabilities: model.RailCapabilities{
|
||||
CanPayOut: true,
|
||||
},
|
||||
IsEnabled: true,
|
||||
},
|
||||
{
|
||||
ID: "provider-gw",
|
||||
InstanceID: "provider-gw",
|
||||
Rail: model.RailProviderSettlement,
|
||||
Network: "TRON",
|
||||
Currencies: []string{"USDT"},
|
||||
IsEnabled: true,
|
||||
},
|
||||
{
|
||||
ID: "card-gw",
|
||||
InstanceID: "card-gw",
|
||||
Rail: model.RailCardPayout,
|
||||
Currencies: []string{"USDT"},
|
||||
Capabilities: model.RailCapabilities{
|
||||
CanPayOut: true,
|
||||
},
|
||||
IsEnabled: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
orgID := bson.NewObjectID()
|
||||
planModel, err := svc.BuildPlan(context.Background(), ComputeInput{
|
||||
OrganizationRef: orgID.Hex(),
|
||||
OrganizationID: orgID,
|
||||
BaseIdempotencyKey: "idem-graph",
|
||||
Intents: []*transfer_intent_hydrator.QuoteIntent{sampleCryptoToCardQuoteIntent()},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if planModel == nil || len(planModel.Items) != 1 || planModel.Items[0] == nil {
|
||||
t.Fatalf("expected one plan item")
|
||||
}
|
||||
|
||||
item := planModel.Items[0]
|
||||
if got, want := len(item.Steps), 3; got != want {
|
||||
t.Fatalf("unexpected step count: got=%d want=%d", got, want)
|
||||
}
|
||||
if got, want := string(item.Steps[1].Rail), string(model.RailProviderSettlement); got != want {
|
||||
t.Fatalf("unexpected transit rail: got=%q want=%q", got, want)
|
||||
}
|
||||
if got := strings.ToUpper(strings.TrimSpace(item.Route.GetHops()[1].GetRail())); got != "PROVIDER_SETTLEMENT" {
|
||||
t.Fatalf("unexpected route transit hop rail: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPlan_RouteGraphNoPathReturnsError(t *testing.T) {
|
||||
svc := New(nil, WithRouteStore(staticRouteStore{items: []*model.PaymentRoute{
|
||||
{FromRail: model.RailCrypto, ToRail: model.RailLedger, Network: "TRON", IsEnabled: true},
|
||||
}}))
|
||||
|
||||
orgID := bson.NewObjectID()
|
||||
_, err := svc.BuildPlan(context.Background(), ComputeInput{
|
||||
OrganizationRef: orgID.Hex(),
|
||||
OrganizationID: orgID,
|
||||
BaseIdempotencyKey: "idem-graph-no-path",
|
||||
Intents: []*transfer_intent_hydrator.QuoteIntent{sampleCryptoToCardQuoteIntent()},
|
||||
})
|
||||
if !errors.Is(err, merrors.ErrInvalidArg) {
|
||||
t.Fatalf("expected invalid argument when no path exists in route graph, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPlan_RouteGraphPrefersDirectPath(t *testing.T) {
|
||||
svc := New(nil,
|
||||
WithRouteStore(staticRouteStore{items: []*model.PaymentRoute{
|
||||
{FromRail: model.RailCrypto, ToRail: model.RailCardPayout, Network: "TRON", IsEnabled: true},
|
||||
{FromRail: model.RailCrypto, ToRail: model.RailLedger, Network: "TRON", IsEnabled: true},
|
||||
{FromRail: model.RailLedger, ToRail: model.RailCardPayout, Network: "TRON", IsEnabled: true},
|
||||
}}),
|
||||
WithGatewayRegistry(staticGatewayRegistry{
|
||||
items: []*model.GatewayInstanceDescriptor{
|
||||
{
|
||||
ID: "crypto-gw",
|
||||
InstanceID: "crypto-gw",
|
||||
Rail: model.RailCrypto,
|
||||
Network: "TRON",
|
||||
Currencies: []string{"USDT"},
|
||||
Capabilities: model.RailCapabilities{
|
||||
CanPayOut: true,
|
||||
},
|
||||
IsEnabled: true,
|
||||
},
|
||||
{
|
||||
ID: "card-gw",
|
||||
InstanceID: "card-gw",
|
||||
Rail: model.RailCardPayout,
|
||||
Currencies: []string{"USDT"},
|
||||
Capabilities: model.RailCapabilities{
|
||||
CanPayOut: true,
|
||||
},
|
||||
IsEnabled: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
orgID := bson.NewObjectID()
|
||||
planModel, err := svc.BuildPlan(context.Background(), ComputeInput{
|
||||
OrganizationRef: orgID.Hex(),
|
||||
OrganizationID: orgID,
|
||||
BaseIdempotencyKey: "idem-graph-direct",
|
||||
Intents: []*transfer_intent_hydrator.QuoteIntent{sampleCryptoToCardQuoteIntent()},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if planModel == nil || len(planModel.Items) != 1 || planModel.Items[0] == nil {
|
||||
t.Fatalf("expected one plan item")
|
||||
}
|
||||
if got, want := len(planModel.Items[0].Steps), 2; got != want {
|
||||
t.Fatalf("expected direct two-step path, got %d steps", got)
|
||||
}
|
||||
}
|
||||
|
||||
type staticRouteStore struct {
|
||||
items []*model.PaymentRoute
|
||||
}
|
||||
|
||||
func (s staticRouteStore) List(context.Context, *model.PaymentRouteFilter) (*model.PaymentRouteList, error) {
|
||||
out := make([]*model.PaymentRoute, 0, len(s.items))
|
||||
for _, item := range s.items {
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
cloned := *item
|
||||
out = append(out, &cloned)
|
||||
}
|
||||
return &model.PaymentRouteList{Items: out}, nil
|
||||
}
|
||||
@@ -11,6 +11,7 @@ func buildComputationSteps(
|
||||
index int,
|
||||
intent model.PaymentIntent,
|
||||
destination model.PaymentEndpoint,
|
||||
routeRails []model.Rail,
|
||||
) []*QuoteComputationStep {
|
||||
if intent.Amount == nil {
|
||||
return nil
|
||||
@@ -20,6 +21,7 @@ func buildComputationSteps(
|
||||
amount := protoMoneyFromModel(intent.Amount)
|
||||
sourceRail := sourceRailForIntent(intent)
|
||||
destinationRail := destinationRailForIntent(intent)
|
||||
rails := normalizeRouteRails(sourceRail, destinationRail, routeRails)
|
||||
sourceGatewayID := strings.TrimSpace(lookupAttr(attrs,
|
||||
"source_gateway",
|
||||
"sourceGateway",
|
||||
@@ -37,8 +39,8 @@ func buildComputationSteps(
|
||||
steps := []*QuoteComputationStep{
|
||||
{
|
||||
StepID: sourceStepID,
|
||||
Rail: sourceRail,
|
||||
Operation: sourceOperationForRail(sourceRail),
|
||||
Rail: rails[0],
|
||||
Operation: sourceOperationForRail(rails[0]),
|
||||
GatewayID: sourceGatewayID,
|
||||
InstanceID: sourceInstanceID,
|
||||
Amount: cloneProtoMoney(amount),
|
||||
@@ -48,39 +50,54 @@ func buildComputationSteps(
|
||||
}
|
||||
|
||||
lastStepID := sourceStepID
|
||||
fxAssigned := false
|
||||
if intent.RequiresFX {
|
||||
fxStepID := fmt.Sprintf("i%d.fx", index)
|
||||
steps = append(steps, &QuoteComputationStep{
|
||||
StepID: fxStepID,
|
||||
Rail: model.RailProviderSettlement,
|
||||
Operation: model.RailOperationFXConvert,
|
||||
DependsOn: []string{sourceStepID},
|
||||
Amount: cloneProtoMoney(amount),
|
||||
Optional: false,
|
||||
IncludeInAggregate: false,
|
||||
})
|
||||
lastStepID = fxStepID
|
||||
if len(rails) > 1 && rails[1] == model.RailProviderSettlement {
|
||||
fxAssigned = true
|
||||
} else {
|
||||
fxStepID := fmt.Sprintf("i%d.fx", index)
|
||||
steps = append(steps, &QuoteComputationStep{
|
||||
StepID: fxStepID,
|
||||
Rail: model.RailProviderSettlement,
|
||||
Operation: model.RailOperationFXConvert,
|
||||
DependsOn: []string{sourceStepID},
|
||||
Amount: cloneProtoMoney(amount),
|
||||
Optional: false,
|
||||
IncludeInAggregate: false,
|
||||
})
|
||||
lastStepID = fxStepID
|
||||
fxAssigned = true
|
||||
}
|
||||
}
|
||||
|
||||
if requiresTransitBridgeStep(sourceRail, destinationRail) {
|
||||
bridgeStepID := fmt.Sprintf("i%d.bridge", index)
|
||||
transitIndex := 1
|
||||
for i := 1; i < len(rails)-1; i++ {
|
||||
rail := rails[i]
|
||||
stepID := fmt.Sprintf("i%d.transit%d", index, transitIndex)
|
||||
operation := model.RailOperationMove
|
||||
if intent.RequiresFX && !fxAssigned && rail == model.RailProviderSettlement {
|
||||
stepID = fmt.Sprintf("i%d.fx", index)
|
||||
operation = model.RailOperationFXConvert
|
||||
fxAssigned = true
|
||||
}
|
||||
steps = append(steps, &QuoteComputationStep{
|
||||
StepID: bridgeStepID,
|
||||
Rail: model.RailLedger,
|
||||
Operation: model.RailOperationMove,
|
||||
StepID: stepID,
|
||||
Rail: rail,
|
||||
Operation: operation,
|
||||
DependsOn: []string{lastStepID},
|
||||
Amount: cloneProtoMoney(amount),
|
||||
Optional: false,
|
||||
IncludeInAggregate: false,
|
||||
})
|
||||
lastStepID = bridgeStepID
|
||||
lastStepID = stepID
|
||||
transitIndex++
|
||||
}
|
||||
|
||||
destinationStepID := fmt.Sprintf("i%d.destination", index)
|
||||
steps = append(steps, &QuoteComputationStep{
|
||||
StepID: destinationStepID,
|
||||
Rail: destinationRail,
|
||||
Operation: destinationOperationForRail(destinationRail),
|
||||
Rail: rails[len(rails)-1],
|
||||
Operation: destinationOperationForRail(rails[len(rails)-1]),
|
||||
GatewayID: destinationGatewayID,
|
||||
InstanceID: destinationInstanceID,
|
||||
DependsOn: []string{lastStepID},
|
||||
@@ -92,6 +109,40 @@ func buildComputationSteps(
|
||||
return steps
|
||||
}
|
||||
|
||||
func normalizeRouteRails(sourceRail, destinationRail model.Rail, routeRails []model.Rail) []model.Rail {
|
||||
if len(routeRails) == 0 {
|
||||
if requiresTransitBridgeStep(sourceRail, destinationRail) {
|
||||
return []model.Rail{sourceRail, model.RailLedger, destinationRail}
|
||||
}
|
||||
return []model.Rail{sourceRail, destinationRail}
|
||||
}
|
||||
|
||||
result := make([]model.Rail, 0, len(routeRails))
|
||||
for _, rail := range routeRails {
|
||||
if rail == model.RailUnspecified {
|
||||
continue
|
||||
}
|
||||
if len(result) > 0 && result[len(result)-1] == rail {
|
||||
continue
|
||||
}
|
||||
result = append(result, rail)
|
||||
}
|
||||
|
||||
if len(result) == 0 {
|
||||
return []model.Rail{sourceRail, destinationRail}
|
||||
}
|
||||
if result[0] != sourceRail {
|
||||
result = append([]model.Rail{sourceRail}, result...)
|
||||
}
|
||||
if result[len(result)-1] != destinationRail {
|
||||
result = append(result, destinationRail)
|
||||
}
|
||||
if len(result) == 1 {
|
||||
result = append(result, destinationRail)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func requiresTransitBridgeStep(sourceRail, destinationRail model.Rail) bool {
|
||||
if sourceRail == model.RailUnspecified || destinationRail == model.RailUnspecified {
|
||||
return false
|
||||
|
||||
@@ -27,17 +27,37 @@ func sameRouteSpecification(left, right *quotationv2.RouteSpecification) bool {
|
||||
if left == nil || right == nil {
|
||||
return left == right
|
||||
}
|
||||
return normalizeRail(left.GetRail()) == normalizeRail(right.GetRail()) &&
|
||||
normalizeProvider(left.GetProvider()) == normalizeProvider(right.GetProvider()) &&
|
||||
normalizePayoutMethod(left.GetPayoutMethod()) == normalizePayoutMethod(right.GetPayoutMethod()) &&
|
||||
normalizeAsset(left.GetSettlementAsset()) == normalizeAsset(right.GetSettlementAsset()) &&
|
||||
normalizeSettlementModel(left.GetSettlementModel()) == normalizeSettlementModel(right.GetSettlementModel()) &&
|
||||
normalizeNetwork(left.GetNetwork()) == normalizeNetwork(right.GetNetwork()) &&
|
||||
return sameRouteSettlement(left.GetSettlement(), right.GetSettlement()) &&
|
||||
sameRouteReference(left.GetRouteRef(), right.GetRouteRef()) &&
|
||||
samePricingProfileReference(left.GetPricingProfileRef(), right.GetPricingProfileRef()) &&
|
||||
sameRouteHops(left.GetHops(), right.GetHops())
|
||||
}
|
||||
|
||||
func sameRouteSettlement(
|
||||
left *quotationv2.RouteSettlement,
|
||||
right *quotationv2.RouteSettlement,
|
||||
) bool {
|
||||
leftChain, leftToken, leftContract, leftModel := normalizeSettlementParts(left)
|
||||
rightChain, rightToken, rightContract, rightModel := normalizeSettlementParts(right)
|
||||
return leftChain == rightChain &&
|
||||
leftToken == rightToken &&
|
||||
leftContract == rightContract &&
|
||||
leftModel == rightModel
|
||||
}
|
||||
|
||||
func normalizeSettlementParts(src *quotationv2.RouteSettlement) (chain, token, contract, model string) {
|
||||
if src != nil {
|
||||
if asset := src.GetAsset(); asset != nil {
|
||||
key := asset.GetKey()
|
||||
chain = normalizeAsset(key.GetChain())
|
||||
token = normalizeAsset(key.GetTokenSymbol())
|
||||
contract = strings.TrimSpace(asset.GetContractAddress())
|
||||
}
|
||||
model = normalizeSettlementModel(src.GetModel())
|
||||
}
|
||||
return chain, token, contract, model
|
||||
}
|
||||
|
||||
func normalizeRail(value string) string {
|
||||
return strings.ToUpper(strings.TrimSpace(value))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package quote_computation_service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1"
|
||||
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
|
||||
)
|
||||
|
||||
func buildRouteSettlement(
|
||||
intent model.PaymentIntent,
|
||||
network string,
|
||||
hops []*quotationv2.RouteHop,
|
||||
) *quotationv2.RouteSettlement {
|
||||
modelValue := normalizeSettlementModel(settlementModelString(intent.SettlementMode))
|
||||
asset := buildRouteSettlementAsset(intent, network, hops)
|
||||
if asset == nil && modelValue == "" {
|
||||
return nil
|
||||
}
|
||||
return "ationv2.RouteSettlement{
|
||||
Asset: asset,
|
||||
Model: modelValue,
|
||||
}
|
||||
}
|
||||
|
||||
func buildRouteSettlementAsset(
|
||||
intent model.PaymentIntent,
|
||||
network string,
|
||||
hops []*quotationv2.RouteHop,
|
||||
) *paymentv1.ChainAsset {
|
||||
chain, token, contract := settlementAssetFromIntent(intent)
|
||||
|
||||
if token == "" {
|
||||
token = normalizeAsset(intent.SettlementCurrency)
|
||||
}
|
||||
if token == "" && intent.Amount != nil {
|
||||
token = normalizeAsset(intent.Amount.GetCurrency())
|
||||
}
|
||||
if chain == "" {
|
||||
chain = normalizeAsset(firstNonEmpty(network, routeNetworkFromHops(hops)))
|
||||
}
|
||||
if chain == "" && token == "" && contract == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
asset := &paymentv1.ChainAsset{
|
||||
Key: &paymentv1.ChainAssetKey{
|
||||
Chain: chain,
|
||||
TokenSymbol: token,
|
||||
},
|
||||
}
|
||||
if contract != "" {
|
||||
asset.ContractAddress = &contract
|
||||
}
|
||||
return asset
|
||||
}
|
||||
|
||||
func settlementAssetFromIntent(intent model.PaymentIntent) (chain, token, contract string) {
|
||||
candidates := []*model.PaymentEndpoint{
|
||||
&intent.Source,
|
||||
&intent.Destination,
|
||||
}
|
||||
for _, endpoint := range candidates {
|
||||
if endpoint == nil {
|
||||
continue
|
||||
}
|
||||
if endpoint.ManagedWallet != nil && endpoint.ManagedWallet.Asset != nil {
|
||||
return normalizedAssetFields(endpoint.ManagedWallet.Asset.Chain, endpoint.ManagedWallet.Asset.TokenSymbol, endpoint.ManagedWallet.Asset.ContractAddress)
|
||||
}
|
||||
if endpoint.ExternalChain != nil && endpoint.ExternalChain.Asset != nil {
|
||||
return normalizedAssetFields(endpoint.ExternalChain.Asset.Chain, endpoint.ExternalChain.Asset.TokenSymbol, endpoint.ExternalChain.Asset.ContractAddress)
|
||||
}
|
||||
}
|
||||
return "", "", ""
|
||||
}
|
||||
|
||||
func normalizedAssetFields(chain, token, contract string) (string, string, string) {
|
||||
return normalizeAsset(chain), normalizeAsset(token), strings.TrimSpace(contract)
|
||||
}
|
||||
|
||||
func routeNetworkFromHops(hops []*quotationv2.RouteHop) string {
|
||||
for _, hop := range hops {
|
||||
if hop == nil {
|
||||
continue
|
||||
}
|
||||
network := strings.TrimSpace(hop.GetNetwork())
|
||||
if network != "" {
|
||||
return network
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -14,27 +14,13 @@ import (
|
||||
|
||||
func buildRouteSpecification(
|
||||
intent model.PaymentIntent,
|
||||
destination model.PaymentEndpoint,
|
||||
destinationRail model.Rail,
|
||||
network string,
|
||||
provider string,
|
||||
steps []*QuoteComputationStep,
|
||||
) *quotationv2.RouteSpecification {
|
||||
hops := buildRouteHops(steps, network)
|
||||
if strings.TrimSpace(provider) == "" {
|
||||
provider = providerFromHops(hops)
|
||||
}
|
||||
route := "ationv2.RouteSpecification{
|
||||
Rail: normalizeRail(string(destinationRail)),
|
||||
Provider: normalizeProvider(provider),
|
||||
PayoutMethod: normalizePayoutMethod(payoutMethodFromEndpoint(destination)),
|
||||
SettlementAsset: normalizeAsset(intent.SettlementCurrency),
|
||||
SettlementModel: normalizeSettlementModel(settlementModelString(intent.SettlementMode)),
|
||||
Network: normalizeNetwork(network),
|
||||
Hops: hops,
|
||||
}
|
||||
if route.SettlementAsset == "" && intent.Amount != nil {
|
||||
route.SettlementAsset = normalizeAsset(intent.Amount.GetCurrency())
|
||||
Settlement: buildRouteSettlement(intent, network, hops),
|
||||
Hops: hops,
|
||||
}
|
||||
route.RouteRef = buildRouteReference(route)
|
||||
route.PricingProfileRef = buildPricingProfileReference(route)
|
||||
@@ -88,21 +74,6 @@ func buildExecutionConditions(
|
||||
return conditions, blockReason
|
||||
}
|
||||
|
||||
func payoutMethodFromEndpoint(endpoint model.PaymentEndpoint) string {
|
||||
switch endpoint.Type {
|
||||
case model.EndpointTypeCard:
|
||||
return "CARD"
|
||||
case model.EndpointTypeExternalChain:
|
||||
return "CRYPTO_ADDRESS"
|
||||
case model.EndpointTypeManagedWallet:
|
||||
return "MANAGED_WALLET"
|
||||
case model.EndpointTypeLedger:
|
||||
return "LEDGER"
|
||||
default:
|
||||
return "UNSPECIFIED"
|
||||
}
|
||||
}
|
||||
|
||||
func settlementModelString(mode model.SettlementMode) string {
|
||||
switch mode {
|
||||
case model.SettlementModeFixSource:
|
||||
@@ -164,18 +135,6 @@ func roleForHopIndex(index, last int) quotationv2.RouteHopRole {
|
||||
}
|
||||
}
|
||||
|
||||
func providerFromHops(hops []*quotationv2.RouteHop) string {
|
||||
for i := len(hops) - 1; i >= 0; i-- {
|
||||
if hops[i] == nil {
|
||||
continue
|
||||
}
|
||||
if gateway := normalizeProvider(hops[i].GetGateway()); gateway != "" {
|
||||
return gateway
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func buildRouteReference(route *quotationv2.RouteSpecification) string {
|
||||
signature := routeTopologySignature(route, true)
|
||||
if signature == "" {
|
||||
@@ -198,13 +157,23 @@ func routeTopologySignature(route *quotationv2.RouteSpecification, includeInstan
|
||||
if route == nil {
|
||||
return ""
|
||||
}
|
||||
parts := []string{
|
||||
normalizeRail(route.GetRail()),
|
||||
normalizeProvider(route.GetProvider()),
|
||||
normalizePayoutMethod(route.GetPayoutMethod()),
|
||||
normalizeAsset(route.GetSettlementAsset()),
|
||||
normalizeSettlementModel(route.GetSettlementModel()),
|
||||
normalizeNetwork(route.GetNetwork()),
|
||||
parts := make([]string, 0, 8)
|
||||
if settlement := route.GetSettlement(); settlement != nil {
|
||||
if asset := settlement.GetAsset(); asset != nil {
|
||||
key := asset.GetKey()
|
||||
if chain := normalizeAsset(key.GetChain()); chain != "" {
|
||||
parts = append(parts, chain)
|
||||
}
|
||||
if token := normalizeAsset(key.GetTokenSymbol()); token != "" {
|
||||
parts = append(parts, token)
|
||||
}
|
||||
if contract := strings.TrimSpace(asset.GetContractAddress()); contract != "" {
|
||||
parts = append(parts, strings.ToLower(contract))
|
||||
}
|
||||
}
|
||||
if model := normalizeSettlementModel(settlement.GetModel()); model != "" {
|
||||
parts = append(parts, model)
|
||||
}
|
||||
}
|
||||
|
||||
hops := route.GetHops()
|
||||
@@ -232,5 +201,8 @@ func routeTopologySignature(route *quotationv2.RouteSpecification, includeInstan
|
||||
parts = append(parts, strings.Join(hopParts, ":"))
|
||||
}
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(parts, "|")
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"github.com/tech/sendico/payments/quotation/internal/service/plan"
|
||||
"github.com/tech/sendico/payments/quotation/internal/service/quotation/gateway_funding_profile"
|
||||
"github.com/tech/sendico/payments/quotation/internal/service/quotation/graph_path_finder"
|
||||
)
|
||||
|
||||
type Core interface {
|
||||
@@ -18,11 +19,14 @@ type QuoteComputationService struct {
|
||||
core Core
|
||||
fundingResolver gateway_funding_profile.FundingProfileResolver
|
||||
gatewayRegistry plan.GatewayRegistry
|
||||
routeStore plan.RouteStore
|
||||
pathFinder *graph_path_finder.GraphPathFinder
|
||||
}
|
||||
|
||||
func New(core Core, opts ...Option) *QuoteComputationService {
|
||||
svc := &QuoteComputationService{
|
||||
core: core,
|
||||
core: core,
|
||||
pathFinder: graph_path_finder.New(),
|
||||
}
|
||||
for _, opt := range opts {
|
||||
if opt != nil {
|
||||
@@ -47,3 +51,19 @@ func WithGatewayRegistry(registry plan.GatewayRegistry) Option {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func WithRouteStore(store plan.RouteStore) Option {
|
||||
return func(svc *QuoteComputationService) {
|
||||
if svc != nil {
|
||||
svc.routeStore = store
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func WithPathFinder(pathFinder *graph_path_finder.GraphPathFinder) Option {
|
||||
return func(svc *QuoteComputationService) {
|
||||
if svc != nil && pathFinder != nil {
|
||||
svc.pathFinder = pathFinder
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,42 +87,28 @@ func Extract(err error) (quotationv2.QuoteBlockReason, bool) {
|
||||
return quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED, false
|
||||
}
|
||||
|
||||
type ExecutionStatus struct {
|
||||
set bool
|
||||
executable bool
|
||||
type QuoteState struct {
|
||||
state quotationv2.QuoteState
|
||||
blockReason quotationv2.QuoteBlockReason
|
||||
}
|
||||
|
||||
func (s ExecutionStatus) IsSet() bool {
|
||||
return s.set
|
||||
func (s QuoteState) State() quotationv2.QuoteState {
|
||||
return s.state
|
||||
}
|
||||
|
||||
func (s ExecutionStatus) IsExecutable() bool {
|
||||
return s.set && s.executable
|
||||
}
|
||||
|
||||
func (s ExecutionStatus) BlockReason() quotationv2.QuoteBlockReason {
|
||||
if !s.set || s.executable {
|
||||
func (s QuoteState) BlockReason() quotationv2.QuoteBlockReason {
|
||||
if s.state != quotationv2.QuoteState_QUOTE_STATE_BLOCKED {
|
||||
return quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED
|
||||
}
|
||||
return s.blockReason
|
||||
}
|
||||
|
||||
func (s ExecutionStatus) Apply(quote *quotationv2.PaymentQuote) {
|
||||
func (s QuoteState) Apply(quote *quotationv2.PaymentQuote) {
|
||||
if quote == nil {
|
||||
return
|
||||
}
|
||||
if !s.set {
|
||||
quote.ExecutionStatus = nil
|
||||
return
|
||||
}
|
||||
if s.executable {
|
||||
quote.ExecutionStatus = "ationv2.PaymentQuote_Executable{Executable: true}
|
||||
return
|
||||
}
|
||||
quote.ExecutionStatus = "ationv2.PaymentQuote_BlockReason{
|
||||
BlockReason: s.blockReason,
|
||||
}
|
||||
quote.State = s.state
|
||||
quote.BlockReason = s.BlockReason()
|
||||
}
|
||||
|
||||
type QuoteExecutabilityClassifier struct{}
|
||||
@@ -131,24 +117,22 @@ func New() *QuoteExecutabilityClassifier {
|
||||
return &QuoteExecutabilityClassifier{}
|
||||
}
|
||||
|
||||
func (c *QuoteExecutabilityClassifier) BuildExecutionStatus(
|
||||
kind quotationv2.QuoteKind,
|
||||
lifecycle quotationv2.QuoteLifecycle,
|
||||
func (c *QuoteExecutabilityClassifier) BuildState(
|
||||
previewOnly bool,
|
||||
blockReason quotationv2.QuoteBlockReason,
|
||||
) ExecutionStatus {
|
||||
if kind != quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE ||
|
||||
lifecycle != quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE {
|
||||
return ExecutionStatus{}
|
||||
}
|
||||
if blockReason == quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED {
|
||||
return ExecutionStatus{
|
||||
set: true,
|
||||
executable: true,
|
||||
) QuoteState {
|
||||
if previewOnly {
|
||||
return QuoteState{
|
||||
state: quotationv2.QuoteState_QUOTE_STATE_INDICATIVE,
|
||||
}
|
||||
}
|
||||
return ExecutionStatus{
|
||||
set: true,
|
||||
executable: false,
|
||||
if blockReason == quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED {
|
||||
return QuoteState{
|
||||
state: quotationv2.QuoteState_QUOTE_STATE_EXECUTABLE,
|
||||
}
|
||||
}
|
||||
return QuoteState{
|
||||
state: quotationv2.QuoteState_QUOTE_STATE_BLOCKED,
|
||||
blockReason: blockReason,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,52 +75,40 @@ func TestBlockReasonFromError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildExecutionStatus(t *testing.T) {
|
||||
func TestBuildState(t *testing.T) {
|
||||
classifier := New()
|
||||
|
||||
activeExecutable := classifier.BuildExecutionStatus(
|
||||
quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE,
|
||||
quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE,
|
||||
activeExecutable := classifier.BuildState(
|
||||
false,
|
||||
quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED,
|
||||
)
|
||||
if !activeExecutable.IsSet() {
|
||||
t.Fatalf("expected status to be set")
|
||||
if got, want := activeExecutable.State(), quotationv2.QuoteState_QUOTE_STATE_EXECUTABLE; got != want {
|
||||
t.Fatalf("unexpected state: got=%s want=%s", got.String(), want.String())
|
||||
}
|
||||
if !activeExecutable.IsExecutable() {
|
||||
t.Fatalf("expected executable status")
|
||||
if got := activeExecutable.BlockReason(); got != quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED {
|
||||
t.Fatalf("expected empty block reason, got=%s", got.String())
|
||||
}
|
||||
|
||||
blocked := classifier.BuildExecutionStatus(
|
||||
quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE,
|
||||
quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE,
|
||||
blocked := classifier.BuildState(
|
||||
false,
|
||||
quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_PRICE_STALE,
|
||||
)
|
||||
if !blocked.IsSet() {
|
||||
t.Fatalf("expected blocked status to be set")
|
||||
}
|
||||
if blocked.IsExecutable() {
|
||||
t.Fatalf("expected blocked status")
|
||||
if got, want := blocked.State(), quotationv2.QuoteState_QUOTE_STATE_BLOCKED; got != want {
|
||||
t.Fatalf("unexpected state: got=%s want=%s", got.String(), want.String())
|
||||
}
|
||||
if blocked.BlockReason() != quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_PRICE_STALE {
|
||||
t.Fatalf("unexpected block reason: %s", blocked.BlockReason().String())
|
||||
}
|
||||
|
||||
indicative := classifier.BuildExecutionStatus(
|
||||
quotationv2.QuoteKind_QUOTE_KIND_INDICATIVE,
|
||||
quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE,
|
||||
indicative := classifier.BuildState(
|
||||
true,
|
||||
quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_ROUTE_UNAVAILABLE,
|
||||
)
|
||||
if indicative.IsSet() {
|
||||
t.Fatalf("expected no execution status for indicative quote")
|
||||
if got, want := indicative.State(), quotationv2.QuoteState_QUOTE_STATE_INDICATIVE; got != want {
|
||||
t.Fatalf("unexpected state: got=%s want=%s", got.String(), want.String())
|
||||
}
|
||||
|
||||
expired := classifier.BuildExecutionStatus(
|
||||
quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE,
|
||||
quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_EXPIRED,
|
||||
quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED,
|
||||
)
|
||||
if expired.IsSet() {
|
||||
t.Fatalf("expected no execution status for expired quote")
|
||||
if got := indicative.BlockReason(); got != quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED {
|
||||
t.Fatalf("expected empty block reason for indicative state, got=%s", got.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,32 +116,32 @@ func TestApply(t *testing.T) {
|
||||
classifier := New()
|
||||
quote := "ationv2.PaymentQuote{}
|
||||
|
||||
unset := classifier.BuildExecutionStatus(
|
||||
quotationv2.QuoteKind_QUOTE_KIND_INDICATIVE,
|
||||
quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE,
|
||||
indicative := classifier.BuildState(
|
||||
true,
|
||||
quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED,
|
||||
)
|
||||
unset.Apply(quote)
|
||||
if quote.GetExecutionStatus() != nil {
|
||||
t.Fatalf("expected unset execution status")
|
||||
indicative.Apply(quote)
|
||||
if got, want := quote.GetState(), quotationv2.QuoteState_QUOTE_STATE_INDICATIVE; got != want {
|
||||
t.Fatalf("unexpected state: got=%s want=%s", got.String(), want.String())
|
||||
}
|
||||
|
||||
executable := classifier.BuildExecutionStatus(
|
||||
quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE,
|
||||
quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE,
|
||||
executable := classifier.BuildState(
|
||||
false,
|
||||
quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED,
|
||||
)
|
||||
executable.Apply(quote)
|
||||
if !quote.GetExecutable() {
|
||||
t.Fatalf("expected executable=true")
|
||||
if got, want := quote.GetState(), quotationv2.QuoteState_QUOTE_STATE_EXECUTABLE; got != want {
|
||||
t.Fatalf("unexpected state: got=%s want=%s", got.String(), want.String())
|
||||
}
|
||||
|
||||
blocked := classifier.BuildExecutionStatus(
|
||||
quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE,
|
||||
quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE,
|
||||
blocked := classifier.BuildState(
|
||||
false,
|
||||
quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_INSUFFICIENT_LIQUIDITY,
|
||||
)
|
||||
blocked.Apply(quote)
|
||||
if got, want := quote.GetState(), quotationv2.QuoteState_QUOTE_STATE_BLOCKED; got != want {
|
||||
t.Fatalf("unexpected state: got=%s want=%s", got.String(), want.String())
|
||||
}
|
||||
if got := quote.GetBlockReason(); got != quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_INSUFFICIENT_LIQUIDITY {
|
||||
t.Fatalf("unexpected block reason: %s", got.String())
|
||||
}
|
||||
|
||||
@@ -2,14 +2,6 @@ package quote_persistence_service
|
||||
|
||||
import "strconv"
|
||||
|
||||
func cloneBoolPtr(src *bool) *bool {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
value := *src
|
||||
return &value
|
||||
}
|
||||
|
||||
func itoa(value int) string {
|
||||
return strconv.Itoa(value)
|
||||
}
|
||||
|
||||
@@ -9,9 +9,7 @@ import (
|
||||
)
|
||||
|
||||
type StatusInput struct {
|
||||
Kind quotationv2.QuoteKind
|
||||
Lifecycle quotationv2.QuoteLifecycle
|
||||
Executable *bool
|
||||
State quotationv2.QuoteState
|
||||
BlockReason quotationv2.QuoteBlockReason
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ func TestPersistSingle(t *testing.T) {
|
||||
svc := New()
|
||||
store := &fakeQuotesStore{}
|
||||
orgID := bson.NewObjectID()
|
||||
trueValue := true
|
||||
|
||||
record, err := svc.Persist(context.Background(), store, PersistInput{
|
||||
OrganizationID: orgID,
|
||||
@@ -30,9 +29,7 @@ func TestPersistSingle(t *testing.T) {
|
||||
QuoteRef: "quote-1",
|
||||
},
|
||||
Status: &StatusInput{
|
||||
Kind: quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE,
|
||||
Lifecycle: quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE,
|
||||
Executable: &trueValue,
|
||||
State: quotationv2.QuoteState_QUOTE_STATE_EXECUTABLE,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
@@ -50,11 +47,11 @@ func TestPersistSingle(t *testing.T) {
|
||||
if store.created.StatusV2 == nil {
|
||||
t.Fatalf("expected v2 status metadata")
|
||||
}
|
||||
if store.created.StatusV2.Kind != model.QuoteKindExecutable {
|
||||
t.Fatalf("unexpected kind: %q", store.created.StatusV2.Kind)
|
||||
if store.created.StatusV2.State != model.QuoteStateExecutable {
|
||||
t.Fatalf("unexpected state: %q", store.created.StatusV2.State)
|
||||
}
|
||||
if store.created.StatusV2.Executable == nil || !*store.created.StatusV2.Executable {
|
||||
t.Fatalf("expected executable=true in persisted status")
|
||||
if store.created.StatusV2.BlockReason != model.QuoteBlockReasonUnspecified {
|
||||
t.Fatalf("unexpected block_reason: %q", store.created.StatusV2.BlockReason)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,13 +76,11 @@ func TestPersistBatch(t *testing.T) {
|
||||
},
|
||||
Statuses: []*StatusInput{
|
||||
{
|
||||
Kind: quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE,
|
||||
Lifecycle: quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE,
|
||||
State: quotationv2.QuoteState_QUOTE_STATE_BLOCKED,
|
||||
BlockReason: quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_ROUTE_UNAVAILABLE,
|
||||
},
|
||||
{
|
||||
Kind: quotationv2.QuoteKind_QUOTE_KIND_INDICATIVE,
|
||||
Lifecycle: quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE,
|
||||
State: quotationv2.QuoteState_QUOTE_STATE_INDICATIVE,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -122,13 +117,12 @@ func TestPersistValidation(t *testing.T) {
|
||||
Intent: &model.PaymentIntent{Ref: "intent"},
|
||||
Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q"},
|
||||
Status: &StatusInput{
|
||||
Kind: quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE,
|
||||
Lifecycle: quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE,
|
||||
Executable: boolPtr(false),
|
||||
State: quotationv2.QuoteState_QUOTE_STATE_BLOCKED,
|
||||
BlockReason: quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED,
|
||||
},
|
||||
})
|
||||
if !errors.Is(err, merrors.ErrInvalidArg) {
|
||||
t.Fatalf("expected invalid argument for executable=false, got %v", err)
|
||||
t.Fatalf("expected invalid argument for blocked without reason, got %v", err)
|
||||
}
|
||||
|
||||
_, err = svc.Persist(context.Background(), store, PersistInput{
|
||||
@@ -170,7 +164,3 @@ func (f *fakeQuotesStore) GetByRef(context.Context, bson.ObjectID, string) (*mod
|
||||
func (f *fakeQuotesStore) GetByIdempotencyKey(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) {
|
||||
return nil, quotestorage.ErrQuoteNotFound
|
||||
}
|
||||
|
||||
func boolPtr(v bool) *bool {
|
||||
return &v
|
||||
}
|
||||
|
||||
@@ -11,18 +11,19 @@ func mapStatusInput(input *StatusInput) (*model.QuoteStatusV2, error) {
|
||||
return nil, merrors.InvalidArgument("status is required")
|
||||
}
|
||||
|
||||
if input.Executable != nil && !*input.Executable {
|
||||
return nil, merrors.InvalidArgument("status.executable must be true when set")
|
||||
if input.State == quotationv2.QuoteState_QUOTE_STATE_UNSPECIFIED {
|
||||
return nil, merrors.InvalidArgument("status.state is required")
|
||||
}
|
||||
if input.Executable != nil &&
|
||||
input.BlockReason != quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED {
|
||||
return nil, merrors.InvalidArgument("status.executable and status.block_reason are mutually exclusive")
|
||||
if input.State == quotationv2.QuoteState_QUOTE_STATE_BLOCKED {
|
||||
if input.BlockReason == quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED {
|
||||
return nil, merrors.InvalidArgument("status.block_reason is required for blocked quote")
|
||||
}
|
||||
} else if input.BlockReason != quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED {
|
||||
return nil, merrors.InvalidArgument("status.block_reason is only valid for blocked quote")
|
||||
}
|
||||
|
||||
return &model.QuoteStatusV2{
|
||||
Kind: mapQuoteKind(input.Kind),
|
||||
Lifecycle: mapQuoteLifecycle(input.Lifecycle),
|
||||
Executable: cloneBoolPtr(input.Executable),
|
||||
State: mapQuoteState(input.State),
|
||||
BlockReason: mapQuoteBlockReason(input.BlockReason),
|
||||
}, nil
|
||||
}
|
||||
@@ -43,25 +44,18 @@ func mapStatusInputs(inputs []*StatusInput) ([]*model.QuoteStatusV2, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func mapQuoteKind(kind quotationv2.QuoteKind) model.QuoteKind {
|
||||
switch kind {
|
||||
case quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE:
|
||||
return model.QuoteKindExecutable
|
||||
case quotationv2.QuoteKind_QUOTE_KIND_INDICATIVE:
|
||||
return model.QuoteKindIndicative
|
||||
func mapQuoteState(state quotationv2.QuoteState) model.QuoteState {
|
||||
switch state {
|
||||
case quotationv2.QuoteState_QUOTE_STATE_INDICATIVE:
|
||||
return model.QuoteStateIndicative
|
||||
case quotationv2.QuoteState_QUOTE_STATE_EXECUTABLE:
|
||||
return model.QuoteStateExecutable
|
||||
case quotationv2.QuoteState_QUOTE_STATE_BLOCKED:
|
||||
return model.QuoteStateBlocked
|
||||
case quotationv2.QuoteState_QUOTE_STATE_EXPIRED:
|
||||
return model.QuoteStateExpired
|
||||
default:
|
||||
return model.QuoteKindUnspecified
|
||||
}
|
||||
}
|
||||
|
||||
func mapQuoteLifecycle(lifecycle quotationv2.QuoteLifecycle) model.QuoteLifecycle {
|
||||
switch lifecycle {
|
||||
case quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE:
|
||||
return model.QuoteLifecycleActive
|
||||
case quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_EXPIRED:
|
||||
return model.QuoteLifecycleExpired
|
||||
default:
|
||||
return model.QuoteLifecycleUnspecified
|
||||
return model.QuoteStateUnspecified
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/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/proto"
|
||||
@@ -83,11 +84,10 @@ func cloneRoute(src *quotationv2.RouteSpecification) *quotationv2.RouteSpecifica
|
||||
Rail: strings.TrimSpace(src.GetRail()),
|
||||
Provider: strings.TrimSpace(src.GetProvider()),
|
||||
PayoutMethod: strings.TrimSpace(src.GetPayoutMethod()),
|
||||
SettlementAsset: strings.ToUpper(strings.TrimSpace(src.GetSettlementAsset())),
|
||||
SettlementModel: strings.TrimSpace(src.GetSettlementModel()),
|
||||
Network: strings.TrimSpace(src.GetNetwork()),
|
||||
RouteRef: strings.TrimSpace(src.GetRouteRef()),
|
||||
PricingProfileRef: strings.TrimSpace(src.GetPricingProfileRef()),
|
||||
Settlement: cloneRouteSettlement(src.GetSettlement()),
|
||||
}
|
||||
if hops := src.GetHops(); len(hops) > 0 {
|
||||
result.Hops = make([]*quotationv2.RouteHop, 0, len(hops))
|
||||
@@ -111,6 +111,31 @@ func cloneRoute(src *quotationv2.RouteSpecification) *quotationv2.RouteSpecifica
|
||||
return result
|
||||
}
|
||||
|
||||
func cloneRouteSettlement(src *quotationv2.RouteSettlement) *quotationv2.RouteSettlement {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
result := "ationv2.RouteSettlement{
|
||||
Model: strings.TrimSpace(src.GetModel()),
|
||||
}
|
||||
if asset := src.GetAsset(); asset != nil {
|
||||
key := asset.GetKey()
|
||||
result.Asset = &paymentv1.ChainAsset{
|
||||
Key: &paymentv1.ChainAssetKey{
|
||||
Chain: strings.ToUpper(strings.TrimSpace(key.GetChain())),
|
||||
TokenSymbol: strings.ToUpper(strings.TrimSpace(key.GetTokenSymbol())),
|
||||
},
|
||||
}
|
||||
if contract := strings.TrimSpace(asset.GetContractAddress()); contract != "" {
|
||||
result.Asset.ContractAddress = &contract
|
||||
}
|
||||
}
|
||||
if result.Asset == nil && result.Model == "" {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func cloneExecutionConditions(src *quotationv2.ExecutionConditions) *quotationv2.ExecutionConditions {
|
||||
if src == nil {
|
||||
return nil
|
||||
|
||||
@@ -30,9 +30,7 @@ type CanonicalQuote struct {
|
||||
}
|
||||
|
||||
type QuoteStatus struct {
|
||||
Kind quotationv2.QuoteKind
|
||||
Lifecycle quotationv2.QuoteLifecycle
|
||||
Executable *bool
|
||||
State quotationv2.QuoteState
|
||||
BlockReason quotationv2.QuoteBlockReason
|
||||
}
|
||||
|
||||
@@ -43,8 +41,7 @@ type MapInput struct {
|
||||
}
|
||||
|
||||
type MapOutput struct {
|
||||
Quote *quotationv2.PaymentQuote
|
||||
HasExecutionStatus bool
|
||||
Executable bool
|
||||
BlockReason quotationv2.QuoteBlockReason
|
||||
Quote *quotationv2.PaymentQuote
|
||||
State quotationv2.QuoteState
|
||||
BlockReason quotationv2.QuoteBlockReason
|
||||
}
|
||||
|
||||
@@ -6,67 +6,28 @@ import (
|
||||
)
|
||||
|
||||
type executionDecision struct {
|
||||
hasStatus bool
|
||||
executable bool
|
||||
state quotationv2.QuoteState
|
||||
blockReason quotationv2.QuoteBlockReason
|
||||
}
|
||||
|
||||
func validateStatusInvariants(status QuoteStatus) (executionDecision, error) {
|
||||
if status.Kind == quotationv2.QuoteKind_QUOTE_KIND_UNSPECIFIED {
|
||||
return executionDecision{}, merrors.InvalidArgument("status.kind is required")
|
||||
if status.State == quotationv2.QuoteState_QUOTE_STATE_UNSPECIFIED {
|
||||
return executionDecision{}, merrors.InvalidArgument("status.state is required")
|
||||
}
|
||||
if status.Lifecycle == quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_UNSPECIFIED {
|
||||
return executionDecision{}, merrors.InvalidArgument("status.lifecycle is required")
|
||||
}
|
||||
|
||||
hasExecutable := status.Executable != nil
|
||||
hasBlockReason := status.BlockReason != quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED
|
||||
|
||||
if status.Kind == quotationv2.QuoteKind_QUOTE_KIND_INDICATIVE {
|
||||
if hasExecutable || hasBlockReason {
|
||||
return executionDecision{}, merrors.InvalidArgument("execution_status must be unset for indicative quote")
|
||||
if status.State == quotationv2.QuoteState_QUOTE_STATE_BLOCKED {
|
||||
if status.BlockReason == quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED {
|
||||
return executionDecision{}, merrors.InvalidArgument("status.block_reason is required for blocked quote")
|
||||
}
|
||||
return executionDecision{}, nil
|
||||
}
|
||||
|
||||
if status.Lifecycle == quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_EXPIRED {
|
||||
if hasExecutable || hasBlockReason {
|
||||
return executionDecision{}, merrors.InvalidArgument("execution_status must be unset for expired quote")
|
||||
}
|
||||
return executionDecision{}, nil
|
||||
}
|
||||
|
||||
if status.Kind != quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE ||
|
||||
status.Lifecycle != quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE {
|
||||
if hasExecutable || hasBlockReason {
|
||||
return executionDecision{}, merrors.InvalidArgument("execution_status is only valid for executable active quote")
|
||||
}
|
||||
return executionDecision{}, nil
|
||||
}
|
||||
|
||||
if hasExecutable == hasBlockReason {
|
||||
return executionDecision{}, merrors.InvalidArgument("exactly one execution status is required")
|
||||
}
|
||||
if hasExecutable && !status.ExecutableValue() {
|
||||
return executionDecision{}, merrors.InvalidArgument("execution_status.executable must be true")
|
||||
}
|
||||
|
||||
if hasExecutable {
|
||||
return executionDecision{
|
||||
hasStatus: true,
|
||||
executable: true,
|
||||
state: status.State,
|
||||
blockReason: status.BlockReason,
|
||||
}, nil
|
||||
}
|
||||
if status.BlockReason != quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED {
|
||||
return executionDecision{}, merrors.InvalidArgument("status.block_reason is only valid for blocked quote")
|
||||
}
|
||||
return executionDecision{
|
||||
hasStatus: true,
|
||||
executable: false,
|
||||
blockReason: status.BlockReason,
|
||||
state: status.State,
|
||||
blockReason: quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s QuoteStatus) ExecutableValue() bool {
|
||||
if s.Executable == nil {
|
||||
return false
|
||||
}
|
||||
return *s.Executable
|
||||
}
|
||||
|
||||
@@ -23,8 +23,8 @@ func (m *QuoteResponseMapperV2) Map(in MapInput) (*MapOutput, error) {
|
||||
|
||||
result := "ationv2.PaymentQuote{
|
||||
Storable: mapStorable(in.Meta),
|
||||
Kind: in.Status.Kind,
|
||||
Lifecycle: in.Status.Lifecycle,
|
||||
State: decision.state,
|
||||
BlockReason: decision.blockReason,
|
||||
DebitAmount: cloneMoney(in.Quote.DebitAmount),
|
||||
CreditAmount: cloneMoney(in.Quote.CreditAmount),
|
||||
TotalCost: cloneMoney(in.Quote.TotalCost),
|
||||
@@ -38,21 +38,10 @@ func (m *QuoteResponseMapperV2) Map(in MapInput) (*MapOutput, error) {
|
||||
PricedAt: tsOrNil(in.Quote.PricedAt),
|
||||
}
|
||||
|
||||
if decision.hasStatus {
|
||||
if decision.executable {
|
||||
result.ExecutionStatus = "ationv2.PaymentQuote_Executable{Executable: true}
|
||||
} else {
|
||||
result.ExecutionStatus = "ationv2.PaymentQuote_BlockReason{
|
||||
BlockReason: decision.blockReason,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &MapOutput{
|
||||
Quote: result,
|
||||
HasExecutionStatus: decision.hasStatus,
|
||||
Executable: decision.executable,
|
||||
BlockReason: decision.blockReason,
|
||||
Quote: result,
|
||||
State: decision.state,
|
||||
BlockReason: decision.blockReason,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -7,12 +7,12 @@ import (
|
||||
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1"
|
||||
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
|
||||
)
|
||||
|
||||
func TestMap_ExecutableActiveQuote(t *testing.T) {
|
||||
func TestMap_ExecutableQuote(t *testing.T) {
|
||||
mapper := New()
|
||||
trueValue := true
|
||||
createdAt := time.Unix(100, 0)
|
||||
updatedAt := time.Unix(120, 0)
|
||||
expiresAt := time.Unix(200, 0)
|
||||
@@ -39,11 +39,17 @@ func TestMap_ExecutableActiveQuote(t *testing.T) {
|
||||
Currency: "USD",
|
||||
},
|
||||
Route: "ationv2.RouteSpecification{
|
||||
Rail: "CARD_PAYOUT",
|
||||
Provider: "monetix",
|
||||
PayoutMethod: "CARD",
|
||||
SettlementAsset: "USD",
|
||||
SettlementModel: "FIX_SOURCE",
|
||||
Rail: "CARD_PAYOUT",
|
||||
Provider: "monetix",
|
||||
PayoutMethod: "CARD",
|
||||
Settlement: "ationv2.RouteSettlement{
|
||||
Asset: &paymentv1.ChainAsset{
|
||||
Key: &paymentv1.ChainAssetKey{
|
||||
TokenSymbol: "USD",
|
||||
},
|
||||
},
|
||||
Model: "FIX_SOURCE",
|
||||
},
|
||||
},
|
||||
Conditions: "ationv2.ExecutionConditions{
|
||||
Readiness: quotationv2.QuoteExecutionReadiness_QUOTE_EXECUTION_READINESS_LIQUIDITY_READY,
|
||||
@@ -55,9 +61,7 @@ func TestMap_ExecutableActiveQuote(t *testing.T) {
|
||||
PricedAt: pricedAt,
|
||||
},
|
||||
Status: QuoteStatus{
|
||||
Kind: quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE,
|
||||
Lifecycle: quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE,
|
||||
Executable: &trueValue,
|
||||
State: quotationv2.QuoteState_QUOTE_STATE_EXECUTABLE,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
@@ -66,14 +70,17 @@ func TestMap_ExecutableActiveQuote(t *testing.T) {
|
||||
if out == nil || out.Quote == nil {
|
||||
t.Fatalf("expected mapped quote")
|
||||
}
|
||||
if !out.HasExecutionStatus || !out.Executable {
|
||||
t.Fatalf("expected executable status")
|
||||
if got, want := out.State, quotationv2.QuoteState_QUOTE_STATE_EXECUTABLE; got != want {
|
||||
t.Fatalf("unexpected state: got=%s want=%s", got.String(), want.String())
|
||||
}
|
||||
if !out.Quote.GetExecutable() {
|
||||
t.Fatalf("expected proto executable=true")
|
||||
if got := out.BlockReason; got != quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED {
|
||||
t.Fatalf("expected empty block reason, got=%s", got.String())
|
||||
}
|
||||
if got, want := out.Quote.GetState(), quotationv2.QuoteState_QUOTE_STATE_EXECUTABLE; got != want {
|
||||
t.Fatalf("unexpected proto state: got=%s want=%s", got.String(), want.String())
|
||||
}
|
||||
if out.Quote.GetBlockReason() != quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED {
|
||||
t.Fatalf("expected empty block reason")
|
||||
t.Fatalf("expected empty proto block reason")
|
||||
}
|
||||
if out.Quote.GetStorable().GetId() != "rec-1" {
|
||||
t.Fatalf("expected storable id rec-1, got %q", out.Quote.GetStorable().GetId())
|
||||
@@ -93,20 +100,22 @@ func TestMap_ExecutableActiveQuote(t *testing.T) {
|
||||
if got, want := out.Quote.GetRoute().GetProvider(), "monetix"; got != want {
|
||||
t.Fatalf("unexpected route provider: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := out.Quote.GetRoute().GetSettlement().GetAsset().GetKey().GetTokenSymbol(), "USD"; got != want {
|
||||
t.Fatalf("unexpected settlement token: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := out.Quote.GetTotalCost().GetAmount(), "10.2"; got != want {
|
||||
t.Fatalf("unexpected total_cost amount: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMap_BlockedExecutableQuote(t *testing.T) {
|
||||
func TestMap_BlockedQuote(t *testing.T) {
|
||||
mapper := New()
|
||||
out, err := mapper.Map(MapInput{
|
||||
Quote: CanonicalQuote{
|
||||
QuoteRef: "q-2",
|
||||
},
|
||||
Status: QuoteStatus{
|
||||
Kind: quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE,
|
||||
Lifecycle: quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE,
|
||||
State: quotationv2.QuoteState_QUOTE_STATE_BLOCKED,
|
||||
BlockReason: quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_ROUTE_UNAVAILABLE,
|
||||
},
|
||||
})
|
||||
@@ -116,92 +125,66 @@ func TestMap_BlockedExecutableQuote(t *testing.T) {
|
||||
if out == nil || out.Quote == nil {
|
||||
t.Fatalf("expected mapped quote")
|
||||
}
|
||||
if !out.HasExecutionStatus || out.Executable {
|
||||
t.Fatalf("expected blocked status")
|
||||
if got, want := out.Quote.GetState(), quotationv2.QuoteState_QUOTE_STATE_BLOCKED; got != want {
|
||||
t.Fatalf("unexpected state: got=%s want=%s", got.String(), want.String())
|
||||
}
|
||||
if got := out.Quote.GetBlockReason(); got != quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_ROUTE_UNAVAILABLE {
|
||||
t.Fatalf("unexpected block reason: %s", got.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMap_IndicativeAndExpiredMustHaveNoExecutionStatus(t *testing.T) {
|
||||
func TestMap_IndicativeAndExpiredAreValidWithoutBlockReason(t *testing.T) {
|
||||
mapper := New()
|
||||
trueValue := true
|
||||
|
||||
_, err := mapper.Map(MapInput{
|
||||
Status: QuoteStatus{
|
||||
Kind: quotationv2.QuoteKind_QUOTE_KIND_INDICATIVE,
|
||||
Lifecycle: quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE,
|
||||
Executable: &trueValue,
|
||||
},
|
||||
})
|
||||
if !errors.Is(err, merrors.ErrInvalidArg) {
|
||||
t.Fatalf("expected invalid arg for indicative with execution status, got %v", err)
|
||||
states := []quotationv2.QuoteState{
|
||||
quotationv2.QuoteState_QUOTE_STATE_INDICATIVE,
|
||||
quotationv2.QuoteState_QUOTE_STATE_EXPIRED,
|
||||
}
|
||||
|
||||
_, err = mapper.Map(MapInput{
|
||||
Status: QuoteStatus{
|
||||
Kind: quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE,
|
||||
Lifecycle: quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_EXPIRED,
|
||||
Executable: &trueValue,
|
||||
},
|
||||
})
|
||||
if !errors.Is(err, merrors.ErrInvalidArg) {
|
||||
t.Fatalf("expected invalid arg for expired with execution status, got %v", err)
|
||||
}
|
||||
|
||||
out, err := mapper.Map(MapInput{
|
||||
Status: QuoteStatus{
|
||||
Kind: quotationv2.QuoteKind_QUOTE_KIND_INDICATIVE,
|
||||
Lifecycle: quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if out.HasExecutionStatus {
|
||||
t.Fatalf("expected unset execution status")
|
||||
}
|
||||
if out.Quote.GetExecutionStatus() != nil {
|
||||
t.Fatalf("expected no execution_status oneof")
|
||||
for _, state := range states {
|
||||
out, err := mapper.Map(MapInput{
|
||||
Status: QuoteStatus{
|
||||
State: state,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error for state=%s: %v", state.String(), err)
|
||||
}
|
||||
if out == nil || out.Quote == nil {
|
||||
t.Fatalf("expected mapped quote for state=%s", state.String())
|
||||
}
|
||||
if got := out.Quote.GetBlockReason(); got != quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED {
|
||||
t.Fatalf("expected empty block reason for state=%s, got=%s", state.String(), got.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMap_ExecutableActiveRequiresExactlyOneExecutionStatus(t *testing.T) {
|
||||
func TestMap_StateInvariants(t *testing.T) {
|
||||
mapper := New()
|
||||
trueValue := true
|
||||
|
||||
_, err := mapper.Map(MapInput{
|
||||
Status: QuoteStatus{
|
||||
Kind: quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE,
|
||||
Lifecycle: quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE,
|
||||
State: quotationv2.QuoteState_QUOTE_STATE_UNSPECIFIED,
|
||||
},
|
||||
})
|
||||
if !errors.Is(err, merrors.ErrInvalidArg) {
|
||||
t.Fatalf("expected invalid arg when execution status is missing, got %v", err)
|
||||
t.Fatalf("expected invalid arg for unspecified state, got %v", err)
|
||||
}
|
||||
|
||||
_, err = mapper.Map(MapInput{
|
||||
Status: QuoteStatus{
|
||||
Kind: quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE,
|
||||
Lifecycle: quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE,
|
||||
Executable: &trueValue,
|
||||
State: quotationv2.QuoteState_QUOTE_STATE_BLOCKED,
|
||||
},
|
||||
})
|
||||
if !errors.Is(err, merrors.ErrInvalidArg) {
|
||||
t.Fatalf("expected invalid arg for blocked without reason, got %v", err)
|
||||
}
|
||||
|
||||
_, err = mapper.Map(MapInput{
|
||||
Status: QuoteStatus{
|
||||
State: quotationv2.QuoteState_QUOTE_STATE_EXECUTABLE,
|
||||
BlockReason: quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_LIMIT_BLOCKED,
|
||||
},
|
||||
})
|
||||
if !errors.Is(err, merrors.ErrInvalidArg) {
|
||||
t.Fatalf("expected invalid arg when both executable and block_reason are set, got %v", err)
|
||||
}
|
||||
|
||||
falseValue := false
|
||||
_, err = mapper.Map(MapInput{
|
||||
Status: QuoteStatus{
|
||||
Kind: quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE,
|
||||
Lifecycle: quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE,
|
||||
Executable: &falseValue,
|
||||
},
|
||||
})
|
||||
if !errors.Is(err, merrors.ErrInvalidArg) {
|
||||
t.Fatalf("expected invalid arg for executable=false, got %v", err)
|
||||
t.Fatalf("expected invalid arg for non-blocked with block reason, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@ Calls existing core for quote and plan building.
|
||||
Returns quote + expiry + optional plan.
|
||||
QuoteExecutabilityClassifier
|
||||
Converts plan/build errors to QuoteBlockReason.
|
||||
Produces execution_status (executable=true or block_reason).
|
||||
Produces quote state (executable/blocked/indicative + block_reason when blocked).
|
||||
QuotePersistenceService
|
||||
Persists quote record with v2 status metadata.
|
||||
Keeps legacy ExecutionNote for backward compatibility.
|
||||
|
||||
144
api/payments/storage/model/gateway_affinity.go
Normal file
144
api/payments/storage/model/gateway_affinity.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package model
|
||||
|
||||
import "strings"
|
||||
|
||||
// SelectGatewayByPreference picks a gateway candidate using persisted affinity hints.
|
||||
// Matching order:
|
||||
// 1) gateway ID + instance ID
|
||||
// 2) invoke URI
|
||||
// 3) gateway ID
|
||||
// 4) instance ID
|
||||
// 5) first candidate fallback
|
||||
func SelectGatewayByPreference(
|
||||
candidates []*GatewayInstanceDescriptor,
|
||||
gatewayID string,
|
||||
instanceID string,
|
||||
invokeURI string,
|
||||
) (*GatewayInstanceDescriptor, string) {
|
||||
if len(candidates) == 0 {
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
gatewayID = strings.TrimSpace(gatewayID)
|
||||
instanceID = strings.TrimSpace(instanceID)
|
||||
invokeURI = strings.TrimSpace(invokeURI)
|
||||
|
||||
if gatewayID != "" && instanceID != "" {
|
||||
for _, entry := range candidates {
|
||||
if entry == nil {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(entry.ID), gatewayID) &&
|
||||
strings.EqualFold(strings.TrimSpace(entry.InstanceID), instanceID) {
|
||||
return entry, "exact"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if invokeURI != "" {
|
||||
for _, entry := range candidates {
|
||||
if entry == nil {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(entry.InvokeURI), invokeURI) {
|
||||
return entry, "invoke_uri"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if gatewayID != "" {
|
||||
for _, entry := range candidates {
|
||||
if entry == nil {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(entry.ID), gatewayID) {
|
||||
return entry, "gateway_id"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if instanceID != "" {
|
||||
for _, entry := range candidates {
|
||||
if entry == nil {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(entry.InstanceID), instanceID) {
|
||||
return entry, "instance_id"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, entry := range candidates {
|
||||
if entry != nil {
|
||||
return entry, "rail_fallback"
|
||||
}
|
||||
}
|
||||
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
// GatewayDescriptorIdentityKey returns a stable dedupe key for gateway entries.
|
||||
// The key is composed from logical gateway ID + instance identity; invoke URI is
|
||||
// used as a fallback identity when instance ID is missing.
|
||||
func GatewayDescriptorIdentityKey(entry *GatewayInstanceDescriptor) string {
|
||||
if entry == nil {
|
||||
return ""
|
||||
}
|
||||
return GatewayIdentityKey(entry.ID, entry.InstanceID, entry.InvokeURI)
|
||||
}
|
||||
|
||||
// GatewayIdentityKey composes a stable identity key from gateway affinity fields.
|
||||
func GatewayIdentityKey(gatewayID string, instanceID string, invokeURI string) string {
|
||||
id := strings.ToLower(strings.TrimSpace(gatewayID))
|
||||
if id == "" {
|
||||
return ""
|
||||
}
|
||||
instance := strings.ToLower(strings.TrimSpace(instanceID))
|
||||
if instance == "" {
|
||||
instance = strings.ToLower(strings.TrimSpace(invokeURI))
|
||||
}
|
||||
if instance == "" {
|
||||
return id
|
||||
}
|
||||
return id + "|" + instance
|
||||
}
|
||||
|
||||
// LessGatewayDescriptor orders gateway descriptors deterministically.
|
||||
func LessGatewayDescriptor(left *GatewayInstanceDescriptor, right *GatewayInstanceDescriptor) bool {
|
||||
if left == nil {
|
||||
return right != nil
|
||||
}
|
||||
if right == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if cmp := compareFolded(left.ID, right.ID); cmp != 0 {
|
||||
return cmp < 0
|
||||
}
|
||||
if cmp := compareFolded(left.InstanceID, right.InstanceID); cmp != 0 {
|
||||
return cmp < 0
|
||||
}
|
||||
if cmp := compareFolded(left.InvokeURI, right.InvokeURI); cmp != 0 {
|
||||
return cmp < 0
|
||||
}
|
||||
if cmp := compareFolded(string(left.Rail), string(right.Rail)); cmp != 0 {
|
||||
return cmp < 0
|
||||
}
|
||||
if cmp := compareFolded(left.Network, right.Network); cmp != 0 {
|
||||
return cmp < 0
|
||||
}
|
||||
return compareFolded(left.Version, right.Version) < 0
|
||||
}
|
||||
|
||||
func compareFolded(left string, right string) int {
|
||||
l := strings.ToLower(strings.TrimSpace(left))
|
||||
r := strings.ToLower(strings.TrimSpace(right))
|
||||
switch {
|
||||
case l < r:
|
||||
return -1
|
||||
case l > r:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
29
api/payments/storage/model/gateway_affinity_identity_test.go
Normal file
29
api/payments/storage/model/gateway_affinity_identity_test.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package model
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestGatewayIdentityKey(t *testing.T) {
|
||||
if got, want := GatewayIdentityKey(" gw ", "inst-1", "grpc://one"), "gw|inst-1"; got != want {
|
||||
t.Fatalf("unexpected gateway identity key: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := GatewayIdentityKey("gw", "", " grpc://one "), "gw|grpc://one"; got != want {
|
||||
t.Fatalf("unexpected gateway identity key with invoke fallback: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := GatewayIdentityKey(" gw ", "", ""), "gw"; got != want {
|
||||
t.Fatalf("unexpected gateway identity key with id only: got=%q want=%q", got, want)
|
||||
}
|
||||
if got := GatewayIdentityKey("", "inst-1", "grpc://one"); got != "" {
|
||||
t.Fatalf("expected empty key when gateway id missing, got=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLessGatewayDescriptor(t *testing.T) {
|
||||
a := &GatewayInstanceDescriptor{ID: "gw", InstanceID: "inst-a", InvokeURI: "grpc://a"}
|
||||
b := &GatewayInstanceDescriptor{ID: "gw", InstanceID: "inst-b", InvokeURI: "grpc://b"}
|
||||
if !LessGatewayDescriptor(a, b) {
|
||||
t.Fatalf("expected inst-a to sort before inst-b")
|
||||
}
|
||||
if LessGatewayDescriptor(b, a) {
|
||||
t.Fatalf("expected inst-b not to sort before inst-a")
|
||||
}
|
||||
}
|
||||
@@ -278,17 +278,19 @@ type ExecutionRefs struct {
|
||||
|
||||
// PaymentStep is an explicit action within a payment plan.
|
||||
type PaymentStep struct {
|
||||
StepID string `bson:"stepId,omitempty" json:"stepId,omitempty"`
|
||||
Rail Rail `bson:"rail" json:"rail"`
|
||||
GatewayID string `bson:"gatewayId,omitempty" json:"gatewayId,omitempty"`
|
||||
InstanceID string `bson:"instanceId,omitempty" json:"instanceId,omitempty"`
|
||||
Action RailOperation `bson:"action" json:"action"`
|
||||
DependsOn []string `bson:"dependsOn,omitempty" json:"dependsOn,omitempty"`
|
||||
CommitPolicy CommitPolicy `bson:"commitPolicy,omitempty" json:"commitPolicy,omitempty"`
|
||||
CommitAfter []string `bson:"commitAfter,omitempty" json:"commitAfter,omitempty"`
|
||||
Amount *paymenttypes.Money `bson:"amount,omitempty" json:"amount,omitempty"`
|
||||
FromRole *account_role.AccountRole `bson:"fromRole,omitempty" json:"fromRole,omitempty"`
|
||||
ToRole *account_role.AccountRole `bson:"toRole,omitempty" json:"toRole,omitempty"`
|
||||
StepID string `bson:"stepId,omitempty" json:"stepId,omitempty"`
|
||||
Rail Rail `bson:"rail" json:"rail"`
|
||||
GatewayID string `bson:"gatewayId,omitempty" json:"gatewayId,omitempty"`
|
||||
InstanceID string `bson:"instanceId,omitempty" json:"instanceId,omitempty"`
|
||||
GatewayInvokeURI string `bson:"gatewayInvokeUri,omitempty" json:"gatewayInvokeUri,omitempty"`
|
||||
Action RailOperation `bson:"action" json:"action"`
|
||||
ReportVisibility ReportVisibility `bson:"reportVisibility,omitempty" json:"reportVisibility,omitempty"`
|
||||
DependsOn []string `bson:"dependsOn,omitempty" json:"dependsOn,omitempty"`
|
||||
CommitPolicy CommitPolicy `bson:"commitPolicy,omitempty" json:"commitPolicy,omitempty"`
|
||||
CommitAfter []string `bson:"commitAfter,omitempty" json:"commitAfter,omitempty"`
|
||||
Amount *paymenttypes.Money `bson:"amount,omitempty" json:"amount,omitempty"`
|
||||
FromRole *account_role.AccountRole `bson:"fromRole,omitempty" json:"fromRole,omitempty"`
|
||||
ToRole *account_role.AccountRole `bson:"toRole,omitempty" json:"toRole,omitempty"`
|
||||
}
|
||||
|
||||
// PaymentPlan captures the ordered list of steps to execute a payment.
|
||||
@@ -311,6 +313,7 @@ type ExecutionStep struct {
|
||||
DestinationRef string `bson:"destinationRef,omitempty" json:"destinationRef,omitempty"`
|
||||
TransferRef string `bson:"transferRef,omitempty" json:"transferRef,omitempty"`
|
||||
OperationRef string `bson:"operationRef,omitempty" json:"operationRef,omitempty"`
|
||||
ReportVisibility ReportVisibility `bson:"reportVisibility,omitempty" json:"reportVisibility,omitempty"`
|
||||
Error string `bson:"error,omitempty" json:"error,omitempty"`
|
||||
State OperationState `bson:"state,omitempty" json:"state,omitempty"`
|
||||
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
|
||||
@@ -437,6 +440,7 @@ func (p *Payment) Normalize() {
|
||||
step.SourceWalletRef = strings.TrimSpace(step.SourceWalletRef)
|
||||
step.DestinationRef = strings.TrimSpace(step.DestinationRef)
|
||||
step.TransferRef = strings.TrimSpace(step.TransferRef)
|
||||
step.ReportVisibility = NormalizeReportVisibility(step.ReportVisibility)
|
||||
if step.Metadata != nil {
|
||||
for k, v := range step.Metadata {
|
||||
step.Metadata[k] = strings.TrimSpace(v)
|
||||
@@ -455,7 +459,9 @@ func (p *Payment) Normalize() {
|
||||
step.Rail = Rail(strings.TrimSpace(string(step.Rail)))
|
||||
step.GatewayID = strings.TrimSpace(step.GatewayID)
|
||||
step.InstanceID = strings.TrimSpace(step.InstanceID)
|
||||
step.GatewayInvokeURI = strings.TrimSpace(step.GatewayInvokeURI)
|
||||
step.Action = RailOperation(strings.TrimSpace(string(step.Action)))
|
||||
step.ReportVisibility = NormalizeReportVisibility(step.ReportVisibility)
|
||||
step.CommitPolicy = normalizeCommitPolicy(step.CommitPolicy)
|
||||
step.DependsOn = normalizeStringList(step.DependsOn)
|
||||
step.CommitAfter = normalizeStringList(step.CommitAfter)
|
||||
|
||||
@@ -13,6 +13,7 @@ type OrchestrationStep struct {
|
||||
StepID string `bson:"stepId" json:"stepId"`
|
||||
Rail Rail `bson:"rail" json:"rail"`
|
||||
Operation string `bson:"operation" json:"operation"`
|
||||
ReportVisibility ReportVisibility `bson:"reportVisibility,omitempty" json:"reportVisibility,omitempty"`
|
||||
DependsOn []string `bson:"dependsOn,omitempty" json:"dependsOn,omitempty"`
|
||||
CommitPolicy CommitPolicy `bson:"commitPolicy,omitempty" json:"commitPolicy,omitempty"`
|
||||
CommitAfter []string `bson:"commitAfter,omitempty" json:"commitAfter,omitempty"`
|
||||
@@ -52,6 +53,7 @@ func (t *PaymentPlanTemplate) Normalize() {
|
||||
step.StepID = strings.TrimSpace(step.StepID)
|
||||
step.Rail = Rail(strings.ToUpper(strings.TrimSpace(string(step.Rail))))
|
||||
step.Operation = strings.ToLower(strings.TrimSpace(step.Operation))
|
||||
step.ReportVisibility = NormalizeReportVisibility(step.ReportVisibility)
|
||||
step.CommitPolicy = normalizeCommitPolicy(step.CommitPolicy)
|
||||
step.DependsOn = normalizeStringList(step.DependsOn)
|
||||
step.CommitAfter = normalizeStringList(step.CommitAfter)
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
package model
|
||||
|
||||
// QuoteKind captures v2 quote kind metadata for persistence.
|
||||
type QuoteKind string
|
||||
// QuoteState captures v2 quote state metadata for persistence.
|
||||
type QuoteState string
|
||||
|
||||
const (
|
||||
QuoteKindUnspecified QuoteKind = "unspecified"
|
||||
QuoteKindExecutable QuoteKind = "executable"
|
||||
QuoteKindIndicative QuoteKind = "indicative"
|
||||
)
|
||||
|
||||
// QuoteLifecycle captures v2 quote lifecycle metadata for persistence.
|
||||
type QuoteLifecycle string
|
||||
|
||||
const (
|
||||
QuoteLifecycleUnspecified QuoteLifecycle = "unspecified"
|
||||
QuoteLifecycleActive QuoteLifecycle = "active"
|
||||
QuoteLifecycleExpired QuoteLifecycle = "expired"
|
||||
QuoteStateUnspecified QuoteState = "unspecified"
|
||||
QuoteStateIndicative QuoteState = "indicative"
|
||||
QuoteStateExecutable QuoteState = "executable"
|
||||
QuoteStateBlocked QuoteState = "blocked"
|
||||
QuoteStateExpired QuoteState = "expired"
|
||||
)
|
||||
|
||||
// QuoteBlockReason captures v2 non-executability reason for persistence.
|
||||
@@ -34,8 +27,6 @@ const (
|
||||
|
||||
// QuoteStatusV2 stores execution status metadata from quotation v2.
|
||||
type QuoteStatusV2 struct {
|
||||
Kind QuoteKind `bson:"kind,omitempty" json:"kind,omitempty"`
|
||||
Lifecycle QuoteLifecycle `bson:"lifecycle,omitempty" json:"lifecycle,omitempty"`
|
||||
Executable *bool `bson:"executable,omitempty" json:"executable,omitempty"`
|
||||
State QuoteState `bson:"state,omitempty" json:"state,omitempty"`
|
||||
BlockReason QuoteBlockReason `bson:"blockReason,omitempty" json:"blockReason,omitempty"`
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user