diff --git a/apps/hellgate/src/hg_invoice_payment.erl b/apps/hellgate/src/hg_invoice_payment.erl index 3633d42..d35cedf 100644 --- a/apps/hellgate/src/hg_invoice_payment.erl +++ b/apps/hellgate/src/hg_invoice_payment.erl @@ -1976,9 +1976,12 @@ produce_routing_events(Ctx = #{error := Error}, _Revision, St) when Error =/= un %% rejected because of provider gone critical, then use subcode to highlight %% the offender. Like 'provider_dead' or 'conversion_lacking'. Failure = genlib:define(St#st.failure, construct_routing_failure(Error)), - InitialCandidates = [hg_route:to_payment_route(R) || R <- hg_routing_ctx:initial_candidates(Ctx)], - Route = hd(InitialCandidates), - Candidates = ordsets:from_list(InitialCandidates), + %% NOTE Not all initial candidates have their according limits held. And so + %% we must account only for those that can be rolled back. + RollbackableCandidates = hg_routing_ctx:accounted_candidates(Ctx), + Route = hg_route:to_payment_route(hd(RollbackableCandidates)), + Candidates = + ordsets:from_list([hg_route:to_payment_route(R) || R <- RollbackableCandidates]), %% For protocol compatability we set choosen route in route_changed event. %% It doesn't influence cash_flow building because this step will be %% skipped. And all limit's 'hold' operations will be rolled back. @@ -2201,7 +2204,7 @@ finish_session_processing(Activity, {Events0, Action}, Session, St0) -> %% Previously used routes are supposed to have their limits already rolled back. Route = get_route(St0), Routes = [Route], - _ = rollback_payment_limits(Routes, get_iter(St0), St0), + _ = rollback_payment_limits(Routes, get_iter(St0), St0, []), _ = rollback_payment_cashflow(St0); _ -> ok @@ -2285,7 +2288,7 @@ process_result({payment, processing_accounter}, Action, St) -> process_result({payment, routing_failure}, Action, St = #st{failure = Failure}) -> NewAction = hg_machine_action:set_timeout(0, Action), Routes = get_candidate_routes(St), - _ = rollback_payment_limits(Routes, get_iter(St), St, [ignore_business_error]), + _ = rollback_payment_limits(Routes, get_iter(St), St, [ignore_business_error, ignore_not_found]), {done, {[?payment_status_changed(?failed(Failure))], NewAction}}; process_result({payment, processing_failure}, Action, St = #st{failure = Failure}) -> NewAction = hg_machine_action:set_timeout(0, Action), @@ -2293,7 +2296,7 @@ process_result({payment, processing_failure}, Action, St = #st{failure = Failure %% Previously used routes are supposed to have their limits already rolled back. Route = get_route(St), Routes = [Route], - _ = rollback_payment_limits(Routes, get_iter(St), St), + _ = rollback_payment_limits(Routes, get_iter(St), St, []), _ = rollback_payment_cashflow(St), Revision = get_payment_revision(St), Behaviour = get_route_cascade_behaviour(Route, Revision), @@ -2310,7 +2313,7 @@ process_result({payment, finalizing_accounter}, Action, St) -> commit_payment_cashflow(St); ?cancelled() -> Route = get_route(St), - _ = rollback_payment_limits([Route], get_iter(St), St), + _ = rollback_payment_limits([Route], get_iter(St), St, []), rollback_payment_cashflow(St) end, check_recurrent_token(St), @@ -2548,9 +2551,6 @@ do_reject_route(LimiterError, Route, TurnoverLimits, {LimitHeldRoutes, RejectedR RejectedRoute = hg_route:to_rejected_route(Route, {'LimitHoldError', LimitsIDs, LimiterError}), {LimitHeldRoutes, [RejectedRoute | RejectedRoutes]}. -rollback_payment_limits(Routes, Iter, St) -> - rollback_payment_limits(Routes, Iter, St, []). - rollback_payment_limits(Routes, Iter, St, Flags) -> Opts = get_opts(St), Revision = get_payment_revision(St), @@ -2597,7 +2597,7 @@ rollback_unused_payment_limits(St) -> Route = get_route(St), Routes = get_candidate_routes(St), UnUsedRoutes = Routes -- [Route], - rollback_payment_limits(UnUsedRoutes, get_iter(St), St, [ignore_business_error]). + rollback_payment_limits(UnUsedRoutes, get_iter(St), St, [ignore_business_error, ignore_not_found]). get_turnover_limits(ProviderTerms) -> TurnoverLimitSelector = ProviderTerms#domain_PaymentsProvisionTerms.turnover_limits, diff --git a/apps/hellgate/src/hg_limiter.erl b/apps/hellgate/src/hg_limiter.erl index dc86d5b..f2823b2 100644 --- a/apps/hellgate/src/hg_limiter.erl +++ b/apps/hellgate/src/hg_limiter.erl @@ -14,7 +14,7 @@ -type route() :: hg_route:payment_route(). -type refund() :: hg_invoice_payment:domain_refund(). -type cash() :: dmsl_domain_thrift:'Cash'(). --type handling_flag() :: ignore_business_error. +-type handling_flag() :: ignore_business_error | ignore_not_found. -type turnover_limit_value() :: dmsl_payproc_thrift:'TurnoverLimitValue'(). -type change_queue() :: [hg_limiter_client:limit_change()]. @@ -129,6 +129,14 @@ commit_refund_limits(TurnoverLimits, Invoice, Payment, Refund, Route) -> ok = commit(LimitChanges, Clock, Context), ok = log_limit_changes(TurnoverLimits, Clock, Context). +%% @doc This function supports flags that can change reaction behaviour to +%% limiter response: +%% +%% - `ignore_business_error` -- prevents error raise upon misconfiguration +%% failures in limiter config +%% +%% - `ignore_not_found` -- does not raise error if limiter won't be able to +%% find according posting plan in accountant service -spec rollback_payment_limits([turnover_limit()], route(), pos_integer(), invoice(), payment(), [handling_flag()]) -> ok. rollback_payment_limits(TurnoverLimits, Route, Iter, Invoice, Payment, Flags) -> @@ -167,16 +175,26 @@ process_changes(LimitChangesQueues, WithFun, Clock, Context, Flags) -> LimitChangesQueues ). -process_changes_try_wrap([LimitChange], WithFun, Clock, Context, _Flags) -> - WithFun(LimitChange, Clock, Context); +%% Very specific error to crutch around +-define(POSTING_PLAN_NOT_FOUND(ID), #base_InvalidRequest{errors = [<<"Posting plan not found: ", ID/binary>>]}). + +process_changes_try_wrap([LimitChange], WithFun, Clock, Context, Flags) -> + IgnoreNotFound = lists:member(ignore_not_found, Flags), + #limiter_LimitChange{change_id = ChangeID} = LimitChange, + try + WithFun(LimitChange, Clock, Context) + catch + error:(?POSTING_PLAN_NOT_FOUND(ChangeID)) when IgnoreNotFound =:= true -> + %% See `limproto_limiter_thrift:'Clock'/0` + {latest, #limiter_LatestClock{}} + end; process_changes_try_wrap([LimitChange | OtherLimitChanges], WithFun, Clock, Context, Flags) -> IgnoreBusinessError = lists:member(ignore_business_error, Flags), #limiter_LimitChange{change_id = ChangeID} = LimitChange, try WithFun(LimitChange, Clock, Context) catch - %% Very specific error to crutch around - error:#base_InvalidRequest{errors = [<<"Posting plan not found: ", ChangeID/binary>>]} -> + error:(?POSTING_PLAN_NOT_FOUND(ChangeID)) -> process_changes_try_wrap(OtherLimitChanges, WithFun, Clock, Context, Flags); Class:Reason:Stacktrace -> handle_caught_exception(Class, Reason, Stacktrace, IgnoreBusinessError) diff --git a/apps/hellgate/src/hg_routing_ctx.erl b/apps/hellgate/src/hg_routing_ctx.erl index b941b9a..1653208 100644 --- a/apps/hellgate/src/hg_routing_ctx.erl +++ b/apps/hellgate/src/hg_routing_ctx.erl @@ -13,6 +13,7 @@ -export([initial_candidates/1]). -export([stash_current_candidates/1]). -export([considered_candidates/1]). +-export([accounted_candidates/1]). -export([choosen_route/1]). -export([process/2]). -export([with_guard/1]). @@ -127,7 +128,17 @@ initial_candidates(#{initial_candidates := InitialCandidates}) -> considered_candidates(Ctx) -> maps:get(stashed_candidates, Ctx, candidates(Ctx)). +%% @doc Same as 'considered_candidates/1' except for it fallbacks to initial +%% candidates if no were stashed. +%% +%% Its use-case is simillar to 'considered_candidates/1' as well. +-spec accounted_candidates(t()) -> [hg_route:t()]. +accounted_candidates(Ctx) -> + maps:get(stashed_candidates, Ctx, initial_candidates(Ctx)). + -spec stash_current_candidates(t()) -> t(). +stash_current_candidates(Ctx = #{candidates := []}) -> + Ctx; stash_current_candidates(Ctx) -> Ctx#{stashed_candidates => candidates(Ctx)}. diff --git a/apps/hellgate/test/hg_dummy_provider.erl b/apps/hellgate/test/hg_dummy_provider.erl index 8978e09..259e82e 100644 --- a/apps/hellgate/test/hg_dummy_provider.erl +++ b/apps/hellgate/test/hg_dummy_provider.erl @@ -263,24 +263,16 @@ token_respond(Response, CallbackResult) -> % % Payments % -process_payment( - ?processed(), - undefined, - PaymentInfo, - #{<<"always_fail">> := FailureCode, <<"override">> := ProviderCode} = CtxOpts, - _ -) -> - _ = maybe_sleep(CtxOpts), - Reason = <<"sub failure by ", ProviderCode/binary>>, - Failure = payproc_errors:from_notation(FailureCode, <<"sub failure by ", ProviderCode/binary>>), - TrxID = hg_utils:construct_complex_id([get_payment_id(PaymentInfo), get_ctx_opts_override(CtxOpts)]), - result(?finish({failure, Failure}), <<"state: ", Reason/binary>>, mk_trx(TrxID, PaymentInfo)); process_payment(?processed(), undefined, PaymentInfo, CtxOpts, _) -> case get_payment_info_scenario(PaymentInfo) of {preauth_3ds, Timeout} -> Tag = generate_tag(<<"payment">>), Uri = get_callback_url(), - result(?suspend(Tag, Timeout, ?redirect(Uri, #{<<"tag">> => Tag})), <<"suspended">>); + SuspendWithUI = result(?suspend(Tag, Timeout, ?redirect(Uri, #{<<"tag">> => Tag})), <<"suspended">>), + case CtxOpts of + #{<<"allow_ui">> := _} -> SuspendWithUI; + _ -> maybe_fail(PaymentInfo, CtxOpts, SuspendWithUI) + end; change_cash_increase -> %% simple workflow without 3DS result(?sleep(0), <<"sleeping">>); @@ -289,7 +281,7 @@ process_payment(?processed(), undefined, PaymentInfo, CtxOpts, _) -> result(?sleep(0), <<"sleeping">>); no_preauth -> %% simple workflow without 3DS - result(?sleep(0), <<"sleeping">>); + maybe_fail(PaymentInfo, CtxOpts, result(?sleep(0), <<"sleeping">>)); empty_cvv -> %% simple workflow without 3DS result(?sleep(0), <<"sleeping">>); @@ -352,7 +344,7 @@ process_payment(?processed(), <<"sleeping">>, PaymentInfo, CtxOpts, _) -> {temporary_unavailability, Scenario} -> process_failure_scenario(PaymentInfo, Scenario, TrxID); _ -> - finish(success(PaymentInfo), mk_trx(TrxID, PaymentInfo)) + maybe_fail(PaymentInfo, CtxOpts, finish(success(PaymentInfo), mk_trx(TrxID, PaymentInfo))) end; process_payment(?processed(), <<"sleeping_with_user_interaction">>, PaymentInfo, CtxOpts, _) -> Key = {get_invoice_id(PaymentInfo), get_payment_id(PaymentInfo)}, @@ -546,6 +538,15 @@ result(Intent, NextState, Trx) -> trx = Trx }. +maybe_fail(PaymentInfo, #{<<"always_fail">> := FailureCode, <<"override">> := ProviderCode} = CtxOpts, _OrElse) -> + _ = maybe_sleep(CtxOpts), + Reason = <<"sub failure by ", ProviderCode/binary>>, + Failure = payproc_errors:from_notation(FailureCode, <<"sub failure by ", ProviderCode/binary>>), + TrxID = hg_utils:construct_complex_id([get_payment_id(PaymentInfo), get_ctx_opts_override(CtxOpts)]), + result(?finish({failure, Failure}), <<"state: ", Reason/binary>>, mk_trx(TrxID, PaymentInfo)); +maybe_fail(_PaymentInfo, _CtxOpts, OrElse) -> + OrElse. + -spec mk_trx(binary()) -> dmsl_domain_thrift:'TransactionInfo'(). mk_trx(TrxID) -> mk_trx_w_extra(TrxID, #{}). diff --git a/apps/hellgate/test/hg_invoice_tests_SUITE.erl b/apps/hellgate/test/hg_invoice_tests_SUITE.erl index 9f518a1..12f38e1 100644 --- a/apps/hellgate/test/hg_invoice_tests_SUITE.erl +++ b/apps/hellgate/test/hg_invoice_tests_SUITE.erl @@ -195,6 +195,7 @@ -export([payment_cascade_fail_wo_route_candidates/1]). -export([payment_cascade_success_w_refund/1]). -export([payment_big_cascade_success/1]). +-export([payment_cascade_limit_overflow/1]). -export([payment_cascade_fail_wo_available_attempt_limit/1]). -export([payment_cascade_failures/1]). -export([payment_cascade_deadline_failures/1]). @@ -482,11 +483,12 @@ groups() -> payment_cascade_fail_wo_route_candidates, payment_cascade_success_w_refund, payment_big_cascade_success, + payment_cascade_limit_overflow, payment_cascade_fail_wo_available_attempt_limit, payment_cascade_failures, payment_cascade_deadline_failures, - payment_cascade_fail_provider_error - %% payment_cascade_fail_ui + payment_cascade_fail_provider_error, + payment_cascade_fail_ui ]} ]. @@ -5931,9 +5933,12 @@ consistent_account_balances(C) -> -define(PAYMENT_CASCADE_DEADLINE_FAILURES_ID, 700). -define(PAYMENT_CASCADE_FAIL_PROVIDER_ERROR_ID, 800). -define(PAYMENT_CASCADE_FAIL_UI_ID, 900). +-define(PAYMENT_CASCADE_LIMIT_OVERFLOW_ID, 1000). cascade_fixture_pre_shop_create(Revision, C) -> payment_big_cascade_success_fixture_pre(Revision, C) ++ + payment_cascade_limit_overflow_fixture_pre(Revision, C) ++ + payment_cascade_fail_ui_fixture_pre(Revision, C) ++ payment_cascade_fail_wo_route_candidates_fixture_pre(Revision, C) ++ payment_cascade_fail_wo_available_attempt_limit_fixture_pre(Revision, C) ++ payment_cascade_fail_provider_error_fixture_pre(Revision, C). @@ -5944,7 +5949,7 @@ cascade_fixture(Revision, C) -> [ hg_ct_fixture:construct_payment_routing_ruleset( ?ruleset(2), - <<"2 routes with failing providers">>, + <<"Multiple routes with failing providers">>, {delegates, [ ?delegate( ?partycond(PartyID, {shop_is, cfg({shop_id, ?PAYMENT_CASCADE_SUCCESS_ID}, C)}), @@ -5980,14 +5985,21 @@ cascade_fixture(Revision, C) -> ?delegate( ?partycond(PartyID, {shop_is, cfg({shop_id, ?PAYMENT_CASCADE_FAIL_PROVIDER_ERROR_ID}, C)}), ?ruleset(?CASCADE_ID_RANGE(?PAYMENT_CASCADE_FAIL_PROVIDER_ERROR_ID)) + ), + ?delegate( + ?partycond(PartyID, {shop_is, cfg({shop_id, ?PAYMENT_CASCADE_LIMIT_OVERFLOW_ID}, C)}), + ?ruleset(?CASCADE_ID_RANGE(?PAYMENT_CASCADE_LIMIT_OVERFLOW_ID)) + ), + ?delegate( + ?partycond(PartyID, {shop_is, cfg({shop_id, ?PAYMENT_CASCADE_FAIL_UI_ID}, C)}), + ?ruleset(?CASCADE_ID_RANGE(?PAYMENT_CASCADE_FAIL_UI_ID)) ) - %% ?delegate( - %% ?partycond(PartyID, {shop_is, cfg({shop_id, ?PAYMENT_CASCADE_FAIL_UI_ID}, C)}), - %% ?ruleset(?CASCADE_ID_RANGE(?PAYMENT_CASCADE_FAIL_UI_ID))) ]} ) ] ++ payment_cascade_success_fixture(Revision, C) ++ + payment_cascade_limit_overflow_fixture(Revision, C) ++ + payment_cascade_fail_ui_fixture(Revision, C) ++ payment_cascade_fail_wo_route_candidates_fixture(Revision, C) ++ payment_cascade_success_w_refund_fixture(Revision, C) ++ payment_big_cascade_success_fixture(Revision, C) ++ @@ -6061,9 +6073,27 @@ init_route_cascading_group(C1) -> PartyClient ) }, + { + {shop_id, ?PAYMENT_CASCADE_LIMIT_OVERFLOW_ID}, + hg_ct_helper:create_shop( + PartyID, + ?cat(1), + <<"RUB">>, + ?tmpl(?CASCADE_ID_RANGE(?PAYMENT_CASCADE_LIMIT_OVERFLOW_ID)), + ?pinst(1), + PartyClient + ) + }, { {shop_id, ?PAYMENT_CASCADE_FAIL_UI_ID}, - hg_ct_helper:create_shop(PartyID, ?cat(1), <<"RUB">>, ?tmpl(1), ?pinst(1), PartyClient) + hg_ct_helper:create_shop( + PartyID, + ?cat(1), + <<"RUB">>, + ?tmpl(?CASCADE_ID_RANGE(?PAYMENT_CASCADE_FAIL_UI_ID)), + ?pinst(1), + PartyClient + ) } | C1 ], @@ -6081,6 +6111,9 @@ init_per_cascade_case(payment_cascade_success_w_refund, C) -> init_per_cascade_case(payment_big_cascade_success, C) -> ShopID = cfg({shop_id, ?PAYMENT_BIG_CASCADE_SUCCESS_ID}, C), [{shop_id, ShopID} | C]; +init_per_cascade_case(payment_cascade_limit_overflow, C) -> + ShopID = cfg({shop_id, ?PAYMENT_CASCADE_LIMIT_OVERFLOW_ID}, C), + [{shop_id, ShopID} | C]; init_per_cascade_case(payment_cascade_fail_wo_available_attempt_limit, C) -> ShopID = cfg({shop_id, ?PAYMENT_CASCADE_FAIL_WO_AVAILABLE_ATTEMPT_LIMIT_ID}, C), [{shop_id, ShopID} | C]; @@ -6456,6 +6489,140 @@ payment_big_cascade_success_fixture(Revision, _C) -> ) ]). +payment_cascade_limit_overflow_fixture_pre(Revision, _C) -> + lists:flatten([ + hg_ct_fixture:construct_contract_template( + ?tmpl(?CASCADE_ID_RANGE(?PAYMENT_CASCADE_LIMIT_OVERFLOW_ID)), + ?trms(?CASCADE_ID_RANGE(?PAYMENT_CASCADE_LIMIT_OVERFLOW_ID)) + ), + new_merchant_terms_attempt_limit( + ?trms(1), + ?trms(?CASCADE_ID_RANGE(?PAYMENT_CASCADE_LIMIT_OVERFLOW_ID)), + 10, + Revision + ) + ]). + +payment_cascade_limit_overflow_fixture(Revision, _C) -> + Brovider = + #domain_Provider{abs_account = AbsAccount, accounts = Accounts, terms = Terms} = + hg_domain:get(Revision, {provider, ?prv(1)}), + Terms1 = + Terms#domain_ProvisionTermSet{ + payments = Terms#domain_ProvisionTermSet.payments#domain_PaymentsProvisionTerms{ + turnover_limits = + {value, [ + #domain_TurnoverLimit{ + id = ?LIMIT_ID4, + upper_boundary = ?LIMIT_UPPER_BOUNDARY, + domain_revision = Revision + } + ]} + } + }, + [ + {provider, #domain_ProviderObject{ + ref = ?prv(?CASCADE_ID_RANGE(?PAYMENT_CASCADE_LIMIT_OVERFLOW_ID + 1)), + data = Brovider#domain_Provider{terms = Terms1} + }}, + {provider, #domain_ProviderObject{ + ref = ?prv(?CASCADE_ID_RANGE(?PAYMENT_CASCADE_LIMIT_OVERFLOW_ID + 2)), + data = #domain_Provider{ + name = <<"Duck Blocker">>, + description = <<"No rubber ducks for you!">>, + proxy = #domain_Proxy{ + ref = ?prx(1), + additional = #{ + <<"always_fail">> => <<"authorization_failed:unknown">>, + <<"override">> => <<"duckblocker">> + } + }, + abs_account = AbsAccount, + accounts = Accounts, + %% No limit boundaries configured + terms = Terms + } + }}, + {terminal, #domain_TerminalObject{ + ref = ?trm(?CASCADE_ID_RANGE(?PAYMENT_CASCADE_LIMIT_OVERFLOW_ID + 1)), + data = #domain_Terminal{ + name = <<"Brominal 1">>, + description = <<"Brominal 1">>, + provider_ref = ?prv(?CASCADE_ID_RANGE(?PAYMENT_CASCADE_LIMIT_OVERFLOW_ID + 1)) + } + }}, + {terminal, #domain_TerminalObject{ + ref = ?trm(?CASCADE_ID_RANGE(?PAYMENT_CASCADE_LIMIT_OVERFLOW_ID + 2)), + data = #domain_Terminal{ + name = <<"Not-Brominal">>, + description = <<"Not-Brominal">>, + provider_ref = ?prv(?CASCADE_ID_RANGE(?PAYMENT_CASCADE_LIMIT_OVERFLOW_ID + 2)) + } + }}, + hg_ct_fixture:construct_payment_routing_ruleset( + ?ruleset(?CASCADE_ID_RANGE(?PAYMENT_CASCADE_LIMIT_OVERFLOW_ID)), + <<"Main with cascading">>, + {candidates, [ + ?candidate({constant, true}, ?trm(?CASCADE_ID_RANGE(?PAYMENT_CASCADE_LIMIT_OVERFLOW_ID + 2))), + ?candidate({constant, true}, ?trm(?CASCADE_ID_RANGE(?PAYMENT_CASCADE_LIMIT_OVERFLOW_ID + 1))) + ]} + ) + ]. + +-spec payment_cascade_limit_overflow(config()) -> test_return(). +payment_cascade_limit_overflow(C) -> + Client = cfg(client, C), + Amount = 42000 + ?LIMIT_UPPER_BOUNDARY, + InvoiceParams = make_invoice_params( + cfg(party_id, C), + cfg(shop_id, C), + <<"rubberduck">>, + make_due_date(10), + make_cash(Amount) + ), + ?invoice_state(Invoice = ?invoice(InvoiceID)) = hg_client_invoicing:create(InvoiceParams, Client), + {PaymentTool, Session} = hg_dummy_provider:make_payment_tool(no_preauth, ?pmt_sys(<<"visa-ref">>)), + Context = #base_Content{ + type = <<"application/x-erlang-binary">>, + data = erlang:term_to_binary({you, 643, "not", [<<"welcome">>, here]}) + }, + PayerSessionInfo = #domain_PayerSessionInfo{ + redirect_url = <<"https://redirectly.io/merchant">> + }, + PaymentParams = (make_payment_params(PaymentTool, Session, instant))#payproc_InvoicePaymentParams{ + payer_session_info = PayerSessionInfo, + context = Context + }, + #payproc_InvoicePayment{payment = Payment} = hg_client_invoicing:start_payment(InvoiceID, PaymentParams, Client), + ?invoice_created(?invoice_w_status(?invoice_unpaid())) = next_change(InvoiceID, Client), + {ok, Limit} = hg_limiter_helper:get_payment_limit_amount(?LIMIT_ID4, hg_domain:head(), Payment, Invoice), + InitialAccountedAmount = hg_limiter_helper:get_amount(Limit), + ?payment_ev(PaymentID, ?payment_started(?payment_w_status(?pending()))) = + next_change(InvoiceID, Client), + ?payment_ev(PaymentID, ?risk_score_changed(_)) = + next_change(InvoiceID, Client), + {Route1, _CashFlow1, _TrxID1, Failure1} = + await_cascade_triggering(InvoiceID, PaymentID, Client), + ok = payproc_errors:match('PaymentFailure', Failure1, fun({authorization_failed, {unknown, _}}) -> ok end), + %% And again but no route found + [ + ?payment_ev(PaymentID, ?route_changed(Route2, Candidates2)), + ?payment_ev(PaymentID, ?payment_rollback_started({failure, Failure2})), + ?payment_ev(PaymentID, ?payment_status_changed(?failed({failure, Failure2}))) + ] = + next_changes(InvoiceID, 3, Client), + ?assertNotEqual(Route1, Route2), + ?assertNot(lists:member(Route1, Candidates2)), + %% No route found and so we pass original failure from previous attempt + ok = payproc_errors:match('PaymentFailure', Failure2, fun({authorization_failed, {unknown, _}}) -> ok end), + %% Assert payment status IS failed + ?invoice_state(?invoice_w_status(_), [?payment_state(FinalPayment)]) = + hg_client_invoicing:get(InvoiceID, Client), + ?assertMatch(#domain_InvoicePayment{status = {failed, _}}, FinalPayment), + ?invoice_status_changed(?invoice_cancelled(<<"overdue">>)) = next_change(InvoiceID, Client), + %% At the end of this scenario limit must not be changed. + hg_limiter_helper:assert_payment_limit_amount(?LIMIT_ID4, InitialAccountedAmount, FinalPayment, Invoice). + -spec payment_big_cascade_success(config()) -> test_return(). payment_big_cascade_success(C) -> Client = cfg(client, C), @@ -6656,10 +6823,126 @@ payment_cascade_fail_provider_error(C) -> ?assertMatch(#domain_InvoicePayment{status = {failed, _}}, Payment), ?invoice_status_changed(?invoice_cancelled(<<"overdue">>)) = next_change(InvoiceID, Client). +payment_cascade_fail_ui_fixture_pre(Revision, _C) -> + lists:flatten([ + hg_ct_fixture:construct_contract_template( + ?tmpl(?CASCADE_ID_RANGE(?PAYMENT_CASCADE_FAIL_UI_ID)), + ?trms(?CASCADE_ID_RANGE(?PAYMENT_CASCADE_FAIL_UI_ID)) + ), + new_merchant_terms_attempt_limit( + ?trms(1), + ?trms(?CASCADE_ID_RANGE(?PAYMENT_CASCADE_FAIL_UI_ID)), + 10, + Revision + ) + ]). + +payment_cascade_fail_ui_fixture(Revision, _C) -> + Brovider = + #domain_Provider{abs_account = AbsAccount, accounts = Accounts, terms = Terms} = + hg_domain:get(Revision, {provider, ?prv(1)}), + lists:flatten([ + {provider, #domain_ProviderObject{ + ref = ?prv(?CASCADE_ID_RANGE(?PAYMENT_CASCADE_FAIL_UI_ID + 1)), + data = Brovider#domain_Provider{terms = Terms} + }}, + {provider, #domain_ProviderObject{ + ref = ?prv(?CASCADE_ID_RANGE(?PAYMENT_CASCADE_FAIL_UI_ID + 2)), + data = #domain_Provider{ + name = <<"Rubber GUI">>, + description = <<"( ͡° ͜ʖ ͡° )">>, + proxy = #domain_Proxy{ + ref = ?prx(1), + additional = #{ + <<"allow_ui">> => <<"true">>, + <<"always_fail">> => <<"preauthorization_failed:unknown">>, + <<"override">> => <<"rubber_gui">> + } + }, + abs_account = AbsAccount, + accounts = Accounts, + terms = Terms + } + }}, + {provider, #domain_ProviderObject{ + ref = ?prv(?CASCADE_ID_RANGE(?PAYMENT_CASCADE_FAIL_UI_ID + 3)), + data = #domain_Provider{ + name = <<"Duck Blocker">>, + description = <<"No rubber ducks for you!">>, + proxy = #domain_Proxy{ + ref = ?prx(1), + additional = #{ + <<"always_fail">> => <<"authorization_failed:unknown">>, + <<"override">> => <<"duckblocker">> + } + }, + abs_account = AbsAccount, + accounts = Accounts, + terms = Terms + } + }}, + [ + {terminal, #domain_TerminalObject{ + ref = ?trm(?CASCADE_ID_RANGE(?PAYMENT_CASCADE_FAIL_UI_ID + I)), + data = #domain_Terminal{ + name = <<"Brominal ", (integer_to_binary(I))/binary>>, + description = <<"Brominal ", (integer_to_binary(I))/binary>>, + provider_ref = ?prv(?CASCADE_ID_RANGE(?PAYMENT_CASCADE_FAIL_UI_ID + I)) + } + }} + || I <- lists:seq(1, 3) + ], + hg_ct_fixture:construct_payment_routing_ruleset( + ?ruleset(?CASCADE_ID_RANGE(?PAYMENT_CASCADE_FAIL_UI_ID)), + <<"1 fail, 2 with UI, 3 never reached">>, + {candidates, [ + ?candidate({constant, true}, ?trm(?CASCADE_ID_RANGE(?PAYMENT_CASCADE_FAIL_UI_ID + I))) + || I <- lists:reverse(lists:seq(1, 3)) + ]} + ) + ]). + -spec payment_cascade_fail_ui(config()) -> test_return(). -payment_cascade_fail_ui(_C) -> - %% TODO teach hg_dummy_provider how to fail after receiving user_interaction - ok. +payment_cascade_fail_ui(C) -> + Client = cfg(client, C), + Amount = 42000, + InvoiceID = start_invoice(<<"rubberduck">>, make_due_date(10), Amount, C), + {PaymentTool, Session} = hg_dummy_provider:make_payment_tool(preauth_3ds, ?pmt_sys(<<"visa-ref">>)), + PaymentParams = make_payment_params(PaymentTool, Session, instant), + hg_client_invoicing:start_payment(InvoiceID, PaymentParams, Client), + ?payment_ev(PaymentID, ?payment_started(?payment_w_status(?pending()))) = + next_change(InvoiceID, Client), + ?payment_ev(PaymentID, ?risk_score_changed(_)) = + next_change(InvoiceID, Client), + {_Route1, _CashFlow1, _TrxID1, Failure1} = + await_cascade_triggering(InvoiceID, PaymentID, Client), + ok = payproc_errors:match('PaymentFailure', Failure1, fun({authorization_failed, {unknown, _}}) -> ok end), + %% And again with UI + [ + ?payment_ev(PaymentID, ?route_changed(_Route2)), + ?payment_ev(PaymentID, ?cash_flow_changed(_CashFlow2)) + ] = next_changes(InvoiceID, 2, Client), + UserInteraction = await_payment_process_interaction(InvoiceID, PaymentID, Client), + {URL, Form} = get_post_request(UserInteraction), + _ = assert_success_post_request({URL, Form}), + ok = await_payment_process_interaction_completion(InvoiceID, PaymentID, UserInteraction, Client), + [ + ?payment_ev(PaymentID, ?session_ev(?processed(), ?trx_bound(?trx_info(_TrxID2)))), + ?payment_ev( + PaymentID, + ?session_ev(?processed(), ?session_finished(?session_failed({failure, Failure2}))) + ), + ?payment_ev(PaymentID, ?payment_rollback_started({failure, Failure2})) + ] = + next_changes(InvoiceID, 3, Client), + ?payment_ev(PaymentID, ?payment_status_changed(?failed({failure, Failure2}))) = + next_change(InvoiceID, Client), + ok = payproc_errors:match('PaymentFailure', Failure2, fun({preauthorization_failed, {unknown, _}}) -> ok end), + %% Assert payment status IS failed + ?invoice_state(?invoice_w_status(_), [?payment_state(Payment)]) = + hg_client_invoicing:get(InvoiceID, Client), + ?assertMatch(#domain_InvoicePayment{status = {failed, _}}, Payment), + ?invoice_status_changed(?invoice_cancelled(<<"overdue">>)) = next_change(InvoiceID, Client). payment_cascade_fail_wo_route_candidates_fixture_pre(Revision, _C) -> lists:flatten([