diff --git a/api/fx/storage/model/quote.go b/api/fx/storage/model/quote.go index c77923b..92ee894 100644 --- a/api/fx/storage/model/quote.go +++ b/api/fx/storage/model/quote.go @@ -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"` diff --git a/api/fx/storage/model/types.go b/api/fx/storage/model/types.go index 401bb07..3769043 100644 --- a/api/fx/storage/model/types.go +++ b/api/fx/storage/model/types.go @@ -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"` diff --git a/api/ledger/storage/model/types.go b/api/ledger/storage/model/types.go index 653f004..fd1362d 100644 --- a/api/ledger/storage/model/types.go +++ b/api/ledger/storage/model/types.go @@ -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"` diff --git a/api/pkg/model/money.go b/api/pkg/model/money.go new file mode 100644 index 0000000..566a845 --- /dev/null +++ b/api/pkg/model/money.go @@ -0,0 +1,6 @@ +package model + +type Money struct { + Currency string `bson:"currency" json:"currency"` + Amount string `bson:"amount" json:"amount"` +} diff --git a/api/pkg/model/payment.go b/api/pkg/model/payment.go index 27e255c..0c2f227 100644 --- a/api/pkg/model/payment.go +++ b/api/pkg/model/payment.go @@ -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 { diff --git a/api/proto/payments/orchestrator/v1/orchestrator.proto b/api/proto/payments/orchestrator/v1/orchestrator.proto index 8949c99..b02d39d 100644 --- a/api/proto/payments/orchestrator/v1/orchestrator.proto +++ b/api/proto/payments/orchestrator/v1/orchestrator.proto @@ -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 { diff --git a/api/server/go.mod b/api/server/go.mod index 677ffe3..4744b4d 100644 --- a/api/server/go.mod +++ b/api/server/go.mod @@ -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 diff --git a/api/server/go.sum b/api/server/go.sum index 698c293..b718c2c 100644 --- a/api/server/go.sum +++ b/api/server/go.sum @@ -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= diff --git a/api/server/interface/api/srequest/endpoint_payloads.go b/api/server/interface/api/srequest/endpoint_payloads.go index f9e46fd..14e41ca 100644 --- a/api/server/interface/api/srequest/endpoint_payloads.go +++ b/api/server/interface/api/srequest/endpoint_payloads.go @@ -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. diff --git a/api/server/interface/api/srequest/endpoint_union.go b/api/server/interface/api/srequest/endpoint_union.go index 16e167f..4d0458c 100644 --- a/api/server/interface/api/srequest/endpoint_union.go +++ b/api/server/interface/api/srequest/endpoint_union.go @@ -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 diff --git a/api/server/interface/api/srequest/payment.go b/api/server/interface/api/srequest/payment.go index 5ad13fb..89e3208 100644 --- a/api/server/interface/api/srequest/payment.go +++ b/api/server/interface/api/srequest/payment.go @@ -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 } } diff --git a/api/server/interface/api/srequest/payment_intent.go b/api/server/interface/api/srequest/payment_intent.go index af652bc..455b16d 100644 --- a/api/server/interface/api/srequest/payment_intent.go +++ b/api/server/interface/api/srequest/payment_intent.go @@ -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 +} diff --git a/api/server/interface/api/srequest/payment_types_test.go b/api/server/interface/api/srequest/payment_types_test.go index fa7fd4f..0a41375 100644 --- a/api/server/interface/api/srequest/payment_types_test.go +++ b/api/server/interface/api/srequest/payment_types_test.go @@ -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") diff --git a/api/server/interface/api/srequest/payment_value_objects.go b/api/server/interface/api/srequest/payment_value_objects.go index e0029a3..41732ee 100644 --- a/api/server/interface/api/srequest/payment_value_objects.go +++ b/api/server/interface/api/srequest/payment_value_objects.go @@ -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 +} diff --git a/api/server/interface/api/srequest/validateable.go b/api/server/interface/api/srequest/validateable.go new file mode 100644 index 0000000..b4a5c12 --- /dev/null +++ b/api/server/interface/api/srequest/validateable.go @@ -0,0 +1,5 @@ +package srequest + +type Validatable interface { + Validate() error +} diff --git a/api/server/interface/api/sresponse/money.go b/api/server/interface/api/sresponse/money.go index ea7291c..6a6866d 100644 --- a/api/server/interface/api/sresponse/money.go +++ b/api/server/interface/api/sresponse/money.go @@ -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(), } diff --git a/api/server/interface/api/sresponse/payment.go b/api/server/interface/api/sresponse/payment.go index 4acd973..e7315d3 100644 --- a/api/server/interface/api/sresponse/payment.go +++ b/api/server/interface/api/sresponse/payment.go @@ -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 { diff --git a/api/server/interface/api/sresponse/wallet.go b/api/server/interface/api/sresponse/wallet.go index 92b7431..f5d0374 100644 --- a/api/server/interface/api/sresponse/wallet.go +++ b/api/server/interface/api/sresponse/wallet.go @@ -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 { diff --git a/api/server/internal/server/paymentapiimp/mapper.go b/api/server/internal/server/paymentapiimp/mapper.go index eec145c..010447a 100644 --- a/api/server/internal/server/paymentapiimp/mapper.go +++ b/api/server/internal/server/paymentapiimp/mapper.go @@ -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): diff --git a/api/server/internal/server/paymentapiimp/quote.go b/api/server/internal/server/paymentapiimp/quote.go index b2ad152..3a21df4 100644 --- a/api/server/internal/server/paymentapiimp/quote.go +++ b/api/server/internal/server/paymentapiimp/quote.go @@ -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 { diff --git a/api/server/internal/server/paymentapiimp/service.go b/api/server/internal/server/paymentapiimp/service.go index 84748ed..0322180 100644 --- a/api/server/internal/server/paymentapiimp/service.go +++ b/api/server/internal/server/paymentapiimp/service.go @@ -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 diff --git a/frontend/pshared/lib/api/requests/payment/base.dart b/frontend/pshared/lib/api/requests/payment/base.dart new file mode 100644 index 0000000..8072c91 --- /dev/null +++ b/frontend/pshared/lib/api/requests/payment/base.dart @@ -0,0 +1,20 @@ +import 'package:json_annotation/json_annotation.dart'; + + +part 'base.g.dart'; + + +@JsonSerializable() +class PaymentBaseRequest { + final String idempotencyKey; + final Map? metadata; + + const PaymentBaseRequest({ + required this.idempotencyKey, + this.metadata, + }); + + factory PaymentBaseRequest.fromJson(Map json) => _$PaymentBaseRequestFromJson(json); + Map toJson() => _$PaymentBaseRequestToJson(this); + +} \ No newline at end of file diff --git a/frontend/pshared/lib/api/requests/payment/initiate.dart b/frontend/pshared/lib/api/requests/payment/initiate.dart new file mode 100644 index 0000000..10a1c7d --- /dev/null +++ b/frontend/pshared/lib/api/requests/payment/initiate.dart @@ -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 json) => _$InitiatePaymentRequestFromJson(json); + @override + Map toJson() => _$InitiatePaymentRequestToJson(this); +} diff --git a/frontend/pshared/lib/api/requests/payment/quote.dart b/frontend/pshared/lib/api/requests/payment/quote.dart new file mode 100644 index 0000000..ee25336 --- /dev/null +++ b/frontend/pshared/lib/api/requests/payment/quote.dart @@ -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 json) => _$QuotePaymentRequestFromJson(json); + @override + Map toJson() => _$QuotePaymentRequestToJson(this); +} diff --git a/frontend/pshared/lib/api/responses/payment_method.dart b/frontend/pshared/lib/api/responses/payment/method.dart similarity index 100% rename from frontend/pshared/lib/api/responses/payment_method.dart rename to frontend/pshared/lib/api/responses/payment/method.dart diff --git a/frontend/pshared/lib/api/responses/payment/quotation.dart b/frontend/pshared/lib/api/responses/payment/quotation.dart new file mode 100644 index 0000000..25202cd --- /dev/null +++ b/frontend/pshared/lib/api/responses/payment/quotation.dart @@ -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 json) => _$PaymentQuoteResponseFromJson(json); + @override + Map toJson() => _$PaymentQuoteResponseToJson(this); +} \ No newline at end of file diff --git a/frontend/pshared/lib/data/dto/payment/asset.dart b/frontend/pshared/lib/data/dto/payment/asset.dart new file mode 100644 index 0000000..13a1deb --- /dev/null +++ b/frontend/pshared/lib/data/dto/payment/asset.dart @@ -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 json) => _$AssetDTOFromJson(json); + Map toJson() => _$AssetDTOToJson(this); +} diff --git a/frontend/pshared/lib/data/dto/payment/card.dart b/frontend/pshared/lib/data/dto/payment/card.dart index 0bd5651..dbefb1a 100644 --- a/frontend/pshared/lib/data/dto/payment/card.dart +++ b/frontend/pshared/lib/data/dto/payment/card.dart @@ -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 json) => _$CardPaymentDataDTOFromJson(json); - Map toJson() => _$CardPaymentDataDTOToJson(this); + factory CardEndpointDTO.fromJson(Map json) => _$CardEndpointDTOFromJson(json); + Map toJson() => _$CardEndpointDTOToJson(this); } diff --git a/frontend/pshared/lib/data/dto/payment/card_token.dart b/frontend/pshared/lib/data/dto/payment/card_token.dart new file mode 100644 index 0000000..a49c228 --- /dev/null +++ b/frontend/pshared/lib/data/dto/payment/card_token.dart @@ -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 json) => _$CardTokenEndpointDTOFromJson(json); + Map toJson() => _$CardTokenEndpointDTOToJson(this); +} diff --git a/frontend/pshared/lib/data/dto/payment/crypto_address.dart b/frontend/pshared/lib/data/dto/payment/crypto_address.dart deleted file mode 100644 index 4c8df60..0000000 --- a/frontend/pshared/lib/data/dto/payment/crypto_address.dart +++ /dev/null @@ -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 json) => _$CryptoAddressPaymentDataDTOFromJson(json); - Map toJson() => _$CryptoAddressPaymentDataDTOToJson(this); -} diff --git a/frontend/pshared/lib/data/dto/payment/currency_pair.dart b/frontend/pshared/lib/data/dto/payment/currency_pair.dart new file mode 100644 index 0000000..c40f820 --- /dev/null +++ b/frontend/pshared/lib/data/dto/payment/currency_pair.dart @@ -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 json) => _$CurrencyPairDTOFromJson(json); + Map toJson() => _$CurrencyPairDTOToJson(this); +} diff --git a/frontend/pshared/lib/data/dto/payment/endpoint.dart b/frontend/pshared/lib/data/dto/payment/endpoint.dart new file mode 100644 index 0000000..947834d --- /dev/null +++ b/frontend/pshared/lib/data/dto/payment/endpoint.dart @@ -0,0 +1,20 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'endpoint.g.dart'; + + +@JsonSerializable() +class PaymentEndpointDTO { + final String type; + final Map data; + final Map? metadata; + + const PaymentEndpointDTO({ + required this.type, + required this.data, + this.metadata, + }); + + factory PaymentEndpointDTO.fromJson(Map json) => _$PaymentEndpointDTOFromJson(json); + Map toJson() => _$PaymentEndpointDTOToJson(this); +} diff --git a/frontend/pshared/lib/data/dto/payment/external_chain.dart b/frontend/pshared/lib/data/dto/payment/external_chain.dart new file mode 100644 index 0000000..aa92ec4 --- /dev/null +++ b/frontend/pshared/lib/data/dto/payment/external_chain.dart @@ -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 json) => _$ExternalChainEndpointDTOFromJson(json); + Map toJson() => _$ExternalChainEndpointDTOToJson(this); +} diff --git a/frontend/pshared/lib/data/dto/payment/fee_line.dart b/frontend/pshared/lib/data/dto/payment/fee_line.dart new file mode 100644 index 0000000..63aa712 --- /dev/null +++ b/frontend/pshared/lib/data/dto/payment/fee_line.dart @@ -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? meta; + + const FeeLineDTO({ + this.ledgerAccountRef, + this.amount, + this.lineType, + this.side, + this.meta, + }); + + factory FeeLineDTO.fromJson(Map json) => _$FeeLineDTOFromJson(json); + Map toJson() => _$FeeLineDTOToJson(this); +} diff --git a/frontend/pshared/lib/data/dto/payment/fx_quote.dart b/frontend/pshared/lib/data/dto/payment/fx_quote.dart new file mode 100644 index 0000000..edfe801 --- /dev/null +++ b/frontend/pshared/lib/data/dto/payment/fx_quote.dart @@ -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 json) => _$FxQuoteDTOFromJson(json); + Map toJson() => _$FxQuoteDTOToJson(this); +} diff --git a/frontend/pshared/lib/data/dto/payment/intent/fx.dart b/frontend/pshared/lib/data/dto/payment/intent/fx.dart new file mode 100644 index 0000000..ea46949 --- /dev/null +++ b/frontend/pshared/lib/data/dto/payment/intent/fx.dart @@ -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 json) => _$FxIntentDTOFromJson(json); + Map toJson() => _$FxIntentDTOToJson(this); +} diff --git a/frontend/pshared/lib/data/dto/payment/intent/payment.dart b/frontend/pshared/lib/data/dto/payment/intent/payment.dart new file mode 100644 index 0000000..5ce9c38 --- /dev/null +++ b/frontend/pshared/lib/data/dto/payment/intent/payment.dart @@ -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? attributes; + + const PaymentIntentDTO({ + this.kind, + this.source, + this.destination, + this.amount, + this.fx, + this.settlementMode, + this.attributes, + }); + + factory PaymentIntentDTO.fromJson(Map json) => _$PaymentIntentDTOFromJson(json); + Map toJson() => _$PaymentIntentDTOToJson(this); +} diff --git a/frontend/pshared/lib/data/dto/payment/ledger.dart b/frontend/pshared/lib/data/dto/payment/ledger.dart new file mode 100644 index 0000000..461f416 --- /dev/null +++ b/frontend/pshared/lib/data/dto/payment/ledger.dart @@ -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 json) => _$LedgerEndpointDTOFromJson(json); + Map toJson() => _$LedgerEndpointDTOToJson(this); +} diff --git a/frontend/pshared/lib/data/dto/payment/managed_wallet.dart b/frontend/pshared/lib/data/dto/payment/managed_wallet.dart new file mode 100644 index 0000000..915ecd8 --- /dev/null +++ b/frontend/pshared/lib/data/dto/payment/managed_wallet.dart @@ -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 json) => _$ManagedWalletEndpointDTOFromJson(json); + Map toJson() => _$ManagedWalletEndpointDTOToJson(this); +} diff --git a/frontend/pshared/lib/data/dto/payment/money.dart b/frontend/pshared/lib/data/dto/payment/money.dart new file mode 100644 index 0000000..17bf8dc --- /dev/null +++ b/frontend/pshared/lib/data/dto/payment/money.dart @@ -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 json) => _$MoneyDTOFromJson(json); + Map toJson() => _$MoneyDTOToJson(this); +} diff --git a/frontend/pshared/lib/data/dto/payment/network_fee.dart b/frontend/pshared/lib/data/dto/payment/network_fee.dart new file mode 100644 index 0000000..adfa1b2 --- /dev/null +++ b/frontend/pshared/lib/data/dto/payment/network_fee.dart @@ -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 json) => _$NetworkFeeDTOFromJson(json); + Map toJson() => _$NetworkFeeDTOToJson(this); +} diff --git a/frontend/pshared/lib/data/dto/payment/payment.dart b/frontend/pshared/lib/data/dto/payment/payment.dart new file mode 100644 index 0000000..29f9859 --- /dev/null +++ b/frontend/pshared/lib/data/dto/payment/payment.dart @@ -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 json) => _$PaymentDTOFromJson(json); + Map toJson() => _$PaymentDTOToJson(this); +} diff --git a/frontend/pshared/lib/data/dto/payment/payment_quote.dart b/frontend/pshared/lib/data/dto/payment/payment_quote.dart new file mode 100644 index 0000000..6d8a91d --- /dev/null +++ b/frontend/pshared/lib/data/dto/payment/payment_quote.dart @@ -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? 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 json) => _$PaymentQuoteDTOFromJson(json); + Map toJson() => _$PaymentQuoteDTOToJson(this); +} diff --git a/frontend/pshared/lib/data/mapper/payment/asset.dart b/frontend/pshared/lib/data/mapper/payment/asset.dart new file mode 100644 index 0000000..613a459 --- /dev/null +++ b/frontend/pshared/lib/data/mapper/payment/asset.dart @@ -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, + ); +} diff --git a/frontend/pshared/lib/data/mapper/payment/card.dart b/frontend/pshared/lib/data/mapper/payment/card.dart index c4e19ae..bab5f3a 100644 --- a/frontend/pshared/lib/data/mapper/payment/card.dart +++ b/frontend/pshared/lib/data/mapper/payment/card.dart @@ -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, ); } diff --git a/frontend/pshared/lib/data/mapper/payment/card_token.dart b/frontend/pshared/lib/data/mapper/payment/card_token.dart new file mode 100644 index 0000000..d9d8308 --- /dev/null +++ b/frontend/pshared/lib/data/mapper/payment/card_token.dart @@ -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, + ); +} diff --git a/frontend/pshared/lib/data/mapper/payment/crypto_address.dart b/frontend/pshared/lib/data/mapper/payment/crypto_address.dart index 98788e6..620760e 100644 --- a/frontend/pshared/lib/data/mapper/payment/crypto_address.dart +++ b/frontend/pshared/lib/data/mapper/payment/crypto_address.dart @@ -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, ); } diff --git a/frontend/pshared/lib/data/mapper/payment/currency_pair.dart b/frontend/pshared/lib/data/mapper/payment/currency_pair.dart new file mode 100644 index 0000000..80c8d02 --- /dev/null +++ b/frontend/pshared/lib/data/mapper/payment/currency_pair.dart @@ -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, + ); +} diff --git a/frontend/pshared/lib/data/mapper/payment/enums.dart b/frontend/pshared/lib/data/mapper/payment/enums.dart new file mode 100644 index 0000000..7502c22 --- /dev/null +++ b/frontend/pshared/lib/data/mapper/payment/enums.dart @@ -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'; + } +} diff --git a/frontend/pshared/lib/data/mapper/payment/fee_line.dart b/frontend/pshared/lib/data/mapper/payment/fee_line.dart new file mode 100644 index 0000000..4845d87 --- /dev/null +++ b/frontend/pshared/lib/data/mapper/payment/fee_line.dart @@ -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, + ); +} diff --git a/frontend/pshared/lib/data/mapper/payment/fx_quote.dart b/frontend/pshared/lib/data/mapper/payment/fx_quote.dart new file mode 100644 index 0000000..c45356c --- /dev/null +++ b/frontend/pshared/lib/data/mapper/payment/fx_quote.dart @@ -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, + ); +} diff --git a/frontend/pshared/lib/data/mapper/payment/intent/fx.dart b/frontend/pshared/lib/data/mapper/payment/intent/fx.dart new file mode 100644 index 0000000..fbafbc2 --- /dev/null +++ b/frontend/pshared/lib/data/mapper/payment/intent/fx.dart @@ -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, + ); +} diff --git a/frontend/pshared/lib/data/mapper/payment/intent/payment.dart b/frontend/pshared/lib/data/mapper/payment/intent/payment.dart new file mode 100644 index 0000000..0086294 --- /dev/null +++ b/frontend/pshared/lib/data/mapper/payment/intent/payment.dart @@ -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, + ); +} diff --git a/frontend/pshared/lib/data/mapper/payment/ledger.dart b/frontend/pshared/lib/data/mapper/payment/ledger.dart new file mode 100644 index 0000000..d8235ef --- /dev/null +++ b/frontend/pshared/lib/data/mapper/payment/ledger.dart @@ -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, + ); +} diff --git a/frontend/pshared/lib/data/mapper/payment/managed_wallet.dart b/frontend/pshared/lib/data/mapper/payment/managed_wallet.dart new file mode 100644 index 0000000..65aa7e8 --- /dev/null +++ b/frontend/pshared/lib/data/mapper/payment/managed_wallet.dart @@ -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(), + ); +} diff --git a/frontend/pshared/lib/data/mapper/payment/method.dart b/frontend/pshared/lib/data/mapper/payment/method.dart index fbd188f..640eaf0 100644 --- a/frontend/pshared/lib/data/mapper/payment/method.dart +++ b/frontend/pshared/lib/data/mapper/payment/method.dart @@ -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 _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 _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 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(); } } } diff --git a/frontend/pshared/lib/data/mapper/payment/money.dart b/frontend/pshared/lib/data/mapper/payment/money.dart new file mode 100644 index 0000000..e2a67fb --- /dev/null +++ b/frontend/pshared/lib/data/mapper/payment/money.dart @@ -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, + ); +} diff --git a/frontend/pshared/lib/data/mapper/payment/network_fee.dart b/frontend/pshared/lib/data/mapper/payment/network_fee.dart new file mode 100644 index 0000000..9c7af9e --- /dev/null +++ b/frontend/pshared/lib/data/mapper/payment/network_fee.dart @@ -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, + ); +} diff --git a/frontend/pshared/lib/data/mapper/payment/payment.dart b/frontend/pshared/lib/data/mapper/payment/payment.dart new file mode 100644 index 0000000..6c36078 --- /dev/null +++ b/frontend/pshared/lib/data/mapper/payment/payment.dart @@ -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)}'); + } + } +} diff --git a/frontend/pshared/lib/data/mapper/payment/payment_quote.dart b/frontend/pshared/lib/data/mapper/payment/payment_quote.dart new file mode 100644 index 0000000..9d34274 --- /dev/null +++ b/frontend/pshared/lib/data/mapper/payment/payment_quote.dart @@ -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(), + ); +} diff --git a/frontend/pshared/lib/data/mapper/payment/payment_response.dart b/frontend/pshared/lib/data/mapper/payment/payment_response.dart new file mode 100644 index 0000000..b20b537 --- /dev/null +++ b/frontend/pshared/lib/data/mapper/payment/payment_response.dart @@ -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(), + ); +} diff --git a/frontend/pshared/lib/data/mapper/payment/type.dart b/frontend/pshared/lib/data/mapper/payment/type.dart index af0c172..7f2f61d 100644 --- a/frontend/pshared/lib/data/mapper/payment/type.dart +++ b/frontend/pshared/lib/data/mapper/payment/type.dart @@ -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'; } } diff --git a/frontend/pshared/lib/l10n/en.arb b/frontend/pshared/lib/l10n/en.arb index 80764ee..2e39006 100644 --- a/frontend/pshared/lib/l10n/en.arb +++ b/frontend/pshared/lib/l10n/en.arb @@ -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" } } diff --git a/frontend/pshared/lib/l10n/ru.arb b/frontend/pshared/lib/l10n/ru.arb index 0e28cdf..2a30a8b 100644 --- a/frontend/pshared/lib/l10n/ru.arb +++ b/frontend/pshared/lib/l10n/ru.arb @@ -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" } } diff --git a/frontend/pshared/lib/models/payment/asset.dart b/frontend/pshared/lib/models/payment/asset.dart new file mode 100644 index 0000000..565b72d --- /dev/null +++ b/frontend/pshared/lib/models/payment/asset.dart @@ -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, + }); +} diff --git a/frontend/pshared/lib/models/payment/chain_network.dart b/frontend/pshared/lib/models/payment/chain_network.dart new file mode 100644 index 0000000..e055d80 --- /dev/null +++ b/frontend/pshared/lib/models/payment/chain_network.dart @@ -0,0 +1 @@ +enum ChainNetwork { unspecified, ethereumMainnet, arbitrumOne, otherEvm } diff --git a/frontend/pshared/lib/models/payment/currency_pair.dart b/frontend/pshared/lib/models/payment/currency_pair.dart new file mode 100644 index 0000000..d937510 --- /dev/null +++ b/frontend/pshared/lib/models/payment/currency_pair.dart @@ -0,0 +1,9 @@ +class CurrencyPair { + final String base; + final String quote; + + const CurrencyPair({ + required this.base, + required this.quote, + }); +} diff --git a/frontend/pshared/lib/models/payment/fees/line.dart b/frontend/pshared/lib/models/payment/fees/line.dart new file mode 100644 index 0000000..bd5b40f --- /dev/null +++ b/frontend/pshared/lib/models/payment/fees/line.dart @@ -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? meta; + + const FeeLine({ + required this.ledgerAccountRef, + required this.amount, + required this.lineType, + required this.side, + required this.meta, + }); +} diff --git a/frontend/pshared/lib/models/payment/fees/network.dart b/frontend/pshared/lib/models/payment/fees/network.dart new file mode 100644 index 0000000..f9ea24b --- /dev/null +++ b/frontend/pshared/lib/models/payment/fees/network.dart @@ -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, + }); +} diff --git a/frontend/pshared/lib/models/payment/fx/intent.dart b/frontend/pshared/lib/models/payment/fx/intent.dart new file mode 100644 index 0000000..11432e0 --- /dev/null +++ b/frontend/pshared/lib/models/payment/fx/intent.dart @@ -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, + }); +} diff --git a/frontend/pshared/lib/models/payment/fx/quote.dart b/frontend/pshared/lib/models/payment/fx/quote.dart new file mode 100644 index 0000000..91c21dd --- /dev/null +++ b/frontend/pshared/lib/models/payment/fx/quote.dart @@ -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, + }); +} diff --git a/frontend/pshared/lib/models/payment/fx/side.dart b/frontend/pshared/lib/models/payment/fx/side.dart new file mode 100644 index 0000000..9ac1eee --- /dev/null +++ b/frontend/pshared/lib/models/payment/fx/side.dart @@ -0,0 +1 @@ +enum FxSide { unspecified, buyBaseSellQuote, sellBaseBuyQuote } diff --git a/frontend/pshared/lib/models/payment/insufficient_net_policy.dart b/frontend/pshared/lib/models/payment/insufficient_net_policy.dart new file mode 100644 index 0000000..cc8e536 --- /dev/null +++ b/frontend/pshared/lib/models/payment/insufficient_net_policy.dart @@ -0,0 +1,6 @@ +enum InsufficientNetPolicy { + unspecified, + blockPosting, + sweepOrgCash, + invoiceLater +} diff --git a/frontend/pshared/lib/models/payment/intent.dart b/frontend/pshared/lib/models/payment/intent.dart new file mode 100644 index 0000000..943c61e --- /dev/null +++ b/frontend/pshared/lib/models/payment/intent.dart @@ -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? attributes; + + const PaymentIntent({ + this.kind = PaymentKind.unspecified, + this.source, + this.destination, + this.amount, + this.fx, + this.settlementMode = SettlementMode.unspecified, + this.attributes, + }); +} diff --git a/frontend/pshared/lib/models/payment/kind.dart b/frontend/pshared/lib/models/payment/kind.dart new file mode 100644 index 0000000..7aaa1e7 --- /dev/null +++ b/frontend/pshared/lib/models/payment/kind.dart @@ -0,0 +1 @@ +enum PaymentKind { unspecified, payout, internalTransfer, fxConversion } diff --git a/frontend/pshared/lib/models/payment/methods/card.dart b/frontend/pshared/lib/models/payment/methods/card.dart index 6643e2b..ca7b164 100644 --- a/frontend/pshared/lib/models/payment/methods/card.dart +++ b/frontend/pshared/lib/models/payment/methods/card.dart @@ -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? metadata; - CardPaymentMethod({ + const CardPaymentMethod({ required this.pan, + this.expMonth, + this.expYear, required this.firstName, required this.lastName, + this.country, + this.metadata, }); -} \ No newline at end of file +} diff --git a/frontend/pshared/lib/models/payment/methods/card_token.dart b/frontend/pshared/lib/models/payment/methods/card_token.dart new file mode 100644 index 0000000..06c948a --- /dev/null +++ b/frontend/pshared/lib/models/payment/methods/card_token.dart @@ -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? metadata; + + const CardTokenPaymentMethod({ + required this.token, + required this.maskedPan, + this.metadata, + }); +} diff --git a/frontend/pshared/lib/models/payment/methods/crypto_address.dart b/frontend/pshared/lib/models/payment/methods/crypto_address.dart index acc8b92..2835d82 100644 --- a/frontend/pshared/lib/models/payment/methods/crypto_address.dart +++ b/frontend/pshared/lib/models/payment/methods/crypto_address.dart @@ -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? metadata; - CryptoAddressPaymentMethod({ + const CryptoAddressPaymentMethod({ + this.asset, required this.address, - required this.network, - this.destinationTag, + this.memo, + this.metadata, }); } diff --git a/frontend/pshared/lib/models/payment/methods/data.dart b/frontend/pshared/lib/models/payment/methods/data.dart index 7f80efe..0ba0ce6 100644 --- a/frontend/pshared/lib/models/payment/methods/data.dart +++ b/frontend/pshared/lib/models/payment/methods/data.dart @@ -3,6 +3,7 @@ import 'package:pshared/models/payment/type.dart'; abstract class PaymentMethodData { PaymentType get type; + Map? get metadata; } typedef MethodMap = Map; \ No newline at end of file diff --git a/frontend/pshared/lib/models/payment/methods/iban.dart b/frontend/pshared/lib/models/payment/methods/iban.dart index 83f7698..a85d64b 100644 --- a/frontend/pshared/lib/models/payment/methods/iban.dart +++ b/frontend/pshared/lib/models/payment/methods/iban.dart @@ -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? metadata; - IbanPaymentMethod({ + const IbanPaymentMethod({ required this.iban, required this.accountHolder, this.bic, this.bankName, + this.metadata, }); } diff --git a/frontend/pshared/lib/models/payment/methods/ledger.dart b/frontend/pshared/lib/models/payment/methods/ledger.dart new file mode 100644 index 0000000..5dd11e1 --- /dev/null +++ b/frontend/pshared/lib/models/payment/methods/ledger.dart @@ -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? metadata; + + const LedgerPaymentMethod({ + required this.ledgerAccountRef, + this.contraLedgerAccountRef, + this.metadata, + }); +} diff --git a/frontend/pshared/lib/models/payment/methods/managed_wallet.dart b/frontend/pshared/lib/models/payment/methods/managed_wallet.dart new file mode 100644 index 0000000..48fc143 --- /dev/null +++ b/frontend/pshared/lib/models/payment/methods/managed_wallet.dart @@ -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? metadata; + + const ManagedWalletPaymentMethod({ + required this.managedWalletRef, + this.asset, + this.metadata, + }); +} diff --git a/frontend/pshared/lib/models/payment/methods/russian_bank.dart b/frontend/pshared/lib/models/payment/methods/russian_bank.dart index 70de691..7d84934 100644 --- a/frontend/pshared/lib/models/payment/methods/russian_bank.dart +++ b/frontend/pshared/lib/models/payment/methods/russian_bank.dart @@ -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? 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, }); } diff --git a/frontend/pshared/lib/models/payment/methods/wallet.dart b/frontend/pshared/lib/models/payment/methods/wallet.dart index 587631e..51886a2 100644 --- a/frontend/pshared/lib/models/payment/methods/wallet.dart +++ b/frontend/pshared/lib/models/payment/methods/wallet.dart @@ -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? metadata; + + WalletPaymentMethod({required this.walletId, this.metadata}); } diff --git a/frontend/pshared/lib/models/payment/money.dart b/frontend/pshared/lib/models/payment/money.dart new file mode 100644 index 0000000..e971b5d --- /dev/null +++ b/frontend/pshared/lib/models/payment/money.dart @@ -0,0 +1,9 @@ +class Money { + final String amount; + final String currency; + + const Money({ + required this.amount, + required this.currency, + }); +} diff --git a/frontend/pshared/lib/models/payment/payment.dart b/frontend/pshared/lib/models/payment/payment.dart new file mode 100644 index 0000000..42219f8 --- /dev/null +++ b/frontend/pshared/lib/models/payment/payment.dart @@ -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, + }); +} diff --git a/frontend/pshared/lib/models/payment/quote.dart b/frontend/pshared/lib/models/payment/quote.dart new file mode 100644 index 0000000..ff8af82 --- /dev/null +++ b/frontend/pshared/lib/models/payment/quote.dart @@ -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? 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, + }); +} diff --git a/frontend/pshared/lib/models/payment/settlement_mode.dart b/frontend/pshared/lib/models/payment/settlement_mode.dart new file mode 100644 index 0000000..4316c86 --- /dev/null +++ b/frontend/pshared/lib/models/payment/settlement_mode.dart @@ -0,0 +1 @@ +enum SettlementMode { unspecified, fixSource, fixReceived } diff --git a/frontend/pshared/lib/models/payment/type.dart b/frontend/pshared/lib/models/payment/type.dart index d263401..467b6e9 100644 --- a/frontend/pshared/lib/models/payment/type.dart +++ b/frontend/pshared/lib/models/payment/type.dart @@ -1,7 +1,10 @@ enum PaymentType { + ledger, + managedWallet, + externalChain, bankAccount, iban, wallet, card, - cryptoAddress, + cardToken, } diff --git a/frontend/pshared/lib/provider/payment/quotation.dart b/frontend/pshared/lib/provider/payment/quotation.dart new file mode 100644 index 0000000..6ef3702 --- /dev/null +++ b/frontend/pshared/lib/provider/payment/quotation.dart @@ -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 _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 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(); + } +} diff --git a/frontend/pshared/lib/service/payment/quotation.dart b/frontend/pshared/lib/service/payment/quotation.dart new file mode 100644 index 0000000..90fe900 --- /dev/null +++ b/frontend/pshared/lib/service/payment/quotation.dart @@ -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 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(); + } +} diff --git a/frontend/pshared/lib/service/services.dart b/frontend/pshared/lib/service/services.dart index aaddc8f..b23f4ae 100644 --- a/frontend/pshared/lib/service/services.dart +++ b/frontend/pshared/lib/service/services.dart @@ -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'; diff --git a/frontend/pshared/lib/utils/l10n/chain.dart b/frontend/pshared/lib/utils/l10n/chain.dart new file mode 100644 index 0000000..ff10fce --- /dev/null +++ b/frontend/pshared/lib/utils/l10n/chain.dart @@ -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; + } + } +} diff --git a/frontend/pweb/lib/app/router/payout_routes.dart b/frontend/pweb/lib/app/router/payout_routes.dart index 9334dcf..95c3038 100644 --- a/frontend/pweb/lib/app/router/payout_routes.dart +++ b/frontend/pweb/lib/app/router/payout_routes.dart @@ -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: diff --git a/frontend/pweb/lib/l10n/en.arb b/frontend/pweb/lib/l10n/en.arb index 2ec2221..1987f47 100644 --- a/frontend/pweb/lib/l10n/en.arb +++ b/frontend/pweb/lib/l10n/en.arb @@ -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", diff --git a/frontend/pweb/lib/l10n/ru.arb b/frontend/pweb/lib/l10n/ru.arb index d79cca5..8743ca8 100644 --- a/frontend/pweb/lib/l10n/ru.arb +++ b/frontend/pweb/lib/l10n/ru.arb @@ -312,6 +312,16 @@ "paymentTypeIban": "IBAN", "paymentTypeWallet": "Кошелек", "paymentTypeCryptoAddress": "Крипто-адрес", + "paymentTypeLedger": "Леджер счет", + "paymentTypeManagedWallet": "Управляемый кошелек", + "paymentTypeCardToken": "Токен карты", + + "cryptoAddressLabel": "Крипто-адрес", + "enterCryptoAddress": "Введите крипто-адрес", + "tokenSymbolLabel": "Символ токена", + "tokenSymbolRequiredWhenNetwork": "Укажите символ токена, если выбрана сеть или указан адрес контракта", + "contractAddressLabel": "Адрес контракта (необязательно)", + "memoLabel": "Destination tag / memo (необязательно)", "cardNumber": "Номер карты", "enterCardNumber": "Введите номер карты", diff --git a/frontend/pweb/lib/main.dart b/frontend/pweb/lib/main.dart index 1e2715b..696d989 100644 --- a/frontend/pweb/lib/main.dart +++ b/frontend/pweb/lib/main.dart @@ -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( + create: (_) => QuotationProvider(), + update: (context, orgnization, provider) => provider!..update(orgnization), + ), ], child: const PayApp(), ), diff --git a/frontend/pweb/lib/pages/address_book/form/page.dart b/frontend/pweb/lib/pages/address_book/form/page.dart index 1568be3..0404025 100644 --- a/frontend/pweb/lib/pages/address_book/form/page.dart +++ b/frontend/pweb/lib/pages/address_book/form/page.dart @@ -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 { 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'), }; } } diff --git a/frontend/pweb/lib/pages/payment_methods/add/crypto_address.dart b/frontend/pweb/lib/pages/payment_methods/add/crypto_address.dart index d848d69..780d761 100644 --- a/frontend/pweb/lib/pages/payment_methods/add/crypto_address.dart +++ b/frontend/pweb/lib/pages/payment_methods/add/crypto_address.dart @@ -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 { + final _formKey = GlobalKey(); + 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 { 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( + 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(); + } } diff --git a/frontend/pweb/lib/pages/payment_methods/form.dart b/frontend/pweb/lib/pages/payment_methods/form.dart index daa78cd..cee66f4 100644 --- a/frontend/pweb/lib/pages/payment_methods/form.dart +++ b/frontend/pweb/lib/pages/payment_methods/form.dart @@ -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, diff --git a/frontend/pweb/lib/pages/payment_methods/icon.dart b/frontend/pweb/lib/pages/payment_methods/icon.dart index bc38a2a..764c614 100644 --- a/frontend/pweb/lib/pages/payment_methods/icon.dart +++ b/frontend/pweb/lib/pages/payment_methods/icon.dart @@ -13,7 +13,10 @@ IconData iconForPaymentType(PaymentType type) { return Icons.account_balance_wallet; case PaymentType.card: return Icons.credit_card; - case PaymentType.cryptoAddress: + case PaymentType.externalChain: return Icons.currency_bitcoin; + //TODO: define new payment methods + default: + return Icons.question_mark; } } diff --git a/frontend/pweb/lib/utils/payment/label.dart b/frontend/pweb/lib/utils/payment/label.dart index e332737..077ea81 100644 --- a/frontend/pweb/lib/utils/payment/label.dart +++ b/frontend/pweb/lib/utils/payment/label.dart @@ -1,5 +1,8 @@ import 'package:flutter/widgets.dart'; +import 'package:pshared/models/payment/methods/card_token.dart'; +import 'package:pshared/models/payment/methods/ledger.dart'; +import 'package:pshared/models/payment/methods/managed_wallet.dart'; import 'package:pshared/models/payment/methods/type.dart'; import 'package:pshared/models/payment/type.dart'; @@ -12,21 +15,31 @@ String getPaymentTypeLabel(BuildContext context, PaymentType type) { final l10n = AppLocalizations.of(context)!; return switch (type) { PaymentType.card => l10n.paymentTypeCard, + PaymentType.cardToken => l10n.paymentTypeCardToken, PaymentType.bankAccount => l10n.paymentTypeBankAccount, PaymentType.iban => l10n.paymentTypeIban, PaymentType.wallet => l10n.paymentTypeWallet, - PaymentType.cryptoAddress => l10n.paymentTypeCryptoAddress, + PaymentType.managedWallet => l10n.paymentTypeManagedWallet, + PaymentType.externalChain => l10n.paymentTypeCryptoAddress, + PaymentType.ledger => l10n.paymentTypeLedger, }; } String? _displayString(PaymentMethod m) => switch (m.type) { PaymentType.card => maskCardNumber(m.cardData?.pan), + PaymentType.cardToken => m.dataAsOrNull()?.maskedPan, PaymentType.bankAccount => m.bankAccountData?.accountNumber, PaymentType.iban => m.ibanData?.iban, PaymentType.wallet => m.walletData?.walletId, - PaymentType.cryptoAddress => m.cryptoAddressData?.address, + PaymentType.managedWallet => () { + final data = m.dataAsOrNull(); + if (data == null) return null; + return data.asset?.tokenSymbol ?? data.managedWalletRef; + }(), + PaymentType.externalChain => m.cryptoAddressData?.address, + PaymentType.ledger => m.dataAsOrNull()?.ledgerAccountRef, }; String getPaymentTypeDescription(BuildContext context, PaymentMethod m) { return _displayString(m) ?? AppLocalizations.of(context)!.notSet; -} \ No newline at end of file +}