6 Commits

Author SHA1 Message Date
19b7b69bd8 Merge pull request 'Refresh button for balance' (#219) from SEND022 into main
Some checks failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline failed
Reviewed-on: #219
2025-12-30 21:14:55 +00:00
Arseni
b157522fdb Refresh button for balance 2025-12-30 18:36:29 +03:00
202582626a Merge pull request 'fixed signature check' (#215) from signature-214 into main
All checks were successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #215
2025-12-29 23:23:27 +00:00
Stephan D
6a2efd3d22 fixed signature check 2025-12-30 00:22:49 +01:00
a6374d1136 Merge pull request 'increased payout timeout' (#213) from timeout-210 into main
All checks were successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
Reviewed-on: #213
2025-12-29 15:43:48 +00:00
Stephan D
7c864dc304 increased payout timeout 2025-12-29 16:43:03 +01:00
32 changed files with 445 additions and 720 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,25 +2,16 @@ 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';
@@ -84,27 +75,8 @@ extension PaymentMethodDataEndpointMapper on PaymentMethodData {
).toJson(), ).toJson(),
metadata: metadata, metadata: metadata,
); );
case PaymentType.iban: default:
final payload = this as IbanPaymentMethod; throw UnsupportedError('Unsupported payment endpoint type: $type');
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,
);
} }
} }
} }
@@ -154,15 +126,8 @@ extension PaymentEndpointDTOMapper on PaymentEndpointDTO {
maskedPan: payload.maskedPan, maskedPan: payload.maskedPan,
metadata: metadata, metadata: metadata,
); );
case PaymentType.iban: default:
final payload = IbanPaymentDataDTO.fromJson(data); throw UnsupportedError('Unsupported payment endpoint type: ${paymentTypeFromValue(type)}');
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();
} }
} }
} }

View File

@@ -4,11 +4,9 @@ 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;
@@ -19,10 +17,4 @@ class PaymentAmountProvider with ChangeNotifier {
_payerCoversFee = value; _payerCoversFee = value;
notifyListeners(); notifyListeners();
} }
void setEditing(bool value) {
if (_isEditing == value) return;
_isEditing = value;
notifyListeners();
}
} }

View File

@@ -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.hasLiveQuote) throw StateError('Quotation is not ready'); if (!_quotation.isReady) 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');

View File

@@ -1,6 +1,3 @@
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';
@@ -18,7 +15,6 @@ 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';
@@ -38,16 +34,6 @@ 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,
@@ -58,81 +44,44 @@ class QuotationProvider extends ChangeNotifier {
PaymentMethodsProvider methods, PaymentMethodsProvider methods,
) { ) {
_organizations = venue; _organizations = venue;
_organizationAttached = true; final t = flow.selectedType;
final wasEditing = _amountEditing; final method = methods.methods.firstWhereOrNull((m) => m.type == t);
_amountEditing = payment.isEditing; if ((wallets.selectedWallet != null) && (method != null)) {
final editingJustEnded = wasEditing && !_amountEditing; final customer = _buildCustomer(
_pendingIntent = _buildIntent( recipient: recipients.currentObject,
payment: payment, method: method,
wallets: wallets,
flow: flow,
recipients: recipients,
methods: methods,
); );
getQuotation(PaymentIntent(
if (_pendingIntent == null) { kind: PaymentKind.payout,
_reset(); amount: Money(
return; amount: payment.amount.toString(),
// TODO: adapt to possible other sources
currency: currencyCodeToString(wallets.selectedWallet!.currency),
),
destination: method.data,
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) { PaymentQuote? get quotation => _quotation.data;
_debounceTimer?.cancel();
return;
}
if (editingJustEnded) { bool get isReady => _isLoaded && !_quotation.isLoading && _quotation.error == null;
refreshNow(force: false);
return;
}
_scheduleQuotationRefresh(); 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);
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 _isExpired {
final remaining = timeToExpire;
return remaining != null && remaining <= Duration.zero;
}
bool get hasQuoteForCurrentIntent {
if (_pendingIntent == null || _lastRequestSignature == null) return false;
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,
@@ -191,117 +140,10 @@ class QuotationProvider extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
void refreshNow({bool force = true}) { Future<PaymentQuote?> getQuotation(PaymentIntent intent) async {
_debounceTimer?.cancel(); if (!_organizations.isOrganizationSet) throw StateError('Organization is not set');
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(
@@ -310,9 +152,7 @@ class QuotationProvider extends ChangeNotifier {
), ),
); );
_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,
@@ -320,68 +160,13 @@ class QuotationProvider extends ChangeNotifier {
isLoading: false, isLoading: false,
)); ));
} }
notifyListeners();
return _quotation.data; return _quotation.data;
} }
void _startExpirationTimer() { void reset() {
_expirationTimer?.cancel(); _setResource(Resource(data: null, isLoading: false, error: null));
final remaining = timeToExpire; _isLoaded = false;
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(); notifyListeners();
} }
});
}
void _reset() {
_debounceTimer?.cancel();
_expirationTimer?.cancel();
_pendingIntent = null;
_lastRequestSignature = null;
_isLoaded = false;
_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();
}
} }

