diff --git a/.gitignore b/.gitignore index 50331e8..de477f6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Build artifacts /_build/ +/_checkouts/ *.o *.beam *.plt diff --git a/apps/capi/src/capi.erl b/apps/capi/src/capi.erl index 8c914d9..820cade 100644 --- a/apps/capi/src/capi.erl +++ b/apps/capi/src/capi.erl @@ -14,6 +14,7 @@ -spec start(normal, any()) -> {ok, pid()} | {error, any()}. start(_StartType, _StartArgs) -> ok = setup_metrics(), + ok = validate_checkout_url_generation_config(), capi_sup:start_link(). -spec stop(any()) -> ok. @@ -24,3 +25,13 @@ stop(_State) -> setup_metrics() -> ok = woody_ranch_prometheus_collector:setup(), ok = woody_hackney_prometheus_collector:setup(). + +validate_checkout_url_generation_config() -> + case genlib_app:env(capi, checkout_url_generation) of + undefined -> + erlang:throw({missing, [capi, checkout_url_generation]}); + #{default_base_url := V} when is_binary(V) -> + ok; + _ -> + erlang:throw({missing, [capi, checkout_url_generation, default_base_url]}) + end. diff --git a/apps/capi/src/capi_handler_decoder_invoicing.erl b/apps/capi/src/capi_handler_decoder_invoicing.erl index 49ba856..6b78c37 100644 --- a/apps/capi/src/capi_handler_decoder_invoicing.erl +++ b/apps/capi/src/capi_handler_decoder_invoicing.erl @@ -25,7 +25,7 @@ -export([decode_recurrent_parent/1]). -export([decode_make_recurrent/1]). --export([make_invoice_and_token/2]). +-export([make_invoice_and_token/3]). -type processing_context() :: capi_handler:processing_context(). -type decode_data() :: capi_handler_decoder_utils:decode_data(). @@ -718,12 +718,18 @@ decode_mobile_phone(#domain_MobilePhone{cc = Cc, ctn = Ctn}) -> gen_phone_number(#{<<"cc">> := Cc, <<"ctn">> := Ctn}) -> <<"+", Cc/binary, Ctn/binary>>. --spec make_invoice_and_token(capi_handler_encoder:encode_data(), processing_context()) -> - capi_handler_decoder_utils:decode_data(). -make_invoice_and_token(Invoice, ProcessingContext) -> +-spec make_invoice_and_token( + capi_handler_encoder:encode_data(), + capi_handler_utils:url_params(), + processing_context() +) -> capi_handler_decoder_utils:decode_data(). +make_invoice_and_token(Invoice, UrlParams, ProcessingContext) -> + #{<<"payload">> := AccessToken} = + InvoiceAccessToken = capi_handler_utils:issue_access_token(Invoice, ProcessingContext), #{ <<"invoice">> => decode_invoice(Invoice), - <<"invoiceAccessToken">> => capi_handler_utils:issue_access_token(Invoice, ProcessingContext) + <<"invoiceAccessToken">> => InvoiceAccessToken, + <<"invoiceUrl">> => capi_handler_utils:create_checkout_url(Invoice, AccessToken, UrlParams, ProcessingContext) }. %% diff --git a/apps/capi/src/capi_handler_invoice_templates.erl b/apps/capi/src/capi_handler_invoice_templates.erl index b1ccc8d..5d58166 100644 --- a/apps/capi/src/capi_handler_invoice_templates.erl +++ b/apps/capi/src/capi_handler_invoice_templates.erl @@ -11,7 +11,13 @@ -behaviour(woody_server_thrift_handler). -export([handle_function/4]). --import(capi_handler_utils, [general_error/2, logic_error/2, conflict_error/1, map_service_result/1]). +-import(capi_handler_utils, [ + general_error/2, + logic_error/2, + conflict_error/1, + invalid_url_params_error/1, + map_service_result/1 +]). -spec prepare( OperationID :: capi_handler:operation_id(), @@ -156,24 +162,31 @@ prepare('CreateInvoiceWithTemplate' = OperationID, Req, Context) -> Process = fun() -> capi_handler:respond_if_undefined(InvoiceTpl, general_error(404, <<"Invoice template not found">>)), InvoiceParams = maps:get('InvoiceParamsWithTemplate', Req), + UrlParams = maps:get(<<"urlParams">>, InvoiceParams, #{}), PartyID = InvoiceTpl#domain_InvoiceTemplate.party_ref#domain_PartyConfigRef.id, - try create_invoice(PartyID, InvoiceTplID, InvoiceParams, Context, OperationID) of - {ok, #'payproc_Invoice'{invoice = Invoice}} -> - {ok, {201, #{}, capi_handler_decoder_invoicing:make_invoice_and_token(Invoice, Context)}}; - {exception, #base_InvalidRequest{errors = Errors}} -> - FormattedErrors = capi_handler_utils:format_request_errors(Errors), - {ok, logic_error('invalidRequest', FormattedErrors)}; - {exception, #payproc_InvalidPartyStatus{}} -> - {ok, logic_error('invalidPartyStatus', <<"Invalid party status">>)}; - {exception, #payproc_InvalidShopStatus{}} -> - {ok, logic_error('invalidShopStatus', <<"Invalid shop status">>)}; - {exception, #payproc_InvoiceTemplateNotFound{}} -> - {ok, general_error(404, <<"Invoice Template not found">>)}; - {exception, #payproc_InvoiceTemplateRemoved{}} -> - {ok, general_error(404, <<"Invoice Template not found">>)}; - {exception, #payproc_InvoiceTermsViolated{}} -> - {ok, logic_error('invoiceTermsViolated', <<"Invoice parameters violate terms">>)} + try + ok = validate_checkout_url_params(UrlParams), + case create_invoice(PartyID, InvoiceTplID, InvoiceParams, Context, OperationID) of + {ok, #'payproc_Invoice'{invoice = Invoice}} -> + {ok, + {201, #{}, capi_handler_decoder_invoicing:make_invoice_and_token(Invoice, UrlParams, Context)}}; + {exception, #base_InvalidRequest{errors = Errors}} -> + FormattedErrors = capi_handler_utils:format_request_errors(Errors), + {ok, logic_error('invalidRequest', FormattedErrors)}; + {exception, #payproc_InvalidPartyStatus{}} -> + {ok, logic_error('invalidPartyStatus', <<"Invalid party status">>)}; + {exception, #payproc_InvalidShopStatus{}} -> + {ok, logic_error('invalidShopStatus', <<"Invalid shop status">>)}; + {exception, #payproc_InvoiceTemplateNotFound{}} -> + {ok, general_error(404, <<"Invoice Template not found">>)}; + {exception, #payproc_InvoiceTemplateRemoved{}} -> + {ok, general_error(404, <<"Invoice Template not found">>)}; + {exception, #payproc_InvoiceTermsViolated{}} -> + {ok, logic_error('invoiceTermsViolated', <<"Invoice parameters violate terms">>)} + end catch + throw:{invalid_url_params, Reason} -> + {ok, invalid_url_params_error(Reason)}; throw:{bad_invoice_params, currency_no_amount} -> {ok, logic_error('invalidRequest', <<"Amount is required for the currency">>)}; throw:{bad_invoice_params, amount_no_currency} -> @@ -286,6 +299,12 @@ mask_invoice_template_notfound(Resolution) -> %% +validate_checkout_url_params(UrlParams) -> + case capi_handler_utils:validate_checkout_url_params(UrlParams) of + ok -> ok; + {error, Reason} -> erlang:throw({invalid_url_params, Reason}) + end. + create_invoice(PartyID, InvoiceTplID, InvoiceParams, Context, BenderPrefix) -> #{woody_context := WoodyCtx} = Context, % CAPI#344: Since the prefixes are different, it's possible to create 2 copies of the same Invoice with the same diff --git a/apps/capi/src/capi_handler_invoices.erl b/apps/capi/src/capi_handler_invoices.erl index aa7bbe2..c4f4a32 100644 --- a/apps/capi/src/capi_handler_invoices.erl +++ b/apps/capi/src/capi_handler_invoices.erl @@ -8,7 +8,13 @@ -export([prepare/3]). --import(capi_handler_utils, [general_error/2, logic_error/2, conflict_error/1, map_service_result/1]). +-import(capi_handler_utils, [ + general_error/2, + logic_error/2, + conflict_error/1, + invalid_url_params_error/1, + map_service_result/1 +]). -spec prepare( OperationID :: capi_handler:operation_id(), @@ -28,11 +34,14 @@ prepare('CreateInvoice' = OperationID, Req, Context) -> end, Process = fun() -> try + UrlParams = maps:get(<<"urlParams">>, InvoiceParams, #{}), + ok = validate_checkout_url_params(UrlParams), Allocation = maps:get(<<"allocation">>, InvoiceParams, undefined), ok = validate_allocation(Allocation), case create_invoice(PartyID, InvoiceParams, Context, OperationID) of {ok, #'payproc_Invoice'{invoice = Invoice}} -> - {ok, {201, #{}, capi_handler_decoder_invoicing:make_invoice_and_token(Invoice, Context)}}; + {ok, + {201, #{}, capi_handler_decoder_invoicing:make_invoice_and_token(Invoice, UrlParams, Context)}}; {exception, #'payproc_PartyNotFound'{}} -> {ok, logic_error('invalidPartyID', <<"Party not found">>)}; {exception, #base_InvalidRequest{errors = Errors}} -> @@ -55,6 +64,8 @@ prepare('CreateInvoice' = OperationID, Req, Context) -> {ok, logic_error('invalidAllocation', Message)} end catch + throw:{invalid_url_params, Reason} -> + {ok, invalid_url_params_error(Reason)}; throw:invoice_cart_empty -> {ok, logic_error('invalidInvoiceCart', <<"Wrong size. Path to item: cart">>)}; throw:invalid_invoice_cost -> @@ -86,6 +97,31 @@ prepare('CreateInvoiceAccessToken' = OperationID, Req, Context) -> {ok, {201, #{}, Response}} end, {ok, #{authorize => Authorize, process => Process}}; +prepare('CreateInvoiceUrl' = OperationID, Req, Context) -> + InvoiceID = maps:get('invoiceID', Req), + ResultInvoice = map_service_result(capi_handler_utils:get_invoice_by_id(InvoiceID, Context)), + Authorize = fun() -> + Prototypes = [ + {operation, #{id => OperationID, invoice => InvoiceID}}, + {payproc, #{invoice => ResultInvoice}} + ], + Resolution = capi_auth:authorize_operation(Prototypes, Context), + {ok, Resolution} + end, + Process = fun() -> + UrlParams = maps:get('InvoiceUrlParams', Req), + case capi_handler_utils:validate_checkout_url_params(UrlParams) of + ok -> + capi_handler:respond_if_undefined(ResultInvoice, general_error(404, <<"Invoice not found">>)), + Invoice = ResultInvoice#payproc_Invoice.invoice, + #{<<"payload">> := AccessToken} = capi_handler_utils:issue_access_token(Invoice, Context), + Response = capi_handler_utils:create_checkout_url(Invoice, AccessToken, UrlParams, Context), + {ok, {201, #{}, Response}}; + {error, Reason} -> + {ok, invalid_url_params_error(Reason)} + end + end, + {ok, #{authorize => Authorize, process => Process}}; prepare('GetInvoiceByID' = OperationID, Req, Context) -> InvoiceID = maps:get('invoiceID', Req), ResultInvoice = map_service_result(capi_handler_utils:get_invoice_by_id(InvoiceID, Context)), @@ -256,6 +292,12 @@ prepare(_OperationID, _Req, _Context) -> %% +validate_checkout_url_params(UrlParams) -> + case capi_handler_utils:validate_checkout_url_params(UrlParams) of + ok -> ok; + {error, Reason} -> erlang:throw({invalid_url_params, Reason}) + end. + validate_allocation(Allocation) -> case capi_allocation:validate(Allocation) of ok -> ok; diff --git a/apps/capi/src/capi_handler_utils.erl b/apps/capi/src/capi_handler_utils.erl index 50c965e..e9f9788 100644 --- a/apps/capi/src/capi_handler_utils.erl +++ b/apps/capi/src/capi_handler_utils.erl @@ -10,6 +10,7 @@ -export([logic_error/2]). -export([server_error/1]). -export([format_request_errors/1]). +-export([invalid_url_params_error/1]). -export([assert_party_accessible/2]). -export([run_if_party_accessible/3]). @@ -24,6 +25,8 @@ -export([get_party_id/1]). -export([issue_access_token/2]). +-export([validate_checkout_url_params/1]). +-export([create_checkout_url/4]). -export([merge_and_compact/2]). -export([get_time/2]). -export([collect_events/4]). @@ -36,6 +39,8 @@ -export([emplace_token_provider_data/3]). +-export_type([url_params/0]). + -type processing_context() :: capi_handler:processing_context(). -type response() :: capi_handler:response(). -type entity() :: @@ -44,6 +49,8 @@ | dmsl_customer_thrift:'Customer'(). -type token_source() :: capi_auth:token_spec() | entity(). +-type url_params() :: #{binary() => binary()}. + -spec conflict_error(binary() | {binary(), binary()}) -> response(). conflict_error({ID, ExternalID}) -> Data = #{ @@ -86,6 +93,19 @@ server_error(Code) when Code >= 500 andalso Code < 600 -> format_request_errors([]) -> <<>>; format_request_errors(Errors) -> genlib_string:join(<<"\n">>, Errors). +-spec invalid_url_params_error(term()) -> response(). +invalid_url_params_error({bad_keys, [_ | _] = BadKeys, Whitelist}) when is_list(BadKeys) andalso is_list(Whitelist) -> + Message = [ + <<"Bad keys: ">>, + genlib_string:join(<<", ">>, BadKeys), + <<$\n>>, + <<"Allowed keys: ">>, + genlib_string:join(<<", ">>, Whitelist) + ], + logic_error('invalidUrlParams', genlib_string:join(<<>>, Message)); +invalid_url_params_error(_Reason) -> + logic_error('invalidUrlParams', <<"Bad URL params">>). + %%% -spec service_call({atom(), atom(), tuple()}, processing_context()) -> woody:result(). @@ -120,6 +140,67 @@ get_party_id(Context) -> %% Utils +-spec validate_checkout_url_params(url_params()) -> + ok + | {error, + Reason :: + {invalid_input | invalid_encoding, term()} + | {bad_keys, [binary()], [binary()]}}. +validate_checkout_url_params(Params0) -> + UrlGenOpts = genlib_app:env(capi, checkout_url_generation), + Whitelist = maps:get(params_whitelist, UrlGenOpts, []), + case maps:keys(maps:without(Whitelist, Params0)) of + [] -> + Params1 = maps:with(Whitelist, Params0), + case uri_string:compose_query(maps:to_list(Params1), [{encoding, utf8}]) of + {error, Error, Term} -> + {error, {Error, Term}}; + _ -> + ok + end; + BadKeys -> + {error, {bad_keys, BadKeys, Whitelist}} + end. + +-spec create_checkout_url( + dmsl_domain_thrift:'Invoice'(), + token_keeper_client:token(), + url_params(), + processing_context() +) -> map() | no_return(). +create_checkout_url(Invoice, AccessToken, Params0, ProcessingContext) -> + UrlGenOpts = genlib_app:env(capi, checkout_url_generation), + Params1 = maps:with(maps:get(params_whitelist, UrlGenOpts, []), Params0), + %% TODO Warn if params filtered out + Params2 = maps:merge(Params1, #{ + <<"invoiceID">> => Invoice#domain_Invoice.id, + <<"invoiceAccessToken">> => AccessToken + }), + BaseUrl = get_base_url(Invoice, UrlGenOpts, ProcessingContext), + %% TODO Sanitize params? + case uri_string:compose_query(maps:to_list(Params2), [{encoding, utf8}]) of + {error, Error, Term} -> + erlang:throw({Error, Term}); + EncodedParams -> + #{<<"url">> => <>} + end. + +get_base_url( + #domain_Invoice{party_ref = #domain_PartyConfigRef{id = PartyID}, shop_ref = #domain_ShopConfigRef{id = ShopID}}, + #{default_base_url := Default}, + ProcessingContext +) -> + case capi_party:get_shop(PartyID, ShopID, ProcessingContext) of + {ok, #domain_ShopConfig{ + checkout_location = #domain_ShopCheckoutLocation{ + locations = [#domain_CheckoutLocation{base_url = V} | _] + } + }} -> + V; + _ -> + Default + end. + -spec issue_access_token(token_source(), processing_context()) -> map(). issue_access_token(#domain_Invoice{} = Invoice, ProcessingContext) -> TokenSpec = #{ diff --git a/apps/capi/test/capi_base_api_token_tests_SUITE.erl b/apps/capi/test/capi_base_api_token_tests_SUITE.erl index 1987669..5d53ff8 100644 --- a/apps/capi/test/capi_base_api_token_tests_SUITE.erl +++ b/apps/capi/test/capi_base_api_token_tests_SUITE.erl @@ -24,15 +24,19 @@ -export([ create_invoice_ok_test/1, + create_invoice_bad_keys_test/1, create_invoice_rand_amount_ok_test/1, create_invoice_autorization_error_test/1, get_invoice_by_external_id/1, get_invoice_by_external_id_for_party/1, get_invoice_by_external_id_not_impl_error/1, create_invoice_access_token_ok_test/1, + create_invoice_url_ok_test/1, + create_invoice_url_not_allowed_test/1, create_invoice_template_ok_test/1, create_invoice_template_w_randomization_ok_test/1, create_invoice_with_template_test/1, + create_invoice_with_template_bad_keys_test/1, create_invoice_template_autorization_error_test/1, rescind_invoice_ok_test/1, fulfill_invoice_ok_test/1, @@ -123,6 +127,7 @@ groups() -> ]}, {operations_by_any_token, [], [ create_invoice_ok_test, + create_invoice_bad_keys_test, create_invoice_rand_amount_ok_test, create_invoice_autorization_error_test, get_invoice_by_external_id, @@ -130,10 +135,13 @@ groups() -> get_invoice_by_external_id_not_impl_error, check_no_invoice_by_external_id_test, create_invoice_access_token_ok_test, + create_invoice_url_ok_test, + create_invoice_url_not_allowed_test, create_invoice_template_ok_test, create_invoice_template_w_randomization_ok_test, create_invoice_template_autorization_error_test, create_invoice_with_template_test, + create_invoice_with_template_bad_keys_test, rescind_invoice_ok_test, fulfill_invoice_ok_test, update_invoice_template_ok_test, @@ -256,9 +264,33 @@ create_invoice_ok_test(Config) -> Config ), _ = capi_ct_helper_bouncer:mock_assert_shop_op_ctx(<<"CreateInvoice">>, ?STRING, ?STRING, Config), - {ok, #{<<"invoice">> := Invoice}} = capi_client_invoices:create_invoice(?config(context, Config), ?INVOICE_PARAMS), + InvoiceParams0 = ?INVOICE_PARAMS, + UrlParams = #{ + <<"theme">> => ?STRING, + <<"locale">> => ?STRING + }, + InvoiceParams1 = maps:merge(InvoiceParams0, #{<<"urlParams">> => UrlParams}), + {ok, #{<<"invoice">> := Invoice, <<"invoiceUrl">> := InvoiceUrl}} = + capi_client_invoices:create_invoice(?config(context, Config), InvoiceParams1), + assert_invoice_url(?STRING, ?CHECKOUT_URL, UrlParams, InvoiceUrl), ?assertMatch(#{<<"clientInfo">> := #{<<"trustLevel">> := <<"wellKnown">>}}, Invoice). +-spec create_invoice_bad_keys_test(config()) -> _. +create_invoice_bad_keys_test(Config) -> + _ = capi_ct_helper_bouncer:mock_assert_shop_op_ctx(<<"CreateInvoice">>, ?STRING, ?STRING, Config), + InvoiceParams0 = ?INVOICE_PARAMS, + UrlParams = #{ + <<"theme">> => ?STRING, + <<"locale">> => ?STRING, + <<"not-whitelisted">> => ?STRING + }, + InvoiceParams1 = maps:merge(InvoiceParams0, #{<<"urlParams">> => UrlParams}), + ?assertMatch( + {error, + {400, #{<<"code">> := <<"invalidUrlParams">>, <<"message">> := <<"Bad keys: not-whitelisted", _/binary>>}}}, + capi_client_invoices:create_invoice(?config(context, Config), InvoiceParams1) + ). + -spec create_invoice_rand_amount_ok_test(config()) -> _. create_invoice_rand_amount_ok_test(Config) -> RandomizedAmount = ?INTEGER + ?SMALLER_INTEGER, @@ -403,6 +435,54 @@ create_invoice_access_token_ok_test(Config) -> ), {ok, _} = capi_client_invoices:create_invoice_access_token(?config(context, Config), ?STRING). +-spec create_invoice_url_ok_test(config()) -> _. +create_invoice_url_ok_test(Config) -> + _ = capi_ct_helper:mock_services( + [ + {invoicing, fun('Get', _) -> {ok, ?PAYPROC_INVOICE} end} + ], + Config + ), + _ = capi_ct_helper_bouncer:mock_assert_invoice_op_ctx( + <<"CreateInvoiceUrl">>, + ?STRING, + ?STRING, + ?STRING, + Config + ), + Params = #{ + <<"theme">> => ?STRING, + <<"locale">> => ?STRING + }, + {ok, InvoiceUrl} = capi_client_invoices:create_invoice_url(?config(context, Config), Params, ?STRING), + assert_invoice_url(?STRING, ?CHECKOUT_URL, Params, InvoiceUrl). + +-spec create_invoice_url_not_allowed_test(config()) -> _. +create_invoice_url_not_allowed_test(Config) -> + _ = capi_ct_helper:mock_services( + [ + {invoicing, fun('Get', _) -> {ok, ?PAYPROC_INVOICE} end} + ], + Config + ), + _ = capi_ct_helper_bouncer:mock_assert_invoice_op_ctx( + <<"CreateInvoiceUrl">>, + ?STRING, + ?STRING, + ?STRING, + Config + ), + Params = #{ + <<"theme">> => ?STRING, + <<"locale">> => ?STRING, + <<"not-whitelisted">> => ?STRING + }, + ?assertMatch( + {error, + {400, #{<<"code">> := <<"invalidUrlParams">>, <<"message">> := <<"Bad keys: not-whitelisted", _/binary>>}}}, + capi_client_invoices:create_invoice_url(?config(context, Config), Params, ?STRING) + ). + -spec create_invoice_template_ok_test(config()) -> _. create_invoice_template_ok_test(Config) -> _ = capi_ct_helper:mock_services( @@ -559,12 +639,16 @@ create_invoice_with_template_test(Config) -> ], Config ), - + UrlParams = #{ + <<"theme">> => ?STRING, + <<"locale">> => ?STRING + }, Req = #{ <<"amount">> => ?INTEGER, <<"currency">> => ?RUB, <<"metadata">> => #{<<"invoice_dummy_metadata">> => <<"test_value">>}, - <<"externalID">> => ExternalID + <<"externalID">> => ExternalID, + <<"urlParams">> => UrlParams }, Ctx = ?config(context, Config), _ = capi_ct_helper_bouncer:mock_assert_invoice_tpl_op_ctx( @@ -574,12 +658,50 @@ create_invoice_with_template_test(Config) -> ?STRING, Config ), - - {ok, #{<<"invoice">> := Invoice}} = + {ok, #{<<"invoice">> := Invoice, <<"invoiceUrl">> := InvoiceUrl}} = capi_client_invoice_templates:create_invoice(Ctx, ?STRING, Req), + assert_invoice_url(BenderKey, ?CHECKOUT_URL, UrlParams, InvoiceUrl), ?assertEqual(BenderKey, maps:get(<<"id">>, Invoice)), ?assertEqual(ExternalID, maps:get(<<"externalID">>, Invoice)). +-spec create_invoice_with_template_bad_keys_test(config()) -> _. +create_invoice_with_template_bad_keys_test(Config) -> + ExternalID = <<"external_id">>, + _ = capi_ct_helper:mock_services( + [ + {invoice_templating, fun + ('Create', _) -> {ok, ?INVOICE_TPL}; + ('Get', _) -> {ok, ?INVOICE_TPL} + end} + ], + Config + ), + UrlParams = #{ + <<"theme">> => ?STRING, + <<"locale">> => ?STRING, + <<"not-whitelisted">> => ?STRING + }, + Req = #{ + <<"amount">> => ?INTEGER, + <<"currency">> => ?RUB, + <<"metadata">> => #{<<"invoice_dummy_metadata">> => <<"test_value">>}, + <<"externalID">> => ExternalID, + <<"urlParams">> => UrlParams + }, + Ctx = ?config(context, Config), + _ = capi_ct_helper_bouncer:mock_assert_invoice_tpl_op_ctx( + <<"CreateInvoiceWithTemplate">>, + ?STRING, + ?STRING, + ?STRING, + Config + ), + ?assertMatch( + {error, + {400, #{<<"code">> := <<"invalidUrlParams">>, <<"message">> := <<"Bad keys: not-whitelisted", _/binary>>}}}, + capi_client_invoice_templates:create_invoice(Ctx, ?STRING, Req) + ). + -spec check_no_internal_id_for_external_id_test(config()) -> _. check_no_internal_id_for_external_id_test(Config) -> ExternalID = capi_utils:get_unique_id(), @@ -1814,3 +1936,15 @@ different_ip_header(Config) -> Context0 = ?config(context, Config), Context1 = Context0#{ip_address => IPAddress}, {ok, _} = capi_client_shops:get_shop_by_id_for_party(Context1, ?STRING, ?STRING). + +-spec assert_invoice_url(binary(), binary(), map(), map()) -> ok | no_return(). +assert_invoice_url(InvoiceID, BaseUrl, Params0, InvoiceUrl) -> + EncodedParams = uri_string:compose_query( + maps:to_list(Params0#{ + <<"invoiceAccessToken">> => ?API_TOKEN, + <<"invoiceID">> => InvoiceID + }), + [{encoding, utf8}] + ), + Expected = <>, + ?assertMatch(#{<<"url">> := Expected}, InvoiceUrl). diff --git a/apps/capi/test/capi_ct_helper.erl b/apps/capi/test/capi_ct_helper.erl index 1fbd761..485dec1 100644 --- a/apps/capi/test/capi_ct_helper.erl +++ b/apps/capi/test/capi_ct_helper.erl @@ -175,6 +175,32 @@ start_capi(Config, ExtraEnv) -> user_email => ?TK_META_USER_EMAIL, customer_id => ?TK_META_CUSTOMER_ID } + }}, + {checkout_url_generation, #{ + default_base_url => <<"https://checkout.example.com/pay">>, + params_whitelist => [ + <<"name">>, + <<"description">>, + <<"obscureCardCvv">>, + <<"requireCardHolder">>, + <<"redirectUrl">>, + <<"cancelUrl">>, + <<"locale">>, + <<"recurring">>, + <<"metadata">>, + <<"terminalFormValues">>, + <<"skipUserInteraction">>, + <<"isExternalIDIncluded">>, + <<"theme">>, + <<"paymentFlow">>, + <<"deepLink">>, + <<"email">>, + <<"phoneNumber">>, + <<"dateOfBirth">>, + <<"documentId">>, + <<"firstName">>, + <<"lastName">> + ] }} ], start_app(capi, CapiEnv). diff --git a/apps/capi/test/capi_dummy_data.hrl b/apps/capi/test/capi_dummy_data.hrl index 55c71ce..a5dad45 100644 --- a/apps/capi/test/capi_dummy_data.hrl +++ b/apps/capi/test/capi_dummy_data.hrl @@ -31,6 +31,7 @@ -define(TEST_RULESET_ID, <<"test/api">>). -define(API_TOKEN, <<"letmein">>). -define(EMAIL, <<"test@test.ru">>). +-define(CHECKOUT_URL, <<"http://shop-specific.local/path/to/checkout">>). -define(KZT_PARTY_ID, <<"95a158c2-343a-40c9-b690-247dbee3fa40">>). -define(KZT_SHOP_ID, <<"bbe49f63-0ff8-4cc4-99e8-00892a683cec">>). @@ -456,7 +457,10 @@ }, party_ref = #domain_PartyConfigRef{id = ?STRING}, location = ?SHOP_LOCATION, - category = #domain_CategoryRef{id = ?INTEGER} + category = #domain_CategoryRef{id = ?INTEGER}, + checkout_location = #domain_ShopCheckoutLocation{ + locations = [#domain_CheckoutLocation{base_url = ?CHECKOUT_URL}] + } }). -define(SHOP, ?SHOP(?RUB)). diff --git a/apps/capi_client/src/capi_client_invoices.erl b/apps/capi_client/src/capi_client_invoices.erl index d059c63..89c177e 100644 --- a/apps/capi_client/src/capi_client_invoices.erl +++ b/apps/capi_client/src/capi_client_invoices.erl @@ -2,6 +2,7 @@ -export([create_invoice/2]). -export([create_invoice_access_token/2]). +-export([create_invoice_url/3]). -export([get_invoice_events/3]). -export([get_invoice_events/4]). -export([get_invoice_by_id/2]). @@ -27,6 +28,16 @@ create_invoice_access_token(Context, InvoiceID) -> Response = swag_client_invoices_api:create_invoice_access_token(Url, PreparedParams, Opts), capi_client_lib:handle_response(Response). +-spec create_invoice_url(context(), map(), binary()) -> {ok, term()} | {error, term()}. +create_invoice_url(Context, UrlParams, InvoiceID) when is_map(UrlParams) -> + Params = #{ + binding => #{<<"invoiceID">> => InvoiceID}, + body => UrlParams + }, + {Url, PreparedParams, Opts} = capi_client_lib:make_request(Context, Params), + Response = swag_client_invoices_api:create_invoice_url(Url, PreparedParams, Opts), + capi_client_lib:handle_response(Response). + -spec get_invoice_events(context(), binary(), integer()) -> {ok, term()} | {error, term()}. get_invoice_events(Context, InvoiceID, Limit) -> Qs = #{ diff --git a/config/sys.config b/config/sys.config index 8144f93..77fcc52 100644 --- a/config/sys.config +++ b/config/sys.config @@ -84,6 +84,33 @@ user_email => <<"dev.vality.user.email">>, customer_id => <<"dev.vality.customer.id">> } + }}, + {checkout_url_generation, #{ + default_base_url => <<"https://checkout.example.com/pay">>, + %% NOTE Allowed `createInvoiceUrl` operation's parameter keys are listed below + params_whitelist => [ + <<"name">>, + <<"description">>, + <<"obscureCardCvv">>, + <<"requireCardHolder">>, + <<"redirectUrl">>, + <<"cancelUrl">>, + <<"locale">>, + <<"recurring">>, + <<"metadata">>, + <<"terminalFormValues">>, + <<"skipUserInteraction">>, + <<"isExternalIDIncluded">>, + <<"theme">>, + <<"paymentFlow">>, + <<"deepLink">>, + <<"email">>, + <<"phoneNumber">>, + <<"dateOfBirth">>, + <<"documentId">>, + <<"firstName">>, + <<"lastName">> + ] }} ]}, diff --git a/rebar.config b/rebar.config index b4a57b6..214b4a8 100644 --- a/rebar.config +++ b/rebar.config @@ -36,7 +36,7 @@ {cowboy_draining_server, {git, "https://github.com/valitydev/cowboy_draining_server.git", {branch, "master"}}}, {woody, {git, "https://github.com/valitydev/woody_erlang.git", {tag, "v1.1.0"}}}, {woody_user_identity, {git, "https://github.com/valitydev/woody_erlang_user_identity.git", {tag, "v1.1.0"}}}, - {damsel, {git, "https://github.com/valitydev/damsel.git", {tag, "v2.2.33"}}}, + {damsel, {git, "https://github.com/valitydev/damsel.git", {tag, "v2.2.37"}}}, {bender_proto, {git, "https://github.com/valitydev/bender-proto.git", {branch, "master"}}}, {bender_client, {git, "https://github.com/valitydev/bender-client-erlang.git", {tag, "v1.1.0"}}}, {dmt_client, {git, "https://github.com/valitydev/dmt_client.git", {tag, "v2.0.3"}}}, diff --git a/rebar.lock b/rebar.lock index c9de94e..561f8bd 100644 --- a/rebar.lock +++ b/rebar.lock @@ -41,7 +41,7 @@ {<<"ctx">>,{pkg,<<"ctx">>,<<"0.6.0">>},2}, {<<"damsel">>, {git,"https://github.com/valitydev/damsel.git", - {ref,"e7a302a684deba1bb18a00d1056879329219d280"}}, + {ref,"dcd668277f5a2a144dd823bb0e6c9ba7b94428cc"}}, 0}, {<<"dmt_client">>, {git,"https://github.com/valitydev/dmt_client.git", @@ -87,7 +87,7 @@ {ref,"3f9c136ae2f70b9830f46c990e00d75dacfd73d9"}}, 0}, {<<"metrics">>,{pkg,<<"metrics">>,<<"1.0.1">>},2}, - {<<"mimerl">>,{pkg,<<"mimerl">>,<<"1.4.0">>},2}, + {<<"mimerl">>,{pkg,<<"mimerl">>,<<"1.5.0">>},2}, {<<"msgpack_proto">>, {git,"https://github.com/valitydev/msgpack-proto.git", {ref,"7e447496aa5df4a5f1ace7ef2e3c31248b2a3ed0"}}, @@ -126,11 +126,11 @@ {<<"ssl_verify_fun">>,{pkg,<<"ssl_verify_fun">>,<<"1.1.7">>},2}, {<<"swag_client">>, {git,"https://github.com/valitydev/swag-payments", - {ref,"19a29dea00abf5d6653a1728d68dd28c4403f21a"}}, + {ref,"fc4d014dd6d1a9b86bb1efa9fd36b2a718ee755d"}}, 0}, {<<"swag_server">>, {git,"https://github.com/valitydev/swag-payments", - {ref,"1c6370142523e39c40b193f08827905a26d9df74"}}, + {ref,"1e0ffc210a419c42fa10955b16a2405399a2cc70"}}, 0}, {<<"thrift">>, {git,"https://github.com/valitydev/thrift_erlang.git", @@ -174,7 +174,7 @@ {<<"idna">>, <<"8A63070E9F7D0C62EB9D9FCB360A7DE382448200FBBD1B106CC96D3D8099DF8D">>}, {<<"jsx">>, <<"D12516BAA0BB23A59BB35DCCAF02A1BD08243FCBB9EFE24F2D9D056CCFF71268">>}, {<<"metrics">>, <<"25F094DEA2CDA98213CECC3AEFF09E940299D950904393B2A29D191C346A8486">>}, - {<<"mimerl">>, <<"3882A5CA67FBBE7117BA8947F27643557ADEC38FA2307490C4C4207624CB213B">>}, + {<<"mimerl">>, <<"F35ACA6F23242339B3666E0AC0702379E362B469D0AEA167F6CC713547E777ED">>}, {<<"opentelemetry">>, <<"20D0F12D3D1C398D3670FD44FD1A7C495DD748AB3E5B692A7906662E2FB1A38A">>}, {<<"opentelemetry_api">>, <<"1A676F3E3340CAB81C763E939A42E11A70C22863F645AA06AAFEFC689B5550CF">>}, {<<"opentelemetry_exporter">>, <<"972E142392DBFA679EC959914664ADEFEA38399E4F56CEBA5C473E1CABDBAD79">>}, @@ -204,7 +204,7 @@ {<<"idna">>, <<"92376EB7894412ED19AC475E4A86F7B413C1B9FBB5BD16DCCD57934157944CEA">>}, {<<"jsx">>, <<"0C5CC8FDC11B53CC25CF65AC6705AD39E54ECC56D1C22E4ADB8F5A53FB9427F3">>}, {<<"metrics">>, <<"69B09ADDDC4F74A40716AE54D140F93BEB0FB8978D8636EADED0C31B6F099F16">>}, - {<<"mimerl">>, <<"13AF15F9F68C65884ECCA3A3891D50A7B57D82152792F3E19D88650AA126B144">>}, + {<<"mimerl">>, <<"DB648CE065BAE14EA84CA8B5DD123F42F49417CEF693541110BF6F9E9BE9ECC4">>}, {<<"opentelemetry">>, <<"A9173B058C4549BF824CBC2F1D2FA2ADC5CDEDC22AA3F0F826951187BBD53131">>}, {<<"opentelemetry_api">>, <<"F53EC8A1337AE4A487D43AC89DA4BD3A3C99DDF576655D071DEED8B56A2D5DDA">>}, {<<"opentelemetry_exporter">>, <<"33A116ED7304CB91783F779DEC02478F887C87988077BFD72840F760B8D4B952">>},