From 38344502fd3bc5a3053ab77677c35ba7848443f0 Mon Sep 17 00:00:00 2001 From: Aleksey Kashapov Date: Wed, 2 Sep 2026 16:48:12 +0300 Subject: [PATCH 1/8] feat(routing): XYZ-451: Specifies limit id on routing failure Adds support for encoding limit id into processing failure subcode during routing when limits overflow checks result in `no_route_found`. --- apps/ff_transfer/src/ff_withdrawal.erl | 21 ++++++-- .../test/ff_withdrawal_limits_SUITE.erl | 14 ++++- apps/hellgate/src/hg_invoice_payment.erl | 52 +++++++++++++++---- apps/hellgate/src/hg_limiter.erl | 16 +++--- apps/hellgate/test/hg_invoice_tests_SUITE.erl | 6 ++- rebar.config | 6 ++- rebar.lock | 2 +- 7 files changed, 89 insertions(+), 28 deletions(-) diff --git a/apps/ff_transfer/src/ff_withdrawal.erl b/apps/ff_transfer/src/ff_withdrawal.erl index 5e36f2f5..20809ba9 100644 --- a/apps/ff_transfer/src/ff_withdrawal.erl +++ b/apps/ff_transfer/src/ff_withdrawal.erl @@ -1929,10 +1929,11 @@ build_failure({route_not_found, []}, _Withdrawal) -> code => <<"no_route_found">> }; build_failure({route_not_found, RejectedRoutes}, _Withdrawal) -> - #{ + genlib_map:compact(#{ code => <<"no_route_found">>, - reason => genlib:format({rejected_routes, RejectedRoutes}) - }; + reason => genlib:format({rejected_routes, RejectedRoutes}), + sub => build_route_not_found_sub_failure(RejectedRoutes) + }); build_failure({inconsistent_quote_route, {Type, FoundID}}, Withdrawal) -> Details = {inconsistent_quote_route, #{ @@ -1948,6 +1949,20 @@ build_failure(session, Withdrawal) -> {failed, Failure} = Result, Failure. +-define(rejected_route(Reason), {_PrvRef, _TrmRef, Reason}). + +build_route_not_found_sub_failure([]) -> + undefined; +%% NOTE If limit overflow reason present in any rejected route See reason-tuple +%% construction in `ff_withdrawal_routing:validate_turnover_limits/4` and +%% `ff_limiter:check_limits/4`. +build_route_not_found_sub_failure([ + ?rejected_route({terms_violation, {overflow, [{LimitID, _Amount, _Boundary} | _]}}) | _ +]) -> + #{code => <<"limit_overflow">>, sub => #{code => LimitID}}; +build_route_not_found_sub_failure([_H | Rest]) -> + build_route_not_found_sub_failure(Rest). + get_quote_field(provider_id, #{route := Route}) -> ff_withdrawal_routing:get_provider(Route); get_quote_field(terminal_id, #{route := Route}) -> diff --git a/apps/ff_transfer/test/ff_withdrawal_limits_SUITE.erl b/apps/ff_transfer/test/ff_withdrawal_limits_SUITE.erl index 4ec2c5e9..01dfaa11 100644 --- a/apps/ff_transfer/test/ff_withdrawal_limits_SUITE.erl +++ b/apps/ff_transfer/test/ff_withdrawal_limits_SUITE.erl @@ -257,7 +257,19 @@ limit_overflow(C) -> PreviousAmount = get_limit_amount(Cash, WalletID, DestinationID, ?LIMIT_TURNOVER_NUM_PAYTOOL_ID2, C), ok = ff_withdrawal_machine:create(WithdrawalParams, ff_entity_context:new()), Result = await_final_withdrawal_status(WithdrawalID), - ?assertMatch({failed, #{code := <<"no_route_found">>}}, Result), + ?assertMatch( + {failed, #{ + code := <<"no_route_found">>, + sub := #{ + code := <<"limit_overflow">>, + sub := #{ + code := LimitID + } + } + }} when + is_binary(LimitID), + Result + ), %% we get final withdrawal status before we rollback limits so wait for it some amount of time ok = timer:sleep(500), Withdrawal = get_withdrawal(WithdrawalID), diff --git a/apps/hellgate/src/hg_invoice_payment.erl b/apps/hellgate/src/hg_invoice_payment.erl index 2babf01a..bcad0e63 100644 --- a/apps/hellgate/src/hg_invoice_payment.erl +++ b/apps/hellgate/src/hg_invoice_payment.erl @@ -1969,8 +1969,8 @@ process_shop_limit_initialization(_Action, St) -> case check_shop_limits(Opts, St) of ok -> {next, {[?shop_limit_initiated()], timeout}}; - {error, {limit_overflow = Error, IDs}} -> - Failure = construct_shop_limit_failure(Error, IDs), + {error, {limit_overflow = Error, LimitID}} -> + Failure = construct_shop_limit_failure(Error, LimitID), Events = [ ?shop_limit_initiated(), ?payment_rollback_started(Failure) @@ -1978,9 +1978,9 @@ process_shop_limit_initialization(_Action, St) -> {next, {Events, timeout}} end. -construct_shop_limit_failure(limit_overflow, IDs) -> - Error = mk_static_error([authorization_failed, shop_limit_exceeded, unknown]), - Reason = genlib:format("Limits with following IDs overflowed: ~p", [IDs]), +construct_shop_limit_failure(limit_overflow, LimitID) -> + Error = mk_static_error([authorization_failed, shop_limit_exceeded, unknown, LimitID]), + Reason = genlib:format("Limit ~p overflowed", [LimitID]), {failure, payproc_errors:construct('PaymentFailure', Error, Reason)}. process_shop_limit_failure(_Action, #st{failure = Failure} = St) -> @@ -2190,11 +2190,17 @@ log_rejected_route_groups(Result, VS) -> construct_routing_failure({rejected_routes, {SubCode, RejectedRoutes}}) when SubCode =:= limit_misconfiguration orelse - SubCode =:= limit_overflow orelse SubCode =:= adapter_unavailable orelse SubCode =:= provider_conversion_is_too_low -> construct_routing_failure([rejected, SubCode], genlib:format(normalize_rejected_routes(RejectedRoutes))); +construct_routing_failure({rejected_routes, {limit_overflow, RejectedRoutes}}) -> + %% NOTE For limit overflow subcode, we care only about the first rejected + %% route in this code pass. + %% See reason-tuple construction in `get_limit_overflow_routes/4`. + [{_PrvRef, _TrmRef, {'LimitOverflow', LimitID}} | _Rest] = + NormalizedRejectedRoutes = normalize_rejected_routes(RejectedRoutes), + construct_routing_failure([rejected, limit_overflow, LimitID], genlib:format(NormalizedRejectedRoutes)); construct_routing_failure({rejected_routes, {_SubCode, RejectedRoutes}}) -> construct_routing_failure([forbidden], genlib:format(normalize_rejected_routes(RejectedRoutes))); construct_routing_failure({misconfiguration = Code, Details}) -> @@ -2217,9 +2223,19 @@ normalize_rejected_route(Route) -> construct_routing_failure(Codes, Reason) -> {failure, payproc_errors:construct('PaymentFailure', mk_static_error([no_route_found | Codes]), Reason)}. -mk_static_error([_ | _] = Codes) -> mk_static_error_(#payproc_error_GeneralFailure{}, lists:reverse(Codes)). -mk_static_error_(T, []) -> T; -mk_static_error_(Sub, [Code | Codes]) -> mk_static_error_({Code, Sub}, Codes). +mk_static_error([_ | _] = Codes0) -> + %% NOTE If last code is binary, then we consider it an arbitrary reason code + %% that belongs **inside** general failure struct. + case lists:reverse(Codes0) of + [H | Codes1] when is_binary(H) -> + mk_static_error_(#payproc_error_GeneralFailure{reason_code = H}, Codes1); + Codes1 -> + mk_static_error_(#payproc_error_GeneralFailure{}, Codes1) + end. +mk_static_error_(T, []) -> + T; +mk_static_error_(Sub, [Code | Codes]) -> + mk_static_error_({Code, Sub}, Codes). -spec process_cash_flow_building(action(), st()) -> machine_result(). process_cash_flow_building(_Action, St) -> @@ -2796,8 +2812,8 @@ get_limit_overflow_routes(Routes, VS, Iter, St) -> case hg_limiter:check_limits(TurnoverLimits, Invoice, Payment, Session, PaymentRoute, Iter) of {ok, Limits} -> {[Route | RoutesNoOverflowIn], RejectedIn, LimitsIn#{PaymentRoute => Limits}}; - {error, {limit_overflow, IDs, Limits}} -> - RejectedRoute = hg_route:set_rejection_reason({'LimitOverflow', IDs}, Route), + {error, {limit_overflow, LimitID, Limits}} -> + RejectedRoute = hg_route:set_rejection_reason({'LimitOverflow', LimitID}, Route), {RoutesNoOverflowIn, [RejectedRoute | RejectedIn], LimitsIn#{PaymentRoute => Limits}} end end, @@ -4391,4 +4407,18 @@ shop_limits_regression_test() -> collapse_changes(Events, undefined, ChangeOpts) ). +-spec mk_static_error_test_() -> _. +mk_static_error_test_() -> + [ + ?_assertEqual( + {authorization_failed, {shop_limit_exceeded, {unknown, #payproc_error_GeneralFailure{}}}}, + mk_static_error([authorization_failed, shop_limit_exceeded, unknown]) + ), + ?_assertEqual( + {authorization_failed, + {shop_limit_exceeded, {unknown, #payproc_error_GeneralFailure{reason_code = ~"test"}}}}, + mk_static_error([authorization_failed, shop_limit_exceeded, unknown, ~"test"]) + ) + ]. + -endif. diff --git a/apps/hellgate/src/hg_limiter.erl b/apps/hellgate/src/hg_limiter.erl index 17a18b2b..5663c437 100644 --- a/apps/hellgate/src/hg_limiter.erl +++ b/apps/hellgate/src/hg_limiter.erl @@ -109,7 +109,7 @@ get_batch_limit_values(Context, TurnoverLimits, OperationIdSegments) -> -spec check_limits([turnover_limit()], invoice(), payment(), session() | undefined, route(), pos_integer()) -> {ok, [turnover_limit_value()]} - | {error, {limit_overflow, [binary()], [turnover_limit_value()]}}. + | {error, {limit_overflow, binary(), [turnover_limit_value()]}}. check_limits(TurnoverLimits, Invoice, Payment, Session, Route, Iter) -> Context = gen_limit_context(Invoice, Payment, Session, Route), Limits = get_limit_values(Context, TurnoverLimits, make_route_operation_segments(Invoice, Payment, Route, Iter)), @@ -117,14 +117,13 @@ check_limits(TurnoverLimits, Invoice, Payment, Session, Route, Iter) -> ok = check_limits_(Limits, Context), {ok, Limits} catch - throw:limit_overflow -> - IDs = [T#domain_TurnoverLimit.ref#domain_LimitConfigRef.id || T <- TurnoverLimits], - {error, {limit_overflow, IDs, Limits}} + throw:{limit_overflow, LimitID} -> + {error, {limit_overflow, LimitID, Limits}} end. -spec check_shop_limits([turnover_limit()], party_config_ref(), shop_config_ref(), invoice(), payment()) -> ok - | {error, {limit_overflow, [binary()]}}. + | {error, {limit_overflow, binary()}}. check_shop_limits(TurnoverLimits, PartyConfigRef, ShopConfigRef, Invoice, Payment) -> Context = gen_limit_shop_context(Invoice, Payment), Limits = get_limit_values( @@ -133,9 +132,8 @@ check_shop_limits(TurnoverLimits, PartyConfigRef, ShopConfigRef, Invoice, Paymen try check_limits_(Limits, Context) catch - throw:limit_overflow -> - IDs = [T#domain_TurnoverLimit.ref#domain_LimitConfigRef.id || T <- TurnoverLimits], - {error, {limit_overflow, IDs}} + throw:{limit_overflow, LimitID} -> + {error, {limit_overflow, LimitID}} end. make_shop_operation_segments(PartyConfigRef, ShopConfigRef, Invoice, Payment) -> @@ -165,7 +163,7 @@ check_limits_([TurnoverLimitValue | TLVs], Context) -> LimiterAmount, UpperBoundary ]), - throw(limit_overflow) + throw({limit_overflow, LimitID}) end. -spec hold_payment_limits([turnover_limit()], invoice(), payment(), session() | undefined, route(), pos_integer()) -> diff --git a/apps/hellgate/test/hg_invoice_tests_SUITE.erl b/apps/hellgate/test/hg_invoice_tests_SUITE.erl index fa500256..1c6094ea 100644 --- a/apps/hellgate/test/hg_invoice_tests_SUITE.erl +++ b/apps/hellgate/test/hg_invoice_tests_SUITE.erl @@ -1362,7 +1362,11 @@ payment_limit_overflow(C) -> ok = payproc_errors:match( 'PaymentFailure', Failure, - fun({no_route_found, {rejected, {limit_overflow, _}}}) -> ok end + fun({no_route_found, {rejected, {limit_overflow, #payproc_error_GeneralFailure{reason_code = LimitID}}}}) when + is_binary(LimitID) + -> + ok + end ). -spec limit_hold_currency_error(config()) -> test_return(). diff --git a/rebar.config b/rebar.config index eda67919..2a6a11f8 100644 --- a/rebar.config +++ b/rebar.config @@ -36,9 +36,11 @@ {woody, {git, "https://github.com/valitydev/woody_erlang.git", {tag, "v1.1.2"}}}, {scoper, {git, "https://github.com/valitydev/scoper.git", {tag, "v1.1.0"}}}, {thrift, {git, "https://github.com/valitydev/thrift_erlang.git", {tag, "v1.0.0"}}}, - {damsel, {git, "https://github.com/valitydev/damsel.git", {tag, "v2.2.41"}}}, + {damsel, {git, "https://github.com/valitydev/damsel.git", {branch, "XYZ-451/ft/limit-overflow-failure-details"}}}, {exrates_proto, {git, "https://github.com/valitydev/exrates-proto.git", {branch, "master"}}}, - {payproc_errors, {git, "https://github.com/valitydev/payproc-errors-erlang.git", {branch, "master"}}}, + {payproc_errors, + {git, "https://github.com/valitydev/payproc-errors-erlang.git", + {branch, "XYZ-451/ft/limit-overflow-failure-details"}}}, {mg_proto, {git, "https://github.com/valitydev/machinegun-proto.git", {branch, "master"}}}, {dmt_client, {git, "https://github.com/valitydev/dmt-client.git", {tag, "v2.0.3"}}}, {party_client, {git, "https://github.com/valitydev/party-client-erlang.git", {tag, "v2.0.1"}}}, diff --git a/rebar.lock b/rebar.lock index e35a8fe9..4a6d1bc8 100644 --- a/rebar.lock +++ b/rebar.lock @@ -31,7 +31,7 @@ {<<"ctx">>,{pkg,<<"ctx">>,<<"0.6.0">>},2}, {<<"damsel">>, {git,"https://github.com/valitydev/damsel.git", - {ref,"239638b3689dfa4a20dcb17f3715167957129c30"}}, + {ref,"e434fa8fda147186e0ea192f6002cdbf92851b65"}}, 0}, {<<"dmt_client">>, {git,"https://github.com/valitydev/dmt-client.git", From 326c8423459d0d2671d5a0db471f790c934eff35 Mon Sep 17 00:00:00 2001 From: Aleksey Kashapov Date: Wed, 2 Sep 2026 18:05:16 +0300 Subject: [PATCH 2/8] Bumps payproc helper lib and adds assertion for borderline case --- apps/hellgate/test/hg_invoice_tests_SUITE.erl | 38 +++++++++++++++++-- rebar.lock | 2 +- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/apps/hellgate/test/hg_invoice_tests_SUITE.erl b/apps/hellgate/test/hg_invoice_tests_SUITE.erl index 1c6094ea..49520218 100644 --- a/apps/hellgate/test/hg_invoice_tests_SUITE.erl +++ b/apps/hellgate/test/hg_invoice_tests_SUITE.erl @@ -1362,11 +1362,41 @@ payment_limit_overflow(C) -> ok = payproc_errors:match( 'PaymentFailure', Failure, - fun({no_route_found, {rejected, {limit_overflow, #payproc_error_GeneralFailure{reason_code = LimitID}}}}) when - is_binary(LimitID) - -> - ok + fun + %% NOTE If LimitID doesn't match any existing atom, payproc_error + %% will translate static tuple representation using it as a + %% reason_code for closing sub failure. + %% Otherwise it becomes an unknown error. Though it does normally + %% translate into generic Failure+SubFailure structs in both cases. + %% See subsequent assertion. + ({no_route_found, {rejected, {limit_overflow, #payproc_error_GeneralFailure{reason_code = LimitID}}}}) when + is_binary(LimitID) + -> + ok; + ( + {no_route_found, + {rejected, + {limit_overflow, + {{unknown_error, LimitID}, #payproc_error_GeneralFailure{reason_code = undefined}}}}} + ) when + is_binary(LimitID) + -> + ok end + ), + LimitID = genlib:unique(), + ?assertEqual( + payproc_errors:construct( + 'PaymentFailure', + {no_route_found, {rejected, {limit_overflow, #payproc_error_GeneralFailure{reason_code = LimitID}}}} + ), + payproc_errors:construct( + 'PaymentFailure', + {no_route_found, + {rejected, + {limit_overflow, + {{unknown_error, LimitID}, #payproc_error_GeneralFailure{reason_code = undefined}}}}} + ) ). -spec limit_hold_currency_error(config()) -> test_return(). diff --git a/rebar.lock b/rebar.lock index 4a6d1bc8..0cd934f6 100644 --- a/rebar.lock +++ b/rebar.lock @@ -117,7 +117,7 @@ 0}, {<<"payproc_errors">>, {git,"https://github.com/valitydev/payproc-errors-erlang.git", - {ref,"8ae8586239ef68098398acf7eb8363d9ec3b3234"}}, + {ref,"f09631ea4aad57cd9fe3ec52361eaf3dc5d25ba6"}}, 0}, {<<"progressor">>, {git,"https://github.com/valitydev/progressor.git", From ffdcfa9243de9daaa57e7a52d0a33d61669331f1 Mon Sep 17 00:00:00 2001 From: Aleksey Kashapov Date: Fri, 4 Sep 2026 12:32:05 +0300 Subject: [PATCH 3/8] Adds minor refactor of HG's check_limits funs --- apps/hellgate/src/hg_invoice_payment.erl | 18 +++--- apps/hellgate/src/hg_limiter.erl | 61 +++++++++---------- apps/hellgate/test/hg_invoice_tests_SUITE.erl | 10 +-- 3 files changed, 42 insertions(+), 47 deletions(-) diff --git a/apps/hellgate/src/hg_invoice_payment.erl b/apps/hellgate/src/hg_invoice_payment.erl index bcad0e63..a928bc17 100644 --- a/apps/hellgate/src/hg_invoice_payment.erl +++ b/apps/hellgate/src/hg_invoice_payment.erl @@ -1969,8 +1969,8 @@ process_shop_limit_initialization(_Action, St) -> case check_shop_limits(Opts, St) of ok -> {next, {[?shop_limit_initiated()], timeout}}; - {error, {limit_overflow = Error, LimitID}} -> - Failure = construct_shop_limit_failure(Error, LimitID), + {error, {limit_overflow = Error, LimitIDs}} -> + Failure = construct_shop_limit_failure(Error, LimitIDs), Events = [ ?shop_limit_initiated(), ?payment_rollback_started(Failure) @@ -1978,9 +1978,9 @@ process_shop_limit_initialization(_Action, St) -> {next, {Events, timeout}} end. -construct_shop_limit_failure(limit_overflow, LimitID) -> - Error = mk_static_error([authorization_failed, shop_limit_exceeded, unknown, LimitID]), - Reason = genlib:format("Limit ~p overflowed", [LimitID]), +construct_shop_limit_failure(limit_overflow, LimitIDs) -> + Error = mk_static_error([authorization_failed, shop_limit_exceeded, unknown, hd(LimitIDs)]), + Reason = genlib:format("Limits ~p overflowed", [LimitIDs]), {failure, payproc_errors:construct('PaymentFailure', Error, Reason)}. process_shop_limit_failure(_Action, #st{failure = Failure} = St) -> @@ -2198,9 +2198,9 @@ construct_routing_failure({rejected_routes, {limit_overflow, RejectedRoutes}}) - %% NOTE For limit overflow subcode, we care only about the first rejected %% route in this code pass. %% See reason-tuple construction in `get_limit_overflow_routes/4`. - [{_PrvRef, _TrmRef, {'LimitOverflow', LimitID}} | _Rest] = + [{_PrvRef, _TrmRef, {'LimitOverflow', LimitIDs}} | _Rest] = NormalizedRejectedRoutes = normalize_rejected_routes(RejectedRoutes), - construct_routing_failure([rejected, limit_overflow, LimitID], genlib:format(NormalizedRejectedRoutes)); + construct_routing_failure([rejected, limit_overflow, hd(LimitIDs)], genlib:format(NormalizedRejectedRoutes)); construct_routing_failure({rejected_routes, {_SubCode, RejectedRoutes}}) -> construct_routing_failure([forbidden], genlib:format(normalize_rejected_routes(RejectedRoutes))); construct_routing_failure({misconfiguration = Code, Details}) -> @@ -2812,8 +2812,8 @@ get_limit_overflow_routes(Routes, VS, Iter, St) -> case hg_limiter:check_limits(TurnoverLimits, Invoice, Payment, Session, PaymentRoute, Iter) of {ok, Limits} -> {[Route | RoutesNoOverflowIn], RejectedIn, LimitsIn#{PaymentRoute => Limits}}; - {error, {limit_overflow, LimitID, Limits}} -> - RejectedRoute = hg_route:set_rejection_reason({'LimitOverflow', LimitID}, Route), + {error, {limit_overflow, LimitIDs}, Limits} -> + RejectedRoute = hg_route:set_rejection_reason({'LimitOverflow', LimitIDs}, Route), {RoutesNoOverflowIn, [RejectedRoute | RejectedIn], LimitsIn#{PaymentRoute => Limits}} end end, diff --git a/apps/hellgate/src/hg_limiter.erl b/apps/hellgate/src/hg_limiter.erl index 5663c437..703a71ab 100644 --- a/apps/hellgate/src/hg_limiter.erl +++ b/apps/hellgate/src/hg_limiter.erl @@ -18,6 +18,7 @@ -type turnover_limit_value() :: dmsl_payproc_thrift:'TurnoverLimitValue'(). -type party_config_ref() :: dmsl_domain_thrift:'PartyConfigRef'(). -type shop_config_ref() :: dmsl_domain_thrift:'ShopConfigRef'(). +-type limit_id() :: dmsl_domain_thrift:'LimitConfigID'(). -export_type([turnover_limit_value/0]). @@ -109,32 +110,26 @@ get_batch_limit_values(Context, TurnoverLimits, OperationIdSegments) -> -spec check_limits([turnover_limit()], invoice(), payment(), session() | undefined, route(), pos_integer()) -> {ok, [turnover_limit_value()]} - | {error, {limit_overflow, binary(), [turnover_limit_value()]}}. + | {error, {limit_overflow, nonempty_list(limit_id())}, [turnover_limit_value()]}. check_limits(TurnoverLimits, Invoice, Payment, Session, Route, Iter) -> Context = gen_limit_context(Invoice, Payment, Session, Route), Limits = get_limit_values(Context, TurnoverLimits, make_route_operation_segments(Invoice, Payment, Route, Iter)), - try - ok = check_limits_(Limits, Context), - {ok, Limits} - catch - throw:{limit_overflow, LimitID} -> - {error, {limit_overflow, LimitID, Limits}} + case check_limits_(Limits) of + ok -> + {ok, Limits}; + {error, Reason} -> + {error, Reason, Limits} end. -spec check_shop_limits([turnover_limit()], party_config_ref(), shop_config_ref(), invoice(), payment()) -> ok - | {error, {limit_overflow, binary()}}. + | {error, {limit_overflow, nonempty_list(limit_id())}}. check_shop_limits(TurnoverLimits, PartyConfigRef, ShopConfigRef, Invoice, Payment) -> Context = gen_limit_shop_context(Invoice, Payment), Limits = get_limit_values( Context, TurnoverLimits, make_shop_operation_segments(PartyConfigRef, ShopConfigRef, Invoice, Payment) ), - try - check_limits_(Limits, Context) - catch - throw:{limit_overflow, LimitID} -> - {error, {limit_overflow, LimitID}} - end. + check_limits_(Limits). make_shop_operation_segments(PartyConfigRef, ShopConfigRef, Invoice, Payment) -> [ @@ -144,26 +139,30 @@ make_shop_operation_segments(PartyConfigRef, ShopConfigRef, Invoice, Payment) -> get_payment_id(Payment) ]. -check_limits_([], _) -> - ok; -check_limits_([TurnoverLimitValue | TLVs], Context) -> +check_limits_(LimitValues) -> + case lists:foldl(fun check_limit_/2, [], LimitValues) of + [] -> + ok; + LimitIDs -> + {error, {limit_overflow, ordsets:from_list(LimitIDs)}} + end. + +check_limit_( #payproc_TurnoverLimitValue{ - limit = #domain_TurnoverLimit{ - ref = ?ref(LimitID), - upper_boundary = UpperBoundary - }, - value = LimiterAmount - } = TurnoverLimitValue, - case LimiterAmount =< UpperBoundary of + limit = #domain_TurnoverLimit{ref = ?ref(LimitID), upper_boundary = UpperBoundary}, + value = LimitAmount + }, + OverflownLimits +) -> + case LimitAmount =< UpperBoundary of true -> - check_limits_(TLVs, Context); + OverflownLimits; false -> - logger:notice("Limit with id ~p overflowed, amount ~p upper boundary ~p", [ - LimitID, - LimiterAmount, - UpperBoundary - ]), - throw({limit_overflow, LimitID}) + ok = logger:notice( + "Limit with id ~p overflowed, amount ~p upper boundary ~p", + [LimitID, LimitAmount, UpperBoundary] + ), + [LimitID | OverflownLimits] end. -spec hold_payment_limits([turnover_limit()], invoice(), payment(), session() | undefined, route(), pos_integer()) -> diff --git a/apps/hellgate/test/hg_invoice_tests_SUITE.erl b/apps/hellgate/test/hg_invoice_tests_SUITE.erl index 49520218..cc18d442 100644 --- a/apps/hellgate/test/hg_invoice_tests_SUITE.erl +++ b/apps/hellgate/test/hg_invoice_tests_SUITE.erl @@ -1369,18 +1369,14 @@ payment_limit_overflow(C) -> %% Otherwise it becomes an unknown error. Though it does normally %% translate into generic Failure+SubFailure structs in both cases. %% See subsequent assertion. - ({no_route_found, {rejected, {limit_overflow, #payproc_error_GeneralFailure{reason_code = LimitID}}}}) when - is_binary(LimitID) - -> + ({no_route_found, {rejected, {limit_overflow, #payproc_error_GeneralFailure{reason_code = ?LIMIT_ID}}}}) -> ok; ( {no_route_found, {rejected, {limit_overflow, - {{unknown_error, LimitID}, #payproc_error_GeneralFailure{reason_code = undefined}}}}} - ) when - is_binary(LimitID) - -> + {{unknown_error, ?LIMIT_ID}, #payproc_error_GeneralFailure{reason_code = undefined}}}}} + ) -> ok end ), From 81224852fac30cbc17d19e7ed2a413b9264579dc Mon Sep 17 00:00:00 2001 From: Aleksey Kashapov Date: Tue, 8 Sep 2026 14:43:46 +0300 Subject: [PATCH 4/8] WIP Retires use of `payproc_errors` lib --- apps/hellgate/include/domain.hrl | 9 +- apps/hellgate/src/hg_cascade.erl | 8 +- apps/hellgate/src/hg_invoice_payment.erl | 149 ++++++++---------- .../src/hg_invoice_payment_refund.erl | 18 +-- apps/hellgate/src/hg_invoice_utils.erl | 7 + apps/hellgate/test/hg_ct_domain.hrl | 2 - apps/hellgate/test/hg_dummy_provider.erl | 21 ++- apps/hellgate/test/hg_invoice_tests_SUITE.erl | 117 +++++++------- 8 files changed, 155 insertions(+), 176 deletions(-) diff --git a/apps/hellgate/include/domain.hrl b/apps/hellgate/include/domain.hrl index eeedc6d4..8919f5c5 100644 --- a/apps/hellgate/include/domain.hrl +++ b/apps/hellgate/include/domain.hrl @@ -13,13 +13,8 @@ terminal = TerminalRef }). --define(failure(Code), - ?failure(Code, undefined) -). - --define(failure(Code, Reason), - {failure, #domain_Failure{code = Code, reason = Reason}} -). +-define(failure(Code, Reason, Sub), #domain_Failure{code = Code, sub = Sub, reason = Reason}). +-define(subfailure(Code, Sub), #domain_SubFailure{code = Code, sub = Sub}). -define(operation_timeout(), {operation_timeout, #domain_OperationTimeout{}} diff --git a/apps/hellgate/src/hg_cascade.erl b/apps/hellgate/src/hg_cascade.erl index 2864d56d..9ad99598 100644 --- a/apps/hellgate/src/hg_cascade.erl +++ b/apps/hellgate/src/hg_cascade.erl @@ -72,10 +72,10 @@ is_mapped_errors_triggered(#domain_CascadeOnMappedErrors{}, {operation_timeout, failure_matches_any_transient(Failure, TransientErrorsList) -> lists:any( fun(ExpectNotation) -> - payproc_errors:match_notation(Failure, fun - (Notation) when binary_part(Notation, {0, byte_size(ExpectNotation)}) =:= ExpectNotation -> true; - (_) -> false - end) + case iolist_to_binary(hg_invoice_utils:format_failure(Failure)) of + Notation when binary_part(Notation, {0, byte_size(ExpectNotation)}) =:= ExpectNotation -> true; + _ -> false + end end, TransientErrorsList ). diff --git a/apps/hellgate/src/hg_invoice_payment.erl b/apps/hellgate/src/hg_invoice_payment.erl index a928bc17..88c0b249 100644 --- a/apps/hellgate/src/hg_invoice_payment.erl +++ b/apps/hellgate/src/hg_invoice_payment.erl @@ -1048,11 +1048,9 @@ validate_processing_deadline(#domain_InvoicePayment{processing_deadline = Deadli ok -> ok; {error, deadline_reached} -> - {failure, - payproc_errors:construct( - 'PaymentFailure', - {authorization_failed, {processing_deadline_reached, #payproc_error_GeneralFailure{}}} - )} + {failure, #domain_Failure{ + code = <<"authorization_failed">>, sub = #domain_SubFailure{code = <<"processing_deadline_reached">>} + }} end; validate_processing_deadline(_, _TargetType) -> ok. @@ -1979,9 +1977,14 @@ process_shop_limit_initialization(_Action, St) -> end. construct_shop_limit_failure(limit_overflow, LimitIDs) -> - Error = mk_static_error([authorization_failed, shop_limit_exceeded, unknown, hd(LimitIDs)]), - Reason = genlib:format("Limits ~p overflowed", [LimitIDs]), - {failure, payproc_errors:construct('PaymentFailure', Error, Reason)}. + {failure, #domain_Failure{ + reason = genlib:format("Limits ~p overflowed", [LimitIDs]), + code = <<"authorization_failed">>, + sub = #domain_SubFailure{ + code = <<"shop_limit_exceeded">>, + sub = #domain_SubFailure{code = <<"unknown">>, sub = #domain_SubFailure{code = hd(LimitIDs)}} + } + }}. process_shop_limit_failure(_Action, #st{failure = Failure} = St) -> Opts = get_opts(St), @@ -2193,22 +2196,36 @@ construct_routing_failure({rejected_routes, {SubCode, RejectedRoutes}}) when SubCode =:= adapter_unavailable orelse SubCode =:= provider_conversion_is_too_low -> - construct_routing_failure([rejected, SubCode], genlib:format(normalize_rejected_routes(RejectedRoutes))); + construct_routing_failure( + #domain_SubFailure{code = <<"rejected">>, sub = #domain_SubFailure{code = atom_to_binary(SubCode)}}, + genlib:format(normalize_rejected_routes(RejectedRoutes)) + ); construct_routing_failure({rejected_routes, {limit_overflow, RejectedRoutes}}) -> %% NOTE For limit overflow subcode, we care only about the first rejected %% route in this code pass. %% See reason-tuple construction in `get_limit_overflow_routes/4`. [{_PrvRef, _TrmRef, {'LimitOverflow', LimitIDs}} | _Rest] = NormalizedRejectedRoutes = normalize_rejected_routes(RejectedRoutes), - construct_routing_failure([rejected, limit_overflow, hd(LimitIDs)], genlib:format(NormalizedRejectedRoutes)); + construct_routing_failure( + #domain_SubFailure{ + code = <<"rejected">>, + sub = #domain_SubFailure{code = <<"limit_overflow">>, sub = #domain_SubFailure{code = hd(LimitIDs)}} + }, + genlib:format(NormalizedRejectedRoutes) + ); construct_routing_failure({rejected_routes, {_SubCode, RejectedRoutes}}) -> - construct_routing_failure([forbidden], genlib:format(normalize_rejected_routes(RejectedRoutes))); + construct_routing_failure( + #domain_SubFailure{code = <<"forbidden">>}, genlib:format(normalize_rejected_routes(RejectedRoutes)) + ); construct_routing_failure({misconfiguration = Code, Details}) -> - construct_routing_failure([unknown, {unknown_error, atom_to_binary(Code)}], genlib:format(Details)); + construct_routing_failure( + #domain_SubFailure{code = <<"unknown">>, sub = #domain_SubFailure{code = atom_to_binary(Code)}}, + genlib:format(Details) + ); construct_routing_failure(risk_score_is_too_high = Code) -> - construct_routing_failure([Code], undefined); + construct_routing_failure(#domain_SubFailure{code = atom_to_binary(Code)}, undefined); construct_routing_failure(Error) when is_atom(Error) -> - construct_routing_failure([{unknown_error, Error}], undefined). + construct_routing_failure(#domain_SubFailure{code = atom_to_binary(Error)}, undefined). normalize_rejected_routes(RejectedRoutes) -> [normalize_rejected_route(Route) || Route <- RejectedRoutes]. @@ -2220,22 +2237,8 @@ normalize_rejected_route(#{provider_ref := _, terminal_ref := _, rejection_reaso normalize_rejected_route(Route) -> Route. -construct_routing_failure(Codes, Reason) -> - {failure, payproc_errors:construct('PaymentFailure', mk_static_error([no_route_found | Codes]), Reason)}. - -mk_static_error([_ | _] = Codes0) -> - %% NOTE If last code is binary, then we consider it an arbitrary reason code - %% that belongs **inside** general failure struct. - case lists:reverse(Codes0) of - [H | Codes1] when is_binary(H) -> - mk_static_error_(#payproc_error_GeneralFailure{reason_code = H}, Codes1); - Codes1 -> - mk_static_error_(#payproc_error_GeneralFailure{}, Codes1) - end. -mk_static_error_(T, []) -> - T; -mk_static_error_(Sub, [Code | Codes]) -> - mk_static_error_({Code, Sub}, Codes). +construct_routing_failure(SubFailure, Reason) -> + {failure, #domain_Failure{reason = Reason, code = <<"no_route_found">>, sub = SubFailure}}. -spec process_cash_flow_building(action(), st()) -> machine_result(). process_cash_flow_building(_Action, St) -> @@ -2643,25 +2646,29 @@ get_bank_card_token(_) -> undefined. choose_fd_operation_status_for_failure({failure, Failure}) -> - payproc_errors:match('PaymentFailure', Failure, fun do_choose_fd_operation_status_for_failure/1); -choose_fd_operation_status_for_failure(_Failure) -> - finish. - -do_choose_fd_operation_status_for_failure({authorization_failed, {FailType, _}}) -> - DefaultBenignFailures = [ - insufficient_funds, - rejected_by_issuer, - processing_deadline_reached - ], - FDConfig = genlib_app:env(hellgate, fault_detector, #{}), - Config = genlib_map:get(conversion, FDConfig, #{}), - BenignFailures = genlib_map:get(benign_failures, Config, DefaultBenignFailures), - case lists:member(FailType, BenignFailures) of - false -> error; - true -> finish - end; -do_choose_fd_operation_status_for_failure(_Failure) -> - finish. + case Failure of + ?failure(<<"authorization_failed">>, _, ?subfailure(FailType0, _)) -> + DefaultBenignFailures = [ + insufficient_funds, + rejected_by_issuer, + processing_deadline_reached + ], + FDConfig = genlib_app:env(hellgate, fault_detector, #{}), + Config = genlib_map:get(conversion, FDConfig, #{}), + BenignFailures = genlib_map:get(benign_failures, Config, DefaultBenignFailures), + FailType1 = + try + erlang:binary_to_existing_atom(FailType0, utf8) + catch + error:badarg -> undefined + end, + case lists:member(FailType1, BenignFailures) of + false -> error; + true -> finish + end; + _ -> + finish + end. maybe_notify_fault_detector({payment, processing_session}, processed, Status, St) -> ProviderRef = get_route_provider(get_route(St)), @@ -2702,7 +2709,7 @@ get_initial_retry_strategy(TargetType) -> St :: st(), Timeout :: non_neg_integer(). check_retry_possibility(Target, Failure, St) -> - case check_failure_type(Target, Failure) of + case check_failure_type(Failure) of transient -> RetryStrategy = get_actual_retry_strategy(Target, St), case hg_retry:next_step(RetryStrategy) of @@ -2717,21 +2724,14 @@ check_retry_possibility(Target, Failure, St) -> fatal end. --spec check_failure_type(target(), failure()) -> transient | fatal. -check_failure_type(Target, {failure, Failure}) -> - payproc_errors:match(get_error_class(Target), Failure, fun do_check_failure_type/1); -check_failure_type(_Target, _Other) -> - fatal. - -get_error_class({Target, _}) when Target =:= processed; Target =:= captured; Target =:= cancelled -> - 'PaymentFailure'; -get_error_class(Target) -> - error({unsupported_target, Target}). - -do_check_failure_type({authorization_failed, {temporarily_unavailable, _}}) -> - transient; -do_check_failure_type(_Failure) -> - fatal. +-spec check_failure_type(failure()) -> transient | fatal. +check_failure_type({failure, Failure}) -> + case Failure of + ?failure(<<"authorization_failed">>, _, ?subfailure(<<"temporarily_unavailable">>, _)) -> + transient; + _ -> + fatal + end. get_action(?processed(), _Action, St) -> case get_payment_flow(get_payment(St)) of @@ -4197,10 +4197,7 @@ format_status_details(_) -> format_failure({operation_timeout, _}) -> [<<"timeout">>]; format_failure({failure, Failure}) -> - format_domain_failure(Failure). - -format_domain_failure(Failure) -> - payproc_errors:format_raw(Failure). + hg_invoice_utils:format_failure(Failure). get_account_key({AccountParty, AccountType}) -> hg_utils:join(AccountParty, $., AccountType). @@ -4407,18 +4404,4 @@ shop_limits_regression_test() -> collapse_changes(Events, undefined, ChangeOpts) ). --spec mk_static_error_test_() -> _. -mk_static_error_test_() -> - [ - ?_assertEqual( - {authorization_failed, {shop_limit_exceeded, {unknown, #payproc_error_GeneralFailure{}}}}, - mk_static_error([authorization_failed, shop_limit_exceeded, unknown]) - ), - ?_assertEqual( - {authorization_failed, - {shop_limit_exceeded, {unknown, #payproc_error_GeneralFailure{reason_code = ~"test"}}}}, - mk_static_error([authorization_failed, shop_limit_exceeded, unknown, ~"test"]) - ) - ]. - -endif. diff --git a/apps/hellgate/src/hg_invoice_payment_refund.erl b/apps/hellgate/src/hg_invoice_payment_refund.erl index f482a60d..79466fb6 100644 --- a/apps/hellgate/src/hg_invoice_payment_refund.erl +++ b/apps/hellgate/src/hg_invoice_payment_refund.erl @@ -304,11 +304,9 @@ process_refund_cashflow(Refund) -> {next, {Events, Action}}; _ -> Failure = - {failure, - payproc_errors:construct( - 'RefundFailure', - {terms_violated, {insufficient_merchant_funds, #payproc_error_GeneralFailure{}}} - )}, + {failure, #domain_Failure{ + code = <<"terms_violated">>, sub = #domain_SubFailure{code = <<"insufficient_merchant_funds">>} + }}, {next, {[?refund_rollback_started(Failure)], Action}} end. @@ -484,12 +482,10 @@ check_retry_possibility(Failure, Refund) -> end. check_failure_type({failure, Failure}) -> - payproc_errors:match('RefundFailure', Failure, fun do_check_failure_type/1). - -do_check_failure_type({authorization_failed, {temporarily_unavailable, _}}) -> - transient; -do_check_failure_type(_Failure) -> - fatal. + case Failure of + ?failure(<<"authorization_failed">>, _, ?subfailure(<<"temporarily_unavailable">>, _)) -> transient; + _ -> fatal + end. get_actual_retry_strategy(Refund) -> hg_retry:skip_steps(get_initial_retry_strategy(), retry_attempts(Refund)). diff --git a/apps/hellgate/src/hg_invoice_utils.erl b/apps/hellgate/src/hg_invoice_utils.erl index 815ed574..03159c8e 100644 --- a/apps/hellgate/src/hg_invoice_utils.erl +++ b/apps/hellgate/src/hg_invoice_utils.erl @@ -21,6 +21,7 @@ -export([check_deadline/1]). -export([assert_party_unblocked/1]). -export([assert_shop_unblocked/1]). +-export([format_failure/1]). -type account_id() :: dmsl_domain_thrift:'AccountID'(). -type amount() :: dmsl_domain_thrift:'Amount'(). @@ -180,3 +181,9 @@ check_deadline(Deadline) -> _ -> {error, deadline_reached} end. + +-spec format_failure(dmsl_domain_thrift:'Failure'() | undefined) -> iolist(). +format_failure(Failure) -> lists:join($:, extract_failure_code(Failure)). +extract_failure_code(undefined) -> []; +extract_failure_code(#domain_Failure{code = Code, sub = Sub}) -> [Code | extract_failure_code(Sub)]; +extract_failure_code(#domain_SubFailure{code = Code, sub = Sub}) -> [Code | extract_failure_code(Sub)]. diff --git a/apps/hellgate/test/hg_ct_domain.hrl b/apps/hellgate/test/hg_ct_domain.hrl index f62a8416..04952162 100644 --- a/apps/hellgate/test/hg_ct_domain.hrl +++ b/apps/hellgate/test/hg_ct_domain.hrl @@ -233,8 +233,6 @@ ]} }). --define(err_gen_failure(), #payproc_error_GeneralFailure{}). - -define(redirect(Uri, Form), {redirect, {post_request, #user_interaction_BrowserPostRequest{uri = Uri, form = Form}}} ). diff --git a/apps/hellgate/test/hg_dummy_provider.erl b/apps/hellgate/test/hg_dummy_provider.erl index a74a2e2a..90014da4 100644 --- a/apps/hellgate/test/hg_dummy_provider.erl +++ b/apps/hellgate/test/hg_dummy_provider.erl @@ -415,7 +415,7 @@ get_failure_scenario_step(Scenario, Step) -> lists:nth(Step, Scenario). process_refund(_State, PaymentInfo, #{<<"always_fail">> := FailureCode, <<"override">> := ProviderCode} = CtxOpts, _) -> - Failure = payproc_errors:from_notation(FailureCode, <<"sub failure by ", ProviderCode/binary>>), + Failure = 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}), undefined, mk_trx(TrxID, PaymentInfo)); process_refund(undefined, PaymentInfo, CtxOpts, _) -> @@ -472,7 +472,7 @@ result(Intent, NextState, 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>>), + Failure = 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) -> @@ -524,11 +524,7 @@ failure(Code) when is_atom(Code) -> failure(Code, unknown). failure(Code, Sub) when is_atom(Code), is_atom(Sub) -> - {failure, - payproc_errors:construct( - 'PaymentFailure', - {Code, {Sub, #payproc_error_GeneralFailure{}}} - )}. + {failure, #domain_Failure{code = atom_to_binary(Code), sub = #domain_SubFailure{code = atom_to_binary(Sub)}}}. get_payment_id(#proxy_provider_PaymentInfo{payment = Payment}) -> Payment#proxy_provider_InvoicePayment.id. @@ -916,3 +912,14 @@ maybe_sleep(_Opts) -> get_ctx_opts_override(CtxOpts) -> maps:get(<<"override">>, CtxOpts, <<"">>). + +from_notation(Notation, Reason) when is_binary(Notation) -> + Codes = lists:reverse(binary:split(Notation, <<$:>>, [global])), + do_construct_from_notation(Codes, Reason, undefined). + +do_construct_from_notation([<<"">>], _Reason, _SubFailure) -> + undefined; +do_construct_from_notation([Code], Reason, SubFailure) -> + #domain_Failure{code = Code, reason = Reason, sub = SubFailure}; +do_construct_from_notation([SubCode | Codes], Reason, SubFailure) -> + do_construct_from_notation(Codes, Reason, #domain_SubFailure{code = SubCode, sub = SubFailure}). diff --git a/apps/hellgate/test/hg_invoice_tests_SUITE.erl b/apps/hellgate/test/hg_invoice_tests_SUITE.erl index cc18d442..438a216c 100644 --- a/apps/hellgate/test/hg_invoice_tests_SUITE.erl +++ b/apps/hellgate/test/hg_invoice_tests_SUITE.erl @@ -653,15 +653,24 @@ end_per_suite(C) -> -define(system_to_external_fixed, ?fixed(20, <<"RUB">>)). -define(merchant_to_system_fixed, ?fixed(100, <<"RUB">>)). --define(assertRouteNotFound(Failure, Sub, ReasonSubstring), begin - ok = payproc_errors:match('PaymentFailure', Failure, fun({no_route_found, Sub}) -> ok end), +-define(assertFailure(Failure, TupleMask), ?assertMatch(TupleMask, failure_to_tuple(Failure))). + +-define(assertFailure(Failure, TupleMask, ReasonSubstring), begin + FailureTuple = failure_to_tuple(Failure), + ?assertMatch(TupleMask, FailureTuple), Reason = Failure#domain_Failure.reason, ?assert( nomatch =/= binary:match(Reason, ReasonSubstring), - <<"Failure reason '", Reason/binary, "' for 'no_route_found' doesn't match '", ReasonSubstring/binary, "'">> + iolist_to_binary( + io_lib:format("Failure reason '~s' for '~p' doesn't match '~s'", [Reason, FailureTuple, ReasonSubstring]) + ) ) end). +-define(assertRouteNotFound(Failure, Sub, ReasonSubstring), begin + ?assertFailure(Failure, {no_route_found, Sub}, ReasonSubstring) +end). + -spec init_per_group(group_name(), config()) -> config(). init_per_group(route_cascading, C) -> [{pre_group_domain_revision, hg_domain:head()} | init_route_cascading_group(C)]; @@ -1245,11 +1254,8 @@ payment_shop_limit_overflow(C) -> Failure = create_payment_shop_limit_overflow( PartyConfigRef, ShopConfigRef, PaymentAmount, Client, ?pmt_sys(<<"visa-ref">>) ), - ok = payproc_errors:match('PaymentFailure', Failure, fun( - {authorization_failed, {shop_limit_exceeded, {unknown, _}}} - ) -> - ok - end). + %% TODO Replace with similar assert macro all other occurrences of payproc_errors:match/2 + ?assertFailure(Failure, {authorization_failed, {shop_limit_exceeded, {unknown, _}}}). -spec payment_shop_limit_more_overflow(config()) -> test_return(). payment_shop_limit_more_overflow(C) -> @@ -1379,20 +1385,6 @@ payment_limit_overflow(C) -> ) -> ok end - ), - LimitID = genlib:unique(), - ?assertEqual( - payproc_errors:construct( - 'PaymentFailure', - {no_route_found, {rejected, {limit_overflow, #payproc_error_GeneralFailure{reason_code = LimitID}}}} - ), - payproc_errors:construct( - 'PaymentFailure', - {no_route_found, - {rejected, - {limit_overflow, - {{unknown_error, LimitID}, #payproc_error_GeneralFailure{reason_code = undefined}}}}} - ) ). -spec limit_hold_currency_error(config()) -> test_return(). @@ -2370,12 +2362,13 @@ payment_session_changed_to_fail(C) -> %% Payment w/ preauth for suspend w/ user interaction occurrence. PaymentID = start_payment(InvoiceID, make_tds_payment_params(instant, ?pmt_sys(<<"visa-ref">>)), Client), UserInteraction = await_payment_process_interaction(InvoiceID, PaymentID, Client), - - Failure = payproc_errors:construct( - 'PaymentFailure', - {authorization_failed, {operation_blocked, ?err_gen_failure()}}, - genlib:unique() - ), + Failure = #domain_Failure{ + reason = genlib:unique(), + code = <<"authorization_failed">>, + sub = #domain_SubFailure{ + code = <<"operation_blocked">> + } + }, Change = #proxy_provider_PaymentSessionChange{status = {failure, Failure}}, %% Unknown session callback tag @@ -4752,11 +4745,9 @@ payment_refund_success(C) -> PaymentID = await_payment_capture(InvoiceID, PaymentID, Client), % not enough funds on the merchant account Failure = - {failure, - payproc_errors:construct( - 'RefundFailure', - {terms_violated, {insufficient_merchant_funds, ?err_gen_failure()}} - )}, + {failure, #domain_Failure{ + code = <<"terms_violated">>, sub = #domain_SubFailure{code = <<"insufficient_merchant_funds">>} + }}, ?refund_id(RefundID0) = hg_client_invoicing:refund_payment(InvoiceID, PaymentID, RefundParams, Client), PaymentID = await_refund_created(InvoiceID, PaymentID, RefundID0, Client), @@ -4797,11 +4788,9 @@ payment_refund_failure(C) -> PaymentID = await_payment_capture(InvoiceID, PaymentID, Client), % not enough funds on the merchant account NoFunds = - {failure, - payproc_errors:construct( - 'RefundFailure', - {terms_violated, {insufficient_merchant_funds, ?err_gen_failure()}} - )}, + {failure, #domain_Failure{ + code = <<"terms_violated">>, sub = #domain_SubFailure{code = <<"insufficient_merchant_funds">>} + }}, ?refund_id(RefundID0) = hg_client_invoicing:refund_payment(InvoiceID, PaymentID, RefundParams, Client), PaymentID = await_refund_created(InvoiceID, PaymentID, RefundID0, Client), @@ -4909,11 +4898,9 @@ deadline_doesnt_affect_payment_refund(C) -> timer:sleep(ProcessingDeadline), % not enough funds on the merchant account NoFunds = - {failure, - payproc_errors:construct( - 'RefundFailure', - {terms_violated, {insufficient_merchant_funds, ?err_gen_failure()}} - )}, + {failure, #domain_Failure{ + code = <<"terms_violated">>, sub = #domain_SubFailure{code = <<"insufficient_merchant_funds">>} + }}, ?refund_id(RefundID0) = hg_client_invoicing:refund_payment(InvoiceID, PaymentID, RefundParams, Client), PaymentID = await_refund_created(InvoiceID, PaymentID, RefundID0, Client), @@ -4951,11 +4938,9 @@ payment_manual_refund(C) -> PaymentID = await_payment_capture(InvoiceID, PaymentID, Client), % not enough funds on the merchant account NoFunds = - {failure, - payproc_errors:construct( - 'RefundFailure', - {terms_violated, {insufficient_merchant_funds, ?err_gen_failure()}} - )}, + {failure, #domain_Failure{ + code = <<"terms_violated">>, sub = #domain_SubFailure{code = <<"insufficient_merchant_funds">>} + }}, Refund0 = ?refund_id(RefundID0) = hg_client_invoicing:refund_payment_manual(InvoiceID, PaymentID, RefundParams, Client), @@ -5637,10 +5622,7 @@ adhoc_repair_force_invalid_transition(C) -> PaymentParams = make_payment_params(?pmt_sys(<<"visa-ref">>)), PaymentID = execute_payment(InvoiceID, PaymentParams, Client), _ = ?assertEqual(ok, hg_invoice:fail(InvoiceID)), - Failure = payproc_errors:construct( - 'PaymentFailure', - {authorization_failed, {unknown, ?err_gen_failure()}} - ), + Failure = #domain_Failure{code = <<"authorization_failed">>, sub = #domain_SubFailure{code = <<"unknown">>}}, InvalidChanges = [ ?payment_ev(PaymentID, ?payment_status_changed(?failed({failure, Failure}))), ?invoice_status_changed(?invoice_unpaid()) @@ -5850,11 +5832,11 @@ repair_fail_session_on_processed_succeeded(C) -> timeout = next_change(InvoiceID, 2000, Client), - Failure = payproc_errors:construct( - 'PaymentFailure', - {authorization_failed, {security_policy_violated, ?err_gen_failure()}}, - genlib:unique() - ), + Failure = #domain_Failure{ + reason = genlib:unique(), + code = <<"authorization_failed">>, + sub = #domain_SubFailure{code = <<"security_policy_violated">>} + }, ok = repair_invoice_with_scenario(InvoiceID, {fail_session, Failure}, Client), [ @@ -6034,10 +6016,7 @@ repair_fulfill_session_with_trx_succeeded(C) -> PaymentID = await_payment_capture(InvoiceID, PaymentID, Client). construct_authorization_failure() -> - payproc_errors:construct( - 'PaymentFailure', - {authorization_failed, {unknown, ?err_gen_failure()}} - ). + #domain_Failure{code = <<"authorization_failed">>, sub = #domain_SubFailure{code = <<"unknown">>}}. %% @@ -8189,7 +8168,7 @@ repair_invoice(InvoiceID, Changes, Action, Params, Client) -> hg_client_invoicing:repair(InvoiceID, Changes, Action, Params, Client). create_repair_scenario(fail_pre_processing) -> - Failure = payproc_errors:construct('PaymentFailure', {no_route_found, {unknown, ?err_gen_failure()}}), + Failure = #domain_Failure{code = <<"no_route_found">>, sub = #domain_SubFailure{code = <<"unknown">>}}, {'fail_pre_processing', #'payproc_InvoiceRepairFailPreProcessing'{failure = Failure}}; create_repair_scenario(skip_inspector) -> {'skip_inspector', #'payproc_InvoiceRepairSkipInspector'{risk_score = low}}; @@ -10758,3 +10737,17 @@ mock_fault_detector(SupPid) -> configured_limit_version(C) -> genlib:define(cfg(original_domain_revision, C), cfg(base_limits_domain_revision, C)). + +failure_to_tuple(undefined) -> undefined; +failure_to_tuple(?subfailure(Code, SubFailure)) -> failure_to_tuple_(Code, SubFailure); +failure_to_tuple(?failure(Code, _, SubFailure)) -> failure_to_tuple_(Code, SubFailure). + +failure_to_tuple_(Code, SubFailure) -> + {normalize_failure_code(Code), failure_to_tuple(SubFailure)}. +normalize_failure_code(Code) -> + try + erlang:binary_to_existing_atom(Code, utf8) + catch + error:badarg -> + {unknown_error, Code} + end. From 822353fa5b439b49ad24bcdad39737aa60264532 Mon Sep 17 00:00:00 2001 From: Aleksey Kashapov Date: Wed, 9 Sep 2026 12:12:20 +0300 Subject: [PATCH 5/8] Completely retires `payproc_errors` --- apps/hellgate/src/hellgate.app.src | 1 - apps/hellgate/test/hg_invoice_tests_SUITE.erl | 101 ++++-------------- rebar.config | 3 - rebar.lock | 4 - 4 files changed, 20 insertions(+), 89 deletions(-) diff --git a/apps/hellgate/src/hellgate.app.src b/apps/hellgate/src/hellgate.app.src index 0efc1939..000a0d1d 100644 --- a/apps/hellgate/src/hellgate.app.src +++ b/apps/hellgate/src/hellgate.app.src @@ -25,7 +25,6 @@ dmt_client, party_client, bender_client, - payproc_errors, erl_health, limiter_proto, opentelemetry_api, diff --git a/apps/hellgate/test/hg_invoice_tests_SUITE.erl b/apps/hellgate/test/hg_invoice_tests_SUITE.erl index 438a216c..28e3e25c 100644 --- a/apps/hellgate/test/hg_invoice_tests_SUITE.erl +++ b/apps/hellgate/test/hg_invoice_tests_SUITE.erl @@ -1254,7 +1254,6 @@ payment_shop_limit_overflow(C) -> Failure = create_payment_shop_limit_overflow( PartyConfigRef, ShopConfigRef, PaymentAmount, Client, ?pmt_sys(<<"visa-ref">>) ), - %% TODO Replace with similar assert macro all other occurrences of payproc_errors:match/2 ?assertFailure(Failure, {authorization_failed, {shop_limit_exceeded, {unknown, _}}}). -spec payment_shop_limit_more_overflow(config()) -> test_return(). @@ -1282,11 +1281,7 @@ payment_shop_limit_more_overflow(C) -> Failure = create_payment_shop_limit_overflow( PartyConfigRef, ShopConfigRef, PaymentAmount, Client, ?pmt_sys(<<"visa-ref">>) ), - ok = payproc_errors:match('PaymentFailure', Failure, fun( - {authorization_failed, {shop_limit_exceeded, {unknown, _}}} - ) -> - ok - end). + ?assertFailure(Failure, {authorization_failed, {shop_limit_exceeded, {unknown, _}}}). -spec payment_routes_limit_values(config()) -> test_return(). payment_routes_limit_values(C) -> @@ -1365,27 +1360,7 @@ payment_limit_overflow(C) -> ok = hg_limiter_helper:assert_payment_limit_amount( ?LIMIT_ID, configured_limit_version(C), PaymentAmount, Payment, Invoice ), - ok = payproc_errors:match( - 'PaymentFailure', - Failure, - fun - %% NOTE If LimitID doesn't match any existing atom, payproc_error - %% will translate static tuple representation using it as a - %% reason_code for closing sub failure. - %% Otherwise it becomes an unknown error. Though it does normally - %% translate into generic Failure+SubFailure structs in both cases. - %% See subsequent assertion. - ({no_route_found, {rejected, {limit_overflow, #payproc_error_GeneralFailure{reason_code = ?LIMIT_ID}}}}) -> - ok; - ( - {no_route_found, - {rejected, - {limit_overflow, - {{unknown_error, ?LIMIT_ID}, #payproc_error_GeneralFailure{reason_code = undefined}}}}} - ) -> - ok - end - ). + ?assertFailure(Failure, {no_route_found, {rejected, {limit_overflow, {?LIMIT_ID, undefined}}}}). -spec limit_hold_currency_error(config()) -> test_return(). limit_hold_currency_error(C) -> @@ -1505,11 +1480,7 @@ refund_limit_success(C) -> ?payment(PaymentID) = Payment, Failure = create_payment_limit_overflow(PartyConfigRef, ShopConfigRef, 50000, Client, PmtSys), - ok = payproc_errors:match( - 'PaymentFailure', - Failure, - fun({no_route_found, {rejected, {limit_overflow, _}}}) -> ok end - ), + ?assertFailure(Failure, {no_route_found, {rejected, {limit_overflow, _}}}), % create a refund finally RefundParams = make_refund_params(), RefundID = execute_payment_refund(InvoiceID, PaymentID, RefundParams, Client), @@ -1633,11 +1604,7 @@ processing_deadline_reached_test(C) -> ?payment_ev(PaymentID, ?payment_rollback_started({failure, Failure})), ?payment_ev(PaymentID, ?payment_status_changed(?failed({failure, Failure}))) ] = next_changes(InvoiceID, 2, Client), - ok = payproc_errors:match( - 'PaymentFailure', - Failure, - fun({authorization_failed, {processing_deadline_reached, _}}) -> ok end - ). + ?assertFailure(Failure, {authorization_failed, {processing_deadline_reached, _}}). -spec payment_w_misconfigured_routing_failed(config()) -> test_return(). payment_w_misconfigured_routing_failed(C) -> @@ -2557,11 +2524,7 @@ payment_risk_score_check(C) -> ?payment_ev(PaymentID3, ?risk_score_changed(fatal)), ?payment_ev(PaymentID3, ?payment_status_changed(?failed({failure, Failure}))) ] = next_changes(InvoiceID3, 5, Client), - ok = payproc_errors:match( - 'PaymentFailure', - Failure, - fun({no_route_found, _}) -> ok end - ). + ?assertFailure(Failure, {no_route_found, _}). -spec payment_risk_score_check_fail(config()) -> test_return(). payment_risk_score_check_fail(C) -> @@ -3420,11 +3383,7 @@ payment_temporary_unavailability_too_many_retries(C) -> PaymentID = await_payment_session_started(InvoiceID, PaymentID, Client, ?processed()), {failed, PaymentID, {failure, Failure}} = await_payment_process_failure(InvoiceID, PaymentID, Client, 3), - ok = payproc_errors:match( - 'PaymentFailure', - Failure, - fun({authorization_failed, {temporarily_unavailable, _}}) -> ok end - ). + ?assertFailure(Failure, {authorization_failed, {temporarily_unavailable, _}}). update_payment_terms_cashflow(ProviderRef, CashFlow) -> Provider = hg_domain:get({provider, ProviderRef}), @@ -5687,7 +5646,7 @@ payment_with_offsite_preauth_failed(C) -> next_change(InvoiceID, 8000, Client), ?payment_ev(PaymentID, ?payment_status_changed(?failed({failure, Failure}))) = next_change(InvoiceID, 8000, Client), - ok = payproc_errors:match('PaymentFailure', Failure, fun({authorization_failed, _}) -> ok end), + ?assertFailure(Failure, {authorization_failed, _}), ?invoice_status_changed(?invoice_cancelled(<<"overdue">>)) = next_change(InvoiceID, Client). -spec payment_with_tokenized_bank_card(config()) -> test_return(). @@ -6447,11 +6406,7 @@ payment_cascade_success(C) -> ] = next_changes(InvoiceID, 4, Client), {Route1, _Candidates1, _CashFlow1, TrxID1, Failure1} = await_cascade_triggering(InvoiceID, PaymentID, Client), - ok = payproc_errors:match( - 'PaymentFailure', - Failure1, - fun({preauthorization_failed, {card_blocked, _}}) -> ok end - ), + ?assertFailure(Failure1, {preauthorization_failed, {card_blocked, _}}), %% Assert payment status IS NOT failed ?invoice_state(?invoice_w_status(_), [?payment_state(PaymentInterim)]) = hg_client_invoicing:get(InvoiceID, Client), @@ -6837,7 +6792,7 @@ payment_cascade_limit_overflow(C) -> ] = next_changes(InvoiceID, 4, Client), {Route1, _Candidates1, _CashFlow1, _TrxID1, Failure1} = await_cascade_triggering(InvoiceID, PaymentID, Client), - ok = payproc_errors:match('PaymentFailure', Failure1, fun({authorization_failed, {unknown, _}}) -> ok end), + ?assertFailure(Failure1, {authorization_failed, {unknown, _}}), %% And again but no route found [ ?payment_ev(PaymentID, ?route_changed(Route2, Candidates2)), @@ -6848,7 +6803,7 @@ payment_cascade_limit_overflow(C) -> ?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), + ?assertFailure(Failure2, {authorization_failed, {unknown, _}}), %% Assert payment status IS failed ?invoice_state(?invoice_w_status(_), [?payment_state(FinalPayment)]) = hg_client_invoicing:get(InvoiceID, Client), @@ -6898,11 +6853,7 @@ payment_big_cascade_success(C) -> (fun() -> {Route, Candidates, _CashFlow, _TrxID, Failure} = await_cascade_triggering(InvoiceID, PaymentID, Client), - ok = payproc_errors:match( - 'PaymentFailure', - Failure, - fun({preauthorization_failed, {card_blocked, _}}) -> ok end - ), + ?assertFailure(Failure, {authorization_failed, {card_blocked, _}}), _ = [ ?assertMatch( HoldValue when HoldValue =:= 0 orelse HoldValue =:= Amount, @@ -7188,7 +7139,7 @@ payment_cascade_fail_ui(C) -> ] = next_changes(InvoiceID, 4, Client), {_Route1, _Candidates1, _CashFlow1, _TrxID1, Failure1} = await_cascade_triggering(InvoiceID, PaymentID, Client), - ok = payproc_errors:match('PaymentFailure', Failure1, fun({authorization_failed, {unknown, _}}) -> ok end), + ?assertFailure(Failure1, {authorization_failed, {unknown, _}}), %% And again with UI [ ?payment_ev(PaymentID, ?route_changed(_Route2)), @@ -7209,7 +7160,7 @@ payment_cascade_fail_ui(C) -> 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), + ?assertFailure(Failure2, {preauthorization_failed, {unknown, _}}), %% Assert payment status IS failed ?invoice_state(?invoice_w_status(_), [?payment_state(Payment)]) = hg_client_invoicing:get(InvoiceID, Client), @@ -7393,7 +7344,7 @@ payment_cascade_fail_wo_available_attempt_limit(C) -> await_cascade_triggering(InvoiceID, PaymentID, Client), ?payment_ev(PaymentID, ?payment_status_changed(?failed({failure, Failure}))) = next_change(InvoiceID, Client), - ok = payproc_errors:match('PaymentFailure', Failure, fun({preauthorization_failed, {card_blocked, _}}) -> ok end), + ?assertFailure(Failure, {preauthorization_failed, {card_blocked, _}}), %% Assert payment status IS failed ?invoice_state(?invoice_w_status(_), [?payment_state(Payment)]) = hg_client_invoicing:get(InvoiceID, Client), @@ -7481,13 +7432,13 @@ payment_cascade_failures(C) -> ] = next_changes(InvoiceID, 4, Client), {_Route1, _Candidates1, _CashFlow1, _TrxID1, Failure1} = await_cascade_triggering(InvoiceID, PaymentID, Client), - ok = payproc_errors:match('PaymentFailure', Failure1, fun({preauthorization_failed, {card_blocked, _}}) -> ok end), + ?assertFailure(Failure1, {preauthorization_failed, {card_blocked, _}}), %% And again {_Route2, _Candidates2, _CashFlow2, _TrxID2, Failure2} = await_cascade_triggering(InvoiceID, PaymentID, Client), ?payment_ev(PaymentID, ?payment_status_changed(?failed({failure, Failure2}))) = next_change(InvoiceID, Client), - ok = payproc_errors:match('PaymentFailure', Failure2, fun({preauthorization_failed, {card_blocked, _}}) -> ok end), + ?assertFailure(Failure2, {preauthorization_failed, {card_blocked, _}}), %% Assert payment status IS failed ?invoice_state(?invoice_w_status(_), [?payment_state(Payment)]) = hg_client_invoicing:get(InvoiceID, Client), @@ -7582,7 +7533,7 @@ payment_cascade_deadline_failures(C) -> ] = next_changes(InvoiceID, 4, Client), {_Route1, _Candidates1, _CashFlow1, _TrxID1, Failure1} = await_cascade_triggering(InvoiceID, PaymentID, Client), - ok = payproc_errors:match('PaymentFailure', Failure1, fun({preauthorization_failed, {card_blocked, _}}) -> ok end), + ?assertFailure(Failure1, {preauthorization_failed, {card_blocked, _}}), %% And again ?payment_ev(PaymentID, ?route_changed(_Route2)) = next_change(InvoiceID, Client), @@ -7592,11 +7543,7 @@ payment_cascade_deadline_failures(C) -> next_change(InvoiceID, Client), ?payment_ev(PaymentID, ?payment_status_changed(?failed({failure, Failure2}))) = next_change(InvoiceID, Client), - ok = payproc_errors:match( - 'PaymentFailure', - Failure2, - fun({authorization_failed, {processing_deadline_reached, _}}) -> ok end - ), + ?assertFailure(Failure2, {authorization_failed, {processing_deadline_reached, _}}), %% Assert payment status IS failed ?invoice_state(?invoice_w_status(_), [?payment_state(Payment)]) = hg_client_invoicing:get(InvoiceID, Client), @@ -7821,11 +7768,7 @@ payment_recurrent_cascade_success(C) -> #domain_PaymentRoute{provider = ?prv(?CASCADE_ID_RANGE(?PAYMENT_RECURRENT_CASCADE_SUCCESS_ID + 1))}, Route1 ), - ok = payproc_errors:match( - 'PaymentFailure', - Failure1, - fun({preauthorization_failed, {card_blocked, _}}) -> ok end - ), + ?assertFailure(Failure1, {preauthorization_failed, {card_blocked, _}}), [ ?payment_ev(PaymentID, ?route_changed(Route2)), ?payment_ev(PaymentID, ?cash_flow_changed(_CashFlow2)) @@ -7878,11 +7821,7 @@ payment_recurrent_cascade_fail(C) -> next_change(InvoiceID, Client), ?payment_ev(PaymentID, ?payment_status_changed(?failed({failure, Failure}))) = next_change(InvoiceID, Client), - ok = payproc_errors:match( - 'PaymentFailure', - Failure, - fun({preauthorization_failed, {card_blocked, _}}) -> ok end - ), + ?assertFailure(Failure, {preauthorization_failed, {card_blocked, _}}), ?invoice_state(?invoice_w_status(_), [?payment_state(Payment)]) = hg_client_invoicing:get(InvoiceID, Client), ?assertMatch(#domain_InvoicePayment{status = {failed, _}}, Payment). diff --git a/rebar.config b/rebar.config index 2a6a11f8..12e59322 100644 --- a/rebar.config +++ b/rebar.config @@ -38,9 +38,6 @@ {thrift, {git, "https://github.com/valitydev/thrift_erlang.git", {tag, "v1.0.0"}}}, {damsel, {git, "https://github.com/valitydev/damsel.git", {branch, "XYZ-451/ft/limit-overflow-failure-details"}}}, {exrates_proto, {git, "https://github.com/valitydev/exrates-proto.git", {branch, "master"}}}, - {payproc_errors, - {git, "https://github.com/valitydev/payproc-errors-erlang.git", - {branch, "XYZ-451/ft/limit-overflow-failure-details"}}}, {mg_proto, {git, "https://github.com/valitydev/machinegun-proto.git", {branch, "master"}}}, {dmt_client, {git, "https://github.com/valitydev/dmt-client.git", {tag, "v2.0.3"}}}, {party_client, {git, "https://github.com/valitydev/party-client-erlang.git", {tag, "v2.0.1"}}}, diff --git a/rebar.lock b/rebar.lock index 0cd934f6..03cb7663 100644 --- a/rebar.lock +++ b/rebar.lock @@ -115,10 +115,6 @@ {git,"https://github.com/valitydev/party-client-erlang.git", {ref,"88cb5a9b5abd9bb437222de168bba096edd10882"}}, 0}, - {<<"payproc_errors">>, - {git,"https://github.com/valitydev/payproc-errors-erlang.git", - {ref,"f09631ea4aad57cd9fe3ec52361eaf3dc5d25ba6"}}, - 0}, {<<"progressor">>, {git,"https://github.com/valitydev/progressor.git", {ref,"2435f86863a6ee3e6a1900cfcafaf15886e41112"}}, From 675a1574190ea463d3cda81616189c18be574f44 Mon Sep 17 00:00:00 2001 From: Aleksey Kashapov Date: Wed, 9 Sep 2026 12:23:34 +0300 Subject: [PATCH 6/8] Fixes missing clauses for operation failures --- apps/hellgate/src/hg_invoice_payment.erl | 56 +++++++++---------- .../src/hg_invoice_payment_refund.erl | 9 ++- 2 files changed, 29 insertions(+), 36 deletions(-) diff --git a/apps/hellgate/src/hg_invoice_payment.erl b/apps/hellgate/src/hg_invoice_payment.erl index 88c0b249..f270ea4e 100644 --- a/apps/hellgate/src/hg_invoice_payment.erl +++ b/apps/hellgate/src/hg_invoice_payment.erl @@ -2645,30 +2645,27 @@ get_bank_card_token(?recurrent_payer({bank_card, #domain_BankCard{token = Token} get_bank_card_token(_) -> undefined. -choose_fd_operation_status_for_failure({failure, Failure}) -> - case Failure of - ?failure(<<"authorization_failed">>, _, ?subfailure(FailType0, _)) -> - DefaultBenignFailures = [ - insufficient_funds, - rejected_by_issuer, - processing_deadline_reached - ], - FDConfig = genlib_app:env(hellgate, fault_detector, #{}), - Config = genlib_map:get(conversion, FDConfig, #{}), - BenignFailures = genlib_map:get(benign_failures, Config, DefaultBenignFailures), - FailType1 = - try - erlang:binary_to_existing_atom(FailType0, utf8) - catch - error:badarg -> undefined - end, - case lists:member(FailType1, BenignFailures) of - false -> error; - true -> finish - end; - _ -> - finish - end. +choose_fd_operation_status_for_failure({failure, ?failure(<<"authorization_failed">>, _, ?subfailure(FailType0, _))}) -> + DefaultBenignFailures = [ + insufficient_funds, + rejected_by_issuer, + processing_deadline_reached + ], + FDConfig = genlib_app:env(hellgate, fault_detector, #{}), + Config = genlib_map:get(conversion, FDConfig, #{}), + BenignFailures = genlib_map:get(benign_failures, Config, DefaultBenignFailures), + FailType1 = + try + erlang:binary_to_existing_atom(FailType0, utf8) + catch + error:badarg -> undefined + end, + case lists:member(FailType1, BenignFailures) of + false -> error; + true -> finish + end; +choose_fd_operation_status_for_failure(_) -> + finish. maybe_notify_fault_detector({payment, processing_session}, processed, Status, St) -> ProviderRef = get_route_provider(get_route(St)), @@ -2725,13 +2722,10 @@ check_retry_possibility(Target, Failure, St) -> end. -spec check_failure_type(failure()) -> transient | fatal. -check_failure_type({failure, Failure}) -> - case Failure of - ?failure(<<"authorization_failed">>, _, ?subfailure(<<"temporarily_unavailable">>, _)) -> - transient; - _ -> - fatal - end. +check_failure_type({failure, ?failure(<<"authorization_failed">>, _, ?subfailure(<<"temporarily_unavailable">>, _))}) -> + transient; +check_failure_type(_) -> + fatal. get_action(?processed(), _Action, St) -> case get_payment_flow(get_payment(St)) of diff --git a/apps/hellgate/src/hg_invoice_payment_refund.erl b/apps/hellgate/src/hg_invoice_payment_refund.erl index 79466fb6..4b53434d 100644 --- a/apps/hellgate/src/hg_invoice_payment_refund.erl +++ b/apps/hellgate/src/hg_invoice_payment_refund.erl @@ -481,11 +481,10 @@ check_retry_possibility(Failure, Refund) -> fatal end. -check_failure_type({failure, Failure}) -> - case Failure of - ?failure(<<"authorization_failed">>, _, ?subfailure(<<"temporarily_unavailable">>, _)) -> transient; - _ -> fatal - end. +check_failure_type({failure, ?failure(<<"authorization_failed">>, _, ?subfailure(<<"temporarily_unavailable">>, _))}) -> + transient; +check_failure_type(_) -> + fatal. get_actual_retry_strategy(Refund) -> hg_retry:skip_steps(get_initial_retry_strategy(), retry_attempts(Refund)). From 4d4e5ab1a5263e1aa4f5e6154e2c6eea2df1e8e0 Mon Sep 17 00:00:00 2001 From: Aleksey Kashapov Date: Wed, 9 Sep 2026 13:24:33 +0300 Subject: [PATCH 7/8] Adds support in failure subcode for list of overflowed limits --- apps/ff_transfer/src/ff_withdrawal.erl | 9 ++++++-- apps/hellgate/src/hg_invoice_payment.erl | 22 ++++++++++++------- apps/hellgate/src/hg_limiter.erl | 6 ++--- apps/hellgate/test/hg_invoice_tests_SUITE.erl | 15 +++++++++---- rebar.config | 2 +- rebar.lock | 2 +- 6 files changed, 37 insertions(+), 19 deletions(-) diff --git a/apps/ff_transfer/src/ff_withdrawal.erl b/apps/ff_transfer/src/ff_withdrawal.erl index 20809ba9..3a530de2 100644 --- a/apps/ff_transfer/src/ff_withdrawal.erl +++ b/apps/ff_transfer/src/ff_withdrawal.erl @@ -1957,9 +1957,14 @@ build_route_not_found_sub_failure([]) -> %% construction in `ff_withdrawal_routing:validate_turnover_limits/4` and %% `ff_limiter:check_limits/4`. build_route_not_found_sub_failure([ - ?rejected_route({terms_violation, {overflow, [{LimitID, _Amount, _Boundary} | _]}}) | _ + ?rejected_route({terms_violation, {overflow, OverflowedLimits}}) | _ ]) -> - #{code => <<"limit_overflow">>, sub => #{code => LimitID}}; + #{ + code => <<"limit_overflow">>, + sub => #{ + code => genlib_string:join($,, [LimitID || {LimitID, _Amount, _Boundary} <- OverflowedLimits]) + } + }; build_route_not_found_sub_failure([_H | Rest]) -> build_route_not_found_sub_failure(Rest). diff --git a/apps/hellgate/src/hg_invoice_payment.erl b/apps/hellgate/src/hg_invoice_payment.erl index f270ea4e..2e42918e 100644 --- a/apps/hellgate/src/hg_invoice_payment.erl +++ b/apps/hellgate/src/hg_invoice_payment.erl @@ -1982,7 +1982,10 @@ construct_shop_limit_failure(limit_overflow, LimitIDs) -> code = <<"authorization_failed">>, sub = #domain_SubFailure{ code = <<"shop_limit_exceeded">>, - sub = #domain_SubFailure{code = <<"unknown">>, sub = #domain_SubFailure{code = hd(LimitIDs)}} + sub = #domain_SubFailure{ + code = <<"unknown">>, + sub = #domain_SubFailure{code = genlib_string:join($,, LimitIDs)} + } } }}. @@ -2201,17 +2204,20 @@ construct_routing_failure({rejected_routes, {SubCode, RejectedRoutes}}) when genlib:format(normalize_rejected_routes(RejectedRoutes)) ); construct_routing_failure({rejected_routes, {limit_overflow, RejectedRoutes}}) -> - %% NOTE For limit overflow subcode, we care only about the first rejected - %% route in this code pass. - %% See reason-tuple construction in `get_limit_overflow_routes/4`. - [{_PrvRef, _TrmRef, {'LimitOverflow', LimitIDs}} | _Rest] = - NormalizedRejectedRoutes = normalize_rejected_routes(RejectedRoutes), + %% NOTE See reason-tuple construction in `get_limit_overflow_routes/4`. + LimitIDs = lists:flatten([ + LimitIDs + || {_PrvRef, _TrmRef, {'LimitOverflow', LimitIDs}} <- normalize_rejected_routes(RejectedRoutes) + ]), construct_routing_failure( #domain_SubFailure{ code = <<"rejected">>, - sub = #domain_SubFailure{code = <<"limit_overflow">>, sub = #domain_SubFailure{code = hd(LimitIDs)}} + sub = #domain_SubFailure{ + code = <<"limit_overflow">>, + sub = #domain_SubFailure{code = genlib_string:join($,, LimitIDs)} + } }, - genlib:format(NormalizedRejectedRoutes) + genlib:format("Limits ~p overflowed", [LimitIDs]) ); construct_routing_failure({rejected_routes, {_SubCode, RejectedRoutes}}) -> construct_routing_failure( diff --git a/apps/hellgate/src/hg_limiter.erl b/apps/hellgate/src/hg_limiter.erl index 703a71ab..5fabdcb1 100644 --- a/apps/hellgate/src/hg_limiter.erl +++ b/apps/hellgate/src/hg_limiter.erl @@ -152,17 +152,17 @@ check_limit_( limit = #domain_TurnoverLimit{ref = ?ref(LimitID), upper_boundary = UpperBoundary}, value = LimitAmount }, - OverflownLimits + OverflowedLimits ) -> case LimitAmount =< UpperBoundary of true -> - OverflownLimits; + OverflowedLimits; false -> ok = logger:notice( "Limit with id ~p overflowed, amount ~p upper boundary ~p", [LimitID, LimitAmount, UpperBoundary] ), - [LimitID | OverflownLimits] + [LimitID | OverflowedLimits] end. -spec hold_payment_limits([turnover_limit()], invoice(), payment(), session() | undefined, route(), pos_integer()) -> diff --git a/apps/hellgate/test/hg_invoice_tests_SUITE.erl b/apps/hellgate/test/hg_invoice_tests_SUITE.erl index 28e3e25c..83aa2821 100644 --- a/apps/hellgate/test/hg_invoice_tests_SUITE.erl +++ b/apps/hellgate/test/hg_invoice_tests_SUITE.erl @@ -1360,7 +1360,10 @@ payment_limit_overflow(C) -> ok = hg_limiter_helper:assert_payment_limit_amount( ?LIMIT_ID, configured_limit_version(C), PaymentAmount, Payment, Invoice ), - ?assertFailure(Failure, {no_route_found, {rejected, {limit_overflow, {?LIMIT_ID, undefined}}}}). + %% NOTE We expect binary value of limit id to match exixting atom, so helper + %% fun inside an assert will translate it to it. + ExpectedLimitID = binary_to_existing_atom(?LIMIT_ID), + ?assertFailure(Failure, {no_route_found, {rejected, {limit_overflow, {ExpectedLimitID, undefined}}}}). -spec limit_hold_currency_error(config()) -> test_return(). limit_hold_currency_error(C) -> @@ -1381,7 +1384,11 @@ limit_hold_payment_tool_not_supported(C) -> -spec limit_hold_two_routes_failure(config()) -> test_return(). limit_hold_two_routes_failure(C) -> Failure = payment_route_not_found(C), - ?assertRouteNotFound(Failure, {rejected, {limit_overflow, _}}, <<"[{">>). + ?assertRouteNotFound( + Failure, + {rejected, {limit_overflow, _}}, + iolist_to_binary(io_lib:format("Limits ~p overflowed", [[?LIMIT_ID2]])) + ). payment_route_not_found(C) -> PmtSys = ?pmt_sys(<<"visa-ref">>), @@ -1622,7 +1629,7 @@ payment_w_misconfigured_routing_failed(C) -> ?payment_ev(PaymentID, ?payment_status_changed(?failed({failure, Failure}))) ] = next_changes(InvoiceID, 5, Client), Reason = genlib:format({routing_decisions, {delegates, []}}), - ?assertRouteNotFound(Failure, {unknown, {{unknown_error, <<"misconfiguration">>}, _}}, Reason). + ?assertRouteNotFound(Failure, {unknown, {misconfiguration, _}}, Reason). payment_w_misconfigured_routing_failed_fixture(_Revision, _C) -> [ @@ -6853,7 +6860,7 @@ payment_big_cascade_success(C) -> (fun() -> {Route, Candidates, _CashFlow, _TrxID, Failure} = await_cascade_triggering(InvoiceID, PaymentID, Client), - ?assertFailure(Failure, {authorization_failed, {card_blocked, _}}), + ?assertFailure(Failure, {preauthorization_failed, {card_blocked, _}}), _ = [ ?assertMatch( HoldValue when HoldValue =:= 0 orelse HoldValue =:= Amount, diff --git a/rebar.config b/rebar.config index 12e59322..786dc778 100644 --- a/rebar.config +++ b/rebar.config @@ -36,7 +36,7 @@ {woody, {git, "https://github.com/valitydev/woody_erlang.git", {tag, "v1.1.2"}}}, {scoper, {git, "https://github.com/valitydev/scoper.git", {tag, "v1.1.0"}}}, {thrift, {git, "https://github.com/valitydev/thrift_erlang.git", {tag, "v1.0.0"}}}, - {damsel, {git, "https://github.com/valitydev/damsel.git", {branch, "XYZ-451/ft/limit-overflow-failure-details"}}}, + {damsel, {git, "https://github.com/valitydev/damsel.git", {tag, "v2.2.41"}}}, {exrates_proto, {git, "https://github.com/valitydev/exrates-proto.git", {branch, "master"}}}, {mg_proto, {git, "https://github.com/valitydev/machinegun-proto.git", {branch, "master"}}}, {dmt_client, {git, "https://github.com/valitydev/dmt-client.git", {tag, "v2.0.3"}}}, diff --git a/rebar.lock b/rebar.lock index 03cb7663..6f24095f 100644 --- a/rebar.lock +++ b/rebar.lock @@ -31,7 +31,7 @@ {<<"ctx">>,{pkg,<<"ctx">>,<<"0.6.0">>},2}, {<<"damsel">>, {git,"https://github.com/valitydev/damsel.git", - {ref,"e434fa8fda147186e0ea192f6002cdbf92851b65"}}, + {ref,"239638b3689dfa4a20dcb17f3715167957129c30"}}, 0}, {<<"dmt_client">>, {git,"https://github.com/valitydev/dmt-client.git", From 3c5172097b45869406ad72b244c034cc980c4d88 Mon Sep 17 00:00:00 2001 From: Aleksey Kashapov Date: Thu, 10 Sep 2026 09:54:45 +0300 Subject: [PATCH 8/8] Minor fixes to codestyle, consistency and error details info --- apps/hellgate/src/hg_invoice_payment.erl | 12 +++++++----- apps/hellgate/src/hg_invoice_utils.erl | 1 + apps/hellgate/test/hg_invoice_tests_SUITE.erl | 4 +++- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/apps/hellgate/src/hg_invoice_payment.erl b/apps/hellgate/src/hg_invoice_payment.erl index 2e42918e..daa3bf33 100644 --- a/apps/hellgate/src/hg_invoice_payment.erl +++ b/apps/hellgate/src/hg_invoice_payment.erl @@ -2205,10 +2205,12 @@ construct_routing_failure({rejected_routes, {SubCode, RejectedRoutes}}) when ); construct_routing_failure({rejected_routes, {limit_overflow, RejectedRoutes}}) -> %% NOTE See reason-tuple construction in `get_limit_overflow_routes/4`. - LimitIDs = lists:flatten([ - LimitIDs - || {_PrvRef, _TrmRef, {'LimitOverflow', LimitIDs}} <- normalize_rejected_routes(RejectedRoutes) - ]), + LimitIDs = ordsets:from_list( + lists:flatten([ + LimitIDs + || {_PrvRef, _TrmRef, {'LimitOverflow', LimitIDs}} <- normalize_rejected_routes(RejectedRoutes) + ]) + ), construct_routing_failure( #domain_SubFailure{ code = <<"rejected">>, @@ -2217,7 +2219,7 @@ construct_routing_failure({rejected_routes, {limit_overflow, RejectedRoutes}}) - sub = #domain_SubFailure{code = genlib_string:join($,, LimitIDs)} } }, - genlib:format("Limits ~p overflowed", [LimitIDs]) + genlib:format(normalize_rejected_routes(RejectedRoutes)) ); construct_routing_failure({rejected_routes, {_SubCode, RejectedRoutes}}) -> construct_routing_failure( diff --git a/apps/hellgate/src/hg_invoice_utils.erl b/apps/hellgate/src/hg_invoice_utils.erl index 03159c8e..f413b2c2 100644 --- a/apps/hellgate/src/hg_invoice_utils.erl +++ b/apps/hellgate/src/hg_invoice_utils.erl @@ -184,6 +184,7 @@ check_deadline(Deadline) -> -spec format_failure(dmsl_domain_thrift:'Failure'() | undefined) -> iolist(). format_failure(Failure) -> lists:join($:, extract_failure_code(Failure)). + extract_failure_code(undefined) -> []; extract_failure_code(#domain_Failure{code = Code, sub = Sub}) -> [Code | extract_failure_code(Sub)]; extract_failure_code(#domain_SubFailure{code = Code, sub = Sub}) -> [Code | extract_failure_code(Sub)]. diff --git a/apps/hellgate/test/hg_invoice_tests_SUITE.erl b/apps/hellgate/test/hg_invoice_tests_SUITE.erl index 83aa2821..2247b149 100644 --- a/apps/hellgate/test/hg_invoice_tests_SUITE.erl +++ b/apps/hellgate/test/hg_invoice_tests_SUITE.erl @@ -1387,7 +1387,9 @@ limit_hold_two_routes_failure(C) -> ?assertRouteNotFound( Failure, {rejected, {limit_overflow, _}}, - iolist_to_binary(io_lib:format("Limits ~p overflowed", [[?LIMIT_ID2]])) + %% NOTE Start of long and nested list of tuples describing route with + %% corresponding limit's states + <<"[{">> ). payment_route_not_found(C) ->