diff --git a/api/edge/bff/interface/api/sresponse/payment.go b/api/edge/bff/interface/api/sresponse/payment.go index acdd44c7..e3769fc2 100644 --- a/api/edge/bff/interface/api/sresponse/payment.go +++ b/api/edge/bff/interface/api/sresponse/payment.go @@ -14,8 +14,11 @@ import ( gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1" paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1" oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" + endpointv1 "github.com/tech/sendico/pkg/proto/payments/endpoint/v1" orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2" quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" + "github.com/tech/sendico/server/interface/api/srequest" + "go.mongodb.org/mongo-driver/v2/bson" "google.golang.org/protobuf/types/known/timestamppb" ) @@ -67,16 +70,24 @@ type PaymentQuotes struct { } type Payment struct { - PaymentRef string `json:"paymentRef,omitempty"` - IdempotencyKey string `json:"idempotencyKey,omitempty"` - State string `json:"state,omitempty"` - Comment string `json:"comment,omitempty"` - FailureCode string `json:"failureCode,omitempty"` - FailureReason string `json:"failureReason,omitempty"` - Operations []PaymentOperation `json:"operations,omitempty"` - LastQuote *PaymentQuote `json:"lastQuote,omitempty"` - CreatedAt time.Time `json:"createdAt,omitempty"` - Meta map[string]string `json:"meta,omitempty"` + PaymentRef string `json:"paymentRef,omitempty"` + State string `json:"state,omitempty"` + Comment string `json:"comment,omitempty"` + Source *PaymentEndpoint `json:"source"` + Destination *PaymentEndpoint `json:"destination"` + FailureCode string `json:"failureCode,omitempty"` + FailureReason string `json:"failureReason,omitempty"` + Operations []PaymentOperation `json:"operations,omitempty"` + LastQuote *PaymentQuote `json:"lastQuote,omitempty"` + CreatedAt time.Time `json:"createdAt,omitempty"` + Meta map[string]string `json:"meta,omitempty"` +} + +type PaymentEndpoint struct { + Type string `json:"type,omitempty"` + Data any `json:"data,omitempty"` + PaymentMethodRef string `json:"paymentMethodRef,omitempty"` + PayeeRef string `json:"payeeRef,omitempty"` } type PaymentOperation struct { @@ -290,22 +301,257 @@ func toPayment(p *orchestrationv2.Payment) *Payment { if p == nil { return nil } + intent := p.GetIntentSnapshot() operations := toUserVisibleOperations(p.GetStepExecutions(), p.GetQuoteSnapshot()) failureCode, failureReason := firstFailure(operations) return &Payment{ - PaymentRef: p.GetPaymentRef(), - State: enumJSONName(p.GetState().String()), - Comment: strings.TrimSpace(p.GetIntentSnapshot().GetComment()), - FailureCode: failureCode, - FailureReason: failureReason, - Operations: operations, - LastQuote: toPaymentQuote(p.GetQuoteSnapshot()), - CreatedAt: timestampAsTime(p.GetCreatedAt()), - Meta: paymentMeta(p), - IdempotencyKey: "", + PaymentRef: p.GetPaymentRef(), + State: enumJSONName(p.GetState().String()), + Comment: strings.TrimSpace(intent.GetComment()), + Source: toPaymentEndpoint(intent.GetSource()), + Destination: toPaymentEndpoint(intent.GetDestination()), + FailureCode: failureCode, + FailureReason: failureReason, + Operations: operations, + LastQuote: toPaymentQuote(p.GetQuoteSnapshot()), + CreatedAt: timestampAsTime(p.GetCreatedAt()), + Meta: paymentMeta(p), } } +func toPaymentEndpoint(endpoint *endpointv1.PaymentEndpoint) *PaymentEndpoint { + if endpoint == nil { + return nil + } + if paymentMethodRef := strings.TrimSpace(endpoint.GetPaymentMethodRef()); paymentMethodRef != "" { + return &PaymentEndpoint{PaymentMethodRef: paymentMethodRef} + } + if payeeRef := strings.TrimSpace(endpoint.GetPayeeRef()); payeeRef != "" { + return &PaymentEndpoint{PayeeRef: payeeRef} + } + method := endpoint.GetPaymentMethod() + if method == nil { + return nil + } + return &PaymentEndpoint{ + Type: paymentEndpointType(method.GetType()), + Data: paymentEndpointData(method), + } +} + +func paymentEndpointType(methodType endpointv1.PaymentMethodType) string { + switch methodType { + case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_IBAN: + return string(srequest.EndpointTypeIBAN) + case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CARD: + return string(srequest.EndpointTypeCard) + case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CARD_TOKEN: + return string(srequest.EndpointTypeCardToken) + case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_BANK_ACCOUNT: + return string(srequest.EndpointTypeBankAccount) + case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_WALLET: + return string(srequest.EndpointTypeWallet) + case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CRYPTO_ADDRESS: + return string(srequest.EndpointTypeExternalChain) + case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_LEDGER: + return string(srequest.EndpointTypeLedger) + case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_ACCOUNT: + return "account" + default: + return "unspecified" + } +} + +func paymentEndpointData(method *endpointv1.PaymentMethod) any { + if method == nil { + return nil + } + switch method.GetType() { + case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_LEDGER: + type ledgerMethodData struct { + LedgerAccountRef string `bson:"ledgerAccountRef"` + ContraLedgerAccountRef string `bson:"contraLedgerAccountRef,omitempty"` + } + var payload ledgerMethodData + if err := bson.Unmarshal(method.GetData(), &payload); err != nil { + return toRawBSON(method.GetData()) + } + return srequest.LedgerEndpoint{ + LedgerAccountRef: strings.TrimSpace(payload.LedgerAccountRef), + ContraLedgerAccountRef: strings.TrimSpace(payload.ContraLedgerAccountRef), + } + + case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_WALLET: + type walletMethodData struct { + WalletID string `bson:"walletId"` + } + var payload walletMethodData + if err := bson.Unmarshal(method.GetData(), &payload); err != nil { + return toRawBSON(method.GetData()) + } + return srequest.WalletEndpoint{ + WalletID: strings.TrimSpace(payload.WalletID), + } + + case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CRYPTO_ADDRESS: + type cryptoMethodData struct { + Currency string `bson:"currency"` + Address string `bson:"address"` + Network string `bson:"network"` + DestinationTag *string `bson:"destinationTag,omitempty"` + } + var payload cryptoMethodData + if err := bson.Unmarshal(method.GetData(), &payload); err != nil { + return toRawBSON(method.GetData()) + } + endpoint := srequest.ExternalChainEndpoint{ + Asset: &srequest.Asset{ + Chain: parseChainNetwork(payload.Network), + TokenSymbol: strings.ToUpper(strings.TrimSpace(payload.Currency)), + }, + Address: strings.TrimSpace(payload.Address), + } + if memo := strings.TrimSpace(strPtr(payload.DestinationTag)); memo != "" { + endpoint.Memo = memo + } + if endpoint.Asset.Chain == srequest.ChainNetworkUnspecified && endpoint.Asset.TokenSymbol == "" { + endpoint.Asset = nil + } + return endpoint + + case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CARD: + type cardMethodData struct { + Pan string `bson:"pan"` + FirstName string `bson:"firstName"` + LastName string `bson:"lastName"` + ExpMonth string `bson:"expMonth"` + ExpYear string `bson:"expYear"` + Country string `bson:"country,omitempty"` + } + var payload cardMethodData + if err := bson.Unmarshal(method.GetData(), &payload); err != nil { + return toRawBSON(method.GetData()) + } + return srequest.CardEndpoint{ + Pan: strings.TrimSpace(payload.Pan), + FirstName: strings.TrimSpace(payload.FirstName), + LastName: strings.TrimSpace(payload.LastName), + ExpMonth: parseUint32(payload.ExpMonth), + ExpYear: parseUint32(payload.ExpYear), + Country: strings.TrimSpace(payload.Country), + } + + case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CARD_TOKEN: + type cardTokenMethodData struct { + Token string `bson:"token"` + Last4 string `bson:"last4,omitempty"` + } + var payload cardTokenMethodData + if err := bson.Unmarshal(method.GetData(), &payload); err != nil { + return toRawBSON(method.GetData()) + } + return srequest.CardTokenEndpoint{ + Token: strings.TrimSpace(payload.Token), + MaskedPan: strings.TrimSpace(payload.Last4), + } + + case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_BANK_ACCOUNT: + type bankAccountMethodData struct { + RecipientName string `bson:"recipientName"` + Inn string `bson:"inn"` + Kpp string `bson:"kpp"` + BankName string `bson:"bankName"` + Bik string `bson:"bik"` + AccountNumber string `bson:"accountNumber"` + CorrespondentAccount string `bson:"correspondentAccount"` + } + var payload bankAccountMethodData + if err := bson.Unmarshal(method.GetData(), &payload); err != nil { + return toRawBSON(method.GetData()) + } + return srequest.BankAccountEndpoint{ + RecipientName: strings.TrimSpace(payload.RecipientName), + Inn: strings.TrimSpace(payload.Inn), + Kpp: strings.TrimSpace(payload.Kpp), + BankName: strings.TrimSpace(payload.BankName), + Bik: strings.TrimSpace(payload.Bik), + AccountNumber: strings.TrimSpace(payload.AccountNumber), + CorrespondentAccount: strings.TrimSpace(payload.CorrespondentAccount), + } + + case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_IBAN: + type ibanMethodData struct { + IBAN string `bson:"iban"` + AccountHolder string `bson:"accountHolder"` + BIC *string `bson:"bic,omitempty"` + BankName *string `bson:"bankName,omitempty"` + } + var payload ibanMethodData + if err := bson.Unmarshal(method.GetData(), &payload); err != nil { + return toRawBSON(method.GetData()) + } + return srequest.IBANEndpoint{ + IBAN: strings.TrimSpace(payload.IBAN), + AccountHolder: strings.TrimSpace(payload.AccountHolder), + BIC: strings.TrimSpace(strPtr(payload.BIC)), + BankName: strings.TrimSpace(strPtr(payload.BankName)), + } + + default: + return toRawBSON(method.GetData()) + } +} + +func toRawBSON(raw []byte) map[string]any { + if len(raw) == 0 { + return nil + } + var data map[string]any + if err := bson.Unmarshal(raw, &data); err != nil { + return nil + } + if len(data) == 0 { + return nil + } + return data +} + +func parseChainNetwork(value string) srequest.ChainNetwork { + switch strings.ToUpper(strings.TrimSpace(value)) { + case "ETHEREUM_MAINNET": + return srequest.ChainNetworkEthereumMainnet + case "ARBITRUM_ONE": + return srequest.ChainNetworkArbitrumOne + case "TRON_MAINNET": + return srequest.ChainNetworkTronMainnet + case "TRON_NILE": + return srequest.ChainNetworkTronNile + case "", "UNSPECIFIED": + return srequest.ChainNetworkUnspecified + default: + return srequest.ChainNetwork(strings.ToLower(strings.TrimSpace(value))) + } +} + +func parseUint32(value string) uint32 { + clean := strings.TrimSpace(value) + if clean == "" { + return 0 + } + parsed, err := strconv.ParseUint(clean, 10, 32) + if err != nil { + return 0 + } + return uint32(parsed) +} + +func strPtr(v *string) string { + if v == nil { + return "" + } + return *v +} + func firstFailure(operations []PaymentOperation) (string, string) { for _, op := range operations { if strings.TrimSpace(op.FailureCode) == "" && strings.TrimSpace(op.FailureReason) == "" { diff --git a/api/edge/bff/interface/api/sresponse/payment_test.go b/api/edge/bff/interface/api/sresponse/payment_test.go index a2256b81..9efcd809 100644 --- a/api/edge/bff/interface/api/sresponse/payment_test.go +++ b/api/edge/bff/interface/api/sresponse/payment_test.go @@ -5,9 +5,12 @@ import ( gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + endpointv1 "github.com/tech/sendico/pkg/proto/payments/endpoint/v1" orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2" quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" + "github.com/tech/sendico/server/interface/api/srequest" + "go.mongodb.org/mongo-driver/v2/bson" ) func TestToUserVisibleOperationsFiltersByVisibility(t *testing.T) { @@ -137,6 +140,125 @@ func TestToPaymentMapsIntentComment(t *testing.T) { } } +func TestToPaymentMapsSourceAndDestination(t *testing.T) { + sourceRaw, err := bson.Marshal(struct { + WalletID string `bson:"walletId"` + }{ + WalletID: "wallet-src-1", + }) + if err != nil { + t.Fatalf("marshal source method data: %v", err) + } + destinationRaw, err := bson.Marshal(struct { + Currency string `bson:"currency"` + Address string `bson:"address"` + Network string `bson:"network"` + DestinationTag *string `bson:"destinationTag,omitempty"` + }{ + Currency: "USDT", + Address: "TXabc", + Network: "TRON_MAINNET", + }) + if err != nil { + t.Fatalf("marshal destination method data: %v", err) + } + + dto := toPayment(&orchestrationv2.Payment{ + PaymentRef: "pay-src-dst", + State: orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_CREATED, + IntentSnapshot: "ationv2.QuoteIntent{ + Source: &endpointv1.PaymentEndpoint{ + Source: &endpointv1.PaymentEndpoint_PaymentMethod{ + PaymentMethod: &endpointv1.PaymentMethod{ + Type: endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_WALLET, + Data: sourceRaw, + }, + }, + }, + Destination: &endpointv1.PaymentEndpoint{ + Source: &endpointv1.PaymentEndpoint_PaymentMethod{ + PaymentMethod: &endpointv1.PaymentMethod{ + Type: endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CRYPTO_ADDRESS, + Data: destinationRaw, + }, + }, + }, + }, + }) + if dto == nil { + t.Fatal("expected non-nil payment dto") + } + if dto.Source == nil { + t.Fatal("expected source endpoint") + } + if got, want := dto.Source.Type, string(srequest.EndpointTypeWallet); got != want { + t.Fatalf("source type mismatch: got=%q want=%q", got, want) + } + sourceEndpoint, ok := dto.Source.Data.(srequest.WalletEndpoint) + if !ok { + t.Fatalf("source endpoint payload type mismatch: got=%T", dto.Source.Data) + } + if got, want := sourceEndpoint.WalletID, "wallet-src-1"; got != want { + t.Fatalf("source wallet id mismatch: got=%q want=%q", got, want) + } + if dto.Destination == nil { + t.Fatal("expected destination endpoint") + } + if got, want := dto.Destination.Type, string(srequest.EndpointTypeExternalChain); got != want { + t.Fatalf("destination type mismatch: got=%q want=%q", got, want) + } + destinationEndpoint, ok := dto.Destination.Data.(srequest.ExternalChainEndpoint) + if !ok { + t.Fatalf("destination endpoint payload type mismatch: got=%T", dto.Destination.Data) + } + if got, want := destinationEndpoint.Address, "TXabc"; got != want { + t.Fatalf("destination address mismatch: got=%q want=%q", got, want) + } + if destinationEndpoint.Asset == nil { + t.Fatal("expected destination asset") + } + if got, want := destinationEndpoint.Asset.TokenSymbol, "USDT"; got != want { + t.Fatalf("destination token mismatch: got=%q want=%q", got, want) + } + if got, want := destinationEndpoint.Asset.Chain, srequest.ChainNetworkTronMainnet; got != want { + t.Fatalf("destination chain mismatch: got=%q want=%q", got, want) + } +} + +func TestToPaymentMapsEndpointRefs(t *testing.T) { + dto := toPayment(&orchestrationv2.Payment{ + PaymentRef: "pay-refs", + State: orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_CREATED, + IntentSnapshot: "ationv2.QuoteIntent{ + Source: &endpointv1.PaymentEndpoint{ + Source: &endpointv1.PaymentEndpoint_PaymentMethodRef{ + PaymentMethodRef: "pm-123", + }, + }, + Destination: &endpointv1.PaymentEndpoint{ + Source: &endpointv1.PaymentEndpoint_PayeeRef{ + PayeeRef: "payee-777", + }, + }, + }, + }) + if dto == nil { + t.Fatal("expected non-nil payment dto") + } + if dto.Source == nil { + t.Fatal("expected source endpoint") + } + if got, want := dto.Source.PaymentMethodRef, "pm-123"; got != want { + t.Fatalf("source payment_method_ref mismatch: got=%q want=%q", got, want) + } + if dto.Destination == nil { + t.Fatal("expected destination endpoint") + } + if got, want := dto.Destination.PayeeRef, "payee-777"; got != want { + t.Fatalf("destination payee_ref mismatch: got=%q want=%q", got, want) + } +} + func TestToPaymentQuote_MapsIntentRef(t *testing.T) { dto := toPaymentQuote("ationv2.PaymentQuote{ QuoteRef: "quote-1", diff --git a/frontend/pshared/lib/data/dto/payment/payment.dart b/frontend/pshared/lib/data/dto/payment/payment.dart index 3c67c1fa..162b0e0f 100644 --- a/frontend/pshared/lib/data/dto/payment/payment.dart +++ b/frontend/pshared/lib/data/dto/payment/payment.dart @@ -2,15 +2,16 @@ import 'package:json_annotation/json_annotation.dart'; import 'package:pshared/data/dto/payment/operation.dart'; import 'package:pshared/data/dto/payment/payment_quote.dart'; +import 'package:pshared/data/dto/payment/response_endpoint.dart'; part 'payment.g.dart'; - @JsonSerializable() class PaymentDTO { final String? paymentRef; - final String? idempotencyKey; final String? state; + final PaymentResponseEndpointDTO? source; + final PaymentResponseEndpointDTO? destination; final String? failureCode; final String? failureReason; final List operations; @@ -20,8 +21,9 @@ class PaymentDTO { const PaymentDTO({ this.paymentRef, - this.idempotencyKey, this.state, + this.source, + this.destination, this.failureCode, this.failureReason, this.operations = const [], diff --git a/frontend/pshared/lib/data/dto/payment/response_endpoint.dart b/frontend/pshared/lib/data/dto/payment/response_endpoint.dart new file mode 100644 index 00000000..5a8be677 --- /dev/null +++ b/frontend/pshared/lib/data/dto/payment/response_endpoint.dart @@ -0,0 +1,22 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'response_endpoint.g.dart'; + +@JsonSerializable() +class PaymentResponseEndpointDTO { + final String? type; + final Map? data; + final String? paymentMethodRef; + final String? payeeRef; + + const PaymentResponseEndpointDTO({ + this.type, + this.data, + this.paymentMethodRef, + this.payeeRef, + }); + + factory PaymentResponseEndpointDTO.fromJson(Map json) => + _$PaymentResponseEndpointDTOFromJson(json); + Map toJson() => _$PaymentResponseEndpointDTOToJson(this); +} diff --git a/frontend/pshared/lib/data/mapper/payment/payment.dart b/frontend/pshared/lib/data/mapper/payment/payment.dart index 39b6483d..93835344 100644 --- a/frontend/pshared/lib/data/mapper/payment/payment.dart +++ b/frontend/pshared/lib/data/mapper/payment/payment.dart @@ -2,20 +2,24 @@ 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/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/russian_bank.dart'; +import 'package:pshared/data/dto/payment/wallet.dart'; import 'package:pshared/data/mapper/payment/asset.dart'; import 'package:pshared/data/mapper/payment/enums.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/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/wallet.dart'; import 'package:pshared/models/payment/type.dart'; - extension PaymentMethodDataEndpointMapper on PaymentMethodData { PaymentEndpointDTO toDTO() { final metadata = this.metadata; @@ -76,8 +80,40 @@ extension PaymentMethodDataEndpointMapper on PaymentMethodData { ).toJson(), metadata: metadata, ); - default: - throw UnsupportedError('Unsupported payment endpoint type: $type'); + case PaymentType.wallet: + final payload = this as WalletPaymentMethod; + return PaymentEndpointDTO( + type: endpointTypeToValue(type), + data: WalletPaymentDataDTO(walletId: payload.walletId).toJson(), + metadata: metadata, + ); + case PaymentType.bankAccount: + final payload = this as RussianBankAccountPaymentMethod; + return PaymentEndpointDTO( + type: endpointTypeToValue(type), + data: RussianBankAccountPaymentDataDTO( + recipientName: payload.recipientName, + inn: payload.inn, + kpp: payload.kpp, + bankName: payload.bankName, + bik: payload.bik, + accountNumber: payload.accountNumber, + correspondentAccount: payload.correspondentAccount, + ).toJson(), + metadata: metadata, + ); + case PaymentType.iban: + final payload = this as IbanPaymentMethod; + return PaymentEndpointDTO( + type: endpointTypeToValue(type), + data: IbanPaymentDataDTO( + iban: payload.iban, + accountHolder: payload.accountHolder, + bic: payload.bic, + bankName: payload.bankName, + ).toJson(), + metadata: metadata, + ); } } } @@ -127,14 +163,40 @@ extension PaymentEndpointDTOMapper on PaymentEndpointDTO { maskedPan: payload.maskedPan, metadata: metadata, ); - default: - throw UnsupportedError('Unsupported payment endpoint type: ${paymentTypeFromValue(type)}'); + case PaymentType.wallet: + final payload = WalletPaymentDataDTO.fromJson(data); + return WalletPaymentMethod( + walletId: payload.walletId, + metadata: metadata, + ); + case PaymentType.bankAccount: + final payload = RussianBankAccountPaymentDataDTO.fromJson(data); + return RussianBankAccountPaymentMethod( + recipientName: payload.recipientName, + inn: payload.inn, + kpp: payload.kpp, + bankName: payload.bankName, + bik: payload.bik, + accountNumber: payload.accountNumber, + correspondentAccount: payload.correspondentAccount, + metadata: metadata, + ); + case PaymentType.iban: + final payload = IbanPaymentDataDTO.fromJson(data); + return IbanPaymentMethod( + iban: payload.iban, + accountHolder: payload.accountHolder, + bic: payload.bic, + bankName: payload.bankName, + metadata: metadata, + ); } } } PaymentType _resolveEndpointType(String type, Map data) { - if (type == 'card' && (data.containsKey('token') || data.containsKey('masked_pan'))) { + if (type == 'card' && + (data.containsKey('token') || data.containsKey('masked_pan'))) { return PaymentType.cardToken; } return endpointTypeFromValue(type); diff --git a/frontend/pshared/lib/data/mapper/payment/payment_response.dart b/frontend/pshared/lib/data/mapper/payment/payment_response.dart index d79fa17f..905db7da 100644 --- a/frontend/pshared/lib/data/mapper/payment/payment_response.dart +++ b/frontend/pshared/lib/data/mapper/payment/payment_response.dart @@ -1,15 +1,16 @@ import 'package:pshared/data/dto/payment/payment.dart'; import 'package:pshared/data/mapper/payment/operation.dart'; import 'package:pshared/data/mapper/payment/quote.dart'; +import 'package:pshared/data/mapper/payment/response_endpoint.dart'; import 'package:pshared/models/payment/payment.dart'; import 'package:pshared/models/payment/state.dart'; - extension PaymentDTOMapper on PaymentDTO { Payment toDomain() => Payment( paymentRef: paymentRef, - idempotencyKey: idempotencyKey, state: state, + source: source?.toDomain(), + destination: destination?.toDomain(), orchestrationState: paymentOrchestrationStateFromValue(state), failureCode: failureCode, failureReason: failureReason, @@ -23,8 +24,9 @@ extension PaymentDTOMapper on PaymentDTO { extension PaymentMapper on Payment { PaymentDTO toDTO() => PaymentDTO( paymentRef: paymentRef, - idempotencyKey: idempotencyKey, state: state ?? paymentOrchestrationStateToValue(orchestrationState), + source: source?.toDTO(), + destination: destination?.toDTO(), failureCode: failureCode, failureReason: failureReason, operations: operations.map((item) => item.toDTO()).toList(), diff --git a/frontend/pshared/lib/data/mapper/payment/response_endpoint.dart b/frontend/pshared/lib/data/mapper/payment/response_endpoint.dart new file mode 100644 index 00000000..28920508 --- /dev/null +++ b/frontend/pshared/lib/data/mapper/payment/response_endpoint.dart @@ -0,0 +1,66 @@ +import 'package:pshared/data/dto/payment/endpoint.dart'; +import 'package:pshared/data/dto/payment/response_endpoint.dart'; +import 'package:pshared/data/mapper/payment/payment.dart'; +import 'package:pshared/models/payment/endpoint.dart'; +import 'package:pshared/models/payment/methods/data.dart'; + +extension PaymentResponseEndpointDTOMapper on PaymentResponseEndpointDTO { + PaymentEndpoint toDomain() { + final normalizedType = _normalize(type); + final normalizedData = _cloneData(data); + + return PaymentEndpoint( + method: _tryParseMethod(normalizedType, normalizedData), + paymentMethodRef: _normalize(paymentMethodRef), + payeeRef: _normalize(payeeRef), + type: normalizedType, + rawData: normalizedData, + ); + } +} + +extension PaymentEndpointMapper on PaymentEndpoint { + PaymentResponseEndpointDTO toDTO() { + final methodData = method; + if (methodData != null) { + final endpoint = methodData.toDTO(); + return PaymentResponseEndpointDTO( + type: endpoint.type, + data: endpoint.data, + paymentMethodRef: _normalize(paymentMethodRef), + payeeRef: _normalize(payeeRef), + ); + } + + return PaymentResponseEndpointDTO( + type: _normalize(type), + data: _cloneData(rawData), + paymentMethodRef: _normalize(paymentMethodRef), + payeeRef: _normalize(payeeRef), + ); + } +} + +PaymentMethodData? _tryParseMethod(String? type, Map? data) { + if (type == null || data == null) { + return null; + } + + try { + return PaymentEndpointDTO(type: type, data: data).toDomain(); + } catch (_) { + return null; + } +} + +String? _normalize(String? value) { + final trimmed = value?.trim(); + if (trimmed == null || trimmed.isEmpty) return null; + return trimmed; +} + +Map? _cloneData(Map? data) { + if (data == null) return null; + if (data.isEmpty) return {}; + return Map.from(data); +} diff --git a/frontend/pshared/lib/models/payment/endpoint.dart b/frontend/pshared/lib/models/payment/endpoint.dart new file mode 100644 index 00000000..64c6f5e9 --- /dev/null +++ b/frontend/pshared/lib/models/payment/endpoint.dart @@ -0,0 +1,17 @@ +import 'package:pshared/models/payment/methods/data.dart'; + +class PaymentEndpoint { + final PaymentMethodData? method; + final String? paymentMethodRef; + final String? payeeRef; + final String? type; + final Map? rawData; + + const PaymentEndpoint({ + required this.method, + required this.paymentMethodRef, + required this.payeeRef, + required this.type, + required this.rawData, + }); +} diff --git a/frontend/pshared/lib/models/payment/payment.dart b/frontend/pshared/lib/models/payment/payment.dart index 90dc2156..69e4c469 100644 --- a/frontend/pshared/lib/models/payment/payment.dart +++ b/frontend/pshared/lib/models/payment/payment.dart @@ -1,11 +1,13 @@ +import 'package:pshared/models/payment/endpoint.dart'; import 'package:pshared/models/payment/execution_operation.dart'; import 'package:pshared/models/payment/quote/quote.dart'; import 'package:pshared/models/payment/state.dart'; class Payment { final String? paymentRef; - final String? idempotencyKey; final String? state; + final PaymentEndpoint? source; + final PaymentEndpoint? destination; final PaymentOrchestrationState orchestrationState; final String? failureCode; final String? failureReason; @@ -16,8 +18,9 @@ class Payment { const Payment({ required this.paymentRef, - required this.idempotencyKey, required this.state, + required this.source, + required this.destination, required this.orchestrationState, required this.failureCode, required this.failureReason, diff --git a/frontend/pshared/lib/provider/payment/payments.dart b/frontend/pshared/lib/provider/payment/payments.dart index f78e2209..341be14d 100644 --- a/frontend/pshared/lib/provider/payment/payments.dart +++ b/frontend/pshared/lib/provider/payment/payments.dart @@ -254,9 +254,7 @@ class PaymentsProvider with ChangeNotifier { } String? _paymentKey(Payment payment) { - final ref = _normalize(payment.paymentRef); - if (ref != null) return ref; - return _normalize(payment.idempotencyKey); + return _normalize(payment.paymentRef); } List? _normalizeStates(List? states) { diff --git a/frontend/pshared/lib/provider/payment/updates.dart b/frontend/pshared/lib/provider/payment/updates.dart index dbaaad38..99a01e4c 100644 --- a/frontend/pshared/lib/provider/payment/updates.dart +++ b/frontend/pshared/lib/provider/payment/updates.dart @@ -79,8 +79,6 @@ class PaymentsUpdatesProvider extends ChangeNotifier { String? _key(Payment payment) { final ref = payment.paymentRef?.trim(); if (ref != null && ref.isNotEmpty) return ref; - final idempotency = payment.idempotencyKey?.trim(); - if (idempotency != null && idempotency.isNotEmpty) return idempotency; return null; } diff --git a/frontend/pshared/test/payment/payment_state_model_test.dart b/frontend/pshared/test/payment/payment_state_model_test.dart index 5797458d..b16cff74 100644 --- a/frontend/pshared/test/payment/payment_state_model_test.dart +++ b/frontend/pshared/test/payment/payment_state_model_test.dart @@ -64,8 +64,9 @@ void main() { test('isPending and isTerminal are derived from typed state', () { const created = Payment( paymentRef: 'p-1', - idempotencyKey: 'idem-1', state: 'orchestration_state_created', + source: null, + destination: null, orchestrationState: PaymentOrchestrationState.created, failureCode: null, failureReason: null, @@ -76,8 +77,9 @@ void main() { ); const settled = Payment( paymentRef: 'p-2', - idempotencyKey: 'idem-2', state: 'orchestration_state_settled', + source: null, + destination: null, orchestrationState: PaymentOrchestrationState.settled, failureCode: null, failureReason: null, @@ -96,8 +98,9 @@ void main() { test('isFailure handles both explicit code and failed state', () { const withFailureCode = Payment( paymentRef: 'p-3', - idempotencyKey: 'idem-3', state: 'orchestration_state_executing', + source: null, + destination: null, orchestrationState: PaymentOrchestrationState.executing, failureCode: 'failure_ledger', failureReason: 'ledger failed', @@ -108,8 +111,9 @@ void main() { ); const failedState = Payment( paymentRef: 'p-4', - idempotencyKey: 'idem-4', state: 'orchestration_state_failed', + source: null, + destination: null, orchestrationState: PaymentOrchestrationState.failed, failureCode: null, failureReason: null, diff --git a/frontend/pshared/test/payment/request_dto_format_test.dart b/frontend/pshared/test/payment/request_dto_format_test.dart index f584b1d5..06eff0e2 100644 --- a/frontend/pshared/test/payment/request_dto_format_test.dart +++ b/frontend/pshared/test/payment/request_dto_format_test.dart @@ -5,6 +5,7 @@ import 'package:test/test.dart'; import 'package:pshared/api/requests/payment/initiate.dart'; import 'package:pshared/api/requests/payment/initiate_payments.dart'; import 'package:pshared/api/requests/payment/quote.dart'; +import 'package:pshared/api/responses/payment/payment.dart'; import 'package:pshared/api/responses/payment/quotation.dart'; import 'package:pshared/data/dto/money.dart'; import 'package:pshared/data/dto/payment/currency_pair.dart'; @@ -12,11 +13,13 @@ import 'package:pshared/data/dto/payment/endpoint.dart'; import 'package:pshared/data/dto/payment/intent/fx.dart'; import 'package:pshared/data/dto/payment/intent/payment.dart'; import 'package:pshared/data/mapper/payment/payment.dart'; +import 'package:pshared/data/mapper/payment/payment_response.dart'; import 'package:pshared/models/payment/asset.dart'; import 'package:pshared/models/payment/chain_network.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/managed_wallet.dart'; +import 'package:pshared/models/payment/methods/wallet.dart'; void main() { group('Payment request DTO contract', () { @@ -185,5 +188,38 @@ void main() { expect(json.containsKey('intentRef'), isFalse); expect(json.containsKey('intentRefs'), isFalse); }); + + test( + 'payment response parses source and destination endpoint snapshots', + () { + final response = PaymentResponse.fromJson({ + 'accessToken': { + 'token': 'token', + 'expiration': '2026-02-25T00:00:00Z', + }, + 'payment': { + 'paymentRef': 'pay-1', + 'state': 'orchestration_state_created', + 'source': { + 'type': 'wallet', + 'data': {'walletId': 'wallet-1'}, + }, + 'destination': {'paymentMethodRef': 'pm-123'}, + 'operations': [], + 'meta': {'quotationRef': 'quote-1'}, + }, + }); + + final payment = response.payment.toDomain(); + expect(payment.paymentRef, equals('pay-1')); + expect(payment.source, isNotNull); + expect(payment.destination, isNotNull); + expect(payment.destination?.paymentMethodRef, equals('pm-123')); + expect(payment.source?.method, isA()); + + final sourceMethod = payment.source?.method as WalletPaymentMethod; + expect(sourceMethod.walletId, equals('wallet-1')); + }, + ); }); } diff --git a/frontend/pweb/lib/controllers/payments/details.dart b/frontend/pweb/lib/controllers/payments/details.dart index 2258a203..8108a742 100644 --- a/frontend/pweb/lib/controllers/payments/details.dart +++ b/frontend/pweb/lib/controllers/payments/details.dart @@ -89,7 +89,6 @@ class PaymentDetailsController extends ChangeNotifier { if (trimmed.isEmpty) return null; for (final payment in payments) { if (payment.paymentRef == trimmed) return payment; - if (payment.idempotencyKey == trimmed) return payment; } return null; } diff --git a/frontend/pweb/lib/utils/report/payment_mapper.dart b/frontend/pweb/lib/utils/report/payment_mapper.dart index f41027c2..ab054146 100644 --- a/frontend/pweb/lib/utils/report/payment_mapper.dart +++ b/frontend/pweb/lib/utils/report/payment_mapper.dart @@ -19,15 +19,9 @@ OperationItem mapPaymentToOperation(Payment payment) { : parseMoneyAmount(settlement.amount); final toCurrency = settlement?.currency ?? currency; - final payId = - _firstNonEmpty([payment.paymentRef, payment.idempotencyKey]) ?? '-'; + final payId = _firstNonEmpty([payment.paymentRef]) ?? '-'; final name = - _firstNonEmpty([ - payment.lastQuote?.quoteRef, - payment.paymentRef, - payment.idempotencyKey, - ]) ?? - '-'; + _firstNonEmpty([payment.lastQuote?.quoteRef, payment.paymentRef]) ?? '-'; final comment = _firstNonEmpty([ payment.failureReason, diff --git a/interface/models/payment/payment.yaml b/interface/models/payment/payment.yaml index cd5604b1..c8f0b3bc 100644 --- a/interface/models/payment/payment.yaml +++ b/interface/models/payment/payment.yaml @@ -116,6 +116,51 @@ components: description: Unique identifier of the wallet. type: string + BankAccountEndpoint: + description: Domestic bank-account payout endpoint. + type: object + additionalProperties: false + required: + - recipientName + - inn + - kpp + - bankName + - bik + - accountNumber + - correspondentAccount + properties: + recipientName: + type: string + inn: + type: string + kpp: + type: string + bankName: + type: string + bik: + type: string + accountNumber: + type: string + correspondentAccount: + type: string + + IBANEndpoint: + description: International bank-account payout endpoint. + type: object + additionalProperties: false + required: + - iban + - accountHolder + properties: + iban: + type: string + accountHolder: + type: string + bic: + type: string + bankName: + type: string + Endpoint: description: Polymorphic payment endpoint definition. type: object @@ -142,6 +187,41 @@ components: additionalProperties: type: string + PaymentResponseEndpoint: + description: Endpoint snapshot attached to a persisted payment. + type: object + additionalProperties: false + properties: + paymentMethodRef: + description: Reference to a stored payment method when endpoint is referenced by ID. + type: string + payeeRef: + description: Reference to a payee profile when endpoint is resolved by payee. + type: string + type: + description: Endpoint type for inline endpoint snapshots. + type: string + enum: + - ledger + - managedWallet + - cryptoAddress + - card + - cardToken + - wallet + - bankAccount + - iban + data: + description: Inline endpoint payload snapshot; shape depends on `type`. + oneOf: + - $ref: ./payment.yaml#/components/schemas/LedgerEndpoint + - $ref: ./payment.yaml#/components/schemas/ManagedWalletEndpoint + - $ref: ./payment.yaml#/components/schemas/ExternalChainEndpoint + - $ref: ./payment.yaml#/components/schemas/CardEndpoint + - $ref: ./payment.yaml#/components/schemas/CardTokenEndpoint + - $ref: ./payment.yaml#/components/schemas/WalletEndpoint + - $ref: ./payment.yaml#/components/schemas/BankAccountEndpoint + - $ref: ./payment.yaml#/components/schemas/IBANEndpoint + Customer: description: Customer identity and address attributes for compliance and routing. type: object @@ -262,6 +342,9 @@ components: type: object additionalProperties: type: string + comment: + description: Optional free-form comment attached to the payment intent. + type: string customer: description: Optional customer information attached to the payment intent. $ref: ./payment.yaml#/components/schemas/Customer @@ -432,12 +515,18 @@ components: paymentRef: description: Unique payment reference identifier. type: string - idempotencyKey: - description: Idempotency key used to safely deduplicate create requests. - type: string state: description: Current lifecycle state of the payment. $ref: ../../external/payment_state.yaml#/components/schemas/PaymentState + comment: + description: Optional comment copied from the original payment intent. + type: string + source: + description: Source endpoint snapshot captured from intent. + $ref: ./payment.yaml#/components/schemas/PaymentResponseEndpoint + destination: + description: Destination endpoint snapshot captured from intent. + $ref: ./payment.yaml#/components/schemas/PaymentResponseEndpoint failureCode: description: Failure code set when the payment cannot be completed. type: string