From f653e420a0516cc02113bbd198b11c762c8c1528 Mon Sep 17 00:00:00 2001 From: Andrew Mayorov Date: Wed, 24 Jan 2018 17:16:39 +0300 Subject: [PATCH] CAPI-231: Put the notion of digital wallets into this shithole (#145) * CAPI-231: Proudly employ cowboy_access_log * BA-52: Bump to master rbkmoney/swag@13e66dd * BA-52: Bump to master rbkmoney/damsel@620cca5 --- apps/capi/src/capi.app.src | 1 + apps/capi/src/capi_real_handler.erl | 301 +++++++++++++------------- apps/capi/src/capi_swagger_server.erl | 55 +---- apps/capi/src/capi_utils.erl | 58 +++++ apps/capi/test/capi_tests_SUITE.erl | 98 +++++++-- rebar.config | 5 + rebar.lock | 10 +- schemes/swag | 2 +- 8 files changed, 307 insertions(+), 223 deletions(-) diff --git a/apps/capi/src/capi.app.src b/apps/capi/src/capi.app.src index bb6f52b..ce152ee 100644 --- a/apps/capi/src/capi.app.src +++ b/apps/capi/src/capi.app.src @@ -16,6 +16,7 @@ swag_server, jose, cowboy_cors, + cowboy_access_log, rfc3339, base64url, snowflake, diff --git a/apps/capi/src/capi_real_handler.erl b/apps/capi/src/capi_real_handler.erl index 033067a..43975e8 100644 --- a/apps/capi/src/capi_real_handler.erl +++ b/apps/capi/src/capi_real_handler.erl @@ -159,18 +159,25 @@ process_request('CreatePayment', Req, Context, ReqCtx) -> process_request('CreatePaymentResource', Req, Context, ReqCtx) -> Params = maps:get('PaymentResourceParams', Req), - ClientInfo = maps:get(<<"clientInfo">>, Params), - PaymentTool = maps:get(<<"paymentTool">>, Params), - case PaymentTool of - #{<<"paymentToolType">> := <<"CardData">>} -> - process_card_data(ClientInfo, PaymentTool, Context, ReqCtx); - #{<<"paymentToolType">> := <<"PaymentTerminalData">>} -> - process_payment_terminal_data(ClientInfo, PaymentTool, Context); - _ -> - {ok, {400, [], logic_error( - invalidPaymentTool, - <<"Specified payment tool is invalid or unsupported">> - )}} + ClientInfo = enrich_client_info(maps:get(<<"clientInfo">>, Params), Context), + try + V = maps:get(<<"paymentTool">>, Params), + {PaymentTool, PaymentSessionID} = case V of + #{<<"paymentToolType">> := <<"CardData">>} -> + process_card_data(V, ReqCtx); + #{<<"paymentToolType">> := <<"PaymentTerminalData">>} -> + process_payment_terminal_data(V, ReqCtx); + #{<<"paymentToolType">> := <<"DigitalWalletData">>} -> + process_digital_wallet_data(V, ReqCtx) + end, + {ok, {201, [], decode_disposable_payment_resource(#domain_DisposablePaymentResource{ + payment_tool = PaymentTool, + payment_session_id = PaymentSessionID, + client_info = encode_client_info(ClientInfo) + })}} + catch + Result -> + Result end; process_request('CreateInvoiceAccessToken', Req, Context, ReqCtx) -> @@ -1907,7 +1914,7 @@ encode_payer_params(#{ <<"contactInfo">> := ContactInfo }) -> PaymentTool = encode_payment_tool_token(Token), - {ClientInfo, PaymentSession} = unwrap_session(EncodedSession), + {ClientInfo, PaymentSession} = unwrap_payment_session(EncodedSession), {payment_resource, #payproc_PaymentResourcePayerParams{ resource = #domain_DisposablePaymentResource{ payment_tool = PaymentTool, @@ -1922,7 +1929,9 @@ encode_payment_tool_token(Token) -> #{<<"type">> := <<"bank_card">>} = Encoded -> encode_bank_card(Encoded); #{<<"type">> := <<"payment_terminal">>} = Encoded -> - encode_payment_terminal(Encoded) + encode_payment_terminal(Encoded); + #{<<"type">> := <<"digital_wallet">>} = Encoded -> + encode_digital_wallet(Encoded) catch error:badarg -> erlang:throw(invalid_token) @@ -1950,6 +1959,22 @@ decode_payment_terminal(#domain_PaymentTerminal{ <<"terminal_type">> => Type }). +decode_digital_wallet(#domain_DigitalWallet{ + provider = Provider, + id = ID +}) -> + capi_utils:map_to_base64url(#{ + <<"type">> => <<"digital_wallet">>, + <<"provider">> => atom_to_binary(Provider, utf8), + <<"id">> => ID + }). + +decode_client_info(ClientInfo) -> + #{ + <<"fingerprint">> => ClientInfo#domain_ClientInfo.fingerprint, + <<"ip">> => ClientInfo#domain_ClientInfo.ip_address + }. + encode_client_info(ClientInfo) -> #domain_ClientInfo{ fingerprint = maps:get(<<"fingerprint">>, ClientInfo), @@ -2334,6 +2359,12 @@ encode_payment_terminal(#{<<"terminal_type">> := Type}) -> terminal_type = binary_to_existing_atom(Type, utf8) }}. +encode_digital_wallet(#{<<"provider">> := Provider, <<"id">> := ID}) -> + {digital_wallet, #domain_DigitalWallet{ + provider = binary_to_existing_atom(Provider, utf8), + id = ID + }}. + encode_customer_params(PartyID, Params) -> #payproc_CustomerParams{ party_id = PartyID, @@ -2358,7 +2389,7 @@ encode_customer_binding_params(#{ } }) -> PaymentTool = encode_payment_tool_token(Token), - {ClientInfo, PaymentSession} = unwrap_session(EncodedSession), + {ClientInfo, PaymentSession} = unwrap_payment_session(EncodedSession), #payproc_CustomerBindingParams{ payment_resource = #domain_DisposablePaymentResource{ payment_tool = PaymentTool, @@ -2367,23 +2398,6 @@ encode_customer_binding_params(#{ } }. -wrap_session(ClientInfo, PaymentSession) -> - capi_utils:map_to_base64url(#{ - <<"clientInfo">> => ClientInfo, - <<"paymentSession">> => PaymentSession - }). - -unwrap_session(Encoded) -> - #{ - <<"clientInfo">> := ClientInfo, - <<"paymentSession">> := PaymentSession - } = try capi_utils:base64url_to_map(Encoded) - catch - error:badarg -> - erlang:throw(invalid_payment_session) - end, - {ClientInfo, PaymentSession}. - decode_invoice_event(#payproc_Event{ id = EventID, created_at = CreatedAt, @@ -2584,26 +2598,40 @@ decode_payer({payment_resource, #domain_PaymentResourcePayer{ decode_payment_tool_token({bank_card, BankCard}) -> decode_bank_card(BankCard); decode_payment_tool_token({payment_terminal, PaymentTerminal}) -> - decode_payment_terminal(PaymentTerminal). + decode_payment_terminal(PaymentTerminal); +decode_payment_tool_token({digital_wallet, DigitalWallet}) -> + decode_digital_wallet(DigitalWallet). -decode_payment_tool_details({bank_card, BankCard}) -> - decode_bank_card_details(<<"PaymentToolDetailsBankCard">>, BankCard); -decode_payment_tool_details({payment_terminal, #domain_PaymentTerminal{ +decode_payment_tool_details({bank_card, V}) -> + decode_bank_card_details(V, #{<<"detailsType">> => <<"PaymentToolDetailsBankCard">>}); +decode_payment_tool_details({payment_terminal, V}) -> + decode_payment_terminal_details(V, #{<<"detailsType">> => <<"PaymentToolDetailsPaymentTerminal">>}); +decode_payment_tool_details({digital_wallet, V}) -> + decode_digital_wallet_details(V, #{<<"detailsType">> => <<"PaymentToolDetailsDigitalWallet">>}). + +decode_bank_card_details(#domain_BankCard{ + 'payment_system' = PaymentSystem, + 'masked_pan' = MaskedPan +}, V) -> + V#{ + <<"cardNumberMask">> => decode_masked_pan(MaskedPan), + <<"paymentSystem">> => genlib:to_binary(PaymentSystem) + }. + +decode_payment_terminal_details(#domain_PaymentTerminal{ terminal_type = Type -}}) -> - #{ - <<"detailsType">> => <<"PaymentToolDetailsPaymentTerminal">>, +}, V) -> + V#{ <<"provider">> => genlib:to_binary(Type) }. -decode_bank_card_details(DetailsType, #domain_BankCard{ - 'payment_system' = PaymentSystem, - 'masked_pan' = MaskedPan -}) -> - #{ - <<"detailsType">> => DetailsType, - <<"cardNumberMask">> => decode_masked_pan(MaskedPan), - <<"paymentSystem">> => genlib:to_binary(PaymentSystem) +decode_digital_wallet_details(#domain_DigitalWallet{ + provider = qiwi, + id = ID +}, V) -> + V#{ + <<"digitalWalletDetailsType">> => <<"DigitalWalletDetailsQIWI">>, + <<"phoneNumberMask">> => mask_phone_number(ID) }. -define(MASKED_PAN_MAX_LENGTH, 4). @@ -2613,6 +2641,9 @@ decode_masked_pan(MaskedPan) when byte_size(MaskedPan) > ?MASKED_PAN_MAX_LENGTH decode_masked_pan(MaskedPan) -> MaskedPan. +mask_phone_number(PhoneNumber) -> + capi_utils:redact(PhoneNumber, <<"^\\+\\d(\\d{1,10}?)\\d{2,4}$">>). + decode_contact_info(#domain_ContactInfo{ phone_number = PhoneNumber, email = Email @@ -3047,8 +3078,8 @@ decode_russian_bank_account(#domain_RussianBankAccount{ bank_name = BankName, bank_post_account = BankPostAccount, bank_bik = BankBik -}) -> - #{ +}, V) -> + V#{ <<"account">> => Account, <<"bankName">> => BankName, <<"bankPostAccount">> => BankPostAccount, @@ -3061,8 +3092,8 @@ decode_international_bank_account(#domain_InternationalBankAccount{ bank_address = BankAddress, iban = Iban, bic = Bic -}) -> - #{ +}, V) -> + V#{ <<"accountHolder">> => AccountHolder, <<"bankName">> => BankName, <<"bankAddress">> => BankAddress, @@ -3158,7 +3189,7 @@ decode_legal_entity({ <<"representativePosition">> => RepresentativePosition, <<"representativeFullName">> => RepresentativeFullName, <<"representativeDocument">> => RepresentativeDocument, - <<"bankAccount">> => decode_russian_bank_account(BankAccount) + <<"bankAccount">> => decode_russian_bank_account(BankAccount, #{}) }; decode_legal_entity({ international_legal_entity, @@ -3336,18 +3367,12 @@ decode_stat_payout_status({Status, _}) -> decode_stat_payout_tool_details(PayoutType) -> decode_payout_tool_details(merchstat_to_domain(PayoutType)). -decode_payout_tool_details({bank_card, BankCard}) -> - decode_bank_card_details(<<"PayoutToolDetailsBankCard">>, BankCard); -decode_payout_tool_details({russian_bank_account, BankAccount}) -> - maps:merge( - #{<<"detailsType">> => <<"PayoutToolDetailsBankAccount">>}, - decode_russian_bank_account(BankAccount) - ); -decode_payout_tool_details({international_bank_account, BankAccount}) -> - maps:merge( - #{<<"detailsType">> => <<"PayoutToolDetailsInternationalBankAccount">>}, - decode_international_bank_account(BankAccount) - ). +decode_payout_tool_details({bank_card, V}) -> + decode_bank_card_details(V, #{<<"detailsType">> => <<"PayoutToolDetailsBankCard">>}); +decode_payout_tool_details({russian_bank_account, V}) -> + decode_russian_bank_account(V, #{<<"detailsType">> => <<"PayoutToolDetailsBankAccount">>}); +decode_payout_tool_details({international_bank_account, V}) -> + decode_international_bank_account(V, #{<<"detailsType">> => <<"PayoutToolDetailsInternationalBankAccount">>}). encode_payout_type('PayoutCard') -> <<"bank_card">>; @@ -3840,20 +3865,15 @@ decode_customer_binding(#payproc_CustomerBinding{ decode_disposable_payment_resource(#domain_DisposablePaymentResource{ payment_tool = PaymentTool, - payment_session_id = PaymentSession, - client_info = #domain_ClientInfo{ - fingerprint = Fingerprint, - ip_address = IP - } + payment_session_id = PaymentSessionID, + client_info = ClientInfo0 }) -> + ClientInfo = decode_client_info(ClientInfo0), #{ <<"paymentToolToken">> => decode_payment_tool_token(PaymentTool), - <<"paymentSession">> => PaymentSession, + <<"paymentSession">> => wrap_payment_session(ClientInfo, PaymentSessionID), <<"paymentToolDetails">> => decode_payment_tool_details(PaymentTool), - <<"clientInfo">> => #{ - <<"ip">> => IP, - <<"fingerprint">> => Fingerprint - } + <<"clientInfo">> => ClientInfo }. decode_customer_binding_status({Status, StatusInfo}) -> @@ -4122,24 +4142,12 @@ process_search_request_result(QueryType, Result, #{decode_fun := DecodeFun}) -> end. get_time(Key, Req) -> - to_universal_time(genlib_map:get(Key, Req)). - -to_universal_time(Tz = undefined) -> - Tz; -to_universal_time(Tz) -> - {ok, {Date, Time, Usec, TzOffset}} = rfc3339:parse(Tz), - TzSec = calendar:datetime_to_gregorian_seconds({Date, Time}), - %% The following crappy code is a dialyzer workaround - %% for the wrong rfc3339:parse/1 spec. - {UtcDate, UtcTime} = calendar:gregorian_seconds_to_datetime( - case TzOffset of - _ when is_integer(TzOffset) -> - TzSec - (60*TzOffset); - _ -> - TzSec - end), - {ok, Utc} = rfc3339:format({UtcDate, UtcTime, Usec, 0}), - Utc. + case genlib_map:get(Key, Req) of + Timestamp when is_binary(Timestamp) -> + capi_utils:to_universal_time(Timestamp); + undefined -> + undefined + end. get_split_interval(SplitSize, minute) -> SplitSize * 60; @@ -4362,7 +4370,9 @@ decode_payment_methods({value, PaymentMethodRefs}) -> decode_payment_method(bank_card, PaymentSystems) -> #{<<"method">> => <<"BankCard">>, <<"paymentSystems">> => lists:map(fun genlib:to_binary/1, PaymentSystems)}; decode_payment_method(payment_terminal, Providers) -> - #{<<"method">> => <<"PaymentTerminal">>, <<"providers">> => lists:map(fun genlib:to_binary/1, Providers)}. + #{<<"method">> => <<"PaymentTerminal">>, <<"providers">> => lists:map(fun genlib:to_binary/1, Providers)}; +decode_payment_method(digital_wallet, Providers) -> + #{<<"method">> => <<"DigitalWallet">>, <<"providers">> => lists:map(fun genlib:to_binary/1, Providers)}. compute_terms(ServiceName, Args, Context) -> service_call( @@ -4375,81 +4385,87 @@ compute_terms(ServiceName, Args, Context) -> reply_5xx(Code) when Code >= 500 andalso Code < 600 -> {Code, [], <<>>}. -process_card_data(ClientInfo0, PaymentTool, Context, ReqCtx) -> - CardData = get_card_data(PaymentTool), +process_card_data(Data, ReqCtx) -> Result = service_call( cds_storage, 'PutCardData', - [CardData], + [encode_card_data(Data)], ReqCtx ), case Result of {ok, #'PutCardDataResult'{ - session_id = PaymentSession, + session_id = SessionID, bank_card = BankCard }} -> - Token = decode_bank_card(BankCard), - PreparedIP = get_prepared_ip(Context), - ClientInfo = ClientInfo0#{<<"ip">> => PreparedIP}, - - Session = wrap_session(ClientInfo, PaymentSession), - Resp = #{ - <<"paymentToolToken">> => Token, - <<"paymentSession">> => Session, - <<"paymentToolDetails">> => decode_payment_tool_details({bank_card, BankCard}), - <<"clientInfo">> => ClientInfo - }, - {ok, {201, [], Resp}}; + {{bank_card, BankCard}, SessionID}; {exception, Exception} -> case Exception of #'InvalidCardData'{} -> - {ok, {400, [], logic_error(invalidRequest, <<"Card data is invalid">>)}}; + throw({ok, {400, [], logic_error(invalidRequest, <<"Card data is invalid">>)}}); #'KeyringLocked'{} -> % TODO % It's better for the cds to signal woody-level unavailability when the % keyring is locked, isn't it? It could always mention keyring lock as a % reason in a woody error definition. - {error, reply_5xx(503)} + throw({error, reply_5xx(503)}) end end. -get_card_data(PaymentTool) -> - {Month, Year} = parse_exp_date(genlib_map:get(<<"expDate">>, PaymentTool)), - CardNumber = genlib:to_binary(genlib_map:get(<<"cardNumber">>, PaymentTool)), +encode_card_data(CardData) -> + {Month, Year} = parse_exp_date(genlib_map:get(<<"expDate">>, CardData)), + CardNumber = genlib:to_binary(genlib_map:get(<<"cardNumber">>, CardData)), #'CardData'{ pan = CardNumber, exp_date = #'ExpDate'{ month = Month, year = Year }, - cardholder_name = genlib_map:get(<<"cardHolder">>, PaymentTool), - cvv = genlib_map:get(<<"cvv">>, PaymentTool) + cardholder_name = genlib_map:get(<<"cardHolder">>, CardData), + cvv = genlib_map:get(<<"cvv">>, CardData) }. -get_prepared_ip(Context) -> - #{ - ip_address := IP - } = get_peer_info(Context), - genlib:to_binary(inet:ntoa(IP)). - -process_payment_terminal_data(ClientInfo0, TerminalData, Context) -> +process_payment_terminal_data(Data, _ReqCtx) -> PaymentTerminal = #domain_PaymentTerminal{ terminal_type = binary_to_existing_atom( - genlib_map:get(<<"provider">>, TerminalData), + genlib_map:get(<<"provider">>, Data), utf8 ) }, - Token = decode_payment_terminal(PaymentTerminal), - PreparedIP = get_prepared_ip(Context), - ClientInfo = ClientInfo0#{<<"ip">> => PreparedIP}, - Session = wrap_session(ClientInfo, <<"">>), - Resp = #{ - <<"paymentToolToken">> => Token, - <<"paymentSession">> => Session, - <<"paymentToolDetails">> => decode_payment_tool_details({payment_terminal, PaymentTerminal}), - <<"clientInfo">> => ClientInfo - }, - {ok, {201, [], Resp}}. + {{payment_terminal, PaymentTerminal}, <<>>}. + +process_digital_wallet_data(Data, _ReqCtx) -> + DigitalWallet = case Data of + #{<<"digitalWalletType">> := <<"DigitalWalletQIWI">>} -> + #domain_DigitalWallet{ + provider = qiwi, + id = maps:get(<<"phoneNumber">>, Data) + } + end, + {{digital_wallet, DigitalWallet}, <<>>}. + +enrich_client_info(ClientInfo, Context) -> + ClientInfo#{<<"ip">> => prepare_client_ip(Context)}. + +prepare_client_ip(Context) -> + #{ip_address := IP} = get_peer_info(Context), + genlib:to_binary(inet:ntoa(IP)). + +wrap_payment_session(ClientInfo, PaymentSession) -> + capi_utils:map_to_base64url(#{ + <<"clientInfo">> => ClientInfo, + <<"paymentSession">> => PaymentSession + }). + +unwrap_payment_session(Encoded) -> + #{ + <<"clientInfo">> := ClientInfo, + <<"paymentSession">> := PaymentSession + } = try capi_utils:base64url_to_map(Encoded) + catch + error:badarg -> + erlang:throw(invalid_payment_session) + end, + {ClientInfo, PaymentSession}. get_default_url_lifetime() -> Now = erlang:system_time(second), @@ -4460,18 +4476,3 @@ get_default_url_lifetime() -> Error -> error(Error) end. - --ifdef(TEST). --include_lib("eunit/include/eunit.hrl"). - --spec test() -> _. - --spec to_universal_time_test() -> _. -to_universal_time_test() -> - ?assertEqual(undefined, to_universal_time(undefined)), - ?assertEqual(<<"2017-04-19T13:56:07Z">>, to_universal_time(<<"2017-04-19T13:56:07Z">>)), - ?assertEqual(<<"2017-04-19T13:56:07.530000Z">>, to_universal_time(<<"2017-04-19T13:56:07.53Z">>)), - ?assertEqual(<<"2017-04-19T10:36:07.530000Z">>, to_universal_time(<<"2017-04-19T13:56:07.53+03:20">>)), - ?assertEqual(<<"2017-04-19T17:16:07.530000Z">>, to_universal_time(<<"2017-04-19T13:56:07.53-03:20">>)). - --endif. %%TEST diff --git a/apps/capi/src/capi_swagger_server.erl b/apps/capi/src/capi_swagger_server.erl index b2fa90a..55c5fca 100644 --- a/apps/capi/src/capi_swagger_server.erl +++ b/apps/capi/src/capi_swagger_server.erl @@ -39,7 +39,7 @@ get_cowboy_config(LogicHandler) -> cowboy_cors, cowboy_handler ]}, - {onrequest, fun ?MODULE:request_hook/1}, + {onrequest, cowboy_access_log:get_request_hook()}, {onresponse, fun ?MODULE:response_hook/4} ]. @@ -52,10 +52,10 @@ request_hook(Req) -> -spec response_hook(cowboy:http_status(), cowboy:http_headers(), iodata(), cowboy_req:req()) -> cowboy_req:req(). -response_hook(Code, Headers, _, Req) -> +response_hook(Code, Headers, Body, Req) -> try {Code1, Headers1, Req1} = handle_response(Code, Headers, Req), - _ = log_access(Code1, Headers1, Req1), + _ = log_access(Code1, Headers1, Body, Req1), Req1 catch Class:Reason -> @@ -110,49 +110,6 @@ get_oops_body_safe(Code) -> get_oops_body(Code) -> genlib_map:get(Code, genlib_app:env(?APP, oops_bodies, #{}), undefined). -log_access(Code, Headers, Req) -> - {Method, _} = cowboy_req:method(Req), - {Path, _} = cowboy_req:path(Req), - {ReqLen, _} = cowboy_req:body_length(Req), - {ReqId, _} = cowboy_req:header(<<"x-request-id">>, Req, undefined), - RemAddr = get_remote_addr(Req), - RespLen = get_response_len(Headers), - Duration = get_request_duration(Req), - ReqMeta = [ - {remote_addr, RemAddr}, - {request_method, Method}, - {request_path, Path}, - {request_length, ReqLen}, - {response_length, RespLen}, - {request_time, Duration}, - {'http_x-request-id', ReqId}, - {status, Code} - ], - Meta = orddict:merge(fun(_Key, New, _Old) -> New end, ReqMeta, lager:md()), - %% Call lager:log/5 here directly in order to pass request metadata (fused into - %% lager metadata) without storing it in a process dict via lager:md/1. - lager:log(capi_access_lager_event, info, Meta, "", []). - -get_remote_addr(Req) -> - case swag_server_handler_api:determine_peer(Req) of - {{ok, #{ip_address := IP}}, _} -> - genlib:to_binary(inet:ntoa(IP)); - {_, _} -> - undefined - end. - -get_request_duration(Req) -> - case cowboy_req:meta(?START_TIME_TAG, Req) of - {undefined, _} -> - undefined; - {StartTime, _} -> - (genlib_time:ticks() - StartTime) / 1000000 - end. - -get_response_len(Headers) -> - case lists:keyfind(<<"content-length">>, 1, Headers) of - {_, Len} -> - genlib:to_int(Len); - false -> - undefined - end. +log_access(Code, Headers, Body, Req) -> + LogFun = cowboy_access_log:get_response_hook(capi_access_lager_event), + LogFun(Code, Headers, Body, Req). diff --git a/apps/capi/src/capi_utils.erl b/apps/capi/src/capi_utils.erl index 573747e..0f23f1b 100644 --- a/apps/capi/src/capi_utils.erl +++ b/apps/capi/src/capi_utils.erl @@ -4,6 +4,10 @@ -export([base64url_to_map/1]). -export([map_to_base64url/1]). +-export([to_universal_time/1]). + +-export([redact/2]). + -spec logtag_process(atom(), any()) -> ok. logtag_process(Key, Value) when is_atom(Key) -> @@ -27,3 +31,57 @@ map_to_base64url(Map) when is_map(Map) -> _ = lager:debug("encoding map ~p to base64 failed with ~p:~p", [Map, Class, Reason]), erlang:error(badarg) end. + +-spec redact(Subject :: binary(), Pattern :: binary()) -> Redacted :: binary(). +redact(Subject, Pattern) -> + case re:run(Subject, Pattern, [global, {capture, all_but_first, index}]) of + {match, Captures} -> + lists:foldl(fun redact_match/2, Subject, Captures); + nomatch -> + Subject + end. + +redact_match({S, Len}, Subject) -> + <> = Subject, + <
>, Len))/binary, Rest/binary>>;
+redact_match([Capture], Message) ->
+    redact_match(Capture, Message).
+
+-spec to_universal_time(Timestamp :: binary()) -> TimestampUTC :: binary().
+to_universal_time(Timestamp) ->
+    {ok, {Date, Time, Usec, TZOffset}} = rfc3339:parse(Timestamp),
+    Seconds = calendar:datetime_to_gregorian_seconds({Date, Time}),
+    %% The following crappy code is a dialyzer workaround
+    %% for the wrong rfc3339:parse/1 spec.
+    {DateUTC, TimeUTC} = calendar:gregorian_seconds_to_datetime(
+        case TZOffset of
+            _ when is_integer(TZOffset) ->
+                Seconds - (60 * TZOffset);
+            _ ->
+                Seconds
+        end
+    ),
+    {ok, TimestampUTC} = rfc3339:format({DateUTC, TimeUTC, Usec, 0}),
+    TimestampUTC.
+
+%%
+
+-ifdef(TEST).
+-include_lib("eunit/include/eunit.hrl").
+
+-spec test() -> _.
+
+-spec to_universal_time_test() -> _.
+to_universal_time_test() ->
+    ?assertEqual(<<"2017-04-19T13:56:07Z">>,        to_universal_time(<<"2017-04-19T13:56:07Z">>)),
+    ?assertEqual(<<"2017-04-19T13:56:07.530000Z">>, to_universal_time(<<"2017-04-19T13:56:07.53Z">>)),
+    ?assertEqual(<<"2017-04-19T10:36:07.530000Z">>, to_universal_time(<<"2017-04-19T13:56:07.53+03:20">>)),
+    ?assertEqual(<<"2017-04-19T17:16:07.530000Z">>, to_universal_time(<<"2017-04-19T13:56:07.53-03:20">>)).
+
+-spec redact_test() -> _.
+redact_test() ->
+    P1 = <<"^\\+\\d(\\d{1,10}?)\\d{2,4}$">>,
+    ?assertEqual(<<"+7******3210">>, redact(<<"+79876543210">>, P1)),
+    ?assertEqual(       <<"+1*11">>, redact(<<"+1111">>, P1)).
+
+-endif.
diff --git a/apps/capi/test/capi_tests_SUITE.erl b/apps/capi/test/capi_tests_SUITE.erl
index a30be1d..c00dad9 100644
--- a/apps/capi/test/capi_tests_SUITE.erl
+++ b/apps/capi/test/capi_tests_SUITE.erl
@@ -64,7 +64,10 @@
     cancel_payment_ok_test/1,
     capture_payment_ok_test/1,
 
