diff --git a/api/billing/documents/go.mod b/api/billing/documents/go.mod index cf89e01d..287f815f 100644 --- a/api/billing/documents/go.mod +++ b/api/billing/documents/go.mod @@ -6,8 +6,8 @@ replace github.com/tech/sendico/pkg => ../../pkg require ( github.com/aws/aws-sdk-go-v2 v1.41.1 - github.com/aws/aws-sdk-go-v2/config v1.32.7 - github.com/aws/aws-sdk-go-v2/credentials v1.19.7 + github.com/aws/aws-sdk-go-v2/config v1.32.8 + github.com/aws/aws-sdk-go-v2/credentials v1.19.8 github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0 github.com/jung-kurt/gofpdf v1.16.2 github.com/prometheus/client_golang v1.23.2 @@ -32,7 +32,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 // indirect github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect github.com/aws/smithy-go v1.24.0 // indirect github.com/beorn7/perks v1.0.1 // indirect @@ -65,6 +65,6 @@ require ( golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/api/billing/documents/go.sum b/api/billing/documents/go.sum index cbc4195b..d3ae4fe9 100644 --- a/api/billing/documents/go.sum +++ b/api/billing/documents/go.sum @@ -8,10 +8,10 @@ github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6ce github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4= -github.com/aws/aws-sdk-go-v2/config v1.32.7 h1:vxUyWGUwmkQ2g19n7JY/9YL8MfAIl7bTesIUykECXmY= -github.com/aws/aws-sdk-go-v2/config v1.32.7/go.mod h1:2/Qm5vKUU/r7Y+zUk/Ptt2MDAEKAfUtKc1+3U1Mo3oY= -github.com/aws/aws-sdk-go-v2/credentials v1.19.7 h1:tHK47VqqtJxOymRrNtUXN5SP/zUTvZKeLx4tH6PGQc8= -github.com/aws/aws-sdk-go-v2/credentials v1.19.7/go.mod h1:qOZk8sPDrxhf+4Wf4oT2urYJrYt3RejHSzgAquYeppw= +github.com/aws/aws-sdk-go-v2/config v1.32.8 h1:iu+64gwDKEoKnyTQskSku72dAwggKI5sV6rNvgSMpMs= +github.com/aws/aws-sdk-go-v2/config v1.32.8/go.mod h1:MI2XvA+qDi3i9AJxX1E2fu730syEBzp/jnXrjxuHwgI= +github.com/aws/aws-sdk-go-v2/credentials v1.19.8 h1:Jp2JYH1lRT3KhX4mshHPvVYsR5qqRec3hGvEarNYoR0= +github.com/aws/aws-sdk-go-v2/credentials v1.19.8/go.mod h1:fZG9tuvyVfxknv1rKibIz3DobRaFw1Poe8IKtXB3XYY= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 h1:I0GyV8wiYrP8XpA70g1HBcQO1JlQxCMTW9npl5UbDHY= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17/go.mod h1:tyw7BOl5bBe/oqvoIeECFJjMdzXoa/dfVz3QQ5lgHGA= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U= @@ -36,8 +36,8 @@ github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 h1:VrhDvQib/i0lxvr3zqlUwLwJP4 github.com/aws/aws-sdk-go-v2/service/signin v1.0.5/go.mod h1:k029+U8SY30/3/ras4G/Fnv/b88N4mAfliNn08Dem4M= github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 h1:v6EiMvhEYBoHABfbGB4alOYmCIrcgyPPiBE1wZAEbqk= github.com/aws/aws-sdk-go-v2/service/sso v1.30.9/go.mod h1:yifAsgBxgJWn3ggx70A3urX2AN49Y5sJTD1UQFlfqBw= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 h1:gd84Omyu9JLriJVCbGApcLzVR3XtmC4ZDPcAI6Ftvds= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13/go.mod h1:sTGThjphYE4Ohw8vJiRStAcu3rbjtXRsdNB0TvZ5wwo= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14 h1:0jbJeuEHlwKJ9PfXtpSFc4MF+WIWORdhN1n30ITZGFM= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14/go.mod h1:sTGThjphYE4Ohw8vJiRStAcu3rbjtXRsdNB0TvZ5wwo= github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/pJ1jOWYlFDJTjRQ= github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ= github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= @@ -258,8 +258,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/billing/documents/internal/service/documents/service.go b/api/billing/documents/internal/service/documents/service.go index 444dfe81..f2ae9393 100644 --- a/api/billing/documents/internal/service/documents/service.go +++ b/api/billing/documents/internal/service/documents/service.go @@ -111,7 +111,7 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro } if svc.template == nil { if tmpl, err := newTemplateRenderer(svc.config.AcceptanceTemplatePath()); err != nil { - svc.logger.Warn("failed to load acceptance template", zap.Error(err)) + svc.logger.Warn("Failed to load acceptance template", zap.Error(err)) } else { svc.template = tmpl } diff --git a/api/billing/documents/internal/service/documents/template.go b/api/billing/documents/internal/service/documents/template.go index 63c7cd46..3490f16b 100644 --- a/api/billing/documents/internal/service/documents/template.go +++ b/api/billing/documents/internal/service/documents/template.go @@ -20,7 +20,7 @@ type templateRenderer struct { func newTemplateRenderer(path string) (*templateRenderer, error) { data, err := os.ReadFile(path) if err != nil { - return nil, fmt.Errorf("read template: %w", err) + return nil, fmt.Errorf("Read template: %w", err) } funcs := template.FuncMap{ @@ -30,7 +30,7 @@ func newTemplateRenderer(path string) (*templateRenderer, error) { tpl, err := template.New("acceptance").Funcs(funcs).Option("missingkey=error").Parse(string(data)) if err != nil { - return nil, fmt.Errorf("parse template: %w", err) + return nil, fmt.Errorf("Parse template: %w", err) } return &templateRenderer{tpl: tpl}, nil @@ -39,7 +39,7 @@ 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()) } diff --git a/api/billing/documents/renderer/tags.go b/api/billing/documents/renderer/tags.go index 4d354adf..4ddd9272 100644 --- a/api/billing/documents/renderer/tags.go +++ b/api/billing/documents/renderer/tags.go @@ -79,7 +79,7 @@ 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() diff --git a/api/billing/documents/storage/mongo/repository.go b/api/billing/documents/storage/mongo/repository.go index 31f5378a..624456cc 100644 --- a/api/billing/documents/storage/mongo/repository.go +++ b/api/billing/documents/storage/mongo/repository.go @@ -42,13 +42,13 @@ func New(logger mlogger.Logger, conn *db.MongoConnection) (*Store, error) { defer cancel() if err := result.Ping(ctx); err != nil { - result.logger.Error("mongo ping failed during store init", zap.Error(err)) + 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)) + result.logger.Error("Failed to initialise documents store", zap.Error(err)) return nil, err } result.documents = documentsStore diff --git a/api/billing/documents/storage/mongo/store/documents.go b/api/billing/documents/storage/mongo/store/documents.go index 5b552b74..7f1484c1 100644 --- a/api/billing/documents/storage/mongo/store/documents.go +++ b/api/billing/documents/storage/mongo/store/documents.go @@ -38,13 +38,13 @@ 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())) + logger.Error("Failed to ensure documents index", zap.Error(err), zap.String("collection", repo.Collection())) return nil, err } } childLogger := logger.Named("documents") - childLogger.Debug("documents store initialised") + childLogger.Debug("Documents store initialised") return &Documents{ logger: childLogger, @@ -68,7 +68,7 @@ func (d *Documents) Create(ctx context.Context, record *model.DocumentRecord) er } return err } - d.logger.Debug("document record created", zap.String("payment_ref", record.PaymentRef)) + d.logger.Debug("Document record created", zap.String("payment_ref", record.PaymentRef)) return nil } @@ -124,7 +124,7 @@ func (d *Documents) ListByPaymentRefs(ctx context.Context, paymentRefs []string) 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)) + d.logger.Warn("Failed to decode document record", zap.Error(err)) return err } records = append(records, &rec) diff --git a/api/billing/fees/go.mod b/api/billing/fees/go.mod index 7c9329a4..c76eaffa 100644 --- a/api/billing/fees/go.mod +++ b/api/billing/fees/go.mod @@ -50,6 +50,6 @@ require ( golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect google.golang.org/protobuf v1.36.11 ) diff --git a/api/billing/fees/go.sum b/api/billing/fees/go.sum index 3552c709..a7714150 100644 --- a/api/billing/fees/go.sum +++ b/api/billing/fees/go.sum @@ -208,8 +208,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/billing/fees/internal/server/internal/serverimp.go b/api/billing/fees/internal/server/internal/serverimp.go index 8ec3470d..fc708d7c 100644 --- a/api/billing/fees/internal/server/internal/serverimp.go +++ b/api/billing/fees/internal/server/internal/serverimp.go @@ -116,11 +116,11 @@ func (i *Imp) Start() error { Insecure: cfg.Oracle.InsecureTransport, }) if err != nil { - i.logger.Warn("failed to initialise oracle client", zap.String("address", addr), zap.Error(err)) + i.logger.Warn("Failed to initialise oracle client", zap.String("address", addr), zap.Error(err)) } else { oracleClient = oc i.oracleClient = oc - i.logger.Info("connected to oracle service", zap.String("address", addr)) + i.logger.Info("Connected to oracle service", zap.String("address", addr)) } } diff --git a/api/billing/fees/internal/service/fees/internal/calculator/impl.go b/api/billing/fees/internal/service/fees/internal/calculator/impl.go index 882aa538..27ad8b17 100644 --- a/api/billing/fees/internal/service/fees/internal/calculator/impl.go +++ b/api/billing/fees/internal/service/fees/internal/calculator/impl.go @@ -94,7 +94,7 @@ func (c *quoteCalculator) Compute(ctx context.Context, plan *model.FeePlan, inte amount, scale, calcErr := c.calculateRuleAmount(baseAmount, baseScale, rule) if calcErr != nil { if !errors.Is(calcErr, merrors.ErrInvalidArg) { - c.logger.Warn("failed to calculate fee rule amount", zap.String("rule_id", rule.RuleID), zap.Error(calcErr)) + c.logger.Warn("Failed to calculate fee rule amount", zap.String("rule_id", rule.RuleID), zap.Error(calcErr)) } continue } @@ -247,7 +247,7 @@ func (c *quoteCalculator) buildFxUsed(ctx context.Context, intent *feesv1.Intent Provider: provider, }) if err != nil { - c.logger.Warn("fees: failed to fetch FX context", zap.Error(err)) + c.logger.Warn("Fees: failed to fetch FX context", zap.Error(err)) return nil } if snapshot == nil { diff --git a/api/billing/fees/internal/service/fees/service.go b/api/billing/fees/internal/service/fees/service.go index 8b284253..220bc772 100644 --- a/api/billing/fees/internal/service/fees/service.go +++ b/api/billing/fees/internal/service/fees/service.go @@ -261,7 +261,7 @@ func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFees var token string if token, err = encodeTokenPayload(payload); err != nil { - logger.Warn("failed to encode fee quote token", zap.Error(err)) + logger.Warn("Failed to encode fee quote token", zap.Error(err)) err = status.Error(codes.Internal, "failed to encode fee quote token") return nil, err } @@ -333,7 +333,7 @@ func (s *Service) ValidateFeeToken(ctx context.Context, req *feesv1.ValidateFeeT payload, decodeErr := decodeTokenPayload(req.GetFeeQuoteToken()) if decodeErr != nil { resultReason = "invalid_token" - logger.Warn("failed to decode fee quote token", zap.Error(decodeErr)) + logger.Warn("Failed to decode fee quote token", zap.Error(decodeErr)) resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "invalid_token"} return resp, nil } @@ -346,7 +346,7 @@ func (s *Service) ValidateFeeToken(ctx context.Context, req *feesv1.ValidateFeeT if now.UnixMilli() > payload.ExpiresAtUnixMs { resultReason = "expired" - logger.Info("fee quote token expired") + logger.Info("Fee quote token expired") resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "expired"} return resp, nil } @@ -354,7 +354,7 @@ func (s *Service) ValidateFeeToken(ctx context.Context, req *feesv1.ValidateFeeT orgRef, parseErr := bson.ObjectIDFromHex(payload.OrganizationRef) if parseErr != nil { resultReason = "invalid_token" - logger.Warn("token contained invalid organization reference", zap.Error(parseErr)) + logger.Warn("Token contained invalid organization reference", zap.Error(parseErr)) resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "invalid_token"} return resp, nil } @@ -461,7 +461,7 @@ func (s *Service) computeQuoteWithTime(ctx context.Context, orgRef bson.ObjectID if errors.Is(calcErr, merrors.ErrInvalidArg) { return nil, nil, nil, status.Error(codes.InvalidArgument, calcErr.Error()) } - logger.Warn("failed to compute fee quote", zap.Error(calcErr)) + logger.Warn("Failed to compute fee quote", zap.Error(calcErr)) return nil, nil, nil, status.Error(codes.Internal, "failed to compute fee quote") } diff --git a/api/billing/fees/storage/mongo/repository.go b/api/billing/fees/storage/mongo/repository.go index 149e3774..e8505f02 100644 --- a/api/billing/fees/storage/mongo/repository.go +++ b/api/billing/fees/storage/mongo/repository.go @@ -43,13 +43,13 @@ func New(logger mlogger.Logger, conn *db.MongoConnection) (*Store, error) { defer cancel() if err := result.Ping(ctx); err != nil { - result.logger.Error("mongo ping failed during store init", zap.Error(err)) + result.logger.Error("Mongo ping failed during store init", zap.Error(err)) return nil, err } plansStore, err := store.NewPlans(result.logger, database) if err != nil { - result.logger.Error("failed to initialise plans store", zap.Error(err)) + result.logger.Error("Failed to initialise plans store", zap.Error(err)) return nil, err } result.plans = plansStore diff --git a/api/billing/fees/storage/mongo/store/plans.go b/api/billing/fees/storage/mongo/store/plans.go index 9151cb4b..2d04f4d1 100644 --- a/api/billing/fees/storage/mongo/store/plans.go +++ b/api/billing/fees/storage/mongo/store/plans.go @@ -40,7 +40,7 @@ func NewPlans(logger mlogger.Logger, db *mongo.Database) (storage.PlansStore, er }, } if err := repo.CreateIndex(orgIndex); err != nil { - logger.Error("failed to ensure fee plan organization index", zap.Error(err)) + logger.Error("Failed to ensure fee plan organization index", zap.Error(err)) return nil, err } @@ -53,7 +53,7 @@ func NewPlans(logger mlogger.Logger, db *mongo.Database) (storage.PlansStore, er Unique: true, } if err := repo.CreateIndex(uniqueIndex); err != nil { - logger.Error("failed to ensure fee plan uniqueness index", zap.Error(err)) + logger.Error("Failed to ensure fee plan uniqueness index", zap.Error(err)) return nil, err } @@ -67,7 +67,7 @@ func NewPlans(logger mlogger.Logger, db *mongo.Database) (storage.PlansStore, er }, } if err := repo.CreateIndex(activeIndex); err != nil { - logger.Warn("failed to ensure fee plan active index", zap.Error(err)) + logger.Warn("Failed to ensure fee plan active index", zap.Error(err)) } return &plansStore{ @@ -88,7 +88,7 @@ func (p *plansStore) Create(ctx context.Context, plan *model.FeePlan) error { if errors.Is(err, merrors.ErrDataConflict) { return storage.ErrDuplicateFeePlan } - p.logger.Warn("failed to create fee plan", zap.Error(err)) + p.logger.Warn("Failed to create fee plan", zap.Error(err)) return err } return nil @@ -106,7 +106,7 @@ func (p *plansStore) Update(ctx context.Context, plan *model.FeePlan) error { } if err := p.repo.Update(ctx, plan); err != nil { - p.logger.Warn("failed to update fee plan", zap.Error(err)) + p.logger.Warn("Failed to update fee plan", zap.Error(err)) return err } return nil diff --git a/api/discovery/go.mod b/api/discovery/go.mod index aacafd49..9fd4a53a 100644 --- a/api/discovery/go.mod +++ b/api/discovery/go.mod @@ -43,7 +43,7 @@ require ( golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect google.golang.org/grpc v1.79.1 // indirect google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/api/discovery/go.sum b/api/discovery/go.sum index 3552c709..a7714150 100644 --- a/api/discovery/go.sum +++ b/api/discovery/go.sum @@ -208,8 +208,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/fx/ingestor/config.dev.yml b/api/fx/ingestor/config.dev.yml index 245076bb..9ae70b69 100644 --- a/api/fx/ingestor/config.dev.yml +++ b/api/fx/ingestor/config.dev.yml @@ -19,10 +19,10 @@ market: quote: "EUR" symbol: "EURUSDT" invert: true - - base: "USD" - quote: "USDT" + - base: "USDT" + quote: "USD" symbol: "USDTUSD" - invert: true + invert: false - base: "UAH" quote: "USDT" symbol: "USDTUAH" diff --git a/api/fx/ingestor/go.mod b/api/fx/ingestor/go.mod index eb28b3f9..1e894f9a 100644 --- a/api/fx/ingestor/go.mod +++ b/api/fx/ingestor/go.mod @@ -47,7 +47,7 @@ require ( golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect google.golang.org/grpc v1.79.1 // indirect google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/api/fx/ingestor/go.sum b/api/fx/ingestor/go.sum index 3552c709..a7714150 100644 --- a/api/fx/ingestor/go.sum +++ b/api/fx/ingestor/go.sum @@ -208,8 +208,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/fx/ingestor/internal/market/cbr/connector.go b/api/fx/ingestor/internal/market/cbr/connector.go index 48aac630..565164c0 100644 --- a/api/fx/ingestor/internal/market/cbr/connector.go +++ b/api/fx/ingestor/internal/market/cbr/connector.go @@ -49,11 +49,6 @@ const ( defaultRequestTimeout = 10 * time.Second ) -const ( - maxSymbolParts = 2 - isoCodeLen = 3 -) - func NewConnector(logger mlogger.Logger, settings model.SettingsT) (mmodel.Connector, error) { //nolint:cyclop,funlen,nestif,ireturn baseURL := defaultCBRBaseURL provider := strings.ToLower(mmodel.DriverCBR.String()) diff --git a/api/fx/oracle/go.mod b/api/fx/oracle/go.mod index a79335c1..ce9b235e 100644 --- a/api/fx/oracle/go.mod +++ b/api/fx/oracle/go.mod @@ -48,5 +48,5 @@ require ( golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect ) diff --git a/api/fx/oracle/go.sum b/api/fx/oracle/go.sum index 3552c709..a7714150 100644 --- a/api/fx/oracle/go.sum +++ b/api/fx/oracle/go.sum @@ -208,8 +208,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/gateway/chain/go.mod b/api/gateway/chain/go.mod index 53abec77..f120e4c6 100644 --- a/api/gateway/chain/go.mod +++ b/api/gateway/chain/go.mod @@ -4,14 +4,17 @@ go 1.25.7 replace github.com/tech/sendico/pkg => ../../pkg +replace github.com/tech/sendico/gateway/common => ../common + require ( github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 - github.com/ethereum/go-ethereum v1.16.8 + github.com/ethereum/go-ethereum v1.17.0 github.com/hashicorp/vault/api v1.22.0 github.com/mitchellh/mapstructure v1.5.0 github.com/prometheus/client_golang v1.23.2 github.com/shopspring/decimal v1.4.0 github.com/stretchr/testify v1.11.1 + github.com/tech/sendico/gateway/common v0.1.0 github.com/tech/sendico/pkg v0.1.0 go.mongodb.org/mongo-driver/v2 v2.5.0 go.uber.org/zap v1.27.1 @@ -22,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-20260213131322-086e44a26cf3 // indirect + github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260215031811-a0ab0b218a81 // 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 @@ -33,13 +36,13 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/consensys/gnark-crypto v0.19.2 // indirect github.com/crate-crypto/go-eth-kzg v1.5.0 // indirect - github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/deckarep/golang-set/v2 v2.8.0 // indirect github.com/ethereum/c-kzg-4844/v2 v2.1.5 // indirect - github.com/ethereum/go-verkle v0.2.2 // indirect github.com/go-chi/chi/v5 v5.2.5 // indirect github.com/go-jose/go-jose/v4 v4.1.3 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect @@ -75,6 +78,10 @@ require ( github.com/xdg-go/stringprep v1.0.4 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel v1.40.0 // indirect + go.opentelemetry.io/otel/metric v1.40.0 // indirect + go.opentelemetry.io/otel/trace v1.40.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect golang.org/x/crypto v0.48.0 // indirect @@ -84,5 +91,5 @@ require ( golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect golang.org/x/time v0.14.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect ) diff --git a/api/gateway/chain/go.sum b/api/gateway/chain/go.sum index be993286..648de7bb 100644 --- a/api/gateway/chain/go.sum +++ b/api/gateway/chain/go.sum @@ -6,8 +6,8 @@ github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ= github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260213131322-086e44a26cf3 h1:QD30TjDPWtvXb5PBZGZ6Wdvaq7HQixIBtZ/yuseNXc8= -github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260213131322-086e44a26cf3/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI= +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/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= @@ -52,8 +52,6 @@ github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/crate-crypto/go-eth-kzg v1.5.0 h1:FYRiJMJG2iv+2Dy3fi14SVGjcPteZ5HAAUe4YWlJygc= github.com/crate-crypto/go-eth-kzg v1.5.0/go.mod h1:J9/u5sWfznSObptgfa92Jq8rTswn6ahQWEuiLHOjCUI= -github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a h1:W8mUrRp6NOVl3J+MYp5kPMoUZPp7aOYHtaua31lwRHg= -github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a/go.mod h1:sTwzHBvIzm2RfVCGNEBZgRyjwK40bVoun3ZnGOCafNM= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dchest/siphash v1.2.3 h1:QXwFc8cFOR2dSa/gE6o/HokBMWtLUaNDVd+22aKHeEA= @@ -78,10 +76,8 @@ github.com/ethereum/c-kzg-4844/v2 v2.1.5 h1:aVtoLK5xwJ6c5RiqO8g8ptJ5KU+2Hdquf6G3 github.com/ethereum/c-kzg-4844/v2 v2.1.5/go.mod h1:u59hRTTah4Co6i9fDWtiCjTrblJv0UwsqZKCc0GfgUs= github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab h1:rvv6MJhy07IMfEKuARQ9TKojGqLVNxQajaXEp/BoqSk= github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab/go.mod h1:IuLm4IsPipXKF7CW5Lzf68PIbZ5yl7FFd74l/E0o9A8= -github.com/ethereum/go-ethereum v1.16.8 h1:LLLfkZWijhR5m6yrAXbdlTeXoqontH+Ga2f9igY7law= -github.com/ethereum/go-ethereum v1.16.8/go.mod h1:Fs6QebQbavneQTYcA39PEKv2+zIjX7rPUZ14DER46wk= -github.com/ethereum/go-verkle v0.2.2 h1:I2W0WjnrFUIzzVPwm8ykY+7pL2d4VhlsePn4j7cnFk8= -github.com/ethereum/go-verkle v0.2.2/go.mod h1:M3b90YRnzqKyyzBEWJGqj8Qff4IDeXnzFw0P9bFw3uk= +github.com/ethereum/go-ethereum v1.17.0 h1:2D+1Fe23CwZ5tQoAS5DfwKFNI1HGcTwi65/kRlAVxes= +github.com/ethereum/go-ethereum v1.17.0/go.mod h1:2W3msvdosS/MCWytpqTcqgFiRYbTH59FxDJzqah120o= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= @@ -94,6 +90,7 @@ github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= @@ -124,6 +121,10 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grafana/pyroscope-go v1.2.7 h1:VWBBlqxjyR0Cwk2W6UrE8CdcdD80GOFNutj0Kb1T8ac= +github.com/grafana/pyroscope-go v1.2.7/go.mod h1:o/bpSLiJYYP6HQtvcoVKiE9s5RiNgjYTj1DhiddP2Pc= +github.com/grafana/pyroscope-go/godeltaprof v0.1.9 h1:c1Us8i6eSmkW+Ez05d3co8kasnuOY813tbMN8i/a3Og= +github.com/grafana/pyroscope-go/godeltaprof v0.1.9/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -179,8 +180,6 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= -github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g= github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= @@ -211,8 +210,6 @@ github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4= github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs= github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= -github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= -github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= @@ -241,10 +238,8 @@ github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTU github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= -github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= -github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/cors v1.7.0 h1:+88SsELBHx5r+hZ8TCkggzSstaWNbDvThkVK8H6f9ik= github.com/rs/cors v1.7.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= @@ -298,16 +293,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= @@ -360,8 +355,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/gateway/chain/internal/server/internal/serverimp.go b/api/gateway/chain/internal/server/internal/serverimp.go index 5ec1c204..7cf85aa6 100644 --- a/api/gateway/chain/internal/server/internal/serverimp.go +++ b/api/gateway/chain/internal/server/internal/serverimp.go @@ -162,6 +162,9 @@ func (i *Imp) Start() error { gatewayservice.WithDriverRegistry(driverRegistry), gatewayservice.WithSettings(cfg.Settings), } + if cfg.Messaging != nil { + opts = append(opts, gatewayservice.WithMessagingSettings(cfg.Messaging.Settings)) + } svc := gatewayservice.NewService(logger, repo, producer, opts...) i.service = svc return svc, nil diff --git a/api/gateway/chain/internal/service/gateway/options.go b/api/gateway/chain/internal/service/gateway/options.go index d5139ff9..6166586f 100644 --- a/api/gateway/chain/internal/service/gateway/options.go +++ b/api/gateway/chain/internal/service/gateway/options.go @@ -91,3 +91,12 @@ func WithDiscoveryInvokeURI(invokeURI string) Option { s.invokeURI = strings.TrimSpace(invokeURI) } } + +// WithMessagingSettings applies messaging driver settings. +func WithMessagingSettings(settings pmodel.SettingsT) Option { + return func(s *Service) { + if settings != nil { + s.msgCfg = settings + } + } +} diff --git a/api/gateway/chain/internal/service/gateway/outbox_reliable.go b/api/gateway/chain/internal/service/gateway/outbox_reliable.go new file mode 100644 index 00000000..d365c4b9 --- /dev/null +++ b/api/gateway/chain/internal/service/gateway/outbox_reliable.go @@ -0,0 +1,47 @@ +package gateway + +import ( + "context" + + gatewayoutbox "github.com/tech/sendico/gateway/common/outbox" + "github.com/tech/sendico/pkg/db/transaction" + me "github.com/tech/sendico/pkg/messaging/envelope" +) + +type chainOutboxProvider interface { + Outbox() gatewayoutbox.Store +} + +type chainTransactionProvider interface { + TransactionFactory() transaction.Factory +} + +func (s *Service) outboxStore() gatewayoutbox.Store { + provider, ok := s.storage.(chainOutboxProvider) + if !ok || provider == nil { + return nil + } + return provider.Outbox() +} + +func (s *Service) startOutboxReliableProducer() error { + if s == nil || s.storage == nil { + return nil + } + return s.outbox.Start(s.logger, s.producer, s.outboxStore(), s.msgCfg) +} + +func (s *Service) sendWithOutbox(ctx context.Context, env me.Envelope) error { + if err := s.startOutboxReliableProducer(); err != nil { + return err + } + return s.outbox.Send(ctx, env) +} + +func (s *Service) executeTransaction(ctx context.Context, cb transaction.Callback) (any, error) { + provider, ok := s.storage.(chainTransactionProvider) + if !ok || provider == nil || provider.TransactionFactory() == nil { + return cb(ctx) + } + return provider.TransactionFactory().CreateTransaction().Execute(ctx, cb) +} diff --git a/api/gateway/chain/internal/service/gateway/service.go b/api/gateway/chain/internal/service/gateway/service.go index dba70a8b..9fac15eb 100644 --- a/api/gateway/chain/internal/service/gateway/service.go +++ b/api/gateway/chain/internal/service/gateway/service.go @@ -12,6 +12,7 @@ import ( "github.com/tech/sendico/gateway/chain/internal/service/gateway/rpcclient" "github.com/tech/sendico/gateway/chain/internal/service/gateway/shared" "github.com/tech/sendico/gateway/chain/storage" + gatewayoutbox "github.com/tech/sendico/gateway/common/outbox" "github.com/tech/sendico/pkg/api/routers" "github.com/tech/sendico/pkg/api/routers/gsresponse" clockpkg "github.com/tech/sendico/pkg/clock" @@ -22,6 +23,7 @@ import ( "github.com/tech/sendico/pkg/mservice" connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" + "go.uber.org/zap" "google.golang.org/grpc" ) @@ -40,9 +42,11 @@ type Service struct { logger mlogger.Logger storage storage.Repository producer msg.Producer + msgCfg pmodel.SettingsT clock clockpkg.Clock settings CacheSettings + outbox gatewayoutbox.ReliableRuntime networks map[pmodel.ChainNetwork]shared.Network serviceWallet shared.ServiceWallet @@ -63,6 +67,7 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro logger: logger.Named("service"), storage: repo, producer: producer, + msgCfg: map[string]any{}, clock: clockpkg.System{}, settings: defaultSettings(), networks: map[pmodel.ChainNetwork]shared.Network{}, @@ -84,6 +89,9 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro } svc.settings = svc.settings.withDefaults() svc.networkRegistry = rpcclient.NewRegistry(svc.networks, svc.rpcClients) + if err := svc.startOutboxReliableProducer(); err != nil { + svc.logger.Warn("Failed to initialise outbox reliable producer", zap.Error(err)) + } svc.commands = commands.NewRegistry(commands.RegistryDeps{ Wallet: commandsWalletDeps(svc), @@ -105,6 +113,7 @@ func (s *Service) Shutdown() { if s == nil { return } + s.outbox.Stop() for _, announcer := range s.announcers { if announcer != nil { announcer.Stop() diff --git a/api/gateway/chain/internal/service/gateway/transfer_notifications.go b/api/gateway/chain/internal/service/gateway/transfer_notifications.go index 5abed7cb..4e63236f 100644 --- a/api/gateway/chain/internal/service/gateway/transfer_notifications.go +++ b/api/gateway/chain/internal/service/gateway/transfer_notifications.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/tech/sendico/gateway/chain/storage/model" + "github.com/tech/sendico/pkg/merrors" paymentgateway "github.com/tech/sendico/pkg/messaging/notifications/paymentgateway" pmodel "github.com/tech/sendico/pkg/model" "github.com/tech/sendico/pkg/mservice" @@ -13,6 +14,9 @@ import ( ) func isFinalStatus(t *model.Transfer) bool { + if t == nil { + return false + } switch t.Status { case model.TransferStatusFailed, model.TransferStatusSuccess, model.TransferStatusCancelled: return true @@ -21,16 +25,25 @@ func isFinalStatus(t *model.Transfer) bool { } } -func toOpStatus(t *model.Transfer) rail.OperationResult { +func isFinalTransferStatus(status model.TransferStatus) bool { + switch status { + case model.TransferStatusFailed, model.TransferStatusSuccess, model.TransferStatusCancelled: + return true + default: + return false + } +} + +func toOpStatus(t *model.Transfer) (rail.OperationResult, error) { switch t.Status { case model.TransferStatusFailed: - return rail.OperationResultFailed + return rail.OperationResultFailed, nil case model.TransferStatusSuccess: - return rail.OperationResultSuccess + return rail.OperationResultSuccess, nil case model.TransferStatusCancelled: - return rail.OperationResultCancelled + return rail.OperationResultCancelled, nil default: - panic(fmt.Sprintf("toOpStatus: unexpected transfer status: %s", t.Status)) + return rail.OperationResultFailed, merrors.InvalidArgument(fmt.Sprintf("unexpected transfer status: %s", t.Status), "transfer.status") } } @@ -45,19 +58,47 @@ func toError(t *model.Transfer) string { } func (s *Service) updateTransferStatus(ctx context.Context, transferRef string, status model.TransferStatus, failureReason, txHash string) (*model.Transfer, error) { - transfer, err := s.storage.Transfers().UpdateStatus(ctx, transferRef, status, failureReason, txHash) + if !isFinalTransferStatus(status) { + transfer, err := s.storage.Transfers().UpdateStatus(ctx, transferRef, status, failureReason, txHash) + if err != nil { + s.logger.Warn("Failed to update transfer status", zap.String("transfer_ref", transferRef), zap.String("status", string(status)), zap.Error(err)) + } + return transfer, err + } + + res, err := s.executeTransaction(ctx, func(txCtx context.Context) (any, error) { + transfer, statusErr := s.storage.Transfers().UpdateStatus(txCtx, transferRef, status, failureReason, txHash) + if statusErr != nil { + return nil, statusErr + } + if isFinalStatus(transfer) { + if emitErr := s.emitTransferStatusEvent(txCtx, transfer); emitErr != nil { + return nil, emitErr + } + } + return transfer, nil + }) if err != nil { s.logger.Warn("Failed to update transfer status", zap.String("transfer_ref", transferRef), zap.String("status", string(status)), zap.Error(err)) + return nil, err } - if isFinalStatus(transfer) { - s.emitTransferStatusEvent(transfer) - } - return transfer, err + + transfer, _ := res.(*model.Transfer) + return transfer, nil } -func (s *Service) emitTransferStatusEvent(transfer *model.Transfer) { - if s == nil || s.producer == nil || transfer == nil { - return +func (s *Service) emitTransferStatusEvent(ctx context.Context, transfer *model.Transfer) error { + if s == nil || transfer == nil { + return nil + } + if s.producer == nil || s.outboxStore() == nil { + return nil + } + + status, err := toOpStatus(transfer) + if err != nil { + s.logger.Warn("Failed to map transfer status for transfer status event", zap.Error(err), zap.String("transfer_ref", transfer.TransferRef)) + return err } exec := pmodel.PaymentGatewayExecution{ @@ -65,13 +106,15 @@ func (s *Service) emitTransferStatusEvent(transfer *model.Transfer) { IdempotencyKey: transfer.IdempotencyKey, ExecutedMoney: transfer.NetAmount, PaymentRef: transfer.PaymentRef, - Status: toOpStatus(transfer), + Status: status, OperationRef: transfer.OperationRef, Error: toError(transfer), TransferRef: transfer.TransferRef, } env := paymentgateway.PaymentGatewayExecution(mservice.ChainGateway, &exec) - if err := s.producer.SendMessage(env); err != nil { + if err := s.sendWithOutbox(ctx, env); err != nil { s.logger.Warn("Failed to publish transfer status event", zap.Error(err), zap.String("transfer_ref", transfer.TransferRef)) + return err } + return nil } diff --git a/api/gateway/chain/storage/mongo/repository.go b/api/gateway/chain/storage/mongo/repository.go index f89eae75..c98ba806 100644 --- a/api/gateway/chain/storage/mongo/repository.go +++ b/api/gateway/chain/storage/mongo/repository.go @@ -6,7 +6,9 @@ import ( "github.com/tech/sendico/gateway/chain/storage" "github.com/tech/sendico/gateway/chain/storage/mongo/store" + gatewayoutbox "github.com/tech/sendico/gateway/common/outbox" "github.com/tech/sendico/pkg/db" + "github.com/tech/sendico/pkg/db/transaction" "github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/mlogger" "go.mongodb.org/mongo-driver/v2/mongo" @@ -15,13 +17,15 @@ import ( // Store implements storage.Repository backed by MongoDB. type Store struct { - logger mlogger.Logger - conn *db.MongoConnection - db *mongo.Database + logger mlogger.Logger + conn *db.MongoConnection + db *mongo.Database + txFactory transaction.Factory wallets storage.WalletsStore transfers storage.TransfersStore deposits storage.DepositsStore + outbox gatewayoutbox.Store } // New creates a new Mongo-backed repository. @@ -35,9 +39,10 @@ func New(logger mlogger.Logger, conn *db.MongoConnection) (*Store, error) { } result := &Store{ - logger: logger.Named("storage").Named("mongo"), - conn: conn, - db: conn.Database(), + logger: logger.Named("storage").Named("mongo"), + conn: conn, + db: conn.Database(), + txFactory: newMongoTransactionFactory(client), } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) @@ -63,10 +68,16 @@ func New(logger mlogger.Logger, conn *db.MongoConnection) (*Store, error) { result.logger.Error("Failed to initialise deposits store", zap.Error(err)) return nil, err } + outboxStore, err := gatewayoutbox.NewMongoStore(result.logger, result.db) + if err != nil { + result.logger.Error("Failed to initialise outbox store", zap.Error(err)) + return nil, err + } result.wallets = walletsStore result.transfers = transfersStore result.deposits = depositsStore + result.outbox = outboxStore result.logger.Info("Chain gateway MongoDB storage initialised") return result, nil @@ -95,4 +106,12 @@ func (s *Store) Deposits() storage.DepositsStore { return s.deposits } +func (s *Store) Outbox() gatewayoutbox.Store { + return s.outbox +} + +func (s *Store) TransactionFactory() transaction.Factory { + return s.txFactory +} + var _ storage.Repository = (*Store)(nil) diff --git a/api/gateway/chain/storage/mongo/transaction.go b/api/gateway/chain/storage/mongo/transaction.go new file mode 100644 index 00000000..52bad57e --- /dev/null +++ b/api/gateway/chain/storage/mongo/transaction.go @@ -0,0 +1,38 @@ +package mongo + +import ( + "context" + + "github.com/tech/sendico/pkg/db/transaction" + "go.mongodb.org/mongo-driver/v2/mongo" +) + +type mongoTransactionFactory struct { + client *mongo.Client +} + +func (f *mongoTransactionFactory) CreateTransaction() transaction.Transaction { + return &mongoTransaction{client: f.client} +} + +type mongoTransaction struct { + client *mongo.Client +} + +func (t *mongoTransaction) Execute(ctx context.Context, cb transaction.Callback) (any, error) { + session, err := t.client.StartSession() + if err != nil { + return nil, err + } + defer session.EndSession(ctx) + + run := func(sessCtx context.Context) (any, error) { + return cb(sessCtx) + } + + return session.WithTransaction(ctx, run) +} + +func newMongoTransactionFactory(client *mongo.Client) transaction.Factory { + return &mongoTransactionFactory{client: client} +} diff --git a/api/gateway/common/go.mod b/api/gateway/common/go.mod new file mode 100644 index 00000000..8be7a4a6 --- /dev/null +++ b/api/gateway/common/go.mod @@ -0,0 +1,30 @@ +module github.com/tech/sendico/gateway/common + +go 1.25.7 + +replace github.com/tech/sendico/pkg => ../../pkg + +require ( + github.com/tech/sendico/pkg v0.1.0 + go.mongodb.org/mongo-driver/v2 v2.5.0 + go.uber.org/zap v1.27.1 +) + +require ( + github.com/google/uuid v1.6.0 // indirect + github.com/klauspost/compress v1.18.4 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/nats-io/nats.go v1.48.0 // indirect + github.com/nats-io/nkeys v0.4.15 // indirect + github.com/nats-io/nuid v1.0.1 // indirect + github.com/xdg-go/pbkdf2 v1.0.0 // indirect + github.com/xdg-go/scram v1.2.0 // indirect + github.com/xdg-go/stringprep v1.0.4 // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect +) diff --git a/api/gateway/common/go.sum b/api/gateway/common/go.sum new file mode 100644 index 00000000..61889025 --- /dev/null +++ b/api/gateway/common/go.sum @@ -0,0 +1,158 @@ +dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= +dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v27.3.1+incompatible h1:KttF0XoteNTicmUtBO0L2tP+J7FGRFTjaEF4k6WdhfI= +github.com/docker/docker v27.3.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= +github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54 h1:mFWunSatvkQQDhpdyuFAYwyAan3hzCuma+Pz8sqvOfg= +github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= +github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo= +github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U= +github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= +github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4= +github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= +github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= +github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= +github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/testcontainers/testcontainers-go v0.33.0 h1:zJS9PfXYT5O0ZFXM2xxXfk4J5UMw/kRiISng037Gxdw= +github.com/testcontainers/testcontainers-go v0.33.0/go.mod h1:W80YpTa8D5C3Yy16icheD01UTDu+LmXIA2Keo+jWtT8= +github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0 h1:iXVA84s5hKMS5gn01GWOYHE3ymy/2b+0YkpFeTxB2XY= +github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0/go.mod h1:R6tMjTojRiaoo89fh/hf7tOmfzohdqSU17R9DwSVSog= +github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4= +github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4= +github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso= +github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= +github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +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/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +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= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/api/gateway/common/outbox/model.go b/api/gateway/common/outbox/model.go new file mode 100644 index 00000000..b332ede3 --- /dev/null +++ b/api/gateway/common/outbox/model.go @@ -0,0 +1,33 @@ +package outbox + +import ( + "time" + + "github.com/tech/sendico/pkg/db/storable" +) + +const Collection = "outbox" + +type Status string + +const ( + StatusPending Status = "pending" + StatusSent Status = "sent" + StatusFailed Status = "failed" +) + +// Event represents an outbox message pending dispatch to the broker. +type Event struct { + storable.Base `bson:",inline" json:",inline"` + + EventID string `bson:"eventId" json:"eventId"` + Subject string `bson:"subject" json:"subject"` + Payload []byte `bson:"payload" json:"payload"` + Status Status `bson:"status" json:"status"` + Attempts int `bson:"attempts" json:"attempts"` + SentAt *time.Time `bson:"sentAt,omitempty" json:"sentAt,omitempty"` +} + +func (*Event) Collection() string { + return Collection +} diff --git a/api/gateway/common/outbox/mongo_store.go b/api/gateway/common/outbox/mongo_store.go new file mode 100644 index 00000000..d510bb4f --- /dev/null +++ b/api/gateway/common/outbox/mongo_store.go @@ -0,0 +1,123 @@ +package outbox + +import ( + "context" + "time" + + "github.com/tech/sendico/pkg/db/repository" + ri "github.com/tech/sendico/pkg/db/repository/index" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.uber.org/zap" +) + +type mongoStore struct { + logger mlogger.Logger + repo repository.Repository +} + +func NewMongoStore(logger mlogger.Logger, db *mongo.Database) (Store, error) { + if db == nil { + return nil, merrors.InvalidArgument("mongo database is nil") + } + if logger == nil { + logger = zap.NewNop() + } + repo := repository.CreateMongoRepository(db, Collection) + + statusIndex := &ri.Definition{ + Keys: []ri.Key{{Field: "status", Sort: ri.Asc}, {Field: "createdAt", Sort: ri.Asc}}, + } + if err := repo.CreateIndex(statusIndex); err != nil { + logger.Error("Failed to ensure outbox status index", zap.Error(err)) + return nil, err + } + + eventIDIndex := &ri.Definition{ + Keys: []ri.Key{{Field: "eventId", Sort: ri.Asc}}, + Unique: true, + } + if err := repo.CreateIndex(eventIDIndex); err != nil { + logger.Error("Failed to ensure outbox eventId index", zap.Error(err)) + return nil, err + } + + childLogger := logger.Named(Collection) + childLogger.Debug("Outbox store initialised", zap.String("collection", Collection)) + + return &mongoStore{logger: childLogger, repo: repo}, nil +} + +func (o *mongoStore) Create(ctx context.Context, event *Event) error { + if event == nil { + o.logger.Warn("Attempt to create nil outbox event") + return merrors.InvalidArgument("outbox: nil event") + } + + if err := o.repo.Insert(ctx, event, nil); err != nil { + if mongo.IsDuplicateKeyError(err) { + o.logger.Warn("Duplicate outbox event id", zap.String("event_id", event.EventID)) + return merrors.DataConflict("outbox event with this id already exists") + } + o.logger.Warn("Failed to create outbox event", zap.Error(err)) + return err + } + + o.logger.Debug("Outbox event created", zap.String("event_id", event.EventID), zap.String("subject", event.Subject)) + return nil +} + +func (o *mongoStore) ListPending(ctx context.Context, limit int) ([]*Event, error) { + limit64 := int64(limit) + query := repository.Query(). + Filter(repository.Field("status"), StatusPending). + Limit(&limit64). + Sort(repository.Field("createdAt"), true) + + events := make([]*Event, 0) + err := o.repo.FindManyByFilter(ctx, query, func(cur *mongo.Cursor) error { + doc := &Event{} + if err := cur.Decode(doc); err != nil { + return err + } + events = append(events, doc) + return nil + }) + if err != nil { + o.logger.Warn("Failed to list pending outbox events", zap.Error(err)) + return nil, err + } + + return events, nil +} + +func (o *mongoStore) MarkSent(ctx context.Context, eventRef bson.ObjectID, sentAt time.Time) error { + if eventRef.IsZero() { + return merrors.InvalidArgument("outbox: zero event id") + } + + patch := repository.Patch(). + Set(repository.Field("status"), StatusSent). + Set(repository.Field("sentAt"), sentAt) + return o.repo.Patch(ctx, eventRef, patch) +} + +func (o *mongoStore) MarkFailed(ctx context.Context, eventRef bson.ObjectID) error { + if eventRef.IsZero() { + return merrors.InvalidArgument("outbox: zero event id") + } + + patch := repository.Patch().Set(repository.Field("status"), StatusFailed) + return o.repo.Patch(ctx, eventRef, patch) +} + +func (o *mongoStore) IncrementAttempts(ctx context.Context, eventRef bson.ObjectID) error { + if eventRef.IsZero() { + return merrors.InvalidArgument("outbox: zero event id") + } + + patch := repository.Patch().Inc(repository.Field("attempts"), 1) + return o.repo.Patch(ctx, eventRef, patch) +} diff --git a/api/gateway/common/outbox/reliable_adapter.go b/api/gateway/common/outbox/reliable_adapter.go new file mode 100644 index 00000000..6fe62bb2 --- /dev/null +++ b/api/gateway/common/outbox/reliable_adapter.go @@ -0,0 +1,108 @@ +package outbox + +import ( + "context" + "strings" + "time" + + pmessaging "github.com/tech/sendico/pkg/messaging" + pmessagingreliable "github.com/tech/sendico/pkg/messaging/reliable" + "github.com/tech/sendico/pkg/mlogger" + cfgmodel "github.com/tech/sendico/pkg/model" + "go.mongodb.org/mongo-driver/v2/bson" +) + +type reliableStoreAdapter struct { + store Store +} + +func NewReliableProducer(logger mlogger.Logger, direct pmessaging.Producer, store Store, messagingSettings cfgmodel.SettingsT, opts ...pmessagingreliable.Option) (*pmessagingreliable.ReliableProducer, pmessagingreliable.Settings, error) { + if store == nil { + return nil, pmessagingreliable.DefaultSettings(), nil + } + producer, settings, err := pmessagingreliable.NewReliableProducerFromConfig(logger, direct, &reliableStoreAdapter{store: store}, messagingSettings, opts...) + if err != nil { + return nil, pmessagingreliable.Settings{}, err + } + return producer, settings, nil +} + +func (a *reliableStoreAdapter) Enqueue(ctx context.Context, msg pmessagingreliable.OutboxMessage) error { + if a == nil || a.store == nil { + return nil + } + return a.store.Create(ctx, &Event{ + EventID: strings.TrimSpace(msg.EventID), + Subject: strings.TrimSpace(msg.Subject), + Payload: append([]byte(nil), msg.Payload...), + Status: StatusPending, + Attempts: msg.Attempts, + }) +} + +func (a *reliableStoreAdapter) ListPending(ctx context.Context, limit int) ([]pmessagingreliable.OutboxMessage, error) { + if a == nil || a.store == nil { + return nil, nil + } + events, err := a.store.ListPending(ctx, limit) + if err != nil { + return nil, err + } + + result := make([]pmessagingreliable.OutboxMessage, 0, len(events)) + for _, event := range events { + if event == nil { + continue + } + reference := "" + if eventRef := event.GetID(); eventRef != nil && !eventRef.IsZero() { + reference = eventRef.Hex() + } + result = append(result, pmessagingreliable.OutboxMessage{ + Reference: reference, + EventID: strings.TrimSpace(event.EventID), + Subject: strings.TrimSpace(event.Subject), + Payload: append([]byte(nil), event.Payload...), + Attempts: event.Attempts, + CreatedAt: event.CreatedAt, + }) + } + return result, nil +} + +func (a *reliableStoreAdapter) MarkSent(ctx context.Context, reference string, sentAt time.Time) error { + if a == nil || a.store == nil { + return nil + } + eventRef, err := parseObjectID(strings.TrimSpace(reference)) + if err != nil { + return err + } + return a.store.MarkSent(ctx, eventRef, sentAt) +} + +func (a *reliableStoreAdapter) MarkFailed(ctx context.Context, reference string) error { + if a == nil || a.store == nil { + return nil + } + eventRef, err := parseObjectID(strings.TrimSpace(reference)) + if err != nil { + return err + } + return a.store.MarkFailed(ctx, eventRef) +} + +func (a *reliableStoreAdapter) IncrementAttempts(ctx context.Context, reference string) error { + if a == nil || a.store == nil { + return nil + } + eventRef, err := parseObjectID(strings.TrimSpace(reference)) + if err != nil { + return err + } + return a.store.IncrementAttempts(ctx, eventRef) +} + +func parseObjectID(raw string) (bson.ObjectID, error) { + return bson.ObjectIDFromHex(raw) +} diff --git a/api/gateway/common/outbox/reliable_adapter_integration_test.go b/api/gateway/common/outbox/reliable_adapter_integration_test.go new file mode 100644 index 00000000..8517ab69 --- /dev/null +++ b/api/gateway/common/outbox/reliable_adapter_integration_test.go @@ -0,0 +1,330 @@ +package outbox + +import ( + "context" + "errors" + "sort" + "strings" + "sync" + "testing" + "time" + + me "github.com/tech/sendico/pkg/messaging/envelope" + pmessagingreliable "github.com/tech/sendico/pkg/messaging/reliable" + domainmodel "github.com/tech/sendico/pkg/model" + notification "github.com/tech/sendico/pkg/model/notification" + "github.com/tech/sendico/pkg/mservice" + "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" +) + +func TestGatewayReliableProducerPersistsAndRetriesOnBrokerFailure(t *testing.T) { + store := newMemoryOutboxStore() + broker := &flakyDirectProducer{failuresRemaining: 1} + + producer, _, err := NewReliableProducer( + zap.NewNop(), + broker, + store, + nil, + pmessagingreliable.WithBatchSize(1), + pmessagingreliable.WithMaxAttempts(3), + ) + if err != nil { + t.Fatalf("failed to create reliable producer: %v", err) + } + + env := newTestEnvelope(t, []byte(`{"transferRef":"tx-1","status":"pending"}`)) + if err := producer.SendWithOutbox(context.Background(), env); err != nil { + t.Fatalf("failed to enqueue envelope into outbox: %v", err) + } + + eventID := env.GetMessageId().String() + persisted := store.EventByID(eventID) + if persisted == nil { + t.Fatalf("expected outbox event %s to be persisted", eventID) + } + if persisted.Status != StatusPending { + t.Fatalf("expected pending status after enqueue, got %q", persisted.Status) + } + if persisted.Attempts != 0 { + t.Fatalf("expected zero attempts after enqueue, got %d", persisted.Attempts) + } + + processed, err := producer.DispatchPending(context.Background()) + if err != nil { + t.Fatalf("first dispatch failed: %v", err) + } + if processed != 1 { + t.Fatalf("expected first dispatch to process 1 event, got %d", processed) + } + + afterFailure := store.EventByID(eventID) + if afterFailure == nil { + t.Fatalf("expected outbox event %s to exist after broker failure", eventID) + } + if afterFailure.Status != StatusPending { + t.Fatalf("expected event to stay pending after transient broker error, got %q", afterFailure.Status) + } + if afterFailure.Attempts != 1 { + t.Fatalf("expected attempts to increment to 1 after failure, got %d", afterFailure.Attempts) + } + if afterFailure.SentAt != nil { + t.Fatalf("expected sentAt to be empty after failed publish") + } + + processed, err = producer.DispatchPending(context.Background()) + if err != nil { + t.Fatalf("second dispatch failed: %v", err) + } + if processed != 1 { + t.Fatalf("expected second dispatch to process 1 event, got %d", processed) + } + + afterRetry := store.EventByID(eventID) + if afterRetry == nil { + t.Fatalf("expected outbox event %s to exist after retry", eventID) + } + if afterRetry.Status != StatusSent { + t.Fatalf("expected event to be sent after retry, got %q", afterRetry.Status) + } + if afterRetry.Attempts != 1 { + t.Fatalf("expected attempts to remain 1 after successful retry, got %d", afterRetry.Attempts) + } + if afterRetry.SentAt == nil { + t.Fatalf("expected sentAt to be set after successful publish") + } + + if attempts := broker.Attempts(); attempts != 2 { + t.Fatalf("expected two broker attempts (fail then success), got %d", attempts) + } +} + +func TestGatewayReliableProducerMarksFailedAfterMaxAttempts(t *testing.T) { + store := newMemoryOutboxStore() + broker := &flakyDirectProducer{failuresRemaining: 10} + + producer, _, err := NewReliableProducer( + zap.NewNop(), + broker, + store, + nil, + pmessagingreliable.WithBatchSize(1), + pmessagingreliable.WithMaxAttempts(2), + ) + if err != nil { + t.Fatalf("failed to create reliable producer: %v", err) + } + + env := newTestEnvelope(t, []byte(`{"transferRef":"tx-2","status":"pending"}`)) + if err := producer.SendWithOutbox(context.Background(), env); err != nil { + t.Fatalf("failed to enqueue envelope into outbox: %v", err) + } + + eventID := env.GetMessageId().String() + + processed, err := producer.DispatchPending(context.Background()) + if err != nil { + t.Fatalf("first dispatch failed: %v", err) + } + if processed != 1 { + t.Fatalf("expected first dispatch to process 1 event, got %d", processed) + } + + processed, err = producer.DispatchPending(context.Background()) + if err != nil { + t.Fatalf("second dispatch failed: %v", err) + } + if processed != 1 { + t.Fatalf("expected second dispatch to process 1 event, got %d", processed) + } + + processed, err = producer.DispatchPending(context.Background()) + if err != nil { + t.Fatalf("third dispatch failed: %v", err) + } + if processed != 0 { + t.Fatalf("expected failed event to be excluded from pending queue, got processed=%d", processed) + } + + final := store.EventByID(eventID) + if final == nil { + t.Fatalf("expected outbox event %s to exist", eventID) + } + if final.Status != StatusFailed { + t.Fatalf("expected event to be marked failed after max attempts, got %q", final.Status) + } + if final.Attempts != 2 { + t.Fatalf("expected attempts to equal max attempts (2), got %d", final.Attempts) + } + if final.SentAt != nil { + t.Fatalf("expected sentAt to remain empty for failed event") + } +} + +func newTestEnvelope(t *testing.T, payload []byte) me.Envelope { + t.Helper() + + env := me.CreateEnvelope("gateway.common.outbox.test", domainmodel.NewNotification(mservice.ChainGateway, notification.NAUpdated)) + if _, err := env.Wrap(payload); err != nil { + t.Fatalf("failed to wrap test payload: %v", err) + } + + return env +} + +type memoryOutboxStore struct { + mu sync.Mutex + + eventsByRef map[bson.ObjectID]*Event + refByEvent map[string]bson.ObjectID +} + +func newMemoryOutboxStore() *memoryOutboxStore { + return &memoryOutboxStore{ + eventsByRef: make(map[bson.ObjectID]*Event), + refByEvent: make(map[string]bson.ObjectID), + } +} + +func (s *memoryOutboxStore) Create(_ context.Context, event *Event) error { + if event == nil { + return errors.New("event is nil") + } + + s.mu.Lock() + defer s.mu.Unlock() + + eventID := strings.TrimSpace(event.EventID) + if eventID == "" { + return errors.New("event id is required") + } + if _, exists := s.refByEvent[eventID]; exists { + return errors.New("duplicate event id") + } + + stored := cloneEvent(event) + stored.SetID(bson.NewObjectID()) + if stored.Status == "" { + stored.Status = StatusPending + } + + ref := *stored.GetID() + s.eventsByRef[ref] = stored + s.refByEvent[eventID] = ref + return nil +} + +func (s *memoryOutboxStore) ListPending(_ context.Context, limit int) ([]*Event, error) { + s.mu.Lock() + defer s.mu.Unlock() + + pending := make([]*Event, 0, len(s.eventsByRef)) + for _, event := range s.eventsByRef { + if event.Status == StatusPending { + pending = append(pending, cloneEvent(event)) + } + } + sort.Slice(pending, func(i, j int) bool { + return pending[i].CreatedAt.Before(pending[j].CreatedAt) + }) + + if limit > 0 && len(pending) > limit { + pending = pending[:limit] + } + return pending, nil +} + +func (s *memoryOutboxStore) MarkSent(_ context.Context, eventRef bson.ObjectID, sentAt time.Time) error { + s.mu.Lock() + defer s.mu.Unlock() + + event, ok := s.eventsByRef[eventRef] + if !ok { + return errors.New("event not found") + } + event.Status = StatusSent + when := sentAt.UTC() + event.SentAt = &when + event.Update() + return nil +} + +func (s *memoryOutboxStore) MarkFailed(_ context.Context, eventRef bson.ObjectID) error { + s.mu.Lock() + defer s.mu.Unlock() + + event, ok := s.eventsByRef[eventRef] + if !ok { + return errors.New("event not found") + } + event.Status = StatusFailed + event.Update() + return nil +} + +func (s *memoryOutboxStore) IncrementAttempts(_ context.Context, eventRef bson.ObjectID) error { + s.mu.Lock() + defer s.mu.Unlock() + + event, ok := s.eventsByRef[eventRef] + if !ok { + return errors.New("event not found") + } + event.Attempts++ + event.Update() + return nil +} + +func (s *memoryOutboxStore) EventByID(eventID string) *Event { + s.mu.Lock() + defer s.mu.Unlock() + + ref, ok := s.refByEvent[eventID] + if !ok { + return nil + } + + event, ok := s.eventsByRef[ref] + if !ok { + return nil + } + return cloneEvent(event) +} + +func cloneEvent(event *Event) *Event { + if event == nil { + return nil + } + copyEvent := *event + copyEvent.Payload = append([]byte(nil), event.Payload...) + if event.SentAt != nil { + sentAt := *event.SentAt + copyEvent.SentAt = &sentAt + } + return ©Event +} + +type flakyDirectProducer struct { + mu sync.Mutex + failuresRemaining int + attempts int +} + +func (p *flakyDirectProducer) SendMessage(_ me.Envelope) error { + p.mu.Lock() + defer p.mu.Unlock() + + p.attempts++ + if p.failuresRemaining > 0 { + p.failuresRemaining-- + return errors.New("broker unavailable") + } + return nil +} + +func (p *flakyDirectProducer) Attempts() int { + p.mu.Lock() + defer p.mu.Unlock() + return p.attempts +} diff --git a/api/gateway/common/outbox/reliable_runtime.go b/api/gateway/common/outbox/reliable_runtime.go new file mode 100644 index 00000000..fcb6d82d --- /dev/null +++ b/api/gateway/common/outbox/reliable_runtime.go @@ -0,0 +1,72 @@ +package outbox + +import ( + "context" + "sync" + + "github.com/tech/sendico/pkg/merrors" + pmessaging "github.com/tech/sendico/pkg/messaging" + me "github.com/tech/sendico/pkg/messaging/envelope" + pmessagingreliable "github.com/tech/sendico/pkg/messaging/reliable" + "github.com/tech/sendico/pkg/mlogger" + cfgmodel "github.com/tech/sendico/pkg/model" + "go.uber.org/zap" +) + +// ReliableRuntime owns a reliable producer lifecycle for gateway outbox dispatch. +type ReliableRuntime struct { + once sync.Once + cancel context.CancelFunc + producer *pmessagingreliable.ReliableProducer + settings pmessagingreliable.Settings + initErr error +} + +func (r *ReliableRuntime) Start(logger mlogger.Logger, direct pmessaging.Producer, store Store, messagingSettings cfgmodel.SettingsT, opts ...pmessagingreliable.Option) error { + if r == nil { + return nil + } + if logger == nil { + logger = zap.NewNop() + } + logger = logger.Named("outbox_reliable") + + r.once.Do(func() { + reliableProducer, settings, err := NewReliableProducer(logger, direct, store, messagingSettings, opts...) + if err != nil { + r.initErr = err + return + } + r.producer = reliableProducer + r.settings = settings + if r.producer == nil || direct == nil { + logger.Info("Outbox reliable publisher disabled", zap.Bool("enabled", settings.Enabled)) + return + } + logger.Info("Outbox reliable publisher configured", + zap.Bool("enabled", settings.Enabled), + zap.Int("batch_size", settings.BatchSize), + zap.Int("poll_interval_seconds", settings.PollIntervalSeconds), + zap.Int("max_attempts", settings.MaxAttempts)) + + ctx, cancel := context.WithCancel(context.Background()) + r.cancel = cancel + go r.producer.Run(ctx) + }) + + return r.initErr +} + +func (r *ReliableRuntime) Send(ctx context.Context, envelope me.Envelope) error { + if r == nil || r.producer == nil { + return merrors.Internal("reliable outbox producer is not configured") + } + return r.producer.SendWithOutbox(ctx, envelope) +} + +func (r *ReliableRuntime) Stop() { + if r == nil || r.cancel == nil { + return + } + r.cancel() +} diff --git a/api/gateway/common/outbox/store.go b/api/gateway/common/outbox/store.go new file mode 100644 index 00000000..61ed1858 --- /dev/null +++ b/api/gateway/common/outbox/store.go @@ -0,0 +1,17 @@ +package outbox + +import ( + "context" + "time" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +// Store persists gateway outbox events. +type Store interface { + Create(ctx context.Context, event *Event) error + ListPending(ctx context.Context, limit int) ([]*Event, error) + MarkSent(ctx context.Context, eventRef bson.ObjectID, sentAt time.Time) error + MarkFailed(ctx context.Context, eventRef bson.ObjectID) error + IncrementAttempts(ctx context.Context, eventRef bson.ObjectID) error +} diff --git a/api/gateway/mntx/go.mod b/api/gateway/mntx/go.mod index 0bfa7825..d2a80454 100644 --- a/api/gateway/mntx/go.mod +++ b/api/gateway/mntx/go.mod @@ -4,10 +4,13 @@ go 1.25.7 replace github.com/tech/sendico/pkg => ../../pkg +replace github.com/tech/sendico/gateway/common => ../common + require ( github.com/go-chi/chi/v5 v5.2.5 github.com/prometheus/client_golang v1.23.2 github.com/shopspring/decimal v1.4.0 + github.com/tech/sendico/gateway/common v0.1.0 github.com/tech/sendico/pkg v0.1.0 go.mongodb.org/mongo-driver/v2 v2.5.0 go.uber.org/zap v1.27.1 @@ -48,5 +51,5 @@ require ( golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect ) diff --git a/api/gateway/mntx/go.sum b/api/gateway/mntx/go.sum index 5cf7861e..ffcdff38 100644 --- a/api/gateway/mntx/go.sum +++ b/api/gateway/mntx/go.sum @@ -210,8 +210,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/gateway/mntx/internal/server/internal/serverimp.go b/api/gateway/mntx/internal/server/internal/serverimp.go index e5573542..1a799ff6 100644 --- a/api/gateway/mntx/internal/server/internal/serverimp.go +++ b/api/gateway/mntx/internal/server/internal/serverimp.go @@ -191,14 +191,18 @@ func (i *Imp) Start() error { if cfg.GRPC != nil { invokeURI = cfg.GRPC.DiscoveryInvokeURI() } - svc := mntxservice.NewService(logger, + opts := []mntxservice.Option{ mntxservice.WithDiscoveryInvokeURI(invokeURI), mntxservice.WithProducer(producer), mntxservice.WithMonetixConfig(monetixCfg), mntxservice.WithGatewayDescriptor(gatewayDescriptor), mntxservice.WithHTTPClient(&http.Client{Timeout: monetixCfg.Timeout()}), mntxservice.WithStorage(repo), - ) + } + if cfg.Messaging != nil { + opts = append(opts, mntxservice.WithMessagingSettings(cfg.Messaging.Settings)) + } + svc := mntxservice.NewService(logger, opts...) i.service = svc if err := i.startHTTPCallbackServer(svc, callbackCfg); err != nil { diff --git a/api/gateway/mntx/internal/service/gateway/card_processor.go b/api/gateway/mntx/internal/service/gateway/card_processor.go index 29027ff4..a134e217 100644 --- a/api/gateway/mntx/internal/service/gateway/card_processor.go +++ b/api/gateway/mntx/internal/service/gateway/card_processor.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/shopspring/decimal" + gatewayoutbox "github.com/tech/sendico/gateway/common/outbox" "github.com/tech/sendico/gateway/mntx/internal/service/monetix" "github.com/tech/sendico/gateway/mntx/storage" "github.com/tech/sendico/gateway/mntx/storage/model" @@ -17,6 +18,7 @@ import ( "github.com/tech/sendico/pkg/merrors" msg "github.com/tech/sendico/pkg/messaging" "github.com/tech/sendico/pkg/mlogger" + pmodel "github.com/tech/sendico/pkg/model" gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1" mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" "go.mongodb.org/mongo-driver/v2/bson" @@ -30,6 +32,8 @@ type cardPayoutProcessor struct { store storage.Repository httpClient *http.Client producer msg.Producer + msgCfg pmodel.SettingsT + outbox *gatewayoutbox.ReliableRuntime perTxMinAmountMinor int64 perTxMinAmountMinorByCurrency map[string]int64 diff --git a/api/gateway/mntx/internal/service/gateway/options.go b/api/gateway/mntx/internal/service/gateway/options.go index b0686a88..6465333a 100644 --- a/api/gateway/mntx/internal/service/gateway/options.go +++ b/api/gateway/mntx/internal/service/gateway/options.go @@ -8,6 +8,7 @@ import ( "github.com/tech/sendico/gateway/mntx/storage" "github.com/tech/sendico/pkg/clock" msg "github.com/tech/sendico/pkg/messaging" + pmodel "github.com/tech/sendico/pkg/model" gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1" ) @@ -67,3 +68,12 @@ func WithDiscoveryInvokeURI(invokeURI string) Option { s.invokeURI = strings.TrimSpace(invokeURI) } } + +// WithMessagingSettings applies messaging driver settings. +func WithMessagingSettings(settings pmodel.SettingsT) Option { + return func(s *Service) { + if settings != nil { + s.msgCfg = settings + } + } +} diff --git a/api/gateway/mntx/internal/service/gateway/outbox_reliable.go b/api/gateway/mntx/internal/service/gateway/outbox_reliable.go new file mode 100644 index 00000000..0e6c2f83 --- /dev/null +++ b/api/gateway/mntx/internal/service/gateway/outbox_reliable.go @@ -0,0 +1,50 @@ +package gateway + +import ( + "context" + + gatewayoutbox "github.com/tech/sendico/gateway/common/outbox" + "github.com/tech/sendico/pkg/db/transaction" + me "github.com/tech/sendico/pkg/messaging/envelope" +) + +type mntxOutboxProvider interface { + Outbox() gatewayoutbox.Store +} + +type mntxTransactionProvider interface { + TransactionFactory() transaction.Factory +} + +func (p *cardPayoutProcessor) outboxStore() gatewayoutbox.Store { + provider, ok := p.store.(mntxOutboxProvider) + if !ok || provider == nil { + return nil + } + return provider.Outbox() +} + +func (p *cardPayoutProcessor) startOutboxReliableProducer() error { + if p == nil || p.outbox == nil { + return nil + } + return p.outbox.Start(p.logger, p.producer, p.outboxStore(), p.msgCfg) +} + +func (p *cardPayoutProcessor) sendWithOutbox(ctx context.Context, env me.Envelope) error { + if err := p.startOutboxReliableProducer(); err != nil { + return err + } + if p.outbox == nil { + return nil + } + return p.outbox.Send(ctx, env) +} + +func (p *cardPayoutProcessor) executeTransaction(ctx context.Context, cb transaction.Callback) (any, error) { + provider, ok := p.store.(mntxTransactionProvider) + if !ok || provider == nil || provider.TransactionFactory() == nil { + return cb(ctx) + } + return provider.TransactionFactory().CreateTransaction().Execute(ctx, cb) +} diff --git a/api/gateway/mntx/internal/service/gateway/service.go b/api/gateway/mntx/internal/service/gateway/service.go index 9331d2bf..32ef2d0d 100644 --- a/api/gateway/mntx/internal/service/gateway/service.go +++ b/api/gateway/mntx/internal/service/gateway/service.go @@ -5,6 +5,7 @@ import ( "net/http" "strings" + gatewayoutbox "github.com/tech/sendico/gateway/common/outbox" "github.com/tech/sendico/gateway/mntx/internal/appversion" "github.com/tech/sendico/gateway/mntx/internal/service/monetix" "github.com/tech/sendico/gateway/mntx/storage" @@ -14,6 +15,7 @@ import ( "github.com/tech/sendico/pkg/discovery" msg "github.com/tech/sendico/pkg/messaging" "github.com/tech/sendico/pkg/mlogger" + pmodel "github.com/tech/sendico/pkg/model" "github.com/tech/sendico/pkg/mservice" gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1" connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1" @@ -25,10 +27,12 @@ type Service struct { logger mlogger.Logger clock clockpkg.Clock producer msg.Producer + msgCfg pmodel.SettingsT storage storage.Repository config monetix.Config httpClient *http.Client card *cardPayoutProcessor + outbox gatewayoutbox.ReliableRuntime gatewayDescriptor *gatewayv1.GatewayInstanceDescriptor announcer *discovery.Announcer invokeURI string @@ -64,6 +68,7 @@ func NewService(logger mlogger.Logger, opts ...Option) *Service { logger: logger.Named("service"), clock: clockpkg.NewSystem(), config: monetix.DefaultConfig(), + msgCfg: map[string]any{}, } initMetrics() @@ -85,6 +90,11 @@ func NewService(logger mlogger.Logger, opts ...Option) *Service { } svc.card = newCardPayoutProcessor(svc.logger, svc.config, svc.clock, svc.storage, svc.httpClient, svc.producer) + svc.card.outbox = &svc.outbox + svc.card.msgCfg = svc.msgCfg + if err := svc.card.startOutboxReliableProducer(); err != nil { + svc.logger.Warn("Failed to initialise outbox reliable producer", zap.Error(err)) + } svc.card.applyGatewayDescriptor(svc.gatewayDescriptor) svc.startDiscoveryAnnouncer() @@ -102,6 +112,7 @@ func (s *Service) Shutdown() { if s == nil { return } + s.outbox.Stop() if s.announcer != nil { s.announcer.Stop() } diff --git a/api/gateway/mntx/internal/service/gateway/transfer_notifications.go b/api/gateway/mntx/internal/service/gateway/transfer_notifications.go index 29078d28..cb8ba4b6 100644 --- a/api/gateway/mntx/internal/service/gateway/transfer_notifications.go +++ b/api/gateway/mntx/internal/service/gateway/transfer_notifications.go @@ -38,27 +38,49 @@ func toOpStatus(t *model.CardPayout) (rail.OperationResult, error) { } func (p *cardPayoutProcessor) updatePayoutStatus(ctx context.Context, state *model.CardPayout) error { - if err := p.store.Payouts().Upsert(ctx, state); err != nil { + if !isFinalStatus(state) { + if err := p.store.Payouts().Upsert(ctx, state); err != nil { + p.logger.Warn("Failed to update transfer status", zap.Error(err), mzap.ObjRef("payout_ref", state.ID), + zap.String("payment_ref", state.PaymentRef), zap.String("status", string(state.Status)), + ) + return err + } + return nil + } + + _, err := p.executeTransaction(ctx, func(txCtx context.Context) (any, error) { + if upsertErr := p.store.Payouts().Upsert(txCtx, state); upsertErr != nil { + return nil, upsertErr + } + if isFinalStatus(state) { + if emitErr := p.emitTransferStatusEvent(txCtx, state); emitErr != nil { + return nil, emitErr + } + } + return nil, nil + }) + if err != nil { p.logger.Warn("Failed to update transfer status", zap.Error(err), mzap.ObjRef("payout_ref", state.ID), zap.String("payment_ref", state.PaymentRef), zap.String("status", string(state.Status)), ) - } - if isFinalStatus(state) { - p.emitTransferStatusEvent(state) + return err } return nil } -func (p *cardPayoutProcessor) emitTransferStatusEvent(payout *model.CardPayout) { - if p == nil || p.producer == nil || payout == nil { - return +func (p *cardPayoutProcessor) emitTransferStatusEvent(ctx context.Context, payout *model.CardPayout) error { + if p == nil || payout == nil { + return nil + } + if p.producer == nil || p.outboxStore() == nil { + return nil } status, err := toOpStatus(payout) if err != nil { p.logger.Warn("Failed to convert payout status to operation status for transfer status event", zap.Error(err), mzap.ObjRef("payout_ref", payout.ID), zap.String("payment_ref", payout.PaymentRef), zap.String("status", string(payout.Status))) - return + return err } exec := pmodel.PaymentGatewayExecution{ @@ -75,7 +97,9 @@ func (p *cardPayoutProcessor) emitTransferStatusEvent(payout *model.CardPayout) TransferRef: payout.GetID().Hex(), } env := paymentgateway.PaymentGatewayExecution(mservice.MntxGateway, &exec) - if err := p.producer.SendMessage(env); err != nil { + if err := p.sendWithOutbox(ctx, env); err != nil { p.logger.Warn("Failed to publish transfer status event", zap.Error(err), mzap.ObjRef("transfer_ref", payout.ID)) + return err } + return nil } diff --git a/api/gateway/mntx/storage/mongo/repository.go b/api/gateway/mntx/storage/mongo/repository.go index c48c08d2..2ebbda9f 100644 --- a/api/gateway/mntx/storage/mongo/repository.go +++ b/api/gateway/mntx/storage/mongo/repository.go @@ -4,9 +4,11 @@ import ( "context" "time" + gatewayoutbox "github.com/tech/sendico/gateway/common/outbox" "github.com/tech/sendico/gateway/mntx/storage" "github.com/tech/sendico/gateway/mntx/storage/mongo/store" "github.com/tech/sendico/pkg/db" + "github.com/tech/sendico/pkg/db/transaction" "github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/mlogger" "go.mongodb.org/mongo-driver/v2/mongo" @@ -14,11 +16,13 @@ import ( ) type Repository struct { - logger mlogger.Logger - conn *db.MongoConnection - db *mongo.Database + logger mlogger.Logger + conn *db.MongoConnection + db *mongo.Database + txFactory transaction.Factory payouts storage.PayoutsStore + outbox gatewayoutbox.Store } func New(logger mlogger.Logger, conn *db.MongoConnection) (*Repository, error) { @@ -42,9 +46,10 @@ func New(logger mlogger.Logger, conn *db.MongoConnection) (*Repository, error) { logger = logger.With(zap.String("database", dbName)) } result := &Repository{ - logger: logger, - conn: conn, - db: db, + logger: logger, + conn: conn, + db: db, + txFactory: newMongoTransactionFactory(client), } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -57,7 +62,13 @@ func New(logger mlogger.Logger, conn *db.MongoConnection) (*Repository, error) { result.logger.Error("Failed to initialise payouts store", zap.Error(err), zap.String("store", "payments")) return nil, err } + outboxStore, err := gatewayoutbox.NewMongoStore(result.logger, result.db) + if err != nil { + result.logger.Error("Failed to initialise outbox store", zap.Error(err), zap.String("store", "outbox")) + return nil, err + } result.payouts = payoutsStore + result.outbox = outboxStore result.logger.Info("Payouts gateway MongoDB storage initialised") return result, nil } @@ -66,4 +77,12 @@ func (r *Repository) Payouts() storage.PayoutsStore { return r.payouts } +func (r *Repository) Outbox() gatewayoutbox.Store { + return r.outbox +} + +func (r *Repository) TransactionFactory() transaction.Factory { + return r.txFactory +} + var _ storage.Repository = (*Repository)(nil) diff --git a/api/gateway/mntx/storage/mongo/transaction.go b/api/gateway/mntx/storage/mongo/transaction.go new file mode 100644 index 00000000..52bad57e --- /dev/null +++ b/api/gateway/mntx/storage/mongo/transaction.go @@ -0,0 +1,38 @@ +package mongo + +import ( + "context" + + "github.com/tech/sendico/pkg/db/transaction" + "go.mongodb.org/mongo-driver/v2/mongo" +) + +type mongoTransactionFactory struct { + client *mongo.Client +} + +func (f *mongoTransactionFactory) CreateTransaction() transaction.Transaction { + return &mongoTransaction{client: f.client} +} + +type mongoTransaction struct { + client *mongo.Client +} + +func (t *mongoTransaction) Execute(ctx context.Context, cb transaction.Callback) (any, error) { + session, err := t.client.StartSession() + if err != nil { + return nil, err + } + defer session.EndSession(ctx) + + run := func(sessCtx context.Context) (any, error) { + return cb(sessCtx) + } + + return session.WithTransaction(ctx, run) +} + +func newMongoTransactionFactory(client *mongo.Client) transaction.Factory { + return &mongoTransactionFactory{client: client} +} diff --git a/api/gateway/tgsettle/go.mod b/api/gateway/tgsettle/go.mod index e3eab7da..f9affff6 100644 --- a/api/gateway/tgsettle/go.mod +++ b/api/gateway/tgsettle/go.mod @@ -4,7 +4,10 @@ go 1.25.7 replace github.com/tech/sendico/pkg => ../../pkg +replace github.com/tech/sendico/gateway/common => ../common + require ( + github.com/tech/sendico/gateway/common v0.1.0 github.com/tech/sendico/pkg v0.1.0 go.mongodb.org/mongo-driver/v2 v2.5.0 go.uber.org/zap v1.27.1 @@ -45,5 +48,5 @@ require ( golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect ) diff --git a/api/gateway/tgsettle/go.sum b/api/gateway/tgsettle/go.sum index 3552c709..a7714150 100644 --- a/api/gateway/tgsettle/go.sum +++ b/api/gateway/tgsettle/go.sum @@ -208,8 +208,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/gateway/tgsettle/internal/server/internal/serverimp.go b/api/gateway/tgsettle/internal/server/internal/serverimp.go index a114be45..686c6cdf 100644 --- a/api/gateway/tgsettle/internal/server/internal/serverimp.go +++ b/api/gateway/tgsettle/internal/server/internal/serverimp.go @@ -90,13 +90,18 @@ func (i *Imp) Start() error { if cfg.GRPC != nil { invokeURI = cfg.GRPC.DiscoveryInvokeURI() } + msgSettings := map[string]any(nil) + if cfg.Messaging != nil { + msgSettings = cfg.Messaging.Settings + } gwCfg := gateway.Config{ - Rail: cfg.Gateway.Rail, - TargetChatIDEnv: cfg.Gateway.TargetChatIDEnv, - TimeoutSeconds: cfg.Gateway.TimeoutSeconds, - AcceptedUserIDs: cfg.Gateway.AcceptedUserIDs, - SuccessReaction: cfg.Gateway.SuccessReaction, - InvokeURI: invokeURI, + Rail: cfg.Gateway.Rail, + TargetChatIDEnv: cfg.Gateway.TargetChatIDEnv, + TimeoutSeconds: cfg.Gateway.TimeoutSeconds, + AcceptedUserIDs: cfg.Gateway.AcceptedUserIDs, + SuccessReaction: cfg.Gateway.SuccessReaction, + InvokeURI: invokeURI, + MessagingSettings: msgSettings, } svc := gateway.NewService(logger, repo, producer, broker, gwCfg) i.service = svc diff --git a/api/gateway/tgsettle/internal/service/gateway/outbox_reliable.go b/api/gateway/tgsettle/internal/service/gateway/outbox_reliable.go new file mode 100644 index 00000000..e9e31c6d --- /dev/null +++ b/api/gateway/tgsettle/internal/service/gateway/outbox_reliable.go @@ -0,0 +1,47 @@ +package gateway + +import ( + "context" + + gatewayoutbox "github.com/tech/sendico/gateway/common/outbox" + "github.com/tech/sendico/pkg/db/transaction" + me "github.com/tech/sendico/pkg/messaging/envelope" +) + +type tgOutboxProvider interface { + Outbox() gatewayoutbox.Store +} + +type tgTransactionProvider interface { + TransactionFactory() transaction.Factory +} + +func (s *Service) outboxStore() gatewayoutbox.Store { + provider, ok := s.repo.(tgOutboxProvider) + if !ok || provider == nil { + return nil + } + return provider.Outbox() +} + +func (s *Service) startOutboxReliableProducer() error { + if s == nil || s.repo == nil { + return nil + } + return s.outbox.Start(s.logger, s.producer, s.outboxStore(), s.msgCfg) +} + +func (s *Service) sendWithOutbox(ctx context.Context, env me.Envelope) error { + if err := s.startOutboxReliableProducer(); err != nil { + return err + } + return s.outbox.Send(ctx, env) +} + +func (s *Service) executeTransaction(ctx context.Context, cb transaction.Callback) (any, error) { + provider, ok := s.repo.(tgTransactionProvider) + if !ok || provider == nil || provider.TransactionFactory() == nil { + return cb(ctx) + } + return provider.TransactionFactory().CreateTransaction().Execute(ctx, cb) +} diff --git a/api/gateway/tgsettle/internal/service/gateway/service.go b/api/gateway/tgsettle/internal/service/gateway/service.go index e6f6c863..92c22c06 100644 --- a/api/gateway/tgsettle/internal/service/gateway/service.go +++ b/api/gateway/tgsettle/internal/service/gateway/service.go @@ -7,6 +7,7 @@ import ( "strings" "time" + gatewayoutbox "github.com/tech/sendico/gateway/common/outbox" "github.com/tech/sendico/gateway/tgsettle/storage" storagemodel "github.com/tech/sendico/gateway/tgsettle/storage/model" "github.com/tech/sendico/pkg/api/routers" @@ -20,6 +21,7 @@ import ( tnotifications "github.com/tech/sendico/pkg/messaging/notifications/telegram" "github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/model" + pmodel "github.com/tech/sendico/pkg/model" "github.com/tech/sendico/pkg/mservice" paymenttypes "github.com/tech/sendico/pkg/payments/types" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" @@ -48,12 +50,13 @@ const ( ) type Config struct { - Rail string - TargetChatIDEnv string - TimeoutSeconds int32 - AcceptedUserIDs []string - SuccessReaction string - InvokeURI string + Rail string + TargetChatIDEnv string + TimeoutSeconds int32 + AcceptedUserIDs []string + SuccessReaction string + InvokeURI string + MessagingSettings pmodel.SettingsT } type Service struct { @@ -62,11 +65,13 @@ type Service struct { producer msg.Producer broker mb.Broker cfg Config + msgCfg pmodel.SettingsT rail string chatID string announcer *discovery.Announcer invokeURI string successReaction string + outbox gatewayoutbox.ReliableRuntime consumers []msg.Consumer @@ -84,6 +89,7 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro producer: producer, broker: broker, cfg: cfg, + msgCfg: cfg.MessagingSettings, rail: strings.TrimSpace(cfg.Rail), invokeURI: strings.TrimSpace(cfg.InvokeURI), } @@ -92,6 +98,9 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro if svc.successReaction == "" { svc.successReaction = defaultTelegramSuccessReaction } + if err := svc.startOutboxReliableProducer(); err != nil { + svc.logger.Warn("Failed to initialise outbox reliable producer", zap.Error(err)) + } svc.startConsumers() svc.startAnnouncer() return svc @@ -107,6 +116,7 @@ func (s *Service) Shutdown() { if s == nil { return } + s.outbox.Stop() if s.announcer != nil { s.announcer.Stop() } diff --git a/api/gateway/tgsettle/internal/service/gateway/transfer_notifications.go b/api/gateway/tgsettle/internal/service/gateway/transfer_notifications.go index 8a9e2958..b926bf16 100644 --- a/api/gateway/tgsettle/internal/service/gateway/transfer_notifications.go +++ b/api/gateway/tgsettle/internal/service/gateway/transfer_notifications.go @@ -4,6 +4,7 @@ import ( "context" "github.com/tech/sendico/gateway/tgsettle/storage/model" + "github.com/tech/sendico/pkg/merrors" paymentgateway "github.com/tech/sendico/pkg/messaging/notifications/paymentgateway" pmodel "github.com/tech/sendico/pkg/model" "github.com/tech/sendico/pkg/mservice" @@ -21,33 +22,57 @@ func isFinalStatus(t *model.PaymentRecord) bool { } } -func toOpStatus(t *model.PaymentRecord) rail.OperationResult { +func toOpStatus(t *model.PaymentRecord) (rail.OperationResult, error) { switch t.Status { case model.PaymentStatusFailed: - return rail.OperationResultFailed + return rail.OperationResultFailed, nil case model.PaymentStatusSuccess: - return rail.OperationResultSuccess + return rail.OperationResultSuccess, nil case model.PaymentStatusCancelled: - return rail.OperationResultCancelled + return rail.OperationResultCancelled, nil default: - panic("unexpected transfer status") + return rail.OperationResultFailed, merrors.InvalidArgument("unexpected transfer status", "payment.status") } } func (s *Service) updateTransferStatus(ctx context.Context, record *model.PaymentRecord) error { - if err := s.repo.Payments().Upsert(ctx, record); err != nil { + if !isFinalStatus(record) { + if err := s.repo.Payments().Upsert(ctx, record); err != nil { + s.logger.Warn("Failed to update transfer status", zap.String("payment_ref", record.PaymentIntentID), zap.String("status", string(record.Status)), zap.Error(err)) + return err + } + return nil + } + + _, err := s.executeTransaction(ctx, func(txCtx context.Context) (any, error) { + if upsertErr := s.repo.Payments().Upsert(txCtx, record); upsertErr != nil { + return nil, upsertErr + } + if isFinalStatus(record) { + if emitErr := s.emitTransferStatusEvent(txCtx, record); emitErr != nil { + return nil, emitErr + } + } + return nil, nil + }) + if err != nil { s.logger.Warn("Failed to update transfer status", zap.String("payment_ref", record.PaymentIntentID), zap.String("status", string(record.Status)), zap.Error(err)) return err } - if isFinalStatus(record) { - s.emitTransferStatusEvent(ctx, record) - } return nil } -func (s *Service) emitTransferStatusEvent(_ context.Context, record *model.PaymentRecord) { - if s == nil || s.producer == nil || record == nil { - return +func (s *Service) emitTransferStatusEvent(ctx context.Context, record *model.PaymentRecord) error { + if s == nil || record == nil { + return nil + } + if s.producer == nil || s.outboxStore() == nil { + return nil + } + status, err := toOpStatus(record) + if err != nil { + s.logger.Warn("Failed to map transfer status for transfer status event", zap.Error(err), mzap.ObjRef("transfer_ref", record.ID)) + return err } exec := pmodel.PaymentGatewayExecution{ @@ -55,13 +80,15 @@ func (s *Service) emitTransferStatusEvent(_ context.Context, record *model.Payme IdempotencyKey: record.IdempotencyKey, ExecutedMoney: record.ExecutedMoney, PaymentRef: record.PaymentRef, - Status: toOpStatus(record), + Status: status, OperationRef: record.OperationRef, Error: record.FailureReason, TransferRef: record.ID.Hex(), } env := paymentgateway.PaymentGatewayExecution(mservice.MntxGateway, &exec) - if err := s.producer.SendMessage(env); err != nil { - s.logger.Warn("Failed to publish transfer status event", zap.Error(err), mzap.ObjRef("transfer_ref", record.ID)) + if sendErr := s.sendWithOutbox(ctx, env); sendErr != nil { + s.logger.Warn("Failed to publish transfer status event", zap.Error(sendErr), mzap.ObjRef("transfer_ref", record.ID)) + return sendErr } + return nil } diff --git a/api/gateway/tgsettle/storage/mongo/repository.go b/api/gateway/tgsettle/storage/mongo/repository.go index 0db396f6..d3b842ea 100644 --- a/api/gateway/tgsettle/storage/mongo/repository.go +++ b/api/gateway/tgsettle/storage/mongo/repository.go @@ -4,9 +4,11 @@ import ( "context" "time" + gatewayoutbox "github.com/tech/sendico/gateway/common/outbox" "github.com/tech/sendico/gateway/tgsettle/storage" "github.com/tech/sendico/gateway/tgsettle/storage/mongo/store" "github.com/tech/sendico/pkg/db" + "github.com/tech/sendico/pkg/db/transaction" "github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/mlogger" "go.mongodb.org/mongo-driver/v2/mongo" @@ -14,12 +16,14 @@ import ( ) type Repository struct { - logger mlogger.Logger - conn *db.MongoConnection - db *mongo.Database + logger mlogger.Logger + conn *db.MongoConnection + db *mongo.Database + txFactory transaction.Factory payments storage.PaymentsStore tg storage.TelegramConfirmationsStore + outbox gatewayoutbox.Store } func New(logger mlogger.Logger, conn *db.MongoConnection) (*Repository, error) { @@ -43,9 +47,10 @@ func New(logger mlogger.Logger, conn *db.MongoConnection) (*Repository, error) { logger = logger.With(zap.String("database", dbName)) } result := &Repository{ - logger: logger, - conn: conn, - db: db, + logger: logger, + conn: conn, + db: db, + txFactory: newMongoTransactionFactory(client), } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -63,8 +68,14 @@ func New(logger mlogger.Logger, conn *db.MongoConnection) (*Repository, error) { result.logger.Error("Failed to initialise telegram confirmations store", zap.Error(err), zap.String("store", "telegram_confirmations")) return nil, err } + outboxStore, err := gatewayoutbox.NewMongoStore(result.logger, result.db) + if err != nil { + result.logger.Error("Failed to initialise outbox store", zap.Error(err), zap.String("store", "outbox")) + return nil, err + } result.payments = paymentsStore result.tg = tgStore + result.outbox = outboxStore result.logger.Info("Payment gateway MongoDB storage initialised") return result, nil } @@ -77,4 +88,12 @@ func (r *Repository) TelegramConfirmations() storage.TelegramConfirmationsStore return r.tg } +func (r *Repository) Outbox() gatewayoutbox.Store { + return r.outbox +} + +func (r *Repository) TransactionFactory() transaction.Factory { + return r.txFactory +} + var _ storage.Repository = (*Repository)(nil) diff --git a/api/gateway/tgsettle/storage/mongo/transaction.go b/api/gateway/tgsettle/storage/mongo/transaction.go new file mode 100644 index 00000000..52bad57e --- /dev/null +++ b/api/gateway/tgsettle/storage/mongo/transaction.go @@ -0,0 +1,38 @@ +package mongo + +import ( + "context" + + "github.com/tech/sendico/pkg/db/transaction" + "go.mongodb.org/mongo-driver/v2/mongo" +) + +type mongoTransactionFactory struct { + client *mongo.Client +} + +func (f *mongoTransactionFactory) CreateTransaction() transaction.Transaction { + return &mongoTransaction{client: f.client} +} + +type mongoTransaction struct { + client *mongo.Client +} + +func (t *mongoTransaction) Execute(ctx context.Context, cb transaction.Callback) (any, error) { + session, err := t.client.StartSession() + if err != nil { + return nil, err + } + defer session.EndSession(ctx) + + run := func(sessCtx context.Context) (any, error) { + return cb(sessCtx) + } + + return session.WithTransaction(ctx, run) +} + +func newMongoTransactionFactory(client *mongo.Client) transaction.Factory { + return &mongoTransactionFactory{client: client} +} diff --git a/api/gateway/tron/go.mod b/api/gateway/tron/go.mod index a7cb8a06..f8b30314 100644 --- a/api/gateway/tron/go.mod +++ b/api/gateway/tron/go.mod @@ -4,9 +4,11 @@ go 1.25.7 replace github.com/tech/sendico/pkg => ../../pkg +replace github.com/tech/sendico/gateway/common => ../common + require ( github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 - github.com/ethereum/go-ethereum v1.16.8 + github.com/ethereum/go-ethereum v1.17.0 github.com/fbsobreira/gotron-sdk v0.24.1 github.com/hashicorp/vault/api v1.22.0 github.com/mitchellh/mapstructure v1.5.0 @@ -14,6 +16,7 @@ require ( github.com/shengdoushi/base58 v1.0.0 github.com/shopspring/decimal v1.4.0 github.com/stretchr/testify v1.11.1 + github.com/tech/sendico/gateway/common v0.1.0 github.com/tech/sendico/pkg v0.1.0 go.mongodb.org/mongo-driver/v2 v2.5.0 go.uber.org/zap v1.27.1 @@ -24,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-20260213131322-086e44a26cf3 // indirect + github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260215031811-a0ab0b218a81 // 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 @@ -36,14 +39,14 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/consensys/gnark-crypto v0.19.2 // indirect github.com/crate-crypto/go-eth-kzg v1.5.0 // indirect - github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/deckarep/golang-set v1.8.0 // indirect github.com/deckarep/golang-set/v2 v2.8.0 // indirect github.com/ethereum/c-kzg-4844/v2 v2.1.5 // indirect - github.com/ethereum/go-verkle v0.2.2 // indirect github.com/go-chi/chi/v5 v5.2.5 // indirect github.com/go-jose/go-jose/v4 v4.1.3 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect @@ -83,6 +86,10 @@ require ( github.com/xdg-go/stringprep v1.0.4 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel v1.40.0 // indirect + go.opentelemetry.io/otel/metric v1.40.0 // indirect + go.opentelemetry.io/otel/trace v1.40.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect golang.org/x/crypto v0.48.0 // indirect @@ -92,6 +99,6 @@ require ( golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect golang.org/x/time v0.14.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect ) diff --git a/api/gateway/tron/go.sum b/api/gateway/tron/go.sum index 7b0b6710..923d354b 100644 --- a/api/gateway/tron/go.sum +++ b/api/gateway/tron/go.sum @@ -6,8 +6,8 @@ github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ= github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260213131322-086e44a26cf3 h1:QD30TjDPWtvXb5PBZGZ6Wdvaq7HQixIBtZ/yuseNXc8= -github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260213131322-086e44a26cf3/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI= +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/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= @@ -54,8 +54,6 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/crate-crypto/go-eth-kzg v1.5.0 h1:FYRiJMJG2iv+2Dy3fi14SVGjcPteZ5HAAUe4YWlJygc= github.com/crate-crypto/go-eth-kzg v1.5.0/go.mod h1:J9/u5sWfznSObptgfa92Jq8rTswn6ahQWEuiLHOjCUI= -github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a h1:W8mUrRp6NOVl3J+MYp5kPMoUZPp7aOYHtaua31lwRHg= -github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a/go.mod h1:sTwzHBvIzm2RfVCGNEBZgRyjwK40bVoun3ZnGOCafNM= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dchest/siphash v1.2.3 h1:QXwFc8cFOR2dSa/gE6o/HokBMWtLUaNDVd+22aKHeEA= @@ -82,10 +80,8 @@ github.com/ethereum/c-kzg-4844/v2 v2.1.5 h1:aVtoLK5xwJ6c5RiqO8g8ptJ5KU+2Hdquf6G3 github.com/ethereum/c-kzg-4844/v2 v2.1.5/go.mod h1:u59hRTTah4Co6i9fDWtiCjTrblJv0UwsqZKCc0GfgUs= github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab h1:rvv6MJhy07IMfEKuARQ9TKojGqLVNxQajaXEp/BoqSk= github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab/go.mod h1:IuLm4IsPipXKF7CW5Lzf68PIbZ5yl7FFd74l/E0o9A8= -github.com/ethereum/go-ethereum v1.16.8 h1:LLLfkZWijhR5m6yrAXbdlTeXoqontH+Ga2f9igY7law= -github.com/ethereum/go-ethereum v1.16.8/go.mod h1:Fs6QebQbavneQTYcA39PEKv2+zIjX7rPUZ14DER46wk= -github.com/ethereum/go-verkle v0.2.2 h1:I2W0WjnrFUIzzVPwm8ykY+7pL2d4VhlsePn4j7cnFk8= -github.com/ethereum/go-verkle v0.2.2/go.mod h1:M3b90YRnzqKyyzBEWJGqj8Qff4IDeXnzFw0P9bFw3uk= +github.com/ethereum/go-ethereum v1.17.0 h1:2D+1Fe23CwZ5tQoAS5DfwKFNI1HGcTwi65/kRlAVxes= +github.com/ethereum/go-ethereum v1.17.0/go.mod h1:2W3msvdosS/MCWytpqTcqgFiRYbTH59FxDJzqah120o= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/fbsobreira/gotron-sdk v0.24.1 h1:YxvF26zyXNkho1GxywQeq/gRi70aQ6sbWYop6OTWL7E= @@ -100,6 +96,7 @@ github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= @@ -131,6 +128,10 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grafana/pyroscope-go v1.2.7 h1:VWBBlqxjyR0Cwk2W6UrE8CdcdD80GOFNutj0Kb1T8ac= +github.com/grafana/pyroscope-go v1.2.7/go.mod h1:o/bpSLiJYYP6HQtvcoVKiE9s5RiNgjYTj1DhiddP2Pc= +github.com/grafana/pyroscope-go/godeltaprof v0.1.9 h1:c1Us8i6eSmkW+Ez05d3co8kasnuOY813tbMN8i/a3Og= +github.com/grafana/pyroscope-go/godeltaprof v0.1.9/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -186,8 +187,6 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= -github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g= github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= @@ -218,8 +217,6 @@ github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4= github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs= github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= -github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= -github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= @@ -250,12 +247,10 @@ github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTU github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= -github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rjeczalik/notify v0.9.3 h1:6rJAzHTGKXGj76sbRgDiDcYj/HniypXmSJo1SWakZeY= github.com/rjeczalik/notify v0.9.3/go.mod h1:gF3zSOrafR9DQEWSE8TjfI9NkooDxbyT4UgRGKZA0lc= -github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= -github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/cors v1.7.0 h1:+88SsELBHx5r+hZ8TCkggzSstaWNbDvThkVK8H6f9ik= github.com/rs/cors v1.7.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= @@ -313,16 +308,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= @@ -379,10 +374,10 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 h1:JLQynH/LBHfCTSbDWl+py8C+Rg/k1OVH3xfcaiANuF0= -google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:kSJwQxqmFXeo79zOmbrALdflXQeAYcUbgS7PbpMknCY= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d h1:EocjzKLywydp5uZ5tJ79iP6Q0UjDnyiHkGRWxuPBP8s= +google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:48U2I+QQUYhsFrg2SY6r+nJzeOtjey7j//WBESw+qyQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/gateway/tron/internal/server/internal/serverimp.go b/api/gateway/tron/internal/server/internal/serverimp.go index f8822c21..a4de7253 100644 --- a/api/gateway/tron/internal/server/internal/serverimp.go +++ b/api/gateway/tron/internal/server/internal/serverimp.go @@ -179,6 +179,9 @@ func (i *Imp) Start() error { gatewayservice.WithDriverRegistry(driverRegistry), gatewayservice.WithSettings(cfg.Settings), } + if cfg.Messaging != nil { + opts = append(opts, gatewayservice.WithMessagingSettings(cfg.Messaging.Settings)) + } svc := gatewayservice.NewService(logger, repo, producer, opts...) i.service = svc return svc, nil diff --git a/api/gateway/tron/internal/service/gateway/options.go b/api/gateway/tron/internal/service/gateway/options.go index 19e3866a..34c8571b 100644 --- a/api/gateway/tron/internal/service/gateway/options.go +++ b/api/gateway/tron/internal/service/gateway/options.go @@ -9,6 +9,7 @@ import ( "github.com/tech/sendico/gateway/tron/internal/service/gateway/tronclient" "github.com/tech/sendico/gateway/tron/shared" clockpkg "github.com/tech/sendico/pkg/clock" + pmodel "github.com/tech/sendico/pkg/model" ) // Option configures the Service. @@ -98,3 +99,12 @@ func WithDiscoveryInvokeURI(invokeURI string) Option { s.invokeURI = strings.TrimSpace(invokeURI) } } + +// WithMessagingSettings applies messaging driver settings. +func WithMessagingSettings(settings pmodel.SettingsT) Option { + return func(s *Service) { + if settings != nil { + s.msgCfg = settings + } + } +} diff --git a/api/gateway/tron/internal/service/gateway/outbox_reliable.go b/api/gateway/tron/internal/service/gateway/outbox_reliable.go new file mode 100644 index 00000000..a7b459e2 --- /dev/null +++ b/api/gateway/tron/internal/service/gateway/outbox_reliable.go @@ -0,0 +1,47 @@ +package gateway + +import ( + "context" + + gatewayoutbox "github.com/tech/sendico/gateway/common/outbox" + "github.com/tech/sendico/pkg/db/transaction" + me "github.com/tech/sendico/pkg/messaging/envelope" +) + +type tronOutboxProvider interface { + Outbox() gatewayoutbox.Store +} + +type tronTransactionProvider interface { + TransactionFactory() transaction.Factory +} + +func (s *Service) outboxStore() gatewayoutbox.Store { + provider, ok := s.storage.(tronOutboxProvider) + if !ok || provider == nil { + return nil + } + return provider.Outbox() +} + +func (s *Service) startOutboxReliableProducer() error { + if s == nil || s.storage == nil { + return nil + } + return s.outbox.Start(s.logger, s.producer, s.outboxStore(), s.msgCfg) +} + +func (s *Service) sendWithOutbox(ctx context.Context, env me.Envelope) error { + if err := s.startOutboxReliableProducer(); err != nil { + return err + } + return s.outbox.Send(ctx, env) +} + +func (s *Service) executeTransaction(ctx context.Context, cb transaction.Callback) (any, error) { + provider, ok := s.storage.(tronTransactionProvider) + if !ok || provider == nil || provider.TransactionFactory() == nil { + return cb(ctx) + } + return provider.TransactionFactory().CreateTransaction().Execute(ctx, cb) +} diff --git a/api/gateway/tron/internal/service/gateway/service.go b/api/gateway/tron/internal/service/gateway/service.go index 5284bf67..c3fca4e5 100644 --- a/api/gateway/tron/internal/service/gateway/service.go +++ b/api/gateway/tron/internal/service/gateway/service.go @@ -3,6 +3,7 @@ package gateway import ( "context" + gatewayoutbox "github.com/tech/sendico/gateway/common/outbox" "github.com/tech/sendico/gateway/tron/internal/appversion" "github.com/tech/sendico/gateway/tron/internal/keymanager" "github.com/tech/sendico/gateway/tron/internal/service/gateway/commands" @@ -19,9 +20,11 @@ import ( "github.com/tech/sendico/pkg/discovery" msg "github.com/tech/sendico/pkg/messaging" "github.com/tech/sendico/pkg/mlogger" + pmodel "github.com/tech/sendico/pkg/model" "github.com/tech/sendico/pkg/mservice" connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" + "go.uber.org/zap" "google.golang.org/grpc" ) @@ -40,9 +43,11 @@ type Service struct { logger mlogger.Logger storage storage.Repository producer msg.Producer + msgCfg pmodel.SettingsT clock clockpkg.Clock settings CacheSettings + outbox gatewayoutbox.ReliableRuntime networks map[string]shared.Network serviceWallet shared.ServiceWallet @@ -64,6 +69,7 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro logger: logger.Named("service"), storage: repo, producer: producer, + msgCfg: map[string]any{}, clock: clockpkg.System{}, settings: defaultSettings(), networks: map[string]shared.Network{}, @@ -85,6 +91,9 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro } svc.settings = svc.settings.withDefaults() svc.networkRegistry = rpcclient.NewRegistry(svc.networks, svc.rpcClients) + if err := svc.startOutboxReliableProducer(); err != nil { + svc.logger.Warn("Failed to initialise outbox reliable producer", zap.Error(err)) + } svc.commands = commands.NewRegistry(commands.RegistryDeps{ Wallet: commandsWalletDeps(svc), @@ -106,6 +115,7 @@ func (s *Service) Shutdown() { if s == nil { return } + s.outbox.Stop() for _, announcer := range s.announcers { if announcer != nil { announcer.Stop() diff --git a/api/gateway/tron/internal/service/gateway/transfer_notifications.go b/api/gateway/tron/internal/service/gateway/transfer_notifications.go index e3750c84..842a70ee 100644 --- a/api/gateway/tron/internal/service/gateway/transfer_notifications.go +++ b/api/gateway/tron/internal/service/gateway/transfer_notifications.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/tech/sendico/gateway/tron/storage/model" + "github.com/tech/sendico/pkg/merrors" paymentgateway "github.com/tech/sendico/pkg/messaging/notifications/paymentgateway" pmodel "github.com/tech/sendico/pkg/model" "github.com/tech/sendico/pkg/mservice" @@ -13,6 +14,9 @@ import ( ) func isFinalStatus(t *model.Transfer) bool { + if t == nil { + return false + } switch t.Status { case model.TransferStatusFailed, model.TransferStatusSuccess, model.TransferStatusCancelled: return true @@ -21,16 +25,25 @@ func isFinalStatus(t *model.Transfer) bool { } } -func toOpStatus(t *model.Transfer) rail.OperationResult { +func isFinalTransferStatus(status model.TransferStatus) bool { + switch status { + case model.TransferStatusFailed, model.TransferStatusSuccess, model.TransferStatusCancelled: + return true + default: + return false + } +} + +func toOpStatus(t *model.Transfer) (rail.OperationResult, error) { switch t.Status { case model.TransferStatusFailed: - return rail.OperationResultFailed + return rail.OperationResultFailed, nil case model.TransferStatusSuccess: - return rail.OperationResultSuccess + return rail.OperationResultSuccess, nil case model.TransferStatusCancelled: - return rail.OperationResultCancelled + return rail.OperationResultCancelled, nil default: - panic(fmt.Sprintf("toOpStatus: unexpected transfer status: %s", t.Status)) + return rail.OperationResultFailed, merrors.InvalidArgument(fmt.Sprintf("unexpected transfer status: %s", t.Status), "transfer.status") } } @@ -45,19 +58,47 @@ func toError(t *model.Transfer) string { } func (s *Service) updateTransferStatus(ctx context.Context, transferRef string, status model.TransferStatus, failureReason, txHash string) (*model.Transfer, error) { - transfer, err := s.storage.Transfers().UpdateStatus(ctx, transferRef, status, failureReason, txHash) + if !isFinalTransferStatus(status) { + transfer, err := s.storage.Transfers().UpdateStatus(ctx, transferRef, status, failureReason, txHash) + if err != nil { + s.logger.Warn("Failed to update transfer status", zap.String("transfer_ref", transferRef), zap.String("status", string(status)), zap.Error(err)) + } + return transfer, err + } + + res, err := s.executeTransaction(ctx, func(txCtx context.Context) (any, error) { + transfer, statusErr := s.storage.Transfers().UpdateStatus(txCtx, transferRef, status, failureReason, txHash) + if statusErr != nil { + return nil, statusErr + } + if isFinalStatus(transfer) { + if emitErr := s.emitTransferStatusEvent(txCtx, transfer); emitErr != nil { + return nil, emitErr + } + } + return transfer, nil + }) if err != nil { s.logger.Warn("Failed to update transfer status", zap.String("transfer_ref", transferRef), zap.String("status", string(status)), zap.Error(err)) + return nil, err } - if isFinalStatus(transfer) { - s.emitTransferStatusEvent(transfer) - } - return transfer, err + + transfer, _ := res.(*model.Transfer) + return transfer, nil } -func (s *Service) emitTransferStatusEvent(transfer *model.Transfer) { - if s == nil || s.producer == nil || transfer == nil { - return +func (s *Service) emitTransferStatusEvent(ctx context.Context, transfer *model.Transfer) error { + if s == nil || transfer == nil { + return nil + } + if s.producer == nil || s.outboxStore() == nil { + return nil + } + + status, err := toOpStatus(transfer) + if err != nil { + s.logger.Warn("Failed to map transfer status for transfer status event", zap.Error(err), zap.String("transfer_ref", transfer.TransferRef)) + return err } exec := pmodel.PaymentGatewayExecution{ @@ -65,13 +106,15 @@ func (s *Service) emitTransferStatusEvent(transfer *model.Transfer) { IdempotencyKey: transfer.IdempotencyKey, ExecutedMoney: transfer.NetAmount, PaymentRef: transfer.PaymentRef, - Status: toOpStatus(transfer), + Status: status, OperationRef: transfer.OperationRef, Error: toError(transfer), TransferRef: transfer.TransferRef, } env := paymentgateway.PaymentGatewayExecution(mservice.ChainGateway, &exec) - if err := s.producer.SendMessage(env); err != nil { + if err := s.sendWithOutbox(ctx, env); err != nil { s.logger.Warn("Failed to publish transfer status event", zap.Error(err), zap.String("transfer_ref", transfer.TransferRef)) + return err } + return nil } diff --git a/api/gateway/tron/storage/mongo/repository.go b/api/gateway/tron/storage/mongo/repository.go index f607727c..14f6052a 100644 --- a/api/gateway/tron/storage/mongo/repository.go +++ b/api/gateway/tron/storage/mongo/repository.go @@ -4,9 +4,11 @@ import ( "context" "time" + gatewayoutbox "github.com/tech/sendico/gateway/common/outbox" "github.com/tech/sendico/gateway/tron/storage" "github.com/tech/sendico/gateway/tron/storage/mongo/store" "github.com/tech/sendico/pkg/db" + "github.com/tech/sendico/pkg/db/transaction" "github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/mlogger" "go.mongodb.org/mongo-driver/v2/mongo" @@ -15,13 +17,15 @@ import ( // Store implements storage.Repository backed by MongoDB. type Store struct { - logger mlogger.Logger - conn *db.MongoConnection - db *mongo.Database + logger mlogger.Logger + conn *db.MongoConnection + db *mongo.Database + txFactory transaction.Factory wallets storage.WalletsStore transfers storage.TransfersStore deposits storage.DepositsStore + outbox gatewayoutbox.Store } // New creates a new Mongo-backed repository. @@ -35,9 +39,10 @@ func New(logger mlogger.Logger, conn *db.MongoConnection) (*Store, error) { } result := &Store{ - logger: logger.Named("storage").Named("mongo"), - conn: conn, - db: conn.Database(), + logger: logger.Named("storage").Named("mongo"), + conn: conn, + db: conn.Database(), + txFactory: newMongoTransactionFactory(client), } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) @@ -63,10 +68,16 @@ func New(logger mlogger.Logger, conn *db.MongoConnection) (*Store, error) { result.logger.Error("Failed to initialise deposits store", zap.Error(err)) return nil, err } + outboxStore, err := gatewayoutbox.NewMongoStore(result.logger, result.db) + if err != nil { + result.logger.Error("Failed to initialise outbox store", zap.Error(err)) + return nil, err + } result.wallets = walletsStore result.transfers = transfersStore result.deposits = depositsStore + result.outbox = outboxStore result.logger.Info("Chain gateway MongoDB storage initialised") return result, nil @@ -95,4 +106,12 @@ func (s *Store) Deposits() storage.DepositsStore { return s.deposits } +func (s *Store) Outbox() gatewayoutbox.Store { + return s.outbox +} + +func (s *Store) TransactionFactory() transaction.Factory { + return s.txFactory +} + var _ storage.Repository = (*Store)(nil) diff --git a/api/gateway/tron/storage/mongo/transaction.go b/api/gateway/tron/storage/mongo/transaction.go new file mode 100644 index 00000000..52bad57e --- /dev/null +++ b/api/gateway/tron/storage/mongo/transaction.go @@ -0,0 +1,38 @@ +package mongo + +import ( + "context" + + "github.com/tech/sendico/pkg/db/transaction" + "go.mongodb.org/mongo-driver/v2/mongo" +) + +type mongoTransactionFactory struct { + client *mongo.Client +} + +func (f *mongoTransactionFactory) CreateTransaction() transaction.Transaction { + return &mongoTransaction{client: f.client} +} + +type mongoTransaction struct { + client *mongo.Client +} + +func (t *mongoTransaction) Execute(ctx context.Context, cb transaction.Callback) (any, error) { + session, err := t.client.StartSession() + if err != nil { + return nil, err + } + defer session.EndSession(ctx) + + run := func(sessCtx context.Context) (any, error) { + return cb(sessCtx) + } + + return session.WithTransaction(ctx, run) +} + +func newMongoTransactionFactory(client *mongo.Client) transaction.Factory { + return &mongoTransactionFactory{client: client} +} diff --git a/api/ledger/config.dev.yml b/api/ledger/config.dev.yml index 7675a4d4..481e4960 100644 --- a/api/ledger/config.dev.yml +++ b/api/ledger/config.dev.yml @@ -34,6 +34,12 @@ messaging: max_reconnects: 10 reconnect_wait: 5 buffer_size: 1024 + # Optional: remove this block to use package defaults. + reliable_publisher: + enabled: true + batch_size: 100 + poll_interval_seconds: 1 + max_attempts: 5 fees: address: "dev-billing-fees:50060" diff --git a/api/ledger/config.yml b/api/ledger/config.yml index 7365be0a..e2b5a11c 100644 --- a/api/ledger/config.yml +++ b/api/ledger/config.yml @@ -34,6 +34,12 @@ messaging: max_reconnects: 10 reconnect_wait: 5 buffer_size: 1024 + # Optional: remove this block to use package defaults. + reliable_publisher: + enabled: true + batch_size: 100 + poll_interval_seconds: 1 + max_attempts: 5 fees: address: "sendico_billing_fees:50060" diff --git a/api/ledger/go.mod b/api/ledger/go.mod index 91f63e60..2767736a 100644 --- a/api/ledger/go.mod +++ b/api/ledger/go.mod @@ -1,6 +1,6 @@ module github.com/tech/sendico/ledger -go 1.24.0 +go 1.25.0 replace github.com/tech/sendico/pkg => ../pkg @@ -49,5 +49,5 @@ require ( golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect ) diff --git a/api/ledger/go.sum b/api/ledger/go.sum index fd724be9..8025ed84 100644 --- a/api/ledger/go.sum +++ b/api/ledger/go.sum @@ -210,8 +210,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/ledger/internal/model/outbox.go b/api/ledger/internal/model/outbox.go deleted file mode 100644 index 14a945fb..00000000 --- a/api/ledger/internal/model/outbox.go +++ /dev/null @@ -1,35 +0,0 @@ -package ledger - -import ( - "time" - - "github.com/tech/sendico/pkg/db/storable" - "github.com/tech/sendico/pkg/mservice" -) - -// Delivery status enum -type OutboxStatus string - -const ( - OutboxPending OutboxStatus = "pending" - OutboxSent OutboxStatus = "sent" - OutboxFailed OutboxStatus = "failed" // terminal after max retries, or keep pending with NextAttemptAt=nil -) - -type OutboxEvent struct { - storable.Base `bson:",inline" json:",inline"` - - EventID string `bson:"eventId" json:"eventId"` // deterministic; use as NATS Msg-Id - Subject string `bson:"subject" json:"subject"` // NATS subject / stream routing key - Payload []byte `bson:"payload" json:"payload"` // JSON (or other) payload - Status OutboxStatus `bson:"status" json:"status"` // enum - Attempts int `bson:"attempts" json:"attempts"` // total tries - NextAttemptAt *time.Time `bson:"nextAttemptAt,omitempty" json:"nextAttemptAt,omitempty"` // for backoff scheduler - SentAt *time.Time `bson:"sentAt,omitempty" json:"sentAt,omitempty"` - LastError string `bson:"lastError,omitempty" json:"lastError,omitempty"` // brief reason of last failure - CorrelationRef string `bson:"correlationRef,omitempty" json:"correlationRef,omitempty"` // e.g., journalEntryRef or idempotencyKey -} - -func (o *OutboxEvent) Collection() string { - return mservice.LedgerOutbox -} diff --git a/api/ledger/internal/server/internal/serverimp.go b/api/ledger/internal/server/internal/serverimp.go index cdde7c64..3213342f 100644 --- a/api/ledger/internal/server/internal/serverimp.go +++ b/api/ledger/internal/server/internal/serverimp.go @@ -120,7 +120,14 @@ func (i *Imp) Start() error { if cfg.GRPC != nil { invokeURI = cfg.GRPC.DiscoveryInvokeURI() } - svc := ledger.NewService(logger, repo, producer, feesClient, feesTimeout, invokeURI) + msgSettings := map[string]any(nil) + if cfg.Messaging != nil { + msgSettings = cfg.Messaging.Settings + } + svc, err := ledger.NewService(logger, repo, producer, msgSettings, feesClient, feesTimeout, invokeURI) + if err != nil { + return nil, err + } if err := svc.EnsureSystemAccounts(context.Background()); err != nil { return nil, err } diff --git a/api/ledger/internal/service/ledger/account_status.go b/api/ledger/internal/service/ledger/account_status.go index c07e6bfa..aefa44a3 100644 --- a/api/ledger/internal/service/ledger/account_status.go +++ b/api/ledger/internal/service/ledger/account_status.go @@ -38,7 +38,7 @@ func (s *Service) blockAccountResponder(_ context.Context, req *ledgerv1.BlockAc if err == storage.ErrAccountNotFound { return nil, merrors.NoData("account not found") } - logger.Warn("failed to get account for block", zap.Error(err)) + logger.Warn("Failed to get account for block", zap.Error(err)) return nil, merrors.Internal("failed to get account") } @@ -61,17 +61,17 @@ func (s *Service) blockAccountResponder(_ context.Context, req *ledgerv1.BlockAc } if account.Status == pmodel.LedgerAccountStatusFrozen { - logger.Debug("account already frozen", mzap.AccRef(accountRef)) + logger.Debug("Account already frozen", mzap.AccRef(accountRef)) return &ledgerv1.BlockAccountResponse{Account: toProtoAccount(account)}, nil } if err := s.storage.Accounts().UpdateStatus(ctx, accountRef, pmodel.LedgerAccountStatusFrozen); err != nil { - logger.Warn("failed to freeze account", zap.Error(err)) + logger.Warn("Failed to freeze account", zap.Error(err)) return nil, merrors.Internal("failed to block account") } account.Status = pmodel.LedgerAccountStatusFrozen - logger.Info("account blocked (frozen)", mzap.AccRef(accountRef)) + logger.Info("Account blocked (frozen)", mzap.AccRef(accountRef)) return &ledgerv1.BlockAccountResponse{Account: toProtoAccount(account)}, nil } } @@ -101,7 +101,7 @@ func (s *Service) unblockAccountResponder(_ context.Context, req *ledgerv1.Unblo if err == storage.ErrAccountNotFound { return nil, merrors.NoData("account not found") } - logger.Warn("failed to get account for unblock", zap.Error(err)) + logger.Warn("Failed to get account for unblock", zap.Error(err)) return nil, merrors.Internal("failed to get account") } @@ -124,17 +124,17 @@ func (s *Service) unblockAccountResponder(_ context.Context, req *ledgerv1.Unblo } if account.Status == pmodel.LedgerAccountStatusActive { - logger.Debug("account already active", mzap.AccRef(accountRef)) + logger.Debug("Account already active", mzap.AccRef(accountRef)) return &ledgerv1.UnblockAccountResponse{Account: toProtoAccount(account)}, nil } if err := s.storage.Accounts().UpdateStatus(ctx, accountRef, pmodel.LedgerAccountStatusActive); err != nil { - logger.Warn("failed to activate account", zap.Error(err)) + logger.Warn("Failed to activate account", zap.Error(err)) return nil, merrors.Internal("failed to unblock account") } account.Status = pmodel.LedgerAccountStatusActive - logger.Info("account unblocked (active)", mzap.AccRef(accountRef)) + logger.Info("Account unblocked (active)", mzap.AccRef(accountRef)) return &ledgerv1.UnblockAccountResponse{Account: toProtoAccount(account)}, nil } } diff --git a/api/ledger/internal/service/ledger/list_accounts.go b/api/ledger/internal/service/ledger/list_accounts.go index 16b838ad..fd48902e 100644 --- a/api/ledger/internal/service/ledger/list_accounts.go +++ b/api/ledger/internal/service/ledger/list_accounts.go @@ -48,7 +48,7 @@ func (s *Service) listAccountsResponder(_ context.Context, req *ledgerv1.ListAcc // No pagination requested; return all accounts for the organization. accounts, err := s.storage.Accounts().ListByOrganization(ctx, orgRef, filter, 0, 0) if err != nil { - s.logger.Warn("failed to list ledger accounts", zap.Error(err), mzap.ObjRef("organization_ref", orgRef)) + s.logger.Warn("Failed to list ledger accounts", zap.Error(err), mzap.ObjRef("organization_ref", orgRef)) return nil, err } diff --git a/api/ledger/internal/service/ledger/outbox_publisher.go b/api/ledger/internal/service/ledger/outbox_publisher.go deleted file mode 100644 index bad4baff..00000000 --- a/api/ledger/internal/service/ledger/outbox_publisher.go +++ /dev/null @@ -1,207 +0,0 @@ -package ledger - -import ( - "context" - "encoding/json" - "errors" - "time" - - "github.com/tech/sendico/ledger/storage" - ledgerModel "github.com/tech/sendico/ledger/storage/model" - "github.com/tech/sendico/pkg/merrors" - pmessaging "github.com/tech/sendico/pkg/messaging" - me "github.com/tech/sendico/pkg/messaging/envelope" - "github.com/tech/sendico/pkg/mlogger" - domainmodel "github.com/tech/sendico/pkg/model" - notification "github.com/tech/sendico/pkg/model/notification" - "github.com/tech/sendico/pkg/mservice" - "go.uber.org/zap" -) - -const ( - defaultOutboxBatchSize = 100 - defaultOutboxPollInterval = time.Second - maxOutboxDeliveryAttempts = 5 - outboxPublisherSender = "ledger.outbox.publisher" -) - -type outboxPublisher struct { - logger mlogger.Logger - store storage.OutboxStore - producer pmessaging.Producer - - batchSize int - pollInterval time.Duration -} - -func newOutboxPublisher(logger mlogger.Logger, store storage.OutboxStore, producer pmessaging.Producer) *outboxPublisher { - return &outboxPublisher{ - logger: logger.Named("outbox.publisher"), - store: store, - producer: producer, - batchSize: defaultOutboxBatchSize, - pollInterval: defaultOutboxPollInterval, - } -} - -func (p *outboxPublisher) run(ctx context.Context) { - p.logger.Info("started") - defer p.logger.Info("stopped") - - for { - if ctx.Err() != nil { - return - } - - processed, err := p.dispatchPending(ctx) - if err != nil && !errors.Is(err, context.Canceled) { - p.logger.Warn("failed to dispatch ledger outbox events", zap.Error(err)) - } - if processed > 0 { - p.logger.Debug("dispatched ledger outbox events", - zap.Int("count", processed), - zap.Int("batch_size", p.batchSize)) - } - - if ctx.Err() != nil { - return - } - - if processed == 0 { - select { - case <-ctx.Done(): - return - case <-time.After(p.pollInterval): - } - } - } -} - -func (p *outboxPublisher) dispatchPending(ctx context.Context) (int, error) { - if p.store == nil || p.producer == nil { - return 0, nil - } - - events, err := p.store.ListPending(ctx, p.batchSize) - if err != nil { - return 0, err - } - - for _, event := range events { - if ctx.Err() != nil { - return len(events), ctx.Err() - } - if err := p.publishEvent(ctx, event); err != nil { - if errors.Is(err, context.Canceled) { - return len(events), err - } - p.logger.Warn("failed to publish outbox event", - zap.Error(err), - zap.String("eventId", event.EventID), - zap.String("subject", event.Subject), - zap.String("organizationRef", event.OrganizationRef.Hex()), - zap.Int("attempts", event.Attempts)) - p.handleFailure(ctx, event) - continue - } - if err := p.markSent(ctx, event); err != nil { - if errors.Is(err, context.Canceled) { - return len(events), err - } - p.logger.Warn("failed to mark outbox event as sent", - zap.Error(err), - zap.String("eventId", event.EventID), - zap.String("subject", event.Subject), - zap.String("organizationRef", event.OrganizationRef.Hex())) - } else { - p.logger.Debug("outbox event marked sent", - zap.String("eventId", event.EventID), - zap.String("subject", event.Subject), - zap.String("organizationRef", event.OrganizationRef.Hex())) - } - } - - return len(events), nil -} - -func (p *outboxPublisher) publishEvent(_ context.Context, event *ledgerModel.OutboxEvent) error { - docID := event.GetID() - if docID == nil || docID.IsZero() { - return merrors.InvalidArgument("outbox event missing identifier") - } - - payload, err := p.wrapPayload(event) - if err != nil { - return err - } - - env := me.CreateEnvelope(outboxPublisherSender, domainmodel.NewNotification(mservice.LedgerOutbox, notification.NASent)) - if _, err = env.Wrap(payload); err != nil { - return err - } - - return p.producer.SendMessage(env) -} - -func (p *outboxPublisher) wrapPayload(event *ledgerModel.OutboxEvent) ([]byte, error) { - message := ledgerOutboxMessage{ - EventID: event.EventID, - Subject: event.Subject, - Payload: json.RawMessage(event.Payload), - Attempts: event.Attempts, - OrganizationRef: event.OrganizationRef.Hex(), - CreatedAt: event.CreatedAt, - } - return json.Marshal(message) -} - -func (p *outboxPublisher) markSent(ctx context.Context, event *ledgerModel.OutboxEvent) error { - eventRef := event.GetID() - if eventRef == nil || eventRef.IsZero() { - return merrors.InvalidArgument("outbox event missing identifier") - } - - return p.store.MarkSent(ctx, *eventRef, time.Now().UTC()) -} - -func (p *outboxPublisher) handleFailure(ctx context.Context, event *ledgerModel.OutboxEvent) { - eventRef := event.GetID() - if eventRef == nil || eventRef.IsZero() { - p.logger.Warn("cannot record outbox failure: missing identifier", zap.String("eventId", event.EventID)) - return - } - - if err := p.store.IncrementAttempts(ctx, *eventRef); err != nil && !errors.Is(err, context.Canceled) { - p.logger.Warn("failed to increment outbox attempts", - zap.Error(err), - zap.String("eventId", event.EventID), - zap.String("subject", event.Subject), - zap.String("organizationRef", event.OrganizationRef.Hex())) - } - - if event.Attempts+1 >= maxOutboxDeliveryAttempts { - if err := p.store.MarkFailed(ctx, *eventRef); err != nil && !errors.Is(err, context.Canceled) { - p.logger.Warn("failed to mark outbox event failed", - zap.Error(err), - zap.String("eventId", event.EventID), - zap.String("subject", event.Subject), - zap.String("organizationRef", event.OrganizationRef.Hex()), - zap.Int("attempts", event.Attempts+1)) - } else { - p.logger.Warn("ledger outbox event marked as failed", - zap.String("eventId", event.EventID), - zap.String("subject", event.Subject), - zap.String("organizationRef", event.OrganizationRef.Hex()), - zap.Int("attempts", event.Attempts+1)) - } - } -} - -type ledgerOutboxMessage struct { - EventID string `json:"eventId"` - Subject string `json:"subject"` - Payload json.RawMessage `json:"payload"` - Attempts int `json:"attempts"` - OrganizationRef string `json:"organizationRef"` - CreatedAt time.Time `json:"createdAt"` -} diff --git a/api/ledger/internal/service/ledger/outbox_reliable.go b/api/ledger/internal/service/ledger/outbox_reliable.go new file mode 100644 index 00000000..302f9f08 --- /dev/null +++ b/api/ledger/internal/service/ledger/outbox_reliable.go @@ -0,0 +1,168 @@ +package ledger + +import ( + "context" + "encoding/json" + "strings" + "time" + + "github.com/tech/sendico/ledger/storage" + "github.com/tech/sendico/ledger/storage/model" + pmessaging "github.com/tech/sendico/pkg/messaging" + me "github.com/tech/sendico/pkg/messaging/envelope" + pmessagingreliable "github.com/tech/sendico/pkg/messaging/reliable" + "github.com/tech/sendico/pkg/mlogger" + cfgmodel "github.com/tech/sendico/pkg/model" + domainmodel "github.com/tech/sendico/pkg/model" + notification "github.com/tech/sendico/pkg/model/notification" + "github.com/tech/sendico/pkg/mservice" +) + +const ( + outboxPublisherSender = "ledger.outbox.publisher" +) + +type ledgerOutboxMessage struct { + EventID string `json:"eventId"` + Subject string `json:"subject"` + Payload json.RawMessage `json:"payload"` + Attempts int `json:"attempts"` + OrganizationRef string `json:"organizationRef"` + CreatedAt time.Time `json:"createdAt"` +} + +type ledgerOutboxStoreAdapter struct { + store storage.OutboxStore +} + +func newLedgerReliableProducer(logger mlogger.Logger, direct pmessaging.Producer, store storage.OutboxStore, messagingSettings cfgmodel.SettingsT) (*pmessagingreliable.ReliableProducer, pmessagingreliable.Settings, error) { + if store == nil { + return nil, pmessagingreliable.DefaultSettings(), nil + } + producer, settings, err := pmessagingreliable.NewReliableProducerFromConfig(logger, direct, &ledgerOutboxStoreAdapter{store: store}, messagingSettings, + pmessagingreliable.WithEnvelopeDecoder(ledgerOutboxDecoder), + ) + if err != nil { + return nil, pmessagingreliable.Settings{}, err + } + return producer, settings, nil +} + +func (a *ledgerOutboxStoreAdapter) Enqueue(ctx context.Context, msg pmessagingreliable.OutboxMessage) error { + if a == nil || a.store == nil { + return nil + } + event := &model.OutboxEvent{ + EventID: strings.TrimSpace(msg.EventID), + Subject: strings.TrimSpace(msg.Subject), + Payload: append([]byte(nil), msg.Payload...), + Status: model.OutboxStatusPending, + Attempts: msg.Attempts, + } + if organizationRef := strings.TrimSpace(msg.OrganizationRef); organizationRef != "" { + orgRef, err := parseObjectID(organizationRef) + if err != nil { + return err + } + event.OrganizationRef = orgRef + } + return a.store.Create(ctx, event) +} + +func (a *ledgerOutboxStoreAdapter) ListPending(ctx context.Context, limit int) ([]pmessagingreliable.OutboxMessage, error) { + if a == nil || a.store == nil { + return nil, nil + } + events, err := a.store.ListPending(ctx, limit) + if err != nil { + return nil, err + } + + result := make([]pmessagingreliable.OutboxMessage, 0, len(events)) + for _, event := range events { + if event == nil { + continue + } + reference := "" + if eventRef := event.GetID(); eventRef != nil && !eventRef.IsZero() { + reference = eventRef.Hex() + } + result = append(result, pmessagingreliable.OutboxMessage{ + Reference: reference, + EventID: strings.TrimSpace(event.EventID), + Subject: strings.TrimSpace(event.Subject), + Payload: append([]byte(nil), event.Payload...), + Attempts: event.Attempts, + OrganizationRef: event.OrganizationRef.Hex(), + CreatedAt: event.CreatedAt, + }) + } + + return result, nil +} + +func (a *ledgerOutboxStoreAdapter) MarkSent(ctx context.Context, reference string, sentAt time.Time) error { + if a == nil || a.store == nil { + return nil + } + eventRef, err := parseObjectID(strings.TrimSpace(reference)) + if err != nil { + return err + } + return a.store.MarkSent(ctx, eventRef, sentAt) +} + +func (a *ledgerOutboxStoreAdapter) MarkFailed(ctx context.Context, reference string) error { + if a == nil || a.store == nil { + return nil + } + eventRef, err := parseObjectID(strings.TrimSpace(reference)) + if err != nil { + return err + } + return a.store.MarkFailed(ctx, eventRef) +} + +func (a *ledgerOutboxStoreAdapter) IncrementAttempts(ctx context.Context, reference string) error { + if a == nil || a.store == nil { + return nil + } + eventRef, err := parseObjectID(strings.TrimSpace(reference)) + if err != nil { + return err + } + return a.store.IncrementAttempts(ctx, eventRef) +} + +func ledgerOutboxDecoder(record pmessagingreliable.OutboxMessage) (me.Envelope, error) { + env, err := me.Deserialize(record.Payload) + if err == nil { + return env, nil + } + if strings.TrimSpace(record.Subject) != ledgerOutboxSubject { + return nil, err + } + return buildLedgerOutboxEnvelope(record.EventID, record.Payload, record.Attempts, record.OrganizationRef, record.CreatedAt) +} + +func buildLedgerOutboxEnvelope(eventID string, payload []byte, attempts int, organizationRef string, createdAt time.Time) (me.Envelope, error) { + msg := ledgerOutboxMessage{ + EventID: strings.TrimSpace(eventID), + Subject: ledgerOutboxSubject, + Payload: append([]byte(nil), payload...), + Attempts: attempts, + OrganizationRef: strings.TrimSpace(organizationRef), + CreatedAt: createdAt, + } + + body, err := json.Marshal(msg) + if err != nil { + return nil, err + } + + env := me.CreateEnvelope(outboxPublisherSender, domainmodel.NewNotification(mservice.LedgerOutbox, notification.NASent)) + if _, err = env.Wrap(body); err != nil { + return nil, err + } + return env, nil +} diff --git a/api/ledger/internal/service/ledger/outbox_publisher_test.go b/api/ledger/internal/service/ledger/outbox_reliable_test.go similarity index 59% rename from api/ledger/internal/service/ledger/outbox_publisher_test.go rename to api/ledger/internal/service/ledger/outbox_reliable_test.go index 1b0cc92a..7db12bfa 100644 --- a/api/ledger/internal/service/ledger/outbox_publisher_test.go +++ b/api/ledger/internal/service/ledger/outbox_reliable_test.go @@ -12,33 +12,35 @@ import ( "github.com/stretchr/testify/require" "github.com/tech/sendico/ledger/storage/model" me "github.com/tech/sendico/pkg/messaging/envelope" + pmessagingreliable "github.com/tech/sendico/pkg/messaging/reliable" "go.mongodb.org/mongo-driver/v2/bson" "go.uber.org/zap" ) -func TestOutboxPublisherDispatchSuccess(t *testing.T) { +func TestLedgerReliableProducerDispatchesLegacyOutboxRecords(t *testing.T) { logger := zap.NewNop() event := &model.OutboxEvent{ EventID: "entry-1", - Subject: "ledger.entry.posted", + Subject: ledgerOutboxSubject, Payload: []byte(`{"journalEntryRef":"abc123"}`), Attempts: 0, } event.SetID(bson.NewObjectID()) event.OrganizationRef = bson.NewObjectID() - store := &recordingOutboxStore{ + store := &recordingLedgerOutboxStore{ pending: []*model.OutboxEvent{event}, } - producer := &stubProducer{} - publisher := newOutboxPublisher(logger, store, producer) + direct := &stubDirectProducer{} + producer, _, err := newLedgerReliableProducer(logger, direct, store, nil) + require.NoError(t, err) - processed, err := publisher.dispatchPending(context.Background()) + processed, err := producer.DispatchPending(context.Background()) require.NoError(t, err) assert.Equal(t, 1, processed) - require.Len(t, producer.envelopes, 1) - env := producer.envelopes[0] + require.Len(t, direct.envelopes, 1) + env := direct.envelopes[0] assert.Equal(t, outboxPublisherSender, env.GetSender()) assert.Equal(t, "ledger_outbox_sent", env.GetSignature().ToString()) @@ -47,6 +49,7 @@ func TestOutboxPublisherDispatchSuccess(t *testing.T) { assert.Equal(t, event.EventID, message.EventID) assert.Equal(t, event.Subject, message.Subject) assert.Equal(t, event.OrganizationRef.Hex(), message.OrganizationRef) + assert.JSONEq(t, `{"journalEntryRef":"abc123"}`, string(message.Payload)) require.Len(t, store.markedSent, 1) assert.Equal(t, *event.GetID(), store.markedSent[0]) @@ -54,37 +57,36 @@ func TestOutboxPublisherDispatchSuccess(t *testing.T) { assert.Empty(t, store.incremented) } -func TestOutboxPublisherDispatchFailureMarksAttempts(t *testing.T) { +func TestLedgerReliableProducerMarksFailedOnDispatchError(t *testing.T) { logger := zap.NewNop() event := &model.OutboxEvent{ EventID: "entry-2", - Subject: "ledger.entry.posted", + Subject: ledgerOutboxSubject, Payload: []byte(`{"journalEntryRef":"xyz789"}`), - Attempts: maxOutboxDeliveryAttempts - 1, + Attempts: pmessagingreliable.DefaultSettings().MaxAttempts - 1, } event.SetID(bson.NewObjectID()) event.OrganizationRef = bson.NewObjectID() - store := &recordingOutboxStore{ + store := &recordingLedgerOutboxStore{ pending: []*model.OutboxEvent{event}, } - producer := &stubProducer{err: errors.New("publish failed")} - publisher := newOutboxPublisher(logger, store, producer) + direct := &stubDirectProducer{err: errors.New("publish failed")} + producer, _, err := newLedgerReliableProducer(logger, direct, store, nil) + require.NoError(t, err) - processed, err := publisher.dispatchPending(context.Background()) + processed, err := producer.DispatchPending(context.Background()) require.NoError(t, err) assert.Equal(t, 1, processed) require.Len(t, store.incremented, 1) assert.Equal(t, *event.GetID(), store.incremented[0]) - require.Len(t, store.markedFailed, 1) assert.Equal(t, *event.GetID(), store.markedFailed[0]) - assert.Empty(t, store.markedSent) } -type recordingOutboxStore struct { +type recordingLedgerOutboxStore struct { mu sync.Mutex pending []*model.OutboxEvent @@ -94,11 +96,11 @@ type recordingOutboxStore struct { incremented []bson.ObjectID } -func (s *recordingOutboxStore) Create(context.Context, *model.OutboxEvent) error { +func (s *recordingLedgerOutboxStore) Create(context.Context, *model.OutboxEvent) error { return nil } -func (s *recordingOutboxStore) ListPending(context.Context, int) ([]*model.OutboxEvent, error) { +func (s *recordingLedgerOutboxStore) ListPending(context.Context, int) ([]*model.OutboxEvent, error) { s.mu.Lock() defer s.mu.Unlock() events := s.pending @@ -106,35 +108,34 @@ func (s *recordingOutboxStore) ListPending(context.Context, int) ([]*model.Outbo return events, nil } -func (s *recordingOutboxStore) MarkSent(_ context.Context, eventRef bson.ObjectID, sentAt time.Time) error { - _ = sentAt +func (s *recordingLedgerOutboxStore) MarkSent(_ context.Context, eventRef bson.ObjectID, _ time.Time) error { s.mu.Lock() defer s.mu.Unlock() s.markedSent = append(s.markedSent, eventRef) return nil } -func (s *recordingOutboxStore) MarkFailed(_ context.Context, eventRef bson.ObjectID) error { +func (s *recordingLedgerOutboxStore) MarkFailed(_ context.Context, eventRef bson.ObjectID) error { s.mu.Lock() defer s.mu.Unlock() s.markedFailed = append(s.markedFailed, eventRef) return nil } -func (s *recordingOutboxStore) IncrementAttempts(_ context.Context, eventRef bson.ObjectID) error { +func (s *recordingLedgerOutboxStore) IncrementAttempts(_ context.Context, eventRef bson.ObjectID) error { s.mu.Lock() defer s.mu.Unlock() s.incremented = append(s.incremented, eventRef) return nil } -type stubProducer struct { +type stubDirectProducer struct { mu sync.Mutex envelopes []me.Envelope err error } -func (p *stubProducer) SendMessage(env me.Envelope) error { +func (p *stubDirectProducer) SendMessage(env me.Envelope) error { p.mu.Lock() defer p.mu.Unlock() p.envelopes = append(p.envelopes, env) diff --git a/api/ledger/internal/service/ledger/posting.go b/api/ledger/internal/service/ledger/posting.go index 047747b9..f4969aad 100644 --- a/api/ledger/internal/service/ledger/posting.go +++ b/api/ledger/internal/service/ledger/posting.go @@ -66,7 +66,7 @@ func (s *Service) postCreditResponder(_ context.Context, req *ledgerv1.PostCredi existingEntry, err := s.storage.JournalEntries().GetByIdempotencyKey(ctx, orgRef, req.IdempotencyKey) if err == nil && existingEntry != nil { recordDuplicateRequest("credit") - logger.Info("duplicate credit request (idempotency)", + logger.Info("Duplicate credit request (idempotency)", zap.String("existingEntryID", existingEntry.GetID().Hex())) return &ledgerv1.PostResponse{ JournalEntryRef: existingEntry.GetID().Hex(), @@ -76,7 +76,7 @@ func (s *Service) postCreditResponder(_ context.Context, req *ledgerv1.PostCredi } if err != nil && err != storage.ErrJournalEntryNotFound { recordJournalEntryError("credit", "idempotency_check_failed") - logger.Warn("failed to check idempotency", zap.Error(err)) + logger.Warn("Failed to check idempotency", zap.Error(err)) return nil, merrors.Internal("failed to check idempotency") } @@ -99,7 +99,7 @@ func (s *Service) postCreditResponder(_ context.Context, req *ledgerv1.PostCredi charges := req.Charges if len(charges) == 0 { if computed, err := s.quoteFeesForCredit(ctx, req); err != nil { - logger.Warn("failed to quote fees", zap.Error(err)) + logger.Warn("Failed to quote fees", zap.Error(err)) } else if len(computed) > 0 { charges = computed } @@ -133,7 +133,7 @@ func (s *Service) postCreditResponder(_ context.Context, req *ledgerv1.PostCredi if err == storage.ErrAccountNotFound { return nil, merrors.NoData(fmt.Sprintf("charges[%d]: account not found", i)) } - logger.Warn("failed to get charge account", zap.Error(err), zap.String("chargeAccountRef", chargeAccountRef.Hex())) + logger.Warn("Failed to get charge account", zap.Error(err), zap.String("chargeAccountRef", chargeAccountRef.Hex())) return nil, merrors.Internal("failed to get charge account") } if err := validateAccountForOrg(chargeAccount, orgRef, charge.Money.Currency); err != nil { @@ -199,7 +199,7 @@ func (s *Service) postCreditResponder(_ context.Context, req *ledgerv1.PostCredi entry.OrganizationRef = orgRef if err := s.storage.JournalEntries().Create(txCtx, entry); err != nil { - logger.Warn("failed to create journal entry", zap.Error(err)) + logger.Warn("Failed to create journal entry", zap.Error(err)) return nil, merrors.Internal("failed to create journal entry") } @@ -217,7 +217,7 @@ func (s *Service) postCreditResponder(_ context.Context, req *ledgerv1.PostCredi } if err := s.storage.PostingLines().CreateMany(txCtx, postingLines); err != nil { - logger.Warn("failed to create posting lines", zap.Error(err)) + logger.Warn("Failed to create posting lines", zap.Error(err)) return nil, merrors.Internal("failed to create posting lines") } diff --git a/api/ledger/internal/service/ledger/posting_debit.go b/api/ledger/internal/service/ledger/posting_debit.go index 5e02ad0f..ccf0bdc4 100644 --- a/api/ledger/internal/service/ledger/posting_debit.go +++ b/api/ledger/internal/service/ledger/posting_debit.go @@ -64,7 +64,7 @@ func (s *Service) postDebitResponder(_ context.Context, req *ledgerv1.PostDebitR existingEntry, err := s.storage.JournalEntries().GetByIdempotencyKey(ctx, orgRef, req.IdempotencyKey) if err == nil && existingEntry != nil { recordDuplicateRequest("debit") - logger.Info("duplicate debit request (idempotency)", + logger.Info("Duplicate debit request (idempotency)", zap.String("existingEntryID", existingEntry.GetID().Hex())) return &ledgerv1.PostResponse{ JournalEntryRef: existingEntry.GetID().Hex(), @@ -73,7 +73,7 @@ func (s *Service) postDebitResponder(_ context.Context, req *ledgerv1.PostDebitR }, nil } if err != nil && err != storage.ErrJournalEntryNotFound { - logger.Warn("failed to check idempotency", zap.Error(err)) + logger.Warn("Failed to check idempotency", zap.Error(err)) return nil, merrors.Internal("failed to check idempotency") } @@ -96,7 +96,7 @@ func (s *Service) postDebitResponder(_ context.Context, req *ledgerv1.PostDebitR charges := req.Charges if len(charges) == 0 { if computed, err := s.quoteFeesForDebit(ctx, req); err != nil { - logger.Warn("failed to quote fees", zap.Error(err)) + logger.Warn("Failed to quote fees", zap.Error(err)) } else if len(computed) > 0 { charges = computed } @@ -130,7 +130,7 @@ func (s *Service) postDebitResponder(_ context.Context, req *ledgerv1.PostDebitR if err == storage.ErrAccountNotFound { return nil, merrors.NoData(fmt.Sprintf("charges[%d]: account not found", i)) } - logger.Warn("failed to get charge account", zap.Error(err), zap.String("chargeAccountRef", chargeAccountRef.Hex())) + logger.Warn("Failed to get charge account", zap.Error(err), zap.String("chargeAccountRef", chargeAccountRef.Hex())) return nil, merrors.Internal("failed to get charge account") } if err := validateAccountForOrg(chargeAccount, orgRef, charge.Money.Currency); err != nil { @@ -196,7 +196,7 @@ func (s *Service) postDebitResponder(_ context.Context, req *ledgerv1.PostDebitR entry.OrganizationRef = orgRef if err := s.storage.JournalEntries().Create(txCtx, entry); err != nil { - logger.Warn("failed to create journal entry", zap.Error(err)) + logger.Warn("Failed to create journal entry", zap.Error(err)) return nil, merrors.Internal("failed to create journal entry") } @@ -214,7 +214,7 @@ func (s *Service) postDebitResponder(_ context.Context, req *ledgerv1.PostDebitR } if err := s.storage.PostingLines().CreateMany(txCtx, postingLines); err != nil { - logger.Warn("failed to create posting lines", zap.Error(err)) + logger.Warn("Failed to create posting lines", zap.Error(err)) return nil, merrors.Internal("failed to create posting lines") } diff --git a/api/ledger/internal/service/ledger/posting_external.go b/api/ledger/internal/service/ledger/posting_external.go index f52f4c2d..362eb8b5 100644 --- a/api/ledger/internal/service/ledger/posting_external.go +++ b/api/ledger/internal/service/ledger/posting_external.go @@ -61,7 +61,7 @@ func (s *Service) postExternalCreditResponder(_ context.Context, req *ledgerv1.P existingEntry, err := s.storage.JournalEntries().GetByIdempotencyKey(ctx, orgRef, req.IdempotencyKey) if err == nil && existingEntry != nil { recordDuplicateRequest("credit") - logger.Info("duplicate external credit request (idempotency)", + logger.Info("Duplicate external credit request (idempotency)", zap.String("existingEntryID", existingEntry.GetID().Hex())) return &ledgerv1.PostResponse{ JournalEntryRef: existingEntry.GetID().Hex(), @@ -71,7 +71,7 @@ func (s *Service) postExternalCreditResponder(_ context.Context, req *ledgerv1.P } if err != nil && err != storage.ErrJournalEntryNotFound { recordJournalEntryError("credit", "idempotency_check_failed") - logger.Warn("failed to check idempotency", zap.Error(err)) + logger.Warn("Failed to check idempotency", zap.Error(err)) return nil, merrors.Internal("failed to check idempotency") } @@ -113,7 +113,7 @@ func (s *Service) postExternalCreditResponder(_ context.Context, req *ledgerv1.P charges := req.Charges if len(charges) == 0 { if computed, err := s.quoteFeesForCredit(ctx, req); err != nil { - logger.Warn("failed to quote fees", zap.Error(err)) + logger.Warn("Failed to quote fees", zap.Error(err)) } else if len(computed) > 0 { charges = computed } @@ -147,7 +147,7 @@ func (s *Service) postExternalCreditResponder(_ context.Context, req *ledgerv1.P if err == storage.ErrAccountNotFound { return nil, merrors.NoData(fmt.Sprintf("charges[%d]: account not found", i)) } - logger.Warn("failed to get charge account", zap.Error(err), zap.String("chargeAccountRef", chargeAccountRef.Hex())) + logger.Warn("Failed to get charge account", zap.Error(err), zap.String("chargeAccountRef", chargeAccountRef.Hex())) return nil, merrors.Internal("failed to get charge account") } if err := validateAccountForOrg(chargeAccount, orgRef, charge.Money.Currency); err != nil { @@ -202,7 +202,7 @@ func (s *Service) postExternalCreditResponder(_ context.Context, req *ledgerv1.P entry.OrganizationRef = orgRef if err := s.storage.JournalEntries().Create(txCtx, entry); err != nil { - logger.Warn("failed to create journal entry", zap.Error(err)) + logger.Warn("Failed to create journal entry", zap.Error(err)) return nil, merrors.Internal("failed to create journal entry") } @@ -220,7 +220,7 @@ func (s *Service) postExternalCreditResponder(_ context.Context, req *ledgerv1.P } if err := s.storage.PostingLines().CreateMany(txCtx, postingLines); err != nil { - logger.Warn("failed to create posting lines", zap.Error(err)) + logger.Warn("Failed to create posting lines", zap.Error(err)) return nil, merrors.Internal("failed to create posting lines") } @@ -294,7 +294,7 @@ func (s *Service) postExternalDebitResponder(_ context.Context, req *ledgerv1.Po existingEntry, err := s.storage.JournalEntries().GetByIdempotencyKey(ctx, orgRef, req.IdempotencyKey) if err == nil && existingEntry != nil { recordDuplicateRequest("debit") - logger.Info("duplicate external debit request (idempotency)", + logger.Info("Duplicate external debit request (idempotency)", zap.String("existingEntryID", existingEntry.GetID().Hex())) return &ledgerv1.PostResponse{ JournalEntryRef: existingEntry.GetID().Hex(), @@ -304,7 +304,7 @@ func (s *Service) postExternalDebitResponder(_ context.Context, req *ledgerv1.Po } if err != nil && err != storage.ErrJournalEntryNotFound { recordJournalEntryError("debit", "idempotency_check_failed") - logger.Warn("failed to check idempotency", zap.Error(err)) + logger.Warn("Failed to check idempotency", zap.Error(err)) return nil, merrors.Internal("failed to check idempotency") } @@ -346,7 +346,7 @@ func (s *Service) postExternalDebitResponder(_ context.Context, req *ledgerv1.Po charges := req.Charges if len(charges) == 0 { if computed, err := s.quoteFeesForDebit(ctx, req); err != nil { - logger.Warn("failed to quote fees", zap.Error(err)) + logger.Warn("Failed to quote fees", zap.Error(err)) } else if len(computed) > 0 { charges = computed } @@ -380,7 +380,7 @@ func (s *Service) postExternalDebitResponder(_ context.Context, req *ledgerv1.Po if err == storage.ErrAccountNotFound { return nil, merrors.NoData(fmt.Sprintf("charges[%d]: account not found", i)) } - logger.Warn("failed to get charge account", zap.Error(err), zap.String("chargeAccountRef", chargeAccountRef.Hex())) + logger.Warn("Failed to get charge account", zap.Error(err), zap.String("chargeAccountRef", chargeAccountRef.Hex())) return nil, merrors.Internal("failed to get charge account") } if err := validateAccountForOrg(chargeAccount, orgRef, charge.Money.Currency); err != nil { @@ -435,7 +435,7 @@ func (s *Service) postExternalDebitResponder(_ context.Context, req *ledgerv1.Po entry.OrganizationRef = orgRef if err := s.storage.JournalEntries().Create(txCtx, entry); err != nil { - logger.Warn("failed to create journal entry", zap.Error(err)) + logger.Warn("Failed to create journal entry", zap.Error(err)) return nil, merrors.Internal("failed to create journal entry") } @@ -453,7 +453,7 @@ func (s *Service) postExternalDebitResponder(_ context.Context, req *ledgerv1.Po } if err := s.storage.PostingLines().CreateMany(txCtx, postingLines); err != nil { - logger.Warn("failed to create posting lines", zap.Error(err)) + logger.Warn("Failed to create posting lines", zap.Error(err)) return nil, merrors.Internal("failed to create posting lines") } diff --git a/api/ledger/internal/service/ledger/posting_fx.go b/api/ledger/internal/service/ledger/posting_fx.go index d967a8bc..10a0a227 100644 --- a/api/ledger/internal/service/ledger/posting_fx.go +++ b/api/ledger/internal/service/ledger/posting_fx.go @@ -77,7 +77,7 @@ func (s *Service) fxResponder(_ context.Context, req *ledgerv1.FXRequest) gsresp existingEntry, err := s.storage.JournalEntries().GetByIdempotencyKey(ctx, orgRef, req.IdempotencyKey) if err == nil && existingEntry != nil { recordDuplicateRequest("fx") - logger.Info("duplicate FX request (idempotency)", + logger.Info("Duplicate FX request (idempotency)", zap.String("existingEntryID", existingEntry.GetID().Hex())) return &ledgerv1.PostResponse{ JournalEntryRef: existingEntry.GetID().Hex(), @@ -86,7 +86,7 @@ func (s *Service) fxResponder(_ context.Context, req *ledgerv1.FXRequest) gsresp }, nil } if err != nil && err != storage.ErrJournalEntryNotFound { - logger.Warn("failed to check idempotency", zap.Error(err)) + logger.Warn("Failed to check idempotency", zap.Error(err)) return nil, merrors.Internal("failed to check idempotency") } @@ -96,7 +96,7 @@ func (s *Service) fxResponder(_ context.Context, req *ledgerv1.FXRequest) gsresp if err == storage.ErrAccountNotFound { return nil, merrors.NoData("from_account not found") } - logger.Warn("failed to get from_account", zap.Error(err)) + logger.Warn("Failed to get from_account", zap.Error(err)) return nil, merrors.Internal("failed to get from_account") } if err := validateAccountForOrg(fromAccount, orgRef, req.FromMoney.Currency); err != nil { @@ -108,7 +108,7 @@ func (s *Service) fxResponder(_ context.Context, req *ledgerv1.FXRequest) gsresp if err == storage.ErrAccountNotFound { return nil, merrors.NoData("to_account not found") } - logger.Warn("failed to get to_account", zap.Error(err)) + logger.Warn("Failed to get to_account", zap.Error(err)) return nil, merrors.Internal("failed to get to_account") } if err := validateAccountForOrg(toAccount, orgRef, req.ToMoney.Currency); err != nil { @@ -162,7 +162,7 @@ func (s *Service) fxResponder(_ context.Context, req *ledgerv1.FXRequest) gsresp if err == storage.ErrAccountNotFound { return nil, merrors.NoData(fmt.Sprintf("charges[%d]: account not found", i)) } - logger.Warn("failed to get FX charge account", zap.Error(err), zap.String("chargeAccountRef", chargeAccountRef.Hex())) + logger.Warn("Failed to get FX charge account", zap.Error(err), zap.String("chargeAccountRef", chargeAccountRef.Hex())) return nil, merrors.Internal("failed to get charge account") } if err := validateAccountForOrg(chargeAccount, orgRef, charge.Money.Currency); err != nil { @@ -210,7 +210,7 @@ func (s *Service) fxResponder(_ context.Context, req *ledgerv1.FXRequest) gsresp entry.OrganizationRef = orgRef if err := s.storage.JournalEntries().Create(txCtx, entry); err != nil { - logger.Warn("failed to create journal entry", zap.Error(err)) + logger.Warn("Failed to create journal entry", zap.Error(err)) return nil, merrors.Internal("failed to create journal entry") } @@ -224,7 +224,7 @@ func (s *Service) fxResponder(_ context.Context, req *ledgerv1.FXRequest) gsresp } if err := s.storage.PostingLines().CreateMany(txCtx, postingLines); err != nil { - logger.Warn("failed to create posting lines", zap.Error(err)) + logger.Warn("Failed to create posting lines", zap.Error(err)) return nil, merrors.Internal("failed to create posting lines") } diff --git a/api/ledger/internal/service/ledger/posting_support.go b/api/ledger/internal/service/ledger/posting_support.go index d3cbd4b5..d347a610 100644 --- a/api/ledger/internal/service/ledger/posting_support.go +++ b/api/ledger/internal/service/ledger/posting_support.go @@ -139,7 +139,7 @@ func (s *Service) resolveSettlementAccount(ctx context.Context, orgRef bson.Obje if errors.Is(err, storage.ErrAccountNotFound) { return nil, merrors.NoData("contra account not found") } - s.logger.Warn("failed to load override contra account", zap.Error(err), zap.String("accountRef", overrideRef.Hex())) + s.logger.Warn("Failed to load override contra account", zap.Error(err), zap.String("accountRef", overrideRef.Hex())) return nil, merrors.Internal("failed to load contra account") } if err := validateAccountForOrg(account, orgRef, currency); err != nil { @@ -153,7 +153,7 @@ func (s *Service) resolveSettlementAccount(ctx context.Context, orgRef bson.Obje if errors.Is(err, storage.ErrAccountNotFound) { return nil, merrors.InvalidArgument("no default settlement account configured for currency") } - s.logger.Warn("failed to resolve default settlement account", + s.logger.Warn("Failed to resolve default settlement account", zap.Error(err), mzap.ObjRef("organization_ref", orgRef), zap.String("currency", currency)) @@ -197,13 +197,13 @@ func (s *Service) upsertBalances(ctx context.Context, lines []*model.PostingLine for accountRef, delta := range balanceDeltas { account := accounts[accountRef] if account == nil { - s.logger.Warn("account cache missing for balance update", mzap.AccRef(accountRef)) + s.logger.Warn("Account cache missing for balance update", mzap.AccRef(accountRef)) return merrors.Internal("account cache missing for balance update") } currentBalance, err := balancesStore.Get(ctx, accountRef) if err != nil && !errors.Is(err, storage.ErrBalanceNotFound) { - s.logger.Warn("failed to fetch account balance", + s.logger.Warn("Failed to fetch account balance", zap.Error(err), mzap.AccRef(accountRef)) return merrors.Internal("failed to update balance") @@ -238,7 +238,7 @@ func (s *Service) upsertBalances(ctx context.Context, lines []*model.PostingLine } if err := balancesStore.Upsert(ctx, newBalance); err != nil { - s.logger.Warn("failed to upsert account balance", zap.Error(err), mzap.AccRef(accountRef)) + s.logger.Warn("Failed to upsert account balance", zap.Error(err), mzap.AccRef(accountRef)) return merrors.Internal("failed to update balance") } } @@ -275,21 +275,26 @@ func (s *Service) enqueueOutbox(ctx context.Context, entry *model.JournalEntry, body, err := json.Marshal(payload) if err != nil { - s.logger.Warn("failed to marshal ledger outbox payload", zap.Error(err)) + s.logger.Warn("Failed to marshal ledger outbox payload", zap.Error(err)) return merrors.Internal("failed to marshal ledger event") } - - event := &model.OutboxEvent{ - EventID: entryID.Hex(), - Subject: ledgerOutboxSubject, - Payload: body, - Status: model.OutboxStatusPending, - Attempts: 0, + envelope, err := buildLedgerOutboxEnvelope(entryID.Hex(), body, 0, entry.OrganizationRef.Hex(), time.Now().UTC()) + if err != nil { + s.logger.Warn("Failed to build ledger outbox envelope", zap.Error(err)) + return merrors.Internal("failed to prepare ledger event envelope") } - event.OrganizationRef = entry.OrganizationRef - if err := s.storage.Outbox().Create(ctx, event); err != nil { - s.logger.Warn("failed to enqueue ledger outbox event", zap.Error(err)) + if err := s.startOutboxReliableProducer(); err != nil { + s.logger.Warn("Failed to initialise outbox reliable producer", zap.Error(err)) + return merrors.Internal("failed to initialize reliable outbox") + } + if s.outbox.producer == nil { + s.logger.Warn("Failed to enqueue ledger outbox event: reliable producer not configured") + return merrors.Internal("failed to enqueue ledger event") + } + + if err := s.outbox.producer.SendWithOutbox(ctx, envelope); err != nil { + s.logger.Warn("Failed to enqueue ledger outbox event", zap.Error(err)) return merrors.Internal("failed to enqueue ledger event") } diff --git a/api/ledger/internal/service/ledger/posting_support_test.go b/api/ledger/internal/service/ledger/posting_support_test.go index 090deba1..fe3b981f 100644 --- a/api/ledger/internal/service/ledger/posting_support_test.go +++ b/api/ledger/internal/service/ledger/posting_support_test.go @@ -12,6 +12,7 @@ import ( "github.com/tech/sendico/ledger/storage" "github.com/tech/sendico/ledger/storage/model" "github.com/tech/sendico/pkg/merrors" + me "github.com/tech/sendico/pkg/messaging/envelope" pmodel "github.com/tech/sendico/pkg/model" "github.com/tech/sendico/pkg/model/account_role" "go.mongodb.org/mongo-driver/v2/bson" @@ -278,11 +279,20 @@ func TestEnqueueOutbox_CreatesEvent(t *testing.T) { require.NoError(t, service.enqueueOutbox(ctx, entry, lines)) require.Len(t, producer.created, 1) event := producer.created[0] - assert.Equal(t, entryID.Hex(), event.EventID) - assert.Equal(t, ledgerOutboxSubject, event.Subject) + assert.Equal(t, "ledger_outbox_sent", event.Subject) + + envelope, err := me.Deserialize(event.Payload) + require.NoError(t, err) + assert.Equal(t, outboxPublisherSender, envelope.GetSender()) + assert.Equal(t, "ledger_outbox_sent", envelope.GetSignature().ToString()) + + var wrapped ledgerOutboxMessage + require.NoError(t, json.Unmarshal(envelope.GetData(), &wrapped)) + assert.Equal(t, entryID.Hex(), wrapped.EventID) + assert.Equal(t, ledgerOutboxSubject, wrapped.Subject) var payload outboxJournalPayload - require.NoError(t, json.Unmarshal(event.Payload, &payload)) + require.NoError(t, json.Unmarshal(wrapped.Payload, &payload)) assert.Equal(t, entryID.Hex(), payload.JournalEntryRef) assert.Equal(t, "credit", payload.EntryType) assert.Len(t, payload.Lines, 1) diff --git a/api/ledger/internal/service/ledger/posting_transfer.go b/api/ledger/internal/service/ledger/posting_transfer.go index 693fe279..213b9b4e 100644 --- a/api/ledger/internal/service/ledger/posting_transfer.go +++ b/api/ledger/internal/service/ledger/posting_transfer.go @@ -87,7 +87,7 @@ func (s *Service) transferResponder(_ context.Context, req *ledgerv1.TransferReq existingEntry, err := s.storage.JournalEntries().GetByIdempotencyKey(ctx, orgRef, req.IdempotencyKey) if err == nil && existingEntry != nil { recordDuplicateRequest("transfer") - logger.Info("duplicate transfer request (idempotency)", + logger.Info("Duplicate transfer request (idempotency)", zap.String("existingEntryID", existingEntry.GetID().Hex())) return &ledgerv1.PostResponse{ JournalEntryRef: existingEntry.GetID().Hex(), @@ -96,7 +96,7 @@ func (s *Service) transferResponder(_ context.Context, req *ledgerv1.TransferReq }, nil } if err != nil && err != storage.ErrJournalEntryNotFound { - logger.Warn("failed to check idempotency", zap.Error(err)) + logger.Warn("Failed to check idempotency", zap.Error(err)) return nil, merrors.Internal("failed to check idempotency") } @@ -172,7 +172,7 @@ func (s *Service) transferResponder(_ context.Context, req *ledgerv1.TransferReq if err == storage.ErrAccountNotFound { return nil, merrors.NoData(fmt.Sprintf("charges[%d]: account not found", i)) } - logger.Warn("failed to get charge account", zap.Error(err), zap.String("chargeAccountRef", chargeAccountRef.Hex())) + logger.Warn("Failed to get charge account", zap.Error(err), zap.String("chargeAccountRef", chargeAccountRef.Hex())) return nil, merrors.Internal("failed to get charge account") } if err := validateAccountForOrg(chargeAccount, orgRef, charge.Money.Currency); err != nil { @@ -208,7 +208,7 @@ func (s *Service) transferResponder(_ context.Context, req *ledgerv1.TransferReq entry.OrganizationRef = orgRef if err := s.storage.JournalEntries().Create(txCtx, entry); err != nil { - logger.Warn("failed to create journal entry", zap.Error(err)) + logger.Warn("Failed to create journal entry", zap.Error(err)) return nil, merrors.Internal("failed to create journal entry") } @@ -226,7 +226,7 @@ func (s *Service) transferResponder(_ context.Context, req *ledgerv1.TransferReq } if err := s.storage.PostingLines().CreateMany(txCtx, postingLines); err != nil { - logger.Warn("failed to create posting lines", zap.Error(err)) + logger.Warn("Failed to create posting lines", zap.Error(err)) return nil, merrors.Internal("failed to create posting lines") } diff --git a/api/ledger/internal/service/ledger/queries.go b/api/ledger/internal/service/ledger/queries.go index da80179c..670806ae 100644 --- a/api/ledger/internal/service/ledger/queries.go +++ b/api/ledger/internal/service/ledger/queries.go @@ -36,7 +36,7 @@ func (s *Service) getBalanceResponder(_ context.Context, req *ledgerv1.GetBalanc if err == storage.ErrAccountNotFound { return nil, merrors.NoData("account not found") } - logger.Warn("failed to get account", zap.Error(err)) + logger.Warn("Failed to get account", zap.Error(err)) return nil, merrors.Internal("failed to get account") } @@ -55,7 +55,7 @@ func (s *Service) getBalanceResponder(_ context.Context, req *ledgerv1.GetBalanc LastUpdated: timestamppb.Now(), }, nil } - logger.Warn("failed to get balance", zap.Error(err)) + logger.Warn("Failed to get balance", zap.Error(err)) return nil, merrors.Internal("failed to get balance") } @@ -92,14 +92,14 @@ func (s *Service) getJournalEntryResponder(_ context.Context, req *ledgerv1.GetE if err == storage.ErrJournalEntryNotFound { return nil, merrors.NoData("journal entry not found") } - logger.Warn("failed to get journal entry", zap.Error(err)) + logger.Warn("Failed to get journal entry", zap.Error(err)) return nil, merrors.Internal("failed to get journal entry") } // Get posting lines for this entry lines, err := s.storage.PostingLines().ListByJournalEntry(ctx, entryRef) if err != nil { - logger.Warn("failed to get posting lines", zap.Error(err)) + logger.Warn("Failed to get posting lines", zap.Error(err)) return nil, merrors.Internal("failed to get posting lines") } @@ -151,7 +151,7 @@ func (s *Service) getStatementResponder(_ context.Context, req *ledgerv1.GetStat if err == storage.ErrAccountNotFound { return nil, merrors.NoData("account not found") } - logger.Warn("failed to get account", zap.Error(err)) + logger.Warn("Failed to get account", zap.Error(err)) return nil, merrors.Internal("failed to get account") } @@ -176,7 +176,7 @@ func (s *Service) getStatementResponder(_ context.Context, req *ledgerv1.GetStat // Get posting lines for account postingLines, err := s.storage.PostingLines().ListByAccount(ctx, accountRef, limit+1, offset) if err != nil { - logger.Warn("failed to get posting lines", zap.Error(err)) + logger.Warn("Failed to get posting lines", zap.Error(err)) return nil, merrors.Internal("failed to get posting lines") } @@ -196,20 +196,20 @@ func (s *Service) getStatementResponder(_ context.Context, req *ledgerv1.GetStat for entryRefHex := range entryMap { entryRef, err := parseObjectID(entryRefHex) if err != nil { - s.logger.Warn("invalid journal entry ref in posting lines", zap.String("entry_ref", entryRefHex), zap.Error(err)) + s.logger.Warn("Invalid journal entry ref in posting lines", zap.String("entry_ref", entryRefHex), zap.Error(err)) return nil, err } entry, err := s.storage.JournalEntries().Get(ctx, entryRef) if err != nil { - logger.Warn("failed to get journal entry for statement", zap.Error(err), zap.String("entry_ref", entryRefHex)) + logger.Warn("Failed to get journal entry for statement", zap.Error(err), zap.String("entry_ref", entryRefHex)) continue } // Get all lines for this entry lines, err := s.storage.PostingLines().ListByJournalEntry(ctx, entryRef) if err != nil { - logger.Warn("failed to get posting lines for entry", zap.Error(err), zap.String("entry_ref", entryRefHex)) + logger.Warn("Failed to get posting lines for entry", zap.Error(err), zap.String("entry_ref", entryRefHex)) continue } diff --git a/api/ledger/internal/service/ledger/service.go b/api/ledger/internal/service/ledger/service.go index ddb70841..2773fe29 100644 --- a/api/ledger/internal/service/ledger/service.go +++ b/api/ledger/internal/service/ledger/service.go @@ -23,6 +23,7 @@ import ( "github.com/tech/sendico/pkg/discovery" "github.com/tech/sendico/pkg/merrors" pmessaging "github.com/tech/sendico/pkg/messaging" + pmessagingreliable "github.com/tech/sendico/pkg/messaging/reliable" "github.com/tech/sendico/pkg/mlogger" pmodel "github.com/tech/sendico/pkg/model" "github.com/tech/sendico/pkg/mservice" @@ -44,14 +45,15 @@ type Service struct { logger mlogger.Logger storage storage.Repository producer pmessaging.Producer + msgCfg pmodel.SettingsT fees feesDependency announcer *discovery.Announcer invokeURI string outbox struct { - once sync.Once - cancel context.CancelFunc - publisher *outboxPublisher + once sync.Once + cancel context.CancelFunc + producer *pmessagingreliable.ReliableProducer } systemAccounts struct { @@ -70,7 +72,7 @@ func (f feesDependency) available() bool { return f.client != nil } -func NewService(logger mlogger.Logger, repo storage.Repository, prod pmessaging.Producer, feesClient feesv1.FeeEngineClient, feesTimeout time.Duration, invokeURI string) *Service { +func NewService(logger mlogger.Logger, repo storage.Repository, prod pmessaging.Producer, msgCfg pmodel.SettingsT, feesClient feesv1.FeeEngineClient, feesTimeout time.Duration, invokeURI string) (*Service, error) { // Initialize Prometheus metrics initMetrics() @@ -78,6 +80,7 @@ func NewService(logger mlogger.Logger, repo storage.Repository, prod pmessaging. logger: logger.Named("ledger"), storage: repo, producer: prod, + msgCfg: msgCfg, invokeURI: strings.TrimSpace(invokeURI), fees: feesDependency{ client: feesClient, @@ -85,9 +88,11 @@ func NewService(logger mlogger.Logger, repo storage.Repository, prod pmessaging. }, } - service.startOutboxPublisher() + if err := service.startOutboxReliableProducer(); err != nil { + return nil, err + } service.startDiscoveryAnnouncer() - return service + return service, nil } func (s *Service) Register(router routers.GRPC) error { @@ -405,23 +410,39 @@ func (s *Service) startDiscoveryAnnouncer() { s.announcer.Start() } -func (s *Service) startOutboxPublisher() { - if s.storage == nil || s.producer == nil { - return +func (s *Service) startOutboxReliableProducer() error { + if s.storage == nil { + return nil } + var initErr error s.outbox.once.Do(func() { outboxStore := s.storage.Outbox() if outboxStore == nil { return } + reliableProducer, settings, err := newLedgerReliableProducer(s.logger, s.producer, outboxStore, s.msgCfg) + if err != nil { + initErr = err + return + } + s.outbox.producer = reliableProducer + if s.outbox.producer == nil || s.producer == nil { + s.logger.Info("Outbox reliable publisher disabled", + zap.Bool("enabled", settings.Enabled)) + return + } + s.logger.Info("Outbox reliable publisher configured", + zap.Bool("enabled", settings.Enabled), + zap.Int("batch_size", settings.BatchSize), + zap.Int("poll_interval_seconds", settings.PollIntervalSeconds), + zap.Int("max_attempts", settings.MaxAttempts)) ctx, cancel := context.WithCancel(context.Background()) s.outbox.cancel = cancel - s.outbox.publisher = newOutboxPublisher(s.logger, outboxStore, s.producer) - - go s.outbox.publisher.run(ctx) + go s.outbox.producer.Run(ctx) }) + return initErr } // BlockAccount freezes a ledger account diff --git a/api/ledger/storage/mongo/store/balances.go b/api/ledger/storage/mongo/store/balances.go index 6fa7b8a5..95e2fd52 100644 --- a/api/ledger/storage/mongo/store/balances.go +++ b/api/ledger/storage/mongo/store/balances.go @@ -32,12 +32,12 @@ func NewBalances(logger mlogger.Logger, db *mongo.Database) (storage.BalancesSto Unique: true, } if err := repo.CreateIndex(uniqueIndex); err != nil { - logger.Error("failed to ensure balances unique index", zap.Error(err)) + logger.Error("Failed to ensure balances unique index", zap.Error(err)) return nil, err } childLogger := logger.Named(model.AccountBalancesCollection) - childLogger.Debug("balances store initialised", zap.String("collection", model.AccountBalancesCollection)) + childLogger.Debug("Balances store initialised", zap.String("collection", model.AccountBalancesCollection)) return &balancesStore{ logger: childLogger, @@ -47,7 +47,7 @@ func NewBalances(logger mlogger.Logger, db *mongo.Database) (storage.BalancesSto func (b *balancesStore) Get(ctx context.Context, accountRef bson.ObjectID) (*model.AccountBalance, error) { if accountRef.IsZero() { - b.logger.Warn("attempt to get balance with zero account ID") + b.logger.Warn("Attempt to get balance with zero account ID") return nil, merrors.InvalidArgument("balancesStore: zero account ID") } @@ -56,25 +56,25 @@ func (b *balancesStore) Get(ctx context.Context, accountRef bson.ObjectID) (*mod result := &model.AccountBalance{} if err := b.repo.FindOneByFilter(ctx, query, result); err != nil { if errors.Is(err, merrors.ErrNoData) { - b.logger.Debug("balance not found", mzap.AccRef(accountRef)) + b.logger.Debug("Balance not found", mzap.AccRef(accountRef)) return nil, storage.ErrBalanceNotFound } - b.logger.Warn("failed to get balance", zap.Error(err), mzap.AccRef(accountRef)) + b.logger.Warn("Failed to get balance", zap.Error(err), mzap.AccRef(accountRef)) return nil, err } - b.logger.Debug("balance loaded", mzap.AccRef(accountRef), + b.logger.Debug("Balance loaded", mzap.AccRef(accountRef), zap.String("balance", result.Balance)) return result, nil } func (b *balancesStore) Upsert(ctx context.Context, balance *model.AccountBalance) error { if balance == nil { - b.logger.Warn("attempt to upsert nil balance") + b.logger.Warn("Attempt to upsert nil balance") return merrors.InvalidArgument("balancesStore: nil balance") } if balance.AccountRef.IsZero() { - b.logger.Warn("attempt to upsert balance with zero account ID") + b.logger.Warn("Attempt to upsert balance with zero account ID") return merrors.InvalidArgument("balancesStore: zero account ID") } @@ -83,24 +83,24 @@ func (b *balancesStore) Upsert(ctx context.Context, balance *model.AccountBalanc if err := b.repo.FindOneByFilter(ctx, filter, existing); err != nil { if errors.Is(err, merrors.ErrNoData) { - b.logger.Debug("inserting new balance", zap.String("accountRef", balance.AccountRef.Hex())) + b.logger.Debug("Inserting new balance", zap.String("accountRef", balance.AccountRef.Hex())) return b.repo.Insert(ctx, balance, filter) } - b.logger.Warn("failed to fetch balance", zap.Error(err), zap.String("accountRef", balance.AccountRef.Hex())) + b.logger.Warn("Failed to fetch balance", zap.Error(err), zap.String("accountRef", balance.AccountRef.Hex())) return err } if existing.GetID() != nil { balance.SetID(*existing.GetID()) } - b.logger.Debug("updating balance", zap.String("accountRef", balance.AccountRef.Hex()), + b.logger.Debug("Updating balance", zap.String("accountRef", balance.AccountRef.Hex()), zap.String("balance", balance.Balance)) return b.repo.Update(ctx, balance) } func (b *balancesStore) IncrementBalance(ctx context.Context, accountRef bson.ObjectID, amount string) error { if accountRef.IsZero() { - b.logger.Warn("attempt to increment balance with zero account ID") + b.logger.Warn("Attempt to increment balance with zero account ID") return merrors.InvalidArgument("balancesStore: zero account ID") } diff --git a/api/ledger/storage/mongo/store/journal_entries.go b/api/ledger/storage/mongo/store/journal_entries.go index e3f3d48c..2c66ed5f 100644 --- a/api/ledger/storage/mongo/store/journal_entries.go +++ b/api/ledger/storage/mongo/store/journal_entries.go @@ -33,7 +33,7 @@ func NewJournalEntries(logger mlogger.Logger, db *mongo.Database) (storage.Journ Unique: true, } if err := repo.CreateIndex(uniqueIndex); err != nil { - logger.Error("failed to ensure journal entries idempotency index", zap.Error(err)) + logger.Error("Failed to ensure journal entries idempotency index", zap.Error(err)) return nil, err } @@ -45,12 +45,12 @@ func NewJournalEntries(logger mlogger.Logger, db *mongo.Database) (storage.Journ }, } if err := repo.CreateIndex(orgIndex); err != nil { - logger.Error("failed to ensure journal entries organization index", zap.Error(err)) + logger.Error("Failed to ensure journal entries organization index", zap.Error(err)) return nil, err } childLogger := logger.Named(model.JournalEntriesCollection) - childLogger.Debug("journal entries store initialised", zap.String("collection", model.JournalEntriesCollection)) + childLogger.Debug("Journal entries store initialised", zap.String("collection", model.JournalEntriesCollection)) return &journalEntriesStore{ logger: childLogger, @@ -60,52 +60,52 @@ func NewJournalEntries(logger mlogger.Logger, db *mongo.Database) (storage.Journ func (j *journalEntriesStore) Create(ctx context.Context, entry *model.JournalEntry) error { if entry == nil { - j.logger.Warn("attempt to create nil journal entry") + j.logger.Warn("Attempt to create nil journal entry") return merrors.InvalidArgument("journalEntriesStore: nil journal entry") } if err := j.repo.Insert(ctx, entry, nil); err != nil { if mongo.IsDuplicateKeyError(err) { - j.logger.Warn("duplicate idempotency key", zap.String("idempotency_key", entry.IdempotencyKey)) + j.logger.Warn("Duplicate idempotency key", zap.String("idempotency_key", entry.IdempotencyKey)) return storage.ErrDuplicateIdempotency } - j.logger.Warn("failed to create journal entry", zap.Error(err)) + j.logger.Warn("Failed to create journal entry", zap.Error(err)) return err } - j.logger.Debug("journal entry created", zap.String("idempotency_key", entry.IdempotencyKey), + j.logger.Debug("Journal entry created", zap.String("idempotency_key", entry.IdempotencyKey), zap.String("entryType", string(entry.EntryType))) return nil } func (j *journalEntriesStore) Get(ctx context.Context, entryRef bson.ObjectID) (*model.JournalEntry, error) { if entryRef.IsZero() { - j.logger.Warn("attempt to get journal entry with zero ID") + j.logger.Warn("Attempt to get journal entry with zero ID") return nil, merrors.InvalidArgument("journalEntriesStore: zero entry ID") } result := &model.JournalEntry{} if err := j.repo.Get(ctx, entryRef, result); err != nil { if errors.Is(err, merrors.ErrNoData) { - j.logger.Debug("journal entry not found", mzap.ObjRef("entry_ref", entryRef)) + j.logger.Debug("Journal entry not found", mzap.ObjRef("entry_ref", entryRef)) return nil, storage.ErrJournalEntryNotFound } - j.logger.Warn("failed to get journal entry", zap.Error(err), mzap.ObjRef("entry_ref", entryRef)) + j.logger.Warn("Failed to get journal entry", zap.Error(err), mzap.ObjRef("entry_ref", entryRef)) return nil, err } - j.logger.Debug("journal entry loaded", mzap.ObjRef("entry_ref", entryRef), + j.logger.Debug("Journal entry loaded", mzap.ObjRef("entry_ref", entryRef), zap.String("idempotency_key", result.IdempotencyKey)) return result, nil } func (j *journalEntriesStore) GetByIdempotencyKey(ctx context.Context, orgRef bson.ObjectID, idempotencyKey string) (*model.JournalEntry, error) { if orgRef.IsZero() { - j.logger.Warn("attempt to get journal entry with zero organization ID") + j.logger.Warn("Attempt to get journal entry with zero organization ID") return nil, merrors.InvalidArgument("journalEntriesStore: zero organization ID") } if idempotencyKey == "" { - j.logger.Warn("attempt to get journal entry with empty idempotency key") + j.logger.Warn("Attempt to get journal entry with empty idempotency key") return nil, merrors.InvalidArgument("journalEntriesStore: empty idempotency key") } @@ -116,21 +116,21 @@ func (j *journalEntriesStore) GetByIdempotencyKey(ctx context.Context, orgRef bs result := &model.JournalEntry{} if err := j.repo.FindOneByFilter(ctx, query, result); err != nil { if errors.Is(err, merrors.ErrNoData) { - j.logger.Debug("journal entry not found by idempotency key", zap.String("idempotency_key", idempotencyKey)) + j.logger.Debug("Journal entry not found by idempotency key", zap.String("idempotency_key", idempotencyKey)) return nil, storage.ErrJournalEntryNotFound } - j.logger.Warn("failed to get journal entry by idempotency key", zap.Error(err), + j.logger.Warn("Failed to get journal entry by idempotency key", zap.Error(err), zap.String("idempotency_key", idempotencyKey)) return nil, err } - j.logger.Debug("journal entry loaded by idempotency key", zap.String("idempotency_key", idempotencyKey)) + j.logger.Debug("Journal entry loaded by idempotency key", zap.String("idempotency_key", idempotencyKey)) return result, nil } func (j *journalEntriesStore) ListByOrganization(ctx context.Context, orgRef bson.ObjectID, limit int, offset int) ([]*model.JournalEntry, error) { if orgRef.IsZero() { - j.logger.Warn("attempt to list journal entries with zero organization ID") + j.logger.Warn("Attempt to list journal entries with zero organization ID") return nil, merrors.InvalidArgument("journalEntriesStore: zero organization ID") } @@ -152,10 +152,10 @@ func (j *journalEntriesStore) ListByOrganization(ctx context.Context, orgRef bso return nil }) if err != nil { - j.logger.Warn("failed to list journal entries", zap.Error(err)) + j.logger.Warn("Failed to list journal entries", zap.Error(err)) return nil, err } - j.logger.Debug("listed journal entries", zap.Int("count", len(entries))) + j.logger.Debug("Listed journal entries", zap.Int("count", len(entries))) return entries, nil } diff --git a/api/ledger/storage/mongo/store/outbox.go b/api/ledger/storage/mongo/store/outbox.go index c1048f8b..9170ba47 100644 --- a/api/ledger/storage/mongo/store/outbox.go +++ b/api/ledger/storage/mongo/store/outbox.go @@ -31,7 +31,7 @@ func NewOutbox(logger mlogger.Logger, db *mongo.Database) (storage.OutboxStore, }, } if err := repo.CreateIndex(statusIndex); err != nil { - logger.Error("failed to ensure outbox status index", zap.Error(err)) + logger.Error("Failed to ensure outbox status index", zap.Error(err)) return nil, err } @@ -43,12 +43,12 @@ func NewOutbox(logger mlogger.Logger, db *mongo.Database) (storage.OutboxStore, Unique: true, } if err := repo.CreateIndex(eventIdIndex); err != nil { - logger.Error("failed to ensure outbox eventId index", zap.Error(err)) + logger.Error("Failed to ensure outbox eventId index", zap.Error(err)) return nil, err } childLogger := logger.Named(model.OutboxCollection) - childLogger.Debug("outbox store initialised", zap.String("collection", model.OutboxCollection)) + childLogger.Debug("Outbox store initialised", zap.String("collection", model.OutboxCollection)) return &outboxStore{ logger: childLogger, @@ -58,20 +58,20 @@ func NewOutbox(logger mlogger.Logger, db *mongo.Database) (storage.OutboxStore, func (o *outboxStore) Create(ctx context.Context, event *model.OutboxEvent) error { if event == nil { - o.logger.Warn("attempt to create nil outbox event") + o.logger.Warn("Attempt to create nil outbox event") return merrors.InvalidArgument("outboxStore: nil outbox event") } if err := o.repo.Insert(ctx, event, nil); err != nil { if mongo.IsDuplicateKeyError(err) { - o.logger.Warn("duplicate event ID", zap.String("eventId", event.EventID)) + o.logger.Warn("Duplicate event ID", zap.String("eventId", event.EventID)) return merrors.DataConflict("outbox event with this ID already exists") } - o.logger.Warn("failed to create outbox event", zap.Error(err)) + o.logger.Warn("Failed to create outbox event", zap.Error(err)) return err } - o.logger.Debug("outbox event created", zap.String("eventId", event.EventID), + o.logger.Debug("Outbox event created", zap.String("eventId", event.EventID), zap.String("subject", event.Subject)) return nil } @@ -93,17 +93,17 @@ func (o *outboxStore) ListPending(ctx context.Context, limit int) ([]*model.Outb return nil }) if err != nil { - o.logger.Warn("failed to list pending outbox events", zap.Error(err)) + o.logger.Warn("Failed to list pending outbox events", zap.Error(err)) return nil, err } - o.logger.Debug("listed pending outbox events", zap.Int("count", len(events))) + o.logger.Debug("Listed pending outbox events", zap.Int("count", len(events))) return events, nil } func (o *outboxStore) MarkSent(ctx context.Context, eventRef bson.ObjectID, sentAt time.Time) error { if eventRef.IsZero() { - o.logger.Warn("attempt to mark sent with zero event ID") + o.logger.Warn("Attempt to mark sent with zero event ID") return merrors.InvalidArgument("outboxStore: zero event ID") } @@ -112,44 +112,44 @@ func (o *outboxStore) MarkSent(ctx context.Context, eventRef bson.ObjectID, sent Set(repository.Field("sentAt"), sentAt) if err := o.repo.Patch(ctx, eventRef, patch); err != nil { - o.logger.Warn("failed to mark outbox event as sent", zap.Error(err), zap.String("eventRef", eventRef.Hex())) + o.logger.Warn("Failed to mark outbox event as sent", zap.Error(err), zap.String("eventRef", eventRef.Hex())) return err } - o.logger.Debug("outbox event marked as sent", zap.String("eventRef", eventRef.Hex())) + o.logger.Debug("Outbox event marked as sent", zap.String("eventRef", eventRef.Hex())) return nil } func (o *outboxStore) MarkFailed(ctx context.Context, eventRef bson.ObjectID) error { if eventRef.IsZero() { - o.logger.Warn("attempt to mark failed with zero event ID") + o.logger.Warn("Attempt to mark failed with zero event ID") return merrors.InvalidArgument("outboxStore: zero event ID") } patch := repository.Patch().Set(repository.Field("status"), model.OutboxStatusFailed) if err := o.repo.Patch(ctx, eventRef, patch); err != nil { - o.logger.Warn("failed to mark outbox event as failed", zap.Error(err), zap.String("eventRef", eventRef.Hex())) + o.logger.Warn("Failed to mark outbox event as failed", zap.Error(err), zap.String("eventRef", eventRef.Hex())) return err } - o.logger.Debug("outbox event marked as failed", zap.String("eventRef", eventRef.Hex())) + o.logger.Debug("Outbox event marked as failed", zap.String("eventRef", eventRef.Hex())) return nil } func (o *outboxStore) IncrementAttempts(ctx context.Context, eventRef bson.ObjectID) error { if eventRef.IsZero() { - o.logger.Warn("attempt to increment attempts with zero event ID") + o.logger.Warn("Attempt to increment attempts with zero event ID") return merrors.InvalidArgument("outboxStore: zero event ID") } patch := repository.Patch().Inc(repository.Field("attempts"), 1) if err := o.repo.Patch(ctx, eventRef, patch); err != nil { - o.logger.Warn("failed to increment outbox attempts", zap.Error(err), zap.String("eventRef", eventRef.Hex())) + o.logger.Warn("Failed to increment outbox attempts", zap.Error(err), zap.String("eventRef", eventRef.Hex())) return err } - o.logger.Debug("outbox attempts incremented", zap.String("eventRef", eventRef.Hex())) + o.logger.Debug("Outbox attempts incremented", zap.String("eventRef", eventRef.Hex())) return nil } diff --git a/api/ledger/storage/mongo/store/posting_lines.go b/api/ledger/storage/mongo/store/posting_lines.go index 03e215f9..cf19e9ee 100644 --- a/api/ledger/storage/mongo/store/posting_lines.go +++ b/api/ledger/storage/mongo/store/posting_lines.go @@ -31,7 +31,7 @@ func NewPostingLines(logger mlogger.Logger, db *mongo.Database) (storage.Posting }, } if err := repo.CreateIndex(entryIndex); err != nil { - logger.Error("failed to ensure posting lines entry index", zap.Error(err)) + logger.Error("Failed to ensure posting lines entry index", zap.Error(err)) return nil, err } @@ -43,12 +43,12 @@ func NewPostingLines(logger mlogger.Logger, db *mongo.Database) (storage.Posting }, } if err := repo.CreateIndex(accountIndex); err != nil { - logger.Error("failed to ensure posting lines account index", zap.Error(err)) + logger.Error("Failed to ensure posting lines account index", zap.Error(err)) return nil, err } childLogger := logger.Named(model.PostingLinesCollection) - childLogger.Debug("posting lines store initialised", zap.String("collection", model.PostingLinesCollection)) + childLogger.Debug("Posting lines store initialised", zap.String("collection", model.PostingLinesCollection)) return &postingLinesStore{ logger: childLogger, @@ -58,31 +58,31 @@ func NewPostingLines(logger mlogger.Logger, db *mongo.Database) (storage.Posting func (p *postingLinesStore) CreateMany(ctx context.Context, lines []*model.PostingLine) error { if len(lines) == 0 { - p.logger.Warn("attempt to create empty posting lines array") + p.logger.Warn("Attempt to create empty posting lines array") return nil } storables := make([]storable.Storable, len(lines)) for i, line := range lines { if line == nil { - p.logger.Warn("attempt to create nil posting line") + p.logger.Warn("Attempt to create nil posting line") return merrors.InvalidArgument("postingLinesStore: nil posting line") } storables[i] = line } if err := p.repo.InsertMany(ctx, storables); err != nil { - p.logger.Warn("failed to create posting lines", zap.Error(err), zap.Int("count", len(lines))) + p.logger.Warn("Failed to create posting lines", zap.Error(err), zap.Int("count", len(lines))) return err } - p.logger.Debug("posting lines created", zap.Int("count", len(lines))) + p.logger.Debug("Posting lines created", zap.Int("count", len(lines))) return nil } func (p *postingLinesStore) ListByJournalEntry(ctx context.Context, entryRef bson.ObjectID) ([]*model.PostingLine, error) { if entryRef.IsZero() { - p.logger.Warn("attempt to list posting lines with zero entry ID") + p.logger.Warn("Attempt to list posting lines with zero entry ID") return nil, merrors.InvalidArgument("postingLinesStore: zero entry ID") } @@ -98,17 +98,17 @@ func (p *postingLinesStore) ListByJournalEntry(ctx context.Context, entryRef bso return nil }) if err != nil { - p.logger.Warn("failed to list posting lines by entry", zap.Error(err), mzap.ObjRef("entry_ref", entryRef)) + p.logger.Warn("Failed to list posting lines by entry", zap.Error(err), mzap.ObjRef("entry_ref", entryRef)) return nil, err } - p.logger.Debug("listed posting lines by entry", zap.Int("count", len(lines)), mzap.ObjRef("entry_ref", entryRef)) + p.logger.Debug("Listed posting lines by entry", zap.Int("count", len(lines)), mzap.ObjRef("entry_ref", entryRef)) return lines, nil } func (p *postingLinesStore) ListByAccount(ctx context.Context, accountRef bson.ObjectID, limit int, offset int) ([]*model.PostingLine, error) { if accountRef.IsZero() { - p.logger.Warn("attempt to list posting lines with zero account ID") + p.logger.Warn("Attempt to list posting lines with zero account ID") return nil, merrors.InvalidArgument("postingLinesStore: zero account ID") } @@ -130,10 +130,10 @@ func (p *postingLinesStore) ListByAccount(ctx context.Context, accountRef bson.O return nil }) if err != nil { - p.logger.Warn("failed to list posting lines by account", zap.Error(err), mzap.AccRef(accountRef)) + p.logger.Warn("Failed to list posting lines by account", zap.Error(err), mzap.AccRef(accountRef)) return nil, err } - p.logger.Debug("listed posting lines by account", zap.Int("count", len(lines)), mzap.AccRef(accountRef)) + p.logger.Debug("Listed posting lines by account", zap.Int("count", len(lines)), mzap.AccRef(accountRef)) return lines, nil } diff --git a/api/notification/go.mod b/api/notification/go.mod index 7766b98c..eda25add 100644 --- a/api/notification/go.mod +++ b/api/notification/go.mod @@ -50,7 +50,7 @@ require ( golang.org/x/net v0.50.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect google.golang.org/grpc v1.79.1 // indirect google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/api/notification/go.sum b/api/notification/go.sum index 1e2c6e6b..400caa47 100644 --- a/api/notification/go.sum +++ b/api/notification/go.sum @@ -225,8 +225,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/payments/methods/client/client.go b/api/payments/methods/client/client.go index b87d0564..6434616d 100644 --- a/api/payments/methods/client/client.go +++ b/api/payments/methods/client/client.go @@ -18,6 +18,7 @@ import ( type Client interface { CreatePaymentMethod(ctx context.Context, req *methodsv1.CreatePaymentMethodRequest) (*methodsv1.CreatePaymentMethodResponse, error) GetPaymentMethod(ctx context.Context, req *methodsv1.GetPaymentMethodRequest) (*methodsv1.GetPaymentMethodResponse, error) + GetPaymentMethodPrivate(ctx context.Context, req *methodsv1.GetPaymentMethodPrivateRequest) (*methodsv1.GetPaymentMethodPrivateResponse, error) UpdatePaymentMethod(ctx context.Context, req *methodsv1.UpdatePaymentMethodRequest) (*methodsv1.UpdatePaymentMethodResponse, error) DeletePaymentMethod(ctx context.Context, req *methodsv1.DeletePaymentMethodRequest) (*methodsv1.DeletePaymentMethodResponse, error) SetPaymentMethodArchived(ctx context.Context, req *methodsv1.SetPaymentMethodArchivedRequest) (*methodsv1.SetPaymentMethodArchivedResponse, error) @@ -28,6 +29,7 @@ type Client interface { type grpcPaymentMethodsClient interface { CreatePaymentMethod(ctx context.Context, in *methodsv1.CreatePaymentMethodRequest, opts ...grpc.CallOption) (*methodsv1.CreatePaymentMethodResponse, error) GetPaymentMethod(ctx context.Context, in *methodsv1.GetPaymentMethodRequest, opts ...grpc.CallOption) (*methodsv1.GetPaymentMethodResponse, error) + GetPaymentMethodPrivate(ctx context.Context, in *methodsv1.GetPaymentMethodPrivateRequest, opts ...grpc.CallOption) (*methodsv1.GetPaymentMethodPrivateResponse, error) UpdatePaymentMethod(ctx context.Context, in *methodsv1.UpdatePaymentMethodRequest, opts ...grpc.CallOption) (*methodsv1.UpdatePaymentMethodResponse, error) DeletePaymentMethod(ctx context.Context, in *methodsv1.DeletePaymentMethodRequest, opts ...grpc.CallOption) (*methodsv1.DeletePaymentMethodResponse, error) SetPaymentMethodArchived(ctx context.Context, in *methodsv1.SetPaymentMethodArchivedRequest, opts ...grpc.CallOption) (*methodsv1.SetPaymentMethodArchivedResponse, error) @@ -106,6 +108,12 @@ func (c *paymentMethodsClient) GetPaymentMethod(ctx context.Context, req *method return c.client.GetPaymentMethod(callCtx, req) } +func (c *paymentMethodsClient) GetPaymentMethodPrivate(ctx context.Context, req *methodsv1.GetPaymentMethodPrivateRequest) (*methodsv1.GetPaymentMethodPrivateResponse, error) { + callCtx, cancel := c.callContext(ctx) + defer cancel() + return c.client.GetPaymentMethodPrivate(callCtx, req) +} + func (c *paymentMethodsClient) UpdatePaymentMethod(ctx context.Context, req *methodsv1.UpdatePaymentMethodRequest) (*methodsv1.UpdatePaymentMethodResponse, error) { callCtx, cancel := c.callContext(ctx) defer cancel() diff --git a/api/payments/methods/go.mod b/api/payments/methods/go.mod index 2980ab15..0be5e3b3 100644 --- a/api/payments/methods/go.mod +++ b/api/payments/methods/go.mod @@ -48,5 +48,5 @@ require ( golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect ) diff --git a/api/payments/methods/go.sum b/api/payments/methods/go.sum index 3552c709..a7714150 100644 --- a/api/payments/methods/go.sum +++ b/api/payments/methods/go.sum @@ -208,8 +208,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/payments/methods/internal/server/internal/serverimp.go b/api/payments/methods/internal/server/internal/serverimp.go index e52563c3..e5e4b0db 100644 --- a/api/payments/methods/internal/server/internal/serverimp.go +++ b/api/payments/methods/internal/server/internal/serverimp.go @@ -51,7 +51,7 @@ func (i *Imp) Start() error { permissionsDB := cfg.PermissionsDatabase if permissionsDB == nil { - i.logger.Info("permissions_database is not configured, falling back to database settings") + i.logger.Info("Permissions_database is not configured, falling back to database settings") permissionsDB = cfg.Database } diff --git a/api/payments/methods/internal/service/methods/get_private.go b/api/payments/methods/internal/service/methods/get_private.go new file mode 100644 index 00000000..67b2abeb --- /dev/null +++ b/api/payments/methods/internal/service/methods/get_private.go @@ -0,0 +1,168 @@ +package methods + +import ( + "context" + + "github.com/tech/sendico/pkg/merrors" + pkgmodel "github.com/tech/sendico/pkg/model" + methodsv1 "github.com/tech/sendico/pkg/proto/payments/methods/v1" + "go.mongodb.org/mongo-driver/v2/bson" +) + +const ( + paymentTypeAccount pkgmodel.PaymentType = 8 + maxPrivateMethodResolutionDepth = 8 +) + +func (s *Service) GetPaymentMethodPrivate(ctx context.Context, req *methodsv1.GetPaymentMethodPrivateRequest) (*methodsv1.GetPaymentMethodPrivateResponse, error) { + if req == nil { + return autoError[methodsv1.GetPaymentMethodPrivateResponse](ctx, s.logger, merrors.InvalidArgument("request is required")) + } + if s.pmstore == nil { + return autoError[methodsv1.GetPaymentMethodPrivateResponse](ctx, s.logger, errStoreUnavailable) + } + + if req.GetEndpoint() == methodsv1.PrivateEndpoint_PRIVATE_ENDPOINT_UNSPECIFIED { + return autoError[methodsv1.GetPaymentMethodPrivateResponse](ctx, s.logger, merrors.InvalidArgument("endpoint is required", "endpoint")) + } + + organizationRef, err := parseObjectID(req.GetOrganizationRef(), "organization_ref") + if err != nil { + return autoError[methodsv1.GetPaymentMethodPrivateResponse](ctx, s.logger, err) + } + + var resolved *pkgmodel.PaymentMethod + switch req.GetSelector().(type) { + case *methodsv1.GetPaymentMethodPrivateRequest_PaymentMethodRef: + methodRef, err := parseObjectID(req.GetPaymentMethodRef(), "payment_method_ref") + if err != nil { + return autoError[methodsv1.GetPaymentMethodPrivateResponse](ctx, s.logger, err) + } + resolved, err = s.resolvePrivateByMethodRef(ctx, organizationRef, methodRef, req.GetEndpoint(), 0) + if err != nil { + return autoError[methodsv1.GetPaymentMethodPrivateResponse](ctx, s.logger, err) + } + case *methodsv1.GetPaymentMethodPrivateRequest_PayeeRef: + payeeRef, err := parseObjectID(req.GetPayeeRef(), "payee_ref") + if err != nil { + return autoError[methodsv1.GetPaymentMethodPrivateResponse](ctx, s.logger, err) + } + resolved, err = s.resolvePrivateByRecipientRef(ctx, organizationRef, payeeRef, req.GetEndpoint(), 0) + if err != nil { + return autoError[methodsv1.GetPaymentMethodPrivateResponse](ctx, s.logger, err) + } + default: + return autoError[methodsv1.GetPaymentMethodPrivateResponse](ctx, s.logger, merrors.InvalidArgument( + "selector must include payment_method_ref or payee_ref", + "selector", + )) + } + + record, err := encodePaymentMethodRecord(resolved) + if err != nil { + return autoError[methodsv1.GetPaymentMethodPrivateResponse](ctx, s.logger, err) + } + + return &methodsv1.GetPaymentMethodPrivateResponse{ + PaymentMethodRecord: record, + }, nil +} + +func (s *Service) resolvePrivateByMethodRef( + ctx context.Context, + organizationRef bson.ObjectID, + methodRef bson.ObjectID, + endpoint methodsv1.PrivateEndpoint, + depth int, +) (*pkgmodel.PaymentMethod, error) { + method, err := s.pmstore.GetPrivate(ctx, methodRef) + if err != nil { + return nil, err + } + return s.resolvePrivateMethod(ctx, organizationRef, method, endpoint, depth) +} + +func (s *Service) resolvePrivateByRecipientRef( + ctx context.Context, + organizationRef bson.ObjectID, + recipientRef bson.ObjectID, + endpoint methodsv1.PrivateEndpoint, + depth int, +) (*pkgmodel.PaymentMethod, error) { + items, err := s.pmstore.ListPrivate(ctx, organizationRef, recipientRef, nil) + if err != nil { + return nil, err + } + if len(items) == 0 { + return nil, merrors.InvalidArgument("no payment methods available for recipient") + } + + selected := pickPreferredPrivateMethod(items, endpoint) + if selected == nil { + return nil, merrors.InvalidArgument("no routable payment methods available for recipient") + } + return s.resolvePrivateMethod(ctx, organizationRef, selected, endpoint, depth) +} + +func (s *Service) resolvePrivateMethod( + ctx context.Context, + organizationRef bson.ObjectID, + method *pkgmodel.PaymentMethod, + endpoint methodsv1.PrivateEndpoint, + depth int, +) (*pkgmodel.PaymentMethod, error) { + if method == nil { + return nil, merrors.InvalidArgument("payment method is required") + } + if depth >= maxPrivateMethodResolutionDepth { + return nil, merrors.InvalidArgument("payment method resolution depth exceeded") + } + + if methodIsAccount(method) { + if method.RecipientRef.IsZero() { + return nil, merrors.InvalidArgument("account payment method recipient_ref is required") + } + return s.resolvePrivateByRecipientRef(ctx, organizationRef, method.RecipientRef, endpoint, depth+1) + } + return method, nil +} + +func methodIsAccount(method *pkgmodel.PaymentMethod) bool { + if method == nil { + return false + } + return method.Type == paymentTypeAccount +} + +func pickPreferredPrivateMethod(items []pkgmodel.PaymentMethod, endpoint methodsv1.PrivateEndpoint) *pkgmodel.PaymentMethod { + switch endpoint { + case methodsv1.PrivateEndpoint_PRIVATE_ENDPOINT_SOURCE: + return pickMainThenAnyNonAccount(items) + case methodsv1.PrivateEndpoint_PRIVATE_ENDPOINT_DESTINATION: + return pickMainThenAnyNonAccount(items) + default: + return nil + } +} + +func pickMainThenAnyNonAccount(items []pkgmodel.PaymentMethod) *pkgmodel.PaymentMethod { + for i := range items { + if items[i].IsMain && !methodIsAccount(&items[i]) { + return &items[i] + } + } + for i := range items { + if !methodIsAccount(&items[i]) { + return &items[i] + } + } + for i := range items { + if items[i].IsMain { + return &items[i] + } + } + if len(items) == 0 { + return nil + } + return &items[0] +} diff --git a/api/payments/orchestrator/go.mod b/api/payments/orchestrator/go.mod index 97632f98..4cc03bef 100644 --- a/api/payments/orchestrator/go.mod +++ b/api/payments/orchestrator/go.mod @@ -64,5 +64,5 @@ require ( golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect ) diff --git a/api/payments/orchestrator/go.sum b/api/payments/orchestrator/go.sum index db932601..e65b798e 100644 --- a/api/payments/orchestrator/go.sum +++ b/api/payments/orchestrator/go.sum @@ -211,8 +211,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/payments/orchestrator/internal/service/execution/payment_plan_analyzer.go b/api/payments/orchestrator/internal/service/execution/payment_plan_analyzer.go index 2e1b646a..ea667542 100644 --- a/api/payments/orchestrator/internal/service/execution/payment_plan_analyzer.go +++ b/api/payments/orchestrator/internal/service/execution/payment_plan_analyzer.go @@ -49,7 +49,7 @@ func stepLiveness( pStep, ok := pStepIdx[step.Code] if !ok { - logger.Error("step missing in payment plan", + logger.Error("Step missing in payment plan", zap.String("step_id", step.Code), ) return StepDead @@ -58,7 +58,7 @@ func stepLiveness( for _, depID := range pStep.DependsOn { dep := eStepIdx[depID] if dep == nil { - logger.Warn("dependency missing in execution plan", + logger.Warn("Dependency missing in execution plan", zap.String("step_id", step.Code), zap.String("dep_id", depID), ) diff --git a/api/payments/orchestrator/internal/service/orchestrator/card_payout_funding.go b/api/payments/orchestrator/internal/service/orchestrator/card_payout_funding.go index 706e6613..c26b16d6 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/card_payout_funding.go +++ b/api/payments/orchestrator/internal/service/orchestrator/card_payout_funding.go @@ -71,7 +71,7 @@ func (s *Service) submitCardFundingTransfers(ctx context.Context, payment *model } chainClient, _, err := s.resolveChainGatewayClient(ctx, network, intentAmount, actions, instanceID, payment.PaymentRef) if err != nil { - s.logger.Warn("card funding gateway resolution failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef)) + s.logger.Warn("Card funding gateway resolution failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef)) return err } @@ -116,7 +116,7 @@ func (s *Service) submitCardFundingTransfers(ctx context.Context, payment *model EstimatedTotalFee: estimatedTotalFee, }) if err != nil { - s.logger.Warn("card funding gas top-up compute failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef)) + s.logger.Warn("Card funding gas top-up compute failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef)) return err } if computeResp != nil { @@ -211,7 +211,7 @@ func (s *Service) submitCardFundingTransfers(ctx context.Context, payment *model PaymentRef: payment.PaymentRef, }) if gasErr != nil { - s.logger.Warn("card gas top-up transfer failed", zap.Error(gasErr), zap.String("payment_ref", payment.PaymentRef)) + s.logger.Warn("Card gas top-up transfer failed", zap.Error(gasErr), zap.String("payment_ref", payment.PaymentRef)) return gasErr } if gasStep != nil { @@ -252,7 +252,7 @@ func (s *Service) submitCardFundingTransfers(ctx context.Context, payment *model } } if gasStep != nil { - s.logger.Info("card gas top-up transfer submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", gasStep.TransferRef)) + s.logger.Info("Card gas top-up transfer submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", gasStep.TransferRef)) } updateExecutionPlanTotalNetworkFee(plan) } @@ -300,7 +300,7 @@ func (s *Service) submitCardFundingTransfers(ctx context.Context, payment *model feeStep.TransferRef = exec.FeeTransferRef } setExecutionStepStatus(feeStep, model.OperationStateWaiting) - s.logger.Info("card fee transfer submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", exec.FeeTransferRef)) + s.logger.Info("Card fee transfer submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", exec.FeeTransferRef)) } payment.Execution = exec @@ -325,16 +325,16 @@ func (s *Service) estimateTransferNetworkFee(ctx context.Context, client chaincl Amount: cloneProtoMoney(amount), }) if err != nil { - s.logger.Warn("chain gateway fee estimation failed", zap.Error(err), zap.String("source_wallet_ref", sourceWalletRef)) + s.logger.Warn("Chain gateway fee estimation failed", zap.Error(err), zap.String("source_wallet_ref", sourceWalletRef)) return nil, merrors.Internal("chain_gateway_fee_estimation_failed") } if resp == nil { - s.logger.Warn("chain gateway fee estimation returned empty response", zap.String("source_wallet_ref", sourceWalletRef)) + s.logger.Warn("Chain gateway fee estimation returned empty response", zap.String("source_wallet_ref", sourceWalletRef)) return nil, merrors.Internal("chain_gateway_fee_estimation_failed") } fee := resp.GetNetworkFee() if fee == nil || strings.TrimSpace(fee.GetAmount()) == "" || strings.TrimSpace(fee.GetCurrency()) == "" { - s.logger.Warn("chain gateway fee estimation missing network fee", zap.String("source_wallet_ref", sourceWalletRef)) + s.logger.Warn("Chain gateway fee estimation missing network fee", zap.String("source_wallet_ref", sourceWalletRef)) return nil, merrors.Internal("chain_gateway_fee_estimation_failed") } return cloneProtoMoney(fee), nil diff --git a/api/payments/orchestrator/internal/service/orchestrator/card_payout_routes.go b/api/payments/orchestrator/internal/service/orchestrator/card_payout_routes.go index 621e693f..927e1d29 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/card_payout_routes.go +++ b/api/payments/orchestrator/internal/service/orchestrator/card_payout_routes.go @@ -9,7 +9,7 @@ import ( func (s *Service) cardRoute(gateway string) (CardGatewayRoute, error) { if len(s.deps.cardRoutes) == 0 { - s.logger.Warn("card routing not configured", zap.String("gateway", gateway)) + s.logger.Warn("Card routing not configured", zap.String("gateway", gateway)) return CardGatewayRoute{}, merrors.InvalidArgument("card routing not configured") } key := strings.ToLower(strings.TrimSpace(gateway)) @@ -18,11 +18,11 @@ func (s *Service) cardRoute(gateway string) (CardGatewayRoute, error) { } route, ok := s.deps.cardRoutes[key] if !ok { - s.logger.Warn("card routing missing for gateway", zap.String("gateway", key)) + s.logger.Warn("Card routing missing for gateway", zap.String("gateway", key)) return CardGatewayRoute{}, merrors.InvalidArgument("card routing missing for gateway " + key) } if strings.TrimSpace(route.FundingAddress) == "" { - s.logger.Warn("card routing missing funding address", zap.String("gateway", key)) + s.logger.Warn("Card routing missing funding address", zap.String("gateway", key)) return CardGatewayRoute{}, merrors.InvalidArgument("card funding address is required for gateway " + key) } return route, nil diff --git a/api/payments/orchestrator/internal/service/orchestrator/card_payout_submit.go b/api/payments/orchestrator/internal/service/orchestrator/card_payout_submit.go index 4f5d8b9b..343ca1a6 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/card_payout_submit.go +++ b/api/payments/orchestrator/internal/service/orchestrator/card_payout_submit.go @@ -108,7 +108,7 @@ func (s *Service) submitCardPayout(ctx context.Context, operationRef string, pay } resp, err := s.deps.mntx.client.CreateCardTokenPayout(ctx, req) if err != nil { - s.logger.Warn("card token payout failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef)) + s.logger.Warn("Card token payout failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef)) return err } state = resp.GetPayout() @@ -138,7 +138,7 @@ func (s *Service) submitCardPayout(ctx context.Context, operationRef string, pay } resp, err := s.deps.mntx.client.CreateCardPayout(ctx, req) if err != nil { - s.logger.Warn("card payout failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef)) + s.logger.Warn("Card payout failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef)) return err } state = resp.GetPayout() @@ -175,7 +175,7 @@ func (s *Service) submitCardPayout(ctx context.Context, operationRef string, pay updateExecutionPlanTotalNetworkFee(plan) } - s.logger.Info("card payout submitted", zap.String("payment_ref", payment.PaymentRef), + s.logger.Info("Card payout submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("payout_id", exec.CardPayoutRef), zap.String("operation_ref", state.OperationRef)) return nil diff --git a/api/payments/orchestrator/internal/service/orchestrator/gateway_execution_consumer.go b/api/payments/orchestrator/internal/service/orchestrator/gateway_execution_consumer.go index a6065af9..a28b0e93 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/gateway_execution_consumer.go +++ b/api/payments/orchestrator/internal/service/orchestrator/gateway_execution_consumer.go @@ -147,17 +147,17 @@ func updateExecutionStepsFromGatewayExecution( zap.String("gateway_status", string(exec.Status)), ) - log.Debug("gateway execution received") + log.Debug("Gateway execution received") if payment == nil || payment.PaymentPlan == nil || exec == nil { - log.Warn("invalid input: payment/plan/exec is nil") + log.Warn("Invalid input: payment/plan/exec is nil") return paymodel.PaymentStateSubmitted, merrors.DataConflict("payment is missing plan or execution step") } operationRef := strings.TrimSpace(exec.OperationRef) if operationRef == "" { - log.Warn("empty operation_ref from gateway") + log.Warn("Empty operation_ref from gateway") return paymodel.PaymentStateSubmitted, merrors.InvalidArgument("no operation reference provided") } diff --git a/api/payments/orchestrator/internal/service/orchestrator/handlers_commands.go b/api/payments/orchestrator/internal/service/orchestrator/handlers_commands.go index 6a72d9aa..f172b03e 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/handlers_commands.go +++ b/api/payments/orchestrator/internal/service/orchestrator/handlers_commands.go @@ -9,6 +9,7 @@ import ( "github.com/tech/sendico/payments/orchestrator/internal/service/shared" "github.com/tech/sendico/payments/storage" "github.com/tech/sendico/payments/storage/model" + quotestorage "github.com/tech/sendico/payments/storage/quote" "github.com/tech/sendico/pkg/api/routers/gsresponse" "github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/mlogger" @@ -50,7 +51,7 @@ func (h *initiatePaymentsCommand) Execute(ctx context.Context, req *orchestrator } record, err := quotesStore.GetByRef(ctx, orgRef, quoteRef) if err != nil { - if errors.Is(err, storage.ErrQuoteNotFound) { + if errors.Is(err, quotestorage.ErrQuoteNotFound) { return gsresponse.FailedPrecondition[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, "quote_not_found", merrors.InvalidArgument("quote_ref not found or expired")) } return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) diff --git a/api/payments/orchestrator/internal/service/orchestrator/handlers_events.go b/api/payments/orchestrator/internal/service/orchestrator/handlers_events.go index 3513d1d1..17535a63 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/handlers_events.go +++ b/api/payments/orchestrator/internal/service/orchestrator/handlers_events.go @@ -126,12 +126,12 @@ func (h *paymentEventHandler) processTransferUpdate(ctx context.Context, req *or if payment.Execution.CardPayoutRef == "" { payment.State = model.PaymentStateFundsReserved if h.submitCardPayout == nil { - h.logger.Warn("card payout execution skipped", zap.String("payment_ref", payment.PaymentRef)) + h.logger.Warn("Card payout execution skipped", zap.String("payment_ref", payment.PaymentRef)) } else if err := h.submitCardPayout(ctx, transfer.GetOperationRef(), payment); err != nil { payment.State = model.PaymentStateFailed payment.FailureCode = model.PaymentFailureCodePolicy payment.FailureReason = strings.TrimSpace(err.Error()) - h.logger.Warn("card payout execution failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef)) + h.logger.Warn("Card payout execution failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef)) } } } @@ -143,7 +143,7 @@ func (h *paymentEventHandler) processTransferUpdate(ctx context.Context, req *or if err := store.Update(ctx, payment); err != nil { return gsresponse.Auto[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, err) } - h.logger.Info("transfer update applied", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", transferRef), zap.Any("state", payment.State)) + h.logger.Info("Transfer update applied", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", transferRef), zap.Any("state", payment.State)) return gsresponse.Success(&orchestratorv1.ProcessTransferUpdateResponse{Payment: toProtoPayment(payment)}) } @@ -151,7 +151,7 @@ func (h *paymentEventHandler) processTransferUpdate(ctx context.Context, req *or if err := store.Update(ctx, payment); err != nil { return gsresponse.Auto[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, err) } - h.logger.Info("transfer update applied", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", transferRef), zap.Any("state", payment.State)) + h.logger.Info("Transfer update applied", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", transferRef), zap.Any("state", payment.State)) return gsresponse.Success(&orchestratorv1.ProcessTransferUpdateResponse{Payment: toProtoPayment(payment)}) } @@ -198,7 +198,7 @@ func (h *paymentEventHandler) processDepositObserved(ctx context.Context, req *o if err := store.Update(ctx, payment); err != nil { return gsresponse.Auto[orchestratorv1.ProcessDepositObservedResponse](h.logger, mservice.PaymentOrchestrator, err) } - h.logger.Info("deposit observed matched payment", zap.String("payment_ref", payment.PaymentRef), zap.String("wallet_ref", walletRef)) + h.logger.Info("Deposit observed matched payment", zap.String("payment_ref", payment.PaymentRef), zap.String("wallet_ref", walletRef)) return gsresponse.Success(&orchestratorv1.ProcessDepositObservedResponse{Payment: toProtoPayment(payment)}) } return gsresponse.Success(&orchestratorv1.ProcessDepositObservedResponse{}) @@ -231,7 +231,7 @@ func (h *paymentEventHandler) processCardPayoutUpdate(ctx context.Context, req * switch payout.GetStatus() { case mntxv1.PayoutStatus_PAYOUT_STATUS_SUCCESS: - h.logger.Info("card payout success received", + h.logger.Info("Card payout success received", zap.String("payment_ref", payment.PaymentRef), zap.String("payout_ref", payout.GetPayoutId()), zap.String("payment_state_before", string(payment.State)), @@ -241,19 +241,19 @@ func (h *paymentEventHandler) processCardPayoutUpdate(ctx context.Context, req * if h.resumePlan != nil && payment.PaymentPlan != nil && len(payment.PaymentPlan.Steps) > 0 { if err := h.resumePlan(ctx, store, payment); err != nil { - h.logger.Error("resumePlan failed after payout success", + h.logger.Error("ResumePlan failed after payout success", zap.String("payment_ref", payment.PaymentRef), zap.String("payout_ref", payout.GetPayoutId()), zap.Error(err), ) return gsresponse.Auto[orchestratorv1.ProcessCardPayoutUpdateResponse](h.logger, mservice.PaymentOrchestrator, err) } - h.logger.Info("resumePlan executed after payout success", + h.logger.Info("ResumePlan executed after payout success", zap.String("payment_ref", payment.PaymentRef), zap.String("payout_ref", payout.GetPayoutId()), ) } else { - h.logger.Warn("payout success but plan cannot be resumed", + h.logger.Warn("Payout success but plan cannot be resumed", zap.String("payment_ref", payment.PaymentRef), zap.String("payout_ref", payout.GetPayoutId()), zap.Bool("resume_plan_present", h.resumePlan != nil), @@ -262,7 +262,7 @@ func (h *paymentEventHandler) processCardPayoutUpdate(ctx context.Context, req * } case mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED: - h.logger.Warn("card payout failed", + h.logger.Warn("Card payout failed", zap.String("payment_ref", payment.PaymentRef), zap.String("payout_ref", payout.GetPayoutId()), zap.String("provider_message", payout.GetProviderMessage()), @@ -273,13 +273,13 @@ func (h *paymentEventHandler) processCardPayoutUpdate(ctx context.Context, req * payment.FailureReason = strings.TrimSpace(payout.GetProviderMessage()) if h.releaseHold != nil && payment.PaymentPlan != nil && len(payment.PaymentPlan.Steps) > 0 { - h.logger.Info("releasing hold after payout failure", + h.logger.Info("Releasing hold after payout failure", zap.String("payment_ref", payment.PaymentRef), zap.String("payout_ref", payout.GetPayoutId()), ) if err := h.releaseHold(ctx, store, payment); err != nil { - h.logger.Error("releaseHold failed after payout failure", + h.logger.Error("ReleaseHold failed after payout failure", zap.String("payment_ref", payment.PaymentRef), zap.String("payout_ref", payout.GetPayoutId()), zap.Error(err), @@ -287,7 +287,7 @@ func (h *paymentEventHandler) processCardPayoutUpdate(ctx context.Context, req * return gsresponse.Auto[orchestratorv1.ProcessCardPayoutUpdateResponse](h.logger, mservice.PaymentOrchestrator, err) } } else { - h.logger.Warn("payout failed but hold cannot be released", + h.logger.Warn("Payout failed but hold cannot be released", zap.String("payment_ref", payment.PaymentRef), zap.String("payout_ref", payout.GetPayoutId()), zap.Bool("release_hold_present", h.releaseHold != nil), @@ -300,7 +300,7 @@ func (h *paymentEventHandler) processCardPayoutUpdate(ctx context.Context, req * return gsresponse.Auto[orchestratorv1.ProcessCardPayoutUpdateResponse](h.logger, mservice.PaymentOrchestrator, err) } - h.logger.Info("card payout update applied", zap.String("payment_ref", payment.PaymentRef), zap.String("payout_id", paymentRef), zap.Any("state", payment.State)) + h.logger.Info("Card payout update applied", zap.String("payment_ref", payment.PaymentRef), zap.String("payout_id", paymentRef), zap.Any("state", payment.State)) return gsresponse.Success(&orchestratorv1.ProcessCardPayoutUpdateResponse{ Payment: toProtoPayment(payment), }) diff --git a/api/payments/orchestrator/internal/service/orchestrator/handlers_queries.go b/api/payments/orchestrator/internal/service/orchestrator/handlers_queries.go index 9604a49e..b190b7f2 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/handlers_queries.go +++ b/api/payments/orchestrator/internal/service/orchestrator/handlers_queries.go @@ -47,7 +47,7 @@ func (h *paymentQueryHandler) getPayment(ctx context.Context, req *orchestratorv if err != nil { return paymentNotFoundResponder[orchestratorv1.GetPaymentResponse](mservice.PaymentOrchestrator, h.logger, err) } - h.logger.Debug("payment fetched", zap.String("payment_ref", paymentRef)) + h.logger.Debug("Payment fetched", zap.String("payment_ref", paymentRef)) return gsresponse.Success(&orchestratorv1.GetPaymentResponse{Payment: toProtoPayment(entity)}) } @@ -76,6 +76,6 @@ func (h *paymentQueryHandler) listPayments(ctx context.Context, req *orchestrato for _, item := range result.Items { resp.Payments = append(resp.Payments, toProtoPayment(item)) } - h.logger.Debug("payments listed", zap.Int("count", len(resp.Payments)), zap.String("next_cursor", resp.GetPage().GetNextCursor())) + h.logger.Debug("Payments listed", zap.Int("count", len(resp.Payments)), zap.String("next_cursor", resp.GetPage().GetNextCursor())) return gsresponse.Success(resp) } diff --git a/api/payments/orchestrator/internal/service/orchestrator/service_helpers.go b/api/payments/orchestrator/internal/service/orchestrator/service_helpers.go index f53833b9..0b84a04f 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/service_helpers.go +++ b/api/payments/orchestrator/internal/service/orchestrator/service_helpers.go @@ -116,7 +116,7 @@ func (s *Service) resolvePaymentQuote(ctx context.Context, in quoteResolutionInp } record, err := quotesStore.GetByRef(ctx, in.OrgID, ref) if err != nil { - if errors.Is(err, storage.ErrQuoteNotFound) { + if errors.Is(err, quotestorage.ErrQuoteNotFound) { return nil, nil, nil, quoteResolutionError{code: "quote_not_found", err: merrors.InvalidArgument("quote_ref not found or expired")} } return nil, nil, nil, err diff --git a/api/payments/orchestrator/internal/service/orchestrator/service_helpers_test.go b/api/payments/orchestrator/internal/service/orchestrator/service_helpers_test.go index 7eac6bb5..2f0208d1 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/service_helpers_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/service_helpers_test.go @@ -477,17 +477,17 @@ func (s *helperQuotesStore) Create(_ context.Context, _ *model.PaymentQuoteRecor func (s *helperQuotesStore) GetByRef(_ context.Context, _ bson.ObjectID, ref string) (*model.PaymentQuoteRecord, error) { if s.records == nil { - return nil, storage.ErrQuoteNotFound + return nil, quotestorage.ErrQuoteNotFound } if rec, ok := s.records[ref]; ok { return rec, nil } - return nil, storage.ErrQuoteNotFound + return nil, quotestorage.ErrQuoteNotFound } func (s *helperQuotesStore) GetByIdempotencyKey(_ context.Context, orgRef bson.ObjectID, ref string) (*model.PaymentQuoteRecord, error) { if s.records == nil { - return nil, storage.ErrQuoteNotFound + return nil, quotestorage.ErrQuoteNotFound } for _, rec := range s.records { if rec.OrganizationRef != orgRef { @@ -497,7 +497,7 @@ func (s *helperQuotesStore) GetByIdempotencyKey(_ context.Context, orgRef bson.O return rec, nil } } - return nil, storage.ErrQuoteNotFound + return nil, quotestorage.ErrQuoteNotFound } type helperQuotationClient struct { diff --git a/api/payments/orchestrator/internal/service/orchestrator/service_test.go b/api/payments/orchestrator/internal/service/orchestrator/service_test.go index c2f9caa8..ff2384c8 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/service_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/service_test.go @@ -447,17 +447,17 @@ func (s *stubQuotesStore) Create(ctx context.Context, quote *model.PaymentQuoteR func (s *stubQuotesStore) GetByRef(ctx context.Context, orgRef bson.ObjectID, quoteRef string) (*model.PaymentQuoteRecord, error) { if s.quotes == nil { - return nil, storage.ErrQuoteNotFound + return nil, quotestorage.ErrQuoteNotFound } if q, ok := s.quotes[strings.TrimSpace(quoteRef)]; ok { return q, nil } - return nil, storage.ErrQuoteNotFound + return nil, quotestorage.ErrQuoteNotFound } func (s *stubQuotesStore) GetByIdempotencyKey(ctx context.Context, orgRef bson.ObjectID, idempotencyKey string) (*model.PaymentQuoteRecord, error) { if s.quotes == nil { - return nil, storage.ErrQuoteNotFound + return nil, quotestorage.ErrQuoteNotFound } for _, q := range s.quotes { if q.OrganizationRef != orgRef { @@ -467,7 +467,7 @@ func (s *stubQuotesStore) GetByIdempotencyKey(ctx context.Context, orgRef bson.O return q, nil } } - return nil, storage.ErrQuoteNotFound + return nil, quotestorage.ErrQuoteNotFound } type stubRoutesStore struct { diff --git a/api/payments/orchestrator/internal/service/plan_builder/default.go b/api/payments/orchestrator/internal/service/plan_builder/default.go index d47d138d..3401e887 100644 --- a/api/payments/orchestrator/internal/service/plan_builder/default.go +++ b/api/payments/orchestrator/internal/service/plan_builder/default.go @@ -47,7 +47,7 @@ func (b *defaultPlanBuilder) Build(ctx context.Context, payment *model.Payment, logger.Warn("Failed to build fx conversion plan", zap.Error(err)) return nil, err } - logger.Info("fx conversion plan built", zap.Int("steps", len(plan.Steps))) + logger.Info("Fx conversion plan built", zap.Int("steps", len(plan.Steps))) return plan, nil } diff --git a/api/payments/orchestrator/internal/service/plan_builder/steps.go b/api/payments/orchestrator/internal/service/plan_builder/steps.go index 664630dd..2068e6e2 100644 --- a/api/payments/orchestrator/internal/service/plan_builder/steps.go +++ b/api/payments/orchestrator/internal/service/plan_builder/steps.go @@ -104,7 +104,7 @@ func (b *defaultPlanBuilder) buildPlanFromTemplate(ctx context.Context, payment action, err := actionForOperation(tpl.Operation) if err != nil { - b.logger.Warn("plan builder: unsupported operation in plan template step", zap.String("step_id", stepID), zap.String("operation", tpl.Operation), zap.Error(err)) + b.logger.Warn("Plan builder: unsupported operation in plan template step", zap.String("step_id", stepID), zap.String("operation", tpl.Operation), zap.Error(err)) return nil, err } diff --git a/api/payments/quotation/go.mod b/api/payments/quotation/go.mod index 8da6fa14..89c1abea 100644 --- a/api/payments/quotation/go.mod +++ b/api/payments/quotation/go.mod @@ -63,5 +63,5 @@ require ( golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect ) diff --git a/api/payments/quotation/go.sum b/api/payments/quotation/go.sum index 470bab37..6978c968 100644 --- a/api/payments/quotation/go.sum +++ b/api/payments/quotation/go.sum @@ -211,8 +211,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/payments/quotation/internal/service/plan/plan_builder_default.go b/api/payments/quotation/internal/service/plan/plan_builder_default.go index 973ba924..ac28fe1a 100644 --- a/api/payments/quotation/internal/service/plan/plan_builder_default.go +++ b/api/payments/quotation/internal/service/plan/plan_builder_default.go @@ -46,7 +46,7 @@ func (b *defaultBuilder) Build(ctx context.Context, payment *model.Payment, quot logger.Warn("Failed to build fx conversion plan", zap.Error(err)) return nil, err } - logger.Info("fx conversion plan built", zap.Int("steps", len(plan.Steps))) + logger.Info("Fx conversion plan built", zap.Int("steps", len(plan.Steps))) return plan, nil } diff --git a/api/payments/quotation/internal/service/plan/plan_builder_steps.go b/api/payments/quotation/internal/service/plan/plan_builder_steps.go index ca97624e..f801178d 100644 --- a/api/payments/quotation/internal/service/plan/plan_builder_steps.go +++ b/api/payments/quotation/internal/service/plan/plan_builder_steps.go @@ -4,7 +4,7 @@ import ( "context" "strings" - "github.com/tech/sendico/payments/quotation/internal/service/shared" + "github.com/tech/sendico/payments/quotation/internal/shared" "github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/mutil/mzap" @@ -105,7 +105,7 @@ func (b *defaultBuilder) buildPlanFromTemplate(ctx context.Context, payment *mod action, err := actionForOperation(tpl.Operation) if err != nil { - b.logger.Warn("plan builder: unsupported operation in plan template step", zap.String("step_id", stepID), zap.String("operation", tpl.Operation), zap.Error(err)) + b.logger.Warn("Plan builder: unsupported operation in plan template step", zap.String("step_id", stepID), zap.String("operation", tpl.Operation), zap.Error(err)) return nil, err } diff --git a/api/payments/quotation/internal/service/quotation/batch_quote_processor_v2/idempotency.go b/api/payments/quotation/internal/service/quotation/batch_quote_processor_v2/idempotency.go new file mode 100644 index 00000000..5ef1697c --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/batch_quote_processor_v2/idempotency.go @@ -0,0 +1,20 @@ +package batch_quote_processor_v2 + +import ( + "fmt" + "strings" +) + +func deriveItemIdempotencyKey(base string, previewOnly bool, index int, total int) string { + if previewOnly { + return "" + } + base = strings.TrimSpace(base) + if base == "" { + return "" + } + if total <= 1 { + return base + } + return fmt.Sprintf("%s:%d", base, index+1) +} diff --git a/api/payments/quotation/internal/service/quotation/batch_quote_processor_v2/input.go b/api/payments/quotation/internal/service/quotation/batch_quote_processor_v2/input.go new file mode 100644 index 00000000..264e85f7 --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/batch_quote_processor_v2/input.go @@ -0,0 +1,53 @@ +package batch_quote_processor_v2 + +import ( + "github.com/tech/sendico/payments/quotation/internal/service/quotation/transfer_intent_hydrator" + quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" + "go.mongodb.org/mongo-driver/v2/bson" +) + +// BatchContext carries normalized request-level parameters for batch processing. +type BatchContext struct { + OrganizationRef string + OrganizationID bson.ObjectID + InitiatorRef string + PreviewOnly bool + BaseIdempotencyKey string +} + +// ProcessInput is the input contract for batch quote processing. +type ProcessInput struct { + Context BatchContext + Intents []*transfer_intent_hydrator.QuoteIntent +} + +// BatchItem is one derived processing unit for a single intent. +type BatchItem struct { + Index int + Count int + IdempotencyKey string + Intent *transfer_intent_hydrator.QuoteIntent +} + +// SingleProcessInput is forwarded to the single-intent processor. +type SingleProcessInput struct { + Context BatchContext + Item BatchItem +} + +// SingleProcessOutput is returned by the single-intent processor. +type SingleProcessOutput struct { + Quote *quotationv2.PaymentQuote +} + +// BatchItemResult is one successful item output. +type BatchItemResult struct { + Item BatchItem + Quote *quotationv2.PaymentQuote +} + +// ProcessOutput is an ordered list of item outputs. +type ProcessOutput struct { + Context BatchContext + Items []*BatchItemResult +} diff --git a/api/payments/quotation/internal/service/quotation/batch_quote_processor_v2/service.go b/api/payments/quotation/internal/service/quotation/batch_quote_processor_v2/service.go new file mode 100644 index 00000000..9e592853 --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/batch_quote_processor_v2/service.go @@ -0,0 +1,111 @@ +package batch_quote_processor_v2 + +import ( + "context" + "fmt" + "strings" + + "github.com/tech/sendico/payments/quotation/internal/service/quotation/transfer_intent_hydrator" + "github.com/tech/sendico/pkg/merrors" +) + +// SingleIntentProcessor processes one intent in v2 flow. +type SingleIntentProcessor interface { + Process(ctx context.Context, in SingleProcessInput) (*SingleProcessOutput, error) +} + +// BatchQuoteProcessorV2 iterates single-intent processing for batch requests. +type BatchQuoteProcessorV2 struct { + single SingleIntentProcessor +} + +func New(single SingleIntentProcessor) *BatchQuoteProcessorV2 { + return &BatchQuoteProcessorV2{single: single} +} + +func (p *BatchQuoteProcessorV2) Process(ctx context.Context, in ProcessInput) (*ProcessOutput, error) { + if p == nil || p.single == nil { + return nil, merrors.InvalidArgument("single processor is required") + } + + normalizedCtx, err := normalizeContext(in.Context) + if err != nil { + return nil, err + } + if len(in.Intents) == 0 { + return nil, merrors.InvalidArgument("intents are required") + } + + items, err := buildBatchItems(normalizedCtx, in.Intents) + if err != nil { + return nil, err + } + + results := make([]*BatchItemResult, 0, len(items)) + for _, item := range items { + res, processErr := p.single.Process(ctx, SingleProcessInput{ + Context: normalizedCtx, + Item: *item, + }) + if processErr != nil { + return nil, fmt.Errorf("intents[%d]: %w", item.Index, processErr) + } + if res == nil || res.Quote == nil { + return nil, merrors.InvalidArgument(fmt.Sprintf("intents[%d]: quote is required", item.Index)) + } + + results = append(results, &BatchItemResult{ + Item: *item, + Quote: res.Quote, + }) + } + + return &ProcessOutput{ + Context: normalizedCtx, + Items: results, + }, nil +} + +func normalizeContext(ctx BatchContext) (BatchContext, error) { + ctx.OrganizationRef = strings.TrimSpace(ctx.OrganizationRef) + ctx.InitiatorRef = strings.TrimSpace(ctx.InitiatorRef) + ctx.BaseIdempotencyKey = strings.TrimSpace(ctx.BaseIdempotencyKey) + + if ctx.OrganizationRef == "" { + return BatchContext{}, merrors.InvalidArgument("organization_ref is required") + } + if ctx.OrganizationID.IsZero() { + return BatchContext{}, merrors.InvalidArgument("organization_id is required") + } + if ctx.InitiatorRef == "" { + return BatchContext{}, merrors.InvalidArgument("initiator_ref is required") + } + if ctx.PreviewOnly && ctx.BaseIdempotencyKey != "" { + return BatchContext{}, merrors.InvalidArgument("preview requests must not use idempotency key") + } + if !ctx.PreviewOnly && ctx.BaseIdempotencyKey == "" { + return BatchContext{}, merrors.InvalidArgument("idempotency key is required") + } + + return ctx, nil +} + +func buildBatchItems(ctx BatchContext, intents []*transfer_intent_hydrator.QuoteIntent) ([]*BatchItem, error) { + items := make([]*BatchItem, 0, len(intents)) + total := len(intents) + + for i, intent := range intents { + if intent == nil { + return nil, merrors.InvalidArgument(fmt.Sprintf("intents[%d] is required", i)) + } + + items = append(items, &BatchItem{ + Index: i, + Count: total, + Intent: intent, + IdempotencyKey: deriveItemIdempotencyKey(ctx.BaseIdempotencyKey, ctx.PreviewOnly, i, total), + }) + } + + return items, nil +} diff --git a/api/payments/quotation/internal/service/quotation/batch_quote_processor_v2/service_test.go b/api/payments/quotation/internal/service/quotation/batch_quote_processor_v2/service_test.go new file mode 100644 index 00000000..d7683fda --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/batch_quote_processor_v2/service_test.go @@ -0,0 +1,201 @@ +package batch_quote_processor_v2 + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/tech/sendico/payments/quotation/internal/service/quotation/transfer_intent_hydrator" + "github.com/tech/sendico/pkg/merrors" + quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func TestProcess_DerivesPerItemIdempotency(t *testing.T) { + processor := &fakeSingleProcessor{} + svc := New(processor) + orgID := bson.NewObjectID() + + out, err := svc.Process(context.Background(), ProcessInput{ + Context: BatchContext{ + OrganizationRef: orgID.Hex(), + OrganizationID: orgID, + InitiatorRef: "initiator-1", + PreviewOnly: false, + BaseIdempotencyKey: "idem-batch", + }, + Intents: []*transfer_intent_hydrator.QuoteIntent{ + {Ref: "i1"}, + {Ref: "i2"}, + }, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out == nil || len(out.Items) != 2 { + t.Fatalf("expected two items") + } + + if got, want := processor.calls[0].Item.IdempotencyKey, "idem-batch:1"; got != want { + t.Fatalf("unexpected first item key: got=%q want=%q", got, want) + } + if got, want := processor.calls[1].Item.IdempotencyKey, "idem-batch:2"; got != want { + t.Fatalf("unexpected second item key: got=%q want=%q", got, want) + } + if out.Items[0].Item.Index != 0 || out.Items[1].Item.Index != 1 { + t.Fatalf("expected stable ordered indices") + } +} + +func TestProcess_SingleIntentKeepsBaseIdempotency(t *testing.T) { + processor := &fakeSingleProcessor{} + svc := New(processor) + orgID := bson.NewObjectID() + + _, err := svc.Process(context.Background(), ProcessInput{ + Context: BatchContext{ + OrganizationRef: orgID.Hex(), + OrganizationID: orgID, + InitiatorRef: "initiator-1", + PreviewOnly: false, + BaseIdempotencyKey: "idem-single", + }, + Intents: []*transfer_intent_hydrator.QuoteIntent{ + {Ref: "i1"}, + }, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got, want := processor.calls[0].Item.IdempotencyKey, "idem-single"; got != want { + t.Fatalf("unexpected idempotency key: got=%q want=%q", got, want) + } +} + +func TestProcess_PreviewUsesEmptyPerItemIdempotency(t *testing.T) { + processor := &fakeSingleProcessor{} + svc := New(processor) + orgID := bson.NewObjectID() + + _, err := svc.Process(context.Background(), ProcessInput{ + Context: BatchContext{ + OrganizationRef: orgID.Hex(), + OrganizationID: orgID, + InitiatorRef: "initiator-1", + PreviewOnly: true, + }, + Intents: []*transfer_intent_hydrator.QuoteIntent{{Ref: "i1"}, {Ref: "i2"}}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if processor.calls[0].Item.IdempotencyKey != "" || processor.calls[1].Item.IdempotencyKey != "" { + t.Fatalf("expected empty idempotency keys for preview") + } +} + +func TestProcess_WrapsItemError(t *testing.T) { + processor := &fakeSingleProcessor{ + errAtIndex: 1, + err: errors.New("boom"), + } + svc := New(processor) + orgID := bson.NewObjectID() + + _, err := svc.Process(context.Background(), ProcessInput{ + Context: BatchContext{ + OrganizationRef: orgID.Hex(), + OrganizationID: orgID, + InitiatorRef: "initiator-1", + PreviewOnly: false, + BaseIdempotencyKey: "idem", + }, + Intents: []*transfer_intent_hydrator.QuoteIntent{{Ref: "i1"}, {Ref: "i2"}}, + }) + if err == nil { + t.Fatalf("expected wrapped error") + } + if !strings.Contains(err.Error(), "intents[1]") { + t.Fatalf("expected indexed error, got %v", err) + } +} + +func TestProcess_Validation(t *testing.T) { + orgID := bson.NewObjectID() + baseInput := ProcessInput{ + Context: BatchContext{ + OrganizationRef: orgID.Hex(), + OrganizationID: orgID, + InitiatorRef: "initiator-1", + PreviewOnly: false, + BaseIdempotencyKey: "idem", + }, + Intents: []*transfer_intent_hydrator.QuoteIntent{{Ref: "i1"}}, + } + + _, err := New(nil).Process(context.Background(), baseInput) + if !errors.Is(err, merrors.ErrInvalidArg) { + t.Fatalf("expected invalid argument for nil single processor, got %v", err) + } + + previewWithIdem := baseInput + previewWithIdem.Context.PreviewOnly = true + _, err = New(&fakeSingleProcessor{}).Process(context.Background(), previewWithIdem) + if !errors.Is(err, merrors.ErrInvalidArg) { + t.Fatalf("expected invalid argument for preview idempotency, got %v", err) + } + + nonPreviewNoIdem := baseInput + nonPreviewNoIdem.Context.BaseIdempotencyKey = "" + _, err = New(&fakeSingleProcessor{}).Process(context.Background(), nonPreviewNoIdem) + if !errors.Is(err, merrors.ErrInvalidArg) { + t.Fatalf("expected invalid argument for missing idempotency, got %v", err) + } + + withNilIntent := baseInput + withNilIntent.Intents = []*transfer_intent_hydrator.QuoteIntent{nil} + _, err = New(&fakeSingleProcessor{}).Process(context.Background(), withNilIntent) + if !errors.Is(err, merrors.ErrInvalidArg) { + t.Fatalf("expected invalid argument for nil intent, got %v", err) + } +} + +func TestProcess_RejectsNilQuoteOutput(t *testing.T) { + svc := New(&fakeSingleProcessor{returnNilQuote: true}) + orgID := bson.NewObjectID() + + _, err := svc.Process(context.Background(), ProcessInput{ + Context: BatchContext{ + OrganizationRef: orgID.Hex(), + OrganizationID: orgID, + InitiatorRef: "initiator-1", + PreviewOnly: false, + BaseIdempotencyKey: "idem", + }, + Intents: []*transfer_intent_hydrator.QuoteIntent{{Ref: "i1"}}, + }) + if !errors.Is(err, merrors.ErrInvalidArg) { + t.Fatalf("expected invalid argument for nil quote output, got %v", err) + } +} + +type fakeSingleProcessor struct { + calls []SingleProcessInput + errAtIndex int + err error + returnNilQuote bool +} + +func (f *fakeSingleProcessor) Process(_ context.Context, in SingleProcessInput) (*SingleProcessOutput, error) { + f.calls = append(f.calls, in) + if f.err != nil && in.Item.Index == f.errAtIndex { + return nil, f.err + } + if f.returnNilQuote { + return &SingleProcessOutput{}, nil + } + return &SingleProcessOutput{ + Quote: "ationv2.PaymentQuote{QuoteRef: in.Item.Intent.Ref}, + }, nil +} diff --git a/api/payments/quotation/internal/service/quotation/gateway_funding_profile/funding_gate_builder.go b/api/payments/quotation/internal/service/quotation/gateway_funding_profile/funding_gate_builder.go new file mode 100644 index 00000000..0d656186 --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/gateway_funding_profile/funding_gate_builder.go @@ -0,0 +1,181 @@ +package gateway_funding_profile + +import ( + "strings" + + "github.com/tech/sendico/payments/quotation/internal/shared" + "github.com/tech/sendico/payments/storage/model" + "github.com/tech/sendico/pkg/merrors" + paymenttypes "github.com/tech/sendico/pkg/payments/types" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" +) + +func BuildFundingGateFromProfile( + profile *GatewayFundingProfile, + requiredAmount *moneyv1.Money, +) (*QuoteFundingGate, error) { + if profile == nil { + return nil, nil + } + + mode := shared.NormalizeFundingMode(profile.Mode) + gate := &QuoteFundingGate{ + Mode: mode, + RequiredAmount: cloneProtoMoney(requiredAmount), + Funding: clonePaymentEndpoint(profile.FundingDestination), + Fee: clonePaymentEndpoint(profile.FeeDestination), + Reserve: cloneReservePolicy(profile.Reserve), + DepositCheck: cloneDepositCheck(profile.DepositCheck), + } + + switch mode { + case model.FundingModeNone: + return gate, nil + + case model.FundingModeBalanceReserve: + if gate.Reserve == nil { + return nil, merrors.InvalidArgument("funding profile: reserve policy is required for balance_reserve") + } + hasBlock := strings.TrimSpace(gate.Reserve.BlockAccountRef) != "" + hasRoles := gate.Reserve.FromRole != nil && gate.Reserve.ToRole != nil + if !hasBlock && !hasRoles { + return nil, merrors.InvalidArgument("funding profile: block account or reserve roles are required for balance_reserve") + } + if gate.RequiredAmount == nil { + return nil, merrors.InvalidArgument("funding profile: required amount is required for balance_reserve") + } + return gate, nil + + case model.FundingModeDepositObserved: + if gate.DepositCheck == nil { + return nil, merrors.InvalidArgument("funding profile: deposit check policy is required for deposit_observed") + } + if strings.TrimSpace(gate.DepositCheck.WalletRef) == "" { + return nil, merrors.InvalidArgument("funding profile: deposit wallet_ref is required for deposit_observed") + } + if gate.DepositCheck.ExpectedAmount == nil { + gate.DepositCheck.ExpectedAmount = cloneProtoMoney(requiredAmount) + } + if gate.DepositCheck.ExpectedAmount == nil { + return nil, merrors.InvalidArgument("funding profile: deposit expected_amount is required for deposit_observed") + } + return gate, nil + + default: + return nil, merrors.InvalidArgument("funding profile: mode is required") + } +} + +func cloneProtoMoney(src *moneyv1.Money) *moneyv1.Money { + if src == nil { + return nil + } + return &moneyv1.Money{ + Amount: strings.TrimSpace(src.GetAmount()), + Currency: strings.TrimSpace(src.GetCurrency()), + } +} + +func clonePaymentEndpoint(src *model.PaymentEndpoint) *model.PaymentEndpoint { + if src == nil { + return nil + } + result := &model.PaymentEndpoint{ + Type: src.Type, + InstanceID: strings.TrimSpace(src.InstanceID), + } + if src.Ledger != nil { + result.Ledger = &model.LedgerEndpoint{ + LedgerAccountRef: strings.TrimSpace(src.Ledger.LedgerAccountRef), + ContraLedgerAccountRef: strings.TrimSpace(src.Ledger.ContraLedgerAccountRef), + } + } + if src.ManagedWallet != nil { + result.ManagedWallet = &model.ManagedWalletEndpoint{ + ManagedWalletRef: strings.TrimSpace(src.ManagedWallet.ManagedWalletRef), + Asset: cloneAsset(src.ManagedWallet.Asset), + } + } + if src.ExternalChain != nil { + result.ExternalChain = &model.ExternalChainEndpoint{ + Asset: cloneAsset(src.ExternalChain.Asset), + Address: strings.TrimSpace(src.ExternalChain.Address), + Memo: strings.TrimSpace(src.ExternalChain.Memo), + } + } + if src.Card != nil { + result.Card = &model.CardEndpoint{ + Pan: strings.TrimSpace(src.Card.Pan), + Token: strings.TrimSpace(src.Card.Token), + Cardholder: strings.TrimSpace(src.Card.Cardholder), + CardholderSurname: strings.TrimSpace(src.Card.CardholderSurname), + ExpMonth: src.Card.ExpMonth, + ExpYear: src.Card.ExpYear, + Country: strings.TrimSpace(src.Card.Country), + MaskedPan: strings.TrimSpace(src.Card.MaskedPan), + } + } + return result +} + +func cloneAsset(src *paymenttypes.Asset) *paymenttypes.Asset { + if src == nil { + return nil + } + return &paymenttypes.Asset{ + Chain: strings.TrimSpace(src.Chain), + TokenSymbol: strings.TrimSpace(src.TokenSymbol), + ContractAddress: strings.TrimSpace(src.ContractAddress), + } +} + +func cloneStringMap(src map[string]string) map[string]string { + if len(src) == 0 { + return nil + } + out := make(map[string]string, len(src)) + for k, v := range src { + kk := strings.TrimSpace(k) + if kk == "" { + continue + } + out[kk] = strings.TrimSpace(v) + } + if len(out) == 0 { + return nil + } + return out +} + +func cloneReservePolicy(src *ReservePolicy) *ReservePolicy { + if src == nil { + return nil + } + result := &ReservePolicy{ + DebitAccountRef: strings.TrimSpace(src.DebitAccountRef), + BlockAccountRef: strings.TrimSpace(src.BlockAccountRef), + ReleaseOnFail: src.ReleaseOnFail, + ReleaseOnCancel: src.ReleaseOnCancel, + } + if src.FromRole != nil { + role := *src.FromRole + result.FromRole = &role + } + if src.ToRole != nil { + role := *src.ToRole + result.ToRole = &role + } + return result +} + +func cloneDepositCheck(src *model.DepositCheckPolicy) *model.DepositCheckPolicy { + if src == nil { + return nil + } + return &model.DepositCheckPolicy{ + WalletRef: strings.TrimSpace(src.WalletRef), + ExpectedAmount: cloneProtoMoney(src.ExpectedAmount), + MinConfirmations: src.MinConfirmations, + TimeoutSeconds: src.TimeoutSeconds, + } +} diff --git a/api/payments/quotation/internal/service/quotation/gateway_funding_profile/funding_gate_builder_test.go b/api/payments/quotation/internal/service/quotation/gateway_funding_profile/funding_gate_builder_test.go new file mode 100644 index 00000000..bd4dee94 --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/gateway_funding_profile/funding_gate_builder_test.go @@ -0,0 +1,127 @@ +package gateway_funding_profile + +import ( + "errors" + "testing" + + "github.com/tech/sendico/payments/storage/model" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/model/account_role" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" +) + +func TestBuildFundingGateFromProfile_NilProfile(t *testing.T) { + gate, err := BuildFundingGateFromProfile(nil, &moneyv1.Money{ + Amount: "100", + Currency: "USD", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gate != nil { + t.Fatalf("expected nil gate") + } +} + +func TestBuildFundingGateFromProfile_None(t *testing.T) { + gate, err := BuildFundingGateFromProfile(&GatewayFundingProfile{ + Mode: model.FundingModeNone, + }, &moneyv1.Money{ + Amount: "100", + Currency: "USD", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gate == nil { + t.Fatalf("expected gate") + } + if gate.Mode != model.FundingModeNone { + t.Fatalf("expected mode %q, got %q", model.FundingModeNone, gate.Mode) + } +} + +func TestBuildFundingGateFromProfile_BalanceReserve(t *testing.T) { + from := account_role.AccountRoleOperating + to := account_role.AccountRoleHold + gate, err := BuildFundingGateFromProfile(&GatewayFundingProfile{ + Mode: model.FundingModeBalanceReserve, + Reserve: &ReservePolicy{ + BlockAccountRef: "ledger:block", + FromRole: &from, + ToRole: &to, + ReleaseOnFail: true, + ReleaseOnCancel: true, + }, + }, &moneyv1.Money{ + Amount: "100", + Currency: "USD", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gate == nil || gate.Reserve == nil { + t.Fatalf("expected reserve gate") + } + if gate.RequiredAmount == nil || gate.RequiredAmount.GetAmount() != "100" { + t.Fatalf("expected required amount 100, got %#v", gate.RequiredAmount) + } +} + +func TestBuildFundingGateFromProfile_BalanceReserveMissingPolicy(t *testing.T) { + _, err := BuildFundingGateFromProfile(&GatewayFundingProfile{ + Mode: model.FundingModeBalanceReserve, + }, &moneyv1.Money{ + Amount: "100", + Currency: "USD", + }) + if !errors.Is(err, merrors.ErrInvalidArg) { + t.Fatalf("expected invalid arg error, got %v", err) + } +} + +func TestBuildFundingGateFromProfile_DepositObservedUsesRequiredAmountFallback(t *testing.T) { + gate, err := BuildFundingGateFromProfile(&GatewayFundingProfile{ + Mode: model.FundingModeDepositObserved, + DepositCheck: &model.DepositCheckPolicy{ + WalletRef: "wallet-1", + }, + }, &moneyv1.Money{ + Amount: "42", + Currency: "USDT", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gate == nil || gate.DepositCheck == nil { + t.Fatalf("expected deposit gate") + } + if gate.DepositCheck.ExpectedAmount == nil { + t.Fatalf("expected fallback expected amount") + } + if got := gate.DepositCheck.ExpectedAmount.GetAmount(); got != "42" { + t.Fatalf("expected amount 42, got %q", got) + } +} + +func TestBuildFundingGateFromProfile_DepositObservedMissingWallet(t *testing.T) { + _, err := BuildFundingGateFromProfile(&GatewayFundingProfile{ + Mode: model.FundingModeDepositObserved, + DepositCheck: &model.DepositCheckPolicy{ + ExpectedAmount: &moneyv1.Money{ + Amount: "42", + Currency: "USDT", + }, + }, + }, nil) + if !errors.Is(err, merrors.ErrInvalidArg) { + t.Fatalf("expected invalid arg error, got %v", err) + } +} + +func TestBuildFundingGateFromProfile_UnspecifiedMode(t *testing.T) { + _, err := BuildFundingGateFromProfile(&GatewayFundingProfile{}, nil) + if !errors.Is(err, merrors.ErrInvalidArg) { + t.Fatalf("expected invalid arg error, got %v", err) + } +} diff --git a/api/payments/quotation/internal/service/quotation/gateway_funding_profile/funding_profile.go b/api/payments/quotation/internal/service/quotation/gateway_funding_profile/funding_profile.go new file mode 100644 index 00000000..626d52c6 --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/gateway_funding_profile/funding_profile.go @@ -0,0 +1,63 @@ +package gateway_funding_profile + +import ( + "context" + + "github.com/tech/sendico/payments/storage/model" + "github.com/tech/sendico/pkg/model/account_role" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" +) + +// ReservePolicy defines where funds are blocked/released for pre-funding. +type ReservePolicy struct { + DebitAccountRef string + BlockAccountRef string + FromRole *account_role.AccountRole + ToRole *account_role.AccountRole + ReleaseOnFail bool + ReleaseOnCancel bool +} + +// GatewayFundingProfile is the single source for funding/block resolution. +type GatewayFundingProfile struct { + GatewayID string + InstanceID string + Rail model.Rail + Network string + Currency string + Mode model.FundingMode + + FundingDestination *model.PaymentEndpoint + FeeDestination *model.PaymentEndpoint + Reserve *ReservePolicy + DepositCheck *model.DepositCheckPolicy + + Defaults map[string]string +} + +// QuoteFundingGate describes pre-funding requirements before payout execution. +type QuoteFundingGate struct { + Mode model.FundingMode + RequiredAmount *moneyv1.Money + Funding *model.PaymentEndpoint + Fee *model.PaymentEndpoint + Reserve *ReservePolicy + DepositCheck *model.DepositCheckPolicy +} + +type FundingProfileRequest struct { + OrganizationRef string + GatewayID string + InstanceID string + Rail model.Rail + Network string + Currency string + Amount *moneyv1.Money + Source *model.PaymentEndpoint + Destination *model.PaymentEndpoint + Attributes map[string]string +} + +type FundingProfileResolver interface { + ResolveGatewayFundingProfile(ctx context.Context, req FundingProfileRequest) (*GatewayFundingProfile, error) +} diff --git a/api/payments/quotation/internal/service/quotation/gateway_funding_profile/funding_profile_resolver_static.go b/api/payments/quotation/internal/service/quotation/gateway_funding_profile/funding_profile_resolver_static.go new file mode 100644 index 00000000..1c4dc231 --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/gateway_funding_profile/funding_profile_resolver_static.go @@ -0,0 +1,366 @@ +package gateway_funding_profile + +import ( + "context" + "strings" + + "github.com/tech/sendico/payments/quotation/internal/shared" + "github.com/tech/sendico/payments/storage/model" + "github.com/tech/sendico/pkg/model/account_role" + paymenttypes "github.com/tech/sendico/pkg/payments/types" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" +) + +const ( + defaultCardGatewayKey = "monetix" +) + +type CardGatewayFundingRoute struct { + FundingAddress string + FeeAddress string + FeeWalletRef string +} + +type StaticFundingProfileResolverInput struct { + DefaultCardGateway string + DefaultMode model.FundingMode + GatewayModes map[string]model.FundingMode + CardRoutes map[string]CardGatewayFundingRoute + FeeLedgerAccounts map[string]string + Profiles map[string]*GatewayFundingProfile +} + +type StaticFundingProfileResolver struct { + defaultCardGateway string + defaultMode model.FundingMode + gatewayModes map[string]model.FundingMode + cardRoutes map[string]CardGatewayFundingRoute + feeLedgerAccounts map[string]string + profiles map[string]*GatewayFundingProfile +} + +func NewStaticFundingProfileResolver(in StaticFundingProfileResolverInput) *StaticFundingProfileResolver { + r := &StaticFundingProfileResolver{ + defaultCardGateway: normalizeGatewayKey(in.DefaultCardGateway), + defaultMode: shared.NormalizeFundingMode(in.DefaultMode), + gatewayModes: map[string]model.FundingMode{}, + cardRoutes: map[string]CardGatewayFundingRoute{}, + feeLedgerAccounts: map[string]string{}, + profiles: map[string]*GatewayFundingProfile{}, + } + if r.defaultCardGateway == "" { + r.defaultCardGateway = defaultCardGatewayKey + } + if r.defaultMode == model.FundingModeUnspecified { + r.defaultMode = model.FundingModeNone + } + for k, v := range in.GatewayModes { + if key := normalizeGatewayKey(k); key != "" { + r.gatewayModes[key] = shared.NormalizeFundingMode(v) + } + } + for k, v := range in.CardRoutes { + if key := normalizeGatewayKey(k); key != "" { + r.cardRoutes[key] = CardGatewayFundingRoute{ + FundingAddress: strings.TrimSpace(v.FundingAddress), + FeeAddress: strings.TrimSpace(v.FeeAddress), + FeeWalletRef: strings.TrimSpace(v.FeeWalletRef), + } + } + } + for k, v := range in.FeeLedgerAccounts { + if key := normalizeGatewayKey(k); key != "" { + if val := strings.TrimSpace(v); val != "" { + r.feeLedgerAccounts[key] = val + } + } + } + for k, v := range in.Profiles { + if key := normalizeGatewayKey(k); key != "" && v != nil { + r.profiles[key] = cloneGatewayFundingProfile(v) + } + } + return r +} + +func (r *StaticFundingProfileResolver) ResolveGatewayFundingProfile( + _ context.Context, + req FundingProfileRequest, +) (*GatewayFundingProfile, error) { + if r == nil { + return nil, nil + } + + gatewayKey := r.gatewayKey(req) + attrs := cloneStringMap(req.Attributes) + if attrs == nil { + attrs = map[string]string{} + } + + modeSet := false + mode := model.FundingModeUnspecified + + var profile *GatewayFundingProfile + if gatewayKey != "" { + if configured, ok := r.profiles[gatewayKey]; ok && configured != nil { + profile = cloneGatewayFundingProfile(configured) + mode = shared.NormalizeFundingMode(profile.Mode) + modeSet = mode != model.FundingModeUnspecified + } + } + if profile == nil { + profile = &GatewayFundingProfile{} + } + + profile.GatewayID = firstNonEmpty( + strings.TrimSpace(profile.GatewayID), + gatewayKey, + ) + profile.InstanceID = firstNonEmpty( + strings.TrimSpace(profile.InstanceID), + strings.TrimSpace(req.InstanceID), + ) + if profile.Rail == model.RailUnspecified { + profile.Rail = req.Rail + } + if strings.TrimSpace(profile.Network) == "" { + profile.Network = strings.TrimSpace(req.Network) + } + if strings.TrimSpace(profile.Currency) == "" { + profile.Currency = normalizedCurrency(req.Currency, req.Amount) + } + + if !modeSet && gatewayKey != "" { + if configured, ok := r.gatewayModes[gatewayKey]; ok { + mode = shared.NormalizeFundingMode(configured) + modeSet = mode != model.FundingModeUnspecified + } + } + if !modeSet { + mode = r.defaultMode + } + profile.Mode = mode + + sourceAsset := sourceAsset(req.Source) + + if gatewayKey != "" { + if route, ok := r.cardRoutes[gatewayKey]; ok { + if profile.FundingDestination == nil && route.FundingAddress != "" { + profile.FundingDestination = &model.PaymentEndpoint{ + Type: model.EndpointTypeExternalChain, + ExternalChain: &model.ExternalChainEndpoint{ + Address: route.FundingAddress, + Asset: cloneAsset(sourceAsset), + }, + } + } + if profile.FeeDestination == nil { + switch { + case route.FeeWalletRef != "": + profile.FeeDestination = &model.PaymentEndpoint{ + Type: model.EndpointTypeManagedWallet, + ManagedWallet: &model.ManagedWalletEndpoint{ + ManagedWalletRef: route.FeeWalletRef, + Asset: cloneAsset(sourceAsset), + }, + } + case route.FeeAddress != "": + profile.FeeDestination = &model.PaymentEndpoint{ + Type: model.EndpointTypeExternalChain, + ExternalChain: &model.ExternalChainEndpoint{ + Address: route.FeeAddress, + Asset: cloneAsset(sourceAsset), + }, + } + } + } + } + } + + if feeAccount := r.feeLedgerAccounts[gatewayKey]; feeAccount != "" { + if profile.Defaults == nil { + profile.Defaults = map[string]string{} + } + if strings.TrimSpace(profile.Defaults["fee_ledger_account_ref"]) == "" { + profile.Defaults["fee_ledger_account_ref"] = feeAccount + } + } + if len(attrs) > 0 { + if profile.Defaults == nil { + profile.Defaults = map[string]string{} + } + if _, ok := profile.Defaults["gateway"]; !ok && gatewayKey != "" { + profile.Defaults["gateway"] = gatewayKey + } + } + + if profile.Reserve == nil { + if reserve := reservePolicyFromRequest(req); reserve != nil { + profile.Reserve = reserve + } + } + + if profile.Mode == model.FundingModeNone && profile.Reserve != nil { + profile.Mode = model.FundingModeBalanceReserve + } + + if isEmptyFundingProfile(profile) { + return nil, nil + } + return profile, nil +} + +func (r *StaticFundingProfileResolver) gatewayKey(req FundingProfileRequest) string { + if key := normalizeGatewayKey(req.GatewayID); key != "" { + return key + } + if key := normalizeGatewayKey(req.Attributes["gateway"]); key != "" { + return key + } + if req.Destination != nil && req.Destination.Card != nil { + return r.defaultCardGateway + } + return "" +} + +func normalizeGatewayKey(key string) string { + return strings.ToLower(strings.TrimSpace(key)) +} + +func normalizedCurrency(currency string, amount *moneyv1.Money) string { + if cur := strings.ToUpper(strings.TrimSpace(currency)); cur != "" { + return cur + } + if amount != nil { + return strings.ToUpper(strings.TrimSpace(amount.GetCurrency())) + } + return "" +} + +func sourceAsset(source *model.PaymentEndpoint) *paymenttypes.Asset { + if source == nil { + return nil + } + if source.ManagedWallet != nil { + return source.ManagedWallet.Asset + } + if source.ExternalChain != nil { + return source.ExternalChain.Asset + } + return nil +} + +func reservePolicyFromRequest(req FundingProfileRequest) *ReservePolicy { + if req.Source == nil && len(req.Attributes) == 0 { + return nil + } + reserve := &ReservePolicy{ + DebitAccountRef: strings.TrimSpace(lookupAttr(req.Attributes, + "ledger_debit_account_ref", + "ledgerDebitAccountRef", + )), + BlockAccountRef: strings.TrimSpace(lookupAttr(req.Attributes, + "ledger_block_account_ref", + "ledgerBlockAccountRef", + "ledger_hold_account_ref", + "ledgerHoldAccountRef", + "ledger_debit_contra_account_ref", + "ledgerDebitContraAccountRef", + )), + ReleaseOnFail: true, + ReleaseOnCancel: true, + } + if req.Source != nil && req.Source.Ledger != nil { + if reserve.DebitAccountRef == "" { + reserve.DebitAccountRef = strings.TrimSpace(req.Source.Ledger.LedgerAccountRef) + } + if reserve.BlockAccountRef == "" { + reserve.BlockAccountRef = strings.TrimSpace(req.Source.Ledger.ContraLedgerAccountRef) + } + } + + if role, ok := parseAccountRole(lookupAttr(req.Attributes, "from_role", "fromRole")); ok { + reserve.FromRole = &role + } + if role, ok := parseAccountRole(lookupAttr(req.Attributes, "to_role", "toRole")); ok { + reserve.ToRole = &role + } + + if reserve.DebitAccountRef == "" && + reserve.BlockAccountRef == "" && + reserve.FromRole == nil && + reserve.ToRole == nil { + return nil + } + return reserve +} + +func parseAccountRole(value string) (account_role.AccountRole, bool) { + role, ok := account_role.Parse(strings.TrimSpace(value)) + if !ok || role == "" { + return "", false + } + return role, true +} + +func lookupAttr(attrs map[string]string, keys ...string) string { + if len(attrs) == 0 { + return "" + } + for _, key := range keys { + if key == "" { + continue + } + if val := strings.TrimSpace(attrs[key]); val != "" { + return val + } + } + return "" +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func cloneGatewayFundingProfile(src *GatewayFundingProfile) *GatewayFundingProfile { + if src == nil { + return nil + } + return &GatewayFundingProfile{ + GatewayID: strings.TrimSpace(src.GatewayID), + InstanceID: strings.TrimSpace(src.InstanceID), + Rail: src.Rail, + Network: strings.TrimSpace(src.Network), + Currency: strings.ToUpper(strings.TrimSpace(src.Currency)), + Mode: shared.NormalizeFundingMode(src.Mode), + FundingDestination: clonePaymentEndpoint(src.FundingDestination), + FeeDestination: clonePaymentEndpoint(src.FeeDestination), + Reserve: cloneReservePolicy(src.Reserve), + DepositCheck: cloneDepositCheck(src.DepositCheck), + Defaults: cloneStringMap(src.Defaults), + } +} + +func isEmptyFundingProfile(profile *GatewayFundingProfile) bool { + if profile == nil { + return true + } + if profile.FundingDestination != nil || profile.FeeDestination != nil || profile.Reserve != nil || profile.DepositCheck != nil { + return false + } + if profile.Mode != model.FundingModeUnspecified && profile.Mode != model.FundingModeNone { + return false + } + if len(profile.Defaults) > 0 { + return false + } + if profile.GatewayID != "" { + return false + } + return true +} diff --git a/api/payments/quotation/internal/service/quotation/gateway_funding_profile/funding_profile_resolver_static_test.go b/api/payments/quotation/internal/service/quotation/gateway_funding_profile/funding_profile_resolver_static_test.go new file mode 100644 index 00000000..1b451b74 --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/gateway_funding_profile/funding_profile_resolver_static_test.go @@ -0,0 +1,188 @@ +package gateway_funding_profile + +import ( + "context" + "testing" + + "github.com/tech/sendico/payments/storage/model" + "github.com/tech/sendico/pkg/model/account_role" + paymenttypes "github.com/tech/sendico/pkg/payments/types" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" +) + +func TestStaticFundingProfileResolver_DefaultCardRoute(t *testing.T) { + resolver := NewStaticFundingProfileResolver(StaticFundingProfileResolverInput{ + DefaultMode: model.FundingModeNone, + CardRoutes: map[string]CardGatewayFundingRoute{ + "monetix": { + FundingAddress: "T-FUNDING", + FeeWalletRef: "wallet-fee", + }, + }, + FeeLedgerAccounts: map[string]string{ + "monetix": "ledger:fees", + }, + }) + + profile, err := resolver.ResolveGatewayFundingProfile(context.Background(), FundingProfileRequest{ + OrganizationRef: "org-1", + Amount: &moneyv1.Money{ + Amount: "100", + Currency: "USDT", + }, + Source: &model.PaymentEndpoint{ + Type: model.EndpointTypeManagedWallet, + ManagedWallet: &model.ManagedWalletEndpoint{ + ManagedWalletRef: "wallet-src", + Asset: &paymenttypes.Asset{ + Chain: "TRON_MAINNET", + TokenSymbol: "USDT", + }, + }, + }, + Destination: &model.PaymentEndpoint{ + Type: model.EndpointTypeCard, + Card: &model.CardEndpoint{ + MaskedPan: "****", + }, + }, + Attributes: map[string]string{ + "initiator_ref": "usr-1", + }, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if profile == nil { + t.Fatalf("expected profile") + } + if profile.GatewayID != "monetix" { + t.Fatalf("expected gateway monetix, got %q", profile.GatewayID) + } + if profile.Mode != model.FundingModeNone { + t.Fatalf("expected mode none, got %q", profile.Mode) + } + if profile.FundingDestination == nil || profile.FundingDestination.ExternalChain == nil { + t.Fatalf("expected funding destination") + } + if got := profile.FundingDestination.ExternalChain.Address; got != "T-FUNDING" { + t.Fatalf("expected funding address T-FUNDING, got %q", got) + } + if profile.FeeDestination == nil || profile.FeeDestination.ManagedWallet == nil { + t.Fatalf("expected managed wallet fee destination") + } + if got := profile.FeeDestination.ManagedWallet.ManagedWalletRef; got != "wallet-fee" { + t.Fatalf("expected fee wallet wallet-fee, got %q", got) + } + if got := profile.Defaults["fee_ledger_account_ref"]; got != "ledger:fees" { + t.Fatalf("expected fee ledger account mapping, got %q", got) + } +} + +func TestStaticFundingProfileResolver_ReserveFromSourceAndAttrs(t *testing.T) { + resolver := NewStaticFundingProfileResolver(StaticFundingProfileResolverInput{ + GatewayModes: map[string]model.FundingMode{ + "mntx": model.FundingModeBalanceReserve, + }, + }) + + profile, err := resolver.ResolveGatewayFundingProfile(context.Background(), FundingProfileRequest{ + GatewayID: "mntx", + Source: &model.PaymentEndpoint{ + Type: model.EndpointTypeLedger, + Ledger: &model.LedgerEndpoint{ + LedgerAccountRef: "ledger:operating", + ContraLedgerAccountRef: "ledger:hold", + }, + }, + Attributes: map[string]string{ + "ledger_block_account_ref": "ledger:block", + "from_role": string(account_role.AccountRoleOperating), + "to_role": string(account_role.AccountRoleHold), + }, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if profile == nil { + t.Fatalf("expected profile") + } + if profile.Mode != model.FundingModeBalanceReserve { + t.Fatalf("expected balance_reserve mode, got %q", profile.Mode) + } + if profile.Reserve == nil { + t.Fatalf("expected reserve policy") + } + if got := profile.Reserve.DebitAccountRef; got != "ledger:operating" { + t.Fatalf("expected debit account ledger:operating, got %q", got) + } + if got := profile.Reserve.BlockAccountRef; got != "ledger:block" { + t.Fatalf("expected block account ledger:block, got %q", got) + } + if profile.Reserve.FromRole == nil || *profile.Reserve.FromRole != account_role.AccountRoleOperating { + t.Fatalf("expected from role operating, got %#v", profile.Reserve.FromRole) + } + if profile.Reserve.ToRole == nil || *profile.Reserve.ToRole != account_role.AccountRoleHold { + t.Fatalf("expected to role hold, got %#v", profile.Reserve.ToRole) + } +} + +func TestStaticFundingProfileResolver_EmptyInputReturnsNil(t *testing.T) { + resolver := NewStaticFundingProfileResolver(StaticFundingProfileResolverInput{}) + + profile, err := resolver.ResolveGatewayFundingProfile(context.Background(), FundingProfileRequest{ + OrganizationRef: "org-1", + Amount: &moneyv1.Money{ + Amount: "10", + Currency: "USD", + }, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if profile != nil { + t.Fatalf("expected nil profile") + } +} + +func TestStaticFundingProfileResolver_ConfiguredProfileCloned(t *testing.T) { + resolver := NewStaticFundingProfileResolver(StaticFundingProfileResolverInput{ + Profiles: map[string]*GatewayFundingProfile{ + "monetix": { + Mode: model.FundingModeDepositObserved, + DepositCheck: &model.DepositCheckPolicy{ + WalletRef: "wallet-deposit", + ExpectedAmount: &moneyv1.Money{ + Amount: "15", + Currency: "USDT", + }, + MinConfirmations: 2, + }, + }, + }, + }) + + first, err := resolver.ResolveGatewayFundingProfile(context.Background(), FundingProfileRequest{ + GatewayID: "monetix", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if first == nil || first.DepositCheck == nil { + t.Fatalf("expected configured profile") + } + first.DepositCheck.WalletRef = "changed" + + second, err := resolver.ResolveGatewayFundingProfile(context.Background(), FundingProfileRequest{ + GatewayID: "monetix", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if second == nil || second.DepositCheck == nil { + t.Fatalf("expected configured profile") + } + if second.DepositCheck.WalletRef != "wallet-deposit" { + t.Fatalf("expected cloned profile, got %q", second.DepositCheck.WalletRef) + } +} diff --git a/api/payments/quotation/internal/service/quotation/handlers_commands.go b/api/payments/quotation/internal/service/quotation/handlers_commands.go index 2b86f4c5..09245da1 100644 --- a/api/payments/quotation/internal/service/quotation/handlers_commands.go +++ b/api/payments/quotation/internal/service/quotation/handlers_commands.go @@ -9,7 +9,6 @@ import ( "strings" "time" - "github.com/tech/sendico/payments/storage" "github.com/tech/sendico/payments/storage/model" quotestorage "github.com/tech/sendico/payments/storage/quote" "github.com/tech/sendico/pkg/api/routers/gsresponse" @@ -131,7 +130,7 @@ func (h *quotePaymentCommand) quotePayment( } existing, err := quotesStore.GetByIdempotencyKey(ctx, qc.orgRef, qc.idempotencyKey) - if err != nil && !errors.Is(err, storage.ErrQuoteNotFound) && !errors.Is(err, merrors.ErrNoData) { + if err != nil && !errors.Is(err, quotestorage.ErrQuoteNotFound) && !errors.Is(err, merrors.ErrNoData) { h.logger.Warn("Failed to lookup quote by idempotency key", zap.Error(err), mzap.ObjRef("org_ref", qc.orgRef), zap.String("idempotency_key", qc.idempotencyKey), ) @@ -203,7 +202,7 @@ func (h *quotePaymentCommand) quotePayment( record.SetOrganizationRef(qc.orgRef) if err := quotesStore.Create(ctx, record); err != nil { - if errors.Is(err, storage.ErrDuplicateQuote) { + if errors.Is(err, quotestorage.ErrDuplicateQuote) { existing, getErr := quotesStore.GetByIdempotencyKey(ctx, qc.orgRef, qc.idempotencyKey) if getErr == nil && existing != nil { if existing.Hash != qc.hash { @@ -422,7 +421,7 @@ func (h *quotePaymentsCommand) tryReuse( rec, err := quotesStore.GetByIdempotencyKey(ctx, qc.orgRef, qc.idempotencyKey) if err != nil { - if errors.Is(err, storage.ErrQuoteNotFound) { + if errors.Is(err, quotestorage.ErrQuoteNotFound) { return nil, false, nil } h.logger.Warn( @@ -538,7 +537,7 @@ func (h *quotePaymentsCommand) storeBatch( record.SetOrganizationRef(qc.orgRef) if err := quotesStore.Create(ctx, record); err != nil { - if errors.Is(err, storage.ErrDuplicateQuote) { + if errors.Is(err, quotestorage.ErrDuplicateQuote) { rec, ok, reuseErr := h.tryReuse(ctx, quotesStore, qc) if reuseErr != nil { return nil, reuseErr diff --git a/api/payments/quotation/internal/service/quotation/handlers_commands_test.go b/api/payments/quotation/internal/service/quotation/handlers_commands_test.go index 9ff93376..23305110 100644 --- a/api/payments/quotation/internal/service/quotation/handlers_commands_test.go +++ b/api/payments/quotation/internal/service/quotation/handlers_commands_test.go @@ -184,12 +184,12 @@ func (s *quoteCommandTestQuotesStore) GetByRef(_ context.Context, _ bson.ObjectI return rec, nil } } - return nil, storage.ErrQuoteNotFound + return nil, quotestorage.ErrQuoteNotFound } func (s *quoteCommandTestQuotesStore) GetByIdempotencyKey(_ context.Context, _ bson.ObjectID, idempotencyKey string) (*model.PaymentQuoteRecord, error) { if rec, ok := s.byID[idempotencyKey]; ok { return rec, nil } - return nil, storage.ErrQuoteNotFound + return nil, quotestorage.ErrQuoteNotFound } diff --git a/api/payments/quotation/internal/service/quotation/payment_plan_factory.go b/api/payments/quotation/internal/service/quotation/payment_plan_factory.go index 2a31e594..8b67373c 100644 --- a/api/payments/quotation/internal/service/quotation/payment_plan_factory.go +++ b/api/payments/quotation/internal/service/quotation/payment_plan_factory.go @@ -4,7 +4,7 @@ import ( "context" "strings" - "github.com/tech/sendico/payments/quotation/internal/service/shared" + "github.com/tech/sendico/payments/quotation/internal/shared" "github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/pkg/merrors" paymenttypes "github.com/tech/sendico/pkg/payments/types" diff --git a/api/payments/quotation/internal/service/quotation/quotation_service_v2/converters.go b/api/payments/quotation/internal/service/quotation/quotation_service_v2/converters.go new file mode 100644 index 00000000..1bcbfb13 --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/quotation_service_v2/converters.go @@ -0,0 +1,243 @@ +package quotation_service_v2 + +import ( + "strings" + "time" + + "github.com/tech/sendico/payments/quotation/internal/service/quotation/quote_computation_service" + "github.com/tech/sendico/payments/quotation/internal/service/quotation/quote_persistence_service" + "github.com/tech/sendico/payments/quotation/internal/service/quotation/quote_response_mapper_v2" + "github.com/tech/sendico/payments/storage/model" + paymenttypes "github.com/tech/sendico/pkg/payments/types" + fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" + quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" + "google.golang.org/protobuf/types/known/timestamppb" +) + +func statusInputFromStatus(status quote_response_mapper_v2.QuoteStatus) *quote_persistence_service.StatusInput { + return "e_persistence_service.StatusInput{ + Kind: status.Kind, + Lifecycle: status.Lifecycle, + Executable: cloneBool(status.Executable), + 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, + } + status.Executable = boolPtr(true) + return status + } + + status := quote_response_mapper_v2.QuoteStatus{ + Kind: quoteKindToProto(input.Kind), + Lifecycle: quoteLifecycleToProto(input.Lifecycle), + Executable: cloneBool(input.Executable), + BlockReason: quoteBlockReasonToProto(input.BlockReason), + } + if status.Kind == quotationv2.QuoteKind_QUOTE_KIND_UNSPECIFIED { + status.Kind = quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE + } + if status.Lifecycle == quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_UNSPECIFIED { + status.Lifecycle = quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE + } + return status +} + +func quoteSnapshotFromComputed(src *quote_computation_service.ComputedQuote) *model.PaymentQuoteSnapshot { + if src == nil { + return nil + } + return &model.PaymentQuoteSnapshot{ + DebitAmount: modelMoneyFromProto(src.DebitAmount), + ExpectedSettlementAmount: modelMoneyFromProto(src.CreditAmount), + TotalCost: modelMoneyFromProto(src.TotalCost), + FeeLines: feeLinesFromProto(src.FeeLines), + FeeRules: feeRulesFromProto(src.FeeRules), + Route: modelRouteFromProto(src.Route), + ExecutionConditions: modelExecutionConditionsFromProto(src.ExecutionConditions), + QuoteRef: strings.TrimSpace(src.QuoteRef), + FXQuote: modelFXQuoteFromProto(src.FXQuote), + } +} + +func canonicalFromSnapshot( + snapshot *model.PaymentQuoteSnapshot, + expiresAt time.Time, + pricedAt time.Time, + fallbackQuoteRef string, +) quote_response_mapper_v2.CanonicalQuote { + if snapshot == nil { + return quote_response_mapper_v2.CanonicalQuote{ + QuoteRef: strings.TrimSpace(fallbackQuoteRef), + ExpiresAt: expiresAt, + PricedAt: pricedAt, + } + } + return quote_response_mapper_v2.CanonicalQuote{ + QuoteRef: firstNonEmpty(snapshot.QuoteRef, fallbackQuoteRef), + DebitAmount: protoMoneyFromModel(snapshot.DebitAmount), + CreditAmount: protoMoneyFromModel(snapshot.ExpectedSettlementAmount), + TotalCost: protoMoneyFromModel(snapshot.TotalCost), + FeeLines: feeLinesToProto(snapshot.FeeLines), + FeeRules: feeRulesToProto(snapshot.FeeRules), + Route: protoRouteFromModel(snapshot.Route), + Conditions: protoExecutionConditionsFromModel(snapshot.ExecutionConditions), + FXQuote: protoFXQuoteFromModel(snapshot.FXQuote), + ExpiresAt: expiresAt, + PricedAt: pricedAt, + } +} + +func modelMoneyFromProto(src *moneyv1.Money) *paymenttypes.Money { + if src == nil { + return nil + } + return &paymenttypes.Money{ + Amount: strings.TrimSpace(src.GetAmount()), + Currency: strings.ToUpper(strings.TrimSpace(src.GetCurrency())), + } +} + +func protoMoneyFromModel(src *paymenttypes.Money) *moneyv1.Money { + if src == nil { + return nil + } + return &moneyv1.Money{ + Amount: strings.TrimSpace(src.GetAmount()), + Currency: strings.ToUpper(strings.TrimSpace(src.GetCurrency())), + } +} + +func modelFXQuoteFromProto(src *oraclev1.Quote) *paymenttypes.FXQuote { + if src == nil { + return nil + } + modelQuote := &paymenttypes.FXQuote{ + QuoteRef: strings.TrimSpace(src.GetQuoteRef()), + Side: sideFromProto(src.GetSide()), + Price: &paymenttypes.Decimal{Value: strings.TrimSpace(src.GetPrice().GetValue())}, + BaseAmount: modelMoneyFromProto(src.GetBaseAmount()), + QuoteAmount: modelMoneyFromProto(src.GetQuoteAmount()), + ExpiresAtUnixMs: src.GetExpiresAtUnixMs(), + Provider: strings.TrimSpace(src.GetProvider()), + RateRef: strings.TrimSpace(src.GetRateRef()), + Firm: src.GetFirm(), + } + if pair := src.GetPair(); pair != nil { + modelQuote.Pair = &paymenttypes.CurrencyPair{ + Base: strings.ToUpper(strings.TrimSpace(pair.GetBase())), + Quote: strings.ToUpper(strings.TrimSpace(pair.GetQuote())), + } + } + if pricedAt := src.GetPricedAt(); pricedAt != nil { + modelQuote.PricedAtUnixMs = pricedAt.AsTime().UnixMilli() + } + return modelQuote +} + +func protoFXQuoteFromModel(src *paymenttypes.FXQuote) *oraclev1.Quote { + if src == nil { + return nil + } + quote := &oraclev1.Quote{ + QuoteRef: strings.TrimSpace(src.QuoteRef), + Side: sideToProto(src.GetSide()), + Price: &moneyv1.Decimal{Value: strings.TrimSpace(src.GetPrice().GetValue())}, + BaseAmount: protoMoneyFromModel(src.GetBaseAmount()), + QuoteAmount: protoMoneyFromModel(src.GetQuoteAmount()), + ExpiresAtUnixMs: src.GetExpiresAtUnixMs(), + Provider: strings.TrimSpace(src.GetProvider()), + RateRef: strings.TrimSpace(src.GetRateRef()), + Firm: src.GetFirm(), + } + if pair := src.GetPair(); pair != nil { + quote.Pair = &fxv1.CurrencyPair{ + Base: strings.ToUpper(strings.TrimSpace(pair.GetBase())), + Quote: strings.ToUpper(strings.TrimSpace(pair.GetQuote())), + } + } + if src.GetPricedAtUnixMs() > 0 { + quote.PricedAt = timestamppb.New(time.UnixMilli(src.GetPricedAtUnixMs())) + } + return quote +} + +func sideFromProto(side fxv1.Side) paymenttypes.FXSide { + switch side { + case fxv1.Side_BUY_BASE_SELL_QUOTE: + return paymenttypes.FXSideBuyBaseSellQuote + case fxv1.Side_SELL_BASE_BUY_QUOTE: + return paymenttypes.FXSideSellBaseBuyQuote + default: + return paymenttypes.FXSideUnspecified + } +} + +func sideToProto(side paymenttypes.FXSide) fxv1.Side { + switch side { + case paymenttypes.FXSideBuyBaseSellQuote: + return fxv1.Side_BUY_BASE_SELL_QUOTE + case paymenttypes.FXSideSellBaseBuyQuote: + return fxv1.Side_SELL_BASE_BUY_QUOTE + default: + return fxv1.Side_SIDE_UNSPECIFIED + } +} + +func 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 + 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 + } +} + +func quoteBlockReasonToProto(reason model.QuoteBlockReason) quotationv2.QuoteBlockReason { + switch reason { + case model.QuoteBlockReasonRouteUnavailable: + return quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_ROUTE_UNAVAILABLE + case model.QuoteBlockReasonLimitBlocked: + return quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_LIMIT_BLOCKED + case model.QuoteBlockReasonRiskBlocked: + return quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_RISK_BLOCKED + case model.QuoteBlockReasonInsufficientLiquidity: + return quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_INSUFFICIENT_LIQUIDITY + case model.QuoteBlockReasonPriceStale: + return quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_PRICE_STALE + case model.QuoteBlockReasonAmountTooSmall: + return quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_AMOUNT_TOO_SMALL + case model.QuoteBlockReasonAmountTooLarge: + return quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_AMOUNT_TOO_LARGE + default: + return quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED + } +} + +func cloneBool(src *bool) *bool { + if src == nil { + return nil + } + value := *src + return &value +} diff --git a/api/payments/quotation/internal/service/quotation/quotation_service_v2/fee_converters.go b/api/payments/quotation/internal/service/quotation/quotation_service_v2/fee_converters.go new file mode 100644 index 00000000..12f33bd8 --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/quotation_service_v2/fee_converters.go @@ -0,0 +1,195 @@ +package quotation_service_v2 + +import ( + "strings" + + paymenttypes "github.com/tech/sendico/pkg/payments/types" + feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" + accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" +) + +func feeLinesFromProto(lines []*feesv1.DerivedPostingLine) []*paymenttypes.FeeLine { + if len(lines) == 0 { + return nil + } + result := make([]*paymenttypes.FeeLine, 0, len(lines)) + for _, line := range lines { + if line == nil { + continue + } + result = append(result, &paymenttypes.FeeLine{ + LedgerAccountRef: strings.TrimSpace(line.GetLedgerAccountRef()), + Money: modelMoneyFromProto(line.GetMoney()), + LineType: postingLineTypeFromProto(line.GetLineType()), + Side: entrySideFromProto(line.GetSide()), + Meta: cloneStringMap(line.GetMeta()), + }) + } + if len(result) == 0 { + return nil + } + return result +} + +func feeLinesToProto(lines []*paymenttypes.FeeLine) []*feesv1.DerivedPostingLine { + if len(lines) == 0 { + return nil + } + result := make([]*feesv1.DerivedPostingLine, 0, len(lines)) + for _, line := range lines { + if line == nil { + continue + } + result = append(result, &feesv1.DerivedPostingLine{ + LedgerAccountRef: strings.TrimSpace(line.GetLedgerAccountRef()), + Money: protoMoneyFromModel(line.GetMoney()), + LineType: postingLineTypeToProto(line.GetLineType()), + Side: entrySideToProto(line.GetSide()), + Meta: cloneStringMap(line.Meta), + }) + } + if len(result) == 0 { + return nil + } + return result +} + +func feeRulesFromProto(rules []*feesv1.AppliedRule) []*paymenttypes.AppliedRule { + if len(rules) == 0 { + return nil + } + result := make([]*paymenttypes.AppliedRule, 0, len(rules)) + for _, rule := range rules { + if rule == nil { + continue + } + result = append(result, &paymenttypes.AppliedRule{ + RuleID: strings.TrimSpace(rule.GetRuleId()), + RuleVersion: strings.TrimSpace(rule.GetRuleVersion()), + Formula: strings.TrimSpace(rule.GetFormula()), + Rounding: roundingModeFromProto(rule.GetRounding()), + TaxCode: strings.TrimSpace(rule.GetTaxCode()), + TaxRate: strings.TrimSpace(rule.GetTaxRate()), + Parameters: cloneStringMap(rule.GetParameters()), + }) + } + if len(result) == 0 { + return nil + } + return result +} + +func feeRulesToProto(rules []*paymenttypes.AppliedRule) []*feesv1.AppliedRule { + if len(rules) == 0 { + return nil + } + result := make([]*feesv1.AppliedRule, 0, len(rules)) + for _, rule := range rules { + if rule == nil { + continue + } + result = append(result, &feesv1.AppliedRule{ + RuleId: strings.TrimSpace(rule.RuleID), + RuleVersion: strings.TrimSpace(rule.RuleVersion), + Formula: strings.TrimSpace(rule.Formula), + Rounding: roundingModeToProto(rule.Rounding), + TaxCode: strings.TrimSpace(rule.TaxCode), + TaxRate: strings.TrimSpace(rule.TaxRate), + Parameters: cloneStringMap(rule.Parameters), + }) + } + if len(result) == 0 { + return nil + } + return result +} + +func entrySideFromProto(side accountingv1.EntrySide) paymenttypes.EntrySide { + switch side { + case accountingv1.EntrySide_ENTRY_SIDE_DEBIT: + return paymenttypes.EntrySideDebit + case accountingv1.EntrySide_ENTRY_SIDE_CREDIT: + return paymenttypes.EntrySideCredit + default: + return paymenttypes.EntrySideUnspecified + } +} + +func entrySideToProto(side paymenttypes.EntrySide) accountingv1.EntrySide { + switch side { + case paymenttypes.EntrySideDebit: + return accountingv1.EntrySide_ENTRY_SIDE_DEBIT + case paymenttypes.EntrySideCredit: + return accountingv1.EntrySide_ENTRY_SIDE_CREDIT + default: + return accountingv1.EntrySide_ENTRY_SIDE_UNSPECIFIED + } +} + +func postingLineTypeFromProto(lineType accountingv1.PostingLineType) paymenttypes.PostingLineType { + switch lineType { + case accountingv1.PostingLineType_POSTING_LINE_FEE: + return paymenttypes.PostingLineTypeFee + case accountingv1.PostingLineType_POSTING_LINE_TAX: + return paymenttypes.PostingLineTypeTax + case accountingv1.PostingLineType_POSTING_LINE_SPREAD: + return paymenttypes.PostingLineTypeSpread + case accountingv1.PostingLineType_POSTING_LINE_REVERSAL: + return paymenttypes.PostingLineTypeReversal + default: + return paymenttypes.PostingLineTypeUnspecified + } +} + +func postingLineTypeToProto(lineType paymenttypes.PostingLineType) accountingv1.PostingLineType { + switch lineType { + case paymenttypes.PostingLineTypeFee: + return accountingv1.PostingLineType_POSTING_LINE_FEE + case paymenttypes.PostingLineTypeTax: + return accountingv1.PostingLineType_POSTING_LINE_TAX + case paymenttypes.PostingLineTypeSpread: + return accountingv1.PostingLineType_POSTING_LINE_SPREAD + case paymenttypes.PostingLineTypeReversal: + return accountingv1.PostingLineType_POSTING_LINE_REVERSAL + default: + return accountingv1.PostingLineType_POSTING_LINE_TYPE_UNSPECIFIED + } +} + +func roundingModeFromProto(mode moneyv1.RoundingMode) paymenttypes.RoundingMode { + switch mode { + case moneyv1.RoundingMode_ROUND_HALF_EVEN: + return paymenttypes.RoundingModeHalfEven + case moneyv1.RoundingMode_ROUND_HALF_UP: + return paymenttypes.RoundingModeHalfUp + case moneyv1.RoundingMode_ROUND_DOWN: + return paymenttypes.RoundingModeDown + default: + return paymenttypes.RoundingModeUnspecified + } +} + +func roundingModeToProto(mode paymenttypes.RoundingMode) moneyv1.RoundingMode { + switch mode { + case paymenttypes.RoundingModeHalfEven: + return moneyv1.RoundingMode_ROUND_HALF_EVEN + case paymenttypes.RoundingModeHalfUp: + return moneyv1.RoundingMode_ROUND_HALF_UP + case paymenttypes.RoundingModeDown: + return moneyv1.RoundingMode_ROUND_DOWN + default: + return moneyv1.RoundingMode_ROUNDING_MODE_UNSPECIFIED + } +} + +func cloneStringMap(input map[string]string) map[string]string { + if len(input) == 0 { + return nil + } + result := make(map[string]string, len(input)) + for k, v := range input { + result[k] = v + } + return result +} diff --git a/api/payments/quotation/internal/service/quotation/quotation_service_v2/helpers.go b/api/payments/quotation/internal/service/quotation/quotation_service_v2/helpers.go new file mode 100644 index 00000000..cdea403f --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/quotation_service_v2/helpers.go @@ -0,0 +1,49 @@ +package quotation_service_v2 + +import ( + "strings" + "time" + + 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 { + if value.IsZero() { + continue + } + if min.IsZero() || value.Before(min) { + min = value + } + } + if min.IsZero() { + return time.Time{}, false + } + return min, true +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed != "" { + return trimmed + } + } + return "" +} + +func cloneProtoMoney(src *moneyv1.Money) *moneyv1.Money { + if src == nil { + return nil + } + return &moneyv1.Money{ + Amount: strings.TrimSpace(src.GetAmount()), + Currency: strings.ToUpper(strings.TrimSpace(src.GetCurrency())), + } +} diff --git a/api/payments/quotation/internal/service/quotation/quotation_service_v2/process_batch.go b/api/payments/quotation/internal/service/quotation/quotation_service_v2/process_batch.go new file mode 100644 index 00000000..24e4d80f --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/quotation_service_v2/process_batch.go @@ -0,0 +1,167 @@ +package quotation_service_v2 + +import ( + "context" + "errors" + "strings" + "time" + + "github.com/tech/sendico/payments/quotation/internal/service/quotation/batch_quote_processor_v2" + "github.com/tech/sendico/payments/quotation/internal/service/quotation/quote_idempotency_service" + "github.com/tech/sendico/payments/quotation/internal/service/quotation/quote_persistence_service" + "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" +) + +func (s *QuotationServiceV2) ProcessQuotePayments( + ctx context.Context, + req *quotationv2.QuotePaymentsRequest, +) (*QuotePaymentsResult, error) { + if err := s.validateDependencies(); err != nil { + return nil, err + } + + requestCtx, err := s.deps.Validator.ValidateQuotePayments(req) + if err != nil { + return nil, err + } + + fingerprint := "" + if !requestCtx.PreviewOnly { + fingerprint = s.deps.Idempotency.FingerprintQuotePayments(req) + reusedRecord, reused, reuseErr := s.deps.Idempotency.TryReuse(ctx, s.deps.QuotesStore, quote_idempotency_service.ReuseInput{ + OrganizationID: requestCtx.OrganizationID, + IdempotencyKey: requestCtx.IdempotencyKey, + Fingerprint: fingerprint, + Shape: quote_idempotency_service.QuoteShapeBatch, + }) + if reuseErr != nil { + return nil, reuseErr + } + if reused { + return s.batchResultFromRecord(reusedRecord) + } + } + + hydrated, err := s.deps.Hydrator.HydrateMany(ctx, transfer_intent_hydrator.HydrateManyInput{ + OrganizationRef: requestCtx.OrganizationRef, + InitiatorRef: requestCtx.InitiatorRef, + Intents: req.GetIntents(), + }) + if err != nil { + return nil, err + } + + quoteRef := "" + if !requestCtx.PreviewOnly { + quoteRef = normalizeQuoteRef(s.deps.NewRef()) + if quoteRef == "" { + return nil, merrors.InvalidArgument("quote_ref is required") + } + } + + collector := newItemCollector() + single := newSingleIntentProcessorV2( + s.deps.Computation, + s.deps.Classifier, + s.deps.ResponseMapper, + quoteRef, + s.deps.Now().UTC(), + collector, + ) + batch := batch_quote_processor_v2.New(single) + + batchOut, err := batch.Process(ctx, batch_quote_processor_v2.ProcessInput{ + Context: newBatchContext(requestCtx), + Intents: hydrated, + }) + if err != nil { + return nil, err + } + if batchOut == nil || len(batchOut.Items) != len(hydrated) { + return nil, merrors.InvalidArgument("batch quote output is invalid") + } + + quotes := make([]*quotationv2.PaymentQuote, 0, len(batchOut.Items)) + for _, item := range batchOut.Items { + if item == nil || item.Quote == nil { + return nil, merrors.InvalidArgument("batch item quote is required") + } + quotes = append(quotes, item.Quote) + } + + details := collector.Ordered(len(batchOut.Items)) + if len(details) != len(batchOut.Items) { + return nil, merrors.InvalidArgument("batch processing details are incomplete") + } + + response := "ationv2.QuotePaymentsResponse{ + QuoteRef: quoteRef, + Quotes: quotes, + IdempotencyKey: strings.TrimSpace(requestCtx.IdempotencyKey), + } + result := &QuotePaymentsResult{ + Response: response, + } + if requestCtx.PreviewOnly { + return result, nil + } + + expires := make([]time.Time, 0, len(details)) + intents := make([]model.PaymentIntent, 0, len(details)) + snapshots := make([]*model.PaymentQuoteSnapshot, 0, len(details)) + statuses := make([]*quote_persistence_service.StatusInput, 0, len(details)) + for _, detail := range details { + if detail == nil || detail.Intent.Amount == nil || detail.Quote == nil { + return nil, merrors.InvalidArgument("batch processing detail is incomplete") + } + expires = append(expires, detail.ExpiresAt) + intents = append(intents, detail.Intent) + snapshots = append(snapshots, quoteSnapshotFromComputed(detail.Quote)) + statuses = append(statuses, statusInputFromStatus(detail.Status)) + } + + expiresAt, ok := minExpiry(expires) + if !ok { + return nil, merrors.InvalidArgument("expires_at is required") + } + + record, err := s.deps.Persistence.BuildRecord(quote_persistence_service.PersistInput{ + OrganizationID: requestCtx.OrganizationID, + QuoteRef: quoteRef, + IdempotencyKey: requestCtx.IdempotencyKey, + Hash: fingerprint, + ExpiresAt: expiresAt, + Intents: intents, + Quotes: snapshots, + Statuses: statuses, + }) + if err != nil { + return nil, err + } + + stored, reused, err := s.deps.Idempotency.CreateOrReuse(ctx, s.deps.QuotesStore, quote_idempotency_service.CreateInput{ + Record: record, + Reuse: quote_idempotency_service.ReuseInput{ + OrganizationID: requestCtx.OrganizationID, + IdempotencyKey: requestCtx.IdempotencyKey, + Fingerprint: fingerprint, + Shape: quote_idempotency_service.QuoteShapeBatch, + }, + }) + if err != nil { + if errors.Is(err, quote_idempotency_service.ErrIdempotencyParamMismatch) || + errors.Is(err, quote_idempotency_service.ErrIdempotencyShapeMismatch) { + return nil, merrors.InvalidArgument(err.Error()) + } + return nil, err + } + if reused { + return s.batchResultFromRecord(stored) + } + + result.Record = stored + return result, nil +} diff --git a/api/payments/quotation/internal/service/quotation/quotation_service_v2/process_single.go b/api/payments/quotation/internal/service/quotation/quotation_service_v2/process_single.go new file mode 100644 index 00000000..a3a41c12 --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/quotation_service_v2/process_single.go @@ -0,0 +1,148 @@ +package quotation_service_v2 + +import ( + "context" + "errors" + "strings" + + "github.com/tech/sendico/payments/quotation/internal/service/quotation/batch_quote_processor_v2" + "github.com/tech/sendico/payments/quotation/internal/service/quotation/quote_idempotency_service" + "github.com/tech/sendico/payments/quotation/internal/service/quotation/quote_persistence_service" + "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" +) + +func (s *QuotationServiceV2) ProcessQuotePayment( + ctx context.Context, + req *quotationv2.QuotePaymentRequest, +) (*QuotePaymentResult, error) { + if err := s.validateDependencies(); err != nil { + return nil, err + } + + requestCtx, err := s.deps.Validator.ValidateQuotePayment(req) + if err != nil { + return nil, err + } + + fingerprint := "" + if !requestCtx.PreviewOnly { + fingerprint = s.deps.Idempotency.FingerprintQuotePayment(req) + reusedRecord, reused, reuseErr := s.deps.Idempotency.TryReuse(ctx, s.deps.QuotesStore, quote_idempotency_service.ReuseInput{ + OrganizationID: requestCtx.OrganizationID, + IdempotencyKey: requestCtx.IdempotencyKey, + Fingerprint: fingerprint, + Shape: quote_idempotency_service.QuoteShapeSingle, + }) + if reuseErr != nil { + return nil, reuseErr + } + if reused { + return s.singleResultFromRecord(reusedRecord) + } + } + + hydrated, err := s.deps.Hydrator.HydrateOne(ctx, transfer_intent_hydrator.HydrateOneInput{ + OrganizationRef: requestCtx.OrganizationRef, + InitiatorRef: requestCtx.InitiatorRef, + Intent: req.GetIntent(), + }) + if err != nil { + return nil, err + } + + quoteRef := "" + if !requestCtx.PreviewOnly { + quoteRef = normalizeQuoteRef(s.deps.NewRef()) + if quoteRef == "" { + return nil, merrors.InvalidArgument("quote_ref is required") + } + } + + collector := newItemCollector() + single := newSingleIntentProcessorV2( + s.deps.Computation, + s.deps.Classifier, + s.deps.ResponseMapper, + quoteRef, + s.deps.Now().UTC(), + collector, + ) + batch := batch_quote_processor_v2.New(single) + + batchOut, err := batch.Process(ctx, batch_quote_processor_v2.ProcessInput{ + Context: newBatchContext(requestCtx), + Intents: []*transfer_intent_hydrator.QuoteIntent{hydrated}, + }) + if err != nil { + return nil, err + } + if batchOut == nil || len(batchOut.Items) != 1 || batchOut.Items[0] == nil || batchOut.Items[0].Quote == nil { + return nil, merrors.InvalidArgument("single quote output is invalid") + } + + response := "ationv2.QuotePaymentResponse{ + Quote: batchOut.Items[0].Quote, + IdempotencyKey: strings.TrimSpace(requestCtx.IdempotencyKey), + } + + detail, ok := collector.Get(0) + if !ok || detail == nil { + return nil, merrors.InvalidArgument("single processing detail is required") + } + + result := &QuotePaymentResult{ + Response: response, + } + if requestCtx.PreviewOnly { + return result, nil + } + + expiresAt := detail.ExpiresAt + if expiresAt.IsZero() { + return nil, merrors.InvalidArgument("expires_at is required") + } + + record, err := s.deps.Persistence.BuildRecord(quote_persistence_service.PersistInput{ + OrganizationID: requestCtx.OrganizationID, + QuoteRef: quoteRef, + IdempotencyKey: requestCtx.IdempotencyKey, + Hash: fingerprint, + ExpiresAt: expiresAt, + Intent: pointerTo(detail.Intent), + Quote: quoteSnapshotFromComputed(detail.Quote), + Status: statusInputFromStatus(detail.Status), + }) + if err != nil { + return nil, err + } + + stored, reused, err := s.deps.Idempotency.CreateOrReuse(ctx, s.deps.QuotesStore, quote_idempotency_service.CreateInput{ + Record: record, + Reuse: quote_idempotency_service.ReuseInput{ + OrganizationID: requestCtx.OrganizationID, + IdempotencyKey: requestCtx.IdempotencyKey, + Fingerprint: fingerprint, + Shape: quote_idempotency_service.QuoteShapeSingle, + }, + }) + if err != nil { + if errors.Is(err, quote_idempotency_service.ErrIdempotencyParamMismatch) || + errors.Is(err, quote_idempotency_service.ErrIdempotencyShapeMismatch) { + return nil, merrors.InvalidArgument(err.Error()) + } + return nil, err + } + + if reused { + return s.singleResultFromRecord(stored) + } + result.Record = stored + return result, nil +} + +func pointerTo(intent model.PaymentIntent) *model.PaymentIntent { + return &intent +} diff --git a/api/payments/quotation/internal/service/quotation/quotation_service_v2/result.go b/api/payments/quotation/internal/service/quotation/quotation_service_v2/result.go new file mode 100644 index 00000000..78836154 --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/quotation_service_v2/result.go @@ -0,0 +1,16 @@ +package quotation_service_v2 + +import ( + "github.com/tech/sendico/payments/storage/model" + quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" +) + +type QuotePaymentResult struct { + Response *quotationv2.QuotePaymentResponse + Record *model.PaymentQuoteRecord +} + +type QuotePaymentsResult struct { + Response *quotationv2.QuotePaymentsResponse + Record *model.PaymentQuoteRecord +} diff --git a/api/payments/quotation/internal/service/quotation/quotation_service_v2/reuse.go b/api/payments/quotation/internal/service/quotation/quotation_service_v2/reuse.go new file mode 100644 index 00000000..a38576a8 --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/quotation_service_v2/reuse.go @@ -0,0 +1,81 @@ +package quotation_service_v2 + +import ( + "strings" + + "github.com/tech/sendico/payments/quotation/internal/service/quotation/quote_response_mapper_v2" + "github.com/tech/sendico/payments/storage/model" + "github.com/tech/sendico/pkg/merrors" + quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" +) + +func (s *QuotationServiceV2) singleResultFromRecord(record *model.PaymentQuoteRecord) (*QuotePaymentResult, error) { + if record == nil { + return nil, merrors.InvalidArgument("record is required") + } + if record.Quote == nil { + return nil, merrors.InvalidArgument("record quote is required") + } + + status := statusFromStored(record.StatusV2) + mapped, err := s.deps.ResponseMapper.Map(quote_response_mapper_v2.MapInput{ + Meta: quote_response_mapper_v2.QuoteMeta{ + ID: record.GetID().Hex(), + CreatedAt: record.CreatedAt, + UpdatedAt: record.UpdatedAt, + }, + Quote: canonicalFromSnapshot(record.Quote, record.ExpiresAt, s.deps.Now().UTC(), strings.TrimSpace(record.QuoteRef)), + Status: status, + }) + if err != nil { + return nil, err + } + return &QuotePaymentResult{ + Response: "ationv2.QuotePaymentResponse{ + Quote: mapped.Quote, + IdempotencyKey: strings.TrimSpace(record.IdempotencyKey), + }, + Record: record, + }, nil +} + +func (s *QuotationServiceV2) batchResultFromRecord(record *model.PaymentQuoteRecord) (*QuotePaymentsResult, error) { + if record == nil { + return nil, merrors.InvalidArgument("record is required") + } + if len(record.Quotes) == 0 { + return nil, merrors.InvalidArgument("record quotes are required") + } + + quotes := make([]*quotationv2.PaymentQuote, 0, len(record.Quotes)) + for idx, snapshot := range record.Quotes { + var storedStatus *model.QuoteStatusV2 + if idx < len(record.StatusesV2) { + storedStatus = record.StatusesV2[idx] + } + status := statusFromStored(storedStatus) + + mapped, err := s.deps.ResponseMapper.Map(quote_response_mapper_v2.MapInput{ + Meta: quote_response_mapper_v2.QuoteMeta{ + ID: record.GetID().Hex(), + CreatedAt: record.CreatedAt, + UpdatedAt: record.UpdatedAt, + }, + Quote: canonicalFromSnapshot(snapshot, record.ExpiresAt, s.deps.Now().UTC(), strings.TrimSpace(record.QuoteRef)), + Status: status, + }) + if err != nil { + return nil, err + } + quotes = append(quotes, mapped.Quote) + } + + return &QuotePaymentsResult{ + Response: "ationv2.QuotePaymentsResponse{ + QuoteRef: strings.TrimSpace(record.QuoteRef), + Quotes: quotes, + IdempotencyKey: strings.TrimSpace(record.IdempotencyKey), + }, + Record: record, + }, nil +} diff --git a/api/payments/quotation/internal/service/quotation/quotation_service_v2/route_conditions_converters.go b/api/payments/quotation/internal/service/quotation/quotation_service_v2/route_conditions_converters.go new file mode 100644 index 00000000..077632ec --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/quotation_service_v2/route_conditions_converters.go @@ -0,0 +1,180 @@ +package quotation_service_v2 + +import ( + "strings" + + paymenttypes "github.com/tech/sendico/pkg/payments/types" + quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" +) + +func modelRouteFromProto(src *quotationv2.RouteSpecification) *paymenttypes.QuoteRouteSpecification { + if src == nil { + return nil + } + result := &paymenttypes.QuoteRouteSpecification{ + 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()), + } + if hops := src.GetHops(); len(hops) > 0 { + result.Hops = make([]*paymenttypes.QuoteRouteHop, 0, len(hops)) + for _, hop := range hops { + if hop == nil { + continue + } + result.Hops = append(result.Hops, &paymenttypes.QuoteRouteHop{ + Index: hop.GetIndex(), + Rail: strings.TrimSpace(hop.GetRail()), + Gateway: strings.TrimSpace(hop.GetGateway()), + InstanceID: strings.TrimSpace(hop.GetInstanceId()), + Network: strings.TrimSpace(hop.GetNetwork()), + Role: routeHopRoleFromProto(hop.GetRole()), + }) + } + if len(result.Hops) == 0 { + result.Hops = nil + } + } + return result +} + +func protoRouteFromModel(src *paymenttypes.QuoteRouteSpecification) *quotationv2.RouteSpecification { + if src == nil { + return nil + } + result := "ationv2.RouteSpecification{ + 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), + } + if len(src.Hops) > 0 { + result.Hops = make([]*quotationv2.RouteHop, 0, len(src.Hops)) + for _, hop := range src.Hops { + if hop == nil { + continue + } + result.Hops = append(result.Hops, "ationv2.RouteHop{ + Index: hop.Index, + Rail: strings.TrimSpace(hop.Rail), + Gateway: strings.TrimSpace(hop.Gateway), + InstanceId: strings.TrimSpace(hop.InstanceID), + Network: strings.TrimSpace(hop.Network), + Role: routeHopRoleToProto(hop.Role), + }) + } + if len(result.Hops) == 0 { + result.Hops = nil + } + } + return result +} + +func modelExecutionConditionsFromProto(src *quotationv2.ExecutionConditions) *paymenttypes.QuoteExecutionConditions { + if src == nil { + return nil + } + return &paymenttypes.QuoteExecutionConditions{ + Readiness: readinessFromProto(src.GetReadiness()), + BatchingEligible: src.GetBatchingEligible(), + PrefundingRequired: src.GetPrefundingRequired(), + PrefundingCostIncluded: src.GetPrefundingCostIncluded(), + LiquidityCheckRequiredAtExecution: src.GetLiquidityCheckRequiredAtExecution(), + LatencyHint: strings.TrimSpace(src.GetLatencyHint()), + Assumptions: cloneStringSlice(src.GetAssumptions()), + } +} + +func protoExecutionConditionsFromModel(src *paymenttypes.QuoteExecutionConditions) *quotationv2.ExecutionConditions { + if src == nil { + return nil + } + return "ationv2.ExecutionConditions{ + Readiness: readinessToProto(src.Readiness), + BatchingEligible: src.BatchingEligible, + PrefundingRequired: src.PrefundingRequired, + PrefundingCostIncluded: src.PrefundingCostIncluded, + LiquidityCheckRequiredAtExecution: src.LiquidityCheckRequiredAtExecution, + LatencyHint: strings.TrimSpace(src.LatencyHint), + Assumptions: cloneStringSlice(src.Assumptions), + } +} + +func readinessFromProto(src quotationv2.QuoteExecutionReadiness) paymenttypes.QuoteExecutionReadiness { + switch src { + case quotationv2.QuoteExecutionReadiness_QUOTE_EXECUTION_READINESS_LIQUIDITY_READY: + return paymenttypes.QuoteExecutionReadinessLiquidityReady + case quotationv2.QuoteExecutionReadiness_QUOTE_EXECUTION_READINESS_LIQUIDITY_OBTAINABLE: + return paymenttypes.QuoteExecutionReadinessLiquidityObtainable + case quotationv2.QuoteExecutionReadiness_QUOTE_EXECUTION_READINESS_INDICATIVE: + return paymenttypes.QuoteExecutionReadinessIndicative + default: + return paymenttypes.QuoteExecutionReadinessUnspecified + } +} + +func readinessToProto(src paymenttypes.QuoteExecutionReadiness) quotationv2.QuoteExecutionReadiness { + switch src { + case paymenttypes.QuoteExecutionReadinessLiquidityReady: + return quotationv2.QuoteExecutionReadiness_QUOTE_EXECUTION_READINESS_LIQUIDITY_READY + case paymenttypes.QuoteExecutionReadinessLiquidityObtainable: + return quotationv2.QuoteExecutionReadiness_QUOTE_EXECUTION_READINESS_LIQUIDITY_OBTAINABLE + case paymenttypes.QuoteExecutionReadinessIndicative: + return quotationv2.QuoteExecutionReadiness_QUOTE_EXECUTION_READINESS_INDICATIVE + default: + return quotationv2.QuoteExecutionReadiness_QUOTE_EXECUTION_READINESS_UNSPECIFIED + } +} + +func cloneStringSlice(src []string) []string { + if len(src) == 0 { + return nil + } + result := make([]string, 0, len(src)) + for _, value := range src { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + continue + } + result = append(result, trimmed) + } + if len(result) == 0 { + return nil + } + return result +} + +func routeHopRoleFromProto(src quotationv2.RouteHopRole) paymenttypes.QuoteRouteHopRole { + switch src { + case quotationv2.RouteHopRole_ROUTE_HOP_ROLE_SOURCE: + return paymenttypes.QuoteRouteHopRoleSource + case quotationv2.RouteHopRole_ROUTE_HOP_ROLE_TRANSIT: + return paymenttypes.QuoteRouteHopRoleTransit + case quotationv2.RouteHopRole_ROUTE_HOP_ROLE_DESTINATION: + return paymenttypes.QuoteRouteHopRoleDestination + default: + return paymenttypes.QuoteRouteHopRoleUnspecified + } +} + +func routeHopRoleToProto(src paymenttypes.QuoteRouteHopRole) quotationv2.RouteHopRole { + switch src { + case paymenttypes.QuoteRouteHopRoleSource: + return quotationv2.RouteHopRole_ROUTE_HOP_ROLE_SOURCE + case paymenttypes.QuoteRouteHopRoleTransit: + return quotationv2.RouteHopRole_ROUTE_HOP_ROLE_TRANSIT + case paymenttypes.QuoteRouteHopRoleDestination: + return quotationv2.RouteHopRole_ROUTE_HOP_ROLE_DESTINATION + default: + return quotationv2.RouteHopRole_ROUTE_HOP_ROLE_UNSPECIFIED + } +} diff --git a/api/payments/quotation/internal/service/quotation/quotation_service_v2/service.go b/api/payments/quotation/internal/service/quotation/quotation_service_v2/service.go new file mode 100644 index 00000000..f2732282 --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/quotation_service_v2/service.go @@ -0,0 +1,140 @@ +package quotation_service_v2 + +import ( + "context" + "strings" + "time" + + "github.com/tech/sendico/payments/quotation/internal/service/quotation/batch_quote_processor_v2" + "github.com/tech/sendico/payments/quotation/internal/service/quotation/quote_computation_service" + "github.com/tech/sendico/payments/quotation/internal/service/quotation/quote_executability_classifier" + "github.com/tech/sendico/payments/quotation/internal/service/quotation/quote_idempotency_service" + "github.com/tech/sendico/payments/quotation/internal/service/quotation/quote_persistence_service" + quote_request_validator_v2 "github.com/tech/sendico/payments/quotation/internal/service/quotation/quote_request_validator" + "github.com/tech/sendico/payments/quotation/internal/service/quotation/quote_response_mapper_v2" + "github.com/tech/sendico/payments/quotation/internal/service/quotation/transfer_intent_hydrator" + quotestorage "github.com/tech/sendico/payments/storage/quote" + "github.com/tech/sendico/pkg/merrors" + quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" + "go.mongodb.org/mongo-driver/v2/bson" +) + +type Dependencies struct { + QuotesStore quotestorage.QuotesStore + Validator *quote_request_validator_v2.QuoteRequestValidatorV2 + Hydrator *transfer_intent_hydrator.TransferIntentHydrator + Idempotency *quote_idempotency_service.QuoteIdempotencyService + Computation *quote_computation_service.QuoteComputationService + Classifier *quote_executability_classifier.QuoteExecutabilityClassifier + Persistence *quote_persistence_service.QuotePersistenceService + ResponseMapper *quote_response_mapper_v2.QuoteResponseMapperV2 + Now func() time.Time + NewRef func() string +} + +type QuotationServiceV2 struct { + deps Dependencies + quotationv2.UnimplementedQuotationServiceServer +} + +func New(deps Dependencies) *QuotationServiceV2 { + if deps.Validator == nil { + deps.Validator = quote_request_validator_v2.New() + } + if deps.Idempotency == nil { + deps.Idempotency = quote_idempotency_service.New() + } + if deps.Classifier == nil { + deps.Classifier = quote_executability_classifier.New() + } + if deps.Persistence == nil { + deps.Persistence = quote_persistence_service.New() + } + if deps.ResponseMapper == nil { + deps.ResponseMapper = quote_response_mapper_v2.New() + } + if deps.Now == nil { + deps.Now = time.Now + } + if deps.NewRef == nil { + deps.NewRef = func() string { return bson.NewObjectID().Hex() } + } + return &QuotationServiceV2{deps: deps} +} + +func (s *QuotationServiceV2) QuotePayment(ctx context.Context, req *quotationv2.QuotePaymentRequest) (*quotationv2.QuotePaymentResponse, error) { + result, err := s.ProcessQuotePayment(ctx, req) + if err != nil { + return nil, err + } + return result.Response, nil +} + +func (s *QuotationServiceV2) QuotePayments(ctx context.Context, req *quotationv2.QuotePaymentsRequest) (*quotationv2.QuotePaymentsResponse, error) { + result, err := s.ProcessQuotePayments(ctx, req) + if err != nil { + return nil, err + } + return result.Response, nil +} + +func (s *QuotationServiceV2) validateDependencies() error { + if s == nil { + return merrors.InvalidArgument("service is required") + } + if s.deps.QuotesStore == nil { + return merrors.InvalidArgument("quotes store is required") + } + if s.deps.Validator == nil { + return merrors.InvalidArgument("validator is required") + } + if s.deps.Hydrator == nil { + return merrors.InvalidArgument("transfer intent hydrator is required") + } + if s.deps.Computation == nil { + return merrors.InvalidArgument("quote computation service is required") + } + if s.deps.Idempotency == nil { + return merrors.InvalidArgument("quote idempotency service is required") + } + if s.deps.Persistence == nil { + return merrors.InvalidArgument("quote persistence service is required") + } + if s.deps.ResponseMapper == nil { + return merrors.InvalidArgument("quote response mapper is required") + } + if s.deps.Classifier == nil { + return merrors.InvalidArgument("quote executability classifier is required") + } + if s.deps.Now == nil { + return merrors.InvalidArgument("now factory is required") + } + if s.deps.NewRef == nil { + return merrors.InvalidArgument("ref factory is required") + } + 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) +} + +func newBatchContext(ctx *quote_request_validator_v2.Context) batch_quote_processor_v2.BatchContext { + if ctx == nil { + return batch_quote_processor_v2.BatchContext{} + } + return batch_quote_processor_v2.BatchContext{ + OrganizationRef: strings.TrimSpace(ctx.OrganizationRef), + OrganizationID: ctx.OrganizationID, + InitiatorRef: strings.TrimSpace(ctx.InitiatorRef), + PreviewOnly: ctx.PreviewOnly, + BaseIdempotencyKey: strings.TrimSpace(ctx.IdempotencyKey), + } +} diff --git a/api/payments/quotation/internal/service/quotation/quotation_service_v2/service_e2e_test.go b/api/payments/quotation/internal/service/quotation/quotation_service_v2/service_e2e_test.go new file mode 100644 index 00000000..e5e6fa24 --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/quotation_service_v2/service_e2e_test.go @@ -0,0 +1,718 @@ +package quotation_service_v2 + +import ( + "context" + "fmt" + "strings" + "testing" + "time" + + "github.com/shopspring/decimal" + "github.com/tech/sendico/payments/quotation/internal/service/quotation/quote_computation_service" + "github.com/tech/sendico/payments/quotation/internal/service/quotation/transfer_intent_hydrator" + "github.com/tech/sendico/payments/storage/model" + quotestorage "github.com/tech/sendico/payments/storage/quote" + "github.com/tech/sendico/pkg/merrors" + pkgmodel "github.com/tech/sendico/pkg/model" + feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" + accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1" + fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + 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" + sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" + transferv1 "github.com/tech/sendico/pkg/proto/payments/transfer/v1" + "go.mongodb.org/mongo-driver/v2/bson" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/timestamppb" +) + +func TestQuotePayment_USDTToRUB_EndToEnd(t *testing.T) { + now := time.Unix(1_700_000_000, 0).UTC() + orgID := bson.NewObjectID() + + store := newInMemoryQuotesStore() + core := &fakeQuoteCore{now: now} + svc := New(Dependencies{ + QuotesStore: store, + Hydrator: transfer_intent_hydrator.New(nil, transfer_intent_hydrator.WithRefFactory(func() string { + return "q-intent-single" + })), + Computation: quote_computation_service.New(core), + Now: func() time.Time { return now }, + NewRef: func() string { return "quote-single-usdt-rub" }, + }) + + req := "ationv2.QuotePaymentRequest{ + Meta: &sharedv1.RequestMeta{ + OrganizationRef: orgID.Hex(), + }, + IdempotencyKey: "idem-single-usdt-rub", + InitiatorRef: "initiator-42", + PreviewOnly: false, + Intent: makeTransferIntent(t, "100", "USDT", "wallet-usdt-source", "4111111111111111", "RU"), + } + + result, err := svc.ProcessQuotePayment(context.Background(), req) + if err != nil { + t.Fatalf("ProcessQuotePayment returned error: %v", err) + } + if result == nil || result.Response == nil || result.Response.GetQuote() == nil { + t.Fatalf("expected quote response") + } + quote := result.Response.GetQuote() + + 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.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, want := quote.GetDebitAmount().GetAmount(), "100"; got != want { + t.Fatalf("unexpected debit amount: got=%q want=%q", got, want) + } + if got, want := quote.GetDebitAmount().GetCurrency(), "USDT"; got != want { + t.Fatalf("unexpected debit currency: got=%q want=%q", got, want) + } + if got, want := quote.GetCreditAmount().GetAmount(), "9150"; got != want { + t.Fatalf("unexpected credit amount: got=%q want=%q", got, want) + } + if got, want := quote.GetCreditAmount().GetCurrency(), "RUB"; got != want { + t.Fatalf("unexpected credit currency: got=%q want=%q", got, want) + } + if got, want := quote.GetTotalCost().GetAmount(), "101.8"; got != want { + t.Fatalf("unexpected total_cost amount: got=%q want=%q", got, want) + } + if got, want := quote.GetTotalCost().GetCurrency(), "USDT"; got != want { + t.Fatalf("unexpected total_cost currency: got=%q want=%q", got, want) + } + if got, want := len(quote.GetFeeLines()), 2; got != want { + t.Fatalf("unexpected fee lines count: got=%d want=%d", got, want) + } + if got, want := quote.GetFeeLines()[0].GetLineType(), accountingv1.PostingLineType_POSTING_LINE_FEE; got != want { + t.Fatalf("unexpected first fee line type: got=%s want=%s", got.String(), want.String()) + } + if got, want := len(quote.GetFeeRules()), 1; got != want { + t.Fatalf("unexpected fee rules count: got=%d want=%d", got, want) + } + if got := strings.TrimSpace(quote.GetFeeRules()[0].GetRuleId()); got == "" || !strings.Contains(got, "fee_") { + t.Fatalf("expected route-bound fee rule id, got=%q", got) + } + if quote.GetFxQuote() == nil { + t.Fatalf("expected fx quote") + } + if got, want := quote.GetFxQuote().GetPair().GetBase(), "USDT"; got != want { + t.Fatalf("unexpected fx base: got=%q want=%q", got, want) + } + if got, want := quote.GetFxQuote().GetPair().GetQuote(), "RUB"; got != want { + t.Fatalf("unexpected fx quote currency: got=%q want=%q", got, want) + } + 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, want := quote.GetRoute().GetProvider(), "monetix"; got != want { + t.Fatalf("unexpected route provider: got=%q want=%q", got, want) + } + if got := strings.TrimSpace(quote.GetRoute().GetRouteRef()); got == "" { + t.Fatalf("expected route_ref") + } + if got := strings.TrimSpace(quote.GetRoute().GetPricingProfileRef()); got == "" { + t.Fatalf("expected pricing_profile_ref") + } + if got, want := len(quote.GetRoute().GetHops()), 3; got != want { + t.Fatalf("unexpected route hops count: got=%d want=%d", got, want) + } + if quote.GetExecutionConditions() == nil { + t.Fatalf("expected execution conditions") + } + if got, want := quote.GetExecutionConditions().GetReadiness(), quotationv2.QuoteExecutionReadiness_QUOTE_EXECUTION_READINESS_LIQUIDITY_READY; got != want { + t.Fatalf("unexpected readiness: got=%s want=%s", got.String(), want.String()) + } + if quote.GetExecutionConditions().GetPrefundingRequired() { + t.Fatalf("expected prefunding_required=false") + } + + // Verify that idempotent reuse keeps full quote payload (including fee lines/rules). + reused, err := svc.ProcessQuotePayment(context.Background(), req) + if err != nil { + t.Fatalf("idempotent ProcessQuotePayment returned error: %v", err) + } + if reused == nil || reused.Response == nil || reused.Response.GetQuote() == nil { + t.Fatalf("expected idempotent quote response") + } + if got, want := len(reused.Response.GetQuote().GetFeeLines()), 2; got != want { + t.Fatalf("unexpected idempotent fee lines count: got=%d want=%d", got, want) + } + 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) + } + + t.Logf("single request:\n%s", mustProtoJSON(t, req)) + t.Logf("single response:\n%s", mustProtoJSON(t, result.Response)) +} + +func TestQuotePayments_USDTToRUB_ThreeItems_EndToEnd(t *testing.T) { + now := time.Unix(1_700_000_000, 0).UTC() + orgID := bson.NewObjectID() + + store := newInMemoryQuotesStore() + core := &fakeQuoteCore{now: now} + svc := New(Dependencies{ + QuotesStore: store, + Hydrator: transfer_intent_hydrator.New(nil, transfer_intent_hydrator.WithRefFactory(func() string { + return fmt.Sprintf("q-intent-%d", time.Now().UnixNano()) + })), + Computation: quote_computation_service.New(core), + Now: func() time.Time { return now }, + NewRef: func() string { return "quote-batch-usdt-rub" }, + }) + + req := "ationv2.QuotePaymentsRequest{ + Meta: &sharedv1.RequestMeta{ + OrganizationRef: orgID.Hex(), + }, + IdempotencyKey: "idem-batch-usdt-rub", + InitiatorRef: "initiator-42", + PreviewOnly: false, + Intents: []*transferv1.TransferIntent{ + makeTransferIntent(t, "100", "USDT", "wallet-usdt-source", "4111111111111111", "RU"), + makeTransferIntent(t, "125", "USDT", "wallet-usdt-source", "4222222222222222", "RU"), + makeTransferIntent(t, "80", "USDT", "wallet-usdt-source", "4333333333333333", "RU"), + }, + } + + result, err := svc.ProcessQuotePayments(context.Background(), req) + if err != nil { + t.Fatalf("ProcessQuotePayments returned error: %v", err) + } + if result == nil || result.Response == nil { + t.Fatalf("expected batch response") + } + + if got, want := result.Response.GetQuoteRef(), "quote-batch-usdt-rub"; got != want { + t.Fatalf("unexpected batch quote_ref: got=%q want=%q", got, want) + } + if got, want := len(result.Response.GetQuotes()), 3; got != want { + t.Fatalf("unexpected quote count: got=%d want=%d", got, want) + } + + for i, quote := range result.Response.GetQuotes() { + if quote == nil { + t.Fatalf("quote[%d] is nil", i) + } + 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 quote.GetDebitAmount().GetCurrency() != "USDT" { + t.Fatalf("unexpected debit currency for item %d: %q", i, quote.GetDebitAmount().GetCurrency()) + } + if quote.GetCreditAmount().GetCurrency() != "RUB" { + t.Fatalf("unexpected credit currency for item %d: %q", i, quote.GetCreditAmount().GetCurrency()) + } + 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().GetRouteRef()); got == "" { + t.Fatalf("expected route_ref for item %d", i) + } + if got := strings.TrimSpace(quote.GetRoute().GetPricingProfileRef()); got == "" { + t.Fatalf("expected pricing_profile_ref for item %d", i) + } + 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.GetExecutionConditions() == nil { + t.Fatalf("expected execution conditions for item %d", i) + } + if got, want := quote.GetExecutionConditions().GetReadiness(), quotationv2.QuoteExecutionReadiness_QUOTE_EXECUTION_READINESS_LIQUIDITY_READY; got != want { + t.Fatalf("unexpected readiness for item %d: got=%s want=%s", i, got.String(), want.String()) + } + if got, want := len(quote.GetFeeLines()), 2; got != want { + t.Fatalf("unexpected fee lines count for item %d: got=%d want=%d", i, got, want) + } + if got, want := len(quote.GetFeeRules()), 1; got != want { + t.Fatalf("unexpected fee rules count for item %d: got=%d want=%d", i, got, want) + } + } + + if got, want := core.quoteRequestIdempotencyKeys, []string{ + "idem-batch-usdt-rub:1", + "idem-batch-usdt-rub:2", + "idem-batch-usdt-rub:3", + }; !equalStrings(got, want) { + t.Fatalf("unexpected per-item idempotency keys: got=%v want=%v", got, want) + } + + t.Logf("batch request:\n%s", mustProtoJSON(t, req)) + t.Logf("batch response:\n%s", mustProtoJSON(t, result.Response)) +} + +func TestQuotePayment_SelectsEligibleGatewaysAndIgnoresIrrelevant(t *testing.T) { + now := time.Unix(1_700_000_000, 0).UTC() + orgID := bson.NewObjectID() + + store := newInMemoryQuotesStore() + core := &fakeQuoteCore{now: now} + svc := New(Dependencies{ + QuotesStore: store, + Hydrator: transfer_intent_hydrator.New(nil, transfer_intent_hydrator.WithRefFactory(func() string { + return "q-intent-topology" + })), + Computation: quote_computation_service.New( + core, + quote_computation_service.WithGatewayRegistry(staticGatewayRegistryForE2E{ + items: []*model.GatewayInstanceDescriptor{ + { + ID: "crypto-disabled", + InstanceID: "crypto-disabled", + Rail: model.RailCrypto, + Network: "TRON", + Currencies: []string{"USDT"}, + Capabilities: model.RailCapabilities{ + CanPayOut: true, + }, + IsEnabled: false, + }, + { + ID: "crypto-network-mismatch", + InstanceID: "crypto-network-mismatch", + Rail: model.RailCrypto, + Network: "ETH", + Currencies: []string{"USDT"}, + Capabilities: model.RailCapabilities{ + CanPayOut: true, + }, + IsEnabled: true, + }, + { + ID: "crypto-currency-mismatch", + InstanceID: "crypto-currency-mismatch", + Rail: model.RailCrypto, + Network: "TRON", + Currencies: []string{"EUR"}, + Capabilities: model.RailCapabilities{ + CanPayOut: true, + }, + IsEnabled: true, + }, + { + ID: "crypto-gw-1", + InstanceID: "crypto-gw-1", + Rail: model.RailCrypto, + Network: "TRON", + Currencies: []string{"USDT"}, + Capabilities: model.RailCapabilities{ + CanPayOut: true, + }, + IsEnabled: true, + }, + { + ID: "payout-disabled", + InstanceID: "payout-disabled", + Rail: model.RailCardPayout, + Currencies: []string{"USDT"}, + Capabilities: model.RailCapabilities{ + CanPayOut: true, + }, + IsEnabled: false, + }, + { + ID: "payout-currency-mismatch", + InstanceID: "payout-currency-mismatch", + Rail: model.RailCardPayout, + Currencies: []string{"EUR"}, + Capabilities: model.RailCapabilities{ + CanPayOut: true, + }, + IsEnabled: true, + }, + { + ID: "payout-gw-1", + InstanceID: "payout-gw-1", + Rail: model.RailCardPayout, + Currencies: []string{"USDT"}, + Capabilities: model.RailCapabilities{ + CanPayOut: true, + }, + IsEnabled: true, + }, + }, + }), + ), + Now: func() time.Time { return now }, + NewRef: func() string { return "quote-topology-usdt-rub" }, + }) + + req := "ationv2.QuotePaymentRequest{ + Meta: &sharedv1.RequestMeta{ + OrganizationRef: orgID.Hex(), + }, + IdempotencyKey: "idem-topology-usdt-rub", + InitiatorRef: "initiator-42", + PreviewOnly: false, + Intent: makeTransferIntent(t, "100", "USDT", "wallet-usdt-source", "4111111111111111", "RU"), + } + + result, err := svc.ProcessQuotePayment(context.Background(), req) + if err != nil { + t.Fatalf("ProcessQuotePayment returned error: %v", err) + } + if result == nil || result.Response == nil || result.Response.GetQuote() == nil { + t.Fatalf("expected quote response") + } + quote := result.Response.GetQuote() + 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, want := len(quote.GetRoute().GetHops()), 3; got != want { + t.Fatalf("unexpected hops count: got=%d want=%d", got, want) + } + if got, want := quote.GetRoute().GetHops()[0].GetGateway(), "crypto-gw-1"; got != want { + t.Fatalf("unexpected source hop gateway: got=%q want=%q", got, want) + } + if got, want := quote.GetRoute().GetHops()[1].GetGateway(), "internal"; got != want { + t.Fatalf("unexpected bridge hop gateway: got=%q want=%q", got, want) + } + 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 got, want := quote.GetTotalCost().GetAmount(), "102.4"; got != want { + t.Fatalf("unexpected total_cost amount: got=%q want=%q", got, want) + } +} + +func makeTransferIntent( + t *testing.T, + amount string, + currency string, + sourceWalletID string, + destinationPAN string, + destinationCountry string, +) *transferv1.TransferIntent { + t.Helper() + + walletData, err := bson.Marshal(pkgmodel.WalletPaymentData{WalletID: sourceWalletID}) + if err != nil { + t.Fatalf("failed to marshal wallet method data: %v", err) + } + cardData, err := bson.Marshal(pkgmodel.CardPaymentData{ + Pan: destinationPAN, + FirstName: "Ivan", + LastName: "Petrov", + ExpMonth: "12", + ExpYear: "2032", + Country: destinationCountry, + }) + if err != nil { + t.Fatalf("failed to marshal card method data: %v", err) + } + + return &transferv1.TransferIntent{ + Source: &endpointv1.PaymentEndpoint{ + Source: &endpointv1.PaymentEndpoint_PaymentMethod{ + PaymentMethod: &endpointv1.PaymentMethod{ + Type: endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_WALLET, + Data: walletData, + }, + }, + }, + Destination: &endpointv1.PaymentEndpoint{ + Source: &endpointv1.PaymentEndpoint_PaymentMethod{ + PaymentMethod: &endpointv1.PaymentMethod{ + Type: endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CARD, + Data: cardData, + }, + }, + }, + Amount: &moneyv1.Money{Amount: amount, Currency: currency}, + } +} + +func mustProtoJSON(t *testing.T, msg proto.Message) string { + t.Helper() + payload, err := protojson.MarshalOptions{Multiline: true, Indent: " "}.Marshal(msg) + if err != nil { + t.Fatalf("failed to marshal proto json: %v", err) + } + return string(payload) +} + +type fakeQuoteCore struct { + now time.Time + + quoteRequestIdempotencyKeys []string +} + +func (f *fakeQuoteCore) BuildQuote(_ context.Context, in quote_computation_service.BuildQuoteInput) (*quote_computation_service.ComputedQuote, time.Time, error) { + if strings.TrimSpace(in.IdempotencyKey) != "" { + f.quoteRequestIdempotencyKeys = append(f.quoteRequestIdempotencyKeys, strings.TrimSpace(in.IdempotencyKey)) + } + if in.Route == nil { + return nil, time.Time{}, fmt.Errorf("selected route is required for route-bound quote pricing") + } + if in.ExecutionConditions == nil { + return nil, time.Time{}, fmt.Errorf("execution conditions are required for route-bound quote pricing") + } + if strings.TrimSpace(in.Route.GetRouteRef()) == "" { + return nil, time.Time{}, fmt.Errorf("route_ref is required for route-bound quote pricing") + } + if strings.TrimSpace(in.Route.GetPricingProfileRef()) == "" { + return nil, time.Time{}, fmt.Errorf("pricing_profile_ref is required for route-bound quote pricing") + } + if len(in.Route.GetHops()) == 0 { + return nil, time.Time{}, fmt.Errorf("route hops are required for route-bound quote pricing") + } + + baseAmount := decimal.RequireFromString(in.Intent.Amount.GetAmount()) + rate := decimal.RequireFromString("91.5") + quoteAmount := baseAmount.Mul(rate) + feeAmount := decimal.RequireFromString("1.50") + taxAmount := decimal.RequireFromString("0.30") + if routeFeeClass(in.Route) != "card_payout:3_hops:monetix" { + feeAmount = decimal.RequireFromString("2.00") + taxAmount = decimal.RequireFromString("0.40") + } + + quote := "e_computation_service.ComputedQuote{ + DebitAmount: &moneyv1.Money{ + Amount: baseAmount.String(), + Currency: "USDT", + }, + CreditAmount: &moneyv1.Money{ + Amount: quoteAmount.String(), + Currency: "RUB", + }, + FeeLines: []*feesv1.DerivedPostingLine{ + { + LedgerAccountRef: "ledger:fees:usdt", + Money: &moneyv1.Money{Amount: feeAmount.StringFixed(2), Currency: "USDT"}, + LineType: accountingv1.PostingLineType_POSTING_LINE_FEE, + Side: accountingv1.EntrySide_ENTRY_SIDE_DEBIT, + Meta: map[string]string{ + "component": "platform_fee", + "provider": strings.TrimSpace(in.Route.GetProvider()), + }, + }, + { + LedgerAccountRef: "ledger:tax:usdt", + Money: &moneyv1.Money{Amount: taxAmount.StringFixed(2), Currency: "USDT"}, + LineType: accountingv1.PostingLineType_POSTING_LINE_TAX, + Side: accountingv1.EntrySide_ENTRY_SIDE_DEBIT, + Meta: map[string]string{ + "component": "vat", + "provider": strings.TrimSpace(in.Route.GetProvider()), + }, + }, + }, + FeeRules: []*feesv1.AppliedRule{ + { + RuleId: "rule.platform.usdt." + strings.TrimSpace(in.Route.GetPricingProfileRef()), + RuleVersion: "2026-02-01", + Formula: "flat(1.50)+tax(0.30)", + Rounding: moneyv1.RoundingMode_ROUND_HALF_UP, + TaxCode: "VAT", + TaxRate: "0.20", + Parameters: map[string]string{ + "country": "RU", + }, + }, + }, + Route: cloneRouteSpecForTest(in.Route), + ExecutionConditions: cloneExecutionConditionsForTest(in.ExecutionConditions), + FXQuote: &oraclev1.Quote{ + QuoteRef: "fx-usdt-rub", + Pair: &fxv1.CurrencyPair{ + Base: "USDT", + Quote: "RUB", + }, + Side: fxv1.Side_BUY_BASE_SELL_QUOTE, + Price: &moneyv1.Decimal{Value: rate.String()}, + BaseAmount: &moneyv1.Money{Amount: baseAmount.String(), Currency: "USDT"}, + QuoteAmount: &moneyv1.Money{Amount: quoteAmount.String(), Currency: "RUB"}, + ExpiresAtUnixMs: f.now.Add(5 * time.Minute).UnixMilli(), + Provider: "test-oracle", + RateRef: "rate-usdt-rub", + Firm: true, + PricedAt: timestamppb.New(f.now), + }, + } + return quote, f.now.Add(5 * time.Minute), nil +} + +func cloneRouteSpecForTest(src *quotationv2.RouteSpecification) *quotationv2.RouteSpecification { + if src == nil { + return nil + } + result := "ationv2.RouteSpecification{ + Rail: strings.TrimSpace(src.GetRail()), + Provider: strings.TrimSpace(src.GetProvider()), + PayoutMethod: strings.TrimSpace(src.GetPayoutMethod()), + SettlementAsset: strings.ToUpper(strings.TrimSpace(src.GetSettlementAsset())), + SettlementModel: strings.TrimSpace(src.GetSettlementModel()), + Network: strings.TrimSpace(src.GetNetwork()), + RouteRef: strings.TrimSpace(src.GetRouteRef()), + PricingProfileRef: strings.TrimSpace(src.GetPricingProfileRef()), + } + if hops := src.GetHops(); len(hops) > 0 { + result.Hops = make([]*quotationv2.RouteHop, 0, len(hops)) + for _, hop := range hops { + if hop == nil { + continue + } + result.Hops = append(result.Hops, "ationv2.RouteHop{ + Index: hop.GetIndex(), + Rail: strings.TrimSpace(hop.GetRail()), + Gateway: strings.TrimSpace(hop.GetGateway()), + InstanceId: strings.TrimSpace(hop.GetInstanceId()), + Network: strings.TrimSpace(hop.GetNetwork()), + Role: hop.GetRole(), + }) + } + if len(result.Hops) == 0 { + result.Hops = nil + } + } + return result +} + +func cloneExecutionConditionsForTest(src *quotationv2.ExecutionConditions) *quotationv2.ExecutionConditions { + if src == nil { + return nil + } + result := "ationv2.ExecutionConditions{ + Readiness: src.GetReadiness(), + BatchingEligible: src.GetBatchingEligible(), + PrefundingRequired: src.GetPrefundingRequired(), + PrefundingCostIncluded: src.GetPrefundingCostIncluded(), + LiquidityCheckRequiredAtExecution: src.GetLiquidityCheckRequiredAtExecution(), + LatencyHint: strings.TrimSpace(src.GetLatencyHint()), + } + if assumptions := src.GetAssumptions(); len(assumptions) > 0 { + result.Assumptions = make([]string, 0, len(assumptions)) + for _, assumption := range assumptions { + trimmed := strings.TrimSpace(assumption) + if trimmed == "" { + continue + } + result.Assumptions = append(result.Assumptions, trimmed) + } + if len(result.Assumptions) == 0 { + result.Assumptions = nil + } + } + return result +} + +func routeFeeClass(route *quotationv2.RouteSpecification) string { + if route == nil { + return "" + } + hops := route.GetHops() + destGateway := "" + if n := len(hops); n > 0 && hops[n-1] != nil { + destGateway = strings.ToLower(strings.TrimSpace(hops[n-1].GetGateway())) + } + return strings.ToLower(strings.TrimSpace(route.GetRail())) + + ":" + fmt.Sprintf("%d_hops", len(hops)) + + ":" + destGateway +} + +type inMemoryQuotesStore struct { + byRef map[string]*model.PaymentQuoteRecord + byKey map[string]*model.PaymentQuoteRecord +} + +type staticGatewayRegistryForE2E struct { + items []*model.GatewayInstanceDescriptor +} + +func (r staticGatewayRegistryForE2E) List(context.Context) ([]*model.GatewayInstanceDescriptor, error) { + if len(r.items) == 0 { + return nil, nil + } + out := make([]*model.GatewayInstanceDescriptor, 0, len(r.items)) + for _, item := range r.items { + if item == nil { + continue + } + cloned := *item + if item.Currencies != nil { + cloned.Currencies = append([]string(nil), item.Currencies...) + } + out = append(out, &cloned) + } + return out, nil +} + +func newInMemoryQuotesStore() *inMemoryQuotesStore { + return &inMemoryQuotesStore{ + byRef: map[string]*model.PaymentQuoteRecord{}, + byKey: map[string]*model.PaymentQuoteRecord{}, + } +} + +func (s *inMemoryQuotesStore) Create(_ context.Context, quote *model.PaymentQuoteRecord) error { + if quote == nil { + return merrors.InvalidArgument("quote is required") + } + if _, exists := s.byRef[quote.QuoteRef]; exists { + return quotestorage.ErrDuplicateQuote + } + if _, exists := s.byKey[quote.IdempotencyKey]; exists { + return quotestorage.ErrDuplicateQuote + } + s.byRef[quote.QuoteRef] = quote + s.byKey[quote.IdempotencyKey] = quote + return nil +} + +func (s *inMemoryQuotesStore) GetByRef(_ context.Context, orgRef bson.ObjectID, quoteRef string) (*model.PaymentQuoteRecord, error) { + record, ok := s.byRef[strings.TrimSpace(quoteRef)] + if !ok || record == nil || record.GetOrganizationRef() != orgRef { + return nil, quotestorage.ErrQuoteNotFound + } + return record, nil +} + +func (s *inMemoryQuotesStore) GetByIdempotencyKey(_ context.Context, orgRef bson.ObjectID, idempotencyKey string) (*model.PaymentQuoteRecord, error) { + record, ok := s.byKey[strings.TrimSpace(idempotencyKey)] + if !ok || record == nil || record.GetOrganizationRef() != orgRef { + return nil, quotestorage.ErrQuoteNotFound + } + return record, nil +} + +func equalStrings(got, want []string) bool { + if len(got) != len(want) { + return false + } + for i := range got { + if got[i] != want[i] { + return false + } + } + return true +} diff --git a/api/payments/quotation/internal/service/quotation/quotation_service_v2/single_processor.go b/api/payments/quotation/internal/service/quotation/quotation_service_v2/single_processor.go new file mode 100644 index 00000000..b4a9610a --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/quotation_service_v2/single_processor.go @@ -0,0 +1,182 @@ +package quotation_service_v2 + +import ( + "context" + "sort" + "time" + + "github.com/tech/sendico/payments/quotation/internal/service/quotation/batch_quote_processor_v2" + "github.com/tech/sendico/payments/quotation/internal/service/quotation/quote_computation_service" + "github.com/tech/sendico/payments/quotation/internal/service/quotation/quote_executability_classifier" + "github.com/tech/sendico/payments/quotation/internal/service/quotation/quote_response_mapper_v2" + "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 { + Index int + Intent model.PaymentIntent + Quote *quote_computation_service.ComputedQuote + ExpiresAt time.Time + Status quote_response_mapper_v2.QuoteStatus +} + +type itemCollector struct { + items map[int]*itemProcessDetail +} + +func newItemCollector() *itemCollector { + return &itemCollector{items: make(map[int]*itemProcessDetail)} +} + +func (c *itemCollector) Add(detail *itemProcessDetail) { + if c == nil || detail == nil { + return + } + c.items[detail.Index] = detail +} + +func (c *itemCollector) Get(index int) (*itemProcessDetail, bool) { + if c == nil { + return nil, false + } + detail, ok := c.items[index] + return detail, ok +} + +func (c *itemCollector) Ordered(count int) []*itemProcessDetail { + if c == nil || count <= 0 { + return nil + } + ordered := make([]*itemProcessDetail, 0, count) + indices := make([]int, 0, len(c.items)) + for idx := range c.items { + indices = append(indices, idx) + } + sort.Ints(indices) + for _, idx := range indices { + ordered = append(ordered, c.items[idx]) + } + return ordered +} + +type singleIntentProcessorV2 struct { + computation *quote_computation_service.QuoteComputationService + classifier *quote_executability_classifier.QuoteExecutabilityClassifier + mapper *quote_response_mapper_v2.QuoteResponseMapperV2 + + quoteRef string + pricedAt time.Time + collector *itemCollector +} + +func newSingleIntentProcessorV2( + computation *quote_computation_service.QuoteComputationService, + classifier *quote_executability_classifier.QuoteExecutabilityClassifier, + mapper *quote_response_mapper_v2.QuoteResponseMapperV2, + quoteRef string, + pricedAt time.Time, + collector *itemCollector, +) *singleIntentProcessorV2 { + return &singleIntentProcessorV2{ + computation: computation, + classifier: classifier, + mapper: mapper, + quoteRef: quoteRef, + pricedAt: pricedAt, + collector: collector, + } +} + +func (p *singleIntentProcessorV2) Process( + ctx context.Context, + in batch_quote_processor_v2.SingleProcessInput, +) (*batch_quote_processor_v2.SingleProcessOutput, error) { + + if p == nil || p.computation == nil { + return nil, merrors.InvalidArgument("quote computation service is required") + } + if p.classifier == nil { + return nil, merrors.InvalidArgument("quote executability classifier is required") + } + if p.mapper == nil { + return nil, merrors.InvalidArgument("quote response mapper is required") + } + if in.Item.Intent == nil { + return nil, merrors.InvalidArgument("intent is required") + } + + computed, err := p.computation.Compute(ctx, quote_computation_service.ComputeInput{ + OrganizationRef: in.Context.OrganizationRef, + OrganizationID: in.Context.OrganizationID, + BaseIdempotencyKey: in.Item.IdempotencyKey, + PreviewOnly: in.Context.PreviewOnly, + Intents: []*transfer_intent_hydrator.QuoteIntent{in.Item.Intent}, + }) + if err != nil { + return nil, err + } + if computed == nil || computed.Plan == nil || len(computed.Results) != 1 || len(computed.Plan.Items) != 1 { + return nil, merrors.InvalidArgument("invalid computation output for single item") + } + + result := computed.Results[0] + planItem := computed.Plan.Items[0] + if result == nil || planItem == nil || result.Quote == nil || planItem.Intent.Amount == nil { + 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) + 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() + } + } + + canonical := quote_response_mapper_v2.CanonicalQuote{ + QuoteRef: p.quoteRef, + DebitAmount: cloneProtoMoney(result.Quote.DebitAmount), + CreditAmount: cloneProtoMoney(result.Quote.CreditAmount), + TotalCost: cloneProtoMoney(result.Quote.TotalCost), + FeeLines: result.Quote.FeeLines, + FeeRules: result.Quote.FeeRules, + FXQuote: result.Quote.FXQuote, + Route: result.Quote.Route, + Conditions: result.Quote.ExecutionConditions, + ExpiresAt: result.ExpiresAt, + PricedAt: p.pricedAt, + } + + mapped, mapErr := p.mapper.Map(quote_response_mapper_v2.MapInput{ + Quote: canonical, + Status: status, + }) + if mapErr != nil { + return nil, mapErr + } + if mapped == nil || mapped.Quote == nil { + return nil, merrors.InvalidArgument("mapped quote is required") + } + + p.collector.Add(&itemProcessDetail{ + Index: in.Item.Index, + Intent: planItem.Intent, + Quote: result.Quote, + ExpiresAt: result.ExpiresAt, + Status: status, + }) + + return &batch_quote_processor_v2.SingleProcessOutput{ + Quote: mapped.Quote, + }, nil +} diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/compute.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/compute.go new file mode 100644 index 00000000..92edafd8 --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/compute.go @@ -0,0 +1,62 @@ +package quote_computation_service + +import ( + "context" + "fmt" + + "github.com/tech/sendico/pkg/merrors" +) + +func (s *QuoteComputationService) Compute(ctx context.Context, in ComputeInput) (*ComputeOutput, error) { + if s == nil || s.core == nil { + return nil, merrors.InvalidArgument("quote computation core is required") + } + + planModel, err := s.BuildPlan(ctx, in) + if err != nil { + return nil, err + } + + results := make([]*QuoteComputationResult, 0, len(planModel.Items)) + for _, item := range planModel.Items { + computed, computeErr := s.computePlanItem(ctx, item) + if computeErr != nil { + if item == nil { + return nil, computeErr + } + return nil, fmt.Errorf("Item %d: %w", item.Index, computeErr) + } + results = append(results, computed) + } + + return &ComputeOutput{ + Plan: planModel, + Results: results, + }, nil +} + +func (s *QuoteComputationService) computePlanItem( + ctx context.Context, + item *QuoteComputationPlanItem, +) (*QuoteComputationResult, error) { + if item == nil || item.QuoteInput.Intent.Amount == nil { + return nil, merrors.InvalidArgument("plan item is required") + } + + quote, expiresAt, err := s.core.BuildQuote(ctx, item.QuoteInput) + if err != nil { + return nil, err + } + enrichedQuote := ensureComputedQuote(quote, item) + if bindErr := validateQuoteRouteBinding(enrichedQuote, item.QuoteInput); bindErr != nil { + return nil, bindErr + } + + result := &QuoteComputationResult{ + ItemIndex: item.Index, + Quote: enrichedQuote, + ExpiresAt: expiresAt, + BlockReason: item.BlockReason, + } + return result, nil +} diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/compute_test.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/compute_test.go new file mode 100644 index 00000000..4e4178dc --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/compute_test.go @@ -0,0 +1,501 @@ +package quote_computation_service + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/tech/sendico/payments/quotation/internal/service/quotation/gateway_funding_profile" + "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" + paymenttypes "github.com/tech/sendico/pkg/payments/types" + feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" + accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func TestBuildPlan_BuildsStepsAndFundingGate(t *testing.T) { + svc := New(nil, WithFundingProfileResolver(gateway_funding_profile.NewStaticFundingProfileResolver(gateway_funding_profile.StaticFundingProfileResolverInput{ + GatewayModes: map[string]model.FundingMode{ + "monetix": model.FundingModeBalanceReserve, + }, + }))) + + orgID := bson.NewObjectID() + intent := sampleCardQuoteIntent() + intent.Attributes["ledger_block_account_ref"] = "ledger:block" + + planModel, err := svc.BuildPlan(context.Background(), ComputeInput{ + OrganizationRef: orgID.Hex(), + OrganizationID: orgID, + BaseIdempotencyKey: "idem-key", + PreviewOnly: false, + Intents: []*transfer_intent_hydrator.QuoteIntent{intent}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if planModel == nil || len(planModel.Items) != 1 { + t.Fatalf("expected single plan item") + } + + item := planModel.Items[0] + if item == nil { + t.Fatalf("expected plan item") + } + if item.IdempotencyKey != "idem-key" { + t.Fatalf("expected item idempotency key idem-key, got %q", item.IdempotencyKey) + } + if item.QuoteInput.IdempotencyKey != "idem-key" { + t.Fatalf("expected quote input idempotency key idem-key, got %q", item.QuoteInput.IdempotencyKey) + } + if len(item.Steps) != 2 { + t.Fatalf("expected 2 steps, got %d", len(item.Steps)) + } + if item.Steps[0].Operation != model.RailOperationMove { + t.Fatalf("expected source operation MOVE, got %q", item.Steps[0].Operation) + } + if item.Steps[1].Operation != model.RailOperationSend { + t.Fatalf("expected destination operation SEND, got %q", item.Steps[1].Operation) + } + if item.Funding == nil { + t.Fatalf("expected funding gate") + } + if item.Funding.Mode != model.FundingModeBalanceReserve { + t.Fatalf("expected funding mode balance_reserve, got %q", item.Funding.Mode) + } + 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 := item.Route.GetRouteRef(); got == "" { + t.Fatalf("expected route_ref") + } + if got := item.Route.GetPricingProfileRef(); got == "" { + t.Fatalf("expected pricing_profile_ref") + } + if got, want := len(item.Route.GetHops()), 2; got != want { + t.Fatalf("unexpected route hops count: got=%d want=%d", got, want) + } + if item.ExecutionConditions == nil { + t.Fatalf("expected execution conditions") + } + if !item.ExecutionConditions.GetPrefundingRequired() { + t.Fatalf("expected prefunding required for balance reserve mode") + } +} + +func TestBuildPlan_RequiresFXAddsMiddleStep(t *testing.T) { + svc := New(nil) + orgID := bson.NewObjectID() + intent := sampleCardQuoteIntent() + intent.RequiresFX = true + + planModel, err := svc.BuildPlan(context.Background(), ComputeInput{ + OrganizationRef: orgID.Hex(), + OrganizationID: orgID, + BaseIdempotencyKey: "idem-key", + PreviewOnly: false, + Intents: []*transfer_intent_hydrator.QuoteIntent{intent}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(planModel.Items) != 1 || len(planModel.Items[0].Steps) != 3 { + t.Fatalf("expected 3 steps for FX intent") + } + if got := planModel.Items[0].Steps[1].Operation; got != model.RailOperationFXConvert { + t.Fatalf("expected middle step FX_CONVERT, got %q", got) + } + if planModel.Items[0].Route == nil { + t.Fatalf("expected route specification") + } + if got, want := len(planModel.Items[0].Route.GetHops()), 3; got != want { + t.Fatalf("unexpected route hops count: got=%d want=%d", got, want) + } + if got, want := planModel.Items[0].Route.GetHops()[1].GetRole(), quotationv2.RouteHopRole_ROUTE_HOP_ROLE_TRANSIT; got != want { + t.Fatalf("unexpected middle hop role: got=%s want=%s", got.String(), want.String()) + } +} + +func TestBuildPlan_SelectsGatewaysAndIgnoresIrrelevant(t *testing.T) { + svc := New(nil, WithGatewayRegistry(staticGatewayRegistry{ + items: []*model.GatewayInstanceDescriptor{ + { + ID: "crypto-disabled", + InstanceID: "crypto-disabled", + Rail: model.RailCrypto, + Network: "TRON", + Currencies: []string{"USDT"}, + Capabilities: model.RailCapabilities{ + CanPayOut: true, + }, + IsEnabled: false, + }, + { + ID: "crypto-network-mismatch", + InstanceID: "crypto-network-mismatch", + Rail: model.RailCrypto, + Network: "ETH", + Currencies: []string{"USDT"}, + Capabilities: model.RailCapabilities{ + CanPayOut: true, + }, + IsEnabled: true, + }, + { + ID: "crypto-currency-mismatch", + InstanceID: "crypto-currency-mismatch", + Rail: model.RailCrypto, + Network: "TRON", + Currencies: []string{"EUR"}, + Capabilities: model.RailCapabilities{ + CanPayOut: true, + }, + IsEnabled: true, + }, + { + ID: "crypto-gw-1", + InstanceID: "crypto-gw-1", + Rail: model.RailCrypto, + Network: "TRON", + Currencies: []string{"USDT"}, + Capabilities: model.RailCapabilities{ + CanPayOut: true, + }, + IsEnabled: true, + }, + { + ID: "payout-disabled", + InstanceID: "payout-disabled", + Rail: model.RailCardPayout, + Currencies: []string{"USDT"}, + Capabilities: model.RailCapabilities{ + CanPayOut: true, + }, + IsEnabled: false, + }, + { + ID: "payout-currency-mismatch", + InstanceID: "payout-currency-mismatch", + Rail: model.RailCardPayout, + Currencies: []string{"EUR"}, + Capabilities: model.RailCapabilities{ + CanPayOut: true, + }, + IsEnabled: true, + }, + { + ID: "payout-gw-1", + InstanceID: "payout-gw-1", + Rail: model.RailCardPayout, + Currencies: []string{"USDT"}, + Capabilities: model.RailCapabilities{ + CanPayOut: true, + }, + IsEnabled: true, + }, + { + ID: "provider-ignored", + InstanceID: "provider-ignored", + Rail: model.RailProviderSettlement, + Network: "TRON", + Currencies: []string{"USDT"}, + IsEnabled: true, + }, + }, + })) + + orgID := bson.NewObjectID() + planModel, err := svc.BuildPlan(context.Background(), ComputeInput{ + OrganizationRef: orgID.Hex(), + OrganizationID: orgID, + BaseIdempotencyKey: "idem-key", + PreviewOnly: false, + Intents: []*transfer_intent_hydrator.QuoteIntent{sampleCryptoToCardQuoteIntent()}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if planModel == nil || len(planModel.Items) != 1 { + t.Fatalf("expected one plan item") + } + + item := planModel.Items[0] + if item == nil { + t.Fatalf("expected non-nil plan item") + } + if got, want := len(item.Steps), 3; got != want { + t.Fatalf("unexpected step count: got=%d want=%d", got, want) + } + if got, want := item.Steps[0].GatewayID, "crypto-gw-1"; got != want { + t.Fatalf("unexpected source gateway: got=%q want=%q", got, want) + } + if got, want := item.Steps[1].GatewayID, "internal"; got != want { + t.Fatalf("unexpected bridge gateway: got=%q want=%q", got, want) + } + if got, want := item.Steps[2].GatewayID, "payout-gw-1"; got != want { + t.Fatalf("unexpected destination gateway: got=%q want=%q", got, want) + } + 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, want := len(item.Route.GetHops()), 3; got != want { + t.Fatalf("unexpected route hop count: got=%d want=%d", got, want) + } + if got, want := item.Route.GetHops()[1].GetRail(), "LEDGER"; got != want { + t.Fatalf("unexpected bridge rail: got=%q want=%q", got, want) + } + 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 got := item.Route.GetRouteRef(); got == "" { + t.Fatalf("expected route_ref") + } + if got := item.Route.GetPricingProfileRef(); got == "" { + t.Fatalf("expected pricing_profile_ref") + } +} + +func TestCompute_EnrichesRouteConditionsAndTotalCost(t *testing.T) { + core := &fakeCore{ + quote: &ComputedQuote{ + DebitAmount: &moneyv1.Money{Amount: "100", Currency: "USD"}, + FeeLines: []*feesv1.DerivedPostingLine{ + { + Money: &moneyv1.Money{Amount: "1.50", Currency: "USD"}, + Side: accountingv1.EntrySide_ENTRY_SIDE_DEBIT, + LineType: accountingv1.PostingLineType_POSTING_LINE_FEE, + }, + }, + }, + expiresAt: time.Unix(1000, 0), + } + svc := New(core, WithFundingProfileResolver(gateway_funding_profile.NewStaticFundingProfileResolver(gateway_funding_profile.StaticFundingProfileResolverInput{ + GatewayModes: map[string]model.FundingMode{ + "monetix": model.FundingModeBalanceReserve, + }, + }))) + + orgID := bson.NewObjectID() + output, err := svc.Compute(context.Background(), ComputeInput{ + OrganizationRef: orgID.Hex(), + OrganizationID: orgID, + BaseIdempotencyKey: "idem-key", + PreviewOnly: false, + Intents: []*transfer_intent_hydrator.QuoteIntent{sampleCardQuoteIntent()}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if output == nil || len(output.Results) != 1 { + t.Fatalf("expected single result") + } + result := output.Results[0] + if result == nil || result.Quote == nil { + t.Fatalf("expected result quote") + } + 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 := result.Quote.Route.GetRouteRef(); got == "" { + t.Fatalf("expected route_ref") + } + if got := result.Quote.Route.GetPricingProfileRef(); got == "" { + t.Fatalf("expected pricing_profile_ref") + } + 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.ExecutionConditions == nil { + t.Fatalf("expected execution conditions") + } + if got := result.Quote.ExecutionConditions.GetReadiness(); got != quotationv2.QuoteExecutionReadiness_QUOTE_EXECUTION_READINESS_LIQUIDITY_OBTAINABLE { + t.Fatalf("unexpected readiness: %s", got.String()) + } + if result.Quote.TotalCost == nil { + t.Fatalf("expected total cost") + } + if got, want := result.Quote.TotalCost.GetAmount(), "101.5"; got != want { + t.Fatalf("unexpected total cost amount: got=%q want=%q", got, want) + } + 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) + } + if core.lastQuoteIn.ExecutionConditions == nil { + t.Fatalf("expected execution conditions to be passed into build quote input") + } + if got := core.lastQuoteIn.ExecutionConditions.GetPrefundingRequired(); !got { + t.Fatalf("expected prefunding_required in build quote input for reserve mode") + } +} + +func TestCompute_PreviewMarksIndicativeReadiness(t *testing.T) { + core := &fakeCore{ + quote: &ComputedQuote{ + DebitAmount: &moneyv1.Money{Amount: "100", Currency: "USD"}, + }, + expiresAt: time.Unix(1000, 0), + } + svc := New(core) + + orgID := bson.NewObjectID() + output, err := svc.Compute(context.Background(), ComputeInput{ + OrganizationRef: orgID.Hex(), + OrganizationID: orgID, + PreviewOnly: true, + Intents: []*transfer_intent_hydrator.QuoteIntent{sampleCardQuoteIntent()}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if output == nil || len(output.Results) != 1 { + t.Fatalf("expected single result") + } + if output.Results[0].Quote == nil || output.Results[0].Quote.ExecutionConditions == nil { + t.Fatalf("expected execution conditions") + } + if got := output.Results[0].Quote.ExecutionConditions.GetReadiness(); got != quotationv2.QuoteExecutionReadiness_QUOTE_EXECUTION_READINESS_INDICATIVE { + t.Fatalf("unexpected readiness: %s", got.String()) + } +} + +func TestCompute_FailsWhenCoreReturnsDifferentRoute(t *testing.T) { + core := &fakeCore{ + quote: &ComputedQuote{ + DebitAmount: &moneyv1.Money{Amount: "100", Currency: "USD"}, + Route: "ationv2.RouteSpecification{ + Rail: "CARD_PAYOUT", + Provider: "other-provider", + PayoutMethod: "CARD", + }, + }, + expiresAt: time.Unix(1000, 0), + } + svc := New(core) + + orgID := bson.NewObjectID() + _, err := svc.Compute(context.Background(), ComputeInput{ + OrganizationRef: orgID.Hex(), + OrganizationID: orgID, + BaseIdempotencyKey: "idem-key", + PreviewOnly: false, + Intents: []*transfer_intent_hydrator.QuoteIntent{sampleCardQuoteIntent()}, + }) + if !errors.Is(err, merrors.ErrInvalidArg) { + t.Fatalf("expected invalid argument for route mismatch, got %v", err) + } +} + +type fakeCore struct { + quote *ComputedQuote + expiresAt time.Time + quoteErr error + quoteCalls int + lastQuoteIn BuildQuoteInput +} + +func (f *fakeCore) BuildQuote(_ context.Context, in BuildQuoteInput) (*ComputedQuote, time.Time, error) { + f.quoteCalls++ + f.lastQuoteIn = in + if f.quoteErr != nil { + return nil, time.Time{}, f.quoteErr + } + return f.quote, f.expiresAt, nil +} + +func sampleCardQuoteIntent() *transfer_intent_hydrator.QuoteIntent { + return &transfer_intent_hydrator.QuoteIntent{ + Ref: "intent-1", + Kind: transfer_intent_hydrator.QuoteIntentKindPayout, + SettlementMode: transfer_intent_hydrator.QuoteSettlementModeFixSource, + Source: transfer_intent_hydrator.QuoteEndpoint{ + Type: transfer_intent_hydrator.QuoteEndpointTypeLedger, + Ledger: &transfer_intent_hydrator.QuoteLedgerEndpoint{ + LedgerAccountRef: "ledger:src", + ContraLedgerAccountRef: "ledger:contra", + }, + }, + Destination: transfer_intent_hydrator.QuoteEndpoint{ + Type: transfer_intent_hydrator.QuoteEndpointTypeCard, + Card: &transfer_intent_hydrator.QuoteCardEndpoint{ + Token: "tok_1", + }, + }, + Amount: &paymenttypes.Money{ + Amount: "100", + Currency: "USD", + }, + SettlementCurrency: "USD", + Attributes: map[string]string{ + "gateway": "monetix", + }, + } +} + +func sampleCryptoToCardQuoteIntent() *transfer_intent_hydrator.QuoteIntent { + return &transfer_intent_hydrator.QuoteIntent{ + Ref: "intent-crypto-card", + Kind: transfer_intent_hydrator.QuoteIntentKindPayout, + SettlementMode: transfer_intent_hydrator.QuoteSettlementModeFixSource, + Source: transfer_intent_hydrator.QuoteEndpoint{ + Type: transfer_intent_hydrator.QuoteEndpointTypeManagedWallet, + ManagedWallet: &transfer_intent_hydrator.QuoteManagedWalletEndpoint{ + ManagedWalletRef: "wallet-usdt-source", + Asset: &paymenttypes.Asset{ + Chain: "TRON", + TokenSymbol: "USDT", + }, + }, + }, + Destination: transfer_intent_hydrator.QuoteEndpoint{ + Type: transfer_intent_hydrator.QuoteEndpointTypeCard, + Card: &transfer_intent_hydrator.QuoteCardEndpoint{ + Token: "tok_1", + }, + }, + Amount: &paymenttypes.Money{ + Amount: "100", + Currency: "USDT", + }, + SettlementCurrency: "USDT", + Attributes: map[string]string{}, + } +} + +type staticGatewayRegistry struct { + items []*model.GatewayInstanceDescriptor +} + +func (r staticGatewayRegistry) List(context.Context) ([]*model.GatewayInstanceDescriptor, error) { + if len(r.items) == 0 { + return nil, nil + } + out := make([]*model.GatewayInstanceDescriptor, 0, len(r.items)) + for _, item := range r.items { + if item == nil { + continue + } + cloned := *item + if item.Currencies != nil { + cloned.Currencies = append([]string(nil), item.Currencies...) + } + out = append(out, &cloned) + } + return out, nil +} diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/computed_quote_enricher.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/computed_quote_enricher.go new file mode 100644 index 00000000..d46d6f21 --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/computed_quote_enricher.go @@ -0,0 +1,136 @@ +package quote_computation_service + +import ( + "strings" + + "github.com/shopspring/decimal" + 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" + quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" +) + +func ensureComputedQuote(src *ComputedQuote, item *QuoteComputationPlanItem) *ComputedQuote { + if src == nil { + src = &ComputedQuote{} + } + if item == nil { + return src + } + if src.Route == nil { + src.Route = cloneRouteSpecification(item.Route) + } + if src.ExecutionConditions == nil { + src.ExecutionConditions = cloneExecutionConditions(item.ExecutionConditions) + } + if src.TotalCost == nil { + src.TotalCost = deriveTotalCost(src.DebitAmount, src.FeeLines) + } + return src +} + +func deriveTotalCost( + debitAmount *moneyv1.Money, + feeLines []*feesv1.DerivedPostingLine, +) *moneyv1.Money { + if debitAmount == nil { + return nil + } + currency := strings.ToUpper(strings.TrimSpace(debitAmount.GetCurrency())) + baseValue, err := decimal.NewFromString(strings.TrimSpace(debitAmount.GetAmount())) + if err != nil { + return nil + } + + total := baseValue + for _, line := range feeLines { + if line == nil || line.GetMoney() == nil { + continue + } + lineCurrency := strings.ToUpper(strings.TrimSpace(line.GetMoney().GetCurrency())) + if lineCurrency == "" || lineCurrency != currency { + continue + } + lineAmount, convErr := decimal.NewFromString(strings.TrimSpace(line.GetMoney().GetAmount())) + if convErr != nil { + continue + } + switch line.GetSide() { + case accountingv1.EntrySide_ENTRY_SIDE_DEBIT: + total = total.Add(lineAmount) + case accountingv1.EntrySide_ENTRY_SIDE_CREDIT: + total = total.Sub(lineAmount) + } + } + return &moneyv1.Money{ + Amount: total.String(), + Currency: currency, + } +} + +func cloneRouteSpecification(src *quotationv2.RouteSpecification) *quotationv2.RouteSpecification { + if src == nil { + return nil + } + result := "ationv2.RouteSpecification{ + Rail: strings.TrimSpace(src.GetRail()), + Provider: strings.TrimSpace(src.GetProvider()), + PayoutMethod: strings.TrimSpace(src.GetPayoutMethod()), + SettlementAsset: strings.ToUpper(strings.TrimSpace(src.GetSettlementAsset())), + SettlementModel: strings.TrimSpace(src.GetSettlementModel()), + Network: strings.TrimSpace(src.GetNetwork()), + RouteRef: strings.TrimSpace(src.GetRouteRef()), + PricingProfileRef: strings.TrimSpace(src.GetPricingProfileRef()), + } + if hops := src.GetHops(); len(hops) > 0 { + result.Hops = make([]*quotationv2.RouteHop, 0, len(hops)) + for _, hop := range hops { + if cloned := cloneRouteHop(hop); cloned != nil { + result.Hops = append(result.Hops, cloned) + } + } + if len(result.Hops) == 0 { + result.Hops = nil + } + } + return result +} + +func cloneExecutionConditions(src *quotationv2.ExecutionConditions) *quotationv2.ExecutionConditions { + if src == nil { + return nil + } + result := "ationv2.ExecutionConditions{ + Readiness: src.GetReadiness(), + BatchingEligible: src.GetBatchingEligible(), + PrefundingRequired: src.GetPrefundingRequired(), + PrefundingCostIncluded: src.GetPrefundingCostIncluded(), + LiquidityCheckRequiredAtExecution: src.GetLiquidityCheckRequiredAtExecution(), + LatencyHint: strings.TrimSpace(src.GetLatencyHint()), + } + for _, assumption := range src.GetAssumptions() { + value := strings.TrimSpace(assumption) + if value == "" { + continue + } + result.Assumptions = append(result.Assumptions, value) + } + if len(result.Assumptions) == 0 { + result.Assumptions = nil + } + return result +} + +func cloneRouteHop(src *quotationv2.RouteHop) *quotationv2.RouteHop { + if src == nil { + return nil + } + return "ationv2.RouteHop{ + Index: src.GetIndex(), + Rail: strings.TrimSpace(src.GetRail()), + Gateway: strings.TrimSpace(src.GetGateway()), + InstanceId: strings.TrimSpace(src.GetInstanceId()), + Network: strings.TrimSpace(src.GetNetwork()), + Role: src.GetRole(), + } +} diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/gateway_selector.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/gateway_selector.go new file mode 100644 index 00000000..ff2a684b --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/gateway_selector.go @@ -0,0 +1,176 @@ +package quote_computation_service + +import ( + "context" + "fmt" + "sort" + "strings" + + "github.com/shopspring/decimal" + "github.com/tech/sendico/payments/quotation/internal/service/plan" + "github.com/tech/sendico/payments/storage/model" + "github.com/tech/sendico/pkg/merrors" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" +) + +func (s *QuoteComputationService) resolveStepGateways( + ctx context.Context, + steps []*QuoteComputationStep, + routeNetwork string, +) error { + if s == nil || s.gatewayRegistry == nil { + return nil + } + + gateways, err := s.gatewayRegistry.List(ctx) + if err != nil { + return err + } + if len(gateways) == 0 { + return merrors.InvalidArgument("gateway registry has no entries") + } + + sorted := make([]*model.GatewayInstanceDescriptor, 0, len(gateways)) + for _, gw := range gateways { + if gw != nil { + sorted = append(sorted, gw) + } + } + sort.Slice(sorted, func(i, j int) bool { + return strings.TrimSpace(sorted[i].ID) < strings.TrimSpace(sorted[j].ID) + }) + + for idx, step := range steps { + if step == nil { + continue + } + if strings.TrimSpace(step.GatewayID) != "" { + continue + } + if step.Rail == model.RailLedger { + step.GatewayID = "internal" + continue + } + + selected, selectErr := selectGatewayForStep(sorted, step, routeNetwork) + if selectErr != nil { + 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) + } + } + + return nil +} + +func selectGatewayForStep( + gateways []*model.GatewayInstanceDescriptor, + step *QuoteComputationStep, + routeNetwork string, +) (*model.GatewayInstanceDescriptor, error) { + if step == nil { + return nil, merrors.InvalidArgument("step is required") + } + if len(gateways) == 0 { + return nil, merrors.InvalidArgument("gateway list is empty") + } + + currency := "" + amount := decimal.Zero + if step.Amount != nil { + currency = strings.ToUpper(strings.TrimSpace(step.Amount.GetCurrency())) + if parsed, err := parseDecimalAmount(step.Amount); err == nil { + amount = parsed + } + } + action := gatewayEligibilityOperation(step.Operation) + direction := plan.SendDirectionForRail(step.Rail) + network := networkForGatewaySelection(step.Rail, routeNetwork) + + 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 + } + + if lastErr != nil { + return nil, merrors.InvalidArgument("no eligible gateway: " + lastErr.Error()) + } + return nil, merrors.InvalidArgument("no eligible gateway") +} + +func parseDecimalAmount(m *moneyv1.Money) (decimal.Decimal, error) { + if m == nil { + return decimal.Zero, nil + } + value := strings.TrimSpace(m.GetAmount()) + if value == "" { + return decimal.Zero, nil + } + parsed, err := decimal.NewFromString(value) + if err != nil { + return decimal.Zero, err + } + return parsed, nil +} + +func gatewayEligibilityOperation(op model.RailOperation) model.RailOperation { + switch op { + case model.RailOperationExternalDebit, model.RailOperationExternalCredit: + return model.RailOperationSend + default: + return op + } +} + +func networkForGatewaySelection(rail model.Rail, routeNetwork string) string { + switch rail { + case model.RailCrypto, model.RailProviderSettlement, model.RailFiatOnRamp: + return strings.ToUpper(strings.TrimSpace(routeNetwork)) + default: + return "" + } +} + +func hasExplicitDestinationGateway(attrs map[string]string) bool { + return strings.TrimSpace(firstNonEmpty( + lookupAttr(attrs, "gateway", "gateway_id", "gatewayId"), + lookupAttr(attrs, "destination_gateway", "destinationGateway"), + )) != "" +} + +func clearImplicitDestinationGateway(steps []*QuoteComputationStep) { + if len(steps) == 0 { + return + } + last := steps[len(steps)-1] + if last == nil { + return + } + last.GatewayID = "" +} + +func destinationGatewayFromSteps(steps []*QuoteComputationStep) string { + for i := len(steps) - 1; i >= 0; i-- { + step := steps[i] + if step == nil { + continue + } + if gateway := normalizeGatewayKey(step.GatewayID); gateway != "" { + return gateway + } + } + return "" +} diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/helpers.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/helpers.go new file mode 100644 index 00000000..db3698b3 --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/helpers.go @@ -0,0 +1,167 @@ +package quote_computation_service + +import ( + "strings" + + "github.com/tech/sendico/payments/storage/model" + paymenttypes "github.com/tech/sendico/pkg/payments/types" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" +) + +const defaultCardGateway = "monetix" + +func cloneProtoMoney(src *moneyv1.Money) *moneyv1.Money { + if src == nil { + return nil + } + return &moneyv1.Money{ + Amount: strings.TrimSpace(src.GetAmount()), + Currency: strings.TrimSpace(src.GetCurrency()), + } +} + +func protoMoneyFromModel(src *paymenttypes.Money) *moneyv1.Money { + if src == nil { + return nil + } + return &moneyv1.Money{ + Amount: strings.TrimSpace(src.GetAmount()), + Currency: strings.ToUpper(strings.TrimSpace(src.GetCurrency())), + } +} + +func cloneStringMap(src map[string]string) map[string]string { + if len(src) == 0 { + return nil + } + out := make(map[string]string, len(src)) + for k, v := range src { + key := strings.TrimSpace(k) + if key == "" { + continue + } + out[key] = strings.TrimSpace(v) + } + if len(out) == 0 { + return nil + } + return out +} + +func cloneAsset(src *paymenttypes.Asset) *paymenttypes.Asset { + if src == nil { + return nil + } + return &paymenttypes.Asset{ + Chain: strings.TrimSpace(src.Chain), + TokenSymbol: strings.TrimSpace(src.TokenSymbol), + ContractAddress: strings.TrimSpace(src.ContractAddress), + } +} + +func cloneModelMoney(src *paymenttypes.Money) *paymenttypes.Money { + if src == nil { + return nil + } + return &paymenttypes.Money{ + Amount: strings.TrimSpace(src.GetAmount()), + Currency: strings.ToUpper(strings.TrimSpace(src.GetCurrency())), + } +} + +func clonePaymentIntent(src model.PaymentIntent) model.PaymentIntent { + out := model.PaymentIntent{ + Ref: strings.TrimSpace(src.Ref), + Kind: src.Kind, + Source: clonePaymentEndpoint(src.Source), + Destination: clonePaymentEndpoint(src.Destination), + Amount: cloneModelMoney(src.Amount), + RequiresFX: src.RequiresFX, + FX: nil, + FeePolicy: src.FeePolicy, + SettlementMode: src.SettlementMode, + SettlementCurrency: strings.ToUpper(strings.TrimSpace(src.SettlementCurrency)), + Attributes: cloneStringMap(src.Attributes), + Customer: src.Customer, + } + if src.FX != nil { + fx := *src.FX + out.FX = &fx + } + return out +} + +func clonePaymentEndpoint(src model.PaymentEndpoint) model.PaymentEndpoint { + out := model.PaymentEndpoint{ + Type: src.Type, + InstanceID: strings.TrimSpace(src.InstanceID), + Metadata: nil, + Ledger: nil, + ManagedWallet: nil, + ExternalChain: nil, + Card: nil, + } + if src.Ledger != nil { + out.Ledger = &model.LedgerEndpoint{ + LedgerAccountRef: strings.TrimSpace(src.Ledger.LedgerAccountRef), + ContraLedgerAccountRef: strings.TrimSpace(src.Ledger.ContraLedgerAccountRef), + } + } + if src.ManagedWallet != nil { + out.ManagedWallet = &model.ManagedWalletEndpoint{ + ManagedWalletRef: strings.TrimSpace(src.ManagedWallet.ManagedWalletRef), + Asset: cloneAsset(src.ManagedWallet.Asset), + } + } + if src.ExternalChain != nil { + out.ExternalChain = &model.ExternalChainEndpoint{ + Asset: cloneAsset(src.ExternalChain.Asset), + Address: strings.TrimSpace(src.ExternalChain.Address), + Memo: strings.TrimSpace(src.ExternalChain.Memo), + } + } + if src.Card != nil { + out.Card = &model.CardEndpoint{ + Pan: strings.TrimSpace(src.Card.Pan), + Token: strings.TrimSpace(src.Card.Token), + Cardholder: strings.TrimSpace(src.Card.Cardholder), + CardholderSurname: strings.TrimSpace(src.Card.CardholderSurname), + ExpMonth: src.Card.ExpMonth, + ExpYear: src.Card.ExpYear, + Country: strings.TrimSpace(src.Card.Country), + MaskedPan: strings.TrimSpace(src.Card.MaskedPan), + } + } + if len(src.Metadata) > 0 { + out.Metadata = cloneStringMap(src.Metadata) + } + return out +} + +func lookupAttr(attrs map[string]string, keys ...string) string { + if len(attrs) == 0 { + return "" + } + for _, key := range keys { + if key == "" { + continue + } + if val := strings.TrimSpace(attrs[key]); val != "" { + return val + } + } + return "" +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func normalizeGatewayKey(value string) string { + return strings.ToLower(strings.TrimSpace(value)) +} diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/input.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/input.go new file mode 100644 index 00000000..6719e2fc --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/input.go @@ -0,0 +1,19 @@ +package quote_computation_service + +import ( + "github.com/tech/sendico/payments/quotation/internal/service/quotation/transfer_intent_hydrator" + "go.mongodb.org/mongo-driver/v2/bson" +) + +type ComputeInput struct { + OrganizationRef string + OrganizationID bson.ObjectID + BaseIdempotencyKey string + PreviewOnly bool + Intents []*transfer_intent_hydrator.QuoteIntent +} + +type ComputeOutput struct { + Plan *QuoteComputationPlan + Results []*QuoteComputationResult +} diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/intent_adapters.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/intent_adapters.go new file mode 100644 index 00000000..8b36bd0f --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/intent_adapters.go @@ -0,0 +1,108 @@ +package quote_computation_service + +import ( + "strings" + + "github.com/tech/sendico/payments/quotation/internal/service/quotation/transfer_intent_hydrator" + "github.com/tech/sendico/payments/storage/model" +) + +func modelIntentFromQuoteIntent(src *transfer_intent_hydrator.QuoteIntent) model.PaymentIntent { + if src == nil { + return model.PaymentIntent{} + } + settlementCurrency := strings.ToUpper(strings.TrimSpace(src.SettlementCurrency)) + if settlementCurrency == "" && src.Amount != nil { + settlementCurrency = strings.ToUpper(strings.TrimSpace(src.Amount.Currency)) + } + + return model.PaymentIntent{ + Ref: strings.TrimSpace(src.Ref), + Kind: modelPaymentKind(src.Kind, src.Destination), + Source: modelEndpointFromQuoteEndpoint(src.Source), + Destination: modelEndpointFromQuoteEndpoint(src.Destination), + Amount: cloneModelMoney(src.Amount), + RequiresFX: src.RequiresFX, + Attributes: cloneStringMap(src.Attributes), + SettlementMode: modelSettlementMode(src.SettlementMode), + SettlementCurrency: settlementCurrency, + } +} + +func modelEndpointFromQuoteEndpoint(src transfer_intent_hydrator.QuoteEndpoint) model.PaymentEndpoint { + result := model.PaymentEndpoint{ + Type: model.EndpointTypeUnspecified, + } + + switch src.Type { + case transfer_intent_hydrator.QuoteEndpointTypeLedger: + result.Type = model.EndpointTypeLedger + if src.Ledger != nil { + result.Ledger = &model.LedgerEndpoint{ + LedgerAccountRef: strings.TrimSpace(src.Ledger.LedgerAccountRef), + ContraLedgerAccountRef: strings.TrimSpace(src.Ledger.ContraLedgerAccountRef), + } + } + case transfer_intent_hydrator.QuoteEndpointTypeManagedWallet: + result.Type = model.EndpointTypeManagedWallet + if src.ManagedWallet != nil { + result.ManagedWallet = &model.ManagedWalletEndpoint{ + ManagedWalletRef: strings.TrimSpace(src.ManagedWallet.ManagedWalletRef), + Asset: cloneAsset(src.ManagedWallet.Asset), + } + } + case transfer_intent_hydrator.QuoteEndpointTypeExternalChain: + result.Type = model.EndpointTypeExternalChain + if src.ExternalChain != nil { + result.ExternalChain = &model.ExternalChainEndpoint{ + Asset: cloneAsset(src.ExternalChain.Asset), + Address: strings.TrimSpace(src.ExternalChain.Address), + Memo: strings.TrimSpace(src.ExternalChain.Memo), + } + } + case transfer_intent_hydrator.QuoteEndpointTypeCard: + result.Type = model.EndpointTypeCard + if src.Card != nil { + result.Card = &model.CardEndpoint{ + Pan: strings.TrimSpace(src.Card.Pan), + Token: strings.TrimSpace(src.Card.Token), + Cardholder: strings.TrimSpace(src.Card.Cardholder), + CardholderSurname: strings.TrimSpace(src.Card.CardholderSurname), + ExpMonth: src.Card.ExpMonth, + ExpYear: src.Card.ExpYear, + Country: strings.TrimSpace(src.Card.Country), + MaskedPan: strings.TrimSpace(src.Card.MaskedPan), + } + } + } + + return result +} + +func modelPaymentKind(kind transfer_intent_hydrator.QuoteIntentKind, destination transfer_intent_hydrator.QuoteEndpoint) model.PaymentKind { + switch kind { + case transfer_intent_hydrator.QuoteIntentKindPayout: + return model.PaymentKindPayout + case transfer_intent_hydrator.QuoteIntentKindInternalTransfer: + return model.PaymentKindInternalTransfer + case transfer_intent_hydrator.QuoteIntentKindFXConversion: + return model.PaymentKindFXConversion + } + switch destination.Type { + case transfer_intent_hydrator.QuoteEndpointTypeExternalChain, transfer_intent_hydrator.QuoteEndpointTypeCard: + return model.PaymentKindPayout + default: + return model.PaymentKindInternalTransfer + } +} + +func modelSettlementMode(mode transfer_intent_hydrator.QuoteSettlementMode) model.SettlementMode { + switch mode { + case transfer_intent_hydrator.QuoteSettlementModeFixSource: + return model.SettlementModeFixSource + case transfer_intent_hydrator.QuoteSettlementModeFixReceived: + return model.SettlementModeFixReceived + default: + return model.SettlementModeUnspecified + } +} diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/plan.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/plan.go new file mode 100644 index 00000000..3b4c95a5 --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/plan.go @@ -0,0 +1,95 @@ +package quote_computation_service + +import ( + "time" + + "github.com/tech/sendico/payments/quotation/internal/service/quotation/gateway_funding_profile" + "github.com/tech/sendico/payments/storage/model" + "github.com/tech/sendico/pkg/model/account_role" + feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" + quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" + "go.mongodb.org/mongo-driver/v2/bson" +) + +// PlanMode defines whether the computation plan is for one intent or many. +type PlanMode string + +const ( + PlanModeUnspecified PlanMode = "unspecified" + PlanModeSingle PlanMode = "single" + PlanModeBatch PlanMode = "batch" +) + +// BuildQuoteInput is the domain input for one quote computation request. +type BuildQuoteInput struct { + OrganizationRef string + IdempotencyKey string + PreviewOnly bool + Intent model.PaymentIntent + Route *quotationv2.RouteSpecification + ExecutionConditions *quotationv2.ExecutionConditions +} + +// ComputedQuote is a canonical quote payload for v2 processing. +type ComputedQuote struct { + QuoteRef string + DebitAmount *moneyv1.Money + CreditAmount *moneyv1.Money + TotalCost *moneyv1.Money + FeeLines []*feesv1.DerivedPostingLine + FeeRules []*feesv1.AppliedRule + FXQuote *oraclev1.Quote + Route *quotationv2.RouteSpecification + ExecutionConditions *quotationv2.ExecutionConditions +} + +// QuoteComputationPlan is an orchestration plan for quote computations. +// It is intentionally separate from executable payment plans. +type QuoteComputationPlan struct { + Mode PlanMode + OrganizationRef string + OrganizationID bson.ObjectID + PreviewOnly bool + BaseIdempotencyKey string + Items []*QuoteComputationPlanItem +} + +// QuoteComputationPlanItem is one quote-computation unit. +type QuoteComputationPlanItem struct { + Index int + IdempotencyKey string + IntentRef string + Intent model.PaymentIntent + QuoteInput BuildQuoteInput + Steps []*QuoteComputationStep + Funding *gateway_funding_profile.QuoteFundingGate + Route *quotationv2.RouteSpecification + ExecutionConditions *quotationv2.ExecutionConditions + BlockReason quotationv2.QuoteBlockReason +} + +// QuoteComputationStep is one planner step in a generic execution graph. +// The planner should use rail+operation instead of custom per-case leg kinds. +type QuoteComputationStep struct { + StepID string + Rail model.Rail + Operation model.RailOperation + GatewayID string + InstanceID string + DependsOn []string + Amount *moneyv1.Money + FromRole *account_role.AccountRole + ToRole *account_role.AccountRole + Optional bool + IncludeInAggregate bool +} + +// QuoteComputationResult is the computed output for one planned item. +type QuoteComputationResult struct { + ItemIndex int + Quote *ComputedQuote + ExpiresAt time.Time + BlockReason quotationv2.QuoteBlockReason +} diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/planner.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/planner.go new file mode 100644 index 00000000..48b7a971 --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/planner.go @@ -0,0 +1,244 @@ +package quote_computation_service + +import ( + "context" + "fmt" + "strings" + + "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/transfer_intent_hydrator" + "github.com/tech/sendico/payments/storage/model" + "github.com/tech/sendico/pkg/merrors" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func (s *QuoteComputationService) BuildPlan(ctx context.Context, in ComputeInput) (*QuoteComputationPlan, error) { + if strings.TrimSpace(in.OrganizationRef) == "" { + return nil, merrors.InvalidArgument("organization_ref is required") + } + if in.OrganizationID == bson.NilObjectID { + return nil, merrors.InvalidArgument("organization_id is required") + } + if len(in.Intents) == 0 { + return nil, merrors.InvalidArgument("intents are required") + } + if !in.PreviewOnly && strings.TrimSpace(in.BaseIdempotencyKey) == "" { + return nil, merrors.InvalidArgument("idempotency_key is required") + } + + mode := PlanModeSingle + if len(in.Intents) > 1 { + mode = PlanModeBatch + } + planModel := &QuoteComputationPlan{ + Mode: mode, + OrganizationRef: strings.TrimSpace(in.OrganizationRef), + OrganizationID: in.OrganizationID, + PreviewOnly: in.PreviewOnly, + BaseIdempotencyKey: strings.TrimSpace(in.BaseIdempotencyKey), + Items: make([]*QuoteComputationPlanItem, 0, len(in.Intents)), + } + + for i, intent := range in.Intents { + item, err := s.buildPlanItem(ctx, in, i, intent) + if err != nil { + return nil, fmt.Errorf("intents[%d]: %w", i, err) + } + planModel.Items = append(planModel.Items, item) + } + + return planModel, nil +} + +func (s *QuoteComputationService) buildPlanItem( + ctx context.Context, + in ComputeInput, + index int, + intent *transfer_intent_hydrator.QuoteIntent, +) (*QuoteComputationPlanItem, error) { + if intent == nil { + return nil, merrors.InvalidArgument("intent is required") + } + + modelIntent := modelIntentFromQuoteIntent(intent) + if modelIntent.Amount == nil { + return nil, merrors.InvalidArgument("intent.amount is required") + } + if modelIntent.Source.Type == model.EndpointTypeUnspecified { + return nil, merrors.InvalidArgument("intent.source is required") + } + if modelIntent.Destination.Type == model.EndpointTypeUnspecified { + return nil, merrors.InvalidArgument("intent.destination is required") + } + + itemIdempotencyKey := deriveItemIdempotencyKey(strings.TrimSpace(in.BaseIdempotencyKey), len(in.Intents), index) + + source := clonePaymentEndpoint(modelIntent.Source) + destination := clonePaymentEndpoint(modelIntent.Destination) + _, sourceNetwork, err := plan.RailFromEndpoint(source, modelIntent.Attributes, true) + if err != nil { + return nil, err + } + destRail, destNetwork, err := plan.RailFromEndpoint(destination, modelIntent.Attributes, false) + if err != nil { + return nil, err + } + routeNetwork, err := plan.ResolveRouteNetwork(modelIntent.Attributes, sourceNetwork, destNetwork) + if err != nil { + return nil, err + } + + steps := buildComputationSteps(index, modelIntent, destination) + if modelIntent.Destination.Type == model.EndpointTypeCard && + s.gatewayRegistry != nil && + !hasExplicitDestinationGateway(modelIntent.Attributes) { + // Avoid sticky default provider when registry-driven selection is available. + clearImplicitDestinationGateway(steps) + } + if err := s.resolveStepGateways( + ctx, + steps, + firstNonEmpty(routeNetwork, destNetwork, sourceNetwork), + ); err != nil { + return nil, err + } + provider := firstNonEmpty( + destinationGatewayFromSteps(steps), + gatewayKeyForFunding(modelIntent.Attributes, destination), + ) + if provider == "" && destRail == model.RailLedger { + provider = "internal" + } + funding, err := s.resolveFundingGate(ctx, resolveFundingGateInput{ + OrganizationRef: strings.TrimSpace(in.OrganizationRef), + Rail: destRail, + Network: firstNonEmpty(routeNetwork, destNetwork, sourceNetwork), + Amount: protoMoneyFromModel(modelIntent.Amount), + Source: source, + Destination: destination, + Attributes: modelIntent.Attributes, + Currency: firstNonEmpty( + strings.TrimSpace(modelIntent.SettlementCurrency), + strings.TrimSpace(modelIntent.Amount.GetCurrency()), + ), + GatewayID: provider, + InstanceID: instanceIDForFunding(modelIntent.Attributes), + }) + if err != nil { + return nil, err + } + 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) { + blockReason = quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_ROUTE_UNAVAILABLE + } + quoteInput := BuildQuoteInput{ + OrganizationRef: strings.TrimSpace(in.OrganizationRef), + IdempotencyKey: itemIdempotencyKey, + Intent: clonePaymentIntent(modelIntent), + PreviewOnly: in.PreviewOnly, + Route: cloneRouteSpecification(route), + ExecutionConditions: cloneExecutionConditions(conditions), + } + + intentRef := strings.TrimSpace(modelIntent.Ref) + if intentRef == "" { + intentRef = fmt.Sprintf("intent-%d", index) + } + + return &QuoteComputationPlanItem{ + Index: index, + IdempotencyKey: itemIdempotencyKey, + IntentRef: intentRef, + Intent: modelIntent, + QuoteInput: quoteInput, + Steps: steps, + Funding: funding, + Route: route, + ExecutionConditions: conditions, + BlockReason: blockReason, + }, nil +} + +func deriveItemIdempotencyKey(base string, total, index int) string { + base = strings.TrimSpace(base) + if base == "" { + return "" + } + if total <= 1 { + return base + } + return fmt.Sprintf("%s:%d", base, index+1) +} + +func gatewayKeyForFunding(attrs map[string]string, destination model.PaymentEndpoint) string { + key := firstNonEmpty( + lookupAttr(attrs, "gateway", "gateway_id", "gatewayId"), + lookupAttr(attrs, "destination_gateway", "destinationGateway"), + ) + if key == "" && destination.Card != nil { + return defaultCardGateway + } + return normalizeGatewayKey(key) +} + +func instanceIDForFunding(attrs map[string]string) string { + return strings.TrimSpace(lookupAttr(attrs, + "instance_id", + "instanceId", + "destination_instance_id", + "destinationInstanceId", + )) +} + +type resolveFundingGateInput struct { + OrganizationRef string + GatewayID string + InstanceID string + Rail model.Rail + Network string + Currency string + Amount *moneyv1.Money + Source model.PaymentEndpoint + Destination model.PaymentEndpoint + Attributes map[string]string +} + +func (s *QuoteComputationService) resolveFundingGate( + ctx context.Context, + in resolveFundingGateInput, +) (*gateway_funding_profile.QuoteFundingGate, error) { + if s == nil || s.fundingResolver == nil { + return nil, nil + } + + profile, err := s.fundingResolver.ResolveGatewayFundingProfile(ctx, gateway_funding_profile.FundingProfileRequest{ + OrganizationRef: strings.TrimSpace(in.OrganizationRef), + GatewayID: normalizeGatewayKey(in.GatewayID), + InstanceID: strings.TrimSpace(in.InstanceID), + Rail: in.Rail, + Network: strings.TrimSpace(in.Network), + Currency: strings.ToUpper(strings.TrimSpace(in.Currency)), + Amount: in.Amount, + Source: &in.Source, + Destination: &in.Destination, + Attributes: in.Attributes, + }) + if err != nil { + return nil, err + } + if profile == nil { + return nil, nil + } + return gateway_funding_profile.BuildFundingGateFromProfile(profile, in.Amount) +} diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/planner_steps.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/planner_steps.go new file mode 100644 index 00000000..f60ddc8c --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/planner_steps.go @@ -0,0 +1,147 @@ +package quote_computation_service + +import ( + "fmt" + "strings" + + "github.com/tech/sendico/payments/storage/model" +) + +func buildComputationSteps( + index int, + intent model.PaymentIntent, + destination model.PaymentEndpoint, +) []*QuoteComputationStep { + if intent.Amount == nil { + return nil + } + + attrs := intent.Attributes + amount := protoMoneyFromModel(intent.Amount) + sourceRail := sourceRailForIntent(intent) + destinationRail := destinationRailForIntent(intent) + sourceGatewayID := strings.TrimSpace(lookupAttr(attrs, + "source_gateway", + "sourceGateway", + "source_gateway_id", + "sourceGatewayId", + )) + sourceInstanceID := strings.TrimSpace(lookupAttr(attrs, "source_instance_id", "sourceInstanceId")) + destinationGatewayID := gatewayKeyForFunding(attrs, destination) + destinationInstanceID := firstNonEmpty( + strings.TrimSpace(lookupAttr(attrs, "destination_instance_id", "destinationInstanceId")), + strings.TrimSpace(lookupAttr(attrs, "instance_id", "instanceId")), + ) + + sourceStepID := fmt.Sprintf("i%d.source", index) + steps := []*QuoteComputationStep{ + { + StepID: sourceStepID, + Rail: sourceRail, + Operation: sourceOperationForRail(sourceRail), + GatewayID: sourceGatewayID, + InstanceID: sourceInstanceID, + Amount: cloneProtoMoney(amount), + Optional: false, + IncludeInAggregate: false, + }, + } + + lastStepID := sourceStepID + if intent.RequiresFX { + fxStepID := fmt.Sprintf("i%d.fx", index) + steps = append(steps, &QuoteComputationStep{ + StepID: fxStepID, + Rail: model.RailProviderSettlement, + Operation: model.RailOperationFXConvert, + DependsOn: []string{sourceStepID}, + Amount: cloneProtoMoney(amount), + Optional: false, + IncludeInAggregate: false, + }) + lastStepID = fxStepID + } + + if requiresTransitBridgeStep(sourceRail, destinationRail) { + bridgeStepID := fmt.Sprintf("i%d.bridge", index) + steps = append(steps, &QuoteComputationStep{ + StepID: bridgeStepID, + Rail: model.RailLedger, + Operation: model.RailOperationMove, + DependsOn: []string{lastStepID}, + Amount: cloneProtoMoney(amount), + Optional: false, + IncludeInAggregate: false, + }) + lastStepID = bridgeStepID + } + + destinationStepID := fmt.Sprintf("i%d.destination", index) + steps = append(steps, &QuoteComputationStep{ + StepID: destinationStepID, + Rail: destinationRail, + Operation: destinationOperationForRail(destinationRail), + GatewayID: destinationGatewayID, + InstanceID: destinationInstanceID, + DependsOn: []string{lastStepID}, + Amount: cloneProtoMoney(amount), + Optional: false, + IncludeInAggregate: true, + }) + + return steps +} + +func requiresTransitBridgeStep(sourceRail, destinationRail model.Rail) bool { + if sourceRail == model.RailUnspecified || destinationRail == model.RailUnspecified { + return false + } + if sourceRail == destinationRail { + return false + } + if sourceRail == model.RailLedger || destinationRail == model.RailLedger { + return false + } + return true +} + +func sourceRailForIntent(intent model.PaymentIntent) model.Rail { + if intent.Source.Type == model.EndpointTypeLedger { + return model.RailLedger + } + if intent.Source.Type == model.EndpointTypeManagedWallet || intent.Source.Type == model.EndpointTypeExternalChain { + return model.RailCrypto + } + return model.RailLedger +} + +func destinationRailForIntent(intent model.PaymentIntent) model.Rail { + switch intent.Destination.Type { + case model.EndpointTypeCard: + return model.RailCardPayout + case model.EndpointTypeManagedWallet, model.EndpointTypeExternalChain: + return model.RailCrypto + case model.EndpointTypeLedger: + return model.RailLedger + default: + return model.RailProviderSettlement + } +} + +func sourceOperationForRail(rail model.Rail) model.RailOperation { + if rail == model.RailLedger { + return model.RailOperationMove + } + return model.RailOperationExternalDebit +} + +func destinationOperationForRail(rail model.Rail) model.RailOperation { + switch rail { + case model.RailLedger: + return model.RailOperationMove + case model.RailCardPayout: + return model.RailOperationSend + default: + return model.RailOperationExternalCredit + } +} diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/quote_binding_validation.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/quote_binding_validation.go new file mode 100644 index 00000000..5e1bb077 --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/quote_binding_validation.go @@ -0,0 +1,95 @@ +package quote_computation_service + +import ( + "strings" + + "github.com/tech/sendico/pkg/merrors" + quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" +) + +func validateQuoteRouteBinding(quote *ComputedQuote, input BuildQuoteInput) error { + if quote == nil { + return merrors.InvalidArgument("computed quote is required") + } + if input.Route == nil { + return merrors.InvalidArgument("build_quote_input.route is required") + } + if quote.Route == nil { + return merrors.InvalidArgument("computed quote route is required") + } + if !sameRouteSpecification(quote.Route, input.Route) { + return merrors.InvalidArgument("computed quote route must match selected route") + } + return nil +} + +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()) && + sameRouteReference(left.GetRouteRef(), right.GetRouteRef()) && + samePricingProfileReference(left.GetPricingProfileRef(), right.GetPricingProfileRef()) && + sameRouteHops(left.GetHops(), right.GetHops()) +} + +func normalizeRail(value string) string { + return strings.ToUpper(strings.TrimSpace(value)) +} + +func normalizeProvider(value string) string { + return strings.ToLower(strings.TrimSpace(value)) +} + +func normalizePayoutMethod(value string) string { + return strings.ToUpper(strings.TrimSpace(value)) +} + +func normalizeAsset(value string) string { + return strings.ToUpper(strings.TrimSpace(value)) +} + +func normalizeSettlementModel(value string) string { + return strings.ToUpper(strings.TrimSpace(value)) +} + +func normalizeNetwork(value string) string { + return strings.ToLower(strings.TrimSpace(value)) +} + +func sameRouteReference(left, right string) bool { + return strings.TrimSpace(left) == strings.TrimSpace(right) +} + +func samePricingProfileReference(left, right string) bool { + return strings.TrimSpace(left) == strings.TrimSpace(right) +} + +func sameRouteHops(left, right []*quotationv2.RouteHop) bool { + if len(left) != len(right) { + return false + } + for i := range left { + if !sameRouteHop(left[i], right[i]) { + return false + } + } + return true +} + +func sameRouteHop(left, right *quotationv2.RouteHop) bool { + if left == nil || right == nil { + return left == right + } + return left.GetIndex() == right.GetIndex() && + normalizeRail(left.GetRail()) == normalizeRail(right.GetRail()) && + normalizeProvider(left.GetGateway()) == normalizeProvider(right.GetGateway()) && + strings.TrimSpace(left.GetInstanceId()) == strings.TrimSpace(right.GetInstanceId()) && + normalizeNetwork(left.GetNetwork()) == normalizeNetwork(right.GetNetwork()) && + left.GetRole() == right.GetRole() +} diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/route_spec_builder.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/route_spec_builder.go new file mode 100644 index 00000000..59067016 --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/route_spec_builder.go @@ -0,0 +1,236 @@ +package quote_computation_service + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "sort" + "strings" + + "github.com/tech/sendico/payments/quotation/internal/service/quotation/gateway_funding_profile" + "github.com/tech/sendico/payments/storage/model" + quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" +) + +func buildRouteSpecification( + intent model.PaymentIntent, + destination model.PaymentEndpoint, + destinationRail model.Rail, + network string, + provider string, + steps []*QuoteComputationStep, +) *quotationv2.RouteSpecification { + hops := buildRouteHops(steps, network) + if strings.TrimSpace(provider) == "" { + provider = providerFromHops(hops) + } + route := "ationv2.RouteSpecification{ + Rail: normalizeRail(string(destinationRail)), + Provider: normalizeProvider(provider), + PayoutMethod: normalizePayoutMethod(payoutMethodFromEndpoint(destination)), + SettlementAsset: normalizeAsset(intent.SettlementCurrency), + SettlementModel: normalizeSettlementModel(settlementModelString(intent.SettlementMode)), + Network: normalizeNetwork(network), + Hops: hops, + } + if route.SettlementAsset == "" && intent.Amount != nil { + route.SettlementAsset = normalizeAsset(intent.Amount.GetCurrency()) + } + route.RouteRef = buildRouteReference(route) + route.PricingProfileRef = buildPricingProfileReference(route) + return route +} + +func buildExecutionConditions( + previewOnly bool, + steps []*QuoteComputationStep, + funding *gateway_funding_profile.QuoteFundingGate, +) (*quotationv2.ExecutionConditions, quotationv2.QuoteBlockReason) { + blockReason := quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED + conditions := "ationv2.ExecutionConditions{ + Readiness: quotationv2.QuoteExecutionReadiness_QUOTE_EXECUTION_READINESS_LIQUIDITY_READY, + BatchingEligible: isBatchingEligible(steps), + PrefundingRequired: false, + PrefundingCostIncluded: false, + LiquidityCheckRequiredAtExecution: true, + LatencyHint: "instant", + Assumptions: []string{ + "execution_time_liquidity_check", + "execution_time_provider_limits", + }, + } + + if previewOnly { + conditions.Readiness = quotationv2.QuoteExecutionReadiness_QUOTE_EXECUTION_READINESS_INDICATIVE + conditions.LatencyHint = "indicative" + } + + if funding != nil { + switch funding.Mode { + case model.FundingModeBalanceReserve: + conditions.PrefundingRequired = true + conditions.LatencyHint = "reserve_before_payout" + case model.FundingModeDepositObserved: + conditions.PrefundingRequired = true + conditions.LatencyHint = "deposit_confirmation_required" + } + } + + if !previewOnly && conditions.PrefundingRequired { + conditions.Readiness = quotationv2.QuoteExecutionReadiness_QUOTE_EXECUTION_READINESS_LIQUIDITY_OBTAINABLE + conditions.Assumptions = append(conditions.Assumptions, "prefunding_may_be_required_at_execution") + } + + if !previewOnly && !conditions.PrefundingRequired { + conditions.Assumptions = append(conditions.Assumptions, "liquidity_expected_available_now") + } + + 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: + return "FIX_SOURCE" + case model.SettlementModeFixReceived: + return "FIX_RECEIVED" + default: + return "UNSPECIFIED" + } +} + +func isBatchingEligible(steps []*QuoteComputationStep) bool { + for _, step := range steps { + if step != nil && step.IncludeInAggregate { + return true + } + } + return false +} + +func buildRouteHops(steps []*QuoteComputationStep, fallbackNetwork string) []*quotationv2.RouteHop { + filtered := make([]*QuoteComputationStep, 0, len(steps)) + for _, step := range steps { + if step == nil { + continue + } + filtered = append(filtered, step) + } + if len(filtered) == 0 { + return nil + } + result := make([]*quotationv2.RouteHop, 0, len(filtered)) + lastIndex := len(filtered) - 1 + for i, step := range filtered { + hop := "ationv2.RouteHop{ + Index: uint32(i + 1), + Rail: normalizeRail(string(step.Rail)), + Gateway: normalizeProvider(step.GatewayID), + InstanceId: strings.TrimSpace(step.InstanceID), + Network: normalizeNetwork(firstNonEmpty(fallbackNetwork)), + Role: roleForHopIndex(i, lastIndex), + } + if hop.Gateway == "" && hop.Rail == normalizeRail(string(model.RailLedger)) { + hop.Gateway = "internal" + } + result = append(result, hop) + } + return result +} + +func roleForHopIndex(index, last int) quotationv2.RouteHopRole { + switch { + case index <= 0: + return quotationv2.RouteHopRole_ROUTE_HOP_ROLE_SOURCE + case index >= last: + return quotationv2.RouteHopRole_ROUTE_HOP_ROLE_DESTINATION + default: + return quotationv2.RouteHopRole_ROUTE_HOP_ROLE_TRANSIT + } +} + +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 == "" { + return "" + } + sum := sha256.Sum256([]byte(signature)) + return "rte_" + hex.EncodeToString(sum[:12]) +} + +func buildPricingProfileReference(route *quotationv2.RouteSpecification) string { + signature := routeTopologySignature(route, false) + if signature == "" { + return "" + } + sum := sha256.Sum256([]byte(signature)) + return "fee_" + hex.EncodeToString(sum[:10]) +} + +func routeTopologySignature(route *quotationv2.RouteSpecification, includeInstances bool) string { + if route == nil { + return "" + } + parts := []string{ + normalizeRail(route.GetRail()), + normalizeProvider(route.GetProvider()), + normalizePayoutMethod(route.GetPayoutMethod()), + normalizeAsset(route.GetSettlementAsset()), + normalizeSettlementModel(route.GetSettlementModel()), + normalizeNetwork(route.GetNetwork()), + } + + hops := route.GetHops() + if len(hops) > 0 { + copied := make([]*quotationv2.RouteHop, 0, len(hops)) + for _, hop := range hops { + if hop != nil { + copied = append(copied, hop) + } + } + sort.Slice(copied, func(i, j int) bool { + return copied[i].GetIndex() < copied[j].GetIndex() + }) + for _, hop := range copied { + hopParts := []string{ + fmt.Sprintf("%d", hop.GetIndex()), + normalizeRail(hop.GetRail()), + normalizeProvider(hop.GetGateway()), + normalizeNetwork(hop.GetNetwork()), + fmt.Sprintf("%d", hop.GetRole()), + } + if includeInstances { + hopParts = append(hopParts, strings.TrimSpace(hop.GetInstanceId())) + } + parts = append(parts, strings.Join(hopParts, ":")) + } + } + return strings.Join(parts, "|") +} diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/service.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/service.go new file mode 100644 index 00000000..1582acfd --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/service.go @@ -0,0 +1,49 @@ +package quote_computation_service + +import ( + "context" + "time" + + "github.com/tech/sendico/payments/quotation/internal/service/plan" + "github.com/tech/sendico/payments/quotation/internal/service/quotation/gateway_funding_profile" +) + +type Core interface { + BuildQuote(ctx context.Context, in BuildQuoteInput) (*ComputedQuote, time.Time, error) +} + +type Option func(*QuoteComputationService) + +type QuoteComputationService struct { + core Core + fundingResolver gateway_funding_profile.FundingProfileResolver + gatewayRegistry plan.GatewayRegistry +} + +func New(core Core, opts ...Option) *QuoteComputationService { + svc := &QuoteComputationService{ + core: core, + } + for _, opt := range opts { + if opt != nil { + opt(svc) + } + } + return svc +} + +func WithFundingProfileResolver(resolver gateway_funding_profile.FundingProfileResolver) Option { + return func(svc *QuoteComputationService) { + if svc != nil { + svc.fundingResolver = resolver + } + } +} + +func WithGatewayRegistry(registry plan.GatewayRegistry) Option { + return func(svc *QuoteComputationService) { + if svc != nil { + svc.gatewayRegistry = registry + } + } +} diff --git a/api/payments/quotation/internal/service/quotation/quote_engine.go b/api/payments/quotation/internal/service/quotation/quote_engine.go index b1b80be6..b71f5c97 100644 --- a/api/payments/quotation/internal/service/quotation/quote_engine.go +++ b/api/payments/quotation/internal/service/quotation/quote_engine.go @@ -37,7 +37,7 @@ func (s *Service) buildPaymentQuote(ctx context.Context, orgRef string, req *quo if err != nil { return nil, time.Time{}, err } - s.logger.Debug("fx quote attached to payment quote", zap.String("org_ref", orgRef)) + s.logger.Debug("Fx quote attached to payment quote", zap.String("org_ref", orgRef)) } payAmount, settlementAmountBeforeFees := resolveTradeAmounts(amount, fxQuote, fxSide) @@ -448,7 +448,7 @@ func (s *Service) estimateNetworkFee(ctx context.Context, intent *sharedv1.Payme resp, err := client.EstimateTransferFee(ctx, req) if err != nil { - s.logger.Warn("chain gateway fee estimation failed", zap.Error(err)) + s.logger.Warn("Chain gateway fee estimation failed", zap.Error(err)) return nil, merrors.Internal("chain_gateway_fee_estimation_failed") } return resp, nil @@ -510,7 +510,7 @@ func (s *Service) requestFXQuote(ctx context.Context, orgRef string, req *quotat quote, err := s.deps.oracle.client.GetQuote(ctx, params) if err != nil { - s.logger.Warn("fx oracle quote failed", zap.Error(err)) + s.logger.Warn("Fx oracle quote failed", zap.Error(err)) return nil, merrors.Internal(fmt.Sprintf("orchestrator: fx quote failed, %s", err.Error())) } if quote == nil { diff --git a/api/payments/quotation/internal/service/quotation/quote_executability_classifier/quote_executability_classifier.go b/api/payments/quotation/internal/service/quotation/quote_executability_classifier/quote_executability_classifier.go new file mode 100644 index 00000000..27d3c830 --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/quote_executability_classifier/quote_executability_classifier.go @@ -0,0 +1,175 @@ +package quote_executability_classifier + +import ( + "errors" + + "github.com/tech/sendico/pkg/merrors" + quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" +) + +type blockReasonError struct { + reason quotationv2.QuoteBlockReason + cause error +} + +func (e *blockReasonError) Error() string { + if e == nil { + return "" + } + if e.cause == nil { + return e.reason.String() + } + return e.cause.Error() +} + +func (e *blockReasonError) Unwrap() error { + if e == nil { + return nil + } + return e.cause +} + +func (e *blockReasonError) BlockReason() quotationv2.QuoteBlockReason { + if e == nil { + return quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED + } + return e.reason +} + +func Wrap(err error, reason quotationv2.QuoteBlockReason) error { + if err == nil { + return nil + } + return &blockReasonError{ + reason: reason, + cause: err, + } +} + +func WrapRouteUnavailable(err error) error { + return Wrap(err, quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_ROUTE_UNAVAILABLE) +} + +func WrapLimitBlocked(err error) error { + return Wrap(err, quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_LIMIT_BLOCKED) +} + +func WrapRiskBlocked(err error) error { + return Wrap(err, quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_RISK_BLOCKED) +} + +func WrapInsufficientLiquidity(err error) error { + return Wrap(err, quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_INSUFFICIENT_LIQUIDITY) +} + +func WrapPriceStale(err error) error { + return Wrap(err, quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_PRICE_STALE) +} + +func WrapAmountTooSmall(err error) error { + return Wrap(err, quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_AMOUNT_TOO_SMALL) +} + +func WrapAmountTooLarge(err error) error { + return Wrap(err, quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_AMOUNT_TOO_LARGE) +} + +func Extract(err error) (quotationv2.QuoteBlockReason, bool) { + var reasonErr interface { + BlockReason() quotationv2.QuoteBlockReason + } + if errors.As(err, &reasonErr) { + reason := reasonErr.BlockReason() + if reason != quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED { + return reason, true + } + } + return quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED, false +} + +type ExecutionStatus struct { + set bool + executable bool + blockReason quotationv2.QuoteBlockReason +} + +func (s ExecutionStatus) IsSet() bool { + return s.set +} + +func (s ExecutionStatus) IsExecutable() bool { + return s.set && s.executable +} + +func (s ExecutionStatus) BlockReason() quotationv2.QuoteBlockReason { + if !s.set || s.executable { + return quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED + } + return s.blockReason +} + +func (s ExecutionStatus) Apply(quote *quotationv2.PaymentQuote) { + if quote == nil { + return + } + if !s.set { + quote.ExecutionStatus = nil + return + } + if s.executable { + quote.ExecutionStatus = "ationv2.PaymentQuote_Executable{Executable: true} + return + } + quote.ExecutionStatus = "ationv2.PaymentQuote_BlockReason{ + BlockReason: s.blockReason, + } +} + +type QuoteExecutabilityClassifier struct{} + +func New() *QuoteExecutabilityClassifier { + return &QuoteExecutabilityClassifier{} +} + +func (c *QuoteExecutabilityClassifier) BuildExecutionStatus( + kind quotationv2.QuoteKind, + lifecycle quotationv2.QuoteLifecycle, + blockReason quotationv2.QuoteBlockReason, +) ExecutionStatus { + if kind != quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE || + lifecycle != quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE { + return ExecutionStatus{} + } + if blockReason == quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED { + return ExecutionStatus{ + set: true, + executable: true, + } + } + return ExecutionStatus{ + set: true, + executable: false, + blockReason: blockReason, + } +} + +func (c *QuoteExecutabilityClassifier) BlockReasonFromError(err error) quotationv2.QuoteBlockReason { + if err == nil { + return quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED + } + + if reason, ok := Extract(err); ok { + return reason + } + + switch { + case errors.Is(err, merrors.ErrNoData): + return quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_ROUTE_UNAVAILABLE + case errors.Is(err, merrors.ErrAccessDenied), errors.Is(err, merrors.ErrUnauthorized): + return quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_RISK_BLOCKED + case errors.Is(err, merrors.ErrInvalidArg): + return quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_ROUTE_UNAVAILABLE + default: + return quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED + } +} diff --git a/api/payments/quotation/internal/service/quotation/quote_executability_classifier/quote_executability_classifier_test.go b/api/payments/quotation/internal/service/quotation/quote_executability_classifier/quote_executability_classifier_test.go new file mode 100644 index 00000000..481bdb69 --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/quote_executability_classifier/quote_executability_classifier_test.go @@ -0,0 +1,160 @@ +package quote_executability_classifier + +import ( + "errors" + "testing" + + "github.com/tech/sendico/pkg/merrors" + quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func TestExtract_TypedReasonWrapper(t *testing.T) { + base := merrors.InvalidArgument("x") + err := WrapAmountTooSmall(base) + + reason, ok := Extract(err) + if !ok { + t.Fatalf("expected extracted reason") + } + if reason != quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_AMOUNT_TOO_SMALL { + t.Fatalf("unexpected reason: %s", reason.String()) + } + if !errors.Is(err, merrors.ErrInvalidArg) { + t.Fatalf("expected wrapped error to preserve cause") + } +} + +func TestBlockReasonFromError(t *testing.T) { + classifier := New() + + tests := []struct { + name string + err error + want quotationv2.QuoteBlockReason + }{ + { + name: "nil error", + err: nil, + want: quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED, + }, + { + name: "typed wrapper wins", + err: WrapInsufficientLiquidity(merrors.InvalidArgument("x")), + want: quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_INSUFFICIENT_LIQUIDITY, + }, + { + name: "no data fallback", + err: merrors.NoData("x"), + want: quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_ROUTE_UNAVAILABLE, + }, + { + name: "access denied fallback", + err: merrors.AccessDenied("payment", "execute", bson.NilObjectID), + want: quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_RISK_BLOCKED, + }, + { + name: "invalid arg fallback", + err: merrors.InvalidArgument("x"), + want: quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_ROUTE_UNAVAILABLE, + }, + { + name: "unknown error", + err: errors.New("boom"), + want: quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := classifier.BlockReasonFromError(tt.err) + if got != tt.want { + t.Fatalf("unexpected block reason: got=%s want=%s", got.String(), tt.want.String()) + } + }) + } +} + +func TestBuildExecutionStatus(t *testing.T) { + classifier := New() + + activeExecutable := classifier.BuildExecutionStatus( + quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE, + quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE, + quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED, + ) + if !activeExecutable.IsSet() { + t.Fatalf("expected status to be set") + } + if !activeExecutable.IsExecutable() { + t.Fatalf("expected executable status") + } + + blocked := classifier.BuildExecutionStatus( + quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE, + quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE, + 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 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, + quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_ROUTE_UNAVAILABLE, + ) + if indicative.IsSet() { + t.Fatalf("expected no execution status for indicative quote") + } + + 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") + } +} + +func TestApply(t *testing.T) { + classifier := New() + quote := "ationv2.PaymentQuote{} + + unset := classifier.BuildExecutionStatus( + quotationv2.QuoteKind_QUOTE_KIND_INDICATIVE, + quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE, + quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED, + ) + unset.Apply(quote) + if quote.GetExecutionStatus() != nil { + t.Fatalf("expected unset execution status") + } + + executable := classifier.BuildExecutionStatus( + quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE, + quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE, + quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED, + ) + executable.Apply(quote) + if !quote.GetExecutable() { + t.Fatalf("expected executable=true") + } + + blocked := classifier.BuildExecutionStatus( + quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE, + quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE, + quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_INSUFFICIENT_LIQUIDITY, + ) + blocked.Apply(quote) + if got := quote.GetBlockReason(); got != quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_INSUFFICIENT_LIQUIDITY { + t.Fatalf("unexpected block reason: %s", got.String()) + } +} diff --git a/api/payments/quotation/internal/service/quotation/quote_idempotency_service/errors.go b/api/payments/quotation/internal/service/quotation/quote_idempotency_service/errors.go new file mode 100644 index 00000000..fb21af8c --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/quote_idempotency_service/errors.go @@ -0,0 +1,8 @@ +package quote_idempotency_service + +import "errors" + +var ( + ErrIdempotencyParamMismatch = errors.New("idempotency key reuse with different parameters") + ErrIdempotencyShapeMismatch = errors.New("idempotency key already used for a different quote shape") +) diff --git a/api/payments/quotation/internal/service/quotation/quote_idempotency_service/fingerprint.go b/api/payments/quotation/internal/service/quotation/quote_idempotency_service/fingerprint.go new file mode 100644 index 00000000..6aa3f0e1 --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/quote_idempotency_service/fingerprint.go @@ -0,0 +1,48 @@ +package quote_idempotency_service + +import ( + "crypto/sha256" + "encoding/hex" + + quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" + "google.golang.org/protobuf/proto" +) + +func (s *QuoteIdempotencyService) FingerprintQuotePayment(req *quotationv2.QuotePaymentRequest) string { + if req == nil { + return hashBytes([]byte("nil_request")) + } + + cloned := proto.Clone(req).(*quotationv2.QuotePaymentRequest) + cloned.Meta = nil + cloned.IdempotencyKey = "" + cloned.PreviewOnly = false + + return fingerprintMessage(cloned) +} + +func (s *QuoteIdempotencyService) FingerprintQuotePayments(req *quotationv2.QuotePaymentsRequest) string { + if req == nil { + return hashBytes([]byte("nil_request")) + } + + cloned := proto.Clone(req).(*quotationv2.QuotePaymentsRequest) + cloned.Meta = nil + cloned.IdempotencyKey = "" + cloned.PreviewOnly = false + + return fingerprintMessage(cloned) +} + +func fingerprintMessage(msg proto.Message) string { + b, err := proto.MarshalOptions{Deterministic: true}.Marshal(msg) + if err != nil { + return hashBytes([]byte("marshal_error")) + } + return hashBytes(b) +} + +func hashBytes(data []byte) string { + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]) +} diff --git a/api/payments/quotation/internal/service/quotation/quote_idempotency_service/fingerprint_test.go b/api/payments/quotation/internal/service/quotation/quote_idempotency_service/fingerprint_test.go new file mode 100644 index 00000000..8526be60 --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/quote_idempotency_service/fingerprint_test.go @@ -0,0 +1,120 @@ +package quote_idempotency_service + +import ( + "testing" + + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + endpointv1 "github.com/tech/sendico/pkg/proto/payments/endpoint/v1" + quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" + sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" + transferv1 "github.com/tech/sendico/pkg/proto/payments/transfer/v1" + "go.mongodb.org/mongo-driver/v2/bson" + "google.golang.org/protobuf/proto" +) + +func TestFingerprintQuotePayment_IgnoresTransportFields(t *testing.T) { + svc := New() + base := "ationv2.QuotePaymentRequest{ + Meta: &sharedv1.RequestMeta{OrganizationRef: bson.NewObjectID().Hex()}, + IdempotencyKey: "idem-a", + Intent: testTransferIntent("10"), + PreviewOnly: false, + InitiatorRef: "actor-1", + } + alt := proto.Clone(base).(*quotationv2.QuotePaymentRequest) + alt.Meta = &sharedv1.RequestMeta{OrganizationRef: bson.NewObjectID().Hex()} + alt.IdempotencyKey = "idem-b" + alt.PreviewOnly = true + + if got, want := svc.FingerprintQuotePayment(base), svc.FingerprintQuotePayment(alt); got != want { + t.Fatalf("expected same fingerprint, got %q != %q", got, want) + } +} + +func TestFingerprintQuotePayment_DetectsBusinessPayloadChanges(t *testing.T) { + svc := New() + base := "ationv2.QuotePaymentRequest{ + Meta: &sharedv1.RequestMeta{OrganizationRef: bson.NewObjectID().Hex()}, + IdempotencyKey: "idem-a", + Intent: testTransferIntent("10"), + InitiatorRef: "actor-1", + } + + changedInitiator := proto.Clone(base).(*quotationv2.QuotePaymentRequest) + changedInitiator.InitiatorRef = "actor-2" + if got, want := svc.FingerprintQuotePayment(base), svc.FingerprintQuotePayment(changedInitiator); got == want { + t.Fatalf("expected different fingerprint for initiator change") + } + + changedAmount := proto.Clone(base).(*quotationv2.QuotePaymentRequest) + changedAmount.Intent = testTransferIntent("11") + if got, want := svc.FingerprintQuotePayment(base), svc.FingerprintQuotePayment(changedAmount); got == want { + t.Fatalf("expected different fingerprint for amount change") + } +} + +func TestFingerprintQuotePayments_IgnoresTransportFields(t *testing.T) { + svc := New() + base := "ationv2.QuotePaymentsRequest{ + Meta: &sharedv1.RequestMeta{OrganizationRef: bson.NewObjectID().Hex()}, + IdempotencyKey: "idem-a", + Intents: []*transferv1.TransferIntent{ + testTransferIntent("10"), + testTransferIntent("20"), + }, + PreviewOnly: false, + InitiatorRef: "actor-1", + } + alt := proto.Clone(base).(*quotationv2.QuotePaymentsRequest) + alt.Meta = &sharedv1.RequestMeta{OrganizationRef: bson.NewObjectID().Hex()} + alt.IdempotencyKey = "idem-b" + alt.PreviewOnly = true + + if got, want := svc.FingerprintQuotePayments(base), svc.FingerprintQuotePayments(alt); got != want { + t.Fatalf("expected same fingerprint, got %q != %q", got, want) + } +} + +func TestFingerprintQuotePayments_DetectsBusinessPayloadChanges(t *testing.T) { + svc := New() + base := "ationv2.QuotePaymentsRequest{ + Meta: &sharedv1.RequestMeta{OrganizationRef: bson.NewObjectID().Hex()}, + IdempotencyKey: "idem-a", + Intents: []*transferv1.TransferIntent{ + testTransferIntent("10"), + testTransferIntent("20"), + }, + InitiatorRef: "actor-1", + } + + changedInitiator := proto.Clone(base).(*quotationv2.QuotePaymentsRequest) + changedInitiator.InitiatorRef = "actor-2" + if got, want := svc.FingerprintQuotePayments(base), svc.FingerprintQuotePayments(changedInitiator); got == want { + t.Fatalf("expected different fingerprint for initiator change") + } + + reordered := proto.Clone(base).(*quotationv2.QuotePaymentsRequest) + reordered.Intents = []*transferv1.TransferIntent{ + testTransferIntent("20"), + testTransferIntent("10"), + } + if got, want := svc.FingerprintQuotePayments(base), svc.FingerprintQuotePayments(reordered); got == want { + t.Fatalf("expected different fingerprint for intent order change") + } +} + +func testTransferIntent(amount string) *transferv1.TransferIntent { + return &transferv1.TransferIntent{ + Source: endpointWithMethodRef("pm-src"), + Destination: endpointWithMethodRef("pm-dst"), + Amount: &moneyv1.Money{Amount: amount, Currency: "USD"}, + } +} + +func endpointWithMethodRef(methodRef string) *endpointv1.PaymentEndpoint { + return &endpointv1.PaymentEndpoint{ + Source: &endpointv1.PaymentEndpoint_PaymentMethodRef{ + PaymentMethodRef: methodRef, + }, + } +} diff --git a/api/payments/quotation/internal/service/quotation/quote_idempotency_service/reuse.go b/api/payments/quotation/internal/service/quotation/quote_idempotency_service/reuse.go new file mode 100644 index 00000000..1b0215a0 --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/quote_idempotency_service/reuse.go @@ -0,0 +1,113 @@ +package quote_idempotency_service + +import ( + "context" + "errors" + "strings" + + "github.com/tech/sendico/payments/storage/model" + quotestorage "github.com/tech/sendico/payments/storage/quote" + "github.com/tech/sendico/pkg/merrors" + "go.mongodb.org/mongo-driver/v2/bson" +) + +type ReuseInput struct { + OrganizationID bson.ObjectID + IdempotencyKey string + Fingerprint string + Shape QuoteShape +} + +type CreateInput struct { + Record *model.PaymentQuoteRecord + Reuse ReuseInput +} + +func (s *QuoteIdempotencyService) TryReuse( + ctx context.Context, + quotesStore quotestorage.QuotesStore, + in ReuseInput, +) (*model.PaymentQuoteRecord, bool, error) { + + if quotesStore == nil { + return nil, false, merrors.InvalidArgument("quotes store is required") + } + if in.OrganizationID == bson.NilObjectID { + return nil, false, merrors.InvalidArgument("organization_id is required") + } + + idempotencyKey := strings.TrimSpace(in.IdempotencyKey) + if idempotencyKey == "" { + return nil, false, merrors.InvalidArgument("idempotency_key is required") + } + fingerprint := strings.TrimSpace(in.Fingerprint) + if fingerprint == "" { + return nil, false, merrors.InvalidArgument("fingerprint is required") + } + + record, err := quotesStore.GetByIdempotencyKey(ctx, in.OrganizationID, idempotencyKey) + if err != nil { + if errors.Is(err, quotestorage.ErrQuoteNotFound) || errors.Is(err, merrors.ErrNoData) { + return nil, false, nil + } + return nil, false, err + } + if record == nil { + return nil, false, nil + } + + if !shapeMatches(record, in.Shape) { + return nil, false, ErrIdempotencyShapeMismatch + } + if strings.TrimSpace(record.Hash) != fingerprint { + return nil, false, ErrIdempotencyParamMismatch + } + + return record, true, nil +} + +func (s *QuoteIdempotencyService) CreateOrReuse( + ctx context.Context, + quotesStore quotestorage.QuotesStore, + in CreateInput, +) (*model.PaymentQuoteRecord, bool, error) { + + if quotesStore == nil { + return nil, false, merrors.InvalidArgument("quotes store is required") + } + if in.Record == nil { + return nil, false, merrors.InvalidArgument("record is required") + } + + if err := quotesStore.Create(ctx, in.Record); err != nil { + if !errors.Is(err, quotestorage.ErrDuplicateQuote) { + return nil, false, err + } + + record, reused, reuseErr := s.TryReuse(ctx, quotesStore, in.Reuse) + if reuseErr != nil { + return nil, false, reuseErr + } + if reused { + return record, true, nil + } + return nil, false, err + } + + return in.Record, false, nil +} + +func shapeMatches(record *model.PaymentQuoteRecord, shape QuoteShape) bool { + if record == nil { + return false + } + + switch shape { + case QuoteShapeSingle: + return len(record.Quotes) == 0 + case QuoteShapeBatch: + return len(record.Quotes) > 0 + default: + return true + } +} diff --git a/api/payments/quotation/internal/service/quotation/quote_idempotency_service/reuse_test.go b/api/payments/quotation/internal/service/quotation/quote_idempotency_service/reuse_test.go new file mode 100644 index 00000000..73b8ffe0 --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/quote_idempotency_service/reuse_test.go @@ -0,0 +1,288 @@ +package quote_idempotency_service + +import ( + "context" + "errors" + "testing" + + "github.com/tech/sendico/payments/storage/model" + quotestorage "github.com/tech/sendico/payments/storage/quote" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func TestTryReuse_NotFound(t *testing.T) { + svc := New() + store := &fakeQuotesStore{ + getByIdempotencyKeyFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { + return nil, quotestorage.ErrQuoteNotFound + }, + } + + record, reused, err := svc.TryReuse(context.Background(), store, ReuseInput{ + OrganizationID: bson.NewObjectID(), + IdempotencyKey: "idem-1", + Fingerprint: "hash-1", + Shape: QuoteShapeSingle, + }) + if err != nil { + t.Fatalf("TryReuse returned error: %v", err) + } + if reused { + t.Fatalf("expected reused=false") + } + if record != nil { + t.Fatalf("expected nil record") + } +} + +func TestTryReuse_ParamMismatch(t *testing.T) { + svc := New() + store := &fakeQuotesStore{ + getByIdempotencyKeyFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { + return &model.PaymentQuoteRecord{ + IdempotencyKey: "idem-1", + Hash: "stored-hash", + Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q1"}, + }, nil + }, + } + + _, _, err := svc.TryReuse(context.Background(), store, ReuseInput{ + OrganizationID: bson.NewObjectID(), + IdempotencyKey: "idem-1", + Fingerprint: "different-hash", + Shape: QuoteShapeSingle, + }) + if !errors.Is(err, ErrIdempotencyParamMismatch) { + t.Fatalf("expected ErrIdempotencyParamMismatch, got %v", err) + } +} + +func TestTryReuse_ShapeMismatch(t *testing.T) { + svc := New() + store := &fakeQuotesStore{ + getByIdempotencyKeyFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { + return &model.PaymentQuoteRecord{ + IdempotencyKey: "idem-1", + Hash: "hash-1", + Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q1"}, + }, nil + }, + } + + _, _, err := svc.TryReuse(context.Background(), store, ReuseInput{ + OrganizationID: bson.NewObjectID(), + IdempotencyKey: "idem-1", + Fingerprint: "hash-1", + Shape: QuoteShapeBatch, + }) + if !errors.Is(err, ErrIdempotencyShapeMismatch) { + t.Fatalf("expected ErrIdempotencyShapeMismatch, got %v", err) + } +} + +func TestTryReuse_ShapeMismatchSingle(t *testing.T) { + svc := New() + store := &fakeQuotesStore{ + getByIdempotencyKeyFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { + return &model.PaymentQuoteRecord{ + IdempotencyKey: "idem-1", + Hash: "hash-1", + Quotes: []*model.PaymentQuoteSnapshot{ + {QuoteRef: "q1"}, + }, + }, nil + }, + } + + _, _, err := svc.TryReuse(context.Background(), store, ReuseInput{ + OrganizationID: bson.NewObjectID(), + IdempotencyKey: "idem-1", + Fingerprint: "hash-1", + Shape: QuoteShapeSingle, + }) + if !errors.Is(err, ErrIdempotencyShapeMismatch) { + t.Fatalf("expected ErrIdempotencyShapeMismatch, got %v", err) + } +} + +func TestTryReuse_Success(t *testing.T) { + svc := New() + existing := &model.PaymentQuoteRecord{ + IdempotencyKey: "idem-1", + Hash: "hash-1", + Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q1"}, + } + store := &fakeQuotesStore{ + getByIdempotencyKeyFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { + return existing, nil + }, + } + + record, reused, err := svc.TryReuse(context.Background(), store, ReuseInput{ + OrganizationID: bson.NewObjectID(), + IdempotencyKey: "idem-1", + Fingerprint: "hash-1", + Shape: QuoteShapeSingle, + }) + if err != nil { + t.Fatalf("TryReuse returned error: %v", err) + } + if !reused { + t.Fatalf("expected reused=true") + } + if record != existing { + t.Fatalf("expected existing record to be returned") + } +} + +func TestCreateOrReuse_CreateSuccess(t *testing.T) { + svc := New() + store := &fakeQuotesStore{ + createFn: func(context.Context, *model.PaymentQuoteRecord) error { return nil }, + } + record := &model.PaymentQuoteRecord{ + IdempotencyKey: "idem-1", + Hash: "hash-1", + Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q1"}, + } + + got, reused, err := svc.CreateOrReuse(context.Background(), store, CreateInput{ + Record: record, + Reuse: ReuseInput{ + OrganizationID: bson.NewObjectID(), + IdempotencyKey: "idem-1", + Fingerprint: "hash-1", + Shape: QuoteShapeSingle, + }, + }) + if err != nil { + t.Fatalf("CreateOrReuse returned error: %v", err) + } + if reused { + t.Fatalf("expected reused=false") + } + if got != record { + t.Fatalf("expected newly created record") + } +} + +func TestCreateOrReuse_DuplicateReturnsExisting(t *testing.T) { + svc := New() + existing := &model.PaymentQuoteRecord{ + IdempotencyKey: "idem-1", + Hash: "hash-1", + Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q1"}, + } + store := &fakeQuotesStore{ + createFn: func(context.Context, *model.PaymentQuoteRecord) error { return quotestorage.ErrDuplicateQuote }, + getByIdempotencyKeyFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { + return existing, nil + }, + } + record := &model.PaymentQuoteRecord{ + IdempotencyKey: "idem-1", + Hash: "hash-1", + Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q2"}, + } + + got, reused, err := svc.CreateOrReuse(context.Background(), store, CreateInput{ + Record: record, + Reuse: ReuseInput{ + OrganizationID: bson.NewObjectID(), + IdempotencyKey: "idem-1", + Fingerprint: "hash-1", + Shape: QuoteShapeSingle, + }, + }) + if err != nil { + t.Fatalf("CreateOrReuse returned error: %v", err) + } + if !reused { + t.Fatalf("expected reused=true") + } + if got != existing { + t.Fatalf("expected existing record") + } +} + +func TestCreateOrReuse_DuplicateParamMismatch(t *testing.T) { + svc := New() + store := &fakeQuotesStore{ + createFn: func(context.Context, *model.PaymentQuoteRecord) error { return quotestorage.ErrDuplicateQuote }, + getByIdempotencyKeyFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { + return &model.PaymentQuoteRecord{ + IdempotencyKey: "idem-1", + Hash: "stored-hash", + Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q1"}, + }, nil + }, + } + + _, _, err := svc.CreateOrReuse(context.Background(), store, CreateInput{ + Record: &model.PaymentQuoteRecord{ + IdempotencyKey: "idem-1", + Hash: "new-hash", + Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q2"}, + }, + Reuse: ReuseInput{ + OrganizationID: bson.NewObjectID(), + IdempotencyKey: "idem-1", + Fingerprint: "new-hash", + Shape: QuoteShapeSingle, + }, + }) + if !errors.Is(err, ErrIdempotencyParamMismatch) { + t.Fatalf("expected ErrIdempotencyParamMismatch, got %v", err) + } +} + +func TestCreateOrReuse_DuplicateWithoutReusableRecordReturnsDuplicate(t *testing.T) { + svc := New() + store := &fakeQuotesStore{ + createFn: func(context.Context, *model.PaymentQuoteRecord) error { return quotestorage.ErrDuplicateQuote }, + getByIdempotencyKeyFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { + return nil, quotestorage.ErrQuoteNotFound + }, + } + + _, _, err := svc.CreateOrReuse(context.Background(), store, CreateInput{ + Record: &model.PaymentQuoteRecord{ + IdempotencyKey: "idem-1", + Hash: "hash-1", + Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q2"}, + }, + Reuse: ReuseInput{ + OrganizationID: bson.NewObjectID(), + IdempotencyKey: "idem-1", + Fingerprint: "hash-1", + Shape: QuoteShapeSingle, + }, + }) + if !errors.Is(err, quotestorage.ErrDuplicateQuote) { + t.Fatalf("expected ErrDuplicateQuote, got %v", err) + } +} + +type fakeQuotesStore struct { + createFn func(ctx context.Context, quote *model.PaymentQuoteRecord) error + getByIdempotencyKeyFn func(ctx context.Context, orgRef bson.ObjectID, idempotencyKey string) (*model.PaymentQuoteRecord, error) +} + +func (f *fakeQuotesStore) Create(ctx context.Context, quote *model.PaymentQuoteRecord) error { + if f.createFn == nil { + return nil + } + return f.createFn(ctx, quote) +} + +func (f *fakeQuotesStore) GetByRef(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { + return nil, quotestorage.ErrQuoteNotFound +} + +func (f *fakeQuotesStore) GetByIdempotencyKey(ctx context.Context, orgRef bson.ObjectID, idempotencyKey string) (*model.PaymentQuoteRecord, error) { + if f.getByIdempotencyKeyFn == nil { + return nil, quotestorage.ErrQuoteNotFound + } + return f.getByIdempotencyKeyFn(ctx, orgRef, idempotencyKey) +} diff --git a/api/payments/quotation/internal/service/quotation/quote_idempotency_service/service.go b/api/payments/quotation/internal/service/quotation/quote_idempotency_service/service.go new file mode 100644 index 00000000..eb674e76 --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/quote_idempotency_service/service.go @@ -0,0 +1,15 @@ +package quote_idempotency_service + +type QuoteShape string + +const ( + QuoteShapeUnspecified QuoteShape = "unspecified" + QuoteShapeSingle QuoteShape = "single" + QuoteShapeBatch QuoteShape = "batch" +) + +type QuoteIdempotencyService struct{} + +func New() *QuoteIdempotencyService { + return &QuoteIdempotencyService{} +} diff --git a/api/payments/quotation/internal/service/quotation/quote_persistence_service/helpers.go b/api/payments/quotation/internal/service/quotation/quote_persistence_service/helpers.go new file mode 100644 index 00000000..bad50681 --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/quote_persistence_service/helpers.go @@ -0,0 +1,15 @@ +package quote_persistence_service + +import "strconv" + +func cloneBoolPtr(src *bool) *bool { + if src == nil { + return nil + } + value := *src + return &value +} + +func itoa(value int) string { + return strconv.Itoa(value) +} diff --git a/api/payments/quotation/internal/service/quotation/quote_persistence_service/input.go b/api/payments/quotation/internal/service/quotation/quote_persistence_service/input.go new file mode 100644 index 00000000..1f65377e --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/quote_persistence_service/input.go @@ -0,0 +1,33 @@ +package quote_persistence_service + +import ( + "time" + + "github.com/tech/sendico/payments/storage/model" + quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" + "go.mongodb.org/mongo-driver/v2/bson" +) + +type StatusInput struct { + Kind quotationv2.QuoteKind + Lifecycle quotationv2.QuoteLifecycle + Executable *bool + BlockReason quotationv2.QuoteBlockReason +} + +type PersistInput struct { + OrganizationID bson.ObjectID + QuoteRef string + IdempotencyKey string + Hash string + ExpiresAt time.Time + + Intent *model.PaymentIntent + Intents []model.PaymentIntent + + Quote *model.PaymentQuoteSnapshot + Quotes []*model.PaymentQuoteSnapshot + + Status *StatusInput + Statuses []*StatusInput +} diff --git a/api/payments/quotation/internal/service/quotation/quote_persistence_service/service.go b/api/payments/quotation/internal/service/quotation/quote_persistence_service/service.go new file mode 100644 index 00000000..fe0465b6 --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/quote_persistence_service/service.go @@ -0,0 +1,104 @@ +package quote_persistence_service + +import ( + "context" + "strings" + + "github.com/tech/sendico/payments/storage/model" + quotestorage "github.com/tech/sendico/payments/storage/quote" + "github.com/tech/sendico/pkg/merrors" + "go.mongodb.org/mongo-driver/v2/bson" +) + +type QuotePersistenceService struct{} + +func New() *QuotePersistenceService { + return &QuotePersistenceService{} +} + +func (s *QuotePersistenceService) Persist( + ctx context.Context, + quotesStore quotestorage.QuotesStore, + in PersistInput, +) (*model.PaymentQuoteRecord, error) { + if quotesStore == nil { + return nil, merrors.InvalidArgument("quotes store is required") + } + + record, err := s.BuildRecord(in) + if err != nil { + return nil, err + } + + if err := quotesStore.Create(ctx, record); err != nil { + return nil, err + } + return record, nil +} + +func (s *QuotePersistenceService) BuildRecord(in PersistInput) (*model.PaymentQuoteRecord, error) { + if in.OrganizationID == bson.NilObjectID { + return nil, merrors.InvalidArgument("organization_id is required") + } + if strings.TrimSpace(in.QuoteRef) == "" { + return nil, merrors.InvalidArgument("quote_ref is required") + } + if strings.TrimSpace(in.IdempotencyKey) == "" { + return nil, merrors.InvalidArgument("idempotency_key is required") + } + if strings.TrimSpace(in.Hash) == "" { + return nil, merrors.InvalidArgument("hash is required") + } + if in.ExpiresAt.IsZero() { + return nil, merrors.InvalidArgument("expires_at is required") + } + + isSingle := in.Quote != nil + isBatch := len(in.Quotes) > 0 + + if isSingle == isBatch { + return nil, merrors.InvalidArgument("exactly one quote shape is required") + } + + record := &model.PaymentQuoteRecord{ + QuoteRef: strings.TrimSpace(in.QuoteRef), + IdempotencyKey: strings.TrimSpace(in.IdempotencyKey), + Hash: strings.TrimSpace(in.Hash), + ExpiresAt: in.ExpiresAt, + } + record.SetID(bson.NewObjectID()) + record.SetOrganizationRef(in.OrganizationID) + + if isSingle { + if in.Intent == nil { + return nil, merrors.InvalidArgument("intent is required") + } + status, err := mapStatusInput(in.Status) + if err != nil { + return nil, err + } + record.Intent = *in.Intent + record.Quote = in.Quote + record.StatusV2 = status + return record, nil + } + + if len(in.Intents) == 0 { + return nil, merrors.InvalidArgument("intents are required") + } + if len(in.Intents) != len(in.Quotes) { + return nil, merrors.InvalidArgument("intents and quotes count mismatch") + } + statuses, err := mapStatusInputs(in.Statuses) + if err != nil { + return nil, err + } + if len(statuses) != len(in.Quotes) { + return nil, merrors.InvalidArgument("statuses and quotes count mismatch") + } + + record.Intents = in.Intents + record.Quotes = in.Quotes + record.StatusesV2 = statuses + return record, nil +} diff --git a/api/payments/quotation/internal/service/quotation/quote_persistence_service/service_test.go b/api/payments/quotation/internal/service/quotation/quote_persistence_service/service_test.go new file mode 100644 index 00000000..8123e5d0 --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/quote_persistence_service/service_test.go @@ -0,0 +1,176 @@ +package quote_persistence_service + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/tech/sendico/payments/storage/model" + quotestorage "github.com/tech/sendico/payments/storage/quote" + "github.com/tech/sendico/pkg/merrors" + quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func TestPersistSingle(t *testing.T) { + svc := New() + store := &fakeQuotesStore{} + orgID := bson.NewObjectID() + trueValue := true + + record, err := svc.Persist(context.Background(), store, PersistInput{ + OrganizationID: orgID, + QuoteRef: "quote-1", + IdempotencyKey: "idem-1", + Hash: "hash-1", + ExpiresAt: time.Now().Add(time.Minute), + Intent: &model.PaymentIntent{Ref: "intent-1"}, + Quote: &model.PaymentQuoteSnapshot{ + QuoteRef: "quote-1", + }, + Status: &StatusInput{ + Kind: quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE, + Lifecycle: quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE, + Executable: &trueValue, + }, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if record == nil { + t.Fatalf("expected record") + } + if store.created == nil { + t.Fatalf("expected record to be created") + } + if store.created.ExecutionNote != "" { + t.Fatalf("expected no legacy execution note, got %q", store.created.ExecutionNote) + } + 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.Executable == nil || !*store.created.StatusV2.Executable { + t.Fatalf("expected executable=true in persisted status") + } +} + +func TestPersistBatch(t *testing.T) { + svc := New() + store := &fakeQuotesStore{} + orgID := bson.NewObjectID() + + record, err := svc.Persist(context.Background(), store, PersistInput{ + OrganizationID: orgID, + QuoteRef: "quote-batch-1", + IdempotencyKey: "idem-batch-1", + Hash: "hash-batch-1", + ExpiresAt: time.Now().Add(time.Minute), + Intents: []model.PaymentIntent{ + {Ref: "i1"}, + {Ref: "i2"}, + }, + Quotes: []*model.PaymentQuoteSnapshot{ + {QuoteRef: "q1"}, + {QuoteRef: "q2"}, + }, + Statuses: []*StatusInput{ + { + Kind: quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE, + Lifecycle: quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE, + BlockReason: quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_ROUTE_UNAVAILABLE, + }, + { + Kind: quotationv2.QuoteKind_QUOTE_KIND_INDICATIVE, + Lifecycle: quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE, + }, + }, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if record == nil { + t.Fatalf("expected record") + } + if len(record.StatusesV2) != 2 { + t.Fatalf("expected 2 statuses, got %d", len(record.StatusesV2)) + } + if record.StatusesV2[0].BlockReason != model.QuoteBlockReasonRouteUnavailable { + t.Fatalf("unexpected first status block reason: %q", record.StatusesV2[0].BlockReason) + } +} + +func TestPersistValidation(t *testing.T) { + svc := New() + store := &fakeQuotesStore{} + orgID := bson.NewObjectID() + + _, err := svc.Persist(context.Background(), nil, PersistInput{}) + if !errors.Is(err, merrors.ErrInvalidArg) { + t.Fatalf("expected invalid argument for nil store, got %v", err) + } + + _, err = svc.Persist(context.Background(), store, PersistInput{ + OrganizationID: orgID, + QuoteRef: "q", + IdempotencyKey: "i", + Hash: "h", + ExpiresAt: time.Now().Add(time.Minute), + 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), + }, + }) + if !errors.Is(err, merrors.ErrInvalidArg) { + t.Fatalf("expected invalid argument for executable=false, got %v", err) + } + + _, err = svc.Persist(context.Background(), store, PersistInput{ + OrganizationID: orgID, + QuoteRef: "q", + IdempotencyKey: "i", + Hash: "h", + ExpiresAt: time.Now().Add(time.Minute), + Intents: []model.PaymentIntent{ + {Ref: "i1"}, + }, + Quotes: []*model.PaymentQuoteSnapshot{ + {QuoteRef: "q1"}, + }, + Statuses: []*StatusInput{}, + }) + if !errors.Is(err, merrors.ErrInvalidArg) { + t.Fatalf("expected invalid argument for statuses mismatch, got %v", err) + } +} + +type fakeQuotesStore struct { + created *model.PaymentQuoteRecord + createErr error +} + +func (f *fakeQuotesStore) Create(_ context.Context, quote *model.PaymentQuoteRecord) error { + if f.createErr != nil { + return f.createErr + } + f.created = quote + return nil +} + +func (f *fakeQuotesStore) GetByRef(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { + return nil, quotestorage.ErrQuoteNotFound +} + +func (f *fakeQuotesStore) GetByIdempotencyKey(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { + return nil, quotestorage.ErrQuoteNotFound +} + +func boolPtr(v bool) *bool { + return &v +} diff --git a/api/payments/quotation/internal/service/quotation/quote_persistence_service/status_mapper.go b/api/payments/quotation/internal/service/quotation/quote_persistence_service/status_mapper.go new file mode 100644 index 00000000..a23658ef --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/quote_persistence_service/status_mapper.go @@ -0,0 +1,87 @@ +package quote_persistence_service + +import ( + "github.com/tech/sendico/payments/storage/model" + "github.com/tech/sendico/pkg/merrors" + quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" +) + +func mapStatusInput(input *StatusInput) (*model.QuoteStatusV2, error) { + if input == nil { + 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.Executable != nil && + input.BlockReason != quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED { + return nil, merrors.InvalidArgument("status.executable and status.block_reason are mutually exclusive") + } + + return &model.QuoteStatusV2{ + Kind: mapQuoteKind(input.Kind), + Lifecycle: mapQuoteLifecycle(input.Lifecycle), + Executable: cloneBoolPtr(input.Executable), + BlockReason: mapQuoteBlockReason(input.BlockReason), + }, nil +} + +func mapStatusInputs(inputs []*StatusInput) ([]*model.QuoteStatusV2, error) { + if len(inputs) == 0 { + return nil, nil + } + + result := make([]*model.QuoteStatusV2, 0, len(inputs)) + for i, item := range inputs { + mapped, err := mapStatusInput(item) + if err != nil { + return nil, merrors.InvalidArgument("statuses[" + itoa(i) + "]: " + err.Error()) + } + result = append(result, mapped) + } + 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 + 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 + } +} + +func mapQuoteBlockReason(reason quotationv2.QuoteBlockReason) model.QuoteBlockReason { + switch reason { + case quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_ROUTE_UNAVAILABLE: + return model.QuoteBlockReasonRouteUnavailable + case quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_LIMIT_BLOCKED: + return model.QuoteBlockReasonLimitBlocked + case quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_RISK_BLOCKED: + return model.QuoteBlockReasonRiskBlocked + case quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_INSUFFICIENT_LIQUIDITY: + return model.QuoteBlockReasonInsufficientLiquidity + case quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_PRICE_STALE: + return model.QuoteBlockReasonPriceStale + case quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_AMOUNT_TOO_SMALL: + return model.QuoteBlockReasonAmountTooSmall + case quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_AMOUNT_TOO_LARGE: + return model.QuoteBlockReasonAmountTooLarge + default: + return model.QuoteBlockReasonUnspecified + } +} diff --git a/api/payments/quotation/internal/service/quotation/quote_request_validator/quote_request_validator.go b/api/payments/quotation/internal/service/quotation/quote_request_validator/quote_request_validator.go new file mode 100644 index 00000000..885e768c --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/quote_request_validator/quote_request_validator.go @@ -0,0 +1,167 @@ +package quote_request_validator_v2 + +import ( + "errors" + "fmt" + "strings" + + "github.com/tech/sendico/pkg/merrors" + endpointv1 "github.com/tech/sendico/pkg/proto/payments/endpoint/v1" + quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" + sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" + transferv1 "github.com/tech/sendico/pkg/proto/payments/transfer/v1" + "go.mongodb.org/mongo-driver/v2/bson" +) + +var ( + ErrIdempotencyRequired = errors.New("idempotency key is required") + ErrPreviewWithIdempotency = errors.New("preview requests must not use idempotency key") + ErrInitiatorRefRequired = errors.New("initiator_ref is required") +) + +type Context struct { + OrganizationRef string + OrganizationID bson.ObjectID + IdempotencyKey string + InitiatorRef string + PreviewOnly bool + IntentCount int +} + +type QuoteRequestValidatorV2 struct{} + +func New() *QuoteRequestValidatorV2 { + return &QuoteRequestValidatorV2{} +} + +func (v *QuoteRequestValidatorV2) ValidateQuotePayment(req *quotationv2.QuotePaymentRequest) (*Context, error) { + if req == nil { + return nil, merrors.InvalidArgument("nil request") + } + + orgRef, orgID, err := validateMeta(req.GetMeta()) + if err != nil { + return nil, err + } + if err := validateTransferIntent(req.GetIntent(), "intent"); err != nil { + return nil, err + } + + idempotencyKey := strings.TrimSpace(req.GetIdempotencyKey()) + previewOnly := req.GetPreviewOnly() + if err := validateIdempotency(idempotencyKey, previewOnly); err != nil { + return nil, err + } + + initiatorRef := strings.TrimSpace(req.GetInitiatorRef()) + if initiatorRef == "" { + return nil, ErrInitiatorRefRequired + } + + return &Context{ + OrganizationRef: orgRef, + OrganizationID: orgID, + IdempotencyKey: idempotencyKey, + InitiatorRef: initiatorRef, + PreviewOnly: previewOnly, + IntentCount: 1, + }, nil +} + +func (v *QuoteRequestValidatorV2) ValidateQuotePayments(req *quotationv2.QuotePaymentsRequest) (*Context, error) { + if req == nil { + return nil, merrors.InvalidArgument("nil request") + } + + orgRef, orgID, err := validateMeta(req.GetMeta()) + if err != nil { + return nil, err + } + + intents := req.GetIntents() + if len(intents) == 0 { + return nil, merrors.InvalidArgument("intents are required") + } + for i, intent := range intents { + if err := validateTransferIntent(intent, fmt.Sprintf("intents[%d]", i)); err != nil { + return nil, err + } + } + + idempotencyKey := strings.TrimSpace(req.GetIdempotencyKey()) + previewOnly := req.GetPreviewOnly() + if err := validateIdempotency(idempotencyKey, previewOnly); err != nil { + return nil, err + } + + initiatorRef := strings.TrimSpace(req.GetInitiatorRef()) + if initiatorRef == "" { + return nil, ErrInitiatorRefRequired + } + + return &Context{ + OrganizationRef: orgRef, + OrganizationID: orgID, + IdempotencyKey: idempotencyKey, + InitiatorRef: initiatorRef, + PreviewOnly: previewOnly, + IntentCount: len(intents), + }, nil +} + +func validateMeta(meta *sharedv1.RequestMeta) (string, bson.ObjectID, error) { + if meta == nil { + return "", bson.NilObjectID, merrors.InvalidArgument("meta is required") + } + + orgRef := strings.TrimSpace(meta.GetOrganizationRef()) + if orgRef == "" { + return "", bson.NilObjectID, merrors.InvalidArgument("organization_ref is required") + } + + orgID, err := bson.ObjectIDFromHex(orgRef) + if err != nil { + return "", bson.NilObjectID, merrors.InvalidArgument("organization_ref must be a valid objectID") + } + + return orgRef, orgID, nil +} + +func validateTransferIntent(intent *transferv1.TransferIntent, field string) error { + if intent == nil { + return merrors.InvalidArgument(field + " is required") + } + if !hasEndpointValue(intent.GetSource()) { + return merrors.InvalidArgument(field + ".source is required") + } + if !hasEndpointValue(intent.GetDestination()) { + return merrors.InvalidArgument(field + ".destination is required") + } + if intent.GetAmount() == nil { + return merrors.InvalidArgument(field + ".amount is required") + } + return nil +} + +func hasEndpointValue(endpoint *endpointv1.PaymentEndpoint) bool { + if endpoint == nil { + return false + } + if strings.TrimSpace(endpoint.GetPaymentMethodRef()) != "" { + return true + } + if endpoint.GetPaymentMethod() != nil { + return true + } + return strings.TrimSpace(endpoint.GetPayeeRef()) != "" +} + +func validateIdempotency(idempotencyKey string, previewOnly bool) error { + if previewOnly && idempotencyKey != "" { + return ErrPreviewWithIdempotency + } + if !previewOnly && idempotencyKey == "" { + return ErrIdempotencyRequired + } + return nil +} diff --git a/api/payments/quotation/internal/service/quotation/quote_request_validator/quote_request_validator_test.go b/api/payments/quotation/internal/service/quotation/quote_request_validator/quote_request_validator_test.go new file mode 100644 index 00000000..94e8e64b --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/quote_request_validator/quote_request_validator_test.go @@ -0,0 +1,251 @@ +package quote_request_validator_v2 + +import ( + "errors" + "strings" + "testing" + + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + endpointv1 "github.com/tech/sendico/pkg/proto/payments/endpoint/v1" + quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" + sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" + transferv1 "github.com/tech/sendico/pkg/proto/payments/transfer/v1" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func TestValidateQuotePayment_Success(t *testing.T) { + validator := New() + orgID := bson.NewObjectID() + req := "ationv2.QuotePaymentRequest{ + Meta: &sharedv1.RequestMeta{OrganizationRef: orgID.Hex()}, + IdempotencyKey: "idem-1", + Intent: validTransferIntent(), + PreviewOnly: false, + InitiatorRef: "actor-1", + } + + ctx, err := validator.ValidateQuotePayment(req) + if err != nil { + t.Fatalf("ValidateQuotePayment returned error: %v", err) + } + if ctx == nil { + t.Fatalf("expected validation context") + } + if got, want := ctx.OrganizationRef, orgID.Hex(); got != want { + t.Fatalf("expected organization_ref %q, got %q", want, got) + } + if got, want := ctx.OrganizationID, orgID; got != want { + t.Fatalf("expected organization_id %s, got %s", want.Hex(), got.Hex()) + } + if got, want := ctx.IdempotencyKey, "idem-1"; got != want { + t.Fatalf("expected idempotency_key %q, got %q", want, got) + } + if got, want := ctx.InitiatorRef, "actor-1"; got != want { + t.Fatalf("expected initiator_ref %q, got %q", want, got) + } + if got, want := ctx.IntentCount, 1; got != want { + t.Fatalf("expected intent_count %d, got %d", want, got) + } +} + +func TestValidateQuotePayment_Rules(t *testing.T) { + validator := New() + orgHex := bson.NewObjectID().Hex() + + testCases := []struct { + name string + req *quotationv2.QuotePaymentRequest + checkErr func(error) bool + }{ + { + name: "idempotency required for non-preview", + req: "ationv2.QuotePaymentRequest{ + Meta: &sharedv1.RequestMeta{OrganizationRef: orgHex}, + Intent: validTransferIntent(), + PreviewOnly: false, + InitiatorRef: "actor-1", + }, + checkErr: func(err error) bool { return errors.Is(err, ErrIdempotencyRequired) }, + }, + { + name: "preview must not include idempotency", + req: "ationv2.QuotePaymentRequest{ + Meta: &sharedv1.RequestMeta{OrganizationRef: orgHex}, + IdempotencyKey: "idem-1", + Intent: validTransferIntent(), + PreviewOnly: true, + InitiatorRef: "actor-1", + }, + checkErr: func(err error) bool { return errors.Is(err, ErrPreviewWithIdempotency) }, + }, + { + name: "initiator ref required", + req: "ationv2.QuotePaymentRequest{ + Meta: &sharedv1.RequestMeta{OrganizationRef: orgHex}, + IdempotencyKey: "idem-1", + Intent: validTransferIntent(), + PreviewOnly: false, + }, + checkErr: func(err error) bool { return errors.Is(err, ErrInitiatorRefRequired) }, + }, + { + name: "invalid org ref", + req: "ationv2.QuotePaymentRequest{ + Meta: &sharedv1.RequestMeta{OrganizationRef: "bad-org"}, + IdempotencyKey: "idem-1", + Intent: validTransferIntent(), + PreviewOnly: false, + InitiatorRef: "actor-1", + }, + checkErr: func(err error) bool { + return err != nil && strings.Contains(err.Error(), "organization_ref must be a valid objectID") + }, + }, + { + name: "source required", + req: "ationv2.QuotePaymentRequest{ + Meta: &sharedv1.RequestMeta{OrganizationRef: orgHex}, + IdempotencyKey: "idem-1", + Intent: &transferv1.TransferIntent{ + Destination: endpointWithMethodRef("pm-dst"), + Amount: &moneyv1.Money{Amount: "10", Currency: "USD"}, + }, + PreviewOnly: false, + InitiatorRef: "actor-1", + }, + checkErr: func(err error) bool { return err != nil && strings.Contains(err.Error(), "intent.source is required") }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + _, err := validator.ValidateQuotePayment(tc.req) + if !tc.checkErr(err) { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} + +func TestValidateQuotePayments_Success(t *testing.T) { + validator := New() + orgID := bson.NewObjectID() + req := "ationv2.QuotePaymentsRequest{ + Meta: &sharedv1.RequestMeta{OrganizationRef: orgID.Hex()}, + Intents: []*transferv1.TransferIntent{validTransferIntent(), validTransferIntent()}, + PreviewOnly: true, + InitiatorRef: "actor-1", + } + + ctx, err := validator.ValidateQuotePayments(req) + if err != nil { + t.Fatalf("ValidateQuotePayments returned error: %v", err) + } + if ctx == nil { + t.Fatalf("expected validation context") + } + if got, want := ctx.IntentCount, 2; got != want { + t.Fatalf("expected intent_count %d, got %d", want, got) + } + if got, want := ctx.PreviewOnly, true; got != want { + t.Fatalf("expected preview_only %v, got %v", want, got) + } + if got, want := ctx.IdempotencyKey, ""; got != want { + t.Fatalf("expected idempotency_key %q, got %q", want, got) + } +} + +func TestValidateQuotePayments_Rules(t *testing.T) { + validator := New() + orgHex := bson.NewObjectID().Hex() + + testCases := []struct { + name string + req *quotationv2.QuotePaymentsRequest + checkErr func(error) bool + }{ + { + name: "intents required", + req: "ationv2.QuotePaymentsRequest{ + Meta: &sharedv1.RequestMeta{OrganizationRef: orgHex}, + IdempotencyKey: "idem-1", + PreviewOnly: false, + InitiatorRef: "actor-1", + }, + checkErr: func(err error) bool { return err != nil && strings.Contains(err.Error(), "intents are required") }, + }, + { + name: "idempotency required for non-preview", + req: "ationv2.QuotePaymentsRequest{ + Meta: &sharedv1.RequestMeta{OrganizationRef: orgHex}, + Intents: []*transferv1.TransferIntent{validTransferIntent()}, + PreviewOnly: false, + InitiatorRef: "actor-1", + }, + checkErr: func(err error) bool { return errors.Is(err, ErrIdempotencyRequired) }, + }, + { + name: "preview must not include idempotency", + req: "ationv2.QuotePaymentsRequest{ + Meta: &sharedv1.RequestMeta{OrganizationRef: orgHex}, + IdempotencyKey: "idem-1", + Intents: []*transferv1.TransferIntent{validTransferIntent()}, + PreviewOnly: true, + InitiatorRef: "actor-1", + }, + checkErr: func(err error) bool { return errors.Is(err, ErrPreviewWithIdempotency) }, + }, + { + name: "initiator ref required", + req: "ationv2.QuotePaymentsRequest{ + Meta: &sharedv1.RequestMeta{OrganizationRef: orgHex}, + Intents: []*transferv1.TransferIntent{validTransferIntent()}, + PreviewOnly: true, + }, + checkErr: func(err error) bool { return errors.Is(err, ErrInitiatorRefRequired) }, + }, + { + name: "indexed intent validation", + req: "ationv2.QuotePaymentsRequest{ + Meta: &sharedv1.RequestMeta{OrganizationRef: orgHex}, + IdempotencyKey: "idem-1", + Intents: []*transferv1.TransferIntent{ + { + Source: endpointWithMethodRef("pm-src"), + Destination: endpointWithMethodRef("pm-dst"), + }, + }, + PreviewOnly: false, + InitiatorRef: "actor-1", + }, + checkErr: func(err error) bool { + return err != nil && strings.Contains(err.Error(), "intents[0].amount is required") + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + _, err := validator.ValidateQuotePayments(tc.req) + if !tc.checkErr(err) { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} + +func validTransferIntent() *transferv1.TransferIntent { + return &transferv1.TransferIntent{ + Source: endpointWithMethodRef("pm-src"), + Destination: endpointWithMethodRef("pm-dst"), + Amount: &moneyv1.Money{Amount: "10", Currency: "USD"}, + } +} + +func endpointWithMethodRef(methodRef string) *endpointv1.PaymentEndpoint { + return &endpointv1.PaymentEndpoint{ + Source: &endpointv1.PaymentEndpoint_PaymentMethodRef{ + PaymentMethodRef: methodRef, + }, + } +} diff --git a/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/helpers.go b/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/helpers.go new file mode 100644 index 00000000..732bfd30 --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/helpers.go @@ -0,0 +1,140 @@ +package quote_response_mapper_v2 + +import ( + "strings" + + feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/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" +) + +func cloneMoney(src *moneyv1.Money) *moneyv1.Money { + if src == nil { + return nil + } + return &moneyv1.Money{ + Amount: strings.TrimSpace(src.GetAmount()), + Currency: strings.TrimSpace(src.GetCurrency()), + } +} + +func cloneFeeLines(src []*feesv1.DerivedPostingLine) []*feesv1.DerivedPostingLine { + if len(src) == 0 { + return nil + } + result := make([]*feesv1.DerivedPostingLine, 0, len(src)) + for _, line := range src { + if line == nil { + result = append(result, nil) + continue + } + cloned, ok := proto.Clone(line).(*feesv1.DerivedPostingLine) + if !ok { + continue + } + result = append(result, cloned) + } + if len(result) == 0 { + return nil + } + return result +} + +func cloneFeeRules(src []*feesv1.AppliedRule) []*feesv1.AppliedRule { + if len(src) == 0 { + return nil + } + result := make([]*feesv1.AppliedRule, 0, len(src)) + for _, rule := range src { + if rule == nil { + result = append(result, nil) + continue + } + cloned, ok := proto.Clone(rule).(*feesv1.AppliedRule) + if !ok { + continue + } + result = append(result, cloned) + } + if len(result) == 0 { + return nil + } + return result +} + +func cloneFXQuote(src *oraclev1.Quote) *oraclev1.Quote { + if src == nil { + return nil + } + cloned, ok := proto.Clone(src).(*oraclev1.Quote) + if !ok { + return nil + } + return cloned +} + +func cloneRoute(src *quotationv2.RouteSpecification) *quotationv2.RouteSpecification { + if src == nil { + return nil + } + result := "ationv2.RouteSpecification{ + Rail: strings.TrimSpace(src.GetRail()), + Provider: strings.TrimSpace(src.GetProvider()), + PayoutMethod: strings.TrimSpace(src.GetPayoutMethod()), + SettlementAsset: strings.ToUpper(strings.TrimSpace(src.GetSettlementAsset())), + SettlementModel: strings.TrimSpace(src.GetSettlementModel()), + Network: strings.TrimSpace(src.GetNetwork()), + RouteRef: strings.TrimSpace(src.GetRouteRef()), + PricingProfileRef: strings.TrimSpace(src.GetPricingProfileRef()), + } + if hops := src.GetHops(); len(hops) > 0 { + result.Hops = make([]*quotationv2.RouteHop, 0, len(hops)) + for _, hop := range hops { + if hop == nil { + continue + } + result.Hops = append(result.Hops, "ationv2.RouteHop{ + Index: hop.GetIndex(), + Rail: strings.TrimSpace(hop.GetRail()), + Gateway: strings.TrimSpace(hop.GetGateway()), + InstanceId: strings.TrimSpace(hop.GetInstanceId()), + Network: strings.TrimSpace(hop.GetNetwork()), + Role: hop.GetRole(), + }) + } + if len(result.Hops) == 0 { + result.Hops = nil + } + } + return result +} + +func cloneExecutionConditions(src *quotationv2.ExecutionConditions) *quotationv2.ExecutionConditions { + if src == nil { + return nil + } + result := "ationv2.ExecutionConditions{ + Readiness: src.GetReadiness(), + BatchingEligible: src.GetBatchingEligible(), + PrefundingRequired: src.GetPrefundingRequired(), + PrefundingCostIncluded: src.GetPrefundingCostIncluded(), + LiquidityCheckRequiredAtExecution: src.GetLiquidityCheckRequiredAtExecution(), + LatencyHint: strings.TrimSpace(src.GetLatencyHint()), + } + if assumptions := src.GetAssumptions(); len(assumptions) > 0 { + result.Assumptions = make([]string, 0, len(assumptions)) + for _, assumption := range assumptions { + trimmed := strings.TrimSpace(assumption) + if trimmed == "" { + continue + } + result.Assumptions = append(result.Assumptions, trimmed) + } + if len(result.Assumptions) == 0 { + result.Assumptions = nil + } + } + return result +} diff --git a/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/input.go b/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/input.go new file mode 100644 index 00000000..ed9afb92 --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/input.go @@ -0,0 +1,50 @@ +package quote_response_mapper_v2 + +import ( + "time" + + feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" + quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" +) + +type QuoteMeta struct { + ID string + CreatedAt time.Time + UpdatedAt time.Time +} + +type CanonicalQuote struct { + QuoteRef string + DebitAmount *moneyv1.Money + CreditAmount *moneyv1.Money + TotalCost *moneyv1.Money + FeeLines []*feesv1.DerivedPostingLine + FeeRules []*feesv1.AppliedRule + FXQuote *oraclev1.Quote + Route *quotationv2.RouteSpecification + Conditions *quotationv2.ExecutionConditions + ExpiresAt time.Time + PricedAt time.Time +} + +type QuoteStatus struct { + Kind quotationv2.QuoteKind + Lifecycle quotationv2.QuoteLifecycle + Executable *bool + BlockReason quotationv2.QuoteBlockReason +} + +type MapInput struct { + Meta QuoteMeta + Quote CanonicalQuote + Status QuoteStatus +} + +type MapOutput struct { + Quote *quotationv2.PaymentQuote + HasExecutionStatus bool + Executable bool + BlockReason quotationv2.QuoteBlockReason +} diff --git a/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/invariants.go b/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/invariants.go new file mode 100644 index 00000000..2ef57e8b --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/invariants.go @@ -0,0 +1,72 @@ +package quote_response_mapper_v2 + +import ( + "github.com/tech/sendico/pkg/merrors" + quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" +) + +type executionDecision struct { + hasStatus bool + executable bool + 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.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 + } + return executionDecision{ + hasStatus: true, + executable: false, + blockReason: status.BlockReason, + }, nil +} + +func (s QuoteStatus) ExecutableValue() bool { + if s.Executable == nil { + return false + } + return *s.Executable +} diff --git a/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/service.go b/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/service.go new file mode 100644 index 00000000..e6f78381 --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/service.go @@ -0,0 +1,76 @@ +package quote_response_mapper_v2 + +import ( + "strings" + "time" + + storablev1 "github.com/tech/sendico/pkg/proto/common/storable/v1" + quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" + "google.golang.org/protobuf/types/known/timestamppb" +) + +type QuoteResponseMapperV2 struct{} + +func New() *QuoteResponseMapperV2 { + return &QuoteResponseMapperV2{} +} + +func (m *QuoteResponseMapperV2) Map(in MapInput) (*MapOutput, error) { + decision, err := validateStatusInvariants(in.Status) + if err != nil { + return nil, err + } + + result := "ationv2.PaymentQuote{ + Storable: mapStorable(in.Meta), + Kind: in.Status.Kind, + Lifecycle: in.Status.Lifecycle, + DebitAmount: cloneMoney(in.Quote.DebitAmount), + CreditAmount: cloneMoney(in.Quote.CreditAmount), + TotalCost: cloneMoney(in.Quote.TotalCost), + FeeLines: cloneFeeLines(in.Quote.FeeLines), + FeeRules: cloneFeeRules(in.Quote.FeeRules), + FxQuote: cloneFXQuote(in.Quote.FXQuote), + Route: cloneRoute(in.Quote.Route), + ExecutionConditions: cloneExecutionConditions(in.Quote.Conditions), + QuoteRef: strings.TrimSpace(in.Quote.QuoteRef), + ExpiresAt: tsOrNil(in.Quote.ExpiresAt), + PricedAt: tsOrNil(in.Quote.PricedAt), + } + + if decision.hasStatus { + if decision.executable { + result.ExecutionStatus = "ationv2.PaymentQuote_Executable{Executable: true} + } else { + result.ExecutionStatus = "ationv2.PaymentQuote_BlockReason{ + BlockReason: decision.blockReason, + } + } + } + + return &MapOutput{ + Quote: result, + HasExecutionStatus: decision.hasStatus, + Executable: decision.executable, + BlockReason: decision.blockReason, + }, nil +} + +func mapStorable(meta QuoteMeta) *storablev1.Storable { + id := strings.TrimSpace(meta.ID) + if id == "" && meta.CreatedAt.IsZero() && meta.UpdatedAt.IsZero() { + return nil + } + return &storablev1.Storable{ + Id: id, + CreatedAt: tsOrNil(meta.CreatedAt), + UpdatedAt: tsOrNil(meta.UpdatedAt), + } +} + +func tsOrNil(value time.Time) *timestamppb.Timestamp { + if value.IsZero() { + return nil + } + return timestamppb.New(value) +} diff --git a/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/service_test.go b/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/service_test.go new file mode 100644 index 00000000..c4b99d35 --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/service_test.go @@ -0,0 +1,207 @@ +package quote_response_mapper_v2 + +import ( + "errors" + "testing" + "time" + + "github.com/tech/sendico/pkg/merrors" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" +) + +func TestMap_ExecutableActiveQuote(t *testing.T) { + mapper := New() + trueValue := true + createdAt := time.Unix(100, 0) + updatedAt := time.Unix(120, 0) + expiresAt := time.Unix(200, 0) + pricedAt := time.Unix(150, 0) + + out, err := mapper.Map(MapInput{ + Meta: QuoteMeta{ + ID: "rec-1", + CreatedAt: createdAt, + UpdatedAt: updatedAt, + }, + Quote: CanonicalQuote{ + QuoteRef: "q-1", + DebitAmount: &moneyv1.Money{ + Amount: "10", + Currency: "USD", + }, + CreditAmount: &moneyv1.Money{ + Amount: "9", + Currency: "EUR", + }, + TotalCost: &moneyv1.Money{ + Amount: "10.2", + Currency: "USD", + }, + Route: "ationv2.RouteSpecification{ + Rail: "CARD_PAYOUT", + Provider: "monetix", + PayoutMethod: "CARD", + SettlementAsset: "USD", + SettlementModel: "FIX_SOURCE", + }, + Conditions: "ationv2.ExecutionConditions{ + Readiness: quotationv2.QuoteExecutionReadiness_QUOTE_EXECUTION_READINESS_LIQUIDITY_READY, + BatchingEligible: true, + PrefundingRequired: false, + LiquidityCheckRequiredAtExecution: true, + }, + ExpiresAt: expiresAt, + PricedAt: pricedAt, + }, + Status: QuoteStatus{ + Kind: quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE, + Lifecycle: quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE, + Executable: &trueValue, + }, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out == nil || out.Quote == nil { + t.Fatalf("expected mapped quote") + } + if !out.HasExecutionStatus || !out.Executable { + t.Fatalf("expected executable status") + } + if !out.Quote.GetExecutable() { + t.Fatalf("expected proto executable=true") + } + if out.Quote.GetBlockReason() != quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED { + t.Fatalf("expected empty block reason") + } + if out.Quote.GetStorable().GetId() != "rec-1" { + t.Fatalf("expected storable id rec-1, got %q", out.Quote.GetStorable().GetId()) + } + if got := out.Quote.GetStorable().GetCreatedAt().AsTime(); !got.Equal(createdAt) { + t.Fatalf("unexpected created_at: %v", got) + } + if got := out.Quote.GetStorable().GetUpdatedAt().AsTime(); !got.Equal(updatedAt) { + t.Fatalf("unexpected updated_at: %v", got) + } + if got := out.Quote.GetExpiresAt().AsTime(); !got.Equal(expiresAt) { + t.Fatalf("unexpected expires_at: %v", got) + } + if got := out.Quote.GetPricedAt().AsTime(); !got.Equal(pricedAt) { + t.Fatalf("unexpected priced_at: %v", got) + } + 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.GetTotalCost().GetAmount(), "10.2"; got != want { + t.Fatalf("unexpected total_cost amount: got=%q want=%q", got, want) + } +} + +func TestMap_BlockedExecutableQuote(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, + BlockReason: quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_ROUTE_UNAVAILABLE, + }, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out == nil || out.Quote == nil { + t.Fatalf("expected mapped quote") + } + if !out.HasExecutionStatus || out.Executable { + t.Fatalf("expected blocked status") + } + 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) { + 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) + } + + _, err = mapper.Map(MapInput{ + Status: QuoteStatus{ + Kind: quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE, + Lifecycle: quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_EXPIRED, + Executable: &trueValue, + }, + }) + if !errors.Is(err, merrors.ErrInvalidArg) { + t.Fatalf("expected invalid arg for expired with execution status, got %v", err) + } + + out, err := mapper.Map(MapInput{ + Status: QuoteStatus{ + Kind: quotationv2.QuoteKind_QUOTE_KIND_INDICATIVE, + Lifecycle: quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE, + }, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out.HasExecutionStatus { + t.Fatalf("expected unset execution status") + } + if out.Quote.GetExecutionStatus() != nil { + t.Fatalf("expected no execution_status oneof") + } +} + +func TestMap_ExecutableActiveRequiresExactlyOneExecutionStatus(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, + }, + }) + if !errors.Is(err, merrors.ErrInvalidArg) { + t.Fatalf("expected invalid arg when execution status is missing, got %v", err) + } + + _, err = mapper.Map(MapInput{ + Status: QuoteStatus{ + Kind: quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE, + Lifecycle: quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE, + Executable: &trueValue, + 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) + } +} diff --git a/api/payments/quotation/internal/service/quotation/service_helpers.go b/api/payments/quotation/internal/service/quotation/service_helpers.go index fd6061ae..0a3c1667 100644 --- a/api/payments/quotation/internal/service/quotation/service_helpers.go +++ b/api/payments/quotation/internal/service/quotation/service_helpers.go @@ -78,7 +78,7 @@ func (s *Service) resolvePaymentQuote(ctx context.Context, in quoteResolutionInp } record, err := quotesStore.GetByRef(ctx, in.OrgID, ref) if err != nil { - if errors.Is(err, storage.ErrQuoteNotFound) { + if errors.Is(err, quotestorage.ErrQuoteNotFound) { return nil, nil, nil, quoteResolutionError{code: "quote_not_found", err: merrors.InvalidArgument("quote_ref not found or expired")} } return nil, nil, nil, err diff --git a/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/endpoint_resolver.go b/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/endpoint_resolver.go new file mode 100644 index 00000000..477b2e87 --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/endpoint_resolver.go @@ -0,0 +1,150 @@ +package transfer_intent_hydrator + +import ( + "context" + "strings" + + "github.com/tech/sendico/pkg/merrors" + endpointv1 "github.com/tech/sendico/pkg/proto/payments/endpoint/v1" + methodsv1 "github.com/tech/sendico/pkg/proto/payments/methods/v1" +) + +func (h *TransferIntentHydrator) hydrateEndpoint( + ctx context.Context, + organizationRef string, + endpoint *endpointv1.PaymentEndpoint, + field string, + role methodsv1.PrivateEndpoint, +) (QuoteEndpoint, error) { + if endpoint == nil { + return QuoteEndpoint{}, merrors.InvalidArgument(field + " is required") + } + + if methodRef := strings.TrimSpace(endpoint.GetPaymentMethodRef()); methodRef != "" { + resolved, resolvedMethodRef, err := h.resolvePrivate(ctx, organizationRef, field+".payment_method_ref", role, privateSelector{ + paymentMethodRef: methodRef, + }) + if err != nil { + return QuoteEndpoint{}, err + } + if resolvedMethodRef != "" { + resolved.PaymentMethodRef = resolvedMethodRef + } else { + resolved.PaymentMethodRef = methodRef + } + return resolved, nil + } + + if method := endpoint.GetPaymentMethod(); method != nil { + if method.GetType() != endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_ACCOUNT { + return hydrateFromPaymentMethod(method, field) + } + recipientRef := strings.TrimSpace(method.GetRecipientRef()) + if recipientRef == "" { + return QuoteEndpoint{}, merrors.InvalidArgument(field + ".payment_method.recipient_ref is required for account payment method") + } + resolved, resolvedMethodRef, err := h.resolvePrivate(ctx, organizationRef, field+".payment_method", role, privateSelector{ + payeeRef: recipientRef, + }) + if err != nil { + return QuoteEndpoint{}, err + } + if resolvedMethodRef != "" { + resolved.PaymentMethodRef = resolvedMethodRef + } + return resolved, nil + } + + if payeeRef := strings.TrimSpace(endpoint.GetPayeeRef()); payeeRef != "" { + resolved, resolvedMethodRef, err := h.resolvePrivate(ctx, organizationRef, field+".payee_ref", role, privateSelector{ + payeeRef: payeeRef, + }) + if err != nil { + return QuoteEndpoint{}, err + } + resolved.PayeeRef = payeeRef + resolved.PaymentMethodRef = resolvedMethodRef + return resolved, nil + } + + return QuoteEndpoint{}, merrors.InvalidArgument(field + " must include payment_method_ref, payment_method, or payee_ref") +} + +type privateSelector struct { + paymentMethodRef string + payeeRef string +} + +func (h *TransferIntentHydrator) resolvePrivate( + ctx context.Context, + organizationRef string, + field string, + role methodsv1.PrivateEndpoint, + selector privateSelector, +) (QuoteEndpoint, string, error) { + if h.methodsClient == nil { + return QuoteEndpoint{}, "", ErrPaymentMethodsClientRequired + } + + req := &methodsv1.GetPaymentMethodPrivateRequest{ + OrganizationRef: strings.TrimSpace(organizationRef), + Endpoint: role, + } + if selector.paymentMethodRef != "" { + req.Selector = &methodsv1.GetPaymentMethodPrivateRequest_PaymentMethodRef{ + PaymentMethodRef: strings.TrimSpace(selector.paymentMethodRef), + } + } + if selector.payeeRef != "" { + req.Selector = &methodsv1.GetPaymentMethodPrivateRequest_PayeeRef{ + PayeeRef: strings.TrimSpace(selector.payeeRef), + } + } + + resp, err := h.methodsClient.GetPaymentMethodPrivate(ctx, req) + if err != nil { + return QuoteEndpoint{}, "", err + } + record := resp.GetPaymentMethodRecord() + if record == nil || record.GetPaymentMethod() == nil { + return QuoteEndpoint{}, "", merrors.InvalidArgument(field + " not found") + } + + resolved, err := hydrateFromPaymentMethod(record.GetPaymentMethod(), field) + if err != nil { + return QuoteEndpoint{}, "", err + } + + methodRef := strings.TrimSpace(record.GetPermissionBound().GetStorable().GetId()) + return resolved, methodRef, nil +} + +/* +Processing Classes + +QuoteRequestValidatorV2 +Validates meta, idempotency, preview rules, initiator_ref, non-empty intents. +TransferIntentHydrator +Converts transferv1.TransferIntent to canonical sharedv1.PaymentIntent. +Resolves endpoint refs (payment_method_ref, payee_ref) using a new methods dependency. +Applies defaults for kind, settlement_mode, settlement_currency, attributes. +QuoteIdempotencyService +Computes request fingerprint for v2 requests. +Reuse/create logic against QuotesStore (same pattern as handlers_commands.go). +QuoteComputationService +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). +QuotePersistenceService +Persists quote record with v2 status metadata. +Keeps legacy ExecutionNote for backward compatibility. +QuoteResponseMapperV2 +Maps canonical quote + status to quotationv2.PaymentQuote. +Enforces your lifecycle/execution invariants. +BatchQuoteProcessorV2 +Iterates single-intent processor with per-item idempotency derivation. +Returns QuotePaymentsResponse without aggregate. + +*/ diff --git a/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/errors.go b/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/errors.go new file mode 100644 index 00000000..663738c5 --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/errors.go @@ -0,0 +1,7 @@ +package transfer_intent_hydrator + +import "errors" + +var ( + ErrPaymentMethodsClientRequired = errors.New("payment methods client is required to resolve endpoint references") +) diff --git a/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/helpers.go b/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/helpers.go new file mode 100644 index 00000000..46a6d104 --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/helpers.go @@ -0,0 +1,40 @@ +package transfer_intent_hydrator + +import ( + "strconv" + "strings" + + "github.com/tech/sendico/pkg/merrors" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func decodeMethodData(data []byte, dst any, field string) error { + if len(data) == 0 { + return merrors.InvalidArgument(field + ".data is required") + } + if err := bson.Unmarshal(data, dst); err != nil { + return merrors.InvalidArgument(field + ".data is invalid") + } + return nil +} + +func parseUint32(value string, field string) (uint32, error) { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return 0, nil + } + num, err := strconv.ParseUint(trimmed, 10, 32) + if err != nil { + return 0, merrors.InvalidArgument(field + " must be numeric") + } + return uint32(num), nil +} + +func inferKind(destination QuoteEndpoint) QuoteIntentKind { + switch destination.Type { + case QuoteEndpointTypeExternalChain, QuoteEndpointTypeCard: + return QuoteIntentKindPayout + default: + return QuoteIntentKindInternalTransfer + } +} diff --git a/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/hydrator.go b/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/hydrator.go new file mode 100644 index 00000000..dd388079 --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/hydrator.go @@ -0,0 +1,148 @@ +package transfer_intent_hydrator + +import ( + "context" + "fmt" + "strings" + + "github.com/tech/sendico/pkg/merrors" + paymenttypes "github.com/tech/sendico/pkg/payments/types" + methodsv1 "github.com/tech/sendico/pkg/proto/payments/methods/v1" + transferv1 "github.com/tech/sendico/pkg/proto/payments/transfer/v1" + "go.mongodb.org/mongo-driver/v2/bson" + "google.golang.org/grpc" +) + +type HydrateOneInput struct { + OrganizationRef string + InitiatorRef string + Intent *transferv1.TransferIntent +} + +type HydrateManyInput struct { + OrganizationRef string + InitiatorRef string + Intents []*transferv1.TransferIntent +} + +type PaymentMethodsClient interface { + GetPaymentMethodPrivate(ctx context.Context, in *methodsv1.GetPaymentMethodPrivateRequest, opts ...grpc.CallOption) (*methodsv1.GetPaymentMethodPrivateResponse, error) +} + +type Option func(*TransferIntentHydrator) + +type TransferIntentHydrator struct { + methodsClient PaymentMethodsClient + newRef func() string +} + +func New(methodsClient PaymentMethodsClient, opts ...Option) *TransferIntentHydrator { + h := &TransferIntentHydrator{ + methodsClient: methodsClient, + newRef: func() string { + return bson.NewObjectID().Hex() + }, + } + for _, opt := range opts { + if opt != nil { + opt(h) + } + } + if h.newRef == nil { + h.newRef = func() string { return bson.NewObjectID().Hex() } + } + return h +} + +func WithRefFactory(newRef func() string) Option { + return func(h *TransferIntentHydrator) { + if newRef != nil { + h.newRef = newRef + } + } +} + +func (h *TransferIntentHydrator) HydrateOne(ctx context.Context, in HydrateOneInput) (*QuoteIntent, error) { + if strings.TrimSpace(in.OrganizationRef) == "" { + return nil, merrors.InvalidArgument("organization_ref is required") + } + if strings.TrimSpace(in.InitiatorRef) == "" { + return nil, merrors.InvalidArgument("initiator_ref is required") + } + if in.Intent == nil { + return nil, merrors.InvalidArgument("intent is required") + } + if in.Intent.GetAmount() == nil { + return nil, merrors.InvalidArgument("intent.amount is required") + } + + source, err := h.hydrateEndpoint( + ctx, + in.OrganizationRef, + in.Intent.GetSource(), + "intent.source", + methodsv1.PrivateEndpoint_PRIVATE_ENDPOINT_SOURCE, + ) + if err != nil { + return nil, err + } + destination, err := h.hydrateEndpoint( + ctx, + in.OrganizationRef, + in.Intent.GetDestination(), + "intent.destination", + methodsv1.PrivateEndpoint_PRIVATE_ENDPOINT_DESTINATION, + ) + if err != nil { + return nil, err + } + + amount := &paymenttypes.Money{ + Amount: strings.TrimSpace(in.Intent.GetAmount().GetAmount()), + Currency: strings.TrimSpace(in.Intent.GetAmount().GetCurrency()), + } + if amount.Amount == "" { + return nil, merrors.InvalidArgument("intent.amount.amount is required") + } + if amount.Currency == "" { + return nil, merrors.InvalidArgument("intent.amount.currency is required") + } + + intent := &QuoteIntent{ + Ref: h.newRef(), + Kind: inferKind(destination), + Source: source, + Destination: destination, + Amount: amount, + Comment: strings.TrimSpace(in.Intent.GetComment()), + SettlementMode: QuoteSettlementModeUnspecified, + SettlementCurrency: amount.Currency, + RequiresFX: false, + Attributes: map[string]string{ + "initiator_ref": strings.TrimSpace(in.InitiatorRef), + }, + } + if intent.Comment != "" { + intent.Attributes["comment"] = intent.Comment + } + return intent, nil +} + +func (h *TransferIntentHydrator) HydrateMany(ctx context.Context, in HydrateManyInput) ([]*QuoteIntent, error) { + if len(in.Intents) == 0 { + return nil, merrors.InvalidArgument("intents are required") + } + out := make([]*QuoteIntent, 0, len(in.Intents)) + for i, intent := range in.Intents { + item, err := h.HydrateOne(ctx, HydrateOneInput{ + OrganizationRef: in.OrganizationRef, + InitiatorRef: in.InitiatorRef, + Intent: intent, + }) + if err != nil { + return nil, fmt.Errorf("intents[%d]: %w", i, err) + } + out = append(out, item) + } + return out, nil +} diff --git a/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/payment_method_mapper.go b/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/payment_method_mapper.go new file mode 100644 index 00000000..b43bd1ce --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/payment_method_mapper.go @@ -0,0 +1,161 @@ +package transfer_intent_hydrator + +import ( + "fmt" + "strings" + + chainpkg "github.com/tech/sendico/pkg/chain" + "github.com/tech/sendico/pkg/merrors" + pkgmodel "github.com/tech/sendico/pkg/model" + paymenttypes "github.com/tech/sendico/pkg/payments/types" + endpointv1 "github.com/tech/sendico/pkg/proto/payments/endpoint/v1" +) + +func hydrateFromPaymentMethod(method *endpointv1.PaymentMethod, field string) (QuoteEndpoint, error) { + if method == nil { + return QuoteEndpoint{}, merrors.InvalidArgument(field + " is required") + } + switch method.GetType() { + case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_WALLET: + return hydrateWalletMethod(method, field) + case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CRYPTO_ADDRESS: + return hydrateCryptoAddressMethod(method, field) + case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CARD: + return hydrateCardMethod(method, field) + case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CARD_TOKEN: + return hydrateCardTokenMethod(method, field) + case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_LEDGER: + return hydrateLedgerMethod(method, field) + default: + return QuoteEndpoint{}, merrors.InvalidArgument( + fmt.Sprintf("%s uses unsupported payment method type: %s", field, method.GetType().String()), + ) + } +} + +func hydrateWalletMethod(method *endpointv1.PaymentMethod, field string) (QuoteEndpoint, error) { + var data pkgmodel.WalletPaymentData + if err := decodeMethodData(method.GetData(), &data, field); err != nil { + return QuoteEndpoint{}, err + } + ref := strings.TrimSpace(data.WalletID) + if ref == "" { + return QuoteEndpoint{}, merrors.InvalidArgument(field + ".wallet_id is required") + } + return QuoteEndpoint{ + Type: QuoteEndpointTypeManagedWallet, + ManagedWallet: &QuoteManagedWalletEndpoint{ + ManagedWalletRef: ref, + }, + }, nil +} + +func hydrateCryptoAddressMethod(method *endpointv1.PaymentMethod, field string) (QuoteEndpoint, error) { + var data pkgmodel.CryptoAddressPaymentData + if err := decodeMethodData(method.GetData(), &data, field); err != nil { + return QuoteEndpoint{}, err + } + addr := strings.TrimSpace(data.Address) + if addr == "" { + return QuoteEndpoint{}, merrors.InvalidArgument(field + ".address is required") + } + asset := &paymenttypes.Asset{ + TokenSymbol: strings.ToUpper(strings.TrimSpace(string(data.Currency))), + } + network := chainpkg.NetworkFromString(data.Network) + if networkAlias := strings.TrimSpace(chainpkg.NetworkAlias(network)); networkAlias != "" && networkAlias != "UNSPECIFIED" { + asset.Chain = networkAlias + } + if data.DestinationTag != nil { + memo := strings.TrimSpace(*data.DestinationTag) + return QuoteEndpoint{ + Type: QuoteEndpointTypeExternalChain, + ExternalChain: &QuoteExternalChainEndpoint{ + Asset: asset, + Address: addr, + Memo: memo, + }, + }, nil + } + return QuoteEndpoint{ + Type: QuoteEndpointTypeExternalChain, + ExternalChain: &QuoteExternalChainEndpoint{ + Asset: asset, + Address: addr, + }, + }, nil +} + +func hydrateCardMethod(method *endpointv1.PaymentMethod, field string) (QuoteEndpoint, error) { + var data pkgmodel.CardPaymentData + if err := decodeMethodData(method.GetData(), &data, field); err != nil { + return QuoteEndpoint{}, err + } + expMonth, err := parseUint32(data.ExpMonth, field+".exp_month") + if err != nil { + return QuoteEndpoint{}, err + } + expYear, err := parseUint32(data.ExpYear, field+".exp_year") + if err != nil { + return QuoteEndpoint{}, err + } + return QuoteEndpoint{ + Type: QuoteEndpointTypeCard, + Card: &QuoteCardEndpoint{ + Pan: strings.TrimSpace(data.Pan), + Cardholder: strings.TrimSpace(data.FirstName), + CardholderSurname: strings.TrimSpace(data.LastName), + ExpMonth: expMonth, + ExpYear: expYear, + Country: strings.TrimSpace(data.Country), + }, + }, nil +} + +func hydrateCardTokenMethod(method *endpointv1.PaymentMethod, field string) (QuoteEndpoint, error) { + var data pkgmodel.TokenPaymentData + if err := decodeMethodData(method.GetData(), &data, field); err != nil { + return QuoteEndpoint{}, err + } + expMonth, err := parseUint32(data.ExpMonth, field+".exp_month") + if err != nil { + return QuoteEndpoint{}, err + } + expYear, err := parseUint32(data.ExpYear, field+".exp_year") + if err != nil { + return QuoteEndpoint{}, err + } + return QuoteEndpoint{ + Type: QuoteEndpointTypeCard, + Card: &QuoteCardEndpoint{ + Token: strings.TrimSpace(data.Token), + Cardholder: strings.TrimSpace(data.CardholderName), + ExpMonth: expMonth, + ExpYear: expYear, + Country: strings.TrimSpace(data.Country), + MaskedPan: strings.TrimSpace(data.Last4), + }, + }, nil +} + +func hydrateLedgerMethod(method *endpointv1.PaymentMethod, field string) (QuoteEndpoint, error) { + type ledgerData struct { + LedgerAccountRef string `bson:"ledgerAccountRef"` + ContraLedgerAccountRef string `bson:"contraLedgerAccountRef,omitempty"` + } + var data ledgerData + if err := decodeMethodData(method.GetData(), &data, field); err != nil { + return QuoteEndpoint{}, err + } + ref := strings.TrimSpace(data.LedgerAccountRef) + if ref == "" { + return QuoteEndpoint{}, merrors.InvalidArgument(field + ".ledger_account_ref is required") + } + return QuoteEndpoint{ + Type: QuoteEndpointTypeLedger, + Ledger: &QuoteLedgerEndpoint{ + LedgerAccountRef: ref, + ContraLedgerAccountRef: strings.TrimSpace(data.ContraLedgerAccountRef), + }, + }, nil +} diff --git a/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/quote_intent.go b/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/quote_intent.go new file mode 100644 index 00000000..3f6852c3 --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/quote_intent.go @@ -0,0 +1,81 @@ +package transfer_intent_hydrator + +import paymenttypes "github.com/tech/sendico/pkg/payments/types" + +type QuoteIntentKind string + +const ( + QuoteIntentKindUnspecified QuoteIntentKind = "unspecified" + QuoteIntentKindPayout QuoteIntentKind = "payout" + QuoteIntentKindInternalTransfer QuoteIntentKind = "internal_transfer" + QuoteIntentKindFXConversion QuoteIntentKind = "fx_conversion" +) + +type QuoteSettlementMode string + +const ( + QuoteSettlementModeUnspecified QuoteSettlementMode = "unspecified" + QuoteSettlementModeFixSource QuoteSettlementMode = "fix_source" + QuoteSettlementModeFixReceived QuoteSettlementMode = "fix_received" +) + +type QuoteEndpointType string + +const ( + QuoteEndpointTypeUnspecified QuoteEndpointType = "unspecified" + QuoteEndpointTypeLedger QuoteEndpointType = "ledger" + QuoteEndpointTypeManagedWallet QuoteEndpointType = "managed_wallet" + QuoteEndpointTypeExternalChain QuoteEndpointType = "external_chain" + QuoteEndpointTypeCard QuoteEndpointType = "card" +) + +type QuoteLedgerEndpoint struct { + LedgerAccountRef string + ContraLedgerAccountRef string +} + +type QuoteManagedWalletEndpoint struct { + ManagedWalletRef string + Asset *paymenttypes.Asset +} + +type QuoteExternalChainEndpoint struct { + Asset *paymenttypes.Asset + Address string + Memo string +} + +type QuoteCardEndpoint struct { + Pan string + Token string + Cardholder string + CardholderSurname string + ExpMonth uint32 + ExpYear uint32 + Country string + MaskedPan string +} + +type QuoteEndpoint struct { + Type QuoteEndpointType + PaymentMethodRef string + PayeeRef string + + Ledger *QuoteLedgerEndpoint + ManagedWallet *QuoteManagedWalletEndpoint + ExternalChain *QuoteExternalChainEndpoint + Card *QuoteCardEndpoint +} + +type QuoteIntent struct { + Ref string + Kind QuoteIntentKind + Source QuoteEndpoint + Destination QuoteEndpoint + Amount *paymenttypes.Money + Comment string + SettlementMode QuoteSettlementMode + SettlementCurrency string + RequiresFX bool + Attributes map[string]string +} diff --git a/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/transfer_intent_hydrator_test.go b/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/transfer_intent_hydrator_test.go new file mode 100644 index 00000000..7f9dd334 --- /dev/null +++ b/api/payments/quotation/internal/service/quotation/transfer_intent_hydrator/transfer_intent_hydrator_test.go @@ -0,0 +1,454 @@ +package transfer_intent_hydrator + +import ( + "context" + "errors" + "strings" + "testing" + + pkgmodel "github.com/tech/sendico/pkg/model" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + pboundv1 "github.com/tech/sendico/pkg/proto/common/permission_bound/v1" + storablev1 "github.com/tech/sendico/pkg/proto/common/storable/v1" + endpointv1 "github.com/tech/sendico/pkg/proto/payments/endpoint/v1" + methodsv1 "github.com/tech/sendico/pkg/proto/payments/methods/v1" + transferv1 "github.com/tech/sendico/pkg/proto/payments/transfer/v1" + "go.mongodb.org/mongo-driver/v2/bson" + "google.golang.org/grpc" +) + +func TestHydrateOne_SuccessWithInlineMethods(t *testing.T) { + h := New(nil, WithRefFactory(func() string { return "q-intent-1" })) + tag := "12345" + intent := &transferv1.TransferIntent{ + Source: &endpointv1.PaymentEndpoint{ + Source: &endpointv1.PaymentEndpoint_PaymentMethod{ + PaymentMethod: &endpointv1.PaymentMethod{ + Type: endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_WALLET, + Data: mustMarshalBSON(t, pkgmodel.WalletPaymentData{ + WalletID: "mw-src-1", + }), + }, + }, + }, + Destination: &endpointv1.PaymentEndpoint{ + Source: &endpointv1.PaymentEndpoint_PaymentMethod{ + PaymentMethod: &endpointv1.PaymentMethod{ + Type: endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CRYPTO_ADDRESS, + Data: mustMarshalBSON(t, pkgmodel.CryptoAddressPaymentData{ + Currency: pkgmodel.CurrencyUSDT, + Address: "TXYZ", + Network: "TRON_MAINNET", + DestinationTag: &tag, + }), + }, + }, + }, + Amount: newMoney("10.25", "USDT"), + Comment: "transfer note", + } + + got, err := h.HydrateOne(context.Background(), HydrateOneInput{ + OrganizationRef: bson.NewObjectID().Hex(), + InitiatorRef: bson.NewObjectID().Hex(), + Intent: intent, + }) + if err != nil { + t.Fatalf("HydrateOne returned error: %v", err) + } + if got == nil { + t.Fatalf("expected hydrated intent") + } + if got.Ref != "q-intent-1" { + t.Fatalf("expected ref q-intent-1, got %q", got.Ref) + } + if got.Kind != QuoteIntentKindPayout { + t.Fatalf("expected payout kind, got %s", got.Kind) + } + if got.Source.Type != QuoteEndpointTypeManagedWallet { + t.Fatalf("expected managed wallet source, got %s", got.Source.Type) + } + if got.Source.ManagedWallet == nil || got.Source.ManagedWallet.ManagedWalletRef != "mw-src-1" { + t.Fatalf("unexpected managed wallet source: %#v", got.Source.ManagedWallet) + } + if got.Destination.Type != QuoteEndpointTypeExternalChain { + t.Fatalf("expected external chain destination, got %s", got.Destination.Type) + } + if got.Destination.ExternalChain == nil { + t.Fatalf("expected external chain payload") + } + if got.Destination.ExternalChain.Asset == nil || got.Destination.ExternalChain.Asset.Chain != "TRON_MAINNET" { + t.Fatalf("expected TRON_MAINNET chain, got %#v", got.Destination.ExternalChain.Asset) + } + if got.Destination.ExternalChain.Memo != tag { + t.Fatalf("expected destination memo %q, got %q", tag, got.Destination.ExternalChain.Memo) + } + if got.SettlementCurrency != "USDT" { + t.Fatalf("expected settlement currency USDT, got %q", got.SettlementCurrency) + } + if got.Amount == nil || got.Amount.Amount != "10.25" { + t.Fatalf("unexpected amount: %#v", got.Amount) + } +} + +func TestHydrateOne_ResolvesPaymentMethodRefViaPrivateMethod(t *testing.T) { + orgRef := bson.NewObjectID().Hex() + methodRef := bson.NewObjectID().Hex() + resolvedMethodRef := bson.NewObjectID().Hex() + + var getReq *methodsv1.GetPaymentMethodPrivateRequest + h := New(&fakeMethodsClient{ + getPaymentMethodPrivateFn: func( + _ context.Context, + req *methodsv1.GetPaymentMethodPrivateRequest, + _ ...grpc.CallOption, + ) (*methodsv1.GetPaymentMethodPrivateResponse, error) { + getReq = req + return &methodsv1.GetPaymentMethodPrivateResponse{ + PaymentMethodRecord: &endpointv1.PaymentMethodRecord{ + PermissionBound: &pboundv1.PermissionBound{ + Storable: &storablev1.Storable{Id: resolvedMethodRef}, + }, + PaymentMethod: &endpointv1.PaymentMethod{ + Type: endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_WALLET, + Data: mustMarshalBSON(t, pkgmodel.WalletPaymentData{ + WalletID: "mw-source-ref", + }), + }, + }, + }, nil + }, + }) + + intent := &transferv1.TransferIntent{ + Source: &endpointv1.PaymentEndpoint{ + Source: &endpointv1.PaymentEndpoint_PaymentMethodRef{PaymentMethodRef: methodRef}, + }, + Destination: &endpointv1.PaymentEndpoint{ + Source: &endpointv1.PaymentEndpoint_PaymentMethod{ + PaymentMethod: &endpointv1.PaymentMethod{ + Type: endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CARD, + Data: mustMarshalBSON(t, pkgmodel.CardPaymentData{ + Pan: "4111111111111111", + ExpMonth: "12", + ExpYear: "2030", + Country: "US", + }), + }, + }, + }, + Amount: newMoney("1", "USD"), + } + + got, err := h.HydrateOne(context.Background(), HydrateOneInput{ + OrganizationRef: orgRef, + InitiatorRef: bson.NewObjectID().Hex(), + Intent: intent, + }) + if err != nil { + t.Fatalf("HydrateOne returned error: %v", err) + } + if getReq == nil { + t.Fatalf("expected GetPaymentMethodPrivate call") + } + if getReq.GetOrganizationRef() != orgRef { + t.Fatalf("unexpected organization_ref in request: %#v", getReq) + } + if getReq.GetEndpoint() != methodsv1.PrivateEndpoint_PRIVATE_ENDPOINT_SOURCE { + t.Fatalf("expected source endpoint role, got %s", getReq.GetEndpoint().String()) + } + if getReq.GetPaymentMethodRef() != methodRef { + t.Fatalf("unexpected payment_method_ref in request: %#v", getReq) + } + if got.Source.PaymentMethodRef != resolvedMethodRef { + t.Fatalf("expected resolved payment_method_ref %q, got %q", resolvedMethodRef, got.Source.PaymentMethodRef) + } + if got.Source.ManagedWallet == nil || got.Source.ManagedWallet.ManagedWalletRef != "mw-source-ref" { + t.Fatalf("unexpected resolved source: %#v", got.Source) + } +} + +func TestHydrateOne_ResolvesPayeeRefViaPrivateMethod(t *testing.T) { + orgRef := bson.NewObjectID().Hex() + payeeRef := bson.NewObjectID().Hex() + mainMethodRef := bson.NewObjectID().Hex() + + var getReq *methodsv1.GetPaymentMethodPrivateRequest + h := New(&fakeMethodsClient{ + getPaymentMethodPrivateFn: func( + _ context.Context, + req *methodsv1.GetPaymentMethodPrivateRequest, + _ ...grpc.CallOption, + ) (*methodsv1.GetPaymentMethodPrivateResponse, error) { + getReq = req + return &methodsv1.GetPaymentMethodPrivateResponse{ + PaymentMethodRecord: &endpointv1.PaymentMethodRecord{ + PermissionBound: &pboundv1.PermissionBound{ + Storable: &storablev1.Storable{Id: mainMethodRef}, + }, + PaymentMethod: &endpointv1.PaymentMethod{ + Type: endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CARD_TOKEN, + Data: mustMarshalBSON(t, pkgmodel.TokenPaymentData{ + Token: "tok-1", + CardholderName: "John", + ExpMonth: "1", + ExpYear: "2031", + Country: "US", + }), + }, + }, + }, nil + }, + }) + + intent := &transferv1.TransferIntent{ + Source: &endpointv1.PaymentEndpoint{ + Source: &endpointv1.PaymentEndpoint_PaymentMethod{ + PaymentMethod: &endpointv1.PaymentMethod{ + Type: endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_WALLET, + Data: mustMarshalBSON(t, pkgmodel.WalletPaymentData{ + WalletID: "mw-src", + }), + }, + }, + }, + Destination: &endpointv1.PaymentEndpoint{ + Source: &endpointv1.PaymentEndpoint_PayeeRef{PayeeRef: payeeRef}, + }, + Amount: newMoney("7.5", "USD"), + } + + got, err := h.HydrateOne(context.Background(), HydrateOneInput{ + OrganizationRef: orgRef, + InitiatorRef: bson.NewObjectID().Hex(), + Intent: intent, + }) + if err != nil { + t.Fatalf("HydrateOne returned error: %v", err) + } + if getReq == nil { + t.Fatalf("expected GetPaymentMethodPrivate call") + } + if getReq.GetOrganizationRef() != orgRef || getReq.GetPayeeRef() != payeeRef { + t.Fatalf("unexpected request: %#v", getReq) + } + if getReq.GetEndpoint() != methodsv1.PrivateEndpoint_PRIVATE_ENDPOINT_DESTINATION { + t.Fatalf("expected destination endpoint role, got %s", getReq.GetEndpoint().String()) + } + if got.Destination.Type != QuoteEndpointTypeCard { + t.Fatalf("expected card destination, got %s", got.Destination.Type) + } + if got.Destination.Card == nil || got.Destination.Card.Token != "tok-1" { + t.Fatalf("unexpected selected destination method: %#v", got.Destination) + } + if got.Destination.PaymentMethodRef != mainMethodRef { + t.Fatalf("expected selected main method ref %q, got %q", mainMethodRef, got.Destination.PaymentMethodRef) + } +} + +func TestHydrateOne_ResolvesInlineAccountPaymentMethodViaPrivateMethod(t *testing.T) { + orgRef := bson.NewObjectID().Hex() + accountRecipientRef := bson.NewObjectID().Hex() + childMethodRef := bson.NewObjectID().Hex() + + var getReq *methodsv1.GetPaymentMethodPrivateRequest + h := New(&fakeMethodsClient{ + getPaymentMethodPrivateFn: func( + _ context.Context, + req *methodsv1.GetPaymentMethodPrivateRequest, + _ ...grpc.CallOption, + ) (*methodsv1.GetPaymentMethodPrivateResponse, error) { + getReq = req + return &methodsv1.GetPaymentMethodPrivateResponse{ + PaymentMethodRecord: &endpointv1.PaymentMethodRecord{ + PermissionBound: &pboundv1.PermissionBound{ + Storable: &storablev1.Storable{Id: childMethodRef}, + }, + PaymentMethod: &endpointv1.PaymentMethod{ + Type: endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_WALLET, + Data: mustMarshalBSON(t, pkgmodel.WalletPaymentData{ + WalletID: "mw-child-main", + }), + }, + }, + }, nil + }, + }) + + intent := &transferv1.TransferIntent{ + Source: &endpointv1.PaymentEndpoint{ + Source: &endpointv1.PaymentEndpoint_PaymentMethod{ + PaymentMethod: &endpointv1.PaymentMethod{ + Type: endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_WALLET, + Data: mustMarshalBSON(t, pkgmodel.WalletPaymentData{ + WalletID: "mw-src", + }), + }, + }, + }, + Destination: &endpointv1.PaymentEndpoint{ + Source: &endpointv1.PaymentEndpoint_PaymentMethod{ + PaymentMethod: &endpointv1.PaymentMethod{ + Type: endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_ACCOUNT, + RecipientRef: accountRecipientRef, + }, + }, + }, + Amount: newMoney("4.2", "USD"), + } + + got, err := h.HydrateOne(context.Background(), HydrateOneInput{ + OrganizationRef: orgRef, + InitiatorRef: bson.NewObjectID().Hex(), + Intent: intent, + }) + if err != nil { + t.Fatalf("HydrateOne returned error: %v", err) + } + if getReq == nil { + t.Fatalf("expected GetPaymentMethodPrivate call") + } + if getReq.GetOrganizationRef() != orgRef || getReq.GetPayeeRef() != accountRecipientRef { + t.Fatalf("unexpected request: %#v", getReq) + } + if getReq.GetEndpoint() != methodsv1.PrivateEndpoint_PRIVATE_ENDPOINT_DESTINATION { + t.Fatalf("expected destination endpoint role, got %s", getReq.GetEndpoint().String()) + } + if got.Destination.Type != QuoteEndpointTypeManagedWallet { + t.Fatalf("expected managed wallet destination, got %s", got.Destination.Type) + } + if got.Destination.ManagedWallet == nil || got.Destination.ManagedWallet.ManagedWalletRef != "mw-child-main" { + t.Fatalf("unexpected resolved destination: %#v", got.Destination.ManagedWallet) + } + if got.Destination.PaymentMethodRef != childMethodRef { + t.Fatalf("expected resolved child method ref %q, got %q", childMethodRef, got.Destination.PaymentMethodRef) + } +} + +func TestHydrateOne_ReferenceWithoutMethodsClientFails(t *testing.T) { + h := New(nil) + intent := &transferv1.TransferIntent{ + Source: &endpointv1.PaymentEndpoint{ + Source: &endpointv1.PaymentEndpoint_PaymentMethodRef{PaymentMethodRef: bson.NewObjectID().Hex()}, + }, + Destination: &endpointv1.PaymentEndpoint{ + Source: &endpointv1.PaymentEndpoint_PaymentMethod{ + PaymentMethod: &endpointv1.PaymentMethod{ + Type: endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_WALLET, + Data: mustMarshalBSON(t, pkgmodel.WalletPaymentData{ + WalletID: "mw-dst", + }), + }, + }, + }, + Amount: newMoney("1", "USD"), + } + + _, err := h.HydrateOne(context.Background(), HydrateOneInput{ + OrganizationRef: bson.NewObjectID().Hex(), + InitiatorRef: bson.NewObjectID().Hex(), + Intent: intent, + }) + if !errors.Is(err, ErrPaymentMethodsClientRequired) { + t.Fatalf("expected ErrPaymentMethodsClientRequired, got %v", err) + } +} + +func TestHydrateMany_IndexesError(t *testing.T) { + h := New(nil) + intents := []*transferv1.TransferIntent{ + { + Source: &endpointv1.PaymentEndpoint{ + Source: &endpointv1.PaymentEndpoint_PaymentMethod{ + PaymentMethod: &endpointv1.PaymentMethod{ + Type: endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_WALLET, + Data: mustMarshalBSON(t, pkgmodel.WalletPaymentData{ + WalletID: "mw-src", + }), + }, + }, + }, + Destination: &endpointv1.PaymentEndpoint{ + Source: &endpointv1.PaymentEndpoint_PaymentMethod{ + PaymentMethod: &endpointv1.PaymentMethod{ + Type: endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CARD, + Data: mustMarshalBSON(t, pkgmodel.CardPaymentData{ + Pan: "4111111111111111", + ExpMonth: "12", + ExpYear: "2030", + }), + }, + }, + }, + Amount: newMoney("1", "USD"), + }, + { + Source: &endpointv1.PaymentEndpoint{ + Source: &endpointv1.PaymentEndpoint_PaymentMethod{ + PaymentMethod: &endpointv1.PaymentMethod{ + Type: endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_WALLET, + Data: mustMarshalBSON(t, pkgmodel.WalletPaymentData{ + WalletID: "mw-src-2", + }), + }, + }, + }, + Destination: &endpointv1.PaymentEndpoint{ + Source: &endpointv1.PaymentEndpoint_PaymentMethod{ + PaymentMethod: &endpointv1.PaymentMethod{ + Type: endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CARD, + Data: mustMarshalBSON(t, pkgmodel.CardPaymentData{ + Pan: "4111111111111111", + ExpMonth: "nope", + ExpYear: "2030", + }), + }, + }, + }, + Amount: newMoney("2", "USD"), + }, + } + + _, err := h.HydrateMany(context.Background(), HydrateManyInput{ + OrganizationRef: bson.NewObjectID().Hex(), + InitiatorRef: bson.NewObjectID().Hex(), + Intents: intents, + }) + if err == nil { + t.Fatalf("expected batch hydration error") + } + if !strings.Contains(err.Error(), "intents[1]") { + t.Fatalf("expected indexed error, got %v", err) + } +} + +type fakeMethodsClient struct { + getPaymentMethodPrivateFn func(context.Context, *methodsv1.GetPaymentMethodPrivateRequest, ...grpc.CallOption) (*methodsv1.GetPaymentMethodPrivateResponse, error) +} + +func (f *fakeMethodsClient) GetPaymentMethodPrivate( + ctx context.Context, + req *methodsv1.GetPaymentMethodPrivateRequest, + opts ...grpc.CallOption, +) (*methodsv1.GetPaymentMethodPrivateResponse, error) { + if f.getPaymentMethodPrivateFn == nil { + return nil, errors.New("unexpected GetPaymentMethodPrivate call") + } + return f.getPaymentMethodPrivateFn(ctx, req, opts...) +} + +func newMoney(amount, currency string) *moneyv1.Money { + return &moneyv1.Money{ + Amount: amount, + Currency: currency, + } +} + +func mustMarshalBSON(t *testing.T, value any) []byte { + t.Helper() + data, err := bson.Marshal(value) + if err != nil { + t.Fatalf("marshal bson: %v", err) + } + return data +} diff --git a/api/payments/quotation/internal/service/shared/account.go b/api/payments/quotation/internal/shared/account.go similarity index 100% rename from api/payments/quotation/internal/service/shared/account.go rename to api/payments/quotation/internal/shared/account.go diff --git a/api/payments/quotation/internal/shared/funding.go b/api/payments/quotation/internal/shared/funding.go new file mode 100644 index 00000000..2e3c7d80 --- /dev/null +++ b/api/payments/quotation/internal/shared/funding.go @@ -0,0 +1,20 @@ +package shared + +import ( + "strings" + + "github.com/tech/sendico/payments/storage/model" +) + +func NormalizeFundingMode(mode model.FundingMode) model.FundingMode { + switch strings.ToLower(strings.TrimSpace(string(mode))) { + case string(model.FundingModeNone): + return model.FundingModeNone + case string(model.FundingModeBalanceReserve): + return model.FundingModeBalanceReserve + case string(model.FundingModeDepositObserved): + return model.FundingModeDepositObserved + default: + return model.FundingModeUnspecified + } +} diff --git a/api/payments/storage/model/dpolicy.go b/api/payments/storage/model/dpolicy.go new file mode 100644 index 00000000..e3b8ba3f --- /dev/null +++ b/api/payments/storage/model/dpolicy.go @@ -0,0 +1,11 @@ +package model + +import moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + +// DepositCheckPolicy defines how an external deposit satisfies pre-funding. +type DepositCheckPolicy struct { + WalletRef string + ExpectedAmount *moneyv1.Money + MinConfirmations uint32 + TimeoutSeconds int64 +} diff --git a/api/payments/storage/model/funding.go b/api/payments/storage/model/funding.go new file mode 100644 index 00000000..f62f6b16 --- /dev/null +++ b/api/payments/storage/model/funding.go @@ -0,0 +1,11 @@ +package model + +// FundingMode defines how payout liquidity must be satisfied for a gateway. +type FundingMode string + +const ( + FundingModeUnspecified FundingMode = "unspecified" + FundingModeNone FundingMode = "none" + FundingModeBalanceReserve FundingMode = "balance_reserve" + FundingModeDepositObserved FundingMode = "deposit_observed" +) diff --git a/api/payments/storage/model/payment.go b/api/payments/storage/model/payment.go index 10d1d51c..08780819 100644 --- a/api/payments/storage/model/payment.go +++ b/api/payments/storage/model/payment.go @@ -252,15 +252,18 @@ type Customer struct { // PaymentQuoteSnapshot stores the latest quote info. type PaymentQuoteSnapshot struct { - DebitAmount *paymenttypes.Money `bson:"debitAmount,omitempty" json:"debitAmount,omitempty"` - DebitSettlementAmount *paymenttypes.Money `bson:"debitSettlementAmount,omitempty" json:"debitSettlementAmount,omitempty"` - ExpectedSettlementAmount *paymenttypes.Money `bson:"expectedSettlementAmount,omitempty" json:"expectedSettlementAmount,omitempty"` - ExpectedFeeTotal *paymenttypes.Money `bson:"expectedFeeTotal,omitempty" json:"expectedFeeTotal,omitempty"` - FeeLines []*paymenttypes.FeeLine `bson:"feeLines,omitempty" json:"feeLines,omitempty"` - FeeRules []*paymenttypes.AppliedRule `bson:"feeRules,omitempty" json:"feeRules,omitempty"` - FXQuote *paymenttypes.FXQuote `bson:"fxQuote,omitempty" json:"fxQuote,omitempty"` - NetworkFee *paymenttypes.NetworkFeeEstimate `bson:"networkFee,omitempty" json:"networkFee,omitempty"` - QuoteRef string `bson:"quoteRef,omitempty" json:"quoteRef,omitempty"` + DebitAmount *paymenttypes.Money `bson:"debitAmount,omitempty" json:"debitAmount,omitempty"` + DebitSettlementAmount *paymenttypes.Money `bson:"debitSettlementAmount,omitempty" json:"debitSettlementAmount,omitempty"` + ExpectedSettlementAmount *paymenttypes.Money `bson:"expectedSettlementAmount,omitempty" json:"expectedSettlementAmount,omitempty"` + ExpectedFeeTotal *paymenttypes.Money `bson:"expectedFeeTotal,omitempty" json:"expectedFeeTotal,omitempty"` + TotalCost *paymenttypes.Money `bson:"totalCost,omitempty" json:"totalCost,omitempty"` + FeeLines []*paymenttypes.FeeLine `bson:"feeLines,omitempty" json:"feeLines,omitempty"` + FeeRules []*paymenttypes.AppliedRule `bson:"feeRules,omitempty" json:"feeRules,omitempty"` + Route *paymenttypes.QuoteRouteSpecification `bson:"route,omitempty" json:"route,omitempty"` + ExecutionConditions *paymenttypes.QuoteExecutionConditions `bson:"executionConditions,omitempty" json:"executionConditions,omitempty"` + FXQuote *paymenttypes.FXQuote `bson:"fxQuote,omitempty" json:"fxQuote,omitempty"` + NetworkFee *paymenttypes.NetworkFeeEstimate `bson:"networkFee,omitempty" json:"networkFee,omitempty"` + QuoteRef string `bson:"quoteRef,omitempty" json:"quoteRef,omitempty"` } // ExecutionRefs links to downstream systems. diff --git a/api/payments/storage/model/quote.go b/api/payments/storage/model/quote.go index 43222af2..31cd901f 100644 --- a/api/payments/storage/model/quote.go +++ b/api/payments/storage/model/quote.go @@ -18,6 +18,8 @@ type PaymentQuoteRecord struct { Intents []PaymentIntent `bson:"intents,omitempty" json:"intents,omitempty"` Quote *PaymentQuoteSnapshot `bson:"quote,omitempty" json:"quote,omitempty"` Quotes []*PaymentQuoteSnapshot `bson:"quotes,omitempty" json:"quotes,omitempty"` + StatusV2 *QuoteStatusV2 `bson:"statusV2,omitempty" json:"statusV2,omitempty"` + StatusesV2 []*QuoteStatusV2 `bson:"statusesV2,omitempty" json:"statusesV2,omitempty"` Plan *PaymentPlan `bson:"plan,omitempty" json:"plan,omitempty"` Plans []*PaymentPlan `bson:"plans,omitempty" json:"plans,omitempty"` ExecutionNote string `bson:"executionNote,omitempty" json:"executionNote,omitempty"` diff --git a/api/payments/storage/model/quote_v2.go b/api/payments/storage/model/quote_v2.go new file mode 100644 index 00000000..a21c5a92 --- /dev/null +++ b/api/payments/storage/model/quote_v2.go @@ -0,0 +1,41 @@ +package model + +// QuoteKind captures v2 quote kind metadata for persistence. +type QuoteKind 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" +) + +// QuoteBlockReason captures v2 non-executability reason for persistence. +type QuoteBlockReason string + +const ( + QuoteBlockReasonUnspecified QuoteBlockReason = "unspecified" + QuoteBlockReasonRouteUnavailable QuoteBlockReason = "route_unavailable" + QuoteBlockReasonLimitBlocked QuoteBlockReason = "limit_blocked" + QuoteBlockReasonRiskBlocked QuoteBlockReason = "risk_blocked" + QuoteBlockReasonInsufficientLiquidity QuoteBlockReason = "insufficient_liquidity" + QuoteBlockReasonPriceStale QuoteBlockReason = "price_stale" + QuoteBlockReasonAmountTooSmall QuoteBlockReason = "amount_too_small" + QuoteBlockReasonAmountTooLarge QuoteBlockReason = "amount_too_large" +) + +// 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"` + BlockReason QuoteBlockReason `bson:"blockReason,omitempty" json:"blockReason,omitempty"` +} diff --git a/api/payments/storage/mongo/store/payment_methods.go b/api/payments/storage/mongo/store/payment_methods.go index 24ea1983..67986979 100644 --- a/api/payments/storage/mongo/store/payment_methods.go +++ b/api/payments/storage/mongo/store/payment_methods.go @@ -12,6 +12,7 @@ import ( "github.com/tech/sendico/pkg/mlogger" pkgmodel "github.com/tech/sendico/pkg/model" "github.com/tech/sendico/pkg/mservice" + mutil "github.com/tech/sendico/pkg/mutil/db" mauth "github.com/tech/sendico/pkg/mutil/db/auth" "go.mongodb.org/mongo-driver/v2/bson" "go.uber.org/zap" @@ -50,7 +51,7 @@ func NewPaymentMethods(logger mlogger.Logger, repo repository.Repository, enforc for _, def := range indexes { if err := repo.CreateIndex(def); err != nil { - logger.Error("failed to ensure payment methods index", zap.Error(err), zap.String("collection", repo.Collection())) + logger.Error("Failed to ensure payment methods index", zap.Error(err), zap.String("collection", repo.Collection())) return nil, err } } @@ -112,6 +113,18 @@ func (p *PaymentMethods) Get(ctx context.Context, accountRef, methodRef bson.Obj return method, nil } +func (p *PaymentMethods) GetPrivate(ctx context.Context, methodRef bson.ObjectID) (*pkgmodel.PaymentMethod, error) { + if methodRef == bson.NilObjectID { + return nil, merrors.InvalidArgument("paymentMethodsStore: method_ref is required") + } + + method := &pkgmodel.PaymentMethod{} + if err := p.repo.Get(ctx, methodRef, method); err != nil { + return nil, err + } + return method, nil +} + func (p *PaymentMethods) Update(ctx context.Context, accountRef bson.ObjectID, method *pkgmodel.PaymentMethod) error { if method == nil { return merrors.InvalidArgument("paymentMethodsStore: nil payment method") @@ -193,6 +206,27 @@ func (p *PaymentMethods) List(ctx context.Context, accountRef, organizationRef, return items, err } +func (p *PaymentMethods) ListPrivate(ctx context.Context, organizationRef, recipientRef bson.ObjectID, cursor *pkgmodel.ViewCursor) ([]pkgmodel.PaymentMethod, error) { + if organizationRef == bson.NilObjectID { + return nil, merrors.InvalidArgument("paymentMethodsStore: organization_ref is required") + } + if recipientRef == bson.NilObjectID { + return nil, merrors.InvalidArgument("paymentMethodsStore: recipient_ref is required") + } + + items, err := mutil.GetObjects[pkgmodel.PaymentMethod]( + ctx, + p.logger, + repository.OrgFilter(organizationRef).And(repository.Filter("recipientRef", recipientRef)), + cursor, + p.repo, + ) + if errors.Is(err, merrors.ErrNoData) { + return []pkgmodel.PaymentMethod{}, nil + } + return items, err +} + func (p *PaymentMethods) SetArchivedByRecipient(ctx context.Context, recipientRef bson.ObjectID, archived bool) (int, error) { if recipientRef == bson.NilObjectID { return 0, merrors.InvalidArgument("paymentMethodsStore: recipient_ref is required") diff --git a/api/payments/storage/mongo/store/payments.go b/api/payments/storage/mongo/store/payments.go index ca300173..81385c1a 100644 --- a/api/payments/storage/mongo/store/payments.go +++ b/api/payments/storage/mongo/store/payments.go @@ -61,13 +61,13 @@ func NewPayments(logger mlogger.Logger, repo repository.Repository) (*Payments, for _, def := range indexes { if err := repo.CreateIndex(def); err != nil { - logger.Error("failed to ensure payments index", zap.Error(err), zap.String("collection", repo.Collection())) + logger.Error("Failed to ensure payments index", zap.Error(err), zap.String("collection", repo.Collection())) return nil, err } } childLogger := logger.Named("payments") - childLogger.Debug("payments store initialised") + childLogger.Debug("Payments store initialised") return &Payments{ logger: childLogger, @@ -101,7 +101,7 @@ func (p *Payments) Create(ctx context.Context, payment *model.Payment) error { } return err } - p.logger.Debug("payment created", zap.String("payment_ref", payment.PaymentRef)) + p.logger.Debug("Payment created", zap.String("payment_ref", payment.PaymentRef)) return nil } @@ -218,7 +218,7 @@ func (p *Payments) List(ctx context.Context, filter *model.PaymentFilter) (*mode if oid, err := bson.ObjectIDFromHex(cursor); err == nil { query = query.Comparison(repository.IDField(), builder.Gt, oid) } else { - p.logger.Warn("ignoring invalid payments cursor", zap.String("cursor", cursor), zap.Error(err)) + p.logger.Warn("Ignoring invalid payments cursor", zap.String("cursor", cursor), zap.Error(err)) } } diff --git a/api/payments/storage/mongo/store/plan_templates.go b/api/payments/storage/mongo/store/plan_templates.go index c72c2a0d..cbfb55c1 100644 --- a/api/payments/storage/mongo/store/plan_templates.go +++ b/api/payments/storage/mongo/store/plan_templates.go @@ -49,7 +49,7 @@ func NewPlanTemplates(logger mlogger.Logger, repo repository.Repository) (*PlanT for _, def := range indexes { if err := repo.CreateIndex(def); err != nil { - logger.Error("failed to ensure plan templates index", zap.Error(err), zap.String("collection", repo.Collection())) + logger.Error("Failed to ensure plan templates index", zap.Error(err), zap.String("collection", repo.Collection())) return nil, err } } diff --git a/api/payments/storage/mongo/store/routes.go b/api/payments/storage/mongo/store/routes.go index 4e1c4bd1..49fba147 100644 --- a/api/payments/storage/mongo/store/routes.go +++ b/api/payments/storage/mongo/store/routes.go @@ -49,7 +49,7 @@ func NewRoutes(logger mlogger.Logger, repo repository.Repository) (*Routes, erro for _, def := range indexes { if err := repo.CreateIndex(def); err != nil { - logger.Error("failed to ensure routes index", zap.Error(err), zap.String("collection", repo.Collection())) + logger.Error("Failed to ensure routes index", zap.Error(err), zap.String("collection", repo.Collection())) return nil, err } } diff --git a/api/payments/storage/quote/mongo/store/quotes.go b/api/payments/storage/quote/mongo/store/quotes.go index a4f4ec12..d780fd2f 100644 --- a/api/payments/storage/quote/mongo/store/quotes.go +++ b/api/payments/storage/quote/mongo/store/quotes.go @@ -63,7 +63,7 @@ func NewQuotes(logger mlogger.Logger, repo repository.Repository, retention time for _, def := range indexes { if err := repo.CreateIndex(def); err != nil { - logger.Error("failed to ensure quotes index", zap.Error(err), zap.String("collection", repo.Collection())) + logger.Error("Failed to ensure quotes index", zap.Error(err), zap.String("collection", repo.Collection())) return nil, err } } diff --git a/api/payments/storage/storage.go b/api/payments/storage/storage.go index 6c5d946f..c40d683e 100644 --- a/api/payments/storage/storage.go +++ b/api/payments/storage/storage.go @@ -25,13 +25,6 @@ var ( ErrDuplicatePlanTemplate = errors.New("payments.storage: duplicate plan template") ) -var ( - // Deprecated: use quote/storage.ErrQuoteNotFound. - ErrQuoteNotFound = quotestorage.ErrQuoteNotFound - // Deprecated: use quote/storage.ErrDuplicateQuote. - ErrDuplicateQuote = quotestorage.ErrDuplicateQuote -) - // Repository exposes persistence primitives for the payments domain. type Repository interface { Ping(ctx context.Context) error @@ -56,11 +49,13 @@ type PaymentsStore interface { type PaymentMethodsStore interface { Create(ctx context.Context, accountRef, organizationRef bson.ObjectID, method *pkgmodel.PaymentMethod) error Get(ctx context.Context, accountRef, methodRef bson.ObjectID) (*pkgmodel.PaymentMethod, error) + GetPrivate(ctx context.Context, methodRef bson.ObjectID) (*pkgmodel.PaymentMethod, error) Update(ctx context.Context, accountRef bson.ObjectID, method *pkgmodel.PaymentMethod) error Delete(ctx context.Context, accountRef, methodRef bson.ObjectID) error DeleteCascade(ctx context.Context, accountRef, methodRef bson.ObjectID) error SetArchived(ctx context.Context, accountRef, organizationRef, methodRef bson.ObjectID, archived, cascade bool) error List(ctx context.Context, accountRef, organizationRef, recipientRef bson.ObjectID, cursor *pkgmodel.ViewCursor) ([]pkgmodel.PaymentMethod, error) + ListPrivate(ctx context.Context, organizationRef, recipientRef bson.ObjectID, cursor *pkgmodel.ViewCursor) ([]pkgmodel.PaymentMethod, error) SetArchivedByRecipient(ctx context.Context, recipientRef bson.ObjectID, archived bool) (int, error) DeleteByRecipient(ctx context.Context, recipientRef bson.ObjectID) error diff --git a/api/pkg/api/routers/gsresponse/response.go b/api/pkg/api/routers/gsresponse/response.go index 917ff3a0..f96a45a8 100644 --- a/api/pkg/api/routers/gsresponse/response.go +++ b/api/pkg/api/routers/gsresponse/response.go @@ -47,7 +47,7 @@ func Error[T any](logger mlogger.Logger, service mservice.Type, code codes.Code, if err != nil { fields = append(fields, zap.Error(err)) } - logger.Warn("gRPC request failed", fields...) + logger.Warn("GRPC request failed", fields...) msg := message(err) switch { diff --git a/api/pkg/api/routers/internal/grpcimp/router.go b/api/pkg/api/routers/internal/grpcimp/router.go index cf34e8b8..081368a3 100644 --- a/api/pkg/api/routers/internal/grpcimp/router.go +++ b/api/pkg/api/routers/internal/grpcimp/router.go @@ -204,7 +204,7 @@ func (r *Router) Start(ctx context.Context) error { close(r.serveErr) }() - r.logger.Info("gRPC server started", zap.String("network", r.listener.Addr().Network()), zap.String("address", r.listener.Addr().String())) + r.logger.Info("GRPC server started", zap.String("network", r.listener.Addr().Network()), zap.String("address", r.listener.Addr().String())) return nil } diff --git a/api/pkg/db/internal/mongo/chainassetsdb/db.go b/api/pkg/db/internal/mongo/chainassetsdb/db.go index e437492a..fe1536a1 100644 --- a/api/pkg/db/internal/mongo/chainassetsdb/db.go +++ b/api/pkg/db/internal/mongo/chainassetsdb/db.go @@ -28,7 +28,7 @@ func Create(logger mlogger.Logger, db *mongo.Database) (*ChainAssetsDB, error) { {Field: "asset.tokenSymbol", Sort: ri.Asc}, }, }); err != nil { - p.Logger.Error("failed index (chain, symbol) unique", zap.Error(err)) + p.Logger.Error("Failed index (chain, symbol) unique", zap.Error(err)) return nil, err } @@ -42,7 +42,7 @@ func Create(logger mlogger.Logger, db *mongo.Database) (*ChainAssetsDB, error) { {Field: "asset.contractAddress", Sort: ri.Asc}, }, }); err != nil { - p.Logger.Error("failed index (chain, contract) unique", zap.Error(err)) + p.Logger.Error("Failed index (chain, contract) unique", zap.Error(err)) return nil, err } @@ -54,7 +54,7 @@ func Create(logger mlogger.Logger, db *mongo.Database) (*ChainAssetsDB, error) { {Field: "asset.contractAddress", Sort: ri.Asc}, }, }); err != nil { - p.Logger.Error("failed index contract lookup", zap.Error(err)) + p.Logger.Error("Failed index contract lookup", zap.Error(err)) return nil, err } @@ -65,7 +65,7 @@ func Create(logger mlogger.Logger, db *mongo.Database) (*ChainAssetsDB, error) { {Field: "asset.chain", Sort: ri.Asc}, }, }); err != nil { - p.Logger.Error("failed index chain list", zap.Error(err)) + p.Logger.Error("Failed index chain list", zap.Error(err)) return nil, err } diff --git a/api/pkg/go.mod b/api/pkg/go.mod index f2ddc820..547bd1a2 100644 --- a/api/pkg/go.mod +++ b/api/pkg/go.mod @@ -1,6 +1,6 @@ module github.com/tech/sendico/pkg -go 1.24.0 +go 1.25.0 require ( github.com/casbin/casbin/v2 v2.135.0 @@ -92,6 +92,6 @@ require ( golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect golang.org/x/time v0.5.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/api/pkg/go.sum b/api/pkg/go.sum index d129e3dc..9e50f44e 100644 --- a/api/pkg/go.sum +++ b/api/pkg/go.sum @@ -271,8 +271,8 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/pkg/messaging/internal/natsb/broker.go b/api/pkg/messaging/internal/natsb/broker.go index 3ecbb768..829de692 100644 --- a/api/pkg/messaging/internal/natsb/broker.go +++ b/api/pkg/messaging/internal/natsb/broker.go @@ -114,6 +114,42 @@ func NewNatsBroker(logger mlogger.Logger, settings *nc.Settings) (*NatsBroker, e nats.Name(settings.NATSName), nats.MaxReconnects(settings.MaxReconnects), nats.ReconnectWait(time.Duration(settings.ReconnectWait) * time.Second), + nats.RetryOnFailedConnect(true), + nats.DisconnectErrHandler(func(conn *nats.Conn, err error) { + fields := []zap.Field{ + zap.String("broker", settings.NATSName), + } + if conn != nil { + fields = append(fields, zap.String("connected_url", conn.ConnectedUrl())) + } + if err != nil { + fields = append(fields, zap.Error(err)) + } + l.Warn("Disconnected from NATS", fields...) + }), + nats.ReconnectHandler(func(conn *nats.Conn) { + fields := []zap.Field{ + zap.String("broker", settings.NATSName), + } + if conn != nil { + fields = append(fields, zap.String("connected_url", conn.ConnectedUrl())) + } + l.Info("Reconnected to NATS", fields...) + }), + nats.ClosedHandler(func(conn *nats.Conn) { + fields := []zap.Field{ + zap.String("broker", settings.NATSName), + } + if conn != nil { + if url := conn.ConnectedUrl(); url != "" { + fields = append(fields, zap.String("connected_url", url)) + } + if err := conn.LastError(); err != nil { + fields = append(fields, zap.Error(err)) + } + } + l.Warn("NATS connection closed", fields...) + }), } if cfg != nil { opts = append(opts, nats.UserInfo(cfg.User, cfg.Password)) diff --git a/api/pkg/messaging/internal/producer/producer.go b/api/pkg/messaging/internal/producer/producer.go index d8995ca0..f79e304c 100644 --- a/api/pkg/messaging/internal/producer/producer.go +++ b/api/pkg/messaging/internal/producer/producer.go @@ -17,6 +17,7 @@ func (p *ChannelProducer) SendMessage(envelope me.Envelope) error { // TODO: won't work with Kafka, need to serialize/deserialize if err := p.broker.Publish(envelope); err != nil { p.logger.Warn("Failed to publish message", zap.Error(err), mzap.Envelope(envelope)) + return err } return nil } diff --git a/api/pkg/messaging/producer.go b/api/pkg/messaging/producer.go index 4d9a89ef..825fc294 100644 --- a/api/pkg/messaging/producer.go +++ b/api/pkg/messaging/producer.go @@ -1,7 +1,16 @@ package messaging -import me "github.com/tech/sendico/pkg/messaging/envelope" +import ( + "context" + + me "github.com/tech/sendico/pkg/messaging/envelope" +) type Producer interface { SendMessage(envelope me.Envelope) error } + +type ReliableProducer interface { + Producer + SendWithOutbox(ctx context.Context, envelope me.Envelope) error +} diff --git a/api/pkg/messaging/reliable/factory.go b/api/pkg/messaging/reliable/factory.go new file mode 100644 index 00000000..32a37c91 --- /dev/null +++ b/api/pkg/messaging/reliable/factory.go @@ -0,0 +1,26 @@ +package reliable + +import ( + pmessaging "github.com/tech/sendico/pkg/messaging" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/model" +) + +func NewReliableProducerFromConfig(logger mlogger.Logger, direct pmessaging.Producer, outbox OutboxStore, driverSettings model.SettingsT, opts ...Option) (*ReliableProducer, Settings, error) { + settings, err := ParseSettings(driverSettings) + if err != nil { + return nil, Settings{}, err + } + if !settings.Enabled { + return nil, settings, nil + } + + combined := []Option{ + WithBatchSize(settings.BatchSize), + WithPollInterval(settings.PollInterval()), + WithMaxAttempts(settings.MaxAttempts), + } + combined = append(combined, opts...) + + return NewReliableProducer(logger, direct, outbox, combined...), settings, nil +} diff --git a/api/pkg/messaging/reliable/factory_test.go b/api/pkg/messaging/reliable/factory_test.go new file mode 100644 index 00000000..520e21b7 --- /dev/null +++ b/api/pkg/messaging/reliable/factory_test.go @@ -0,0 +1,28 @@ +package reliable + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tech/sendico/pkg/model" + "go.uber.org/zap" +) + +func TestNewReliableProducerFromConfigUsesDefaults(t *testing.T) { + producer, settings, err := NewReliableProducerFromConfig(zap.NewNop(), &recordingDirectProducer{}, &recordingStore{}, model.SettingsT{}) + require.NoError(t, err) + require.NotNil(t, producer) + assert.Equal(t, DefaultSettings(), settings) +} + +func TestNewReliableProducerFromConfigCanDisable(t *testing.T) { + producer, settings, err := NewReliableProducerFromConfig(zap.NewNop(), &recordingDirectProducer{}, &recordingStore{}, model.SettingsT{ + SettingsBlockKey: map[string]any{ + "enabled": false, + }, + }) + require.NoError(t, err) + assert.Nil(t, producer) + assert.False(t, settings.Enabled) +} diff --git a/api/pkg/messaging/reliable/producer.go b/api/pkg/messaging/reliable/producer.go new file mode 100644 index 00000000..eac5f6ad --- /dev/null +++ b/api/pkg/messaging/reliable/producer.go @@ -0,0 +1,238 @@ +package reliable + +import ( + "context" + "errors" + "time" + + "github.com/google/uuid" + "github.com/tech/sendico/pkg/merrors" + pmessaging "github.com/tech/sendico/pkg/messaging" + me "github.com/tech/sendico/pkg/messaging/envelope" + "github.com/tech/sendico/pkg/mlogger" + "go.uber.org/zap" +) + +const ( + defaultBatchSize = 100 + defaultPollInterval = time.Second + defaultMaxAttempts = 5 +) + +type OutboxMessage struct { + Reference string + EventID string + Subject string + Payload []byte + Attempts int + OrganizationRef string + CreatedAt time.Time +} + +type OutboxStore interface { + Enqueue(ctx context.Context, msg OutboxMessage) error + ListPending(ctx context.Context, limit int) ([]OutboxMessage, error) + MarkSent(ctx context.Context, reference string, sentAt time.Time) error + MarkFailed(ctx context.Context, reference string) error + IncrementAttempts(ctx context.Context, reference string) error +} + +type EnvelopeDecoder func(record OutboxMessage) (me.Envelope, error) + +type Option func(*ReliableProducer) + +type ReliableProducer struct { + logger mlogger.Logger + direct pmessaging.Producer + outbox OutboxStore + batchSize int + pollInterval time.Duration + maxAttempts int + decode EnvelopeDecoder +} + +func NewReliableProducer(logger mlogger.Logger, direct pmessaging.Producer, outbox OutboxStore, opts ...Option) *ReliableProducer { + if logger == nil { + logger = zap.NewNop() + } + res := &ReliableProducer{ + logger: logger.Named("reliable_producer"), + direct: direct, + outbox: outbox, + batchSize: defaultBatchSize, + pollInterval: defaultPollInterval, + maxAttempts: defaultMaxAttempts, + decode: defaultEnvelopeDecoder, + } + for _, opt := range opts { + if opt != nil { + opt(res) + } + } + return res +} + +func WithBatchSize(size int) Option { + return func(p *ReliableProducer) { + if size > 0 { + p.batchSize = size + } + } +} + +func WithPollInterval(interval time.Duration) Option { + return func(p *ReliableProducer) { + if interval > 0 { + p.pollInterval = interval + } + } +} + +func WithMaxAttempts(maxAttempts int) Option { + return func(p *ReliableProducer) { + p.maxAttempts = maxAttempts + } +} + +func WithEnvelopeDecoder(decoder EnvelopeDecoder) Option { + return func(p *ReliableProducer) { + if decoder != nil { + p.decode = decoder + } + } +} + +func (p *ReliableProducer) SendMessage(envelope me.Envelope) error { + if p == nil { + return merrors.Internal("reliable producer is nil") + } + if p.direct == nil { + return merrors.Internal("reliable producer direct publisher is not configured") + } + return p.direct.SendMessage(envelope) +} + +func (p *ReliableProducer) SendWithOutbox(ctx context.Context, envelope me.Envelope) error { + if p == nil { + return merrors.Internal("reliable producer is nil") + } + if envelope == nil { + return merrors.InvalidArgument("envelope is required") + } + if p.outbox == nil { + return merrors.Internal("reliable producer outbox store is not configured") + } + + data, err := envelope.Serialize() + if err != nil { + return err + } + + eventID := envelope.GetMessageId().String() + if _, err = uuid.Parse(eventID); err != nil { + return merrors.InvalidArgument("envelope message id is invalid") + } + + return p.outbox.Enqueue(ctx, OutboxMessage{ + EventID: eventID, + Subject: envelope.GetSignature().ToString(), + Payload: data, + }) +} + +func (p *ReliableProducer) Run(ctx context.Context) { + if p == nil { + return + } + if p.outbox == nil { + p.logger.Warn("Outbox dispatcher disabled: store is not configured") + return + } + if p.direct == nil { + p.logger.Warn("Outbox dispatcher disabled: direct producer is not configured") + return + } + + p.logger.Info("Outbox dispatcher started") + defer p.logger.Info("Outbox dispatcher stopped") + + for { + if ctx.Err() != nil { + return + } + + processed, err := p.DispatchPending(ctx) + if err != nil && !errors.Is(err, context.Canceled) { + p.logger.Warn("Failed to dispatch outbox events", zap.Error(err)) + } + if processed == 0 { + select { + case <-ctx.Done(): + return + case <-time.After(p.pollInterval): + } + } + } +} + +func (p *ReliableProducer) DispatchPending(ctx context.Context) (int, error) { + if p == nil || p.outbox == nil || p.direct == nil { + return 0, nil + } + + events, err := p.outbox.ListPending(ctx, p.batchSize) + if err != nil { + return 0, err + } + + for _, event := range events { + if ctx.Err() != nil { + return len(events), ctx.Err() + } + + env, decodeErr := p.decode(event) + if decodeErr != nil { + p.logger.Warn("Failed to decode outbox envelope", zap.String("event_id", event.EventID), zap.Error(decodeErr)) + p.handleFailure(ctx, event, decodeErr) + continue + } + + if sendErr := p.direct.SendMessage(env); sendErr != nil { + p.logger.Warn("Failed to publish outbox event", zap.String("event_id", event.EventID), zap.String("subject", event.Subject), zap.Error(sendErr)) + p.handleFailure(ctx, event, sendErr) + continue + } + + if markErr := p.outbox.MarkSent(ctx, event.Reference, time.Now().UTC()); markErr != nil { + p.logger.Warn("Failed to mark outbox event sent", zap.String("event_id", event.EventID), zap.String("reference", event.Reference), zap.Error(markErr)) + } + } + + return len(events), nil +} + +func (p *ReliableProducer) handleFailure(ctx context.Context, event OutboxMessage, _ error) { + if p == nil || p.outbox == nil { + return + } + if event.Reference == "" { + p.logger.Warn("Cannot record outbox failure: missing record reference", zap.String("event_id", event.EventID)) + return + } + + if err := p.outbox.IncrementAttempts(ctx, event.Reference); err != nil && !errors.Is(err, context.Canceled) { + p.logger.Warn("Failed to increment outbox attempts", zap.String("event_id", event.EventID), zap.String("reference", event.Reference), zap.Error(err)) + } + + if p.maxAttempts > 0 && event.Attempts+1 >= p.maxAttempts { + if err := p.outbox.MarkFailed(ctx, event.Reference); err != nil && !errors.Is(err, context.Canceled) { + p.logger.Warn("Failed to mark outbox event failed", zap.String("event_id", event.EventID), zap.String("reference", event.Reference), zap.Error(err)) + } + } +} + +func defaultEnvelopeDecoder(record OutboxMessage) (me.Envelope, error) { + return me.Deserialize(record.Payload) +} + +var _ pmessaging.ReliableProducer = (*ReliableProducer)(nil) diff --git a/api/pkg/messaging/reliable/producer_test.go b/api/pkg/messaging/reliable/producer_test.go new file mode 100644 index 00000000..b5914c27 --- /dev/null +++ b/api/pkg/messaging/reliable/producer_test.go @@ -0,0 +1,164 @@ +package reliable + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + me "github.com/tech/sendico/pkg/messaging/envelope" + domainmodel "github.com/tech/sendico/pkg/model" + notification "github.com/tech/sendico/pkg/model/notification" + "github.com/tech/sendico/pkg/mservice" + "go.uber.org/zap" +) + +func TestReliableProducerSendWithOutbox(t *testing.T) { + store := &recordingStore{} + producer := NewReliableProducer(zap.NewNop(), nil, store) + + env := me.CreateEnvelope("test-sender", domainmodel.NewNotification(mservice.Payments, notification.NACreated)) + _, err := env.Wrap([]byte(`{"ok":true}`)) + require.NoError(t, err) + + err = producer.SendWithOutbox(context.Background(), env) + require.NoError(t, err) + + require.Len(t, store.enqueued, 1) + record := store.enqueued[0] + assert.Equal(t, env.GetMessageId().String(), record.EventID) + assert.Equal(t, "payments_created", record.Subject) + + decoded, err := me.Deserialize(record.Payload) + require.NoError(t, err) + assert.Equal(t, env.GetMessageId(), decoded.GetMessageId()) + assert.Equal(t, env.GetSignature().ToString(), decoded.GetSignature().ToString()) +} + +func TestReliableProducerDispatchPendingSuccess(t *testing.T) { + env := me.CreateEnvelope("test-sender", domainmodel.NewNotification(mservice.Payments, notification.NAUpdated)) + _, err := env.Wrap([]byte(`{"event":"updated"}`)) + require.NoError(t, err) + + payload, err := env.Serialize() + require.NoError(t, err) + + store := &recordingStore{ + pending: []OutboxMessage{ + { + Reference: "ref-1", + EventID: env.GetMessageId().String(), + Subject: env.GetSignature().ToString(), + Payload: payload, + Attempts: 0, + }, + }, + } + direct := &recordingDirectProducer{} + producer := NewReliableProducer(zap.NewNop(), direct, store) + + processed, err := producer.DispatchPending(context.Background()) + require.NoError(t, err) + assert.Equal(t, 1, processed) + require.Len(t, direct.sent, 1) + assert.Equal(t, env.GetMessageId(), direct.sent[0].GetMessageId()) + require.Len(t, store.markedSent, 1) + assert.Equal(t, "ref-1", store.markedSent[0]) + assert.Empty(t, store.incremented) + assert.Empty(t, store.markedFailed) +} + +func TestReliableProducerDispatchPendingFailureMarksFailed(t *testing.T) { + env := me.CreateEnvelope("test-sender", domainmodel.NewNotification(mservice.Payments, notification.NAUpdated)) + _, err := env.Wrap([]byte(`{"event":"updated"}`)) + require.NoError(t, err) + + payload, err := env.Serialize() + require.NoError(t, err) + + store := &recordingStore{ + pending: []OutboxMessage{ + { + Reference: "ref-2", + EventID: env.GetMessageId().String(), + Subject: env.GetSignature().ToString(), + Payload: payload, + Attempts: 4, + }, + }, + } + direct := &recordingDirectProducer{err: errors.New("publish failed")} + producer := NewReliableProducer(zap.NewNop(), direct, store, WithMaxAttempts(5)) + + processed, err := producer.DispatchPending(context.Background()) + require.NoError(t, err) + assert.Equal(t, 1, processed) + require.Len(t, store.incremented, 1) + assert.Equal(t, "ref-2", store.incremented[0]) + require.Len(t, store.markedFailed, 1) + assert.Equal(t, "ref-2", store.markedFailed[0]) + assert.Empty(t, store.markedSent) +} + +type recordingStore struct { + mu sync.Mutex + + enqueued []OutboxMessage + pending []OutboxMessage + + markedSent []string + markedFailed []string + incremented []string +} + +func (s *recordingStore) Enqueue(_ context.Context, msg OutboxMessage) error { + s.mu.Lock() + defer s.mu.Unlock() + s.enqueued = append(s.enqueued, msg) + return nil +} + +func (s *recordingStore) ListPending(_ context.Context, _ int) ([]OutboxMessage, error) { + s.mu.Lock() + defer s.mu.Unlock() + events := append([]OutboxMessage(nil), s.pending...) + s.pending = nil + return events, nil +} + +func (s *recordingStore) MarkSent(_ context.Context, reference string, _ time.Time) error { + s.mu.Lock() + defer s.mu.Unlock() + s.markedSent = append(s.markedSent, reference) + return nil +} + +func (s *recordingStore) MarkFailed(_ context.Context, reference string) error { + s.mu.Lock() + defer s.mu.Unlock() + s.markedFailed = append(s.markedFailed, reference) + return nil +} + +func (s *recordingStore) IncrementAttempts(_ context.Context, reference string) error { + s.mu.Lock() + defer s.mu.Unlock() + s.incremented = append(s.incremented, reference) + return nil +} + +type recordingDirectProducer struct { + mu sync.Mutex + sent []me.Envelope + err error +} + +func (p *recordingDirectProducer) SendMessage(env me.Envelope) error { + p.mu.Lock() + defer p.mu.Unlock() + p.sent = append(p.sent, env) + return p.err +} diff --git a/api/pkg/messaging/reliable/settings.go b/api/pkg/messaging/reliable/settings.go new file mode 100644 index 00000000..dcbd87a8 --- /dev/null +++ b/api/pkg/messaging/reliable/settings.go @@ -0,0 +1,64 @@ +package reliable + +import ( + "time" + + "github.com/mitchellh/mapstructure" + "github.com/tech/sendico/pkg/model" +) + +const SettingsBlockKey = "reliable_publisher" + +type Settings struct { + Enabled bool `mapstructure:"enabled" yaml:"enabled"` + BatchSize int `mapstructure:"batch_size" yaml:"batch_size"` + PollIntervalSeconds int `mapstructure:"poll_interval_seconds" yaml:"poll_interval_seconds"` + MaxAttempts int `mapstructure:"max_attempts" yaml:"max_attempts"` +} + +func DefaultSettings() Settings { + return Settings{ + Enabled: true, + BatchSize: defaultBatchSize, + PollIntervalSeconds: int(defaultPollInterval.Seconds()), + MaxAttempts: defaultMaxAttempts, + } +} + +func ParseSettings(driverSettings model.SettingsT) (Settings, error) { + settings := DefaultSettings() + if len(driverSettings) == 0 { + return settings, nil + } + + raw, ok := driverSettings[SettingsBlockKey] + if !ok || raw == nil { + return settings, nil + } + + if err := mapstructure.Decode(raw, &settings); err != nil { + return Settings{}, err + } + + settings.applyDefaults() + return settings, nil +} + +func (s *Settings) PollInterval() time.Duration { + if s == nil || s.PollIntervalSeconds <= 0 { + return defaultPollInterval + } + return time.Duration(s.PollIntervalSeconds) * time.Second +} + +func (s *Settings) applyDefaults() { + if s.BatchSize <= 0 { + s.BatchSize = defaultBatchSize + } + if s.PollIntervalSeconds <= 0 { + s.PollIntervalSeconds = int(defaultPollInterval.Seconds()) + } + if s.MaxAttempts <= 0 { + s.MaxAttempts = defaultMaxAttempts + } +} diff --git a/api/pkg/messaging/reliable/settings_test.go b/api/pkg/messaging/reliable/settings_test.go new file mode 100644 index 00000000..dad0203c --- /dev/null +++ b/api/pkg/messaging/reliable/settings_test.go @@ -0,0 +1,62 @@ +package reliable + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tech/sendico/pkg/model" +) + +func TestParseSettingsDefaultsWhenBlockMissing(t *testing.T) { + got, err := ParseSettings(model.SettingsT{ + "url_env": "NATS_URL", + }) + require.NoError(t, err) + + assert.Equal(t, DefaultSettings(), got) +} + +func TestParseSettingsOverrides(t *testing.T) { + got, err := ParseSettings(model.SettingsT{ + SettingsBlockKey: map[string]any{ + "enabled": true, + "batch_size": 250, + "poll_interval_seconds": 3, + "max_attempts": 7, + }, + }) + require.NoError(t, err) + + assert.True(t, got.Enabled) + assert.Equal(t, 250, got.BatchSize) + assert.Equal(t, 3, got.PollIntervalSeconds) + assert.Equal(t, 7, got.MaxAttempts) +} + +func TestParseSettingsAppliesDefaultsForInvalidNumbers(t *testing.T) { + got, err := ParseSettings(model.SettingsT{ + SettingsBlockKey: map[string]any{ + "enabled": true, + "batch_size": 0, + "poll_interval_seconds": -1, + "max_attempts": 0, + }, + }) + require.NoError(t, err) + + assert.True(t, got.Enabled) + assert.Equal(t, defaultBatchSize, got.BatchSize) + assert.Equal(t, int(defaultPollInterval.Seconds()), got.PollIntervalSeconds) + assert.Equal(t, defaultMaxAttempts, got.MaxAttempts) +} + +func TestParseSettingsCanDisableReliablePublisher(t *testing.T) { + got, err := ParseSettings(model.SettingsT{ + SettingsBlockKey: map[string]any{ + "enabled": false, + }, + }) + require.NoError(t, err) + assert.False(t, got.Enabled) +} diff --git a/api/pkg/model/notificationevent.go b/api/pkg/model/notificationevent.go index 995e7e39..6a631654 100644 --- a/api/pkg/model/notificationevent.go +++ b/api/pkg/model/notificationevent.go @@ -80,6 +80,7 @@ func StringToNotificationAction(s string) (nm.NotificationAction, error) { nm.NAUpdated, nm.NAArchived, nm.NADeleted, + nm.NASent, nm.NAAssigned, nm.NAPasswordReset, nm.NAConfirmationRequest, diff --git a/api/pkg/payments/types/quote_v2.go b/api/pkg/payments/types/quote_v2.go new file mode 100644 index 00000000..7e319b94 --- /dev/null +++ b/api/pkg/payments/types/quote_v2.go @@ -0,0 +1,54 @@ +package types + +// QuoteExecutionReadiness classifies whether execution is immediately possible. +type QuoteExecutionReadiness string + +const ( + QuoteExecutionReadinessUnspecified QuoteExecutionReadiness = "unspecified" + QuoteExecutionReadinessLiquidityReady QuoteExecutionReadiness = "liquidity_ready" + QuoteExecutionReadinessLiquidityObtainable QuoteExecutionReadiness = "liquidity_obtainable" + QuoteExecutionReadinessIndicative QuoteExecutionReadiness = "indicative" +) + +type QuoteRouteHopRole string + +const ( + QuoteRouteHopRoleUnspecified QuoteRouteHopRole = "unspecified" + QuoteRouteHopRoleSource QuoteRouteHopRole = "source" + QuoteRouteHopRoleTransit QuoteRouteHopRole = "transit" + QuoteRouteHopRoleDestination QuoteRouteHopRole = "destination" +) + +type QuoteRouteHop struct { + Index uint32 `bson:"index,omitempty" json:"index,omitempty"` + Rail string `bson:"rail,omitempty" json:"rail,omitempty"` + Gateway string `bson:"gateway,omitempty" json:"gateway,omitempty"` + InstanceID string `bson:"instanceId,omitempty" json:"instanceId,omitempty"` + Network string `bson:"network,omitempty" json:"network,omitempty"` + Role QuoteRouteHopRole `bson:"role,omitempty" json:"role,omitempty"` +} + +// QuoteRouteSpecification is an abstract route selected during quotation. +// It intentionally omits execution steps/operations. +type QuoteRouteSpecification struct { + Rail string `bson:"rail,omitempty" json:"rail,omitempty"` + Provider string `bson:"provider,omitempty" json:"provider,omitempty"` + PayoutMethod string `bson:"payoutMethod,omitempty" json:"payoutMethod,omitempty"` + SettlementAsset string `bson:"settlementAsset,omitempty" json:"settlementAsset,omitempty"` + SettlementModel string `bson:"settlementModel,omitempty" json:"settlementModel,omitempty"` + Network string `bson:"network,omitempty" json:"network,omitempty"` + RouteRef string `bson:"routeRef,omitempty" json:"routeRef,omitempty"` + PricingProfileRef string `bson:"pricingProfileRef,omitempty" json:"pricingProfileRef,omitempty"` + Hops []*QuoteRouteHop `bson:"hops,omitempty" json:"hops,omitempty"` +} + +// QuoteExecutionConditions stores quotation-time assumptions and constraints. +type QuoteExecutionConditions struct { + Readiness QuoteExecutionReadiness `bson:"readiness,omitempty" json:"readiness,omitempty"` + BatchingEligible bool `bson:"batchingEligible,omitempty" json:"batchingEligible,omitempty"` + PrefundingRequired bool `bson:"prefundingRequired,omitempty" json:"prefundingRequired,omitempty"` + PrefundingCostIncluded bool `bson:"prefundingCostIncluded,omitempty" json:"prefundingCostIncluded,omitempty"` + LiquidityCheckRequiredAtExecution bool `bson:"liquidityCheckRequiredAtExecution,omitempty" json:"liquidityCheckRequiredAtExecution,omitempty"` + LatencyHint string `bson:"latencyHint,omitempty" json:"latencyHint,omitempty"` + Assumptions []string `bson:"assumptions,omitempty" json:"assumptions,omitempty"` +} diff --git a/api/pkg/server/grpcapp/app.go b/api/pkg/server/grpcapp/app.go index 8a35d17c..d40bb606 100644 --- a/api/pkg/server/grpcapp/app.go +++ b/api/pkg/server/grpcapp/app.go @@ -184,10 +184,10 @@ func (a *App[T]) Start() error { a.cleanup(context.Background()) return err } - a.logger.Debug("gRPC services registered") + a.logger.Debug("GRPC services registered") a.runCtx, a.cancel = context.WithCancel(context.Background()) - a.logger.Debug("gRPC server context initialised") + a.logger.Debug("GRPC server context initialised") if err := a.grpc.Start(a.runCtx); err != nil { a.logger.Error("Failed to start gRPC server", zap.Error(err)) @@ -210,9 +210,9 @@ func (a *App[T]) Start() error { err = <-a.grpc.Done() if err != nil && !errors.Is(err, context.Canceled) { - a.logger.Error("gRPC server stopped with error", zap.Error(err)) + a.logger.Error("GRPC server stopped with error", zap.Error(err)) } else { - a.logger.Info("gRPC server finished") + a.logger.Info("GRPC server finished") } a.cleanup(context.Background()) @@ -230,7 +230,7 @@ func (a *App[T]) Shutdown(ctx context.Context) { if err := a.grpc.Finish(ctx); err != nil && !errors.Is(err, context.Canceled) { a.logger.Warn("Failed to stop gRPC server gracefully", zap.Error(err)) } else { - a.logger.Info("gRPC server stopped") + a.logger.Info("GRPC server stopped") } } a.cleanup(ctx) diff --git a/api/proto/payments/endpoint/v1/endpoint.proto b/api/proto/payments/endpoint/v1/endpoint.proto index de1bd114..41831049 100644 --- a/api/proto/payments/endpoint/v1/endpoint.proto +++ b/api/proto/payments/endpoint/v1/endpoint.proto @@ -16,6 +16,7 @@ enum PaymentMethodType { PAYMENT_METHOD_TYPE_WALLET = 5; PAYMENT_METHOD_TYPE_CRYPTO_ADDRESS = 6; PAYMENT_METHOD_TYPE_LEDGER = 7; + PAYMENT_METHOD_TYPE_ACCOUNT = 8; } message PaymentMethod { diff --git a/api/proto/payments/methods/v1/methods.proto b/api/proto/payments/methods/v1/methods.proto index b2a4c964..be04a5b9 100644 --- a/api/proto/payments/methods/v1/methods.proto +++ b/api/proto/payments/methods/v1/methods.proto @@ -26,6 +26,25 @@ message GetPaymentMethodResponse { payments.endpoint.v1.PaymentMethodRecord payment_method_record = 1; } +message GetPaymentMethodPrivateRequest { + string organization_ref = 1; + oneof selector { + string payment_method_ref = 2; + string payee_ref = 3; + } + PrivateEndpoint endpoint = 4; +} + +enum PrivateEndpoint { + PRIVATE_ENDPOINT_UNSPECIFIED = 0; + PRIVATE_ENDPOINT_SOURCE = 1; + PRIVATE_ENDPOINT_DESTINATION = 2; +} + +message GetPaymentMethodPrivateResponse { + payments.endpoint.v1.PaymentMethodRecord payment_method_record = 1; +} + message UpdatePaymentMethodRequest { string account_ref = 1; payments.endpoint.v1.PaymentMethodRecord payment_method_record = 2; @@ -78,4 +97,6 @@ service PaymentMethodsService { rpc SetPaymentMethodArchived(SetPaymentMethodArchivedRequest) returns (SetPaymentMethodArchivedResponse); // ListPaymentMethods retrieves a list of payment methods. rpc ListPaymentMethods(ListPaymentMethodsRequest) returns (ListPaymentMethodsResponse); + // GetPaymentMethodPrivate retrieves a payment method without permission checks. + rpc GetPaymentMethodPrivate(GetPaymentMethodPrivateRequest) returns (GetPaymentMethodPrivateResponse); } diff --git a/api/proto/payments/quotation/v2/interface.proto b/api/proto/payments/quotation/v2/interface.proto index d93f9499..84485430 100644 --- a/api/proto/payments/quotation/v2/interface.proto +++ b/api/proto/payments/quotation/v2/interface.proto @@ -7,7 +7,6 @@ option go_package = "github.com/tech/sendico/pkg/proto/payments/quotation/v2;quo import "google/protobuf/timestamp.proto"; import "api/proto/common/storable/v1/storable.proto"; import "api/proto/common/money/v1/money.proto"; -import "api/proto/common/fx/v1/fx.proto"; import "api/proto/billing/fees/v1/fees.proto"; import "api/proto/oracle/v1/oracle.proto"; @@ -18,11 +17,11 @@ enum QuoteKind { QUOTE_KIND_INDICATIVE = 2; // informational only } -enum QuoteState { - QUOTE_STATE_UNSPECIFIED = 0; +enum QuoteLifecycle { + QUOTE_LIFECYCLE_UNSPECIFIED = 0; - QUOTE_STATE_ACTIVE = 1; - QUOTE_STATE_EXPIRED = 2; + QUOTE_LIFECYCLE_ACTIVE = 1; + QUOTE_LIFECYCLE_EXPIRED = 2; } enum QuoteBlockReason { @@ -37,12 +36,68 @@ enum QuoteBlockReason { QUOTE_BLOCK_REASON_AMOUNT_TOO_LARGE = 7; } +enum QuoteExecutionReadiness { + QUOTE_EXECUTION_READINESS_UNSPECIFIED = 0; + QUOTE_EXECUTION_READINESS_LIQUIDITY_READY = 1; + QUOTE_EXECUTION_READINESS_LIQUIDITY_OBTAINABLE = 2; + QUOTE_EXECUTION_READINESS_INDICATIVE = 3; +} + +enum RouteHopRole { + ROUTE_HOP_ROLE_UNSPECIFIED = 0; + ROUTE_HOP_ROLE_SOURCE = 1; + ROUTE_HOP_ROLE_TRANSIT = 2; + ROUTE_HOP_ROLE_DESTINATION = 3; +} + +message RouteHop { + uint32 index = 1; + string rail = 2; + string gateway = 3; + string instance_id = 4; + string network = 5; + RouteHopRole role = 6; +} + +// Abstract execution route selected during quotation. +// This is not an execution plan and must not contain operational steps. +message RouteSpecification { + string rail = 1; + string provider = 2; + string payout_method = 3; + string settlement_asset = 4; + string settlement_model = 5; + string network = 6; + string route_ref = 7; + string pricing_profile_ref = 8; + repeated RouteHop hops = 9; +} + +// Execution assumptions and constraints evaluated at quotation time. +// Operational planning is performed by the execution layer later. +message ExecutionConditions { + QuoteExecutionReadiness readiness = 1; + bool batching_eligible = 2; + bool prefunding_required = 3; + bool prefunding_cost_included = 4; + bool liquidity_check_required_at_execution = 5; + string latency_hint = 6; + repeated string assumptions = 7; +} message PaymentQuote { common.storable.v1.Storable storable = 1; QuoteKind kind = 2; - QuoteState state = 3; - optional QuoteBlockReason block_reason = 4; + QuoteLifecycle lifecycle = 3; + + // Execution-status rules: + // 1) kind=QUOTE_KIND_INDICATIVE => execution_status must be unset. + // 2) lifecycle=QUOTE_LIFECYCLE_EXPIRED => execution_status must be unset. + // 3) kind=QUOTE_KIND_EXECUTABLE and lifecycle=QUOTE_LIFECYCLE_ACTIVE => execution_status must be set. + oneof execution_status { + bool executable = 13; // must be true when set + QuoteBlockReason block_reason = 4; + } common.money.v1.Money debit_amount = 5; common.money.v1.Money credit_amount = 6; @@ -56,4 +111,8 @@ message PaymentQuote { google.protobuf.Timestamp expires_at = 11; google.protobuf.Timestamp priced_at = 12; + + RouteSpecification route = 14; + ExecutionConditions execution_conditions = 15; + common.money.v1.Money total_cost = 16; } diff --git a/api/proto/payments/quotation/v2/quotation.proto b/api/proto/payments/quotation/v2/quotation.proto index d3580429..64c6bb59 100644 --- a/api/proto/payments/quotation/v2/quotation.proto +++ b/api/proto/payments/quotation/v2/quotation.proto @@ -35,3 +35,11 @@ message QuotePaymentsResponse { repeated payments.quotation.v2.PaymentQuote quotes = 3; string idempotency_key = 4; } + +// Quotation service interface +service QuotationService { + // QuotePayment returns a quote for a single payment request. + rpc QuotePayment(QuotePaymentRequest) returns (QuotePaymentResponse); + // QuotePayments returns quotes for multiple payment requests. + rpc QuotePayments(QuotePaymentsRequest) returns (QuotePaymentsResponse); +} diff --git a/api/proto/payments/transfer/v1/transfer.proto b/api/proto/payments/transfer/v1/transfer.proto index d22314ea..86757ca9 100644 --- a/api/proto/payments/transfer/v1/transfer.proto +++ b/api/proto/payments/transfer/v1/transfer.proto @@ -5,7 +5,6 @@ package payments.transfer.v1; option go_package = "github.com/tech/sendico/pkg/proto/payments/transfer/v1;transferv1"; import "api/proto/common/money/v1/money.proto"; -import "api/proto/common/storable/v1/storable.proto"; import "api/proto/payments/endpoint/v1/endpoint.proto"; diff --git a/api/server/go.mod b/api/server/go.mod index 2c0df783..42bec138 100644 --- a/api/server/go.mod +++ b/api/server/go.mod @@ -16,8 +16,8 @@ replace github.com/tech/sendico/gateway/tron => ../gateway/tron require ( github.com/aws/aws-sdk-go-v2 v1.41.1 - github.com/aws/aws-sdk-go-v2/config v1.32.7 - github.com/aws/aws-sdk-go-v2/credentials v1.19.7 + github.com/aws/aws-sdk-go-v2/config v1.32.8 + github.com/aws/aws-sdk-go-v2/credentials v1.19.8 github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0 github.com/go-chi/chi/v5 v5.2.5 github.com/go-chi/cors v1.2.2 @@ -65,7 +65,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 // indirect github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect github.com/aws/smithy-go v1.24.0 // indirect github.com/beorn7/perks v1.0.1 // indirect @@ -144,5 +144,5 @@ require ( golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect ) diff --git a/api/server/go.sum b/api/server/go.sum index 309e0e39..5c9aa180 100644 --- a/api/server/go.sum +++ b/api/server/go.sum @@ -10,10 +10,10 @@ github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6ce github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4= -github.com/aws/aws-sdk-go-v2/config v1.32.7 h1:vxUyWGUwmkQ2g19n7JY/9YL8MfAIl7bTesIUykECXmY= -github.com/aws/aws-sdk-go-v2/config v1.32.7/go.mod h1:2/Qm5vKUU/r7Y+zUk/Ptt2MDAEKAfUtKc1+3U1Mo3oY= -github.com/aws/aws-sdk-go-v2/credentials v1.19.7 h1:tHK47VqqtJxOymRrNtUXN5SP/zUTvZKeLx4tH6PGQc8= -github.com/aws/aws-sdk-go-v2/credentials v1.19.7/go.mod h1:qOZk8sPDrxhf+4Wf4oT2urYJrYt3RejHSzgAquYeppw= +github.com/aws/aws-sdk-go-v2/config v1.32.8 h1:iu+64gwDKEoKnyTQskSku72dAwggKI5sV6rNvgSMpMs= +github.com/aws/aws-sdk-go-v2/config v1.32.8/go.mod h1:MI2XvA+qDi3i9AJxX1E2fu730syEBzp/jnXrjxuHwgI= +github.com/aws/aws-sdk-go-v2/credentials v1.19.8 h1:Jp2JYH1lRT3KhX4mshHPvVYsR5qqRec3hGvEarNYoR0= +github.com/aws/aws-sdk-go-v2/credentials v1.19.8/go.mod h1:fZG9tuvyVfxknv1rKibIz3DobRaFw1Poe8IKtXB3XYY= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 h1:I0GyV8wiYrP8XpA70g1HBcQO1JlQxCMTW9npl5UbDHY= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17/go.mod h1:tyw7BOl5bBe/oqvoIeECFJjMdzXoa/dfVz3QQ5lgHGA= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U= @@ -38,8 +38,8 @@ github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 h1:VrhDvQib/i0lxvr3zqlUwLwJP4 github.com/aws/aws-sdk-go-v2/service/signin v1.0.5/go.mod h1:k029+U8SY30/3/ras4G/Fnv/b88N4mAfliNn08Dem4M= github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 h1:v6EiMvhEYBoHABfbGB4alOYmCIrcgyPPiBE1wZAEbqk= github.com/aws/aws-sdk-go-v2/service/sso v1.30.9/go.mod h1:yifAsgBxgJWn3ggx70A3urX2AN49Y5sJTD1UQFlfqBw= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 h1:gd84Omyu9JLriJVCbGApcLzVR3XtmC4ZDPcAI6Ftvds= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13/go.mod h1:sTGThjphYE4Ohw8vJiRStAcu3rbjtXRsdNB0TvZ5wwo= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14 h1:0jbJeuEHlwKJ9PfXtpSFc4MF+WIWORdhN1n30ITZGFM= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14/go.mod h1:sTGThjphYE4Ohw8vJiRStAcu3rbjtXRsdNB0TvZ5wwo= github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/pJ1jOWYlFDJTjRQ= github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ= github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= @@ -363,8 +363,8 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 h1:JLQynH/LBHfCTSbDWl+py8C+Rg/k1OVH3xfcaiANuF0= google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:kSJwQxqmFXeo79zOmbrALdflXQeAYcUbgS7PbpMknCY= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/server/internal/api/discovery_resolver.go b/api/server/internal/api/discovery_resolver.go index c3876691..bd946e0e 100644 --- a/api/server/internal/api/discovery_resolver.go +++ b/api/server/internal/api/discovery_resolver.go @@ -346,13 +346,13 @@ func selectGatewayEndpoint(gateways []discovery.GatewaySummary, preferredNetwork func parseDiscoveryInvokeURI(raw string) (discoveryEndpoint, error) { raw = strings.TrimSpace(raw) if raw == "" { - return discoveryEndpoint{}, fmt.Errorf("invoke uri is empty") + return discoveryEndpoint{}, fmt.Errorf("Invoke uri is empty") } // Without a scheme we expect a plain host:port target. if !strings.Contains(raw, "://") { if _, _, err := net.SplitHostPort(raw); err != nil { - return discoveryEndpoint{}, fmt.Errorf("invoke uri must include host:port: %w", err) + return discoveryEndpoint{}, fmt.Errorf("Invoke uri must include host:port: %w", err) } return discoveryEndpoint{ address: raw, @@ -370,7 +370,7 @@ func parseDiscoveryInvokeURI(raw string) (discoveryEndpoint, error) { case "grpc": address := strings.TrimSpace(parsed.Host) if _, _, splitErr := net.SplitHostPort(address); splitErr != nil { - return discoveryEndpoint{}, fmt.Errorf("grpc invoke uri must include host:port: %w", splitErr) + return discoveryEndpoint{}, fmt.Errorf("Grpc invoke uri must include host:port: %w", splitErr) } return discoveryEndpoint{ address: address, @@ -380,7 +380,7 @@ func parseDiscoveryInvokeURI(raw string) (discoveryEndpoint, error) { case "grpcs": address := strings.TrimSpace(parsed.Host) if _, _, splitErr := net.SplitHostPort(address); splitErr != nil { - return discoveryEndpoint{}, fmt.Errorf("grpcs invoke uri must include host:port: %w", splitErr) + return discoveryEndpoint{}, fmt.Errorf("Grpcs invoke uri must include host:port: %w", splitErr) } return discoveryEndpoint{ address: address, @@ -395,7 +395,7 @@ func parseDiscoveryInvokeURI(raw string) (discoveryEndpoint, error) { raw: raw, }, nil default: - return discoveryEndpoint{}, fmt.Errorf("unsupported invoke uri scheme: %s", parsed.Scheme) + return discoveryEndpoint{}, fmt.Errorf("Unsupported invoke uri scheme: %s", parsed.Scheme) } } diff --git a/ci/scripts/proto/generate.sh b/ci/scripts/proto/generate.sh index 0228756a..b1520ffc 100755 --- a/ci/scripts/proto/generate.sh +++ b/ci/scripts/proto/generate.sh @@ -149,10 +149,26 @@ if [ -f "${PROTO_DIR}/payments/orchestration/v1/orchestration.proto" ]; then generate_go_with_grpc "${PROTO_DIR}/payments/orchestration/v1/orchestration.proto" fi -if [ -f "${PROTO_DIR}/payments/quotation/v1/quotation.proto" ]; then +if [ -f "${PROTO_DIR}/payments/transfer/v1/transfer.proto" ]; then + info "Compiling payments transfer protos" + clean_pb_files "./pkg/proto/payments/transfer" + generate_go "${PROTO_DIR}/payments/transfer/v1/transfer.proto" +fi + +if [ -f "${PROTO_DIR}/payments/quotation/v1/quotation.proto" ] || \ + [ -f "${PROTO_DIR}/payments/quotation/v2/interface.proto" ] || \ + [ -f "${PROTO_DIR}/payments/quotation/v2/quotation.proto" ]; then info "Compiling payments quotation protos" clean_pb_files "./pkg/proto/payments/quotation" - generate_go_with_grpc "${PROTO_DIR}/payments/quotation/v1/quotation.proto" + if [ -f "${PROTO_DIR}/payments/quotation/v1/quotation.proto" ]; then + generate_go_with_grpc "${PROTO_DIR}/payments/quotation/v1/quotation.proto" + fi + if [ -f "${PROTO_DIR}/payments/quotation/v2/interface.proto" ]; then + generate_go "api/proto/payments/quotation/v2/interface.proto" + fi + if [ -f "${PROTO_DIR}/payments/quotation/v2/quotation.proto" ]; then + generate_go_with_grpc "api/proto/payments/quotation/v2/quotation.proto" + fi fi if [ -f "${PROTO_DIR}/payments/endpoint/v1/endpoint.proto" ]; then diff --git a/frontend/pshared/lib/pshared.dart b/frontend/pshared/lib/pshared.dart index baa1a9dc..103235e2 100644 --- a/frontend/pshared/lib/pshared.dart +++ b/frontend/pshared/lib/pshared.dart @@ -4,5 +4,3 @@ library; export 'utils/http/requests.dart'; - -// TODO: Export any libraries intended for clients of this package.