Merge pull request '+ quotation provider' (#60) from quote-front-59 into main
Some checks failed
ci/woodpecker/push/fx_oracle Pipeline is pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/mntx_gateway Pipeline is pending
ci/woodpecker/push/nats Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
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 failed
ci/woodpecker/push/frontend Pipeline failed

Reviewed-on: #60
This commit was merged in pull request #60.
This commit is contained in:
2025-12-11 00:13:35 +00:00
102 changed files with 2242 additions and 246 deletions

View File

@@ -4,6 +4,7 @@ import (
"time"
"github.com/tech/sendico/pkg/db/storable"
"github.com/tech/sendico/pkg/model"
)
// Quote represents a firm or indicative quote persisted by the oracle.
@@ -16,8 +17,8 @@ type Quote struct {
Pair CurrencyPair `bson:"pair" json:"pair"`
Side QuoteSide `bson:"side" json:"side"`
Price string `bson:"price" json:"price"`
BaseAmount Money `bson:"baseAmount" json:"baseAmount"`
QuoteAmount Money `bson:"quoteAmount" json:"quoteAmount"`
BaseAmount model.Money `bson:"baseAmount" json:"baseAmount"`
QuoteAmount model.Money `bson:"quoteAmount" json:"quoteAmount"`
AmountType QuoteAmountType `bson:"amountType" json:"amountType"`
ExpiresAtUnixMs int64 `bson:"expiresAtUnixMs" json:"expiresAtUnixMs"`
ExpiresAt *time.Time `bson:"expiresAt,omitempty" json:"expiresAt,omitempty"`

View File

@@ -51,12 +51,6 @@ type CurrencyPair struct {
Quote string `bson:"quote" json:"quote"`
}
// Money represents an exact decimal amount with its currency.
type Money struct {
Currency string `bson:"currency" json:"currency"`
Amount string `bson:"amount" json:"amount"`
}
// QuoteMeta carries request-scoped metadata associated with a quote.
type QuoteMeta struct {
model.OrganizationBoundBase `bson:",inline" json:",inline"`

View File

@@ -62,12 +62,6 @@ const (
OutboxStatusFailed OutboxStatus = "failed"
)
// Money represents an exact decimal amount with its currency.
type Money struct {
Currency string `bson:"currency" json:"currency"`
Amount string `bson:"amount" json:"amount"` // stored as string for exact decimal representation
}
// LedgerMeta carries organization-scoped metadata for ledger entities.
type LedgerMeta struct {
model.OrganizationBoundBase `bson:",inline" json:",inline"`

6
api/pkg/model/money.go Normal file
View File

@@ -0,0 +1,6 @@
package model
type Money struct {
Currency string `bson:"currency" json:"currency"`
Amount string `bson:"amount" json:"amount"`
}

View File

@@ -19,6 +19,7 @@ const (
PaymentTypeBankAccount
PaymentTypeWallet
PaymentTypeCryptoAddress
PaymentTypeLedger
)
var paymentTypeToString = map[PaymentType]string{
@@ -28,6 +29,7 @@ var paymentTypeToString = map[PaymentType]string{
PaymentTypeBankAccount: "bankAccount",
PaymentTypeWallet: "wallet",
PaymentTypeCryptoAddress: "cryptoAddress",
PaymentTypeLedger: "ledger",
}
var paymentTypeFromString = map[string]PaymentType{
@@ -37,6 +39,7 @@ var paymentTypeFromString = map[string]PaymentType{
"bankAccount": PaymentTypeBankAccount,
"wallet": PaymentTypeWallet,
"cryptoAddress": PaymentTypeCryptoAddress,
"ledger": PaymentTypeLedger,
}
func (t PaymentType) String() string {

View File

@@ -76,10 +76,11 @@ message CardEndpoint {
string token = 2; // network or gateway-issued token
}
string cardholder_name = 3;
uint32 exp_month = 4;
uint32 exp_year = 5;
string country = 6;
string masked_pan = 7;
string cardholder_surname = 4;
uint32 exp_month = 5;
uint32 exp_year = 6;
string country = 7;
string masked_pan = 8;
}
message PaymentEndpoint {

View File

@@ -21,6 +21,7 @@ require (
github.com/go-chi/metrics v0.1.1
github.com/google/uuid v1.6.0
github.com/mitchellh/mapstructure v1.5.0
github.com/shopspring/decimal v1.4.0
github.com/stretchr/testify v1.11.1
github.com/tech/sendico/gateway/chain v0.0.0-00010101000000-000000000000
github.com/tech/sendico/ledger v0.0.0-00010101000000-000000000000

View File

@@ -213,6 +213,8 @@ github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFt
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=

View File

@@ -26,15 +26,44 @@ type ExternalChainEndpoint struct {
Memo string `json:"memo,omitempty"`
}
// CardEndpoint represents a card payout payload.
// CardEndpoint represents a card payout payload (PAN or network token).
type CardEndpoint struct {
Pan string `json:"pan,omitempty"`
Token string `json:"token,omitempty"`
Cardholder string `json:"cardholder,omitempty"`
ExpMonth uint32 `json:"exp_month,omitempty"`
ExpYear uint32 `json:"exp_year,omitempty"`
Country string `json:"country,omitempty"`
MaskedPan string `json:"masked_pan,omitempty"`
Pan string `json:"pan"`
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
ExpMonth uint32 `json:"exp_month,omitempty"`
ExpYear uint32 `json:"exp_year,omitempty"`
Country string `json:"country,omitempty"`
}
// CardTokenEndpoint represents a vaulted card token payout payload.
type CardTokenEndpoint struct {
Token string `json:"token"`
MaskedPan string `json:"masked_pan"`
}
// WalletEndpoint represents a Sendico wallet payout payload.
type WalletEndpoint struct {
WalletID string `json:"walletId"`
}
// BankAccountEndpoint represents a domestic bank account payout payload.
type BankAccountEndpoint struct {
RecipientName string `json:"recipientName"`
Inn string `json:"inn"`
Kpp string `json:"kpp"`
BankName string `json:"bankName"`
Bik string `json:"bik"`
AccountNumber string `json:"accountNumber"`
CorrespondentAccount string `json:"correspondentAccount"`
}
// IBANEndpoint represents an international bank account payout payload.
type IBANEndpoint struct {
IBAN string `json:"iban"`
AccountHolder string `json:"accountHolder"`
BIC string `json:"bic,omitempty"`
BankName string `json:"bankName,omitempty"`
}
// LegacyPaymentEndpoint mirrors the previous bag-of-pointers DTO for backward compatibility.

View File

@@ -10,9 +10,13 @@ type EndpointType string
const (
EndpointTypeLedger EndpointType = "ledger"
EndpointTypeManagedWallet EndpointType = "managed_wallet"
EndpointTypeExternalChain EndpointType = "external_chain"
EndpointTypeManagedWallet EndpointType = "managedWallet"
EndpointTypeExternalChain EndpointType = "cryptoAddress"
EndpointTypeCard EndpointType = "card"
EndpointTypeCardToken EndpointType = "cardToken"
EndpointTypeWallet EndpointType = "wallet"
EndpointTypeBankAccount EndpointType = "bankAccount"
EndpointTypeIBAN EndpointType = "iban"
)
// Endpoint is a discriminated union for payment endpoints.
@@ -35,10 +39,11 @@ func newEndpoint(kind EndpointType, payload interface{}, metadata map[string]str
}
func (e Endpoint) decodePayload(expected EndpointType, dst interface{}) error {
if e.Type == "" {
actual := normalizeEndpointType(e.Type)
if actual == "" {
return merrors.InvalidArgument("endpoint type is required")
}
if e.Type != expected {
if actual != expected {
return merrors.InvalidArgument("expected endpoint type " + string(expected) + ", got " + string(e.Type))
}
if len(e.Data) == 0 {
@@ -62,7 +67,7 @@ func (e *Endpoint) UnmarshalJSON(data []byte) error {
return merrors.InvalidArgument("endpoint type is required")
}
*e = Endpoint{
Type: envelope.Type,
Type: normalizeEndpointType(envelope.Type),
Data: envelope.Data,
Metadata: cloneStringMap(envelope.Metadata),
}
@@ -101,6 +106,22 @@ func NewCardEndpointDTO(payload CardEndpoint, metadata map[string]string) (Endpo
return newEndpoint(EndpointTypeCard, payload, metadata)
}
func NewCardTokenEndpointDTO(payload CardTokenEndpoint, metadata map[string]string) (Endpoint, error) {
return newEndpoint(EndpointTypeCardToken, payload, metadata)
}
func NewWalletEndpointDTO(payload WalletEndpoint, metadata map[string]string) (Endpoint, error) {
return newEndpoint(EndpointTypeWallet, payload, metadata)
}
func NewBankAccountEndpointDTO(payload BankAccountEndpoint, metadata map[string]string) (Endpoint, error) {
return newEndpoint(EndpointTypeBankAccount, payload, metadata)
}
func NewIBANEndpointDTO(payload IBANEndpoint, metadata map[string]string) (Endpoint, error) {
return newEndpoint(EndpointTypeIBAN, payload, metadata)
}
func (e Endpoint) DecodeLedger() (LedgerEndpoint, error) {
var payload LedgerEndpoint
return payload, e.decodePayload(EndpointTypeLedger, &payload)
@@ -121,6 +142,26 @@ func (e Endpoint) DecodeCard() (CardEndpoint, error) {
return payload, e.decodePayload(EndpointTypeCard, &payload)
}
func (e Endpoint) DecodeCardToken() (CardTokenEndpoint, error) {
var payload CardTokenEndpoint
return payload, e.decodePayload(EndpointTypeCardToken, &payload)
}
func (e Endpoint) DecodeWallet() (WalletEndpoint, error) {
var payload WalletEndpoint
return payload, e.decodePayload(EndpointTypeWallet, &payload)
}
func (e Endpoint) DecodeBankAccount() (BankAccountEndpoint, error) {
var payload BankAccountEndpoint
return payload, e.decodePayload(EndpointTypeBankAccount, &payload)
}
func (e Endpoint) DecodeIBAN() (IBANEndpoint, error) {
var payload IBANEndpoint
return payload, e.decodePayload(EndpointTypeIBAN, &payload)
}
func LegacyPaymentEndpointToEndpointDTO(old *LegacyPaymentEndpoint) (*Endpoint, error) {
if old == nil {
return nil, nil
@@ -168,7 +209,7 @@ func EndpointDTOToLegacyPaymentEndpoint(new *Endpoint) (*LegacyPaymentEndpoint,
Metadata: cloneStringMap(new.Metadata),
}
switch new.Type {
switch normalizeEndpointType(new.Type) {
case EndpointTypeLedger:
payload, err := new.DecodeLedger()
if err != nil {
@@ -199,6 +240,20 @@ func EndpointDTOToLegacyPaymentEndpoint(new *Endpoint) (*LegacyPaymentEndpoint,
return legacy, nil
}
var endpointTypeAliases = map[EndpointType]EndpointType{
"managed_wallet": EndpointTypeManagedWallet,
"external_chain": EndpointTypeExternalChain,
"card_token": EndpointTypeCardToken,
"bank_account": EndpointTypeBankAccount,
}
func normalizeEndpointType(t EndpointType) EndpointType {
if canonical, ok := endpointTypeAliases[t]; ok {
return canonical
}
return t
}
func cloneStringMap(src map[string]string) map[string]string {
if len(src) == 0 {
return nil

View File

@@ -4,43 +4,54 @@ import (
"github.com/tech/sendico/pkg/merrors"
)
type QuotePayment struct {
type PaymentBase struct {
IdempotencyKey string `json:"idempotencyKey"`
Intent PaymentIntent `json:"intent"`
PreviewOnly bool `json:"previewOnly"`
Metadata map[string]string `json:"metadata,omitempty"`
}
type InitiatePayment struct {
IdempotencyKey string `json:"idempotencyKey"`
Metadata map[string]string `json:"metadata,omitempty"`
Intent *PaymentIntent `json:"intent,omitempty"`
QuoteRef string `json:"quoteRef,omitempty"`
}
func (r QuotePayment) Validate() error {
if r.IdempotencyKey == "" {
func (b *PaymentBase) Validate() error {
if b.IdempotencyKey == "" {
return merrors.InvalidArgument("idempotencyKey is required", "idempotencyKey")
}
return nil
}
if validator, ok := any(r.Intent).(interface{ Validate() error }); ok {
if err := validator.Validate(); err != nil {
return err
}
type QuotePayment struct {
PaymentBase `json:",inline"`
Intent PaymentIntent `json:"intent"`
PreviewOnly bool `json:"previewOnly"`
}
func (r *QuotePayment) Validate() error {
// base checks
if err := r.PaymentBase.Validate(); err != nil {
return err
}
// intent is mandatory, so validate always
if err := r.Intent.Validate(); err != nil {
return err
}
return nil
}
// Validate проверяет базовые инварианты запроса на инициацию платежа.
type InitiatePayment struct {
PaymentBase `json:",inline"`
Intent *PaymentIntent `json:"intent,omitempty"`
QuoteRef string `json:"quoteRef,omitempty"`
}
func (r InitiatePayment) Validate() error {
if r.IdempotencyKey == "" {
return merrors.InvalidArgument("idempotencyKey is required", "idempotencyKey")
// base checks
if err := r.PaymentBase.Validate(); err != nil {
return err
}
hasIntent := r.Intent != nil
hasQuote := r.QuoteRef != ""
// must be exactly one
switch {
case !hasIntent && !hasQuote:
return merrors.NoData("either intent or quoteRef must be provided")
@@ -48,11 +59,10 @@ func (r InitiatePayment) Validate() error {
return merrors.DataConflict("intent and quoteRef are mutually exclusive")
}
// if intent provided → validate it
if hasIntent {
if validator, ok := any(*r.Intent).(interface{ Validate() error }); ok {
if err := validator.Validate(); err != nil {
return err
}
if err := r.Intent.Validate(); err != nil {
return err
}
}

View File

@@ -1,12 +1,47 @@
package srequest
import (
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model"
)
type PaymentIntent struct {
Kind PaymentKind `json:"kind,omitempty"`
Source *Endpoint `json:"source,omitempty"`
Destination *Endpoint `json:"destination,omitempty"`
Amount *Money `json:"amount,omitempty"`
RequiresFX bool `json:"requires_fx,omitempty"`
Amount *model.Money `json:"amount,omitempty"`
FX *FXIntent `json:"fx,omitempty"`
SettlementMode SettlementMode `json:"settlement_mode,omitempty"`
Attributes map[string]string `json:"attributes,omitempty"`
}
func (p *PaymentIntent) Validate() error {
// Kind must be set (non-zero)
var zeroKind PaymentKind
if p.Kind == zeroKind {
return merrors.InvalidArgument("kind is required", "intent.kind")
}
if p.Source == nil {
return merrors.InvalidArgument("source is required", "intent.source")
}
if p.Destination == nil {
return merrors.InvalidArgument("destination is required", "intent.destination")
}
if p.Amount == nil {
return merrors.InvalidArgument("amount is required", "intent.amount")
}
if err := ValidateMoney(p.Amount); err != nil {
return err
}
if p.FX != nil {
if err := p.FX.Validate(); err != nil {
return err
}
}
return nil
}

View File

@@ -5,6 +5,8 @@ import (
"reflect"
"strings"
"testing"
"github.com/tech/sendico/pkg/model"
)
func TestEndpointDTOBuildersAndDecoders(t *testing.T) {
@@ -86,7 +88,14 @@ func TestEndpointDTOBuildersAndDecoders(t *testing.T) {
})
t.Run("card", func(t *testing.T) {
payload := CardEndpoint{Pan: "pan", Token: "token", Cardholder: "Jane", ExpMonth: 12, ExpYear: 2030, Country: "US", MaskedPan: "****"}
payload := CardEndpoint{
Pan: "pan",
FirstName: "Jane",
LastName: "Doe",
ExpMonth: 12,
ExpYear: 2030,
Country: "US",
}
endpoint, err := NewCardEndpointDTO(payload, map[string]string{"k": "v"})
if err != nil {
t.Fatalf("build card endpoint: %v", err)
@@ -106,6 +115,94 @@ func TestEndpointDTOBuildersAndDecoders(t *testing.T) {
}
})
t.Run("card token", func(t *testing.T) {
payload := CardTokenEndpoint{Token: "token", MaskedPan: "****1234"}
endpoint, err := NewCardTokenEndpointDTO(payload, nil)
if err != nil {
t.Fatalf("build card token endpoint: %v", err)
}
if endpoint.Type != EndpointTypeCardToken {
t.Fatalf("expected type %s got %s", EndpointTypeCardToken, endpoint.Type)
}
decoded, err := endpoint.DecodeCardToken()
if err != nil {
t.Fatalf("decode card token: %v", err)
}
if !reflect.DeepEqual(decoded, payload) {
t.Fatalf("decoded payload mismatch: %#v vs %#v", decoded, payload)
}
})
t.Run("wallet", func(t *testing.T) {
payload := WalletEndpoint{WalletID: "wallet-1"}
endpoint, err := NewWalletEndpointDTO(payload, nil)
if err != nil {
t.Fatalf("build wallet endpoint: %v", err)
}
if endpoint.Type != EndpointTypeWallet {
t.Fatalf("expected type %s got %s", EndpointTypeWallet, endpoint.Type)
}
decoded, err := endpoint.DecodeWallet()
if err != nil {
t.Fatalf("decode wallet: %v", err)
}
if decoded != payload {
t.Fatalf("decoded payload mismatch: %#v vs %#v", decoded, payload)
}
})
t.Run("bank account", func(t *testing.T) {
payload := BankAccountEndpoint{
RecipientName: "ACME",
Inn: "inn",
Kpp: "kpp",
BankName: "bank",
Bik: "bik",
AccountNumber: "123",
CorrespondentAccount: "456",
}
endpoint, err := NewBankAccountEndpointDTO(payload, map[string]string{"note": "n"})
if err != nil {
t.Fatalf("build bank account endpoint: %v", err)
}
if endpoint.Type != EndpointTypeBankAccount {
t.Fatalf("expected type %s got %s", EndpointTypeBankAccount, endpoint.Type)
}
decoded, err := endpoint.DecodeBankAccount()
if err != nil {
t.Fatalf("decode bank account: %v", err)
}
if !reflect.DeepEqual(decoded, payload) {
t.Fatalf("decoded payload mismatch: %#v vs %#v", decoded, payload)
}
if endpoint.Metadata["note"] != "n" {
t.Fatalf("expected metadata copy, got %s", endpoint.Metadata["note"])
}
})
t.Run("iban", func(t *testing.T) {
payload := IBANEndpoint{
IBAN: "DE123",
AccountHolder: "John Doe",
BIC: "BICCODE",
BankName: "BankName",
}
endpoint, err := NewIBANEndpointDTO(payload, nil)
if err != nil {
t.Fatalf("build iban endpoint: %v", err)
}
if endpoint.Type != EndpointTypeIBAN {
t.Fatalf("expected type %s got %s", EndpointTypeIBAN, endpoint.Type)
}
decoded, err := endpoint.DecodeIBAN()
if err != nil {
t.Fatalf("decode iban: %v", err)
}
if !reflect.DeepEqual(decoded, payload) {
t.Fatalf("decoded payload mismatch: %#v vs %#v", decoded, payload)
}
})
t.Run("type mismatch", func(t *testing.T) {
endpoint, err := NewLedgerEndpointDTO(LedgerEndpoint{LedgerAccountRef: "acc"}, nil)
if err != nil {
@@ -122,6 +219,24 @@ func TestEndpointDTOBuildersAndDecoders(t *testing.T) {
t.Fatalf("expected decode error")
}
})
t.Run("legacy type alias normalizes", func(t *testing.T) {
raw := []byte(`{"type":"managed_wallet","data":{"managed_wallet_ref":"mw-legacy"}}`)
var endpoint Endpoint
if err := json.Unmarshal(raw, &endpoint); err != nil {
t.Fatalf("unmarshal with legacy type: %v", err)
}
if endpoint.Type != EndpointTypeManagedWallet {
t.Fatalf("expected normalized type %s got %s", EndpointTypeManagedWallet, endpoint.Type)
}
payload, err := endpoint.DecodeManagedWallet()
if err != nil {
t.Fatalf("decode managed wallet with alias: %v", err)
}
if payload.ManagedWalletRef != "mw-legacy" {
t.Fatalf("decoded payload mismatch from alias: %#v", payload)
}
})
}
func TestPaymentIntentJSONRoundTrip(t *testing.T) {
@@ -140,8 +255,7 @@ func TestPaymentIntentJSONRoundTrip(t *testing.T) {
Kind: PaymentKindPayout,
Source: &source,
Destination: &dest,
Amount: &Money{Amount: "10", Currency: "USD"},
RequiresFX: true,
Amount: &model.Money{Amount: "10", Currency: "USD"},
FX: &FXIntent{
Pair: &CurrencyPair{Base: "USD", Quote: "EUR"},
Side: FXSideBuyBaseSellQuote,
@@ -163,7 +277,7 @@ func TestPaymentIntentJSONRoundTrip(t *testing.T) {
t.Fatalf("unmarshal intent: %v", err)
}
if decoded.Kind != intent.Kind || decoded.RequiresFX != intent.RequiresFX || decoded.SettlementMode != intent.SettlementMode {
if decoded.Kind != intent.Kind || decoded.SettlementMode != intent.SettlementMode {
t.Fatalf("scalar fields changed after round trip")
}
if decoded.Amount == nil || *decoded.Amount != *intent.Amount {
@@ -210,7 +324,7 @@ func TestPaymentIntentMinimalRoundTrip(t *testing.T) {
Kind: PaymentKindInternalTransfer,
Source: &source,
Destination: &dest,
Amount: &Money{Amount: "1", Currency: "USD"},
Amount: &model.Money{Amount: "1", Currency: "USD"},
}
data, err := json.Marshal(intent)
@@ -222,7 +336,7 @@ func TestPaymentIntentMinimalRoundTrip(t *testing.T) {
t.Fatalf("unmarshal intent: %v", err)
}
if decoded.Kind != intent.Kind || decoded.RequiresFX || decoded.FX != nil {
if decoded.Kind != intent.Kind || decoded.FX != nil {
t.Fatalf("unexpected fx data in minimal intent: %#v", decoded)
}
if decoded.Amount == nil || *decoded.Amount != *intent.Amount {
@@ -287,7 +401,7 @@ func TestLegacyEndpointRoundTrip(t *testing.T) {
func TestLegacyEndpointConversionRejectsMultiple(t *testing.T) {
_, err := LegacyPaymentEndpointToEndpointDTO(&LegacyPaymentEndpoint{
Ledger: &LedgerEndpoint{LedgerAccountRef: "a"},
Card: &CardEndpoint{Token: "t"},
Card: &CardEndpoint{Pan: "t"},
})
if err == nil {
t.Fatalf("expected error when multiple legacy endpoints are set")

View File

@@ -1,8 +1,33 @@
package srequest
type Money struct {
Amount string `json:"amount"`
Currency string `json:"currency"`
import (
"github.com/shopspring/decimal"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model"
)
func ValidateMoney(m *model.Money) error {
if m.Amount == "" {
return merrors.InvalidArgument("amount is required", "intent.amount")
}
if m.Currency == "" {
return merrors.InvalidArgument("currency is required", "intent.currency")
}
if _, err := decimal.NewFromString(m.Amount); err != nil {
return merrors.InvalidArgument("invalid amount decimal", "intent.amount")
}
if len(m.Currency) != 3 {
return merrors.InvalidArgument("currency must be 3 letters", "intent.currency")
}
for _, c := range m.Currency {
if c < 'A' || c > 'Z' {
return merrors.InvalidArgument("currency must be uppercase A-Z", "intent.currency")
}
}
return nil
}
type CurrencyPair struct {
@@ -10,6 +35,35 @@ type CurrencyPair struct {
Quote string `json:"quote"`
}
func (p *CurrencyPair) Validate() error {
if p.Base == "" {
return merrors.InvalidArgument("base currency is required", "intent.fx.pair.base")
}
if p.Quote == "" {
return merrors.InvalidArgument("quote currency is required", "intent.fx.pair.quote")
}
if len(p.Base) != 3 {
return merrors.InvalidArgument("base currency must be 3 letters", "intent.fx.pair.base")
}
if len(p.Quote) != 3 {
return merrors.InvalidArgument("quote currency must be 3 letters", "intent.fx.pair.quote")
}
for _, c := range p.Base {
if c < 'A' || c > 'Z' {
return merrors.InvalidArgument("base currency must be uppercase A-Z", "intent.fx.pair.base")
}
}
for _, c := range p.Quote {
if c < 'A' || c > 'Z' {
return merrors.InvalidArgument("quote currency must be uppercase A-Z", "intent.fx.pair.quote")
}
}
return nil
}
type FXIntent struct {
Pair *CurrencyPair `json:"pair,omitempty"`
Side FXSide `json:"side,omitempty"`
@@ -18,3 +72,29 @@ type FXIntent struct {
PreferredProvider string `json:"preferred_provider,omitempty"`
MaxAgeMs int32 `json:"max_age_ms,omitempty"`
}
func (fx *FXIntent) Validate() error {
if fx.Pair != nil {
if err := fx.Pair.Validate(); err != nil {
return err
}
}
var zeroSide FXSide
if fx.Side == zeroSide {
return merrors.InvalidArgument("fx side is required", "intent.fx.side")
}
if fx.TTLms < 0 {
return merrors.InvalidArgument("fx ttl_ms cannot be negative", "intent.fx.ttl_ms")
}
if fx.TTLms == 0 && fx.Firm {
return merrors.InvalidArgument("firm quote requires positive ttl_ms", "intent.fx.ttl_ms")
}
if fx.MaxAgeMs < 0 {
return merrors.InvalidArgument("fx max_age_ms cannot be negative", "intent.fx.max_age_ms")
}
return nil
}

View File

@@ -0,0 +1,5 @@
package srequest
type Validatable interface {
Validate() error
}

View File

@@ -1,17 +1,15 @@
package sresponse
import moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
import (
"github.com/tech/sendico/pkg/model"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
)
type Money struct {
Amount string `json:"amount"`
Currency string `json:"currency"`
}
func toMoney(m *moneyv1.Money) *Money {
func toMoney(m *moneyv1.Money) *model.Money {
if m == nil {
return nil
}
return &Money{
return &model.Money{
Amount: m.GetAmount(),
Currency: m.GetCurrency(),
}

View File

@@ -5,6 +5,7 @@ import (
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
@@ -13,40 +14,40 @@ import (
type FeeLine struct {
LedgerAccountRef string `json:"ledgerAccountRef,omitempty"`
Amount *Money `json:"amount,omitempty"`
Amount *model.Money `json:"amount,omitempty"`
LineType string `json:"lineType,omitempty"`
Side string `json:"side,omitempty"`
Meta map[string]string `json:"meta,omitempty"`
}
type NetworkFee struct {
NetworkFee *Money `json:"networkFee,omitempty"`
EstimationContext string `json:"estimationContext,omitempty"`
NetworkFee *model.Money `json:"networkFee,omitempty"`
EstimationContext string `json:"estimationContext,omitempty"`
}
type FxQuote struct {
QuoteRef string `json:"quoteRef,omitempty"`
BaseCurrency string `json:"baseCurrency,omitempty"`
QuoteCurrency string `json:"quoteCurrency,omitempty"`
Side string `json:"side,omitempty"`
Price string `json:"price,omitempty"`
BaseAmount *Money `json:"baseAmount,omitempty"`
QuoteAmount *Money `json:"quoteAmount,omitempty"`
ExpiresAtUnixMs int64 `json:"expiresAtUnixMs,omitempty"`
Provider string `json:"provider,omitempty"`
RateRef string `json:"rateRef,omitempty"`
Firm bool `json:"firm,omitempty"`
QuoteRef string `json:"quoteRef,omitempty"`
BaseCurrency string `json:"baseCurrency,omitempty"`
QuoteCurrency string `json:"quoteCurrency,omitempty"`
Side string `json:"side,omitempty"`
Price string `json:"price,omitempty"`
BaseAmount *model.Money `json:"baseAmount,omitempty"`
QuoteAmount *model.Money `json:"quoteAmount,omitempty"`
ExpiresAtUnixMs int64 `json:"expiresAtUnixMs,omitempty"`
Provider string `json:"provider,omitempty"`
RateRef string `json:"rateRef,omitempty"`
Firm bool `json:"firm,omitempty"`
}
type PaymentQuote struct {
QuoteRef string `json:"quoteRef,omitempty"`
DebitAmount *Money `json:"debitAmount,omitempty"`
ExpectedSettlementAmount *Money `json:"expectedSettlementAmount,omitempty"`
ExpectedFeeTotal *Money `json:"expectedFeeTotal,omitempty"`
FeeQuoteToken string `json:"feeQuoteToken,omitempty"`
FeeLines []FeeLine `json:"feeLines,omitempty"`
NetworkFee *NetworkFee `json:"networkFee,omitempty"`
FxQuote *FxQuote `json:"fxQuote,omitempty"`
QuoteRef string `json:"quoteRef,omitempty"`
DebitAmount *model.Money `json:"debitAmount,omitempty"`
ExpectedSettlementAmount *model.Money `json:"expectedSettlementAmount,omitempty"`
ExpectedFeeTotal *model.Money `json:"expectedFeeTotal,omitempty"`
FeeQuoteToken string `json:"feeQuoteToken,omitempty"`
FeeLines []FeeLine `json:"feeLines,omitempty"`
NetworkFee *NetworkFee `json:"networkFee,omitempty"`
FxQuote *FxQuote `json:"fxQuote,omitempty"`
}
type Payment struct {

View File

@@ -6,6 +6,7 @@ import (
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
"google.golang.org/protobuf/types/known/timestamppb"
@@ -36,10 +37,10 @@ type walletsResponse struct {
}
type walletBalance struct {
Available *Money `json:"available,omitempty"`
PendingInbound *Money `json:"pendingInbound,omitempty"`
PendingOutbound *Money `json:"pendingOutbound,omitempty"`
CalculatedAt string `json:"calculatedAt,omitempty"`
Available *model.Money `json:"available,omitempty"`
PendingInbound *model.Money `json:"pendingInbound,omitempty"`
PendingOutbound *model.Money `json:"pendingOutbound,omitempty"`
CalculatedAt string `json:"calculatedAt,omitempty"`
}
type walletBalanceResponse struct {

View File

@@ -4,6 +4,7 @@ import (
"strings"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model"
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
@@ -45,7 +46,7 @@ func mapPaymentIntent(intent *srequest.PaymentIntent) (*orchestratorv1.PaymentIn
Source: source,
Destination: destination,
Amount: mapMoney(intent.Amount),
RequiresFx: intent.RequiresFX,
RequiresFx: fx != nil,
Fx: fx,
SettlementMode: settlementMode,
Attributes: copyStringMap(intent.Attributes),
@@ -99,6 +100,14 @@ func mapPaymentEndpoint(endpoint *srequest.Endpoint, field string) (*orchestrato
result.Endpoint = &orchestratorv1.PaymentEndpoint_Card{
Card: mapCardEndpoint(&payload),
}
case srequest.EndpointTypeCardToken:
payload, err := endpoint.DecodeCardToken()
if err != nil {
return nil, merrors.InvalidArgument(field + " endpoint: " + err.Error())
}
result.Endpoint = &orchestratorv1.PaymentEndpoint_Card{
Card: mapCardTokenEndpoint(&payload),
}
case "":
return nil, merrors.InvalidArgument(field + " endpoint type is required")
default:
@@ -163,7 +172,7 @@ func mapAsset(asset *srequest.Asset) (*chainv1.Asset, error) {
}, nil
}
func mapMoney(m *srequest.Money) *moneyv1.Money {
func mapMoney(m *model.Money) *moneyv1.Money {
if m == nil {
return nil
}
@@ -206,21 +215,28 @@ func mapCardEndpoint(card *srequest.CardEndpoint) *orchestratorv1.CardEndpoint {
return nil
}
result := &orchestratorv1.CardEndpoint{
CardholderName: strings.TrimSpace(card.Cardholder),
ExpMonth: card.ExpMonth,
ExpYear: card.ExpYear,
Country: strings.TrimSpace(card.Country),
MaskedPan: strings.TrimSpace(card.MaskedPan),
CardholderName: strings.TrimSpace(card.FirstName),
CardholderSurname: strings.TrimSpace(card.LastName),
ExpMonth: card.ExpMonth,
ExpYear: card.ExpYear,
Country: strings.TrimSpace(card.Country),
}
if pan := strings.TrimSpace(card.Pan); pan != "" {
result.Card = &orchestratorv1.CardEndpoint_Pan{Pan: pan}
}
if token := strings.TrimSpace(card.Token); token != "" {
result.Card = &orchestratorv1.CardEndpoint_Token{Token: token}
}
return result
}
func mapCardTokenEndpoint(card *srequest.CardTokenEndpoint) *orchestratorv1.CardEndpoint {
if card == nil {
return nil
}
return &orchestratorv1.CardEndpoint{
Card: &orchestratorv1.CardEndpoint_Token{Token: strings.TrimSpace(card.Token)},
MaskedPan: strings.TrimSpace(card.MaskedPan),
}
}
func mapPaymentKind(kind srequest.PaymentKind) (orchestratorv1.PaymentKind, error) {
switch strings.TrimSpace(string(kind)) {
case "", string(srequest.PaymentKindUnspecified):

View File

@@ -36,11 +36,17 @@ func (a *PaymentAPI) quotePayment(r *http.Request, account *model.Account, token
payload, err := decodeQuotePayload(r)
if err != nil {
a.logger.Debug("Failed to decode payload", zap.Error(err), mutil.PLog(a.oph, r))
return response.BadPayload(a.logger, a.Name(), err)
}
if err := payload.Validate(); err != nil {
a.logger.Debug("Failed to validate payload", zap.Error(err), mutil.PLog(a.oph, r))
return response.Auto(a.logger, a.Name(), err)
}
intent, err := mapPaymentIntent(&payload.Intent)
if err != nil {
a.logger.Debug("Failed to map payment intent", zap.Error(err), mutil.PLog(a.oph, r))
return response.BadPayload(a.logger, a.Name(), err)
}
@@ -66,7 +72,7 @@ func decodeQuotePayload(r *http.Request) (*srequest.QuotePayment, error) {
payload := &srequest.QuotePayment{}
if err := json.NewDecoder(r.Body).Decode(payload); err != nil {
return nil, merrors.InvalidArgument("invalid payload: " + err.Error())
return nil, merrors.InvalidArgument("invalid payload: "+err.Error(), "payload")
}
payload.IdempotencyKey = strings.TrimSpace(payload.IdempotencyKey)
if err := payload.Validate(); err != nil {

View File

@@ -35,7 +35,7 @@ type PaymentAPI struct {
permissionRef primitive.ObjectID
}
func (a *PaymentAPI) Name() mservice.Type { return mservice.PaymentOrchestrator }
func (a *PaymentAPI) Name() mservice.Type { return mservice.Payments }
func (a *PaymentAPI) Finish(ctx context.Context) error {
if a.client != nil {
@@ -48,12 +48,12 @@ func (a *PaymentAPI) Finish(ctx context.Context) error {
func CreateAPI(apiCtx eapi.API) (*PaymentAPI, error) {
p := &PaymentAPI{
logger: apiCtx.Logger().Named(mservice.PaymentOrchestrator),
logger: apiCtx.Logger().Named(mservice.Payments),
enf: apiCtx.Permissions().Enforcer(),
oph: mutil.CreatePH(mservice.Organizations),
}
desc, err := apiCtx.Permissions().GetPolicyDescription(context.Background(), mservice.PaymentOrchestrator)
desc, err := apiCtx.Permissions().GetPolicyDescription(context.Background(), mservice.Payments)
if err != nil {
p.logger.Warn("Failed to fetch payment orchestrator permission description", zap.Error(err))
return nil, err

View File

@@ -0,0 +1,20 @@
import 'package:json_annotation/json_annotation.dart';
part 'base.g.dart';
@JsonSerializable()
class PaymentBaseRequest {
final String idempotencyKey;
final Map<String, String>? metadata;
const PaymentBaseRequest({
required this.idempotencyKey,
this.metadata,
});
factory PaymentBaseRequest.fromJson(Map<String, dynamic> json) => _$PaymentBaseRequestFromJson(json);
Map<String, dynamic> toJson() => _$PaymentBaseRequestToJson(this);
}

View File

@@ -0,0 +1,24 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:pshared/api/requests/payment/base.dart';
import 'package:pshared/data/dto/payment/intent/payment.dart';
part 'initiate.g.dart';
@JsonSerializable()
class InitiatePaymentRequest extends PaymentBaseRequest {
final PaymentIntentDTO? intent;
final String? quoteRef;
const InitiatePaymentRequest({
required super.idempotencyKey,
super.metadata,
this.intent,
this.quoteRef,
});
factory InitiatePaymentRequest.fromJson(Map<String, dynamic> json) => _$InitiatePaymentRequestFromJson(json);
@override
Map<String, dynamic> toJson() => _$InitiatePaymentRequestToJson(this);
}

View File

@@ -0,0 +1,26 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:pshared/api/requests/payment/base.dart';
import 'package:pshared/data/dto/payment/intent/payment.dart';
part 'quote.g.dart';
@JsonSerializable()
class QuotePaymentRequest extends PaymentBaseRequest {
final PaymentIntentDTO intent;
@JsonKey(defaultValue: false)
final bool previewOnly;
const QuotePaymentRequest({
required super.idempotencyKey,
super.metadata,
required this.intent,
this.previewOnly = false,
});
factory QuotePaymentRequest.fromJson(Map<String, dynamic> json) => _$QuotePaymentRequestFromJson(json);
@override
Map<String, dynamic> toJson() => _$QuotePaymentRequestToJson(this);
}

View File

@@ -0,0 +1,20 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:pshared/api/responses/base.dart';
import 'package:pshared/api/responses/token.dart';
import 'package:pshared/data/dto/payment/payment_quote.dart';
part 'quotation.g.dart';
@JsonSerializable(explicitToJson: true)
class PaymentQuoteResponse extends BaseAuthorizedResponse {
final PaymentQuoteDTO quote;
const PaymentQuoteResponse({required super.accessToken, required this.quote});
factory PaymentQuoteResponse.fromJson(Map<String, dynamic> json) => _$PaymentQuoteResponseFromJson(json);
@override
Map<String, dynamic> toJson() => _$PaymentQuoteResponseToJson(this);
}

View File

@@ -0,0 +1,24 @@
import 'package:json_annotation/json_annotation.dart';
part 'asset.g.dart';
@JsonSerializable()
class AssetDTO {
final String chain;
@JsonKey(name: 'token_symbol')
final String tokenSymbol;
@JsonKey(name: 'contract_address')
final String? contractAddress;
const AssetDTO({
required this.chain,
required this.tokenSymbol,
this.contractAddress,
});
factory AssetDTO.fromJson(Map<String, dynamic> json) => _$AssetDTOFromJson(json);
Map<String, dynamic> toJson() => _$AssetDTOToJson(this);
}

View File

@@ -4,17 +4,28 @@ part 'card.g.dart';
@JsonSerializable()
class CardPaymentDataDTO {
class CardEndpointDTO {
final String pan;
final String firstName;
final String lastName;
const CardPaymentDataDTO({
@JsonKey(name: 'exp_month')
final int? expMonth;
@JsonKey(name: 'exp_year')
final int? expYear;
final String? country;
const CardEndpointDTO({
required this.pan,
required this.firstName,
required this.lastName,
required this.expMonth,
required this.expYear,
this.country,
});
factory CardPaymentDataDTO.fromJson(Map<String, dynamic> json) => _$CardPaymentDataDTOFromJson(json);
Map<String, dynamic> toJson() => _$CardPaymentDataDTOToJson(this);
factory CardEndpointDTO.fromJson(Map<String, dynamic> json) => _$CardEndpointDTOFromJson(json);
Map<String, dynamic> toJson() => _$CardEndpointDTOToJson(this);
}

View File

@@ -0,0 +1,20 @@
import 'package:json_annotation/json_annotation.dart';
part 'card_token.g.dart';
@JsonSerializable()
class CardTokenEndpointDTO {
final String token;
@JsonKey(name: 'masked_pan')
final String maskedPan;
const CardTokenEndpointDTO({
required this.maskedPan,
required this.token,
});
factory CardTokenEndpointDTO.fromJson(Map<String, dynamic> json) => _$CardTokenEndpointDTOFromJson(json);
Map<String, dynamic> toJson() => _$CardTokenEndpointDTOToJson(this);
}

View File

@@ -1,20 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
part 'crypto_address.g.dart';
@JsonSerializable()
class CryptoAddressPaymentDataDTO {
final String address;
final String network;
final String? destinationTag;
const CryptoAddressPaymentDataDTO({
required this.address,
required this.network,
this.destinationTag,
});
factory CryptoAddressPaymentDataDTO.fromJson(Map<String, dynamic> json) => _$CryptoAddressPaymentDataDTOFromJson(json);
Map<String, dynamic> toJson() => _$CryptoAddressPaymentDataDTOToJson(this);
}

View File

@@ -0,0 +1,18 @@
import 'package:json_annotation/json_annotation.dart';
part 'currency_pair.g.dart';
@JsonSerializable()
class CurrencyPairDTO {
final String base;
final String quote;
const CurrencyPairDTO({
required this.base,
required this.quote,
});
factory CurrencyPairDTO.fromJson(Map<String, dynamic> json) => _$CurrencyPairDTOFromJson(json);
Map<String, dynamic> toJson() => _$CurrencyPairDTOToJson(this);
}

View File

@@ -0,0 +1,20 @@
import 'package:json_annotation/json_annotation.dart';
part 'endpoint.g.dart';
@JsonSerializable()
class PaymentEndpointDTO {
final String type;
final Map<String, dynamic> data;
final Map<String, String>? metadata;
const PaymentEndpointDTO({
required this.type,
required this.data,
this.metadata,
});
factory PaymentEndpointDTO.fromJson(Map<String, dynamic> json) => _$PaymentEndpointDTOFromJson(json);
Map<String, dynamic> toJson() => _$PaymentEndpointDTOToJson(this);
}

View File

@@ -0,0 +1,22 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:pshared/data/dto/payment/asset.dart';
part 'external_chain.g.dart';
@JsonSerializable()
class ExternalChainEndpointDTO {
final AssetDTO? asset;
final String address;
final String? memo;
const ExternalChainEndpointDTO({
this.asset,
required this.address,
this.memo,
});
factory ExternalChainEndpointDTO.fromJson(Map<String, dynamic> json) => _$ExternalChainEndpointDTOFromJson(json);
Map<String, dynamic> toJson() => _$ExternalChainEndpointDTOToJson(this);
}

View File

@@ -0,0 +1,26 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:pshared/data/dto/payment/money.dart';
part 'fee_line.g.dart';
@JsonSerializable()
class FeeLineDTO {
final String? ledgerAccountRef;
final MoneyDTO? amount;
final String? lineType;
final String? side;
final Map<String, String>? meta;
const FeeLineDTO({
this.ledgerAccountRef,
this.amount,
this.lineType,
this.side,
this.meta,
});
factory FeeLineDTO.fromJson(Map<String, dynamic> json) => _$FeeLineDTOFromJson(json);
Map<String, dynamic> toJson() => _$FeeLineDTOToJson(this);
}

View File

@@ -0,0 +1,40 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:pshared/data/dto/payment/money.dart';
part 'fx_quote.g.dart';
@JsonSerializable()
class FxQuoteDTO {
final String? quoteRef;
final String? baseCurrency;
final String? quoteCurrency;
final String? side;
final String? price;
final MoneyDTO? baseAmount;
final MoneyDTO? quoteAmount;
final int? expiresAtUnixMs;
final String? provider;
final String? rateRef;
@JsonKey(defaultValue: false)
final bool? firm;
const FxQuoteDTO({
this.quoteRef,
this.baseCurrency,
this.quoteCurrency,
this.side,
this.price,
this.baseAmount,
this.quoteAmount,
this.expiresAtUnixMs,
this.provider,
this.rateRef,
this.firm = false,
});
factory FxQuoteDTO.fromJson(Map<String, dynamic> json) => _$FxQuoteDTOFromJson(json);
Map<String, dynamic> toJson() => _$FxQuoteDTOToJson(this);
}

View File

@@ -0,0 +1,34 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:pshared/data/dto/payment/currency_pair.dart';
part 'fx.g.dart';
@JsonSerializable()
class FxIntentDTO {
final CurrencyPairDTO? pair;
final String? side;
final bool firm;
@JsonKey(name: 'ttl_ms')
final int? ttlMs;
@JsonKey(name: 'preferred_provider')
final String? preferredProvider;
@JsonKey(name: 'max_age_ms')
final int? maxAgeMs;
const FxIntentDTO({
this.pair,
this.side,
this.firm = false,
this.ttlMs,
this.preferredProvider,
this.maxAgeMs,
});
factory FxIntentDTO.fromJson(Map<String, dynamic> json) => _$FxIntentDTOFromJson(json);
Map<String, dynamic> toJson() => _$FxIntentDTOToJson(this);
}

View File

@@ -0,0 +1,36 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:pshared/data/dto/payment/endpoint.dart';
import 'package:pshared/data/dto/payment/intent/fx.dart';
import 'package:pshared/data/dto/payment/money.dart';
part 'payment.g.dart';
@JsonSerializable()
class PaymentIntentDTO {
final String? kind;
final PaymentEndpointDTO? source;
final PaymentEndpointDTO? destination;
final MoneyDTO? amount;
final FxIntentDTO? fx;
@JsonKey(name: 'settlement_mode')
final String? settlementMode;
final Map<String, String>? attributes;
const PaymentIntentDTO({
this.kind,
this.source,
this.destination,
this.amount,
this.fx,
this.settlementMode,
this.attributes,
});
factory PaymentIntentDTO.fromJson(Map<String, dynamic> json) => _$PaymentIntentDTOFromJson(json);
Map<String, dynamic> toJson() => _$PaymentIntentDTOToJson(this);
}

View File

@@ -0,0 +1,21 @@
import 'package:json_annotation/json_annotation.dart';
part 'ledger.g.dart';
@JsonSerializable()
class LedgerEndpointDTO {
@JsonKey(name: 'ledger_account_ref')
final String ledgerAccountRef;
@JsonKey(name: 'contra_ledger_account_ref')
final String? contraLedgerAccountRef;
const LedgerEndpointDTO({
required this.ledgerAccountRef,
this.contraLedgerAccountRef,
});
factory LedgerEndpointDTO.fromJson(Map<String, dynamic> json) => _$LedgerEndpointDTOFromJson(json);
Map<String, dynamic> toJson() => _$LedgerEndpointDTOToJson(this);
}

View File

@@ -0,0 +1,22 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:pshared/data/dto/payment/asset.dart';
part 'managed_wallet.g.dart';
@JsonSerializable()
class ManagedWalletEndpointDTO {
@JsonKey(name: 'managed_wallet_ref')
final String managedWalletRef;
final AssetDTO? asset;
const ManagedWalletEndpointDTO({
required this.managedWalletRef,
this.asset,
});
factory ManagedWalletEndpointDTO.fromJson(Map<String, dynamic> json) => _$ManagedWalletEndpointDTOFromJson(json);
Map<String, dynamic> toJson() => _$ManagedWalletEndpointDTOToJson(this);
}

View File

@@ -0,0 +1,18 @@
import 'package:json_annotation/json_annotation.dart';
part 'money.g.dart';
@JsonSerializable()
class MoneyDTO {
final String amount;
final String currency;
const MoneyDTO({
required this.amount,
required this.currency,
});
factory MoneyDTO.fromJson(Map<String, dynamic> json) => _$MoneyDTOFromJson(json);
Map<String, dynamic> toJson() => _$MoneyDTOToJson(this);
}

View File

@@ -0,0 +1,20 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:pshared/data/dto/payment/money.dart';
part 'network_fee.g.dart';
@JsonSerializable()
class NetworkFeeDTO {
final MoneyDTO? networkFee;
final String? estimationContext;
const NetworkFeeDTO({
this.networkFee,
this.estimationContext,
});
factory NetworkFeeDTO.fromJson(Map<String, dynamic> json) => _$NetworkFeeDTOFromJson(json);
Map<String, dynamic> toJson() => _$NetworkFeeDTOToJson(this);
}

View File

@@ -0,0 +1,28 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:pshared/data/dto/payment/payment_quote.dart';
part 'payment.g.dart';
@JsonSerializable()
class PaymentDTO {
final String? paymentRef;
final String? idempotencyKey;
final String? state;
final String? failureCode;
final String? failureReason;
final PaymentQuoteDTO? lastQuote;
const PaymentDTO({
this.paymentRef,
this.idempotencyKey,
this.state,
this.failureCode,
this.failureReason,
this.lastQuote,
});
factory PaymentDTO.fromJson(Map<String, dynamic> json) => _$PaymentDTOFromJson(json);
Map<String, dynamic> toJson() => _$PaymentDTOToJson(this);
}

View File

@@ -0,0 +1,35 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:pshared/data/dto/payment/fee_line.dart';
import 'package:pshared/data/dto/payment/fx_quote.dart';
import 'package:pshared/data/dto/payment/money.dart';
import 'package:pshared/data/dto/payment/network_fee.dart';
part 'payment_quote.g.dart';
@JsonSerializable()
class PaymentQuoteDTO {
final String? quoteRef;
final MoneyDTO? debitAmount;
final MoneyDTO? expectedSettlementAmount;
final MoneyDTO? expectedFeeTotal;
final String? feeQuoteToken;
final List<FeeLineDTO>? feeLines;
final NetworkFeeDTO? networkFee;
final FxQuoteDTO? fxQuote;
const PaymentQuoteDTO({
this.quoteRef,
this.debitAmount,
this.expectedSettlementAmount,
this.expectedFeeTotal,
this.feeQuoteToken,
this.feeLines,
this.networkFee,
this.fxQuote,
});
factory PaymentQuoteDTO.fromJson(Map<String, dynamic> json) => _$PaymentQuoteDTOFromJson(json);
Map<String, dynamic> toJson() => _$PaymentQuoteDTOToJson(this);
}

View File

@@ -0,0 +1,20 @@
import 'package:pshared/data/dto/payment/asset.dart';
import 'package:pshared/data/mapper/payment/enums.dart';
import 'package:pshared/models/payment/asset.dart';
extension PaymentAssetMapper on PaymentAsset {
AssetDTO toDTO() => AssetDTO(
chain: chainNetworkToValue(chain),
tokenSymbol: tokenSymbol,
contractAddress: contractAddress,
);
}
extension AssetDTOMapper on AssetDTO {
PaymentAsset toDomain() => PaymentAsset(
chain: chainNetworkFromValue(chain),
tokenSymbol: tokenSymbol,
contractAddress: contractAddress,
);
}

View File

@@ -3,17 +3,23 @@ import 'package:pshared/models/payment/methods/card.dart';
extension CardPaymentMethodMapper on CardPaymentMethod {
CardPaymentDataDTO toDTO() => CardPaymentDataDTO(
CardEndpointDTO toDTO() => CardEndpointDTO(
pan: pan,
firstName: firstName,
lastName: lastName,
expMonth: expMonth,
expYear: expYear,
country: country,
);
}
extension CardPaymentDataDTOMapper on CardPaymentDataDTO {
extension CardPaymentDataDTOMapper on CardEndpointDTO {
CardPaymentMethod toDomain() => CardPaymentMethod(
pan: pan,
firstName: firstName,
lastName: lastName,
expMonth: expMonth,
expYear: expYear,
country: country,
);
}

View File

@@ -0,0 +1,17 @@
import 'package:pshared/data/dto/payment/card_token.dart';
import 'package:pshared/models/payment/methods/card_token.dart';
extension CardTokenPaymentMethodMapper on CardTokenPaymentMethod {
CardTokenEndpointDTO toDTO() => CardTokenEndpointDTO(
token: token,
maskedPan: maskedPan,
);
}
extension CardTokenPaymentDataDTOMapper on CardTokenEndpointDTO {
CardTokenPaymentMethod toDomain() => CardTokenPaymentMethod(
token: token,
maskedPan: maskedPan,
);
}

View File

@@ -1,19 +1,20 @@
import 'package:pshared/data/dto/payment/crypto_address.dart';
import 'package:pshared/data/dto/payment/external_chain.dart';
import 'package:pshared/data/mapper/payment/asset.dart';
import 'package:pshared/models/payment/methods/crypto_address.dart';
extension CryptoAddressPaymentMethodMapper on CryptoAddressPaymentMethod {
CryptoAddressPaymentDataDTO toDTO() => CryptoAddressPaymentDataDTO(
ExternalChainEndpointDTO toDTO() => ExternalChainEndpointDTO(
address: address,
network: network,
destinationTag: destinationTag,
asset: asset?.toDTO(),
memo: memo,
);
}
extension CryptoAddressPaymentDataDTOMapper on CryptoAddressPaymentDataDTO {
extension CryptoAddressPaymentDataDTOMapper on ExternalChainEndpointDTO {
CryptoAddressPaymentMethod toDomain() => CryptoAddressPaymentMethod(
address: address,
network: network,
destinationTag: destinationTag,
asset: asset?.toDomain(),
memo: memo,
);
}

View File

@@ -0,0 +1,16 @@
import 'package:pshared/data/dto/payment/currency_pair.dart';
import 'package:pshared/models/payment/currency_pair.dart';
extension CurrencyPairMapper on CurrencyPair {
CurrencyPairDTO toDTO() => CurrencyPairDTO(
base: base,
quote: quote,
);
}
extension CurrencyPairDTOMapper on CurrencyPairDTO {
CurrencyPair toDomain() => CurrencyPair(
base: base,
quote: quote,
);
}

View File

@@ -0,0 +1,183 @@
import 'package:pshared/models/payment/chain_network.dart';
import 'package:pshared/models/payment/fx/side.dart';
import 'package:pshared/models/payment/insufficient_net_policy.dart';
import 'package:pshared/models/payment/kind.dart';
import 'package:pshared/models/payment/type.dart';
import 'package:pshared/models/payment/settlement_mode.dart';
PaymentKind paymentKindFromValue(String? value) {
switch (value) {
case 'payout':
return PaymentKind.payout;
case 'internal_transfer':
return PaymentKind.internalTransfer;
case 'fx_conversion':
return PaymentKind.fxConversion;
case 'unspecified':
return PaymentKind.unspecified;
default:
throw ArgumentError('Unknown PaymentKind value: $value');
}
}
String paymentKindToValue(PaymentKind kind) {
switch (kind) {
case PaymentKind.payout:
return 'payout';
case PaymentKind.internalTransfer:
return 'internal_transfer';
case PaymentKind.fxConversion:
return 'fx_conversion';
case PaymentKind.unspecified:
return 'unspecified';
}
}
SettlementMode settlementModeFromValue(String? value) {
switch (value) {
case 'fix_source':
return SettlementMode.fixSource;
case 'fix_received':
return SettlementMode.fixReceived;
case 'unspecified':
return SettlementMode.unspecified;
default:
throw ArgumentError('Unknown SettlementMode value: $value');
}
}
String settlementModeToValue(SettlementMode mode) {
switch (mode) {
case SettlementMode.fixSource:
return 'fix_source';
case SettlementMode.fixReceived:
return 'fix_received';
case SettlementMode.unspecified:
return 'unspecified';
}
}
FxSide fxSideFromValue(String? value) {
switch (value) {
case 'buy_base_sell_quote':
return FxSide.buyBaseSellQuote;
case 'sell_base_buy_quote':
return FxSide.sellBaseBuyQuote;
case 'unspecified':
return FxSide.unspecified;
default:
throw ArgumentError('Unknown FxSide value: $value');
}
}
String fxSideToValue(FxSide side) {
switch (side) {
case FxSide.buyBaseSellQuote:
return 'buy_base_sell_quote';
case FxSide.sellBaseBuyQuote:
return 'sell_base_buy_quote';
case FxSide.unspecified:
return 'unspecified';
}
}
ChainNetwork chainNetworkFromValue(String? value) {
switch (value) {
case 'ethereum_mainnet':
return ChainNetwork.ethereumMainnet;
case 'arbitrum_one':
return ChainNetwork.arbitrumOne;
case 'other_evm':
return ChainNetwork.otherEvm;
case 'unspecified':
return ChainNetwork.unspecified;
default:
throw ArgumentError('Unknown ChainNetwork value: $value');
}
}
String chainNetworkToValue(ChainNetwork chain) {
switch (chain) {
case ChainNetwork.ethereumMainnet:
return 'ethereum_mainnet';
case ChainNetwork.arbitrumOne:
return 'arbitrum_one';
case ChainNetwork.otherEvm:
return 'other_evm';
case ChainNetwork.unspecified:
return 'unspecified';
}
}
InsufficientNetPolicy insufficientNetPolicyFromValue(String? value) {
switch (value) {
case 'block_posting':
return InsufficientNetPolicy.blockPosting;
case 'sweep_org_cash':
return InsufficientNetPolicy.sweepOrgCash;
case 'invoice_later':
return InsufficientNetPolicy.invoiceLater;
case 'unspecified':
return InsufficientNetPolicy.unspecified;
default:
throw ArgumentError('Unknown InsufficientNetPolicy value: $value');
}
}
String insufficientNetPolicyToValue(InsufficientNetPolicy policy) {
switch (policy) {
case InsufficientNetPolicy.blockPosting:
return 'block_posting';
case InsufficientNetPolicy.sweepOrgCash:
return 'sweep_org_cash';
case InsufficientNetPolicy.invoiceLater:
return 'invoice_later';
case InsufficientNetPolicy.unspecified:
return 'unspecified';
}
}
PaymentType endpointTypeFromValue(String? value) {
switch (value) {
case 'managedWallet':
return PaymentType.managedWallet;
case 'externalChain':
return PaymentType.externalChain;
case 'card':
return PaymentType.card;
case 'cardToken':
return PaymentType.cardToken;
case 'ledger':
return PaymentType.ledger;
case 'bankAccount':
return PaymentType.bankAccount;
case 'iban':
return PaymentType.iban;
case 'wallet':
return PaymentType.wallet;
default:
throw ArgumentError('Unknown PaymentType value: $value');
}
}
String endpointTypeToValue(PaymentType type) {
switch (type) {
case PaymentType.ledger:
return 'ledger';
case PaymentType.managedWallet:
return 'managedWallet';
case PaymentType.externalChain:
return 'externalChain';
case PaymentType.card:
return 'card';
case PaymentType.cardToken:
return 'cardToken';
case PaymentType.bankAccount:
return 'bankAccount';
case PaymentType.iban:
return 'iban';
case PaymentType.wallet:
return 'wallet';
}
}

View File

@@ -0,0 +1,24 @@
import 'package:pshared/data/dto/payment/fee_line.dart';
import 'package:pshared/data/mapper/payment/money.dart';
import 'package:pshared/models/payment/fees/line.dart';
extension FeeLineDTOMapper on FeeLineDTO {
FeeLine toDomain() => FeeLine(
ledgerAccountRef: ledgerAccountRef,
amount: amount?.toDomain(),
lineType: lineType,
side: side,
meta: meta,
);
}
extension FeeLineMapper on FeeLine {
FeeLineDTO toDTO() => FeeLineDTO(
ledgerAccountRef: ledgerAccountRef,
amount: amount?.toDTO(),
lineType: lineType,
side: side,
meta: meta,
);
}

View File

@@ -0,0 +1,36 @@
import 'package:pshared/data/dto/payment/fx_quote.dart';
import 'package:pshared/data/mapper/payment/money.dart';
import 'package:pshared/models/payment/fx/quote.dart';
extension FxQuoteDTOMapper on FxQuoteDTO {
FxQuote toDomain() => FxQuote(
quoteRef: quoteRef,
baseCurrency: baseCurrency,
quoteCurrency: quoteCurrency,
side: side,
price: price,
baseAmount: baseAmount?.toDomain(),
quoteAmount: quoteAmount?.toDomain(),
expiresAtUnixMs: expiresAtUnixMs,
provider: provider,
rateRef: rateRef,
firm: firm ?? false,
);
}
extension FxQuoteMapper on FxQuote {
FxQuoteDTO toDTO() => FxQuoteDTO(
quoteRef: quoteRef,
baseCurrency: baseCurrency,
quoteCurrency: quoteCurrency,
side: side,
price: price,
baseAmount: baseAmount?.toDTO(),
quoteAmount: quoteAmount?.toDTO(),
expiresAtUnixMs: expiresAtUnixMs,
provider: provider,
rateRef: rateRef,
firm: firm,
);
}

View File

@@ -0,0 +1,27 @@
import 'package:pshared/data/dto/payment/intent/fx.dart';
import 'package:pshared/data/mapper/payment/currency_pair.dart';
import 'package:pshared/data/mapper/payment/enums.dart';
import 'package:pshared/models/payment/fx/intent.dart';
extension FxIntentMapper on FxIntent {
FxIntentDTO toDTO() => FxIntentDTO(
pair: pair?.toDTO(),
side: fxSideToValue(side),
firm: firm,
ttlMs: ttlMs,
preferredProvider: preferredProvider,
maxAgeMs: maxAgeMs,
);
}
extension FxIntentDTOMapper on FxIntentDTO {
FxIntent toDomain() => FxIntent(
pair: pair?.toDomain(),
side: fxSideFromValue(side),
firm: firm,
ttlMs: ttlMs,
preferredProvider: preferredProvider,
maxAgeMs: maxAgeMs,
);
}

View File

@@ -0,0 +1,30 @@
import 'package:pshared/data/dto/payment/intent/payment.dart';
import 'package:pshared/data/mapper/payment/payment.dart';
import 'package:pshared/data/mapper/payment/enums.dart';
import 'package:pshared/data/mapper/payment/intent/fx.dart';
import 'package:pshared/data/mapper/payment/money.dart';
import 'package:pshared/models/payment/intent.dart';
extension PaymentIntentMapper on PaymentIntent {
PaymentIntentDTO toDTO() => PaymentIntentDTO(
kind: paymentKindToValue(kind),
source: source?.toDTO(),
destination: destination?.toDTO(),
amount: amount?.toDTO(),
fx: fx?.toDTO(),
settlementMode: settlementModeToValue(settlementMode),
attributes: attributes,
);
}
extension PaymentIntentDTOMapper on PaymentIntentDTO {
PaymentIntent toDomain() => PaymentIntent(
kind: paymentKindFromValue(kind),
source: source?.toDomain(),
destination: destination?.toDomain(),
amount: amount?.toDomain(),
fx: fx?.toDomain(),
settlementMode: settlementModeFromValue(settlementMode),
attributes: attributes,
);
}

View File

@@ -0,0 +1,17 @@
import 'package:pshared/data/dto/payment/ledger.dart';
import 'package:pshared/models/payment/methods/ledger.dart';
extension LedgerPaymentMethodMapper on LedgerPaymentMethod {
LedgerEndpointDTO toDTO() => LedgerEndpointDTO(
ledgerAccountRef: ledgerAccountRef,
contraLedgerAccountRef: contraLedgerAccountRef,
);
}
extension LedgerPaymentDataDTOMapper on LedgerEndpointDTO {
LedgerPaymentMethod toDomain() => LedgerPaymentMethod(
ledgerAccountRef: ledgerAccountRef,
contraLedgerAccountRef: contraLedgerAccountRef,
);
}

View File

@@ -0,0 +1,18 @@
import 'package:pshared/data/dto/payment/managed_wallet.dart';
import 'package:pshared/data/mapper/payment/asset.dart';
import 'package:pshared/models/payment/methods/managed_wallet.dart';
extension ManagedWalletPaymentMethodMapper on ManagedWalletPaymentMethod {
ManagedWalletEndpointDTO toDTO() => ManagedWalletEndpointDTO(
managedWalletRef: managedWalletRef,
asset: asset?.toDTO(),
);
}
extension ManagedWalletDataDTOMapper on ManagedWalletEndpointDTO {
ManagedWalletPaymentMethod toDomain() => ManagedWalletPaymentMethod(
managedWalletRef: managedWalletRef,
asset: asset?.toDomain(),
);
}

View File

@@ -1,21 +1,30 @@
import 'package:pshared/data/dto/payment/card.dart';
import 'package:pshared/data/dto/payment/crypto_address.dart';
import 'package:pshared/data/dto/payment/card_token.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/managed_wallet.dart';
import 'package:pshared/data/dto/payment/method.dart';
import 'package:pshared/data/dto/payment/russian_bank.dart';
import 'package:pshared/data/dto/payment/wallet.dart';
import 'package:pshared/data/mapper/payment/card_token.dart';
import 'package:pshared/data/mapper/payment/crypto_address.dart';
import 'package:pshared/data/mapper/payment/card.dart';
import 'package:pshared/data/mapper/payment/iban.dart';
import 'package:pshared/data/mapper/payment/ledger.dart';
import 'package:pshared/data/mapper/payment/managed_wallet.dart';
import 'package:pshared/data/mapper/payment/russian_bank.dart';
import 'package:pshared/data/mapper/payment/type.dart';
import 'package:pshared/data/mapper/payment/wallet.dart';
import 'package:pshared/models/describable.dart';
import 'package:pshared/models/organization/bound.dart';
import 'package:pshared/models/payment/methods/card.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/data.dart';
import 'package:pshared/models/payment/methods/iban.dart';
import 'package:pshared/models/payment/methods/ledger.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/type.dart';
import 'package:pshared/models/payment/methods/wallet.dart';
@@ -40,20 +49,17 @@ extension PaymentMethodMapper on PaymentMethod {
isMain: isMain,
);
Map<String, dynamic> _dataToJson(PaymentMethodData data) {
switch (data.type) {
case PaymentType.card:
return (data as CardPaymentMethod).toDTO().toJson();
case PaymentType.iban:
return (data as IbanPaymentMethod).toDTO().toJson();
case PaymentType.bankAccount:
return (data as RussianBankAccountPaymentMethod).toDTO().toJson();
case PaymentType.wallet:
return (data as WalletPaymentMethod).toDTO().toJson();
case PaymentType.cryptoAddress:
return (data as CryptoAddressPaymentMethod).toDTO().toJson();
}
}
Map<String, dynamic> _dataToJson(PaymentMethodData data) => switch (data) {
CardPaymentMethod card => card.toDTO().toJson(),
CardTokenPaymentMethod cardToken => cardToken.toDTO().toJson(),
IbanPaymentMethod iban => iban.toDTO().toJson(),
RussianBankAccountPaymentMethod bankAccount => bankAccount.toDTO().toJson(),
WalletPaymentMethod wallet => wallet.toDTO().toJson(),
CryptoAddressPaymentMethod crypto => crypto.toDTO().toJson(),
LedgerPaymentMethod ledger => ledger.toDTO().toJson(),
ManagedWalletPaymentMethod managedWallet => managedWallet.toDTO().toJson(),
_ => throw UnsupportedError('Unsupported payment method data: ${data.runtimeType}'),
};
}
extension PaymentMethodDTOMapper on PaymentMethodDTO {
@@ -73,15 +79,21 @@ extension PaymentMethodDTOMapper on PaymentMethodDTO {
PaymentMethodData _dataToDomain(PaymentType paymentType, Map<String, dynamic> payload) {
switch (paymentType) {
case PaymentType.card:
return CardPaymentDataDTO.fromJson(payload).toDomain();
return CardEndpointDTO.fromJson(payload).toDomain();
case PaymentType.cardToken:
return CardTokenEndpointDTO.fromJson(payload).toDomain();
case PaymentType.iban:
return IbanPaymentDataDTO.fromJson(payload).toDomain();
case PaymentType.bankAccount:
return RussianBankAccountPaymentDataDTO.fromJson(payload).toDomain();
case PaymentType.wallet:
return WalletPaymentDataDTO.fromJson(payload).toDomain();
case PaymentType.cryptoAddress:
return CryptoAddressPaymentDataDTO.fromJson(payload).toDomain();
case PaymentType.externalChain:
return ExternalChainEndpointDTO.fromJson(payload).toDomain();
case PaymentType.ledger:
return LedgerEndpointDTO.fromJson(payload).toDomain();
case PaymentType.managedWallet:
return ManagedWalletEndpointDTO.fromJson(payload).toDomain();
}
}
}

View File

@@ -0,0 +1,16 @@
import 'package:pshared/data/dto/payment/money.dart';
import 'package:pshared/models/payment/money.dart';
extension MoneyMapper on Money {
MoneyDTO toDTO() => MoneyDTO(
amount: amount,
currency: currency,
);
}
extension MoneyDTOMapper on MoneyDTO {
Money toDomain() => Money(
amount: amount,
currency: currency,
);
}

View File

@@ -0,0 +1,18 @@
import 'package:pshared/data/dto/payment/network_fee.dart';
import 'package:pshared/data/mapper/payment/money.dart';
import 'package:pshared/models/payment/fees/network.dart';
extension NetworkFeeDTOMapper on NetworkFeeDTO {
NetworkFee toDomain() => NetworkFee(
networkFee: networkFee?.toDomain(),
estimationContext: estimationContext,
);
}
extension NetworkFeeMapper on NetworkFee {
NetworkFeeDTO toDTO() => NetworkFeeDTO(
networkFee: networkFee?.toDTO(),
estimationContext: estimationContext,
);
}

View File

@@ -0,0 +1,133 @@
import 'package:pshared/data/dto/payment/card.dart';
import 'package:pshared/data/dto/payment/card_token.dart';
import 'package:pshared/data/dto/payment/endpoint.dart';
import 'package:pshared/data/dto/payment/external_chain.dart';
import 'package:pshared/data/dto/payment/ledger.dart';
import 'package:pshared/data/dto/payment/managed_wallet.dart';
import 'package:pshared/data/mapper/payment/asset.dart';
import 'package:pshared/data/mapper/payment/type.dart';
import 'package:pshared/models/payment/methods/card.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/data.dart';
import 'package:pshared/models/payment/methods/ledger.dart';
import 'package:pshared/models/payment/methods/managed_wallet.dart';
import 'package:pshared/models/payment/type.dart';
extension PaymentMethodDataEndpointMapper on PaymentMethodData {
PaymentEndpointDTO toDTO() {
final metadata = this.metadata;
switch (type) {
case PaymentType.ledger:
final payload = this as LedgerPaymentMethod;
return PaymentEndpointDTO(
type: paymentTypeToValue(type),
data: LedgerEndpointDTO(
ledgerAccountRef: payload.ledgerAccountRef,
contraLedgerAccountRef: payload.contraLedgerAccountRef,
).toJson(),
metadata: metadata,
);
case PaymentType.managedWallet:
final payload = this as ManagedWalletPaymentMethod;
return PaymentEndpointDTO(
type: paymentTypeToValue(type),
data: ManagedWalletEndpointDTO(
managedWalletRef: payload.managedWalletRef,
asset: payload.asset?.toDTO(),
).toJson(),
metadata: metadata,
);
case PaymentType.externalChain:
final payload = this as CryptoAddressPaymentMethod;
return PaymentEndpointDTO(
type: paymentTypeToValue(type),
data: ExternalChainEndpointDTO(
asset: payload.asset?.toDTO(),
address: payload.address,
memo: payload.memo,
).toJson(),
metadata: metadata,
);
case PaymentType.card:
final payload = this as CardPaymentMethod;
return PaymentEndpointDTO(
type: paymentTypeToValue(type),
data: CardEndpointDTO(
pan: payload.pan,
expMonth: payload.expMonth,
expYear: payload.expYear,
country: payload.country,
firstName: payload.firstName,
lastName: payload.lastName,
).toJson(),
metadata: metadata,
);
case PaymentType.cardToken:
final payload = this as CardTokenPaymentMethod;
return PaymentEndpointDTO(
type: paymentTypeToValue(type),
data: CardTokenEndpointDTO(
token: payload.token,
maskedPan: payload.maskedPan,
).toJson(),
metadata: metadata,
);
default:
throw UnsupportedError('Unsupported payment endpoint type: $type');
}
}
}
extension PaymentEndpointDTOMapper on PaymentEndpointDTO {
PaymentMethodData toDomain() {
final metadata = this.metadata;
switch (paymentTypeFromValue(type)) {
case PaymentType.ledger:
final payload = LedgerEndpointDTO.fromJson(data);
return LedgerPaymentMethod(
ledgerAccountRef: payload.ledgerAccountRef,
contraLedgerAccountRef: payload.contraLedgerAccountRef,
metadata: metadata,
);
case PaymentType.managedWallet:
final payload = ManagedWalletEndpointDTO.fromJson(data);
return ManagedWalletPaymentMethod(
managedWalletRef: payload.managedWalletRef,
asset: payload.asset?.toDomain(),
metadata: metadata,
);
case PaymentType.externalChain:
final payload = ExternalChainEndpointDTO.fromJson(data);
return CryptoAddressPaymentMethod(
asset: payload.asset?.toDomain(),
address: payload.address,
memo: payload.memo,
metadata: metadata,
);
case PaymentType.card:
final payload = CardEndpointDTO.fromJson(data);
return CardPaymentMethod(
pan: payload.pan,
firstName: payload.firstName,
lastName: payload.lastName,
expMonth: payload.expMonth,
expYear: payload.expYear,
country: payload.country,
metadata: metadata,
);
case PaymentType.cardToken:
final payload = CardTokenEndpointDTO.fromJson(data);
return CardTokenPaymentMethod(
token: payload.token,
maskedPan: payload.maskedPan,
metadata: metadata,
);
default:
throw UnsupportedError('Unsupported payment endpoint type: ${paymentTypeFromValue(type)}');
}
}
}

View File

@@ -0,0 +1,33 @@
import 'package:pshared/data/dto/payment/payment_quote.dart';
import 'package:pshared/data/mapper/payment/fee_line.dart';
import 'package:pshared/data/mapper/payment/fx_quote.dart';
import 'package:pshared/data/mapper/payment/money.dart';
import 'package:pshared/data/mapper/payment/network_fee.dart';
import 'package:pshared/models/payment/quote.dart';
extension PaymentQuoteDTOMapper on PaymentQuoteDTO {
PaymentQuote toDomain() => PaymentQuote(
quoteRef: quoteRef,
debitAmount: debitAmount?.toDomain(),
expectedSettlementAmount: expectedSettlementAmount?.toDomain(),
expectedFeeTotal: expectedFeeTotal?.toDomain(),
feeQuoteToken: feeQuoteToken,
feeLines: feeLines?.map((line) => line.toDomain()).toList(),
networkFee: networkFee?.toDomain(),
fxQuote: fxQuote?.toDomain(),
);
}
extension PaymentQuoteMapper on PaymentQuote {
PaymentQuoteDTO toDTO() => PaymentQuoteDTO(
quoteRef: quoteRef,
debitAmount: debitAmount?.toDTO(),
expectedSettlementAmount: expectedSettlementAmount?.toDTO(),
expectedFeeTotal: expectedFeeTotal?.toDTO(),
feeQuoteToken: feeQuoteToken,
feeLines: feeLines?.map((line) => line.toDTO()).toList(),
networkFee: networkFee?.toDTO(),
fxQuote: fxQuote?.toDTO(),
);
}

View File

@@ -0,0 +1,26 @@
import 'package:pshared/data/dto/payment/payment.dart';
import 'package:pshared/data/mapper/payment/payment_quote.dart';
import 'package:pshared/models/payment/payment.dart';
extension PaymentDTOMapper on PaymentDTO {
Payment toDomain() => Payment(
paymentRef: paymentRef,
idempotencyKey: idempotencyKey,
state: state,
failureCode: failureCode,
failureReason: failureReason,
lastQuote: lastQuote?.toDomain(),
);
}
extension PaymentMapper on Payment {
PaymentDTO toDTO() => PaymentDTO(
paymentRef: paymentRef,
idempotencyKey: idempotencyKey,
state: state,
failureCode: failureCode,
failureReason: failureReason,
lastQuote: lastQuote?.toDTO(),
);
}

View File

@@ -7,12 +7,18 @@ PaymentType paymentTypeFromValue(String value) {
return PaymentType.iban;
case 'card':
return PaymentType.card;
case 'cardToken':
return PaymentType.cardToken;
case 'bankAccount':
return PaymentType.bankAccount;
case 'ledger':
return PaymentType.ledger;
case 'wallet':
return PaymentType.wallet;
case 'managedWallet':
return PaymentType.managedWallet;
case 'cryptoAddress':
return PaymentType.cryptoAddress;
return PaymentType.externalChain;
default:
return PaymentType.iban;
}
@@ -24,11 +30,17 @@ String paymentTypeToValue(PaymentType type) {
return 'iban';
case PaymentType.card:
return 'card';
case PaymentType.cardToken:
return 'cardToken';
case PaymentType.ledger:
return 'ledger';
case PaymentType.bankAccount:
return 'bankAccount';
case PaymentType.wallet:
return 'wallet';
case PaymentType.cryptoAddress:
case PaymentType.managedWallet:
return 'managedWallet';
case PaymentType.externalChain:
return 'cryptoAddress';
}
}

View File

@@ -29,5 +29,25 @@
"resourceEmpty": "Empty data",
"@resourceEmpty": {
"description": "Default message shown when no data is available"
},
"chainNetworkUnspecified": "Unspecified network",
"@chainNetworkUnspecified": {
"description": "Fallback label when the chain network is not known"
},
"chainNetworkEthereumMainnet": "Ethereum Mainnet",
"@chainNetworkEthereumMainnet": {
"description": "Label for the Ethereum mainnet network"
},
"chainNetworkArbitrumOne": "Arbitrum One",
"@chainNetworkArbitrumOne": {
"description": "Label for the Arbitrum One network"
},
"chainNetworkOtherEvm": "Other EVM chain",
"@chainNetworkOtherEvm": {
"description": "Label for any other EVM-compatible network"
}
}

View File

@@ -29,5 +29,25 @@
"resourceEmpty": "Нет данных",
"@resourceEmpty": {
"description": "Default message shown when no data is available"
},
"chainNetworkUnspecified": "Сеть не указана",
"@chainNetworkUnspecified": {
"description": "Fallback label when the chain network is not known"
},
"chainNetworkEthereumMainnet": "Ethereum Mainnet",
"@chainNetworkEthereumMainnet": {
"description": "Label for the Ethereum mainnet network"
},
"chainNetworkArbitrumOne": "Arbitrum One",
"@chainNetworkArbitrumOne": {
"description": "Label for the Arbitrum One network"
},
"chainNetworkOtherEvm": "Другая EVM сеть",
"@chainNetworkOtherEvm": {
"description": "Label for any other EVM-compatible network"
}
}

View File

@@ -0,0 +1,14 @@
import 'package:pshared/models/payment/chain_network.dart';
class PaymentAsset {
final ChainNetwork chain;
final String tokenSymbol;
final String? contractAddress;
const PaymentAsset({
this.chain = ChainNetwork.unspecified,
required this.tokenSymbol,
this.contractAddress,
});
}

View File

@@ -0,0 +1 @@
enum ChainNetwork { unspecified, ethereumMainnet, arbitrumOne, otherEvm }

View File

@@ -0,0 +1,9 @@
class CurrencyPair {
final String base;
final String quote;
const CurrencyPair({
required this.base,
required this.quote,
});
}

View File

@@ -0,0 +1,18 @@
import 'package:pshared/models/payment/money.dart';
class FeeLine {
final String? ledgerAccountRef;
final Money? amount;
final String? lineType;
final String? side;
final Map<String, String>? meta;
const FeeLine({
required this.ledgerAccountRef,
required this.amount,
required this.lineType,
required this.side,
required this.meta,
});
}

View File

@@ -0,0 +1,12 @@
import 'package:pshared/models/payment/money.dart';
class NetworkFee {
final Money? networkFee;
final String? estimationContext;
const NetworkFee({
required this.networkFee,
required this.estimationContext,
});
}

View File

@@ -0,0 +1,21 @@
import 'package:pshared/models/payment/currency_pair.dart';
import 'package:pshared/models/payment/fx/side.dart';
class FxIntent {
final CurrencyPair? pair;
final FxSide side;
final bool firm;
final int? ttlMs;
final String? preferredProvider;
final int? maxAgeMs;
const FxIntent({
this.pair,
this.side = FxSide.unspecified,
this.firm = false,
this.ttlMs,
this.preferredProvider,
this.maxAgeMs,
});
}

View File

@@ -0,0 +1,30 @@
import 'package:pshared/models/payment/money.dart';
class FxQuote {
final String? quoteRef;
final String? baseCurrency;
final String? quoteCurrency;
final String? side;
final String? price;
final Money? baseAmount;
final Money? quoteAmount;
final int? expiresAtUnixMs;
final String? provider;
final String? rateRef;
final bool firm;
const FxQuote({
required this.quoteRef,
required this.baseCurrency,
required this.quoteCurrency,
required this.side,
required this.price,
required this.baseAmount,
required this.quoteAmount,
required this.expiresAtUnixMs,
required this.provider,
required this.rateRef,
this.firm = false,
});
}

View File

@@ -0,0 +1 @@
enum FxSide { unspecified, buyBaseSellQuote, sellBaseBuyQuote }

View File

@@ -0,0 +1,6 @@
enum InsufficientNetPolicy {
unspecified,
blockPosting,
sweepOrgCash,
invoiceLater
}

View File

@@ -0,0 +1,26 @@
import 'package:pshared/models/payment/fx/intent.dart';
import 'package:pshared/models/payment/kind.dart';
import 'package:pshared/models/payment/methods/data.dart';
import 'package:pshared/models/payment/money.dart';
import 'package:pshared/models/payment/settlement_mode.dart';
class PaymentIntent {
final PaymentKind kind;
final PaymentMethodData? source;
final PaymentMethodData? destination;
final Money? amount;
final FxIntent? fx;
final SettlementMode settlementMode;
final Map<String, String>? attributes;
const PaymentIntent({
this.kind = PaymentKind.unspecified,
this.source,
this.destination,
this.amount,
this.fx,
this.settlementMode = SettlementMode.unspecified,
this.attributes,
});
}

View File

@@ -0,0 +1 @@
enum PaymentKind { unspecified, payout, internalTransfer, fxConversion }

View File

@@ -2,17 +2,25 @@ import 'package:pshared/models/payment/methods/data.dart';
import 'package:pshared/models/payment/type.dart';
class CardPaymentMethod extends PaymentMethodData {
class CardPaymentMethod implements PaymentMethodData {
@override
final PaymentType type = PaymentType.card;
final String pan;
final String firstName;
final String lastName;
final int? expMonth;
final int? expYear;
final String? country;
@override
final Map<String, String>? metadata;
CardPaymentMethod({
const CardPaymentMethod({
required this.pan,
this.expMonth,
this.expYear,
required this.firstName,
required this.lastName,
this.country,
this.metadata,
});
}

View File

@@ -0,0 +1,18 @@
import 'package:pshared/models/payment/methods/data.dart';
import 'package:pshared/models/payment/type.dart';
class CardTokenPaymentMethod implements PaymentMethodData {
@override
final PaymentType type = PaymentType.cardToken;
final String token;
final String maskedPan;
@override
final Map<String, String>? metadata;
const CardTokenPaymentMethod({
required this.token,
required this.maskedPan,
this.metadata,
});
}

View File

@@ -1,18 +1,21 @@
import 'package:pshared/models/payment/methods/data.dart';
import 'package:pshared/models/payment/asset.dart';
import 'package:pshared/models/payment/type.dart';
class CryptoAddressPaymentMethod extends PaymentMethodData {
class CryptoAddressPaymentMethod implements PaymentMethodData {
@override
final PaymentType type = PaymentType.cryptoAddress;
final PaymentType type = PaymentType.externalChain;
final PaymentAsset? asset;
final String address;
final String network;
final String? destinationTag;
final String? memo;
@override
final Map<String, String>? metadata;
CryptoAddressPaymentMethod({
const CryptoAddressPaymentMethod({
this.asset,
required this.address,
required this.network,
this.destinationTag,
this.memo,
this.metadata,
});
}

View File

@@ -3,6 +3,7 @@ import 'package:pshared/models/payment/type.dart';
abstract class PaymentMethodData {
PaymentType get type;
Map<String, String>? get metadata;
}
typedef MethodMap = Map<PaymentType, PaymentMethodData?>;

View File

@@ -2,7 +2,7 @@ import 'package:pshared/models/payment/methods/data.dart';
import 'package:pshared/models/payment/type.dart';
class IbanPaymentMethod extends PaymentMethodData {
class IbanPaymentMethod implements PaymentMethodData {
@override
final PaymentType type = PaymentType.iban;
@@ -10,11 +10,14 @@ class IbanPaymentMethod extends PaymentMethodData {
final String accountHolder; // Full name of the recipient
final String? bic; // Optional: for cross-border transfers
final String? bankName; // Optional: for UI clarity
@override
final Map<String, String>? metadata;
IbanPaymentMethod({
const IbanPaymentMethod({
required this.iban,
required this.accountHolder,
this.bic,
this.bankName,
this.metadata,
});
}

View File

@@ -0,0 +1,18 @@
import 'package:pshared/models/payment/methods/data.dart';
import 'package:pshared/models/payment/type.dart';
class LedgerPaymentMethod implements PaymentMethodData {
@override
final PaymentType type = PaymentType.ledger;
final String ledgerAccountRef;
final String? contraLedgerAccountRef;
@override
final Map<String, String>? metadata;
const LedgerPaymentMethod({
required this.ledgerAccountRef,
this.contraLedgerAccountRef,
this.metadata,
});
}

View File

@@ -0,0 +1,19 @@
import 'package:pshared/models/payment/methods/data.dart';
import 'package:pshared/models/payment/asset.dart';
import 'package:pshared/models/payment/type.dart';
class ManagedWalletPaymentMethod implements PaymentMethodData {
@override
final PaymentType type = PaymentType.managedWallet;
final String managedWalletRef;
final PaymentAsset? asset;
@override
final Map<String, String>? metadata;
const ManagedWalletPaymentMethod({
required this.managedWalletRef,
this.asset,
this.metadata,
});
}

View File

@@ -2,7 +2,7 @@ import 'package:pshared/models/payment/methods/data.dart';
import 'package:pshared/models/payment/type.dart';
class RussianBankAccountPaymentMethod extends PaymentMethodData {
class RussianBankAccountPaymentMethod implements PaymentMethodData {
@override
final PaymentType type = PaymentType.bankAccount;
@@ -13,8 +13,10 @@ class RussianBankAccountPaymentMethod extends PaymentMethodData {
final String bik;
final String accountNumber;
final String correspondentAccount;
@override
final Map<String, String>? metadata;
RussianBankAccountPaymentMethod({
const RussianBankAccountPaymentMethod({
required this.recipientName,
required this.inn,
required this.kpp,
@@ -22,5 +24,6 @@ class RussianBankAccountPaymentMethod extends PaymentMethodData {
required this.bik,
required this.accountNumber,
required this.correspondentAccount,
this.metadata,
});
}

View File

@@ -2,11 +2,14 @@ import 'package:pshared/models/payment/methods/data.dart';
import 'package:pshared/models/payment/type.dart';
class WalletPaymentMethod extends PaymentMethodData {
class WalletPaymentMethod implements PaymentMethodData {
@override
final PaymentType type = PaymentType.wallet;
final String walletId;
WalletPaymentMethod({required this.walletId});
@override
final Map<String, String>? metadata;
WalletPaymentMethod({required this.walletId, this.metadata});
}

View File

@@ -0,0 +1,9 @@
class Money {
final String amount;
final String currency;
const Money({
required this.amount,
required this.currency,
});
}

View File

@@ -0,0 +1,20 @@
import 'package:pshared/models/payment/quote.dart';
class Payment {
final String? paymentRef;
final String? idempotencyKey;
final String? state;
final String? failureCode;
final String? failureReason;
final PaymentQuote? lastQuote;
const Payment({
required this.paymentRef,
required this.idempotencyKey,
required this.state,
required this.failureCode,
required this.failureReason,
required this.lastQuote,
});
}

View File

@@ -0,0 +1,27 @@
import 'package:pshared/models/payment/fees/line.dart';
import 'package:pshared/models/payment/fx/quote.dart';
import 'package:pshared/models/payment/money.dart';
import 'package:pshared/models/payment/fees/network.dart';
class PaymentQuote {
final String? quoteRef;
final Money? debitAmount;
final Money? expectedSettlementAmount;
final Money? expectedFeeTotal;
final String? feeQuoteToken;
final List<FeeLine>? feeLines;
final NetworkFee? networkFee;
final FxQuote? fxQuote;
const PaymentQuote({
required this.quoteRef,
required this.debitAmount,
required this.expectedSettlementAmount,
required this.expectedFeeTotal,
required this.feeQuoteToken,
required this.feeLines,
required this.networkFee,
required this.fxQuote,
});
}

View File

@@ -0,0 +1 @@
enum SettlementMode { unspecified, fixSource, fixReceived }

View File

@@ -1,7 +1,10 @@
enum PaymentType {
ledger,
managedWallet,
externalChain,
bankAccount,
iban,
wallet,
card,
cryptoAddress,
cardToken,
}

View File

@@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
import 'package:pshared/api/requests/payment/quote.dart';
import 'package:pshared/data/mapper/payment/intent/payment.dart';
import 'package:pshared/models/payment/intent.dart';
import 'package:pshared/models/payment/quote.dart';
import 'package:pshared/provider/organizations.dart';
import 'package:pshared/provider/resource.dart';
import 'package:pshared/service/payment/quotation.dart';
import 'package:uuid/uuid.dart';
class QuotationProvider extends ChangeNotifier {
Resource<PaymentQuote> _quotation = Resource(data: null, isLoading: false, error: null);
late OrganizationsProvider _organizations;
bool _isLoaded = false;
void update(OrganizationsProvider venue) {
_organizations = venue;
}
PaymentQuote? get quotation => _quotation.data;
bool get isReady => _isLoaded && !_quotation.isLoading && _quotation.error == null;
Future<PaymentQuote?> getQuotation(PaymentIntent intent) async {
if (!_organizations.isOrganizationSet) throw StateError('Organization is not set');
try {
_quotation = _quotation.copyWith(isLoading: true, error: null);
final response = await QuotationService.getQuotation(
_organizations.current.id,
QuotePaymentRequest(
idempotencyKey: Uuid().v4(),
intent: intent.toDTO(),
),
);
_isLoaded = true;
_quotation = _quotation.copyWith(data: response, isLoading: false);
} catch (e) {
_quotation = _quotation.copyWith(
error: e is Exception ? e : Exception(e.toString()),
isLoading: false,
);
}
notifyListeners();
return _quotation.data;
}
void reset() {
_quotation = Resource(data: null, isLoading: false, error: null);
_isLoaded = false;
notifyListeners();
}
}

View File

@@ -0,0 +1,20 @@
import 'package:logging/logging.dart';
import 'package:pshared/api/requests/payment/quote.dart';
import 'package:pshared/api/responses/payment/quotation.dart';
import 'package:pshared/data/mapper/payment/payment_quote.dart';
import 'package:pshared/models/payment/quote.dart';
import 'package:pshared/service/services.dart';
import 'package:pshared/utils/http/requests.dart';
class QuotationService {
static final _logger = Logger('service.payment.quotation');
static const String _objectType = Services.payments;
static Future<PaymentQuote> getQuotation(String organizationRef, QuotePaymentRequest request) async {
_logger.fine('Quoting payment for organization $organizationRef');
final response = await getPOSTResponse(_objectType, '/quote/$organizationRef', request.toJson());
return PaymentQuoteResponse.fromJson(response).quote.toDomain();
}
}

View File

@@ -11,8 +11,8 @@ class Services {
static const String recipients = 'recipients';
static const String paymentMethods = 'payment_methods';
static const String payments = 'payments';
static const String amplitude = 'amplitude';
static const String clients = 'clients';
static const String logo = 'logo';
static const String notifications = 'notifications';

View File

@@ -0,0 +1,22 @@
import 'package:flutter/widgets.dart';
import 'package:pshared/generated/i18n/ps_localizations.dart';
import 'package:pshared/models/payment/chain_network.dart';
/// Localized labels for [ChainNetwork] values.
extension ChainNetworkL10n on ChainNetwork {
/// Returns a human-readable, localized name for the chain.
String localizedName(BuildContext context) {
final l10n = PSLocalizations.of(context)!;
switch (this) {
case ChainNetwork.ethereumMainnet:
return l10n.chainNetworkEthereumMainnet;
case ChainNetwork.arbitrumOne:
return l10n.chainNetworkArbitrumOne;
case ChainNetwork.otherEvm:
return l10n.chainNetworkOtherEvm;
case ChainNetwork.unspecified:
return l10n.chainNetworkUnspecified;
}
}
}

View File

@@ -1,7 +1,9 @@
import 'package:flutter/widgets.dart';
import 'package:collection/collection.dart';
import 'package:go_router/go_router.dart';
import 'package:pshared/models/payment/type.dart';
import 'package:pweb/widgets/sidebar/destinations.dart';
@@ -92,8 +94,6 @@ class PayoutRoutes {
return PayoutDestination.recipients;
case addRecipient:
return PayoutDestination.addrecipient;
case payment:
return PayoutDestination.payment;
case settings:
return PayoutDestination.settings;
case reports:

View File

@@ -312,6 +312,16 @@
"paymentTypeIban": "IBAN",
"paymentTypeWallet": "Wallet",
"paymentTypeCryptoAddress": "Crypto address",
"paymentTypeLedger": "Ledger account",
"paymentTypeManagedWallet": "Managed wallet",
"paymentTypeCardToken": "Card token",
"cryptoAddressLabel": "Crypto address",
"enterCryptoAddress": "Enter a crypto address",
"tokenSymbolLabel": "Token symbol",
"tokenSymbolRequiredWhenNetwork": "Token symbol is required when a network or contract address is specified",
"contractAddressLabel": "Contract address (optional)",
"memoLabel": "Destination tag / memo (optional)",
"cardNumber": "Card Number",
"enterCardNumber": "Enter the card number",

View File

@@ -312,6 +312,16 @@
"paymentTypeIban": "IBAN",
"paymentTypeWallet": "Кошелек",
"paymentTypeCryptoAddress": "Крипто-адрес",
"paymentTypeLedger": "Леджер счет",
"paymentTypeManagedWallet": "Управляемый кошелек",
"paymentTypeCardToken": "Токен карты",
"cryptoAddressLabel": "Крипто-адрес",
"enterCryptoAddress": "Введите крипто-адрес",
"tokenSymbolLabel": "Символ токена",
"tokenSymbolRequiredWhenNetwork": "Укажите символ токена, если выбрана сеть или указан адрес контракта",
"contractAddressLabel": "Адрес контракта (необязательно)",
"memoLabel": "Destination tag / memo (необязательно)",
"cardNumber": "Номер карты",
"enterCardNumber": "Введите номер карты",

View File

@@ -12,6 +12,7 @@ import 'package:pshared/provider/locale.dart';
import 'package:pshared/provider/permissions.dart';
import 'package:pshared/provider/account.dart';
import 'package:pshared/provider/organizations.dart';
import 'package:pshared/provider/payment/quotation.dart';
import 'package:pshared/provider/recipient/provider.dart';
import 'package:pshared/provider/recipient/pmethods.dart';
@@ -24,7 +25,6 @@ import 'package:pweb/providers/two_factor.dart';
import 'package:pweb/providers/upload_history.dart';
import 'package:pweb/providers/wallets.dart';
import 'package:pweb/providers/wallet_transactions.dart';
// import 'package:pweb/services/amplitude.dart';
import 'package:pweb/services/operations.dart';
import 'package:pweb/services/payments/history.dart';
import 'package:pweb/services/wallet_transactions.dart';
@@ -70,7 +70,6 @@ void main() async {
update: (context, orgnization, provider) => provider!..update(orgnization),
),
ChangeNotifierProvider(create: (_) => CarouselIndexProvider()),
ChangeNotifierProvider(
create: (_) => UploadHistoryProvider(service: MockUploadHistoryService())..load(),
),
@@ -92,10 +91,13 @@ void main() async {
ChangeNotifierProvider(
create: (_) => MockPaymentProvider(),
),
ChangeNotifierProvider(
create: (_) => OperationProvider(OperationService())..loadOperations(),
),
ChangeNotifierProxyProvider<OrganizationsProvider, QuotationProvider>(
create: (_) => QuotationProvider(),
update: (context, orgnization, provider) => provider!..update(orgnization),
),
],
child: const PayApp(),
),

View File

@@ -14,7 +14,6 @@ import 'package:pshared/provider/recipient/pmethods.dart';
import 'package:pshared/provider/recipient/provider.dart';
import 'package:pweb/pages/address_book/form/view.dart';
// import 'package:pweb/services/amplitude.dart';
import 'package:pweb/utils/error/snackbar.dart';
import 'package:pweb/utils/payment/label.dart';
import 'package:pweb/utils/snackbar.dart';
@@ -54,7 +53,9 @@ class _AdressBookRecipientFormState extends State<AdressBookRecipientForm> {
PaymentType.iban => m.ibanData,
PaymentType.wallet => m.walletData,
PaymentType.bankAccount => m.bankAccountData,
PaymentType.cryptoAddress => m.cryptoAddressData,
PaymentType.externalChain => m.cryptoAddressData,
//TODO: support new payment methods
_ => throw UnimplementedError('Payment method ${m.type} is not supported yet'),
};
}
}

View File

@@ -1,9 +1,14 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/payment/asset.dart';
import 'package:pshared/models/payment/chain_network.dart';
import 'package:pshared/models/payment/methods/crypto_address.dart';
import 'package:pshared/utils/l10n/chain.dart';
import 'package:pweb/utils/text_field_styles.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class CryptoAddressForm extends StatefulWidget {
final void Function(CryptoAddressPaymentMethod) onChanged;
@@ -22,28 +27,67 @@ class CryptoAddressForm extends StatefulWidget {
}
class _CryptoAddressFormState extends State<CryptoAddressForm> {
final _formKey = GlobalKey<FormState>();
late TextEditingController _addressCtrl;
late TextEditingController _networkCtrl;
late TextEditingController _destinationTagCtrl;
late TextEditingController _tokenCtrl;
late TextEditingController _contractCtrl;
late TextEditingController _memoCtrl;
late ChainNetwork _chain;
@override
void initState() {
super.initState();
_addressCtrl = TextEditingController(text: widget.initialData?.address);
_networkCtrl = TextEditingController(text: widget.initialData?.network);
_destinationTagCtrl = TextEditingController(text: widget.initialData?.destinationTag);
final initial = widget.initialData;
_chain = initial?.asset?.chain ?? ChainNetwork.unspecified;
_addressCtrl = TextEditingController(text: initial?.address ?? '');
_tokenCtrl = TextEditingController(text: initial?.asset?.tokenSymbol ?? '');
_contractCtrl = TextEditingController(text: initial?.asset?.contractAddress ?? '');
_memoCtrl = TextEditingController(text: initial?.memo ?? '');
WidgetsBinding.instance.addPostFrameCallback((_) => _emitIfValid());
}
void _emit() {
if (_addressCtrl.text.isNotEmpty && _networkCtrl.text.isNotEmpty) {
widget.onChanged(
CryptoAddressPaymentMethod(
address: _addressCtrl.text,
network: _networkCtrl.text,
destinationTag: _destinationTagCtrl.text.isNotEmpty ? _destinationTagCtrl.text : null,
),
);
bool get _hasChainSelection => _chain != ChainNetwork.unspecified;
String? _validateAddress(AppLocalizations l10n, String? value) {
if (value == null || value.trim().isEmpty) return l10n.enterCryptoAddress;
return null;
}
String? _validateToken(AppLocalizations l10n) {
final token = _tokenCtrl.text.trim();
final contract = _contractCtrl.text.trim();
if ((_hasChainSelection || contract.isNotEmpty) && token.isEmpty) {
return l10n.tokenSymbolRequiredWhenNetwork;
}
return null;
}
PaymentAsset? _buildAsset() {
final token = _tokenCtrl.text.trim();
final contract = _contractCtrl.text.trim();
if (token.isEmpty && contract.isEmpty && !_hasChainSelection) return null;
if (token.isEmpty) return null;
return PaymentAsset(
chain: _chain,
tokenSymbol: token,
contractAddress: contract.isNotEmpty ? contract : null,
);
}
void _emitIfValid() {
if (!(_formKey.currentState?.validate() ?? false)) return;
widget.onChanged(
CryptoAddressPaymentMethod(
asset: _buildAsset(),
address: _addressCtrl.text.trim(),
memo: _memoCtrl.text.trim().isNotEmpty ? _memoCtrl.text.trim() : null,
),
);
}
@override
@@ -54,48 +98,88 @@ class _CryptoAddressFormState extends State<CryptoAddressForm> {
if (newData == null && oldData != null) {
_addressCtrl.clear();
_networkCtrl.clear();
_destinationTagCtrl.clear();
_tokenCtrl.clear();
_contractCtrl.clear();
_memoCtrl.clear();
_chain = ChainNetwork.unspecified;
return;
}
if (newData != null && newData != oldData) {
_addressCtrl.text = newData.address;
_networkCtrl.text = newData.network;
_destinationTagCtrl.text = newData.destinationTag ?? '';
_tokenCtrl.text = newData.asset?.tokenSymbol ?? '';
_contractCtrl.text = newData.asset?.contractAddress ?? '';
_memoCtrl.text = newData.memo ?? '';
_chain = newData.asset?.chain ?? ChainNetwork.unspecified;
WidgetsBinding.instance.addPostFrameCallback((_) => _emitIfValid());
}
}
@override
Widget build(BuildContext context) {
return Column(
children: [
TextFormField(
readOnly: !widget.isEditable,
controller: _addressCtrl,
decoration: getInputDecoration(context, 'Crypto address', widget.isEditable),
style: getTextFieldStyle(context, widget.isEditable),
onChanged: (_) => _emit(),
validator: (val) => (val?.isEmpty ?? true) ? 'Enter crypto address' : null,
),
const SizedBox(height: 12),
TextFormField(
readOnly: !widget.isEditable,
controller: _networkCtrl,
decoration: getInputDecoration(context, 'Network', widget.isEditable),
style: getTextFieldStyle(context, widget.isEditable),
onChanged: (_) => _emit(),
validator: (val) => (val?.isEmpty ?? true) ? 'Enter network' : null,
),
const SizedBox(height: 12),
TextFormField(
readOnly: !widget.isEditable,
controller: _destinationTagCtrl,
decoration: getInputDecoration(context, 'Destination tag / memo (optional)', widget.isEditable),
style: getTextFieldStyle(context, widget.isEditable),
onChanged: (_) => _emit(),
),
],
final l10n = AppLocalizations.of(context)!;
return Form(
key: _formKey,
onChanged: _emitIfValid,
child: Column(
mainAxisSize: MainAxisSize.min,
spacing: 12.0,
children: [
TextFormField(
readOnly: !widget.isEditable,
controller: _addressCtrl,
decoration: getInputDecoration(context, l10n.cryptoAddressLabel, widget.isEditable),
style: getTextFieldStyle(context, widget.isEditable),
validator: (val) => _validateAddress(l10n, val),
),
DropdownButtonFormField<ChainNetwork>(
initialValue: _chain,
decoration: getInputDecoration(context, l10n.walletTopUpNetworkLabel, widget.isEditable),
items: ChainNetwork.values
.map((chain) => DropdownMenuItem(
value: chain,
child: Text(chain.localizedName(context)),
))
.toList(),
onChanged: widget.isEditable
? (value) {
if (value == null) return;
setState(() => _chain = value);
_emitIfValid();
}
: null,
),
TextFormField(
readOnly: !widget.isEditable,
controller: _tokenCtrl,
decoration: getInputDecoration(context, l10n.tokenSymbolLabel, widget.isEditable),
style: getTextFieldStyle(context, widget.isEditable),
validator: (_) => _validateToken(l10n),
),
TextFormField(
readOnly: !widget.isEditable,
controller: _contractCtrl,
decoration: getInputDecoration(context, l10n.contractAddressLabel, widget.isEditable),
style: getTextFieldStyle(context, widget.isEditable),
),
TextFormField(
readOnly: !widget.isEditable,
controller: _memoCtrl,
decoration: getInputDecoration(context, l10n.memoLabel, widget.isEditable),
style: getTextFieldStyle(context, widget.isEditable),
),
],
),
);
}
@override
void dispose() {
_addressCtrl.dispose();
_tokenCtrl.dispose();
_contractCtrl.dispose();
_memoCtrl.dispose();
super.dispose();
}
}

View File

@@ -52,7 +52,7 @@ class PaymentMethodForm extends StatelessWidget {
initialData: initialData as RussianBankAccountPaymentMethod?,
isEditable: isEditable,
),
PaymentType.cryptoAddress => CryptoAddressForm(
PaymentType.externalChain => CryptoAddressForm(
onChanged: onChanged,
initialData: initialData as CryptoAddressPaymentMethod?,
isEditable: isEditable,

Some files were not shown because too many files have changed in this diff Show More