unified gateway interface
This commit is contained in:
258
api/gateway/chain/client/rail_gateway.go
Normal file
258
api/gateway/chain/client/rail_gateway.go
Normal file
@@ -0,0 +1,258 @@
|
||||
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
|
||||
}
|
||||
@@ -22,7 +22,7 @@ require (
|
||||
|
||||
require (
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251229120209-a0d175451f7b // indirect
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251230134950-44c893854e3f // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bits-and-blooms/bitset v1.24.4 // indirect
|
||||
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
|
||||
|
||||
@@ -6,8 +6,8 @@ github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ=
|
||||
github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251229120209-a0d175451f7b h1:g/wCbvJGhOAqfGBjWnqtD6CVsXdr3G4GCbjLR6z9kNw=
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251229120209-a0d175451f7b/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251230134950-44c893854e3f h1:a5PUgHGinaD6XrLmIDLQmGHocjIjBsBAcR5gALjZvMU=
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251230134950-44c893854e3f/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
|
||||
github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0=
|
||||
github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
|
||||
@@ -36,6 +36,7 @@ type Imp struct {
|
||||
app *grpcapp.App[storage.Repository]
|
||||
|
||||
rpcClients *rpcclient.Clients
|
||||
service *gatewayservice.Service
|
||||
}
|
||||
|
||||
type config struct {
|
||||
@@ -100,6 +101,10 @@ func (i *Imp) Shutdown() {
|
||||
timeout = i.config.Runtime.ShutdownTimeout()
|
||||
}
|
||||
|
||||
if i.service != nil {
|
||||
i.service.Shutdown()
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
@@ -151,7 +156,9 @@ func (i *Imp) Start() error {
|
||||
gatewayservice.WithDriverRegistry(driverRegistry),
|
||||
gatewayservice.WithSettings(cfg.Settings),
|
||||
}
|
||||
return gatewayservice.NewService(logger, repo, producer, opts...), nil
|
||||
svc := gatewayservice.NewService(logger, repo, producer, opts...)
|
||||
i.service = svc
|
||||
return svc, nil
|
||||
}
|
||||
|
||||
app, err := grpcapp.NewApp(i.logger, "chain", cfg.Config, i.debug, repoFactory, serviceFactory)
|
||||
|
||||
@@ -3,6 +3,7 @@ package gateway
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/tech/sendico/gateway/chain/internal/appversion"
|
||||
"github.com/tech/sendico/gateway/chain/internal/keymanager"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/commands"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/commands/transfer"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
"github.com/tech/sendico/pkg/api/routers"
|
||||
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||
clockpkg "github.com/tech/sendico/pkg/clock"
|
||||
"github.com/tech/sendico/pkg/discovery"
|
||||
msg "github.com/tech/sendico/pkg/messaging"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
@@ -47,6 +49,7 @@ type Service struct {
|
||||
networkRegistry *rpcclient.Registry
|
||||
drivers *drivers.Registry
|
||||
commands commands.Registry
|
||||
announcers []*discovery.Announcer
|
||||
|
||||
chainv1.UnimplementedChainGatewayServiceServer
|
||||
}
|
||||
@@ -83,6 +86,7 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro
|
||||
Wallet: commandsWalletDeps(svc),
|
||||
Transfer: commandsTransferDeps(svc),
|
||||
})
|
||||
svc.startDiscoveryAnnouncers()
|
||||
|
||||
return svc
|
||||
}
|
||||
@@ -94,6 +98,17 @@ func (s *Service) Register(router routers.GRPC) error {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) Shutdown() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
for _, announcer := range s.announcers {
|
||||
if announcer != nil {
|
||||
announcer.Stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) CreateManagedWallet(ctx context.Context, req *chainv1.CreateManagedWalletRequest) (*chainv1.CreateManagedWalletResponse, error) {
|
||||
return executeUnary(ctx, s, "CreateManagedWallet", s.commands.CreateManagedWallet.Execute, req)
|
||||
}
|
||||
@@ -174,3 +189,29 @@ func executeUnary[TReq any, TResp any](ctx context.Context, svc *Service, method
|
||||
observeRPC(method, err, svc.clock.Now().Sub(start))
|
||||
return resp, err
|
||||
}
|
||||
|
||||
func (s *Service) startDiscoveryAnnouncers() {
|
||||
if s == nil || s.producer == nil || len(s.networks) == 0 {
|
||||
return
|
||||
}
|
||||
version := appversion.Create().Short()
|
||||
for _, network := range s.networks {
|
||||
currencies := []string{shared.NativeCurrency(network)}
|
||||
for _, token := range network.TokenConfigs {
|
||||
if token.Symbol != "" {
|
||||
currencies = append(currencies, token.Symbol)
|
||||
}
|
||||
}
|
||||
announce := discovery.Announcement{
|
||||
Service: "CRYPTO_RAIL_GATEWAY",
|
||||
Rail: "CRYPTO",
|
||||
Network: network.Name,
|
||||
Operations: []string{"balance.read", "payin.crypto", "payout.crypto", "fee.send"},
|
||||
Currencies: currencies,
|
||||
Version: version,
|
||||
}
|
||||
announcer := discovery.NewAnnouncer(s.logger, s.producer, string(mservice.ChainGateway), announce)
|
||||
announcer.Start()
|
||||
s.announcers = append(s.announcers, announcer)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
This service now supports Monetix “payout by card”.
|
||||
|
||||
## Runtime entry points
|
||||
- gRPC: `MntxGatewayService.CreateCardPayout` and `GetCardPayoutStatus`.
|
||||
- gRPC: `MntxGatewayService.CreateCardPayout`, `CreateCardTokenPayout`, `GetCardPayoutStatus`, `ListGatewayInstances`.
|
||||
- Callback HTTP server (default): `:8084/monetix/callback` for Monetix payout status notifications.
|
||||
- Metrics: Prometheus on `:9404/metrics`.
|
||||
|
||||
@@ -13,6 +13,7 @@ This service now supports Monetix “payout by card”.
|
||||
- `MONETIX_PROJECT_ID` – integer project ID
|
||||
- `MONETIX_SECRET_KEY` – signature secret
|
||||
- Optional: `allowed_currencies`, `require_customer_address`, `request_timeout_seconds`
|
||||
- Gateway descriptor: `gateway.id`, optional `gateway.currencies`, `gateway.limits`
|
||||
- Callback server: `MNTX_GATEWAY_HTTP_PORT` (exposed as 8084), `http.callback.path`, optional `allowed_cidrs`
|
||||
|
||||
## Outbound request (CreateCardPayout)
|
||||
@@ -39,7 +40,8 @@ Signature: HMAC-SHA256 over the JSON body (without `signature`), using `MONETIX_
|
||||
- `sendico_mntx_gateway_card_payout_requests_total{outcome}`
|
||||
- `sendico_mntx_gateway_card_payout_request_latency_seconds{outcome}`
|
||||
- `sendico_mntx_gateway_card_payout_callbacks_total{status}`
|
||||
- Existing RPC/payout counters remain for compatibility.
|
||||
- `sendico_mntx_gateway_rpc_requests_total{method,status}`
|
||||
- `sendico_mntx_gateway_rpc_latency_seconds{method}`
|
||||
|
||||
## Notes / PCI
|
||||
- PAN is only logged in masked form; do not persist raw PAN.
|
||||
|
||||
@@ -17,6 +17,7 @@ type Client interface {
|
||||
CreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error)
|
||||
CreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error)
|
||||
GetCardPayoutStatus(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error)
|
||||
ListGatewayInstances(ctx context.Context, req *mntxv1.ListGatewayInstancesRequest) (*mntxv1.ListGatewayInstancesResponse, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
@@ -96,3 +97,9 @@ func (g *gatewayClient) GetCardPayoutStatus(ctx context.Context, req *mntxv1.Get
|
||||
defer cancel()
|
||||
return g.client.GetCardPayoutStatus(ctx, req)
|
||||
}
|
||||
|
||||
func (g *gatewayClient) ListGatewayInstances(ctx context.Context, req *mntxv1.ListGatewayInstancesRequest) (*mntxv1.ListGatewayInstancesResponse, error) {
|
||||
ctx, cancel := g.callContext(ctx, "ListGatewayInstances")
|
||||
defer cancel()
|
||||
return g.client.ListGatewayInstances(ctx, req)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ type Fake struct {
|
||||
CreateCardPayoutFn func(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error)
|
||||
CreateCardTokenPayoutFn func(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error)
|
||||
GetCardPayoutStatusFn func(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error)
|
||||
ListGatewayInstancesFn func(ctx context.Context, req *mntxv1.ListGatewayInstancesRequest) (*mntxv1.ListGatewayInstancesResponse, error)
|
||||
}
|
||||
|
||||
func (f *Fake) CreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) {
|
||||
@@ -34,4 +35,11 @@ func (f *Fake) GetCardPayoutStatus(ctx context.Context, req *mntxv1.GetCardPayou
|
||||
return &mntxv1.GetCardPayoutStatusResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *Fake) ListGatewayInstances(ctx context.Context, req *mntxv1.ListGatewayInstancesRequest) (*mntxv1.ListGatewayInstancesResponse, error) {
|
||||
if f.ListGatewayInstancesFn != nil {
|
||||
return f.ListGatewayInstancesFn(ctx, req)
|
||||
}
|
||||
return &mntxv1.ListGatewayInstancesResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *Fake) Close() error { return nil }
|
||||
|
||||
@@ -32,6 +32,14 @@ monetix:
|
||||
status_success: "success"
|
||||
status_processing: "processing"
|
||||
|
||||
gateway:
|
||||
id: "monetix"
|
||||
is_enabled: true
|
||||
# network: "VISA_DIRECT"
|
||||
# currencies: ["RUB"]
|
||||
# limits:
|
||||
# min_amount: "0"
|
||||
|
||||
http:
|
||||
callback:
|
||||
address: ":8084"
|
||||
|
||||
@@ -6,7 +6,6 @@ replace github.com/tech/sendico/pkg => ../../pkg
|
||||
|
||||
require (
|
||||
github.com/go-chi/chi/v5 v5.2.3
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/shopspring/decimal v1.4.0
|
||||
github.com/tech/sendico/pkg v0.1.0
|
||||
@@ -23,6 +22,7 @@ require (
|
||||
github.com/casbin/govaluate v1.10.0 // indirect
|
||||
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/golang/snappy v1.0.0 // indirect
|
||||
github.com/klauspost/compress v1.18.2 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
|
||||
@@ -40,6 +40,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
|
||||
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
@@ -57,8 +59,6 @@ github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
|
||||
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
||||
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
|
||||
@@ -12,12 +12,14 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/tech/sendico/gateway/mntx/internal/appversion"
|
||||
mntxservice "github.com/tech/sendico/gateway/mntx/internal/service/gateway"
|
||||
"github.com/tech/sendico/gateway/mntx/internal/service/monetix"
|
||||
"github.com/tech/sendico/pkg/api/routers"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
msg "github.com/tech/sendico/pkg/messaging"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1"
|
||||
"github.com/tech/sendico/pkg/server/grpcapp"
|
||||
"go.uber.org/zap"
|
||||
"gopkg.in/yaml.v3"
|
||||
@@ -28,14 +30,16 @@ type Imp struct {
|
||||
file string
|
||||
debug bool
|
||||
|
||||
config *config
|
||||
app *grpcapp.App[struct{}]
|
||||
http *http.Server
|
||||
config *config
|
||||
app *grpcapp.App[struct{}]
|
||||
http *http.Server
|
||||
service *mntxservice.Service
|
||||
}
|
||||
|
||||
type config struct {
|
||||
*grpcapp.Config `yaml:",inline"`
|
||||
Monetix monetixConfig `yaml:"monetix"`
|
||||
Gateway gatewayConfig `yaml:"gateway"`
|
||||
HTTP httpConfig `yaml:"http"`
|
||||
}
|
||||
|
||||
@@ -53,6 +57,33 @@ type monetixConfig struct {
|
||||
StatusProcessing string `yaml:"status_processing"`
|
||||
}
|
||||
|
||||
type gatewayConfig struct {
|
||||
ID string `yaml:"id"`
|
||||
Network string `yaml:"network"`
|
||||
Currencies []string `yaml:"currencies"`
|
||||
IsEnabled *bool `yaml:"is_enabled"`
|
||||
Limits limitsConfig `yaml:"limits"`
|
||||
}
|
||||
|
||||
type limitsConfig struct {
|
||||
MinAmount string `yaml:"min_amount"`
|
||||
MaxAmount string `yaml:"max_amount"`
|
||||
PerTxMaxFee string `yaml:"per_tx_max_fee"`
|
||||
PerTxMinAmount string `yaml:"per_tx_min_amount"`
|
||||
PerTxMaxAmount string `yaml:"per_tx_max_amount"`
|
||||
VolumeLimit map[string]string `yaml:"volume_limit"`
|
||||
VelocityLimit map[string]int `yaml:"velocity_limit"`
|
||||
CurrencyLimits map[string]limitsOverrideCfg `yaml:"currency_limits"`
|
||||
}
|
||||
|
||||
type limitsOverrideCfg struct {
|
||||
MaxVolume string `yaml:"max_volume"`
|
||||
MinAmount string `yaml:"min_amount"`
|
||||
MaxAmount string `yaml:"max_amount"`
|
||||
MaxFee string `yaml:"max_fee"`
|
||||
MaxOps int `yaml:"max_ops"`
|
||||
}
|
||||
|
||||
type httpConfig struct {
|
||||
Callback callbackConfig `yaml:"callback"`
|
||||
}
|
||||
@@ -86,6 +117,9 @@ func (i *Imp) Shutdown() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
if i.service != nil {
|
||||
i.service.Shutdown()
|
||||
}
|
||||
if i.http != nil {
|
||||
_ = i.http.Shutdown(ctx)
|
||||
i.http = nil
|
||||
@@ -131,6 +165,17 @@ func (i *Imp) Start() error {
|
||||
zap.String("status_processing", monetixCfg.ProcessingStatus()),
|
||||
)
|
||||
|
||||
gatewayDescriptor := resolveGatewayDescriptor(cfg.Gateway, monetixCfg)
|
||||
if gatewayDescriptor != nil {
|
||||
i.logger.Info("Gateway descriptor resolved",
|
||||
zap.String("id", gatewayDescriptor.GetId()),
|
||||
zap.String("rail", gatewayDescriptor.GetRail().String()),
|
||||
zap.String("network", gatewayDescriptor.GetNetwork()),
|
||||
zap.Int("currencies", len(gatewayDescriptor.GetCurrencies())),
|
||||
zap.Bool("enabled", gatewayDescriptor.GetIsEnabled()),
|
||||
)
|
||||
}
|
||||
|
||||
i.logger.Info("Callback configuration resolved",
|
||||
zap.String("address", callbackCfg.Address),
|
||||
zap.String("path", callbackCfg.Path),
|
||||
@@ -142,8 +187,10 @@ func (i *Imp) Start() error {
|
||||
svc := mntxservice.NewService(logger,
|
||||
mntxservice.WithProducer(producer),
|
||||
mntxservice.WithMonetixConfig(monetixCfg),
|
||||
mntxservice.WithGatewayDescriptor(gatewayDescriptor),
|
||||
mntxservice.WithHTTPClient(&http.Client{Timeout: monetixCfg.Timeout()}),
|
||||
)
|
||||
i.service = svc
|
||||
|
||||
if err := i.startHTTPCallbackServer(svc, callbackCfg); err != nil {
|
||||
return nil, err
|
||||
@@ -243,6 +290,129 @@ func (i *Imp) resolveMonetixConfig(cfg monetixConfig) (monetix.Config, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func resolveGatewayDescriptor(cfg gatewayConfig, monetixCfg monetix.Config) *gatewayv1.GatewayInstanceDescriptor {
|
||||
id := strings.TrimSpace(cfg.ID)
|
||||
if id == "" {
|
||||
id = "monetix"
|
||||
}
|
||||
|
||||
network := strings.ToUpper(strings.TrimSpace(cfg.Network))
|
||||
currencies := normalizeCurrencies(cfg.Currencies)
|
||||
if len(currencies) == 0 {
|
||||
currencies = normalizeCurrencies(monetixCfg.AllowedCurrencies)
|
||||
}
|
||||
|
||||
enabled := true
|
||||
if cfg.IsEnabled != nil {
|
||||
enabled = *cfg.IsEnabled
|
||||
}
|
||||
|
||||
limits := buildGatewayLimits(cfg.Limits)
|
||||
if limits == nil {
|
||||
limits = &gatewayv1.Limits{MinAmount: "0"}
|
||||
}
|
||||
|
||||
version := strings.TrimSpace(appversion.Version)
|
||||
|
||||
return &gatewayv1.GatewayInstanceDescriptor{
|
||||
Id: id,
|
||||
Rail: gatewayv1.Rail_RAIL_CARD_PAYOUT,
|
||||
Network: network,
|
||||
Currencies: currencies,
|
||||
Capabilities: &gatewayv1.RailCapabilities{
|
||||
CanPayOut: true,
|
||||
CanPayIn: false,
|
||||
CanReadBalance: false,
|
||||
CanSendFee: false,
|
||||
RequiresObserveConfirm: false,
|
||||
},
|
||||
Limits: limits,
|
||||
Version: version,
|
||||
IsEnabled: enabled,
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeCurrencies(values []string) []string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
clean := strings.ToUpper(strings.TrimSpace(value))
|
||||
if clean == "" || seen[clean] {
|
||||
continue
|
||||
}
|
||||
seen[clean] = true
|
||||
result = append(result, clean)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func buildGatewayLimits(cfg limitsConfig) *gatewayv1.Limits {
|
||||
hasValue := strings.TrimSpace(cfg.MinAmount) != "" ||
|
||||
strings.TrimSpace(cfg.MaxAmount) != "" ||
|
||||
strings.TrimSpace(cfg.PerTxMaxFee) != "" ||
|
||||
strings.TrimSpace(cfg.PerTxMinAmount) != "" ||
|
||||
strings.TrimSpace(cfg.PerTxMaxAmount) != "" ||
|
||||
len(cfg.VolumeLimit) > 0 ||
|
||||
len(cfg.VelocityLimit) > 0 ||
|
||||
len(cfg.CurrencyLimits) > 0
|
||||
if !hasValue {
|
||||
return nil
|
||||
}
|
||||
|
||||
limits := &gatewayv1.Limits{
|
||||
MinAmount: strings.TrimSpace(cfg.MinAmount),
|
||||
MaxAmount: strings.TrimSpace(cfg.MaxAmount),
|
||||
PerTxMaxFee: strings.TrimSpace(cfg.PerTxMaxFee),
|
||||
PerTxMinAmount: strings.TrimSpace(cfg.PerTxMinAmount),
|
||||
PerTxMaxAmount: strings.TrimSpace(cfg.PerTxMaxAmount),
|
||||
}
|
||||
|
||||
if len(cfg.VolumeLimit) > 0 {
|
||||
limits.VolumeLimit = map[string]string{}
|
||||
for key, value := range cfg.VolumeLimit {
|
||||
bucket := strings.TrimSpace(key)
|
||||
amount := strings.TrimSpace(value)
|
||||
if bucket == "" || amount == "" {
|
||||
continue
|
||||
}
|
||||
limits.VolumeLimit[bucket] = amount
|
||||
}
|
||||
}
|
||||
|
||||
if len(cfg.VelocityLimit) > 0 {
|
||||
limits.VelocityLimit = map[string]int32{}
|
||||
for key, value := range cfg.VelocityLimit {
|
||||
bucket := strings.TrimSpace(key)
|
||||
if bucket == "" {
|
||||
continue
|
||||
}
|
||||
limits.VelocityLimit[bucket] = int32(value)
|
||||
}
|
||||
}
|
||||
|
||||
if len(cfg.CurrencyLimits) > 0 {
|
||||
limits.CurrencyLimits = map[string]*gatewayv1.LimitsOverride{}
|
||||
for key, override := range cfg.CurrencyLimits {
|
||||
currency := strings.ToUpper(strings.TrimSpace(key))
|
||||
if currency == "" {
|
||||
continue
|
||||
}
|
||||
limits.CurrencyLimits[currency] = &gatewayv1.LimitsOverride{
|
||||
MaxVolume: strings.TrimSpace(override.MaxVolume),
|
||||
MinAmount: strings.TrimSpace(override.MinAmount),
|
||||
MaxAmount: strings.TrimSpace(override.MaxAmount),
|
||||
MaxFee: strings.TrimSpace(override.MaxFee),
|
||||
MaxOps: int32(override.MaxOps),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return limits
|
||||
}
|
||||
|
||||
type callbackRuntimeConfig struct {
|
||||
Address string
|
||||
Path string
|
||||
|
||||
63
api/gateway/mntx/internal/service/gateway/instances.go
Normal file
63
api/gateway/mntx/internal/service/gateway/instances.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||
gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1"
|
||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||
)
|
||||
|
||||
// ListGatewayInstances exposes the Monetix gateway instance descriptors.
|
||||
func (s *Service) ListGatewayInstances(ctx context.Context, req *mntxv1.ListGatewayInstancesRequest) (*mntxv1.ListGatewayInstancesResponse, error) {
|
||||
return executeUnary(ctx, s, "ListGatewayInstances", s.handleListGatewayInstances, req)
|
||||
}
|
||||
|
||||
func (s *Service) handleListGatewayInstances(_ context.Context, _ *mntxv1.ListGatewayInstancesRequest) gsresponse.Responder[mntxv1.ListGatewayInstancesResponse] {
|
||||
items := make([]*gatewayv1.GatewayInstanceDescriptor, 0, 1)
|
||||
if s.gatewayDescriptor != nil {
|
||||
items = append(items, cloneGatewayDescriptor(s.gatewayDescriptor))
|
||||
}
|
||||
return gsresponse.Success(&mntxv1.ListGatewayInstancesResponse{Items: items})
|
||||
}
|
||||
|
||||
func cloneGatewayDescriptor(src *gatewayv1.GatewayInstanceDescriptor) *gatewayv1.GatewayInstanceDescriptor {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
cp := *src
|
||||
if src.Currencies != nil {
|
||||
cp.Currencies = append([]string(nil), src.Currencies...)
|
||||
}
|
||||
if src.Capabilities != nil {
|
||||
cap := *src.Capabilities
|
||||
cp.Capabilities = &cap
|
||||
}
|
||||
if src.Limits != nil {
|
||||
limits := *src.Limits
|
||||
if src.Limits.VolumeLimit != nil {
|
||||
limits.VolumeLimit = map[string]string{}
|
||||
for key, value := range src.Limits.VolumeLimit {
|
||||
limits.VolumeLimit[key] = value
|
||||
}
|
||||
}
|
||||
if src.Limits.VelocityLimit != nil {
|
||||
limits.VelocityLimit = map[string]int32{}
|
||||
for key, value := range src.Limits.VelocityLimit {
|
||||
limits.VelocityLimit[key] = value
|
||||
}
|
||||
}
|
||||
if src.Limits.CurrencyLimits != nil {
|
||||
limits.CurrencyLimits = map[string]*gatewayv1.LimitsOverride{}
|
||||
for key, value := range src.Limits.CurrencyLimits {
|
||||
if value == nil {
|
||||
continue
|
||||
}
|
||||
clone := *value
|
||||
limits.CurrencyLimits[key] = &clone
|
||||
}
|
||||
}
|
||||
cp.Limits = &limits
|
||||
}
|
||||
return &cp
|
||||
}
|
||||
@@ -2,15 +2,12 @@ package gateway
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
"github.com/shopspring/decimal"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -18,11 +15,6 @@ var (
|
||||
|
||||
rpcLatency *prometheus.HistogramVec
|
||||
rpcStatus *prometheus.CounterVec
|
||||
|
||||
payoutCounter *prometheus.CounterVec
|
||||
payoutAmountTotal *prometheus.CounterVec
|
||||
payoutErrorCount *prometheus.CounterVec
|
||||
payoutMissedAmounts *prometheus.CounterVec
|
||||
)
|
||||
|
||||
func initMetrics() {
|
||||
@@ -42,33 +34,6 @@ func initMetrics() {
|
||||
Help: "Total number of RPC invocations grouped by method and status.",
|
||||
}, []string{"method", "status"})
|
||||
|
||||
payoutCounter = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||
Namespace: "sendico",
|
||||
Subsystem: "mntx_gateway",
|
||||
Name: "payouts_total",
|
||||
Help: "Total payouts processed grouped by outcome.",
|
||||
}, []string{"status"})
|
||||
|
||||
payoutAmountTotal = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||
Namespace: "sendico",
|
||||
Subsystem: "mntx_gateway",
|
||||
Name: "payout_amount_total",
|
||||
Help: "Total payout amount grouped by outcome and currency.",
|
||||
}, []string{"status", "currency"})
|
||||
|
||||
payoutErrorCount = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||
Namespace: "sendico",
|
||||
Subsystem: "mntx_gateway",
|
||||
Name: "payout_errors_total",
|
||||
Help: "Payout failures grouped by reason.",
|
||||
}, []string{"reason"})
|
||||
|
||||
payoutMissedAmounts = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||
Namespace: "sendico",
|
||||
Subsystem: "mntx_gateway",
|
||||
Name: "payout_missed_amount_total",
|
||||
Help: "Total payout volume that failed grouped by reason and currency.",
|
||||
}, []string{"reason", "currency"})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -81,71 +46,6 @@ func observeRPC(method string, err error, duration time.Duration) {
|
||||
}
|
||||
}
|
||||
|
||||
func observePayoutSuccess(amount *moneyv1.Money) {
|
||||
if payoutCounter != nil {
|
||||
payoutCounter.WithLabelValues("processed").Inc()
|
||||
}
|
||||
value, currency := monetaryValue(amount)
|
||||
if value > 0 && payoutAmountTotal != nil {
|
||||
payoutAmountTotal.WithLabelValues("processed", currency).Add(value)
|
||||
}
|
||||
}
|
||||
|
||||
func observePayoutError(reason string, amount *moneyv1.Money) {
|
||||
reason = reasonLabel(reason)
|
||||
if payoutCounter != nil {
|
||||
payoutCounter.WithLabelValues("failed").Inc()
|
||||
}
|
||||
if payoutErrorCount != nil {
|
||||
payoutErrorCount.WithLabelValues(reason).Inc()
|
||||
}
|
||||
value, currency := monetaryValue(amount)
|
||||
if value <= 0 {
|
||||
return
|
||||
}
|
||||
if payoutAmountTotal != nil {
|
||||
payoutAmountTotal.WithLabelValues("failed", currency).Add(value)
|
||||
}
|
||||
if payoutMissedAmounts != nil {
|
||||
payoutMissedAmounts.WithLabelValues(reason, currency).Add(value)
|
||||
}
|
||||
}
|
||||
|
||||
func monetaryValue(amount *moneyv1.Money) (float64, string) {
|
||||
if amount == nil {
|
||||
return 0, "unknown"
|
||||
}
|
||||
val := strings.TrimSpace(amount.Amount)
|
||||
if val == "" {
|
||||
return 0, currencyLabel(amount.Currency)
|
||||
}
|
||||
dec, err := decimal.NewFromString(val)
|
||||
if err != nil {
|
||||
return 0, currencyLabel(amount.Currency)
|
||||
}
|
||||
f, _ := dec.Float64()
|
||||
if f < 0 {
|
||||
return 0, currencyLabel(amount.Currency)
|
||||
}
|
||||
return f, currencyLabel(amount.Currency)
|
||||
}
|
||||
|
||||
func currencyLabel(code string) string {
|
||||
code = strings.ToUpper(strings.TrimSpace(code))
|
||||
if code == "" {
|
||||
return "unknown"
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
func reasonLabel(reason string) string {
|
||||
reason = strings.TrimSpace(reason)
|
||||
if reason == "" {
|
||||
return "unknown"
|
||||
}
|
||||
return strings.ToLower(reason)
|
||||
}
|
||||
|
||||
func statusLabel(err error) string {
|
||||
switch {
|
||||
case err == nil:
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"github.com/tech/sendico/gateway/mntx/internal/service/monetix"
|
||||
"github.com/tech/sendico/pkg/clock"
|
||||
msg "github.com/tech/sendico/pkg/messaging"
|
||||
gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1"
|
||||
)
|
||||
|
||||
// Option configures optional service dependencies.
|
||||
@@ -42,3 +43,12 @@ func WithMonetixConfig(cfg monetix.Config) Option {
|
||||
s.config = cfg
|
||||
}
|
||||
}
|
||||
|
||||
// WithGatewayDescriptor sets the self-declared gateway instance descriptor.
|
||||
func WithGatewayDescriptor(descriptor *gatewayv1.GatewayInstanceDescriptor) Option {
|
||||
return func(s *Service) {
|
||||
if descriptor != nil {
|
||||
s.gatewayDescriptor = descriptor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (s *Service) GetPayout(ctx context.Context, req *mntxv1.GetPayoutRequest) (*mntxv1.GetPayoutResponse, error) {
|
||||
return executeUnary(ctx, s, "GetPayout", s.handleGetPayout, req)
|
||||
}
|
||||
|
||||
func (s *Service) handleGetPayout(_ context.Context, req *mntxv1.GetPayoutRequest) gsresponse.Responder[mntxv1.GetPayoutResponse] {
|
||||
ref := strings.TrimSpace(req.GetPayoutRef())
|
||||
log := s.logger.Named("payout")
|
||||
log.Info("Get payout request received", zap.String("payout_ref", ref))
|
||||
if ref == "" {
|
||||
log.Warn("Get payout request missing payout_ref")
|
||||
return gsresponse.InvalidArgument[mntxv1.GetPayoutResponse](s.logger, mservice.MntxGateway, merrors.InvalidArgument("payout_ref is required", "payout_ref"))
|
||||
}
|
||||
|
||||
payout, ok := s.store.Get(ref)
|
||||
if !ok {
|
||||
log.Warn("Payout not found", zap.String("payout_ref", ref))
|
||||
return gsresponse.NotFound[mntxv1.GetPayoutResponse](s.logger, mservice.MntxGateway, merrors.NoData(fmt.Sprintf("payout %s not found", ref)))
|
||||
}
|
||||
|
||||
log.Info("Payout retrieved", zap.String("payout_ref", ref), zap.String("status", payout.GetStatus().String()))
|
||||
return gsresponse.Success(&mntxv1.GetPayoutResponse{Payout: payout})
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
type payoutStore struct {
|
||||
mu sync.RWMutex
|
||||
payouts map[string]*mntxv1.Payout
|
||||
}
|
||||
|
||||
func newPayoutStore() *payoutStore {
|
||||
return &payoutStore{
|
||||
payouts: make(map[string]*mntxv1.Payout),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *payoutStore) Save(p *mntxv1.Payout) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.payouts[p.GetPayoutRef()] = clonePayout(p)
|
||||
}
|
||||
|
||||
func (s *payoutStore) Get(ref string) (*mntxv1.Payout, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
p, ok := s.payouts[ref]
|
||||
return clonePayout(p), ok
|
||||
}
|
||||
|
||||
func clonePayout(p *mntxv1.Payout) *mntxv1.Payout {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := proto.Clone(p)
|
||||
if cp, ok := cloned.(*mntxv1.Payout); ok {
|
||||
return cp
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
messaging "github.com/tech/sendico/pkg/messaging/envelope"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
nm "github.com/tech/sendico/pkg/model/notification"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||
"go.uber.org/zap"
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
func (s *Service) SubmitPayout(ctx context.Context, req *mntxv1.SubmitPayoutRequest) (*mntxv1.SubmitPayoutResponse, error) {
|
||||
return executeUnary(ctx, s, "SubmitPayout", s.handleSubmitPayout, req)
|
||||
}
|
||||
|
||||
func (s *Service) handleSubmitPayout(_ context.Context, req *mntxv1.SubmitPayoutRequest) gsresponse.Responder[mntxv1.SubmitPayoutResponse] {
|
||||
log := s.logger.Named("payout")
|
||||
log.Info("Submit payout request received",
|
||||
zap.String("idempotency_key", strings.TrimSpace(req.GetIdempotencyKey())),
|
||||
zap.String("organization_ref", strings.TrimSpace(req.GetOrganizationRef())),
|
||||
zap.String("currency", strings.TrimSpace(req.GetAmount().GetCurrency())),
|
||||
zap.String("amount", strings.TrimSpace(req.GetAmount().GetAmount())),
|
||||
)
|
||||
|
||||
payout, err := s.buildPayout(req)
|
||||
if err != nil {
|
||||
log.Warn("Submit payout validation failed", zap.Error(err))
|
||||
return gsresponse.Auto[mntxv1.SubmitPayoutResponse](s.logger, mservice.MntxGateway, err)
|
||||
}
|
||||
|
||||
s.store.Save(payout)
|
||||
s.emitEvent(payout, nm.NAPending)
|
||||
go s.completePayout(payout, strings.TrimSpace(req.GetSimulatedFailureReason()))
|
||||
|
||||
log.Info("Payout accepted", zap.String("payout_ref", payout.GetPayoutRef()), zap.String("status", payout.GetStatus().String()))
|
||||
return gsresponse.Success(&mntxv1.SubmitPayoutResponse{Payout: payout})
|
||||
}
|
||||
|
||||
func (s *Service) buildPayout(req *mntxv1.SubmitPayoutRequest) (*mntxv1.Payout, error) {
|
||||
if req == nil {
|
||||
return nil, newPayoutError("invalid_request", merrors.InvalidArgument("request cannot be empty"))
|
||||
}
|
||||
|
||||
idempotencyKey := strings.TrimSpace(req.IdempotencyKey)
|
||||
if idempotencyKey == "" {
|
||||
return nil, newPayoutError("missing_idempotency_key", merrors.InvalidArgument("idempotency_key is required", "idempotency_key"))
|
||||
}
|
||||
|
||||
orgRef := strings.TrimSpace(req.OrganizationRef)
|
||||
if orgRef == "" {
|
||||
return nil, newPayoutError("missing_organization_ref", merrors.InvalidArgument("organization_ref is required", "organization_ref"))
|
||||
}
|
||||
|
||||
if err := validateAmount(req.Amount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := validateDestination(req.Destination); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if reason := strings.TrimSpace(req.SimulatedFailureReason); reason != "" {
|
||||
return nil, newPayoutError(normalizeReason(reason), merrors.InvalidArgument("simulated payout failure requested"))
|
||||
}
|
||||
|
||||
now := timestamppb.New(s.clock.Now())
|
||||
payout := &mntxv1.Payout{
|
||||
PayoutRef: newPayoutRef(),
|
||||
IdempotencyKey: idempotencyKey,
|
||||
OrganizationRef: orgRef,
|
||||
Destination: req.Destination,
|
||||
Amount: req.Amount,
|
||||
Description: strings.TrimSpace(req.Description),
|
||||
Metadata: req.Metadata,
|
||||
Status: mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
return payout, nil
|
||||
}
|
||||
|
||||
func (s *Service) completePayout(original *mntxv1.Payout, simulatedFailure string) {
|
||||
log := s.logger.Named("payout")
|
||||
outcome := clonePayout(original)
|
||||
if outcome == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Simulate async processing delay for realism.
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
|
||||
outcome.UpdatedAt = timestamppb.New(s.clock.Now())
|
||||
|
||||
if simulatedFailure != "" {
|
||||
outcome.Status = mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED
|
||||
outcome.FailureReason = simulatedFailure
|
||||
observePayoutError(simulatedFailure, outcome.Amount)
|
||||
s.store.Save(outcome)
|
||||
s.emitEvent(outcome, nm.NAUpdated)
|
||||
log.Info("Payout completed", zap.String("payout_ref", outcome.GetPayoutRef()), zap.String("status", outcome.GetStatus().String()), zap.String("failure_reason", simulatedFailure))
|
||||
return
|
||||
}
|
||||
|
||||
outcome.Status = mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED
|
||||
observePayoutSuccess(outcome.Amount)
|
||||
s.store.Save(outcome)
|
||||
s.emitEvent(outcome, nm.NAUpdated)
|
||||
log.Info("Payout completed", zap.String("payout_ref", outcome.GetPayoutRef()), zap.String("status", outcome.GetStatus().String()))
|
||||
}
|
||||
|
||||
func (s *Service) emitEvent(payout *mntxv1.Payout, action nm.NotificationAction) {
|
||||
if payout == nil || s.producer == nil {
|
||||
return
|
||||
}
|
||||
|
||||
payload, err := protojson.Marshal(&mntxv1.PayoutStatusChangedEvent{Payout: payout})
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to marshal payout event", zapError(err))
|
||||
return
|
||||
}
|
||||
|
||||
env := messaging.CreateEnvelope(string(mservice.MntxGateway), model.NewNotification(mservice.MntxGateway, action))
|
||||
if _, err := env.Wrap(payload); err != nil {
|
||||
s.logger.Warn("Failed to wrap payout event payload", zapError(err))
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.producer.SendMessage(env); err != nil {
|
||||
s.logger.Warn("Failed to publish payout event", zapError(err))
|
||||
}
|
||||
}
|
||||
|
||||
func zapError(err error) zap.Field {
|
||||
return zap.Error(err)
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||
)
|
||||
|
||||
func validateAmount(amount *moneyv1.Money) error {
|
||||
if amount == nil {
|
||||
return newPayoutError("missing_amount", merrors.InvalidArgument("amount is required", "amount"))
|
||||
}
|
||||
|
||||
if strings.TrimSpace(amount.Currency) == "" {
|
||||
return newPayoutError("missing_currency", merrors.InvalidArgument("amount currency is required", "amount.currency"))
|
||||
}
|
||||
|
||||
val := strings.TrimSpace(amount.Amount)
|
||||
if val == "" {
|
||||
return newPayoutError("missing_amount_value", merrors.InvalidArgument("amount value is required", "amount.amount"))
|
||||
}
|
||||
dec, err := decimal.NewFromString(val)
|
||||
if err != nil {
|
||||
return newPayoutError("invalid_amount", merrors.InvalidArgument("amount must be a decimal value", "amount.amount"))
|
||||
}
|
||||
if dec.Sign() <= 0 {
|
||||
return newPayoutError("non_positive_amount", merrors.InvalidArgument("amount must be positive", "amount.amount"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDestination(dest *mntxv1.PayoutDestination) error {
|
||||
if dest == nil {
|
||||
return newPayoutError("missing_destination", merrors.InvalidArgument("destination is required", "destination"))
|
||||
}
|
||||
|
||||
if bank := dest.GetBankAccount(); bank != nil {
|
||||
return validateBankAccount(bank)
|
||||
}
|
||||
|
||||
if card := dest.GetCard(); card != nil {
|
||||
return validateCardDestination(card)
|
||||
}
|
||||
|
||||
return newPayoutError("invalid_destination", merrors.InvalidArgument("destination must include bank_account or card", "destination"))
|
||||
}
|
||||
|
||||
func validateBankAccount(dest *mntxv1.BankAccount) error {
|
||||
if dest == nil {
|
||||
return newPayoutError("missing_destination", merrors.InvalidArgument("destination is required", "destination"))
|
||||
}
|
||||
iban := strings.TrimSpace(dest.Iban)
|
||||
holder := strings.TrimSpace(dest.AccountHolder)
|
||||
|
||||
if iban == "" && holder == "" {
|
||||
return newPayoutError("invalid_destination", merrors.InvalidArgument("destination must include iban or account_holder", "destination"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCardDestination(card *mntxv1.CardDestination) error {
|
||||
if card == nil {
|
||||
return newPayoutError("missing_destination", merrors.InvalidArgument("destination.card is required", "destination.card"))
|
||||
}
|
||||
|
||||
pan := strings.TrimSpace(card.GetPan())
|
||||
token := strings.TrimSpace(card.GetToken())
|
||||
if pan == "" && token == "" {
|
||||
return newPayoutError("invalid_card_destination", merrors.InvalidArgument("card destination must include pan or token", "destination.card"))
|
||||
}
|
||||
|
||||
if strings.TrimSpace(card.GetCardholderName()) == "" {
|
||||
return newPayoutError("missing_cardholder_name", merrors.InvalidArgument("cardholder_name is required", "destination.card.cardholder_name"))
|
||||
}
|
||||
|
||||
month := strings.TrimSpace(card.GetExpMonth())
|
||||
year := strings.TrimSpace(card.GetExpYear())
|
||||
if pan != "" {
|
||||
if err := validateExpiry(month, year); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateExpiry(month, year string) error {
|
||||
if month == "" || year == "" {
|
||||
return newPayoutError("missing_expiry", merrors.InvalidArgument("exp_month and exp_year are required for card payouts", "destination.card.expiry"))
|
||||
}
|
||||
|
||||
m, err := strconv.Atoi(month)
|
||||
if err != nil || m < 1 || m > 12 {
|
||||
return newPayoutError("invalid_expiry_month", merrors.InvalidArgument("exp_month must be between 01 and 12", "destination.card.exp_month"))
|
||||
}
|
||||
|
||||
if _, err := strconv.Atoi(year); err != nil || len(year) < 2 {
|
||||
return newPayoutError("invalid_expiry_year", merrors.InvalidArgument("exp_year must be numeric", "destination.card.exp_year"))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -5,28 +5,31 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/tech/sendico/gateway/mntx/internal/appversion"
|
||||
"github.com/tech/sendico/gateway/mntx/internal/service/monetix"
|
||||
"github.com/tech/sendico/pkg/api/routers"
|
||||
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||
clockpkg "github.com/tech/sendico/pkg/clock"
|
||||
"github.com/tech/sendico/pkg/discovery"
|
||||
msg "github.com/tech/sendico/pkg/messaging"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1"
|
||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||
"go.uber.org/zap"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
logger mlogger.Logger
|
||||
clock clockpkg.Clock
|
||||
producer msg.Producer
|
||||
store *payoutStore
|
||||
cardStore *cardPayoutStore
|
||||
config monetix.Config
|
||||
httpClient *http.Client
|
||||
card *cardPayoutProcessor
|
||||
logger mlogger.Logger
|
||||
clock clockpkg.Clock
|
||||
producer msg.Producer
|
||||
cardStore *cardPayoutStore
|
||||
config monetix.Config
|
||||
httpClient *http.Client
|
||||
card *cardPayoutProcessor
|
||||
gatewayDescriptor *gatewayv1.GatewayInstanceDescriptor
|
||||
announcer *discovery.Announcer
|
||||
|
||||
mntxv1.UnimplementedMntxGatewayServiceServer
|
||||
}
|
||||
@@ -58,7 +61,6 @@ func NewService(logger mlogger.Logger, opts ...Option) *Service {
|
||||
svc := &Service{
|
||||
logger: logger.Named("service"),
|
||||
clock: clockpkg.NewSystem(),
|
||||
store: newPayoutStore(),
|
||||
cardStore: newCardPayoutStore(),
|
||||
config: monetix.DefaultConfig(),
|
||||
}
|
||||
@@ -86,6 +88,7 @@ func NewService(logger mlogger.Logger, opts ...Option) *Service {
|
||||
}
|
||||
|
||||
svc.card = newCardPayoutProcessor(svc.logger, svc.config, svc.clock, svc.cardStore, svc.httpClient, svc.producer)
|
||||
svc.startDiscoveryAnnouncer()
|
||||
|
||||
return svc
|
||||
}
|
||||
@@ -97,6 +100,15 @@ func (s *Service) Register(router routers.GRPC) error {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) Shutdown() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
if s.announcer != nil {
|
||||
s.announcer.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
func executeUnary[TReq any, TResp any](ctx context.Context, svc *Service, method string, handler func(context.Context, *TReq) gsresponse.Responder[TResp], req *TReq) (*TResp, error) {
|
||||
log := svc.logger.Named("rpc")
|
||||
log.Info("RPC request started", zap.String("method", method))
|
||||
@@ -114,10 +126,6 @@ func executeUnary[TReq any, TResp any](ctx context.Context, svc *Service, method
|
||||
return resp, err
|
||||
}
|
||||
|
||||
func newPayoutRef() string {
|
||||
return "pyt_" + strings.ReplaceAll(uuid.New().String(), "-", "")
|
||||
}
|
||||
|
||||
func normalizeReason(reason string) string {
|
||||
return strings.ToLower(strings.TrimSpace(reason))
|
||||
}
|
||||
@@ -128,3 +136,59 @@ func newPayoutError(reason string, err error) error {
|
||||
err: err,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) startDiscoveryAnnouncer() {
|
||||
if s == nil || s.producer == nil {
|
||||
return
|
||||
}
|
||||
announce := discovery.Announcement{
|
||||
Service: "CARD_PAYOUT_RAIL_GATEWAY",
|
||||
Rail: "CARD_PAYOUT",
|
||||
Operations: []string{"payout.card"},
|
||||
Version: appversion.Create().Short(),
|
||||
}
|
||||
if s.gatewayDescriptor != nil {
|
||||
if id := strings.TrimSpace(s.gatewayDescriptor.GetId()); id != "" {
|
||||
announce.ID = id
|
||||
}
|
||||
announce.Network = strings.TrimSpace(s.gatewayDescriptor.GetNetwork())
|
||||
announce.Currencies = append([]string(nil), s.gatewayDescriptor.GetCurrencies()...)
|
||||
announce.Limits = limitsFromDescriptor(s.gatewayDescriptor.GetLimits())
|
||||
}
|
||||
s.announcer = discovery.NewAnnouncer(s.logger, s.producer, string(mservice.MntxGateway), announce)
|
||||
s.announcer.Start()
|
||||
}
|
||||
|
||||
func limitsFromDescriptor(src *gatewayv1.Limits) *discovery.Limits {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
limits := &discovery.Limits{
|
||||
MinAmount: strings.TrimSpace(src.GetMinAmount()),
|
||||
MaxAmount: strings.TrimSpace(src.GetMaxAmount()),
|
||||
VolumeLimit: map[string]string{},
|
||||
VelocityLimit: map[string]int{},
|
||||
}
|
||||
for key, value := range src.GetVolumeLimit() {
|
||||
k := strings.TrimSpace(key)
|
||||
v := strings.TrimSpace(value)
|
||||
if k == "" || v == "" {
|
||||
continue
|
||||
}
|
||||
limits.VolumeLimit[k] = v
|
||||
}
|
||||
for key, value := range src.GetVelocityLimit() {
|
||||
k := strings.TrimSpace(key)
|
||||
if k == "" {
|
||||
continue
|
||||
}
|
||||
limits.VelocityLimit[k] = int(value)
|
||||
}
|
||||
if len(limits.VolumeLimit) == 0 {
|
||||
limits.VolumeLimit = nil
|
||||
}
|
||||
if len(limits.VelocityLimit) == 0 {
|
||||
limits.VelocityLimit = nil
|
||||
}
|
||||
return limits
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user