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) { 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.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) } }