430 lines
14 KiB
Go
430 lines
14 KiB
Go
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"
|
|
connectorv1 "github.com/tech/sendico/pkg/proto/connector/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"
|
|
"google.golang.org/protobuf/types/known/structpb"
|
|
)
|
|
|
|
const (
|
|
documentsServiceName = "BILLING_DOCUMENTS"
|
|
documentsOperationGet = discovery.OperationDocumentsGet
|
|
documentsCallTimeout = 10 * time.Second
|
|
gatewayCallTimeout = 10 * time.Second
|
|
)
|
|
|
|
var allowedOperationGatewayServices = map[mservice.Type]struct{}{
|
|
mservice.ChainGateway: {},
|
|
mservice.TronGateway: {},
|
|
mservice.MntxGateway: {},
|
|
mservice.PaymentGateway: {},
|
|
mservice.TgSettle: {},
|
|
}
|
|
|
|
func (a *PaymentAPI) getOperationDocument(r *http.Request, account *model.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
|
orgRef, denied := a.authorizeDocumentDownload(r, account)
|
|
if denied != nil {
|
|
return denied
|
|
}
|
|
|
|
query := r.URL.Query()
|
|
gatewayService := normalizeGatewayService(query.Get("gateway_service"))
|
|
if gatewayService == "" {
|
|
return response.BadRequest(a.logger, a.Name(), "missing_parameter", "gateway_service is required")
|
|
}
|
|
if _, ok := allowedOperationGatewayServices[gatewayService]; !ok {
|
|
return response.BadRequest(a.logger, a.Name(), "invalid_parameter", "unsupported gateway_service")
|
|
}
|
|
|
|
operationRef := strings.TrimSpace(query.Get("operation_ref"))
|
|
if operationRef == "" {
|
|
return response.BadRequest(a.logger, a.Name(), "missing_parameter", "operation_ref is required")
|
|
}
|
|
|
|
service, gateway, h := a.resolveOperationDocumentDeps(r.Context(), gatewayService)
|
|
if h != nil {
|
|
return h
|
|
}
|
|
|
|
op, err := a.fetchGatewayOperation(r.Context(), gateway.InvokeURI, operationRef)
|
|
if err != nil {
|
|
a.logger.Warn("Failed to fetch gateway operation for document generation", zap.Error(err), mzap.ObjRef("organization_ref", orgRef), zap.String("gateway_service", string(gatewayService)), zap.String("operation_ref", operationRef))
|
|
return documentErrorResponse(a.logger, a.Name(), err)
|
|
}
|
|
|
|
req := operationDocumentRequest(orgRef.Hex(), gatewayService, operationRef, op)
|
|
|
|
docResp, err := a.fetchOperationDocument(r.Context(), service.InvokeURI, req)
|
|
if err != nil {
|
|
a.logger.Warn("Failed to fetch operation document", zap.Error(err), mzap.ObjRef("organization_ref", orgRef), zap.String("gateway_service", string(gatewayService)), zap.String("operation_ref", operationRef))
|
|
return documentErrorResponse(a.logger, a.Name(), err)
|
|
}
|
|
|
|
return operationDocumentResponse(a.logger, a.Name(), docResp, fmt.Sprintf("operation_%s.pdf", sanitizeFilenameComponent(operationRef)))
|
|
}
|
|
|
|
func (a *PaymentAPI) authorizeDocumentDownload(r *http.Request, account *model.Account) (bson.ObjectID, 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 bson.NilObjectID, 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 bson.NilObjectID, response.Auto(a.logger, a.Name(), err)
|
|
}
|
|
if !allowed {
|
|
a.logger.Debug("Access denied when downloading document", mutil.PLog(a.oph, r))
|
|
return bson.NilObjectID, response.AccessDenied(a.logger, a.Name(), "payments read permission denied")
|
|
}
|
|
|
|
return orgRef, nil
|
|
}
|
|
|
|
func (a *PaymentAPI) resolveOperationDocumentDeps(ctx context.Context, gatewayService mservice.Type) (*discovery.ServiceSummary, *discovery.GatewaySummary, http.HandlerFunc) {
|
|
if a.discovery == nil {
|
|
return nil, nil, 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 nil, nil, response.Auto(a.logger, a.Name(), err)
|
|
}
|
|
|
|
service := findDocumentsService(lookupResp.Services)
|
|
if service == nil {
|
|
return nil, nil, response.Error(a.logger, a.Name(), http.StatusServiceUnavailable, "service_unavailable", "billing documents service unavailable")
|
|
}
|
|
|
|
gateway := findGatewayForService(lookupResp.Gateways, gatewayService)
|
|
if gateway == nil {
|
|
return nil, nil, response.Error(a.logger, a.Name(), http.StatusServiceUnavailable, "service_unavailable", "gateway service unavailable")
|
|
}
|
|
|
|
return service, gateway, nil
|
|
}
|
|
|
|
func operationDocumentResponse(logger mlogger.Logger, source mservice.Type, docResp *documentsv1.GetDocumentResponse, fallbackFilename string) http.HandlerFunc {
|
|
if docResp == nil || len(docResp.GetContent()) == 0 {
|
|
return response.Error(logger, source, http.StatusInternalServerError, "empty_document", "document service returned empty payload")
|
|
}
|
|
|
|
filename := strings.TrimSpace(docResp.GetFilename())
|
|
if filename == "" {
|
|
filename = strings.TrimSpace(fallbackFilename)
|
|
}
|
|
if filename == "" {
|
|
filename = "document.pdf"
|
|
}
|
|
|
|
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 _, err := w.Write(docResp.GetContent()); err != nil {
|
|
logger.Warn("Failed to write document response", zap.Error(err))
|
|
}
|
|
}
|
|
}
|
|
|
|
func normalizeGatewayService(raw string) mservice.Type {
|
|
value := strings.ToLower(strings.TrimSpace(raw))
|
|
if value == "" {
|
|
return ""
|
|
}
|
|
|
|
switch value {
|
|
case string(mservice.ChainGateway):
|
|
return mservice.ChainGateway
|
|
case string(mservice.TronGateway):
|
|
return mservice.TronGateway
|
|
case string(mservice.MntxGateway):
|
|
return mservice.MntxGateway
|
|
case string(mservice.PaymentGateway):
|
|
return mservice.PaymentGateway
|
|
case string(mservice.TgSettle):
|
|
return mservice.TgSettle
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func sanitizeFilenameComponent(value string) string {
|
|
trimmed := strings.TrimSpace(value)
|
|
if trimmed == "" {
|
|
return ""
|
|
}
|
|
|
|
var b strings.Builder
|
|
b.Grow(len(trimmed))
|
|
|
|
for _, r := range trimmed {
|
|
switch {
|
|
case r >= 'a' && r <= 'z':
|
|
b.WriteRune(r)
|
|
case r >= 'A' && r <= 'Z':
|
|
b.WriteRune(r)
|
|
case r >= '0' && r <= '9':
|
|
b.WriteRune(r)
|
|
case r == '-', r == '_':
|
|
b.WriteRune(r)
|
|
default:
|
|
b.WriteRune('_')
|
|
}
|
|
}
|
|
|
|
clean := strings.Trim(b.String(), "_")
|
|
if clean == "" {
|
|
return "operation"
|
|
}
|
|
|
|
return clean
|
|
}
|
|
|
|
func (a *PaymentAPI) fetchOperationDocument(ctx context.Context, invokeURI string, req *documentsv1.GetOperationDocumentRequest) (*documentsv1.GetDocumentResponse, error) {
|
|
conn, err := grpc.NewClient(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.GetOperationDocument(callCtx, req)
|
|
}
|
|
|
|
func (a *PaymentAPI) fetchGatewayOperation(ctx context.Context, invokeURI, operationRef string) (*connectorv1.Operation, error) {
|
|
conn, err := grpc.NewClient(invokeURI, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
|
if err != nil {
|
|
return nil, merrors.InternalWrap(err, "dial gateway connector")
|
|
}
|
|
defer conn.Close()
|
|
|
|
client := connectorv1.NewConnectorServiceClient(conn)
|
|
|
|
callCtx, callCancel := context.WithTimeout(ctx, gatewayCallTimeout)
|
|
defer callCancel()
|
|
|
|
resp, err := client.GetOperation(callCtx, &connectorv1.GetOperationRequest{OperationId: strings.TrimSpace(operationRef)})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
op := resp.GetOperation()
|
|
if op == nil {
|
|
return nil, merrors.NoData("gateway returned empty operation payload")
|
|
}
|
|
|
|
return op, nil
|
|
}
|
|
|
|
func findGatewayForService(gateways []discovery.GatewaySummary, gatewayService mservice.Type) *discovery.GatewaySummary {
|
|
candidates := make([]discovery.GatewaySummary, 0, len(gateways))
|
|
for _, gw := range gateways {
|
|
if !gw.Healthy || strings.TrimSpace(gw.InvokeURI) == "" {
|
|
continue
|
|
}
|
|
|
|
rail := discovery.NormalizeRail(gw.Rail)
|
|
network := strings.ToLower(strings.TrimSpace(gw.Network))
|
|
switch gatewayService {
|
|
case mservice.MntxGateway:
|
|
if rail == discovery.NormalizeRail(discovery.RailCardPayout) {
|
|
candidates = append(candidates, gw)
|
|
}
|
|
case mservice.PaymentGateway, mservice.TgSettle:
|
|
if rail == discovery.NormalizeRail(discovery.RailProviderSettlement) {
|
|
candidates = append(candidates, gw)
|
|
}
|
|
case mservice.TronGateway:
|
|
if rail == discovery.NormalizeRail(discovery.RailCrypto) && strings.Contains(network, "tron") {
|
|
candidates = append(candidates, gw)
|
|
}
|
|
case mservice.ChainGateway:
|
|
if rail == discovery.NormalizeRail(discovery.RailCrypto) && !strings.Contains(network, "tron") {
|
|
candidates = append(candidates, gw)
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(candidates) == 0 && gatewayService == mservice.ChainGateway {
|
|
for _, gw := range gateways {
|
|
if gw.Healthy && strings.TrimSpace(gw.InvokeURI) != "" && discovery.NormalizeRail(gw.Rail) == discovery.NormalizeRail(discovery.RailCrypto) {
|
|
candidates = append(candidates, gw)
|
|
}
|
|
}
|
|
}
|
|
if len(candidates) == 0 {
|
|
return nil
|
|
}
|
|
|
|
best := candidates[0]
|
|
for _, candidate := range candidates[1:] {
|
|
if candidate.RoutingPriority > best.RoutingPriority {
|
|
best = candidate
|
|
}
|
|
}
|
|
|
|
return &best
|
|
}
|
|
|
|
func operationDocumentRequest(organizationRef string, gatewayService mservice.Type, requestedOperationRef string, op *connectorv1.Operation) *documentsv1.GetOperationDocumentRequest {
|
|
req := &documentsv1.GetOperationDocumentRequest{
|
|
OrganizationRef: strings.TrimSpace(organizationRef),
|
|
GatewayService: string(gatewayService),
|
|
OperationRef: firstNonEmpty(strings.TrimSpace(op.GetOperationRef()), strings.TrimSpace(requestedOperationRef)),
|
|
OperationCode: strings.TrimSpace(op.GetType().String()),
|
|
OperationLabel: operationLabel(op.GetType()),
|
|
OperationState: strings.TrimSpace(op.GetStatus().String()),
|
|
Amount: strings.TrimSpace(op.GetMoney().GetAmount()),
|
|
Currency: strings.TrimSpace(op.GetMoney().GetCurrency()),
|
|
}
|
|
|
|
if ts := op.GetCreatedAt(); ts != nil {
|
|
req.StartedAtUnixMs = ts.AsTime().UnixMilli()
|
|
}
|
|
if ts := op.GetUpdatedAt(); ts != nil {
|
|
req.CompletedAtUnixMs = ts.AsTime().UnixMilli()
|
|
}
|
|
|
|
req.PaymentRef = operationParamValue(op.GetParams(), "payment_ref", "parent_payment_ref", "paymentRef", "parentPaymentRef")
|
|
req.FailureCode = firstNonEmpty(
|
|
operationParamValue(op.GetParams(), "failure_code", "provider_code", "error_code"),
|
|
failureCodeFromStatus(op.GetStatus()),
|
|
)
|
|
req.FailureReason = operationParamValue(op.GetParams(), "failure_reason", "provider_message", "error", "message")
|
|
|
|
return req
|
|
}
|
|
|
|
func operationLabel(opType connectorv1.OperationType) string {
|
|
switch opType {
|
|
case connectorv1.OperationType_CREDIT:
|
|
return "Credit"
|
|
case connectorv1.OperationType_DEBIT:
|
|
return "Debit"
|
|
case connectorv1.OperationType_TRANSFER:
|
|
return "Transfer"
|
|
case connectorv1.OperationType_PAYOUT:
|
|
return "Payout"
|
|
case connectorv1.OperationType_FEE_ESTIMATE:
|
|
return "Fee Estimate"
|
|
case connectorv1.OperationType_FX:
|
|
return "FX"
|
|
case connectorv1.OperationType_GAS_TOPUP:
|
|
return "Gas Top Up"
|
|
default:
|
|
return strings.TrimSpace(opType.String())
|
|
}
|
|
}
|
|
|
|
func failureCodeFromStatus(status connectorv1.OperationStatus) string {
|
|
switch status {
|
|
case connectorv1.OperationStatus_OPERATION_FAILED, connectorv1.OperationStatus_OPERATION_CANCELLED:
|
|
return strings.TrimSpace(status.String())
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func operationParamValue(params *structpb.Struct, keys ...string) string {
|
|
if params == nil {
|
|
return ""
|
|
}
|
|
|
|
values := params.AsMap()
|
|
for _, key := range keys {
|
|
raw, ok := values[key]
|
|
if !ok {
|
|
continue
|
|
}
|
|
if text := strings.TrimSpace(fmt.Sprint(raw)); text != "" && text != "<nil>" {
|
|
return text
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|