Compare commits
3 Commits
202582626a
...
SEND021
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9a605ce21 | ||
|
|
c3ec50c8e4 | ||
|
|
f3ad4c2d4f |
@@ -7,7 +7,6 @@ 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"
|
||||||
)
|
)
|
||||||
@@ -24,7 +23,6 @@ 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.
|
||||||
@@ -49,7 +47,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,39 +57,28 @@ func (g *gatewayClient) Close() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *gatewayClient) callContext(ctx context.Context, method string) (context.Context, context.CancelFunc) {
|
func (g *gatewayClient) callContext(ctx context.Context) (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, "CreateCardPayout")
|
ctx, cancel := g.callContext(ctx)
|
||||||
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, "CreateCardTokenPayout")
|
ctx, cancel := g.callContext(ctx)
|
||||||
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, "GetCardPayoutStatus")
|
ctx, cancel := g.callContext(ctx)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
return g.client.GetCardPayoutStatus(ctx, req)
|
return g.client.GetCardPayoutStatus(ctx, req)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,12 @@
|
|||||||
package client
|
package client
|
||||||
|
|
||||||
import (
|
import "time"
|
||||||
"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() {
|
||||||
@@ -21,7 +16,4 @@ 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,10 +1,8 @@
|
|||||||
package gateway
|
package gateway
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"crypto/hmac"
|
"crypto/hmac"
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -45,11 +43,10 @@ type callbackOperation struct {
|
|||||||
Currency string `json:"currency"`
|
Currency string `json:"currency"`
|
||||||
} `json:"sum_converted"`
|
} `json:"sum_converted"`
|
||||||
Provider struct {
|
Provider struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
PaymentID string `json:"payment_id"`
|
PaymentID string `json:"payment_id"`
|
||||||
AuthCode string `json:"auth_code"`
|
Date string `json:"date"`
|
||||||
EndpointID int64 `json:"endpoint_id"`
|
AuthCode string `json:"auth_code"`
|
||||||
Date string `json:"date"`
|
|
||||||
} `json:"provider"`
|
} `json:"provider"`
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
@@ -59,11 +56,7 @@ type monetixCallback struct {
|
|||||||
ProjectID int64 `json:"project_id"`
|
ProjectID int64 `json:"project_id"`
|
||||||
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"`
|
||||||
@@ -127,48 +120,17 @@ func fallbackProviderPaymentID(cb monetixCallback) string {
|
|||||||
return cb.Payment.ID
|
return cb.Payment.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
func verifyCallbackSignature(payload []byte, secret string) (string, error) {
|
func verifyCallbackSignature(cb monetixCallback, secret string) error {
|
||||||
root, err := decodeCallbackPayload(payload)
|
expected := cb.Signature
|
||||||
|
cb.Signature = ""
|
||||||
|
calculated, err := monetix.SignPayload(cb, secret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return err
|
||||||
}
|
}
|
||||||
signature, ok := signatureFromPayload(root)
|
if subtleConstantTimeCompare(expected, calculated) {
|
||||||
if !ok || strings.TrimSpace(signature) == "" {
|
return nil
|
||||||
return "", merrors.InvalidArgument("signature is missing")
|
|
||||||
}
|
}
|
||||||
calculated, err := monetix.SignPayload(root, secret)
|
return merrors.DataConflict("signature mismatch")
|
||||||
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,7 +1,6 @@
|
|||||||
package gateway
|
package gateway
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -39,11 +38,11 @@ func TestMapCallbackToState_StatusMapping(t *testing.T) {
|
|||||||
cfg := monetix.DefaultConfig()
|
cfg := monetix.DefaultConfig()
|
||||||
|
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
paymentStatus string
|
paymentStatus string
|
||||||
operationStatus string
|
operationStatus string
|
||||||
code string
|
code string
|
||||||
expectedStatus mntxv1.PayoutStatus
|
expectedStatus mntxv1.PayoutStatus
|
||||||
expectedOutcome string
|
expectedOutcome string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
@@ -120,20 +119,12 @@ 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
|
||||||
payload, err := json.Marshal(cb)
|
if err := verifyCallbackSignature(cb, secret); err != nil {
|
||||||
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"
|
||||||
payload, err = json.Marshal(cb)
|
if err := verifyCallbackSignature(cb, secret); err == nil {
|
||||||
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,7 +3,6 @@ package gateway
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -339,19 +338,13 @@ func (p *cardPayoutProcessor) ProcessCallback(ctx context.Context, payload []byt
|
|||||||
return http.StatusBadRequest, err
|
return http.StatusBadRequest, err
|
||||||
}
|
}
|
||||||
|
|
||||||
signature, err := verifyCallbackSignature(payload, p.config.SecretKey)
|
if strings.TrimSpace(cb.Signature) == "" {
|
||||||
if err != nil {
|
p.logger.Warn("Monetix callback signature is missing", zap.String("payout_id", cb.Payment.ID))
|
||||||
status := http.StatusBadRequest
|
return http.StatusBadRequest, merrors.InvalidArgument("signature is missing")
|
||||||
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", zap.Error(err))
|
||||||
p.logger.Warn("Monetix callback signature check failed",
|
return http.StatusForbidden, err
|
||||||
zap.String("payout_id", cb.Payment.ID),
|
|
||||||
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,23 +106,12 @@ 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)
|
||||||
fields := []zap.Field{
|
c.logger.Warn("Monetix tokenization request failed", zap.Error(err))
|
||||||
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()
|
||||||
@@ -232,23 +221,11 @@ 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()
|
||||||
@@ -311,23 +288,3 @@ 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,66 +146,3 @@ 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: 15
|
call_timeout_seconds: 3
|
||||||
insecure: true
|
insecure: true
|
||||||
|
|
||||||
oracle:
|
oracle:
|
||||||
|
|||||||
@@ -273,7 +273,6 @@ 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))
|
||||||
|
|||||||
@@ -2,16 +2,25 @@ import 'package:pshared/data/dto/payment/card.dart';
|
|||||||
import 'package:pshared/data/dto/payment/card_token.dart';
|
import 'package:pshared/data/dto/payment/card_token.dart';
|
||||||
import 'package:pshared/data/dto/payment/endpoint.dart';
|
import 'package:pshared/data/dto/payment/endpoint.dart';
|
||||||
import 'package:pshared/data/dto/payment/external_chain.dart';
|
import 'package:pshared/data/dto/payment/external_chain.dart';
|
||||||
|
import 'package:pshared/data/dto/payment/iban.dart';
|
||||||
import 'package:pshared/data/dto/payment/ledger.dart';
|
import 'package:pshared/data/dto/payment/ledger.dart';
|
||||||
import 'package:pshared/data/dto/payment/managed_wallet.dart';
|
import 'package:pshared/data/dto/payment/managed_wallet.dart';
|
||||||
|
import 'package:pshared/data/dto/payment/russian_bank.dart';
|
||||||
|
import 'package:pshared/data/dto/payment/wallet.dart';
|
||||||
import 'package:pshared/data/mapper/payment/asset.dart';
|
import 'package:pshared/data/mapper/payment/asset.dart';
|
||||||
|
import 'package:pshared/data/mapper/payment/iban.dart';
|
||||||
import 'package:pshared/data/mapper/payment/type.dart';
|
import 'package:pshared/data/mapper/payment/type.dart';
|
||||||
|
import 'package:pshared/data/mapper/payment/russian_bank.dart';
|
||||||
|
import 'package:pshared/data/mapper/payment/wallet.dart';
|
||||||
import 'package:pshared/models/payment/methods/card.dart';
|
import 'package:pshared/models/payment/methods/card.dart';
|
||||||
import 'package:pshared/models/payment/methods/card_token.dart';
|
import 'package:pshared/models/payment/methods/card_token.dart';
|
||||||
import 'package:pshared/models/payment/methods/crypto_address.dart';
|
import 'package:pshared/models/payment/methods/crypto_address.dart';
|
||||||
import 'package:pshared/models/payment/methods/data.dart';
|
import 'package:pshared/models/payment/methods/data.dart';
|
||||||
|
import 'package:pshared/models/payment/methods/iban.dart';
|
||||||
import 'package:pshared/models/payment/methods/ledger.dart';
|
import 'package:pshared/models/payment/methods/ledger.dart';
|
||||||
import 'package:pshared/models/payment/methods/managed_wallet.dart';
|
import 'package:pshared/models/payment/methods/managed_wallet.dart';
|
||||||
|
import 'package:pshared/models/payment/methods/russian_bank.dart';
|
||||||
|
import 'package:pshared/models/payment/methods/wallet.dart';
|
||||||
import 'package:pshared/models/payment/type.dart';
|
import 'package:pshared/models/payment/type.dart';
|
||||||
|
|
||||||
|
|
||||||
@@ -75,8 +84,27 @@ extension PaymentMethodDataEndpointMapper on PaymentMethodData {
|
|||||||
).toJson(),
|
).toJson(),
|
||||||
metadata: metadata,
|
metadata: metadata,
|
||||||
);
|
);
|
||||||
default:
|
case PaymentType.iban:
|
||||||
throw UnsupportedError('Unsupported payment endpoint type: $type');
|
final payload = this as IbanPaymentMethod;
|
||||||
|
return PaymentEndpointDTO(
|
||||||
|
type: paymentTypeToValue(type),
|
||||||
|
data: payload.toDTO().toJson(),
|
||||||
|
metadata: metadata,
|
||||||
|
);
|
||||||
|
case PaymentType.bankAccount:
|
||||||
|
final payload = this as RussianBankAccountPaymentMethod;
|
||||||
|
return PaymentEndpointDTO(
|
||||||
|
type: paymentTypeToValue(type),
|
||||||
|
data: payload.toDTO().toJson(),
|
||||||
|
metadata: metadata,
|
||||||
|
);
|
||||||
|
case PaymentType.wallet:
|
||||||
|
final payload = this as WalletPaymentMethod;
|
||||||
|
return PaymentEndpointDTO(
|
||||||
|
type: paymentTypeToValue(type),
|
||||||
|
data: payload.toDTO().toJson(),
|
||||||
|
metadata: metadata,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -126,8 +154,15 @@ extension PaymentEndpointDTOMapper on PaymentEndpointDTO {
|
|||||||
maskedPan: payload.maskedPan,
|
maskedPan: payload.maskedPan,
|
||||||
metadata: metadata,
|
metadata: metadata,
|
||||||
);
|
);
|
||||||
default:
|
case PaymentType.iban:
|
||||||
throw UnsupportedError('Unsupported payment endpoint type: ${paymentTypeFromValue(type)}');
|
final payload = IbanPaymentDataDTO.fromJson(data);
|
||||||
|
return payload.toDomain();
|
||||||
|
case PaymentType.bankAccount:
|
||||||
|
final payload = RussianBankAccountPaymentDataDTO.fromJson(data);
|
||||||
|
return payload.toDomain();
|
||||||
|
case PaymentType.wallet:
|
||||||
|
final payload = WalletPaymentDataDTO.fromJson(data);
|
||||||
|
return payload.toDomain();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import 'package:flutter/material.dart';
|
|||||||
class PaymentAmountProvider with ChangeNotifier {
|
class PaymentAmountProvider with ChangeNotifier {
|
||||||
double _amount = 10.0;
|
double _amount = 10.0;
|
||||||
bool _payerCoversFee = true;
|
bool _payerCoversFee = true;
|
||||||
|
bool _isEditing = false;
|
||||||
|
|
||||||
double get amount => _amount;
|
double get amount => _amount;
|
||||||
bool get payerCoversFee => _payerCoversFee;
|
bool get payerCoversFee => _payerCoversFee;
|
||||||
|
bool get isEditing => _isEditing;
|
||||||
|
|
||||||
void setAmount(double value) {
|
void setAmount(double value) {
|
||||||
_amount = value;
|
_amount = value;
|
||||||
@@ -17,4 +19,10 @@ class PaymentAmountProvider with ChangeNotifier {
|
|||||||
_payerCoversFee = value;
|
_payerCoversFee = value;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setEditing(bool value) {
|
||||||
|
if (_isEditing == value) return;
|
||||||
|
_isEditing = value;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ class PaymentProvider extends ChangeNotifier {
|
|||||||
|
|
||||||
Future<Payment?> pay({String? idempotencyKey, Map<String, String>? metadata}) async {
|
Future<Payment?> pay({String? idempotencyKey, Map<String, String>? metadata}) async {
|
||||||
if (!_organization.isOrganizationSet) throw StateError('Organization is not set');
|
if (!_organization.isOrganizationSet) throw StateError('Organization is not set');
|
||||||
if (!_quotation.isReady) throw StateError('Quotation is not ready');
|
if (!_quotation.hasLiveQuote) throw StateError('Quotation is not ready');
|
||||||
final quoteRef = _quotation.quotation?.quoteRef;
|
final quoteRef = _quotation.quotation?.quoteRef;
|
||||||
if (quoteRef == null || quoteRef.isEmpty) {
|
if (quoteRef == null || quoteRef.isEmpty) {
|
||||||
throw StateError('Quotation reference is not set');
|
throw StateError('Quotation reference is not set');
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
@@ -15,6 +18,7 @@ import 'package:pshared/models/payment/kind.dart';
|
|||||||
import 'package:pshared/models/payment/methods/managed_wallet.dart';
|
import 'package:pshared/models/payment/methods/managed_wallet.dart';
|
||||||
import 'package:pshared/models/payment/methods/type.dart';
|
import 'package:pshared/models/payment/methods/type.dart';
|
||||||
import 'package:pshared/models/payment/money.dart';
|
import 'package:pshared/models/payment/money.dart';
|
||||||
|
import 'package:pshared/models/payment/type.dart';
|
||||||
import 'package:pshared/models/payment/settlement_mode.dart';
|
import 'package:pshared/models/payment/settlement_mode.dart';
|
||||||
import 'package:pshared/models/payment/intent.dart';
|
import 'package:pshared/models/payment/intent.dart';
|
||||||
import 'package:pshared/models/payment/quote.dart';
|
import 'package:pshared/models/payment/quote.dart';
|
||||||
@@ -34,9 +38,19 @@ class QuotationProvider extends ChangeNotifier {
|
|||||||
Resource<PaymentQuote> _quotation = Resource(data: null, isLoading: false, error: null);
|
Resource<PaymentQuote> _quotation = Resource(data: null, isLoading: false, error: null);
|
||||||
late OrganizationsProvider _organizations;
|
late OrganizationsProvider _organizations;
|
||||||
bool _isLoaded = false;
|
bool _isLoaded = false;
|
||||||
|
bool _organizationAttached = false;
|
||||||
|
PaymentIntent? _pendingIntent;
|
||||||
|
String? _lastRequestSignature;
|
||||||
|
Timer? _debounceTimer;
|
||||||
|
Timer? _expirationTimer;
|
||||||
|
bool _autoRefreshEnabled = true;
|
||||||
|
bool _amountEditing = false;
|
||||||
|
|
||||||
|
static const _inputDebounce = Duration(milliseconds: 500);
|
||||||
|
static const _expiryGracePeriod = Duration(seconds: 1);
|
||||||
|
|
||||||
void update(
|
void update(
|
||||||
OrganizationsProvider venue,
|
OrganizationsProvider venue,
|
||||||
PaymentAmountProvider payment,
|
PaymentAmountProvider payment,
|
||||||
WalletsProvider wallets,
|
WalletsProvider wallets,
|
||||||
PaymentFlowProvider flow,
|
PaymentFlowProvider flow,
|
||||||
@@ -44,44 +58,81 @@ class QuotationProvider extends ChangeNotifier {
|
|||||||
PaymentMethodsProvider methods,
|
PaymentMethodsProvider methods,
|
||||||
) {
|
) {
|
||||||
_organizations = venue;
|
_organizations = venue;
|
||||||
final t = flow.selectedType;
|
_organizationAttached = true;
|
||||||
final method = methods.methods.firstWhereOrNull((m) => m.type == t);
|
final wasEditing = _amountEditing;
|
||||||
if ((wallets.selectedWallet != null) && (method != null)) {
|
_amountEditing = payment.isEditing;
|
||||||
final customer = _buildCustomer(
|
final editingJustEnded = wasEditing && !_amountEditing;
|
||||||
recipient: recipients.currentObject,
|
_pendingIntent = _buildIntent(
|
||||||
method: method,
|
payment: payment,
|
||||||
);
|
wallets: wallets,
|
||||||
getQuotation(PaymentIntent(
|
flow: flow,
|
||||||
kind: PaymentKind.payout,
|
recipients: recipients,
|
||||||
amount: Money(
|
methods: methods,
|
||||||
amount: payment.amount.toString(),
|
);
|
||||||
// TODO: adapt to possible other sources
|
|
||||||
currency: currencyCodeToString(wallets.selectedWallet!.currency),
|
if (_pendingIntent == null) {
|
||||||
),
|
_reset();
|
||||||
destination: method.data,
|
return;
|
||||||
source: ManagedWalletPaymentMethod(
|
|
||||||
managedWalletRef: wallets.selectedWallet!.id,
|
|
||||||
),
|
|
||||||
fx: FxIntent(
|
|
||||||
pair: CurrencyPair(
|
|
||||||
base: currencyCodeToString(wallets.selectedWallet!.currency),
|
|
||||||
quote: 'RUB', // TODO: exentd target currencies
|
|
||||||
),
|
|
||||||
side: FxSide.sellBaseBuyQuote,
|
|
||||||
),
|
|
||||||
settlementMode: payment.payerCoversFee ? SettlementMode.fixReceived : SettlementMode.fixSource,
|
|
||||||
customer: customer,
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_amountEditing) {
|
||||||
|
_debounceTimer?.cancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editingJustEnded) {
|
||||||
|
refreshNow(force: false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_scheduleQuotationRefresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
PaymentQuote? get quotation => _quotation.data;
|
PaymentQuote? get quotation => hasQuoteForCurrentIntent ? _quotation.data : null;
|
||||||
|
bool get isLoading => _quotation.isLoading;
|
||||||
|
Exception? get error => _quotation.error;
|
||||||
|
bool get autoRefreshEnabled => _autoRefreshEnabled;
|
||||||
|
bool get canRequestQuote => _organizationAttached && _pendingIntent != null && _organizations.isOrganizationSet;
|
||||||
|
|
||||||
bool get isReady => _isLoaded && !_quotation.isLoading && _quotation.error == null;
|
bool get _isExpired {
|
||||||
|
final remaining = timeToExpire;
|
||||||
|
return remaining != null && remaining <= Duration.zero;
|
||||||
|
}
|
||||||
|
|
||||||
Asset? get fee => quotation == null ? null : createAsset(quotation!.expectedFeeTotal!.currency, quotation!.expectedFeeTotal!.amount);
|
bool get hasQuoteForCurrentIntent {
|
||||||
Asset? get total => quotation == null ? null : createAsset(quotation!.debitAmount!.currency, quotation!.debitAmount!.amount);
|
if (_pendingIntent == null || _lastRequestSignature == null) return false;
|
||||||
Asset? get recipientGets => quotation == null ? null : createAsset(quotation!.expectedSettlementAmount!.currency, quotation!.expectedSettlementAmount!.amount);
|
return _lastRequestSignature == _signature(_pendingIntent!);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get isReady => _isLoaded && !_isExpired && !_quotation.isLoading && _quotation.error == null;
|
||||||
|
|
||||||
|
bool get hasLiveQuote => isReady && quotation != null;
|
||||||
|
|
||||||
|
Duration? get timeToExpire {
|
||||||
|
final expiresAt = _quoteExpiry;
|
||||||
|
if (expiresAt == null) return null;
|
||||||
|
final diff = expiresAt.difference(DateTime.now().toUtc());
|
||||||
|
return diff.isNegative ? Duration.zero : diff;
|
||||||
|
}
|
||||||
|
|
||||||
|
Asset? get fee => quotation == null
|
||||||
|
? null
|
||||||
|
: createAsset(
|
||||||
|
quotation!.expectedFeeTotal!.currency,
|
||||||
|
quotation!.expectedFeeTotal!.amount,
|
||||||
|
);
|
||||||
|
Asset? get total => quotation == null
|
||||||
|
? null
|
||||||
|
: createAsset(
|
||||||
|
quotation!.debitAmount!.currency,
|
||||||
|
quotation!.debitAmount!.amount,
|
||||||
|
);
|
||||||
|
Asset? get recipientGets => quotation == null
|
||||||
|
? null
|
||||||
|
: createAsset(
|
||||||
|
quotation!.expectedSettlementAmount!.currency,
|
||||||
|
quotation!.expectedSettlementAmount!.amount,
|
||||||
|
);
|
||||||
|
|
||||||
Customer _buildCustomer({
|
Customer _buildCustomer({
|
||||||
required Recipient? recipient,
|
required Recipient? recipient,
|
||||||
@@ -140,19 +191,128 @@ class QuotationProvider extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<PaymentQuote?> getQuotation(PaymentIntent intent) async {
|
void refreshNow({bool force = true}) {
|
||||||
if (!_organizations.isOrganizationSet) throw StateError('Organization is not set');
|
_debounceTimer?.cancel();
|
||||||
|
if (!canRequestQuote) {
|
||||||
|
if (_pendingIntent == null) {
|
||||||
|
_reset();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unawaited(_requestQuotation(_pendingIntent!, force: force));
|
||||||
|
}
|
||||||
|
|
||||||
|
void setAutoRefresh(bool enabled) {
|
||||||
|
if (!_organizationAttached) return;
|
||||||
|
if (_autoRefreshEnabled == enabled) return;
|
||||||
|
_autoRefreshEnabled = enabled;
|
||||||
|
if (_autoRefreshEnabled && (!hasLiveQuote || _isExpired) && _pendingIntent != null) {
|
||||||
|
unawaited(_requestQuotation(_pendingIntent!, force: true));
|
||||||
|
} else {
|
||||||
|
_startExpirationTimer();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_debounceTimer?.cancel();
|
||||||
|
_expirationTimer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
PaymentIntent? _buildIntent({
|
||||||
|
required PaymentAmountProvider payment,
|
||||||
|
required WalletsProvider wallets,
|
||||||
|
required PaymentFlowProvider flow,
|
||||||
|
required RecipientsProvider recipients,
|
||||||
|
required PaymentMethodsProvider methods,
|
||||||
|
}) {
|
||||||
|
if (!_organizationAttached || !_organizations.isOrganizationSet) return null;
|
||||||
|
final type = flow.selectedType;
|
||||||
|
final method = methods.methods.firstWhereOrNull((m) => m.type == type);
|
||||||
|
final wallet = wallets.selectedWallet;
|
||||||
|
|
||||||
|
if (wallet == null || method == null) return null;
|
||||||
|
|
||||||
|
final customer = _buildCustomer(
|
||||||
|
recipient: recipients.currentObject,
|
||||||
|
method: method,
|
||||||
|
);
|
||||||
|
|
||||||
|
return PaymentIntent(
|
||||||
|
kind: PaymentKind.payout,
|
||||||
|
amount: Money(
|
||||||
|
amount: payment.amount.toString(),
|
||||||
|
// TODO: adapt to possible other sources
|
||||||
|
currency: currencyCodeToString(wallet.currency),
|
||||||
|
),
|
||||||
|
destination: method.data,
|
||||||
|
source: ManagedWalletPaymentMethod(
|
||||||
|
managedWalletRef: wallet.id,
|
||||||
|
),
|
||||||
|
fx: FxIntent(
|
||||||
|
pair: CurrencyPair(
|
||||||
|
base: currencyCodeToString(wallet.currency),
|
||||||
|
quote: 'RUB', // TODO: exentd target currencies
|
||||||
|
),
|
||||||
|
side: FxSide.sellBaseBuyQuote,
|
||||||
|
),
|
||||||
|
settlementMode: payment.payerCoversFee ? SettlementMode.fixReceived : SettlementMode.fixSource,
|
||||||
|
customer: customer,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _scheduleQuotationRefresh() {
|
||||||
|
_debounceTimer?.cancel();
|
||||||
|
if (_pendingIntent == null) {
|
||||||
|
_reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_debounceTimer = Timer(_inputDebounce, () {
|
||||||
|
unawaited(_requestQuotation(_pendingIntent!, force: false));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<PaymentQuote?> _requestQuotation(PaymentIntent intent, {required bool force}) async {
|
||||||
|
if (!_organizationAttached || !_organizations.isOrganizationSet) {
|
||||||
|
_reset();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final destinationType = intent.destination?.type;
|
||||||
|
if (destinationType == PaymentType.bankAccount) {
|
||||||
|
_setResource(
|
||||||
|
_quotation.copyWith(
|
||||||
|
data: null,
|
||||||
|
isLoading: false,
|
||||||
|
error: Exception('Unsupported payment endpoint type: $destinationType'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final signature = _signature(intent);
|
||||||
|
final isSameIntent = _lastRequestSignature == signature;
|
||||||
|
if (!force && isSameIntent && hasLiveQuote) {
|
||||||
|
_startExpirationTimer();
|
||||||
|
return _quotation.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
_setResource(_quotation.copyWith(isLoading: true, error: null));
|
||||||
try {
|
try {
|
||||||
_quotation = _quotation.copyWith(isLoading: true, error: null);
|
|
||||||
final response = await QuotationService.getQuotation(
|
final response = await QuotationService.getQuotation(
|
||||||
_organizations.current.id,
|
_organizations.current.id,
|
||||||
QuotePaymentRequest(
|
QuotePaymentRequest(
|
||||||
idempotencyKey: Uuid().v4(),
|
idempotencyKey: Uuid().v4(),
|
||||||
intent: intent.toDTO(),
|
intent: intent.toDTO(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
_isLoaded = true;
|
_isLoaded = true;
|
||||||
|
_lastRequestSignature = signature;
|
||||||
_setResource(_quotation.copyWith(data: response, isLoading: false, error: null));
|
_setResource(_quotation.copyWith(data: response, isLoading: false, error: null));
|
||||||
|
_startExpirationTimer();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_setResource(_quotation.copyWith(
|
_setResource(_quotation.copyWith(
|
||||||
data: null,
|
data: null,
|
||||||
@@ -160,13 +320,68 @@ class QuotationProvider extends ChangeNotifier {
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
notifyListeners();
|
|
||||||
return _quotation.data;
|
return _quotation.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
void reset() {
|
void _startExpirationTimer() {
|
||||||
_setResource(Resource(data: null, isLoading: false, error: null));
|
_expirationTimer?.cancel();
|
||||||
|
final remaining = timeToExpire;
|
||||||
|
if (remaining == null) return;
|
||||||
|
|
||||||
|
final triggerOffset = _autoRefreshEnabled ? _expiryGracePeriod : Duration.zero;
|
||||||
|
final duration = remaining > triggerOffset ? remaining - triggerOffset : Duration.zero;
|
||||||
|
_expirationTimer = Timer(duration, () {
|
||||||
|
if (_autoRefreshEnabled && _pendingIntent != null) {
|
||||||
|
unawaited(_requestQuotation(_pendingIntent!, force: true));
|
||||||
|
} else {
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _reset() {
|
||||||
|
_debounceTimer?.cancel();
|
||||||
|
_expirationTimer?.cancel();
|
||||||
|
_pendingIntent = null;
|
||||||
|
_lastRequestSignature = null;
|
||||||
_isLoaded = false;
|
_isLoaded = false;
|
||||||
notifyListeners();
|
_setResource(Resource(data: null, isLoading: false, error: null));
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime? get _quoteExpiry {
|
||||||
|
final expiresAt = quotation?.fxQuote?.expiresAtUnixMs;
|
||||||
|
if (expiresAt == null) return null;
|
||||||
|
return DateTime.fromMillisecondsSinceEpoch(expiresAt, isUtc: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _signature(PaymentIntent intent) {
|
||||||
|
try {
|
||||||
|
return jsonEncode(intent.toDTO().toJson());
|
||||||
|
} catch (_) {
|
||||||
|
return jsonEncode({
|
||||||
|
'kind': intent.kind.toString(),
|
||||||
|
'source': intent.source?.type.toString(),
|
||||||
|
'destination': intent.destination?.type.toString(),
|
||||||
|
'amount': {
|
||||||
|
'value': intent.amount?.amount,
|
||||||
|
'currency': intent.amount?.currency,
|
||||||
|
},
|
||||||
|
'fx': intent.fx == null
|
||||||
|
? null
|
||||||
|
: {
|
||||||
|
'pair': {
|
||||||
|
'base': intent.fx?.pair?.base,
|
||||||
|
'quote': intent.fx?.pair?.quote,
|
||||||
|
},
|
||||||
|
'side': intent.fx?.side.toString(),
|
||||||
|
},
|
||||||
|
'settlementMode': intent.settlementMode.toString(),
|
||||||
|
'customer': intent.customer?.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void reset() {
|
||||||
|
_reset();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,19 @@
|
|||||||
"payout": "Payout",
|
"payout": "Payout",
|
||||||
"sendTo": "Send Payout To",
|
"sendTo": "Send Payout To",
|
||||||
"send": "Send Payout",
|
"send": "Send Payout",
|
||||||
|
"quoteUnavailable": "Waiting for a quote...",
|
||||||
|
"quoteUpdating": "Refreshing quote...",
|
||||||
|
"quoteExpiresIn": "Quote expires in {time}",
|
||||||
|
"@quoteExpiresIn": {
|
||||||
|
"placeholders": {
|
||||||
|
"time": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"quoteExpired": "Quote expired, request a new one",
|
||||||
|
"quoteAutoRefresh": "Auto-refresh quote",
|
||||||
|
"quoteErrorGeneric": "Could not refresh quote, try again later",
|
||||||
"recipientPaysFee": "Recipient pays the fee",
|
"recipientPaysFee": "Recipient pays the fee",
|
||||||
|
|
||||||
"sentAmount": "Sent amount: {amount}",
|
"sentAmount": "Sent amount: {amount}",
|
||||||
|
|||||||
@@ -383,6 +383,19 @@
|
|||||||
"payout": "Выплата",
|
"payout": "Выплата",
|
||||||
"sendTo": "Отправить выплату",
|
"sendTo": "Отправить выплату",
|
||||||
"send": "Отправить выплату",
|
"send": "Отправить выплату",
|
||||||
|
"quoteUnavailable": "Ожидание котировки...",
|
||||||
|
"quoteUpdating": "Обновляем котировку...",
|
||||||
|
"quoteExpiresIn": "Котировка истекает через {time}",
|
||||||
|
"@quoteExpiresIn": {
|
||||||
|
"placeholders": {
|
||||||
|
"time": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"quoteExpired": "Срок котировки истек, запросите новую",
|
||||||
|
"quoteAutoRefresh": "Автообновление котировки",
|
||||||
|
"quoteErrorGeneric": "Не удалось обновить котировку, повторите позже",
|
||||||
"recipientPaysFee": "Получатель оплачивает комиссию",
|
"recipientPaysFee": "Получатель оплачивает комиссию",
|
||||||
|
|
||||||
"sentAmount": "Отправленная сумма: {amount}",
|
"sentAmount": "Отправленная сумма: {amount}",
|
||||||
|
|||||||
1
frontend/pweb/lib/models/button_state.dart
Normal file
1
frontend/pweb/lib/models/button_state.dart
Normal file
@@ -0,0 +1 @@
|
|||||||
|
enum ButtonState { enabled, disabled, loading }
|
||||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import 'package:pshared/provider/payment/amount.dart';
|
import 'package:pshared/provider/payment/amount.dart';
|
||||||
|
import 'package:pshared/provider/payment/quotation.dart';
|
||||||
import 'package:pshared/utils/currency.dart';
|
import 'package:pshared/utils/currency.dart';
|
||||||
|
|
||||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||||
@@ -17,6 +18,7 @@ class PaymentAmountWidget extends StatefulWidget {
|
|||||||
|
|
||||||
class _PaymentAmountWidgetState extends State<PaymentAmountWidget> {
|
class _PaymentAmountWidgetState extends State<PaymentAmountWidget> {
|
||||||
late final TextEditingController _controller;
|
late final TextEditingController _controller;
|
||||||
|
late final FocusNode _focusNode;
|
||||||
bool _isSyncingText = false;
|
bool _isSyncingText = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -24,10 +26,14 @@ class _PaymentAmountWidgetState extends State<PaymentAmountWidget> {
|
|||||||
super.initState();
|
super.initState();
|
||||||
final initialAmount = context.read<PaymentAmountProvider>().amount;
|
final initialAmount = context.read<PaymentAmountProvider>().amount;
|
||||||
_controller = TextEditingController(text: amountToString(initialAmount));
|
_controller = TextEditingController(text: amountToString(initialAmount));
|
||||||
|
_focusNode = FocusNode()..addListener(_handleFocusChange);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_focusNode.removeListener(_handleFocusChange);
|
||||||
|
_focusNode.dispose();
|
||||||
|
context.read<PaymentAmountProvider>().setEditing(false);
|
||||||
_controller.dispose();
|
_controller.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@@ -56,6 +62,16 @@ class _PaymentAmountWidgetState extends State<PaymentAmountWidget> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _handleFocusChange() {
|
||||||
|
final amountProvider = context.read<PaymentAmountProvider>();
|
||||||
|
if (_focusNode.hasFocus) {
|
||||||
|
amountProvider.setEditing(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
amountProvider.setEditing(false);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final amount = context.select<PaymentAmountProvider, double>((provider) => provider.amount);
|
final amount = context.select<PaymentAmountProvider, double>((provider) => provider.amount);
|
||||||
@@ -63,12 +79,14 @@ class _PaymentAmountWidgetState extends State<PaymentAmountWidget> {
|
|||||||
|
|
||||||
return TextField(
|
return TextField(
|
||||||
controller: _controller,
|
controller: _controller,
|
||||||
|
focusNode: _focusNode,
|
||||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: AppLocalizations.of(context)!.amount,
|
labelText: AppLocalizations.of(context)!.amount,
|
||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
onChanged: _onChanged,
|
onChanged: _onChanged,
|
||||||
|
onEditingComplete: () => _focusNode.unfocus(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:pshared/models/payment/type.dart';
|
|||||||
import 'package:pshared/models/recipient/recipient.dart';
|
import 'package:pshared/models/recipient/recipient.dart';
|
||||||
import 'package:pshared/provider/payment/flow.dart';
|
import 'package:pshared/provider/payment/flow.dart';
|
||||||
import 'package:pshared/provider/payment/provider.dart';
|
import 'package:pshared/provider/payment/provider.dart';
|
||||||
|
import 'package:pshared/provider/payment/quotation.dart';
|
||||||
import 'package:pshared/provider/recipient/pmethods.dart';
|
import 'package:pshared/provider/recipient/pmethods.dart';
|
||||||
import 'package:pshared/provider/recipient/provider.dart';
|
import 'package:pshared/provider/recipient/provider.dart';
|
||||||
import 'package:pshared/provider/payment/wallets.dart';
|
import 'package:pshared/provider/payment/wallets.dart';
|
||||||
@@ -81,8 +82,16 @@ class _PaymentPageState extends State<PaymentPage> {
|
|||||||
void _handleSendPayment() {
|
void _handleSendPayment() {
|
||||||
final flowProvider = context.read<PaymentFlowProvider>();
|
final flowProvider = context.read<PaymentFlowProvider>();
|
||||||
final paymentProvider = context.read<PaymentProvider>();
|
final paymentProvider = context.read<PaymentProvider>();
|
||||||
|
final quotationProvider = context.read<QuotationProvider>();
|
||||||
if (paymentProvider.isLoading) return;
|
if (paymentProvider.isLoading) return;
|
||||||
|
|
||||||
|
if (!quotationProvider.hasLiveQuote) {
|
||||||
|
if (quotationProvider.canRequestQuote) {
|
||||||
|
quotationProvider.refreshNow();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
paymentProvider.pay().then((_) {
|
paymentProvider.pay().then((_) {
|
||||||
PosthogService.paymentInitiated(method: flowProvider.selectedType);
|
PosthogService.paymentInitiated(method: flowProvider.selectedType);
|
||||||
}).catchError((error) {
|
}).catchError((error) {
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ import 'package:flutter/material.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/recipient/provider.dart';
|
import 'package:pshared/provider/recipient/provider.dart';
|
||||||
import 'package:pweb/pages/dashboard/payouts/form.dart';
|
|
||||||
|
|
||||||
|
import 'package:pweb/pages/dashboard/payouts/form.dart';
|
||||||
import 'package:pweb/pages/payment_methods/payment_page/back_button.dart';
|
import 'package:pweb/pages/payment_methods/payment_page/back_button.dart';
|
||||||
import 'package:pweb/pages/payment_methods/payment_page/header.dart';
|
import 'package:pweb/pages/payment_methods/payment_page/header.dart';
|
||||||
import 'package:pweb/pages/payment_methods/payment_page/method_selector.dart';
|
import 'package:pweb/pages/payment_methods/payment_page/method_selector.dart';
|
||||||
|
import 'package:pweb/pages/payment_methods/payment_page/quote/quote_status.dart';
|
||||||
import 'package:pweb/pages/payment_methods/payment_page/send_button.dart';
|
import 'package:pweb/pages/payment_methods/payment_page/send_button.dart';
|
||||||
import 'package:pweb/pages/payment_methods/widgets/payment_info_section.dart';
|
import 'package:pweb/pages/payment_methods/widgets/payment_info_section.dart';
|
||||||
import 'package:pweb/pages/payment_methods/widgets/recipient_section.dart';
|
import 'package:pweb/pages/payment_methods/widgets/recipient_section.dart';
|
||||||
@@ -94,7 +95,9 @@ class PaymentPageContent extends StatelessWidget {
|
|||||||
PaymentInfoSection(dimensions: dimensions),
|
PaymentInfoSection(dimensions: dimensions),
|
||||||
SizedBox(height: dimensions.paddingLarge),
|
SizedBox(height: dimensions.paddingLarge),
|
||||||
const PaymentFormWidget(),
|
const PaymentFormWidget(),
|
||||||
SizedBox(height: dimensions.paddingXXXLarge),
|
SizedBox(height: dimensions.paddingLarge),
|
||||||
|
const QuoteStatus(),
|
||||||
|
SizedBox(height: dimensions.paddingXXLarge),
|
||||||
SendButton(onPressed: onSend),
|
SendButton(onPressed: onSend),
|
||||||
SizedBox(height: dimensions.paddingLarge),
|
SizedBox(height: dimensions.paddingLarge),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:pweb/utils/dimensions.dart';
|
||||||
|
|
||||||
|
|
||||||
|
class QuoteStatusActions extends StatelessWidget {
|
||||||
|
final bool isLoading;
|
||||||
|
final bool canRefresh;
|
||||||
|
final bool autoRefreshEnabled;
|
||||||
|
final ValueChanged<bool>? onToggleAutoRefresh;
|
||||||
|
final VoidCallback? onRefresh;
|
||||||
|
final String autoRefreshLabel;
|
||||||
|
final String refreshLabel;
|
||||||
|
final AppDimensions dimensions;
|
||||||
|
final TextTheme theme;
|
||||||
|
|
||||||
|
const QuoteStatusActions({
|
||||||
|
super.key,
|
||||||
|
required this.isLoading,
|
||||||
|
required this.canRefresh,
|
||||||
|
required this.autoRefreshEnabled,
|
||||||
|
required this.onToggleAutoRefresh,
|
||||||
|
required this.onRefresh,
|
||||||
|
required this.autoRefreshLabel,
|
||||||
|
required this.refreshLabel,
|
||||||
|
required this.dimensions,
|
||||||
|
required this.theme,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: SwitchListTile.adaptive(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
value: autoRefreshEnabled,
|
||||||
|
title: Text(autoRefreshLabel, style: theme.bodyMedium),
|
||||||
|
onChanged: onToggleAutoRefresh,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: canRefresh ? onRefresh : null,
|
||||||
|
icon: isLoading
|
||||||
|
? SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.refresh),
|
||||||
|
label: Text(refreshLabel),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:pweb/utils/dimensions.dart';
|
||||||
|
|
||||||
|
|
||||||
|
class QuoteStatusMessage extends StatelessWidget {
|
||||||
|
final String statusText;
|
||||||
|
final String? errorText;
|
||||||
|
final bool showDetails;
|
||||||
|
final VoidCallback onToggleDetails;
|
||||||
|
final AppDimensions dimensions;
|
||||||
|
final TextTheme theme;
|
||||||
|
final String showLabel;
|
||||||
|
final String hideLabel;
|
||||||
|
|
||||||
|
const QuoteStatusMessage({
|
||||||
|
super.key,
|
||||||
|
required this.statusText,
|
||||||
|
required this.errorText,
|
||||||
|
required this.showDetails,
|
||||||
|
required this.onToggleDetails,
|
||||||
|
required this.dimensions,
|
||||||
|
required this.theme,
|
||||||
|
required this.showLabel,
|
||||||
|
required this.hideLabel,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(statusText, style: theme.bodyMedium),
|
||||||
|
if (errorText != null) ...[
|
||||||
|
SizedBox(height: dimensions.paddingSmall),
|
||||||
|
TextButton(
|
||||||
|
onPressed: onToggleDetails,
|
||||||
|
child: Text(showDetails ? hideLabel : showLabel),
|
||||||
|
),
|
||||||
|
if (showDetails) ...[
|
||||||
|
SizedBox(height: dimensions.paddingSmall),
|
||||||
|
Text(
|
||||||
|
errorText!,
|
||||||
|
style: theme.bodySmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import 'package:pshared/provider/payment/quotation.dart';
|
||||||
|
|
||||||
|
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||||
|
import 'package:pweb/pages/payment_methods/payment_page/quote/actions.dart';
|
||||||
|
import 'package:pweb/pages/payment_methods/payment_page/quote/message.dart';
|
||||||
|
import 'package:pweb/utils/dimensions.dart';
|
||||||
|
|
||||||
|
|
||||||
|
class QuoteStatus extends StatefulWidget {
|
||||||
|
const QuoteStatus({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<QuoteStatus> createState() => _QuoteStatusState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _QuoteStatusState extends State<QuoteStatus> {
|
||||||
|
bool _showDetails = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final dimensions = AppDimensions();
|
||||||
|
final loc = AppLocalizations.of(context)!;
|
||||||
|
|
||||||
|
return Consumer<QuotationProvider>(
|
||||||
|
builder: (context, provider, _) {
|
||||||
|
final statusText = _statusText(provider, loc);
|
||||||
|
final canRefresh = provider.canRequestQuote && !provider.isLoading;
|
||||||
|
final refreshLabel = provider.isLoading ? loc.quoteUpdating : loc.retry;
|
||||||
|
final error = provider.error;
|
||||||
|
final backgroundColor = theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.3);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: EdgeInsets.all(dimensions.paddingMedium),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: backgroundColor,
|
||||||
|
borderRadius: BorderRadius.circular(dimensions.borderRadiusSmall),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
QuoteStatusMessage(
|
||||||
|
statusText: statusText,
|
||||||
|
errorText: error?.toString(),
|
||||||
|
showDetails: _showDetails,
|
||||||
|
onToggleDetails: () => setState(() => _showDetails = !_showDetails),
|
||||||
|
dimensions: dimensions,
|
||||||
|
theme: theme.textTheme,
|
||||||
|
showLabel: loc.showDetails,
|
||||||
|
hideLabel: loc.hideDetails,
|
||||||
|
),
|
||||||
|
SizedBox(height: dimensions.paddingSmall),
|
||||||
|
QuoteStatusActions(
|
||||||
|
isLoading: provider.isLoading,
|
||||||
|
canRefresh: canRefresh,
|
||||||
|
autoRefreshEnabled: provider.autoRefreshEnabled,
|
||||||
|
onToggleAutoRefresh: provider.canRequestQuote
|
||||||
|
? (value) => context.read<QuotationProvider>().setAutoRefresh(value)
|
||||||
|
: null,
|
||||||
|
onRefresh: canRefresh ? () => context.read<QuotationProvider>().refreshNow() : null,
|
||||||
|
autoRefreshLabel: loc.quoteAutoRefresh,
|
||||||
|
refreshLabel: refreshLabel,
|
||||||
|
dimensions: dimensions,
|
||||||
|
theme: theme.textTheme,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _statusText(QuotationProvider provider, AppLocalizations loc) {
|
||||||
|
if (provider.error != null) {
|
||||||
|
return loc.quoteErrorGeneric;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!provider.canRequestQuote) {
|
||||||
|
return loc.quoteUnavailable;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider.isLoading) {
|
||||||
|
return loc.quoteUpdating;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider.hasLiveQuote) {
|
||||||
|
final remaining = provider.timeToExpire;
|
||||||
|
if (remaining != null) {
|
||||||
|
return loc.quoteExpiresIn(_formatDuration(remaining));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider.hasQuoteForCurrentIntent) {
|
||||||
|
return loc.quoteExpired;
|
||||||
|
}
|
||||||
|
|
||||||
|
return loc.quoteUnavailable;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatDuration(Duration duration) {
|
||||||
|
final minutes = duration.inMinutes;
|
||||||
|
final seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0');
|
||||||
|
if (duration.inHours > 0) {
|
||||||
|
final hours = duration.inHours;
|
||||||
|
final mins = minutes.remainder(60).toString().padLeft(2, '0');
|
||||||
|
return '$hours:$mins:$seconds';
|
||||||
|
}
|
||||||
|
return '$minutes:$seconds';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,44 +1,76 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import 'package:pshared/provider/payment/provider.dart';
|
||||||
|
import 'package:pshared/provider/payment/quotation.dart';
|
||||||
|
|
||||||
|
import 'package:pweb/models/button_state.dart';
|
||||||
import 'package:pweb/utils/dimensions.dart';
|
import 'package:pweb/utils/dimensions.dart';
|
||||||
|
|
||||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
|
|
||||||
class SendButton extends StatelessWidget {
|
class SendButton extends StatelessWidget {
|
||||||
final VoidCallback onPressed;
|
final VoidCallback? onPressed;
|
||||||
|
|
||||||
const SendButton({super.key, required this.onPressed});
|
const SendButton({
|
||||||
|
super.key,
|
||||||
|
required this.onPressed,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) => Consumer2<QuotationProvider, PaymentProvider>(
|
||||||
final theme = Theme.of(context);
|
builder: (context, quotation, payment, _) {
|
||||||
final dimensions = AppDimensions();
|
final theme = Theme.of(context);
|
||||||
|
final dimensions = AppDimensions();
|
||||||
|
|
||||||
return Center(
|
final canSend = quotation.hasLiveQuote && !payment.isLoading;
|
||||||
child: SizedBox(
|
final state = payment.isLoading
|
||||||
width: dimensions.buttonWidth,
|
? ButtonState.loading
|
||||||
height: dimensions.buttonHeight,
|
: (canSend ? ButtonState.enabled : ButtonState.disabled);
|
||||||
child: InkWell(
|
final isLoading = state == ButtonState.loading;
|
||||||
borderRadius: BorderRadius.circular(dimensions.borderRadiusSmall),
|
final isActive = state == ButtonState.enabled && onPressed != null;
|
||||||
onTap: onPressed,
|
|
||||||
child: Container(
|
final backgroundColor = isActive
|
||||||
decoration: BoxDecoration(
|
? theme.colorScheme.primary
|
||||||
color: theme.colorScheme.primary,
|
: theme.colorScheme.primary.withValues(alpha: 0.5);
|
||||||
borderRadius: BorderRadius.circular(dimensions.borderRadiusSmall),
|
final textColor = theme.colorScheme.onSecondary.withValues(alpha: isActive ? 1 : 0.7);
|
||||||
),
|
|
||||||
child: Center(
|
return Center(
|
||||||
child: Text(
|
child: SizedBox(
|
||||||
AppLocalizations.of(context)!.send,
|
width: dimensions.buttonWidth,
|
||||||
style: theme.textTheme.bodyLarge?.copyWith(
|
height: dimensions.buttonHeight,
|
||||||
color: theme.colorScheme.onSecondary,
|
child: InkWell(
|
||||||
fontWeight: FontWeight.w600,
|
borderRadius: BorderRadius.circular(dimensions.borderRadiusSmall),
|
||||||
|
onTap: isActive ? onPressed : null,
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: backgroundColor,
|
||||||
|
borderRadius: BorderRadius.circular(dimensions.borderRadiusSmall),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: isLoading
|
||||||
|
? SizedBox(
|
||||||
|
height: dimensions.iconSizeSmall,
|
||||||
|
width: dimensions.iconSizeSmall,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(textColor),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Text(
|
||||||
|
AppLocalizations.of(context)!.send,
|
||||||
|
style: theme.textTheme.bodyLarge?.copyWith(
|
||||||
|
color: textColor,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
),
|
},
|
||||||
),
|
);
|
||||||
);
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
24
frontend/pweb/lib/providers/mock_payment.dart
Normal file
24
frontend/pweb/lib/providers/mock_payment.dart
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ class PaymentMethodDropdown extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => DropdownButtonFormField<Wallet>(
|
Widget build(BuildContext context) => DropdownButtonFormField<Wallet>(
|
||||||
dropdownColor: Theme.of(context).colorScheme.onSecondary,
|
dropdownColor: Theme.of(context).colorScheme.onSecondary,
|
||||||
value: _getSelectedMethod(),
|
initialValue: _getSelectedMethod(),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: AppLocalizations.of(context)!.whereGetMoney,
|
labelText: AppLocalizations.of(context)!.whereGetMoney,
|
||||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
|||||||
Reference in New Issue
Block a user