move api/server to api/edge/bff
This commit is contained in:
25
api/edge/bff/internal/server/paymentapiimp/customer.go
Normal file
25
api/edge/bff/internal/server/paymentapiimp/customer.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package paymentapiimp
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/server/interface/api/srequest"
|
||||
)
|
||||
|
||||
func applyCustomerIP(intent *srequest.PaymentIntent, remoteAddr string) {
|
||||
if intent == nil {
|
||||
return
|
||||
}
|
||||
ip := strings.TrimSpace(remoteAddr)
|
||||
if ip == "" {
|
||||
return
|
||||
}
|
||||
if host, _, err := net.SplitHostPort(ip); err == nil && host != "" {
|
||||
ip = host
|
||||
}
|
||||
if intent.Customer == nil {
|
||||
intent.Customer = &srequest.Customer{}
|
||||
}
|
||||
intent.Customer.IP = strings.TrimSpace(ip)
|
||||
}
|
||||
95
api/edge/bff/internal/server/paymentapiimp/discovery.go
Normal file
95
api/edge/bff/internal/server/paymentapiimp/discovery.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package paymentapiimp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
"github.com/tech/sendico/pkg/discovery"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
me "github.com/tech/sendico/pkg/messaging/envelope"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/server/interface/api/sresponse"
|
||||
mutil "github.com/tech/sendico/server/internal/mutil/param"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const discoveryLookupTimeout = 3 * time.Second
|
||||
|
||||
func (a *PaymentAPI) listDiscoveryRegistry(r *http.Request, account *model.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||
if a.discovery == nil {
|
||||
return response.Internal(a.logger, a.Name(), merrors.Internal("discovery client is not configured"))
|
||||
}
|
||||
|
||||
orgRef, err := a.oph.GetRef(r)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to parse organization reference for discovery registry", zap.Error(err), mutil.PLog(a.oph, r))
|
||||
return response.BadReference(a.logger, a.Name(), a.oph.Name(), a.oph.GetID(r), err)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
allowed, err := a.enf.Enforce(ctx, a.permissionRef, account.ID, orgRef, bson.NilObjectID, model.ActionRead)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to check payments access permissions", zap.Error(err), mutil.PLog(a.oph, r))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
if !allowed {
|
||||
a.logger.Debug("Access denied when listing discovery registry", mutil.PLog(a.oph, r))
|
||||
return response.AccessDenied(a.logger, a.Name(), "payments read permission denied")
|
||||
}
|
||||
|
||||
reqCtx, cancel := context.WithTimeout(ctx, discoveryLookupTimeout)
|
||||
defer cancel()
|
||||
|
||||
payload, err := a.discovery.Lookup(reqCtx)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to fetch discovery registry", zap.Error(err))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
return response.Ok(a.logger, payload)
|
||||
}
|
||||
|
||||
func (a *PaymentAPI) getDiscoveryRefresh(r *http.Request, account *model.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||
if a.refreshConsumer == nil {
|
||||
return response.Internal(a.logger, a.Name(), merrors.Internal("discovery refresh consumer is not configured"))
|
||||
}
|
||||
|
||||
orgRef, err := a.oph.GetRef(r)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to parse organization reference for discovery refresh", zap.Error(err), mutil.PLog(a.oph, r))
|
||||
return response.BadReference(a.logger, a.Name(), a.oph.Name(), a.oph.GetID(r), err)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
allowed, err := a.enf.Enforce(ctx, a.permissionRef, account.ID, orgRef, bson.NilObjectID, model.ActionRead)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to check payments access permissions", zap.Error(err), mutil.PLog(a.oph, r))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
if !allowed {
|
||||
a.logger.Debug("Access denied when listing discovery refresh", mutil.PLog(a.oph, r))
|
||||
return response.AccessDenied(a.logger, a.Name(), "payments read permission denied")
|
||||
}
|
||||
|
||||
a.refreshMu.RLock()
|
||||
payload := a.refreshEvent
|
||||
a.refreshMu.RUnlock()
|
||||
|
||||
return response.Ok(a.logger, payload)
|
||||
}
|
||||
|
||||
func (a *PaymentAPI) handleRefreshEvent(_ context.Context, env me.Envelope) error {
|
||||
var payload discovery.RefreshEvent
|
||||
if err := json.Unmarshal(env.GetData(), &payload); err != nil {
|
||||
a.logger.Warn("Failed to decode discovery refresh payload", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
a.refreshMu.Lock()
|
||||
a.refreshEvent = &payload
|
||||
a.refreshMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
172
api/edge/bff/internal/server/paymentapiimp/documents.go
Normal file
172
api/edge/bff/internal/server/paymentapiimp/documents.go
Normal file
@@ -0,0 +1,172 @@
|
||||
package paymentapiimp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
"github.com/tech/sendico/pkg/discovery"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
"github.com/tech/sendico/pkg/mutil/mzap"
|
||||
documentsv1 "github.com/tech/sendico/pkg/proto/billing/documents/v1"
|
||||
"github.com/tech/sendico/server/interface/api/sresponse"
|
||||
mutil "github.com/tech/sendico/server/internal/mutil/param"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
const (
|
||||
documentsServiceName = "BILLING_DOCUMENTS"
|
||||
documentsOperationGet = discovery.OperationDocumentsGet
|
||||
documentsDialTimeout = 5 * time.Second
|
||||
documentsCallTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
func (a *PaymentAPI) getActDocument(r *http.Request, account *model.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||
orgRef, err := a.oph.GetRef(r)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to parse organization reference for document request", zap.Error(err), mutil.PLog(a.oph, r))
|
||||
return response.BadReference(a.logger, a.Name(), a.oph.Name(), a.oph.GetID(r), err)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
allowed, err := a.enf.Enforce(ctx, a.permissionRef, account.ID, orgRef, bson.NilObjectID, model.ActionRead)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to check payments access permissions", zap.Error(err), mutil.PLog(a.oph, r))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
if !allowed {
|
||||
a.logger.Debug("Access denied when downloading act", mutil.PLog(a.oph, r))
|
||||
return response.AccessDenied(a.logger, a.Name(), "payments read permission denied")
|
||||
}
|
||||
|
||||
paymentRef := strings.TrimSpace(r.URL.Query().Get("payment_ref"))
|
||||
if paymentRef == "" {
|
||||
paymentRef = strings.TrimSpace(r.URL.Query().Get("paymentRef"))
|
||||
}
|
||||
if paymentRef == "" {
|
||||
return response.BadRequest(a.logger, a.Name(), "missing_parameter", "payment_ref is required")
|
||||
}
|
||||
|
||||
if a.discovery == nil {
|
||||
return response.Error(a.logger, a.Name(), http.StatusServiceUnavailable, "service_unavailable", "discovery client is not configured")
|
||||
}
|
||||
|
||||
lookupCtx, cancel := context.WithTimeout(ctx, discoveryLookupTimeout)
|
||||
defer cancel()
|
||||
|
||||
lookupResp, err := a.discovery.Lookup(lookupCtx)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to lookup discovery registry", zap.Error(err))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
service := findDocumentsService(lookupResp.Services)
|
||||
if service == nil {
|
||||
return response.Error(a.logger, a.Name(), http.StatusServiceUnavailable, "service_unavailable", "billing documents service unavailable")
|
||||
}
|
||||
|
||||
docResp, err := a.fetchActDocument(ctx, service.InvokeURI, paymentRef)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to fetch act document", zap.Error(err), mzap.ObjRef("organization_ref", orgRef))
|
||||
return documentErrorResponse(a.logger, a.Name(), err)
|
||||
}
|
||||
if len(docResp.GetContent()) == 0 {
|
||||
return response.Error(a.logger, a.Name(), http.StatusInternalServerError, "empty_document", "document service returned empty payload")
|
||||
}
|
||||
|
||||
filename := strings.TrimSpace(docResp.GetFilename())
|
||||
if filename == "" {
|
||||
filename = fmt.Sprintf("act_%s.pdf", paymentRef)
|
||||
}
|
||||
mimeType := strings.TrimSpace(docResp.GetMimeType())
|
||||
if mimeType == "" {
|
||||
mimeType = "application/pdf"
|
||||
}
|
||||
|
||||
return func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", mimeType)
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if _, writeErr := w.Write(docResp.GetContent()); writeErr != nil {
|
||||
a.logger.Warn("Failed to write document response", zap.Error(writeErr))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *PaymentAPI) fetchActDocument(ctx context.Context, invokeURI, paymentRef string) (*documentsv1.GetDocumentResponse, error) {
|
||||
dialCtx, cancel := context.WithTimeout(ctx, documentsDialTimeout)
|
||||
defer cancel()
|
||||
|
||||
conn, err := grpc.DialContext(dialCtx, invokeURI, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
if err != nil {
|
||||
return nil, merrors.InternalWrap(err, "dial billing documents")
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
client := documentsv1.NewDocumentServiceClient(conn)
|
||||
|
||||
callCtx, callCancel := context.WithTimeout(ctx, documentsCallTimeout)
|
||||
defer callCancel()
|
||||
|
||||
return client.GetDocument(callCtx, &documentsv1.GetDocumentRequest{
|
||||
PaymentRef: paymentRef,
|
||||
Type: documentsv1.DocumentType_DOCUMENT_TYPE_ACT,
|
||||
})
|
||||
}
|
||||
|
||||
func findDocumentsService(services []discovery.ServiceSummary) *discovery.ServiceSummary {
|
||||
for _, svc := range services {
|
||||
if !strings.EqualFold(svc.Service, documentsServiceName) {
|
||||
continue
|
||||
}
|
||||
if !svc.Healthy || strings.TrimSpace(svc.InvokeURI) == "" {
|
||||
continue
|
||||
}
|
||||
if len(svc.Ops) == 0 || hasOperation(svc.Ops, documentsOperationGet) {
|
||||
return &svc
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hasOperation(ops []string, target string) bool {
|
||||
for _, op := range ops {
|
||||
if strings.EqualFold(strings.TrimSpace(op), target) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func documentErrorResponse(logger mlogger.Logger, source mservice.Type, err error) http.HandlerFunc {
|
||||
statusErr, ok := status.FromError(err)
|
||||
if !ok {
|
||||
return response.Internal(logger, source, err)
|
||||
}
|
||||
|
||||
switch statusErr.Code() {
|
||||
case codes.InvalidArgument:
|
||||
return response.BadRequest(logger, source, "invalid_argument", statusErr.Message())
|
||||
case codes.NotFound:
|
||||
return response.NotFound(logger, source, statusErr.Message())
|
||||
case codes.Unimplemented:
|
||||
return response.NotImplemented(logger, source, statusErr.Message())
|
||||
case codes.FailedPrecondition:
|
||||
return response.Error(logger, source, http.StatusPreconditionFailed, "failed_precondition", statusErr.Message())
|
||||
case codes.Unavailable:
|
||||
return response.Error(logger, source, http.StatusServiceUnavailable, "service_unavailable", statusErr.Message())
|
||||
default:
|
||||
return response.Internal(logger, source, err)
|
||||
}
|
||||
}
|
||||
41
api/edge/bff/internal/server/paymentapiimp/grpc_error.go
Normal file
41
api/edge/bff/internal/server/paymentapiimp/grpc_error.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package paymentapiimp
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
func grpcErrorResponse(logger mlogger.Logger, source mservice.Type, err error) http.HandlerFunc {
|
||||
statusErr, ok := status.FromError(err)
|
||||
if !ok {
|
||||
return response.Internal(logger, source, err)
|
||||
}
|
||||
|
||||
switch statusErr.Code() {
|
||||
case codes.InvalidArgument:
|
||||
return response.BadRequest(logger, source, "invalid_argument", statusErr.Message())
|
||||
case codes.NotFound:
|
||||
return response.NotFound(logger, source, statusErr.Message())
|
||||
case codes.PermissionDenied:
|
||||
return response.AccessDenied(logger, source, statusErr.Message())
|
||||
case codes.Unauthenticated:
|
||||
return response.Unauthorized(logger, source, statusErr.Message())
|
||||
case codes.AlreadyExists, codes.Aborted:
|
||||
return response.DataConflict(logger, source, statusErr.Message())
|
||||
case codes.Unimplemented:
|
||||
return response.NotImplemented(logger, source, statusErr.Message())
|
||||
case codes.FailedPrecondition:
|
||||
return response.Error(logger, source, http.StatusPreconditionFailed, "failed_precondition", statusErr.Message())
|
||||
case codes.DeadlineExceeded:
|
||||
return response.Error(logger, source, http.StatusGatewayTimeout, "deadline_exceeded", statusErr.Message())
|
||||
case codes.Unavailable:
|
||||
return response.Error(logger, source, http.StatusServiceUnavailable, "service_unavailable", statusErr.Message())
|
||||
default:
|
||||
return response.Internal(logger, source, err)
|
||||
}
|
||||
}
|
||||
199
api/edge/bff/internal/server/paymentapiimp/list.go
Normal file
199
api/edge/bff/internal/server/paymentapiimp/list.go
Normal file
@@ -0,0 +1,199 @@
|
||||
package paymentapiimp
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/pkg/mutil/mzap"
|
||||
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
|
||||
orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2"
|
||||
"github.com/tech/sendico/server/interface/api/sresponse"
|
||||
mutil "github.com/tech/sendico/server/internal/mutil/param"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
const maxInt32 = int64(1<<31 - 1)
|
||||
|
||||
func (a *PaymentAPI) listPayments(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc {
|
||||
orgRef, err := a.oph.GetRef(r)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to parse organization reference for payments list", zap.Error(err), mutil.PLog(a.oph, r))
|
||||
return response.BadReference(a.logger, a.Name(), a.oph.Name(), a.oph.GetID(r), err)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
allowed, err := a.enf.Enforce(ctx, a.permissionRef, account.ID, orgRef, bson.NilObjectID, model.ActionRead)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to check payments access permissions", zap.Error(err), mutil.PLog(a.oph, r))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
if !allowed {
|
||||
a.logger.Debug("Access denied when listing payments", mutil.PLog(a.oph, r))
|
||||
return response.AccessDenied(a.logger, a.Name(), "payments read permission denied")
|
||||
}
|
||||
|
||||
req := &orchestrationv2.ListPaymentsRequest{Meta: requestMeta(orgRef.Hex(), "")}
|
||||
|
||||
if page, err := listPaymentsPage(r); err != nil {
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
} else if page != nil {
|
||||
req.Page = page
|
||||
}
|
||||
|
||||
query := r.URL.Query()
|
||||
if quotationRef := firstNonEmpty(query.Get("quotation_ref"), query.Get("quote_ref")); quotationRef != "" {
|
||||
req.QuotationRef = quotationRef
|
||||
}
|
||||
createdFrom, err := parseRFC3339Timestamp(firstNonEmpty(query.Get("created_from"), query.Get("createdFrom")), "created_from")
|
||||
if err != nil {
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
if createdFrom != nil {
|
||||
req.CreatedFrom = createdFrom
|
||||
}
|
||||
createdTo, err := parseRFC3339Timestamp(firstNonEmpty(query.Get("created_to"), query.Get("createdTo")), "created_to")
|
||||
if err != nil {
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
if createdTo != nil {
|
||||
req.CreatedTo = createdTo
|
||||
}
|
||||
if req.GetCreatedFrom() != nil && req.GetCreatedTo() != nil {
|
||||
if !req.GetCreatedTo().AsTime().After(req.GetCreatedFrom().AsTime()) {
|
||||
return response.Auto(a.logger, a.Name(), merrors.InvalidArgument("created_to must be after created_from", "created_to"))
|
||||
}
|
||||
}
|
||||
|
||||
if states, err := parsePaymentStateFilters(r); err != nil {
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
} else if len(states) > 0 {
|
||||
req.States = states
|
||||
}
|
||||
|
||||
resp, err := a.execution.ListPayments(ctx, req)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to list payments", zap.Error(err), mzap.ObjRef("organization_ref", orgRef))
|
||||
return grpcErrorResponse(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
return sresponse.PaymentsListResponse(a.logger, resp, token)
|
||||
}
|
||||
|
||||
func listPaymentsPage(r *http.Request) (*paginationv1.CursorPageRequest, error) {
|
||||
query := r.URL.Query()
|
||||
cursor := strings.TrimSpace(query.Get("cursor"))
|
||||
limitRaw := strings.TrimSpace(query.Get("limit"))
|
||||
|
||||
var limit int64
|
||||
hasLimit := false
|
||||
if limitRaw != "" {
|
||||
parsed, err := strconv.ParseInt(limitRaw, 10, 32)
|
||||
if err != nil {
|
||||
return nil, merrors.InvalidArgument("invalid limit", "limit")
|
||||
}
|
||||
limit = parsed
|
||||
hasLimit = true
|
||||
}
|
||||
|
||||
if cursor == "" && !hasLimit {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
page := &paginationv1.CursorPageRequest{
|
||||
Cursor: cursor,
|
||||
}
|
||||
if hasLimit {
|
||||
if limit < 0 {
|
||||
limit = 0
|
||||
} else if limit > maxInt32 {
|
||||
limit = maxInt32
|
||||
}
|
||||
page.Limit = int32(limit)
|
||||
}
|
||||
|
||||
return page, nil
|
||||
}
|
||||
|
||||
func parsePaymentStateFilters(r *http.Request) ([]orchestrationv2.OrchestrationState, error) {
|
||||
query := r.URL.Query()
|
||||
values := append([]string{}, query["state"]...)
|
||||
values = append(values, query["states"]...)
|
||||
values = append(values, query["filter_states"]...)
|
||||
if len(values) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
states := make([]orchestrationv2.OrchestrationState, 0, len(values))
|
||||
for _, raw := range values {
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
trimmed := strings.TrimSpace(part)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
state, ok := orchestrationStateFromString(trimmed)
|
||||
if !ok {
|
||||
return nil, merrors.InvalidArgument("unsupported payment state: "+trimmed, "state")
|
||||
}
|
||||
states = append(states, state)
|
||||
}
|
||||
}
|
||||
|
||||
if len(states) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return states, nil
|
||||
}
|
||||
|
||||
func orchestrationStateFromString(value string) (orchestrationv2.OrchestrationState, bool) {
|
||||
upper := strings.ToUpper(strings.TrimSpace(value))
|
||||
if upper == "" {
|
||||
return 0, false
|
||||
}
|
||||
switch upper {
|
||||
case "PAYMENT_STATE_ACCEPTED", "ACCEPTED":
|
||||
return orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_CREATED, true
|
||||
case "PAYMENT_STATE_FUNDS_RESERVED", "FUNDS_RESERVED", "PAYMENT_STATE_SUBMITTED", "SUBMITTED":
|
||||
return orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_EXECUTING, true
|
||||
case "PAYMENT_STATE_SETTLED", "SETTLED":
|
||||
return orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_SETTLED, true
|
||||
case "PAYMENT_STATE_FAILED", "FAILED", "PAYMENT_STATE_CANCELLED", "CANCELLED":
|
||||
return orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_FAILED, true
|
||||
}
|
||||
if !strings.HasPrefix(upper, "ORCHESTRATION_STATE_") {
|
||||
upper = "ORCHESTRATION_STATE_" + upper
|
||||
}
|
||||
enumValue, ok := orchestrationv2.OrchestrationState_value[upper]
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
return orchestrationv2.OrchestrationState(enumValue), true
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseRFC3339Timestamp(raw string, field string) (*timestamppb.Timestamp, error) {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return nil, nil
|
||||
}
|
||||
parsed, err := time.Parse(time.RFC3339, trimmed)
|
||||
if err != nil {
|
||||
return nil, merrors.InvalidArgument("invalid "+field+", expected RFC3339", field)
|
||||
}
|
||||
return timestamppb.New(parsed), nil
|
||||
}
|
||||
319
api/edge/bff/internal/server/paymentapiimp/mapper.go
Normal file
319
api/edge/bff/internal/server/paymentapiimp/mapper.go
Normal file
@@ -0,0 +1,319 @@
|
||||
package paymentapiimp
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
pkgmodel "github.com/tech/sendico/pkg/model"
|
||||
payecon "github.com/tech/sendico/pkg/payments/economics"
|
||||
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"
|
||||
paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1"
|
||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||
endpointv1 "github.com/tech/sendico/pkg/proto/payments/endpoint/v1"
|
||||
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
|
||||
"github.com/tech/sendico/server/interface/api/srequest"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
func mapQuoteIntent(intent *srequest.PaymentIntent) (*quotationv2.QuoteIntent, error) {
|
||||
if intent == nil {
|
||||
return nil, merrors.InvalidArgument("intent is required")
|
||||
}
|
||||
if err := validatePaymentKind(intent.Kind); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
settlementMode, err := mapSettlementMode(intent.SettlementMode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
feeTreatment, err := mapFeeTreatment(intent.FeeTreatment)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resolvedSettlementMode, resolvedFeeTreatment, err := payecon.ResolveSettlementAndFee(settlementMode, feeTreatment)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
settlementCurrency := resolveSettlementCurrency(intent)
|
||||
if settlementCurrency == "" {
|
||||
return nil, merrors.InvalidArgument("unable to derive settlement currency from intent")
|
||||
}
|
||||
|
||||
source, err := mapQuoteEndpoint(intent.Source, "intent.source")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
destination, err := mapQuoteEndpoint(intent.Destination, "intent.destination")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
quoteIntent := "ationv2.QuoteIntent{
|
||||
Source: source,
|
||||
Destination: destination,
|
||||
Amount: mapMoney(intent.Amount),
|
||||
SettlementMode: resolvedSettlementMode,
|
||||
FeeTreatment: resolvedFeeTreatment,
|
||||
SettlementCurrency: settlementCurrency,
|
||||
FxSide: mapFXSide(intent),
|
||||
}
|
||||
if comment := strings.TrimSpace(intent.Attributes["comment"]); comment != "" {
|
||||
quoteIntent.Comment = comment
|
||||
}
|
||||
return quoteIntent, nil
|
||||
}
|
||||
|
||||
func mapFXSide(intent *srequest.PaymentIntent) fxv1.Side {
|
||||
if intent == nil || intent.FX == nil {
|
||||
return fxv1.Side_SIDE_UNSPECIFIED
|
||||
}
|
||||
switch strings.TrimSpace(string(intent.FX.Side)) {
|
||||
case string(srequest.FXSideBuyBaseSellQuote):
|
||||
return fxv1.Side_BUY_BASE_SELL_QUOTE
|
||||
case string(srequest.FXSideSellBaseBuyQuote):
|
||||
return fxv1.Side_SELL_BASE_BUY_QUOTE
|
||||
default:
|
||||
return fxv1.Side_SIDE_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
func validatePaymentKind(kind srequest.PaymentKind) error {
|
||||
switch strings.TrimSpace(string(kind)) {
|
||||
case string(srequest.PaymentKindPayout), string(srequest.PaymentKindInternalTransfer), string(srequest.PaymentKindFxConversion):
|
||||
return nil
|
||||
default:
|
||||
return merrors.InvalidArgument("unsupported payment kind: " + string(kind))
|
||||
}
|
||||
}
|
||||
|
||||
func resolveSettlementCurrency(intent *srequest.PaymentIntent) string {
|
||||
if intent == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
fx := intent.FX
|
||||
if fx != nil && fx.Pair != nil {
|
||||
base := strings.TrimSpace(fx.Pair.Base)
|
||||
quote := strings.TrimSpace(fx.Pair.Quote)
|
||||
switch strings.TrimSpace(string(fx.Side)) {
|
||||
case string(srequest.FXSideBuyBaseSellQuote):
|
||||
if base != "" {
|
||||
return base
|
||||
}
|
||||
case string(srequest.FXSideSellBaseBuyQuote):
|
||||
if quote != "" {
|
||||
return quote
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if intent.Amount != nil {
|
||||
return strings.TrimSpace(intent.Amount.Currency)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func mapQuoteEndpoint(endpoint *srequest.Endpoint, field string) (*endpointv1.PaymentEndpoint, error) {
|
||||
if endpoint == nil {
|
||||
return nil, merrors.InvalidArgument(field + " is required")
|
||||
}
|
||||
|
||||
switch endpoint.Type {
|
||||
case srequest.EndpointTypeLedger:
|
||||
payload, err := endpoint.DecodeLedger()
|
||||
if err != nil {
|
||||
return nil, merrors.InvalidArgument(field + ": " + err.Error())
|
||||
}
|
||||
method := &ledgerMethodData{
|
||||
LedgerAccountRef: strings.TrimSpace(payload.LedgerAccountRef),
|
||||
ContraLedgerAccountRef: strings.TrimSpace(payload.ContraLedgerAccountRef),
|
||||
}
|
||||
if method.LedgerAccountRef == "" {
|
||||
return nil, merrors.InvalidArgument(field + ".ledger_account_ref is required")
|
||||
}
|
||||
return endpointFromMethod(endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_LEDGER, method)
|
||||
|
||||
case srequest.EndpointTypeManagedWallet:
|
||||
payload, err := endpoint.DecodeManagedWallet()
|
||||
if err != nil {
|
||||
return nil, merrors.InvalidArgument(field + ": " + err.Error())
|
||||
}
|
||||
method := &pkgmodel.WalletPaymentData{WalletID: strings.TrimSpace(payload.ManagedWalletRef)}
|
||||
if method.WalletID == "" {
|
||||
return nil, merrors.InvalidArgument(field + ".managed_wallet_ref is required")
|
||||
}
|
||||
return endpointFromMethod(endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_WALLET, method)
|
||||
|
||||
case srequest.EndpointTypeWallet:
|
||||
payload, err := endpoint.DecodeWallet()
|
||||
if err != nil {
|
||||
return nil, merrors.InvalidArgument(field + ": " + err.Error())
|
||||
}
|
||||
method := &pkgmodel.WalletPaymentData{WalletID: strings.TrimSpace(payload.WalletID)}
|
||||
if method.WalletID == "" {
|
||||
return nil, merrors.InvalidArgument(field + ".walletId is required")
|
||||
}
|
||||
return endpointFromMethod(endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_WALLET, method)
|
||||
|
||||
case srequest.EndpointTypeExternalChain:
|
||||
payload, err := endpoint.DecodeExternalChain()
|
||||
if err != nil {
|
||||
return nil, merrors.InvalidArgument(field + ": " + err.Error())
|
||||
}
|
||||
method, mapErr := mapExternalChainMethod(payload, field)
|
||||
if mapErr != nil {
|
||||
return nil, mapErr
|
||||
}
|
||||
return endpointFromMethod(endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CRYPTO_ADDRESS, method)
|
||||
|
||||
case srequest.EndpointTypeCard:
|
||||
payload, err := endpoint.DecodeCard()
|
||||
if err != nil {
|
||||
return nil, merrors.InvalidArgument(field + ": " + err.Error())
|
||||
}
|
||||
method := &pkgmodel.CardPaymentData{
|
||||
Pan: strings.TrimSpace(payload.Pan),
|
||||
FirstName: strings.TrimSpace(payload.FirstName),
|
||||
LastName: strings.TrimSpace(payload.LastName),
|
||||
ExpMonth: uint32ToString(payload.ExpMonth),
|
||||
ExpYear: uint32ToString(payload.ExpYear),
|
||||
Country: strings.TrimSpace(payload.Country),
|
||||
}
|
||||
if method.Pan == "" {
|
||||
return nil, merrors.InvalidArgument(field + ".pan is required")
|
||||
}
|
||||
return endpointFromMethod(endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CARD, method)
|
||||
|
||||
case srequest.EndpointTypeCardToken:
|
||||
payload, err := endpoint.DecodeCardToken()
|
||||
if err != nil {
|
||||
return nil, merrors.InvalidArgument(field + ": " + err.Error())
|
||||
}
|
||||
method := &pkgmodel.TokenPaymentData{
|
||||
Token: strings.TrimSpace(payload.Token),
|
||||
Last4: strings.TrimSpace(payload.MaskedPan),
|
||||
}
|
||||
if method.Token == "" {
|
||||
return nil, merrors.InvalidArgument(field + ".token is required")
|
||||
}
|
||||
return endpointFromMethod(endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CARD_TOKEN, method)
|
||||
|
||||
case "":
|
||||
return nil, merrors.InvalidArgument(field + " endpoint type is required")
|
||||
|
||||
default:
|
||||
return nil, merrors.InvalidArgument(field + " endpoint type is unsupported in v2: " + string(endpoint.Type))
|
||||
}
|
||||
}
|
||||
|
||||
func mapExternalChainMethod(payload srequest.ExternalChainEndpoint, field string) (*pkgmodel.CryptoAddressPaymentData, error) {
|
||||
address := strings.TrimSpace(payload.Address)
|
||||
if address == "" {
|
||||
return nil, merrors.InvalidArgument(field + ".address is required")
|
||||
}
|
||||
if payload.Asset == nil {
|
||||
return nil, merrors.InvalidArgument(field + ".asset is required")
|
||||
}
|
||||
token := strings.ToUpper(strings.TrimSpace(payload.Asset.TokenSymbol))
|
||||
if token == "" {
|
||||
return nil, merrors.InvalidArgument(field + ".asset.token_symbol is required")
|
||||
}
|
||||
if _, err := mapChainNetwork(payload.Asset.Chain); err != nil {
|
||||
return nil, merrors.InvalidArgument(field + ".asset.chain: " + err.Error())
|
||||
}
|
||||
|
||||
result := &pkgmodel.CryptoAddressPaymentData{
|
||||
Currency: pkgmodel.Currency(token),
|
||||
Address: address,
|
||||
Network: strings.ToUpper(strings.TrimSpace(string(payload.Asset.Chain))),
|
||||
}
|
||||
if memo := strings.TrimSpace(payload.Memo); memo != "" {
|
||||
result.DestinationTag = &memo
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func endpointFromMethod(methodType endpointv1.PaymentMethodType, data any) (*endpointv1.PaymentEndpoint, error) {
|
||||
raw, err := bson.Marshal(data)
|
||||
if err != nil {
|
||||
return nil, merrors.InternalWrap(err, "failed to encode payment method data")
|
||||
}
|
||||
method := &endpointv1.PaymentMethod{
|
||||
Type: methodType,
|
||||
Data: raw,
|
||||
}
|
||||
return &endpointv1.PaymentEndpoint{
|
||||
Source: &endpointv1.PaymentEndpoint_PaymentMethod{
|
||||
PaymentMethod: method,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func mapMoney(m *paymenttypes.Money) *moneyv1.Money {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
return &moneyv1.Money{
|
||||
Amount: m.Amount,
|
||||
Currency: m.Currency,
|
||||
}
|
||||
}
|
||||
|
||||
func mapSettlementMode(mode srequest.SettlementMode) (paymentv1.SettlementMode, error) {
|
||||
switch strings.TrimSpace(string(mode)) {
|
||||
case "", string(srequest.SettlementModeUnspecified):
|
||||
return paymentv1.SettlementMode_SETTLEMENT_UNSPECIFIED, nil
|
||||
case string(srequest.SettlementModeFixSource):
|
||||
return paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE, nil
|
||||
case string(srequest.SettlementModeFixReceived):
|
||||
return paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED, nil
|
||||
default:
|
||||
return paymentv1.SettlementMode_SETTLEMENT_UNSPECIFIED, merrors.InvalidArgument("unsupported settlement mode: " + string(mode))
|
||||
}
|
||||
}
|
||||
|
||||
func mapFeeTreatment(treatment srequest.FeeTreatment) (quotationv2.FeeTreatment, error) {
|
||||
switch strings.TrimSpace(string(treatment)) {
|
||||
case "", string(srequest.FeeTreatmentUnspecified):
|
||||
return quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED, nil
|
||||
case string(srequest.FeeTreatmentAddToSource):
|
||||
return quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE, nil
|
||||
case string(srequest.FeeTreatmentDeductFromDestination):
|
||||
return quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION, nil
|
||||
default:
|
||||
return quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED, merrors.InvalidArgument("unsupported fee treatment: " + string(treatment))
|
||||
}
|
||||
}
|
||||
|
||||
func mapChainNetwork(chain srequest.ChainNetwork) (chainv1.ChainNetwork, error) {
|
||||
switch strings.TrimSpace(string(chain)) {
|
||||
case "", string(srequest.ChainNetworkUnspecified):
|
||||
return chainv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED, nil
|
||||
case string(srequest.ChainNetworkEthereumMainnet):
|
||||
return chainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET, nil
|
||||
case string(srequest.ChainNetworkArbitrumOne):
|
||||
return chainv1.ChainNetwork_CHAIN_NETWORK_ARBITRUM_ONE, nil
|
||||
case string(srequest.ChainNetworkTronMainnet):
|
||||
return chainv1.ChainNetwork_CHAIN_NETWORK_TRON_MAINNET, nil
|
||||
case string(srequest.ChainNetworkTronNile):
|
||||
return chainv1.ChainNetwork_CHAIN_NETWORK_TRON_NILE, nil
|
||||
default:
|
||||
return chainv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED, merrors.InvalidArgument("unsupported chain network: " + string(chain))
|
||||
}
|
||||
}
|
||||
|
||||
func uint32ToString(v uint32) string {
|
||||
if v == 0 {
|
||||
return ""
|
||||
}
|
||||
return strconv.FormatUint(uint64(v), 10)
|
||||
}
|
||||
|
||||
type ledgerMethodData struct {
|
||||
LedgerAccountRef string `bson:"ledgerAccountRef"`
|
||||
ContraLedgerAccountRef string `bson:"contraLedgerAccountRef,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package paymentapiimp
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||
paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1"
|
||||
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
|
||||
"github.com/tech/sendico/server/interface/api/srequest"
|
||||
)
|
||||
|
||||
func TestMapQuoteIntent_PropagatesFeeTreatment(t *testing.T) {
|
||||
source, err := srequest.NewManagedWalletEndpointDTO(srequest.ManagedWalletEndpoint{
|
||||
ManagedWalletRef: "wallet-source-1",
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to build source endpoint: %v", err)
|
||||
}
|
||||
|
||||
destination, err := srequest.NewCardEndpointDTO(srequest.CardEndpoint{
|
||||
Pan: "2200700142860161",
|
||||
FirstName: "John",
|
||||
LastName: "Doe",
|
||||
ExpMonth: 3,
|
||||
ExpYear: 2030,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to build destination endpoint: %v", err)
|
||||
}
|
||||
|
||||
intent := &srequest.PaymentIntent{
|
||||
Kind: srequest.PaymentKindPayout,
|
||||
Source: &source,
|
||||
Destination: &destination,
|
||||
Amount: &paymenttypes.Money{Amount: "10", Currency: "USDT"},
|
||||
SettlementMode: srequest.SettlementModeFixReceived,
|
||||
FeeTreatment: srequest.FeeTreatmentDeductFromDestination,
|
||||
}
|
||||
|
||||
got, err := mapQuoteIntent(intent)
|
||||
if err != nil {
|
||||
t.Fatalf("mapQuoteIntent returned error: %v", err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatalf("expected mapped quote intent")
|
||||
}
|
||||
if got.GetFeeTreatment() != quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION {
|
||||
t.Fatalf("unexpected fee treatment: got=%s", got.GetFeeTreatment().String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapQuoteIntent_InvalidFeeTreatmentFails(t *testing.T) {
|
||||
source, err := srequest.NewManagedWalletEndpointDTO(srequest.ManagedWalletEndpoint{
|
||||
ManagedWalletRef: "wallet-source-1",
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to build source endpoint: %v", err)
|
||||
}
|
||||
|
||||
destination, err := srequest.NewCardEndpointDTO(srequest.CardEndpoint{
|
||||
Pan: "2200700142860161",
|
||||
FirstName: "John",
|
||||
LastName: "Doe",
|
||||
ExpMonth: 3,
|
||||
ExpYear: 2030,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to build destination endpoint: %v", err)
|
||||
}
|
||||
|
||||
intent := &srequest.PaymentIntent{
|
||||
Kind: srequest.PaymentKindPayout,
|
||||
Source: &source,
|
||||
Destination: &destination,
|
||||
Amount: &paymenttypes.Money{Amount: "10", Currency: "USDT"},
|
||||
SettlementMode: srequest.SettlementModeFixSource,
|
||||
FeeTreatment: srequest.FeeTreatment("wrong_value"),
|
||||
}
|
||||
|
||||
if _, err := mapQuoteIntent(intent); err == nil {
|
||||
t.Fatalf("expected error for invalid fee treatment")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapQuoteIntent_AcceptsIndependentSettlementAndFeeTreatment(t *testing.T) {
|
||||
source, err := srequest.NewManagedWalletEndpointDTO(srequest.ManagedWalletEndpoint{
|
||||
ManagedWalletRef: "wallet-source-1",
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to build source endpoint: %v", err)
|
||||
}
|
||||
|
||||
destination, err := srequest.NewCardEndpointDTO(srequest.CardEndpoint{
|
||||
Pan: "2200700142860161",
|
||||
FirstName: "John",
|
||||
LastName: "Doe",
|
||||
ExpMonth: 3,
|
||||
ExpYear: 2030,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to build destination endpoint: %v", err)
|
||||
}
|
||||
|
||||
intent := &srequest.PaymentIntent{
|
||||
Kind: srequest.PaymentKindPayout,
|
||||
Source: &source,
|
||||
Destination: &destination,
|
||||
Amount: &paymenttypes.Money{Amount: "10", Currency: "USDT"},
|
||||
SettlementMode: srequest.SettlementModeFixReceived,
|
||||
FeeTreatment: srequest.FeeTreatmentAddToSource,
|
||||
}
|
||||
|
||||
got, err := mapQuoteIntent(intent)
|
||||
if err != nil {
|
||||
t.Fatalf("mapQuoteIntent returned error: %v", err)
|
||||
}
|
||||
if got.GetSettlementMode() != paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED {
|
||||
t.Fatalf("unexpected settlement mode: got=%s", got.GetSettlementMode().String())
|
||||
}
|
||||
if got.GetFeeTreatment() != quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE {
|
||||
t.Fatalf("unexpected fee treatment: got=%s", got.GetFeeTreatment().String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapQuoteIntent_DerivesSettlementCurrencyFromAmountWithoutFX(t *testing.T) {
|
||||
source, err := srequest.NewManagedWalletEndpointDTO(srequest.ManagedWalletEndpoint{
|
||||
ManagedWalletRef: "wallet-source-1",
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to build source endpoint: %v", err)
|
||||
}
|
||||
|
||||
destination, err := srequest.NewCardEndpointDTO(srequest.CardEndpoint{
|
||||
Pan: "2200700142860161",
|
||||
FirstName: "John",
|
||||
LastName: "Doe",
|
||||
ExpMonth: 3,
|
||||
ExpYear: 2030,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to build destination endpoint: %v", err)
|
||||
}
|
||||
|
||||
intent := &srequest.PaymentIntent{
|
||||
Kind: srequest.PaymentKindPayout,
|
||||
Source: &source,
|
||||
Destination: &destination,
|
||||
Amount: &paymenttypes.Money{Amount: "10", Currency: "USDT"},
|
||||
SettlementMode: srequest.SettlementModeFixSource,
|
||||
FeeTreatment: srequest.FeeTreatmentAddToSource,
|
||||
}
|
||||
|
||||
got, err := mapQuoteIntent(intent)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got.GetSettlementCurrency() != "USDT" {
|
||||
t.Fatalf("unexpected settlement currency: got=%q", got.GetSettlementCurrency())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapQuoteIntent_DerivesSettlementCurrencyFromFX(t *testing.T) {
|
||||
source, err := srequest.NewManagedWalletEndpointDTO(srequest.ManagedWalletEndpoint{
|
||||
ManagedWalletRef: "wallet-source-1",
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to build source endpoint: %v", err)
|
||||
}
|
||||
|
||||
destination, err := srequest.NewCardEndpointDTO(srequest.CardEndpoint{
|
||||
Pan: "2200700142860161",
|
||||
FirstName: "John",
|
||||
LastName: "Doe",
|
||||
ExpMonth: 3,
|
||||
ExpYear: 2030,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to build destination endpoint: %v", err)
|
||||
}
|
||||
|
||||
intent := &srequest.PaymentIntent{
|
||||
Kind: srequest.PaymentKindPayout,
|
||||
Source: &source,
|
||||
Destination: &destination,
|
||||
Amount: &paymenttypes.Money{Amount: "10", Currency: "USDT"},
|
||||
SettlementMode: srequest.SettlementModeFixSource,
|
||||
FeeTreatment: srequest.FeeTreatmentAddToSource,
|
||||
FX: &srequest.FXIntent{
|
||||
Pair: &srequest.CurrencyPair{
|
||||
Base: "USDT",
|
||||
Quote: "RUB",
|
||||
},
|
||||
Side: srequest.FXSideSellBaseBuyQuote,
|
||||
},
|
||||
}
|
||||
|
||||
got, err := mapQuoteIntent(intent)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got.GetSettlementCurrency() != "RUB" {
|
||||
t.Fatalf("unexpected settlement currency: got=%q", got.GetSettlementCurrency())
|
||||
}
|
||||
if got.GetFxSide() != fxv1.Side_SELL_BASE_BUY_QUOTE {
|
||||
t.Fatalf("unexpected fx_side: got=%s", got.GetFxSide().String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapQuoteIntent_PropagatesFXSideBuyBaseSellQuote(t *testing.T) {
|
||||
source, err := srequest.NewManagedWalletEndpointDTO(srequest.ManagedWalletEndpoint{
|
||||
ManagedWalletRef: "wallet-source-1",
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to build source endpoint: %v", err)
|
||||
}
|
||||
|
||||
destination, err := srequest.NewCardEndpointDTO(srequest.CardEndpoint{
|
||||
Pan: "2200700142860161",
|
||||
FirstName: "John",
|
||||
LastName: "Doe",
|
||||
ExpMonth: 3,
|
||||
ExpYear: 2030,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to build destination endpoint: %v", err)
|
||||
}
|
||||
|
||||
intent := &srequest.PaymentIntent{
|
||||
Kind: srequest.PaymentKindPayout,
|
||||
Source: &source,
|
||||
Destination: &destination,
|
||||
Amount: &paymenttypes.Money{Amount: "10", Currency: "USDT"},
|
||||
SettlementMode: srequest.SettlementModeFixSource,
|
||||
FeeTreatment: srequest.FeeTreatmentAddToSource,
|
||||
FX: &srequest.FXIntent{
|
||||
Pair: &srequest.CurrencyPair{
|
||||
Base: "RUB",
|
||||
Quote: "USDT",
|
||||
},
|
||||
Side: srequest.FXSideBuyBaseSellQuote,
|
||||
},
|
||||
}
|
||||
|
||||
got, err := mapQuoteIntent(intent)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got.GetFxSide() != fxv1.Side_BUY_BASE_SELL_QUOTE {
|
||||
t.Fatalf("unexpected fx_side: got=%s", got.GetFxSide().String())
|
||||
}
|
||||
if got.GetSettlementCurrency() != "RUB" {
|
||||
t.Fatalf("unexpected settlement currency: got=%q", got.GetSettlementCurrency())
|
||||
}
|
||||
}
|
||||
144
api/edge/bff/internal/server/paymentapiimp/pay.go
Normal file
144
api/edge/bff/internal/server/paymentapiimp/pay.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package paymentapiimp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/pkg/mutil/mzap"
|
||||
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
|
||||
orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2"
|
||||
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
|
||||
sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1"
|
||||
"github.com/tech/sendico/server/interface/api/srequest"
|
||||
"github.com/tech/sendico/server/interface/api/sresponse"
|
||||
mutil "github.com/tech/sendico/server/internal/mutil/param"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// shared initiation pipeline
|
||||
func (a *PaymentAPI) initiatePayment(r *http.Request, account *model.Account, token *sresponse.TokenData, expectQuote bool) http.HandlerFunc {
|
||||
orgRef, err := a.oph.GetRef(r)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to parse organization reference for payment initiation", zap.Error(err), mutil.PLog(a.oph, r))
|
||||
return response.BadReference(a.logger, a.Name(), a.oph.Name(), a.oph.GetID(r), err)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
allowed, err := a.enf.Enforce(ctx, a.permissionRef, account.ID, orgRef, bson.NilObjectID, model.ActionCreate)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to check payments access permissions", zap.Error(err), mutil.PLog(a.oph, r))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
if !allowed {
|
||||
a.logger.Debug("Access denied when initiating payment", mutil.PLog(a.oph, r))
|
||||
return response.AccessDenied(a.logger, a.Name(), "payments write permission denied")
|
||||
}
|
||||
|
||||
payload, err := decodeInitiatePayload(r)
|
||||
if err != nil {
|
||||
return response.BadPayload(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
if expectQuote {
|
||||
if payload.QuoteRef == "" {
|
||||
return response.BadPayload(a.logger, a.Name(), merrors.InvalidArgument("quoteRef is required"))
|
||||
}
|
||||
if payload.Intent != nil {
|
||||
return response.BadPayload(a.logger, a.Name(), merrors.DataConflict("quoteRef cannot be combined with intent"))
|
||||
}
|
||||
} else {
|
||||
if payload.Intent == nil {
|
||||
return response.BadPayload(a.logger, a.Name(), merrors.InvalidArgument("intent is required"))
|
||||
}
|
||||
if payload.QuoteRef != "" {
|
||||
return response.BadPayload(a.logger, a.Name(), merrors.DataConflict("quoteRef cannot be used when intent is provided"))
|
||||
}
|
||||
}
|
||||
|
||||
quotationRef := strings.TrimSpace(payload.QuoteRef)
|
||||
if metadataValue(payload.Metadata, "intent_ref") != "" {
|
||||
return response.BadPayload(a.logger, a.Name(), merrors.InvalidArgument("metadata.intent_ref is no longer supported", "metadata.intent_ref"))
|
||||
}
|
||||
if payload.Intent != nil {
|
||||
applyCustomerIP(payload.Intent, r.RemoteAddr)
|
||||
intent, err := mapQuoteIntent(payload.Intent)
|
||||
if err != nil {
|
||||
return response.BadPayload(a.logger, a.Name(), err)
|
||||
}
|
||||
quoteResp, qErr := a.quotation.QuotePayment(ctx, "ationv2.QuotePaymentRequest{
|
||||
Meta: requestMeta(orgRef.Hex(), payload.IdempotencyKey),
|
||||
IdempotencyKey: strings.TrimSpace(payload.IdempotencyKey),
|
||||
Intent: intent,
|
||||
InitiatorRef: initiatorRef(account),
|
||||
})
|
||||
if qErr != nil {
|
||||
a.logger.Warn("Failed to quote payment before execution", zap.Error(qErr), mzap.ObjRef("organization_ref", orgRef))
|
||||
return grpcErrorResponse(a.logger, a.Name(), qErr)
|
||||
}
|
||||
quotationRef = strings.TrimSpace(quoteResp.GetQuote().GetQuoteRef())
|
||||
if quotationRef == "" {
|
||||
return response.Auto(a.logger, a.Name(), merrors.DataConflict("quotation service returned empty quote_ref"))
|
||||
}
|
||||
}
|
||||
|
||||
req := &orchestrationv2.ExecutePaymentRequest{
|
||||
Meta: requestMeta(orgRef.Hex(), payload.IdempotencyKey),
|
||||
QuotationRef: quotationRef,
|
||||
ClientPaymentRef: metadataValue(payload.Metadata, "client_payment_ref"),
|
||||
}
|
||||
|
||||
resp, err := a.execution.ExecutePayment(ctx, req)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to initiate payment", zap.Error(err), mzap.ObjRef("organization_ref", orgRef))
|
||||
return grpcErrorResponse(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
return sresponse.PaymentResponse(a.logger, resp.GetPayment(), token)
|
||||
}
|
||||
|
||||
func decodeInitiatePayload(r *http.Request) (*srequest.InitiatePayment, error) {
|
||||
defer r.Body.Close()
|
||||
|
||||
payload := &srequest.InitiatePayment{}
|
||||
if err := json.NewDecoder(r.Body).Decode(payload); err != nil {
|
||||
return nil, merrors.InvalidArgument("invalid payload: " + err.Error())
|
||||
}
|
||||
payload.IdempotencyKey = strings.TrimSpace(payload.IdempotencyKey)
|
||||
payload.QuoteRef = strings.TrimSpace(payload.QuoteRef)
|
||||
|
||||
if err := payload.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func requestMeta(organizationRef string, idempotencyKey string) *sharedv1.RequestMeta {
|
||||
return &sharedv1.RequestMeta{
|
||||
OrganizationRef: strings.TrimSpace(organizationRef),
|
||||
Trace: &tracev1.TraceContext{
|
||||
IdempotencyKey: strings.TrimSpace(idempotencyKey),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func metadataValue(meta map[string]string, key string) string {
|
||||
if len(meta) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(meta[strings.TrimSpace(key)])
|
||||
}
|
||||
|
||||
func initiatorRef(account *model.Account) string {
|
||||
if account == nil {
|
||||
return ""
|
||||
}
|
||||
if account.ID != bson.NilObjectID {
|
||||
return account.ID.Hex()
|
||||
}
|
||||
return strings.TrimSpace(account.Login)
|
||||
}
|
||||
65
api/edge/bff/internal/server/paymentapiimp/pay_test.go
Normal file
65
api/edge/bff/internal/server/paymentapiimp/pay_test.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package paymentapiimp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/server/interface/api/sresponse"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
func TestInitiateByQuote_DoesNotUseIntentRef(t *testing.T) {
|
||||
orgRef := bson.NewObjectID()
|
||||
exec := &fakeExecutionClientForBatch{}
|
||||
api := newBatchAPI(exec)
|
||||
|
||||
body := `{"idempotencyKey":"idem-by-quote","quoteRef":"quote-1","metadata":{"client_payment_ref":"client-ref-1"}}`
|
||||
rr := invokeInitiateByQuote(t, api, orgRef, body)
|
||||
if got, want := rr.Code, http.StatusOK; got != want {
|
||||
t.Fatalf("status mismatch: got=%d want=%d body=%s", got, want, rr.Body.String())
|
||||
}
|
||||
if got, want := len(exec.executeReqs), 1; got != want {
|
||||
t.Fatalf("execute calls mismatch: got=%d want=%d", got, want)
|
||||
}
|
||||
if got, want := exec.executeReqs[0].GetClientPaymentRef(), "client-ref-1"; got != want {
|
||||
t.Fatalf("client_payment_ref mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitiateByQuote_RejectsMetadataIntentRef(t *testing.T) {
|
||||
orgRef := bson.NewObjectID()
|
||||
exec := &fakeExecutionClientForBatch{}
|
||||
api := newBatchAPI(exec)
|
||||
|
||||
body := `{"idempotencyKey":"idem-by-quote","quoteRef":"quote-1","metadata":{"intent_ref":"legacy-intent"}}`
|
||||
rr := invokeInitiateByQuote(t, api, orgRef, body)
|
||||
if got, want := rr.Code, http.StatusBadRequest; got != want {
|
||||
t.Fatalf("status mismatch: got=%d want=%d body=%s", got, want, rr.Body.String())
|
||||
}
|
||||
if got := len(exec.executeReqs); got != 0 {
|
||||
t.Fatalf("expected no execute calls, got=%d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func invokeInitiateByQuote(t *testing.T, api *PaymentAPI, orgRef bson.ObjectID, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/by-quote", bytes.NewBufferString(body))
|
||||
routeCtx := chi.NewRouteContext()
|
||||
routeCtx.URLParams.Add("organizations_ref", orgRef.Hex())
|
||||
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler := api.initiateByQuote(req, &model.Account{}, &sresponse.TokenData{
|
||||
Token: "token",
|
||||
Expiration: time.Now().UTC().Add(time.Hour),
|
||||
})
|
||||
handler.ServeHTTP(rr, req)
|
||||
return rr
|
||||
}
|
||||
80
api/edge/bff/internal/server/paymentapiimp/paybatch.go
Normal file
80
api/edge/bff/internal/server/paymentapiimp/paybatch.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package paymentapiimp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2"
|
||||
"github.com/tech/sendico/server/interface/api/srequest"
|
||||
"github.com/tech/sendico/server/interface/api/sresponse"
|
||||
mutil "github.com/tech/sendico/server/internal/mutil/param"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (a *PaymentAPI) initiatePaymentsByQuote(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc {
|
||||
orgRef, err := a.oph.GetRef(r)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to parse organization reference for batch payment initiation", zap.Error(err), zap.String(a.oph.Name(), a.oph.GetID(r)))
|
||||
return response.BadReference(a.logger, a.Name(), a.oph.Name(), a.oph.GetID(r), err)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
allowed, err := a.enf.Enforce(ctx, a.permissionRef, account.ID, orgRef, bson.NilObjectID, model.ActionCreate)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to check payments access permissions", zap.Error(err), mutil.PLog(a.oph, r))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
if !allowed {
|
||||
a.logger.Debug("Access denied when initiating batch payments", mutil.PLog(a.oph, r))
|
||||
return response.AccessDenied(a.logger, a.Name(), "payments write permission denied")
|
||||
}
|
||||
|
||||
payload, err := decodeInitiatePaymentsPayload(r)
|
||||
if err != nil {
|
||||
return response.BadPayload(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
clientPaymentRef := metadataValue(payload.Metadata, "client_payment_ref")
|
||||
idempotencyKey := strings.TrimSpace(payload.IdempotencyKey)
|
||||
quotationRef := strings.TrimSpace(payload.QuoteRef)
|
||||
|
||||
req := &orchestrationv2.ExecuteBatchPaymentRequest{
|
||||
Meta: requestMeta(orgRef.Hex(), idempotencyKey),
|
||||
QuotationRef: quotationRef,
|
||||
ClientPaymentRef: clientPaymentRef,
|
||||
}
|
||||
resp, err := a.execution.ExecuteBatchPayment(ctx, req)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to initiate batch payments", zap.Error(err), zap.String("organization_ref", orgRef.Hex()))
|
||||
return grpcErrorResponse(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
payments := make([]*orchestrationv2.Payment, 0)
|
||||
if resp != nil {
|
||||
payments = append(payments, resp.GetPayments()...)
|
||||
}
|
||||
return sresponse.PaymentsResponse(a.logger, payments, token)
|
||||
}
|
||||
|
||||
func decodeInitiatePaymentsPayload(r *http.Request) (*srequest.InitiatePayments, error) {
|
||||
defer r.Body.Close()
|
||||
|
||||
payload := &srequest.InitiatePayments{}
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(payload); err != nil {
|
||||
return nil, merrors.InvalidArgument("invalid payload: " + err.Error())
|
||||
}
|
||||
payload.IdempotencyKey = strings.TrimSpace(payload.IdempotencyKey)
|
||||
payload.QuoteRef = strings.TrimSpace(payload.QuoteRef)
|
||||
|
||||
if err := payload.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
172
api/edge/bff/internal/server/paymentapiimp/paybatch_test.go
Normal file
172
api/edge/bff/internal/server/paymentapiimp/paybatch_test.go
Normal file
@@ -0,0 +1,172 @@
|
||||
package paymentapiimp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/tech/sendico/pkg/auth"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2"
|
||||
"github.com/tech/sendico/server/interface/api/sresponse"
|
||||
mutil "github.com/tech/sendico/server/internal/mutil/param"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestInitiatePaymentsByQuote_ExecutesBatchPayment(t *testing.T) {
|
||||
orgRef := bson.NewObjectID()
|
||||
exec := &fakeExecutionClientForBatch{}
|
||||
api := newBatchAPI(exec)
|
||||
|
||||
body := `{"idempotencyKey":"idem-batch","quoteRef":"quote-1"}`
|
||||
rr := invokeInitiatePaymentsByQuote(t, api, orgRef, body)
|
||||
if got, want := rr.Code, http.StatusOK; got != want {
|
||||
t.Fatalf("status mismatch: got=%d want=%d body=%s", got, want, rr.Body.String())
|
||||
}
|
||||
|
||||
if got, want := len(exec.executeBatchReqs), 1; got != want {
|
||||
t.Fatalf("execute batch calls mismatch: got=%d want=%d", got, want)
|
||||
}
|
||||
if got := len(exec.executeReqs); got != 0 {
|
||||
t.Fatalf("expected no execute calls, got=%d", got)
|
||||
}
|
||||
if got, want := exec.executeBatchReqs[0].GetQuotationRef(), "quote-1"; got != want {
|
||||
t.Fatalf("quotation_ref mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := exec.executeBatchReqs[0].GetMeta().GetTrace().GetIdempotencyKey(), "idem-batch"; got != want {
|
||||
t.Fatalf("idempotency mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitiatePaymentsByQuote_ForwardsClientPaymentRef(t *testing.T) {
|
||||
orgRef := bson.NewObjectID()
|
||||
exec := &fakeExecutionClientForBatch{}
|
||||
api := newBatchAPI(exec)
|
||||
|
||||
body := `{"idempotencyKey":"idem-batch","quoteRef":"quote-1","metadata":{"client_payment_ref":"client-ref-1"}}`
|
||||
rr := invokeInitiatePaymentsByQuote(t, api, orgRef, body)
|
||||
if got, want := rr.Code, http.StatusOK; got != want {
|
||||
t.Fatalf("status mismatch: got=%d want=%d body=%s", got, want, rr.Body.String())
|
||||
}
|
||||
|
||||
if got, want := len(exec.executeBatchReqs), 1; got != want {
|
||||
t.Fatalf("execute batch calls mismatch: got=%d want=%d", got, want)
|
||||
}
|
||||
if got, want := exec.executeBatchReqs[0].GetClientPaymentRef(), "client-ref-1"; got != want {
|
||||
t.Fatalf("client_payment_ref mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got := len(exec.executeReqs); got != 0 {
|
||||
t.Fatalf("expected no execute calls, got=%d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitiatePaymentsByQuote_RejectsDeprecatedIntentRefField(t *testing.T) {
|
||||
orgRef := bson.NewObjectID()
|
||||
exec := &fakeExecutionClientForBatch{}
|
||||
api := newBatchAPI(exec)
|
||||
|
||||
body := `{"idempotencyKey":"idem-batch","quoteRef":"quote-1","intentRef":"intent-legacy"}`
|
||||
rr := invokeInitiatePaymentsByQuote(t, api, orgRef, body)
|
||||
if got, want := rr.Code, http.StatusBadRequest; got != want {
|
||||
t.Fatalf("status mismatch: got=%d want=%d body=%s", got, want, rr.Body.String())
|
||||
}
|
||||
if got := len(exec.executeReqs); got != 0 {
|
||||
t.Fatalf("expected no execute calls, got=%d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitiatePaymentsByQuote_RejectsDeprecatedIntentRefsField(t *testing.T) {
|
||||
orgRef := bson.NewObjectID()
|
||||
exec := &fakeExecutionClientForBatch{}
|
||||
api := newBatchAPI(exec)
|
||||
|
||||
body := `{"idempotencyKey":"idem-batch","quoteRef":"quote-1","intentRefs":["intent-a","intent-b"]}`
|
||||
rr := invokeInitiatePaymentsByQuote(t, api, orgRef, body)
|
||||
if got, want := rr.Code, http.StatusBadRequest; got != want {
|
||||
t.Fatalf("status mismatch: got=%d want=%d body=%s", got, want, rr.Body.String())
|
||||
}
|
||||
if got := len(exec.executeReqs); got != 0 {
|
||||
t.Fatalf("expected no execute calls, got=%d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func newBatchAPI(exec executionClient) *PaymentAPI {
|
||||
return &PaymentAPI{
|
||||
logger: mlogger.Logger(zap.NewNop()),
|
||||
execution: exec,
|
||||
enf: fakeEnforcerForBatch{allowed: true},
|
||||
oph: mutil.CreatePH(mservice.Organizations),
|
||||
permissionRef: bson.NewObjectID(),
|
||||
}
|
||||
}
|
||||
|
||||
func invokeInitiatePaymentsByQuote(t *testing.T, api *PaymentAPI, orgRef bson.ObjectID, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/by-multiquote", bytes.NewBufferString(body))
|
||||
routeCtx := chi.NewRouteContext()
|
||||
routeCtx.URLParams.Add("organizations_ref", orgRef.Hex())
|
||||
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler := api.initiatePaymentsByQuote(req, &model.Account{}, &sresponse.TokenData{
|
||||
Token: "token",
|
||||
Expiration: time.Now().UTC().Add(time.Hour),
|
||||
})
|
||||
handler.ServeHTTP(rr, req)
|
||||
return rr
|
||||
}
|
||||
|
||||
type fakeExecutionClientForBatch struct {
|
||||
executeReqs []*orchestrationv2.ExecutePaymentRequest
|
||||
executeBatchReqs []*orchestrationv2.ExecuteBatchPaymentRequest
|
||||
}
|
||||
|
||||
func (f *fakeExecutionClientForBatch) ExecutePayment(_ context.Context, req *orchestrationv2.ExecutePaymentRequest) (*orchestrationv2.ExecutePaymentResponse, error) {
|
||||
f.executeReqs = append(f.executeReqs, req)
|
||||
return &orchestrationv2.ExecutePaymentResponse{
|
||||
Payment: &orchestrationv2.Payment{PaymentRef: bson.NewObjectID().Hex()},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (f *fakeExecutionClientForBatch) ExecuteBatchPayment(_ context.Context, req *orchestrationv2.ExecuteBatchPaymentRequest) (*orchestrationv2.ExecuteBatchPaymentResponse, error) {
|
||||
f.executeBatchReqs = append(f.executeBatchReqs, req)
|
||||
return &orchestrationv2.ExecuteBatchPaymentResponse{
|
||||
Payments: []*orchestrationv2.Payment{{PaymentRef: bson.NewObjectID().Hex()}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (*fakeExecutionClientForBatch) ListPayments(context.Context, *orchestrationv2.ListPaymentsRequest) (*orchestrationv2.ListPaymentsResponse, error) {
|
||||
return &orchestrationv2.ListPaymentsResponse{}, nil
|
||||
}
|
||||
|
||||
func (*fakeExecutionClientForBatch) Close() error { return nil }
|
||||
|
||||
type fakeEnforcerForBatch struct {
|
||||
allowed bool
|
||||
}
|
||||
|
||||
func (f fakeEnforcerForBatch) Enforce(context.Context, bson.ObjectID, bson.ObjectID, bson.ObjectID, bson.ObjectID, model.Action) (bool, error) {
|
||||
return f.allowed, nil
|
||||
}
|
||||
|
||||
func (fakeEnforcerForBatch) EnforceBatch(context.Context, []model.PermissionBoundStorable, bson.ObjectID, model.Action) (map[bson.ObjectID]bool, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (fakeEnforcerForBatch) GetRoles(context.Context, bson.ObjectID, bson.ObjectID) ([]model.Role, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (fakeEnforcerForBatch) GetPermissions(context.Context, bson.ObjectID, bson.ObjectID) ([]model.Role, []model.Permission, error) {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
var _ auth.Enforcer = (*fakeEnforcerForBatch)(nil)
|
||||
13
api/edge/bff/internal/server/paymentapiimp/payimmediate.go
Normal file
13
api/edge/bff/internal/server/paymentapiimp/payimmediate.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package paymentapiimp
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/server/interface/api/sresponse"
|
||||
)
|
||||
|
||||
// initiateImmediate runs a one-shot payment using a fresh quote.
|
||||
func (a *PaymentAPI) initiateImmediate(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc {
|
||||
return a.initiatePayment(r, account, token, false)
|
||||
}
|
||||
13
api/edge/bff/internal/server/paymentapiimp/payquote.go
Normal file
13
api/edge/bff/internal/server/paymentapiimp/payquote.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package paymentapiimp
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/server/interface/api/sresponse"
|
||||
)
|
||||
|
||||
// initiateByQuote executes a payment using a previously issued quote_ref.
|
||||
func (a *PaymentAPI) initiateByQuote(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc {
|
||||
return a.initiatePayment(r, account, token, true)
|
||||
}
|
||||
153
api/edge/bff/internal/server/paymentapiimp/quote.go
Normal file
153
api/edge/bff/internal/server/paymentapiimp/quote.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package paymentapiimp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
|
||||
"github.com/tech/sendico/server/interface/api/srequest"
|
||||
"github.com/tech/sendico/server/interface/api/sresponse"
|
||||
mutil "github.com/tech/sendico/server/internal/mutil/param"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (a *PaymentAPI) quotePayment(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc {
|
||||
orgRef, err := a.oph.GetRef(r)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to parse organization reference for quote", zap.Error(err), zap.String(a.oph.Name(), a.oph.GetID(r)))
|
||||
return response.BadReference(a.logger, a.Name(), a.oph.Name(), a.oph.GetID(r), err)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
allowed, err := a.enf.Enforce(ctx, a.permissionRef, account.ID, orgRef, bson.NilObjectID, model.ActionCreate)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to check payments access permissions", zap.Error(err), mutil.PLog(a.oph, r))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
if !allowed {
|
||||
a.logger.Debug("Access denied when quoting payment", mutil.PLog(a.oph, r))
|
||||
return response.AccessDenied(a.logger, a.Name(), "payments write permission denied")
|
||||
}
|
||||
|
||||
payload, err := decodeQuotePayload(r)
|
||||
if err != nil {
|
||||
a.logger.Debug("Failed to decode payload", zap.Error(err), mutil.PLog(a.oph, r))
|
||||
return response.BadPayload(a.logger, a.Name(), err)
|
||||
}
|
||||
if err := payload.Validate(); err != nil {
|
||||
a.logger.Debug("Failed to validate payload", zap.Error(err), mutil.PLog(a.oph, r))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
applyCustomerIP(&payload.Intent, r.RemoteAddr)
|
||||
intent, err := mapQuoteIntent(&payload.Intent)
|
||||
if err != nil {
|
||||
a.logger.Debug("Failed to map payment intent", zap.Error(err), mutil.PLog(a.oph, r))
|
||||
return response.BadPayload(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
req := "ationv2.QuotePaymentRequest{
|
||||
Meta: requestMeta(orgRef.Hex(), payload.IdempotencyKey),
|
||||
IdempotencyKey: payload.IdempotencyKey,
|
||||
Intent: intent,
|
||||
PreviewOnly: payload.PreviewOnly,
|
||||
InitiatorRef: initiatorRef(account),
|
||||
}
|
||||
|
||||
resp, err := a.quotation.QuotePayment(ctx, req)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to quote payment", zap.Error(err), zap.String("organization_ref", orgRef.Hex()))
|
||||
return grpcErrorResponse(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
return sresponse.PaymentQuoteResponse(a.logger, resp.GetIdempotencyKey(), resp.GetQuote(), token)
|
||||
}
|
||||
|
||||
func (a *PaymentAPI) quotePayments(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc {
|
||||
orgRef, err := a.oph.GetRef(r)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to parse organization reference for quotes", zap.Error(err), zap.String(a.oph.Name(), a.oph.GetID(r)))
|
||||
return response.BadReference(a.logger, a.Name(), a.oph.Name(), a.oph.GetID(r), err)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
allowed, err := a.enf.Enforce(ctx, a.permissionRef, account.ID, orgRef, bson.NilObjectID, model.ActionCreate)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to check payments access permissions", zap.Error(err), mutil.PLog(a.oph, r))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
if !allowed {
|
||||
a.logger.Debug("Access denied when quoting payments", mutil.PLog(a.oph, r))
|
||||
return response.AccessDenied(a.logger, a.Name(), "payments write permission denied")
|
||||
}
|
||||
|
||||
payload, err := decodeQuotePaymentsPayload(r)
|
||||
if err != nil {
|
||||
a.logger.Debug("Failed to decode payload", zap.Error(err), mutil.PLog(a.oph, r))
|
||||
return response.BadPayload(a.logger, a.Name(), err)
|
||||
}
|
||||
if err := payload.Validate(); err != nil {
|
||||
a.logger.Debug("Failed to validate payload", zap.Error(err), mutil.PLog(a.oph, r))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
intents := make([]*quotationv2.QuoteIntent, 0, len(payload.Intents))
|
||||
for i := range payload.Intents {
|
||||
applyCustomerIP(&payload.Intents[i], r.RemoteAddr)
|
||||
intent, err := mapQuoteIntent(&payload.Intents[i])
|
||||
if err != nil {
|
||||
a.logger.Debug("Failed to map payment intent", zap.Error(err), mutil.PLog(a.oph, r))
|
||||
return response.BadPayload(a.logger, a.Name(), err)
|
||||
}
|
||||
intents = append(intents, intent)
|
||||
}
|
||||
|
||||
req := "ationv2.QuotePaymentsRequest{
|
||||
Meta: requestMeta(orgRef.Hex(), payload.IdempotencyKey),
|
||||
IdempotencyKey: payload.IdempotencyKey,
|
||||
Intents: intents,
|
||||
PreviewOnly: payload.PreviewOnly,
|
||||
InitiatorRef: initiatorRef(account),
|
||||
}
|
||||
|
||||
resp, err := a.quotation.QuotePayments(ctx, req)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to quote payments", zap.Error(err), zap.String("organization_ref", orgRef.Hex()))
|
||||
return grpcErrorResponse(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
return sresponse.PaymentQuotesResponse(a.logger, resp, token)
|
||||
}
|
||||
|
||||
func decodeQuotePayload(r *http.Request) (*srequest.QuotePayment, error) {
|
||||
defer r.Body.Close()
|
||||
|
||||
payload := &srequest.QuotePayment{}
|
||||
if err := json.NewDecoder(r.Body).Decode(payload); err != nil {
|
||||
return nil, merrors.InvalidArgument("invalid payload: "+err.Error(), "payload")
|
||||
}
|
||||
payload.IdempotencyKey = strings.TrimSpace(payload.IdempotencyKey)
|
||||
if err := payload.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func decodeQuotePaymentsPayload(r *http.Request) (*srequest.QuotePayments, error) {
|
||||
defer r.Body.Close()
|
||||
|
||||
payload := &srequest.QuotePayments{}
|
||||
if err := json.NewDecoder(r.Body).Decode(payload); err != nil {
|
||||
return nil, merrors.InvalidArgument("invalid payload: "+err.Error(), "payload")
|
||||
}
|
||||
payload.IdempotencyKey = strings.TrimSpace(payload.IdempotencyKey)
|
||||
if err := payload.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
293
api/edge/bff/internal/server/paymentapiimp/service.go
Normal file
293
api/edge/bff/internal/server/paymentapiimp/service.go
Normal file
@@ -0,0 +1,293 @@
|
||||
package paymentapiimp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
orchestratorclient "github.com/tech/sendico/payments/orchestrator/client"
|
||||
api "github.com/tech/sendico/pkg/api/http"
|
||||
"github.com/tech/sendico/pkg/auth"
|
||||
"github.com/tech/sendico/pkg/discovery"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
msg "github.com/tech/sendico/pkg/messaging"
|
||||
msgconsumer "github.com/tech/sendico/pkg/messaging/consumer"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2"
|
||||
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
|
||||
eapi "github.com/tech/sendico/server/interface/api"
|
||||
mutil "github.com/tech/sendico/server/internal/mutil/param"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
)
|
||||
|
||||
type executionClient interface {
|
||||
ExecutePayment(ctx context.Context, req *orchestrationv2.ExecutePaymentRequest) (*orchestrationv2.ExecutePaymentResponse, error)
|
||||
ExecuteBatchPayment(ctx context.Context, req *orchestrationv2.ExecuteBatchPaymentRequest) (*orchestrationv2.ExecuteBatchPaymentResponse, error)
|
||||
ListPayments(ctx context.Context, req *orchestrationv2.ListPaymentsRequest) (*orchestrationv2.ListPaymentsResponse, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
type quotationClient interface {
|
||||
QuotePayment(ctx context.Context, req *quotationv2.QuotePaymentRequest) (*quotationv2.QuotePaymentResponse, error)
|
||||
QuotePayments(ctx context.Context, req *quotationv2.QuotePaymentsRequest) (*quotationv2.QuotePaymentsResponse, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
type PaymentAPI struct {
|
||||
logger mlogger.Logger
|
||||
execution executionClient
|
||||
quotation quotationClient
|
||||
enf auth.Enforcer
|
||||
oph mutil.ParamHelper
|
||||
discovery *discovery.Client
|
||||
refreshConsumer msg.Consumer
|
||||
refreshMu sync.RWMutex
|
||||
refreshEvent *discovery.RefreshEvent
|
||||
|
||||
permissionRef bson.ObjectID
|
||||
}
|
||||
|
||||
func (a *PaymentAPI) Name() mservice.Type { return mservice.Payments }
|
||||
|
||||
func (a *PaymentAPI) Finish(ctx context.Context) error {
|
||||
if a.execution != nil {
|
||||
if err := a.execution.Close(); err != nil {
|
||||
a.logger.Warn("Failed to close payment orchestrator client", zap.Error(err))
|
||||
}
|
||||
}
|
||||
if a.quotation != nil {
|
||||
if err := a.quotation.Close(); err != nil {
|
||||
a.logger.Warn("Failed to close payment quotation client", zap.Error(err))
|
||||
}
|
||||
}
|
||||
if a.discovery != nil {
|
||||
a.discovery.Close()
|
||||
}
|
||||
if a.refreshConsumer != nil {
|
||||
a.refreshConsumer.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func CreateAPI(apiCtx eapi.API) (*PaymentAPI, error) {
|
||||
p := &PaymentAPI{
|
||||
logger: apiCtx.Logger().Named(mservice.Payments),
|
||||
enf: apiCtx.Permissions().Enforcer(),
|
||||
oph: mutil.CreatePH(mservice.Organizations),
|
||||
}
|
||||
|
||||
desc, err := apiCtx.Permissions().GetPolicyDescription(context.Background(), mservice.Payments)
|
||||
if err != nil {
|
||||
p.logger.Warn("Failed to fetch payment orchestrator permission description", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
p.permissionRef = desc.ID
|
||||
|
||||
if err := p.initPaymentClient(apiCtx.Config().PaymentOrchestrator, apiCtx.Config().PaymentQuotation); err != nil {
|
||||
p.logger.Error("Failed to initialize payment orchestrator client", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
if err := p.initDiscoveryClient(apiCtx.Config()); err != nil {
|
||||
p.logger.Warn("Failed to initialize discovery client", zap.Error(err))
|
||||
}
|
||||
|
||||
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/quote"), api.Post, p.quotePayment)
|
||||
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/multiquote"), api.Post, p.quotePayments)
|
||||
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/immediate"), api.Post, p.initiateImmediate)
|
||||
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/by-quote"), api.Post, p.initiateByQuote)
|
||||
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/by-multiquote"), api.Post, p.initiatePaymentsByQuote)
|
||||
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/"), api.Get, p.listPayments)
|
||||
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/documents/act"), api.Get, p.getActDocument)
|
||||
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/registry"), api.Get, p.listDiscoveryRegistry)
|
||||
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/registry/refresh"), api.Get, p.getDiscoveryRefresh)
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (a *PaymentAPI) initPaymentClient(cfg *eapi.PaymentOrchestratorConfig, quoteCfg *eapi.PaymentOrchestratorConfig) error {
|
||||
if cfg == nil {
|
||||
return merrors.InvalidArgument("payment orchestrator configuration is not provided")
|
||||
}
|
||||
|
||||
address, err := resolveClientAddress("payment orchestrator", cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
quoteAddress := address
|
||||
quoteInsecure := cfg.Insecure
|
||||
quoteDialTimeout := cfg.DialTimeoutSeconds
|
||||
quoteCallTimeout := cfg.CallTimeoutSeconds
|
||||
if quoteCfg != nil {
|
||||
if addr := strings.TrimSpace(quoteCfg.Address); addr != "" {
|
||||
quoteAddress = addr
|
||||
} else if env := strings.TrimSpace(quoteCfg.AddressEnv); env != "" {
|
||||
if resolved := strings.TrimSpace(os.Getenv(env)); resolved != "" {
|
||||
quoteAddress = resolved
|
||||
}
|
||||
}
|
||||
quoteInsecure = quoteCfg.Insecure
|
||||
quoteDialTimeout = quoteCfg.DialTimeoutSeconds
|
||||
quoteCallTimeout = quoteCfg.CallTimeoutSeconds
|
||||
}
|
||||
|
||||
clientCfg := orchestratorclient.Config{
|
||||
Address: address,
|
||||
DialTimeout: time.Duration(cfg.DialTimeoutSeconds) * time.Second,
|
||||
CallTimeout: time.Duration(cfg.CallTimeoutSeconds) * time.Second,
|
||||
Insecure: cfg.Insecure,
|
||||
}
|
||||
|
||||
execution, err := orchestratorclient.New(context.Background(), clientCfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
quotation, err := newQuotationClient(context.Background(), quotationClientConfig{
|
||||
Address: quoteAddress,
|
||||
DialTimeout: time.Duration(quoteDialTimeout) * time.Second,
|
||||
CallTimeout: time.Duration(quoteCallTimeout) * time.Second,
|
||||
Insecure: quoteInsecure,
|
||||
})
|
||||
if err != nil {
|
||||
_ = execution.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
a.execution = execution
|
||||
a.quotation = quotation
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveClientAddress(service string, cfg *eapi.PaymentOrchestratorConfig) (string, error) {
|
||||
if cfg == nil {
|
||||
return "", merrors.InvalidArgument(strings.TrimSpace(service) + " configuration is not provided")
|
||||
}
|
||||
address := strings.TrimSpace(cfg.Address)
|
||||
if address != "" {
|
||||
return address, nil
|
||||
}
|
||||
if env := strings.TrimSpace(cfg.AddressEnv); env != "" {
|
||||
if resolved := strings.TrimSpace(os.Getenv(env)); resolved != "" {
|
||||
return resolved, nil
|
||||
}
|
||||
return "", merrors.InvalidArgument(fmt.Sprintf("%s address is not specified and address env %s is empty", strings.TrimSpace(service), env))
|
||||
}
|
||||
return "", merrors.InvalidArgument(strings.TrimSpace(service) + " address is not specified")
|
||||
}
|
||||
|
||||
type quotationClientConfig struct {
|
||||
Address string
|
||||
DialTimeout time.Duration
|
||||
CallTimeout time.Duration
|
||||
Insecure bool
|
||||
}
|
||||
|
||||
func (c *quotationClientConfig) setDefaults() {
|
||||
if c.DialTimeout <= 0 {
|
||||
c.DialTimeout = 5 * time.Second
|
||||
}
|
||||
if c.CallTimeout <= 0 {
|
||||
c.CallTimeout = 3 * time.Second
|
||||
}
|
||||
}
|
||||
|
||||
type grpcQuotationClient struct {
|
||||
conn *grpc.ClientConn
|
||||
client quotationv2.QuotationServiceClient
|
||||
callTimeout time.Duration
|
||||
}
|
||||
|
||||
func newQuotationClient(ctx context.Context, cfg quotationClientConfig, opts ...grpc.DialOption) (quotationClient, error) {
|
||||
cfg.setDefaults()
|
||||
if strings.TrimSpace(cfg.Address) == "" {
|
||||
return nil, merrors.InvalidArgument("payment quotation: address is required")
|
||||
}
|
||||
|
||||
dialCtx, cancel := context.WithTimeout(ctx, cfg.DialTimeout)
|
||||
defer cancel()
|
||||
|
||||
dialOpts := make([]grpc.DialOption, 0, len(opts)+1)
|
||||
dialOpts = append(dialOpts, opts...)
|
||||
if cfg.Insecure {
|
||||
dialOpts = append(dialOpts, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
} else {
|
||||
dialOpts = append(dialOpts, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{})))
|
||||
}
|
||||
|
||||
conn, err := grpc.DialContext(dialCtx, cfg.Address, dialOpts...)
|
||||
if err != nil {
|
||||
return nil, merrors.InternalWrap(err, fmt.Sprintf("payment-quotation: dial %s", cfg.Address))
|
||||
}
|
||||
return &grpcQuotationClient{
|
||||
conn: conn,
|
||||
client: quotationv2.NewQuotationServiceClient(conn),
|
||||
callTimeout: cfg.CallTimeout,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *grpcQuotationClient) Close() error {
|
||||
if c == nil || c.conn == nil {
|
||||
return nil
|
||||
}
|
||||
return c.conn.Close()
|
||||
}
|
||||
|
||||
func (c *grpcQuotationClient) QuotePayment(ctx context.Context, req *quotationv2.QuotePaymentRequest) (*quotationv2.QuotePaymentResponse, error) {
|
||||
callCtx, cancel := c.callContext(ctx)
|
||||
defer cancel()
|
||||
return c.client.QuotePayment(callCtx, req)
|
||||
}
|
||||
|
||||
func (c *grpcQuotationClient) QuotePayments(ctx context.Context, req *quotationv2.QuotePaymentsRequest) (*quotationv2.QuotePaymentsResponse, error) {
|
||||
callCtx, cancel := c.callContext(ctx)
|
||||
defer cancel()
|
||||
return c.client.QuotePayments(callCtx, req)
|
||||
}
|
||||
|
||||
func (c *grpcQuotationClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
timeout := c.callTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = 3 * time.Second
|
||||
}
|
||||
return context.WithTimeout(ctx, timeout)
|
||||
}
|
||||
|
||||
func (a *PaymentAPI) initDiscoveryClient(cfg *eapi.Config) error {
|
||||
if cfg == nil || cfg.Mw == nil {
|
||||
return nil
|
||||
}
|
||||
msgCfg := cfg.Mw.Messaging
|
||||
if msgCfg.Driver == "" {
|
||||
return nil
|
||||
}
|
||||
broker, err := msg.CreateMessagingBroker(a.logger.Named("discovery_bus"), &msgCfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client, err := discovery.NewClient(a.logger, broker, nil, string(a.Name()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a.discovery = client
|
||||
refreshConsumer, err := msgconsumer.NewConsumer(a.logger, broker, discovery.RefreshUIEvent())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a.refreshConsumer = refreshConsumer
|
||||
go func() {
|
||||
if err := refreshConsumer.ConsumeMessages(a.handleRefreshEvent); err != nil {
|
||||
a.logger.Warn("Discovery refresh consumer stopped", zap.Error(err))
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user