-    create_payment_tool_token_ok_test/1,
+    create_visa_payment_resource_ok_test/1,
+    create_nspkmir_payment_resource_ok_test/1,
+    create_euroset_payment_resource_ok_test/1,
+    create_qw_payment_resource_ok_test/1,
 
     get_my_party_ok_test/1,
     suspend_my_party_ok_test/1,
@@ -167,7 +170,7 @@ invoice_access_token_tests() ->
         get_payment_by_id_ok_test,
         cancel_payment_ok_test,
         capture_payment_ok_test,
-        create_payment_tool_token_ok_test
+        {group, payment_resources}
     ].
 
 customer_access_token_tests() ->
@@ -190,6 +193,14 @@ groups() ->
                 woody_unknown_test
             ]
         },
+        {payment_resources, [],
+            [
+                create_visa_payment_resource_ok_test,
+                create_nspkmir_payment_resource_ok_test,
+                create_euroset_payment_resource_ok_test,
+                create_qw_payment_resource_ok_test
+            ]
+        },
         {operations_by_base_api_token, [],
             [
                 create_invoice_ok_test,
@@ -757,9 +768,9 @@ capture_payment_ok_test(Config) ->
     mock_services([{invoicing, fun('CapturePayment', _) -> {ok, ok} end}], Config),
     ok = capi_client_payments:capture_payment(?config(context, Config), ?STRING, ?STRING, ?STRING).
 
--spec create_payment_tool_token_ok_test(_) ->
+-spec create_visa_payment_resource_ok_test(_) ->
     _.
-create_payment_tool_token_ok_test(Config) ->
+create_visa_payment_resource_ok_test(Config) ->
     mock_services([
         {cds_storage, fun
             ('PutCardData', [#'CardData'{pan = <<"411111", _:6/binary, Mask:4/binary>>}]) ->
@@ -771,7 +782,30 @@ create_payment_tool_token_ok_test(Config) ->
                         masked_pan = Mask
                     },
                     session_id = ?STRING
-                }};
+                }}
+        end}
+    ], Config),
+    ClientInfo = #{<<"fingerprint">> => <<"test fingerprint">>},
+    {ok, #{<<"paymentToolDetails">> := #{
+        <<"detailsType">> := <<"PaymentToolDetailsBankCard">>,
+        <<"paymentSystem">> := <<"visa">>,
+        <<"cardNumberMask">> := <<"1111">>
+    }}} = capi_client_tokens:create_payment_resource(?config(context, Config), #{
+        <<"paymentTool">> => #{
+            <<"paymentToolType">> => <<"CardData">>,
+            <<"cardNumber">> => <<"4111111111111111">>,
+            <<"cardHolder">> => <<"Alexander Weinerschnitzel">>,
+            <<"expDate">> => <<"08/27">>,
+            <<"cvv">> => <<"232">>
+        },
+        <<"clientInfo">> => ClientInfo
+    }).
+
+-spec create_nspkmir_payment_resource_ok_test(_) ->
+    _.
+create_nspkmir_payment_resource_ok_test(Config) ->
+    mock_services([
+        {cds_storage, fun
             ('PutCardData', [#'CardData'{pan = <<"22001111", _:6/binary, Mask:2/binary>>}]) ->
                 {ok, #'PutCardDataResult'{
                     bank_card = #domain_BankCard{
@@ -784,27 +818,51 @@ create_payment_tool_token_ok_test(Config) ->
                 }}
         end}
     ], Config),
-    PaymentTool = #{
-        <<"paymentToolType">> => <<"CardData">>,
-        <<"cardHolder">> => <<"Alexander Weinerschnitzel">>,
-        <<"expDate">> => <<"08/27">>,
-        <<"cvv">> => <<"232">>
-    },
     ClientInfo = #{<<"fingerprint">> => <<"test fingerprint">>},
