diff --git a/api/payments/orchestrator/internal/service/orchestrator/card_payout.go b/api/payments/orchestrator/internal/service/orchestrator/card_payout.go index 29bc8fc..d418c66 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/card_payout.go +++ b/api/payments/orchestrator/internal/service/orchestrator/card_payout.go @@ -313,6 +313,47 @@ func (s *Service) submitCardPayout(ctx context.Context, payment *model.Payment) currency := strings.TrimSpace(amount.GetCurrency()) holder := strings.TrimSpace(card.Cardholder) meta := cloneMetadata(payment.Metadata) + customer := intent.Customer + customerID := "" + customerFirstName := "" + customerMiddleName := "" + customerLastName := "" + customerIP := "" + customerZip := "" + customerCountry := "" + customerState := "" + customerCity := "" + customerAddress := "" + if customer != nil { + customerID = strings.TrimSpace(customer.ID) + customerFirstName = strings.TrimSpace(customer.FirstName) + customerMiddleName = strings.TrimSpace(customer.MiddleName) + customerLastName = strings.TrimSpace(customer.LastName) + customerIP = strings.TrimSpace(customer.IP) + customerZip = strings.TrimSpace(customer.Zip) + customerCountry = strings.TrimSpace(customer.Country) + customerState = strings.TrimSpace(customer.State) + customerCity = strings.TrimSpace(customer.City) + customerAddress = strings.TrimSpace(customer.Address) + } + if customerFirstName == "" { + customerFirstName = strings.TrimSpace(card.Cardholder) + } + if customerLastName == "" { + customerLastName = strings.TrimSpace(card.CardholderSurname) + } + if customerID == "" { + return merrors.InvalidArgument("card payout: customer id is required") + } + if customerFirstName == "" { + return merrors.InvalidArgument("card payout: customer first name is required") + } + if customerLastName == "" { + return merrors.InvalidArgument("card payout: customer last name is required") + } + if customerIP == "" { + return merrors.InvalidArgument("card payout: customer ip is required") + } var ( state *mntxv1.CardPayoutState @@ -320,13 +361,23 @@ func (s *Service) submitCardPayout(ctx context.Context, payment *model.Payment) if token := strings.TrimSpace(card.Token); token != "" { req := &mntxv1.CardTokenPayoutRequest{ - PayoutId: payoutID, - AmountMinor: minor, - Currency: currency, - CardToken: token, - CardHolder: holder, - MaskedPan: strings.TrimSpace(card.MaskedPan), - Metadata: meta, + PayoutId: payoutID, + CustomerId: customerID, + CustomerFirstName: customerFirstName, + CustomerMiddleName: customerMiddleName, + CustomerLastName: customerLastName, + CustomerIp: customerIP, + CustomerZip: customerZip, + CustomerCountry: customerCountry, + CustomerState: customerState, + CustomerCity: customerCity, + CustomerAddress: customerAddress, + AmountMinor: minor, + Currency: currency, + CardToken: token, + CardHolder: holder, + MaskedPan: strings.TrimSpace(card.MaskedPan), + Metadata: meta, } resp, err := s.deps.mntx.client.CreateCardTokenPayout(ctx, req) if err != nil { @@ -336,14 +387,24 @@ func (s *Service) submitCardPayout(ctx context.Context, payment *model.Payment) state = resp.GetPayout() } else if pan := strings.TrimSpace(card.Pan); pan != "" { req := &mntxv1.CardPayoutRequest{ - PayoutId: payoutID, - AmountMinor: minor, - Currency: currency, - CardPan: pan, - CardExpYear: card.ExpYear, - CardExpMonth: card.ExpMonth, - CardHolder: holder, - Metadata: meta, + PayoutId: payoutID, + CustomerId: customerID, + CustomerFirstName: customerFirstName, + CustomerMiddleName: customerMiddleName, + CustomerLastName: customerLastName, + CustomerIp: customerIP, + CustomerZip: customerZip, + CustomerCountry: customerCountry, + CustomerState: customerState, + CustomerCity: customerCity, + CustomerAddress: customerAddress, + AmountMinor: minor, + Currency: currency, + CardPan: pan, + CardExpYear: card.ExpYear, + CardExpMonth: card.ExpMonth, + CardHolder: holder, + Metadata: meta, } resp, err := s.deps.mntx.client.CreateCardPayout(ctx, req) if err != nil { diff --git a/api/payments/orchestrator/internal/service/orchestrator/card_payout_test.go b/api/payments/orchestrator/internal/service/orchestrator/card_payout_test.go index 72bea5b..e5b807d 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/card_payout_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/card_payout_test.go @@ -266,6 +266,12 @@ func TestSubmitCardPayout_UsesSettlementAmountAndTransfersFee(t *testing.T) { }, }, Amount: &moneyv1.Money{Currency: "USDT", Amount: "5"}, + Customer: &model.Customer{ + ID: "recipient-1", + FirstName: "Stephan", + LastName: "Tester", + IP: "198.51.100.10", + }, }, LastQuote: &model.PaymentQuoteSnapshot{ ExpectedSettlementAmount: &moneyv1.Money{Currency: "RUB", Amount: "392.30"}, diff --git a/api/payments/orchestrator/internal/service/orchestrator/convert.go b/api/payments/orchestrator/internal/service/orchestrator/convert.go index a690b92..7afd0ae 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/convert.go +++ b/api/payments/orchestrator/internal/service/orchestrator/convert.go @@ -26,6 +26,7 @@ func intentFromProto(src *orchestratorv1.PaymentIntent) model.PaymentIntent { FeePolicy: src.GetFeePolicy(), SettlementMode: src.GetSettlementMode(), Attributes: cloneMetadata(src.GetAttributes()), + Customer: customerFromProto(src.GetCustomer()), } if src.GetFx() != nil { intent.FX = fxIntentFromProto(src.GetFx()) @@ -69,13 +70,14 @@ func endpointFromProto(src *orchestratorv1.PaymentEndpoint) model.PaymentEndpoin if card := src.GetCard(); card != nil { result.Type = model.EndpointTypeCard result.Card = &model.CardEndpoint{ - Pan: strings.TrimSpace(card.GetPan()), - Token: strings.TrimSpace(card.GetToken()), - Cardholder: strings.TrimSpace(card.GetCardholderName()), - ExpMonth: card.GetExpMonth(), - ExpYear: card.GetExpYear(), - Country: strings.TrimSpace(card.GetCountry()), - MaskedPan: strings.TrimSpace(card.GetMaskedPan()), + Pan: strings.TrimSpace(card.GetPan()), + Token: strings.TrimSpace(card.GetToken()), + Cardholder: strings.TrimSpace(card.GetCardholderName()), + CardholderSurname: strings.TrimSpace(card.GetCardholderSurname()), + ExpMonth: card.GetExpMonth(), + ExpYear: card.GetExpYear(), + Country: strings.TrimSpace(card.GetCountry()), + MaskedPan: strings.TrimSpace(card.GetMaskedPan()), } return result } @@ -161,6 +163,7 @@ func protoIntentFromModel(src model.PaymentIntent) *orchestratorv1.PaymentIntent FeePolicy: src.FeePolicy, SettlementMode: src.SettlementMode, Attributes: cloneMetadata(src.Attributes), + Customer: protoCustomerFromModel(src.Customer), } if src.FX != nil { intent.Fx = protoFXIntentFromModel(src.FX) @@ -168,6 +171,42 @@ func protoIntentFromModel(src model.PaymentIntent) *orchestratorv1.PaymentIntent return intent } +func customerFromProto(src *orchestratorv1.Customer) *model.Customer { + if src == nil { + return nil + } + return &model.Customer{ + ID: strings.TrimSpace(src.GetId()), + FirstName: strings.TrimSpace(src.GetFirstName()), + MiddleName: strings.TrimSpace(src.GetMiddleName()), + LastName: strings.TrimSpace(src.GetLastName()), + IP: strings.TrimSpace(src.GetIp()), + Zip: strings.TrimSpace(src.GetZip()), + Country: strings.TrimSpace(src.GetCountry()), + State: strings.TrimSpace(src.GetState()), + City: strings.TrimSpace(src.GetCity()), + Address: strings.TrimSpace(src.GetAddress()), + } +} + +func protoCustomerFromModel(src *model.Customer) *orchestratorv1.Customer { + if src == nil { + return nil + } + return &orchestratorv1.Customer{ + Id: strings.TrimSpace(src.ID), + FirstName: strings.TrimSpace(src.FirstName), + MiddleName: strings.TrimSpace(src.MiddleName), + LastName: strings.TrimSpace(src.LastName), + Ip: strings.TrimSpace(src.IP), + Zip: strings.TrimSpace(src.Zip), + Country: strings.TrimSpace(src.Country), + State: strings.TrimSpace(src.State), + City: strings.TrimSpace(src.City), + Address: strings.TrimSpace(src.Address), + } +} + func protoEndpointFromModel(src model.PaymentEndpoint) *orchestratorv1.PaymentEndpoint { endpoint := &orchestratorv1.PaymentEndpoint{ Metadata: cloneMetadata(src.Metadata), @@ -204,11 +243,12 @@ func protoEndpointFromModel(src model.PaymentEndpoint) *orchestratorv1.PaymentEn case model.EndpointTypeCard: if src.Card != nil { card := &orchestratorv1.CardEndpoint{ - CardholderName: src.Card.Cardholder, - ExpMonth: src.Card.ExpMonth, - ExpYear: src.Card.ExpYear, - Country: src.Card.Country, - MaskedPan: src.Card.MaskedPan, + CardholderName: src.Card.Cardholder, + CardholderSurname: src.Card.CardholderSurname, + ExpMonth: src.Card.ExpMonth, + ExpYear: src.Card.ExpYear, + Country: src.Card.Country, + MaskedPan: src.Card.MaskedPan, } if pan := strings.TrimSpace(src.Card.Pan); pan != "" { card.Card = &orchestratorv1.CardEndpoint_Pan{Pan: pan} diff --git a/api/payments/orchestrator/internal/service/orchestrator/convert_card_test.go b/api/payments/orchestrator/internal/service/orchestrator/convert_card_test.go index b90a638..5a08cc0 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/convert_card_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/convert_card_test.go @@ -11,12 +11,13 @@ func TestEndpointFromProtoCard(t *testing.T) { protoEndpoint := &orchestratorv1.PaymentEndpoint{ Endpoint: &orchestratorv1.PaymentEndpoint_Card{ Card: &orchestratorv1.CardEndpoint{ - Card: &orchestratorv1.CardEndpoint_Pan{Pan: " 411111 "}, - CardholderName: " Jane Doe ", - ExpMonth: 12, - ExpYear: 2030, - Country: " US ", - MaskedPan: " ****1111 ", + Card: &orchestratorv1.CardEndpoint_Pan{Pan: " 411111 "}, + CardholderName: " Jane ", + CardholderSurname: " Doe ", + ExpMonth: 12, + ExpYear: 2030, + Country: " US ", + MaskedPan: " ****1111 ", }, }, Metadata: map[string]string{"k": "v"}, @@ -29,7 +30,7 @@ func TestEndpointFromProtoCard(t *testing.T) { if modelEndpoint.Card == nil { t.Fatalf("card payload missing") } - if modelEndpoint.Card.Pan != "411111" || modelEndpoint.Card.Cardholder != "Jane Doe" || modelEndpoint.Card.Country != "US" || modelEndpoint.Card.MaskedPan != "****1111" { + if modelEndpoint.Card.Pan != "411111" || modelEndpoint.Card.Cardholder != "Jane" || modelEndpoint.Card.CardholderSurname != "Doe" || modelEndpoint.Card.Country != "US" || modelEndpoint.Card.MaskedPan != "****1111" { t.Fatalf("card payload not trimmed as expected: %#v", modelEndpoint.Card) } if modelEndpoint.Metadata["k"] != "v" { @@ -41,12 +42,13 @@ func TestProtoEndpointFromModelCard(t *testing.T) { modelEndpoint := model.PaymentEndpoint{ Type: model.EndpointTypeCard, Card: &model.CardEndpoint{ - Token: "tok_123", - Cardholder: "Jane", - ExpMonth: 1, - ExpYear: 2028, - Country: "GB", - MaskedPan: "****1234", + Token: "tok_123", + Cardholder: "Jane", + CardholderSurname: "Doe", + ExpMonth: 1, + ExpYear: 2028, + Country: "GB", + MaskedPan: "****1234", }, Metadata: map[string]string{"k": "v"}, } @@ -60,7 +62,7 @@ func TestProtoEndpointFromModelCard(t *testing.T) { if !ok || token.Token != "tok_123" { t.Fatalf("expected token payload, got %T %#v", card.Card, card.Card) } - if card.GetCardholderName() != "Jane" || card.GetCountry() != "GB" || card.GetMaskedPan() != "****1234" { + if card.GetCardholderName() != "Jane" || card.GetCardholderSurname() != "Doe" || card.GetCountry() != "GB" || card.GetMaskedPan() != "****1234" { t.Fatalf("card details mismatch: %#v", card) } if protoEndpoint.GetMetadata()["k"] != "v" { diff --git a/api/payments/orchestrator/storage/model/payment.go b/api/payments/orchestrator/storage/model/payment.go index 510e7ab..12cb97e 100644 --- a/api/payments/orchestrator/storage/model/payment.go +++ b/api/payments/orchestrator/storage/model/payment.go @@ -82,13 +82,14 @@ type ExternalChainEndpoint struct { // CardEndpoint describes a card payout destination. type CardEndpoint struct { - Pan string `bson:"pan,omitempty" json:"pan,omitempty"` - Token string `bson:"token,omitempty" json:"token,omitempty"` - Cardholder string `bson:"cardholder,omitempty" json:"cardholder,omitempty"` - ExpMonth uint32 `bson:"expMonth,omitempty" json:"expMonth,omitempty"` - ExpYear uint32 `bson:"expYear,omitempty" json:"expYear,omitempty"` - Country string `bson:"country,omitempty" json:"country,omitempty"` - MaskedPan string `bson:"maskedPan,omitempty" json:"maskedPan,omitempty"` + Pan string `bson:"pan,omitempty" json:"pan,omitempty"` + Token string `bson:"token,omitempty" json:"token,omitempty"` + Cardholder string `bson:"cardholder,omitempty" json:"cardholder,omitempty"` + CardholderSurname string `bson:"cardholderSurname,omitempty" json:"cardholderSurname,omitempty"` + ExpMonth uint32 `bson:"expMonth,omitempty" json:"expMonth,omitempty"` + ExpYear uint32 `bson:"expYear,omitempty" json:"expYear,omitempty"` + Country string `bson:"country,omitempty" json:"country,omitempty"` + MaskedPan string `bson:"maskedPan,omitempty" json:"maskedPan,omitempty"` } // CardPayout stores gateway payout tracking info. @@ -134,6 +135,21 @@ type PaymentIntent struct { FeePolicy *feesv1.PolicyOverrides `bson:"feePolicy,omitempty" json:"feePolicy,omitempty"` SettlementMode orchestratorv1.SettlementMode `bson:"settlementMode,omitempty" json:"settlementMode,omitempty"` Attributes map[string]string `bson:"attributes,omitempty" json:"attributes,omitempty"` + Customer *Customer `bson:"customer,omitempty" json:"customer,omitempty"` +} + +// Customer captures payer/recipient identity details for downstream processing. +type Customer struct { + ID string `bson:"id,omitempty" json:"id,omitempty"` + FirstName string `bson:"firstName,omitempty" json:"firstName,omitempty"` + MiddleName string `bson:"middleName,omitempty" json:"middleName,omitempty"` + LastName string `bson:"lastName,omitempty" json:"lastName,omitempty"` + IP string `bson:"ip,omitempty" json:"ip,omitempty"` + Zip string `bson:"zip,omitempty" json:"zip,omitempty"` + Country string `bson:"country,omitempty" json:"country,omitempty"` + State string `bson:"state,omitempty" json:"state,omitempty"` + City string `bson:"city,omitempty" json:"city,omitempty"` + Address string `bson:"address,omitempty" json:"address,omitempty"` } // PaymentQuoteSnapshot stores the latest quote info. @@ -172,8 +188,8 @@ type ExecutionStep struct { // ExecutionPlan captures the ordered list of steps to execute a payment. type ExecutionPlan struct { - Steps []*ExecutionStep `bson:"steps,omitempty" json:"steps,omitempty"` - TotalNetworkFee *moneyv1.Money `bson:"totalNetworkFee,omitempty" json:"totalNetworkFee,omitempty"` + Steps []*ExecutionStep `bson:"steps,omitempty" json:"steps,omitempty"` + TotalNetworkFee *moneyv1.Money `bson:"totalNetworkFee,omitempty" json:"totalNetworkFee,omitempty"` } // Payment persists orchestrated payment lifecycle. @@ -231,6 +247,18 @@ func (p *Payment) Normalize() { p.Intent.Attributes[k] = strings.TrimSpace(v) } } + if p.Intent.Customer != nil { + p.Intent.Customer.ID = strings.TrimSpace(p.Intent.Customer.ID) + p.Intent.Customer.FirstName = strings.TrimSpace(p.Intent.Customer.FirstName) + p.Intent.Customer.MiddleName = strings.TrimSpace(p.Intent.Customer.MiddleName) + p.Intent.Customer.LastName = strings.TrimSpace(p.Intent.Customer.LastName) + p.Intent.Customer.IP = strings.TrimSpace(p.Intent.Customer.IP) + p.Intent.Customer.Zip = strings.TrimSpace(p.Intent.Customer.Zip) + p.Intent.Customer.Country = strings.TrimSpace(p.Intent.Customer.Country) + p.Intent.Customer.State = strings.TrimSpace(p.Intent.Customer.State) + p.Intent.Customer.City = strings.TrimSpace(p.Intent.Customer.City) + p.Intent.Customer.Address = strings.TrimSpace(p.Intent.Customer.Address) + } if p.Execution != nil { p.Execution.DebitEntryRef = strings.TrimSpace(p.Execution.DebitEntryRef) p.Execution.CreditEntryRef = strings.TrimSpace(p.Execution.CreditEntryRef) @@ -293,6 +321,7 @@ func normalizeEndpoint(ep *PaymentEndpoint) { ep.Card.Pan = strings.TrimSpace(ep.Card.Pan) ep.Card.Token = strings.TrimSpace(ep.Card.Token) ep.Card.Cardholder = strings.TrimSpace(ep.Card.Cardholder) + ep.Card.CardholderSurname = strings.TrimSpace(ep.Card.CardholderSurname) ep.Card.Country = strings.TrimSpace(ep.Card.Country) ep.Card.MaskedPan = strings.TrimSpace(ep.Card.MaskedPan) } diff --git a/api/proto/payments/orchestrator/v1/orchestrator.proto b/api/proto/payments/orchestrator/v1/orchestrator.proto index 1f48581..dd1b996 100644 --- a/api/proto/payments/orchestrator/v1/orchestrator.proto +++ b/api/proto/payments/orchestrator/v1/orchestrator.proto @@ -112,6 +112,20 @@ message PaymentIntent { fees.v1.PolicyOverrides fee_policy = 7; map attributes = 8; SettlementMode settlement_mode = 9; + Customer customer = 10; +} + +message Customer { + string id = 1; + string first_name = 2; + string middle_name = 3; + string last_name = 4; + string ip = 5; + string zip = 6; + string country = 7; + string state = 8; + string city = 9; + string address = 10; } message PaymentQuote { diff --git a/api/server/interface/api/srequest/customer.go b/api/server/interface/api/srequest/customer.go new file mode 100644 index 0000000..ae96188 --- /dev/null +++ b/api/server/interface/api/srequest/customer.go @@ -0,0 +1,15 @@ +package srequest + +// Customer captures payer/recipient identity details for downstream processing. +type Customer struct { + ID string `json:"id,omitempty"` + FirstName string `json:"first_name,omitempty"` + MiddleName string `json:"middle_name,omitempty"` + LastName string `json:"last_name,omitempty"` + IP string `json:"ip,omitempty"` + Zip string `json:"zip,omitempty"` + Country string `json:"country,omitempty"` + State string `json:"state,omitempty"` + City string `json:"city,omitempty"` + Address string `json:"address,omitempty"` +} diff --git a/api/server/interface/api/srequest/payment_intent.go b/api/server/interface/api/srequest/payment_intent.go index 9f4e331..3f62b4c 100644 --- a/api/server/interface/api/srequest/payment_intent.go +++ b/api/server/interface/api/srequest/payment_intent.go @@ -13,6 +13,7 @@ type PaymentIntent struct { FX *FXIntent `json:"fx,omitempty"` SettlementMode SettlementMode `json:"settlement_mode,omitempty"` Attributes map[string]string `json:"attributes,omitempty"` + Customer *Customer `json:"customer,omitempty"` } type AssetResolverStub struct{} diff --git a/api/server/internal/server/paymentapiimp/customer.go b/api/server/internal/server/paymentapiimp/customer.go new file mode 100644 index 0000000..09acf63 --- /dev/null +++ b/api/server/internal/server/paymentapiimp/customer.go @@ -0,0 +1,25 @@ +package paymentapiimp + +import ( + "net" + "strings" + + "github.com/tech/sendico/server/interface/api/srequest" +) + +func applyCustomerIP(intent *srequest.PaymentIntent, remoteAddr string) { + if intent == nil { + return + } + ip := strings.TrimSpace(remoteAddr) + if ip == "" { + return + } + if host, _, err := net.SplitHostPort(ip); err == nil && host != "" { + ip = host + } + if intent.Customer == nil { + intent.Customer = &srequest.Customer{} + } + intent.Customer.IP = strings.TrimSpace(ip) +} diff --git a/api/server/internal/server/paymentapiimp/mapper.go b/api/server/internal/server/paymentapiimp/mapper.go index 5e88325..0ea02fb 100644 --- a/api/server/internal/server/paymentapiimp/mapper.go +++ b/api/server/internal/server/paymentapiimp/mapper.go @@ -50,6 +50,7 @@ func mapPaymentIntent(intent *srequest.PaymentIntent) (*orchestratorv1.PaymentIn Fx: fx, SettlementMode: settlementMode, Attributes: copyStringMap(intent.Attributes), + Customer: mapCustomer(intent.Customer), }, nil } @@ -200,6 +201,24 @@ func mapFXIntent(fx *srequest.FXIntent) (*orchestratorv1.FXIntent, error) { }, nil } +func mapCustomer(customer *srequest.Customer) *orchestratorv1.Customer { + if customer == nil { + return nil + } + return &orchestratorv1.Customer{ + Id: strings.TrimSpace(customer.ID), + FirstName: strings.TrimSpace(customer.FirstName), + MiddleName: strings.TrimSpace(customer.MiddleName), + LastName: strings.TrimSpace(customer.LastName), + Ip: strings.TrimSpace(customer.IP), + Zip: strings.TrimSpace(customer.Zip), + Country: strings.TrimSpace(customer.Country), + State: strings.TrimSpace(customer.State), + City: strings.TrimSpace(customer.City), + Address: strings.TrimSpace(customer.Address), + } +} + func mapCurrencyPair(pair *srequest.CurrencyPair) *fxv1.CurrencyPair { if pair == nil { return nil diff --git a/api/server/internal/server/paymentapiimp/pay.go b/api/server/internal/server/paymentapiimp/pay.go index 544e411..cb032f0 100644 --- a/api/server/internal/server/paymentapiimp/pay.go +++ b/api/server/internal/server/paymentapiimp/pay.go @@ -59,6 +59,7 @@ func (a *PaymentAPI) initiatePayment(r *http.Request, account *model.Account, to var intent *orchestratorv1.PaymentIntent if payload.Intent != nil { + applyCustomerIP(payload.Intent, r.RemoteAddr) intent, err = mapPaymentIntent(payload.Intent) if err != nil { return response.BadPayload(a.logger, a.Name(), err) diff --git a/api/server/internal/server/paymentapiimp/quote.go b/api/server/internal/server/paymentapiimp/quote.go index 71d83fd..1a9fd2a 100644 --- a/api/server/internal/server/paymentapiimp/quote.go +++ b/api/server/internal/server/paymentapiimp/quote.go @@ -44,6 +44,7 @@ func (a *PaymentAPI) quotePayment(r *http.Request, account *model.Account, token return response.Auto(a.logger, a.Name(), err) } + applyCustomerIP(&payload.Intent, r.RemoteAddr) intent, err := mapPaymentIntent(&payload.Intent) if err != nil { a.logger.Debug("Failed to map payment intent", zap.Error(err), mutil.PLog(a.oph, r)) @@ -97,6 +98,7 @@ func (a *PaymentAPI) quotePayments(r *http.Request, account *model.Account, toke intents := make([]*orchestratorv1.PaymentIntent, 0, len(payload.Intents)) for i := range payload.Intents { + applyCustomerIP(&payload.Intents[i], r.RemoteAddr) intent, err := mapPaymentIntent(&payload.Intents[i]) if err != nil { a.logger.Debug("Failed to map payment intent", zap.Error(err), mutil.PLog(a.oph, r)) diff --git a/frontend/pshared/lib/data/dto/payment/intent/customer.dart b/frontend/pshared/lib/data/dto/payment/intent/customer.dart new file mode 100644 index 0000000..a327318 --- /dev/null +++ b/frontend/pshared/lib/data/dto/payment/intent/customer.dart @@ -0,0 +1,41 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'customer.g.dart'; + + +@JsonSerializable() +class CustomerDTO { + final String id; + + @JsonKey(name: 'first_name') + final String? firstName; + + @JsonKey(name: 'middle_name') + final String? middleName; + + @JsonKey(name: 'last_name') + final String? lastName; + + final String? ip; + final String? zip; + final String? country; + final String? state; + final String? city; + final String? address; + + const CustomerDTO({ + required this.id, + this.firstName, + this.middleName, + this.lastName, + this.ip, + this.zip, + this.country, + this.state, + this.city, + this.address, + }); + + factory CustomerDTO.fromJson(Map json) => _$CustomerDTOFromJson(json); + Map toJson() => _$CustomerDTOToJson(this); +} diff --git a/frontend/pshared/lib/data/dto/payment/intent/payment.dart b/frontend/pshared/lib/data/dto/payment/intent/payment.dart index 5ce9c38..34cf653 100644 --- a/frontend/pshared/lib/data/dto/payment/intent/payment.dart +++ b/frontend/pshared/lib/data/dto/payment/intent/payment.dart @@ -1,6 +1,7 @@ import 'package:json_annotation/json_annotation.dart'; import 'package:pshared/data/dto/payment/endpoint.dart'; +import 'package:pshared/data/dto/payment/intent/customer.dart'; import 'package:pshared/data/dto/payment/intent/fx.dart'; import 'package:pshared/data/dto/payment/money.dart'; @@ -20,6 +21,7 @@ class PaymentIntentDTO { final String? settlementMode; final Map? attributes; + final CustomerDTO? customer; const PaymentIntentDTO({ this.kind, @@ -29,6 +31,7 @@ class PaymentIntentDTO { this.fx, this.settlementMode, this.attributes, + this.customer, }); factory PaymentIntentDTO.fromJson(Map json) => _$PaymentIntentDTOFromJson(json); diff --git a/frontend/pshared/lib/data/mapper/payment/intent/customer.dart b/frontend/pshared/lib/data/mapper/payment/intent/customer.dart new file mode 100644 index 0000000..e6d3e00 --- /dev/null +++ b/frontend/pshared/lib/data/mapper/payment/intent/customer.dart @@ -0,0 +1,33 @@ +import 'package:pshared/data/dto/payment/intent/customer.dart'; +import 'package:pshared/models/payment/customer.dart'; + + +extension CustomerMapper on Customer { + CustomerDTO toDTO() => CustomerDTO( + id: id, + firstName: firstName, + middleName: middleName, + lastName: lastName, + ip: ip, + zip: zip, + country: country, + state: state, + city: city, + address: address, + ); +} + +extension CustomerDTOMapper on CustomerDTO { + Customer toDomain() => Customer( + id: id, + firstName: firstName, + middleName: middleName, + lastName: lastName, + ip: ip, + zip: zip, + country: country, + state: state, + city: city, + address: address, + ); +} diff --git a/frontend/pshared/lib/data/mapper/payment/intent/payment.dart b/frontend/pshared/lib/data/mapper/payment/intent/payment.dart index 0086294..06286f3 100644 --- a/frontend/pshared/lib/data/mapper/payment/intent/payment.dart +++ b/frontend/pshared/lib/data/mapper/payment/intent/payment.dart @@ -1,30 +1,34 @@ 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/customer.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, - ); + kind: paymentKindToValue(kind), + source: source?.toDTO(), + destination: destination?.toDTO(), + amount: amount?.toDTO(), + fx: fx?.toDTO(), + settlementMode: settlementModeToValue(settlementMode), + attributes: attributes, + customer: customer?.toDTO(), + ); } 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, - ); + kind: paymentKindFromValue(kind), + source: source?.toDomain(), + destination: destination?.toDomain(), + amount: amount?.toDomain(), + fx: fx?.toDomain(), + settlementMode: settlementModeFromValue(settlementMode), + attributes: attributes, + customer: customer?.toDomain(), + ); } diff --git a/frontend/pshared/lib/models/payment/customer.dart b/frontend/pshared/lib/models/payment/customer.dart new file mode 100644 index 0000000..726601f --- /dev/null +++ b/frontend/pshared/lib/models/payment/customer.dart @@ -0,0 +1,25 @@ +class Customer { + final String id; + final String? firstName; + final String? middleName; + final String? lastName; + final String? ip; + final String? zip; + final String? country; + final String? state; + final String? city; + final String? address; + + const Customer({ + required this.id, + this.firstName, + this.middleName, + this.lastName, + this.ip, + this.zip, + this.country, + this.state, + this.city, + this.address, + }); +} diff --git a/frontend/pshared/lib/models/payment/intent.dart b/frontend/pshared/lib/models/payment/intent.dart index 943c61e..24278b1 100644 --- a/frontend/pshared/lib/models/payment/intent.dart +++ b/frontend/pshared/lib/models/payment/intent.dart @@ -1,5 +1,6 @@ import 'package:pshared/models/payment/fx/intent.dart'; import 'package:pshared/models/payment/kind.dart'; +import 'package:pshared/models/payment/customer.dart'; import 'package:pshared/models/payment/methods/data.dart'; import 'package:pshared/models/payment/money.dart'; import 'package:pshared/models/payment/settlement_mode.dart'; @@ -13,6 +14,7 @@ class PaymentIntent { final FxIntent? fx; final SettlementMode settlementMode; final Map? attributes; + final Customer? customer; const PaymentIntent({ this.kind = PaymentKind.unspecified, @@ -22,5 +24,6 @@ class PaymentIntent { this.fx, this.settlementMode = SettlementMode.unspecified, this.attributes, + this.customer, }); } diff --git a/frontend/pshared/lib/provider/payment/quotation.dart b/frontend/pshared/lib/provider/payment/quotation.dart index a36bacc..076cbe8 100644 --- a/frontend/pshared/lib/provider/payment/quotation.dart +++ b/frontend/pshared/lib/provider/payment/quotation.dart @@ -8,18 +8,22 @@ import 'package:pshared/api/requests/payment/quote.dart'; import 'package:pshared/data/mapper/payment/intent/payment.dart'; import 'package:pshared/models/asset.dart'; import 'package:pshared/models/payment/currency_pair.dart'; +import 'package:pshared/models/payment/customer.dart'; import 'package:pshared/models/payment/fx/intent.dart'; import 'package:pshared/models/payment/fx/side.dart'; import 'package:pshared/models/payment/kind.dart'; import 'package:pshared/models/payment/methods/managed_wallet.dart'; +import 'package:pshared/models/payment/methods/type.dart'; import 'package:pshared/models/payment/money.dart'; import 'package:pshared/models/payment/settlement_mode.dart'; import 'package:pshared/models/payment/intent.dart'; import 'package:pshared/models/payment/quote.dart'; +import 'package:pshared/models/recipient/recipient.dart'; import 'package:pshared/provider/organizations.dart'; import 'package:pshared/provider/payment/amount.dart'; import 'package:pshared/provider/payment/flow.dart'; import 'package:pshared/provider/payment/wallets.dart'; +import 'package:pshared/provider/recipient/provider.dart'; import 'package:pshared/provider/recipient/pmethods.dart'; import 'package:pshared/provider/resource.dart'; import 'package:pshared/service/payment/quotation.dart'; @@ -36,12 +40,17 @@ class QuotationProvider extends ChangeNotifier { PaymentAmountProvider payment, WalletsProvider wallets, PaymentFlowProvider flow, + RecipientsProvider recipients, PaymentMethodsProvider methods, ) { _organizations = venue; final t = flow.selectedType; final method = methods.methods.firstWhereOrNull((m) => m.type == t); if ((wallets.selectedWallet != null) && (method != null)) { + final customer = _buildCustomer( + recipient: recipients.currentObject, + method: method, + ); getQuotation(PaymentIntent( kind: PaymentKind.payout, amount: Money( @@ -61,6 +70,7 @@ class QuotationProvider extends ChangeNotifier { side: FxSide.sellBaseBuyQuote, ), settlementMode: payment.payerCoversFee ? SettlementMode.fixReceived : SettlementMode.fixSource, + customer: customer, )); } } @@ -108,3 +118,42 @@ class QuotationProvider extends ChangeNotifier { notifyListeners(); } } + +Customer? _buildCustomer({ + required Recipient? recipient, + required PaymentMethod method, +}) { + final recipientId = (recipient?.id ?? method.recipientRef).trim(); + if (recipientId.isEmpty) { + return null; + } + + var firstName = ''; + var lastName = ''; + final cardData = method.cardData; + if (cardData != null) { + firstName = cardData.firstName.trim(); + lastName = cardData.lastName.trim(); + } + + if ((firstName.isEmpty || lastName.isEmpty) && recipient != null) { + final parts = recipient.name.trim().split(RegExp(r'\s+')); + if (parts.isNotEmpty && firstName.isEmpty) { + firstName = parts.first; + } + if (parts.length > 1 && lastName.isEmpty) { + lastName = parts.sublist(1).join(' '); + } + } + + if (lastName.isEmpty) { + lastName = firstName; + } + + return Customer( + id: recipientId, + firstName: firstName.isEmpty ? null : firstName, + lastName: lastName.isEmpty ? null : lastName, + country: cardData?.country, + ); +} diff --git a/frontend/pweb/lib/pages/payment_methods/page.dart b/frontend/pweb/lib/pages/payment_methods/page.dart index 2c10623..d464088 100644 --- a/frontend/pweb/lib/pages/payment_methods/page.dart +++ b/frontend/pweb/lib/pages/payment_methods/page.dart @@ -128,9 +128,10 @@ class _PaymentPageState extends State { ChangeNotifierProvider( create: (_) => PaymentAmountProvider(), ), - ChangeNotifierProxyProvider5( + ChangeNotifierProxyProvider6( create: (_) => QuotationProvider(), - update: (_, organization, payment, wallet, flow, methods, provider) => provider!..update(organization, payment, wallet, flow, methods), + update: (_, organization, payment, wallet, flow, recipients, methods, provider) => + provider!..update(organization, payment, wallet, flow, recipients, methods), ), ChangeNotifierProxyProvider2( create: (_) => PaymentProvider(),