TRON -> TRON_MAINNET

This commit is contained in:
Stephan D
2026-02-01 03:35:16 +01:00
parent be3fd6075f
commit 8faed5cbaa
28 changed files with 452 additions and 90 deletions

View File

@@ -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 {

View File

@@ -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')
}

View File

@@ -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)

View File

@@ -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"`
}

View File

@@ -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 {

View File

@@ -19,9 +19,6 @@ PARTIES
This Act is made between the following Parties.
#kv
Customer | {{ .OrgLegalName }}
Address | {{ .OrgAddress }}
Executor | {{ .ExecutorFullName }}
Status | Individual

View File

@@ -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)

View File

@@ -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,

View File

@@ -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
}

View File

@@ -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",
},
},

View File

@@ -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

View File

@@ -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(""))

View File

@@ -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 {

View 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)
}
}

View File

@@ -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)