fixed linting config

This commit is contained in:
Stephan D
2026-02-12 13:00:37 +01:00
parent 97395acd8f
commit 7cbcbb4b3c
42 changed files with 1813 additions and 237 deletions

View File

@@ -13,7 +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`
- Gateway descriptor: `gateway.id`, optional `gateway.currencies`, `gateway.limits` (for per-payout minimum use `gateway.limits.per_tx_min_amount`)
- Callback server: `MNTX_GATEWAY_HTTP_PORT` (exposed as 8084), `http.callback.path`, optional `allowed_cidrs`
## Outbound request (CreateCardPayout)

View File

@@ -51,7 +51,7 @@ gateway:
network: "MIR"
currencies: ["RUB"]
limits:
min_amount: "0"
per_tx_min_amount: "0"
http:
callback:

View File

@@ -51,7 +51,7 @@ gateway:
network: "MIR"
currencies: ["RUB"]
limits:
min_amount: "0"
per_tx_min_amount: "100.00"
http:
callback:

View File

@@ -4,9 +4,11 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"github.com/shopspring/decimal"
"github.com/tech/sendico/gateway/mntx/internal/service/monetix"
"github.com/tech/sendico/gateway/mntx/storage"
"github.com/tech/sendico/gateway/mntx/storage/model"
@@ -15,6 +17,7 @@ import (
"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"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
@@ -27,6 +30,9 @@ type cardPayoutProcessor struct {
store storage.Repository
httpClient *http.Client
producer msg.Producer
perTxMinAmountMinor int64
perTxMinAmountMinorByCurrency map[string]int64
}
func mergePayoutStateWithExisting(state, existing *model.CardPayout) {
@@ -118,6 +124,90 @@ func newCardPayoutProcessor(
}
}
func (p *cardPayoutProcessor) applyGatewayDescriptor(descriptor *gatewayv1.GatewayInstanceDescriptor) {
if p == nil {
return
}
minAmountMinor, perCurrency := perTxMinAmountPolicy(descriptor)
p.perTxMinAmountMinor = minAmountMinor
p.perTxMinAmountMinorByCurrency = perCurrency
}
func perTxMinAmountPolicy(descriptor *gatewayv1.GatewayInstanceDescriptor) (int64, map[string]int64) {
if descriptor == nil || descriptor.GetLimits() == nil {
return 0, nil
}
limits := descriptor.GetLimits()
globalMin, _ := decimalAmountToMinor(firstNonEmpty(limits.GetPerTxMinAmount(), limits.GetMinAmount()))
perCurrency := map[string]int64{}
for currency, override := range limits.GetCurrencyLimits() {
if override == nil {
continue
}
minor, ok := decimalAmountToMinor(override.GetMinAmount())
if !ok {
continue
}
code := strings.ToUpper(strings.TrimSpace(currency))
if code == "" {
continue
}
perCurrency[code] = minor
}
if len(perCurrency) == 0 {
perCurrency = nil
}
return globalMin, perCurrency
}
func decimalAmountToMinor(raw string) (int64, bool) {
raw = strings.TrimSpace(raw)
if raw == "" {
return 0, false
}
value, err := decimal.NewFromString(raw)
if err != nil || !value.IsPositive() {
return 0, false
}
minor := value.Mul(decimal.NewFromInt(100)).Ceil().IntPart()
if minor <= 0 {
return 0, false
}
return minor, true
}
func (p *cardPayoutProcessor) validatePerTxMinimum(amountMinor int64, currency string) error {
if p == nil {
return nil
}
minAmountMinor := p.perTxMinimum(currency)
if minAmountMinor <= 0 || amountMinor >= minAmountMinor {
return nil
}
return newPayoutError("amount_below_minimum", merrors.InvalidArgument(
fmt.Sprintf("amount_minor must be at least %d", minAmountMinor),
"amount_minor",
))
}
func (p *cardPayoutProcessor) perTxMinimum(currency string) int64 {
if p == nil {
return 0
}
minAmountMinor := p.perTxMinAmountMinor
if len(p.perTxMinAmountMinorByCurrency) == 0 {
return minAmountMinor
}
code := strings.ToUpper(strings.TrimSpace(currency))
if code == "" {
return minAmountMinor
}
if override, ok := p.perTxMinAmountMinorByCurrency[code]; ok && override > 0 {
return override
}
return minAmountMinor
}
func (p *cardPayoutProcessor) Submit(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) {
if p == nil {
return nil, merrors.Internal("card payout processor not initialised")
@@ -147,6 +237,17 @@ func (p *cardPayoutProcessor) Submit(ctx context.Context, req *mntxv1.CardPayout
)
return nil, err
}
if err := p.validatePerTxMinimum(req.GetAmountMinor(), req.GetCurrency()); err != nil {
p.logger.Warn("Card payout amount below configured minimum",
zap.String("payout_id", req.GetPayoutId()),
zap.String("customer_id", req.GetCustomerId()),
zap.Int64("amount_minor", req.GetAmountMinor()),
zap.String("currency", strings.ToUpper(strings.TrimSpace(req.GetCurrency()))),
zap.Int64("configured_min_amount_minor", p.perTxMinimum(req.GetCurrency())),
zap.Error(err),
)
return nil, err
}
projectID, err := p.resolveProjectID(req.GetProjectId(), "payout_id", req.GetPayoutId())
if err != nil {
@@ -257,6 +358,17 @@ func (p *cardPayoutProcessor) SubmitToken(ctx context.Context, req *mntxv1.CardT
)
return nil, err
}
if err := p.validatePerTxMinimum(req.GetAmountMinor(), req.GetCurrency()); err != nil {
p.logger.Warn("Card token payout amount below configured minimum",
zap.String("payout_id", req.GetPayoutId()),
zap.String("customer_id", req.GetCustomerId()),
zap.Int64("amount_minor", req.GetAmountMinor()),
zap.String("currency", strings.ToUpper(strings.TrimSpace(req.GetCurrency()))),
zap.Int64("configured_min_amount_minor", p.perTxMinimum(req.GetCurrency())),
zap.Error(err),
)
return nil, err
}
projectID, err := p.resolveProjectID(req.GetProjectId(), "payout_id", req.GetPayoutId())
if err != nil {

View File

@@ -14,6 +14,7 @@ import (
"github.com/tech/sendico/gateway/mntx/storage/model"
clockpkg "github.com/tech/sendico/pkg/clock"
"github.com/tech/sendico/pkg/merrors"
gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
"go.uber.org/zap"
)
@@ -119,6 +120,63 @@ func TestCardPayoutProcessor_Submit_MissingConfig(t *testing.T) {
}
}
func TestCardPayoutProcessor_Submit_RejectsAmountBelowConfiguredMinimum(t *testing.T) {
cfg := monetix.Config{
BaseURL: "https://monetix.test",
SecretKey: "secret",
AllowedCurrencies: []string{"RUB"},
}
repo := newMockRepository()
processor := newCardPayoutProcessor(
zap.NewNop(),
cfg,
staticClock{now: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)},
repo,
&http.Client{},
nil,
)
processor.applyGatewayDescriptor(&gatewayv1.GatewayInstanceDescriptor{
Limits: &gatewayv1.Limits{
PerTxMinAmount: "20.00",
},
})
req := validCardPayoutRequest() // 15.00 RUB
_, err := processor.Submit(context.Background(), req)
requireReason(t, err, "amount_below_minimum")
}
func TestCardPayoutProcessor_SubmitToken_RejectsAmountBelowCurrencyMinimum(t *testing.T) {
cfg := monetix.Config{
BaseURL: "https://monetix.test",
SecretKey: "secret",
AllowedCurrencies: []string{"USD"},
}
repo := newMockRepository()
processor := newCardPayoutProcessor(
zap.NewNop(),
cfg,
staticClock{now: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)},
repo,
&http.Client{},
nil,
)
processor.applyGatewayDescriptor(&gatewayv1.GatewayInstanceDescriptor{
Limits: &gatewayv1.Limits{
PerTxMinAmount: "20.00",
CurrencyLimits: map[string]*gatewayv1.LimitsOverride{
"USD": {MinAmount: "30.00"},
},
},
})
req := validCardTokenPayoutRequest() // 25.00 USD
_, err := processor.SubmitToken(context.Background(), req)
requireReason(t, err, "amount_below_minimum")
}
func TestCardPayoutProcessor_ProcessCallback(t *testing.T) {
cfg := monetix.Config{
SecretKey: "secret",

View File

@@ -85,6 +85,7 @@ func NewService(logger mlogger.Logger, opts ...Option) *Service {
}
svc.card = newCardPayoutProcessor(svc.logger, svc.config, svc.clock, svc.storage, svc.httpClient, svc.producer)
svc.card.applyGatewayDescriptor(svc.gatewayDescriptor)
svc.startDiscoveryAnnouncer()
return svc
@@ -149,44 +150,132 @@ func (s *Service) startDiscoveryAnnouncer() {
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())
announce.Currencies = currenciesFromDescriptor(s.gatewayDescriptor)
}
s.announcer = discovery.NewAnnouncer(s.logger, s.producer, string(mservice.MntxGateway), announce)
s.announcer.Start()
}
func limitsFromDescriptor(src *gatewayv1.Limits) *discovery.Limits {
func currenciesFromDescriptor(src *gatewayv1.GatewayInstanceDescriptor) []discovery.CurrencyAnnouncement {
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{},
network := strings.TrimSpace(src.GetNetwork())
limitsCfg := src.GetLimits()
values := src.GetCurrencies()
if len(values) == 0 {
return nil
}
for key, value := range src.GetVolumeLimit() {
k := strings.TrimSpace(key)
v := strings.TrimSpace(value)
if k == "" || v == "" {
seen := map[string]bool{}
result := make([]discovery.CurrencyAnnouncement, 0, len(values))
for _, value := range values {
currency := strings.ToUpper(strings.TrimSpace(value))
if currency == "" || seen[currency] {
continue
}
limits.VolumeLimit[k] = v
seen[currency] = true
result = append(result, discovery.CurrencyAnnouncement{
Currency: currency,
Network: network,
Limits: currencyLimitsFromDescriptor(limitsCfg, currency),
})
}
for key, value := range src.GetVelocityLimit() {
k := strings.TrimSpace(key)
if k == "" {
if len(result) == 0 {
return nil
}
return result
}
func currencyLimitsFromDescriptor(src *gatewayv1.Limits, currency string) *discovery.CurrencyLimits {
if src == nil {
return nil
}
amountMin := firstNonEmpty(src.GetPerTxMinAmount(), src.GetMinAmount())
amountMax := firstNonEmpty(src.GetPerTxMaxAmount(), src.GetMaxAmount())
limits := &discovery.CurrencyLimits{}
if amountMin != "" || amountMax != "" {
limits.Amount = &discovery.CurrencyAmount{
Min: amountMin,
Max: amountMax,
}
}
running := &discovery.CurrencyRunningLimits{}
for bucket, max := range src.GetVolumeLimit() {
bucket = strings.TrimSpace(bucket)
max = strings.TrimSpace(max)
if bucket == "" || max == "" {
continue
}
limits.VelocityLimit[k] = int(value)
running.Volume = append(running.Volume, discovery.VolumeLimit{
Window: discovery.Window{
Raw: bucket,
Named: bucket,
},
Max: max,
})
}
if len(limits.VolumeLimit) == 0 {
limits.VolumeLimit = nil
for bucket, max := range src.GetVelocityLimit() {
bucket = strings.TrimSpace(bucket)
if bucket == "" || max <= 0 {
continue
}
running.Velocity = append(running.Velocity, discovery.VelocityLimit{
Window: discovery.Window{
Raw: bucket,
Named: bucket,
},
Max: int(max),
})
}
if len(limits.VelocityLimit) == 0 {
limits.VelocityLimit = nil
if override := src.GetCurrencyLimits()[strings.ToUpper(strings.TrimSpace(currency))]; override != nil {
if min := strings.TrimSpace(override.GetMinAmount()); min != "" {
if limits.Amount == nil {
limits.Amount = &discovery.CurrencyAmount{}
}
limits.Amount.Min = min
}
if max := strings.TrimSpace(override.GetMaxAmount()); max != "" {
if limits.Amount == nil {
limits.Amount = &discovery.CurrencyAmount{}
}
limits.Amount.Max = max
}
if maxVolume := strings.TrimSpace(override.GetMaxVolume()); maxVolume != "" {
running.Volume = append(running.Volume, discovery.VolumeLimit{
Window: discovery.Window{
Raw: "default",
Named: "default",
},
Max: maxVolume,
})
}
if maxOps := int(override.GetMaxOps()); maxOps > 0 {
running.Velocity = append(running.Velocity, discovery.VelocityLimit{
Window: discovery.Window{
Raw: "default",
Named: "default",
},
Max: maxOps,
})
}
}
if len(running.Volume) > 0 || len(running.Velocity) > 0 {
limits.Running = running
}
if limits.Amount == nil && limits.Running == nil {
return nil
}
return limits
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
clean := strings.TrimSpace(value)
if clean != "" {
return clean
}
}
return ""
}