TRON -> TRON_MAINNET
This commit is contained in:
@@ -217,8 +217,14 @@ func (s *Service) BatchResolveDocuments(ctx context.Context, req *documentsv1.Ba
|
|||||||
for _, ref := range refs {
|
for _, ref := range refs {
|
||||||
meta := &documentsv1.DocumentMeta{PaymentRef: ref}
|
meta := &documentsv1.DocumentMeta{PaymentRef: ref}
|
||||||
if record := recordByRef[ref]; record != nil {
|
if record := recordByRef[ref]; record != nil {
|
||||||
meta.AvailableTypes = toProtoTypes(record.Available)
|
record.Normalize()
|
||||||
meta.ReadyTypes = toProtoTypes(record.Ready)
|
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)
|
items = append(items, meta)
|
||||||
}
|
}
|
||||||
@@ -294,8 +300,9 @@ func (s *Service) GetDocument(ctx context.Context, req *documentsv1.GetDocumentR
|
|||||||
record.Normalize()
|
record.Normalize()
|
||||||
|
|
||||||
targetType := model.DocumentTypeFromProto(docType)
|
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 != "" {
|
if path, ok := record.StoragePaths[targetType]; ok && path != "" {
|
||||||
@@ -310,10 +317,6 @@ func (s *Service) GetDocument(ctx context.Context, req *documentsv1.GetDocumentR
|
|||||||
}, nil
|
}, 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)
|
content, hash, genErr := s.generateActPDF(record.Snapshot)
|
||||||
if genErr != nil {
|
if genErr != nil {
|
||||||
logger.Warn("Failed to generate document", zap.Error(genErr))
|
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.StoragePaths[targetType] = path
|
||||||
record.Hashes[targetType] = hash
|
record.Hashes[targetType] = hash
|
||||||
record.Ready = appendUnique(record.Ready, targetType)
|
|
||||||
if updateErr := s.storage.Documents().Update(ctx, record); updateErr != nil {
|
if updateErr := s.storage.Documents().Update(ctx, record); updateErr != nil {
|
||||||
logger.Warn("Failed to update document record", zap.Error(updateErr))
|
logger.Warn("Failed to update document record", zap.Error(updateErr))
|
||||||
return nil, status.Error(codes.Internal, updateErr.Error())
|
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 {
|
if err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
fileHash := sha256.Sum256(finalBytes)
|
return finalBytes, footerHex, nil
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func toProtoTypes(types []model.DocumentType) []documentsv1.DocumentType {
|
func toProtoTypes(types []model.DocumentType) []documentsv1.DocumentType {
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ package documents
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -100,14 +98,11 @@ func TestGetDocument_IdempotentAndHashed(t *testing.T) {
|
|||||||
ExecutorFullName: "Jane Doe",
|
ExecutorFullName: "Jane Doe",
|
||||||
Amount: decimal.RequireFromString("100.00"),
|
Amount: decimal.RequireFromString("100.00"),
|
||||||
Currency: "USD",
|
Currency: "USD",
|
||||||
OrgLegalName: "Acme Corp",
|
|
||||||
OrgAddress: "42 Galaxy Way",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
record := &model.DocumentRecord{
|
record := &model.DocumentRecord{
|
||||||
PaymentRef: "PAY-123",
|
PaymentRef: "PAY-123",
|
||||||
Snapshot: snapshot,
|
Snapshot: snapshot,
|
||||||
Available: []model.DocumentType{model.DocumentTypeAct},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
documentsStore := &stubDocumentsStore{record: record}
|
documentsStore := &stubDocumentsStore{record: record}
|
||||||
@@ -144,12 +139,15 @@ func TestGetDocument_IdempotentAndHashed(t *testing.T) {
|
|||||||
t.Fatalf("expected content on first call")
|
t.Fatalf("expected content on first call")
|
||||||
}
|
}
|
||||||
|
|
||||||
hash1 := sha256.Sum256(resp1.Content)
|
|
||||||
stored := record.Hashes[model.DocumentTypeAct]
|
stored := record.Hashes[model.DocumentTypeAct]
|
||||||
if stored == "" {
|
if stored == "" {
|
||||||
t.Fatalf("expected stored hash")
|
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)
|
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")
|
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')
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,8 +23,6 @@ func TestTemplateRenderer_Render(t *testing.T) {
|
|||||||
ExecutorFullName: "Jane Doe",
|
ExecutorFullName: "Jane Doe",
|
||||||
Amount: decimal.RequireFromString("123.45"),
|
Amount: decimal.RequireFromString("123.45"),
|
||||||
Currency: "USD",
|
Currency: "USD",
|
||||||
OrgLegalName: "Acme Corp",
|
|
||||||
OrgAddress: "42 Galaxy Way",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
blocks, err := tmpl.Render(snapshot)
|
blocks, err := tmpl.Render(snapshot)
|
||||||
@@ -54,15 +52,15 @@ func TestTemplateRenderer_Render(t *testing.T) {
|
|||||||
if kv == nil {
|
if kv == nil {
|
||||||
t.Fatalf("expected kv block")
|
t.Fatalf("expected kv block")
|
||||||
}
|
}
|
||||||
foundOrg := false
|
foundExecutor := false
|
||||||
for _, row := range kv.Rows {
|
for _, row := range kv.Rows {
|
||||||
if len(row) >= 2 && row[0] == "Customer" && row[1] == snapshot.OrgLegalName {
|
if len(row) >= 2 && row[0] == "Executor" && row[1] == snapshot.ExecutorFullName {
|
||||||
foundOrg = true
|
foundExecutor = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !foundOrg {
|
if !foundExecutor {
|
||||||
t.Fatalf("expected org name in kv block")
|
t.Fatalf("expected executor name in kv block")
|
||||||
}
|
}
|
||||||
|
|
||||||
table := findBlock(blocks, renderer.TagTable)
|
table := findBlock(blocks, renderer.TagTable)
|
||||||
|
|||||||
@@ -46,8 +46,6 @@ type ActSnapshot struct {
|
|||||||
ExecutorFullName string `bson:"executorFullName" json:"executorFullName"`
|
ExecutorFullName string `bson:"executorFullName" json:"executorFullName"`
|
||||||
Amount decimal.Decimal `bson:"amount" json:"amount"`
|
Amount decimal.Decimal `bson:"amount" json:"amount"`
|
||||||
Currency string `bson:"currency" json:"currency"`
|
Currency string `bson:"currency" json:"currency"`
|
||||||
OrgLegalName string `bson:"orgLegalName" json:"orgLegalName"`
|
|
||||||
OrgAddress string `bson:"orgAddress" json:"orgAddress"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ActSnapshot) Normalize() {
|
func (s *ActSnapshot) Normalize() {
|
||||||
@@ -57,8 +55,6 @@ func (s *ActSnapshot) Normalize() {
|
|||||||
s.PaymentID = strings.TrimSpace(s.PaymentID)
|
s.PaymentID = strings.TrimSpace(s.PaymentID)
|
||||||
s.ExecutorFullName = strings.TrimSpace(s.ExecutorFullName)
|
s.ExecutorFullName = strings.TrimSpace(s.ExecutorFullName)
|
||||||
s.Currency = strings.TrimSpace(s.Currency)
|
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.
|
// DocumentRecord stores document metadata and cached artefacts for a payment.
|
||||||
@@ -66,8 +62,6 @@ type DocumentRecord struct {
|
|||||||
storable.Base `bson:",inline" json:",inline"`
|
storable.Base `bson:",inline" json:",inline"`
|
||||||
PaymentRef string `bson:"paymentRef" json:"paymentRef"`
|
PaymentRef string `bson:"paymentRef" json:"paymentRef"`
|
||||||
Snapshot ActSnapshot `bson:"snapshot" json:"snapshot"`
|
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"`
|
StoragePaths map[DocumentType]string `bson:"storagePaths,omitempty" json:"storagePaths,omitempty"`
|
||||||
Hashes map[DocumentType]string `bson:"hashes,omitempty" json:"hashes,omitempty"`
|
Hashes map[DocumentType]string `bson:"hashes,omitempty" json:"hashes,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,12 +34,6 @@ func NewDocuments(logger mlogger.Logger, db *mongo.Database) (*Documents, error)
|
|||||||
Keys: []ri.Key{{Field: "paymentRef", Sort: ri.Asc}},
|
Keys: []ri.Key{{Field: "paymentRef", Sort: ri.Asc}},
|
||||||
Unique: true,
|
Unique: true,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
Keys: []ri.Key{{Field: "availableTypes", Sort: ri.Asc}},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Keys: []ri.Key{{Field: "readyTypes", Sort: ri.Asc}},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, def := range indexes {
|
for _, def := range indexes {
|
||||||
|
|||||||
@@ -19,9 +19,6 @@ PARTIES
|
|||||||
This Act is made between the following Parties.
|
This Act is made between the following Parties.
|
||||||
|
|
||||||
#kv
|
#kv
|
||||||
Customer | {{ .OrgLegalName }}
|
|
||||||
Address | {{ .OrgAddress }}
|
|
||||||
|
|
||||||
Executor | {{ .ExecutorFullName }}
|
Executor | {{ .ExecutorFullName }}
|
||||||
Status | Individual
|
Status | Individual
|
||||||
|
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ func TestAssetConversionRoundTrip(t *testing.T) {
|
|||||||
ContractAddress: "0xabc",
|
ContractAddress: "0xabc",
|
||||||
}
|
}
|
||||||
model := assetFromProto(proto)
|
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)
|
t.Fatalf("assetFromProto mismatch: %#v", model)
|
||||||
}
|
}
|
||||||
back := assetToProto(model)
|
back := assetToProto(model)
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/tech/sendico/payments/orchestrator/storage/model"
|
"github.com/tech/sendico/payments/orchestrator/storage/model"
|
||||||
pmodel "github.com/tech/sendico/pkg/model"
|
|
||||||
mloggerfactory "github.com/tech/sendico/pkg/mlogger/factory"
|
mloggerfactory "github.com/tech/sendico/pkg/mlogger/factory"
|
||||||
|
pmodel "github.com/tech/sendico/pkg/model"
|
||||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/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{
|
ManagedWallet: &model.ManagedWalletEndpoint{
|
||||||
ManagedWalletRef: "wallet-1",
|
ManagedWalletRef: "wallet-1",
|
||||||
Asset: &paymenttypes.Asset{
|
Asset: &paymenttypes.Asset{
|
||||||
Chain: "TRON",
|
Chain: "TRON_MAINNET",
|
||||||
TokenSymbol: "USDT",
|
TokenSymbol: "USDT",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -52,7 +52,7 @@ func TestDefaultPlanBuilder_BuildsPlanFromRoutes_CryptoToCard(t *testing.T) {
|
|||||||
|
|
||||||
routes := &stubRouteStore{
|
routes := &stubRouteStore{
|
||||||
routes: []*model.PaymentRoute{
|
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,
|
FromRail: model.RailCrypto,
|
||||||
ToRail: model.RailCardPayout,
|
ToRail: model.RailCardPayout,
|
||||||
Network: "TRON",
|
Network: "TRON_MAINNET",
|
||||||
IsEnabled: true,
|
IsEnabled: true,
|
||||||
Steps: []model.OrchestrationStep{
|
Steps: []model.OrchestrationStep{
|
||||||
{StepID: "crypto_send", Rail: model.RailCrypto, Operation: "payout.crypto"},
|
{StepID: "crypto_send", Rail: model.RailCrypto, Operation: "payout.crypto"},
|
||||||
@@ -81,7 +81,7 @@ func TestDefaultPlanBuilder_BuildsPlanFromRoutes_CryptoToCard(t *testing.T) {
|
|||||||
ID: "crypto-tron",
|
ID: "crypto-tron",
|
||||||
InstanceID: "crypto-tron-1",
|
InstanceID: "crypto-tron-1",
|
||||||
Rail: model.RailCrypto,
|
Rail: model.RailCrypto,
|
||||||
Network: "TRON",
|
Network: "TRON_MAINNET",
|
||||||
Currencies: []string{"USDT"},
|
Currencies: []string{"USDT"},
|
||||||
Capabilities: model.RailCapabilities{
|
Capabilities: model.RailCapabilities{
|
||||||
CanPayOut: true,
|
CanPayOut: true,
|
||||||
@@ -148,7 +148,7 @@ func TestDefaultPlanBuilder_ErrorsWhenRouteMissing(t *testing.T) {
|
|||||||
Type: model.EndpointTypeManagedWallet,
|
Type: model.EndpointTypeManagedWallet,
|
||||||
ManagedWallet: &model.ManagedWalletEndpoint{
|
ManagedWallet: &model.ManagedWalletEndpoint{
|
||||||
ManagedWalletRef: "wallet-1",
|
ManagedWalletRef: "wallet-1",
|
||||||
Asset: &paymenttypes.Asset{Chain: "TRON"},
|
Asset: &paymenttypes.Asset{Chain: "TRON_MAINNET"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Destination: model.PaymentEndpoint{
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("expected plan, got error: %v", err)
|
t.Fatalf("expected plan, got error: %v", err)
|
||||||
}
|
}
|
||||||
@@ -247,7 +247,7 @@ func TestDefaultPlanBuilder_UsesSourceCurrencyForCryptoSendWithFX(t *testing.T)
|
|||||||
ManagedWallet: &model.ManagedWalletEndpoint{
|
ManagedWallet: &model.ManagedWalletEndpoint{
|
||||||
ManagedWalletRef: "wallet-2",
|
ManagedWalletRef: "wallet-2",
|
||||||
Asset: &paymenttypes.Asset{
|
Asset: &paymenttypes.Asset{
|
||||||
Chain: "TRON",
|
Chain: "TRON_MAINNET",
|
||||||
TokenSymbol: "USDT",
|
TokenSymbol: "USDT",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -275,7 +275,7 @@ func TestDefaultPlanBuilder_UsesSourceCurrencyForCryptoSendWithFX(t *testing.T)
|
|||||||
|
|
||||||
routes := &stubRouteStore{
|
routes := &stubRouteStore{
|
||||||
routes: []*model.PaymentRoute{
|
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,
|
FromRail: model.RailCrypto,
|
||||||
ToRail: model.RailCardPayout,
|
ToRail: model.RailCardPayout,
|
||||||
Network: "TRON",
|
Network: "TRON_MAINNET",
|
||||||
IsEnabled: true,
|
IsEnabled: true,
|
||||||
Steps: []model.OrchestrationStep{
|
Steps: []model.OrchestrationStep{
|
||||||
{StepID: "crypto_send", Rail: model.RailCrypto, Operation: "payout.crypto"},
|
{StepID: "crypto_send", Rail: model.RailCrypto, Operation: "payout.crypto"},
|
||||||
@@ -304,7 +304,7 @@ func TestDefaultPlanBuilder_UsesSourceCurrencyForCryptoSendWithFX(t *testing.T)
|
|||||||
ID: "crypto-tron",
|
ID: "crypto-tron",
|
||||||
InstanceID: "crypto-tron-2",
|
InstanceID: "crypto-tron-2",
|
||||||
Rail: model.RailCrypto,
|
Rail: model.RailCrypto,
|
||||||
Network: "TRON",
|
Network: "TRON_MAINNET",
|
||||||
Currencies: []string{"USDT"},
|
Currencies: []string{"USDT"},
|
||||||
Capabilities: model.RailCapabilities{
|
Capabilities: model.RailCapabilities{
|
||||||
CanPayOut: true,
|
CanPayOut: true,
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ func ensureGatewayForAction(ctx context.Context, logger mlogger.Logger, registry
|
|||||||
logger.Warn("Failed to validate gateway", zap.Error(err),
|
logger.Warn("Failed to validate gateway", zap.Error(err),
|
||||||
zap.String("instance_id", instanceID), zap.String("rail", string(rail)),
|
zap.String("instance_id", instanceID), zap.String("rail", string(rail)),
|
||||||
zap.String("network", network), zap.String("action", string(action)),
|
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
|
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),
|
logger.Warn("Failed to select gateway", zap.Error(err),
|
||||||
zap.String("instance_id", instanceID), zap.String("rail", string(rail)),
|
zap.String("instance_id", instanceID), zap.String("rail", string(rail)),
|
||||||
zap.String("network", network), zap.String("action", string(action)),
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ func TestExecutePayment_ChainFailure(t *testing.T) {
|
|||||||
store := newStubPaymentsStore()
|
store := newStubPaymentsStore()
|
||||||
routes := &stubRoutesStore{
|
routes := &stubRoutesStore{
|
||||||
routes: []*model.PaymentRoute{
|
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{
|
plans := &stubPlanTemplatesStore{
|
||||||
@@ -102,7 +102,7 @@ func TestExecutePayment_ChainFailure(t *testing.T) {
|
|||||||
{
|
{
|
||||||
FromRail: model.RailCrypto,
|
FromRail: model.RailCrypto,
|
||||||
ToRail: model.RailLedger,
|
ToRail: model.RailLedger,
|
||||||
Network: "TRON",
|
Network: "TRON_MAINNET",
|
||||||
IsEnabled: true,
|
IsEnabled: true,
|
||||||
Steps: []model.OrchestrationStep{
|
Steps: []model.OrchestrationStep{
|
||||||
{StepID: "crypto_send", Rail: model.RailCrypto, Operation: "payout.crypto"},
|
{StepID: "crypto_send", Rail: model.RailCrypto, Operation: "payout.crypto"},
|
||||||
@@ -132,7 +132,7 @@ func TestExecutePayment_ChainFailure(t *testing.T) {
|
|||||||
ID: "crypto-tron",
|
ID: "crypto-tron",
|
||||||
InstanceID: "crypto-tron-1",
|
InstanceID: "crypto-tron-1",
|
||||||
Rail: model.RailCrypto,
|
Rail: model.RailCrypto,
|
||||||
Network: "TRON",
|
Network: "TRON_MAINNET",
|
||||||
Currencies: []string{"USDT"},
|
Currencies: []string{"USDT"},
|
||||||
Capabilities: model.RailCapabilities{
|
Capabilities: model.RailCapabilities{
|
||||||
CanPayOut: true,
|
CanPayOut: true,
|
||||||
@@ -162,7 +162,7 @@ func TestExecutePayment_ChainFailure(t *testing.T) {
|
|||||||
ManagedWallet: &model.ManagedWalletEndpoint{
|
ManagedWallet: &model.ManagedWalletEndpoint{
|
||||||
ManagedWalletRef: "wallet-src",
|
ManagedWalletRef: "wallet-src",
|
||||||
Asset: &paymenttypes.Asset{
|
Asset: &paymenttypes.Asset{
|
||||||
Chain: "TRON",
|
Chain: "TRON_MAINNET",
|
||||||
TokenSymbol: "USDT",
|
TokenSymbol: "USDT",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -83,13 +83,15 @@ func NetworkName(chain chainv1.ChainNetwork) string {
|
|||||||
func NetworkAlias(chain chainv1.ChainNetwork) string {
|
func NetworkAlias(chain chainv1.ChainNetwork) string {
|
||||||
switch chain {
|
switch chain {
|
||||||
case chainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET:
|
case chainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET:
|
||||||
return "ETH"
|
return "ETHEREUM_MAINNET"
|
||||||
case chainv1.ChainNetwork_CHAIN_NETWORK_TRON_MAINNET:
|
case chainv1.ChainNetwork_CHAIN_NETWORK_TRON_MAINNET:
|
||||||
return "TRON"
|
return "TRON_MAINNET"
|
||||||
case chainv1.ChainNetwork_CHAIN_NETWORK_TRON_NILE:
|
case chainv1.ChainNetwork_CHAIN_NETWORK_TRON_NILE:
|
||||||
return "TRON_NILE"
|
return "TRON_NILE"
|
||||||
case chainv1.ChainNetwork_CHAIN_NETWORK_ARBITRUM_ONE:
|
case chainv1.ChainNetwork_CHAIN_NETWORK_ARBITRUM_ONE:
|
||||||
return "ARBITRUM"
|
return "ARBITRUM_ONE"
|
||||||
|
case chainv1.ChainNetwork_CHAIN_NETWORK_ARBITRUM_SEPOLIA:
|
||||||
|
return "ARBITRUM_SEPOLIA"
|
||||||
default:
|
default:
|
||||||
name := NetworkName(chain)
|
name := NetworkName(chain)
|
||||||
if name == "" {
|
if name == "" {
|
||||||
@@ -109,14 +111,16 @@ func NetworkFromString(value string) chainv1.ChainNetwork {
|
|||||||
return chainv1.ChainNetwork(val)
|
return chainv1.ChainNetwork(val)
|
||||||
}
|
}
|
||||||
switch normalized {
|
switch normalized {
|
||||||
case "ETH", "ETHEREUM", "ETH_MAINNET":
|
case "ETH", "ETHEREUM", "ETH_MAINNET", "ETHEREUM_MAINNET":
|
||||||
return chainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET
|
return chainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET
|
||||||
case "TRON":
|
case "TRON", "TRON_MAINNET":
|
||||||
return chainv1.ChainNetwork_CHAIN_NETWORK_TRON_MAINNET
|
return chainv1.ChainNetwork_CHAIN_NETWORK_TRON_MAINNET
|
||||||
case "TRON_NILE":
|
case "TRON_NILE":
|
||||||
return chainv1.ChainNetwork_CHAIN_NETWORK_TRON_NILE
|
return chainv1.ChainNetwork_CHAIN_NETWORK_TRON_NILE
|
||||||
case "ARB", "ARBITRUM":
|
case "ARB", "ARBITRUM", "ARBITRUM_ONE":
|
||||||
return chainv1.ChainNetwork_CHAIN_NETWORK_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_") {
|
if !strings.HasPrefix(normalized, "CHAIN_NETWORK_") {
|
||||||
normalized = "CHAIN_NETWORK_" + normalized
|
normalized = "CHAIN_NETWORK_" + normalized
|
||||||
|
|||||||
@@ -13,10 +13,11 @@ func TestNetworkName(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestNetworkAlias(t *testing.T) {
|
func TestNetworkAlias(t *testing.T) {
|
||||||
require.Equal(t, "ETH", NetworkAlias(chainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET))
|
require.Equal(t, "ETHEREUM_MAINNET", NetworkAlias(chainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET))
|
||||||
require.Equal(t, "TRON", NetworkAlias(chainv1.ChainNetwork_CHAIN_NETWORK_TRON_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, "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))
|
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("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-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_ETHEREUM_MAINNET, NetworkFromString("ETH"))
|
||||||
require.Equal(t, chainv1.ChainNetwork_CHAIN_NETWORK_ARBITRUM_ONE, NetworkFromString("ARBITRUM"))
|
require.Equal(t, chainv1.ChainNetwork_CHAIN_NETWORK_ARBITRUM_ONE, NetworkFromString("ARBITRUM"))
|
||||||
require.Equal(t, chainv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED, NetworkFromString(""))
|
require.Equal(t, chainv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED, NetworkFromString(""))
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ enum ChainNetwork {
|
|||||||
CHAIN_NETWORK_ARBITRUM_ONE = 2;
|
CHAIN_NETWORK_ARBITRUM_ONE = 2;
|
||||||
CHAIN_NETWORK_TRON_MAINNET = 4;
|
CHAIN_NETWORK_TRON_MAINNET = 4;
|
||||||
CHAIN_NETWORK_TRON_NILE = 5;
|
CHAIN_NETWORK_TRON_NILE = 5;
|
||||||
|
CHAIN_NETWORK_ARBITRUM_SEPOLIA = 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ManagedWalletStatus {
|
enum ManagedWalletStatus {
|
||||||
|
|||||||
172
api/server/internal/server/paymentapiimp/documents.go
Normal file
172
api/server/internal/server/paymentapiimp/documents.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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-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("/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("/"), 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"), api.Get, p.listDiscoveryRegistry)
|
||||||
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/registry/refresh"), api.Get, p.getDiscoveryRefresh)
|
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/registry/refresh"), api.Get, p.getDiscoveryRefresh)
|
||||||
|
|
||||||
|
|||||||
11
frontend/pshared/lib/models/file/downloaded_file.dart
Normal file
11
frontend/pshared/lib/models/file/downloaded_file.dart
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
class DownloadedFile {
|
||||||
|
final List<int> bytes;
|
||||||
|
final String filename;
|
||||||
|
final String mimeType;
|
||||||
|
|
||||||
|
const DownloadedFile({
|
||||||
|
required this.bytes,
|
||||||
|
required this.filename,
|
||||||
|
required this.mimeType,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ class OperationItem {
|
|||||||
final double toAmount;
|
final double toAmount;
|
||||||
final String toCurrency;
|
final String toCurrency;
|
||||||
final String payId;
|
final String payId;
|
||||||
|
final String? paymentRef;
|
||||||
final String? cardNumber;
|
final String? cardNumber;
|
||||||
final PaymentMethod? paymentMethod;
|
final PaymentMethod? paymentMethod;
|
||||||
final String name;
|
final String name;
|
||||||
@@ -24,6 +25,7 @@ class OperationItem {
|
|||||||
required this.toAmount,
|
required this.toAmount,
|
||||||
required this.toCurrency,
|
required this.toCurrency,
|
||||||
required this.payId,
|
required this.payId,
|
||||||
|
this.paymentRef,
|
||||||
this.cardNumber,
|
this.cardNumber,
|
||||||
this.paymentMethod,
|
this.paymentMethod,
|
||||||
required this.name,
|
required this.name,
|
||||||
|
|||||||
@@ -79,6 +79,11 @@ class AuthorizationService {
|
|||||||
return httpr.getGETResponse(service, url, authToken: token);
|
return httpr.getGETResponse(service, url, authToken: token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<httpr.BinaryResponse> getGETBinaryResponse(String service, String url) async {
|
||||||
|
final token = await TokenService.getAccessTokenSafe();
|
||||||
|
return httpr.getBinaryGETResponse(service, url, authToken: token);
|
||||||
|
}
|
||||||
|
|
||||||
static Future<Map<String, dynamic>> getPOSTResponse(String service, String url, Map<String, dynamic> body) async {
|
static Future<Map<String, dynamic>> getPOSTResponse(String service, String url, Map<String, dynamic> body) async {
|
||||||
final token = await TokenService.getAccessTokenSafe();
|
final token = await TokenService.getAccessTokenSafe();
|
||||||
return httpr.getPOSTResponse(service, url, body, authToken: token);
|
return httpr.getPOSTResponse(service, url, body, authToken: token);
|
||||||
|
|||||||
44
frontend/pshared/lib/service/payment/documents.dart
Normal file
44
frontend/pshared/lib/service/payment/documents.dart
Normal file
@@ -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<DownloadedFile> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -163,3 +163,49 @@ Future<FileUploaded?> getFileUploadResponse(String service, String url, String f
|
|||||||
final streamedResponse = await _fileUploadRequest(service, url, fileName, fileType, mediaType, bytes, authToken: authToken);
|
final streamedResponse = await _fileUploadRequest(service, url, fileName, fileType, mediaType, bytes, authToken: authToken);
|
||||||
return FileUploaded.fromJson(await _handleResponse(http.Response.fromStream(streamedResponse)));
|
return FileUploaded.fromJson(await _handleResponse(http.Response.fromStream(streamedResponse)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class BinaryResponse {
|
||||||
|
final List<int> bytes;
|
||||||
|
final Map<String, String> headers;
|
||||||
|
final int statusCode;
|
||||||
|
|
||||||
|
const BinaryResponse({
|
||||||
|
required this.bytes,
|
||||||
|
required this.headers,
|
||||||
|
required this.statusCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
String? header(String key) => headers[key.toLowerCase()];
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<BinaryResponse> 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ environment:
|
|||||||
# Add regular dependencies here.
|
# Add regular dependencies here.
|
||||||
dependencies:
|
dependencies:
|
||||||
analyzer: ^10.0.0
|
analyzer: ^10.0.0
|
||||||
json_annotation: ^4.9.0
|
json_annotation: ^4.10.0
|
||||||
http: ^1.1.0
|
http: ^1.1.0
|
||||||
provider: ^6.0.5
|
provider: ^6.0.5
|
||||||
flutter:
|
flutter:
|
||||||
|
|||||||
@@ -488,6 +488,14 @@
|
|||||||
"howItWorks": "How it works?",
|
"howItWorks": "How it works?",
|
||||||
"exampleTitle": "File Format & Sample",
|
"exampleTitle": "File Format & Sample",
|
||||||
"downloadSampleCSV": "Download sample.csv",
|
"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)",
|
"tokenColumn": "Token (required)",
|
||||||
"currency": "Currency",
|
"currency": "Currency",
|
||||||
"amount": "Amount",
|
"amount": "Amount",
|
||||||
|
|||||||
@@ -488,6 +488,14 @@
|
|||||||
"howItWorks": "Как это работает?",
|
"howItWorks": "Как это работает?",
|
||||||
"exampleTitle": "Формат файла и образец",
|
"exampleTitle": "Формат файла и образец",
|
||||||
"downloadSampleCSV": "Скачать sample.csv",
|
"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": "Токен (обязательно)",
|
"tokenColumn": "Токен (обязательно)",
|
||||||
"currency": "Валюта",
|
"currency": "Валюта",
|
||||||
"amount": "Сумма",
|
"amount": "Сумма",
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ class _OperationHistoryPageState extends State<OperationHistoryPage> {
|
|||||||
toAmount: toAmount,
|
toAmount: toAmount,
|
||||||
toCurrency: toCurrency,
|
toCurrency: toCurrency,
|
||||||
payId: payId,
|
payId: payId,
|
||||||
|
paymentRef: payment.paymentRef,
|
||||||
cardNumber: null,
|
cardNumber: null,
|
||||||
name: name,
|
name: name,
|
||||||
date: _resolvePaymentDate(payment),
|
date: _resolvePaymentDate(payment),
|
||||||
@@ -141,6 +142,7 @@ class _OperationHistoryPageState extends State<OperationHistoryPage> {
|
|||||||
return OperationStatus.processing;
|
return OperationStatus.processing;
|
||||||
|
|
||||||
case 'settled':
|
case 'settled':
|
||||||
|
case 'success':
|
||||||
return OperationStatus.success;
|
return OperationStatus.success;
|
||||||
|
|
||||||
case 'failed':
|
case 'failed':
|
||||||
|
|||||||
@@ -1,23 +1,43 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import 'package:pshared/models/payment/operation.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:pshared/utils/currency.dart';
|
||||||
|
|
||||||
|
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||||
import 'package:pweb/pages/report/table/badge.dart';
|
import 'package:pweb/pages/report/table/badge.dart';
|
||||||
|
import 'package:pweb/utils/download.dart';
|
||||||
|
import 'package:pweb/utils/error/snackbar.dart';
|
||||||
|
|
||||||
|
|
||||||
class OperationRow {
|
class OperationRow {
|
||||||
static DataRow build(OperationItem op, BuildContext context) {
|
static DataRow build(OperationItem op, BuildContext context) {
|
||||||
final isUnknownDate = op.date.millisecondsSinceEpoch == 0;
|
final isUnknownDate = op.date.millisecondsSinceEpoch == 0;
|
||||||
final localDate = op.date.toLocal();
|
final localDate = op.date.toLocal();
|
||||||
|
final loc = AppLocalizations.of(context)!;
|
||||||
final dateLabel = isUnknownDate
|
final dateLabel = isUnknownDate
|
||||||
? '-'
|
? '-'
|
||||||
: '${TimeOfDay.fromDateTime(localDate).format(context)}\n'
|
: '${TimeOfDay.fromDateTime(localDate).format(context)}\n'
|
||||||
'${localDate.toIso8601String().split("T").first}';
|
'${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: [
|
return DataRow(cells: [
|
||||||
DataCell(OperationStatusBadge(status: op.status)),
|
DataCell(OperationStatusBadge(status: op.status)),
|
||||||
DataCell(Text(op.fileName ?? '')),
|
DataCell(documentCell),
|
||||||
DataCell(Text('${amountToString(op.amount)} ${op.currency}')),
|
DataCell(Text('${amountToString(op.amount)} ${op.currency}')),
|
||||||
DataCell(Text('${amountToString(op.toAmount)} ${op.toCurrency}')),
|
DataCell(Text('${amountToString(op.toAmount)} ${op.toCurrency}')),
|
||||||
DataCell(Text(op.payId)),
|
DataCell(Text(op.payId)),
|
||||||
@@ -27,4 +47,28 @@ class OperationRow {
|
|||||||
DataCell(Text(op.comment)),
|
DataCell(Text(op.comment)),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<void> _downloadAct(BuildContext context, OperationItem op) async {
|
||||||
|
final organizations = context.read<OrganizationsProvider>();
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
25
frontend/pweb/lib/utils/download.dart
Normal file
25
frontend/pweb/lib/utils/download.dart
Normal file
@@ -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<void> 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);
|
||||||
|
}
|
||||||
@@ -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
|
# 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
|
# 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.
|
# 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:
|
environment:
|
||||||
sdk: ^3.8.1
|
sdk: ^3.8.1
|
||||||
@@ -53,7 +53,7 @@ dependencies:
|
|||||||
collection: ^1.18.0
|
collection: ^1.18.0
|
||||||
icann_tlds: ^1.0.0
|
icann_tlds: ^1.0.0
|
||||||
flutter_timezone: ^5.0.1
|
flutter_timezone: ^5.0.1
|
||||||
json_annotation: ^4.9.0
|
json_annotation: ^4.10.0
|
||||||
go_router: ^17.0.0
|
go_router: ^17.0.0
|
||||||
jovial_svg: ^1.1.23
|
jovial_svg: ^1.1.23
|
||||||
cached_network_image: ^3.4.1
|
cached_network_image: ^3.4.1
|
||||||
@@ -70,6 +70,7 @@ dependencies:
|
|||||||
dotted_border: ^3.1.0
|
dotted_border: ^3.1.0
|
||||||
qr_flutter: ^4.1.0
|
qr_flutter: ^4.1.0
|
||||||
duration: ^4.0.3
|
duration: ^4.0.3
|
||||||
|
universal_html: ^2.3.0
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user