unified gateway interface

This commit is contained in:
Stephan D
2025-12-31 17:47:32 +01:00
parent 19b7b69bd8
commit 97ba7500dc
104 changed files with 8228 additions and 1742 deletions

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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