View File

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

View File

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

View File

@@ -383,19 +383,7 @@
"payout": "Payout", "payout": "Payout",
"sendTo": "Send Payout To", "sendTo": "Send Payout To",
"send": "Send Payout", "send": "Send Payout",
"quoteUnavailable": "Waiting for a quote...", "refreshBalance": "Refresh balance",
"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}",

View File

@@ -383,19 +383,7 @@
"payout": "Выплата", "payout": "Выплата",
"sendTo": "Отправить выплату", "sendTo": "Отправить выплату",
"send": "Отправить выплату", "send": "Отправить выплату",
"quoteUnavailable": "Ожидание котировки...", "refreshBalance": "Обновить баланс",
"quoteUpdating": "Обновляем котировку...",
"quoteExpiresIn": "Котировка истекает через {time}",
"@quoteExpiresIn": {
"placeholders": {
"time": {
"type": "String"
}
}
},
"quoteExpired": "Срок котировки истек, запросите новую",
"quoteAutoRefresh": "Автообновление котировки",
"quoteErrorGeneric": "Не удалось обновить котировку, повторите позже",
"recipientPaysFee": "Получатель оплачивает комиссию", "recipientPaysFee": "Получатель оплачивает комиссию",
"sentAmount": "Отправленная сумма: {amount}", "sentAmount": "Отправленная сумма: {amount}",

View File

@@ -1 +0,0 @@
enum ButtonState { enabled, disabled, loading }

View File

@@ -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();

View File

@@ -3,7 +3,6 @@ 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';
@@ -18,7 +17,6 @@ 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
@@ -26,14 +24,10 @@ 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();
} }
@@ -62,16 +56,6 @@ 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);
@@ -79,14 +63,12 @@ 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(),
); );
} }
} }

View File

@@ -6,7 +6,6 @@ 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';
@@ -82,16 +81,8 @@ 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) {

View File

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

View File

@@ -3,12 +3,11 @@ 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';
@@ -95,9 +94,7 @@ 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.paddingLarge), SizedBox(height: dimensions.paddingXXXLarge),
const QuoteStatus(),
SizedBox(height: dimensions.paddingXXLarge),
SendButton(onPressed: onSend), SendButton(onPressed: onSend),
SizedBox(height: dimensions.paddingLarge), SizedBox(height: dimensions.paddingLarge),
], ],

View File

@@ -1,54 +0,0 @@
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),
),
],
);
}

View File

@@ -1,49 +0,0 @@
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,
),
],
],
],
);
}

View File

@@ -1,115 +0,0 @@
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';
}
}

View File

@@ -1,68 +1,37 @@
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({ const SendButton({super.key, required this.onPressed});
super.key,
required this.onPressed,
});
@override @override
Widget build(BuildContext context) => Consumer2<QuotationProvider, PaymentProvider>( Widget build(BuildContext context) {
builder: (context, quotation, payment, _) {
final theme = Theme.of(context); final theme = Theme.of(context);
final dimensions = AppDimensions(); final dimensions = AppDimensions();
final canSend = quotation.hasLiveQuote && !payment.isLoading;
final state = payment.isLoading
? ButtonState.loading
: (canSend ? ButtonState.enabled : ButtonState.disabled);
final isLoading = state == ButtonState.loading;
final isActive = state == ButtonState.enabled && onPressed != null;
final backgroundColor = isActive
? theme.colorScheme.primary
: theme.colorScheme.primary.withValues(alpha: 0.5);
final textColor = theme.colorScheme.onSecondary.withValues(alpha: isActive ? 1 : 0.7);
return Center( return Center(
child: SizedBox( child: SizedBox(
width: dimensions.buttonWidth, width: dimensions.buttonWidth,
height: dimensions.buttonHeight, height: dimensions.buttonHeight,
child: InkWell( child: InkWell(
borderRadius: BorderRadius.circular(dimensions.borderRadiusSmall), borderRadius: BorderRadius.circular(dimensions.borderRadiusSmall),
onTap: isActive ? onPressed : null, onTap: onPressed,
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: backgroundColor, color: theme.colorScheme.primary,
borderRadius: BorderRadius.circular(dimensions.borderRadiusSmall), borderRadius: BorderRadius.circular(dimensions.borderRadiusSmall),
), ),
child: Center( child: Center(
child: isLoading child: Text(
? SizedBox(
height: dimensions.iconSizeSmall,
width: dimensions.iconSizeSmall,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(textColor),
),
)
: Text(
AppLocalizations.of(context)!.send, AppLocalizations.of(context)!.send,
style: theme.textTheme.bodyLarge?.copyWith( style: theme.textTheme.bodyLarge?.copyWith(
color: textColor, color: theme.colorScheme.onSecondary,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
), ),
@@ -71,6 +40,5 @@ class SendButton extends StatelessWidget {
), ),
), ),
); );
}, }
);
} }

View File

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

View File

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

View File

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

View File

@@ -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,
initialValue: _getSelectedMethod(), value: _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)),

View 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),
);
}
}