Compare commits
6 Commits
SEND021
...
19b7b69bd8
| Author | SHA1 | Date | |
|---|---|---|---|
| 19b7b69bd8 | |||
|
|
b157522fdb | ||
| 202582626a | |||
|
|
6a2efd3d22 | ||
| a6374d1136 | |||
|
|
7c864dc304 |
@@ -7,6 +7,7 @@ import (
|
|||||||
|
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||||
|
"go.uber.org/zap"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/grpc/credentials/insecure"
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
)
|
)
|
||||||
@@ -23,6 +24,7 @@ type gatewayClient struct {
|
|||||||
conn *grpc.ClientConn
|
conn *grpc.ClientConn
|
||||||
client mntxv1.MntxGatewayServiceClient
|
client mntxv1.MntxGatewayServiceClient
|
||||||
cfg Config
|
cfg Config
|
||||||
|
logger *zap.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// New dials the Monetix gateway.
|
// New dials the Monetix gateway.
|
||||||
@@ -47,6 +49,7 @@ func New(ctx context.Context, cfg Config, opts ...grpc.DialOption) (Client, erro
|
|||||||
conn: conn,
|
conn: conn,
|
||||||
client: mntxv1.NewMntxGatewayServiceClient(conn),
|
client: mntxv1.NewMntxGatewayServiceClient(conn),
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
|
logger: cfg.Logger,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,28 +60,39 @@ func (g *gatewayClient) Close() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *gatewayClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
func (g *gatewayClient) callContext(ctx context.Context, method string) (context.Context, context.CancelFunc) {
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
timeout := g.cfg.CallTimeout
|
timeout := g.cfg.CallTimeout
|
||||||
if timeout <= 0 {
|
if timeout <= 0 {
|
||||||
timeout = 5 * time.Second
|
timeout = 5 * time.Second
|
||||||
}
|
}
|
||||||
|
fields := []zap.Field{
|
||||||
|
zap.String("method", method),
|
||||||
|
zap.Duration("timeout", timeout),
|
||||||
|
}
|
||||||
|
if deadline, ok := ctx.Deadline(); ok {
|
||||||
|
fields = append(fields, zap.Time("parent_deadline", deadline), zap.Duration("parent_deadline_in", time.Until(deadline)))
|
||||||
|
}
|
||||||
|
g.logger.Info("Mntx gateway client call timeout applied", fields...)
|
||||||
return context.WithTimeout(ctx, timeout)
|
return context.WithTimeout(ctx, timeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *gatewayClient) CreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) {
|
func (g *gatewayClient) CreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) {
|
||||||
ctx, cancel := g.callContext(ctx)
|
ctx, cancel := g.callContext(ctx, "CreateCardPayout")
|
||||||
defer cancel()
|
defer cancel()
|
||||||
return g.client.CreateCardPayout(ctx, req)
|
return g.client.CreateCardPayout(ctx, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *gatewayClient) CreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error) {
|
func (g *gatewayClient) CreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error) {
|
||||||
ctx, cancel := g.callContext(ctx)
|
ctx, cancel := g.callContext(ctx, "CreateCardTokenPayout")
|
||||||
defer cancel()
|
defer cancel()
|
||||||
return g.client.CreateCardTokenPayout(ctx, req)
|
return g.client.CreateCardTokenPayout(ctx, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *gatewayClient) GetCardPayoutStatus(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error) {
|
func (g *gatewayClient) GetCardPayoutStatus(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error) {
|
||||||
ctx, cancel := g.callContext(ctx)
|
ctx, cancel := g.callContext(ctx, "GetCardPayoutStatus")
|
||||||
defer cancel()
|
defer cancel()
|
||||||
return g.client.GetCardPayoutStatus(ctx, req)
|
return g.client.GetCardPayoutStatus(ctx, req)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
package client
|
package client
|
||||||
|
|
||||||
import "time"
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
// Config holds Monetix gateway client settings.
|
// Config holds Monetix gateway client settings.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Address string
|
Address string
|
||||||
DialTimeout time.Duration
|
DialTimeout time.Duration
|
||||||
CallTimeout time.Duration
|
CallTimeout time.Duration
|
||||||
|
Logger *zap.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) setDefaults() {
|
func (c *Config) setDefaults() {
|
||||||
@@ -16,4 +21,7 @@ func (c *Config) setDefaults() {
|
|||||||
if c.CallTimeout <= 0 {
|
if c.CallTimeout <= 0 {
|
||||||
c.CallTimeout = 10 * time.Second
|
c.CallTimeout = 10 * time.Second
|
||||||
}
|
}
|
||||||
|
if c.Logger == nil {
|
||||||
|
c.Logger = zap.NewNop()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package gateway
|
package gateway
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/hmac"
|
"crypto/hmac"
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -45,8 +47,9 @@ type callbackOperation struct {
|
|||||||
Provider struct {
|
Provider struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
PaymentID string `json:"payment_id"`
|
PaymentID string `json:"payment_id"`
|
||||||
Date string `json:"date"`
|
|
||||||
AuthCode string `json:"auth_code"`
|
AuthCode string `json:"auth_code"`
|
||||||
|
EndpointID int64 `json:"endpoint_id"`
|
||||||
|
Date string `json:"date"`
|
||||||
} `json:"provider"`
|
} `json:"provider"`
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
@@ -57,6 +60,10 @@ type monetixCallback struct {
|
|||||||
Payment callbackPayment `json:"payment"`
|
Payment callbackPayment `json:"payment"`
|
||||||
Account struct {
|
Account struct {
|
||||||
Number string `json:"number"`
|
Number string `json:"number"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
CardHolder string `json:"card_holder"`
|
||||||
|
ExpiryMonth string `json:"expiry_month"`
|
||||||
|
ExpiryYear string `json:"expiry_year"`
|
||||||
} `json:"account"`
|
} `json:"account"`
|
||||||
Customer struct {
|
Customer struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
@@ -120,17 +127,48 @@ func fallbackProviderPaymentID(cb monetixCallback) string {
|
|||||||
return cb.Payment.ID
|
return cb.Payment.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
func verifyCallbackSignature(cb monetixCallback, secret string) error {
|
func verifyCallbackSignature(payload []byte, secret string) (string, error) {
|
||||||
expected := cb.Signature
|
root, err := decodeCallbackPayload(payload)
|
||||||
cb.Signature = ""
|
|
||||||
calculated, err := monetix.SignPayload(cb, secret)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return "", err
|
||||||
}
|
}
|
||||||
if subtleConstantTimeCompare(expected, calculated) {
|
signature, ok := signatureFromPayload(root)
|
||||||
return nil
|
if !ok || strings.TrimSpace(signature) == "" {
|
||||||
|
return "", merrors.InvalidArgument("signature is missing")
|
||||||
}
|
}
|
||||||
return merrors.DataConflict("signature mismatch")
|
calculated, err := monetix.SignPayload(root, secret)
|
||||||
|
if err != nil {
|
||||||
|
return signature, err
|
||||||
|
}
|
||||||
|
if subtleConstantTimeCompare(signature, calculated) {
|
||||||
|
return signature, nil
|
||||||
|
}
|
||||||
|
return signature, merrors.DataConflict("signature mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeCallbackPayload(payload []byte) (any, error) {
|
||||||
|
var root any
|
||||||
|
decoder := json.NewDecoder(bytes.NewReader(payload))
|
||||||
|
decoder.UseNumber()
|
||||||
|
if err := decoder.Decode(&root); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return root, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func signatureFromPayload(root any) (string, bool) {
|
||||||
|
payload, ok := root.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
for key, value := range payload {
|
||||||
|
if !strings.EqualFold(key, "signature") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
signature, ok := value.(string)
|
||||||
|
return signature, ok
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
func subtleConstantTimeCompare(a, b string) bool {
|
func subtleConstantTimeCompare(a, b string) bool {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package gateway
|
package gateway
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -119,12 +120,20 @@ func TestVerifyCallbackSignature(t *testing.T) {
|
|||||||
t.Fatalf("failed to sign payload: %v", err)
|
t.Fatalf("failed to sign payload: %v", err)
|
||||||
}
|
}
|
||||||
cb.Signature = sig
|
cb.Signature = sig
|
||||||
if err := verifyCallbackSignature(cb, secret); err != nil {
|
payload, err := json.Marshal(cb)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to marshal callback: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := verifyCallbackSignature(payload, secret); err != nil {
|
||||||
t.Fatalf("expected valid signature, got %v", err)
|
t.Fatalf("expected valid signature, got %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cb.Signature = "invalid"
|
cb.Signature = "invalid"
|
||||||
if err := verifyCallbackSignature(cb, secret); err == nil {
|
payload, err = json.Marshal(cb)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to marshal callback: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := verifyCallbackSignature(payload, secret); err == nil {
|
||||||
t.Fatalf("expected signature mismatch error")
|
t.Fatalf("expected signature mismatch error")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package gateway
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -338,13 +339,19 @@ func (p *cardPayoutProcessor) ProcessCallback(ctx context.Context, payload []byt
|
|||||||
return http.StatusBadRequest, err
|
return http.StatusBadRequest, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.TrimSpace(cb.Signature) == "" {
|
signature, err := verifyCallbackSignature(payload, p.config.SecretKey)
|
||||||
p.logger.Warn("Monetix callback signature is missing", zap.String("payout_id", cb.Payment.ID))
|
if err != nil {
|
||||||
return http.StatusBadRequest, merrors.InvalidArgument("signature is missing")
|
status := http.StatusBadRequest
|
||||||
|
if errors.Is(err, merrors.ErrDataConflict) {
|
||||||
|
status = http.StatusForbidden
|
||||||
}
|
}
|
||||||
if err := verifyCallbackSignature(cb, p.config.SecretKey); err != nil {
|
p.logger.Warn("Monetix callback signature check failed",
|
||||||
p.logger.Warn("Monetix callback signature check failed", zap.Error(err))
|
zap.String("payout_id", cb.Payment.ID),
|
||||||
return http.StatusForbidden, err
|
zap.String("signature", signature),
|
||||||
|
zap.String("payload", string(payload)),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
return status, err
|
||||||
}
|
}
|
||||||
|
|
||||||
state, statusLabel := mapCallbackToState(p.clock, p.config, cb)
|
state, statusLabel := mapCallbackToState(p.clock, p.config, cb)
|
||||||
|
|||||||
@@ -106,12 +106,23 @@ func (c *Client) sendTokenization(ctx context.Context, req CardTokenizeRequest)
|
|||||||
zap.String("masked_pan", MaskPAN(req.Card.PAN)),
|
zap.String("masked_pan", MaskPAN(req.Card.PAN)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logRequestDeadline(c.logger, ctx, url)
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
resp, err := c.client.Do(httpReq)
|
resp, err := c.client.Do(httpReq)
|
||||||
duration := time.Since(start)
|
duration := time.Since(start)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
observeRequest(outcomeNetworkError, duration)
|
observeRequest(outcomeNetworkError, duration)
|
||||||
c.logger.Warn("Monetix tokenization request failed", zap.Error(err))
|
fields := []zap.Field{
|
||||||
|
zap.String("url", url),
|
||||||
|
zap.Error(err),
|
||||||
|
}
|
||||||
|
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||||
|
fields = append(fields, zap.NamedError("ctx_error", ctxErr))
|
||||||
|
}
|
||||||
|
if deadline, ok := ctx.Deadline(); ok {
|
||||||
|
fields = append(fields, zap.Time("deadline", deadline), zap.Duration("time_until_deadline", time.Until(deadline)))
|
||||||
|
}
|
||||||
|
c.logger.Warn("Monetix tokenization request failed", fields...)
|
||||||
return nil, merrors.Internal("monetix tokenization request failed: " + err.Error())
|
return nil, merrors.Internal("monetix tokenization request failed: " + err.Error())
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
@@ -221,11 +232,23 @@ func (c *Client) send(ctx context.Context, req any, path string, dispatchLog fun
|
|||||||
dispatchLog()
|
dispatchLog()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logRequestDeadline(c.logger, ctx, url)
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
resp, err := c.client.Do(httpReq)
|
resp, err := c.client.Do(httpReq)
|
||||||
duration := time.Since(start)
|
duration := time.Since(start)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
observeRequest(outcomeNetworkError, duration)
|
observeRequest(outcomeNetworkError, duration)
|
||||||
|
fields := []zap.Field{
|
||||||
|
zap.String("url", url),
|
||||||
|
zap.Error(err),
|
||||||
|
}
|
||||||
|
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||||
|
fields = append(fields, zap.NamedError("ctx_error", ctxErr))
|
||||||
|
}
|
||||||
|
if deadline, ok := ctx.Deadline(); ok {
|
||||||
|
fields = append(fields, zap.Time("deadline", deadline), zap.Duration("time_until_deadline", time.Until(deadline)))
|
||||||
|
}
|
||||||
|
c.logger.Warn("Monetix request failed", fields...)
|
||||||
return nil, merrors.Internal("monetix request failed: " + err.Error())
|
return nil, merrors.Internal("monetix request failed: " + err.Error())
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
@@ -288,3 +311,23 @@ func clearSignature(req any) (func(string), error) {
|
|||||||
return nil, merrors.Internal("unsupported monetix payload type for signing")
|
return nil, merrors.Internal("unsupported monetix payload type for signing")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func logRequestDeadline(logger *zap.Logger, ctx context.Context, url string) {
|
||||||
|
if logger == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ctx == nil {
|
||||||
|
logger.Info("Monetix request context is nil", zap.String("url", url))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
deadline, ok := ctx.Deadline()
|
||||||
|
if !ok {
|
||||||
|
logger.Info("Monetix request context has no deadline", zap.String("url", url))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger.Info("Monetix request context deadline",
|
||||||
|
zap.String("url", url),
|
||||||
|
zap.Time("deadline", deadline),
|
||||||
|
zap.Duration("time_until_deadline", time.Until(deadline)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -146,3 +146,66 @@ func TestSignPayload_EthEstimateGasExample(t *testing.T) {
|
|||||||
t.Fatalf("unexpected signature\nexpected: %s\ngot: %s", expected, got)
|
t.Fatalf("unexpected signature\nexpected: %s\ngot: %s", expected, got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSignPayload_MonetixCallbackExample(t *testing.T) {
|
||||||
|
payload := map[string]any{
|
||||||
|
"customer": map[string]any{
|
||||||
|
"id": "694ece88df756c2672dc6ce8",
|
||||||
|
},
|
||||||
|
"account": map[string]any{
|
||||||
|
"number": "220070******0161",
|
||||||
|
"type": "mir",
|
||||||
|
"card_holder": "STEPHAN",
|
||||||
|
"expiry_month": "03",
|
||||||
|
"expiry_year": "2030",
|
||||||
|
},
|
||||||
|
"project_id": 157432,
|
||||||
|
"payment": map[string]any{
|
||||||
|
"id": "6952d0b307d2916aba87d4e8",
|
||||||
|
"type": "payout",
|
||||||
|
"status": "success",
|
||||||
|
"date": "2025-12-29T19:04:24+0000",
|
||||||
|
"method": "card",
|
||||||
|
"sum": map[string]any{
|
||||||
|
"amount": 10849,
|
||||||
|
"currency": "RUB",
|
||||||
|
},
|
||||||
|
"description": "",
|
||||||
|
},
|
||||||
|
"operation": map[string]any{
|
||||||
|
"sum_initial": map[string]any{
|
||||||
|
"amount": 10849,
|
||||||
|
"currency": "RUB",
|
||||||
|
},
|
||||||
|
"sum_converted": map[string]any{
|
||||||
|
"amount": 10849,
|
||||||
|
"currency": "RUB",
|
||||||
|
},
|
||||||
|
"code": "0",
|
||||||
|
"message": "Success",
|
||||||
|
"provider": map[string]any{
|
||||||
|
"id": 26226,
|
||||||
|
"payment_id": "a3761838-eabc-4c65-aa36-c854c47a226b",
|
||||||
|
"auth_code": "",
|
||||||
|
"endpoint_id": 26226,
|
||||||
|
"date": "2025-12-29T19:04:23+0000",
|
||||||
|
},
|
||||||
|
"id": int64(5089807000008124),
|
||||||
|
"type": "payout",
|
||||||
|
"status": "success",
|
||||||
|
"date": "2025-12-29T19:04:24+0000",
|
||||||
|
"created_date": "2025-12-29T19:04:21+0000",
|
||||||
|
"request_id": "7c3032f00629c94ad78e399c87da936f1cdc30de-2559ba11d6958d558a9f8ab8c20474d33061c654-05089808",
|
||||||
|
},
|
||||||
|
"signature": "IBgtwCoxhMUxF15q8DLc7orYOIJomeiaNpWs8JHHsdDYPKJsIKn4T+kYavPnKTO+yibhCLNKeL+hk2oWg9wPCQ==",
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := SignPayload(payload, "1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to sign payload: %v", err)
|
||||||
|
}
|
||||||
|
expected := "IBgtwCoxhMUxF15q8DLc7orYOIJomeiaNpWs8JHHsdDYPKJsIKn4T+kYavPnKTO+yibhCLNKeL+hk2oWg9wPCQ=="
|
||||||
|
if got != expected {
|
||||||
|
t.Fatalf("unexpected signature\nexpected: %s\ngot: %s", expected, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ gateway:
|
|||||||
mntx:
|
mntx:
|
||||||
address: "sendico_mntx_gateway:50075"
|
address: "sendico_mntx_gateway:50075"
|
||||||
dial_timeout_seconds: 5
|
dial_timeout_seconds: 5
|
||||||
call_timeout_seconds: 3
|
call_timeout_seconds: 15
|
||||||
insecure: true
|
insecure: true
|
||||||
|
|
||||||
oracle:
|
oracle:
|
||||||
|
|||||||
@@ -273,6 +273,7 @@ func (i *Imp) initMntxClient(cfg clientConfig) mntxclient.Client {
|
|||||||
Address: addr,
|
Address: addr,
|
||||||
DialTimeout: cfg.dialTimeout(),
|
DialTimeout: cfg.dialTimeout(),
|
||||||
CallTimeout: cfg.callTimeout(),
|
CallTimeout: cfg.callTimeout(),
|
||||||
|
Logger: i.logger.Named("client.mntx"),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
i.logger.Warn("Failed to connect to mntx gateway service", zap.String("address", addr), zap.Error(err))
|
i.logger.Warn("Failed to connect to mntx gateway service", zap.String("address", addr), zap.Error(err))
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ class WalletsProvider with ChangeNotifier {
|
|||||||
|
|
||||||
bool _isRefreshingBalances = false;
|
bool _isRefreshingBalances = false;
|
||||||
bool get isRefreshingBalances => _isRefreshingBalances;
|
bool get isRefreshingBalances => _isRefreshingBalances;
|
||||||
|
final Set<String> _refreshingWallets = <String>{};
|
||||||
|
bool isWalletRefreshing(String walletId) => _refreshingWallets.contains(walletId);
|
||||||
|
|
||||||
void update(OrganizationsProvider organizations) {
|
void update(OrganizationsProvider organizations) {
|
||||||
_organizations = organizations;
|
_organizations = organizations;
|
||||||
@@ -81,6 +83,31 @@ class WalletsProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> refreshBalance(String walletId) async {
|
||||||
|
if (_refreshingWallets.contains(walletId)) return;
|
||||||
|
final wallet = wallets.firstWhereOrNull((w) => w.id == walletId);
|
||||||
|
if (wallet == null) return;
|
||||||
|
|
||||||
|
_refreshingWallets.add(walletId);
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final balance = await _service.getBalance(_organizations.current.id, walletId);
|
||||||
|
final updatedWallet = wallet.copyWith(balance: balance);
|
||||||
|
final next = List<Wallet>.from(wallets);
|
||||||
|
final idx = next.indexWhere((w) => w.id == walletId);
|
||||||
|
if (idx >= 0) {
|
||||||
|
next[idx] = updatedWallet;
|
||||||
|
_setResource(_resource.copyWith(data: next));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_setResource(_resource.copyWith(error: toException(e)));
|
||||||
|
} finally {
|
||||||
|
_refreshingWallets.remove(walletId);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void toggleVisibility(String walletId) {
|
void toggleVisibility(String walletId) {
|
||||||
final index = wallets.indexWhere((w) => w.id == walletId);
|
final index = wallets.indexWhere((w) => w.id == walletId);
|
||||||
if (index < 0) return;
|
if (index < 0) return;
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
@monetixSuccess {
|
@monetixSuccess {
|
||||||
path /gateway/m/success*
|
path /gateway/m/success*
|
||||||
method POST
|
method POST
|
||||||
# remote_ip 88.218.112.16 88.218.112.16/32 88.218.113.16 88.218.113.16/32 93.179.90.141 93.179.90.128/25 93.179.90.161 93.179.91.0/24 178.57.67.47 178.57.66.128/25 178.57.67.154 178.57.67.0/24 178.57.68.244
|
remote_ip 88.218.112.16 88.218.112.16/32 88.218.113.16 88.218.113.16/32 93.179.90.141 93.179.90.128/25 93.179.90.161 93.179.91.0/24 178.57.67.47 178.57.66.128/25 178.57.67.154 178.57.67.0/24 178.57.68.244
|
||||||
}
|
}
|
||||||
handle @monetixSuccess {
|
handle @monetixSuccess {
|
||||||
rewrite * /monetix/callback
|
rewrite * /monetix/callback
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
@monetixFail {
|
@monetixFail {
|
||||||
path /gateway/m/fail*
|
path /gateway/m/fail*
|
||||||
method POST
|
method POST
|
||||||
# remote_ip 88.218.112.16 88.218.112.16/32 88.218.113.16 88.218.113.16/32 93.179.90.141 93.179.90.128/25 93.179.90.161 93.179.91.0/24 178.57.67.47 178.57.66.128/25 178.57.67.154 178.57.67.0/24 178.57.68.244
|
remote_ip 88.218.112.16 88.218.112.16/32 88.218.113.16 88.218.113.16/32 93.179.90.141 93.179.90.128/25 93.179.90.161 93.179.91.0/24 178.57.67.47 178.57.66.128/25 178.57.67.154 178.57.67.0/24 178.57.68.244
|
||||||
}
|
}
|
||||||
handle @monetixFail {
|
handle @monetixFail {
|
||||||
rewrite * /monetix/callback
|
rewrite * /monetix/callback
|
||||||
|
|||||||
@@ -383,6 +383,7 @@
|
|||||||
"payout": "Payout",
|
"payout": "Payout",
|
||||||
"sendTo": "Send Payout To",
|
"sendTo": "Send Payout To",
|
||||||
"send": "Send Payout",
|
"send": "Send Payout",
|
||||||
|
"refreshBalance": "Refresh balance",
|
||||||
"recipientPaysFee": "Recipient pays the fee",
|
"recipientPaysFee": "Recipient pays the fee",
|
||||||
|
|
||||||
"sentAmount": "Sent amount: {amount}",
|
"sentAmount": "Sent amount: {amount}",
|
||||||
|
|||||||
@@ -383,6 +383,7 @@
|
|||||||
"payout": "Выплата",
|
"payout": "Выплата",
|
||||||
"sendTo": "Отправить выплату",
|
"sendTo": "Отправить выплату",
|
||||||
"send": "Отправить выплату",
|
"send": "Отправить выплату",
|
||||||
|
"refreshBalance": "Обновить баланс",
|
||||||
"recipientPaysFee": "Получатель оплачивает комиссию",
|
"recipientPaysFee": "Получатель оплачивает комиссию",
|
||||||
|
|
||||||
"sentAmount": "Отправленная сумма: {amount}",
|
"sentAmount": "Отправленная сумма: {amount}",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import 'package:pweb/pages/dashboard/buttons/balance/amount.dart';
|
|||||||
import 'package:pweb/pages/dashboard/buttons/balance/config.dart';
|
import 'package:pweb/pages/dashboard/buttons/balance/config.dart';
|
||||||
import 'package:pweb/pages/dashboard/buttons/balance/header.dart';
|
import 'package:pweb/pages/dashboard/buttons/balance/header.dart';
|
||||||
import 'package:pshared/provider/payment/wallets.dart';
|
import 'package:pshared/provider/payment/wallets.dart';
|
||||||
|
import 'package:pweb/widgets/wallet_balance_refresh_button.dart';
|
||||||
|
|
||||||
|
|
||||||
class WalletCard extends StatelessWidget {
|
class WalletCard extends StatelessWidget {
|
||||||
@@ -37,12 +38,19 @@ class WalletCard extends StatelessWidget {
|
|||||||
walletNetwork: wallet.network,
|
walletNetwork: wallet.network,
|
||||||
tokenSymbol: wallet.tokenSymbol,
|
tokenSymbol: wallet.tokenSymbol,
|
||||||
),
|
),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
BalanceAmount(
|
BalanceAmount(
|
||||||
wallet: wallet,
|
wallet: wallet,
|
||||||
onToggleVisibility: () {
|
onToggleVisibility: () {
|
||||||
context.read<WalletsProvider>().toggleVisibility(wallet.id);
|
context.read<WalletsProvider>().toggleVisibility(wallet.id);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
WalletBalanceRefreshButton(
|
||||||
|
walletId: wallet.id,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
BalanceAddFunds(
|
BalanceAddFunds(
|
||||||
onTopUp: () {
|
onTopUp: () {
|
||||||
onTopUp();
|
onTopUp();
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import 'package:pshared/models/payment/wallet.dart';
|
import 'package:pshared/models/payment/wallet.dart';
|
||||||
import 'package:pshared/models/recipient/recipient.dart';
|
import 'package:pshared/models/recipient/recipient.dart';
|
||||||
|
import 'package:pshared/provider/payment/wallets.dart';
|
||||||
import 'package:pshared/provider/recipient/provider.dart';
|
import 'package:pshared/provider/recipient/provider.dart';
|
||||||
|
|
||||||
import 'package:pweb/pages/payment_methods/payment_page/back_button.dart';
|
import 'package:pweb/pages/payment_methods/payment_page/back_button.dart';
|
||||||
@@ -14,6 +17,7 @@ import 'package:pweb/pages/payment_methods/widgets/recipient_section.dart';
|
|||||||
import 'package:pweb/pages/payment_methods/widgets/section_title.dart';
|
import 'package:pweb/pages/payment_methods/widgets/section_title.dart';
|
||||||
import 'package:pweb/utils/dimensions.dart';
|
import 'package:pweb/utils/dimensions.dart';
|
||||||
import 'package:pweb/widgets/sidebar/destinations.dart';
|
import 'package:pweb/widgets/sidebar/destinations.dart';
|
||||||
|
import 'package:pweb/widgets/wallet_balance_refresh_button.dart';
|
||||||
|
|
||||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
@@ -74,7 +78,20 @@ class PaymentPageContent extends StatelessWidget {
|
|||||||
SizedBox(height: dimensions.paddingSmall),
|
SizedBox(height: dimensions.paddingSmall),
|
||||||
PaymentHeader(),
|
PaymentHeader(),
|
||||||
SizedBox(height: dimensions.paddingXXLarge),
|
SizedBox(height: dimensions.paddingXXLarge),
|
||||||
SectionTitle(loc.sourceOfFunds),
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: SectionTitle(loc.sourceOfFunds)),
|
||||||
|
Consumer<WalletsProvider>(
|
||||||
|
builder: (context, provider, _) {
|
||||||
|
final selectedWalletId = provider.selectedWallet?.id;
|
||||||
|
if (selectedWalletId == null) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
return WalletBalanceRefreshButton(walletId: selectedWalletId);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
SizedBox(height: dimensions.paddingSmall),
|
SizedBox(height: dimensions.paddingSmall),
|
||||||
PaymentMethodSelector(
|
PaymentMethodSelector(
|
||||||
onMethodChanged: onWalletSelected,
|
onMethodChanged: onWalletSelected,
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import 'package:provider/provider.dart';
|
|||||||
import 'package:pshared/utils/currency.dart';
|
import 'package:pshared/utils/currency.dart';
|
||||||
import 'package:pshared/models/payment/wallet.dart';
|
import 'package:pshared/models/payment/wallet.dart';
|
||||||
import 'package:pshared/provider/payment/wallets.dart';
|
import 'package:pshared/provider/payment/wallets.dart';
|
||||||
|
import 'package:pweb/models/visibility.dart';
|
||||||
|
|
||||||
import 'package:pweb/pages/dashboard/buttons/balance/amount.dart';
|
import 'package:pweb/pages/dashboard/buttons/balance/amount.dart';
|
||||||
|
import 'package:pweb/widgets/wallet_balance_refresh_button.dart';
|
||||||
|
|
||||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
@@ -42,6 +44,9 @@ class WalletCard extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
BalanceAmount(
|
BalanceAmount(
|
||||||
wallet: wallet,
|
wallet: wallet,
|
||||||
@@ -49,6 +54,12 @@ class WalletCard extends StatelessWidget {
|
|||||||
context.read<WalletsProvider>().toggleVisibility(wallet.id);
|
context.read<WalletsProvider>().toggleVisibility(wallet.id);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
WalletBalanceRefreshButton(
|
||||||
|
walletId: wallet.id,
|
||||||
|
iconOnly: VisibilityState.hidden,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
Text(
|
Text(
|
||||||
AppLocalizations.of(context)!.paymentTypeCryptoWallet,
|
AppLocalizations.of(context)!.paymentTypeCryptoWallet,
|
||||||
style: theme.textTheme.bodyLarge!.copyWith(
|
style: theme.textTheme.bodyLarge!.copyWith(
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:provider/provider.dart';
|
|||||||
import 'package:pshared/provider/payment/wallets.dart';
|
import 'package:pshared/provider/payment/wallets.dart';
|
||||||
|
|
||||||
import 'package:pweb/pages/dashboard/buttons/balance/amount.dart';
|
import 'package:pweb/pages/dashboard/buttons/balance/amount.dart';
|
||||||
|
import 'package:pweb/widgets/wallet_balance_refresh_button.dart';
|
||||||
|
|
||||||
|
|
||||||
class WalletEditFields extends StatelessWidget {
|
class WalletEditFields extends StatelessWidget {
|
||||||
@@ -26,12 +27,15 @@ class WalletEditFields extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
BalanceAmount(
|
Expanded(
|
||||||
|
child: BalanceAmount(
|
||||||
wallet: wallet,
|
wallet: wallet,
|
||||||
onToggleVisibility: () {
|
onToggleVisibility: () {
|
||||||
context.read<WalletsProvider>().toggleVisibility(wallet.id);
|
context.read<WalletsProvider>().toggleVisibility(wallet.id);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
WalletBalanceRefreshButton(walletId: wallet.id),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
|
|
||||||
class MockPaymentProvider with ChangeNotifier {
|
|
||||||
double _amount = 10.0;
|
|
||||||
bool _payerCoversFee = true;
|
|
||||||
|
|
||||||
double get amount => _amount;
|
|
||||||
bool get payerCoversFee => _payerCoversFee;
|
|
||||||
|
|
||||||
double get fee => _amount * 0.05;
|
|
||||||
double get total => payerCoversFee ? (_amount + fee) : _amount;
|
|
||||||
double get recipientGets => payerCoversFee ? _amount : (_amount - fee);
|
|
||||||
|
|
||||||
void setAmount(double value) {
|
|
||||||
_amount = value;
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
void setPayerCoversFee(bool value) {
|
|
||||||
_payerCoversFee = value;
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
62
frontend/pweb/lib/widgets/wallet_balance_refresh_button.dart
Normal file
62
frontend/pweb/lib/widgets/wallet_balance_refresh_button.dart
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import 'package:pshared/provider/payment/wallets.dart';
|
||||||
|
|
||||||
|
import 'package:pweb/models/visibility.dart';
|
||||||
|
|
||||||
|
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
|
||||||
|
class WalletBalanceRefreshButton extends StatelessWidget {
|
||||||
|
final String walletId;
|
||||||
|
final VisibilityState iconOnly;
|
||||||
|
final double iconSize = 18;
|
||||||
|
|
||||||
|
const WalletBalanceRefreshButton({
|
||||||
|
super.key,
|
||||||
|
required this.walletId,
|
||||||
|
this.iconOnly = VisibilityState.visible,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final walletsProvider = context.watch<WalletsProvider>();
|
||||||
|
final loc = AppLocalizations.of(context)!;
|
||||||
|
final isBusy = walletsProvider.isWalletRefreshing(walletId) || walletsProvider.isLoading;
|
||||||
|
final hasTarget = walletsProvider.wallets.any((w) => w.id == walletId);
|
||||||
|
|
||||||
|
void refresh() {
|
||||||
|
final provider = context.read<WalletsProvider>();
|
||||||
|
provider.refreshBalance(walletId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (iconOnly == VisibilityState.hidden) {
|
||||||
|
return IconButton(
|
||||||
|
tooltip: loc.refreshBalance,
|
||||||
|
onPressed: hasTarget && !isBusy ? refresh : null,
|
||||||
|
icon: isBusy
|
||||||
|
? SizedBox(
|
||||||
|
width: iconSize,
|
||||||
|
height: iconSize,
|
||||||
|
child: const CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.refresh),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return TextButton.icon(
|
||||||
|
onPressed: hasTarget && !isBusy ? refresh : null,
|
||||||
|
icon: isBusy
|
||||||
|
? SizedBox(
|
||||||
|
width: iconSize,
|
||||||
|
height: iconSize,
|
||||||
|
child: const CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.refresh),
|
||||||
|
label: Text(loc.refreshBalance),
|
||||||
|
style: TextButton.styleFrom(visualDensity: VisualDensity.compact),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user