TD-820: Fixes route candidates on no-route failure rollback after limit overflow (#115)

* TD-820: Fixes route candidates on no-route failure rollback after limit overflow

* Adds 'ignore_not_found' flag support for limit rollback

* Don't stash empty candidates list

* Adds ui cascade test cases
This commit is contained in:
Aleksey Kashapov 2024-01-17 11:54:37 +03:00 committed by GitHub
parent 4321821953
commit bdeefbf2d2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 354 additions and 41 deletions

View File

@ -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,

View File

@ -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)

View File

@ -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)}.

View File

@ -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, #{}).

View File

@ -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([