diff --git a/api/billing/documents/internal/service/documents/service.go b/api/billing/documents/internal/service/documents/service.go index 5d9f9e69..e3320886 100644 --- a/api/billing/documents/internal/service/documents/service.go +++ b/api/billing/documents/internal/service/documents/service.go @@ -4,7 +4,6 @@ import ( "context" "crypto/sha256" "encoding/hex" - "errors" "fmt" "path/filepath" "strings" @@ -148,18 +147,17 @@ func (s *Service) Shutdown() { func (s *Service) BatchResolveDocuments(ctx context.Context, req *documentsv1.BatchResolveDocumentsRequest) (resp *documentsv1.BatchResolveDocumentsResponse, err error) { start := time.Now() - - var paymentRefs []string + paymentRefs := 0 if req != nil { - paymentRefs = req.GetPaymentRefs() + paymentRefs = len(req.GetPaymentRefs()) } - logger := s.logger.With(zap.Int("payment_refs", len(paymentRefs))) + logger := s.logger.With(zap.Int("payment_refs", paymentRefs)) defer func() { statusLabel := statusFromError(err) observeRequest("batch_resolve", documentsv1.DocumentType_DOCUMENT_TYPE_UNSPECIFIED, statusLabel, time.Since(start)) - observeBatchSize(len(paymentRefs)) + observeBatchSize(paymentRefs) itemsCount := 0 if resp != nil { @@ -181,80 +179,16 @@ func (s *Service) BatchResolveDocuments(ctx context.Context, req *documentsv1.Ba logger.Info("BatchResolveDocuments finished", fields...) }() - if len(paymentRefs) == 0 { - resp = &documentsv1.BatchResolveDocumentsResponse{} + _ = ctx + err = status.Error(codes.Unimplemented, "payment-level document flow removed; use GetOperationDocument") - return resp, nil - } - - if s.storage == nil { - err = status.Error(codes.Unavailable, errStorageUnavailable.Error()) - - return nil, err - } - - refs := make([]string, 0, len(paymentRefs)) - for _, ref := range paymentRefs { - clean := strings.TrimSpace(ref) - - if clean == "" { - continue - } - - refs = append(refs, clean) - } - - if len(refs) == 0 { - resp = &documentsv1.BatchResolveDocumentsResponse{} - - return resp, nil - } - - records, err := s.storage.Documents().ListByPaymentRefs(ctx, refs) - if err != nil { - return nil, status.Error(codes.Internal, err.Error()) - } - - recordByRef := map[string]*model.DocumentRecord{} - - for _, record := range records { - if record == nil { - continue - } - - recordByRef[record.PaymentRef] = record - } - - items := make([]*documentsv1.DocumentMeta, 0, len(refs)) - for _, ref := range refs { - meta := &documentsv1.DocumentMeta{PaymentRef: ref} - if record := recordByRef[ref]; record != nil { - record.Normalize() - - available := []model.DocumentType{model.DocumentTypeAct} - - ready := make([]model.DocumentType, 0, 1) - if path, ok := record.StoragePaths[model.DocumentTypeAct]; ok && path != "" { - ready = append(ready, model.DocumentTypeAct) - } - - meta.AvailableTypes = toProtoTypes(available) - meta.ReadyTypes = toProtoTypes(ready) - } - - items = append(items, meta) - } - - resp = &documentsv1.BatchResolveDocumentsResponse{Items: items} - - return resp, nil + return nil, err } func (s *Service) GetDocument(ctx context.Context, req *documentsv1.GetDocumentRequest) (resp *documentsv1.GetDocumentResponse, err error) { start := time.Now() docType := documentsv1.DocumentType_DOCUMENT_TYPE_UNSPECIFIED paymentRef := "" - if req != nil { docType = req.GetType() paymentRef = strings.TrimSpace(req.GetPaymentRef()) @@ -293,92 +227,94 @@ func (s *Service) GetDocument(ctx context.Context, req *documentsv1.GetDocumentR logger.Info("GetDocument finished", fields...) }() - if paymentRef == "" { - err = status.Error(codes.InvalidArgument, "payment_ref is required") + _ = ctx + err = status.Error(codes.Unimplemented, "payment-level document flow removed; use GetOperationDocument") - return nil, err + return nil, err +} + +func (s *Service) GetOperationDocument(_ context.Context, req *documentsv1.GetOperationDocumentRequest) (resp *documentsv1.GetDocumentResponse, err error) { + start := time.Now() + organizationRef := "" + gatewayService := "" + operationRef := "" + + if req != nil { + organizationRef = strings.TrimSpace(req.GetOrganizationRef()) + gatewayService = strings.TrimSpace(req.GetGatewayService()) + operationRef = strings.TrimSpace(req.GetOperationRef()) } - if docType == documentsv1.DocumentType_DOCUMENT_TYPE_UNSPECIFIED { - err = status.Error(codes.InvalidArgument, "document type is required") + logger := s.logger.With( + zap.String("organization_ref", organizationRef), + zap.String("gateway_service", gatewayService), + zap.String("operation_ref", operationRef), + ) - return nil, err - } + defer func() { + statusLabel := statusFromError(err) + docType := documentsv1.DocumentType_DOCUMENT_TYPE_UNSPECIFIED + observeRequest("get_operation_document", docType, statusLabel, time.Since(start)) - if s.storage == nil { - err = status.Error(codes.Unavailable, errStorageUnavailable.Error()) - - return nil, err - } - - if s.docStore == nil { - err = status.Error(codes.Unavailable, errDocStoreUnavailable.Error()) - - return nil, err - } - - if s.template == nil { - err = status.Error(codes.FailedPrecondition, errTemplateUnavailable.Error()) - - return nil, err - } - - record, err := s.storage.Documents().GetByPaymentRef(ctx, paymentRef) - if err != nil { - if errors.Is(err, storage.ErrDocumentNotFound) { - return nil, status.Error(codes.NotFound, "document record not found") + if resp != nil { + observeDocumentBytes(docType, len(resp.GetContent())) } - return nil, status.Error(codes.Internal, err.Error()) - } - - record.Normalize() - - targetType := model.DocumentTypeFromProto(docType) - - if docType != documentsv1.DocumentType_DOCUMENT_TYPE_ACT { - return nil, status.Error(codes.Unimplemented, "document type not implemented") - } - - if path, ok := record.StoragePaths[targetType]; ok && path != "" { - content, loadErr := s.docStore.Load(ctx, path) - if loadErr != nil { - return nil, status.Error(codes.Internal, loadErr.Error()) + contentBytes := 0 + if resp != nil { + contentBytes = len(resp.GetContent()) } - return &documentsv1.GetDocumentResponse{ - Content: content, - Filename: documentFilename(docType, paymentRef), - MimeType: "application/pdf", - }, nil + fields := []zap.Field{ + zap.String("status", statusLabel), + zap.Duration("duration", time.Since(start)), + zap.Int("content_bytes", contentBytes), + } + + if err != nil { + logger.Warn("GetOperationDocument failed", append(fields, zap.Error(err))...) + + return + } + + logger.Info("GetOperationDocument finished", fields...) + }() + + if req == nil { + err = status.Error(codes.InvalidArgument, "request is required") + + return nil, err } - content, hash, genErr := s.generateActPDF(record.Snapshot) + if organizationRef == "" { + err = status.Error(codes.InvalidArgument, "organization_ref is required") + + return nil, err + } + + if gatewayService == "" { + err = status.Error(codes.InvalidArgument, "gateway_service is required") + + return nil, err + } + + if operationRef == "" { + err = status.Error(codes.InvalidArgument, "operation_ref is required") + + return nil, err + } + + snapshot := operationSnapshotFromRequest(req) + content, _, genErr := s.generateOperationPDF(snapshot) if genErr != nil { - logger.Warn("Failed to generate document", zap.Error(genErr)) + err = status.Error(codes.Internal, genErr.Error()) - return nil, status.Error(codes.Internal, genErr.Error()) - } - - path := documentStoragePath(paymentRef, docType) - if saveErr := s.docStore.Save(ctx, path, content); saveErr != nil { - logger.Warn("Failed to store document", zap.Error(saveErr)) - - return nil, status.Error(codes.Internal, saveErr.Error()) - } - - record.StoragePaths[targetType] = path - record.Hashes[targetType] = hash - - if updateErr := s.storage.Documents().Update(ctx, record); updateErr != nil { - logger.Warn("Failed to update document record", zap.Error(updateErr)) - - return nil, status.Error(codes.Internal, updateErr.Error()) + return nil, err } resp = &documentsv1.GetDocumentResponse{ Content: content, - Filename: documentFilename(docType, paymentRef), + Filename: operationDocumentFilename(operationRef), MimeType: "application/pdf", } @@ -392,7 +328,7 @@ func (s *Service) startDiscoveryAnnouncer() { announce := discovery.Announcement{ Service: mservice.BillingDocuments, - Operations: []string{discovery.OperationDocumentsBatchResolve, discovery.OperationDocumentsGet}, + Operations: []string{discovery.OperationDocumentsGet}, InvokeURI: s.invokeURI, Version: appversion.Create().Short(), } @@ -418,10 +354,19 @@ func (s *Service) generateActPDF(snapshot model.ActSnapshot) ([]byte, string, er return nil, "", err } + return s.renderPDFWithIntegrity(blocks) +} + +func (s *Service) generateOperationPDF(snapshot operationSnapshot) ([]byte, string, error) { + return s.renderPDFWithIntegrity(buildOperationBlocks(snapshot)) +} + +func (s *Service) renderPDFWithIntegrity(blocks []renderer.Block) ([]byte, string, error) { generated := renderer.Renderer{ Issuer: s.config.Issuer, OwnerPassword: s.config.Protection.OwnerPassword, } + placeholder := strings.Repeat("0", 64) firstPass, err := generated.Render(blocks, placeholder) @@ -440,6 +385,157 @@ func (s *Service) generateActPDF(snapshot model.ActSnapshot) ([]byte, string, er return finalBytes, footerHex, nil } +type operationSnapshot struct { + OrganizationRef string + GatewayService string + OperationRef string + PaymentRef string + OperationCode string + OperationLabel string + OperationState string + FailureCode string + FailureReason string + Amount string + Currency string + StartedAt time.Time + CompletedAt time.Time +} + +func operationSnapshotFromRequest(req *documentsv1.GetOperationDocumentRequest) operationSnapshot { + snapshot := operationSnapshot{ + OrganizationRef: strings.TrimSpace(req.GetOrganizationRef()), + GatewayService: strings.TrimSpace(req.GetGatewayService()), + OperationRef: strings.TrimSpace(req.GetOperationRef()), + PaymentRef: strings.TrimSpace(req.GetPaymentRef()), + OperationCode: strings.TrimSpace(req.GetOperationCode()), + OperationLabel: strings.TrimSpace(req.GetOperationLabel()), + OperationState: strings.TrimSpace(req.GetOperationState()), + FailureCode: strings.TrimSpace(req.GetFailureCode()), + FailureReason: strings.TrimSpace(req.GetFailureReason()), + Amount: strings.TrimSpace(req.GetAmount()), + Currency: strings.TrimSpace(req.GetCurrency()), + } + + if ts := req.GetStartedAtUnixMs(); ts > 0 { + snapshot.StartedAt = time.UnixMilli(ts).UTC() + } + if ts := req.GetCompletedAtUnixMs(); ts > 0 { + snapshot.CompletedAt = time.UnixMilli(ts).UTC() + } + + return snapshot +} + +func buildOperationBlocks(snapshot operationSnapshot) []renderer.Block { + rows := [][]string{ + {"Organization", snapshot.OrganizationRef}, + {"Gateway Service", snapshot.GatewayService}, + {"Operation Ref", snapshot.OperationRef}, + {"Payment Ref", safeValue(snapshot.PaymentRef)}, + {"Code", safeValue(snapshot.OperationCode)}, + {"State", safeValue(snapshot.OperationState)}, + {"Label", safeValue(snapshot.OperationLabel)}, + {"Started At (UTC)", formatSnapshotTime(snapshot.StartedAt)}, + {"Completed At (UTC)", formatSnapshotTime(snapshot.CompletedAt)}, + } + if snapshot.Amount != "" || snapshot.Currency != "" { + rows = append(rows, []string{"Amount", strings.TrimSpace(strings.TrimSpace(snapshot.Amount) + " " + strings.TrimSpace(snapshot.Currency))}) + } + + blocks := []renderer.Block{ + { + Tag: renderer.TagTitle, + Lines: []string{"OPERATION BILLING DOCUMENT"}, + }, + { + Tag: renderer.TagSubtitle, + Lines: []string{"Gateway operation statement"}, + }, + { + Tag: renderer.TagMeta, + Lines: []string{ + "Document Type: Operation", + }, + }, + { + Tag: renderer.TagSection, + Lines: []string{"OPERATION DETAILS"}, + }, + { + Tag: renderer.TagKV, + Rows: rows, + }, + } + + if snapshot.FailureCode != "" || snapshot.FailureReason != "" { + blocks = append(blocks, + renderer.Block{Tag: renderer.TagSection, Lines: []string{"FAILURE DETAILS"}}, + renderer.Block{ + Tag: renderer.TagKV, + Rows: [][]string{ + {"Failure Code", safeValue(snapshot.FailureCode)}, + {"Failure Reason", safeValue(snapshot.FailureReason)}, + }, + }, + ) + } + + return blocks +} + +func formatSnapshotTime(value time.Time) string { + if value.IsZero() { + return "n/a" + } + + return value.UTC().Format(time.RFC3339) +} + +func safeValue(value string) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "n/a" + } + + return trimmed +} + +func operationDocumentFilename(operationRef string) string { + clean := sanitizeFilenameComponent(operationRef) + if clean == "" { + clean = "operation" + } + + return fmt.Sprintf("operation_%s.pdf", clean) +} + +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('_') + } + } + + return strings.Trim(b.String(), "_") +} + func toProtoTypes(types []model.DocumentType) []documentsv1.DocumentType { if len(types) == 0 { return nil diff --git a/api/billing/documents/internal/service/documents/service_test.go b/api/billing/documents/internal/service/documents/service_test.go index 18d78b53..4ab5b882 100644 --- a/api/billing/documents/internal/service/documents/service_test.go +++ b/api/billing/documents/internal/service/documents/service_test.go @@ -12,6 +12,8 @@ import ( "github.com/tech/sendico/billing/documents/storage/model" documentsv1 "github.com/tech/sendico/pkg/proto/billing/documents/v1" "go.uber.org/zap" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" ) type stubRepo struct { @@ -94,9 +96,7 @@ func (s *stubTemplate) Render(_ model.ActSnapshot) ([]renderer.Block, error) { return s.blocks, nil } -func TestGetDocument_IdempotentAndHashed(t *testing.T) { - ctx := context.Background() - +func TestGenerateActPDF_IdempotentAndHashed(t *testing.T) { snapshot := model.ActSnapshot{ PaymentID: "PAY-123", Date: time.Date(2026, 1, 30, 0, 0, 0, 0, time.UTC), @@ -105,14 +105,6 @@ func TestGetDocument_IdempotentAndHashed(t *testing.T) { Currency: "USD", } - record := &model.DocumentRecord{ - PaymentRef: "PAY-123", - Snapshot: snapshot, - } - - documentsStore := &stubDocumentsStore{record: record} - repo := &stubRepo{store: documentsStore} - store := newMemDocStore() tmpl := &stubTemplate{ blocks: []renderer.Block{ {Tag: renderer.TagTitle, Lines: []string{"ACT"}}, @@ -127,62 +119,47 @@ func TestGetDocument_IdempotentAndHashed(t *testing.T) { }, } - svc := NewService(zap.NewNop(), repo, nil, + svc := NewService(zap.NewNop(), nil, nil, WithConfig(cfg), - WithDocumentStore(store), WithTemplateRenderer(tmpl), ) - resp1, err := svc.GetDocument(ctx, &documentsv1.GetDocumentRequest{ - PaymentRef: "PAY-123", - Type: documentsv1.DocumentType_DOCUMENT_TYPE_ACT, - }) + pdf1, hash1, err := svc.generateActPDF(snapshot) if err != nil { - t.Fatalf("GetDocument first call: %v", err) + t.Fatalf("generateActPDF first call: %v", err) } - if len(resp1.GetContent()) == 0 { + if len(pdf1) == 0 { t.Fatalf("expected content on first call") } - stored := record.Hashes[model.DocumentTypeAct] - - if stored == "" { - t.Fatalf("expected stored hash") + if hash1 == "" { + t.Fatalf("expected non-empty hash on first call") } - footerHash := extractFooterHash(resp1.GetContent()) + footerHash := extractFooterHash(pdf1) if footerHash == "" { t.Fatalf("expected footer hash in PDF") } - if stored != footerHash { - t.Fatalf("stored hash mismatch: got %s", stored) + if hash1 != footerHash { + t.Fatalf("stored hash mismatch: got %s", hash1) } - resp2, err := svc.GetDocument(ctx, &documentsv1.GetDocumentRequest{ - PaymentRef: "PAY-123", - Type: documentsv1.DocumentType_DOCUMENT_TYPE_ACT, - }) + pdf2, hash2, err := svc.generateActPDF(snapshot) if err != nil { - t.Fatalf("GetDocument second call: %v", err) + t.Fatalf("generateActPDF second call: %v", err) } - - if !bytes.Equal(resp1.GetContent(), resp2.GetContent()) { - t.Fatalf("expected identical PDF bytes on second call") + if hash2 == "" { + t.Fatalf("expected non-empty hash on second call") } - - if tmpl.calls != 1 { - t.Fatalf("expected template to be rendered once, got %d", tmpl.calls) + footerHash2 := extractFooterHash(pdf2) + if footerHash2 == "" { + t.Fatalf("expected footer hash in second PDF") } - - if store.saveCount != 1 { - t.Fatalf("expected document save once, got %d", store.saveCount) - } - - if store.loadCount == 0 { - t.Fatalf("expected document load on second call") + if footerHash2 != hash2 { + t.Fatalf("second hash mismatch: got=%s want=%s", footerHash2, hash2) } } @@ -212,3 +189,48 @@ func extractFooterHash(pdf []byte) string { func isHexDigit(b byte) bool { return (b >= '0' && b <= '9') || (b >= 'a' && b <= 'f') || (b >= 'A' && b <= 'F') } + +func TestGetOperationDocument_GeneratesPDF(t *testing.T) { + svc := NewService(zap.NewNop(), nil, nil, WithConfig(Config{ + Issuer: renderer.Issuer{ + LegalName: "Sendico Ltd", + }, + })) + + resp, err := svc.GetOperationDocument(context.Background(), &documentsv1.GetOperationDocumentRequest{ + OrganizationRef: "org-1", + GatewayService: "chain_gateway", + OperationRef: "pay-1:step-1", + PaymentRef: "pay-1", + OperationCode: "crypto.transfer", + OperationLabel: "Outbound transfer", + OperationState: "completed", + Amount: "100.50", + Currency: "USDT", + StartedAtUnixMs: time.Date(2026, 3, 4, 10, 0, 0, 0, time.UTC).UnixMilli(), + }) + if err != nil { + t.Fatalf("GetOperationDocument failed: %v", err) + } + if len(resp.GetContent()) == 0 { + t.Fatalf("expected non-empty PDF content") + } + if got, want := resp.GetMimeType(), "application/pdf"; got != want { + t.Fatalf("mime_type mismatch: got=%q want=%q", got, want) + } + if got, want := resp.GetFilename(), "operation_pay-1_step-1.pdf"; got != want { + t.Fatalf("filename mismatch: got=%q want=%q", got, want) + } +} + +func TestGetOperationDocument_RequiresOperationRef(t *testing.T) { + svc := NewService(zap.NewNop(), nil, nil) + + _, err := svc.GetOperationDocument(context.Background(), &documentsv1.GetOperationDocumentRequest{ + OrganizationRef: "org-1", + GatewayService: "chain_gateway", + }) + if status.Code(err) != codes.InvalidArgument { + t.Fatalf("expected InvalidArgument, got=%v err=%v", status.Code(err), err) + } +} diff --git a/api/edge/bff/internal/server/paymentapiimp/documents.go b/api/edge/bff/internal/server/paymentapiimp/documents.go index 8496a57c..0aa9c4f6 100644 --- a/api/edge/bff/internal/server/paymentapiimp/documents.go +++ b/api/edge/bff/internal/server/paymentapiimp/documents.go @@ -15,6 +15,7 @@ import ( "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" @@ -23,43 +24,90 @@ import ( "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 - documentsDialTimeout = 5 * time.Second documentsCallTimeout = 10 * time.Second + gatewayCallTimeout = 10 * time.Second ) -func (a *PaymentAPI) getActDocument(r *http.Request, account *model.Account, _ *sresponse.TokenData) http.HandlerFunc { +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 response.BadReference(a.logger, a.Name(), a.oph.Name(), a.oph.GetID(r), err) + 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 response.Auto(a.logger, a.Name(), err) + return bson.NilObjectID, 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") + 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") } - 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") - } + return orgRef, nil +} +func (a *PaymentAPI) resolveOperationDocumentDeps(ctx context.Context, gatewayService mservice.Type) (*discovery.ServiceSummary, *discovery.GatewaySummary, http.HandlerFunc) { if a.discovery == nil { - return response.Error(a.logger, a.Name(), http.StatusServiceUnavailable, "service_unavailable", "discovery client is not configured") + return nil, nil, response.Error(a.logger, a.Name(), http.StatusServiceUnavailable, "service_unavailable", "discovery client is not configured") } lookupCtx, cancel := context.WithTimeout(ctx, discoveryLookupTimeout) @@ -68,27 +116,35 @@ func (a *PaymentAPI) getActDocument(r *http.Request, account *model.Account, _ * 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) + return nil, nil, 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") + return nil, nil, 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) + gateway := findGatewayForService(lookupResp.Gateways, gatewayService) + if gateway == nil { + return nil, nil, response.Error(a.logger, a.Name(), http.StatusServiceUnavailable, "service_unavailable", "gateway service unavailable") } - if len(docResp.GetContent()) == 0 { - return response.Error(a.logger, a.Name(), http.StatusInternalServerError, "empty_document", "document service returned empty payload") + + 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 = fmt.Sprintf("act_%s.pdf", paymentRef) + filename = strings.TrimSpace(fallbackFilename) } + if filename == "" { + filename = "document.pdf" + } + mimeType := strings.TrimSpace(docResp.GetMimeType()) if mimeType == "" { mimeType = "application/pdf" @@ -98,13 +154,67 @@ func (a *PaymentAPI) getActDocument(r *http.Request, account *model.Account, _ * 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)) + if _, err := w.Write(docResp.GetContent()); err != nil { + logger.Warn("Failed to write document response", zap.Error(err)) } } } -func (a *PaymentAPI) fetchActDocument(ctx context.Context, invokeURI, paymentRef string) (*documentsv1.GetDocumentResponse, error) { +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") @@ -116,10 +226,160 @@ func (a *PaymentAPI) fetchActDocument(ctx context.Context, invokeURI, paymentRef callCtx, callCancel := context.WithTimeout(ctx, documentsCallTimeout) defer callCancel() - return client.GetDocument(callCtx, &documentsv1.GetDocumentRequest{ - PaymentRef: paymentRef, - Type: documentsv1.DocumentType_DOCUMENT_TYPE_ACT, - }) + 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 { diff --git a/api/edge/bff/internal/server/paymentapiimp/service.go b/api/edge/bff/internal/server/paymentapiimp/service.go index 2e04706e..952d4683 100644 --- a/api/edge/bff/internal/server/paymentapiimp/service.go +++ b/api/edge/bff/internal/server/paymentapiimp/service.go @@ -106,7 +106,7 @@ func CreateAPI(apiCtx eapi.API) (*PaymentAPI, error) { 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("/documents/operation"), api.Get, p.getOperationDocument) 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) diff --git a/api/gateway/chain/internal/service/gateway/connector.go b/api/gateway/chain/internal/service/gateway/connector.go index 788bc39b..d936f8d2 100644 --- a/api/gateway/chain/internal/service/gateway/connector.go +++ b/api/gateway/chain/internal/service/gateway/connector.go @@ -8,6 +8,7 @@ import ( "github.com/tech/sendico/gateway/chain/internal/appversion" "github.com/tech/sendico/gateway/chain/internal/service/gateway/shared" + chainstoragemodel "github.com/tech/sendico/gateway/chain/storage/model" chainasset "github.com/tech/sendico/pkg/chain" "github.com/tech/sendico/pkg/connector/params" "github.com/tech/sendico/pkg/merrors" @@ -17,6 +18,7 @@ import ( chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" "go.uber.org/zap" "google.golang.org/protobuf/types/known/structpb" + "google.golang.org/protobuf/types/known/timestamppb" ) const chainConnectorID = "chain" @@ -293,11 +295,21 @@ func (s *Service) GetOperation(ctx context.Context, req *connectorv1.GetOperatio if req == nil || strings.TrimSpace(req.GetOperationId()) == "" { return nil, merrors.InvalidArgument("get_operation: operation_id is required") } - resp, err := s.GetTransfer(ctx, &chainv1.GetTransferRequest{TransferRef: strings.TrimSpace(req.GetOperationId())}) + + operationRef := strings.TrimSpace(req.GetOperationId()) + if s.storage == nil || s.storage.Transfers() == nil { + return nil, merrors.Internal("get_operation: storage is not configured") + } + + transfer, err := s.storage.Transfers().FindByOperationRef(ctx, "", operationRef) if err != nil { return nil, err } - return &connectorv1.GetOperationResponse{Operation: chainTransferToOperation(resp.GetTransfer())}, nil + if transfer == nil { + return nil, merrors.NoData("transfer not found") + } + + return &connectorv1.GetOperationResponse{Operation: chainTransferToOperation(storageTransferToProto(transfer))}, nil } func (s *Service) ListOperations(ctx context.Context, req *connectorv1.ListOperationsRequest) (*connectorv1.ListOperationsResponse, error) { @@ -493,6 +505,61 @@ func feeEstimateResult(resp *chainv1.EstimateTransferFeeResponse) *structpb.Stru return result } +func storageTransferToProto(transfer *chainstoragemodel.Transfer) *chainv1.Transfer { + if transfer == nil { + return nil + } + + destination := &chainv1.TransferDestination{Memo: strings.TrimSpace(transfer.Destination.Memo)} + if managedWalletRef := strings.TrimSpace(transfer.Destination.ManagedWalletRef); managedWalletRef != "" { + destination.Destination = &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: managedWalletRef} + } else if externalAddress := strings.TrimSpace(transfer.Destination.ExternalAddress); externalAddress != "" { + destination.Destination = &chainv1.TransferDestination_ExternalAddress{ExternalAddress: externalAddress} + } + + fees := make([]*chainv1.ServiceFeeBreakdown, 0, len(transfer.Fees)) + for _, fee := range transfer.Fees { + fees = append(fees, &chainv1.ServiceFeeBreakdown{ + FeeCode: strings.TrimSpace(fee.FeeCode), + Amount: fee.Amount, + Description: strings.TrimSpace(fee.Description), + }) + } + + asset := &chainv1.Asset{ + Chain: shared.ChainEnumFromName(transfer.Network), + TokenSymbol: strings.TrimSpace(transfer.TokenSymbol), + ContractAddress: strings.TrimSpace(transfer.ContractAddress), + } + + protoTransfer := &chainv1.Transfer{ + TransferRef: strings.TrimSpace(transfer.TransferRef), + IdempotencyKey: strings.TrimSpace(transfer.IdempotencyKey), + IntentRef: strings.TrimSpace(transfer.IntentRef), + OperationRef: strings.TrimSpace(transfer.OperationRef), + OrganizationRef: strings.TrimSpace(transfer.OrganizationRef), + SourceWalletRef: strings.TrimSpace(transfer.SourceWalletRef), + Destination: destination, + Asset: asset, + RequestedAmount: shared.MonenyToProto(transfer.RequestedAmount), + NetAmount: shared.MonenyToProto(transfer.NetAmount), + Fees: fees, + Status: shared.TransferStatusToProto(transfer.Status), + TransactionHash: strings.TrimSpace(transfer.TxHash), + FailureReason: strings.TrimSpace(transfer.FailureReason), + PaymentRef: strings.TrimSpace(transfer.PaymentRef), + } + + if !transfer.CreatedAt.IsZero() { + protoTransfer.CreatedAt = timestamppb.New(transfer.CreatedAt.UTC()) + } + if !transfer.UpdatedAt.IsZero() { + protoTransfer.UpdatedAt = timestamppb.New(transfer.UpdatedAt.UTC()) + } + + return protoTransfer +} + func gasTopUpResult(amount *moneyv1.Money, capHit bool, transferRef string) *structpb.Struct { payload := map[string]interface{}{ "cap_hit": capHit, @@ -518,18 +585,33 @@ func chainTransferToOperation(transfer *chainv1.Transfer) *connectorv1.Operation return nil } op := &connectorv1.Operation{ - OperationId: strings.TrimSpace(transfer.GetTransferRef()), - Type: connectorv1.OperationType_TRANSFER, - Status: chainTransferStatusToOperation(transfer.GetStatus()), - Money: transfer.GetRequestedAmount(), - ProviderRef: strings.TrimSpace(transfer.GetTransactionHash()), - CreatedAt: transfer.GetCreatedAt(), - UpdatedAt: transfer.GetUpdatedAt(), + OperationId: strings.TrimSpace(transfer.GetTransferRef()), + Type: connectorv1.OperationType_TRANSFER, + Status: chainTransferStatusToOperation(transfer.GetStatus()), + Money: transfer.GetRequestedAmount(), + ProviderRef: strings.TrimSpace(transfer.GetTransactionHash()), + IntentRef: strings.TrimSpace(transfer.GetIntentRef()), + OperationRef: strings.TrimSpace(transfer.GetOperationRef()), + CreatedAt: transfer.GetCreatedAt(), + UpdatedAt: transfer.GetUpdatedAt(), From: &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{ ConnectorId: chainConnectorID, AccountId: strings.TrimSpace(transfer.GetSourceWalletRef()), }}}, } + params := map[string]interface{}{} + if paymentRef := strings.TrimSpace(transfer.GetPaymentRef()); paymentRef != "" { + params["payment_ref"] = paymentRef + } + if organizationRef := strings.TrimSpace(transfer.GetOrganizationRef()); organizationRef != "" { + params["organization_ref"] = organizationRef + } + if failureReason := strings.TrimSpace(transfer.GetFailureReason()); failureReason != "" { + params["failure_reason"] = failureReason + } + if len(params) > 0 { + op.Params = structFromMap(params) + } if dest := transfer.GetDestination(); dest != nil { switch d := dest.GetDestination().(type) { case *chainv1.TransferDestination_ManagedWalletRef: @@ -629,6 +711,17 @@ func operationAccountID(party *connectorv1.OperationParty) string { return "" } +func structFromMap(values map[string]interface{}) *structpb.Struct { + if len(values) == 0 { + return nil + } + result, err := structpb.NewStruct(values) + if err != nil { + return nil + } + return result +} + func connectorError(code connectorv1.ErrorCode, message string, op *connectorv1.Operation, accountID string) *connectorv1.ConnectorError { err := &connectorv1.ConnectorError{ Code: code, diff --git a/api/gateway/chain/internal/service/gateway/service_test.go b/api/gateway/chain/internal/service/gateway/service_test.go index dfb06391..32c03d5b 100644 --- a/api/gateway/chain/internal/service/gateway/service_test.go +++ b/api/gateway/chain/internal/service/gateway/service_test.go @@ -500,6 +500,32 @@ func (t *inMemoryTransfers) Get(ctx context.Context, transferRef string) (*model return transfer, nil } +func (t *inMemoryTransfers) FindByOperationRef(ctx context.Context, organizationRef, operationRef string) (*model.Transfer, error) { + t.mu.Lock() + defer t.mu.Unlock() + + org := strings.TrimSpace(organizationRef) + opRef := strings.TrimSpace(operationRef) + if opRef == "" { + return nil, merrors.InvalidArgument("transfersStore: empty operationRef") + } + + for _, transfer := range t.items { + if transfer == nil { + continue + } + if !strings.EqualFold(strings.TrimSpace(transfer.OperationRef), opRef) { + continue + } + if org != "" && !strings.EqualFold(strings.TrimSpace(transfer.OrganizationRef), org) { + continue + } + return transfer, nil + } + + return nil, merrors.NoData("transfer not found") +} + func (t *inMemoryTransfers) List(ctx context.Context, filter model.TransferFilter) (*model.TransferList, error) { t.mu.Lock() defer t.mu.Unlock() diff --git a/api/gateway/chain/storage/mongo/store/transfers.go b/api/gateway/chain/storage/mongo/store/transfers.go index d0062a88..f3ec70c7 100644 --- a/api/gateway/chain/storage/mongo/store/transfers.go +++ b/api/gateway/chain/storage/mongo/store/transfers.go @@ -40,6 +40,9 @@ func NewTransfers(logger mlogger.Logger, db *mongo.Database) (*Transfers, error) Keys: []ri.Key{{Field: "transferRef", Sort: ri.Asc}}, Unique: true, }, + { + Keys: []ri.Key{{Field: "organizationRef", Sort: ri.Asc}, {Field: "operationRef", Sort: ri.Asc}}, + }, { Keys: []ri.Key{{Field: "idempotencyKey", Sort: ri.Asc}}, Unique: true, @@ -110,6 +113,25 @@ func (t *Transfers) Get(ctx context.Context, transferRef string) (*model.Transfe return transfer, nil } +func (t *Transfers) FindByOperationRef(ctx context.Context, organizationRef, operationRef string) (*model.Transfer, error) { + operationRef = strings.TrimSpace(operationRef) + if operationRef == "" { + return nil, merrors.InvalidArgument("transfersStore: empty operationRef") + } + + query := repository.Query().Filter(repository.Field("operationRef"), operationRef) + if org := strings.TrimSpace(organizationRef); org != "" { + query = query.Filter(repository.Field("organizationRef"), org) + } + + transfer := &model.Transfer{} + if err := t.repo.FindOneByFilter(ctx, query, transfer); err != nil { + return nil, err + } + + return transfer, nil +} + func (t *Transfers) List(ctx context.Context, filter model.TransferFilter) (*model.TransferList, error) { query := repository.Query() if src := strings.TrimSpace(filter.SourceWalletRef); src != "" { diff --git a/api/gateway/chain/storage/storage.go b/api/gateway/chain/storage/storage.go index 1813b19a..aca89a42 100644 --- a/api/gateway/chain/storage/storage.go +++ b/api/gateway/chain/storage/storage.go @@ -42,6 +42,7 @@ type WalletsStore interface { type TransfersStore interface { Create(ctx context.Context, transfer *model.Transfer) (*model.Transfer, error) Get(ctx context.Context, transferRef string) (*model.Transfer, error) + FindByOperationRef(ctx context.Context, organizationRef, operationRef string) (*model.Transfer, error) List(ctx context.Context, filter model.TransferFilter) (*model.TransferList, error) UpdateStatus(ctx context.Context, transferRef string, status model.TransferStatus, failureReason string, txHash string) (*model.Transfer, error) } diff --git a/api/gateway/mntx/internal/service/gateway/connector.go b/api/gateway/mntx/internal/service/gateway/connector.go index 819a7f2f..087289f6 100644 --- a/api/gateway/mntx/internal/service/gateway/connector.go +++ b/api/gateway/mntx/internal/service/gateway/connector.go @@ -12,6 +12,7 @@ import ( moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1" mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" + "google.golang.org/protobuf/types/known/structpb" ) const mntxConnectorID = "mntx" @@ -92,11 +93,21 @@ func (s *Service) GetOperation(ctx context.Context, req *connectorv1.GetOperatio if req == nil || strings.TrimSpace(req.GetOperationId()) == "" { return nil, merrors.InvalidArgument("get_operation: operation_id is required") } - resp, err := s.GetCardPayoutStatus(ctx, &mntxv1.GetCardPayoutStatusRequest{PayoutId: strings.TrimSpace(req.GetOperationId())}) + + operationRef := strings.TrimSpace(req.GetOperationId()) + if s.storage == nil || s.storage.Payouts() == nil { + return nil, merrors.Internal("get_operation: storage is not configured") + } + + payout, err := s.storage.Payouts().FindByOperationRef(ctx, operationRef) if err != nil { return nil, err } - return &connectorv1.GetOperationResponse{Operation: payoutToOperation(resp.GetPayout())}, nil + if payout == nil { + return nil, merrors.NoData("payout not found") + } + + return &connectorv1.GetOperationResponse{Operation: payoutToOperation(StateToProto(payout))}, nil } func (s *Service) ListOperations(_ context.Context, _ *connectorv1.ListOperationsRequest) (*connectorv1.ListOperationsResponse, error) { @@ -274,7 +285,7 @@ func payoutToOperation(state *mntxv1.CardPayoutState) *connectorv1.Operation { if state == nil { return nil } - return &connectorv1.Operation{ + op := &connectorv1.Operation{ OperationId: firstNonEmpty(strings.TrimSpace(state.GetOperationRef()), strings.TrimSpace(state.GetPayoutId())), Type: connectorv1.OperationType_PAYOUT, Status: payoutStatusToOperation(state.GetStatus()), @@ -282,10 +293,30 @@ func payoutToOperation(state *mntxv1.CardPayoutState) *connectorv1.Operation { Amount: minorToDecimal(state.GetAmountMinor()), Currency: strings.ToUpper(strings.TrimSpace(state.GetCurrency())), }, - ProviderRef: strings.TrimSpace(state.GetProviderPaymentId()), - CreatedAt: state.GetCreatedAt(), - UpdatedAt: state.GetUpdatedAt(), + ProviderRef: strings.TrimSpace(state.GetProviderPaymentId()), + IntentRef: strings.TrimSpace(state.GetIntentRef()), + OperationRef: strings.TrimSpace(state.GetOperationRef()), + CreatedAt: state.GetCreatedAt(), + UpdatedAt: state.GetUpdatedAt(), } + + params := map[string]interface{}{} + if paymentRef := strings.TrimSpace(state.GetParentPaymentRef()); paymentRef != "" { + params["payment_ref"] = paymentRef + params["parent_payment_ref"] = paymentRef + } + if providerCode := strings.TrimSpace(state.GetProviderCode()); providerCode != "" { + params["provider_code"] = providerCode + } + if providerMessage := strings.TrimSpace(state.GetProviderMessage()); providerMessage != "" { + params["provider_message"] = providerMessage + params["failure_reason"] = providerMessage + } + if len(params) > 0 { + op.Params = structFromMap(params) + } + + return op } func minorToDecimal(amount int64) string { @@ -316,6 +347,17 @@ func payoutStatusToOperation(status mntxv1.PayoutStatus) connectorv1.OperationSt } } +func structFromMap(values map[string]interface{}) *structpb.Struct { + if len(values) == 0 { + return nil + } + result, err := structpb.NewStruct(values) + if err != nil { + return nil + } + return result +} + func connectorError(code connectorv1.ErrorCode, message string, op *connectorv1.Operation, accountID string) *connectorv1.ConnectorError { err := &connectorv1.ConnectorError{ Code: code, diff --git a/api/gateway/tgsettle/internal/service/gateway/connector.go b/api/gateway/tgsettle/internal/service/gateway/connector.go index 91fd98dc..f287fd9d 100644 --- a/api/gateway/tgsettle/internal/service/gateway/connector.go +++ b/api/gateway/tgsettle/internal/service/gateway/connector.go @@ -11,6 +11,9 @@ import ( connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" "go.uber.org/zap" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/structpb" ) const tgsettleConnectorID = "tgsettle" @@ -152,12 +155,22 @@ func (s *Service) GetOperation(ctx context.Context, req *connectorv1.GetOperatio return nil, merrors.InvalidArgument("get_operation: operation_id is required") } operationID := strings.TrimSpace(req.GetOperationId()) - resp, err := s.GetTransfer(ctx, &chainv1.GetTransferRequest{TransferRef: operationID}) + + if s.repo == nil || s.repo.Payments() == nil { + s.logger.Warn("Get operation storage unavailable", zap.String("operation_id", operationID)) + return nil, merrors.Internal("get_operation: storage is not configured") + } + + record, err := s.repo.Payments().FindByOperationRef(ctx, operationID) if err != nil { - s.logger.Warn("Get operation failed", zap.String("operation_id", operationID), zap.Error(err)) + s.logger.Warn("Get operation lookup by operation_ref failed", zap.String("operation_id", operationID), zap.Error(err)) return nil, err } - return &connectorv1.GetOperationResponse{Operation: transferToOperation(resp.GetTransfer())}, nil + if record == nil { + return nil, status.Error(codes.NotFound, "operation not found") + } + + return &connectorv1.GetOperationResponse{Operation: transferToOperation(transferFromPayment(record, nil))}, nil } func (s *Service) ListOperations(_ context.Context, _ *connectorv1.ListOperationsRequest) (*connectorv1.ListOperationsResponse, error) { @@ -221,6 +234,19 @@ func transferToOperation(transfer *chainv1.Transfer) *connectorv1.Operation { CreatedAt: transfer.GetCreatedAt(), UpdatedAt: transfer.GetUpdatedAt(), } + params := map[string]interface{}{} + if paymentRef := strings.TrimSpace(transfer.GetPaymentRef()); paymentRef != "" { + params["payment_ref"] = paymentRef + } + if organizationRef := strings.TrimSpace(transfer.GetOrganizationRef()); organizationRef != "" { + params["organization_ref"] = organizationRef + } + if failureReason := strings.TrimSpace(transfer.GetFailureReason()); failureReason != "" { + params["failure_reason"] = failureReason + } + if len(params) > 0 { + op.Params = structFromMap(params) + } if source := strings.TrimSpace(transfer.GetSourceWalletRef()); source != "" { op.From = &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{ ConnectorId: tgsettleConnectorID, @@ -281,6 +307,17 @@ func operationAccountID(party *connectorv1.OperationParty) string { return "" } +func structFromMap(values map[string]interface{}) *structpb.Struct { + if len(values) == 0 { + return nil + } + result, err := structpb.NewStruct(values) + if err != nil { + return nil + } + return result +} + func operationLogFields(op *connectorv1.Operation) []zap.Field { if op == nil { return nil diff --git a/api/gateway/tgsettle/internal/service/gateway/service.go b/api/gateway/tgsettle/internal/service/gateway/service.go index 636caaa9..61cc96a8 100644 --- a/api/gateway/tgsettle/internal/service/gateway/service.go +++ b/api/gateway/tgsettle/internal/service/gateway/service.go @@ -675,6 +675,9 @@ func transferFromRequest(req *chainv1.SubmitTransferRequest) *chainv1.Transfer { SourceWalletRef: strings.TrimSpace(req.GetSourceWalletRef()), Destination: req.GetDestination(), RequestedAmount: req.GetAmount(), + IntentRef: strings.TrimSpace(req.GetIntentRef()), + OperationRef: strings.TrimSpace(req.GetOperationRef()), + PaymentRef: strings.TrimSpace(req.GetPaymentRef()), Status: chainv1.TransferStatus_TRANSFER_CREATED, } } @@ -714,6 +717,10 @@ func transferFromPayment(record *storagemodel.PaymentRecord, req *chainv1.Submit IdempotencyKey: strings.TrimSpace(record.IdempotencyKey), RequestedAmount: requested, NetAmount: net, + IntentRef: strings.TrimSpace(record.IntentRef), + OperationRef: strings.TrimSpace(record.OperationRef), + PaymentRef: strings.TrimSpace(record.PaymentRef), + FailureReason: strings.TrimSpace(record.FailureReason), Status: status, } diff --git a/api/gateway/tgsettle/internal/service/gateway/service_test.go b/api/gateway/tgsettle/internal/service/gateway/service_test.go index 9f32c397..3cf38757 100644 --- a/api/gateway/tgsettle/internal/service/gateway/service_test.go +++ b/api/gateway/tgsettle/internal/service/gateway/service_test.go @@ -37,6 +37,20 @@ func (f *fakePaymentsStore) FindByIdempotencyKey(_ context.Context, key string) return f.records[key], nil } +func (f *fakePaymentsStore) FindByOperationRef(_ context.Context, key string) (*storagemodel.PaymentRecord, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.records == nil { + return nil, nil + } + for _, record := range f.records { + if record != nil && record.OperationRef == key { + return record, nil + } + } + return nil, nil +} + func (f *fakePaymentsStore) Upsert(_ context.Context, record *storagemodel.PaymentRecord) error { f.mu.Lock() defer f.mu.Unlock() diff --git a/api/gateway/tgsettle/storage/mongo/store/payments.go b/api/gateway/tgsettle/storage/mongo/store/payments.go index c3a89505..7bc4f000 100644 --- a/api/gateway/tgsettle/storage/mongo/store/payments.go +++ b/api/gateway/tgsettle/storage/mongo/store/payments.go @@ -20,6 +20,7 @@ import ( const ( paymentsCollection = "payments" fieldIdempotencyKey = "idempotencyKey" + fieldOperationRef = "operationRef" ) type Payments struct { @@ -44,6 +45,14 @@ func NewPayments(logger mlogger.Logger, db *mongo.Database) (*Payments, error) { logger.Error("Failed to create payments idempotency index", zap.Error(err), zap.String("index_field", fieldIdempotencyKey)) return nil, err } + if err := repo.CreateIndex(&ri.Definition{ + Keys: []ri.Key{{Field: fieldOperationRef, Sort: ri.Asc}}, + Unique: true, + Sparse: true, + }); err != nil { + logger.Error("Failed to create payments operation index", zap.Error(err), zap.String("index_field", fieldOperationRef)) + return nil, err + } p := &Payments{ logger: logger, @@ -72,6 +81,25 @@ func (p *Payments) FindByIdempotencyKey(ctx context.Context, key string) (*model return &result, nil } +func (p *Payments) FindByOperationRef(ctx context.Context, key string) (*model.PaymentRecord, error) { + key = strings.TrimSpace(key) + if key == "" { + return nil, merrors.InvalidArgument("operation reference is required", "operation_ref") + } + var result model.PaymentRecord + err := p.repo.FindOneByFilter(ctx, repository.Filter(fieldOperationRef, key), &result) + if errors.Is(err, merrors.ErrNoData) { + return nil, nil + } + if err != nil { + if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { + p.logger.Warn("Payment record lookup by operation ref failed", zap.String("operation_ref", key), zap.Error(err)) + } + return nil, err + } + return &result, nil +} + func (p *Payments) Upsert(ctx context.Context, record *model.PaymentRecord) error { if record == nil { return merrors.InvalidArgument("payment record is nil", "record") @@ -82,6 +110,7 @@ func (p *Payments) Upsert(ctx context.Context, record *model.PaymentRecord) erro record.OutgoingLeg = strings.TrimSpace(record.OutgoingLeg) record.TargetChatID = strings.TrimSpace(record.TargetChatID) record.IntentRef = strings.TrimSpace(record.IntentRef) + record.OperationRef = strings.TrimSpace(record.OperationRef) if record.PaymentIntentID == "" { return merrors.InvalidArgument("intention reference is required", "payment_intent_ref") } diff --git a/api/gateway/tgsettle/storage/storage.go b/api/gateway/tgsettle/storage/storage.go index 666d61c8..72eb42b1 100644 --- a/api/gateway/tgsettle/storage/storage.go +++ b/api/gateway/tgsettle/storage/storage.go @@ -18,6 +18,7 @@ type Repository interface { type PaymentsStore interface { FindByIdempotencyKey(ctx context.Context, key string) (*model.PaymentRecord, error) + FindByOperationRef(ctx context.Context, key string) (*model.PaymentRecord, error) Upsert(ctx context.Context, record *model.PaymentRecord) error } diff --git a/api/gateway/tron/internal/service/gateway/connector.go b/api/gateway/tron/internal/service/gateway/connector.go index 3b29d6b8..899f7505 100644 --- a/api/gateway/tron/internal/service/gateway/connector.go +++ b/api/gateway/tron/internal/service/gateway/connector.go @@ -8,6 +8,7 @@ import ( "github.com/tech/sendico/gateway/tron/internal/appversion" "github.com/tech/sendico/gateway/tron/shared" + tronstoragemodel "github.com/tech/sendico/gateway/tron/storage/model" chainasset "github.com/tech/sendico/pkg/chain" "github.com/tech/sendico/pkg/connector/params" "github.com/tech/sendico/pkg/merrors" @@ -17,6 +18,7 @@ import ( chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" "go.uber.org/zap" "google.golang.org/protobuf/types/known/structpb" + "google.golang.org/protobuf/types/known/timestamppb" ) const chainConnectorID = "chain" @@ -293,11 +295,21 @@ func (s *Service) GetOperation(ctx context.Context, req *connectorv1.GetOperatio if req == nil || strings.TrimSpace(req.GetOperationId()) == "" { return nil, merrors.InvalidArgument("get_operation: operation_id is required") } - resp, err := s.GetTransfer(ctx, &chainv1.GetTransferRequest{TransferRef: strings.TrimSpace(req.GetOperationId())}) + + operationRef := strings.TrimSpace(req.GetOperationId()) + if s.storage == nil || s.storage.Transfers() == nil { + return nil, merrors.Internal("get_operation: storage is not configured") + } + + transfer, err := s.storage.Transfers().FindByOperationRef(ctx, "", operationRef) if err != nil { return nil, err } - return &connectorv1.GetOperationResponse{Operation: chainTransferToOperation(resp.GetTransfer())}, nil + if transfer == nil { + return nil, merrors.NoData("transfer not found") + } + + return &connectorv1.GetOperationResponse{Operation: chainTransferToOperation(storageTransferToProto(transfer))}, nil } func (s *Service) ListOperations(ctx context.Context, req *connectorv1.ListOperationsRequest) (*connectorv1.ListOperationsResponse, error) { @@ -493,6 +505,61 @@ func feeEstimateResult(resp *chainv1.EstimateTransferFeeResponse) *structpb.Stru return result } +func storageTransferToProto(transfer *tronstoragemodel.Transfer) *chainv1.Transfer { + if transfer == nil { + return nil + } + + destination := &chainv1.TransferDestination{Memo: strings.TrimSpace(transfer.Destination.Memo)} + if managedWalletRef := strings.TrimSpace(transfer.Destination.ManagedWalletRef); managedWalletRef != "" { + destination.Destination = &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: managedWalletRef} + } else if externalAddress := strings.TrimSpace(transfer.Destination.ExternalAddress); externalAddress != "" { + destination.Destination = &chainv1.TransferDestination_ExternalAddress{ExternalAddress: externalAddress} + } + + fees := make([]*chainv1.ServiceFeeBreakdown, 0, len(transfer.Fees)) + for _, fee := range transfer.Fees { + fees = append(fees, &chainv1.ServiceFeeBreakdown{ + FeeCode: strings.TrimSpace(fee.FeeCode), + Amount: fee.Amount, + Description: strings.TrimSpace(fee.Description), + }) + } + + asset := &chainv1.Asset{ + Chain: shared.ChainEnumFromName(transfer.Network), + TokenSymbol: strings.TrimSpace(transfer.TokenSymbol), + ContractAddress: strings.TrimSpace(transfer.ContractAddress), + } + + protoTransfer := &chainv1.Transfer{ + TransferRef: strings.TrimSpace(transfer.TransferRef), + IdempotencyKey: strings.TrimSpace(transfer.IdempotencyKey), + IntentRef: strings.TrimSpace(transfer.IntentRef), + OperationRef: strings.TrimSpace(transfer.OperationRef), + OrganizationRef: strings.TrimSpace(transfer.OrganizationRef), + SourceWalletRef: strings.TrimSpace(transfer.SourceWalletRef), + Destination: destination, + Asset: asset, + RequestedAmount: shared.MonenyToProto(transfer.RequestedAmount), + NetAmount: shared.MonenyToProto(transfer.NetAmount), + Fees: fees, + Status: shared.TransferStatusToProto(transfer.Status), + TransactionHash: strings.TrimSpace(transfer.TxHash), + FailureReason: strings.TrimSpace(transfer.FailureReason), + PaymentRef: strings.TrimSpace(transfer.PaymentRef), + } + + if !transfer.CreatedAt.IsZero() { + protoTransfer.CreatedAt = timestamppb.New(transfer.CreatedAt.UTC()) + } + if !transfer.UpdatedAt.IsZero() { + protoTransfer.UpdatedAt = timestamppb.New(transfer.UpdatedAt.UTC()) + } + + return protoTransfer +} + func gasTopUpResult(amount *moneyv1.Money, capHit bool, transferRef string) *structpb.Struct { payload := map[string]interface{}{ "cap_hit": capHit, @@ -518,18 +585,33 @@ func chainTransferToOperation(transfer *chainv1.Transfer) *connectorv1.Operation return nil } op := &connectorv1.Operation{ - OperationId: strings.TrimSpace(transfer.GetTransferRef()), - Type: connectorv1.OperationType_TRANSFER, - Status: chainTransferStatusToOperation(transfer.GetStatus()), - Money: transfer.GetRequestedAmount(), - ProviderRef: strings.TrimSpace(transfer.GetTransactionHash()), - CreatedAt: transfer.GetCreatedAt(), - UpdatedAt: transfer.GetUpdatedAt(), + OperationId: strings.TrimSpace(transfer.GetTransferRef()), + Type: connectorv1.OperationType_TRANSFER, + Status: chainTransferStatusToOperation(transfer.GetStatus()), + Money: transfer.GetRequestedAmount(), + ProviderRef: strings.TrimSpace(transfer.GetTransactionHash()), + IntentRef: strings.TrimSpace(transfer.GetIntentRef()), + OperationRef: strings.TrimSpace(transfer.GetOperationRef()), + CreatedAt: transfer.GetCreatedAt(), + UpdatedAt: transfer.GetUpdatedAt(), From: &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{ ConnectorId: chainConnectorID, AccountId: strings.TrimSpace(transfer.GetSourceWalletRef()), }}}, } + params := map[string]interface{}{} + if paymentRef := strings.TrimSpace(transfer.GetPaymentRef()); paymentRef != "" { + params["payment_ref"] = paymentRef + } + if organizationRef := strings.TrimSpace(transfer.GetOrganizationRef()); organizationRef != "" { + params["organization_ref"] = organizationRef + } + if failureReason := strings.TrimSpace(transfer.GetFailureReason()); failureReason != "" { + params["failure_reason"] = failureReason + } + if len(params) > 0 { + op.Params = structFromMap(params) + } if dest := transfer.GetDestination(); dest != nil { switch d := dest.GetDestination().(type) { case *chainv1.TransferDestination_ManagedWalletRef: @@ -629,6 +711,17 @@ func operationAccountID(party *connectorv1.OperationParty) string { return "" } +func structFromMap(values map[string]interface{}) *structpb.Struct { + if len(values) == 0 { + return nil + } + result, err := structpb.NewStruct(values) + if err != nil { + return nil + } + return result +} + func connectorError(code connectorv1.ErrorCode, message string, op *connectorv1.Operation, accountID string) *connectorv1.ConnectorError { err := &connectorv1.ConnectorError{ Code: code, diff --git a/api/gateway/tron/internal/service/gateway/service_test.go b/api/gateway/tron/internal/service/gateway/service_test.go index fdf28a88..9fedaf50 100644 --- a/api/gateway/tron/internal/service/gateway/service_test.go +++ b/api/gateway/tron/internal/service/gateway/service_test.go @@ -554,6 +554,32 @@ func (t *inMemoryTransfers) Get(ctx context.Context, transferRef string) (*model return transfer, nil } +func (t *inMemoryTransfers) FindByOperationRef(ctx context.Context, organizationRef, operationRef string) (*model.Transfer, error) { + t.mu.Lock() + defer t.mu.Unlock() + + org := strings.TrimSpace(organizationRef) + opRef := strings.TrimSpace(operationRef) + if opRef == "" { + return nil, merrors.InvalidArgument("transfersStore: empty operationRef") + } + + for _, transfer := range t.items { + if transfer == nil { + continue + } + if !strings.EqualFold(strings.TrimSpace(transfer.OperationRef), opRef) { + continue + } + if org != "" && !strings.EqualFold(strings.TrimSpace(transfer.OrganizationRef), org) { + continue + } + return transfer, nil + } + + return nil, merrors.NoData("transfer not found") +} + func (t *inMemoryTransfers) List(ctx context.Context, filter model.TransferFilter) (*model.TransferList, error) { t.mu.Lock() defer t.mu.Unlock() diff --git a/api/gateway/tron/storage/mongo/store/transfers.go b/api/gateway/tron/storage/mongo/store/transfers.go index 906a6e5b..f92a3a65 100644 --- a/api/gateway/tron/storage/mongo/store/transfers.go +++ b/api/gateway/tron/storage/mongo/store/transfers.go @@ -40,6 +40,9 @@ func NewTransfers(logger mlogger.Logger, db *mongo.Database) (*Transfers, error) Keys: []ri.Key{{Field: "transferRef", Sort: ri.Asc}}, Unique: true, }, + { + Keys: []ri.Key{{Field: "organizationRef", Sort: ri.Asc}, {Field: "operationRef", Sort: ri.Asc}}, + }, { Keys: []ri.Key{{Field: "idempotencyKey", Sort: ri.Asc}}, Unique: true, @@ -110,6 +113,25 @@ func (t *Transfers) Get(ctx context.Context, transferRef string) (*model.Transfe return transfer, nil } +func (t *Transfers) FindByOperationRef(ctx context.Context, organizationRef, operationRef string) (*model.Transfer, error) { + operationRef = strings.TrimSpace(operationRef) + if operationRef == "" { + return nil, merrors.InvalidArgument("transfersStore: empty operationRef") + } + + query := repository.Query().Filter(repository.Field("operationRef"), operationRef) + if org := strings.TrimSpace(organizationRef); org != "" { + query = query.Filter(repository.Field("organizationRef"), org) + } + + transfer := &model.Transfer{} + if err := t.repo.FindOneByFilter(ctx, query, transfer); err != nil { + return nil, err + } + + return transfer, nil +} + func (t *Transfers) List(ctx context.Context, filter model.TransferFilter) (*model.TransferList, error) { query := repository.Query() if src := strings.TrimSpace(filter.SourceWalletRef); src != "" { diff --git a/api/gateway/tron/storage/storage.go b/api/gateway/tron/storage/storage.go index 28d71d3c..473ff8ab 100644 --- a/api/gateway/tron/storage/storage.go +++ b/api/gateway/tron/storage/storage.go @@ -42,6 +42,7 @@ type WalletsStore interface { type TransfersStore interface { Create(ctx context.Context, transfer *model.Transfer) (*model.Transfer, error) Get(ctx context.Context, transferRef string) (*model.Transfer, error) + FindByOperationRef(ctx context.Context, organizationRef, operationRef string) (*model.Transfer, error) List(ctx context.Context, filter model.TransferFilter) (*model.TransferList, error) UpdateStatus(ctx context.Context, transferRef string, status model.TransferStatus, failureReason string, txHash string) (*model.Transfer, error) } diff --git a/api/proto/billing/documents/v1/documents.proto b/api/proto/billing/documents/v1/documents.proto index 1d989ac8..b33524f5 100644 --- a/api/proto/billing/documents/v1/documents.proto +++ b/api/proto/billing/documents/v1/documents.proto @@ -43,6 +43,11 @@ service DocumentService { // generates it lazily, stores it, and returns it. rpc GetDocument(GetDocumentRequest) returns (GetDocumentResponse); + + // GetOperationDocument returns a generated PDF file for + // a gateway operation snapshot provided by the caller. + rpc GetOperationDocument(GetOperationDocumentRequest) + returns (GetDocumentResponse); } @@ -99,3 +104,24 @@ message GetDocumentResponse { // MIME type, typically "application/pdf" string mime_type = 3; } + +// GetOperationDocumentRequest requests a document for a +// single gateway operation. +message GetOperationDocumentRequest { + string organization_ref = 1; + string gateway_service = 2; + string operation_ref = 3; + + string payment_ref = 4; + string operation_code = 5; + string operation_label = 6; + string operation_state = 7; + string failure_code = 8; + string failure_reason = 9; + + string amount = 10; + string currency = 11; + + int64 started_at_unix_ms = 12; + int64 completed_at_unix_ms = 13; +} diff --git a/interface/api.yaml b/interface/api.yaml index 6fdf4f0a..5bb3e4ca 100644 --- a/interface/api.yaml +++ b/interface/api.yaml @@ -95,6 +95,8 @@ paths: $ref: ./api/payments/by_multiquote.yaml /payments/{organizations_ref}: $ref: ./api/payments/list.yaml + /payments/documents/operation/{organizations_ref}: + $ref: ./api/payments/documents_operation.yaml components: securitySchemes: diff --git a/interface/api/payments/documents_act.yaml b/interface/api/payments/documents_operation.yaml similarity index 67% rename from interface/api/payments/documents_act.yaml rename to interface/api/payments/documents_operation.yaml index 41eefaf4..3670d7e5 100644 --- a/interface/api/payments/documents_act.yaml +++ b/interface/api/payments/documents_operation.yaml @@ -1,27 +1,29 @@ get: tags: [Payments] - summary: Download act document by payment reference - description: Returns the billing act document as binary content. - operationId: paymentsGetActDocument + summary: Download billing document by operation reference + description: | + Returns operation-level billing document as binary content. + The request is resolved by gateway service and operation reference. + operationId: paymentsGetOperationDocument security: - bearerAuth: [] parameters: - $ref: ../parameters/organizations_ref.yaml#/components/parameters/OrganizationsRef - - name: payment_ref + - name: gateway_service in: query - required: false - description: Payment reference for which to fetch the act document. + required: true + description: Gateway service identifier (`chain_gateway`, `tron_gateway`, `mntx_gateway`, `payment_gateway`, `tgsettle_gateway`). schema: type: string - - name: paymentRef + - name: operation_ref in: query - required: false - description: Alias of `payment_ref`. + required: true + description: Operation reference for which to fetch billing document. schema: type: string responses: '200': - description: Act document file + description: Operation billing document file content: application/pdf: schema: