package client import ( "context" "strings" "github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/payments/rail" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) // RailGatewayConfig defines metadata for the rail gateway adapter. type RailGatewayConfig struct { Rail string Network string Capabilities rail.RailCapabilities } type chainRailGateway struct { client Client rail string network string capabilities rail.RailCapabilities } // NewRailGateway wraps a chain gateway client into a rail gateway adapter. func NewRailGateway(client Client, cfg RailGatewayConfig) rail.RailGateway { railName := strings.ToUpper(strings.TrimSpace(cfg.Rail)) if railName == "" { railName = "CRYPTO" } return &chainRailGateway{ client: client, rail: railName, network: strings.ToUpper(strings.TrimSpace(cfg.Network)), capabilities: cfg.Capabilities, } } func (g *chainRailGateway) Rail() string { return g.rail } func (g *chainRailGateway) Network() string { return g.network } func (g *chainRailGateway) Capabilities() rail.RailCapabilities { return g.capabilities } func (g *chainRailGateway) Send(ctx context.Context, req rail.TransferRequest) (rail.RailResult, error) { if g.client == nil { return rail.RailResult{}, merrors.Internal("chain gateway: client is required") } orgRef := strings.TrimSpace(req.OrganizationRef) if orgRef == "" { return rail.RailResult{}, merrors.InvalidArgument("chain gateway: organization_ref is required") } source := strings.TrimSpace(req.FromAccountID) if source == "" { return rail.RailResult{}, merrors.InvalidArgument("chain gateway: from_account_id is required") } destRef := strings.TrimSpace(req.ToAccountID) if destRef == "" { return rail.RailResult{}, merrors.InvalidArgument("chain gateway: to_account_id is required") } currency := strings.TrimSpace(req.Currency) amountValue := strings.TrimSpace(req.Amount) if currency == "" || amountValue == "" { return rail.RailResult{}, merrors.InvalidArgument("chain gateway: amount is required") } reqNetwork := strings.TrimSpace(req.Network) if g.network != "" && reqNetwork != "" && !strings.EqualFold(g.network, reqNetwork) { return rail.RailResult{}, merrors.InvalidArgument("chain gateway: network mismatch") } if strings.TrimSpace(req.IdempotencyKey) == "" { return rail.RailResult{}, merrors.InvalidArgument("chain gateway: idempotency_key is required") } dest, err := g.resolveDestination(ctx, destRef, strings.TrimSpace(req.DestinationMemo)) if err != nil { return rail.RailResult{}, err } fees := toServiceFees(req.Fees) if len(fees) == 0 && req.Fee != nil { if amt := moneyFromRail(req.Fee); amt != nil { fees = []*chainv1.ServiceFeeBreakdown{{ FeeCode: "fee", Amount: amt, }} } } resp, err := g.client.SubmitTransfer(ctx, &chainv1.SubmitTransferRequest{ IdempotencyKey: strings.TrimSpace(req.IdempotencyKey), OrganizationRef: orgRef, SourceWalletRef: source, Destination: dest, Amount: &moneyv1.Money{ Currency: currency, Amount: amountValue, }, Fees: fees, Metadata: cloneMetadata(req.Metadata), ClientReference: strings.TrimSpace(req.ClientReference), }) if err != nil { return rail.RailResult{}, err } if resp == nil || resp.GetTransfer() == nil { return rail.RailResult{}, merrors.Internal("chain gateway: missing transfer response") } transfer := resp.GetTransfer() return rail.RailResult{ ReferenceID: strings.TrimSpace(transfer.GetTransferRef()), Status: statusFromTransfer(transfer.GetStatus()), FinalAmount: railMoneyFromProto(transfer.GetNetAmount()), }, nil } func (g *chainRailGateway) Observe(ctx context.Context, referenceID string) (rail.ObserveResult, error) { if g.client == nil { return rail.ObserveResult{}, merrors.Internal("chain gateway: client is required") } ref := strings.TrimSpace(referenceID) if ref == "" { return rail.ObserveResult{}, merrors.InvalidArgument("chain gateway: reference_id is required") } resp, err := g.client.GetTransfer(ctx, &chainv1.GetTransferRequest{TransferRef: ref}) if err != nil { return rail.ObserveResult{}, err } if resp == nil || resp.GetTransfer() == nil { return rail.ObserveResult{}, merrors.Internal("chain gateway: missing transfer response") } transfer := resp.GetTransfer() return rail.ObserveResult{ ReferenceID: ref, Status: statusFromTransfer(transfer.GetStatus()), FinalAmount: railMoneyFromProto(transfer.GetNetAmount()), }, nil } func (g *chainRailGateway) resolveDestination(ctx context.Context, destRef, memo string) (*chainv1.TransferDestination, error) { managed, err := g.isManagedWallet(ctx, destRef) if err != nil { return nil, err } if managed { return &chainv1.TransferDestination{ Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: destRef}, }, nil } return &chainv1.TransferDestination{ Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: destRef}, Memo: memo, }, nil } func (g *chainRailGateway) isManagedWallet(ctx context.Context, walletRef string) (bool, error) { resp, err := g.client.GetManagedWallet(ctx, &chainv1.GetManagedWalletRequest{WalletRef: walletRef}) if err != nil { if status.Code(err) == codes.NotFound { return false, nil } return false, err } if resp == nil || resp.GetWallet() == nil { return false, nil } return true, nil } func statusFromTransfer(status chainv1.TransferStatus) string { switch status { case chainv1.TransferStatus_TRANSFER_CONFIRMED: return rail.TransferStatusSuccess case chainv1.TransferStatus_TRANSFER_FAILED: return rail.TransferStatusFailed case chainv1.TransferStatus_TRANSFER_CANCELLED: return rail.TransferStatusRejected case chainv1.TransferStatus_TRANSFER_SIGNING, chainv1.TransferStatus_TRANSFER_PENDING, chainv1.TransferStatus_TRANSFER_SUBMITTED: return rail.TransferStatusPending default: return rail.TransferStatusPending } } func toServiceFees(fees []rail.FeeBreakdown) []*chainv1.ServiceFeeBreakdown { if len(fees) == 0 { return nil } result := make([]*chainv1.ServiceFeeBreakdown, 0, len(fees)) for _, fee := range fees { amount := moneyFromRail(fee.Amount) if amount == nil { continue } result = append(result, &chainv1.ServiceFeeBreakdown{ FeeCode: strings.TrimSpace(fee.FeeCode), Amount: amount, Description: strings.TrimSpace(fee.Description), }) } if len(result) == 0 { return nil } return result } func moneyFromRail(m *rail.Money) *moneyv1.Money { if m == nil { return nil } currency := strings.TrimSpace(m.GetCurrency()) amount := strings.TrimSpace(m.GetAmount()) if currency == "" || amount == "" { return nil } return &moneyv1.Money{ Currency: currency, Amount: amount, } } func railMoneyFromProto(m *moneyv1.Money) *rail.Money { if m == nil { return nil } currency := strings.TrimSpace(m.GetCurrency()) amount := strings.TrimSpace(m.GetAmount()) if currency == "" || amount == "" { return nil } return &rail.Money{ Currency: currency, Amount: amount, } } func cloneMetadata(input map[string]string) map[string]string { if len(input) == 0 { return nil } result := make(map[string]string, len(input)) for key, value := range input { result[key] = value } return result }