move api/server to api/edge/bff

This commit is contained in:
Stephan D
2026-02-28 00:39:20 +01:00
parent 34182af3b8
commit 98db0e4e9e
248 changed files with 406 additions and 18 deletions

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

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

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

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

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

View 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 := &quotationv2.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"`
}

View File

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

View 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, &quotationv2.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)
}

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

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

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

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

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

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

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