-    {ok, #{<<"paymentToolDetails">> := #{
-        <<"detailsType">> := <<"PaymentToolDetailsBankCard">>,
-        <<"paymentSystem">> := <<"visa">>,
-        <<"cardNumberMask">> := <<"1111">>
-    }}} = capi_client_tokens:create_payment_resource(?config(context, Config), #{
-        <<"paymentTool">> => PaymentTool#{<<"cardNumber">> => <<"4111111111111111">>},
-        <<"clientInfo">> => ClientInfo
-    }),
     {ok, #{<<"paymentToolDetails">> := #{
         <<"detailsType">> := <<"PaymentToolDetailsBankCard">>,
         <<"paymentSystem">> := <<"nspkmir">>,
         <<"cardNumberMask">> := <<"11">>
     }}} = capi_client_tokens:create_payment_resource(?config(context, Config), #{
-        <<"paymentTool">> => PaymentTool#{<<"cardNumber">> => <<"2200111111111111">>},
+        <<"paymentTool">> => #{
+            <<"paymentToolType">> => <<"CardData">>,
+            <<"cardNumber">> => <<"2200111111111111">>,
+            <<"cardHolder">> => <<"Alexander Weinerschnitzel">>,
+            <<"expDate">> => <<"08/27">>,
+            <<"cvv">> => <<"232">>
+        },
+        <<"clientInfo">> => ClientInfo
+    }).
+
+-spec create_euroset_payment_resource_ok_test(_) ->
+    _.
+create_euroset_payment_resource_ok_test(Config) ->
+    ClientInfo = #{<<"fingerprint">> => <<"test fingerprint">>},
+    {ok, #{<<"paymentToolDetails">> := #{
+        <<"detailsType">> := <<"PaymentToolDetailsPaymentTerminal">>,
+        <<"provider">> := <<"euroset">>
+    }}} = capi_client_tokens:create_payment_resource(?config(context, Config), #{
+        <<"paymentTool">> => #{
+            <<"paymentToolType">> => <<"PaymentTerminalData">>,
+            <<"provider">> => <<"euroset">>
+        },
+        <<"clientInfo">> => ClientInfo
+    }).
+
+-spec create_qw_payment_resource_ok_test(_) ->
+    _.
+create_qw_payment_resource_ok_test(Config) ->
+    ClientInfo = #{<<"fingerprint">> => <<"test fingerprint">>},
+    {ok, #{<<"paymentToolDetails">> := #{
+        <<"detailsType">> := <<"PaymentToolDetailsDigitalWallet">>,
+        <<"digitalWalletDetailsType">> := <<"DigitalWalletDetailsQIWI">>,
+        <<"phoneNumberMask">> := <<"+7******3210">>
+    }}} = capi_client_tokens:create_payment_resource(?config(context, Config), #{
+        <<"paymentTool">> => #{
+            <<"paymentToolType">> => <<"DigitalWalletData">>,
+            <<"digitalWalletType">> => <<"DigitalWalletQIWI">>,
+            <<"phoneNumber">> => <<"+79876543210">>
+        },
         <<"clientInfo">> => ClientInfo
     }).
 
