From 8faed5cbaa876aa34117cddc707122efd1fc9686 Mon Sep 17 00:00:00 2001 From: Stephan D Date: Sun, 1 Feb 2026 03:35:16 +0100 Subject: [PATCH] TRON -> TRON_MAINNET --- .../internal/service/documents/service.go | 39 ++-- .../service/documents/service_test.go | 33 +++- .../service/documents/template_test.go | 12 +- .../documents/storage/model/document.go | 6 - .../storage/mongo/store/documents.go | 6 - .../documents/templates/acceptance.tpl | 3 - .../orchestrator/convert_types_test.go | 2 +- .../orchestrator/plan_builder_default_test.go | 22 +-- .../orchestrator/plan_builder_gateways.go | 4 +- .../service/orchestrator/service_test.go | 8 +- api/pkg/chain/asset.go | 16 +- api/pkg/chain/asset_test.go | 9 +- api/proto/gateway/chain/v1/chain.proto | 1 + .../server/paymentapiimp/documents.go | 172 ++++++++++++++++++ .../internal/server/paymentapiimp/service.go | 1 + .../lib/models/file/downloaded_file.dart | 11 ++ .../pshared/lib/models/payment/operation.dart | 4 +- .../lib/service/authorization/service.dart | 5 + .../lib/service/payment/documents.dart | 44 +++++ frontend/pshared/lib/utils/http/requests.dart | 46 +++++ frontend/pshared/pubspec.yaml | 2 +- frontend/pweb/lib/l10n/en.arb | 8 + frontend/pweb/lib/l10n/ru.arb | 8 + frontend/pweb/lib/pages/report/page.dart | 2 + frontend/pweb/lib/pages/report/table/row.dart | 46 ++++- frontend/pweb/lib/utils/download.dart | 25 +++ frontend/pweb/pubspec.yaml | 5 +- version | 2 +- 28 files changed, 452 insertions(+), 90 deletions(-) create mode 100644 api/server/internal/server/paymentapiimp/documents.go create mode 100644 frontend/pshared/lib/models/file/downloaded_file.dart create mode 100644 frontend/pshared/lib/service/payment/documents.dart create mode 100644 frontend/pweb/lib/utils/download.dart diff --git a/api/billing/documents/internal/service/documents/service.go b/api/billing/documents/internal/service/documents/service.go index b3c89815..444dfe81 100644 --- a/api/billing/documents/internal/service/documents/service.go +++ b/api/billing/documents/internal/service/documents/service.go @@ -217,8 +217,14 @@ func (s *Service) BatchResolveDocuments(ctx context.Context, req *documentsv1.Ba for _, ref := range refs { meta := &documentsv1.DocumentMeta{PaymentRef: ref} if record := recordByRef[ref]; record != nil { - meta.AvailableTypes = toProtoTypes(record.Available) - meta.ReadyTypes = toProtoTypes(record.Ready) + 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) } @@ -294,8 +300,9 @@ func (s *Service) GetDocument(ctx context.Context, req *documentsv1.GetDocumentR record.Normalize() targetType := model.DocumentTypeFromProto(docType) - if !containsDocType(record.Available, targetType) { - return nil, status.Error(codes.NotFound, "document type not available") + + 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 != "" { @@ -310,10 +317,6 @@ func (s *Service) GetDocument(ctx context.Context, req *documentsv1.GetDocumentR }, nil } - if docType != documentsv1.DocumentType_DOCUMENT_TYPE_ACT { - return nil, status.Error(codes.Unimplemented, "document type not implemented") - } - content, hash, genErr := s.generateActPDF(record.Snapshot) if genErr != nil { logger.Warn("Failed to generate document", zap.Error(genErr)) @@ -328,7 +331,6 @@ func (s *Service) GetDocument(ctx context.Context, req *documentsv1.GetDocumentR record.StoragePaths[targetType] = path record.Hashes[targetType] = hash - record.Ready = appendUnique(record.Ready, targetType) 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()) @@ -375,24 +377,7 @@ func (s *Service) generateActPDF(snapshot model.ActSnapshot) ([]byte, string, er if err != nil { return nil, "", err } - fileHash := sha256.Sum256(finalBytes) - return finalBytes, hex.EncodeToString(fileHash[:]), nil -} - -func containsDocType(list []model.DocumentType, target model.DocumentType) bool { - for _, entry := range list { - if entry == target { - return true - } - } - return false -} - -func appendUnique(list []model.DocumentType, value model.DocumentType) []model.DocumentType { - if containsDocType(list, value) { - return list - } - return append(list, value) + return finalBytes, footerHex, nil } func toProtoTypes(types []model.DocumentType) []documentsv1.DocumentType { diff --git a/api/billing/documents/internal/service/documents/service_test.go b/api/billing/documents/internal/service/documents/service_test.go index 56d3ec47..e073d3d7 100644 --- a/api/billing/documents/internal/service/documents/service_test.go +++ b/api/billing/documents/internal/service/documents/service_test.go @@ -3,8 +3,6 @@ package documents import ( "bytes" "context" - "crypto/sha256" - "encoding/hex" "testing" "time" @@ -100,14 +98,11 @@ func TestGetDocument_IdempotentAndHashed(t *testing.T) { ExecutorFullName: "Jane Doe", Amount: decimal.RequireFromString("100.00"), Currency: "USD", - OrgLegalName: "Acme Corp", - OrgAddress: "42 Galaxy Way", } record := &model.DocumentRecord{ PaymentRef: "PAY-123", Snapshot: snapshot, - Available: []model.DocumentType{model.DocumentTypeAct}, } documentsStore := &stubDocumentsStore{record: record} @@ -144,12 +139,15 @@ func TestGetDocument_IdempotentAndHashed(t *testing.T) { t.Fatalf("expected content on first call") } - hash1 := sha256.Sum256(resp1.Content) stored := record.Hashes[model.DocumentTypeAct] if stored == "" { t.Fatalf("expected stored hash") } - if stored != hex.EncodeToString(hash1[:]) { + footerHash := extractFooterHash(resp1.Content) + if footerHash == "" { + t.Fatalf("expected footer hash in PDF") + } + if stored != footerHash { t.Fatalf("stored hash mismatch: got %s", stored) } @@ -174,3 +172,24 @@ func TestGetDocument_IdempotentAndHashed(t *testing.T) { t.Fatalf("expected document load on second call") } } + +func extractFooterHash(pdf []byte) string { + prefix := []byte("Document integrity hash: ") + idx := bytes.Index(pdf, prefix) + if idx == -1 { + return "" + } + start := idx + len(prefix) + end := start + for end < len(pdf) && isHexDigit(pdf[end]) { + end++ + } + if end-start != 64 { + return "" + } + return string(pdf[start:end]) +} + +func isHexDigit(b byte) bool { + return (b >= '0' && b <= '9') || (b >= 'a' && b <= 'f') || (b >= 'A' && b <= 'F') +} diff --git a/api/billing/documents/internal/service/documents/template_test.go b/api/billing/documents/internal/service/documents/template_test.go index 3ae160f7..68e3e50c 100644 --- a/api/billing/documents/internal/service/documents/template_test.go +++ b/api/billing/documents/internal/service/documents/template_test.go @@ -23,8 +23,6 @@ func TestTemplateRenderer_Render(t *testing.T) { ExecutorFullName: "Jane Doe", Amount: decimal.RequireFromString("123.45"), Currency: "USD", - OrgLegalName: "Acme Corp", - OrgAddress: "42 Galaxy Way", } blocks, err := tmpl.Render(snapshot) @@ -54,15 +52,15 @@ func TestTemplateRenderer_Render(t *testing.T) { if kv == nil { t.Fatalf("expected kv block") } - foundOrg := false + foundExecutor := false for _, row := range kv.Rows { - if len(row) >= 2 && row[0] == "Customer" && row[1] == snapshot.OrgLegalName { - foundOrg = true + if len(row) >= 2 && row[0] == "Executor" && row[1] == snapshot.ExecutorFullName { + foundExecutor = true break } } - if !foundOrg { - t.Fatalf("expected org name in kv block") + if !foundExecutor { + t.Fatalf("expected executor name in kv block") } table := findBlock(blocks, renderer.TagTable) diff --git a/api/billing/documents/storage/model/document.go b/api/billing/documents/storage/model/document.go index eb57f010..909969c8 100644 --- a/api/billing/documents/storage/model/document.go +++ b/api/billing/documents/storage/model/document.go @@ -46,8 +46,6 @@ type ActSnapshot struct { ExecutorFullName string `bson:"executorFullName" json:"executorFullName"` Amount decimal.Decimal `bson:"amount" json:"amount"` Currency string `bson:"currency" json:"currency"` - OrgLegalName string `bson:"orgLegalName" json:"orgLegalName"` - OrgAddress string `bson:"orgAddress" json:"orgAddress"` } func (s *ActSnapshot) Normalize() { @@ -57,8 +55,6 @@ func (s *ActSnapshot) Normalize() { s.PaymentID = strings.TrimSpace(s.PaymentID) s.ExecutorFullName = strings.TrimSpace(s.ExecutorFullName) s.Currency = strings.TrimSpace(s.Currency) - s.OrgLegalName = strings.TrimSpace(s.OrgLegalName) - s.OrgAddress = strings.TrimSpace(s.OrgAddress) } // DocumentRecord stores document metadata and cached artefacts for a payment. @@ -66,8 +62,6 @@ type DocumentRecord struct { storable.Base `bson:",inline" json:",inline"` PaymentRef string `bson:"paymentRef" json:"paymentRef"` Snapshot ActSnapshot `bson:"snapshot" json:"snapshot"` - Available []DocumentType `bson:"availableTypes,omitempty" json:"availableTypes,omitempty"` - Ready []DocumentType `bson:"readyTypes,omitempty" json:"readyTypes,omitempty"` StoragePaths map[DocumentType]string `bson:"storagePaths,omitempty" json:"storagePaths,omitempty"` Hashes map[DocumentType]string `bson:"hashes,omitempty" json:"hashes,omitempty"` } diff --git a/api/billing/documents/storage/mongo/store/documents.go b/api/billing/documents/storage/mongo/store/documents.go index 6dc4674a..5b552b74 100644 --- a/api/billing/documents/storage/mongo/store/documents.go +++ b/api/billing/documents/storage/mongo/store/documents.go @@ -34,12 +34,6 @@ func NewDocuments(logger mlogger.Logger, db *mongo.Database) (*Documents, error) Keys: []ri.Key{{Field: "paymentRef", Sort: ri.Asc}}, Unique: true, }, - { - Keys: []ri.Key{{Field: "availableTypes", Sort: ri.Asc}}, - }, - { - Keys: []ri.Key{{Field: "readyTypes", Sort: ri.Asc}}, - }, } for _, def := range indexes { diff --git a/api/billing/documents/templates/acceptance.tpl b/api/billing/documents/templates/acceptance.tpl index 3a3413cd..bd986ecc 100644 --- a/api/billing/documents/templates/acceptance.tpl +++ b/api/billing/documents/templates/acceptance.tpl @@ -19,9 +19,6 @@ PARTIES This Act is made between the following Parties. #kv -Customer | {{ .OrgLegalName }} -Address | {{ .OrgAddress }} - Executor | {{ .ExecutorFullName }} Status | Individual diff --git a/api/payments/orchestrator/internal/service/orchestrator/convert_types_test.go b/api/payments/orchestrator/internal/service/orchestrator/convert_types_test.go index 751883dc..4362a380 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/convert_types_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/convert_types_test.go @@ -104,7 +104,7 @@ func TestAssetConversionRoundTrip(t *testing.T) { ContractAddress: "0xabc", } model := assetFromProto(proto) - if model == nil || model.Chain != "TRON" || model.TokenSymbol != "USDT" || model.ContractAddress != "0xabc" { + if model == nil || model.Chain != "TRON_MAINNET" || model.TokenSymbol != "USDT" || model.ContractAddress != "0xabc" { t.Fatalf("assetFromProto mismatch: %#v", model) } back := assetToProto(model) diff --git a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_default_test.go b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_default_test.go index 7b334d08..65ff87ab 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_default_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_default_test.go @@ -6,8 +6,8 @@ import ( "testing" "github.com/tech/sendico/payments/orchestrator/storage/model" - pmodel "github.com/tech/sendico/pkg/model" mloggerfactory "github.com/tech/sendico/pkg/mlogger/factory" + pmodel "github.com/tech/sendico/pkg/model" paymenttypes "github.com/tech/sendico/pkg/payments/types" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" @@ -27,7 +27,7 @@ func TestDefaultPlanBuilder_BuildsPlanFromRoutes_CryptoToCard(t *testing.T) { ManagedWallet: &model.ManagedWalletEndpoint{ ManagedWalletRef: "wallet-1", Asset: &paymenttypes.Asset{ - Chain: "TRON", + Chain: "TRON_MAINNET", TokenSymbol: "USDT", }, }, @@ -52,7 +52,7 @@ func TestDefaultPlanBuilder_BuildsPlanFromRoutes_CryptoToCard(t *testing.T) { routes := &stubRouteStore{ routes: []*model.PaymentRoute{ - {FromRail: model.RailCrypto, ToRail: model.RailCardPayout, Network: "TRON", IsEnabled: true}, + {FromRail: model.RailCrypto, ToRail: model.RailCardPayout, Network: "TRON_MAINNET", IsEnabled: true}, }, } @@ -61,7 +61,7 @@ func TestDefaultPlanBuilder_BuildsPlanFromRoutes_CryptoToCard(t *testing.T) { { FromRail: model.RailCrypto, ToRail: model.RailCardPayout, - Network: "TRON", + Network: "TRON_MAINNET", IsEnabled: true, Steps: []model.OrchestrationStep{ {StepID: "crypto_send", Rail: model.RailCrypto, Operation: "payout.crypto"}, @@ -81,7 +81,7 @@ func TestDefaultPlanBuilder_BuildsPlanFromRoutes_CryptoToCard(t *testing.T) { ID: "crypto-tron", InstanceID: "crypto-tron-1", Rail: model.RailCrypto, - Network: "TRON", + Network: "TRON_MAINNET", Currencies: []string{"USDT"}, Capabilities: model.RailCapabilities{ CanPayOut: true, @@ -148,7 +148,7 @@ func TestDefaultPlanBuilder_ErrorsWhenRouteMissing(t *testing.T) { Type: model.EndpointTypeManagedWallet, ManagedWallet: &model.ManagedWalletEndpoint{ ManagedWalletRef: "wallet-1", - Asset: &paymenttypes.Asset{Chain: "TRON"}, + Asset: &paymenttypes.Asset{Chain: "TRON_MAINNET"}, }, }, Destination: model.PaymentEndpoint{ @@ -221,7 +221,7 @@ func TestBuildPlanFromTemplate_ProviderSettlementUsesNetAmountWhenFixReceived(t }, } - plan, err := builder.buildPlanFromTemplate(ctx, payment, quote, template, model.RailCrypto, model.RailProviderSettlement, "TRON", "", registry) + plan, err := builder.buildPlanFromTemplate(ctx, payment, quote, template, model.RailCrypto, model.RailProviderSettlement, "TRON_MAINNET", "", registry) if err != nil { t.Fatalf("expected plan, got error: %v", err) } @@ -247,7 +247,7 @@ func TestDefaultPlanBuilder_UsesSourceCurrencyForCryptoSendWithFX(t *testing.T) ManagedWallet: &model.ManagedWalletEndpoint{ ManagedWalletRef: "wallet-2", Asset: &paymenttypes.Asset{ - Chain: "TRON", + Chain: "TRON_MAINNET", TokenSymbol: "USDT", }, }, @@ -275,7 +275,7 @@ func TestDefaultPlanBuilder_UsesSourceCurrencyForCryptoSendWithFX(t *testing.T) routes := &stubRouteStore{ routes: []*model.PaymentRoute{ - {FromRail: model.RailCrypto, ToRail: model.RailCardPayout, Network: "TRON", IsEnabled: true}, + {FromRail: model.RailCrypto, ToRail: model.RailCardPayout, Network: "TRON_MAINNET", IsEnabled: true}, }, } @@ -284,7 +284,7 @@ func TestDefaultPlanBuilder_UsesSourceCurrencyForCryptoSendWithFX(t *testing.T) { FromRail: model.RailCrypto, ToRail: model.RailCardPayout, - Network: "TRON", + Network: "TRON_MAINNET", IsEnabled: true, Steps: []model.OrchestrationStep{ {StepID: "crypto_send", Rail: model.RailCrypto, Operation: "payout.crypto"}, @@ -304,7 +304,7 @@ func TestDefaultPlanBuilder_UsesSourceCurrencyForCryptoSendWithFX(t *testing.T) ID: "crypto-tron", InstanceID: "crypto-tron-2", Rail: model.RailCrypto, - Network: "TRON", + Network: "TRON_MAINNET", Currencies: []string{"USDT"}, Capabilities: model.RailCapabilities{ CanPayOut: true, diff --git a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_gateways.go b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_gateways.go index 6b857c88..97d9683b 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_gateways.go +++ b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_gateways.go @@ -24,7 +24,7 @@ func ensureGatewayForAction(ctx context.Context, logger mlogger.Logger, registry logger.Warn("Failed to validate gateway", zap.Error(err), zap.String("instance_id", instanceID), zap.String("rail", string(rail)), zap.String("network", network), zap.String("action", string(action)), - zap.String("direction", sendDirectionLabel(dir)), zap.Int("ralis_qty", len(cache)), + zap.String("direction", sendDirectionLabel(dir)), zap.Int("rails_qty", len(cache)), ) return nil, err } @@ -36,7 +36,7 @@ func ensureGatewayForAction(ctx context.Context, logger mlogger.Logger, registry logger.Warn("Failed to select gateway", zap.Error(err), zap.String("instance_id", instanceID), zap.String("rail", string(rail)), zap.String("network", network), zap.String("action", string(action)), - zap.String("direction", sendDirectionLabel(dir)), zap.Int("ralis_qty", len(cache)), + zap.String("direction", sendDirectionLabel(dir)), zap.Int("rails_qty", len(cache)), ) return nil, err } diff --git a/api/payments/orchestrator/internal/service/orchestrator/service_test.go b/api/payments/orchestrator/internal/service/orchestrator/service_test.go index d6ec6684..572628cd 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/service_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/service_test.go @@ -94,7 +94,7 @@ func TestExecutePayment_ChainFailure(t *testing.T) { store := newStubPaymentsStore() routes := &stubRoutesStore{ routes: []*model.PaymentRoute{ - {FromRail: model.RailCrypto, ToRail: model.RailLedger, Network: "TRON", IsEnabled: true}, + {FromRail: model.RailCrypto, ToRail: model.RailLedger, Network: "TRON_MAINNET", IsEnabled: true}, }, } plans := &stubPlanTemplatesStore{ @@ -102,7 +102,7 @@ func TestExecutePayment_ChainFailure(t *testing.T) { { FromRail: model.RailCrypto, ToRail: model.RailLedger, - Network: "TRON", + Network: "TRON_MAINNET", IsEnabled: true, Steps: []model.OrchestrationStep{ {StepID: "crypto_send", Rail: model.RailCrypto, Operation: "payout.crypto"}, @@ -132,7 +132,7 @@ func TestExecutePayment_ChainFailure(t *testing.T) { ID: "crypto-tron", InstanceID: "crypto-tron-1", Rail: model.RailCrypto, - Network: "TRON", + Network: "TRON_MAINNET", Currencies: []string{"USDT"}, Capabilities: model.RailCapabilities{ CanPayOut: true, @@ -162,7 +162,7 @@ func TestExecutePayment_ChainFailure(t *testing.T) { ManagedWallet: &model.ManagedWalletEndpoint{ ManagedWalletRef: "wallet-src", Asset: &paymenttypes.Asset{ - Chain: "TRON", + Chain: "TRON_MAINNET", TokenSymbol: "USDT", }, }, diff --git a/api/pkg/chain/asset.go b/api/pkg/chain/asset.go index c6f3a242..22addbe0 100644 --- a/api/pkg/chain/asset.go +++ b/api/pkg/chain/asset.go @@ -83,13 +83,15 @@ func NetworkName(chain chainv1.ChainNetwork) string { func NetworkAlias(chain chainv1.ChainNetwork) string { switch chain { case chainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET: - return "ETH" + return "ETHEREUM_MAINNET" case chainv1.ChainNetwork_CHAIN_NETWORK_TRON_MAINNET: - return "TRON" + return "TRON_MAINNET" case chainv1.ChainNetwork_CHAIN_NETWORK_TRON_NILE: return "TRON_NILE" case chainv1.ChainNetwork_CHAIN_NETWORK_ARBITRUM_ONE: - return "ARBITRUM" + return "ARBITRUM_ONE" + case chainv1.ChainNetwork_CHAIN_NETWORK_ARBITRUM_SEPOLIA: + return "ARBITRUM_SEPOLIA" default: name := NetworkName(chain) if name == "" { @@ -109,14 +111,16 @@ func NetworkFromString(value string) chainv1.ChainNetwork { return chainv1.ChainNetwork(val) } switch normalized { - case "ETH", "ETHEREUM", "ETH_MAINNET": + case "ETH", "ETHEREUM", "ETH_MAINNET", "ETHEREUM_MAINNET": return chainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET - case "TRON": + case "TRON", "TRON_MAINNET": return chainv1.ChainNetwork_CHAIN_NETWORK_TRON_MAINNET case "TRON_NILE": return chainv1.ChainNetwork_CHAIN_NETWORK_TRON_NILE - case "ARB", "ARBITRUM": + case "ARB", "ARBITRUM", "ARBITRUM_ONE": return chainv1.ChainNetwork_CHAIN_NETWORK_ARBITRUM_ONE + case "ARBITRUM_SEPOLIA": + return chainv1.ChainNetwork_CHAIN_NETWORK_ARBITRUM_SEPOLIA } if !strings.HasPrefix(normalized, "CHAIN_NETWORK_") { normalized = "CHAIN_NETWORK_" + normalized diff --git a/api/pkg/chain/asset_test.go b/api/pkg/chain/asset_test.go index ea4d1207..cd38530c 100644 --- a/api/pkg/chain/asset_test.go +++ b/api/pkg/chain/asset_test.go @@ -13,10 +13,11 @@ func TestNetworkName(t *testing.T) { } func TestNetworkAlias(t *testing.T) { - require.Equal(t, "ETH", NetworkAlias(chainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET)) - require.Equal(t, "TRON", NetworkAlias(chainv1.ChainNetwork_CHAIN_NETWORK_TRON_MAINNET)) + require.Equal(t, "ETHEREUM_MAINNET", NetworkAlias(chainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET)) + require.Equal(t, "TRON_MAINNET", NetworkAlias(chainv1.ChainNetwork_CHAIN_NETWORK_TRON_MAINNET)) require.Equal(t, "TRON_NILE", NetworkAlias(chainv1.ChainNetwork_CHAIN_NETWORK_TRON_NILE)) - require.Equal(t, "ARBITRUM", NetworkAlias(chainv1.ChainNetwork_CHAIN_NETWORK_ARBITRUM_ONE)) + require.Equal(t, "ARBITRUM_ONE", NetworkAlias(chainv1.ChainNetwork_CHAIN_NETWORK_ARBITRUM_ONE)) + require.Equal(t, "ARBITRUM_SEPOLIA", NetworkAlias(chainv1.ChainNetwork_CHAIN_NETWORK_ARBITRUM_SEPOLIA)) require.Equal(t, "UNSPECIFIED", NetworkAlias(chainv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED)) } @@ -25,7 +26,7 @@ func TestNetworkFromString(t *testing.T) { require.Equal(t, chainv1.ChainNetwork_CHAIN_NETWORK_TRON_MAINNET, NetworkFromString("CHAIN_NETWORK_TRON_MAINNET")) require.Equal(t, chainv1.ChainNetwork_CHAIN_NETWORK_TRON_MAINNET, NetworkFromString("tron mainnet")) require.Equal(t, chainv1.ChainNetwork_CHAIN_NETWORK_TRON_MAINNET, NetworkFromString("tron-mainnet")) - require.Equal(t, chainv1.ChainNetwork_CHAIN_NETWORK_TRON_MAINNET, NetworkFromString("TRON")) + require.Equal(t, chainv1.ChainNetwork_CHAIN_NETWORK_TRON_MAINNET, NetworkFromString("TRON_MAINNET")) require.Equal(t, chainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET, NetworkFromString("ETH")) require.Equal(t, chainv1.ChainNetwork_CHAIN_NETWORK_ARBITRUM_ONE, NetworkFromString("ARBITRUM")) require.Equal(t, chainv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED, NetworkFromString("")) diff --git a/api/proto/gateway/chain/v1/chain.proto b/api/proto/gateway/chain/v1/chain.proto index 5b379d77..ecfc3682 100644 --- a/api/proto/gateway/chain/v1/chain.proto +++ b/api/proto/gateway/chain/v1/chain.proto @@ -17,6 +17,7 @@ enum ChainNetwork { CHAIN_NETWORK_ARBITRUM_ONE = 2; CHAIN_NETWORK_TRON_MAINNET = 4; CHAIN_NETWORK_TRON_NILE = 5; + CHAIN_NETWORK_ARBITRUM_SEPOLIA = 6; } enum ManagedWalletStatus { diff --git a/api/server/internal/server/paymentapiimp/documents.go b/api/server/internal/server/paymentapiimp/documents.go new file mode 100644 index 00000000..8c074ad8 --- /dev/null +++ b/api/server/internal/server/paymentapiimp/documents.go @@ -0,0 +1,172 @@ +package paymentapiimp + +import ( + "context" + "fmt" + "net/http" + "strings" + "time" + + "github.com/tech/sendico/pkg/api/http/response" + "github.com/tech/sendico/pkg/discovery" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/pkg/mservice" + "github.com/tech/sendico/pkg/mutil/mzap" + documentsv1 "github.com/tech/sendico/pkg/proto/billing/documents/v1" + "github.com/tech/sendico/server/interface/api/sresponse" + mutil "github.com/tech/sendico/server/internal/mutil/param" + "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" +) + +const ( + documentsServiceName = "BILLING_DOCUMENTS" + documentsOperationGet = "documents.get" + documentsDialTimeout = 5 * time.Second + documentsCallTimeout = 10 * time.Second +) + +func (a *PaymentAPI) getActDocument(r *http.Request, account *model.Account, _ *sresponse.TokenData) http.HandlerFunc { + orgRef, err := a.oph.GetRef(r) + if err != nil { + a.logger.Warn("Failed to parse organization reference for document request", zap.Error(err), mutil.PLog(a.oph, r)) + return response.BadReference(a.logger, a.Name(), a.oph.Name(), a.oph.GetID(r), err) + } + + ctx := r.Context() + allowed, err := a.enf.Enforce(ctx, a.permissionRef, account.ID, orgRef, bson.NilObjectID, model.ActionRead) + if err != nil { + a.logger.Warn("Failed to check payments access permissions", zap.Error(err), mutil.PLog(a.oph, r)) + return response.Auto(a.logger, a.Name(), err) + } + if !allowed { + a.logger.Debug("Access denied when downloading act", mutil.PLog(a.oph, r)) + return response.AccessDenied(a.logger, a.Name(), "payments read permission denied") + } + + paymentRef := strings.TrimSpace(r.URL.Query().Get("payment_ref")) + if paymentRef == "" { + paymentRef = strings.TrimSpace(r.URL.Query().Get("paymentRef")) + } + if paymentRef == "" { + return response.BadRequest(a.logger, a.Name(), "missing_parameter", "payment_ref is required") + } + + if a.discovery == nil { + return response.Error(a.logger, a.Name(), http.StatusServiceUnavailable, "service_unavailable", "discovery client is not configured") + } + + lookupCtx, cancel := context.WithTimeout(ctx, discoveryLookupTimeout) + defer cancel() + + lookupResp, err := a.discovery.Lookup(lookupCtx) + if err != nil { + a.logger.Warn("Failed to lookup discovery registry", zap.Error(err)) + return response.Auto(a.logger, a.Name(), err) + } + + service := findDocumentsService(lookupResp.Services) + if service == nil { + return response.Error(a.logger, a.Name(), http.StatusServiceUnavailable, "service_unavailable", "billing documents service unavailable") + } + + docResp, err := a.fetchActDocument(ctx, service.InvokeURI, paymentRef) + if err != nil { + a.logger.Warn("Failed to fetch act document", zap.Error(err), mzap.ObjRef("organization_ref", orgRef)) + return documentErrorResponse(a.logger, a.Name(), err) + } + if len(docResp.GetContent()) == 0 { + return response.Error(a.logger, a.Name(), http.StatusInternalServerError, "empty_document", "document service returned empty payload") + } + + filename := strings.TrimSpace(docResp.GetFilename()) + if filename == "" { + filename = fmt.Sprintf("act_%s.pdf", paymentRef) + } + mimeType := strings.TrimSpace(docResp.GetMimeType()) + if mimeType == "" { + mimeType = "application/pdf" + } + + return func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", mimeType) + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename)) + w.WriteHeader(http.StatusOK) + if _, writeErr := w.Write(docResp.GetContent()); writeErr != nil { + a.logger.Warn("Failed to write document response", zap.Error(writeErr)) + } + } +} + +func (a *PaymentAPI) fetchActDocument(ctx context.Context, invokeURI, paymentRef string) (*documentsv1.GetDocumentResponse, error) { + dialCtx, cancel := context.WithTimeout(ctx, documentsDialTimeout) + defer cancel() + + conn, err := grpc.DialContext(dialCtx, invokeURI, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + return nil, merrors.InternalWrap(err, "dial billing documents") + } + defer conn.Close() + + client := documentsv1.NewDocumentServiceClient(conn) + + callCtx, callCancel := context.WithTimeout(ctx, documentsCallTimeout) + defer callCancel() + + return client.GetDocument(callCtx, &documentsv1.GetDocumentRequest{ + PaymentRef: paymentRef, + Type: documentsv1.DocumentType_DOCUMENT_TYPE_ACT, + }) +} + +func findDocumentsService(services []discovery.ServiceSummary) *discovery.ServiceSummary { + for _, svc := range services { + if !strings.EqualFold(svc.Service, documentsServiceName) { + continue + } + if !svc.Healthy || strings.TrimSpace(svc.InvokeURI) == "" { + continue + } + if len(svc.Ops) == 0 || hasOperation(svc.Ops, documentsOperationGet) { + return &svc + } + } + return nil +} + +func hasOperation(ops []string, target string) bool { + for _, op := range ops { + if strings.EqualFold(strings.TrimSpace(op), target) { + return true + } + } + return false +} + +func documentErrorResponse(logger mlogger.Logger, source mservice.Type, err error) http.HandlerFunc { + statusErr, ok := status.FromError(err) + if !ok { + return response.Internal(logger, source, err) + } + + switch statusErr.Code() { + case codes.InvalidArgument: + return response.BadRequest(logger, source, "invalid_argument", statusErr.Message()) + case codes.NotFound: + return response.NotFound(logger, source, statusErr.Message()) + case codes.Unimplemented: + return response.NotImplemented(logger, source, statusErr.Message()) + case codes.FailedPrecondition: + return response.Error(logger, source, http.StatusPreconditionFailed, "failed_precondition", statusErr.Message()) + case codes.Unavailable: + return response.Error(logger, source, http.StatusServiceUnavailable, "service_unavailable", statusErr.Message()) + default: + return response.Internal(logger, source, err) + } +} diff --git a/api/server/internal/server/paymentapiimp/service.go b/api/server/internal/server/paymentapiimp/service.go index cafbbdd1..e602f8c9 100644 --- a/api/server/internal/server/paymentapiimp/service.go +++ b/api/server/internal/server/paymentapiimp/service.go @@ -91,6 +91,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("/registry"), api.Get, p.listDiscoveryRegistry) apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/registry/refresh"), api.Get, p.getDiscoveryRefresh) diff --git a/frontend/pshared/lib/models/file/downloaded_file.dart b/frontend/pshared/lib/models/file/downloaded_file.dart new file mode 100644 index 00000000..a8ef752a --- /dev/null +++ b/frontend/pshared/lib/models/file/downloaded_file.dart @@ -0,0 +1,11 @@ +class DownloadedFile { + final List bytes; + final String filename; + final String mimeType; + + const DownloadedFile({ + required this.bytes, + required this.filename, + required this.mimeType, + }); +} diff --git a/frontend/pshared/lib/models/payment/operation.dart b/frontend/pshared/lib/models/payment/operation.dart index 3d679578..2beb03af 100644 --- a/frontend/pshared/lib/models/payment/operation.dart +++ b/frontend/pshared/lib/models/payment/operation.dart @@ -10,6 +10,7 @@ class OperationItem { final double toAmount; final String toCurrency; final String payId; + final String? paymentRef; final String? cardNumber; final PaymentMethod? paymentMethod; final String name; @@ -24,10 +25,11 @@ class OperationItem { required this.toAmount, required this.toCurrency, required this.payId, + this.paymentRef, this.cardNumber, this.paymentMethod, required this.name, required this.date, required this.comment, }); -} \ No newline at end of file +} diff --git a/frontend/pshared/lib/service/authorization/service.dart b/frontend/pshared/lib/service/authorization/service.dart index a73a752d..817319bc 100644 --- a/frontend/pshared/lib/service/authorization/service.dart +++ b/frontend/pshared/lib/service/authorization/service.dart @@ -79,6 +79,11 @@ class AuthorizationService { return httpr.getGETResponse(service, url, authToken: token); } + static Future getGETBinaryResponse(String service, String url) async { + final token = await TokenService.getAccessTokenSafe(); + return httpr.getBinaryGETResponse(service, url, authToken: token); + } + static Future> getPOSTResponse(String service, String url, Map body) async { final token = await TokenService.getAccessTokenSafe(); return httpr.getPOSTResponse(service, url, body, authToken: token); diff --git a/frontend/pshared/lib/service/payment/documents.dart b/frontend/pshared/lib/service/payment/documents.dart new file mode 100644 index 00000000..8394ebf3 --- /dev/null +++ b/frontend/pshared/lib/service/payment/documents.dart @@ -0,0 +1,44 @@ +import 'package:logging/logging.dart'; + +import 'package:pshared/models/file/downloaded_file.dart'; +import 'package:pshared/service/authorization/service.dart'; +import 'package:pshared/service/services.dart'; + + +class PaymentDocumentsService { + static final _logger = Logger('service.payment_documents'); + static const String _objectType = Services.payments; + + static Future getAct(String organizationRef, String paymentRef) async { + final encodedRef = Uri.encodeQueryComponent(paymentRef); + final url = '/documents/act/$organizationRef?payment_ref=$encodedRef'; + _logger.fine('Downloading act document for payment $paymentRef'); + final response = await AuthorizationService.getGETBinaryResponse(_objectType, url); + final filename = _filenameFromDisposition(response.header('content-disposition')) ?? + 'act_$paymentRef.pdf'; + final mimeType = response.header('content-type') ?? 'application/pdf'; + return DownloadedFile( + bytes: response.bytes, + filename: filename, + mimeType: mimeType, + ); + } + + static String? _filenameFromDisposition(String? disposition) { + if (disposition == null || disposition.isEmpty) return null; + final parts = disposition.split(';'); + for (final part in parts) { + final trimmed = part.trim(); + if (trimmed.toLowerCase().startsWith('filename=')) { + var value = trimmed.substring('filename='.length).trim(); + if (value.startsWith('"') && value.endsWith('"') && value.length > 1) { + value = value.substring(1, value.length - 1); + } + if (value.isNotEmpty) { + return value; + } + } + } + return null; + } +} diff --git a/frontend/pshared/lib/utils/http/requests.dart b/frontend/pshared/lib/utils/http/requests.dart index 7902d643..396d26d1 100644 --- a/frontend/pshared/lib/utils/http/requests.dart +++ b/frontend/pshared/lib/utils/http/requests.dart @@ -163,3 +163,49 @@ Future getFileUploadResponse(String service, String url, String f final streamedResponse = await _fileUploadRequest(service, url, fileName, fileType, mediaType, bytes, authToken: authToken); return FileUploaded.fromJson(await _handleResponse(http.Response.fromStream(streamedResponse))); } + +class BinaryResponse { + final List bytes; + final Map headers; + final int statusCode; + + const BinaryResponse({ + required this.bytes, + required this.headers, + required this.statusCode, + }); + + String? header(String key) => headers[key.toLowerCase()]; +} + +Future getBinaryGETResponse(String service, String url, {String? authToken}) async { + late http.Response response; + try { + response = await getRequest(service, url, authToken: authToken); + } catch (e) { + throw ConnectivityError(message: e.toString()); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + late HTTPMessage message; + try { + message = HTTPMessage.fromJson(json.decode(response.body)); + } catch (e) { + _throwConnectivityError(response, e); + } + + late ErrorResponse error; + try { + error = ErrorResponse.fromJson(message.data); + } catch (e) { + _throwConnectivityError(response, e); + } + throw error; + } + + return BinaryResponse( + bytes: response.bodyBytes, + headers: response.headers, + statusCode: response.statusCode, + ); +} diff --git a/frontend/pshared/pubspec.yaml b/frontend/pshared/pubspec.yaml index c882fb84..bcb81b4c 100644 --- a/frontend/pshared/pubspec.yaml +++ b/frontend/pshared/pubspec.yaml @@ -8,7 +8,7 @@ environment: # Add regular dependencies here. dependencies: analyzer: ^10.0.0 - json_annotation: ^4.9.0 + json_annotation: ^4.10.0 http: ^1.1.0 provider: ^6.0.5 flutter: diff --git a/frontend/pweb/lib/l10n/en.arb b/frontend/pweb/lib/l10n/en.arb index 9e1bde86..d84bf700 100644 --- a/frontend/pweb/lib/l10n/en.arb +++ b/frontend/pweb/lib/l10n/en.arb @@ -488,6 +488,14 @@ "howItWorks": "How it works?", "exampleTitle": "File Format & Sample", "downloadSampleCSV": "Download sample.csv", + "downloadAct": "Download Act", + "@downloadAct": { + "description": "Button label for downloading the acceptance act PDF" + }, + "downloadActError": "Failed to download act", + "@downloadActError": { + "description": "Error message shown when act download fails" + }, "tokenColumn": "Token (required)", "currency": "Currency", "amount": "Amount", diff --git a/frontend/pweb/lib/l10n/ru.arb b/frontend/pweb/lib/l10n/ru.arb index aaff4ae0..0896b126 100644 --- a/frontend/pweb/lib/l10n/ru.arb +++ b/frontend/pweb/lib/l10n/ru.arb @@ -488,6 +488,14 @@ "howItWorks": "Как это работает?", "exampleTitle": "Формат файла и образец", "downloadSampleCSV": "Скачать sample.csv", + "downloadAct": "Скачать акт", + "@downloadAct": { + "description": "Button label for downloading the acceptance act PDF" + }, + "downloadActError": "Не удалось скачать акт", + "@downloadActError": { + "description": "Error message shown when act download fails" + }, "tokenColumn": "Токен (обязательно)", "currency": "Валюта", "amount": "Сумма", diff --git a/frontend/pweb/lib/pages/report/page.dart b/frontend/pweb/lib/pages/report/page.dart index a9223db6..1a2c3ca9 100644 --- a/frontend/pweb/lib/pages/report/page.dart +++ b/frontend/pweb/lib/pages/report/page.dart @@ -104,6 +104,7 @@ class _OperationHistoryPageState extends State { toAmount: toAmount, toCurrency: toCurrency, payId: payId, + paymentRef: payment.paymentRef, cardNumber: null, name: name, date: _resolvePaymentDate(payment), @@ -141,6 +142,7 @@ class _OperationHistoryPageState extends State { return OperationStatus.processing; case 'settled': + case 'success': return OperationStatus.success; case 'failed': diff --git a/frontend/pweb/lib/pages/report/table/row.dart b/frontend/pweb/lib/pages/report/table/row.dart index e7715e25..a2a91313 100644 --- a/frontend/pweb/lib/pages/report/table/row.dart +++ b/frontend/pweb/lib/pages/report/table/row.dart @@ -1,23 +1,43 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + import 'package:pshared/models/payment/operation.dart'; +import 'package:pshared/models/payment/status.dart'; +import 'package:pshared/provider/organizations.dart'; +import 'package:pshared/service/payment/documents.dart'; import 'package:pshared/utils/currency.dart'; +import 'package:pweb/generated/i18n/app_localizations.dart'; import 'package:pweb/pages/report/table/badge.dart'; +import 'package:pweb/utils/download.dart'; +import 'package:pweb/utils/error/snackbar.dart'; class OperationRow { static DataRow build(OperationItem op, BuildContext context) { final isUnknownDate = op.date.millisecondsSinceEpoch == 0; final localDate = op.date.toLocal(); + final loc = AppLocalizations.of(context)!; final dateLabel = isUnknownDate ? '-' : '${TimeOfDay.fromDateTime(localDate).format(context)}\n' '${localDate.toIso8601String().split("T").first}'; + final canDownload = op.status == OperationStatus.success && + (op.paymentRef ?? '').trim().isNotEmpty; + + final documentCell = canDownload + ? TextButton.icon( + onPressed: () => _downloadAct(context, op), + icon: const Icon(Icons.download), + label: Text(loc.downloadAct), + ) + : Text(op.fileName ?? ''); + return DataRow(cells: [ DataCell(OperationStatusBadge(status: op.status)), - DataCell(Text(op.fileName ?? '')), + DataCell(documentCell), DataCell(Text('${amountToString(op.amount)} ${op.currency}')), DataCell(Text('${amountToString(op.toAmount)} ${op.toCurrency}')), DataCell(Text(op.payId)), @@ -27,4 +47,28 @@ class OperationRow { DataCell(Text(op.comment)), ]); } + + static Future _downloadAct(BuildContext context, OperationItem op) async { + final organizations = context.read(); + if (!organizations.isOrganizationSet) { + return; + } + final paymentRef = (op.paymentRef ?? '').trim(); + if (paymentRef.isEmpty) { + return; + } + + final loc = AppLocalizations.of(context)!; + await executeActionWithNotification( + context: context, + action: () async { + final file = await PaymentDocumentsService.getAct( + organizations.current.id, + paymentRef, + ); + await downloadFile(file); + }, + errorMessage: loc.downloadActError, + ); + } } diff --git a/frontend/pweb/lib/utils/download.dart b/frontend/pweb/lib/utils/download.dart new file mode 100644 index 00000000..8b06893c --- /dev/null +++ b/frontend/pweb/lib/utils/download.dart @@ -0,0 +1,25 @@ +import 'dart:typed_data'; + +import 'package:universal_html/html.dart' as html; + +import 'package:pshared/models/file/downloaded_file.dart'; + + +Future downloadFile(DownloadedFile file) async { + final blob = html.Blob( + [Uint8List.fromList(file.bytes)], + file.mimeType, + ); + + final url = html.Url.createObjectUrlFromBlob(blob); + + final anchor = html.AnchorElement(href: url) + ..download = file.filename + ..style.display = 'none'; + + html.document.body!.append(anchor); + anchor.click(); + anchor.remove(); + + html.Url.revokeObjectUrl(url); +} diff --git a/frontend/pweb/pubspec.yaml b/frontend/pweb/pubspec.yaml index e73c7f88..348e170b 100644 --- a/frontend/pweb/pubspec.yaml +++ b/frontend/pweb/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 2.5.0+495 +version: 2.6.0+507 environment: sdk: ^3.8.1 @@ -53,7 +53,7 @@ dependencies: collection: ^1.18.0 icann_tlds: ^1.0.0 flutter_timezone: ^5.0.1 - json_annotation: ^4.9.0 + json_annotation: ^4.10.0 go_router: ^17.0.0 jovial_svg: ^1.1.23 cached_network_image: ^3.4.1 @@ -70,6 +70,7 @@ dependencies: dotted_border: ^3.1.0 qr_flutter: ^4.1.0 duration: ^4.0.3 + universal_html: ^2.3.0 diff --git a/version b/version index fad066f8..914ec967 100644 --- a/version +++ b/version @@ -1 +1 @@ -2.5.0 \ No newline at end of file +2.6.0 \ No newline at end of file