Fixes + stable gateway ids

This commit is contained in:
Stephan D
2026-02-18 20:38:08 +01:00
parent 4dc182bfa2
commit 770c7b9da9
119 changed files with 3000 additions and 734 deletions

View File

@@ -0,0 +1,195 @@
# See the dedicated "version" documentation section.
version: "2"
linters:
# Default set of linters.
# The value can be:
# - `standard`: https://golangci-lint.run/docs/linters/#enabled-by-default
# - `all`: enables all linters by default.
# - `none`: disables all linters by default.
# - `fast`: enables only linters considered as "fast" (`golangci-lint help linters --json | jq '[ .[] | select(.fast==true) ] | map(.name)'`).
# Default: standard
default: all
# Enable specific linter.
enable:
- arangolint
- asasalint
- asciicheck
- bidichk
- bodyclose
- canonicalheader
- containedctx
- contextcheck
- copyloopvar
- cyclop
- decorder
- dogsled
- dupl
- dupword
- durationcheck
- embeddedstructfieldcheck
- err113
- errcheck
- errchkjson
- errname
- errorlint
- exhaustive
- exptostd
- fatcontext
- forbidigo
- forcetypeassert
- funcorder
- funlen
- ginkgolinter
- gocheckcompilerdirectives
- gochecknoglobals
- gochecknoinits
- gochecksumtype
- gocognit
- goconst
- gocritic
- gocyclo
- godoclint
- godot
- godox
- goheader
- gomodguard
- goprintffuncname
- gosec
- gosmopolitan
- govet
- grouper
- iface
- importas
- inamedparam
- ineffassign
- interfacebloat
- intrange
- iotamixing
- ireturn
- lll
- loggercheck
- maintidx
- makezero
- mirror
- misspell
- mnd
- modernize
- musttag
- nakedret
- nestif
- nilerr
- nilnesserr
- nilnil
- nlreturn
- noctx
- noinlineerr
- nolintlint
- nonamedreturns
- nosprintfhostport
- paralleltest
- perfsprint
- prealloc
- predeclared
- promlinter
- protogetter
- reassign
- recvcheck
- revive
- rowserrcheck
- sloglint
- spancheck
- sqlclosecheck
- staticcheck
- tagalign
- tagliatelle
- testableexamples
- testifylint
- testpackage
- thelper
- tparallel
- unconvert
- unparam
- unqueryvet
- unused
- usestdlibvars
- usetesting
- varnamelen
- wastedassign
- whitespace
- wsl_v5
- zerologlint
# Disable specific linters.
disable:
- depguard
- exhaustruct
- gochecknoglobals
- gomoddirectives
- wsl
- wrapcheck
# All available settings of specific linters.
# See the dedicated "linters.settings" documentation section.
settings:
wsl_v5:
allow-first-in-block: true
allow-whole-block: false
branch-max-lines: 2
# Defines a set of rules to ignore issues.
# It does not skip the analysis, and so does not ignore "typecheck" errors.
exclusions:
# Mode of the generated files analysis.
#
# - `strict`: sources are excluded by strictly following the Go generated file convention.
# Source files that have lines matching only the following regular expression will be excluded: `^// Code generated .* DO NOT EDIT\.$`
# This line must appear before the first non-comment, non-blank text in the file.
# https://go.dev/s/generatedcode
# - `lax`: sources are excluded if they contain lines like `autogenerated file`, `code generated`, `do not edit`, etc.
# - `disable`: disable the generated files exclusion.
#
# Default: strict
generated: lax
# Log a warning if an exclusion rule is unused.
# Default: false
warn-unused: true
# Predefined exclusion rules.
# Default: []
presets:
- comments
- std-error-handling
- common-false-positives
- legacy
# Excluding configuration per-path, per-linter, per-text and per-source.
rules:
# Exclude some linters from running on tests files.
- path: _test\.go
linters:
- gocyclo
- errcheck
- dupl
- gosec
# Run some linter only for test files by excluding its issues for everything else.
- path-except: _test\.go
linters:
- forbidigo
# Exclude known linters from partially hard-vendored code,
# which is impossible to exclude via `nolint` comments.
# `/` will be replaced by the current OS file path separator to properly work on Windows.
- path: internal/hmac/
text: "weak cryptographic primitive"
linters:
- gosec
# Exclude some `staticcheck` messages.
- linters:
- staticcheck
text: "SA9003:"
# Exclude `lll` issues for long lines with `go:generate`.
- linters:
- lll
source: "^//go:generate "
# Which file paths to exclude: they will be analyzed, but issues from them won't be reported.
# "/" will be replaced by the current OS file path separator to properly work on Windows.
# Default: []
paths: []
# Which file paths to not exclude.
# Default: []
paths-except: []

View File

@@ -24,5 +24,6 @@ func Create() version.Printer {
BuildDate: BuildDate,
Version: Version,
}
return vf.Create(&info)
}

View File

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

View File

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

View File

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

View File

@@ -29,6 +29,7 @@ type Imp struct {
type config struct {
*grpcapp.Config `yaml:",inline"`
Documents documents.Config `yaml:"documents"`
}
@@ -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
}

View File

@@ -29,5 +29,6 @@ func (c Config) AcceptanceTemplatePath() string {
if strings.TrimSpace(c.Templates.AcceptancePath) == "" {
return "templates/acceptance.tpl"
}
return c.Templates.AcceptancePath
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,6 +37,7 @@ 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
}
@@ -52,6 +54,7 @@ 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,6 +63,7 @@ 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"`
@@ -70,11 +74,14 @@ 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{}
}

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
// Package serverimp contains the concrete discovery server implementation.
package serverimp
import (

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
// Package main is the entry point for the discovery service.
package main
import (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -47,7 +47,9 @@ func cloneStoredPaymentPlan(src *model.PaymentPlan) *model.PaymentPlan {
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),

View File

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

View File

@@ -17,6 +17,7 @@ func buildFXConversionPlan(payment *model.Payment) (*model.PaymentPlan, error) {
StepID: "fx_convert",
Rail: model.RailLedger,
Action: model.RailOperationFXConvert,
ReportVisibility: model.ReportVisibilityUser,
CommitPolicy: model.CommitPolicyImmediate,
Amount: cloneMoney(payment.Intent.Amount),
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,6 +17,7 @@ func buildFXConversionPlan(payment *model.Payment) (*model.PaymentPlan, error) {
StepID: "fx_convert",
Rail: model.RailLedger,
Action: model.RailOperationFXConvert,
ReportVisibility: model.ReportVisibilityUser,
CommitPolicy: model.CommitPolicyImmediate,
Amount: cloneMoney(payment.Intent.Amount),
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -97,6 +97,7 @@ func cloneStoredPaymentPlan(src *model.PaymentPlan) *model.PaymentPlan {
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,

View File

@@ -18,34 +18,27 @@ import (
func statusInputFromStatus(status quote_response_mapper_v2.QuoteStatus) *quote_persistence_service.StatusInput {
return &quote_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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,7 +50,11 @@ func buildComputationSteps(
}
lastStepID := sourceStepID
fxAssigned := false
if intent.RequiresFX {
if len(rails) > 1 && rails[1] == model.RailProviderSettlement {
fxAssigned = true
} else {
fxStepID := fmt.Sprintf("i%d.fx", index)
steps = append(steps, &QuoteComputationStep{
StepID: fxStepID,
@@ -60,27 +66,38 @@ func buildComputationSteps(
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

View File

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

View File

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

View File

@@ -14,28 +14,14 @@ 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 := &quotationv2.RouteSpecification{
Rail: normalizeRail(string(destinationRail)),
Provider: normalizeProvider(provider),
PayoutMethod: normalizePayoutMethod(payoutMethodFromEndpoint(destination)),
SettlementAsset: normalizeAsset(intent.SettlementCurrency),
SettlementModel: normalizeSettlementModel(settlementModelString(intent.SettlementMode)),
Network: normalizeNetwork(network),
Settlement: buildRouteSettlement(intent, network, hops),
Hops: hops,
}
if route.SettlementAsset == "" && intent.Amount != nil {
route.SettlementAsset = normalizeAsset(intent.Amount.GetCurrency())
}
route.RouteRef = buildRouteReference(route)
route.PricingProfileRef = buildPricingProfileReference(route)
return 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, "|")
}

View File

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

View File

@@ -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 = &quotationv2.PaymentQuote_Executable{Executable: true}
return
}
quote.ExecutionStatus = &quotationv2.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{}
) QuoteState {
if previewOnly {
return QuoteState{
state: quotationv2.QuoteState_QUOTE_STATE_INDICATIVE,
}
}
if blockReason == quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED {
return ExecutionStatus{
set: true,
executable: true,
return QuoteState{
state: quotationv2.QuoteState_QUOTE_STATE_EXECUTABLE,
}
}
return ExecutionStatus{
set: true,
executable: false,
return QuoteState{
state: quotationv2.QuoteState_QUOTE_STATE_BLOCKED,
blockReason: blockReason,
}
}

View File

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

View File

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

View File

@@ -9,9 +9,7 @@ import (
)
type StatusInput struct {
Kind quotationv2.QuoteKind
Lifecycle quotationv2.QuoteLifecycle
Executable *bool
State quotationv2.QuoteState
BlockReason quotationv2.QuoteBlockReason
}

View File

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

View File

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

View File

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

View File

@@ -30,9 +30,7 @@ type CanonicalQuote struct {
}
type QuoteStatus struct {
Kind quotationv2.QuoteKind
Lifecycle quotationv2.QuoteLifecycle
Executable *bool
State quotationv2.QuoteState
BlockReason quotationv2.QuoteBlockReason
}
@@ -44,7 +42,6 @@ type MapInput struct {
type MapOutput struct {
Quote *quotationv2.PaymentQuote
HasExecutionStatus bool
Executable bool
State quotationv2.QuoteState
BlockReason quotationv2.QuoteBlockReason
}

View File

@@ -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")
}
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,
}, nil
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{
hasStatus: true,
executable: false,
state: status.State,
blockReason: status.BlockReason,
}, nil
}
func (s QuoteStatus) ExecutableValue() bool {
if s.Executable == nil {
return false
}
return *s.Executable
if status.BlockReason != quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED {
return executionDecision{}, merrors.InvalidArgument("status.block_reason is only valid for blocked quote")
}
return executionDecision{
state: status.State,
blockReason: quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED,
}, nil
}

View File

@@ -23,8 +23,8 @@ func (m *QuoteResponseMapperV2) Map(in MapInput) (*MapOutput, error) {
result := &quotationv2.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,20 +38,9 @@ func (m *QuoteResponseMapperV2) Map(in MapInput) (*MapOutput, error) {
PricedAt: tsOrNil(in.Quote.PricedAt),
}
if decision.hasStatus {
if decision.executable {
result.ExecutionStatus = &quotationv2.PaymentQuote_Executable{Executable: true}
} else {
result.ExecutionStatus = &quotationv2.PaymentQuote_BlockReason{
BlockReason: decision.blockReason,
}
}
}
return &MapOutput{
Quote: result,
HasExecutionStatus: decision.hasStatus,
Executable: decision.executable,
State: decision.state,
BlockReason: decision.blockReason,
}, nil
}

View File

@@ -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)
@@ -42,8 +42,14 @@ func TestMap_ExecutableActiveQuote(t *testing.T) {
Rail: "CARD_PAYOUT",
Provider: "monetix",
PayoutMethod: "CARD",
SettlementAsset: "USD",
SettlementModel: "FIX_SOURCE",
Settlement: &quotationv2.RouteSettlement{
Asset: &paymentv1.ChainAsset{
Key: &paymentv1.ChainAssetKey{
TokenSymbol: "USD",
},
},
Model: "FIX_SOURCE",
},
},
Conditions: &quotationv2.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)
}
for _, state := range states {
out, err := mapper.Map(MapInput{
Status: QuoteStatus{
Kind: quotationv2.QuoteKind_QUOTE_KIND_INDICATIVE,
Lifecycle: quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE,
State: state,
},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
t.Fatalf("unexpected error for state=%s: %v", state.String(), err)
}
if out.HasExecutionStatus {
t.Fatalf("expected unset execution status")
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())
}
if out.Quote.GetExecutionStatus() != nil {
t.Fatalf("expected no execution_status oneof")
}
}
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)
}
}

View File

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

View File

@@ -0,0 +1,144 @@
package model
import "strings"
// SelectGatewayByPreference picks a gateway candidate using persisted affinity hints.
// Matching order:
// 1) gateway ID + instance ID
// 2) invoke URI
// 3) gateway ID
// 4) instance ID
// 5) first candidate fallback
func SelectGatewayByPreference(
candidates []*GatewayInstanceDescriptor,
gatewayID string,
instanceID string,
invokeURI string,
) (*GatewayInstanceDescriptor, string) {
if len(candidates) == 0 {
return nil, ""
}
gatewayID = strings.TrimSpace(gatewayID)
instanceID = strings.TrimSpace(instanceID)
invokeURI = strings.TrimSpace(invokeURI)
if gatewayID != "" && instanceID != "" {
for _, entry := range candidates {
if entry == nil {
continue
}
if strings.EqualFold(strings.TrimSpace(entry.ID), gatewayID) &&
strings.EqualFold(strings.TrimSpace(entry.InstanceID), instanceID) {
return entry, "exact"
}
}
}
if invokeURI != "" {
for _, entry := range candidates {
if entry == nil {
continue
}
if strings.EqualFold(strings.TrimSpace(entry.InvokeURI), invokeURI) {
return entry, "invoke_uri"
}
}
}
if gatewayID != "" {
for _, entry := range candidates {
if entry == nil {
continue
}
if strings.EqualFold(strings.TrimSpace(entry.ID), gatewayID) {
return entry, "gateway_id"
}
}
}
if instanceID != "" {
for _, entry := range candidates {
if entry == nil {
continue
}
if strings.EqualFold(strings.TrimSpace(entry.InstanceID), instanceID) {
return entry, "instance_id"
}
}
}
for _, entry := range candidates {
if entry != nil {
return entry, "rail_fallback"
}
}
return nil, ""
}
// GatewayDescriptorIdentityKey returns a stable dedupe key for gateway entries.
// The key is composed from logical gateway ID + instance identity; invoke URI is
// used as a fallback identity when instance ID is missing.
func GatewayDescriptorIdentityKey(entry *GatewayInstanceDescriptor) string {
if entry == nil {
return ""
}
return GatewayIdentityKey(entry.ID, entry.InstanceID, entry.InvokeURI)
}
// GatewayIdentityKey composes a stable identity key from gateway affinity fields.
func GatewayIdentityKey(gatewayID string, instanceID string, invokeURI string) string {
id := strings.ToLower(strings.TrimSpace(gatewayID))
if id == "" {
return ""
}
instance := strings.ToLower(strings.TrimSpace(instanceID))
if instance == "" {
instance = strings.ToLower(strings.TrimSpace(invokeURI))
}
if instance == "" {
return id
}
return id + "|" + instance
}
// LessGatewayDescriptor orders gateway descriptors deterministically.
func LessGatewayDescriptor(left *GatewayInstanceDescriptor, right *GatewayInstanceDescriptor) bool {
if left == nil {
return right != nil
}
if right == nil {
return false
}
if cmp := compareFolded(left.ID, right.ID); cmp != 0 {
return cmp < 0
}
if cmp := compareFolded(left.InstanceID, right.InstanceID); cmp != 0 {
return cmp < 0
}
if cmp := compareFolded(left.InvokeURI, right.InvokeURI); cmp != 0 {
return cmp < 0
}
if cmp := compareFolded(string(left.Rail), string(right.Rail)); cmp != 0 {
return cmp < 0
}
if cmp := compareFolded(left.Network, right.Network); cmp != 0 {
return cmp < 0
}
return compareFolded(left.Version, right.Version) < 0
}
func compareFolded(left string, right string) int {
l := strings.ToLower(strings.TrimSpace(left))
r := strings.ToLower(strings.TrimSpace(right))
switch {
case l < r:
return -1
case l > r:
return 1
default:
return 0
}
}

View File

@@ -0,0 +1,29 @@
package model
import "testing"
func TestGatewayIdentityKey(t *testing.T) {
if got, want := GatewayIdentityKey(" gw ", "inst-1", "grpc://one"), "gw|inst-1"; got != want {
t.Fatalf("unexpected gateway identity key: got=%q want=%q", got, want)
}
if got, want := GatewayIdentityKey("gw", "", " grpc://one "), "gw|grpc://one"; got != want {
t.Fatalf("unexpected gateway identity key with invoke fallback: got=%q want=%q", got, want)
}
if got, want := GatewayIdentityKey(" gw ", "", ""), "gw"; got != want {
t.Fatalf("unexpected gateway identity key with id only: got=%q want=%q", got, want)
}
if got := GatewayIdentityKey("", "inst-1", "grpc://one"); got != "" {
t.Fatalf("expected empty key when gateway id missing, got=%q", got)
}
}
func TestLessGatewayDescriptor(t *testing.T) {
a := &GatewayInstanceDescriptor{ID: "gw", InstanceID: "inst-a", InvokeURI: "grpc://a"}
b := &GatewayInstanceDescriptor{ID: "gw", InstanceID: "inst-b", InvokeURI: "grpc://b"}
if !LessGatewayDescriptor(a, b) {
t.Fatalf("expected inst-a to sort before inst-b")
}
if LessGatewayDescriptor(b, a) {
t.Fatalf("expected inst-b not to sort before inst-a")
}
}

View File

@@ -282,7 +282,9 @@ type PaymentStep struct {
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"`
@@ -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)

View File

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

View File

@@ -1,21 +1,14 @@
package model
// QuoteKind captures v2 quote kind metadata for persistence.
type QuoteKind string
// QuoteState captures v2 quote state metadata for persistence.
type QuoteState string
const (
QuoteKindUnspecified QuoteKind = "unspecified"
QuoteKindExecutable QuoteKind = "executable"
QuoteKindIndicative QuoteKind = "indicative"
)
// QuoteLifecycle captures v2 quote lifecycle metadata for persistence.
type QuoteLifecycle string
const (
QuoteLifecycleUnspecified QuoteLifecycle = "unspecified"
QuoteLifecycleActive QuoteLifecycle = "active"
QuoteLifecycleExpired QuoteLifecycle = "expired"
QuoteStateUnspecified QuoteState = "unspecified"
QuoteStateIndicative QuoteState = "indicative"
QuoteStateExecutable QuoteState = "executable"
QuoteStateBlocked QuoteState = "blocked"
QuoteStateExpired QuoteState = "expired"
)
// QuoteBlockReason captures v2 non-executability reason for persistence.
@@ -34,8 +27,6 @@ const (
// QuoteStatusV2 stores execution status metadata from quotation v2.
type QuoteStatusV2 struct {
Kind QuoteKind `bson:"kind,omitempty" json:"kind,omitempty"`
Lifecycle QuoteLifecycle `bson:"lifecycle,omitempty" json:"lifecycle,omitempty"`
Executable *bool `bson:"executable,omitempty" json:"executable,omitempty"`
State QuoteState `bson:"state,omitempty" json:"state,omitempty"`
BlockReason QuoteBlockReason `bson:"blockReason,omitempty" json:"blockReason,omitempty"`
}

Some files were not shown because too many files have changed in this diff Show More