From 6c617ff9dca545533da525ebfc8672717be39f91 Mon Sep 17 00:00:00 2001 From: Aleksey Kashapov Date: Tue, 9 Jun 2026 18:09:20 +0300 Subject: [PATCH 1/7] XYZ-182: [WIP] Implements `CreateInvoiceCheckoutUrl` --- apps/capi/src/capi_handler_invoices.erl | 18 ++++++++++ apps/capi/src/capi_handler_utils.erl | 33 +++++++++++++++++++ .../test/capi_base_api_token_tests_SUITE.erl | 21 ++++++++++++ apps/capi/test/capi_dummy_data.hrl | 3 +- config/sys.config | 30 ++++++++++++++++- rebar.config | 2 +- rebar.lock | 2 +- 7 files changed, 105 insertions(+), 4 deletions(-) diff --git a/apps/capi/src/capi_handler_invoices.erl b/apps/capi/src/capi_handler_invoices.erl index aa7bbe2..b37df28 100644 --- a/apps/capi/src/capi_handler_invoices.erl +++ b/apps/capi/src/capi_handler_invoices.erl @@ -86,6 +86,24 @@ prepare('CreateInvoiceAccessToken' = OperationID, Req, Context) -> {ok, {201, #{}, Response}} end, {ok, #{authorize => Authorize, process => Process}}; +prepare('CreateInvoiceCheckoutUrl' = 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() -> + capi_handler:respond_if_undefined(ResultInvoice, general_error(404, <<"Invoice not found">>)), + Invoice = ResultInvoice#payproc_Invoice.invoice, + Response = capi_handler_utils:create_checkout_url(Invoice, maps:get(<<"checkoutParameters">>, Req), Context), + {ok, {201, #{}, Response}} + 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)), diff --git a/apps/capi/src/capi_handler_utils.erl b/apps/capi/src/capi_handler_utils.erl index 50c965e..9914ebf 100644 --- a/apps/capi/src/capi_handler_utils.erl +++ b/apps/capi/src/capi_handler_utils.erl @@ -24,6 +24,7 @@ -export([get_party_id/1]). -export([issue_access_token/2]). +-export([create_checkout_url/3]). -export([merge_and_compact/2]). -export([get_time/2]). -export([collect_events/4]). @@ -44,6 +45,8 @@ | dmsl_customer_thrift:'Customer'(). -type token_source() :: capi_auth:token_spec() | entity(). +-type checkout_params() :: #{binary() => binary()}. + -spec conflict_error(binary() | {binary(), binary()}) -> response(). conflict_error({ID, ExternalID}) -> Data = #{ @@ -120,6 +123,36 @@ get_party_id(Context) -> %% Utils +-spec create_checkout_url(dmsl_domain_thrift:'Invoice'(), checkout_params(), processing_context()) -> map(). +create_checkout_url(#domain_Invoice{} = Invoice, Params0, ProcessingContext) -> + UrlGenOpts = genlib_app:env(capi, checkout_url_generation), + Params1 = maps:with(maps:get(parameters_whitelist, UrlGenOpts, []), Params0), + TokenSpec = #{ + party => Invoice#domain_Invoice.party_ref#domain_PartyConfigRef.id, + scope => {invoice, Invoice#domain_Invoice.id}, + shop => Invoice#domain_Invoice.shop_ref#domain_ShopConfigRef.id + }, + Params2 = maps:merge(Params1, #{ + <<"invoiceID">> => Invoice#domain_Invoice.id, + <<"invoiceAccessToken">> => capi_auth:issue_access_token(TokenSpec, get_woody_context(ProcessingContext)) + }), + PartyID = Invoice#domain_Invoice.party_ref#domain_PartyConfigRef.id, + ShopID = Invoice#domain_Invoice.shop_ref#domain_ShopConfigRef.id, + BaseUrl = + case capi_party:get_shop(PartyID, ShopID, ProcessingContext) of + {ok, #domain_ShopConfig{checkout_base_url = V}} when V =/= undefined -> + V; + _ -> + maps:get(default_base_url, UrlGenOpts) + end, + #{ + <<"checkoutUrl">> => iolist_to_binary([ + BaseUrl, + "?", + uri_string:compose_query(maps:to_list(Params2), [{encoding, utf8}]) + ]) + }. + -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..fbaf715 100644 --- a/apps/capi/test/capi_base_api_token_tests_SUITE.erl +++ b/apps/capi/test/capi_base_api_token_tests_SUITE.erl @@ -30,6 +30,7 @@ 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_checkout_url_ok_test/1, create_invoice_template_ok_test/1, create_invoice_template_w_randomization_ok_test/1, create_invoice_with_template_test/1, @@ -130,6 +131,7 @@ groups() -> get_invoice_by_external_id_not_impl_error, check_no_invoice_by_external_id_test, create_invoice_access_token_ok_test, + create_invoice_checkout_url_ok_test, create_invoice_template_ok_test, create_invoice_template_w_randomization_ok_test, create_invoice_template_autorization_error_test, @@ -403,6 +405,25 @@ create_invoice_access_token_ok_test(Config) -> ), {ok, _} = capi_client_invoices:create_invoice_access_token(?config(context, Config), ?STRING). +-spec create_invoice_checkout_url_ok_test(config()) -> _. +create_invoice_checkout_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( + <<"CreateInvoiceCheckoutUrl">>, + ?STRING, + ?STRING, + ?STRING, + Config + ), + Params = #{}, + {ok, #{<<"checkoutUrl">> => <<"http://shop-specific.local/path/to/checkout?">>}} = + capi_client_invoices:create_invoice_checkout_url(?config(context, Config), Params, ?STRING). + -spec create_invoice_template_ok_test(config()) -> _. create_invoice_template_ok_test(Config) -> _ = capi_ct_helper:mock_services( diff --git a/apps/capi/test/capi_dummy_data.hrl b/apps/capi/test/capi_dummy_data.hrl index 55c71ce..c5985d6 100644 --- a/apps/capi/test/capi_dummy_data.hrl +++ b/apps/capi/test/capi_dummy_data.hrl @@ -456,7 +456,8 @@ }, party_ref = #domain_PartyConfigRef{id = ?STRING}, location = ?SHOP_LOCATION, - category = #domain_CategoryRef{id = ?INTEGER} + category = #domain_CategoryRef{id = ?INTEGER}, + checkout_base_url = <<"http://shop-specific.local/path/to/checkout">> }). -define(SHOP, ?SHOP(?RUB)). diff --git a/config/sys.config b/config/sys.config index 8144f93..8d4f730 100644 --- a/config/sys.config +++ b/config/sys.config @@ -84,7 +84,35 @@ 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 `createInvoiceCheckoutUrl` operation's parameter + %% keys are listed below + {parameters_whitelist, [ + <<"name">>, + <<"description">>, + <<"obscureCardCvv">>, + <<"requireCardHolder">>, + <<"redirectUrl">>, + <<"cancelUrl">>, + <<"locale">>, + <<"recurring">>, + <<"metadata">>, + <<"terminalFormValues">>, + <<"skipUserInteraction">>, + <<"isExternalIDIncluded">>, + <<"theme">>, + <<"paymentFlow">>, + <<"deepLink">>, + <<"email">>, + <<"phoneNumber">>, + <<"dateOfBirth">>, + <<"documentId">>, + <<"firstName">>, + <<"lastName">> + ]} + ]} ]}, {capi_woody_server, [ diff --git a/rebar.config b/rebar.config index b4a57b6..eec4ac2 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", {branch, "XYZ-182/ft/invoice-checkout-url"}}}, {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..5d86842 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,"5baf467f08ab6df46f82c1c0b63c932449c38043"}}, 0}, {<<"dmt_client">>, {git,"https://github.com/valitydev/dmt_client.git", From e9b7aca05835cf6d83301d8e445a964b660a5db3 Mon Sep 17 00:00:00 2001 From: Aleksey Kashapov Date: Wed, 10 Jun 2026 17:03:35 +0300 Subject: [PATCH 2/7] Refactors invoice url generation and adds it to invoice creation ops --- .../src/capi_handler_decoder_invoicing.erl | 12 ++- .../src/capi_handler_invoice_templates.erl | 10 ++- apps/capi/src/capi_handler_invoices.erl | 30 ++++++-- apps/capi/src/capi_handler_utils.erl | 73 ++++++++++++------- .../test/capi_base_api_token_tests_SUITE.erl | 15 ++-- config/sys.config | 3 +- rebar.lock | 2 +- 7 files changed, 98 insertions(+), 47 deletions(-) diff --git a/apps/capi/src/capi_handler_decoder_invoicing.erl b/apps/capi/src/capi_handler_decoder_invoicing.erl index 49ba856..e5df003 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,16 @@ 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()) -> +-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, ProcessingContext) -> + +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..d4b14df 100644 --- a/apps/capi/src/capi_handler_invoice_templates.erl +++ b/apps/capi/src/capi_handler_invoice_templates.erl @@ -156,10 +156,12 @@ 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, #{}), + ok = validate_checkout_url_params(UrlParams), 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)}}; + {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)}; @@ -286,6 +288,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 b37df28..a51b703 100644 --- a/apps/capi/src/capi_handler_invoices.erl +++ b/apps/capi/src/capi_handler_invoices.erl @@ -28,11 +28,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 +58,8 @@ prepare('CreateInvoice' = OperationID, Req, Context) -> {ok, logic_error('invalidAllocation', Message)} end catch + throw:{invalid_url_params, _Reason} -> + {ok, logic_error('invalidUrlParams', <<"Bad URL params">>)}; throw:invoice_cart_empty -> {ok, logic_error('invalidInvoiceCart', <<"Wrong size. Path to item: cart">>)}; throw:invalid_invoice_cost -> @@ -86,7 +91,7 @@ prepare('CreateInvoiceAccessToken' = OperationID, Req, Context) -> {ok, {201, #{}, Response}} end, {ok, #{authorize => Authorize, process => Process}}; -prepare('CreateInvoiceCheckoutUrl' = OperationID, Req, Context) -> +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() -> @@ -98,10 +103,17 @@ prepare('CreateInvoiceCheckoutUrl' = OperationID, Req, Context) -> {ok, Resolution} end, Process = fun() -> - capi_handler:respond_if_undefined(ResultInvoice, general_error(404, <<"Invoice not found">>)), - Invoice = ResultInvoice#payproc_Invoice.invoice, - Response = capi_handler_utils:create_checkout_url(Invoice, maps:get(<<"checkoutParameters">>, Req), Context), - {ok, {201, #{}, Response}} + UrlParams = maps:get(<<"params">>, 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, logic_error('invalidUrlParams', <<"Bad URL params">>)} + end end, {ok, #{authorize => Authorize, process => Process}}; prepare('GetInvoiceByID' = OperationID, Req, Context) -> @@ -274,6 +286,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 9914ebf..335da9b 100644 --- a/apps/capi/src/capi_handler_utils.erl +++ b/apps/capi/src/capi_handler_utils.erl @@ -24,7 +24,8 @@ -export([get_party_id/1]). -export([issue_access_token/2]). --export([create_checkout_url/3]). +-export([validate_checkout_url_params/1]). +-export([create_checkout_url/4]). -export([merge_and_compact/2]). -export([get_time/2]). -export([collect_events/4]). @@ -37,6 +38,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() :: @@ -45,7 +48,7 @@ | dmsl_customer_thrift:'Customer'(). -type token_source() :: capi_auth:token_spec() | entity(). --type checkout_params() :: #{binary() => binary()}. +-type url_params() :: #{binary() => binary()}. -spec conflict_error(binary() | {binary(), binary()}) -> response(). conflict_error({ID, ExternalID}) -> @@ -123,35 +126,53 @@ get_party_id(Context) -> %% Utils --spec create_checkout_url(dmsl_domain_thrift:'Invoice'(), checkout_params(), processing_context()) -> map(). -create_checkout_url(#domain_Invoice{} = Invoice, Params0, ProcessingContext) -> +-spec validate_checkout_url_params(url_params()) -> ok | {error, Reason :: {atom(), term()}}. +validate_checkout_url_params(Params0) -> + UrlGenOpts = genlib_app:env(capi, checkout_url_generation), + Params1 = maps:with(maps:get(parameters_whitelist, UrlGenOpts, []), Params0), + case uri_string:compose_query(maps:to_list(Params1), [{encoding, utf8}]) of + {error, Error, Term} -> + {error, {Error, Term}}; + _ -> + ok + 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(parameters_whitelist, UrlGenOpts, []), Params0), - TokenSpec = #{ - party => Invoice#domain_Invoice.party_ref#domain_PartyConfigRef.id, - scope => {invoice, Invoice#domain_Invoice.id}, - shop => Invoice#domain_Invoice.shop_ref#domain_ShopConfigRef.id - }, Params2 = maps:merge(Params1, #{ <<"invoiceID">> => Invoice#domain_Invoice.id, - <<"invoiceAccessToken">> => capi_auth:issue_access_token(TokenSpec, get_woody_context(ProcessingContext)) + <<"invoiceAccessToken">> => AccessToken }), - PartyID = Invoice#domain_Invoice.party_ref#domain_PartyConfigRef.id, - ShopID = Invoice#domain_Invoice.shop_ref#domain_ShopConfigRef.id, - BaseUrl = - case capi_party:get_shop(PartyID, ShopID, ProcessingContext) of - {ok, #domain_ShopConfig{checkout_base_url = V}} when V =/= undefined -> - V; - _ -> - maps:get(default_base_url, UrlGenOpts) - end, - #{ - <<"checkoutUrl">> => iolist_to_binary([ - BaseUrl, - "?", - uri_string:compose_query(maps:to_list(Params2), [{encoding, utf8}]) - ]) - }. + BaseUrl = get_base_url(Invoice, UrlGenOpts, ProcessingContext), + case uri_string:compose_query(maps:to_list(Params2), [{encoding, utf8}]) of + {error, Error, Term} -> + erlang:throw({Error, Term}); + ParamsStr -> + #{<<"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) -> 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 fbaf715..23a7b2e 100644 --- a/apps/capi/test/capi_base_api_token_tests_SUITE.erl +++ b/apps/capi/test/capi_base_api_token_tests_SUITE.erl @@ -30,7 +30,7 @@ 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_checkout_url_ok_test/1, + create_invoice_url_ok_test/1, create_invoice_template_ok_test/1, create_invoice_template_w_randomization_ok_test/1, create_invoice_with_template_test/1, @@ -131,7 +131,7 @@ groups() -> get_invoice_by_external_id_not_impl_error, check_no_invoice_by_external_id_test, create_invoice_access_token_ok_test, - create_invoice_checkout_url_ok_test, + create_invoice_url_ok_test, create_invoice_template_ok_test, create_invoice_template_w_randomization_ok_test, create_invoice_template_autorization_error_test, @@ -405,8 +405,9 @@ create_invoice_access_token_ok_test(Config) -> ), {ok, _} = capi_client_invoices:create_invoice_access_token(?config(context, Config), ?STRING). --spec create_invoice_checkout_url_ok_test(config()) -> _. -create_invoice_checkout_url_ok_test(Config) -> +%% TODO More tests to assert filtering out bullshit input in query params +-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} @@ -414,15 +415,15 @@ create_invoice_checkout_url_ok_test(Config) -> Config ), _ = capi_ct_helper_bouncer:mock_assert_invoice_op_ctx( - <<"CreateInvoiceCheckoutUrl">>, + <<"CreateInvoiceUrl">>, ?STRING, ?STRING, ?STRING, Config ), Params = #{}, - {ok, #{<<"checkoutUrl">> => <<"http://shop-specific.local/path/to/checkout?">>}} = - capi_client_invoices:create_invoice_checkout_url(?config(context, Config), Params, ?STRING). + {ok, #{<<"url">> => <<"http://shop-specific.local/path/to/checkout?">>}} = + capi_client_invoices:create_invoice_url(?config(context, Config), Params, ?STRING). -spec create_invoice_template_ok_test(config()) -> _. create_invoice_template_ok_test(Config) -> diff --git a/config/sys.config b/config/sys.config index 8d4f730..b67a901 100644 --- a/config/sys.config +++ b/config/sys.config @@ -87,8 +87,7 @@ }}, {checkout_url_generation, [ {default_base_url, <<"https://checkout.example.com/pay">>}, - %% NOTE Allowed `createInvoiceCheckoutUrl` operation's parameter - %% keys are listed below + %% NOTE Allowed `createInvoiceUrl` operation's parameter keys are listed below {parameters_whitelist, [ <<"name">>, <<"description">>, diff --git a/rebar.lock b/rebar.lock index 5d86842..3b9cc58 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,"5baf467f08ab6df46f82c1c0b63c932449c38043"}}, + {ref,"44baa7b90411233157bf5ec4016a40b82ab5910a"}}, 0}, {<<"dmt_client">>, {git,"https://github.com/valitydev/dmt_client.git", From c25684008a2f49e7e4a0a9d00715b75f3e38d89c Mon Sep 17 00:00:00 2001 From: Aleksey Kashapov Date: Mon, 15 Jun 2026 12:29:53 +0300 Subject: [PATCH 3/7] Fixes handler w/ swag spec; adds checkout url gen config validation --- .gitignore | 1 + apps/capi/src/capi.erl | 11 ++++++++ .../src/capi_handler_decoder_invoicing.erl | 8 +++--- apps/capi/src/capi_handler_invoices.erl | 2 +- .../test/capi_base_api_token_tests_SUITE.erl | 21 ++++++++++++--- apps/capi/test/capi_ct_helper.erl | 26 +++++++++++++++++++ apps/capi/test/capi_dummy_data.hrl | 4 ++- apps/capi_client/src/capi_client_invoices.erl | 11 ++++++++ config/sys.config | 10 +++---- rebar.lock | 8 ------ 10 files changed, 81 insertions(+), 21 deletions(-) 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 e5df003..6b78c37 100644 --- a/apps/capi/src/capi_handler_decoder_invoicing.erl +++ b/apps/capi/src/capi_handler_decoder_invoicing.erl @@ -718,9 +718,11 @@ 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(), capi_handler_utils:url_params(), processing_context()) -> - capi_handler_decoder_utils:decode_data(). - +-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), diff --git a/apps/capi/src/capi_handler_invoices.erl b/apps/capi/src/capi_handler_invoices.erl index a51b703..3b78dde 100644 --- a/apps/capi/src/capi_handler_invoices.erl +++ b/apps/capi/src/capi_handler_invoices.erl @@ -103,7 +103,7 @@ prepare('CreateInvoiceUrl' = OperationID, Req, Context) -> {ok, Resolution} end, Process = fun() -> - UrlParams = maps:get(<<"params">>, Req), + 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">>)), 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 23a7b2e..c17cf9c 100644 --- a/apps/capi/test/capi_base_api_token_tests_SUITE.erl +++ b/apps/capi/test/capi_base_api_token_tests_SUITE.erl @@ -421,9 +421,24 @@ create_invoice_url_ok_test(Config) -> ?STRING, Config ), - Params = #{}, - {ok, #{<<"url">> => <<"http://shop-specific.local/path/to/checkout?">>}} = - capi_client_invoices:create_invoice_url(?config(context, Config), Params, ?STRING). + Params = #{ + <<"theme">> => ?STRING, + <<"locale">> => ?STRING, + <<"not-whitelisted">> => ?STRING + }, + EncodedParams = uri_string:compose_query( + maps:to_list( + maps:without([<<"not-whitelisted">>], Params#{ + <<"invoiceAccessToken">> => ?API_TOKEN, + <<"invoiceID">> => ?STRING + }) + ), + [{encoding, utf8}] + ), + ?assertMatch( + {ok, #{<<"url">> := <<"http://shop-specific.local/path/to/checkout?", EncodedParams/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) -> diff --git a/apps/capi/test/capi_ct_helper.erl b/apps/capi/test/capi_ct_helper.erl index 1fbd761..64661b3 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">>, + parameters_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 c5985d6..e62c40d 100644 --- a/apps/capi/test/capi_dummy_data.hrl +++ b/apps/capi/test/capi_dummy_data.hrl @@ -457,7 +457,9 @@ party_ref = #domain_PartyConfigRef{id = ?STRING}, location = ?SHOP_LOCATION, category = #domain_CategoryRef{id = ?INTEGER}, - checkout_base_url = <<"http://shop-specific.local/path/to/checkout">> + checkout_location = #domain_ShopCheckoutLocation{ + locations = [#domain_CheckoutLocation{base_url = <<"http://shop-specific.local/path/to/checkout">>}] + } }). -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 b67a901..65aaf17 100644 --- a/config/sys.config +++ b/config/sys.config @@ -85,10 +85,10 @@ customer_id => <<"dev.vality.customer.id">> } }}, - {checkout_url_generation, [ - {default_base_url, <<"https://checkout.example.com/pay">>}, + {checkout_url_generation, #{ + default_base_url => <<"https://checkout.example.com/pay">>, %% NOTE Allowed `createInvoiceUrl` operation's parameter keys are listed below - {parameters_whitelist, [ + parameters_whitelist => [ <<"name">>, <<"description">>, <<"obscureCardCvv">>, @@ -110,8 +110,8 @@ <<"documentId">>, <<"firstName">>, <<"lastName">> - ]} - ]} + ] + }} ]}, {capi_woody_server, [ diff --git a/rebar.lock b/rebar.lock index 3b9cc58..133bc2e 100644 --- a/rebar.lock +++ b/rebar.lock @@ -124,14 +124,6 @@ {ref,"de159486ef40cec67074afe71882bdc7f7deab72"}}, 1}, {<<"ssl_verify_fun">>,{pkg,<<"ssl_verify_fun">>,<<"1.1.7">>},2}, - {<<"swag_client">>, - {git,"https://github.com/valitydev/swag-payments", - {ref,"19a29dea00abf5d6653a1728d68dd28c4403f21a"}}, - 0}, - {<<"swag_server">>, - {git,"https://github.com/valitydev/swag-payments", - {ref,"1c6370142523e39c40b193f08827905a26d9df74"}}, - 0}, {<<"thrift">>, {git,"https://github.com/valitydev/thrift_erlang.git", {ref,"3a60e5dc5bbd709495024f26e100b041c3547fd9"}}, From d061d3d4f19141bdb692c3912d769afe3c608802 Mon Sep 17 00:00:00 2001 From: Aleksey Kashapov Date: Mon, 15 Jun 2026 13:42:57 +0300 Subject: [PATCH 4/7] Adds missing url assertitions for invoice creation testcases --- apps/capi/src/capi_handler_utils.erl | 10 ++-- .../test/capi_base_api_token_tests_SUITE.erl | 54 ++++++++++++------- apps/capi/test/capi_ct_helper.erl | 2 +- apps/capi/test/capi_dummy_data.hrl | 3 +- config/sys.config | 2 +- 5 files changed, 45 insertions(+), 26 deletions(-) diff --git a/apps/capi/src/capi_handler_utils.erl b/apps/capi/src/capi_handler_utils.erl index 335da9b..0f29773 100644 --- a/apps/capi/src/capi_handler_utils.erl +++ b/apps/capi/src/capi_handler_utils.erl @@ -129,7 +129,7 @@ get_party_id(Context) -> -spec validate_checkout_url_params(url_params()) -> ok | {error, Reason :: {atom(), term()}}. validate_checkout_url_params(Params0) -> UrlGenOpts = genlib_app:env(capi, checkout_url_generation), - Params1 = maps:with(maps:get(parameters_whitelist, UrlGenOpts, []), Params0), + Params1 = maps:with(maps:get(params_whitelist, UrlGenOpts, []), Params0), case uri_string:compose_query(maps:to_list(Params1), [{encoding, utf8}]) of {error, Error, Term} -> {error, {Error, Term}}; @@ -145,17 +145,19 @@ validate_checkout_url_params(Params0) -> ) -> map() | no_return(). create_checkout_url(Invoice, AccessToken, Params0, ProcessingContext) -> UrlGenOpts = genlib_app:env(capi, checkout_url_generation), - Params1 = maps:with(maps:get(parameters_whitelist, UrlGenOpts, []), Params0), + 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}); - ParamsStr -> - #{<<"url">> => <>} + EncodedParams -> + #{<<"url">> => <>} end. get_base_url( 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 c17cf9c..34a0ae0 100644 --- a/apps/capi/test/capi_base_api_token_tests_SUITE.erl +++ b/apps/capi/test/capi_base_api_token_tests_SUITE.erl @@ -258,7 +258,16 @@ 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, + <<"not-whitelisted">> => ?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_rand_amount_ok_test(config()) -> _. @@ -405,7 +414,6 @@ create_invoice_access_token_ok_test(Config) -> ), {ok, _} = capi_client_invoices:create_invoice_access_token(?config(context, Config), ?STRING). -%% TODO More tests to assert filtering out bullshit input in query params -spec create_invoice_url_ok_test(config()) -> _. create_invoice_url_ok_test(Config) -> _ = capi_ct_helper:mock_services( @@ -426,19 +434,8 @@ create_invoice_url_ok_test(Config) -> <<"locale">> => ?STRING, <<"not-whitelisted">> => ?STRING }, - EncodedParams = uri_string:compose_query( - maps:to_list( - maps:without([<<"not-whitelisted">>], Params#{ - <<"invoiceAccessToken">> => ?API_TOKEN, - <<"invoiceID">> => ?STRING - }) - ), - [{encoding, utf8}] - ), - ?assertMatch( - {ok, #{<<"url">> := <<"http://shop-specific.local/path/to/checkout?", EncodedParams/binary>>}}, - capi_client_invoices:create_invoice_url(?config(context, Config), Params, ?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_template_ok_test(config()) -> _. create_invoice_template_ok_test(Config) -> @@ -596,12 +593,17 @@ create_invoice_with_template_test(Config) -> ], Config ), - + UrlParams = #{ + <<"theme">> => ?STRING, + <<"locale">> => ?STRING, + <<"not-whitelisted">> => ?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( @@ -611,9 +613,9 @@ 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)). @@ -1851,3 +1853,17 @@ 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) -> + UrlGenOpts = genlib_app:env(capi, checkout_url_generation), + Params1 = maps:with(maps:get(params_whitelist, UrlGenOpts, []), Params0), + EncodedParams = uri_string:compose_query( + maps:to_list(Params1#{ + <<"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 64661b3..485dec1 100644 --- a/apps/capi/test/capi_ct_helper.erl +++ b/apps/capi/test/capi_ct_helper.erl @@ -178,7 +178,7 @@ start_capi(Config, ExtraEnv) -> }}, {checkout_url_generation, #{ default_base_url => <<"https://checkout.example.com/pay">>, - parameters_whitelist => [ + params_whitelist => [ <<"name">>, <<"description">>, <<"obscureCardCvv">>, diff --git a/apps/capi/test/capi_dummy_data.hrl b/apps/capi/test/capi_dummy_data.hrl index e62c40d..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">>). @@ -458,7 +459,7 @@ location = ?SHOP_LOCATION, category = #domain_CategoryRef{id = ?INTEGER}, checkout_location = #domain_ShopCheckoutLocation{ - locations = [#domain_CheckoutLocation{base_url = <<"http://shop-specific.local/path/to/checkout">>}] + locations = [#domain_CheckoutLocation{base_url = ?CHECKOUT_URL}] } }). diff --git a/config/sys.config b/config/sys.config index 65aaf17..77fcc52 100644 --- a/config/sys.config +++ b/config/sys.config @@ -88,7 +88,7 @@ {checkout_url_generation, #{ default_base_url => <<"https://checkout.example.com/pay">>, %% NOTE Allowed `createInvoiceUrl` operation's parameter keys are listed below - parameters_whitelist => [ + params_whitelist => [ <<"name">>, <<"description">>, <<"obscureCardCvv">>, From 715378bd2694d606e95035d1f58af5f66f87f55e Mon Sep 17 00:00:00 2001 From: Aleksey Kashapov Date: Tue, 16 Jun 2026 13:47:15 +0300 Subject: [PATCH 5/7] Responds w/ explicit error on bad url params keys --- .../src/capi_handler_invoice_templates.erl | 47 +++++---- apps/capi/src/capi_handler_invoices.erl | 16 ++- apps/capi/src/capi_handler_utils.erl | 39 ++++++-- .../test/capi_base_api_token_tests_SUITE.erl | 99 +++++++++++++++++-- 4 files changed, 162 insertions(+), 39 deletions(-) diff --git a/apps/capi/src/capi_handler_invoice_templates.erl b/apps/capi/src/capi_handler_invoice_templates.erl index d4b14df..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(), @@ -157,25 +163,30 @@ prepare('CreateInvoiceWithTemplate' = OperationID, Req, Context) -> capi_handler:respond_if_undefined(InvoiceTpl, general_error(404, <<"Invoice template not found">>)), InvoiceParams = maps:get('InvoiceParamsWithTemplate', Req), UrlParams = maps:get(<<"urlParams">>, InvoiceParams, #{}), - ok = validate_checkout_url_params(UrlParams), 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, 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">>)} + 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} -> diff --git a/apps/capi/src/capi_handler_invoices.erl b/apps/capi/src/capi_handler_invoices.erl index 3b78dde..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(), @@ -58,8 +64,8 @@ prepare('CreateInvoice' = OperationID, Req, Context) -> {ok, logic_error('invalidAllocation', Message)} end catch - throw:{invalid_url_params, _Reason} -> - {ok, logic_error('invalidUrlParams', <<"Bad URL params">>)}; + 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 -> @@ -111,8 +117,8 @@ prepare('CreateInvoiceUrl' = OperationID, Req, Context) -> #{<<"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, logic_error('invalidUrlParams', <<"Bad URL params">>)} + {error, Reason} -> + {ok, invalid_url_params_error(Reason)} end end, {ok, #{authorize => Authorize, process => Process}}; diff --git a/apps/capi/src/capi_handler_utils.erl b/apps/capi/src/capi_handler_utils.erl index 0f29773..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]). @@ -92,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(). @@ -126,15 +140,26 @@ get_party_id(Context) -> %% Utils --spec validate_checkout_url_params(url_params()) -> ok | {error, Reason :: {atom(), term()}}. +-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), - Params1 = maps:with(maps:get(params_whitelist, UrlGenOpts, []), Params0), - case uri_string:compose_query(maps:to_list(Params1), [{encoding, utf8}]) of - {error, Error, Term} -> - {error, {Error, Term}}; - _ -> - ok + 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( 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 34a0ae0..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,6 +24,7 @@ -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, @@ -31,9 +32,11 @@ 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, @@ -124,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, @@ -132,10 +136,12 @@ groups() -> 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, @@ -261,8 +267,7 @@ create_invoice_ok_test(Config) -> InvoiceParams0 = ?INVOICE_PARAMS, UrlParams = #{ <<"theme">> => ?STRING, - <<"locale">> => ?STRING, - <<"not-whitelisted">> => ?STRING + <<"locale">> => ?STRING }, InvoiceParams1 = maps:merge(InvoiceParams0, #{<<"urlParams">> => UrlParams}), {ok, #{<<"invoice">> := Invoice, <<"invoiceUrl">> := InvoiceUrl}} = @@ -270,6 +275,22 @@ create_invoice_ok_test(Config) -> 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, @@ -431,12 +452,37 @@ create_invoice_url_ok_test(Config) -> ), Params = #{ <<"theme">> => ?STRING, - <<"locale">> => ?STRING, - <<"not-whitelisted">> => ?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( @@ -595,8 +641,7 @@ create_invoice_with_template_test(Config) -> ), UrlParams = #{ <<"theme">> => ?STRING, - <<"locale">> => ?STRING, - <<"not-whitelisted">> => ?STRING + <<"locale">> => ?STRING }, Req = #{ <<"amount">> => ?INTEGER, @@ -619,6 +664,44 @@ create_invoice_with_template_test(Config) -> ?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(), @@ -1856,10 +1939,8 @@ different_ip_header(Config) -> -spec assert_invoice_url(binary(), binary(), map(), map()) -> ok | no_return(). assert_invoice_url(InvoiceID, BaseUrl, Params0, InvoiceUrl) -> - UrlGenOpts = genlib_app:env(capi, checkout_url_generation), - Params1 = maps:with(maps:get(params_whitelist, UrlGenOpts, []), Params0), EncodedParams = uri_string:compose_query( - maps:to_list(Params1#{ + maps:to_list(Params0#{ <<"invoiceAccessToken">> => ?API_TOKEN, <<"invoiceID">> => InvoiceID }), From 3268736ab6318f48112acb42971f1132fc37afb8 Mon Sep 17 00:00:00 2001 From: Aleksey Kashapov Date: Tue, 16 Jun 2026 15:49:24 +0300 Subject: [PATCH 6/7] Bumps `swag_client` & `swag_server` deps --- rebar.lock | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/rebar.lock b/rebar.lock index 133bc2e..f0c3dad 100644 --- a/rebar.lock +++ b/rebar.lock @@ -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"}}, @@ -124,6 +124,14 @@ {ref,"de159486ef40cec67074afe71882bdc7f7deab72"}}, 1}, {<<"ssl_verify_fun">>,{pkg,<<"ssl_verify_fun">>,<<"1.1.7">>},2}, + {<<"swag_client">>, + {git,"https://github.com/valitydev/swag-payments", + {ref,"fc4d014dd6d1a9b86bb1efa9fd36b2a718ee755d"}}, + 0}, + {<<"swag_server">>, + {git,"https://github.com/valitydev/swag-payments", + {ref,"1e0ffc210a419c42fa10955b16a2405399a2cc70"}}, + 0}, {<<"thrift">>, {git,"https://github.com/valitydev/thrift_erlang.git", {ref,"3a60e5dc5bbd709495024f26e100b041c3547fd9"}}, @@ -166,7 +174,7 @@ {<<"idna">>, <<"8A63070E9F7D0C62EB9D9FCB360A7DE382448200FBBD1B106CC96D3D8099DF8D">>}, {<<"jsx">>, <<"D12516BAA0BB23A59BB35DCCAF02A1BD08243FCBB9EFE24F2D9D056CCFF71268">>}, {<<"metrics">>, <<"25F094DEA2CDA98213CECC3AEFF09E940299D950904393B2A29D191C346A8486">>}, - {<<"mimerl">>, <<"3882A5CA67FBBE7117BA8947F27643557ADEC38FA2307490C4C4207624CB213B">>}, + {<<"mimerl">>, <<"F35ACA6F23242339B3666E0AC0702379E362B469D0AEA167F6CC713547E777ED">>}, {<<"opentelemetry">>, <<"20D0F12D3D1C398D3670FD44FD1A7C495DD748AB3E5B692A7906662E2FB1A38A">>}, {<<"opentelemetry_api">>, <<"1A676F3E3340CAB81C763E939A42E11A70C22863F645AA06AAFEFC689B5550CF">>}, {<<"opentelemetry_exporter">>, <<"972E142392DBFA679EC959914664ADEFEA38399E4F56CEBA5C473E1CABDBAD79">>}, @@ -196,7 +204,7 @@ {<<"idna">>, <<"92376EB7894412ED19AC475E4A86F7B413C1B9FBB5BD16DCCD57934157944CEA">>}, {<<"jsx">>, <<"0C5CC8FDC11B53CC25CF65AC6705AD39E54ECC56D1C22E4ADB8F5A53FB9427F3">>}, {<<"metrics">>, <<"69B09ADDDC4F74A40716AE54D140F93BEB0FB8978D8636EADED0C31B6F099F16">>}, - {<<"mimerl">>, <<"13AF15F9F68C65884ECCA3A3891D50A7B57D82152792F3E19D88650AA126B144">>}, + {<<"mimerl">>, <<"DB648CE065BAE14EA84CA8B5DD123F42F49417CEF693541110BF6F9E9BE9ECC4">>}, {<<"opentelemetry">>, <<"A9173B058C4549BF824CBC2F1D2FA2ADC5CDEDC22AA3F0F826951187BBD53131">>}, {<<"opentelemetry_api">>, <<"F53EC8A1337AE4A487D43AC89DA4BD3A3C99DDF576655D071DEED8B56A2D5DDA">>}, {<<"opentelemetry_exporter">>, <<"33A116ED7304CB91783F779DEC02478F887C87988077BFD72840F760B8D4B952">>}, From b1e2ba7bb1d2d750a74946efd216699884780667 Mon Sep 17 00:00:00 2001 From: Aleksey Kashapov Date: Wed, 17 Jun 2026 11:37:24 +0300 Subject: [PATCH 7/7] Bumps damsel --- rebar.config | 2 +- rebar.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/rebar.config b/rebar.config index eec4ac2..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", {branch, "XYZ-182/ft/invoice-checkout-url"}}}, + {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 f0c3dad..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,"44baa7b90411233157bf5ec4016a40b82ab5910a"}}, + {ref,"dcd668277f5a2a144dd823bb0e6c9ba7b94428cc"}}, 0}, {<<"dmt_client">>, {git,"https://github.com/valitydev/dmt_client.git",