diff --git a/.env b/.env index a689ddfc..b2116f2e 100644 --- a/.env +++ b/.env @@ -1,4 +1,3 @@ -SERVICE_NAME=hellgate OTP_VERSION=28.5.0 REBAR_VERSION=3.26 THRIFT_VERSION=0.14.2.3 diff --git a/README.md b/README.md index 7c064550..f303b0aa 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +**TODO: Clean up readme!** + # Hellgate Core logic service for payment states processing. @@ -99,3 +101,6 @@ $ make wdeps-test ## Служебные лимиты Нужно уметь _ограничивать_ максимальное _ожидаемое_ количество тех или иных объектов, превышение которого может негативно влиять на качество обслуживания системы. Например, мы можем считать количество _выводов_ одним участником неограниченным, однако при этом неограниченное количество созданных _личностей_ мы совершенно не ожидаем. В этом случае возможно будет разумно ограничить их количество сверху труднодостижимой для подавляющего большинства планкой, например, в 1000 объектов. В идеале подобное должно быть точечно конфигурируемым. + +# limiter +Service for limits calculating diff --git a/apps/limiter/src/lim_body.erl b/apps/limiter/src/lim_body.erl new file mode 100644 index 00000000..e9223b91 --- /dev/null +++ b/apps/limiter/src/lim_body.erl @@ -0,0 +1,51 @@ +-module(lim_body). + +-export([get/3]). + +-type amount() :: integer(). +-type cash() :: #{ + amount := amount(), + currency := currency() +}. + +-type currency() :: dmsl_domain_thrift:'CurrencySymbolicCode'(). +-type config() :: lim_config_machine:config(). +-type body_type() :: full | partial. + +-export_type([amount/0]). +-export_type([currency/0]). +-export_type([cash/0]). + +-import(lim_pipeline, [do/1, unwrap/1]). + +-spec get(body_type(), config(), lim_context:t()) -> + {ok, cash()} | {error, notfound}. +get(BodyType, Config, LimitContext) -> + do(fun() -> + ContextType = lim_config_machine:context_type(Config), + Operation = unwrap(lim_context:get_operation(ContextType, LimitContext)), + Body = unwrap(get_body_for_operation(BodyType, ContextType, LimitContext)), + apply_op_behaviour(Operation, Body, Config) + end). + +-spec get_body_for_operation(body_type(), lim_context:context_type(), lim_context:t()) -> + {ok, cash()} | {error, notfound}. +get_body_for_operation(full, ContextType, LimitContext) -> + lim_context:get_value(ContextType, cost, LimitContext); +get_body_for_operation(partial, ContextType, LimitContext) -> + lim_context:get_value(ContextType, capture_cost, LimitContext). + +apply_op_behaviour(Operation, Body, #{op_behaviour := ComputationConfig}) -> + case maps:get(Operation, ComputationConfig, undefined) of + addition -> + Body; + subtraction -> + invert_body(Body); + undefined -> + Body + end; +apply_op_behaviour(_Operation, Body, _Config) -> + Body. + +invert_body(#{amount := Amount} = Cash) -> + Cash#{amount := -Amount}. diff --git a/apps/limiter/src/lim_client_woody.erl b/apps/limiter/src/lim_client_woody.erl new file mode 100644 index 00000000..522db345 --- /dev/null +++ b/apps/limiter/src/lim_client_woody.erl @@ -0,0 +1,59 @@ +-module(lim_client_woody). + +-export([call/4]). +-export([call/5]). +-export([get_service_client_url/1]). + +-define(APP, limiter). +-define(DEFAULT_DEADLINE, 5000). + +%% +-type service_name() :: atom(). + +-spec call(service_name(), woody:func(), woody:args(), woody_context:ctx()) -> woody:result(). +call(ServiceName, Function, Args, Context) -> + EventHandler = {scoper_woody_event_handler, #{}}, + call(ServiceName, Function, Args, Context, EventHandler). + +-spec call(service_name(), woody:func(), woody:args(), woody_context:ctx(), woody:ev_handler()) -> woody:result(). +call(ServiceName, Function, Args, Context0, EventHandler) -> + Deadline = get_service_deadline(ServiceName), + Context1 = set_deadline(Deadline, Context0), + Url = get_service_client_url(ServiceName), + Service = get_service_modname(ServiceName), + Request = {Service, Function, Args}, + woody_client:call( + Request, + #{url => Url, event_handler => EventHandler}, + Context1 + ). + +get_service_client_config(ServiceName) -> + ServiceClients = genlib_app:env(?APP, service_clients, #{}), + maps:get(ServiceName, ServiceClients, #{}). + +-spec get_service_client_url(atom()) -> lim_maybe:'maybe'(woody:url()). +get_service_client_url(ServiceName) -> + maps:get(url, get_service_client_config(ServiceName), undefined). + +-spec get_service_modname(service_name()) -> woody:service(). +get_service_modname(xrates) -> + {xrates_rate_thrift, 'Rates'}; +get_service_modname(liminator) -> + {liminator_liminator_thrift, 'LiminatorService'}; +get_service_modname(accounter) -> + {dmsl_accounter_thrift, 'Accounter'}. + +-spec get_service_deadline(service_name()) -> undefined | woody_deadline:deadline(). +get_service_deadline(ServiceName) -> + ServiceClient = get_service_client_config(ServiceName), + Timeout = maps:get(deadline, ServiceClient, ?DEFAULT_DEADLINE), + woody_deadline:from_timeout(Timeout). + +set_deadline(Deadline, Context) -> + case woody_context:get_deadline(Context) of + undefined -> + woody_context:set_deadline(Deadline, Context); + _AlreadySet -> + Context + end. diff --git a/apps/limiter/src/lim_config_machine.erl b/apps/limiter/src/lim_config_machine.erl new file mode 100644 index 00000000..95e13dd1 --- /dev/null +++ b/apps/limiter/src/lim_config_machine.erl @@ -0,0 +1,960 @@ +-module(lim_config_machine). + +-include_lib("limiter_proto/include/limproto_limiter_thrift.hrl"). +-include_lib("damsel/include/dmsl_domain_thrift.hrl"). +-include_lib("damsel/include/dmsl_limiter_config_thrift.hrl"). +-include_lib("damsel/include/dmsl_domain_conf_v2_thrift.hrl"). + +%% Accessors + +-export([type/1]). +-export([context_type/1]). +-export([currency_conversion/1]). + +%% API + +-export([get_values/2]). +-export([get_batch/3]). +-export([hold_batch/3]). +-export([commit_batch/3]). +-export([rollback_batch/3]). + +-export([calculate_shard_id/2]). +-export([mk_scope_prefix/2]). + +-type lim_context() :: lim_context:t(). +-type processor_type() :: lim_router:processor_type(). +-type processor() :: lim_router:processor(). +-type description() :: binary(). + +-type limit_type() :: {turnover, lim_turnover_metric:t()}. +-type limit_scope() :: ordsets:ordset(limit_scope_type()). +-type limit_scope_type() :: + party + | shop + | wallet + | payment_tool + | provider + | terminal + | payer_contact_email + | {destination_field, [Field :: binary()]}. +-type shard_size() :: pos_integer(). +-type shard_id() :: binary(). +-type prefix() :: binary(). +-type time_range_type() :: {calendar, year | month | week | day} | {interval, pos_integer()}. +-type time_range() :: #{ + upper := timestamp(), + lower := timestamp() +}. + +-type context_type() :: lim_context:context_type(). + +-type config() :: #{ + id := lim_id(), + processor_type := processor_type(), + started_at := timestamp(), + shard_size := shard_size(), + time_range_type := time_range_type(), + context_type := context_type(), + type := limit_type(), + scope => limit_scope(), + description => description(), + op_behaviour => op_behaviour(), + currency_conversion => currency_conversion(), + finalization_behaviour => finalization_behaviour() +}. + +-type op_behaviour() :: #{operation_type() := addition | subtraction}. +-type operation_type() :: invoice_payment_refund. +-type currency_conversion() :: boolean(). +-type finalization_behaviour() :: normal | {invertable, session_presence}. + +-type lim_id() :: limproto_limiter_thrift:'LimitID'(). +-type lim_version() :: dmsl_domain_thrift:'DataRevision'(). +-type lim_change() :: limproto_limiter_thrift:'LimitChange'(). +-type limit() :: limproto_limiter_thrift:'Limit'(). +-type timestamp() :: dmsl_base_thrift:'Timestamp'(). +-type operation_id() :: limproto_limiter_thrift:'OperationID'(). +-type lim_changes() :: [lim_change()]. +-type changes_group() :: {context_type(), finalization_behaviour()}. + +-export_type([config/0]). +-export_type([limit_type/0]). +-export_type([limit_scope/0]). +-export_type([time_range_type/0]). +-export_type([time_range/0]). +-export_type([lim_id/0]). +-export_type([lim_change/0]). +-export_type([limit/0]). +-export_type([timestamp/0]). + +%% Handler behaviour + +-callback make_change( + Stage :: lim_turnover_metric:stage(), + LimitChange :: lim_change(), + Config :: config(), + LimitContext :: lim_context() +) -> {ok, lim_liminator:limit_change()} | {error, make_change_error()}. + +-type make_change_error() :: lim_turnover_processor:make_change_error(). +-type get_limit_error() :: lim_turnover_processor:get_limit_error(). +-type hold_error() :: lim_turnover_processor:hold_error(). +-type commit_error() :: lim_turnover_processor:commit_error(). +-type rollback_error() :: lim_turnover_processor:rollback_error(). + +-type config_error() :: {config, notfound}. + +-import(lim_pipeline, [do/1, unwrap/1, unwrap/2]). + +%% Accessors + +-spec started_at(config()) -> timestamp(). +started_at(#{started_at := Value}) -> + Value. + +-spec shard_size(config()) -> shard_size(). +shard_size(#{shard_size := Value}) -> + Value. + +-spec time_range_type(config()) -> time_range_type(). +time_range_type(#{time_range_type := Value}) -> + Value. + +-spec type(config()) -> limit_type(). +type(#{type := Value}) -> + Value; +type(_) -> + {turnover, number}. + +-spec scope(config()) -> limit_scope(). +scope(#{scope := Value}) -> + Value; +scope(_) -> + ordsets:new(). + +-spec context_type(config()) -> context_type(). +context_type(#{context_type := Value}) -> + Value. + +-spec finalization_behaviour(config()) -> finalization_behaviour(). +finalization_behaviour(#{finalization_behaviour := Value}) -> + Value; +finalization_behaviour(_) -> + normal. + +-spec currency_conversion(config()) -> currency_conversion(). +currency_conversion(#{currency_conversion := Value}) -> + Value; +currency_conversion(_) -> + false. + +%% + +-spec get_values(lim_changes(), lim_context()) -> + {ok, [lim_liminator:limit_response()]} | {error, config_error() | {processor(), get_limit_error()}}. +get_values(LimitChanges, LimitContext) -> + do(fun() -> + Changes = unwrap(collect_changes(hold, LimitChanges, LimitContext)), + Names = lists:map(fun lim_liminator:get_name/1, Changes), + unwrap(lim_liminator:get_values(Names, LimitContext)) + end). + +-spec get_batch(operation_id(), lim_changes(), lim_context()) -> + {ok, [lim_liminator:limit_response()]} | {error, config_error() | {processor(), get_limit_error()}}. +get_batch(OperationID, LimitChanges, LimitContext) -> + do(fun() -> + GroupedChanges = unwrap(collect_grouped_changes(hold, LimitChanges, LimitContext)), + F = fun(Group, Changes) -> + OperationIDForGroup = operation_id_for_group(OperationID, Group), + unwrap(OperationID, lim_liminator:get(OperationIDForGroup, Changes, LimitContext)) + end, + lists:flatten(maps:values(maps:map(F, GroupedChanges))) + end). + +-spec hold_batch(operation_id(), lim_changes(), lim_context()) -> + {ok, [lim_liminator:limit_response()]} + | {error, config_error() | {processor(), hold_error()} | {operation_id(), lim_liminator:invalid_request_error()}}. +hold_batch(OperationID, LimitChanges, LimitContext) -> + do(fun() -> + GroupedChanges = unwrap(collect_grouped_changes(hold, LimitChanges, LimitContext)), + F = fun(Group, Changes) -> + OperationIDForGroup = operation_id_for_group(OperationID, Group), + unwrap(OperationID, lim_liminator:hold(OperationIDForGroup, Changes, LimitContext)) + end, + lists:flatten(maps:values(maps:map(F, GroupedChanges))) + end). + +-spec commit_batch(operation_id(), lim_changes(), lim_context()) -> + ok + | {error, config_error() | {processor(), commit_error()} | {operation_id(), lim_liminator:invalid_request_error()}}. +commit_batch(OperationID, LimitChanges, LimitContext) -> + do(fun() -> + GroupedChanges = unwrap(collect_grouped_changes(commit, LimitChanges, LimitContext)), + F = fun(Group, Changes) -> + OperationIDForGroup = operation_id_for_group(OperationID, Group), + Behaviour = resolve_group_finalization_behaviour(Group, LimitContext), + unwrap(OperationID, finalize(OperationIDForGroup, Changes, LimitContext, commit, Behaviour)) + end, + maps:foreach(F, GroupedChanges) + end). + +-spec rollback_batch(operation_id(), lim_changes(), lim_context()) -> + ok + | {error, + config_error() | {processor(), rollback_error()} | {operation_id(), lim_liminator:invalid_request_error()}}. +rollback_batch(OperationID, LimitChanges, LimitContext) -> + do(fun() -> + GroupedChanges = unwrap(collect_grouped_changes(hold, LimitChanges, LimitContext)), + F = fun(Group, Changes) -> + OperationIDForGroup = operation_id_for_group(OperationID, Group), + Behaviour = resolve_group_finalization_behaviour(Group, LimitContext), + unwrap(OperationID, finalize(OperationIDForGroup, Changes, LimitContext, rollback, Behaviour)) + end, + maps:foreach(F, GroupedChanges) + end). + +finalize(OperationIDForGroup, Changes, LimitContext, commit, normal) -> + lim_liminator:commit(OperationIDForGroup, Changes, LimitContext); +finalize(OperationIDForGroup, Changes, LimitContext, commit, inverted) -> + lim_liminator:rollback(OperationIDForGroup, Changes, LimitContext); +finalize(OperationIDForGroup, Changes, LimitContext, rollback, normal) -> + lim_liminator:rollback(OperationIDForGroup, Changes, LimitContext); +finalize(OperationIDForGroup, Changes, LimitContext, rollback, inverted) -> + lim_liminator:commit(OperationIDForGroup, Changes, LimitContext). + +-spec resolve_group_finalization_behaviour(changes_group(), lim_context()) -> normal | inverted. +resolve_group_finalization_behaviour({_, normal}, _) -> + normal; +resolve_group_finalization_behaviour({ContextType, {invertable, session_presence}}, LimitContext) -> + case lim_context:get_value(ContextType, session, LimitContext) of + {ok, undefined} -> + normal; + {ok, _Some} -> + inverted; + {error, {unsupported, _}} -> + %% If context doesn't support session value then we treat it as + %% normal finalization. + normal; + {error, notfound} -> + normal + end. + +operation_id_for_group(OperationID, {_, normal}) -> + OperationID; +operation_id_for_group(OperationID, {_, {invertable, session_presence}}) -> + <>. + +-spec collect_grouped_changes(hold | commit, lim_changes(), lim_context()) -> + {ok, #{changes_group() => lim_changes()}} | {error, config_error() | {processor(), get_limit_error()}}. +collect_grouped_changes(Stage, LimitChanges, LimitContext) -> + collect_grouped_changes(Stage, LimitChanges, LimitContext, #{}). + +collect_grouped_changes(_, [], _, Acc) -> + {ok, Acc}; +collect_grouped_changes(Stage, [LimitChange | Other], LimitContext, Acc0) -> + do(fun() -> + #limiter_LimitChange{id = ID, version = Version} = LimitChange, + %% NOTE We use only "woody_context" of given limit context to get + %% handler. + {Handler, Config} = unwrap(get_handler(ID, Version, LimitContext)), + Change = unwrap(Handler, Handler:make_change(Stage, LimitChange, Config, LimitContext)), + %% NOTE Because rules to resolve change's finalization behaviour can be + %% dependent on operation's context ("context" key of given limit + %% context map), each group is discriminated by bothe context type and + %% finalization behaviour values. + %% Thus, we must ensure changes are in appropriate group before we + %% evalute groups behaviour against operation's limit context. + ContextType = context_type(Config), + FinalizationBehaviour = finalization_behaviour(Config), + Group = {ContextType, FinalizationBehaviour}, + Acc1 = maps:update_with(Group, fun(Changes) -> [Change | Changes] end, [Change], Acc0), + unwrap(collect_grouped_changes(Stage, Other, LimitContext, Acc1)) + end). + +%% NOTE Used only for `get_values/2' function that doesn't care about groups of +%% changes, nor resulting list's order. +collect_changes(_Stage, [], _LimitContext) -> + {ok, []}; +collect_changes(Stage, [LimitChange = #limiter_LimitChange{id = ID, version = Version} | Other], LimitContext) -> + do(fun() -> + {Handler, Config} = unwrap(get_handler(ID, Version, LimitContext)), + Change = unwrap(Handler, Handler:make_change(Stage, LimitChange, Config, LimitContext)), + [Change | unwrap(collect_changes(Stage, Other, LimitContext))] + end). + +get_handler(ID, Version, LimitContext) -> + do(fun() -> + Config = #{processor_type := ProcessorType} = unwrap(config, get_config(ID, Version, LimitContext)), + {ok, Handler} = lim_router:get_handler(ProcessorType), + {Handler, Config} + end). + +-spec get_config(lim_id(), lim_version(), lim_context()) -> {ok, config()} | {error, notfound}. +get_config(ID, Version, #{woody_context := WoodyContext}) -> + LimitConfigRef = {limit_config, #domain_LimitConfigRef{id = ID}}, + try + #domain_conf_v2_VersionedObject{object = {limit_config, ConfigObject}} = + dmt_client:checkout_object(Version, LimitConfigRef, #{woody_context => WoodyContext}), + {ok, unmarshal_limit_config(ConfigObject)} + catch + throw:#domain_conf_v2_ObjectNotFound{} -> + {error, notfound} + end. + +-spec calculate_shard_id(timestamp(), config()) -> shard_id(). +calculate_shard_id(Timestamp, Config) -> + StartedAt = started_at(Config), + ShardSize = shard_size(Config), + case time_range_type(Config) of + {calendar, Range} -> + calculate_calendar_shard_id(Range, Timestamp, StartedAt, ShardSize); + {interval, _Interval} -> + erlang:error({interval_time_range_not_implemented, Config}) + end. + +calculate_calendar_shard_id(Range, Timestamp, StartedAt, ShardSize) -> + StartDatetime = parse_timestamp(StartedAt), + CurrentDatetime = parse_timestamp(Timestamp), + Units = calculate_time_units(Range, CurrentDatetime, StartDatetime), + SignPrefix = mk_sign_prefix(Units), + RangePrefix = mk_unit_prefix(Range), + mk_shard_id(<>, Units, ShardSize). + +calculate_time_units(year, CurrentDatetime, StartDatetime) -> + StartSecBase = calculate_start_of_year_seconds(StartDatetime), + StartSec = calendar:datetime_to_gregorian_seconds(StartDatetime), + CurrentSecBase = calculate_start_of_year_seconds(CurrentDatetime), + CurrentSec = calendar:datetime_to_gregorian_seconds(CurrentDatetime), + + StartDelta = StartSec - StartSecBase, + CurrentDelta = CurrentSec - (CurrentSecBase + StartDelta), + maybe_previous_unit(CurrentDelta, year(CurrentDatetime) - year(StartDatetime)); +calculate_time_units(month, CurrentDatetime, StartDatetime) -> + StartSecBase = calculate_start_of_month_seconds(StartDatetime), + StartSec = calendar:datetime_to_gregorian_seconds(StartDatetime), + CurrentSecBase = calculate_start_of_month_seconds(CurrentDatetime), + CurrentSec = calendar:datetime_to_gregorian_seconds(CurrentDatetime), + + StartDelta = StartSec - StartSecBase, + CurrentDelta = CurrentSec - (CurrentSecBase + StartDelta), + + YearDiff = year(CurrentDatetime) - year(StartDatetime), + MonthDiff = month(CurrentDatetime) - month(StartDatetime), + + maybe_previous_unit(CurrentDelta, YearDiff * 12 + MonthDiff); +calculate_time_units(week, {CurrentDate, CurrentTime}, {StartDate, StartTime}) -> + StartWeekRem = calendar:date_to_gregorian_days(StartDate) rem 7, + StartWeekBase = (calendar:date_to_gregorian_days(StartDate) div 7) * 7, + CurrentWeekBase = (calendar:date_to_gregorian_days(CurrentDate) div 7) * 7, + + StartSecBase = calendar:datetime_to_gregorian_seconds( + {calendar:gregorian_days_to_date(StartWeekBase), {0, 0, 0}} + ), + StartSec = calendar:datetime_to_gregorian_seconds( + {calendar:gregorian_days_to_date(StartWeekBase + StartWeekRem), StartTime} + ), + CurrentSecBase = calendar:datetime_to_gregorian_seconds( + {calendar:gregorian_days_to_date(CurrentWeekBase), {0, 0, 0}} + ), + CurrentSec = calendar:datetime_to_gregorian_seconds( + {calendar:gregorian_days_to_date(CurrentWeekBase + StartWeekRem), CurrentTime} + ), + + StartDelta = StartSec - StartSecBase, + CurrentDelta = CurrentSec - (CurrentSecBase + StartDelta), + + StartWeeks = calendar:date_to_gregorian_days(StartDate) div 7, + CurrentWeeks = calendar:date_to_gregorian_days(CurrentDate) div 7, + maybe_previous_unit(CurrentDelta, CurrentWeeks - StartWeeks); +calculate_time_units(day, {CurrentDate, CurrentTime}, {StartDate, StartTime}) -> + StartSecBase = calendar:datetime_to_gregorian_seconds({StartDate, {0, 0, 0}}), + StartSec = calendar:datetime_to_gregorian_seconds({StartDate, StartTime}), + CurrentSecBase = calendar:datetime_to_gregorian_seconds({CurrentDate, {0, 0, 0}}), + CurrentSec = calendar:datetime_to_gregorian_seconds({CurrentDate, CurrentTime}), + StartDelta = StartSec - StartSecBase, + CurrentDelta = CurrentSec - (CurrentSecBase + StartDelta), + StartDays = calendar:date_to_gregorian_days(StartDate), + CurrentDays = calendar:date_to_gregorian_days(CurrentDate), + maybe_previous_unit(CurrentDelta, CurrentDays - StartDays). + +maybe_previous_unit(Delta, Unit) when Delta < 0 -> + Unit - 1; +maybe_previous_unit(_Delta, Unit) -> + Unit. + +calculate_start_of_year_seconds({{Year, _, _}, _Time}) -> + calendar:datetime_to_gregorian_seconds({{Year, 1, 1}, {0, 0, 0}}). + +calculate_start_of_month_seconds({{Year, Month, _}, _Time}) -> + calendar:datetime_to_gregorian_seconds({{Year, Month, 1}, {0, 0, 0}}). + +year({{Year, _, _}, _Time}) -> + Year. + +month({{_Year, Month, _}, _Time}) -> + Month. + +mk_unit_prefix(day) -> <<"day">>; +mk_unit_prefix(week) -> <<"week">>; +mk_unit_prefix(month) -> <<"month">>; +mk_unit_prefix(year) -> <<"year">>. + +mk_sign_prefix(Units) when Units >= 0 -> <<"future">>; +mk_sign_prefix(_) -> <<"past">>. + +mk_shard_id(Prefix, Units, ShardSize) -> + ID = integer_to_binary(abs(Units) div ShardSize), + <>. + +-spec mk_scope_prefix(config(), lim_context()) -> + {ok, {prefix(), lim_context:change_context()}} | {error, lim_context:context_error()}. +mk_scope_prefix(Config, LimitContext) -> + mk_scope_prefix_impl(scope(Config), context_type(Config), LimitContext). + +-spec mk_scope_prefix_impl(limit_scope(), context_type(), lim_context()) -> + {ok, {prefix(), lim_context:change_context()}} | {error, lim_context:context_error()}. +mk_scope_prefix_impl(Scope, ContextType, LimitContext) -> + do(fun() -> + Bits = enumerate_context_bits(Scope), + lists:foldl( + fun(Bit, {AccPrefix, Map}) -> + BinaryBit = encode_bit(Bit), + Value = unwrap(extract_context_bit(Bit, ContextType, LimitContext)), + {append_prefix(Value, AccPrefix), Map#{<<"Scope.", BinaryBit/binary>> => Value}} + end, + {<<>>, #{}}, + Bits + ) + end). + +encode_bit({prefix, _Prefix}) -> + genlib:to_binary(prefix); +encode_bit({from, {destination_field, FieldPath}}) -> + lim_string:join($., [<<"destination_field">>] ++ FieldPath); +encode_bit({from, BitType}) -> + genlib:to_binary(BitType). + +-spec append_prefix(binary(), prefix()) -> prefix(). +append_prefix(Fragment, Acc) -> + <>. + +-type context_bit() :: + {from, _ValueName :: atom()} + | {prefix, prefix()}. + +-spec enumerate_context_bits(limit_scope()) -> [context_bit()]. +enumerate_context_bits(Types) -> + TypesOrder = + [ + party, + shop, + wallet, + payment_tool, + provider, + terminal, + payer_contact_email, + %% Scope 'destination_field' differs from other scope + %% types by having an attribute and being represented as a + %% tuple '{destination_field, [Field :: binary()]}'. + destination_field, + sender, + receiver + ], + SortedTypes = lists:filtermap( + fun + (destination_field) -> + case lists:keyfind(destination_field, 1, Types) of + {destination_field, _} = Found -> {true, Found}; + _ -> false + end; + (T) -> + ordsets:is_element(T, Types) + end, + TypesOrder + ), + SquashedTypes = squash_scope_types(SortedTypes), + lists:flatmap(fun get_context_bits/1, SquashedTypes). + +squash_scope_types([party, shop | Rest]) -> + % NOTE + % Shop scope implies party scope. + [shop | squash_scope_types(Rest)]; +squash_scope_types([party, wallet | Rest]) -> + % NOTE + % Wallet scope implies party scope. + [wallet | squash_scope_types(Rest)]; +squash_scope_types([provider, terminal | Rest]) -> + % NOTE + % Provider scope implies provider scope. + [terminal | squash_scope_types(Rest)]; +squash_scope_types([Type | Rest]) -> + [Type | squash_scope_types(Rest)]; +squash_scope_types([]) -> + []. + +-spec get_context_bits(limit_scope_type()) -> [context_bit()]. +get_context_bits(party) -> + [{from, owner_id}]; +get_context_bits(shop) -> + % NOTE + % We need to preserve order between party / shop to ensure backwards compatibility. + [{from, owner_id}, {from, shop_id}]; +get_context_bits(payment_tool) -> + [{from, payment_tool}]; +get_context_bits(wallet) -> + [{prefix, <<"wallet">>}, {from, owner_id}, {from, wallet_id}]; +get_context_bits(provider) -> + [{prefix, <<"provider">>}, {from, provider_id}]; +get_context_bits(terminal) -> + [{prefix, <<"terminal">>}, {from, provider_id}, {from, terminal_id}]; +get_context_bits(payer_contact_email) -> + [{prefix, <<"payer_contact_email">>}, {from, payer_contact_email}]; +get_context_bits({destination_field, FieldPath}) -> + [{prefix, <<"destination">>}, {from, {destination_field, FieldPath}}]; +get_context_bits(sender) -> + [{prefix, <<"sender">>}, {from, sender}]; +get_context_bits(receiver) -> + [{prefix, <<"receiver">>}, {from, receiver}]. + +-spec extract_context_bit(context_bit(), context_type(), lim_context()) -> + {ok, binary()} | {error, lim_context:context_error()}. +extract_context_bit({prefix, Prefix}, _ContextType, _LimitContext) -> + {ok, Prefix}; +extract_context_bit({from, {destination_field, FieldPath}}, ContextType, LimitContext) -> + do(fun() -> + #{type := Type, data := Data} = unwrap(get_generic_payment_tool_resource(ContextType, LimitContext)), + DecodedData = unwrap(lim_context_utils:decode_content(Type, Data)), + FieldValue = unwrap(lim_context_utils:get_field_by_path(FieldPath, DecodedData)), + PrefixedValue = lim_string:join($., FieldPath ++ [FieldValue]), + HashedValue = lim_context_utils:base61_hash(PrefixedValue), + mk_scope_component([HashedValue]) + end); +extract_context_bit({from, payment_tool}, ContextType, LimitContext) -> + case lim_context:get_value(ContextType, payment_tool, LimitContext) of + {ok, {bank_card, #{token := Token, exp_date := {Month, Year}}}} -> + {ok, mk_scope_component([Token, Month, Year])}; + {ok, {bank_card, #{token := Token, exp_date := undefined}}} -> + {ok, mk_scope_component([Token, <<"undefined">>])}; + {ok, {digital_wallet, #{id := ID, service := Service}}} -> + {ok, mk_scope_component([<<"DW">>, Service, ID])}; + %% Generic payment tool is supposed to be not supported + {ok, {generic = Type, _}} -> + {error, {unsupported, {payment_tool, Type}}}; + {error, _} = Error -> + Error + end; +extract_context_bit({from, ValueName}, ContextType, LimitContext) -> + lim_context:get_value(ContextType, ValueName, LimitContext). + +mk_scope_component(Fragments) -> + lim_string:join($/, Fragments). + +get_generic_payment_tool_resource(ContextType, LimitContext) -> + case lim_context:get_value(ContextType, payment_tool, LimitContext) of + {ok, {generic, #{data := ResourceData}}} -> + {ok, ResourceData}; + {ok, {ToolType, _}} -> + {error, {unsupported, ToolType}}; + {error, _} = Error -> + Error + end. + +parse_timestamp(Bin) -> + try + MicroSeconds = genlib_rfc3339:parse(Bin, microsecond), + case genlib_rfc3339:is_utc(Bin) of + false -> + erlang:error({bad_timestamp, not_utc}, [Bin]); + true -> + calendar:system_time_to_universal_time(MicroSeconds, microsecond) + end + catch + error:Error:St -> + erlang:raise(error, {bad_timestamp, Bin, Error}, St) + end. + +unmarshal_limit_config(#domain_LimitConfigObject{ + ref = #domain_LimitConfigRef{id = ID}, + data = #limiter_config_LimitConfig{ + processor_type = ProcessorType, + started_at = StartedAt, + shard_size = ShardSize, + time_range_type = TimeRangeType, + context_type = ContextType, + type = Type, + scopes = Scopes, + description = Description, + op_behaviour = OpBehaviour, + currency_conversion = CurrencyConversion, + finalization_behaviour = FinalizationBehaviour + } +}) -> + genlib_map:compact(#{ + id => ID, + processor_type => ProcessorType, + started_at => StartedAt, + shard_size => ShardSize, + time_range_type => unmarshal_time_range_type(TimeRangeType), + context_type => unmarshal_context_type(ContextType), + type => maybe_apply(Type, fun unmarshal_type/1), + scope => maybe_apply(Scopes, fun unmarshal_scope/1), + description => Description, + op_behaviour => maybe_apply(OpBehaviour, fun unmarshal_op_behaviour/1), + currency_conversion => CurrencyConversion =/= undefined, + finalization_behaviour => unmarshal_finalization_behaviour(FinalizationBehaviour) + }). + +unmarshal_finalization_behaviour(undefined) -> + normal; +unmarshal_finalization_behaviour({normal, #limiter_config_Normal{}}) -> + normal; +unmarshal_finalization_behaviour({invertable, {session_presence, #limiter_config_Inversed{}}}) -> + {invertable, session_presence}. + +unmarshal_time_range_type({calendar, CalendarType}) -> + {calendar, unmarshal_calendar_time_range_type(CalendarType)}; +unmarshal_time_range_type({interval, #limiter_config_TimeRangeTypeInterval{amount = Amount}}) -> + {interval, Amount}. + +unmarshal_calendar_time_range_type({day, _}) -> + day; +unmarshal_calendar_time_range_type({week, _}) -> + week; +unmarshal_calendar_time_range_type({month, _}) -> + month; +unmarshal_calendar_time_range_type({year, _}) -> + year. + +unmarshal_context_type({payment_processing, #limiter_config_LimitContextTypePaymentProcessing{}}) -> + payment_processing; +unmarshal_context_type({withdrawal_processing, #limiter_config_LimitContextTypeWithdrawalProcessing{}}) -> + withdrawal_processing. + +unmarshal_type({turnover, #limiter_config_LimitTypeTurnover{metric = Metric}}) -> + {turnover, maybe_apply(Metric, fun unmarshal_turnover_metric/1, number)}. + +unmarshal_turnover_metric({number, _}) -> + number; +unmarshal_turnover_metric({amount, #limiter_config_LimitTurnoverAmount{currency = Currency}}) -> + {amount, Currency}. + +unmarshal_scope({single, Type}) -> + ordsets:from_list([unmarshal_scope_type(Type)]); +unmarshal_scope({multi, Types}) -> + ordsets:from_list(lists:map(fun unmarshal_scope_type/1, ordsets:to_list(Types))); +unmarshal_scope(Types) when is_list(Types) -> + ordsets:from_list(lists:map(fun unmarshal_scope_type/1, ordsets:to_list(Types))). + +unmarshal_scope_type({party, _}) -> + party; +unmarshal_scope_type({shop, _}) -> + shop; +unmarshal_scope_type({wallet, _}) -> + wallet; +unmarshal_scope_type({payment_tool, _}) -> + payment_tool; +unmarshal_scope_type({provider, _}) -> + provider; +unmarshal_scope_type({terminal, _}) -> + terminal; +unmarshal_scope_type({payer_contact_email, _}) -> + payer_contact_email; +unmarshal_scope_type({destination_field, #limiter_config_LimitScopeDestinationFieldDetails{field_path = FieldPath}}) -> + %% Domain config variant clause + {destination_field, FieldPath}; +unmarshal_scope_type({sender, _}) -> + sender; +unmarshal_scope_type({receiver, _}) -> + receiver. + +unmarshal_op_behaviour(#limiter_config_OperationLimitBehaviour{invoice_payment_refund = Refund}) -> + genlib_map:compact(#{ + invoice_payment_refund => maybe_apply(Refund, fun unmarshal_behaviour/1) + }). + +unmarshal_behaviour({subtraction, #limiter_config_Subtraction{}}) -> + subtraction; +unmarshal_behaviour({addition, #limiter_config_Addition{}}) -> + addition. + +maybe_apply(undefined, _) -> + undefined; +maybe_apply(Value, Fun) -> + Fun(Value). + +maybe_apply(undefined, _, Default) -> + Default; +maybe_apply(Value, Fun, _Default) -> + Fun(Value). + +-ifdef(TEST). +-include_lib("eunit/include/eunit.hrl"). + +-include_lib("limiter_proto/include/limproto_context_payproc_thrift.hrl"). +-include_lib("limiter_proto/include/limproto_context_withdrawal_thrift.hrl"). +-include_lib("damsel/include/dmsl_domain_thrift.hrl"). +-include_lib("damsel/include/dmsl_wthd_domain_thrift.hrl"). +-include_lib("limiter_proto/include/limproto_base_thrift.hrl"). + +-spec test() -> _. + +-spec unmarshal_config_object_test() -> _. +unmarshal_config_object_test() -> + Config = #{ + id => <<"id">>, + processor_type => <<"type">>, + started_at => <<"2000-01-01T00:00:00Z">>, + shard_size => 7, + time_range_type => {calendar, day}, + context_type => payment_processing, + type => {turnover, number}, + scope => ordsets:from_list([party, shop, {destination_field, [<<"path">>, <<"to">>, <<"field">>]}]), + description => <<"description">>, + currency_conversion => true, + finalization_behaviour => {invertable, session_presence} + }, + Object = #domain_LimitConfigObject{ + ref = #domain_LimitConfigRef{id = <<"id">>}, + data = #limiter_config_LimitConfig{ + processor_type = <<"type">>, + started_at = <<"2000-01-01T00:00:00Z">>, + shard_size = 7, + time_range_type = {calendar, {day, #limiter_config_TimeRangeTypeCalendarDay{}}}, + context_type = {payment_processing, #limiter_config_LimitContextTypePaymentProcessing{}}, + type = + {turnover, #limiter_config_LimitTypeTurnover{metric = {number, #limiter_config_LimitTurnoverNumber{}}}}, + scopes = ordsets:from_list([ + {'party', #limiter_config_LimitScopeEmptyDetails{}}, + {'shop', #limiter_config_LimitScopeEmptyDetails{}}, + {'destination_field', #limiter_config_LimitScopeDestinationFieldDetails{ + field_path = [<<"path">>, <<"to">>, <<"field">>] + }} + ]), + description = <<"description">>, + currency_conversion = #limiter_config_CurrencyConversion{}, + finalization_behaviour = {invertable, {session_presence, #limiter_config_Inversed{}}} + } + }, + ?assertEqual(Config, unmarshal_limit_config(Object)). + +-spec check_sign_prefix_test() -> _. +check_sign_prefix_test() -> + ?assertEqual(<<"past">>, mk_sign_prefix(-10)), + ?assertEqual(<<"future">>, mk_sign_prefix(0)), + ?assertEqual(<<"future">>, mk_sign_prefix(10)). + +-spec check_calculate_day_shard_id_test() -> _. +check_calculate_day_shard_id_test() -> + StartedAt1 = <<"2000-01-01T00:00:00Z">>, + ?assertEqual(<<"future/day/0">>, calculate_calendar_shard_id(day, <<"2000-01-01T00:00:00Z">>, StartedAt1, 1)), + ?assertEqual(<<"future/day/2">>, calculate_calendar_shard_id(day, <<"2000-01-03T00:00:00Z">>, StartedAt1, 1)), + ?assertEqual(<<"past/day/1">>, calculate_calendar_shard_id(day, <<"1999-12-31T00:00:00Z">>, StartedAt1, 1)), + ?assertEqual(<<"future/day/1">>, calculate_calendar_shard_id(day, <<"2000-01-02T23:59:59Z">>, StartedAt1, 1)), + ?assertEqual(<<"future/day/1">>, calculate_calendar_shard_id(day, <<"2000-01-04T00:00:00Z">>, StartedAt1, 2)), + ?assertEqual(<<"future/day/366">>, calculate_calendar_shard_id(day, <<"2001-01-01T00:00:00Z">>, StartedAt1, 1)), + ?assertEqual(<<"future/day/12">>, calculate_calendar_shard_id(day, <<"2001-01-01T00:00:00Z">>, StartedAt1, 30)), + StartedAt2 = <<"2000-01-01T03:00:00Z">>, + ?assertEqual(<<"past/day/1">>, calculate_calendar_shard_id(day, <<"2000-01-01T00:00:00Z">>, StartedAt2, 1)), + ?assertEqual(<<"future/day/1">>, calculate_calendar_shard_id(day, <<"2000-01-03T00:00:00Z">>, StartedAt2, 1)). + +-spec check_calculate_week_shard_id_test() -> _. +check_calculate_week_shard_id_test() -> + StartedAt1 = <<"2000-01-01T00:00:00Z">>, + ?assertEqual(<<"future/week/0">>, calculate_calendar_shard_id(week, <<"2000-01-01T00:00:00Z">>, StartedAt1, 1)), + ?assertEqual(<<"past/week/1">>, calculate_calendar_shard_id(week, <<"1999-12-31T00:00:00Z">>, StartedAt1, 1)), + ?assertEqual(<<"future/week/1">>, calculate_calendar_shard_id(week, <<"2000-01-08T00:00:00Z">>, StartedAt1, 1)), + ?assertEqual(<<"future/week/1">>, calculate_calendar_shard_id(week, <<"2000-01-15T00:00:00Z">>, StartedAt1, 2)), + ?assertEqual(<<"future/week/52">>, calculate_calendar_shard_id(week, <<"2001-01-01T00:00:00Z">>, StartedAt1, 1)), + ?assertEqual(<<"future/week/13">>, calculate_calendar_shard_id(week, <<"2001-01-01T00:00:00Z">>, StartedAt1, 4)), + StartedAt2 = <<"2000-01-02T03:00:00Z">>, + ?assertEqual(<<"past/week/1">>, calculate_calendar_shard_id(week, <<"2000-01-02T00:00:00Z">>, StartedAt2, 1)), + ?assertEqual(<<"future/week/0">>, calculate_calendar_shard_id(week, <<"2000-01-09T00:00:00Z">>, StartedAt2, 1)). + +-spec check_calculate_month_shard_id_test() -> _. +check_calculate_month_shard_id_test() -> + StartedAt1 = <<"2000-01-01T00:00:00Z">>, + ?assertEqual(<<"future/month/0">>, calculate_calendar_shard_id(month, <<"2000-01-01T00:00:00Z">>, StartedAt1, 1)), + ?assertEqual(<<"past/month/1">>, calculate_calendar_shard_id(month, <<"1999-12-31T00:00:00Z">>, StartedAt1, 1)), + ?assertEqual(<<"future/month/1">>, calculate_calendar_shard_id(month, <<"2000-02-01T00:00:00Z">>, StartedAt1, 1)), + ?assertEqual(<<"future/month/1">>, calculate_calendar_shard_id(month, <<"2000-03-01T00:00:00Z">>, StartedAt1, 2)), + ?assertEqual(<<"future/month/12">>, calculate_calendar_shard_id(month, <<"2001-01-01T00:00:00Z">>, StartedAt1, 1)), + ?assertEqual(<<"future/month/1">>, calculate_calendar_shard_id(month, <<"2001-01-01T00:00:00Z">>, StartedAt1, 12)), + StartedAt2 = <<"2000-01-02T03:00:00Z">>, + ?assertEqual(<<"past/month/1">>, calculate_calendar_shard_id(month, <<"2000-01-02T00:00:00Z">>, StartedAt2, 1)), + ?assertEqual(<<"future/month/0">>, calculate_calendar_shard_id(month, <<"2000-02-02T00:00:00Z">>, StartedAt2, 1)). + +-spec check_calculate_year_shard_id_test() -> _. +check_calculate_year_shard_id_test() -> + StartedAt1 = <<"2000-01-01T00:00:00Z">>, + ?assertEqual(<<"future/year/0">>, calculate_calendar_shard_id(year, <<"2000-01-01T00:00:00Z">>, StartedAt1, 1)), + ?assertEqual(<<"past/year/1">>, calculate_calendar_shard_id(year, <<"1999-12-31T00:00:00Z">>, StartedAt1, 1)), + ?assertEqual(<<"future/year/1">>, calculate_calendar_shard_id(year, <<"2001-01-01T00:00:00Z">>, StartedAt1, 1)), + ?assertEqual(<<"future/year/1">>, calculate_calendar_shard_id(year, <<"2003-01-01T00:00:00Z">>, StartedAt1, 2)), + ?assertEqual(<<"future/year/10">>, calculate_calendar_shard_id(year, <<"2010-01-01T00:00:00Z">>, StartedAt1, 1)), + ?assertEqual(<<"future/year/2">>, calculate_calendar_shard_id(year, <<"2020-01-01T00:00:00Z">>, StartedAt1, 10)), + StartedAt2 = <<"2000-01-02T03:00:00Z">>, + ?assertEqual(<<"past/year/1">>, calculate_calendar_shard_id(year, <<"2000-01-01T00:00:00Z">>, StartedAt2, 1)), + ?assertEqual(<<"future/year/0">>, calculate_calendar_shard_id(year, <<"2001-01-01T00:00:00Z">>, StartedAt2, 1)). + +-define(PAYPROC_CTX_INVOICE(Invoice, Payment, Route), #limiter_LimitContext{ + payment_processing = #context_payproc_Context{ + op = {invoice_payment, #context_payproc_OperationInvoicePayment{}}, + invoice = #context_payproc_Invoice{ + invoice = Invoice, + payment = #context_payproc_InvoicePayment{ + payment = Payment, + route = Route + } + } + } +}). + +-define(PAYPROC_CTX_INVOICE(Invoice), + ?PAYPROC_CTX_INVOICE( + Invoice, + ?PAYMENT(?PAYER(?PAYMENT_TOOL)), + ?ROUTE(22, 2) + ) +). + +-define(INVOICE(OwnerID, ShopID), #domain_Invoice{ + id = <<"ID">>, + party_ref = #domain_PartyConfigRef{id = OwnerID}, + shop_ref = #domain_ShopConfigRef{id = ShopID}, + domain_revision = 1, + created_at = <<"2000-02-02T12:12:12Z">>, + status = {unpaid, #domain_InvoiceUnpaid{}}, + details = #domain_InvoiceDetails{product = <<>>}, + due = <<"2222-02-02T12:12:12Z">>, + cost = #domain_Cash{amount = 42, currency = #domain_CurrencyRef{symbolic_code = <<"CNY">>}} +}). + +-define(PAYMENT(Payer), #domain_InvoicePayment{ + id = <<"ID">>, + created_at = <<"2000-02-02T12:12:12Z">>, + status = {pending, #domain_InvoicePaymentPending{}}, + cost = #domain_Cash{amount = 42, currency = #domain_CurrencyRef{symbolic_code = <<"CNY">>}}, + domain_revision = 1, + flow = {instant, #domain_InvoicePaymentFlowInstant{}}, + payer = Payer +}). + +-define(PAYER(PaymentTool), + {payment_resource, #domain_PaymentResourcePayer{ + resource = #domain_DisposablePaymentResource{payment_tool = PaymentTool}, + contact_info = #domain_ContactInfo{email = <<"email">>} + }} +). + +-define(PAYMENT_TOOL, + {bank_card, #domain_BankCard{ + token = <<"token">>, + bin = <<"****">>, + last_digits = <<"last_digits">>, + exp_date = #domain_BankCardExpDate{month = 2, year = 2022} + }} +). + +-define(WITHDRAWAL_CTX(Withdrawal, WalletID, Route), #limiter_LimitContext{ + withdrawal_processing = #context_withdrawal_Context{ + op = {withdrawal, #context_withdrawal_OperationWithdrawal{}}, + withdrawal = #context_withdrawal_Withdrawal{ + withdrawal = Withdrawal, + wallet_id = WalletID, + route = Route + } + } +}). + +-define(WITHDRAWAL(OwnerID, PaymentTool), #wthd_domain_Withdrawal{ + destination = PaymentTool, + sender = #domain_PartyConfigRef{id = OwnerID}, + created_at = <<"2000-02-02T12:12:12Z">>, + body = #domain_Cash{amount = 42, currency = #domain_CurrencyRef{symbolic_code = <<"CNY">>}} +}). + +-define(ROUTE(ProviderID, TerminalID), #base_Route{ + provider = #domain_ProviderRef{id = ProviderID}, + terminal = #domain_TerminalRef{id = TerminalID} +}). + +-spec global_scope_empty_prefix_test() -> _. +global_scope_empty_prefix_test() -> + Context = #{context => ?PAYPROC_CTX_INVOICE(?INVOICE(<<"OWNER">>, <<"SHOP">>))}, + ?assertEqual({ok, {<<>>, #{}}}, mk_scope_prefix_impl(ordsets:new(), payment_processing, Context)). + +-spec preserve_scope_prefix_order_test_() -> [_TestGen]. +preserve_scope_prefix_order_test_() -> + Context = #{context => ?PAYPROC_CTX_INVOICE(?INVOICE(<<"OWNER">>, <<"SHOP">>))}, + [ + ?_assertEqual( + {ok, {<<"/OWNER/SHOP">>, #{<<"Scope.owner_id">> => <<"OWNER">>, <<"Scope.shop_id">> => <<"SHOP">>}}}, + mk_scope_prefix_impl(ordsets:from_list([shop, party]), payment_processing, Context) + ), + ?_assertEqual( + {ok, {<<"/OWNER/SHOP">>, #{<<"Scope.owner_id">> => <<"OWNER">>, <<"Scope.shop_id">> => <<"SHOP">>}}}, + mk_scope_prefix_impl(ordsets:from_list([party, shop]), payment_processing, Context) + ), + ?_assertEqual( + {ok, {<<"/OWNER/SHOP">>, #{<<"Scope.owner_id">> => <<"OWNER">>, <<"Scope.shop_id">> => <<"SHOP">>}}}, + mk_scope_prefix_impl(ordsets:from_list([shop]), payment_processing, Context) + ) + ]. + +-spec prefix_content_test_() -> [_TestGen]. +prefix_content_test_() -> + Context = #{ + context => ?PAYPROC_CTX_INVOICE( + ?INVOICE(<<"OWNER">>, <<"SHOP">>), + ?PAYMENT(?PAYER(?PAYMENT_TOOL)), + ?ROUTE(22, 2) + ) + }, + WithdrawalContext = #{ + context => ?WITHDRAWAL_CTX( + ?WITHDRAWAL(<<"OWNER">>, ?PAYMENT_TOOL), + <<"WALLET">>, + ?ROUTE(22, 2) + ) + }, + [ + ?_assertEqual( + {ok, + {<<"/terminal/22/2">>, #{ + <<"Scope.prefix">> => <<"terminal">>, + <<"Scope.provider_id">> => <<"22">>, + <<"Scope.terminal_id">> => <<"2">> + }}}, + mk_scope_prefix_impl(ordsets:from_list([terminal, provider]), payment_processing, Context) + ), + ?_assertEqual( + {ok, + {<<"/terminal/22/2">>, #{ + <<"Scope.prefix">> => <<"terminal">>, + <<"Scope.provider_id">> => <<"22">>, + <<"Scope.terminal_id">> => <<"2">> + }}}, + mk_scope_prefix_impl(ordsets:from_list([provider, terminal]), payment_processing, Context) + ), + ?_assertEqual( + {ok, + {<<"/wallet/OWNER/WALLET">>, #{ + <<"Scope.owner_id">> => <<"OWNER">>, + <<"Scope.prefix">> => <<"wallet">>, + <<"Scope.wallet_id">> => <<"WALLET">> + }}}, + mk_scope_prefix_impl(ordsets:from_list([wallet, party]), withdrawal_processing, WithdrawalContext) + ), + ?_assertEqual( + {ok, + {<<"/token/2/2022/payer_contact_email/email">>, #{ + <<"Scope.payer_contact_email">> => <<"email">>, + <<"Scope.payment_tool">> => <<"token/2/2022">>, + <<"Scope.prefix">> => <<"payer_contact_email">> + }}}, + mk_scope_prefix_impl(ordsets:from_list([payer_contact_email, payment_tool]), payment_processing, Context) + ) + ]. + +-endif. diff --git a/apps/limiter/src/lim_context.erl b/apps/limiter/src/lim_context.erl new file mode 100644 index 00000000..b1153bc1 --- /dev/null +++ b/apps/limiter/src/lim_context.erl @@ -0,0 +1,92 @@ +-module(lim_context). + +-include_lib("limiter_proto/include/limproto_limiter_thrift.hrl"). + +-export([create/1]). +-export([woody_context/1]). +-export([get_operation/2]). +-export([make_change_context/2]). +-export([get_value/3]). + +-export([set_context/2]). + +-type woody_context() :: woody_context:ctx(). +-type limit_context() :: limproto_limiter_thrift:'LimitContext'(). + +-type t() :: #{ + woody_context => woody_context(), + context => limit_context() +}. + +-type context_type() :: payment_processing | withdrawal_processing. +-type change_context() :: #{binary() => binary()}. +-type context_inner() :: lim_payproc_context:context() | lim_wthdproc_context:context(). +-type context_operation() :: lim_payproc_context:operation() | lim_wthdproc_context:operation(). + +-type unsupported_error(T) :: {unsupported, T}. +-type operation_context_not_supported_error() :: + {operation_context_not_supported, limproto_limiter_thrift:'LimitContextType'()}. +-type context_error() :: notfound | unsupported_error(_) | operation_context_not_supported_error(). + +-export_type([t/0]). +-export_type([context_type/0]). +-export_type([change_context/0]). +-export_type([context_operation/0]). +-export_type([unsupported_error/1]). +-export_type([operation_context_not_supported_error/0]). +-export_type([context_error/0]). + +-callback get_operation(context_inner()) -> {ok, context_operation()} | {error, notfound}. +-callback make_change_context(context_inner()) -> {ok, change_context()}. +-callback get_value(_Name :: atom(), context_inner()) -> {ok, term()} | {error, notfound | unsupported_error(_)}. + +-spec create(woody_context()) -> t(). +create(WoodyContext) -> + #{woody_context => WoodyContext}. + +-spec woody_context(t()) -> woody_context(). +woody_context(Context) -> + maps:get(woody_context, Context). + +-spec set_context(limit_context(), t()) -> t(). +set_context(Context, LimContext) -> + LimContext#{context => Context}. + +-spec get_operation(context_type(), t()) -> + {ok, context_operation()} | {error, notfound | operation_context_not_supported_error()}. +get_operation(Type, Context) -> + case get_operation_context(Type, Context) of + {error, _} = Error -> Error; + {ok, Mod, OperationContext} -> Mod:get_operation(OperationContext) + end. + +-spec make_change_context(context_type(), t()) -> + {ok, change_context()} | {error, operation_context_not_supported_error()}. +make_change_context(Type, Context) -> + case get_operation_context(Type, Context) of + {error, _} = Error -> Error; + {ok, Mod, OperationContext} -> Mod:make_change_context(OperationContext) + end. + +-spec get_value(context_type(), atom(), t()) -> {ok, term()} | {error, context_error()}. +get_value(Type, ValueName, Context) -> + case get_operation_context(Type, Context) of + {error, _} = Error -> Error; + {ok, Mod, OperationContext} -> Mod:get_value(ValueName, OperationContext) + end. + +get_operation_context(payment_processing, #{context := #limiter_LimitContext{payment_processing = undefined}}) -> + {error, + {operation_context_not_supported, {withdrawal_processing, #limiter_LimitContextTypeWithdrawalProcessing{}}}}; +get_operation_context( + payment_processing, + #{context := #limiter_LimitContext{payment_processing = PayprocContext}} +) -> + {ok, lim_payproc_context, PayprocContext}; +get_operation_context(withdrawal_processing, #{context := #limiter_LimitContext{withdrawal_processing = undefined}}) -> + {error, {operation_context_not_supported, {payment_processing, #limiter_LimitContextTypePaymentProcessing{}}}}; +get_operation_context( + withdrawal_processing, + #{context := #limiter_LimitContext{withdrawal_processing = WithdrawalContext}} +) -> + {ok, lim_wthdproc_context, WithdrawalContext}. diff --git a/apps/limiter/src/lim_context_utils.erl b/apps/limiter/src/lim_context_utils.erl new file mode 100644 index 00000000..a07363d5 --- /dev/null +++ b/apps/limiter/src/lim_context_utils.erl @@ -0,0 +1,50 @@ +-module(lim_context_utils). + +-include_lib("limiter_proto/include/limproto_base_thrift.hrl"). +-include_lib("damsel/include/dmsl_domain_thrift.hrl"). + +-export([route_provider_id/1]). +-export([route_terminal_id/1]). +-export([base61_hash/1]). +-export([decode_content/2]). +-export([get_field_by_path/2]). + +-type provider_id() :: binary(). +-type terminal_id() :: binary(). + +%% + +-spec route_provider_id(limproto_base_thrift:'Route'()) -> + {ok, provider_id()}. +route_provider_id(#base_Route{provider = #domain_ProviderRef{id = ID}}) -> + {ok, genlib:to_binary(ID)}. + +-spec route_terminal_id(limproto_base_thrift:'Route'()) -> + {ok, terminal_id()}. +route_terminal_id(#base_Route{terminal = #domain_TerminalRef{id = ID}}) -> + {ok, genlib:to_binary(ID)}. + +-spec base61_hash(iolist() | binary()) -> binary(). +base61_hash(IOList) -> + <> = crypto:hash(sha, IOList), + genlib_format:format_int_base(I, 61). + +-spec decode_content(Type, binary()) -> + {ok, map()} | {error, {unsupported, Type}} +when + Type :: binary(). +decode_content(<<"application/schema-instance+json; schema=", _/binary>>, Data) -> + {ok, jsx:decode(Data)}; +decode_content(<<"application/json">>, Data) -> + {ok, jsx:decode(Data)}; +decode_content(Type, _Data) -> + {error, {unsupported, Type}}. + +-spec get_field_by_path([binary()], map()) -> {ok, map() | binary() | number()} | {error, notfound}. +get_field_by_path([], Data) -> + {ok, Data}; +get_field_by_path([Key | Path], Data) -> + case maps:get(Key, Data, undefined) of + undefined -> {error, notfound}; + Value -> get_field_by_path(Path, Value) + end. diff --git a/apps/limiter/src/lim_handler.erl b/apps/limiter/src/lim_handler.erl new file mode 100644 index 00000000..045dbfe4 --- /dev/null +++ b/apps/limiter/src/lim_handler.erl @@ -0,0 +1,189 @@ +-module(lim_handler). + +-include_lib("limiter_proto/include/limproto_limiter_thrift.hrl"). +-include_lib("limiter_proto/include/limproto_base_thrift.hrl"). +-include_lib("damsel/include/dmsl_base_thrift.hrl"). +-include_lib("liminator_proto/include/liminator_liminator_thrift.hrl"). + +%% Woody handler + +-behaviour(woody_server_thrift_handler). + +-export([handle_function/4]). + +%% + +-type lim_context() :: lim_context:t(). + +-define(LIMIT_REQUEST(ID, Changes), #limiter_LimitRequest{operation_id = ID, limit_changes = Changes}). + +%% + +-spec handle_function(woody:func(), woody:args(), woody_context:ctx(), woody:options()) -> {ok, woody:result()}. +handle_function(Fn, Args, WoodyCtx, Opts) -> + LimitContext = lim_context:create(WoodyCtx), + scoper:scope( + limiter, + fun() -> handle_function_(Fn, Args, LimitContext, Opts) end + ). + +-spec handle_function_(woody:func(), woody:args(), lim_context(), woody:options()) -> {ok, woody:result()}. +handle_function_('GetValues', {?LIMIT_REQUEST(OperationID, Changes), Context}, LimitContext, _Opts) -> + scoper:add_meta(#{operation_id => OperationID}), + case + lim_config_machine:get_values( + Changes, + lim_context:set_context(Context, LimitContext) + ) + of + {ok, Responses} -> + {ok, convert_responses(Responses)}; + {error, Error} -> + handle_get_error(Error) + end; +handle_function_('GetBatch', {?LIMIT_REQUEST(OperationID, Changes), Context}, LimitContext, _Opts) -> + scoper:add_meta(#{operation_id => OperationID}), + case + lim_config_machine:get_batch( + OperationID, + Changes, + lim_context:set_context(Context, LimitContext) + ) + of + {ok, Responses} -> + {ok, convert_responses(Responses)}; + {error, Error} -> + handle_get_error(Error) + end; +handle_function_('HoldBatch', {?LIMIT_REQUEST(OperationID, Changes), Context}, LimitContext, _Opts) -> + scoper:add_meta(#{operation_id => OperationID}), + case + lim_config_machine:hold_batch( + OperationID, + Changes, + lim_context:set_context(Context, LimitContext) + ) + of + {ok, Responses} -> + {ok, convert_responses(Responses)}; + {error, Error} -> + handle_hold_error(Error) + end; +handle_function_('CommitBatch', {?LIMIT_REQUEST(OperationID, Changes), Context}, LimitContext, _Opts) -> + scoper:add_meta(#{operation_id => OperationID}), + case + lim_config_machine:commit_batch( + OperationID, + Changes, + lim_context:set_context(Context, LimitContext) + ) + of + ok -> + {ok, ok}; + {error, Error} -> + handle_commit_error(Error) + end; +handle_function_('RollbackBatch', {?LIMIT_REQUEST(OperationID, Changes), Context}, LimitContext, _Opts) -> + scoper:add_meta(#{operation_id => OperationID}), + case + lim_config_machine:rollback_batch( + OperationID, + Changes, + lim_context:set_context(Context, LimitContext) + ) + of + ok -> + {ok, ok}; + {error, Error} -> + handle_rollback_error(Error) + end. + +convert_responses([]) -> + []; +convert_responses([Response | Other]) -> + [convert_response(Response) | convert_responses(Other)]. + +convert_response(#liminator_LimitResponse{ + limit_id = LimitID, + total_value = Value +}) -> + #limiter_Limit{ + id = LimitID, + amount = Value + }. + +-spec handle_get_error(_) -> no_return(). +handle_get_error(Error) -> + handle_default_error(Error). + +-spec handle_hold_error(_) -> no_return(). +handle_hold_error({_, {invalid_request, Errors}}) -> + woody_error:raise(business, #base_InvalidRequest{errors = Errors}); +handle_hold_error(Error) -> + handle_business_error(Error). + +-spec handle_business_error(_) -> no_return(). +handle_business_error({_, {invalid_operation_currency, {Currency, ExpectedCurrency}}}) -> + woody_error:raise(business, #limiter_InvalidOperationCurrency{ + currency = Currency, + expected_currency = ExpectedCurrency + }); +handle_business_error({_, {operation_context_not_supported, ContextType}}) -> + woody_error:raise(business, #limiter_OperationContextNotSupported{ + context_type = ContextType + }); +handle_business_error({_, {unsupported, {payment_tool, Type}}}) -> + woody_error:raise(business, #limiter_PaymentToolNotSupported{ + payment_tool = atom_to_binary(Type) + }); +handle_business_error(Error) -> + handle_default_error(Error). + +-spec handle_commit_error(_) -> no_return(). +handle_commit_error({_, {forbidden_operation_amount, Error}}) -> + handle_forbidden_operation_amount_error(Error); +handle_commit_error({_, {invalid_request, Errors}}) -> + woody_error:raise(business, #base_InvalidRequest{errors = Errors}); +handle_commit_error(Error) -> + handle_default_error(Error). + +-spec handle_rollback_error(_) -> no_return(). +handle_rollback_error({_, {invalid_request, Errors}}) -> + woody_error:raise(business, #base_InvalidRequest{errors = Errors}); +handle_rollback_error(Error) -> + handle_business_error(Error). + +-spec handle_default_error(_) -> no_return(). +handle_default_error({config, notfound}) -> + woody_error:raise(business, #limiter_LimitNotFound{}); +handle_default_error(Error) -> + handle_unknown_error(Error). + +-spec handle_unknown_error(_) -> no_return(). +handle_unknown_error(Error) -> + erlang:error({unknown_error, Error}). + +-spec handle_forbidden_operation_amount_error(_) -> no_return(). +handle_forbidden_operation_amount_error(#{ + type := Type, + partial := Partial, + full := Full +}) -> + case Type of + positive -> + woody_error:raise(business, #limiter_ForbiddenOperationAmount{ + amount = Partial, + allowed_range = #base_AmountRange{ + upper = {inclusive, Full}, + lower = {inclusive, 0} + } + }); + negative -> + woody_error:raise(business, #limiter_ForbiddenOperationAmount{ + amount = Partial, + allowed_range = #base_AmountRange{ + upper = {inclusive, 0}, + lower = {inclusive, Full} + } + }) + end. diff --git a/apps/limiter/src/lim_liminator.erl b/apps/limiter/src/lim_liminator.erl new file mode 100644 index 00000000..da65dd17 --- /dev/null +++ b/apps/limiter/src/lim_liminator.erl @@ -0,0 +1,93 @@ +-module(lim_liminator). + +-include_lib("liminator_proto/include/liminator_liminator_thrift.hrl"). + +-export([get_name/1]). +-export([construct_change/4]). + +-export([get_values/2]). +-export([get/3]). +-export([hold/3]). +-export([commit/3]). +-export([rollback/3]). + +-type operation_id() :: liminator_liminator_thrift:'OperationId'(). +-type limit_change() :: liminator_liminator_thrift:'LimitChange'(). +-type limit_name() :: liminator_liminator_thrift:'LimitName'(). +-type limit_id() :: liminator_liminator_thrift:'LimitId'(). +-type change_context() :: liminator_liminator_thrift:'Context'(). +-type limit_response() :: liminator_liminator_thrift:'LimitResponse'(). +-type amount() :: liminator_liminator_thrift:'Value'(). +-type lim_context() :: lim_context:t(). + +-type invalid_request_error() :: {invalid_request, list(binary())}. + +-export_type([amount/0]). +-export_type([invalid_request_error/0]). +-export_type([limit_response/0]). +-export_type([limit_change/0]). +-export_type([change_context/0]). + +-spec construct_change(limit_id(), limit_name(), amount(), change_context()) -> limit_change(). +construct_change(ID, Name, Value, ChangeContext) -> + #liminator_LimitChange{ + limit_id = ID, + limit_name = Name, + value = Value, + context = ChangeContext + }. + +-spec get_name(limit_change()) -> limit_name(). +get_name(#liminator_LimitChange{limit_name = Name}) -> + Name. + +-spec get_values([limit_name()], lim_context()) -> + {ok, [limit_response()]} | {error, invalid_request_error()}. +get_values(Names, LimitContext) -> + do('GetLastLimitsValues', Names, LimitContext). + +-spec get(operation_id(), [limit_change()], lim_context()) -> + {ok, [limit_response()]} | {error, invalid_request_error()}. +get(OperationID, Changes, LimitContext) -> + do('Get', #liminator_LimitRequest{operation_id = OperationID, limit_changes = Changes}, LimitContext). + +-spec hold(operation_id(), [limit_change()], lim_context()) -> + {ok, [limit_response()]} | {error, invalid_request_error()}. +hold(OperationID, Changes, LimitContext) -> + do('Hold', #liminator_LimitRequest{operation_id = OperationID, limit_changes = Changes}, LimitContext). + +-spec commit(operation_id(), [limit_change()], lim_context()) -> {ok, ok} | {error, invalid_request_error()}. +commit(OperationID, Changes, LimitContext) -> + do('Commit', #liminator_LimitRequest{operation_id = OperationID, limit_changes = Changes}, LimitContext). + +-spec rollback(operation_id(), [limit_change()], lim_context()) -> {ok, ok} | {error, invalid_request_error()}. +rollback(OperationID, Changes, LimitContext) -> + do('Rollback', #liminator_LimitRequest{operation_id = OperationID, limit_changes = Changes}, LimitContext). + +do(Op, Arg, LimitContext) -> + case call(Op, {Arg}, LimitContext) of + {ok, Result} -> + {ok, Result}; + {exception, Exception} -> + {error, {invalid_request, convert_exception(Exception)}} + end. + +%% + +-spec call(woody:func(), woody:args(), lim_context()) -> woody:result(). +call(Function, Args, LimitContext) -> + WoodyContext = lim_context:woody_context(LimitContext), + lim_client_woody:call(liminator, Function, Args, WoodyContext). + +convert_exception(#liminator_OperationNotFound{}) -> + [<<"OperationNotFound">>]; +convert_exception(#liminator_LimitNotFound{}) -> + [<<"LimitNotFound">>]; +convert_exception(#liminator_OperationAlreadyInFinalState{}) -> + [<<"OperationAlreadyInFinalState">>]; +convert_exception(#liminator_DuplicateOperation{}) -> + [<<"DuplicateOperation">>]; +convert_exception(#liminator_DuplicateLimitName{}) -> + [<<"DuplicateLimitName">>]; +convert_exception(#liminator_LimitsValuesReadingException{}) -> + [<<"LimitsValuesReadingException">>]. diff --git a/apps/limiter/src/lim_maybe.erl b/apps/limiter/src/lim_maybe.erl new file mode 100644 index 00000000..a14b143d --- /dev/null +++ b/apps/limiter/src/lim_maybe.erl @@ -0,0 +1,53 @@ +%%% +%%% Call me maybe +%%% + +-module(lim_maybe). + +-type 'maybe'(T) :: + undefined | T. + +-export_type(['maybe'/1]). + +-export([from_result/1]). +-export([to_list/1]). +-export([apply/2]). +-export([apply/3]). +-export([get_defined/1]). +-export([get_defined/2]). + +%% + +-spec from_result({ok, T} | {error, _}) -> 'maybe'(T). +from_result({ok, T}) -> + T; +from_result({error, _}) -> + undefined. + +-spec to_list('maybe'(T)) -> [T]. +to_list(undefined) -> + []; +to_list(T) -> + [T]. + +-spec apply(fun(), Arg :: undefined | term()) -> term(). +apply(Fun, Arg) -> + lim_maybe:apply(Fun, Arg, undefined). + +-spec apply(fun(), Arg :: undefined | term(), Default :: term()) -> term(). +apply(Fun, Arg, _Default) when Arg =/= undefined -> + Fun(Arg); +apply(_Fun, undefined, Default) -> + Default. + +-spec get_defined(['maybe'(T)]) -> T. +get_defined([]) -> + erlang:error(badarg); +get_defined([Value | _Tail]) when Value =/= undefined -> + Value; +get_defined([undefined | Tail]) -> + get_defined(Tail). + +-spec get_defined('maybe'(T), 'maybe'(T)) -> T. +get_defined(V1, V2) -> + get_defined([V1, V2]). diff --git a/apps/limiter/src/lim_payproc_context.erl b/apps/limiter/src/lim_payproc_context.erl new file mode 100644 index 00000000..9a056953 --- /dev/null +++ b/apps/limiter/src/lim_payproc_context.erl @@ -0,0 +1,326 @@ +-module(lim_payproc_context). + +-include_lib("limiter_proto/include/limproto_context_payproc_thrift.hrl"). +-include_lib("limiter_proto/include/limproto_base_thrift.hrl"). +-include_lib("damsel/include/dmsl_domain_thrift.hrl"). + +-behaviour(lim_context). +-export([get_operation/1]). +-export([make_change_context/1]). +-export([get_value/2]). + +-type context() :: limproto_context_payproc_thrift:'Context'(). + +-type operation() :: + invoice + | invoice_payment + | invoice_payment_adjustment + | invoice_payment_refund + | invoice_payment_chargeback. + +-export_type([operation/0]). +-export_type([context/0]). + +%% + +-spec get_operation(context()) -> {ok, operation()} | {error, notfound}. +get_operation(#context_payproc_Context{op = {Operation, _}}) -> + {ok, Operation}; +get_operation(#context_payproc_Context{op = undefined}) -> + {error, notfound}. + +-spec make_change_context(context()) -> {ok, lim_context:change_context()}. +make_change_context(#context_payproc_Context{op = undefined}) -> + {ok, #{}}; +make_change_context( + #context_payproc_Context{ + op = {Operation, _} + } = Context +) -> + {ok, + genlib_map:compact(#{ + <<"Context.op">> => genlib:to_binary(Operation), + <<"Context.owner_id">> => try_get_value(owner_id, Context, undefined), + <<"Context.shop_id">> => try_get_value(shop_id, Context, undefined) + })}. + +-spec get_value(atom(), context()) -> {ok, term()} | {error, notfound | {unsupported, _}}. +get_value(ValueName, Context) -> + case get_operation(Context) of + {ok, Operation} -> + get_value(ValueName, Operation, Context); + {error, _} = Error -> + Error + end. + +try_get_value(ValueName, Context, Default) -> + case get_operation(Context) of + {ok, Operation} -> + case get_value(ValueName, Operation, Context) of + {ok, Value} -> + Value; + {error, _} -> + Default + end; + {error, _} -> + Default + end. + +get_value(owner_id, _Operation, Context) -> + get_owner_id(Context); +get_value(shop_id, _Operation, Context) -> + get_shop_id(Context); +get_value(created_at, Operation, Context) -> + get_created_at(Operation, Context); +get_value(cost, Operation, Context) -> + get_cost(Operation, Context); +get_value(capture_cost, Operation, Context) -> + get_capture_cost(Operation, Context); +get_value(payment_tool, Operation, Context) -> + get_payment_tool(Operation, Context); +get_value(provider_id, Operation, Context) -> + get_provider_id(Operation, Context); +get_value(terminal_id, Operation, Context) -> + get_terminal_id(Operation, Context); +get_value(session, Operation, Context) -> + get_session(Operation, Context); +get_value(payer_contact_email, Operation, Context) -> + get_payer_contact_email(Operation, Context); +get_value(sender, Operation, Context) -> + get_destination_sender(Operation, Context); +get_value(receiver, Operation, Context) -> + get_destination_receiver(Operation, Context); +get_value(ValueName, _Operation, _Context) -> + {error, {unsupported, ValueName}}. + +%% + +-define(INVOICE(V), #context_payproc_Context{ + invoice = #context_payproc_Invoice{ + invoice = V = #domain_Invoice{} + } +}). + +-define(INVOICE_PAYMENT(V), #context_payproc_Context{ + invoice = #context_payproc_Invoice{ + payment = #context_payproc_InvoicePayment{payment = V = #domain_InvoicePayment{}} + } +}). + +-define(INVOICE_PAYMENT_ADJUSTMENT(V), #context_payproc_Context{ + invoice = #context_payproc_Invoice{ + payment = #context_payproc_InvoicePayment{adjustment = V = #domain_InvoicePaymentAdjustment{}} + } +}). + +-define(INVOICE_PAYMENT_REFUND(V), #context_payproc_Context{ + invoice = #context_payproc_Invoice{ + payment = #context_payproc_InvoicePayment{refund = V = #domain_InvoicePaymentRefund{}} + } +}). + +-define(INVOICE_PAYMENT_CHARGEBACK(V), #context_payproc_Context{ + invoice = #context_payproc_Invoice{ + payment = #context_payproc_InvoicePayment{chargeback = V = #domain_InvoicePaymentChargeback{}} + } +}). + +-define(INVOICE_PAYMENT_ROUTE(V), #context_payproc_Context{ + invoice = #context_payproc_Invoice{ + payment = #context_payproc_InvoicePayment{route = V = #base_Route{}} + } +}). + +-define(INVOICE_PAYMENT_SESSION(V), #context_payproc_Context{ + invoice = #context_payproc_Invoice{ + session = V + } +}). + +get_owner_id(?INVOICE(Invoice)) -> + {ok, Invoice#domain_Invoice.party_ref#domain_PartyConfigRef.id}; +get_owner_id(_) -> + {error, notfound}. + +get_shop_id(?INVOICE(Invoice)) -> + {ok, Invoice#domain_Invoice.shop_ref#domain_ShopConfigRef.id}; +get_shop_id(_) -> + {error, notfound}. + +get_created_at(invoice, ?INVOICE(Invoice)) -> + {ok, Invoice#domain_Invoice.created_at}; +get_created_at(invoice_payment, ?INVOICE_PAYMENT(Payment)) -> + {ok, Payment#domain_InvoicePayment.created_at}; +get_created_at(invoice_payment_adjustment, ?INVOICE_PAYMENT_ADJUSTMENT(Adjustment)) -> + {ok, Adjustment#domain_InvoicePaymentAdjustment.created_at}; +get_created_at(invoice_payment_refund, ?INVOICE_PAYMENT_REFUND(Refund)) -> + {ok, Refund#domain_InvoicePaymentRefund.created_at}; +get_created_at(invoice_payment_chargeback, ?INVOICE_PAYMENT_CHARGEBACK(Chargeback)) -> + {ok, Chargeback#domain_InvoicePaymentChargeback.created_at}; +get_created_at(_, _CtxInvoice) -> + {error, notfound}. + +get_cost(invoice, ?INVOICE(Invoice)) -> + lim_payproc_utils:cash(Invoice#domain_Invoice.cost); +get_cost(invoice_payment, ?INVOICE_PAYMENT(Payment)) -> + lim_payproc_utils:cash(Payment#domain_InvoicePayment.cost); +get_cost(invoice_payment_refund, ?INVOICE_PAYMENT_REFUND(Refund)) -> + lim_payproc_utils:cash(Refund#domain_InvoicePaymentRefund.cash); +get_cost(invoice_payment_chargeback, ?INVOICE_PAYMENT_CHARGEBACK(Chargeback)) -> + lim_payproc_utils:cash(Chargeback#domain_InvoicePaymentChargeback.body); +get_cost(_, _CtxInvoice) -> + {error, notfound}. + +get_capture_cost(invoice_payment, ?INVOICE_PAYMENT(Payment)) -> + get_capture_cost(Payment#domain_InvoicePayment.status); +get_capture_cost(_, _CtxInvoice) -> + {error, notfound}. + +get_capture_cost({captured, #domain_InvoicePaymentCaptured{cost = Cost}}) when Cost /= undefined -> + lim_payproc_utils:cash(Cost); +get_capture_cost({_Status, _}) -> + {error, notfound}. + +get_payment_tool(Operation, ?INVOICE_PAYMENT(Payment)) when + Operation == invoice_payment; + Operation == invoice_payment_adjustment; + Operation == invoice_payment_refund; + Operation == invoice_payment_chargeback +-> + {_Type, Payer} = Payment#domain_InvoicePayment.payer, + get_payer_payment_tool(Payer); +get_payment_tool(_, _CtxInvoice) -> + {error, notfound}. + +get_payer_payment_tool(#domain_PaymentResourcePayer{resource = #domain_DisposablePaymentResource{payment_tool = PT}}) -> + lim_payproc_utils:payment_tool(PT); +get_payer_payment_tool(#domain_RecurrentPayer{payment_tool = PT}) -> + lim_payproc_utils:payment_tool(PT). + +get_provider_id(Operation, ?INVOICE_PAYMENT_ROUTE(Route)) when + Operation == invoice_payment; + Operation == invoice_payment_adjustment; + Operation == invoice_payment_refund; + Operation == invoice_payment_chargeback +-> + lim_context_utils:route_provider_id(Route); +get_provider_id(_, _CtxInvoice) -> + {error, notfound}. + +get_terminal_id(Operation, ?INVOICE_PAYMENT_ROUTE(Route)) when + Operation == invoice_payment; + Operation == invoice_payment_adjustment; + Operation == invoice_payment_refund; + Operation == invoice_payment_chargeback +-> + lim_context_utils:route_terminal_id(Route); +get_terminal_id(_, _CtxInvoice) -> + {error, notfound}. + +get_session(invoice_payment, ?INVOICE_PAYMENT_SESSION(Session)) -> + {ok, Session}; +get_session(_, _CtxInvoice) -> + {error, notfound}. + +get_payer_contact_email(Operation, ?INVOICE_PAYMENT(Payment)) when + Operation == invoice_payment; + Operation == invoice_payment_adjustment; + Operation == invoice_payment_refund; + Operation == invoice_payment_chargeback +-> + {_Type, Payer} = Payment#domain_InvoicePayment.payer, + CI = get_payer_contact_info(Payer), + {ok, string:lowercase(CI#domain_ContactInfo.email)}; +get_payer_contact_email(_, _CtxInvoice) -> + {error, notfound}. + +get_payer_contact_info(#domain_PaymentResourcePayer{contact_info = CI}) -> + CI; +get_payer_contact_info(#domain_RecurrentPayer{contact_info = CI}) -> + CI. + +get_destination_sender(_, _CtxWithdrawal) -> + {error, notfound}. + +get_destination_receiver(_, _CtxWithdrawal) -> + {error, notfound}. + +-ifdef(TEST). +-include_lib("eunit/include/eunit.hrl"). + +-spec test() -> _. + +-define(PAYMENT_W_PAYER(Payer), #domain_InvoicePayment{ + id = <<"ID">>, + created_at = <<"2000-02-02T12:12:12Z">>, + status = {pending, #domain_InvoicePaymentPending{}}, + cost = #domain_Cash{ + amount = 42, + currency = #domain_CurrencyRef{symbolic_code = <<"CNY">>} + }, + domain_revision = 42, + flow = {instant, #domain_InvoicePaymentFlowInstant{}}, + payer = Payer +}). + +-define(CONTEXT_PAYMENT(Payment), #context_payproc_Context{ + op = {invoice_payment, #context_payproc_OperationInvoicePayment{}}, + invoice = #context_payproc_Invoice{ + payment = #context_payproc_InvoicePayment{ + payment = Payment + } + } +}). + +-spec get_payment_tool_test_() -> [_TestGen]. +get_payment_tool_test_() -> + PaymentTool = + {bank_card, #domain_BankCard{ + token = <<"Token">>, + bin = <<"654321">>, + exp_date = #domain_BankCardExpDate{month = 2, year = 2022}, + last_digits = <<"1234">> + }}, + PaymentResourcePayer = + {payment_resource, #domain_PaymentResourcePayer{ + resource = #domain_DisposablePaymentResource{payment_tool = PaymentTool}, + contact_info = #domain_ContactInfo{} + }}, + RecurrentPayer = + {recurrent, #domain_RecurrentPayer{ + payment_tool = PaymentTool, + recurrent_parent = #domain_RecurrentParentPayment{ + invoice_id = <<"invoice_id">>, + payment_id = <<"payment_id">> + }, + contact_info = #domain_ContactInfo{} + }}, + ExpectedValue = {bank_card, #{token => <<"Token">>, exp_date => {2, 2022}}}, + [ + ?_assertEqual( + {ok, ExpectedValue}, + get_value(payment_tool, ?CONTEXT_PAYMENT(?PAYMENT_W_PAYER(PaymentResourcePayer))) + ), + ?_assertEqual( + {ok, ExpectedValue}, + get_value(payment_tool, ?CONTEXT_PAYMENT(?PAYMENT_W_PAYER(RecurrentPayer))) + ) + ]. + +-spec get_payment_tool_unsupported_test_() -> _TestGen. +get_payment_tool_unsupported_test_() -> + Payer = + {recurrent, #domain_RecurrentPayer{ + payment_tool = {payment_terminal, #domain_PaymentTerminal{}}, + recurrent_parent = #domain_RecurrentParentPayment{ + invoice_id = <<"invoice_id">>, + payment_id = <<"payment_id">> + }, + contact_info = #domain_ContactInfo{} + }}, + ?_assertEqual( + {error, {unsupported, {payment_tool, payment_terminal}}}, + get_value(payment_tool, ?CONTEXT_PAYMENT(?PAYMENT_W_PAYER(Payer))) + ). + +-endif. diff --git a/apps/limiter/src/lim_payproc_utils.erl b/apps/limiter/src/lim_payproc_utils.erl new file mode 100644 index 00000000..8034d13c --- /dev/null +++ b/apps/limiter/src/lim_payproc_utils.erl @@ -0,0 +1,68 @@ +-module(lim_payproc_utils). + +-include_lib("damsel/include/dmsl_domain_thrift.hrl"). +-include_lib("damsel/include/dmsl_base_thrift.hrl"). + +-export([cash/1]). +-export([payment_tool/1]). + +-type cash() :: + lim_body:cash(). + +-type payment_tool() :: + {bank_card, #{ + token := binary(), + exp_date := {1..12, 2000..9999} | undefined + }} + | {digital_wallet, #{ + id := binary(), + service := binary() + }} + | {generic, #{ + service := binary(), + data := #{ + type := binary(), + data := binary() + } + }}. + +%% + +-spec cash(dmsl_domain_thrift:'Cash'()) -> + {ok, cash()}. +cash(#domain_Cash{amount = Amount, currency = #domain_CurrencyRef{symbolic_code = Currency}}) -> + {ok, #{amount => Amount, currency => Currency}}. + +-spec payment_tool(dmsl_domain_thrift:'PaymentTool'()) -> + {ok, payment_tool()} | {error, {unsupported, _}}. +payment_tool({bank_card, BC}) -> + {ok, + {bank_card, #{ + token => BC#domain_BankCard.token, + exp_date => get_bank_card_expdate(BC#domain_BankCard.exp_date) + }}}; +payment_tool({digital_wallet, DW}) -> + {ok, + {digital_wallet, #{ + id => DW#domain_DigitalWallet.id, + service => DW#domain_DigitalWallet.payment_service#domain_PaymentServiceRef.id + }}}; +payment_tool({generic, G}) -> + %% TODO Move to codec into marshal/unmarshal clauses + Content = G#domain_GenericPaymentTool.data, + {ok, + {generic, #{ + service => G#domain_GenericPaymentTool.payment_service#domain_PaymentServiceRef.id, + data => #{ + %% TODO Content decoding + type => Content#base_Content.type, + data => Content#base_Content.data + } + }}}; +payment_tool({Type, _}) -> + {error, {unsupported, {payment_tool, Type}}}. + +get_bank_card_expdate(#domain_BankCardExpDate{month = Month, year = Year}) -> + {Month, Year}; +get_bank_card_expdate(undefined) -> + undefined. diff --git a/apps/limiter/src/lim_pipeline.erl b/apps/limiter/src/lim_pipeline.erl new file mode 100644 index 00000000..707b99a4 --- /dev/null +++ b/apps/limiter/src/lim_pipeline.erl @@ -0,0 +1,74 @@ +%%% +%%% Pipeline +%%% + +-module(lim_pipeline). + +-export([do/1]). +-export([do/2]). +-export([unwrap/1]). +-export([unwrap/2]). +-export([expect/2]). +-export([valid/2]). + +%% + +-type thrown(_E) :: + no_return(). + +-type result(T, E) :: + {ok, T} | {error, E}. + +-spec do(fun(() -> ok | T | thrown(E))) -> ok | result(T, E). +do(Fun) -> + try Fun() of + ok -> + ok; + R -> + {ok, R} + catch + throw:Thrown -> {error, Thrown} + end. + +-spec do(Tag, fun(() -> ok | T | thrown(E))) -> ok | result(T, {Tag, E}). +do(Tag, Fun) -> + do(fun() -> unwrap(Tag, do(Fun)) end). + +-spec unwrap + (ok) -> ok; + ({ok, V}) -> V; + ({error, E}) -> thrown(E). +unwrap(ok) -> + ok; +unwrap({ok, V}) -> + V; +unwrap({error, E}) -> + throw(E). + +-spec expect + (_E, ok) -> ok; + (_E, {ok, V}) -> V; + (E, {error, _}) -> thrown(E). +expect(_, ok) -> + ok; +expect(_, {ok, V}) -> + V; +expect(E, {error, _}) -> + throw(E). + +-spec unwrap + (_Tag, ok) -> ok; + (_Tag, {ok, V}) -> V; + (Tag, {error, E}) -> thrown({Tag, E}). +unwrap(_, ok) -> + ok; +unwrap(_, {ok, V}) -> + V; +unwrap(Tag, {error, E}) -> + throw({Tag, E}). + +-spec valid(T, T) -> ok | {error, T}. +valid(V, V) -> + ok; +valid(_, V) -> + {error, V}. diff --git a/apps/limiter/src/lim_proto_utils.erl b/apps/limiter/src/lim_proto_utils.erl new file mode 100644 index 00000000..ec7d2c99 --- /dev/null +++ b/apps/limiter/src/lim_proto_utils.erl @@ -0,0 +1,67 @@ +-module(lim_proto_utils). + +-export([serialize/2]). +-export([deserialize/2]). + +-type thrift_type() :: + thrift_base_type() + | thrift_collection_type() + | thrift_enum_type() + | thrift_struct_type(). + +-type thrift_base_type() :: + bool + | double + | i8 + | i16 + | i32 + | i64 + | string. + +-type thrift_collection_type() :: + {list, thrift_type()} + | {set, thrift_type()} + | {map, thrift_type(), thrift_type()}. + +-type thrift_enum_type() :: + {enum, thrift_type_ref()}. + +-type thrift_struct_type() :: + {struct, thrift_struct_flavor(), thrift_type_ref() | thrift_struct_def()}. + +-type thrift_struct_flavor() :: struct | union | exception. + +-type thrift_type_ref() :: {module(), Name :: atom()}. + +-type thrift_struct_def() :: list({ + Tag :: pos_integer(), + Requireness :: required | optional | undefined, + Type :: thrift_struct_type(), + Name :: atom(), + Default :: any() +}). + +-spec serialize(thrift_type(), term()) -> binary(). +serialize(Type, Data) -> + Codec0 = thrift_strict_binary_codec:new(), + case thrift_strict_binary_codec:write(Codec0, Type, Data) of + {ok, Codec1} -> + thrift_strict_binary_codec:close(Codec1); + {error, Reason} -> + erlang:error({thrift, {protocol, Reason}}) + end. + +-spec deserialize(thrift_type(), binary()) -> term(). +deserialize(Type, Data) -> + Codec0 = thrift_strict_binary_codec:new(Data), + case thrift_strict_binary_codec:read(Codec0, Type) of + {ok, Result, Codec1} -> + case thrift_strict_binary_codec:close(Codec1) of + <<>> -> + Result; + Leftovers -> + erlang:error({thrift, {protocol, {excess_binary_data, Leftovers}}}) + end; + {error, Reason} -> + erlang:error({thrift, {protocol, Reason}}) + end. diff --git a/apps/limiter/src/lim_rates.erl b/apps/limiter/src/lim_rates.erl new file mode 100644 index 00000000..bc02f631 --- /dev/null +++ b/apps/limiter/src/lim_rates.erl @@ -0,0 +1,65 @@ +-module(lim_rates). + +-include_lib("xrates_proto/include/xrates_rate_thrift.hrl"). + +-export([convert/4]). + +-type limit_context() :: lim_context:t(). +-type config() :: lim_config_machine:config(). + +-type conversion_error() :: quote_not_found | currency_not_found. + +-export_type([conversion_error/0]). + +-define(APP, limiter). +-define(DEFAULT_FACTOR, 1.1). +-define(DEFAULT_FACTOR_NAME, <<"DEFAULT">>). + +-spec convert(lim_body:cash(), lim_body:currency(), config(), limit_context()) -> + {ok, lim_body:cash()} | {error, conversion_error() | lim_context:context_error()}. +convert(#{amount := Amount, currency := Currency}, DestinationCurrency, Config, LimitContext) -> + ContextType = lim_config_machine:context_type(Config), + case lim_context:get_value(ContextType, created_at, LimitContext) of + {ok, Timestamp} -> do_convert(Timestamp, Amount, Currency, DestinationCurrency, LimitContext); + {error, _} = Error -> Error + end. + +do_convert(Timestamp, Amount, Currency, DestinationCurrency, LimitContext) -> + Request = #rate_ConversionRequest{ + source = Currency, + destination = DestinationCurrency, + amount = Amount, + datetime = Timestamp + }, + case call_rates('GetConvertedAmount', {<<"CBR">>, Request}, LimitContext) of + {ok, #base_Rational{p = P, q = Q}} -> + Rational = genlib_rational:new(P, Q), + Factor = get_exchange_factor(Currency), + {ok, #{ + amount => genlib_rational:round(genlib_rational:mul(Rational, Factor)), + currency => DestinationCurrency + }}; + {exception, #rate_QuoteNotFound{}} -> + {error, quote_not_found}; + {exception, #rate_CurrencyNotFound{}} -> + {error, currency_not_found} + end. + +get_exchange_factor(Currency) -> + Factors = genlib_app:env(?APP, exchange_factors, #{}), + case maps:get(Currency, Factors, undefined) of + undefined -> + case maps:get(?DEFAULT_FACTOR_NAME, Factors, undefined) of + undefined -> + ?DEFAULT_FACTOR; + DefaultFactor -> + DefaultFactor + end; + Factor -> + Factor + end. + +%% + +call_rates(Function, Args, LimitContext) -> + lim_client_woody:call(xrates, Function, Args, lim_context:woody_context(LimitContext)). diff --git a/apps/limiter/src/lim_router.erl b/apps/limiter/src/lim_router.erl new file mode 100644 index 00000000..51e59576 --- /dev/null +++ b/apps/limiter/src/lim_router.erl @@ -0,0 +1,17 @@ +-module(lim_router). + +-export([get_handler/1]). + +-type processor_type() :: binary(). +-type processor() :: module(). + +-export_type([processor_type/0]). +-export_type([processor/0]). + +-spec get_handler(processor_type()) -> + {ok, processor()} + | {error, notfound}. +get_handler(<<"TurnoverProcessor">>) -> + {ok, lim_turnover_processor}; +get_handler(_) -> + {error, notfound}. diff --git a/apps/limiter/src/lim_string.erl b/apps/limiter/src/lim_string.erl new file mode 100644 index 00000000..4f58d176 --- /dev/null +++ b/apps/limiter/src/lim_string.erl @@ -0,0 +1,26 @@ +%%% +%%% String manipultion facilities. + +-module(lim_string). + +-export([join/1]). +-export([join/2]). + +%% + +-type fragment() :: + iodata() + | char() + | atom() + | number(). + +-spec join([fragment()]) -> binary(). +join(Fragments) -> + join(<<>>, Fragments). + +-spec join(Delim, [fragment()]) -> binary() when + Delim :: + char() + | iodata(). +join(Delim, Fragments) -> + genlib_string:join(Delim, lists:map(fun genlib:to_binary/1, Fragments)). diff --git a/apps/limiter/src/lim_time.erl b/apps/limiter/src/lim_time.erl new file mode 100644 index 00000000..63ecdfe8 --- /dev/null +++ b/apps/limiter/src/lim_time.erl @@ -0,0 +1,21 @@ +-module(lim_time). + +-export([now/0]). +-export([to_rfc3339/1]). +-export([from_rfc3339/1]). + +-type timestamp_ms() :: integer(). + +-export_type([timestamp_ms/0]). + +-spec now() -> timestamp_ms(). +now() -> + erlang:system_time(millisecond). + +-spec to_rfc3339(timestamp_ms()) -> binary(). +to_rfc3339(Timestamp) -> + genlib_rfc3339:format_relaxed(Timestamp, millisecond). + +-spec from_rfc3339(binary()) -> timestamp_ms(). +from_rfc3339(BTimestamp) -> + genlib_rfc3339:parse(BTimestamp, millisecond). diff --git a/apps/limiter/src/lim_turnover_metric.erl b/apps/limiter/src/lim_turnover_metric.erl new file mode 100644 index 00000000..228ef626 --- /dev/null +++ b/apps/limiter/src/lim_turnover_metric.erl @@ -0,0 +1,74 @@ +-module(lim_turnover_metric). + +-export([compute/4]). + +-type amount() :: lim_body:amount(). +-type currency() :: dmsl_domain_thrift:'CurrencySymbolicCode'(). +-type stage() :: hold | commit. +-type t() :: number | {amount, currency()}. + +-type invalid_operation_currency_error() :: {invalid_operation_currency, {currency(), currency()}}. + +-export_type([t/0]). +-export_type([stage/0]). +-export_type([invalid_operation_currency_error/0]). + +%% + +-spec compute(t(), stage(), lim_config_machine:config(), lim_context:t()) -> + {ok, amount()} | {error, lim_rates:conversion_error()} | {error, invalid_operation_currency_error()}. +compute(number, hold, Config, LimitContext) -> + #{amount := Amount} = get_body(Config, LimitContext), + {ok, sign(Amount)}; +compute(number, commit, Config, LimitContext) -> + case get_commit_body(Config, LimitContext) of + #{amount := Amount} when Amount /= 0 -> + {ok, sign(Amount)}; + #{amount := 0} -> + % Zero amount operation currently means "rollback" in the protocol. + {ok, 0} + end; +compute({amount, Currency}, hold, Config, LimitContext) -> + Body = get_body(Config, LimitContext), + denominate(Body, Currency, Config, LimitContext); +compute({amount, Currency}, commit, Config, LimitContext) -> + Body = get_commit_body(Config, LimitContext), + denominate(Body, Currency, Config, LimitContext). + +get_body(Config, LimitContext) -> + {ok, Body} = lim_body:get(full, Config, LimitContext), + Body. + +get_commit_body(Config, LimitContext) -> + case lim_body:get(partial, Config, LimitContext) of + {ok, Body} -> + Body; + {error, _} -> + get_body(Config, LimitContext) + end. + +%% + +denominate(#{amount := Amount, currency := Currency}, Currency, _Config, _LimitContext) -> + {ok, Amount}; +denominate(#{currency := Currency} = Body, DestinationCurrency, Config, LimitContext) -> + case lim_config_machine:currency_conversion(Config) of + false -> currencies_mismatch_error(Currency, DestinationCurrency); + true -> convert_currency(Body, DestinationCurrency, Config, LimitContext) + end. + +currencies_mismatch_error(Currency, ExpectedCurrency) -> + {error, {invalid_operation_currency, {Currency, ExpectedCurrency}}}. + +convert_currency(Body, DestinationCurrency, Config, LimitContext) -> + case lim_rates:convert(Body, DestinationCurrency, Config, LimitContext) of + {ok, #{amount := AmountConverted}} -> + {ok, AmountConverted}; + {error, _} = Error -> + Error + end. + +sign(Amount) when Amount > 0 -> + +1; +sign(Amount) when Amount < 0 -> + -1. diff --git a/apps/limiter/src/lim_turnover_processor.erl b/apps/limiter/src/lim_turnover_processor.erl new file mode 100644 index 00000000..dc2ebf7e --- /dev/null +++ b/apps/limiter/src/lim_turnover_processor.erl @@ -0,0 +1,79 @@ +-module(lim_turnover_processor). + +-include_lib("limiter_proto/include/limproto_limiter_thrift.hrl"). + +-behaviour(lim_config_machine). + +-export([make_change/4]). + +-type lim_context() :: lim_context:t(). +-type lim_change() :: lim_config_machine:lim_change(). +-type config() :: lim_config_machine:config(). +-type amount() :: integer(). + +-type forbidden_operation_amount_error() :: #{ + type := positive | negative, + partial := amount(), + full := amount() +}. + +-type get_limit_error() :: {range, notfound}. +-type make_change_error() :: + lim_rates:conversion_error() + | lim_context:operation_context_not_supported_error() + | lim_turnover_metric:invalid_operation_currency_error(). + +-type hold_error() :: + lim_rates:conversion_error() + | lim_turnover_metric:invalid_operation_currency_error() + | lim_context:operation_context_not_supported_error() + | lim_context:unsupported_error({payment_tool, atom()}). + +-type commit_error() :: + {forbidden_operation_amount, forbidden_operation_amount_error()} + | lim_rates:conversion_error(). + +-type rollback_error() :: + lim_rates:conversion_error(). + +-export_type([make_change_error/0]). +-export_type([get_limit_error/0]). +-export_type([hold_error/0]). +-export_type([commit_error/0]). +-export_type([rollback_error/0]). + +-import(lim_pipeline, [do/1, unwrap/1]). + +-spec make_change(lim_turnover_metric:stage(), lim_change(), config(), lim_context()) -> + {ok, lim_liminator:limit_change()} | {error, make_change_error()}. +make_change(Stage, #limiter_LimitChange{id = LimitID, version = Version}, Config, LimitContext) -> + do(fun() -> + {LimitRangeID, ScopeChangeContext} = unwrap(compute_limit_range_id(LimitID, Version, Config, LimitContext)), + ChangeContext = unwrap(lim_context:make_change_context(lim_config_machine:context_type(Config), LimitContext)), + Metric = unwrap(compute_metric(Stage, Config, LimitContext)), + lim_liminator:construct_change(LimitID, LimitRangeID, Metric, maps:merge(ChangeContext, ScopeChangeContext)) + end). + +compute_limit_range_id(LimitID, Version, Config, LimitContext) -> + do(fun() -> + Timestamp = unwrap(get_timestamp(Config, LimitContext)), + unwrap(construct_range_id(Timestamp, LimitID, Version, Config, LimitContext)) + end). + +get_timestamp(Config, LimitContext) -> + ContextType = lim_config_machine:context_type(Config), + lim_context:get_value(ContextType, created_at, LimitContext). + +construct_range_id(Timestamp, LimitID, Version, Config, LimitContext) -> + BinaryVersion = genlib:to_binary(Version), + case lim_config_machine:mk_scope_prefix(Config, LimitContext) of + {ok, {Prefix, ChangeContext}} -> + ShardID = lim_config_machine:calculate_shard_id(Timestamp, Config), + {ok, {<>, ChangeContext}}; + {error, _} = Error -> + Error + end. + +compute_metric(Stage, Config, LimitContext) -> + {turnover, Metric} = lim_config_machine:type(Config), + lim_turnover_metric:compute(Metric, Stage, Config, LimitContext). diff --git a/apps/limiter/src/lim_wthdproc_context.erl b/apps/limiter/src/lim_wthdproc_context.erl new file mode 100644 index 00000000..f9b44306 --- /dev/null +++ b/apps/limiter/src/lim_wthdproc_context.erl @@ -0,0 +1,158 @@ +-module(lim_wthdproc_context). + +-include_lib("limiter_proto/include/limproto_context_withdrawal_thrift.hrl"). +-include_lib("limiter_proto/include/limproto_base_thrift.hrl"). +-include_lib("damsel/include/dmsl_wthd_domain_thrift.hrl"). +-include_lib("damsel/include/dmsl_domain_thrift.hrl"). + +-behaviour(lim_context). +-export([get_operation/1]). +-export([make_change_context/1]). +-export([get_value/2]). + +-type context() :: limproto_context_withdrawal_thrift:'Context'(). + +-type operation() :: + withdrawal. + +-export_type([operation/0]). +-export_type([context/0]). + +%% + +-spec get_operation(context()) -> {ok, operation()} | {error, notfound}. +get_operation(#context_withdrawal_Context{op = {Operation, _}}) -> + {ok, Operation}; +get_operation(#context_withdrawal_Context{op = undefined}) -> + {error, notfound}. + +-spec make_change_context(context()) -> {ok, lim_context:change_context()}. +make_change_context(#context_withdrawal_Context{op = undefined}) -> + {ok, #{}}; +make_change_context( + #context_withdrawal_Context{ + op = {Operation, _} + } = Context +) -> + {ok, + genlib_map:compact(#{ + <<"Context.op">> => genlib:to_binary(Operation), + <<"Context.owner_id">> => try_get_value(owner_id, Context, undefined), + <<"Context.wallet_id">> => try_get_value(wallet_id, Context, undefined) + })}. + +-spec get_value(atom(), context()) -> {ok, term()} | {error, notfound | {unsupported, _}}. +get_value(ValueName, Context) -> + case get_operation(Context) of + {ok, Operation} -> + get_value(ValueName, Operation, Context); + {error, _} = Error -> + Error + end. + +try_get_value(ValueName, Context, Default) -> + case get_operation(Context) of + {ok, Operation} -> + case get_value(ValueName, Operation, Context) of + {ok, Value} -> + Value; + {error, _} -> + Default + end; + {error, _} -> + Default + end. + +get_value(owner_id, _Operation, Context) -> + get_owner_id(Context); +get_value(created_at, Operation, Context) -> + get_created_at(Operation, Context); +get_value(cost, Operation, Context) -> + get_cost(Operation, Context); +get_value(payment_tool, Operation, Context) -> + get_payment_tool(Operation, Context); +get_value(wallet_id, Operation, Context) -> + get_wallet_id(Operation, Context); +get_value(provider_id, Operation, Context) -> + get_provider_id(Operation, Context); +get_value(terminal_id, Operation, Context) -> + get_terminal_id(Operation, Context); +get_value(sender, Operation, Context) -> + get_destination_sender(Operation, Context); +get_value(receiver, Operation, Context) -> + get_destination_receiver(Operation, Context); +get_value(ValueName, _Operation, _Context) -> + {error, {unsupported, ValueName}}. + +-define(WITHDRAWAL(V), #context_withdrawal_Context{ + withdrawal = #context_withdrawal_Withdrawal{ + withdrawal = V = #wthd_domain_Withdrawal{} + } +}). + +-define(WALLET_ID(V), #context_withdrawal_Context{ + withdrawal = #context_withdrawal_Withdrawal{ + wallet_id = V + } +}). + +-define(ROUTE(V), #context_withdrawal_Context{ + withdrawal = #context_withdrawal_Withdrawal{ + route = V = #base_Route{} + } +}). + +-define(AUTH_DATA(V), #context_withdrawal_Context{ + withdrawal = #context_withdrawal_Withdrawal{ + withdrawal = #wthd_domain_Withdrawal{auth_data = V} + } +}). + +-define(SENDER_RECEIVER(V), ?AUTH_DATA({sender_receiver, V})). + +get_owner_id(?WITHDRAWAL(Wthd)) -> + {ok, Wthd#wthd_domain_Withdrawal.sender#domain_PartyConfigRef.id}; +get_owner_id(_CtxWithdrawal) -> + {error, notfound}. + +get_created_at(withdrawal, ?WITHDRAWAL(Wthd)) -> + {ok, Wthd#wthd_domain_Withdrawal.created_at}; +get_created_at(_, _CtxWithdrawal) -> + {error, notfound}. + +get_cost(withdrawal, ?WITHDRAWAL(Wthd)) -> + Body = Wthd#wthd_domain_Withdrawal.body, + lim_payproc_utils:cash(Body); +get_cost(_, _CtxWithdrawal) -> + {error, notfound}. + +get_payment_tool(withdrawal, ?WITHDRAWAL(Wthd)) -> + Destination = Wthd#wthd_domain_Withdrawal.destination, + lim_payproc_utils:payment_tool(Destination); +get_payment_tool(_, _CtxWithdrawal) -> + {error, notfound}. + +get_wallet_id(withdrawal, ?WALLET_ID(WalletID)) -> + {ok, WalletID}; +get_wallet_id(_, _CtxWithdrawal) -> + {error, notfound}. + +get_provider_id(withdrawal, ?ROUTE(Route)) -> + lim_context_utils:route_provider_id(Route); +get_provider_id(_, _CtxWithdrawal) -> + {error, notfound}. + +get_terminal_id(withdrawal, ?ROUTE(Route)) -> + lim_context_utils:route_terminal_id(Route); +get_terminal_id(_, _CtxWithdrawal) -> + {error, notfound}. + +get_destination_sender(withdrawal, ?SENDER_RECEIVER(#wthd_domain_SenderReceiverAuthData{sender = Token})) -> + {ok, lim_context_utils:base61_hash(Token)}; +get_destination_sender(_, _CtxWithdrawal) -> + {error, notfound}. + +get_destination_receiver(withdrawal, ?SENDER_RECEIVER(#wthd_domain_SenderReceiverAuthData{receiver = Token})) -> + {ok, lim_context_utils:base61_hash(Token)}; +get_destination_receiver(_, _CtxWithdrawal) -> + {error, notfound}. diff --git a/apps/limiter/src/limiter.app.src b/apps/limiter/src/limiter.app.src new file mode 100644 index 00000000..344e6c9d --- /dev/null +++ b/apps/limiter/src/limiter.app.src @@ -0,0 +1,23 @@ +{application, limiter, [ + {description, "Proto limiter service"}, + {vsn, "1.0.0"}, + {registered, []}, + {applications, [ + kernel, + stdlib, + prometheus, + prometheus_cowboy, + scoper, % should be before any scoper event handler usage + woody, + limiter_proto, + liminator_proto, + xrates_proto, + erl_health, + dmt_client, + opentelemetry_api, + opentelemetry_exporter, + opentelemetry + ]}, + {mod, {limiter, []}}, + {env, []} +]}. diff --git a/apps/limiter/src/limiter.erl b/apps/limiter/src/limiter.erl new file mode 100644 index 00000000..e2788e2f --- /dev/null +++ b/apps/limiter/src/limiter.erl @@ -0,0 +1,101 @@ +-module(limiter). + +%% Application callbacks + +-behaviour(application). + +-export([start/2]). +-export([stop/1]). + +%% Supervisor callbacks + +-behaviour(supervisor). + +-export([init/1]). + +%% + +-spec start(normal, any()) -> {ok, pid()} | {error, any()}. +start(_StartType, _StartArgs) -> + ok = setup_metrics(), + supervisor:start_link({local, ?MODULE}, ?MODULE, []). + +-spec stop(any()) -> ok. +stop(_State) -> + ok. + +%% + +-spec init([]) -> {ok, {supervisor:sup_flags(), [supervisor:child_spec()]}}. +init([]) -> + ServiceOpts = genlib_app:env(?MODULE, services, #{}), + Healthcheck = enable_health_logging(genlib_app:env(?MODULE, health_check, #{})), + EventHandlers = genlib_app:env(?MODULE, woody_event_handlers, [{woody_event_handler_default, #{}}]), + + ChildSpec = woody_server:child_spec( + ?MODULE, + #{ + ip => get_ip_address(), + port => get_port(), + protocol_opts => get_protocol_opts(), + transport_opts => get_transport_opts(), + shutdown_timeout => get_shutdown_timeout(), + event_handler => EventHandlers, + handlers => get_handler_specs(ServiceOpts), + additional_routes => [erl_health_handle:get_route(Healthcheck)] ++ get_prometheus_route() + } + ), + {ok, + { + #{strategy => one_for_all, intensity => 6, period => 30}, + [ChildSpec] + }}. + +-spec get_ip_address() -> inet:ip_address(). +get_ip_address() -> + {ok, Address} = inet:parse_address(genlib_app:env(?MODULE, ip, "::")), + Address. + +-spec get_port() -> inet:port_number(). +get_port() -> + genlib_app:env(?MODULE, port, 8022). + +-spec get_protocol_opts() -> woody_server_thrift_http_handler:protocol_opts(). +get_protocol_opts() -> + genlib_app:env(?MODULE, protocol_opts, #{}). + +-spec get_transport_opts() -> woody_server_thrift_http_handler:transport_opts(). +get_transport_opts() -> + genlib_app:env(?MODULE, transport_opts, #{}). + +-spec get_shutdown_timeout() -> timeout(). +get_shutdown_timeout() -> + genlib_app:env(?MODULE, shutdown_timeout, 0). + +-spec get_handler_specs(map()) -> [woody:http_handler(woody:th_handler())]. +get_handler_specs(ServiceOpts) -> + LimiterService = maps:get(limiter, ServiceOpts, #{}), + [ + { + maps:get(path, LimiterService, <<"/v1/limiter">>), + {{limproto_limiter_thrift, 'Limiter'}, lim_handler} + } + ]. + +%% + +-spec enable_health_logging(erl_health:check()) -> erl_health:check(). +enable_health_logging(Check) -> + EvHandler = {erl_health_event_handler, []}, + maps:map( + fun(_, Runner) -> #{runner => Runner, event_handler => EvHandler} end, + Check + ). + +-spec get_prometheus_route() -> [{iodata(), module(), _Opts :: any()}]. +get_prometheus_route() -> + [{"/metrics/[:registry]", prometheus_cowboy2_handler, []}]. + +setup_metrics() -> + ok = woody_ranch_prometheus_collector:setup(), + ok = woody_hackney_prometheus_collector:setup(). diff --git a/apps/limiter/test/lim_client.erl b/apps/limiter/test/lim_client.erl new file mode 100644 index 00000000..8544df84 --- /dev/null +++ b/apps/limiter/test/lim_client.erl @@ -0,0 +1,112 @@ +-module(lim_client). + +-include_lib("limiter_proto/include/limproto_limiter_thrift.hrl"). + +-export([new/0]). +-export([get/4]). +-export([hold/3]). +-export([commit/3]). +-export([rollback/3]). + +-export([get_values/3]). +-export([get_batch/3]). +-export([hold_batch/3]). +-export([commit_batch/3]). +-export([rollback_batch/3]). + +-type client() :: woody_context:ctx(). + +-type limit_id() :: limproto_limiter_thrift:'LimitID'(). +-type limit() :: limproto_limiter_thrift:'Limit'(). +-type limit_version() :: limproto_limiter_thrift:'Version'(). +-type limit_change() :: limproto_limiter_thrift:'LimitChange'(). +-type limit_request() :: limproto_limiter_thrift:'LimitRequest'(). +-type limit_context() :: limproto_limiter_thrift:'LimitContext'(). + +%%% API + +-spec new() -> client(). +new() -> + woody_context:new(). + +-spec get(limit_id(), limit_version(), limit_context(), client()) -> + {ok, limit()} | {exception, woody_error:business_error()} | no_return(). +get(LimitID, Version, Context, Client) -> + LimitRequest = construct_request(#limiter_LimitChange{id = LimitID, version = Version}), + case get_values(LimitRequest, Context, Client) of + {ok, [Limit]} -> + {ok, Limit}; + {ok, []} -> + {ok, #limiter_Limit{id = LimitID, amount = 0}}; + {exception, _} = Exception -> + Exception + end. + +-spec hold(limit_change(), limit_context(), client()) -> ok | {exception, woody_error:business_error()} | no_return(). +hold(#limiter_LimitChange{} = LimitChange, Context, Client) -> + LimitRequest = construct_request(LimitChange), + case hold_batch(LimitRequest, Context, Client) of + {ok, _} -> + ok; + {exception, _} = Exception -> + Exception + end. + +-spec commit(limit_change(), limit_context(), client()) -> ok | {exception, woody_error:business_error()} | no_return(). +commit(#limiter_LimitChange{} = LimitChange, Context, Client) -> + LimitRequest = construct_request(LimitChange), + unwrap_ok(commit_batch(LimitRequest, Context, Client)). + +-spec rollback(limit_change(), limit_context(), client()) -> + ok | {exception, woody_error:business_error()} | no_return(). +rollback(#limiter_LimitChange{} = LimitChange, Context, Client) -> + LimitRequest = construct_request(LimitChange), + unwrap_ok(rollback_batch(LimitRequest, Context, Client)). + +-spec get_values(limit_request(), limit_context(), client()) -> + {ok, [limit()]} | {exception, woody_error:business_error()} | no_return(). +get_values(LimitRequest, Context, Client) -> + call('GetValues', {LimitRequest, Context}, Client). + +-spec get_batch(limit_request(), limit_context(), client()) -> + {ok, [limit()]} | {exception, woody_error:business_error()} | no_return(). +get_batch(LimitRequest, Context, Client) -> + call('GetBatch', {LimitRequest, Context}, Client). + +-spec hold_batch(limit_request(), limit_context(), client()) -> + {ok, [limit()]} | {exception, woody_error:business_error()} | no_return(). +hold_batch(LimitRequest, Context, Client) -> + call('HoldBatch', {LimitRequest, Context}, Client). + +-spec commit_batch(limit_request(), limit_context(), client()) -> + ok | {exception, woody_error:business_error()} | no_return(). +commit_batch(LimitRequest, Context, Client) -> + unwrap_ok(call('CommitBatch', {LimitRequest, Context}, Client)). + +-spec rollback_batch(limit_request(), limit_context(), client()) -> + ok | {exception, woody_error:business_error()} | no_return(). +rollback_batch(LimitRequest, Context, Client) -> + unwrap_ok(call('RollbackBatch', {LimitRequest, Context}, Client)). + +%%% Internal functions + +construct_request(#limiter_LimitChange{id = LimitID} = LimitChange) -> + #limiter_LimitRequest{ + operation_id = <<"operation.single-change.", LimitID/binary>>, + limit_changes = [LimitChange] + }. + +-spec call(atom(), tuple(), client()) -> woody:result() | no_return(). +call(Function, Args, Client) -> + Call = {{limproto_limiter_thrift, 'Limiter'}, Function, Args}, + Opts = #{ + url => <<"http://limiter:8022/v1/limiter">>, + event_handler => {scoper_woody_event_handler, #{}}, + transport_opts => #{ + max_connections => 10000 + } + }, + woody_client:call(Call, Opts, Client). + +unwrap_ok({ok, ok}) -> ok; +unwrap_ok(ResultOrException) -> ResultOrException. diff --git a/apps/limiter/test/lim_ct_helper.hrl b/apps/limiter/test/lim_ct_helper.hrl new file mode 100644 index 00000000..2d8166bf --- /dev/null +++ b/apps/limiter/test/lim_ct_helper.hrl @@ -0,0 +1,278 @@ +-ifndef(__limiter_ct_helper__). +-define(__limiter_ct_helper__, 42). + +-include_lib("limiter_proto/include/limproto_base_thrift.hrl"). +-include_lib("limiter_proto/include/limproto_limiter_thrift.hrl"). +-include_lib("limiter_proto/include/limproto_context_payproc_thrift.hrl"). +-include_lib("limiter_proto/include/limproto_context_withdrawal_thrift.hrl"). +-include_lib("damsel/include/dmsl_limiter_config_thrift.hrl"). +-include_lib("damsel/include/dmsl_base_thrift.hrl"). +-include_lib("damsel/include/dmsl_domain_thrift.hrl"). +-include_lib("damsel/include/dmsl_wthd_domain_thrift.hrl"). + +-define(currency, <<"RUB">>). +-define(string, <<"STRING">>). +-define(token, <<"TOKEN">>). +-define(integer, 999). +-define(timestamp, <<"2000-01-01T00:00:00Z">>). + +-define(cash(Amount), ?cash(Amount, ?currency)). +-define(cash(Amount, Currency), #domain_Cash{ + amount = Amount, + currency = #domain_CurrencyRef{symbolic_code = Currency} +}). + +-define(route(), ?route(?integer, ?integer)). +-define(route(ProviderID, TerminalID), #base_Route{ + provider = #domain_ProviderRef{id = ProviderID}, + terminal = #domain_TerminalRef{id = TerminalID} +}). + +-define(scopes(Types), ordsets:from_list(Types)). +-define(global(), ?scopes([])). + +-define(scope_party(), {party, #limiter_config_LimitScopeEmptyDetails{}}). +-define(scope_shop(), {shop, #limiter_config_LimitScopeEmptyDetails{}}). +-define(scope_payment_tool(), {payment_tool, #limiter_config_LimitScopeEmptyDetails{}}). +-define(scope_provider(), {provider, #limiter_config_LimitScopeEmptyDetails{}}). +-define(scope_terminal(), {terminal, #limiter_config_LimitScopeEmptyDetails{}}). +-define(scope_payer_contact_email(), {payer_contact_email, #limiter_config_LimitScopeEmptyDetails{}}). +-define(scope_wallet(), {wallet, #limiter_config_LimitScopeEmptyDetails{}}). +-define(scope_sender(), {sender, #limiter_config_LimitScopeEmptyDetails{}}). +-define(scope_receiver(), {receiver, #limiter_config_LimitScopeEmptyDetails{}}). +-define(scope_destination_field(FieldPath), + {destination_field, #limiter_config_LimitScopeDestinationFieldDetails{field_path = FieldPath}} +). + +-define(lim_type_turnover(), ?lim_type_turnover(?turnover_metric_number())). +-define(lim_type_turnover(Metric), + {turnover, #limiter_config_LimitTypeTurnover{metric = Metric}} +). + +-define(turnover_metric_number(), {number, #limiter_config_LimitTurnoverNumber{}}). +-define(turnover_metric_amount(), ?turnover_metric_amount(?currency)). +-define(turnover_metric_amount(Currency), + {amount, #limiter_config_LimitTurnoverAmount{currency = Currency}} +). + +-define(finalization_behaviour_normal, {normal, #limiter_config_Normal{}}). +-define(finalization_behaviour_invertable_by_session, {invertable, {session_presence, #limiter_config_Inversed{}}}). + +-define(time_range_day(), + {calendar, {day, #limiter_config_TimeRangeTypeCalendarDay{}}} +). +-define(time_range_week(), + {calendar, {week, #limiter_config_TimeRangeTypeCalendarWeek{}}} +). +-define(time_range_month(), + {calendar, {month, #limiter_config_TimeRangeTypeCalendarMonth{}}} +). +-define(time_range_year(), + {calendar, {year, #limiter_config_TimeRangeTypeCalendarYear{}}} +). + +-define(op_behaviour(), ?op_behaviour(?op_addition())). +-define(op_behaviour(Refund), #limiter_config_OperationLimitBehaviour{ + invoice_payment_refund = Refund +}). + +-define(currency_conversion(), #limiter_config_CurrencyConversion{}). + +-define(op_addition(), {addition, #limiter_config_Addition{}}). +-define(op_subtraction(), {subtraction, #limiter_config_Subtraction{}}). + +-define(ctx_type_payproc(), + {payment_processing, #limiter_config_LimitContextTypePaymentProcessing{}} +). + +-define(ctx_type_wthdproc(), + {withdrawal_processing, #limiter_config_LimitContextTypeWithdrawalProcessing{}} +). + +%% Payproc + +-define(op_invoice, {invoice, #context_payproc_OperationInvoice{}}). +-define(op_payment, {invoice_payment, #context_payproc_OperationInvoicePayment{}}). +-define(op_refund, {invoice_payment_refund, #context_payproc_OperationInvoicePaymentRefund{}}). + +-define(bank_card(), ?bank_card(?string, 2, 2022)). + +-define(bank_card(Token), + {bank_card, #domain_BankCard{ + token = Token, + bin = ?string, + last_digits = ?string + }} +). + +-define(bank_card(Token, Month, Year), + {bank_card, #domain_BankCard{ + token = Token, + bin = ?string, + last_digits = ?string, + exp_date = #domain_BankCardExpDate{month = Month, year = Year} + }} +). + +-define(digital_wallet(ID, Service), + {digital_wallet, #domain_DigitalWallet{ + id = ID, + payment_service = #domain_PaymentServiceRef{id = Service} + }} +). + +-define(generic_pt(), + {generic, #domain_GenericPaymentTool{ + payment_service = #domain_PaymentServiceRef{id = <<"ID42">>}, + data = #base_Content{ + type = <<"application/json">>, data = <<"{\"opaque\":{\"payload\":{\"data\":\"value\"}}}">> + } + }} +). + +-define(invoice(OwnerID, ShopID, Cost), #domain_Invoice{ + id = ?string, + party_ref = #domain_PartyConfigRef{id = OwnerID}, + shop_ref = #domain_ShopConfigRef{id = ShopID}, + domain_revision = 1, + created_at = ?timestamp, + status = {unpaid, #domain_InvoiceUnpaid{}}, + details = #domain_InvoiceDetails{product = ?string}, + due = ?timestamp, + cost = Cost +}). + +-define(invoice_payment(Cost, CaptureCost), + ?invoice_payment(Cost, CaptureCost, ?bank_card()) +). + +-define(invoice_payment(Cost, CaptureCost, PaymentTool), + ?invoice_payment(Cost, CaptureCost, PaymentTool, ?timestamp) +). + +-define(invoice_payment(Cost, CaptureCost, PaymentTool, CreatedAt), #domain_InvoicePayment{ + id = ?string, + created_at = CreatedAt, + status = {captured, #domain_InvoicePaymentCaptured{cost = CaptureCost}}, + cost = Cost, + domain_revision = 1, + flow = {instant, #domain_InvoicePaymentFlowInstant{}}, + payer = + {payment_resource, #domain_PaymentResourcePayer{ + resource = #domain_DisposablePaymentResource{ + payment_tool = PaymentTool + }, + contact_info = #domain_ContactInfo{ + email = ?string + } + }} +}). + +-define(payproc_ctx(Op, Invoice, InvoicePayment), #limiter_LimitContext{ + payment_processing = #context_payproc_Context{ + op = Op, + invoice = #context_payproc_Invoice{ + invoice = Invoice, + payment = InvoicePayment + } + } +}). + +-define(payproc_ctx(Invoice, InvoicePayment), ?payproc_ctx(?op_invoice, Invoice, InvoicePayment)). + +-define(payproc_ctx_invoice(Cost), ?payproc_ctx(?invoice(?string, ?string, Cost), undefined)). + +-define(payproc_ctx_payment(Cost, CaptureCost), + ?payproc_ctx_payment(?string, ?string, Cost, CaptureCost) +). + +-define(payproc_ctx_payment(OwnerID, ShopID, Cost, CaptureCost), + ?payproc_ctx_payment(OwnerID, ShopID, Cost, CaptureCost, undefined) +). + +-define(payproc_ctx_payment(OwnerID, ShopID, Cost, CaptureCost, Session), #limiter_LimitContext{ + payment_processing = #context_payproc_Context{ + op = ?op_payment, + invoice = #context_payproc_Invoice{ + invoice = ?invoice(OwnerID, ShopID, Cost), + payment = #context_payproc_InvoicePayment{ + payment = ?invoice_payment(Cost, CaptureCost), + route = ?route() + }, + session = Session + } + } +}). + +-define(payproc_ctx_payment(Payment), #limiter_LimitContext{ + payment_processing = #context_payproc_Context{ + op = ?op_payment, + invoice = #context_payproc_Invoice{ + invoice = ?invoice(?string, ?string, ?cash(10)), + payment = #context_payproc_InvoicePayment{ + payment = Payment, + route = ?route() + } + } + } +}). + +-define(payproc_ctx_refund(OwnerID, ShopID, Cost, CaptureCost, RefundCost), #limiter_LimitContext{ + payment_processing = #context_payproc_Context{ + op = ?op_refund, + invoice = #context_payproc_Invoice{ + invoice = ?invoice(OwnerID, ShopID, Cost), + payment = #context_payproc_InvoicePayment{ + payment = ?invoice_payment(Cost, CaptureCost), + refund = #domain_InvoicePaymentRefund{ + id = ?string, + status = {succeeded, #domain_InvoicePaymentRefundSucceeded{}}, + created_at = ?timestamp, + domain_revision = 1, + cash = RefundCost + } + } + } + } +}). + +-define(payproc_ctx_session, #context_payproc_InvoicePaymentSession{}). + +%% Wthdproc + +-define(auth_data(Sender, Receiver), + {sender_receiver, #wthd_domain_SenderReceiverAuthData{sender = Sender, receiver = Receiver}} +). + +-define(withdrawal(Body), ?withdrawal(Body, ?bank_card(), ?string)). + +-define(withdrawal(Body, Destination, OwnerID), ?withdrawal(Body, Destination, OwnerID, undefined)). + +-define(withdrawal(Body, Destination, OwnerID, AuthData), #wthd_domain_Withdrawal{ + body = Body, + created_at = ?timestamp, + destination = Destination, + sender = #domain_PartyConfigRef{id = OwnerID}, + auth_data = AuthData +}). + +-define(op_withdrawal, {withdrawal, #context_withdrawal_OperationWithdrawal{}}). + +-define(wthdproc_ctx(Withdrawal), #limiter_LimitContext{ + withdrawal_processing = #context_withdrawal_Context{ + op = ?op_withdrawal, + withdrawal = #context_withdrawal_Withdrawal{ + withdrawal = Withdrawal, + route = ?route(), + wallet_id = ?string + } + } +}). + +-define(wthdproc_ctx_withdrawal(Cost), ?wthdproc_ctx(?withdrawal(Cost))). + +-define(wthdproc_ctx_withdrawal_w_auth_data(Cost, Sender, Receiver), + ?wthdproc_ctx(?withdrawal(Cost, ?bank_card(), ?string, ?auth_data(Sender, Receiver))) +). + +-endif. diff --git a/apps/limiter/test/lim_mock.erl b/apps/limiter/test/lim_mock.erl new file mode 100644 index 00000000..f93092f2 --- /dev/null +++ b/apps/limiter/test/lim_mock.erl @@ -0,0 +1,83 @@ +-module(lim_mock). + +-include_lib("common_test/include/ct.hrl"). + +-export([start_mocked_service_sup/0]). +-export([stop_mocked_service_sup/1]). +-export([mock_services/2]). + +-define(APP, limiter). + +-spec start_mocked_service_sup() -> _. +start_mocked_service_sup() -> + {ok, SupPid} = genlib_adhoc_supervisor:start_link(#{}, []), + _ = unlink(SupPid), + SupPid. + +-spec stop_mocked_service_sup(pid()) -> _. +stop_mocked_service_sup(SupPid) -> + exit(SupPid, shutdown). + +-define(HOST_IP, "::"). +-define(HOST_NAME, "localhost"). + +-spec mock_services(_, _) -> _. +mock_services(Services, SupOrConfig) -> + maps:map(fun set_cfg/2, mock_services_(Services, SupOrConfig)). + +set_cfg(Service, Url) -> + {ok, Clients} = application:get_env(?APP, service_clients), + #{Service := Cfg} = Clients, + ok = application:set_env( + ?APP, + service_clients, + Clients#{Service => Cfg#{url => Url}} + ). + +mock_services_(Services, Config) when is_list(Config) -> + mock_services_(Services, ?config(test_sup, Config)); +mock_services_(Services, SupPid) when is_pid(SupPid) -> + Name = lists:map(fun get_service_name/1, Services), + + {ok, IP} = inet:parse_address(?HOST_IP), + ServerID = {dummy, Name}, + Options = #{ + ip => IP, + port => 0, + event_handler => {scoper_woody_event_handler, #{}}, + handlers => lists:map(fun mock_service_handler/1, Services), + transport_opts => #{num_acceptors => 1} + }, + ChildSpec = woody_server:child_spec(ServerID, Options), + {ok, _} = supervisor:start_child(SupPid, ChildSpec), + {IP, Port} = woody_server:get_addr(ServerID, Options), + lists:foldl( + fun(Service, Acc) -> + ServiceName = get_service_name(Service), + Acc#{ServiceName => make_url(ServiceName, Port)} + end, + #{}, + Services + ). + +get_service_name({ServiceName, _Fun}) -> + ServiceName; +get_service_name({ServiceName, _WoodyService, _Fun}) -> + ServiceName. + +mock_service_handler({ServiceName, Fun}) -> + mock_service_handler(ServiceName, get_service_modname(ServiceName), Fun); +mock_service_handler({ServiceName, WoodyService, Fun}) -> + mock_service_handler(ServiceName, WoodyService, Fun). + +mock_service_handler(ServiceName, WoodyService, Fun) -> + {make_path(ServiceName), {WoodyService, {lim_mock_service, #{function => Fun}}}}. + +get_service_modname(xrates) -> + {xrates_rate_thrift, 'Rates'}. + +make_url(ServiceName, Port) -> + iolist_to_binary(["http://", ?HOST_NAME, ":", integer_to_list(Port), make_path(ServiceName)]). + +make_path(ServiceName) -> + "/" ++ atom_to_list(ServiceName). diff --git a/apps/limiter/test/lim_mock_service.erl b/apps/limiter/test/lim_mock_service.erl new file mode 100644 index 00000000..f9585cb8 --- /dev/null +++ b/apps/limiter/test/lim_mock_service.erl @@ -0,0 +1,13 @@ +-module(lim_mock_service). + +-behaviour(woody_server_thrift_handler). + +-export([handle_function/4]). + +-type opts() :: #{ + function := fun((woody:func(), woody:args()) -> woody:result()) +}. + +-spec handle_function(woody:func(), woody:args(), woody_context:ctx(), opts()) -> {ok, term()}. +handle_function(FunName, Args, _, #{function := Fun}) -> + Fun(FunName, Args). diff --git a/apps/limiter/test/lim_turnover_SUITE.erl b/apps/limiter/test/lim_turnover_SUITE.erl new file mode 100644 index 00000000..241e9f43 --- /dev/null +++ b/apps/limiter/test/lim_turnover_SUITE.erl @@ -0,0 +1,1146 @@ +-module(lim_turnover_SUITE). + +-include_lib("stdlib/include/assert.hrl"). +-include_lib("common_test/include/ct.hrl"). +-include_lib("damsel/include/dmsl_base_thrift.hrl"). +-include_lib("damsel/include/dmsl_limiter_config_thrift.hrl"). +-include_lib("damsel/include/dmsl_domain_conf_v2_thrift.hrl"). +-include("lim_ct_helper.hrl"). + +-export([all/0]). + +-export([groups/0]). +-export([init_per_suite/1]). +-export([end_per_suite/1]). +-export([init_per_group/2]). +-export([end_per_group/2]). +-export([init_per_testcase/2]). +-export([end_per_testcase/2]). + +-export([commit_with_default_exchange/1]). +-export([partial_commit_with_exchange/1]). +-export([commit_with_exchange/1]). +-export([hold_with_disabled_exchange/1]). +-export([rollback_with_wrong_currency/1]). +-export([hold_with_wrong_operation_context/1]). +-export([rollback_with_wrong_operation_context/1]). +-export([hold_with_wrong_payment_tool/1]). +-export([rollback_with_wrong_payment_tool/1]). +-export([get_limit_ok/1]). +-export([get_limit_notfound/1]). +-export([hold_ok/1]). +-export([commit_ok/1]). +-export([rollback_ok/1]). +-export([refund_ok/1]). +-export([commit_inexistent_hold_fails/1]). +-export([partial_commit_inexistent_hold_fails/1]). +-export([commit_multirange_limit_ok/1]). +-export([commit_with_payment_tool_scope_ok/1]). + +-export([commit_processes_idempotently/1]). +-export([full_commit_processes_idempotently/1]). +-export([partial_commit_processes_idempotently/1]). +-export([rollback_processes_idempotently/1]). + +-export([commit_number_ok/1]). +-export([rollback_number_ok/1]). +-export([commit_refund_keep_number_unchanged/1]). +-export([partial_commit_number_counts_as_single_op/1]). + +-export([commit_with_party_scope_ok/1]). +-export([commit_with_provider_scope_ok/1]). +-export([commit_with_terminal_scope_ok/1]). +-export([commit_with_email_scope_ok/1]). + +-export([commit_with_wallet_scope_ok/1]). +-export([commit_with_multi_scope_ok/1]). +-export([hold_with_sender_notfound/1]). +-export([hold_with_receiver_notfound/1]). +-export([hold_with_destination_field_not_found/1]). +-export([hold_with_destination_field_not_supported/1]). +-export([commit_with_sender_scope_ok/1]). +-export([commit_with_receiver_scope_ok/1]). +-export([commit_with_sender_receiver_scope_ok/1]). +-export([commit_with_destination_field_scope_ok/1]). + +-export([batch_hold_ok/1]). +-export([batch_commit_ok/1]). +-export([batch_rollback_ok/1]). +-export([two_batch_hold_ok/1]). +-export([two_batch_commit_ok/1]). +-export([two_batch_rollback_ok/1]). +-export([retry_batch_hold_ok/1]). +-export([batch_commit_less_ok/1]). +-export([batch_commit_more_ok/1]). +-export([batch_commit_negative_ok/1]). +-export([batch_commit_negative_less_ok/1]). +-export([batch_commit_negative_more_ok/1]). + +-export([batch_with_invertable_rollback_ok/1]). +-export([batch_with_invertable_rollback_with_session_ok/1]). +-export([batch_with_invertable_commit_ok/1]). +-export([batch_with_invertable_commit_with_session_ok/1]). + +-type group_name() :: atom(). +-type test_case_name() :: atom(). + +%% tests descriptions + +-spec all() -> [{group, group_name()}]. +all() -> + [ + {group, default}, + {group, withdrawals}, + {group, cashless}, + {group, idempotency}, + {group, finalization_behaviour} + ]. + +-spec groups() -> [{atom(), list(), [test_case_name()]}]. +groups() -> + [ + {base, [], [ + commit_with_default_exchange, + partial_commit_with_exchange, + commit_with_exchange, + hold_with_disabled_exchange, + rollback_with_wrong_currency, + hold_with_wrong_operation_context, + rollback_with_wrong_operation_context, + hold_with_wrong_payment_tool, + rollback_with_wrong_payment_tool, + get_limit_ok, + get_limit_notfound, + hold_ok, + commit_ok, + rollback_ok, + refund_ok, + commit_inexistent_hold_fails, + partial_commit_inexistent_hold_fails, + commit_with_payment_tool_scope_ok, + commit_with_party_scope_ok, + commit_with_provider_scope_ok, + commit_with_terminal_scope_ok, + commit_with_email_scope_ok, + commit_with_multi_scope_ok, + hold_with_sender_notfound, + hold_with_receiver_notfound, + hold_with_destination_field_not_found, + hold_with_destination_field_not_supported + ]}, + {default, [], [ + {group, base}, + batch_hold_ok, + batch_commit_ok, + batch_rollback_ok, + two_batch_hold_ok, + two_batch_commit_ok, + two_batch_rollback_ok, + retry_batch_hold_ok, + batch_commit_less_ok, + batch_commit_more_ok, + batch_commit_negative_ok, + batch_commit_negative_less_ok, + batch_commit_negative_more_ok + ]}, + {withdrawals, [parallel], [ + get_limit_ok, + hold_ok, + commit_ok, + rollback_ok, + commit_with_party_scope_ok, + commit_with_provider_scope_ok, + commit_with_terminal_scope_ok, + commit_with_wallet_scope_ok, + commit_with_sender_scope_ok, + commit_with_receiver_scope_ok, + commit_with_sender_receiver_scope_ok, + commit_with_destination_field_scope_ok, + hold_with_destination_field_not_supported + ]}, + {cashless, [parallel], [ + commit_number_ok, + rollback_number_ok, + commit_refund_keep_number_unchanged, + partial_commit_number_counts_as_single_op + ]}, + {idempotency, [parallel], [ + commit_processes_idempotently, + full_commit_processes_idempotently, + partial_commit_processes_idempotently, + rollback_processes_idempotently + ]}, + {finalization_behaviour, [], [ + batch_with_invertable_rollback_ok, + batch_with_invertable_rollback_with_session_ok, + batch_with_invertable_commit_ok, + batch_with_invertable_commit_with_session_ok + ]} + ]. + +-type config() :: [{atom(), any()}]. + +-spec init_per_suite(config()) -> config(). +init_per_suite(Config) -> + % dbg:tracer(), dbg:p(all, c), + % dbg:tpl({lim_handler, '_', '_'}, x), + Apps = + genlib_app:start_application_with(dmt_client, [ + % milliseconds + {cache_update_interval, 5000}, + {max_cache_size, #{ + elements => 20, + % 50Mb + memory => 52428800 + }}, + {woody_event_handlers, [ + {scoper_woody_event_handler, #{ + event_handler_opts => #{ + formatter_opts => #{ + max_length => 1000 + } + } + }} + ]}, + {service_urls, #{ + 'AuthorManagement' => <<"http://dmt:8022/v1/domain/author">>, + 'Repository' => <<"http://dmt:8022/v1/domain/repository">>, + 'RepositoryClient' => <<"http://dmt:8022/v1/domain/repository_client">> + }} + ]) ++ + genlib_app:start_application_with(limiter, [ + {service_clients, #{ + liminator => #{ + url => <<"http://liminator:8022/liminator/v1">> + }, + xrates => #{ + url => <<"http://xrates:8022/xrates">> + } + }}, + {exchange_factors, #{ + <<"DEFAULT">> => {1, 1}, + <<"USD">> => {105, 100}, + <<"EUR">> => {12, 10} + }} + ]), + [{apps, Apps}] ++ Config. + +-spec end_per_suite(config()) -> _. +end_per_suite(Config) -> + genlib_app:test_application_stop(?config(apps, Config)). + +-spec init_per_group(test_case_name(), config()) -> config(). +init_per_group(_Name, C) -> + C. + +-spec end_per_group(test_case_name(), config()) -> ok. +end_per_group(_Name, _C) -> + ok. + +-spec init_per_testcase(test_case_name(), config()) -> config(). +init_per_testcase(Name, C) -> + [ + {id, gen_unique_id(Name)}, + {client, lim_client:new()}, + {test_sup, lim_mock:start_mocked_service_sup()} + | C + ]. + +-spec end_per_testcase(test_case_name(), config()) -> ok. +end_per_testcase(_Name, C) -> + _ = lim_mock:stop_mocked_service_sup(?config(test_sup, C)), + ok. + +%% + +-define(LIMIT_CHANGE(ID, Version), #limiter_LimitChange{id = ID, version = Version}). +-define(LIMIT_REQUEST(ID, Changes), #limiter_LimitRequest{operation_id = ID, limit_changes = Changes}). + +-spec commit_with_default_exchange(config()) -> _. +commit_with_default_exchange(C) -> + Rational = #base_Rational{p = 1000000, q = 100}, + _ = mock_exchange(Rational, C), + {ID, Version} = configure_limit( + ?time_range_month(), ?global(), ?turnover_metric_amount(<<"RUB">>), ?currency_conversion(), C + ), + Cost = ?cash(10000, <<"SOME_CURRENCY">>), + Context = ?payproc_ctx_invoice(Cost), + ok = hold_and_commit(?LIMIT_CHANGE(ID, Version), Context, ?config(client, C)), + {ok, #limiter_Limit{amount = 10000}} = lim_client:get(ID, Version, Context, ?config(client, C)). + +-spec partial_commit_with_exchange(config()) -> _. +partial_commit_with_exchange(C) -> + Rational = #base_Rational{p = 800000, q = 100}, + _ = mock_exchange(Rational, C), + {ID, Version} = configure_limit( + ?time_range_month(), ?global(), ?turnover_metric_amount(<<"RUB">>), ?currency_conversion(), C + ), + Cost = ?cash(1000, <<"USD">>), + CaptureCost = ?cash(800, <<"USD">>), + Context = ?payproc_ctx_payment(Cost, CaptureCost), + ok = hold_and_commit(?LIMIT_CHANGE(ID, Version), Context, ?config(client, C)), + {ok, #limiter_Limit{amount = 8400}} = lim_client:get(ID, Version, Context, ?config(client, C)). + +-spec commit_with_exchange(config()) -> _. +commit_with_exchange(C) -> + Rational = #base_Rational{p = 1000000, q = 100}, + _ = mock_exchange(Rational, C), + {ID, Version} = configure_limit( + ?time_range_month(), ?global(), ?turnover_metric_amount(<<"RUB">>), ?currency_conversion(), C + ), + Cost = ?cash(10000, <<"USD">>), + Context = ?payproc_ctx_invoice(Cost), + ok = hold_and_commit(?LIMIT_CHANGE(ID, Version), Context, ?config(client, C)), + {ok, #limiter_Limit{amount = 10500}} = lim_client:get(ID, Version, Context, ?config(client, C)). + +-spec hold_with_disabled_exchange(config()) -> _. +hold_with_disabled_exchange(C) -> + Rational = #base_Rational{p = 1000000, q = 100}, + _ = mock_exchange(Rational, C), + ConfiguredCurrency = <<"RUB">>, + {ID, Version} = configure_limit(?time_range_month(), ?global(), ?turnover_metric_amount(ConfiguredCurrency), C), + Currency = <<"USD">>, + Cost = ?cash(10000, Currency), + Context = ?payproc_ctx_invoice(Cost), + {exception, #limiter_InvalidOperationCurrency{currency = Currency, expected_currency = ConfiguredCurrency}} = + lim_client:hold(?LIMIT_CHANGE(ID, Version), Context, ?config(client, C)). + +-spec rollback_with_wrong_currency(config()) -> _. +rollback_with_wrong_currency(C) -> + Rational = #base_Rational{p = 1000000, q = 100}, + _ = mock_exchange(Rational, C), + ConfiguredCurrency = <<"RUB">>, + {ID, Version} = configure_limit(?time_range_month(), ?global(), ?turnover_metric_amount(ConfiguredCurrency), C), + Currency = <<"USD">>, + Cost = ?cash(10000, Currency), + Context = ?payproc_ctx_invoice(Cost), + {exception, #limiter_InvalidOperationCurrency{currency = Currency, expected_currency = ConfiguredCurrency}} = + lim_client:rollback(?LIMIT_CHANGE(ID, Version), Context, ?config(client, C)). + +-spec hold_with_wrong_operation_context(config()) -> _. +hold_with_wrong_operation_context(C) -> + Rational = #base_Rational{p = 1000000, q = 100}, + _ = mock_exchange(Rational, C), + {ID, Version} = configure_limit(?time_range_month(), ?global(), C), + Cost = ?cash(10000), + Context = ?wthdproc_ctx_withdrawal(Cost), + {exception, #limiter_OperationContextNotSupported{ + context_type = {withdrawal_processing, #limiter_LimitContextTypeWithdrawalProcessing{}} + }} = + lim_client:hold(?LIMIT_CHANGE(ID, Version), Context, ?config(client, C)). + +-spec rollback_with_wrong_operation_context(config()) -> _. +rollback_with_wrong_operation_context(C) -> + Rational = #base_Rational{p = 1000000, q = 100}, + _ = mock_exchange(Rational, C), + {ID, Version} = configure_limit(?time_range_month(), ?global(), C), + Cost = ?cash(10000), + Context = ?wthdproc_ctx_withdrawal(Cost), + {exception, #limiter_OperationContextNotSupported{ + context_type = {withdrawal_processing, #limiter_LimitContextTypeWithdrawalProcessing{}} + }} = + lim_client:rollback(?LIMIT_CHANGE(ID, Version), Context, ?config(client, C)). + +-spec hold_with_wrong_payment_tool(config()) -> _. +hold_with_wrong_payment_tool(C) -> + Rational = #base_Rational{p = 1000000, q = 100}, + _ = mock_exchange(Rational, C), + {ID, Version} = configure_limit(?time_range_week(), ?scopes([?scope_payment_tool()]), ?turnover_metric_number(), C), + NotSupportedPaymentTool = {crypto_currency, #domain_CryptoCurrencyRef{id = <<"wow;so-cryptic;much-hidden">>}}, + Context = ?payproc_ctx_payment(?invoice_payment(?cash(10000), ?cash(10000), NotSupportedPaymentTool)), + {exception, #limiter_PaymentToolNotSupported{payment_tool = <<"crypto_currency">>}} = + lim_client:hold(?LIMIT_CHANGE(ID, Version), Context, ?config(client, C)). + +-spec rollback_with_wrong_payment_tool(config()) -> _. +rollback_with_wrong_payment_tool(C) -> + Rational = #base_Rational{p = 1000000, q = 100}, + _ = mock_exchange(Rational, C), + {ID, Version} = configure_limit(?time_range_week(), ?scopes([?scope_payment_tool()]), ?turnover_metric_number(), C), + NotSupportedPaymentTool = {crypto_currency, #domain_CryptoCurrencyRef{id = <<"wow;so-cryptic;much-hidden">>}}, + Context = ?payproc_ctx_payment(?invoice_payment(?cash(10000), ?cash(10000), NotSupportedPaymentTool)), + {exception, #limiter_PaymentToolNotSupported{payment_tool = <<"crypto_currency">>}} = + lim_client:rollback(?LIMIT_CHANGE(ID, Version), Context, ?config(client, C)). + +-spec get_limit_ok(config()) -> _. +get_limit_ok(C) -> + {ID, Version} = configure_limit(?time_range_month(), ?global(), C), + Context = + case get_group_name(C) of + withdrawals -> ?wthdproc_ctx_withdrawal(?cash(0)); + _Default -> ?payproc_ctx_invoice(?cash(0)) + end, + ?assertMatch( + {ok, #limiter_Limit{amount = 0}}, + lim_client:get(ID, Version, Context, ?config(client, C)) + ). + +-spec get_limit_notfound(config()) -> _. +get_limit_notfound(C) -> + Version = 0, + Context = ?payproc_ctx_invoice(?cash(0)), + ?assertEqual( + {exception, #limiter_LimitNotFound{}}, + lim_client:get(<<"NOSUCHLIMITID">>, Version, Context, ?config(client, C)) + ). + +-spec hold_ok(config()) -> _. +hold_ok(C) -> + {ID, Version} = configure_limit(?time_range_month(), ?global(), C), + Context = + case get_group_name(C) of + withdrawals -> ?wthdproc_ctx_withdrawal(?cash(10)); + _Default -> ?payproc_ctx_invoice(?cash(10)) + end, + ok = lim_client:hold( + ?LIMIT_CHANGE(ID, Version), Context, ?config(client, C) + ), + {ok, #limiter_Limit{}} = lim_client:get(ID, Version, Context, ?config(client, C)). + +-spec commit_ok(config()) -> _. +commit_ok(C) -> + {ID, Version} = configure_limit(?time_range_month(), ?global(), C), + Context = + case get_group_name(C) of + withdrawals -> ?wthdproc_ctx_withdrawal(?cash(10, <<"RUB">>)); + _Default -> ?payproc_ctx_invoice(?cash(10, <<"RUB">>)) + end, + ok = hold_and_commit(?LIMIT_CHANGE(ID, Version), Context, ?config(client, C)), + {ok, #limiter_Limit{}} = lim_client:get(ID, Version, Context, ?config(client, C)). + +-spec rollback_ok(config()) -> _. +rollback_ok(C) -> + {ID, Version} = configure_limit(?time_range_week(), ?global(), C), + Context = + case get_group_name(C) of + withdrawals -> ?wthdproc_ctx_withdrawal(?cash(10, <<"RUB">>)); + _Default -> ?payproc_ctx_invoice(?cash(10, <<"RUB">>)) + end, + Change = ?LIMIT_CHANGE(ID, Version), + ok = lim_client:hold(Change, Context, ?config(client, C)), + ok = lim_client:rollback(Change, Context, ?config(client, C)). + +-spec refund_ok(config()) -> _. +refund_ok(C) -> + Client = ?config(client, C), + OwnerID = <<"WWWcool Ltd">>, + ShopID = <<"shop">>, + {ID, Version} = configure_limit(?time_range_day(), ?scopes([?scope_party(), ?scope_shop()]), C), + Context0 = ?payproc_ctx_payment(OwnerID, ShopID, ?cash(15), ?cash(15)), + RefundContext1 = ?payproc_ctx_refund(OwnerID, ShopID, ?cash(10), ?cash(10), ?cash(10)), + ok = hold_and_commit(?LIMIT_CHANGE(ID, Version), Context0, Client), + ok = hold_and_commit(?LIMIT_CHANGE(ID, Version), RefundContext1, Client), + {ok, #limiter_Limit{} = Limit2} = lim_client:get(ID, Version, RefundContext1, Client), + ?assertEqual(Limit2#limiter_Limit.amount, 5). + +-spec commit_inexistent_hold_fails(config()) -> _. +commit_inexistent_hold_fails(C) -> + {ID, Version} = configure_limit(?time_range_week(), ?global(), C), + Context = ?payproc_ctx_payment(?cash(42), undefined), + % NOTE + % We do not expect `LimitChangeNotFound` here because we no longer reconcile with accounter + % before requesting him to hold / commit. + {exception, #base_InvalidRequest{}} = + lim_client:commit(?LIMIT_CHANGE(ID, Version), Context, ?config(client, C)). + +-spec partial_commit_inexistent_hold_fails(config()) -> _. +partial_commit_inexistent_hold_fails(C) -> + {ID, Version} = configure_limit(?time_range_week(), ?global(), C), + Context = ?payproc_ctx_payment(?cash(42), ?cash(21)), + % NOTE + % We do not expect `LimitChangeNotFound` here because we no longer reconcile with accounter + % before requesting him to hold / commit. + {exception, #base_InvalidRequest{}} = + lim_client:commit(?LIMIT_CHANGE(ID, Version), Context, ?config(client, C)). + +-spec commit_multirange_limit_ok(config()) -> _. +commit_multirange_limit_ok(C) -> + ID = ?config(id, C), + Client = ?config(client, C), + Version = dmt_client:get_latest_version(), + _ = create_limit_config(ID, #limiter_config_LimitConfig{ + processor_type = <<"TurnoverProcessor">>, + started_at = <<"2000-01-01T00:00:00Z">>, + shard_size = 12, + time_range_type = ?time_range_month(), + context_type = ?ctx_type_payproc(), + type = ?lim_type_turnover(?turnover_metric_amount(<<"RUB">>)), + scopes = ?scopes([]), + description = <<"Description">>, + op_behaviour = #limiter_config_OperationLimitBehaviour{} + }), + % NOTE + % Expecting those 3 changes will be accounted in the same limit range machine. + % We have no way to verify it here though. + PaymentJan = ?invoice_payment(?cash(42), ?cash(42), ?bank_card(), <<"2020-01-01T00:00:00Z">>), + ok = hold_and_commit(?LIMIT_CHANGE(ID, 1), ?payproc_ctx_payment(PaymentJan), Client), + PaymentFeb = ?invoice_payment(?cash(43), ?cash(43), ?bank_card(), <<"2020-02-01T00:00:00Z">>), + ok = hold_and_commit(?LIMIT_CHANGE(ID, 2), ?payproc_ctx_payment(PaymentFeb), Client), + PaymentApr = ?invoice_payment(?cash(44), ?cash(44), ?bank_card(), <<"2020-04-01T00:00:00Z">>), + ok = hold_and_commit(?LIMIT_CHANGE(ID, 3), ?payproc_ctx_payment(PaymentApr), Client), + {ok, #limiter_Limit{amount = 42}} = lim_client:get(ID, Version, ?payproc_ctx_payment(PaymentJan), Client), + {ok, #limiter_Limit{amount = 43}} = lim_client:get(ID, Version, ?payproc_ctx_payment(PaymentFeb), Client), + {ok, #limiter_Limit{amount = 44}} = lim_client:get(ID, Version, ?payproc_ctx_payment(PaymentApr), Client). + +-spec commit_with_payment_tool_scope_ok(config()) -> _. +commit_with_payment_tool_scope_ok(C) -> + Client = ?config(client, C), + {ID, Version} = configure_limit(?time_range_week(), ?scopes([?scope_payment_tool()]), ?turnover_metric_number(), C), + Context1 = ?payproc_ctx_payment( + ?invoice_payment(?cash(10), ?cash(10), ?bank_card(<<"Token">>, 2, 2022)) + ), + Context2 = ?payproc_ctx_payment( + ?invoice_payment(?cash(10), ?cash(10), ?bank_card(<<"OtherToken">>, 2, 2022)) + ), + Context3 = ?payproc_ctx_payment( + ?invoice_payment(?cash(10), ?cash(10), ?bank_card(?string, 3, 2022)) + ), + Context4 = ?payproc_ctx_payment( + ?invoice_payment(?cash(10), ?cash(10), ?bank_card(?string)) + ), + Context5 = ?payproc_ctx_payment( + ?invoice_payment(?cash(10), ?cash(10), ?digital_wallet(<<"ID42">>, <<"Pepal">>)) + ), + {ok, LimitState0} = lim_client:get(ID, Version, Context1, Client), + _ = hold_and_commit(?LIMIT_CHANGE(ID, Version), Context1, Client), + _ = hold_and_commit(?LIMIT_CHANGE(ID, Version), Context2, Client), + _ = hold_and_commit(?LIMIT_CHANGE(ID, Version), Context3, Client), + _ = hold_and_commit(?LIMIT_CHANGE(ID, Version), Context4, Client), + _ = hold_and_commit(?LIMIT_CHANGE(ID, Version), Context5, Client), + {ok, LimitState1} = lim_client:get(ID, Version, Context1, Client), + ?assertEqual( + LimitState1#limiter_Limit.amount, + LimitState0#limiter_Limit.amount + 1 + ). + +%% + +-spec commit_processes_idempotently(config()) -> _. +commit_processes_idempotently(C) -> + Client = ?config(client, C), + {ID, Version} = configure_limit(?time_range_week(), ?global(), C), + Context = ?payproc_ctx_payment(?cash(42), undefined), + Change = ?LIMIT_CHANGE(ID, Version), + ok = lim_client:hold(Change, Context, Client), + ok = lim_client:hold(Change, Context, Client), + ok = lim_client:commit(Change, Context, Client), + {ok, Limit = #limiter_Limit{amount = 42}} = lim_client:get(ID, Version, Context, Client), + ok = lim_client:commit(Change, Context, Client), + {ok, Limit} = lim_client:get(ID, Version, Context, Client). + +-spec full_commit_processes_idempotently(config()) -> _. +full_commit_processes_idempotently(C) -> + Client = ?config(client, C), + {ID, Version} = configure_limit(?time_range_week(), ?global(), C), + Cost = ?cash(42), + Context = ?payproc_ctx_payment(Cost, Cost), + Change = ?LIMIT_CHANGE(ID, Version), + ok = lim_client:hold(Change, Context, Client), + ok = lim_client:hold(Change, Context, Client), + ok = lim_client:commit(Change, Context, Client), + {ok, Limit = #limiter_Limit{amount = 42}} = lim_client:get(ID, Version, Context, Client), + ok = lim_client:commit(Change, Context, Client), + {ok, Limit} = lim_client:get(ID, Version, Context, Client). + +-spec partial_commit_processes_idempotently(config()) -> _. +partial_commit_processes_idempotently(C) -> + Client = ?config(client, C), + {ID, Version} = configure_limit(?time_range_week(), ?global(), C), + Context = ?payproc_ctx_payment(?cash(42), ?cash(40)), + Change = ?LIMIT_CHANGE(ID, Version), + ok = lim_client:hold(Change, Context, Client), + ok = lim_client:hold(Change, Context, Client), + ok = lim_client:commit(Change, Context, Client), + {ok, Limit = #limiter_Limit{amount = 40}} = lim_client:get(ID, Version, Context, Client), + ok = lim_client:commit(Change, Context, Client), + {ok, Limit = #limiter_Limit{amount = 40}} = lim_client:get(ID, Version, Context, Client). + +-spec rollback_processes_idempotently(config()) -> _. +rollback_processes_idempotently(C) -> + Client = ?config(client, C), + {ID, Version} = configure_limit(?time_range_week(), ?global(), C), + Context = ?payproc_ctx_payment(?cash(42), ?cash(0)), + Change = ?LIMIT_CHANGE(ID, Version), + ok = lim_client:hold(Change, Context, Client), + ok = lim_client:hold(Change, Context, Client), + ok = lim_client:commit(Change, Context, Client), + {ok, Limit = #limiter_Limit{amount = 0}} = lim_client:get(ID, Version, Context, Client), + ok = lim_client:commit(Change, Context, Client), + {ok, Limit = #limiter_Limit{amount = 0}} = lim_client:get(ID, Version, Context, Client). + +%% + +-spec commit_number_ok(config()) -> _. +commit_number_ok(C) -> + Client = ?config(client, C), + {ID, Version} = configure_limit(?time_range_week(), ?global(), ?turnover_metric_number(), C), + Context = ?payproc_ctx_payment(?cash(10), ?cash(10)), + {ok, LimitState0} = lim_client:get(ID, Version, Context, Client), + _ = hold_and_commit(?LIMIT_CHANGE(ID, Version), Context, Client), + {ok, LimitState1} = lim_client:get(ID, Version, Context, Client), + ?assertEqual( + LimitState1#limiter_Limit.amount, + LimitState0#limiter_Limit.amount + 1 + ). + +-spec rollback_number_ok(config()) -> _. +rollback_number_ok(C) -> + Client = ?config(client, C), + {ID, Version} = configure_limit(?time_range_week(), ?global(), ?turnover_metric_number(), C), + Context = ?payproc_ctx_payment(?cash(10), ?cash(10)), + ContextRollback = ?payproc_ctx_payment(?cash(10), ?cash(0)), + {ok, LimitState0} = lim_client:get(ID, Version, Context, Client), + _ = hold_and_commit(?LIMIT_CHANGE(ID, Version), Context, ContextRollback, Client), + {ok, LimitState1} = lim_client:get(ID, Version, Context, Client), + ?assertEqual( + LimitState1#limiter_Limit.amount, + LimitState0#limiter_Limit.amount + ). + +-spec commit_refund_keep_number_unchanged(config()) -> _. +commit_refund_keep_number_unchanged(C) -> + Client = ?config(client, C), + {ID, Version} = configure_limit(?time_range_week(), ?global(), ?turnover_metric_number(), C), + Cost = ?cash(10), + CaptureCost = ?cash(8), + RefundCost = ?cash(5), + PaymentContext = ?payproc_ctx_payment(<<"OWNER">>, <<"SHOP">>, Cost, CaptureCost), + RefundContext = ?payproc_ctx_refund(<<"OWNER">>, <<"SHOP">>, Cost, CaptureCost, RefundCost), + {ok, LimitState0} = lim_client:get(ID, Version, PaymentContext, Client), + _ = hold_and_commit(?LIMIT_CHANGE(ID, Version), PaymentContext, Client), + _ = hold_and_commit(?LIMIT_CHANGE(ID, Version), RefundContext, Client), + {ok, LimitState1} = lim_client:get(ID, Version, PaymentContext, Client), + ?assertEqual( + % Expected to be the same because refund decreases counter given limit config + LimitState1#limiter_Limit.amount, + LimitState0#limiter_Limit.amount + ). + +-spec partial_commit_number_counts_as_single_op(config()) -> _. +partial_commit_number_counts_as_single_op(C) -> + Client = ?config(client, C), + {ID, Version} = configure_limit(?time_range_week(), ?global(), ?turnover_metric_number(), C), + Context = ?payproc_ctx_payment(?cash(10), ?cash(10)), + ContextPartial = ?payproc_ctx_payment(?cash(10), ?cash(5)), + {ok, LimitState0} = lim_client:get(ID, Version, Context, Client), + _ = hold_and_commit(?LIMIT_CHANGE(ID, Version), Context, ContextPartial, Client), + {ok, LimitState1} = lim_client:get(ID, Version, Context, Client), + ?assertEqual( + LimitState1#limiter_Limit.amount, + LimitState0#limiter_Limit.amount + 1 + ). + +%% + +-spec commit_with_party_scope_ok(config()) -> _. +commit_with_party_scope_ok(C) -> + _ = commit_with_some_scope(?scopes([?scope_party()]), C). + +-spec commit_with_provider_scope_ok(config()) -> _. +commit_with_provider_scope_ok(C) -> + _ = commit_with_some_scope(?scopes([?scope_provider()]), C). + +-spec commit_with_terminal_scope_ok(config()) -> _. +commit_with_terminal_scope_ok(C) -> + _ = commit_with_some_scope(?scopes([?scope_terminal()]), C). + +commit_with_some_scope(Scope, C) -> + Context = + case get_group_name(C) of + withdrawals -> ?wthdproc_ctx_withdrawal_w_auth_data(?cash(10, <<"RUB">>), ?token, ?token); + _Default -> ?payproc_ctx_payment(?cash(10, <<"RUB">>), ?cash(10, <<"RUB">>)) + end, + commit_with_some_scope(Scope, Context, C). + +commit_with_some_scope(Scope, Context, C) -> + {ID, Version} = configure_limit(?time_range_month(), Scope, C), + ok = hold_and_commit(?LIMIT_CHANGE(ID, Version), Context, ?config(client, C)), + {ok, #limiter_Limit{}} = lim_client:get(ID, Version, Context, ?config(client, C)). + +-spec commit_with_email_scope_ok(config()) -> _. +commit_with_email_scope_ok(C) -> + {ID, Version} = configure_limit(?time_range_month(), ?scopes([?scope_payer_contact_email()]), C), + Context = ?payproc_ctx_payment(?cash(10, <<"RUB">>), ?cash(10, <<"RUB">>)), + ok = hold_and_commit(?LIMIT_CHANGE(ID, Version), Context, ?config(client, C)), + {ok, #limiter_Limit{}} = lim_client:get(ID, Version, Context, ?config(client, C)). + +-spec commit_with_wallet_scope_ok(config()) -> _. +commit_with_wallet_scope_ok(C) -> + {ID, Version} = configure_limit(?time_range_month(), ?scopes([?scope_party(), ?scope_wallet()]), C), + Context = ?wthdproc_ctx_withdrawal(?cash(10, <<"RUB">>)), + ok = hold_and_commit(?LIMIT_CHANGE(ID, Version), Context, ?config(client, C)), + {ok, #limiter_Limit{}} = lim_client:get(ID, Version, Context, ?config(client, C)). + +-spec commit_with_multi_scope_ok(config()) -> _. +commit_with_multi_scope_ok(C) -> + Client = ?config(client, C), + {ID, Version} = configure_limit(?time_range_week(), ?scopes([?scope_provider(), ?scope_payment_tool()]), C), + Context1 = ?payproc_ctx_payment( + ?invoice_payment(?cash(10), ?cash(10), ?bank_card(<<"Token">>, 2, 2022)) + ), + Context2 = ?payproc_ctx_payment( + ?invoice_payment(?cash(10), ?cash(10), ?bank_card(<<"OtherToken">>, 2, 2022)) + ), + Context3 = ?payproc_ctx_payment( + ?invoice_payment(?cash(10), ?cash(10), ?bank_card(?string, 3, 2022)) + ), + Context4 = ?payproc_ctx_payment( + ?invoice_payment(?cash(10), ?cash(10), ?bank_card(?string)) + ), + Context5 = ?payproc_ctx_payment( + ?invoice_payment(?cash(10), ?cash(10), ?digital_wallet(<<"ID42">>, <<"Pepal">>)) + ), + {ok, LimitState0} = lim_client:get(ID, Version, Context1, Client), + _ = hold_and_commit(?LIMIT_CHANGE(ID, Version), Context1, Client), + _ = hold_and_commit(?LIMIT_CHANGE(ID, Version), Context2, Client), + _ = hold_and_commit(?LIMIT_CHANGE(ID, Version), Context3, Client), + _ = hold_and_commit(?LIMIT_CHANGE(ID, Version), Context4, Client), + _ = hold_and_commit(?LIMIT_CHANGE(ID, Version), Context5, Client), + {ok, LimitState1} = lim_client:get(ID, Version, Context1, Client), + ?assertEqual( + LimitState1#limiter_Limit.amount, + LimitState0#limiter_Limit.amount + 10 + ). + +-spec hold_with_sender_notfound(config()) -> _. +hold_with_sender_notfound(C) -> + hold_with_scope_notfound([?scope_sender()], C). + +-spec hold_with_receiver_notfound(config()) -> _. +hold_with_receiver_notfound(C) -> + hold_with_scope_notfound([?scope_receiver()], C). + +-spec hold_with_destination_field_not_found(config()) -> _. +hold_with_destination_field_not_found(C) -> + Scopes = [?scope_destination_field([<<"not">>, <<"existing">>, <<"field">>])], + Context = + case get_group_name(C) of + withdrawals -> ?wthdproc_ctx(?withdrawal(?cash(0), ?generic_pt(), ?string)); + _Default -> ?payproc_ctx(?invoice(?string, ?string, ?cash(0)), undefined) + end, + hold_with_scope_notfound(Scopes, Context, C). + +-spec hold_with_destination_field_not_supported(config()) -> _. +hold_with_destination_field_not_supported(C) -> + Scopes = [?scope_destination_field([<<"opaque">>, <<"payload">>, <<"data">>])], + hold_with_scope_unsupported(Scopes, C). + +hold_with_scope_notfound(Scopes, C) -> + Context = + case get_group_name(C) of + withdrawals -> ?wthdproc_ctx_withdrawal(?cash(0)); + _Default -> ?payproc_ctx_invoice(?cash(0)) + end, + hold_with_scope_notfound(Scopes, Context, C). + +hold_with_scope_notfound(Scopes, Context, C) -> + {ID, Version} = configure_limit(?time_range_month(), ?scopes(Scopes), C), + ?assertException( + error, + {woody_error, + {external, result_unexpected, <<"error:{unknown_error,{lim_turnover_processor,notfound}}", _/binary>>}}, + lim_client:hold(?LIMIT_CHANGE(ID, Version), Context, ?config(client, C)) + ). + +hold_with_scope_unsupported(Scopes, C) -> + {ID, Version} = configure_limit(?time_range_month(), ?scopes(Scopes), C), + Context = + case get_group_name(C) of + withdrawals -> + ?wthdproc_ctx(?withdrawal(?cash(10, <<"RUB">>), ?bank_card(), ?string)); + _Default -> + ?payproc_ctx( + ?op_payment, ?invoice(?string, ?string, ?cash(10, <<"RUB">>)), #context_payproc_InvoicePayment{ + payment = ?invoice_payment(?cash(10, <<"RUB">>), ?cash(10, <<"RUB">>)), + route = ?route() + } + ) + end, + ?assertException( + error, + {woody_error, + {external, result_unexpected, + <<"error:{unknown_error,{lim_turnover_processor,{unsupported,bank_card}}}", _/binary>>}}, + lim_client:hold(?LIMIT_CHANGE(ID, Version), Context, ?config(client, C)) + ). + +-spec commit_with_sender_scope_ok(config()) -> _. +commit_with_sender_scope_ok(C) -> + _ = commit_with_some_scope(?scopes([?scope_sender()]), C). + +-spec commit_with_receiver_scope_ok(config()) -> _. +commit_with_receiver_scope_ok(C) -> + _ = commit_with_some_scope(?scopes([?scope_receiver()]), C). + +-spec commit_with_sender_receiver_scope_ok(config()) -> _. +commit_with_sender_receiver_scope_ok(C) -> + _ = commit_with_some_scope(?scopes([?scope_sender(), ?scope_receiver()]), C). + +-spec commit_with_destination_field_scope_ok(config()) -> _. +commit_with_destination_field_scope_ok(C) -> + Scopes = [?scope_destination_field([<<"opaque">>, <<"payload">>, <<"data">>])], + Context = + case get_group_name(C) of + withdrawals -> ?wthdproc_ctx(?withdrawal(?cash(10, <<"RUB">>), ?generic_pt(), ?string)); + _Default -> ?payproc_ctx_payment(?cash(10, <<"RUB">>), ?cash(10, <<"RUB">>)) + end, + _ = commit_with_some_scope(?scopes(Scopes), Context, C). + +%% + +construct_request(C) -> + ID = ?config(id, C), + ?LIMIT_REQUEST(ID, [ + construct_for_limit_change(ID, 0, ?turnover_metric_amount(<<"RUB">>), undefined, C), + construct_for_limit_change(ID, 1, ?turnover_metric_amount(<<"RUB">>), undefined, C), + construct_for_limit_change(ID, 2, ?turnover_metric_amount(<<"RUB">>), undefined, C) + ]). + +-spec batch_hold_ok(config()) -> _. +batch_hold_ok(C) -> + Context = + case get_group_name(C) of + withdrawals -> ?wthdproc_ctx_withdrawal(?cash(10)); + _Default -> ?payproc_ctx_payment(?cash(10), ?cash(10)) + end, + Request = construct_request(C), + ok = hold_and_assert_batch(10, Request, Context, C). + +-spec batch_commit_ok(config()) -> _. +batch_commit_ok(C) -> + Context = + case get_group_name(C) of + withdrawals -> ?wthdproc_ctx_withdrawal(?cash(10)); + _Default -> ?payproc_ctx_payment(?cash(10), ?cash(10)) + end, + Request = construct_request(C), + ok = hold_and_assert_batch(10, Request, Context, C), + ok = lim_client:commit_batch(Request, Context, ?config(client, C)), + ok = assert_values(10, Request, Context, C). + +-spec batch_rollback_ok(config()) -> _. +batch_rollback_ok(C) -> + Context = + case get_group_name(C) of + withdrawals -> ?wthdproc_ctx_withdrawal(?cash(10)); + _Default -> ?payproc_ctx_payment(?cash(10), ?cash(10)) + end, + Request = construct_request(C), + ok = hold_and_assert_batch(10, Request, Context, C), + ok = lim_client:rollback_batch(Request, Context, ?config(client, C)), + ok = assert_values(0, Request, Context, C). + +-spec two_batch_hold_ok(config()) -> _. +two_batch_hold_ok(C) -> + Context = + case get_group_name(C) of + withdrawals -> ?wthdproc_ctx_withdrawal(?cash(10)); + _Default -> ?payproc_ctx_payment(?cash(10), ?cash(10)) + end, + ?LIMIT_REQUEST(RequestID, Changes) = Request0 = construct_request(C), + Request1 = ?LIMIT_REQUEST(genlib:format("~s/~B", [RequestID, 1000]), Changes), + ok = hold_and_assert_batch(10, Request0, Context, C), + ok = hold_and_assert_batch(20, Request1, Context, C). + +-spec two_batch_commit_ok(config()) -> _. +two_batch_commit_ok(C) -> + Context = + case get_group_name(C) of + withdrawals -> ?wthdproc_ctx_withdrawal(?cash(10)); + _Default -> ?payproc_ctx_payment(?cash(10), ?cash(10)) + end, + ?LIMIT_REQUEST(RequestID, Changes) = Request0 = construct_request(C), + Request1 = ?LIMIT_REQUEST(genlib:format("~s/~B", [RequestID, 1000]), Changes), + ok = hold_and_assert_batch(10, Request0, Context, C), + ok = lim_client:commit_batch(Request0, Context, ?config(client, C)), + ok = hold_and_assert_batch(20, Request1, Context, C), + ok = lim_client:commit_batch(Request1, Context, ?config(client, C)), + ok = assert_values(20, Request1, Context, C). + +-spec two_batch_rollback_ok(config()) -> _. +two_batch_rollback_ok(C) -> + Context = + case get_group_name(C) of + withdrawals -> ?wthdproc_ctx_withdrawal(?cash(10)); + _Default -> ?payproc_ctx_payment(?cash(10), ?cash(10)) + end, + ?LIMIT_REQUEST(RequestID, Changes) = Request0 = construct_request(C), + Request1 = ?LIMIT_REQUEST(genlib:format("~s/~B", [RequestID, 1000]), Changes), + ok = hold_and_assert_batch(10, Request0, Context, C), + ok = hold_and_assert_batch(20, Request1, Context, C), + ok = lim_client:rollback_batch(Request0, Context, ?config(client, C)), + ok = assert_values(10, Request1, Context, C), + ok = lim_client:rollback_batch(Request1, Context, ?config(client, C)), + ok = assert_values(0, Request1, Context, C). + +-spec retry_batch_hold_ok(config()) -> _. +retry_batch_hold_ok(C) -> + Context = + case get_group_name(C) of + withdrawals -> ?wthdproc_ctx_withdrawal(?cash(10)); + _Default -> ?payproc_ctx_payment(?cash(10), ?cash(10)) + end, + ?LIMIT_REQUEST(RequestID, Changes) = Request0 = construct_request(C), + Request1 = ?LIMIT_REQUEST(genlib:format("~s/~B", [RequestID, 1000]), Changes), + Request2 = ?LIMIT_REQUEST(genlib:format("~s/~B", [RequestID, 2000]), Changes), + ok = hold_and_assert_batch(10, Request0, Context, C), + ok = assert_batch(10, Request0, Context, C), + ok = hold_and_assert_batch(20, Request1, Context, C), + ok = assert_batch(10, Request0, Context, C), + ok = assert_batch(20, Request1, Context, C), + ok = hold_and_assert_batch(30, Request2, Context, C), + ok = assert_batch(10, Request0, Context, C), + ok = assert_batch(20, Request1, Context, C), + ok = assert_batch(30, Request2, Context, C), + ok = lim_client:commit_batch(Request2, Context, ?config(client, C)), + ok = assert_values(30, Request1, Context, C), + ok = assert_batch(10, Request0, Context, C), + ok = assert_batch(20, Request1, Context, C), + ok = lim_client:rollback_batch(Request1, Context, ?config(client, C)), + ok = assert_values(20, Request1, Context, C), + ok = assert_batch(10, Request0, Context, C), + ok = lim_client:commit_batch(Request0, Context, ?config(client, C)), + ok = assert_values(20, Request1, Context, C). + +-spec batch_commit_less_ok(config()) -> _. +batch_commit_less_ok(C) -> + Cost = ?cash(1000, <<"RUB">>), + CaptureCost = ?cash(800, <<"RUB">>), + Context = + case get_group_name(C) of + withdrawals -> ?wthdproc_ctx_withdrawal(Cost); + _Default -> ?payproc_ctx_payment(Cost, CaptureCost) + end, + Request = construct_request(C), + ok = hold_and_assert_batch(1000, Request, Context, C), + ok = lim_client:commit_batch(Request, Context, ?config(client, C)), + ok = assert_values(800, Request, Context, C). + +-spec batch_commit_more_ok(config()) -> _. +batch_commit_more_ok(C) -> + Cost = ?cash(1000, <<"RUB">>), + CaptureCost = ?cash(1200, <<"RUB">>), + Context = + case get_group_name(C) of + withdrawals -> ?wthdproc_ctx_withdrawal(Cost); + _Default -> ?payproc_ctx_payment(Cost, CaptureCost) + end, + Request = construct_request(C), + ok = hold_and_assert_batch(1000, Request, Context, C), + {exception, #base_InvalidRequest{errors = [<<"OperationNotFound">>]}} = lim_client:commit_batch( + Request, Context, ?config(client, C) + ). + +-spec batch_commit_negative_ok(config()) -> _. +batch_commit_negative_ok(C) -> + Cost = ?cash(-1000, <<"RUB">>), + CaptureCost = ?cash(-1000, <<"RUB">>), + Context = + case get_group_name(C) of + withdrawals -> ?wthdproc_ctx_withdrawal(Cost); + _Default -> ?payproc_ctx_payment(Cost, CaptureCost) + end, + Request = construct_request(C), + ok = hold_and_assert_batch(-1000, Request, Context, C), + ok = lim_client:commit_batch(Request, Context, ?config(client, C)), + ok = assert_values(-1000, Request, Context, C). + +-spec batch_commit_negative_less_ok(config()) -> _. +batch_commit_negative_less_ok(C) -> + Cost = ?cash(-1000, <<"RUB">>), + CaptureCost = ?cash(-800, <<"RUB">>), + Context = + case get_group_name(C) of + withdrawals -> ?wthdproc_ctx_withdrawal(Cost); + _Default -> ?payproc_ctx_payment(Cost, CaptureCost) + end, + Request = construct_request(C), + ok = hold_and_assert_batch(-1000, Request, Context, C), + ok = lim_client:commit_batch(Request, Context, ?config(client, C)), + ok = assert_values(-800, Request, Context, C). + +-spec batch_commit_negative_more_ok(config()) -> _. +batch_commit_negative_more_ok(C) -> + Cost = ?cash(-1000, <<"RUB">>), + CaptureCost = ?cash(-1200, <<"RUB">>), + Context = + case get_group_name(C) of + withdrawals -> ?wthdproc_ctx_withdrawal(Cost); + _Default -> ?payproc_ctx_payment(Cost, CaptureCost) + end, + Request = construct_request(C), + ok = hold_and_assert_batch(-1000, Request, Context, C), + {exception, #base_InvalidRequest{errors = [<<"OperationNotFound">>]}} = lim_client:commit_batch( + Request, Context, ?config(client, C) + ). + +%% Finalization behaviour group + +-spec batch_with_invertable_rollback_ok(config()) -> _. +batch_with_invertable_rollback_ok(C) -> + Context0 = ?payproc_ctx_payment(?string, ?string, ?cash(10), ?cash(10), undefined), + ?LIMIT_REQUEST(_RequestID, _Changes) = Request0 = construct_request_with_invertable(C), + ok = hold_and_assert_batch_with_invertable({1, 1, 10}, Request0, Context0, C), + Context1 = ?payproc_ctx_payment(?string, ?string, ?cash(10), ?cash(10), undefined), + ok = lim_client:rollback_batch(Request0, Context1, ?config(client, C)), + ok = assert_values_with_invertable({0, 0, 0}, Request0, Context1, C). + +-spec batch_with_invertable_rollback_with_session_ok(config()) -> _. +batch_with_invertable_rollback_with_session_ok(C) -> + Context0 = ?payproc_ctx_payment(?string, ?string, ?cash(10), ?cash(10), undefined), + ?LIMIT_REQUEST(_RequestID, _Changes) = Request0 = construct_request_with_invertable(C), + ok = hold_and_assert_batch_with_invertable({1, 1, 10}, Request0, Context0, C), + Context1 = ?payproc_ctx_payment(?string, ?string, ?cash(10), ?cash(10), ?payproc_ctx_session), + ok = lim_client:rollback_batch(Request0, Context1, ?config(client, C)), + ok = assert_values_with_invertable({1, 0, 0}, Request0, Context1, C). + +-spec batch_with_invertable_commit_ok(config()) -> _. +batch_with_invertable_commit_ok(C) -> + Context0 = ?payproc_ctx_payment(?string, ?string, ?cash(10), ?cash(10), undefined), + ?LIMIT_REQUEST(_RequestID, _Changes) = Request0 = construct_request_with_invertable(C), + ok = hold_and_assert_batch_with_invertable({1, 1, 10}, Request0, Context0, C), + Context1 = ?payproc_ctx_payment(?string, ?string, ?cash(10), ?cash(10), undefined), + ok = lim_client:commit_batch(Request0, Context1, ?config(client, C)), + ok = assert_values_with_invertable({1, 1, 10}, Request0, Context1, C). + +-spec batch_with_invertable_commit_with_session_ok(config()) -> _. +batch_with_invertable_commit_with_session_ok(C) -> + Context0 = ?payproc_ctx_payment(?string, ?string, ?cash(10), ?cash(10), undefined), + ?LIMIT_REQUEST(_RequestID, _Changes) = Request0 = construct_request_with_invertable(C), + ok = hold_and_assert_batch_with_invertable({1, 1, 10}, Request0, Context0, C), + Context1 = ?payproc_ctx_payment(?string, ?string, ?cash(10), ?cash(10), ?payproc_ctx_session), + ok = lim_client:commit_batch(Request0, Context1, ?config(client, C)), + ok = assert_values_with_invertable({0, 1, 10}, Request0, Context1, C). + +construct_request_with_invertable(C) -> + ID = ?config(id, C), + ?LIMIT_REQUEST(ID, [ + construct_for_limit_change(ID, 0, ?turnover_metric_number(), ?finalization_behaviour_invertable_by_session, C), + construct_for_limit_change(ID, 1, ?turnover_metric_number(), ?finalization_behaviour_normal, C), + construct_for_limit_change(ID, 2, ?turnover_metric_amount(<<"RUB">>), undefined, C) + ]). + +hold_and_assert_batch_with_invertable({Value0, Value1, Value2}, Request0, Context, C) -> + {ok, LimitStats} = lim_client:hold_batch(Request0, Context, ?config(client, C)), + %% NOTE Split operations for invertablity can break order of items in + %% response and mismatch it for limit changes provided in request. + [LimitState0, LimitState1, LimitState2] = lists:sort(LimitStats), + ?assertEqual(Value0, LimitState0#limiter_Limit.amount), + ?assertEqual(Value1, LimitState1#limiter_Limit.amount), + ?assertEqual(Value2, LimitState2#limiter_Limit.amount), + {ok, [LimitState0 | [LimitState1 | [LimitState2]]]} = lim_client:get_values(Request0, Context, ?config(client, C)), + ok. + +assert_values_with_invertable({Value0, Value1, Value2}, Request0, Context, C) -> + {ok, LimitStats} = lim_client:get_values(Request0, Context, ?config(client, C)), + [LimitState0, LimitState1, LimitState2] = lists:sort(LimitStats), + ?assertEqual(Value0, LimitState0#limiter_Limit.amount), + ?assertEqual(Value1, LimitState1#limiter_Limit.amount), + ?assertEqual(Value2, LimitState2#limiter_Limit.amount), + ok. + +%% + +construct_for_limit_change(BaseID, Num, Metric, FinalizationBehaviour, C) -> + {ID, Version} = configure_limit( + ?time_range_month(), + ?scopes([?scope_provider(), ?scope_payment_tool()]), + Metric, + undefined, + genlib:format("~s/~B", [BaseID, Num]), + FinalizationBehaviour, + C + ), + ?LIMIT_CHANGE(ID, Version). + +hold_and_assert_batch(Value, Request0, Context, C) -> + {ok, [LimitState0 | [LimitState1 | [LimitState2]]]} = lim_client:hold_batch(Request0, Context, ?config(client, C)), + ?assertEqual(Value, LimitState0#limiter_Limit.amount), + ?assertEqual(Value, LimitState1#limiter_Limit.amount), + ?assertEqual(Value, LimitState2#limiter_Limit.amount), + {ok, [LimitState0 | [LimitState1 | [LimitState2]]]} = lim_client:get_values(Request0, Context, ?config(client, C)), + ok. + +assert_batch(BatchValue, Request0, Context, C) -> + {ok, [LimitState0 | [LimitState1 | [LimitState2]]]} = lim_client:get_batch(Request0, Context, ?config(client, C)), + ?assertEqual(BatchValue, LimitState0#limiter_Limit.amount), + ?assertEqual(BatchValue, LimitState1#limiter_Limit.amount), + ?assertEqual(BatchValue, LimitState2#limiter_Limit.amount), + ok. + +assert_values(Value, Request0, Context, C) -> + {ok, [LimitState0 | [LimitState1 | [LimitState2]]]} = lim_client:get_values(Request0, Context, ?config(client, C)), + ?assertEqual(Value, LimitState0#limiter_Limit.amount), + ?assertEqual(Value, LimitState1#limiter_Limit.amount), + ?assertEqual(Value, LimitState2#limiter_Limit.amount), + ok. + +hold_and_commit(Change, Context, Client) -> + hold_and_commit(Change, Context, Context, Client). + +hold_and_commit(?LIMIT_CHANGE(ID, Version) = Change, Context, ContextCommit, Client) -> + OperationID = lim_string:join($., [<<"operation">>, ID, integer_to_binary(Version), genlib:unique()]), + {ok, _} = lim_client:hold_batch(?LIMIT_REQUEST(OperationID, [Change]), Context, Client), + ok = lim_client:commit_batch(?LIMIT_REQUEST(OperationID, [Change]), ContextCommit, Client). + +mock_exchange(Rational, C) -> + lim_mock:mock_services([{xrates, fun('GetConvertedAmount', _) -> {ok, Rational} end}], C). + +configure_limit(TimeRange, Scopes, C) -> + configure_limit(TimeRange, Scopes, ?turnover_metric_amount(<<"RUB">>), C). + +configure_limit(TimeRange, Scopes, Metric, C) -> + configure_limit(TimeRange, Scopes, Metric, undefined, C). + +configure_limit(TimeRange, Scopes, Metric, CurrencyConversion, C) -> + configure_limit(TimeRange, Scopes, Metric, CurrencyConversion, ?config(id, C), C). + +configure_limit(TimeRange, Scopes, Metric, CurrencyConversion, ID, C) -> + configure_limit(TimeRange, Scopes, Metric, CurrencyConversion, ID, {normal, #limiter_config_Normal{}}, C). + +configure_limit(TimeRange, Scopes, Metric, CurrencyConversion, ID, FinalizationBehaviour, C) when is_list(Scopes) -> + ContextType = + case get_group_name(C) of + withdrawals -> ?ctx_type_wthdproc(); + _Default -> ?ctx_type_payproc() + end, + create_limit_config(ID, #limiter_config_LimitConfig{ + processor_type = <<"TurnoverProcessor">>, + started_at = <<"2000-01-01T00:00:00Z">>, + shard_size = 1, + time_range_type = TimeRange, + context_type = ContextType, + type = ?lim_type_turnover(Metric), + scopes = Scopes, + description = <<"Description">>, + op_behaviour = ?op_behaviour(?op_subtraction()), + currency_conversion = CurrencyConversion, + finalization_behaviour = FinalizationBehaviour + }). + +create_limit_config(ID, #limiter_config_LimitConfig{} = LimitConfig) -> + LimitConfigObject = #domain_LimitConfigObject{ + ref = #domain_LimitConfigRef{id = ID}, + data = LimitConfig + }, + Version = dmt_client:insert({limit_config, LimitConfigObject}, ensure_stub_author()), + {ID, Version}. + +gen_unique_id(Prefix) -> + genlib:format("~s/~B", [Prefix, lim_time:now()]). + +get_group_name(C) -> + GroupProps = ?config(tc_group_properties, C), + proplists:get_value(name, GroupProps). + +ensure_stub_author() -> + %% TODO DISCUSS Stubs and fallback authors + ensure_author(~b"unknown", ~b"unknown@local"). + +ensure_author(Name, Email) -> + try + #domain_conf_v2_Author{id = ID} = dmt_client:get_author_by_email(Email), + ID + catch + throw:#domain_conf_v2_AuthorNotFound{} -> + dmt_client:create_author(Name, Email) + end. diff --git a/compose.yaml b/compose.yaml index f8842668..8170e410 100644 --- a/compose.yaml +++ b/compose.yaml @@ -40,7 +40,7 @@ services: db: condition: service_healthy healthcheck: - test: "/opt/dmt/bin/dmt ping" + test: ["CMD", "/opt/dmt/bin/dmt", "ping"] interval: 5s timeout: 3s retries: 12 @@ -54,19 +54,24 @@ services: db: condition: service_healthy healthcheck: - test: "/opt/bender/bin/bender ping" + test: ["CMD", "/opt/bender/bin/bender", "ping"] interval: 10s timeout: 5s retries: 10 limiter: - image: ghcr.io/valitydev/limiter:sha-67cf0b7 - command: /opt/limiter/bin/limiter foreground + image: paycore:local-dev + build: + context: . + args: + - OTP_VERSION + - THRIFT_VERSION + command: ["/opt/limiter/bin/limiter", "foreground"] depends_on: liminator: condition: service_healthy healthcheck: - test: "/opt/limiter/bin/limiter ping" + test: ["CMD", "/opt/limiter/bin/limiter", "ping"] interval: 5s timeout: 1s retries: 20 @@ -106,7 +111,7 @@ services: db: condition: service_healthy healthcheck: - test: "curl http://localhost:8022/actuator/health" + test: ["CMD", "curl", "http://localhost:8022/actuator/health"] interval: 5s timeout: 1s retries: 20 @@ -124,7 +129,7 @@ services: shumway: condition: service_started healthcheck: - test: "/opt/party-management/bin/party-management ping" + test: ["CMD", "/opt/party-management/bin/party-management", "ping"] interval: 10s timeout: 5s retries: 10 @@ -138,7 +143,7 @@ services: db: condition: service_healthy healthcheck: - test: "/opt/cs/bin/cs ping" + test: ["CMD", "/opt/cs/bin/cs", "ping"] interval: 5s timeout: 3s retries: 20 diff --git a/config/sys.config b/config/sys.config index 8647be8e..8a20d8c2 100644 --- a/config/sys.config +++ b/config/sys.config @@ -197,6 +197,11 @@ {machine_id, hostname_hash} ]}, + {os_mon, [ + % for better compatibility with busybox coreutils + {disksup_posix_only, true} + ]}, + {prometheus, [ {collectors, [default]} ]}, @@ -427,5 +432,79 @@ } } }} + ]}, + + {limiter, [ + {ip, "::"}, + {port, 8022}, + {services, #{ + limiter => #{ + path => <<"/v1/limiter">> + }, + configurator => #{ + path => <<"/v1/configurator">> + } + }}, + {service_clients, #{ + liminator => #{ + url => <<"http://liminator:8022/liminator/v1">> + }, + accounter => #{ + url => <<"http://shumway:8022/accounter">> + }, + automaton => #{ + url => <<"http://machinegun:8022/v1/automaton">> + }, + xrates => #{ + url => <<"http://xrates:8022/xrates">> + } + }}, + + {exchange_factors, #{ + <<"DEFAULT">> => {1, 1}, + <<"USD">> => {105, 100}, + <<"EUR">> => {12, 10} + }}, + + {protocol_opts, #{ + % How much to wait for another request before closing a keepalive connection? (ms) + request_timeout => 5000, + % Should be greater than any other timeouts + idle_timeout => infinity + }}, + {transport_opts, #{ + % Maximum number of simultaneous connections. (default = 1024) + max_connections => 8000, + % Size of the acceptor pool. (default = 10) + num_acceptors => 100 + }}, + % How much to wait for outstanding requests completion when asked to shut down? (ms) + {shutdown_timeout, 1000}, + + {woody_event_handlers, [ + {scoper_woody_event_handler, #{ + event_handler_opts => #{ + formatter_opts => #{ + max_length => 1000, + max_printable_string_length => 80 + } + } + }} + ]}, + + {scoper_event_handler_options, #{ + event_handler_opts => #{ + formatter_opts => #{ + max_length => 1000, + max_printable_string_length => 80 + } + } + }}, + + {health_check, #{ + % disk => {erl_health, disk , ["/", 99]}, + % memory => {erl_health, cg_memory, [99]}, + % service => {erl_health, service , [<<"limiter">>]} + }} ]} ]. diff --git a/elvis.config b/elvis.config index bff14eb7..d58a7ba0 100644 --- a/elvis.config +++ b/elvis.config @@ -115,7 +115,8 @@ hg_invoice_helper, hg_invoice_tests_SUITE, hg_invoice_template_tests_SUITE, - hg_direct_recurrent_tests_SUITE + hg_direct_recurrent_tests_SUITE, + lim_turnover_SUITE ] }}, {elvis_style, no_debug_call, #{}}, diff --git a/rebar.config b/rebar.config index 1983cfeb..2735c4dc 100644 --- a/rebar.config +++ b/rebar.config @@ -1,4 +1,4 @@ -% Common project erlang options. +%% Common project erlang options. {erl_opts, [ % mandatory debug_info, @@ -24,7 +24,7 @@ % warn_missing_spec_all ]}. -% Common project dependencies. +%% Common project dependencies. {deps, [ {cowboy, "2.12.0"}, {recon, "2.5.2"}, @@ -53,6 +53,8 @@ {binbase_proto, {git, "https://github.com/valitydev/binbase-proto.git", {branch, "master"}}}, {validator_personal_data_proto, {git, "https://github.com/valitydev/validator-personal-data-proto.git", {branch, "master"}}}, + {liminator_proto, {git, "https://github.com/valitydev/liminator-proto.git", {branch, "master"}}}, + {xrates_proto, {git, "https://github.com/valitydev/xrates-proto.git", {branch, "master"}}}, {prometheus, "4.11.0"}, {prometheus_cowboy, "0.1.9"}, @@ -66,6 +68,7 @@ {git_subdir, "https://github.com/whatsapp/eqwalizer.git", {branch, "main"}, "eqwalizer_support"}} ]}. +%% XRef checks {xref_checks, [ % mandatory undefined_function_calls, @@ -74,6 +77,7 @@ deprecated_functions ]}. +%% Dialyzer static analyzing {dialyzer, [ {warnings, [ % mandatory @@ -87,6 +91,7 @@ {profiles, [ {prod, [ {deps, [ + % for introspection on production {recon, "2.5.2"}, {logger_logstash_formatter, {git, "https://github.com/valitydev/logger_logstash_formatter.git", {ref, "08a66a6"}}} @@ -113,6 +118,18 @@ sasl, ff_server ]}, + %% TODO Package it with core apps! Not as separate release. + {release, {limiter, "1.0.0"}, [ + {recon, load}, + {runtime_tools, load}, + {tools, load}, + {opentelemetry, temporary}, + {logger_logstash_formatter, load}, + prometheus, + prometheus_cowboy, + sasl, + limiter + ]}, {mode, minimal}, {sys_config, "./config/sys.config"}, {overlay, [ diff --git a/rebar.lock b/rebar.lock index 1797ceff..6c5f3f42 100644 --- a/rebar.lock +++ b/rebar.lock @@ -51,7 +51,7 @@ 2}, {<<"eqwalizer_support">>, {git_subdir,"https://github.com/whatsapp/eqwalizer.git", - {ref,"c439b2c8f8a8fac76a59a29a802eca9e1dc2aee8"}, + {ref,"01f151ca206bb019db0999a2c9c8c8aaae79bbab"}, "eqwalizer_support"}, 0}, {<<"erl_health">>, @@ -87,6 +87,10 @@ {<<"jsone">>,{pkg,<<"jsone">>,<<"1.8.0">>},3}, {<<"jsx">>,{pkg,<<"jsx">>,<<"3.1.0">>},1}, {<<"kafka_protocol">>,{pkg,<<"kafka_protocol">>,<<"4.1.10">>},2}, + {<<"liminator_proto">>, + {git,"https://github.com/valitydev/liminator-proto.git", + {ref,"d63ded1d138416e4278d7fbe57d7359c9113f7ea"}}, + 0}, {<<"limiter_proto">>, {git,"https://github.com/valitydev/limiter-proto.git", {ref,"ced168a6ea11f80d62485b793b3605de68223e38"}}, @@ -98,7 +102,7 @@ {<<"metrics">>,{pkg,<<"metrics">>,<<"1.0.1">>},2}, {<<"mg_proto">>, {git,"https://github.com/valitydev/machinegun-proto.git", - {ref,"cc2c27c30d30dc34c0c56fc7c7e96326d6bd6a14"}}, + {ref,"273f2f35cf5714b073923520a33c3bf1099ec42c"}}, 0}, {<<"mimerl">>,{pkg,<<"mimerl">>,<<"1.5.0">>},2}, {<<"msgpack_proto">>, @@ -147,7 +151,7 @@ {ref,"3a60e5dc5bbd709495024f26e100b041c3547fd9"}}, 0}, {<<"tls_certificate_check">>, - {pkg,<<"tls_certificate_check">>,<<"1.31.0">>}, + {pkg,<<"tls_certificate_check">>,<<"1.33.0">>}, 1}, {<<"unicode_util_compat">>,{pkg,<<"unicode_util_compat">>,<<"0.7.1">>},2}, {<<"uuid">>, @@ -161,6 +165,10 @@ {<<"woody">>, {git,"https://github.com/valitydev/woody_erlang.git", {ref,"7194b5d97fc2f68fffaf22c0278c7036b48dd11b"}}, + 0}, + {<<"xrates_proto">>, + {git,"https://github.com/valitydev/xrates-proto.git", + {ref,"bb96f8c153f92a2065a414ae5261bad13ba16d2d"}}, 0}]}. [ {pkg_hash,[ @@ -196,7 +204,7 @@ {<<"ranch">>, <<"8C7A100A139FD57F17327B6413E4167AC559FBC04CA7448E9BE9057311597A1D">>}, {<<"recon">>, <<"CBA53FA8DB83AD968C9A652E09C3ED7DDCC4DA434F27C3EAA9CA47FFB2B1FF03">>}, {<<"ssl_verify_fun">>, <<"354C321CF377240C7B8716899E182CE4890C5938111A1296ADD3EC74CF1715DF">>}, - {<<"tls_certificate_check">>, <<"9A910B54D8CB96CC810CABF4C0129F21360F82022B20180849F1442A25CCBB04">>}, + {<<"tls_certificate_check">>, <<"01E0822A2EBB0B207C4964E8D32AD8B1B8CE1CAF58F446E53F816D06DB953DE4">>}, {<<"unicode_util_compat">>, <<"A48703A25C170EEDADCA83B11E88985AF08D35F37C6F664D6DCFB106A97782FC">>}]}, {pkg_hash_ext,[ {<<"accept">>, <<"CA69388943F5DAD2E7232A5478F16086E3C872F48E32B88B378E1885A59F5649">>}, @@ -231,6 +239,6 @@ {<<"ranch">>, <<"49FBCFD3682FAB1F5D109351B61257676DA1A2FDBE295904176D5E521A2DDFE5">>}, {<<"recon">>, <<"2C7523C8DEE91DFF41F6B3D63CBA2BD49EB6D2FE5BF1EEC0DF7F87EB5E230E1C">>}, {<<"ssl_verify_fun">>, <<"FE4C190E8F37401D30167C8C405EDA19469F34577987C76DDE613E838BBC67F8">>}, - {<<"tls_certificate_check">>, <<"9D2B41B128D5507BD8AD93E1A998E06D0AB2F9A772AF343F4C00BF76C6BE1532">>}, + {<<"tls_certificate_check">>, <<"CAB9A7439E2DBFE91B38104F2D8A4B6D61DBC4D3A5AD59AC364713A88C6CFD9B">>}, {<<"unicode_util_compat">>, <<"B3A917854CE3AE233619744AD1E0102E05673136776FB2FA76234F3E03B23642">>}]} ].