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 != "" { 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) } }