+source +destination in payments

This commit is contained in:
Stephan D
2026-03-10 19:15:20 +01:00
parent 9c2b3bf8bd
commit e5b4de5d48
16 changed files with 716 additions and 56 deletions

View File

@@ -14,8 +14,11 @@ import (
gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1" gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1"
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1" paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/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" orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/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" "google.golang.org/protobuf/types/known/timestamppb"
) )
@@ -67,16 +70,24 @@ type PaymentQuotes struct {
} }
type Payment struct { type Payment struct {
PaymentRef string `json:"paymentRef,omitempty"` PaymentRef string `json:"paymentRef,omitempty"`
IdempotencyKey string `json:"idempotencyKey,omitempty"` State string `json:"state,omitempty"`
State string `json:"state,omitempty"` Comment string `json:"comment,omitempty"`
Comment string `json:"comment,omitempty"` Source *PaymentEndpoint `json:"source"`
FailureCode string `json:"failureCode,omitempty"` Destination *PaymentEndpoint `json:"destination"`
FailureReason string `json:"failureReason,omitempty"` FailureCode string `json:"failureCode,omitempty"`
Operations []PaymentOperation `json:"operations,omitempty"` FailureReason string `json:"failureReason,omitempty"`
LastQuote *PaymentQuote `json:"lastQuote,omitempty"` Operations []PaymentOperation `json:"operations,omitempty"`
CreatedAt time.Time `json:"createdAt,omitempty"` LastQuote *PaymentQuote `json:"lastQuote,omitempty"`
Meta map[string]string `json:"meta,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 { type PaymentOperation struct {
@@ -290,22 +301,257 @@ func toPayment(p *orchestrationv2.Payment) *Payment {
if p == nil { if p == nil {
return nil return nil
} }
intent := p.GetIntentSnapshot()
operations := toUserVisibleOperations(p.GetStepExecutions(), p.GetQuoteSnapshot()) operations := toUserVisibleOperations(p.GetStepExecutions(), p.GetQuoteSnapshot())
failureCode, failureReason := firstFailure(operations) failureCode, failureReason := firstFailure(operations)
return &Payment{ return &Payment{
PaymentRef: p.GetPaymentRef(), PaymentRef: p.GetPaymentRef(),
State: enumJSONName(p.GetState().String()), State: enumJSONName(p.GetState().String()),
Comment: strings.TrimSpace(p.GetIntentSnapshot().GetComment()), Comment: strings.TrimSpace(intent.GetComment()),
FailureCode: failureCode, Source: toPaymentEndpoint(intent.GetSource()),
FailureReason: failureReason, Destination: toPaymentEndpoint(intent.GetDestination()),
Operations: operations, FailureCode: failureCode,
LastQuote: toPaymentQuote(p.GetQuoteSnapshot()), FailureReason: failureReason,
CreatedAt: timestampAsTime(p.GetCreatedAt()), Operations: operations,
Meta: paymentMeta(p), LastQuote: toPaymentQuote(p.GetQuoteSnapshot()),
IdempotencyKey: "", 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) { func firstFailure(operations []PaymentOperation) (string, string) {
for _, op := range operations { for _, op := range operations {
if strings.TrimSpace(op.FailureCode) == "" && strings.TrimSpace(op.FailureReason) == "" { if strings.TrimSpace(op.FailureCode) == "" && strings.TrimSpace(op.FailureReason) == "" {

View File

@@ -5,9 +5,12 @@ import (
gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1" gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/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" orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1" 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) { 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: &quotationv2.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: &quotationv2.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) { func TestToPaymentQuote_MapsIntentRef(t *testing.T) {
dto := toPaymentQuote(&quotationv2.PaymentQuote{ dto := toPaymentQuote(&quotationv2.PaymentQuote{
QuoteRef: "quote-1", QuoteRef: "quote-1",

View File

@@ -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/operation.dart';
import 'package:pshared/data/dto/payment/payment_quote.dart'; import 'package:pshared/data/dto/payment/payment_quote.dart';
import 'package:pshared/data/dto/payment/response_endpoint.dart';
part 'payment.g.dart'; part 'payment.g.dart';
@JsonSerializable() @JsonSerializable()
class PaymentDTO { class PaymentDTO {
final String? paymentRef; final String? paymentRef;
final String? idempotencyKey;
final String? state; final String? state;
final PaymentResponseEndpointDTO? source;
final PaymentResponseEndpointDTO? destination;
final String? failureCode; final String? failureCode;
final String? failureReason; final String? failureReason;
final List<PaymentOperationDTO> operations; final List<PaymentOperationDTO> operations;
@@ -20,8 +21,9 @@ class PaymentDTO {
const PaymentDTO({ const PaymentDTO({
this.paymentRef, this.paymentRef,
this.idempotencyKey,
this.state, this.state,
this.source,
this.destination,
this.failureCode, this.failureCode,
this.failureReason, this.failureReason,
this.operations = const <PaymentOperationDTO>[], this.operations = const <PaymentOperationDTO>[],

View File

@@ -0,0 +1,22 @@
import 'package:json_annotation/json_annotation.dart';
part 'response_endpoint.g.dart';
@JsonSerializable()
class PaymentResponseEndpointDTO {
final String? type;
final Map<String, dynamic>? data;
final String? paymentMethodRef;
final String? payeeRef;
const PaymentResponseEndpointDTO({
this.type,
this.data,
this.paymentMethodRef,
this.payeeRef,
});
factory PaymentResponseEndpointDTO.fromJson(Map<String, dynamic> json) =>
_$PaymentResponseEndpointDTOFromJson(json);
Map<String, dynamic> toJson() => _$PaymentResponseEndpointDTOToJson(this);
}

View File

@@ -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/card_token.dart';
import 'package:pshared/data/dto/payment/endpoint.dart'; import 'package:pshared/data/dto/payment/endpoint.dart';
import 'package:pshared/data/dto/payment/external_chain.dart'; import 'package:pshared/data/dto/payment/external_chain.dart';
import 'package:pshared/data/dto/payment/iban.dart';
import 'package:pshared/data/dto/payment/ledger.dart'; import 'package:pshared/data/dto/payment/ledger.dart';
import 'package:pshared/data/dto/payment/managed_wallet.dart'; import 'package:pshared/data/dto/payment/managed_wallet.dart';
import 'package:pshared/data/dto/payment/russian_bank.dart';
import 'package:pshared/data/dto/payment/wallet.dart';
import 'package:pshared/data/mapper/payment/asset.dart'; import 'package:pshared/data/mapper/payment/asset.dart';
import 'package:pshared/data/mapper/payment/enums.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.dart';
import 'package:pshared/models/payment/methods/card_token.dart'; import 'package:pshared/models/payment/methods/card_token.dart';
import 'package:pshared/models/payment/methods/crypto_address.dart'; import 'package:pshared/models/payment/methods/crypto_address.dart';
import 'package:pshared/models/payment/methods/data.dart'; import 'package:pshared/models/payment/methods/data.dart';
import 'package:pshared/models/payment/methods/iban.dart';
import 'package:pshared/models/payment/methods/ledger.dart'; import 'package:pshared/models/payment/methods/ledger.dart';
import 'package:pshared/models/payment/methods/managed_wallet.dart'; import 'package:pshared/models/payment/methods/managed_wallet.dart';
import 'package:pshared/models/payment/methods/russian_bank.dart';
import 'package:pshared/models/payment/methods/wallet.dart';
import 'package:pshared/models/payment/type.dart'; import 'package:pshared/models/payment/type.dart';
extension PaymentMethodDataEndpointMapper on PaymentMethodData { extension PaymentMethodDataEndpointMapper on PaymentMethodData {
PaymentEndpointDTO toDTO() { PaymentEndpointDTO toDTO() {
final metadata = this.metadata; final metadata = this.metadata;
@@ -76,8 +80,40 @@ extension PaymentMethodDataEndpointMapper on PaymentMethodData {
).toJson(), ).toJson(),
metadata: metadata, metadata: metadata,
); );
default: case PaymentType.wallet:
throw UnsupportedError('Unsupported payment endpoint type: $type'); 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, maskedPan: payload.maskedPan,
metadata: metadata, metadata: metadata,
); );
default: case PaymentType.wallet:
throw UnsupportedError('Unsupported payment endpoint type: ${paymentTypeFromValue(type)}'); 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<String, dynamic> data) { PaymentType _resolveEndpointType(String type, Map<String, dynamic> 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 PaymentType.cardToken;
} }
return endpointTypeFromValue(type); return endpointTypeFromValue(type);

View File

@@ -1,15 +1,16 @@
import 'package:pshared/data/dto/payment/payment.dart'; import 'package:pshared/data/dto/payment/payment.dart';
import 'package:pshared/data/mapper/payment/operation.dart'; import 'package:pshared/data/mapper/payment/operation.dart';
import 'package:pshared/data/mapper/payment/quote.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/payment.dart';
import 'package:pshared/models/payment/state.dart'; import 'package:pshared/models/payment/state.dart';
extension PaymentDTOMapper on PaymentDTO { extension PaymentDTOMapper on PaymentDTO {
Payment toDomain() => Payment( Payment toDomain() => Payment(
paymentRef: paymentRef, paymentRef: paymentRef,
idempotencyKey: idempotencyKey,
state: state, state: state,
source: source?.toDomain(),
destination: destination?.toDomain(),
orchestrationState: paymentOrchestrationStateFromValue(state), orchestrationState: paymentOrchestrationStateFromValue(state),
failureCode: failureCode, failureCode: failureCode,
failureReason: failureReason, failureReason: failureReason,
@@ -23,8 +24,9 @@ extension PaymentDTOMapper on PaymentDTO {
extension PaymentMapper on Payment { extension PaymentMapper on Payment {
PaymentDTO toDTO() => PaymentDTO( PaymentDTO toDTO() => PaymentDTO(
paymentRef: paymentRef, paymentRef: paymentRef,
idempotencyKey: idempotencyKey,
state: state ?? paymentOrchestrationStateToValue(orchestrationState), state: state ?? paymentOrchestrationStateToValue(orchestrationState),
source: source?.toDTO(),
destination: destination?.toDTO(),
failureCode: failureCode, failureCode: failureCode,
failureReason: failureReason, failureReason: failureReason,
operations: operations.map((item) => item.toDTO()).toList(), operations: operations.map((item) => item.toDTO()).toList(),

View File

@@ -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<String, dynamic>? 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<String, dynamic>? _cloneData(Map<String, dynamic>? data) {
if (data == null) return null;
if (data.isEmpty) return <String, dynamic>{};
return Map<String, dynamic>.from(data);
}

View File

@@ -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<String, dynamic>? rawData;
const PaymentEndpoint({
required this.method,
required this.paymentMethodRef,
required this.payeeRef,
required this.type,
required this.rawData,
});
}

View File

@@ -1,11 +1,13 @@
import 'package:pshared/models/payment/endpoint.dart';
import 'package:pshared/models/payment/execution_operation.dart'; import 'package:pshared/models/payment/execution_operation.dart';
import 'package:pshared/models/payment/quote/quote.dart'; import 'package:pshared/models/payment/quote/quote.dart';
import 'package:pshared/models/payment/state.dart'; import 'package:pshared/models/payment/state.dart';
class Payment { class Payment {
final String? paymentRef; final String? paymentRef;
final String? idempotencyKey;
final String? state; final String? state;
final PaymentEndpoint? source;
final PaymentEndpoint? destination;
final PaymentOrchestrationState orchestrationState; final PaymentOrchestrationState orchestrationState;
final String? failureCode; final String? failureCode;
final String? failureReason; final String? failureReason;
@@ -16,8 +18,9 @@ class Payment {
const Payment({ const Payment({
required this.paymentRef, required this.paymentRef,
required this.idempotencyKey,
required this.state, required this.state,
required this.source,
required this.destination,
required this.orchestrationState, required this.orchestrationState,
required this.failureCode, required this.failureCode,
required this.failureReason, required this.failureReason,

View File

@@ -254,9 +254,7 @@ class PaymentsProvider with ChangeNotifier {
} }
String? _paymentKey(Payment payment) { String? _paymentKey(Payment payment) {
final ref = _normalize(payment.paymentRef); return _normalize(payment.paymentRef);
if (ref != null) return ref;
return _normalize(payment.idempotencyKey);
} }
List<String>? _normalizeStates(List<String>? states) { List<String>? _normalizeStates(List<String>? states) {

View File

@@ -79,8 +79,6 @@ class PaymentsUpdatesProvider extends ChangeNotifier {
String? _key(Payment payment) { String? _key(Payment payment) {
final ref = payment.paymentRef?.trim(); final ref = payment.paymentRef?.trim();
if (ref != null && ref.isNotEmpty) return ref; if (ref != null && ref.isNotEmpty) return ref;
final idempotency = payment.idempotencyKey?.trim();
if (idempotency != null && idempotency.isNotEmpty) return idempotency;
return null; return null;
} }

View File

@@ -64,8 +64,9 @@ void main() {
test('isPending and isTerminal are derived from typed state', () { test('isPending and isTerminal are derived from typed state', () {
const created = Payment( const created = Payment(
paymentRef: 'p-1', paymentRef: 'p-1',
idempotencyKey: 'idem-1',
state: 'orchestration_state_created', state: 'orchestration_state_created',
source: null,
destination: null,
orchestrationState: PaymentOrchestrationState.created, orchestrationState: PaymentOrchestrationState.created,
failureCode: null, failureCode: null,
failureReason: null, failureReason: null,
@@ -76,8 +77,9 @@ void main() {
); );
const settled = Payment( const settled = Payment(
paymentRef: 'p-2', paymentRef: 'p-2',
idempotencyKey: 'idem-2',
state: 'orchestration_state_settled', state: 'orchestration_state_settled',
source: null,
destination: null,
orchestrationState: PaymentOrchestrationState.settled, orchestrationState: PaymentOrchestrationState.settled,
failureCode: null, failureCode: null,
failureReason: null, failureReason: null,
@@ -96,8 +98,9 @@ void main() {
test('isFailure handles both explicit code and failed state', () { test('isFailure handles both explicit code and failed state', () {
const withFailureCode = Payment( const withFailureCode = Payment(
paymentRef: 'p-3', paymentRef: 'p-3',
idempotencyKey: 'idem-3',
state: 'orchestration_state_executing', state: 'orchestration_state_executing',
source: null,
destination: null,
orchestrationState: PaymentOrchestrationState.executing, orchestrationState: PaymentOrchestrationState.executing,
failureCode: 'failure_ledger', failureCode: 'failure_ledger',
failureReason: 'ledger failed', failureReason: 'ledger failed',
@@ -108,8 +111,9 @@ void main() {
); );
const failedState = Payment( const failedState = Payment(
paymentRef: 'p-4', paymentRef: 'p-4',
idempotencyKey: 'idem-4',
state: 'orchestration_state_failed', state: 'orchestration_state_failed',
source: null,
destination: null,
orchestrationState: PaymentOrchestrationState.failed, orchestrationState: PaymentOrchestrationState.failed,
failureCode: null, failureCode: null,
failureReason: null, failureReason: null,

View File

@@ -5,6 +5,7 @@ import 'package:test/test.dart';
import 'package:pshared/api/requests/payment/initiate.dart'; import 'package:pshared/api/requests/payment/initiate.dart';
import 'package:pshared/api/requests/payment/initiate_payments.dart'; import 'package:pshared/api/requests/payment/initiate_payments.dart';
import 'package:pshared/api/requests/payment/quote.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/api/responses/payment/quotation.dart';
import 'package:pshared/data/dto/money.dart'; import 'package:pshared/data/dto/money.dart';
import 'package:pshared/data/dto/payment/currency_pair.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/fx.dart';
import 'package:pshared/data/dto/payment/intent/payment.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.dart';
import 'package:pshared/data/mapper/payment/payment_response.dart';
import 'package:pshared/models/payment/asset.dart'; import 'package:pshared/models/payment/asset.dart';
import 'package:pshared/models/payment/chain_network.dart'; import 'package:pshared/models/payment/chain_network.dart';
import 'package:pshared/models/payment/methods/card_token.dart'; import 'package:pshared/models/payment/methods/card_token.dart';
import 'package:pshared/models/payment/methods/crypto_address.dart'; import 'package:pshared/models/payment/methods/crypto_address.dart';
import 'package:pshared/models/payment/methods/managed_wallet.dart'; import 'package:pshared/models/payment/methods/managed_wallet.dart';
import 'package:pshared/models/payment/methods/wallet.dart';
void main() { void main() {
group('Payment request DTO contract', () { group('Payment request DTO contract', () {
@@ -185,5 +188,38 @@ void main() {
expect(json.containsKey('intentRef'), isFalse); expect(json.containsKey('intentRef'), isFalse);
expect(json.containsKey('intentRefs'), 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<WalletPaymentMethod>());
final sourceMethod = payment.source?.method as WalletPaymentMethod;
expect(sourceMethod.walletId, equals('wallet-1'));
},
);
}); });
} }

View File

@@ -89,7 +89,6 @@ class PaymentDetailsController extends ChangeNotifier {
if (trimmed.isEmpty) return null; if (trimmed.isEmpty) return null;
for (final payment in payments) { for (final payment in payments) {
if (payment.paymentRef == trimmed) return payment; if (payment.paymentRef == trimmed) return payment;
if (payment.idempotencyKey == trimmed) return payment;
} }
return null; return null;
} }

View File

@@ -19,15 +19,9 @@ OperationItem mapPaymentToOperation(Payment payment) {
: parseMoneyAmount(settlement.amount); : parseMoneyAmount(settlement.amount);
final toCurrency = settlement?.currency ?? currency; final toCurrency = settlement?.currency ?? currency;
final payId = final payId = _firstNonEmpty([payment.paymentRef]) ?? '-';
_firstNonEmpty([payment.paymentRef, payment.idempotencyKey]) ?? '-';
final name = final name =
_firstNonEmpty([ _firstNonEmpty([payment.lastQuote?.quoteRef, payment.paymentRef]) ?? '-';
payment.lastQuote?.quoteRef,
payment.paymentRef,
payment.idempotencyKey,
]) ??
'-';
final comment = final comment =
_firstNonEmpty([ _firstNonEmpty([
payment.failureReason, payment.failureReason,

View File

@@ -116,6 +116,51 @@ components:
description: Unique identifier of the wallet. description: Unique identifier of the wallet.
type: string 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: Endpoint:
description: Polymorphic payment endpoint definition. description: Polymorphic payment endpoint definition.
type: object type: object
@@ -142,6 +187,41 @@ components:
additionalProperties: additionalProperties:
type: string 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: Customer:
description: Customer identity and address attributes for compliance and routing. description: Customer identity and address attributes for compliance and routing.
type: object type: object
@@ -262,6 +342,9 @@ components:
type: object type: object
additionalProperties: additionalProperties:
type: string type: string
comment:
description: Optional free-form comment attached to the payment intent.
type: string
customer: customer:
description: Optional customer information attached to the payment intent. description: Optional customer information attached to the payment intent.
$ref: ./payment.yaml#/components/schemas/Customer $ref: ./payment.yaml#/components/schemas/Customer
@@ -432,12 +515,18 @@ components:
paymentRef: paymentRef:
description: Unique payment reference identifier. description: Unique payment reference identifier.
type: string type: string
idempotencyKey:
description: Idempotency key used to safely deduplicate create requests.
type: string
state: state:
description: Current lifecycle state of the payment. description: Current lifecycle state of the payment.
$ref: ../../external/payment_state.yaml#/components/schemas/PaymentState $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: failureCode:
description: Failure code set when the payment cannot be completed. description: Failure code set when the payment cannot be completed.
type: string type: string