259 lines
7.4 KiB
Go
259 lines
7.4 KiB
Go
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
|
|
}
|