diff --git a/api/billing/documents/.golangci.yml b/api/billing/documents/.golangci.yml new file mode 100644 index 00000000..f489facb --- /dev/null +++ b/api/billing/documents/.golangci.yml @@ -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: [] diff --git a/api/billing/documents/internal/appversion/version.go b/api/billing/documents/internal/appversion/version.go index 7aaa27b4..65372268 100644 --- a/api/billing/documents/internal/appversion/version.go +++ b/api/billing/documents/internal/appversion/version.go @@ -24,5 +24,6 @@ func Create() version.Printer { BuildDate: BuildDate, Version: Version, } + return vf.Create(&info) } diff --git a/api/billing/documents/internal/docstore/local.go b/api/billing/documents/internal/docstore/local.go index 1544ff94..b1bc79c7 100644 --- a/api/billing/documents/internal/docstore/local.go +++ b/api/billing/documents/internal/docstore/local.go @@ -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 } diff --git a/api/billing/documents/internal/docstore/s3.go b/api/billing/documents/internal/docstore/s3.go index 72db9a3d..b962c231 100644 --- a/api/billing/documents/internal/docstore/s3.go +++ b/api/billing/documents/internal/docstore/s3.go @@ -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) } diff --git a/api/billing/documents/internal/docstore/store.go b/api/billing/documents/internal/docstore/store.go index 458d65f6..9465e4f8 100644 --- a/api/billing/documents/internal/docstore/store.go +++ b/api/billing/documents/internal/docstore/store.go @@ -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") diff --git a/api/billing/documents/internal/server/internal/serverimp.go b/api/billing/documents/internal/server/internal/serverimp.go index 46dd4315..07bbe534 100644 --- a/api/billing/documents/internal/server/internal/serverimp.go +++ b/api/billing/documents/internal/server/internal/serverimp.go @@ -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 } diff --git a/api/billing/documents/internal/service/documents/config.go b/api/billing/documents/internal/service/documents/config.go index 5f82d300..73372ed0 100644 --- a/api/billing/documents/internal/service/documents/config.go +++ b/api/billing/documents/internal/service/documents/config.go @@ -29,5 +29,6 @@ func (c Config) AcceptanceTemplatePath() string { if strings.TrimSpace(c.Templates.AcceptancePath) == "" { return "templates/acceptance.tpl" } + return c.Templates.AcceptancePath } diff --git a/api/billing/documents/internal/service/documents/metrics.go b/api/billing/documents/internal/service/documents/metrics.go index c355fb94..1dfaedb9 100644 --- a/api/billing/documents/internal/service/documents/metrics.go +++ b/api/billing/documents/internal/service/documents/metrics.go @@ -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 } diff --git a/api/billing/documents/internal/service/documents/service.go b/api/billing/documents/internal/service/documents/service.go index f2ae9393..1510f0bb 100644 --- a/api/billing/documents/internal/service/documents/service.go +++ b/api/billing/documents/internal/service/documents/service.go @@ -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) } diff --git a/api/billing/documents/internal/service/documents/service_test.go b/api/billing/documents/internal/service/documents/service_test.go index e073d3d7..18d78b53 100644 --- a/api/billing/documents/internal/service/documents/service_test.go +++ b/api/billing/documents/internal/service/documents/service_test.go @@ -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]) } diff --git a/api/billing/documents/internal/service/documents/template.go b/api/billing/documents/internal/service/documents/template.go index 3490f16b..480c02bc 100644 --- a/api/billing/documents/internal/service/documents/template.go +++ b/api/billing/documents/internal/service/documents/template.go @@ -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") } diff --git a/api/billing/documents/internal/service/documents/template_test.go b/api/billing/documents/internal/service/documents/template_test.go index 68e3e50c..8be1695f 100644 --- a/api/billing/documents/internal/service/documents/template_test.go +++ b/api/billing/documents/internal/service/documents/template_test.go @@ -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 } diff --git a/api/billing/documents/renderer/header.go b/api/billing/documents/renderer/header.go index b7920066..fe8b1409 100644 --- a/api/billing/documents/renderer/header.go +++ b/api/billing/documents/renderer/header.go @@ -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 { diff --git a/api/billing/documents/renderer/layout.go b/api/billing/documents/renderer/layout.go index 94e1ad7f..04ae2eef 100644 --- a/api/billing/documents/renderer/layout.go +++ b/api/billing/documents/renderer/layout.go @@ -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 } diff --git a/api/billing/documents/renderer/renderer_test.go b/api/billing/documents/renderer/renderer_test.go index 942de80b..f6b6e63a 100644 --- a/api/billing/documents/renderer/renderer_test.go +++ b/api/billing/documents/renderer/renderer_test.go @@ -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 } diff --git a/api/billing/documents/renderer/tags.go b/api/billing/documents/renderer/tags.go index 4ddd9272..29aa31ea 100644 --- a/api/billing/documents/renderer/tags.go +++ b/api/billing/documents/renderer/tags.go @@ -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 } diff --git a/api/billing/documents/storage/model/document.go b/api/billing/documents/storage/model/document.go index 909969c8..7077dc9f 100644 --- a/api/billing/documents/storage/model/document.go +++ b/api/billing/documents/storage/model/document.go @@ -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{} } diff --git a/api/billing/documents/storage/mongo/repository.go b/api/billing/documents/storage/mongo/repository.go index 624456cc..5f22eda3 100644 --- a/api/billing/documents/storage/mongo/repository.go +++ b/api/billing/documents/storage/mongo/repository.go @@ -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 } diff --git a/api/billing/documents/storage/mongo/store/documents.go b/api/billing/documents/storage/mongo/store/documents.go index 7f1484c1..39075892 100644 --- a/api/billing/documents/storage/mongo/store/documents.go +++ b/api/billing/documents/storage/mongo/store/documents.go @@ -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 } diff --git a/api/discovery/internal/appversion/version.go b/api/discovery/internal/appversion/version.go index dd85e63d..78a1f6b0 100644 --- a/api/discovery/internal/appversion/version.go +++ b/api/discovery/internal/appversion/version.go @@ -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", diff --git a/api/discovery/internal/server/internal/config.go b/api/discovery/internal/server/internal/config.go index 1a4f7177..ab29a0fd 100644 --- a/api/discovery/internal/server/internal/config.go +++ b/api/discovery/internal/server/internal/config.go @@ -1,3 +1,4 @@ +// Package serverimp contains the concrete discovery server implementation. package serverimp import ( diff --git a/api/discovery/internal/server/internal/serverimp.go b/api/discovery/internal/server/internal/serverimp.go index e2e6fc82..d43830a5 100644 --- a/api/discovery/internal/server/internal/serverimp.go +++ b/api/discovery/internal/server/internal/serverimp.go @@ -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)) diff --git a/api/discovery/internal/server/internal/types.go b/api/discovery/internal/server/internal/types.go index 171d338b..bfa10ad1 100644 --- a/api/discovery/internal/server/internal/types.go +++ b/api/discovery/internal/server/internal/types.go @@ -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 diff --git a/api/discovery/internal/server/server.go b/api/discovery/internal/server/server.go index 3b629236..b8a2f948 100644 --- a/api/discovery/internal/server/server.go +++ b/api/discovery/internal/server/server.go @@ -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 diff --git a/api/discovery/main.go b/api/discovery/main.go index 8f824286..8abf7e86 100644 --- a/api/discovery/main.go +++ b/api/discovery/main.go @@ -1,3 +1,4 @@ +// Package main is the entry point for the discovery service. package main import ( diff --git a/api/gateway/chain/go.mod b/api/gateway/chain/go.mod index f120e4c6..6b2dc4b4 100644 --- a/api/gateway/chain/go.mod +++ b/api/gateway/chain/go.mod @@ -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 diff --git a/api/gateway/chain/go.sum b/api/gateway/chain/go.sum index 648de7bb..18c45618 100644 --- a/api/gateway/chain/go.sum +++ b/api/gateway/chain/go.sum @@ -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= diff --git a/api/gateway/chain/internal/service/gateway/service.go b/api/gateway/chain/internal/service/gateway/service.go index 9fac15eb..9e49112f 100644 --- a/api/gateway/chain/internal/service/gateway/service.go +++ b/api/gateway/chain/internal/service/gateway/service.go @@ -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"}, diff --git a/api/gateway/mntx/internal/service/gateway/service.go b/api/gateway/mntx/internal/service/gateway/service.go index 32ef2d0d..3205e7ed 100644 --- a/api/gateway/mntx/internal/service/gateway/service.go +++ b/api/gateway/mntx/internal/service/gateway/service.go @@ -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() } diff --git a/api/gateway/tgsettle/internal/service/gateway/service.go b/api/gateway/tgsettle/internal/service/gateway/service.go index 92c22c06..aac58761 100644 --- a/api/gateway/tgsettle/internal/service/gateway/service.go +++ b/api/gateway/tgsettle/internal/service/gateway/service.go @@ -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, diff --git a/api/gateway/tron/go.mod b/api/gateway/tron/go.mod index f8b30314..092810f6 100644 --- a/api/gateway/tron/go.mod +++ b/api/gateway/tron/go.mod @@ -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 diff --git a/api/gateway/tron/go.sum b/api/gateway/tron/go.sum index 923d354b..c1136877 100644 --- a/api/gateway/tron/go.sum +++ b/api/gateway/tron/go.sum @@ -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= diff --git a/api/gateway/tron/internal/service/gateway/service.go b/api/gateway/tron/internal/service/gateway/service.go index c3fca4e5..35c7b30f 100644 --- a/api/gateway/tron/internal/service/gateway/service.go +++ b/api/gateway/tron/internal/service/gateway/service.go @@ -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"}, diff --git a/api/ledger/storage/mongo/store/outbox.go b/api/ledger/storage/mongo/store/outbox.go index 9170ba47..65376f76 100644 --- a/api/ledger/storage/mongo/store/outbox.go +++ b/api/ledger/storage/mongo/store/outbox.go @@ -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 } diff --git a/api/payments/orchestrator/go.sum b/api/payments/orchestrator/go.sum index e65b798e..cfb95db4 100644 --- a/api/payments/orchestrator/go.sum +++ b/api/payments/orchestrator/go.sum @@ -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= diff --git a/api/payments/orchestrator/internal/service/orchestrator/composite_gateway_registry.go b/api/payments/orchestrator/internal/service/orchestrator/composite_gateway_registry.go index d2a266a5..5e3f3609 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/composite_gateway_registry.go +++ b/api/payments/orchestrator/internal/service/orchestrator/composite_gateway_registry.go @@ -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 } diff --git a/api/payments/orchestrator/internal/service/orchestrator/discovery_gateway_registry.go b/api/payments/orchestrator/internal/service/orchestrator/discovery_gateway_registry.go index 9d2792e8..6485b4ef 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/discovery_gateway_registry.go +++ b/api/payments/orchestrator/internal/service/orchestrator/discovery_gateway_registry.go @@ -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 } diff --git a/api/payments/orchestrator/internal/service/orchestrator/gateway_registry.go b/api/payments/orchestrator/internal/service/orchestrator/gateway_registry.go index 9abf3673..19b60785 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/gateway_registry.go +++ b/api/payments/orchestrator/internal/service/orchestrator/gateway_registry.go @@ -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 } diff --git a/api/payments/orchestrator/internal/service/orchestrator/gateway_registry_identity_test.go b/api/payments/orchestrator/internal/service/orchestrator/gateway_registry_identity_test.go new file mode 100644 index 00000000..adf3ad86 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/gateway_registry_identity_test.go @@ -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) + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/gateway_resolution.go b/api/payments/orchestrator/internal/service/orchestrator/gateway_resolution.go index 68229a10..b92d92b7 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/gateway_resolution.go +++ b/api/payments/orchestrator/internal/service/orchestrator/gateway_resolution.go @@ -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 } diff --git a/api/payments/orchestrator/internal/service/orchestrator/options.go b/api/payments/orchestrator/internal/service/orchestrator/options.go index 3e8db49b..691e62bf 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/options.go +++ b/api/payments/orchestrator/internal/service/orchestrator/options.go @@ -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)), diff --git a/api/payments/orchestrator/internal/service/orchestrator/options_rail_gateway_test.go b/api/payments/orchestrator/internal/service/orchestrator/options_rail_gateway_test.go new file mode 100644 index 00000000..11aac9df --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/options_rail_gateway_test.go @@ -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) + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_storage.go b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_storage.go index c77fb794..2cf52afd 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_storage.go +++ b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_storage.go @@ -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) } diff --git a/api/payments/orchestrator/internal/service/plan_builder/gateways.go b/api/payments/orchestrator/internal/service/plan_builder/gateways.go index 133e1148..5316ebb0 100644 --- a/api/payments/orchestrator/internal/service/plan_builder/gateways.go +++ b/api/payments/orchestrator/internal/service/plan_builder/gateways.go @@ -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 } diff --git a/api/payments/orchestrator/internal/service/plan_builder/plans.go b/api/payments/orchestrator/internal/service/plan_builder/plans.go index 882e82e2..61e41b5f 100644 --- a/api/payments/orchestrator/internal/service/plan_builder/plans.go +++ b/api/payments/orchestrator/internal/service/plan_builder/plans.go @@ -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, diff --git a/api/payments/orchestrator/internal/service/plan_builder/steps.go b/api/payments/orchestrator/internal/service/plan_builder/steps.go index 2068e6e2..f1d0bbad 100644 --- a/api/payments/orchestrator/internal/service/plan_builder/steps.go +++ b/api/payments/orchestrator/internal/service/plan_builder/steps.go @@ -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", diff --git a/api/payments/orchestrator/internal/service/plan_builder/templates.go b/api/payments/orchestrator/internal/service/plan_builder/templates.go index cf1e7d48..3ec399cc 100644 --- a/api/payments/orchestrator/internal/service/plan_builder/templates.go +++ b/api/payments/orchestrator/internal/service/plan_builder/templates.go @@ -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), diff --git a/api/payments/quotation/go.sum b/api/payments/quotation/go.sum index 6978c968..254466a3 100644 --- a/api/payments/quotation/go.sum +++ b/api/payments/quotation/go.sum @@ -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= diff --git a/api/payments/quotation/internal/service/plan/plan_builder_gateways.go b/api/payments/quotation/internal/service/plan/plan_builder_gateways.go index de3d083c..ffa34496 100644 --- a/api/payments/quotation/internal/service/plan/plan_builder_gateways.go +++ b/api/payments/quotation/internal/service/plan/plan_builder_gateways.go @@ -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 } diff --git a/api/payments/quotation/internal/service/plan/plan_builder_plans.go b/api/payments/quotation/internal/service/plan/plan_builder_plans.go index c5ebe022..df2e6d2e 100644 --- a/api/payments/quotation/internal/service/plan/plan_builder_plans.go +++ b/api/payments/quotation/internal/service/plan/plan_builder_plans.go @@ -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, diff --git a/api/payments/quotation/internal/service/plan/plan_builder_steps.go b/api/payments/quotation/internal/service/plan/plan_builder_steps.go index f801178d..8e7396fe 100644 --- a/api/payments/quotation/internal/service/plan/plan_builder_steps.go +++ b/api/payments/quotation/internal/service/plan/plan_builder_steps.go @@ -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", diff --git a/api/payments/quotation/internal/service/plan/plan_builder_templates.go b/api/payments/quotation/internal/service/plan/plan_builder_templates.go index c0238de1..cb4fee24 100644 --- a/api/payments/quotation/internal/service/plan/plan_builder_templates.go +++ b/api/payments/quotation/internal/service/plan/plan_builder_templates.go @@ -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), diff --git a/api/payments/quotation/internal/service/quotation/composite_gateway_registry.go b/api/payments/quotation/internal/service/quotation/composite_gateway_registry.go index 0b393d3b..01cc8dc0 100644 --- a/api/payments/quotation/internal/service/quotation/composite_gateway_registry.go +++ b/api/payments/quotation/internal/service/quotation/composite_gateway_registry.go @@ -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 } diff --git a/api/payments/quotation/internal/service/quotation/discovery_gateway_registry.go b/api/payments/quotation/internal/service/quotation/discovery_gateway_registry.go index 129f0131..41905588 100644 --- a/api/payments/quotation/internal/service/quotation/discovery_gateway_registry.go +++ b/api/payments/quotation/internal/service/quotation/discovery_gateway_registry.go @@ -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 } diff --git a/api/payments/quotation/internal/service/quotation/gateway_registry.go b/api/payments/quotation/internal/service/quotation/gateway_registry.go index cc7b5482..f41cdc02 100644 --- a/api/payments/quotation/internal/service/quotation/gateway_registry.go +++ b/api/payments/quotation/internal/service/quotation/gateway_registry.go @@ -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 } diff --git a/api/payments/quotation/internal/service/quotation/gateway_registry_identity_test.go b/api/payments/quotation/internal/service/quotation/gateway_registry_identity_test.go new file mode 100644 index 00000000..6e4b4abe --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/gateway_registry_identity_test.go @@ -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) + } +} diff --git a/api/payments/quotation/internal/service/quotation/gateway_resolution.go b/api/payments/quotation/internal/service/quotation/gateway_resolution.go index 4934ea06..b43677ac 100644 --- a/api/payments/quotation/internal/service/quotation/gateway_resolution.go +++ b/api/payments/quotation/internal/service/quotation/gateway_resolution.go @@ -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 } diff --git a/api/payments/quotation/internal/service/quotation/graph_path_finder/adjacency.go b/api/payments/quotation/internal/service/quotation/graph_path_finder/adjacency.go new file mode 100644 index 00000000..a3dc12c5 --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/graph_path_finder/adjacency.go @@ -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)) +} diff --git a/api/payments/quotation/internal/service/quotation/graph_path_finder/input.go b/api/payments/quotation/internal/service/quotation/graph_path_finder/input.go new file mode 100644 index 00000000..c6c7ef8f --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/graph_path_finder/input.go @@ -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 +} diff --git a/api/payments/quotation/internal/service/quotation/graph_path_finder/search.go b/api/payments/quotation/internal/service/quotation/graph_path_finder/search.go new file mode 100644 index 00000000..56bded34 --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/graph_path_finder/search.go @@ -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] + } +} diff --git a/api/payments/quotation/internal/service/quotation/graph_path_finder/service.go b/api/payments/quotation/internal/service/quotation/graph_path_finder/service.go new file mode 100644 index 00000000..c93397ce --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/graph_path_finder/service.go @@ -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 +} diff --git a/api/payments/quotation/internal/service/quotation/graph_path_finder/service_network_test.go b/api/payments/quotation/internal/service/quotation/graph_path_finder/service_network_test.go new file mode 100644 index 00000000..cd917aa3 --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/graph_path_finder/service_network_test.go @@ -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"}) +} diff --git a/api/payments/quotation/internal/service/quotation/graph_path_finder/service_test.go b/api/payments/quotation/internal/service/quotation/graph_path_finder/service_test.go new file mode 100644 index 00000000..711cb92a --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/graph_path_finder/service_test.go @@ -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 +} diff --git a/api/payments/quotation/internal/service/quotation/payment_plan_factory.go b/api/payments/quotation/internal/service/quotation/payment_plan_factory.go index 8b67373c..c171c54c 100644 --- a/api/payments/quotation/internal/service/quotation/payment_plan_factory.go +++ b/api/payments/quotation/internal/service/quotation/payment_plan_factory.go @@ -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) } diff --git a/api/payments/quotation/internal/service/quotation/quotation_service_v2/converters.go b/api/payments/quotation/internal/service/quotation/quotation_service_v2/converters.go index 1bcbfb13..7f8c9dee 100644 --- a/api/payments/quotation/internal/service/quotation/quotation_service_v2/converters.go +++ b/api/payments/quotation/internal/service/quotation/quotation_service_v2/converters.go @@ -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 -} diff --git a/api/payments/quotation/internal/service/quotation/quotation_service_v2/helpers.go b/api/payments/quotation/internal/service/quotation/quotation_service_v2/helpers.go index cdea403f..b092331d 100644 --- a/api/payments/quotation/internal/service/quotation/quotation_service_v2/helpers.go +++ b/api/payments/quotation/internal/service/quotation/quotation_service_v2/helpers.go @@ -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 { diff --git a/api/payments/quotation/internal/service/quotation/quotation_service_v2/route_conditions_converters.go b/api/payments/quotation/internal/service/quotation/quotation_service_v2/route_conditions_converters.go index 077632ec..b04e79f8 100644 --- a/api/payments/quotation/internal/service/quotation/quotation_service_v2/route_conditions_converters.go +++ b/api/payments/quotation/internal/service/quotation/quotation_service_v2/route_conditions_converters.go @@ -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 diff --git a/api/payments/quotation/internal/service/quotation/quotation_service_v2/service.go b/api/payments/quotation/internal/service/quotation/quotation_service_v2/service.go index f2732282..ab3554ff 100644 --- a/api/payments/quotation/internal/service/quotation/quotation_service_v2/service.go +++ b/api/payments/quotation/internal/service/quotation/quotation_service_v2/service.go @@ -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) } diff --git a/api/payments/quotation/internal/service/quotation/quotation_service_v2/service_e2e_test.go b/api/payments/quotation/internal/service/quotation/quotation_service_v2/service_e2e_test.go index e5e6fa24..173a7b4a 100644 --- a/api/payments/quotation/internal/service/quotation/quotation_service_v2/service_e2e_test.go +++ b/api/payments/quotation/internal/service/quotation/quotation_service_v2/service_e2e_test.go @@ -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 diff --git a/api/payments/quotation/internal/service/quotation/quotation_service_v2/single_processor.go b/api/payments/quotation/internal/service/quotation/quotation_service_v2/single_processor.go index b4a9610a..14f69a45 100644 --- a/api/payments/quotation/internal/service/quotation/quotation_service_v2/single_processor.go +++ b/api/payments/quotation/internal/service/quotation/quotation_service_v2/single_processor.go @@ -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{ diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/compute_test.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/compute_test.go index 4e4178dc..045f2f7b 100644 --- a/api/payments/quotation/internal/service/quotation/quote_computation_service/compute_test.go +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/compute_test.go @@ -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") diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/computed_quote_enricher.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/computed_quote_enricher.go index d46d6f21..176cfda0 100644 --- a/api/payments/quotation/internal/service/quotation/quote_computation_service/computed_quote_enricher.go +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/computed_quote_enricher.go @@ -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 diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/gateway_selector.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/gateway_selector.go index ff2a684b..0f1ee9d7 100644 --- a/api/payments/quotation/internal/service/quotation/quote_computation_service/gateway_selector.go +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/gateway_selector.go @@ -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 { diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/gateway_selector_test.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/gateway_selector_test.go new file mode 100644 index 00000000..5658713e --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/gateway_selector_test.go @@ -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) + } +} diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/plan.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/plan.go index 3b4c95a5..3f71c436 100644 --- a/api/payments/quotation/internal/service/quotation/quote_computation_service/plan.go +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/plan.go @@ -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 diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/planner.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/planner.go index 48b7a971..4dccb7de 100644 --- a/api/payments/quotation/internal/service/quotation/quote_computation_service/planner.go +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/planner.go @@ -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{ diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/planner_path_finding.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/planner_path_finding.go new file mode 100644 index 00000000..c2f27722 --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/planner_path_finding.go @@ -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} +} diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/planner_path_finding_test.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/planner_path_finding_test.go new file mode 100644 index 00000000..16ae2c9d --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/planner_path_finding_test.go @@ -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 +} diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/planner_steps.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/planner_steps.go index f60ddc8c..f052318a 100644 --- a/api/payments/quotation/internal/service/quotation/quote_computation_service/planner_steps.go +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/planner_steps.go @@ -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 diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/quote_binding_validation.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/quote_binding_validation.go index 5e1bb077..8ae9b9fa 100644 --- a/api/payments/quotation/internal/service/quotation/quote_computation_service/quote_binding_validation.go +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/quote_binding_validation.go @@ -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)) } diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/route_settlement.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/route_settlement.go new file mode 100644 index 00000000..02da488a --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/route_settlement.go @@ -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 "" +} diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/route_spec_builder.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/route_spec_builder.go index 59067016..3228fd53 100644 --- a/api/payments/quotation/internal/service/quotation/quote_computation_service/route_spec_builder.go +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/route_spec_builder.go @@ -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, "|") } diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/service.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/service.go index 1582acfd..beee457e 100644 --- a/api/payments/quotation/internal/service/quotation/quote_computation_service/service.go +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/service.go @@ -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 + } + } +} diff --git a/api/payments/quotation/internal/service/quotation/quote_executability_classifier/quote_executability_classifier.go b/api/payments/quotation/internal/service/quotation/quote_executability_classifier/quote_executability_classifier.go index 27d3c830..5e3eada7 100644 --- a/api/payments/quotation/internal/service/quotation/quote_executability_classifier/quote_executability_classifier.go +++ b/api/payments/quotation/internal/service/quotation/quote_executability_classifier/quote_executability_classifier.go @@ -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, } } diff --git a/api/payments/quotation/internal/service/quotation/quote_executability_classifier/quote_executability_classifier_test.go b/api/payments/quotation/internal/service/quotation/quote_executability_classifier/quote_executability_classifier_test.go index 481bdb69..43d23d05 100644 --- a/api/payments/quotation/internal/service/quotation/quote_executability_classifier/quote_executability_classifier_test.go +++ b/api/payments/quotation/internal/service/quotation/quote_executability_classifier/quote_executability_classifier_test.go @@ -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()) } diff --git a/api/payments/quotation/internal/service/quotation/quote_persistence_service/helpers.go b/api/payments/quotation/internal/service/quotation/quote_persistence_service/helpers.go index bad50681..0c271617 100644 --- a/api/payments/quotation/internal/service/quotation/quote_persistence_service/helpers.go +++ b/api/payments/quotation/internal/service/quotation/quote_persistence_service/helpers.go @@ -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) } diff --git a/api/payments/quotation/internal/service/quotation/quote_persistence_service/input.go b/api/payments/quotation/internal/service/quotation/quote_persistence_service/input.go index 1f65377e..cc2da92b 100644 --- a/api/payments/quotation/internal/service/quotation/quote_persistence_service/input.go +++ b/api/payments/quotation/internal/service/quotation/quote_persistence_service/input.go @@ -9,9 +9,7 @@ import ( ) type StatusInput struct { - Kind quotationv2.QuoteKind - Lifecycle quotationv2.QuoteLifecycle - Executable *bool + State quotationv2.QuoteState BlockReason quotationv2.QuoteBlockReason } diff --git a/api/payments/quotation/internal/service/quotation/quote_persistence_service/service_test.go b/api/payments/quotation/internal/service/quotation/quote_persistence_service/service_test.go index 8123e5d0..053623af 100644 --- a/api/payments/quotation/internal/service/quotation/quote_persistence_service/service_test.go +++ b/api/payments/quotation/internal/service/quotation/quote_persistence_service/service_test.go @@ -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 -} diff --git a/api/payments/quotation/internal/service/quotation/quote_persistence_service/status_mapper.go b/api/payments/quotation/internal/service/quotation/quote_persistence_service/status_mapper.go index a23658ef..6bd1b14e 100644 --- a/api/payments/quotation/internal/service/quotation/quote_persistence_service/status_mapper.go +++ b/api/payments/quotation/internal/service/quotation/quote_persistence_service/status_mapper.go @@ -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 } } diff --git a/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/helpers.go b/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/helpers.go index 732bfd30..912f2896 100644 --- a/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/helpers.go +++ b/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/helpers.go @@ -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 diff --git a/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/input.go b/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/input.go index ed9afb92..6f7b51db 100644 --- a/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/input.go +++ b/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/input.go @@ -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 } diff --git a/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/invariants.go b/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/invariants.go index 2ef57e8b..73597da3 100644 --- a/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/invariants.go +++ b/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/invariants.go @@ -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 -} diff --git a/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/service.go b/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/service.go index e6f78381..0f1a5f9e 100644 --- a/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/service.go +++ b/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/service.go @@ -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 } diff --git a/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/service_test.go b/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/service_test.go index c4b99d35..d1464312 100644 --- a/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/service_test.go +++ b/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/service_test.go @@ -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) } } diff --git a/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/endpoint_resolver.go b/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/endpoint_resolver.go index 477b2e87..d14be17e 100644 --- a/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/endpoint_resolver.go +++ b/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/endpoint_resolver.go @@ -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. diff --git a/api/payments/storage/model/gateway_affinity.go b/api/payments/storage/model/gateway_affinity.go new file mode 100644 index 00000000..e9d97f74 --- /dev/null +++ b/api/payments/storage/model/gateway_affinity.go @@ -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 + } +} diff --git a/api/payments/storage/model/gateway_affinity_identity_test.go b/api/payments/storage/model/gateway_affinity_identity_test.go new file mode 100644 index 00000000..82323d58 --- /dev/null +++ b/api/payments/storage/model/gateway_affinity_identity_test.go @@ -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") + } +} diff --git a/api/payments/storage/model/payment.go b/api/payments/storage/model/payment.go index 08780819..b21b55cf 100644 --- a/api/payments/storage/model/payment.go +++ b/api/payments/storage/model/payment.go @@ -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) diff --git a/api/payments/storage/model/plan_template.go b/api/payments/storage/model/plan_template.go index 90a652c0..74329cc4 100644 --- a/api/payments/storage/model/plan_template.go +++ b/api/payments/storage/model/plan_template.go @@ -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) diff --git a/api/payments/storage/model/quote_v2.go b/api/payments/storage/model/quote_v2.go index a21c5a92..87cdeeb8 100644 --- a/api/payments/storage/model/quote_v2.go +++ b/api/payments/storage/model/quote_v2.go @@ -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"` } diff --git a/api/payments/storage/model/report_visibility.go b/api/payments/storage/model/report_visibility.go new file mode 100644 index 00000000..a2d72012 --- /dev/null +++ b/api/payments/storage/model/report_visibility.go @@ -0,0 +1,44 @@ +package model + +import "strings" + +// ReportVisibility controls which audience should see a step in reports/timelines. +type ReportVisibility string + +const ( + ReportVisibilityUnspecified ReportVisibility = "" + ReportVisibilityHidden ReportVisibility = "hidden" + ReportVisibilityUser ReportVisibility = "user" + ReportVisibilityBackoffice ReportVisibility = "backoffice" + ReportVisibilityAudit ReportVisibility = "audit" +) + +// NormalizeReportVisibility trims and lowercases the visibility value. +func NormalizeReportVisibility(value ReportVisibility) ReportVisibility { + return ReportVisibility(strings.ToLower(strings.TrimSpace(string(value)))) +} + +// IsValidReportVisibility reports whether the value is a supported enum variant. +func IsValidReportVisibility(value ReportVisibility) bool { + switch NormalizeReportVisibility(value) { + case ReportVisibilityUnspecified, + ReportVisibilityHidden, + ReportVisibilityUser, + ReportVisibilityBackoffice, + ReportVisibilityAudit: + return true + default: + return false + } +} + +// IsUserVisible returns true when the step should be shown to end users. +// Unspecified is treated as user-visible for backward compatibility. +func (value ReportVisibility) IsUserVisible() bool { + switch NormalizeReportVisibility(value) { + case ReportVisibilityUnspecified, ReportVisibilityUser: + return true + default: + return false + } +} diff --git a/api/pkg/discovery/gatewayid.go b/api/pkg/discovery/gatewayid.go new file mode 100644 index 00000000..f4656591 --- /dev/null +++ b/api/pkg/discovery/gatewayid.go @@ -0,0 +1,27 @@ +package discovery + +import "strings" + +// StableGatewayID composes a stable discovery entry ID from a base prefix and key. +func StableGatewayID(prefix string, key string) string { + cleanPrefix := strings.ToLower(strings.TrimSpace(prefix)) + cleanKey := strings.ToLower(strings.TrimSpace(key)) + if cleanKey == "" { + cleanKey = "unknown" + } + if cleanPrefix == "" { + return cleanKey + } + if strings.HasSuffix(cleanPrefix, "_") { + return cleanPrefix + cleanKey + } + return cleanPrefix + "_" + cleanKey +} + +func StableCryptoRailGatewayID(network string) string { + return StableGatewayID("crypto_rail_gateway", network) +} + +func StablePaymentGatewayID(rail string) string { + return StableGatewayID("payment_gateway", rail) +} diff --git a/api/pkg/discovery/gatewayid_test.go b/api/pkg/discovery/gatewayid_test.go new file mode 100644 index 00000000..fd30e453 --- /dev/null +++ b/api/pkg/discovery/gatewayid_test.go @@ -0,0 +1,44 @@ +package discovery + +import "testing" + +func TestStableGatewayID(t *testing.T) { + cases := []struct { + name string + prefix string + key string + want string + }{ + {name: "prefix and key", prefix: "crypto_rail_gateway", key: " TRON ", want: "crypto_rail_gateway_tron"}, + {name: "prefix trailing underscore", prefix: "payment_gateway_", key: " PROVIDER_SETTLEMENT ", want: "payment_gateway_provider_settlement"}, + {name: "missing key", prefix: "payment_gateway", key: " ", want: "payment_gateway_unknown"}, + {name: "missing prefix", prefix: " ", key: "TRON", want: "tron"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := StableGatewayID(tc.prefix, tc.key) + if got != tc.want { + t.Fatalf("unexpected stable gateway id: got=%q want=%q", got, tc.want) + } + }) + } +} + +func TestStableCryptoRailGatewayID(t *testing.T) { + if got, want := StableCryptoRailGatewayID(" TRON "), "crypto_rail_gateway_tron"; got != want { + t.Fatalf("unexpected stable id: got=%q want=%q", got, want) + } + if got, want := StableCryptoRailGatewayID(""), "crypto_rail_gateway_unknown"; got != want { + t.Fatalf("unexpected stable id for empty network: got=%q want=%q", got, want) + } +} + +func TestStablePaymentGatewayID(t *testing.T) { + if got, want := StablePaymentGatewayID(" PROVIDER_SETTLEMENT "), "payment_gateway_provider_settlement"; got != want { + t.Fatalf("unexpected stable id: got=%q want=%q", got, want) + } + if got, want := StablePaymentGatewayID(""), "payment_gateway_unknown"; got != want { + t.Fatalf("unexpected stable id for empty rail: got=%q want=%q", got, want) + } +} diff --git a/api/pkg/model/chainasset.go b/api/pkg/model/chainasset.go index f98b1b40..075ce04d 100644 --- a/api/pkg/model/chainasset.go +++ b/api/pkg/model/chainasset.go @@ -16,11 +16,11 @@ type ChainAsset struct { } type ChainAssetDescription struct { - storable.Storable `bson:",inline" json:",inline"` - Describable `bson:",inline" json:",inline"` - Asset ChainAsset `bson:"asset" json:"asset"` + storable.Base `bson:",inline" json:",inline"` + Describable `bson:",inline" json:",inline"` + Asset ChainAsset `bson:"asset" json:"asset"` } -func Collection(*ChainAssetDescription) mservice.Type { +func (*ChainAssetDescription) Collection() string { return mservice.ChainAssets } diff --git a/api/pkg/model/chainasset_test.go b/api/pkg/model/chainasset_test.go new file mode 100644 index 00000000..1de75361 --- /dev/null +++ b/api/pkg/model/chainasset_test.go @@ -0,0 +1,21 @@ +package model + +import ( + "testing" + + "github.com/tech/sendico/pkg/db/storable" + "github.com/tech/sendico/pkg/mservice" +) + +func TestChainAssetDescriptionImplementsStorable(t *testing.T) { + var _ storable.Storable = (*ChainAssetDescription)(nil) +} + +func TestChainAssetDescriptionCollection(t *testing.T) { + var desc ChainAssetDescription + want := string(mservice.ChainAssets) + if got := desc.Collection(); got != want { + t.Fatalf("Collection() = %q, want %q", got, want) + } +} + diff --git a/api/pkg/payments/types/quote_v2.go b/api/pkg/payments/types/quote_v2.go index 7e319b94..432f25d1 100644 --- a/api/pkg/payments/types/quote_v2.go +++ b/api/pkg/payments/types/quote_v2.go @@ -28,18 +28,22 @@ type QuoteRouteHop struct { Role QuoteRouteHopRole `bson:"role,omitempty" json:"role,omitempty"` } +type QuoteRouteSettlement struct { + Asset *Asset `bson:"asset,omitempty" json:"asset,omitempty"` + Model string `bson:"model,omitempty" json:"model,omitempty"` +} + // QuoteRouteSpecification is an abstract route selected during quotation. // It intentionally omits execution steps/operations. type QuoteRouteSpecification struct { - Rail string `bson:"rail,omitempty" json:"rail,omitempty"` - Provider string `bson:"provider,omitempty" json:"provider,omitempty"` - PayoutMethod string `bson:"payoutMethod,omitempty" json:"payoutMethod,omitempty"` - SettlementAsset string `bson:"settlementAsset,omitempty" json:"settlementAsset,omitempty"` - SettlementModel string `bson:"settlementModel,omitempty" json:"settlementModel,omitempty"` - Network string `bson:"network,omitempty" json:"network,omitempty"` - RouteRef string `bson:"routeRef,omitempty" json:"routeRef,omitempty"` - PricingProfileRef string `bson:"pricingProfileRef,omitempty" json:"pricingProfileRef,omitempty"` - Hops []*QuoteRouteHop `bson:"hops,omitempty" json:"hops,omitempty"` + Rail string `bson:"rail,omitempty" json:"rail,omitempty"` + Provider string `bson:"provider,omitempty" json:"provider,omitempty"` + PayoutMethod string `bson:"payoutMethod,omitempty" json:"payoutMethod,omitempty"` + Settlement *QuoteRouteSettlement `bson:"settlement,omitempty" json:"settlement,omitempty"` + Network string `bson:"network,omitempty" json:"network,omitempty"` + RouteRef string `bson:"routeRef,omitempty" json:"routeRef,omitempty"` + PricingProfileRef string `bson:"pricingProfileRef,omitempty" json:"pricingProfileRef,omitempty"` + Hops []*QuoteRouteHop `bson:"hops,omitempty" json:"hops,omitempty"` } // QuoteExecutionConditions stores quotation-time assumptions and constraints. diff --git a/api/pkg/tagdb.test b/api/pkg/tagdb.test deleted file mode 100755 index 11747dcc..00000000 Binary files a/api/pkg/tagdb.test and /dev/null differ diff --git a/api/proto/common/account_role/v1/account_role.proto b/api/proto/common/account_role/v1/account_role.proto index 6e87bdd3..469d8ad0 100644 --- a/api/proto/common/account_role/v1/account_role.proto +++ b/api/proto/common/account_role/v1/account_role.proto @@ -3,17 +3,31 @@ syntax = "proto3"; package common.account_role.v1; option go_package = "github.com/tech/sendico/pkg/proto/common/account_role/v1;accountrolev1"; +// AccountRole classifies the purpose of a ledger account within the +// double-entry accounting model. enum AccountRole { + // ACCOUNT_ROLE_UNSPECIFIED is the default zero value. ACCOUNT_ROLE_UNSPECIFIED = 0; + // OPERATING is the main operational account for day-to-day activity. OPERATING = 1; + // HOLD temporarily locks funds pending settlement or approval. HOLD = 2; + // TRANSIT is an intermediary account for in-flight transfers. TRANSIT = 3; + // SETTLEMENT collects funds awaiting final settlement. SETTLEMENT = 4; + // CLEARING is used during reconciliation and netting cycles. CLEARING = 5; + // PENDING tracks amounts that are authorised but not yet captured. PENDING = 6; + // RESERVE holds funds set aside as collateral or buffer. RESERVE = 7; + // LIQUIDITY pools funds available for outgoing payments. LIQUIDITY = 8; + // FEE accumulates collected fee revenue. FEE = 9; + // CHARGEBACK records reversed or disputed amounts. CHARGEBACK = 10; + // ADJUSTMENT captures manual or system-initiated balance corrections. ADJUSTMENT = 11; } diff --git a/api/proto/common/archivable/v1/archivable.proto b/api/proto/common/archivable/v1/archivable.proto index 2ba21733..90ec3495 100644 --- a/api/proto/common/archivable/v1/archivable.proto +++ b/api/proto/common/archivable/v1/archivable.proto @@ -4,8 +4,8 @@ package common.archivable.v1; option go_package = "github.com/tech/sendico/pkg/proto/common/archivable/v1;archivablev1"; - - +// Archivable is an embeddable fragment that marks a record as soft-deleted. message Archivable { + // is_archived is true when the record has been logically removed. bool is_archived = 1; -} \ No newline at end of file +} diff --git a/api/proto/common/fx/v1/fx.proto b/api/proto/common/fx/v1/fx.proto index 7620cf78..81c2e7b1 100644 --- a/api/proto/common/fx/v1/fx.proto +++ b/api/proto/common/fx/v1/fx.proto @@ -2,13 +2,20 @@ syntax = "proto3"; package common.fx.v1; option go_package = "github.com/tech/sendico/pkg/proto/common/fx/v1;fxv1"; +// CurrencyPair identifies a foreign-exchange pair (e.g. EUR/USD). message CurrencyPair { + // base is the base currency code (ISO 4217). string base = 1; + // quote is the quote (counter) currency code (ISO 4217). string quote = 2; } +// Side indicates the direction of an FX conversion relative to the pair. enum Side { + // SIDE_UNSPECIFIED is the default zero value. SIDE_UNSPECIFIED = 0; + // BUY_BASE_SELL_QUOTE buys the base currency, selling the quote currency. BUY_BASE_SELL_QUOTE = 1; + // SELL_BASE_BUY_QUOTE sells the base currency, buying the quote currency. SELL_BASE_BUY_QUOTE = 2; } diff --git a/api/proto/common/money/v1/money.proto b/api/proto/common/money/v1/money.proto index 6ae28e50..48b84636 100644 --- a/api/proto/common/money/v1/money.proto +++ b/api/proto/common/money/v1/money.proto @@ -2,22 +2,39 @@ syntax = "proto3"; package common.money.v1; option go_package = "github.com/tech/sendico/pkg/proto/common/money/v1;moneyv1"; -message Decimal { string value = 1; } // exact decimal as string - -message Money { - string amount = 1; // decimal string - string currency = 2; // ISO 4217 or your code set +// Decimal represents an exact decimal value encoded as a string to avoid +// floating-point precision loss. +message Decimal { + // value is the decimal string representation (e.g. "123.45"). + string value = 1; } +// Money pairs a decimal amount with a currency code. +message Money { + // amount is the decimal string representation. + string amount = 1; + // currency is the ISO 4217 currency code (e.g. "USD", "EUR"). + string currency = 2; +} + +// RoundingMode specifies how to round monetary calculations. enum RoundingMode { + // ROUNDING_MODE_UNSPECIFIED is the default zero value. ROUNDING_MODE_UNSPECIFIED = 0; + // ROUND_HALF_EVEN rounds to the nearest even digit (banker's rounding). ROUND_HALF_EVEN = 1; + // ROUND_HALF_UP rounds halves away from zero. ROUND_HALF_UP = 2; + // ROUND_DOWN truncates towards zero. ROUND_DOWN = 3; } +// CurrencyMeta describes the precision and rounding rules for a currency. message CurrencyMeta { + // code is the ISO 4217 currency code. string code = 1; + // decimals is the number of minor-unit digits (e.g. 2 for USD, 0 for JPY). uint32 decimals = 2; + // rounding is the preferred rounding mode for this currency. RoundingMode rounding = 3; } diff --git a/api/proto/common/organization_bound/v1/obound.proto b/api/proto/common/organization_bound/v1/obound.proto index 93670c12..cdefcc5b 100644 --- a/api/proto/common/organization_bound/v1/obound.proto +++ b/api/proto/common/organization_bound/v1/obound.proto @@ -4,7 +4,9 @@ package common.obound.v1; option go_package = "github.com/tech/sendico/pkg/proto/common/organization_bound/v1;oboundv1"; - +// OrganizationBound is an embeddable fragment that ties a record to an +// organisation for multi-tenancy isolation. message OrganizationBound { + // organization_ref is the unique identifier of the owning organisation. string organization_ref = 1; -} \ No newline at end of file +} diff --git a/api/proto/common/pagination/v1/cursor.proto b/api/proto/common/pagination/v1/cursor.proto index e6a77907..6fa89ee0 100644 --- a/api/proto/common/pagination/v1/cursor.proto +++ b/api/proto/common/pagination/v1/cursor.proto @@ -2,11 +2,17 @@ syntax = "proto3"; package common.pagination.v1; option go_package = "github.com/tech/sendico/pkg/proto/common/pagination/v1;paginationv1"; +// CursorPageRequest carries opaque cursor-based pagination parameters. message CursorPageRequest { - string cursor = 1; // opaque - int32 limit = 2; // page size + // cursor is the opaque continuation token from a previous response. + string cursor = 1; + // limit is the maximum number of items to return per page. + int32 limit = 2; } +// CursorPageResponse carries the opaque token for the next page. message CursorPageResponse { - string next_cursor = 1; // opaque + // next_cursor is the opaque token to fetch the next page; empty when no + // more results are available. + string next_cursor = 1; } diff --git a/api/proto/common/pagination/v2/cursor.proto b/api/proto/common/pagination/v2/cursor.proto index 10d8d41a..771ed013 100644 --- a/api/proto/common/pagination/v2/cursor.proto +++ b/api/proto/common/pagination/v2/cursor.proto @@ -2,11 +2,14 @@ syntax = "proto3"; package common.pagination.v2; option go_package = "github.com/tech/sendico/pkg/proto/common/pagination/v2;paginationv2"; - import "google/protobuf/wrappers.proto"; +// ViewCursor provides offset-based pagination with an optional archive filter. message ViewCursor { + // limit is the maximum number of items to return. google.protobuf.Int64Value limit = 1; + // offset is the zero-based starting position in the result set. google.protobuf.Int64Value offset = 2; + // is_archived, when set, filters results by their archived status. google.protobuf.BoolValue is_archived = 3; } diff --git a/api/proto/common/payment/v1/asset.proto b/api/proto/common/payment/v1/asset.proto new file mode 100644 index 00000000..ecd3da8f --- /dev/null +++ b/api/proto/common/payment/v1/asset.proto @@ -0,0 +1,18 @@ +syntax = "proto3"; + +package common.payment.v1; + +option go_package = "github.com/tech/sendico/pkg/proto/common/payment/v1;paymentv1"; + +// ChainAssetKey identifies an on-chain asset by network and token symbol. +message ChainAssetKey { + string chain = 1; + string token_symbol = 2; +} + +// ChainAsset extends ChainAssetKey with optional contract address override. +message ChainAsset { + ChainAssetKey key = 1; + optional string contract_address = 2; +} + diff --git a/api/proto/common/permission_bound/v1/pbound.proto b/api/proto/common/permission_bound/v1/pbound.proto index b991fa52..33823876 100644 --- a/api/proto/common/permission_bound/v1/pbound.proto +++ b/api/proto/common/permission_bound/v1/pbound.proto @@ -8,10 +8,15 @@ import "api/proto/common/storable/v1/storable.proto"; import "api/proto/common/archivable/v1/archivable.proto"; import "api/proto/common/organization_bound/v1/obound.proto"; - +// PermissionBound bundles persistence metadata, soft-delete state, +// organisation ownership, and an RBAC permission reference. message PermissionBound { + // storable carries the record's persistence metadata. common.storable.v1.Storable storable = 1; + // archivable carries the soft-delete flag. common.archivable.v1.Archivable archivable = 2; + // organization_bound ties the record to an organisation. common.obound.v1.OrganizationBound organization_bound = 3; + // permission_ref is the RBAC permission identifier that governs access. string permission_ref = 4; -} \ No newline at end of file +} diff --git a/api/proto/common/trace/v1/trace.proto b/api/proto/common/trace/v1/trace.proto index 53875dbb..7ccb1e13 100644 --- a/api/proto/common/trace/v1/trace.proto +++ b/api/proto/common/trace/v1/trace.proto @@ -2,8 +2,12 @@ syntax = "proto3"; package common.trace.v1; option go_package = "github.com/tech/sendico/pkg/proto/common/trace/v1;tracev1"; +// TraceContext carries cross-service request correlation identifiers. message TraceContext { + // request_ref is the unique identifier for this individual request. string request_ref = 1; + // idempotency_key prevents duplicate processing of the same operation. string idempotency_key = 2; + // trace_ref groups related requests within a single logical flow. string trace_ref = 3; } diff --git a/api/proto/payments/quotation/v2/interface.proto b/api/proto/payments/quotation/v2/interface.proto index 84485430..32a5dec5 100644 --- a/api/proto/payments/quotation/v2/interface.proto +++ b/api/proto/payments/quotation/v2/interface.proto @@ -7,21 +7,17 @@ option go_package = "github.com/tech/sendico/pkg/proto/payments/quotation/v2;quo import "google/protobuf/timestamp.proto"; import "api/proto/common/storable/v1/storable.proto"; import "api/proto/common/money/v1/money.proto"; +import "api/proto/common/payment/v1/asset.proto"; import "api/proto/billing/fees/v1/fees.proto"; import "api/proto/oracle/v1/oracle.proto"; -enum QuoteKind { - QUOTE_KIND_UNSPECIFIED = 0; +enum QuoteState { + QUOTE_STATE_UNSPECIFIED = 0; - QUOTE_KIND_EXECUTABLE = 1; // can be executed now (subject to execution-time checks) - QUOTE_KIND_INDICATIVE = 2; // informational only -} - -enum QuoteLifecycle { - QUOTE_LIFECYCLE_UNSPECIFIED = 0; - - QUOTE_LIFECYCLE_ACTIVE = 1; - QUOTE_LIFECYCLE_EXPIRED = 2; + QUOTE_STATE_INDICATIVE = 1; + QUOTE_STATE_EXECUTABLE = 2; + QUOTE_STATE_BLOCKED = 3; + QUOTE_STATE_EXPIRED = 4; } enum QuoteBlockReason { @@ -59,18 +55,25 @@ message RouteHop { RouteHopRole role = 6; } +message RouteSettlement { + common.payment.v1.ChainAsset asset = 1; + string model = 2; +} + // Abstract execution route selected during quotation. // This is not an execution plan and must not contain operational steps. message RouteSpecification { + // Optional summary fields. Topology is represented by hops + route_ref. string rail = 1; string provider = 2; string payout_method = 3; - string settlement_asset = 4; - string settlement_model = 5; + reserved 4, 5; + reserved "settlement_asset", "settlement_model"; string network = 6; string route_ref = 7; string pricing_profile_ref = 8; repeated RouteHop hops = 9; + RouteSettlement settlement = 10; } // Execution assumptions and constraints evaluated at quotation time. @@ -87,17 +90,10 @@ message ExecutionConditions { message PaymentQuote { common.storable.v1.Storable storable = 1; - QuoteKind kind = 2; - QuoteLifecycle lifecycle = 3; - - // Execution-status rules: - // 1) kind=QUOTE_KIND_INDICATIVE => execution_status must be unset. - // 2) lifecycle=QUOTE_LIFECYCLE_EXPIRED => execution_status must be unset. - // 3) kind=QUOTE_KIND_EXECUTABLE and lifecycle=QUOTE_LIFECYCLE_ACTIVE => execution_status must be set. - oneof execution_status { - bool executable = 13; // must be true when set - QuoteBlockReason block_reason = 4; - } + QuoteState state = 2; + QuoteBlockReason block_reason = 4; + reserved 3, 13; + reserved "kind", "lifecycle", "executable"; common.money.v1.Money debit_amount = 5; common.money.v1.Money credit_amount = 6; diff --git a/api/server/go.sum b/api/server/go.sum index 5c9aa180..f9e01e94 100644 --- a/api/server/go.sum +++ b/api/server/go.sum @@ -361,8 +361,8 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 h1:JLQynH/LBHfCTSbDWl+py8C+Rg/k1OVH3xfcaiANuF0= -google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:kSJwQxqmFXeo79zOmbrALdflXQeAYcUbgS7PbpMknCY= +google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d h1:EocjzKLywydp5uZ5tJ79iP6Q0UjDnyiHkGRWxuPBP8s= +google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:48U2I+QQUYhsFrg2SY6r+nJzeOtjey7j//WBESw+qyQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=