diff --git a/apps/ff_cth/src/ct_helper.erl b/apps/ff_cth/src/ct_helper.erl index 08845f0..de10c7f 100644 --- a/apps/ff_cth/src/ct_helper.erl +++ b/apps/ff_cth/src/ct_helper.erl @@ -130,6 +130,7 @@ start_app(wapi_woody_client = AppName) -> fistful_identity => "http://localhost:8022/v1/identity", fistful_destination => "http://localhost:8022/v1/destination", w2w_transfer => "http://localhost:8022/v1/w2w_transfer", + p2p_transfer => "http://localhost:8022/v1/p2p_transfer", fistful_withdrawal => "http://localhost:8022/v1/withdrawal" }}, {service_retries, #{ diff --git a/apps/ff_server/src/ff_p2p_transfer_codec.erl b/apps/ff_server/src/ff_p2p_transfer_codec.erl index c0c8102..2277f59 100644 --- a/apps/ff_server/src/ff_p2p_transfer_codec.erl +++ b/apps/ff_server/src/ff_p2p_transfer_codec.erl @@ -26,8 +26,8 @@ unmarshal_p2p_quote_params(#p2p_transfer_QuoteParams{ #{ identity_id => unmarshal(id, IdentityID), body => unmarshal(cash, Body), - sender => unmarshal(participant, Sender), - receiver => unmarshal(participant, Receiver) + sender => unmarshal(resource, Sender), + receiver => unmarshal(resource, Receiver) }. -spec marshal_p2p_transfer_state(p2p_transfer:p2p_transfer_state(), ff_entity_context:context()) -> @@ -49,7 +49,7 @@ marshal_p2p_transfer_state(P2PTransferState, Ctx) -> operation_timestamp = marshal(timestamp_ms, p2p_transfer:operation_timestamp(P2PTransferState)), created_at = marshal(timestamp_ms, p2p_transfer:created_at(P2PTransferState)), deadline = maybe_marshal(timestamp_ms, p2p_transfer:deadline(P2PTransferState)), - quote = maybe_marshal(quote, p2p_transfer:quote(P2PTransferState)), + quote = maybe_marshal(quote_state, p2p_transfer:quote(P2PTransferState)), client_info = maybe_marshal(client_info, p2p_transfer:client_info(P2PTransferState)), external_id = maybe_marshal(id, p2p_transfer:external_id(P2PTransferState)), metadata = marshal(ctx, p2p_transfer:metadata(P2PTransferState)), @@ -583,4 +583,4 @@ p2p_timestamped_change_codec_test() -> Decoded = ff_proto_utils:deserialize(Type, Binary), ?assertEqual(TimestampedChange, unmarshal(timestamped_change, Decoded)). --endif. \ No newline at end of file +-endif. diff --git a/apps/fistful/src/ff_party.erl b/apps/fistful/src/ff_party.erl index 2fd4e13..979a9ce 100644 --- a/apps/fistful/src/ff_party.erl +++ b/apps/fistful/src/ff_party.erl @@ -280,7 +280,7 @@ get_contract_terms(PartyID, ContractID, Varset, Timestamp, PartyRevision, Domain {error, #payproc_PartyNotFound{}} -> {error, {party_not_found, PartyID}}; {error, #payproc_ContractNotFound{}} -> - {error, {contract_not_found, PartyID}}; + {error, {contract_not_found, ContractID}}; {error, #payproc_PartyNotExistsYet{}} -> {error, {party_not_exists_yet, PartyID}}; {error, Unexpected} -> diff --git a/apps/wapi/src/wapi_access_backend.erl b/apps/wapi/src/wapi_access_backend.erl index 086f750..adea469 100644 --- a/apps/wapi/src/wapi_access_backend.erl +++ b/apps/wapi/src/wapi_access_backend.erl @@ -5,18 +5,27 @@ -include_lib("fistful_proto/include/ff_proto_destination_thrift.hrl"). -include_lib("fistful_proto/include/ff_proto_p2p_template_thrift.hrl"). -include_lib("fistful_proto/include/ff_proto_w2w_transfer_thrift.hrl"). +-include_lib("fistful_proto/include/ff_proto_p2p_transfer_thrift.hrl"). -include_lib("fistful_proto/include/ff_proto_withdrawal_thrift.hrl"). -export([check_resource/3]). -export([check_resource_by_id/3]). -type id() :: binary(). --type resource_type() :: identity | wallet | destination | p2p_template | w2w_transfer | withdrawal. +-type resource_type() :: identity + | wallet + | destination + | withdrawal + | w2w_transfer + | p2p_transfer + | p2p_template + . -type handler_context() :: wapi_handler:context(). -type data() :: ff_proto_identity_thrift:'IdentityState'() | ff_proto_wallet_thrift:'WalletState'() | + ff_proto_p2p_transfer_thrift:'P2PTransferState'() | ff_proto_p2p_template_thrift:'P2PTemplateState'(). -define(CTX_NS, <<"com.rbkmoney.wapi">>). @@ -86,6 +95,14 @@ get_context_by_id(w2w_transfer, W2WTransferID, WoodyCtx) -> {exception, #fistful_W2WNotFound{}} -> {error, notfound} end; +get_context_by_id(p2p_transfer, P2PTransferID, WoodyCtx) -> + Request = {p2p_transfer, 'GetContext', [P2PTransferID]}, + case wapi_handler_utils:service_call(Request, WoodyCtx) of + {ok, Context} -> + Context; + {exception, #fistful_P2PNotFound{}} -> + {error, notfound} + end; get_context_by_id(withdrawal, WithdrawalID, WoodyCtx) -> Request = {fistful_withdrawal, 'GetContext', [WithdrawalID]}, case wapi_handler_utils:service_call(Request, WoodyCtx) of @@ -105,6 +122,8 @@ get_context_from_state(p2p_template, #p2p_template_P2PTemplateState{context = Co Context; get_context_from_state(w2w_transfer, #w2w_transfer_W2WTransferState{context = Context}) -> Context; +get_context_from_state(p2p_transfer, #p2p_transfer_P2PTransferState{context = Context}) -> + Context; get_context_from_state(withdrawal, #wthd_WithdrawalState{context = Context}) -> Context. diff --git a/apps/wapi/src/wapi_p2p_quote.erl b/apps/wapi/src/wapi_p2p_quote.erl index 535b112..64b284a 100644 --- a/apps/wapi/src/wapi_p2p_quote.erl +++ b/apps/wapi/src/wapi_p2p_quote.erl @@ -1,5 +1,7 @@ -module(wapi_p2p_quote). +-include_lib("fistful_proto/include/ff_proto_p2p_transfer_thrift.hrl"). + -export([create_token_payload/2]). -export([decode_token_payload/1]). @@ -15,17 +17,20 @@ %% Internal types -type party_id() :: binary(). --type quote() :: p2p_quote:quote(). +-type quote() :: ff_proto_p2p_transfer_thrift:'Quote'(). %% API -spec create_token_payload(quote(), party_id()) -> token_payload(). create_token_payload(Quote, PartyID) -> + Type = {struct, struct, {ff_proto_p2p_transfer_thrift, 'Quote'}}, + Bin = ff_proto_utils:serialize(Type, Quote), + EncodedQuote = base64:encode(Bin), genlib_map:compact(#{ <<"version">> => 2, <<"partyID">> => PartyID, - <<"quote">> => encode_quote(Quote) + <<"quote">> => EncodedQuote }). -spec decode_token_payload(token_payload()) -> @@ -36,53 +41,46 @@ decode_token_payload(#{<<"version">> := 2} = Payload) -> <<"partyID">> := _PartyID, <<"quote">> := EncodedQuote } = Payload, - Quote = decode_quote(EncodedQuote), + Type = {struct, struct, {ff_proto_p2p_transfer_thrift, 'Quote'}}, + Bin = base64:decode(EncodedQuote), + Quote = ff_proto_utils:deserialize(Type, Bin), {ok, Quote}; decode_token_payload(#{<<"version">> := 1}) -> {error, token_expired}. %% Internals --spec encode_quote(quote()) -> - token_payload(). -encode_quote(Quote) -> - Type = {struct, struct, {ff_proto_p2p_transfer_thrift, 'Quote'}}, - Bin = ff_proto_utils:serialize(Type, ff_p2p_transfer_codec:marshal(quote, Quote)), - base64:encode(Bin). - --spec decode_quote(token_payload()) -> - quote(). -decode_quote(Encoded) -> - Type = {struct, struct, {ff_proto_p2p_transfer_thrift, 'Quote'}}, - Bin = base64:decode(Encoded), - Thrift = ff_proto_utils:deserialize(Type, Bin), - ff_p2p_transfer_codec:unmarshal(quote, Thrift). - -ifdef(TEST). -include_lib("eunit/include/eunit.hrl"). -spec test() -> _. -spec payload_symmetry_test() -> _. payload_symmetry_test() -> - Quote = #{ - fees => #{ - fees => #{ - surplus => {1000, <<"RUB">>} + Quote = #p2p_transfer_Quote{ + identity_id = <<"identity">>, + created_at = <<"1970-01-01T00:00:00.123Z">>, + expires_on = <<"1970-01-01T00:00:00.321Z">>, + party_revision = 1, + domain_revision = 2, + fees = #'Fees'{fees = #{surplus => #'Cash'{ + amount = 1000000, + currency = #'CurrencyRef'{ + symbolic_code = <<"RUB">> + } + }}}, + body = #'Cash'{ + amount = 1000000, + currency = #'CurrencyRef'{ + symbolic_code = <<"RUB">> } }, - amount => {1000000, <<"RUB">>}, - party_revision => 1, - domain_revision => 2, - created_at => 123, - expires_on => 321, - identity_id => <<"identity">>, - sender => {bank_card, #{ - token => <<"very long token">>, - bin_data_id => nil + sender = {bank_card, #'ResourceBankCard'{ + bank_card = #'BankCard'{token = <<"very long token">>}, + auth_data = {session_data, #'SessionAuthData'{id = <<"1">>}} }}, - receiver => {bank_card, #{ - token => <<"another very long token">>, - bin_data_id => #{[nil] => [nil]} + receiver = {bank_card, #'ResourceBankCard'{ + bank_card = #'BankCard'{token = <<"another very long token">>}, + auth_data = {session_data, #'SessionAuthData'{id = <<"2">>}} }} }, Payload = create_token_payload(Quote, <<"party">>), @@ -91,25 +89,35 @@ payload_symmetry_test() -> -spec payload_v2_decoding_test() -> _. payload_v2_decoding_test() -> - ExpectedQuote = #{ - fees => #{ - fees => #{ - surplus => {1000, <<"RUB">>} + ExpectedQuote = #p2p_transfer_Quote{ + identity_id = <<"identity">>, + created_at = <<"1970-01-01T00:00:00.123Z">>, + expires_on = <<"1970-01-01T00:00:00.321Z">>, + party_revision = 1, + domain_revision = 2, + fees = #'Fees'{fees = #{surplus => #'Cash'{ + amount = 1000, + currency = #'CurrencyRef'{ + symbolic_code = <<"RUB">> + } + }}}, + body = #'Cash'{ + amount = 1000000, + currency = #'CurrencyRef'{ + symbolic_code = <<"RUB">> } }, - amount => {1000000, <<"RUB">>}, - party_revision => 1, - domain_revision => 2, - created_at => 123, - expires_on => 321, - identity_id => <<"identity">>, - sender => {bank_card, #{ - token => <<"very long token">>, - bin_data_id => nil + sender = {bank_card, #'ResourceBankCard'{ + bank_card = #'BankCard'{ + token = <<"very long token">>, + bin_data_id = {nl, #msgp_Nil{}} + } }}, - receiver => {bank_card, #{ - token => <<"another very long token">>, - bin_data_id => #{[nil] => [nil]} + receiver = {bank_card, #'ResourceBankCard'{ + bank_card = #'BankCard'{ + token = <<"another very long token">>, + bin_data_id = {obj, #{{arr, [{nl, #msgp_Nil{}}]} => {arr, [{nl, #msgp_Nil{}}]}}} + } }} }, Payload = #{ diff --git a/apps/wapi/src/wapi_p2p_transfer_backend.erl b/apps/wapi/src/wapi_p2p_transfer_backend.erl new file mode 100644 index 0000000..a154d4f --- /dev/null +++ b/apps/wapi/src/wapi_p2p_transfer_backend.erl @@ -0,0 +1,384 @@ +-module(wapi_p2p_transfer_backend). + +-type req_data() :: wapi_handler:req_data(). +-type handler_context() :: wapi_handler:context(). +-type response_data() :: wapi_handler:response_data(). + +-type id() :: binary(). +-type external_id() :: id(). + +-type error_create() + :: {external_id_conflict, id(), external_id()} + | {identity, unauthorized} + | {identity, notfound} + | {p2p_transfer, forbidden_currency} + | {p2p_transfer, cash_range_exceeded} + | {p2p_transfer, operation_not_permitted} + | {token, {not_verified, identity_mismatch}} + | {token, {not_verified, _}} + | {sender, invalid_resource} + | {receiver, invalid_resource} + . + +-type error_create_quote() + :: {identity, unauthorized} + | {identity, notfound} + | {p2p_transfer, forbidden_currency} + | {p2p_transfer, cash_range_exceeded} + | {p2p_transfer, operation_not_permitted} + | {sender, invalid_resource} + | {receiver, invalid_resource} + . + +-type error_get() + :: {p2p_transfer, unauthorized} + | {p2p_transfer, notfound} + . + +-export([create_transfer/2]). +-export([quote_transfer/2]). +-export([get_transfer/2]). + +-import(ff_pipeline, [do/1, unwrap/1]). + +-include_lib("fistful_proto/include/ff_proto_p2p_transfer_thrift.hrl"). + +-spec create_transfer(req_data(), handler_context()) -> + {ok, response_data()} | {error, error_create()}. +create_transfer(Params = #{<<"identityID">> := IdentityID}, HandlerContext) -> + case wapi_access_backend:check_resource_by_id(identity, IdentityID, HandlerContext) of + ok -> + case wapi_backend_utils:gen_id(p2p_transfer, Params, HandlerContext) of + {ok, ID} -> + do_create_transfer(ID, Params, HandlerContext); + {error, {external_id_conflict, ID}} -> + {error, {external_id_conflict, ID, maps:get(<<"externalID">>, Params, undefined)}} + end; + {error, unauthorized} -> + {error, {identity, unauthorized}}; + {error, notfound} -> + {error, {identity, notfound}} + end. + +-spec get_transfer(req_data(), handler_context()) -> + {ok, response_data()} | {error, error_get()}. +get_transfer(ID, HandlerContext) -> + Request = {p2p_transfer, 'Get', [ID, #'EventRange'{}]}, + case service_call(Request, HandlerContext) of + {ok, TransferThrift} -> + case wapi_access_backend:check_resource(p2p_transfer, TransferThrift, HandlerContext) of + ok -> + {ok, unmarshal_transfer(TransferThrift)}; + {error, unauthorized} -> + {error, {p2p_transfer, unauthorized}} + end; + {exception, #fistful_P2PNotFound{}} -> + {error, {p2p_transfer, notfound}} + end. + +-spec quote_transfer(req_data(), handler_context()) -> + {ok, response_data()} | {error, error_create_quote()}. + +quote_transfer(Params = #{<<"identityID">> := IdentityID}, HandlerContext) -> + case wapi_access_backend:check_resource_by_id(identity, IdentityID, HandlerContext) of + ok -> + do_quote_transfer(Params, HandlerContext); + {error, unauthorized} -> + {error, {identity, unauthorized}}; + {error, notfound} -> + {error, {identity, notfound}} + end. + +%% Internal + +do_quote_transfer(Params, HandlerContext) -> + Request = {p2p_transfer, 'GetQuote', [marshal_quote_params(Params)]}, + case service_call(Request, HandlerContext) of + {ok, Quote} -> + PartyID = wapi_handler_utils:get_owner(HandlerContext), + Token = create_quote_token(Quote, PartyID), + UnmarshaledQuote = unmarshal_quote(Quote), + {ok, UnmarshaledQuote#{<<"token">> => Token}}; + {exception, #p2p_transfer_NoResourceInfo{type = sender}} -> + {error, {sender, invalid_resource}}; + {exception, #p2p_transfer_NoResourceInfo{type = receiver}} -> + {error, {receiver, invalid_resource}}; + {exception, #fistful_ForbiddenOperationCurrency{}} -> + {error, {p2p_transfer, forbidden_currency}}; + {exception, #fistful_ForbiddenOperationAmount{}} -> + {error, {p2p_transfer, cash_range_exceeded}}; + {exception, #fistful_IdentityNotFound{ }} -> + {error, {identity, notfound}} + end. + +create_quote_token(Quote, PartyID) -> + Payload = wapi_p2p_quote:create_token_payload(Quote, PartyID), + {ok, Token} = issue_quote_token(PartyID, Payload), + Token. + +issue_quote_token(PartyID, Payload) -> + uac_authorizer_jwt:issue(wapi_utils:get_unique_id(), PartyID, Payload, wapi_auth:get_signee()). + +do_create_transfer(ID, Params, HandlerContext) -> + do(fun() -> + Context = wapi_backend_utils:make_ctx(Params, HandlerContext), + TransferParams = unwrap(build_transfer_params(Params#{<<"id">> => ID})), + Request = {p2p_transfer, 'Create', [TransferParams, marshal(context, Context)]}, + unwrap(process_p2p_transfer_call(Request, HandlerContext)) + end). + +process_p2p_transfer_call(Request, HandlerContext) -> + case service_call(Request, HandlerContext) of + {ok, Transfer} -> + {ok, unmarshal_transfer(Transfer)}; + {exception, #p2p_transfer_NoResourceInfo{type = sender}} -> + {error, {sender, invalid_resource}}; + {exception, #p2p_transfer_NoResourceInfo{type = receiver}} -> + {error, {receiver, invalid_resource}}; + {exception, #fistful_ForbiddenOperationCurrency{}} -> + {error, {p2p_transfer, forbidden_currency}}; + {exception, #fistful_ForbiddenOperationAmount{}} -> + {error, {p2p_transfer, cash_range_exceeded}}; + {exception, #fistful_OperationNotPermitted{}} -> + {error, {p2p_transfer, operation_not_permitted}}; + {exception, #fistful_IdentityNotFound{ }} -> + {error, {identity, notfound}} + end. + +build_transfer_params(Params = #{<<"quoteToken">> := QuoteToken, <<"identityID">> := IdentityID}) -> + do(fun() -> + VerifiedToken = unwrap(verify_p2p_quote_token(QuoteToken)), + Quote = unwrap(wapi_p2p_quote:decode_token_payload(VerifiedToken)), + ok = unwrap(authorize_p2p_quote_token(Quote, IdentityID)), + TransferParams = marshal_transfer_params(Params), + TransferParams#p2p_transfer_P2PTransferParams{quote = Quote} + end); +build_transfer_params(Params) -> + do(fun() -> marshal_transfer_params(Params) end). + +verify_p2p_quote_token(Token) -> + case uac_authorizer_jwt:verify(Token, #{}) of + {ok, {_, _, VerifiedToken}} -> + {ok, VerifiedToken}; + {error, Error} -> + {error, {token, {not_verified, Error}}} + end. + +authorize_p2p_quote_token(#p2p_transfer_Quote{identity_id = IdentityID}, IdentityID) -> + ok; +authorize_p2p_quote_token(_Quote, _IdentityID) -> + {error, {token, {not_verified, identity_mismatch}}}. + +service_call(Params, HandlerContext) -> + wapi_handler_utils:service_call(Params, HandlerContext). + +%% Marshal + +marshal_quote_params(#{ + <<"body">> := Body, + <<"identityID">> := IdentityID, + <<"sender">> := Sender, + <<"receiver">> := Receiver +}) -> + #p2p_transfer_QuoteParams{ + body = marshal_body(Body), + identity_id = IdentityID, + sender = marshal_quote_participant(Sender), + receiver = marshal_quote_participant(Receiver) + }. + +marshal_quote_participant(#{ + <<"token">> := Token +}) -> + case wapi_crypto:decrypt_bankcard_token(Token) of + unrecognized -> + BankCard = wapi_utils:base64url_to_map(Token), + {bank_card, #'ResourceBankCard'{ + bank_card = #'BankCard'{ + token = maps:get(<<"token">>, BankCard), + bin = maps:get(<<"bin">>, BankCard), + masked_pan = maps:get(<<"lastDigits">>, BankCard) + } + }}; + {ok, BankCard} -> + {bank_card, #'ResourceBankCard'{bank_card = BankCard}} + end. + +marshal_transfer_params(#{ + <<"id">> := ID, + <<"identityID">> := IdentityID, + <<"sender">> := Sender, + <<"receiver">> := Receiver, + <<"body">> := Body, + <<"contactInfo">> := ContactInfo +}) -> + #p2p_transfer_P2PTransferParams{ + id = ID, + identity_id = IdentityID, + sender = marshal_sender(Sender#{<<"contactInfo">> => ContactInfo}), + receiver = marshal_receiver(Receiver), + body = marshal_body(Body) + }. + +marshal_sender(#{ + <<"token">> := Token, + <<"contactInfo">> := ContactInfo +}) -> + Resource = case wapi_crypto:decrypt_bankcard_token(Token) of + unrecognized -> + BankCard = wapi_utils:base64url_to_map(Token), + {bank_card, #'ResourceBankCard'{ + bank_card = #'BankCard'{ + token = maps:get(<<"token">>, BankCard), + bin = maps:get(<<"bin">>, BankCard), + masked_pan = maps:get(<<"lastDigits">>, BankCard) + } + }}; + {ok, BankCard} -> + {bank_card, #'ResourceBankCard'{bank_card = BankCard}} + end, + {resource, #p2p_transfer_RawResource{ + resource = Resource, + contact_info = marshal_contact_info(ContactInfo) + }}. + +marshal_receiver(#{ + <<"token">> := Token +}) -> + Resource = case wapi_crypto:decrypt_bankcard_token(Token) of + unrecognized -> + BankCard = wapi_utils:base64url_to_map(Token), + {bank_card, #'ResourceBankCard'{ + bank_card = #'BankCard'{ + token = maps:get(<<"token">>, BankCard), + bin = maps:get(<<"bin">>, BankCard), + masked_pan = maps:get(<<"lastDigits">>, BankCard) + } + }}; + {ok, BankCard} -> + {bank_card, #'ResourceBankCard'{bank_card = BankCard}} + end, + {resource, #p2p_transfer_RawResource{ + resource = Resource, + contact_info = #'ContactInfo'{} + }}. + +marshal_contact_info(ContactInfo) -> + #'ContactInfo'{ + email = maps:get(<<"email">>, ContactInfo, undefined), + phone_number = maps:get(<<"phoneNumber">>, ContactInfo, undefined) + }. + +marshal_body(#{ + <<"amount">> := Amount, + <<"currency">> := Currency +}) -> + #'Cash'{ + amount = Amount, + currency = marshal_currency(Currency) + }. + +marshal_currency(Currency) -> + #'CurrencyRef'{symbolic_code = Currency}. + +marshal(T, V) -> + ff_codec:marshal(T, V). + +%% Unmarshal + +unmarshal_quote(#p2p_transfer_Quote{ + fees = Fees, + expires_on = ExpiresOn +}) -> + genlib_map:compact(#{ + <<"expiresOn">> => ExpiresOn, + <<"customerFee">> => unmarshal_fees(Fees) + }). + +unmarshal_fees(#'Fees'{fees = #{operation_amount := Cash}}) -> + unmarshal_body(Cash). + +unmarshal_transfer(#p2p_transfer_P2PTransferState{ + id = ID, + owner = IdentityID, + sender = SenderResource, + receiver = ReceiverResource, + body = Body, + created_at = CreatedAt, + status = Status, + external_id = ExternalID +}) -> + Sender = unmarshal_sender(SenderResource), + ContactInfo = maps:get(<<"contactInfo">>, Sender), + genlib_map:compact(#{ + <<"id">> => ID, + <<"identityID">> => IdentityID, + <<"contactInfo">> => ContactInfo, + <<"createdAt">> => CreatedAt, + <<"body">> => unmarshal_body(Body), + <<"sender">> => maps:remove(<<"contactInfo">>, Sender), + <<"receiver">> => unmarshal_receiver(ReceiverResource), + <<"status">> => unmarshal_transfer_status(Status), + <<"externalID">> => maybe_unmarshal(id, ExternalID) + }). + +unmarshal_body(#'Cash'{ + amount = Amount, + currency = Currency +}) -> + #{ + <<"amount">> => unmarshal(amount, Amount), + <<"currency">> => unmarshal(currency_ref, Currency) + }. + +unmarshal_sender({resource, #p2p_transfer_RawResource{ + contact_info = ContactInfo, + resource = {bank_card, #'ResourceBankCard'{ + bank_card = BankCard + }} +}}) -> + genlib_map:compact(#{ + <<"type">> => <<"BankCardSenderResource">>, + <<"contactInfo">> => unmarshal_contact_info(ContactInfo), + <<"token">> => BankCard#'BankCard'.token, + <<"paymentSystem">> => genlib:to_binary(BankCard#'BankCard'.payment_system), + <<"bin">> => BankCard#'BankCard'.bin, + <<"lastDigits">> => wapi_utils:get_last_pan_digits(BankCard#'BankCard'.masked_pan) + }). + +unmarshal_receiver({resource, #p2p_transfer_RawResource{ + resource = {bank_card, #'ResourceBankCard'{ + bank_card = BankCard + }} +}}) -> + genlib_map:compact(#{ + <<"type">> => <<"BankCardReceiverResource">>, + <<"token">> => BankCard#'BankCard'.token, + <<"bin">> => BankCard#'BankCard'.bin, + <<"paymentSystem">> => genlib:to_binary(BankCard#'BankCard'.payment_system), + <<"lastDigits">> => wapi_utils:get_last_pan_digits(BankCard#'BankCard'.masked_pan) + }). + +unmarshal_contact_info(ContactInfo) -> + genlib_map:compact(#{ + <<"phoneNumber">> => ContactInfo#'ContactInfo'.phone_number, + <<"email">> => ContactInfo#'ContactInfo'.email + }). + +unmarshal_transfer_status({pending, _}) -> + #{<<"status">> => <<"Pending">>}; +unmarshal_transfer_status({succeeded, _}) -> + #{<<"status">> => <<"Succeeded">>}; +unmarshal_transfer_status({failed, #p2p_status_Failed{failure = Failure}}) -> + #{ + <<"status">> => <<"Failed">>, + <<"failure">> => unmarshal(failure, Failure) + }. + +unmarshal(T, V) -> + ff_codec:unmarshal(T, V). + +maybe_unmarshal(_T, undefined) -> + undefined; +maybe_unmarshal(T, V) -> + unmarshal(T, V). diff --git a/apps/wapi/src/wapi_wallet_ff_backend.erl b/apps/wapi/src/wapi_wallet_ff_backend.erl index a2857cc..1bfd76d 100644 --- a/apps/wapi/src/wapi_wallet_ff_backend.erl +++ b/apps/wapi/src/wapi_wallet_ff_backend.erl @@ -1017,7 +1017,7 @@ create_quote_token(Quote, WalletID, DestinationID, PartyID) -> Token. create_p2p_quote_token(Quote, PartyID) -> - Payload = wapi_p2p_quote:create_token_payload(Quote, PartyID), + Payload = wapi_p2p_quote:create_token_payload(ff_p2p_transfer_codec:marshal(quote, Quote), PartyID), {ok, Token} = issue_quote_token(PartyID, Payload), Token. @@ -1043,10 +1043,11 @@ maybe_add_p2p_template_quote_token(ID, #{quote_token := QuoteToken} = Params) -> do(fun() -> VerifiedToken = unwrap(verify_p2p_quote_token(QuoteToken)), Quote = unwrap(quote, wapi_p2p_quote:decode_token_payload(VerifiedToken)), + UnmarshaledQuote = ff_p2p_transfer_codec:unmarshal(quote, Quote), Machine = unwrap(p2p_template_machine:get(ID)), State = p2p_template_machine:p2p_template(Machine), - ok = unwrap(authorize_p2p_quote_token(Quote, p2p_template:identity_id(State))), - Params#{quote => Quote} + ok = unwrap(authorize_p2p_quote_token(UnmarshaledQuote, p2p_template:identity_id(State))), + Params#{quote => UnmarshaledQuote} end). maybe_add_p2p_quote_token(#{quote_token := undefined} = Params) -> @@ -1055,8 +1056,9 @@ maybe_add_p2p_quote_token(#{quote_token := QuoteToken, identity_id := IdentityID do(fun() -> VerifiedToken = unwrap(verify_p2p_quote_token(QuoteToken)), Quote = unwrap(quote, wapi_p2p_quote:decode_token_payload(VerifiedToken)), - ok = unwrap(authorize_p2p_quote_token(Quote, IdentityID)), - Params#{quote => Quote} + UnmarshaledQuote = ff_p2p_transfer_codec:unmarshal(quote, Quote), + ok = unwrap(authorize_p2p_quote_token(UnmarshaledQuote, IdentityID)), + Params#{quote => UnmarshaledQuote} end). max_event_id(NewEventID, OldEventID) when is_integer(NewEventID) andalso is_integer(OldEventID) -> diff --git a/apps/wapi/src/wapi_wallet_thrift_handler.erl b/apps/wapi/src/wapi_wallet_thrift_handler.erl index 831a10d..84e3b96 100644 --- a/apps/wapi/src/wapi_wallet_thrift_handler.erl +++ b/apps/wapi/src/wapi_wallet_thrift_handler.erl @@ -469,6 +469,75 @@ process_request('GetW2WTransfer', #{w2wTransferID := ID}, Context, _Opts) -> wapi_handler_utils:reply_ok(404) end; +%% P2P + +process_request('QuoteP2PTransfer', #{'QuoteParameters':= Params}, Context, _Opts) -> + case wapi_p2p_transfer_backend:quote_transfer(Params, Context) of + {ok, Quote} -> + wapi_handler_utils:reply_ok(201, Quote); + {error, {identity, notfound}} -> + wapi_handler_utils:reply_ok(422, + wapi_handler_utils:get_error_msg(<<"No such identity">>)); + {error, {identity, unauthorized}} -> + wapi_handler_utils:reply_ok(422, + wapi_handler_utils:get_error_msg(<<"No such identity">>)); + {error, {sender, invalid_resource}} -> + wapi_handler_utils:reply_ok(422, + wapi_handler_utils:get_error_msg(<<"Invalid sender resource">>)); + {error, {receiver, invalid_resource}} -> + wapi_handler_utils:reply_ok(422, + wapi_handler_utils:get_error_msg(<<"Invalid receiver resource">>)); + {error, {p2p_transfer, forbidden_currency}} -> + wapi_handler_utils:reply_ok(422, + wapi_handler_utils:get_error_msg(<<"Currency not allowed">>)); + {error, {p2p_transfer, cash_range_exceeded}} -> + wapi_handler_utils:reply_ok(422, + wapi_handler_utils:get_error_msg(<<"Transfer amount is out of allowed range">>)) + end; + +process_request('CreateP2PTransfer', #{'P2PTransferParameters' := Params}, Context, _Opts) -> + case wapi_p2p_transfer_backend:create_transfer(Params, Context) of + {ok, P2PTransfer} -> + wapi_handler_utils:reply_ok(202, P2PTransfer); + {error, {external_id_conflict, ID, ExternalID}} -> + wapi_handler_utils:logic_error(external_id_conflict, {ID, ExternalID}); + {error, {identity, notfound}} -> + wapi_handler_utils:reply_ok(422, + wapi_handler_utils:get_error_msg(<<"No such identity">>)); + {error, {identity, unauthorized}} -> + wapi_handler_utils:reply_ok(422, + wapi_handler_utils:get_error_msg(<<"No such identity">>)); + {error, {sender, invalid_resource}} -> + wapi_handler_utils:reply_ok(422, + wapi_handler_utils:get_error_msg(<<"Invalid sender resource">>)); + {error, {receiver, invalid_resource}} -> + wapi_handler_utils:reply_ok(422, + wapi_handler_utils:get_error_msg(<<"Invalid receiver resource">>)); + {error, {token, {not_verified, _}}} -> + wapi_handler_utils:reply_ok(422, + wapi_handler_utils:get_error_msg(<<"Token can't be verified">>)); + {error, {p2p_transfer, operation_not_permitted}} -> + wapi_handler_utils:reply_ok(422, + wapi_handler_utils:get_error_msg(<<"Operation not permitted">>)); + {error, {p2p_transfer, forbidden_currency}} -> + wapi_handler_utils:reply_ok(422, + wapi_handler_utils:get_error_msg(<<"Currency not allowed">>)); + {error, {p2p_transfer, cash_range_exceeded}} -> + wapi_handler_utils:reply_ok(422, + wapi_handler_utils:get_error_msg(<<"Transfer amount is out of allowed range">>)) + % note: thrift has less expressive errors + end; + +process_request('GetP2PTransfer', #{p2pTransferID := ID}, Context, _Opts) -> + case wapi_p2p_transfer_backend:get_transfer(ID, Context) of + {ok, P2PTransfer} -> + wapi_handler_utils:reply_ok(200, P2PTransfer); + {error, {p2p_transfer, unauthorized}} -> + wapi_handler_utils:reply_ok(404); + {error, {p2p_transfer, notfound}} -> + wapi_handler_utils:reply_ok(404) + end; + %% Webhooks process_request('CreateWebhook', #{'WebhookParams' := WebhookParams}, Context, _Opts) -> diff --git a/apps/wapi/test/wapi_p2p_transfer_tests_SUITE.erl b/apps/wapi/test/wapi_p2p_transfer_tests_SUITE.erl new file mode 100644 index 0000000..98e5d5a --- /dev/null +++ b/apps/wapi/test/wapi_p2p_transfer_tests_SUITE.erl @@ -0,0 +1,402 @@ +-module(wapi_p2p_transfer_tests_SUITE). + +-include_lib("common_test/include/ct.hrl"). + +-include_lib("damsel/include/dmsl_domain_config_thrift.hrl"). + +-include_lib("jose/include/jose_jwk.hrl"). +-include_lib("wapi_wallet_dummy_data.hrl"). + +-include_lib("fistful_proto/include/ff_proto_p2p_transfer_thrift.hrl"). + +-export([all/0]). +-export([groups/0]). +-export([init_per_suite/1]). +-export([end_per_suite/1]). +-export([init_per_group/2]). +-export([end_per_group/2]). +-export([init_per_testcase/2]). +-export([end_per_testcase/2]). + +-export([init/1]). + +-export([ + create/1, + create_quote/1, + create_with_quote_token/1, + create_with_bad_quote_token/1, + get/1, + fail_unauthorized/1 +]). + +% common-api is used since it is the domain used in production RN +% TODO: change to wallet-api (or just omit since it is the default one) when new tokens will be a thing +-define(DOMAIN, <<"common-api">>). +-define(badresp(Code), {error, {invalid_response_code, Code}}). +-define(emptyresp(Code), {error, {Code, #{}}}). + +-type test_case_name() :: atom(). +-type config() :: [{atom(), any()}]. +-type group_name() :: atom(). + +-behaviour(supervisor). + +-spec init([]) -> + {ok, {supervisor:sup_flags(), [supervisor:child_spec()]}}. +init([]) -> + {ok, {#{strategy => one_for_all, intensity => 1, period => 1}, []}}. + +-spec all() -> + [test_case_name()]. +all() -> + [ + {group, base} + ]. + +-spec groups() -> + [{group_name(), list(), [test_case_name()]}]. +groups() -> + [ + {base, [], + [ + create, + create_quote, + create_with_quote_token, + create_with_bad_quote_token, + get, + fail_unauthorized + ] + } + ]. + +%% +%% starting/stopping +%% +-spec init_per_suite(config()) -> + config(). +init_per_suite(Config) -> + %% TODO remove this after cut off wapi + ok = application:set_env(wapi, transport, thrift), + ct_helper:makeup_cfg([ + ct_helper:test_case_name(init), + ct_payment_system:setup(#{ + optional_apps => [ + bender_client, + wapi_woody_client, + wapi + ] + }) + ], Config). + +-spec end_per_suite(config()) -> + _. +end_per_suite(C) -> + %% TODO remove this after cut off wapi + ok = ct_payment_system:shutdown(C). + +-spec init_per_group(group_name(), config()) -> + config(). +init_per_group(Group, Config) when Group =:= base -> + ok = ff_context:save(ff_context:create(#{ + party_client => party_client:create_client(), + woody_context => woody_context:new(<<"init_per_group/", (atom_to_binary(Group, utf8))/binary>>) + })), + Party = create_party(Config), + BasePermissions = [ + {[party], read}, + {[party], write} + ], + {ok, Token} = wapi_ct_helper:issue_token(Party, BasePermissions, {deadline, 10}, ?DOMAIN), + Config1 = [{party, Party} | Config], + ContextPcidss = get_context("wapi-pcidss:8080", Token), + [ + {context_pcidss, ContextPcidss}, + {context, wapi_ct_helper:get_context(Token)} | + Config1 + ]; +init_per_group(_, Config) -> + Config. + +-spec end_per_group(group_name(), config()) -> + _. +end_per_group(_Group, _C) -> + ok. + +-spec init_per_testcase(test_case_name(), config()) -> + config(). +init_per_testcase(Name, C) -> + C1 = ct_helper:makeup_cfg([ct_helper:test_case_name(Name), ct_helper:woody_ctx()], C), + ok = ct_helper:set_context(C1), + [{test_sup, wapi_ct_helper:start_mocked_service_sup(?MODULE)} | C1]. + +-spec end_per_testcase(test_case_name(), config()) -> + config(). +end_per_testcase(_Name, C) -> + ok = ct_helper:unset_context(), + wapi_ct_helper:stop_mocked_service_sup(?config(test_sup, C)), + ok. + +%%% Tests + +-spec create(config()) -> + _. +create(C) -> + mock_services(C), + SenderToken = store_bank_card(C, <<"4150399999000900">>, <<"12/2025">>, <<"Buka Bjaka">>), + ReceiverToken = store_bank_card(C, <<"4150399999000900">>, <<"12/2025">>, <<"Buka Bjaka">>), + {ok, _} = call_api( + fun swag_client_wallet_p2_p_api:create_p2_p_transfer/3, + #{ + body => #{ + <<"identityID">> => <<"id">>, + <<"body">> => #{ + <<"amount">> => ?INTEGER, + <<"currency">> => ?RUB + }, + <<"sender">> => #{ + <<"type">> => <<"BankCardSenderResourceParams">>, + <<"token">> => SenderToken, + <<"authData">> => <<"session id">> + }, + <<"receiver">> => #{ + <<"type">> => <<"BankCardReceiverResourceParams">>, + <<"token">> => ReceiverToken + }, + <<"contactInfo">> => #{ + <<"email">> => <<"some@mail.com">>, + <<"phoneNumber">> => <<"+79990000101">> + } + } + }, + ct_helper:cfg(context, C) + ). + +-spec create_quote(config()) -> + _. +create_quote(C) -> + IdentityID = <<"id">>, + PartyID = ?config(party, C), + wapi_ct_helper:mock_services([ + {fistful_identity, fun('Get', _) -> {ok, ?IDENTITY(PartyID)}; + ('GetContext', _) -> {ok, ?DEFAULT_CONTEXT(PartyID)} end}, + {p2p_transfer, fun('GetQuote', _) -> {ok, ?P2P_TRANSFER_QUOTE(IdentityID)} end} + ], C), + SenderToken = store_bank_card(C, <<"4150399999000900">>, <<"12/2025">>, <<"Buka Bjaka">>), + ReceiverToken = store_bank_card(C, <<"4150399999000900">>, <<"12/2025">>, <<"Buka Bjaka">>), + {ok, #{<<"token">> := Token}} = call_api( + fun swag_client_wallet_p2_p_api:quote_p2_p_transfer/3, + #{ + body => #{ + <<"identityID">> => IdentityID, + <<"body">> => #{ + <<"amount">> => ?INTEGER, + <<"currency">> => ?RUB + }, + <<"sender">> => #{ + <<"type">> => <<"BankCardSenderResource">>, + <<"token">> => SenderToken + }, + <<"receiver">> => #{ + <<"type">> => <<"BankCardReceiverResource">>, + <<"token">> => ReceiverToken + } + } + }, + ct_helper:cfg(context, C) + ), + {ok, {_, _, Payload}} = uac_authorizer_jwt:verify(Token, #{}), + {ok, #p2p_transfer_Quote{identity_id = IdentityID}} = wapi_p2p_quote:decode_token_payload(Payload). + +-spec create_with_quote_token(config()) -> + _. +create_with_quote_token(C) -> + IdentityID = <<"id">>, + PartyID = ?config(party, C), + wapi_ct_helper:mock_services([ + {bender_thrift, fun('GenerateID', _) -> {ok, ?GENERATE_ID_RESULT} end}, + {fistful_identity, fun('Get', _) -> {ok, ?IDENTITY(PartyID)}; + ('GetContext', _) -> {ok, ?DEFAULT_CONTEXT(PartyID)} end}, + {p2p_transfer, fun('GetQuote', _) -> {ok, ?P2P_TRANSFER_QUOTE(IdentityID)}; + ('Create', _) -> {ok, ?P2P_TRANSFER(PartyID)} end} + ], C), + SenderToken = store_bank_card(C, <<"4150399999000900">>, <<"12/2025">>, <<"Buka Bjaka">>), + ReceiverToken = store_bank_card(C, <<"4150399999000900">>, <<"12/2025">>, <<"Buka Bjaka">>), + {ok, #{<<"token">> := QuoteToken}} = call_api( + fun swag_client_wallet_p2_p_api:quote_p2_p_transfer/3, + #{ + body => #{ + <<"identityID">> => IdentityID, + <<"body">> => #{ + <<"amount">> => ?INTEGER, + <<"currency">> => ?RUB + }, + <<"sender">> => #{ + <<"type">> => <<"BankCardSenderResource">>, + <<"token">> => SenderToken + }, + <<"receiver">> => #{ + <<"type">> => <<"BankCardReceiverResource">>, + <<"token">> => ReceiverToken + } + } + }, + ct_helper:cfg(context, C) + ), + {ok, _} = call_api( + fun swag_client_wallet_p2_p_api:create_p2_p_transfer/3, + #{ + body => #{ + <<"identityID">> => IdentityID, + <<"body">> => #{ + <<"amount">> => ?INTEGER, + <<"currency">> => ?RUB + }, + <<"sender">> => #{ + <<"type">> => <<"BankCardSenderResourceParams">>, + <<"token">> => SenderToken, + <<"authData">> => <<"session id">> + }, + <<"quoteToken">> => QuoteToken, + <<"receiver">> => #{ + <<"type">> => <<"BankCardReceiverResourceParams">>, + <<"token">> => ReceiverToken + }, + <<"contactInfo">> => #{ + <<"email">> => <<"some@mail.com">>, + <<"phoneNumber">> => <<"+79990000101">> + } + } + }, + ct_helper:cfg(context, C) + ). + +-spec create_with_bad_quote_token(config()) -> + _. +create_with_bad_quote_token(C) -> + mock_services(C), + SenderToken = store_bank_card(C, <<"4150399999000900">>, <<"12/2025">>, <<"Buka Bjaka">>), + ReceiverToken = store_bank_card(C, <<"4150399999000900">>, <<"12/2025">>, <<"Buka Bjaka">>), + {error, {422, #{ + <<"message">> := <<"Token can't be verified">> + }}} = call_api( + fun swag_client_wallet_p2_p_api:create_p2_p_transfer/3, + #{ + body => #{ + <<"identityID">> => <<"id">>, + <<"body">> => #{ + <<"amount">> => ?INTEGER, + <<"currency">> => ?RUB + }, + <<"sender">> => #{ + <<"type">> => <<"BankCardSenderResourceParams">>, + <<"token">> => SenderToken, + <<"authData">> => <<"session id">> + }, + <<"quoteToken">> => <<"bad_quote_token">>, + <<"receiver">> => #{ + <<"type">> => <<"BankCardReceiverResourceParams">>, + <<"token">> => ReceiverToken + }, + <<"contactInfo">> => #{ + <<"email">> => <<"some@mail.com">>, + <<"phoneNumber">> => <<"+79990000101">> + } + } + }, + ct_helper:cfg(context, C) + ). + +-spec get(config()) -> + _. +get(C) -> + PartyID = ?config(party, C), + wapi_ct_helper:mock_services([ + {p2p_transfer, fun('Get', _) -> {ok, ?P2P_TRANSFER(PartyID)} end} + ], C), + {ok, _} = call_api( + fun swag_client_wallet_p2_p_api:get_p2_p_transfer/3, + #{ + binding => #{ + <<"p2pTransferID">> => ?STRING + } + }, + ct_helper:cfg(context, C) +). + +-spec fail_unauthorized(config()) -> + _. +fail_unauthorized(C) -> + WrongPartyID = <<"kek">>, + mock_services(C, WrongPartyID), + SenderToken = store_bank_card(C, <<"4150399999000900">>, <<"12/2025">>, <<"Buka Bjaka">>), + ReceiverToken = store_bank_card(C, <<"4150399999000900">>, <<"12/2025">>, <<"Buka Bjaka">>), + {error, {422, #{ + <<"message">> := <<"No such identity">> + }}} = call_api( + fun swag_client_wallet_p2_p_api:create_p2_p_transfer/3, + #{ + body => #{ + <<"identityID">> => <<"id">>, + <<"body">> => #{ + <<"amount">> => ?INTEGER, + <<"currency">> => ?RUB + }, + <<"sender">> => #{ + <<"type">> => <<"BankCardSenderResourceParams">>, + <<"token">> => SenderToken, + <<"authData">> => <<"session id">> + }, + <<"receiver">> => #{ + <<"type">> => <<"BankCardReceiverResourceParams">>, + <<"token">> => ReceiverToken + }, + <<"contactInfo">> => #{ + <<"email">> => <<"some@mail.com">>, + <<"phoneNumber">> => <<"+79990000101">> + } + } + }, + ct_helper:cfg(context, C) + ). + +%% + +-spec call_api(function(), map(), wapi_client_lib:context()) -> + {ok, term()} | {error, term()}. +call_api(F, Params, Context) -> + {Url, PreparedParams, Opts} = wapi_client_lib:make_request(Context, Params), + Response = F(Url, PreparedParams, Opts), + wapi_client_lib:handle_response(Response). + +create_party(_C) -> + ID = genlib:bsuuid(), + _ = ff_party:create(ID), + ID. + +mock_services(C) -> + mock_services(C, ?config(party, C)). + +mock_services(C, ContextPartyID) -> + PartyID = ?config(party, C), + wapi_ct_helper:mock_services([ + {bender_thrift, fun('GenerateID', _) -> {ok, ?GENERATE_ID_RESULT} end}, + {fistful_identity, fun('Get', _) -> {ok, ?IDENTITY(PartyID)}; + ('GetContext', _) -> {ok, ?DEFAULT_CONTEXT(ContextPartyID)} end}, + {p2p_transfer, fun('Create', _) -> {ok, ?P2P_TRANSFER(PartyID)} end} + ], C). + +store_bank_card(C, Pan, ExpDate, CardHolder) -> + {ok, Res} = call_api( + fun swag_client_payres_payment_resources_api:store_bank_card/3, + #{body => genlib_map:compact(#{ + <<"type">> => <<"BankCard">>, + <<"cardNumber">> => Pan, + <<"expDate">> => ExpDate, + <<"cardHolder">> => CardHolder + })}, + ct_helper:cfg(context_pcidss, C) + ), + maps:get(<<"token">>, Res). + +get_context(Endpoint, Token) -> + wapi_client_lib:get_context(Endpoint, Token, 10000, ipv4). diff --git a/apps/wapi/test/wapi_thrift_SUITE.erl b/apps/wapi/test/wapi_thrift_SUITE.erl index a16d0fd..068c56c 100644 --- a/apps/wapi/test/wapi_thrift_SUITE.erl +++ b/apps/wapi/test/wapi_thrift_SUITE.erl @@ -2,6 +2,7 @@ -include_lib("stdlib/include/assert.hrl"). -include_lib("fistful_proto/include/ff_proto_fistful_thrift.hrl"). +-include_lib("fistful_proto/include/ff_proto_p2p_transfer_thrift.hrl"). -include_lib("wapi_wallet_dummy_data.hrl"). -export([all/0]). @@ -18,6 +19,7 @@ -export([identity_challenge_check_test/1]). -export([destination_check_test/1]). -export([w2w_transfer_check_test/1]). +-export([p2p_transfer_check_test/1]). -export([withdrawal_check_test/1]). % common-api is used since it is the domain used in production RN @@ -48,6 +50,7 @@ groups() -> wallet_check_test, destination_check_test, w2w_transfer_check_test, + p2p_transfer_check_test, withdrawal_check_test ]} ]. @@ -189,6 +192,23 @@ w2w_transfer_check_test(C) -> W2WTransferID2 = create_w2w_transfer(WalletID21, WalletID22, C), ?assertEqual(Keys, maps:keys(get_w2w_transfer(W2WTransferID2, C))). +-spec p2p_transfer_check_test(config()) -> test_return(). + +p2p_transfer_check_test(C) -> + Name = <<"Keyn Fawkes">>, + Provider = ?ID_PROVIDER, + Class = ?ID_CLASS, + IdentityID = create_identity(Name, Provider, Class, C), + Token = store_bank_card(C, <<"4150399999000900">>, <<"12/2025">>, <<"Buka Bjaka">>), + P2PTransferID = create_p2p_transfer(Token, Token, IdentityID, C), + P2PTransfer = get_p2p_transfer(P2PTransferID, C), + ok = application:set_env(wapi, transport, thrift), + P2PTransferIDThrift = create_p2p_transfer(Token, Token, IdentityID, C), + P2PTransferThrift = get_p2p_transfer(P2PTransferIDThrift, C), + ?assertEqual(maps:keys(P2PTransfer), maps:keys(P2PTransferThrift)), + ?assertEqual(maps:without([<<"id">>, <<"createdAt">>], P2PTransfer), + maps:without([<<"id">>, <<"createdAt">>], P2PTransferThrift)). + -spec withdrawal_check_test(config()) -> test_return(). withdrawal_check_test(C) -> @@ -228,6 +248,21 @@ get_context(Endpoint, Token) -> %% +store_bank_card(C, Pan, ExpDate, CardHolder) -> + {ok, Res} = call_api( + fun swag_client_payres_payment_resources_api:store_bank_card/3, + #{body => genlib_map:compact(#{ + <<"type">> => <<"BankCard">>, + <<"cardNumber">> => Pan, + <<"expDate">> => ExpDate, + <<"cardHolder">> => CardHolder + })}, + ct_helper:cfg(context_pcidss, C) + ), + maps:get(<<"token">>, Res). + +%% + create_identity(Name, Provider, Class, C) -> {ok, Identity} = call_api( fun swag_client_wallet_identities_api:create_identity/3, @@ -401,6 +436,78 @@ get_w2w_transfer(W2WTransferID2, C) -> ), W2WTransfer. +create_p2p_transfer(SenderToken, ReceiverToken, IdentityID, C) -> + DefaultParams = #{ + <<"identityID">> => IdentityID, + <<"sender">> => #{ + <<"type">> => <<"BankCardSenderResourceParams">>, + <<"token">> => SenderToken, + <<"authData">> => <<"session id">> + }, + <<"receiver">> => #{ + <<"type">> => <<"BankCardReceiverResourceParams">>, + <<"token">> => ReceiverToken + }, + <<"quoteToken">> => get_quote_token(SenderToken, ReceiverToken, IdentityID, C), + <<"body">> => #{ + <<"amount">> => ?INTEGER, + <<"currency">> => ?RUB + }, + <<"contactInfo">> => #{ + <<"email">> => <<"some@mail.com">>, + <<"phoneNumber">> => <<"+79990000101">> + } + }, + {ok, P2PTransfer} = call_api( + fun swag_client_wallet_p2_p_api:create_p2_p_transfer/3, + #{body => DefaultParams}, + ct_helper:cfg(context, C) + ), + maps:get(<<"id">>, P2PTransfer). + +get_quote_token(SenderToken, ReceiverToken, IdentityID, C) -> + PartyID = ct_helper:cfg(party, C), + {ok, SenderBankCard} = wapi_crypto:decrypt_bankcard_token(SenderToken), + {ok, ReceiverBankCard} = wapi_crypto:decrypt_bankcard_token(ReceiverToken), + {ok, PartyRevision} = ff_party:get_revision(PartyID), + Quote = #p2p_transfer_Quote{ + identity_id = IdentityID, + created_at = <<"1970-01-01T00:00:00.123Z">>, + expires_on = <<"1970-01-01T00:00:00.321Z">>, + party_revision = PartyRevision, + domain_revision = 1, + fees = #'Fees'{fees = #{}}, + body = #'Cash'{ + amount = ?INTEGER, + currency = #'CurrencyRef'{ + symbolic_code = ?RUB + } + }, + sender = {bank_card, #'ResourceBankCard'{ + bank_card = #'BankCard'{ + token = SenderBankCard#'BankCard'.token, + bin_data_id = {i, 123} + } + }}, + receiver = {bank_card, #'ResourceBankCard'{ + bank_card = #'BankCard'{ + token = ReceiverBankCard#'BankCard'.token, + bin_data_id = {i, 123} + } + }} + }, + Payload = wapi_p2p_quote:create_token_payload(Quote, PartyID), + {ok, QuoteToken} = uac_authorizer_jwt:issue(wapi_utils:get_unique_id(), PartyID, Payload, wapi_auth:get_signee()), + QuoteToken. + +get_p2p_transfer(P2PTransferID, C) -> + {ok, P2PTransfer} = call_api( + fun swag_client_wallet_p2_p_api:get_p2_p_transfer/3, + #{binding => #{<<"p2pTransferID">> => P2PTransferID}}, + ct_helper:cfg(context, C) + ), + P2PTransfer. + await_destination(DestID) -> authorized = ct_helper:await( authorized, @@ -511,6 +618,39 @@ get_default_termset() -> } ]} }, + p2p = #domain_P2PServiceTerms{ + currencies = {value, ?ordset([?cur(<<"RUB">>), ?cur(<<"USD">>)])}, + allow = {constant, true}, + cash_limit = {decisions, [ + #domain_CashLimitDecision{ + if_ = {condition, {currency_is, ?cur(<<"RUB">>)}}, + then_ = {value, ?cashrng( + {inclusive, ?cash( 0, <<"RUB">>)}, + {exclusive, ?cash(10001, <<"RUB">>)} + )} + } + ]}, + cash_flow = {decisions, [ + #domain_CashFlowDecision{ + if_ = {condition, {currency_is, ?cur(<<"RUB">>)}}, + then_ = {value, [ + ?cfpost( + {wallet, sender_settlement}, + {wallet, receiver_settlement}, + ?share(1, 1, operation_amount) + ) + ]} + } + ]}, + fees = {decisions, [ + #domain_FeeDecision{ + if_ = {condition, {currency_is, ?cur(<<"RUB">>)}}, + then_ = {value, #domain_Fees{ + fees = #{surplus => ?share(1, 1, operation_amount)} + }} + } + ]} + }, w2w = #domain_W2WServiceTerms{ currencies = {value, ?ordset([?cur(<<"RUB">>), ?cur(<<"USD">>)])}, allow = {constant, true}, diff --git a/apps/wapi/test/wapi_wallet_dummy_data.hrl b/apps/wapi/test/wapi_wallet_dummy_data.hrl index 9ccb8e4..8c97ddf 100644 --- a/apps/wapi/test/wapi_wallet_dummy_data.hrl +++ b/apps/wapi/test/wapi_wallet_dummy_data.hrl @@ -110,7 +110,8 @@ expected_max = ?INTEGER }). --define(RESOURCE, {bank_card, #'BankCard'{ +-define(BANK_CARD, #'BankCard'{ + bin_data_id = {i, ?INTEGER}, token = ?STRING, bin = <<"424242">>, masked_pan = <<"4242">>, @@ -118,7 +119,9 @@ payment_system = visa, issuer_country = rus, card_type = debit -}}). +}). + +-define(RESOURCE, {bank_card, ?BANK_CARD}). -define(DESTINATION_STATUS, {authorized, #dst_Authorized{}}). @@ -437,3 +440,47 @@ ]) } }). + +-define(RESOURCE_BANK_CARD, {bank_card, #'ResourceBankCard'{ + bank_card = ?BANK_CARD +}}). + +-define(RAW_RESOURCE, {resource, #'p2p_transfer_RawResource'{ + contact_info = #'ContactInfo'{}, + resource = ?RESOURCE_BANK_CARD +}}). + +-define(P2P_TRANSFER(PartyID), #p2p_transfer_P2PTransferState{ + id = ?STRING, + owner = ?STRING, + sender = ?RAW_RESOURCE, + receiver = ?RAW_RESOURCE, + body = ?CASH, + status = {pending, #p2p_status_Pending{}}, + created_at = ?TIMESTAMP, + domain_revision = ?INTEGER, + party_revision = ?INTEGER, + operation_timestamp = ?TIMESTAMP, + external_id = ?STRING, + metadata = ?DEFAULT_METADATA(), + context = ?DEFAULT_CONTEXT(PartyID), + effective_final_cash_flow = #cashflow_FinalCashFlow{ + postings = [] + }, + sessions = [], + adjustments = [] +}). + +-define(FEES, #'Fees'{fees = #{operation_amount => ?CASH}}). + +-define(P2P_TRANSFER_QUOTE(IdentityID), #p2p_transfer_Quote{ + body = ?CASH, + created_at = ?TIMESTAMP, + expires_on = ?TIMESTAMP, + domain_revision = ?INTEGER, + party_revision = ?INTEGER, + identity_id = IdentityID, + sender = ?RESOURCE_BANK_CARD, + receiver = ?RESOURCE_BANK_CARD, + fees = ?FEES +}). diff --git a/apps/wapi_woody_client/src/wapi_woody_client.erl b/apps/wapi_woody_client/src/wapi_woody_client.erl index 9334bc3..9b399e2 100644 --- a/apps/wapi_woody_client/src/wapi_woody_client.erl +++ b/apps/wapi_woody_client/src/wapi_woody_client.erl @@ -94,6 +94,8 @@ get_service_modname(fistful_p2p_template) -> {ff_proto_p2p_template_thrift, 'Management'}; get_service_modname(webhook_manager) -> {ff_proto_webhooker_thrift, 'WebhookManager'}; +get_service_modname(p2p_transfer) -> + {ff_proto_p2p_transfer_thrift, 'Management'}; get_service_modname(w2w_transfer) -> {ff_proto_w2w_transfer_thrift, 'Management'}.