diff --git a/rebar.config b/rebar.config
index 95a7626..8904b00 100644
--- a/rebar.config
+++ b/rebar.config
@@ -62,6 +62,11 @@
         {git, "https://github.com/danielwhite/cowboy_cors.git",
             {branch, "master"}
         }
+    },
+    {cowboy_access_log,
+        {git, "git@github.com:rbkmoney/cowboy_access_log.git",
+            {branch, "master"}
+        }
     }
 ]}.
 
diff --git a/rebar.lock b/rebar.lock
index 6ddf831..8509221 100644
--- a/rebar.lock
+++ b/rebar.lock
@@ -2,6 +2,10 @@
 [{<<"base64url">>,{pkg,<<"base64url">>,<<"0.0.1">>},0},
  {<<"certifi">>,{pkg,<<"certifi">>,<<"0.4.0">>},1},
  {<<"cowboy">>,{pkg,<<"cowboy">>,<<"1.0.4">>},0},
+ {<<"cowboy_access_log">>,
+  {git,"git@github.com:rbkmoney/cowboy_access_log.git",
+       {ref,"a06c299a9b05f6868c48f40b1aeb7c00b775ce12"}},
+  0},
  {<<"cowboy_cors">>,
   {git,"https://github.com/danielwhite/cowboy_cors.git",
        {ref,"392f5804b63fff2bd0fda67671d5b2fbe0badd37"}},
@@ -9,7 +13,7 @@
  {<<"cowlib">>,{pkg,<<"cowlib">>,<<"1.0.2">>},1},
  {<<"dmsl">>,
   {git,"git@github.com:rbkmoney/damsel.git",
-       {ref,"07fbb3057e78e37611e642160a7201fe31d6ff69"}},
+       {ref,"4ca2329c564b3730dbd742ae370be9919905b9de"}},
   0},
  {<<"genlib">>,
   {git,"https://github.com/rbkmoney/genlib.git",
@@ -32,7 +36,7 @@
  {<<"metrics">>,{pkg,<<"metrics">>,<<"1.0.1">>},1},
  {<<"mimerl">>,{pkg,<<"mimerl">>,<<"1.0.2">>},1},
  {<<"parse_trans">>,
-  {git,"git@github.com:rbkmoney/parse_trans.git",
+  {git,"https://github.com/rbkmoney/parse_trans.git",
        {ref,"5ee45f5bfa6c04329bea3281977b774f04c89f11"}},
   0},
  {<<"pooler">>,{pkg,<<"pooler">>,<<"1.5.0">>},0},
@@ -49,7 +53,7 @@
   1},
  {<<"woody">>,
   {git,"git@github.com:rbkmoney/woody_erlang.git",
-       {ref,"2d00bda10454534e230d452b7338debafaf0a869"}},
+       {ref,"ad1e91050c36d8de15f1c7d8dd8a2c682d2d158c"}},
   0},
  {<<"woody_user_identity">>,
   {git,"git@github.com:rbkmoney/woody_erlang_user_identity.git",
diff --git a/schemes/swag b/schemes/swag
index 963e8f2..8d4b00c 160000
--- a/schemes/swag
+++ b/schemes/swag
@@ -1 +1 @@
-Subproject commit 963e8f2933bc71710e81c4a18416ff18fede9549
+Subproject commit 8d4b00cd075196f364f25beb6d0093cb741b97a3