OPS-474: Adds support for amount randomization invoice mutation (#136)

* OPS-474: Adds support for amount randomization invoice mutation

* Bumps dmt_client and party_client

* Completes amount mutation with cart's line price and adds unit tests

* Refactors amount mutation func

* Moves mutations into separate module

* Refactors into separate make and apply mutation functions

* Fixes make_mutations foldl

* Fixes cart validation clause

* Retires 'rounding' option in amount randomization
This commit is contained in:
Aleksey Kashapov 2024-06-20 12:11:19 +03:00 committed by GitHub
parent 0f88093b53
commit 47b4b8e8bb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 288 additions and 28 deletions

View File

@ -36,7 +36,7 @@
-export([get/1]).
-export([get_payment/2]).
-export([get_payment_opts/1]).
-export([create/5]).
-export([create/6]).
-export([marshal_invoice/1]).
-export([unmarshal_history/1]).
-export([collapse_history/1]).
@ -126,13 +126,20 @@ get_payment_opts(Revision, _, St = #st{invoice = Invoice}) ->
timestamp => hg_datetime:format_now()
}.
-spec create(hg_machine:id(), undefined | hg_machine:id(), hg_party:party_revision(), invoice_params(), allocation()) ->
-spec create(
hg_machine:id(),
undefined | hg_machine:id(),
hg_party:party_revision(),
invoice_params(),
allocation(),
[hg_invoice_mutation:mutation()]
) ->
invoice().
create(ID, InvoiceTplID, PartyRevision, V = #payproc_InvoiceParams{}, Allocation) ->
create(ID, InvoiceTplID, PartyRevision, V = #payproc_InvoiceParams{}, Allocation, Mutations) ->
OwnerID = V#payproc_InvoiceParams.party_id,
ShopID = V#payproc_InvoiceParams.shop_id,
Cost = V#payproc_InvoiceParams.cost,
#domain_Invoice{
hg_invoice_mutation:apply_mutations(Mutations, #domain_Invoice{
id = ID,
shop_id = ShopID,
owner_id = OwnerID,
@ -147,7 +154,7 @@ create(ID, InvoiceTplID, PartyRevision, V = #payproc_InvoiceParams{}, Allocation
external_id = V#payproc_InvoiceParams.external_id,
client_info = V#payproc_InvoiceParams.client_info,
allocation = Allocation
}.
}).
%%----------------- invoice asserts
assert_invoice(Checks, #st{} = St) when is_list(Checks) ->

View File

@ -40,16 +40,17 @@ handle_function_('Create', {InvoiceParams}, _Opts) ->
Party = hg_party:get_party(PartyID),
Shop = assert_shop_exists(hg_party:get_shop(ShopID, Party)),
_ = assert_party_shop_operable(Shop, Party),
ok = validate_invoice_mutations(InvoiceParams),
{Cost, Mutations} = maybe_make_mutations(InvoiceParams),
VS = #{
cost => InvoiceParams#payproc_InvoiceParams.cost,
cost => Cost,
shop_id => Shop#domain_Shop.id
},
MerchantTerms = hg_invoice_utils:get_merchant_terms(Party, Shop, DomainRevision, hg_datetime:format_now(), VS),
ok = validate_invoice_params(InvoiceParams, Shop, MerchantTerms),
AllocationPrototype = InvoiceParams#payproc_InvoiceParams.allocation,
Cost = InvoiceParams#payproc_InvoiceParams.cost,
Allocation = maybe_allocation(AllocationPrototype, Cost, MerchantTerms, Party, Shop),
ok = ensure_started(InvoiceID, undefined, Party#domain_Party.revision, InvoiceParams, Allocation),
ok = ensure_started(InvoiceID, undefined, Party#domain_Party.revision, InvoiceParams, Allocation, Mutations),
get_invoice_state(get_state(InvoiceID));
handle_function_('CreateWithTemplate', {Params}, _Opts) ->
DomainRevision = hg_domain:head(),
@ -57,16 +58,17 @@ handle_function_('CreateWithTemplate', {Params}, _Opts) ->
_ = set_invoicing_meta(InvoiceID),
TplID = Params#payproc_InvoiceWithTemplateParams.template_id,
{Party, Shop, InvoiceParams} = make_invoice_params(Params),
ok = validate_invoice_mutations(InvoiceParams),
{Cost, Mutations} = maybe_make_mutations(InvoiceParams),
VS = #{
cost => InvoiceParams#payproc_InvoiceParams.cost,
cost => Cost,
shop_id => Shop#domain_Shop.id
},
MerchantTerms = hg_invoice_utils:get_merchant_terms(Party, Shop, DomainRevision, hg_datetime:format_now(), VS),
ok = validate_invoice_params(InvoiceParams, Shop, MerchantTerms),
AllocationPrototype = InvoiceParams#payproc_InvoiceParams.allocation,
Cost = InvoiceParams#payproc_InvoiceParams.cost,
Allocation = maybe_allocation(AllocationPrototype, Cost, MerchantTerms, Party, Shop),
ok = ensure_started(InvoiceID, TplID, Party#domain_Party.revision, InvoiceParams, Allocation),
ok = ensure_started(InvoiceID, TplID, Party#domain_Party.revision, InvoiceParams, Allocation, Mutations),
get_invoice_state(get_state(InvoiceID));
handle_function_('CapturePaymentNew', Args, Opts) ->
handle_function_('CapturePayment', Args, Opts);
@ -146,8 +148,8 @@ handle_function_('ExplainRoute', {InvoiceID, PaymentID}, _Opts) ->
St = get_state(InvoiceID),
hg_routing_explanation:get_explanation(get_payment_session(PaymentID, St), hg_invoice:get_payment_opts(St)).
ensure_started(ID, TemplateID, PartyRevision, Params, Allocation) ->
Invoice = hg_invoice:create(ID, TemplateID, PartyRevision, Params, Allocation),
ensure_started(ID, TemplateID, PartyRevision, Params, Allocation, Mutations) ->
Invoice = hg_invoice:create(ID, TemplateID, PartyRevision, Params, Allocation, Mutations),
case hg_machine:start(hg_invoice:namespace(), ID, hg_invoice:marshal_invoice(Invoice)) of
{ok, _} -> ok;
{error, exists} -> ok;
@ -337,7 +339,8 @@ make_invoice_params(Params) ->
product = Product,
description = Description,
details = TplDetails,
context = TplContext
context = TplContext,
mutations = MutationsParams
} = hg_invoice_template:get(TplID),
Party = hg_party:get_party(PartyID),
Shop = assert_shop_exists(hg_party:get_shop(ShopID, Party)),
@ -359,7 +362,8 @@ make_invoice_params(Params) ->
due = InvoiceDue,
cost = InvoiceCost,
context = InvoiceContext,
external_id = ExternalID
external_id = ExternalID,
mutations = MutationsParams
},
{Party, Shop, InvoiceParams}.
@ -367,11 +371,20 @@ validate_invoice_params(#payproc_InvoiceParams{cost = Cost}, Shop, MerchantTerms
ok = validate_invoice_cost(Cost, Shop, MerchantTerms),
ok.
validate_invoice_mutations(#payproc_InvoiceParams{mutations = Mutations, details = Details}) ->
hg_invoice_mutation:validate_mutations(Mutations, Details).
validate_invoice_cost(Cost, Shop, #domain_TermSet{payments = PaymentTerms}) ->
_ = hg_invoice_utils:validate_cost(Cost, Shop),
_ = hg_invoice_utils:assert_cost_payable(Cost, PaymentTerms),
ok.
maybe_make_mutations(InvoiceParams) ->
Cost = InvoiceParams#payproc_InvoiceParams.cost,
Mutations = hg_invoice_mutation:make_mutations(InvoiceParams#payproc_InvoiceParams.mutations, #{cost => Cost}),
NewCost = hg_invoice_mutation:get_mutated_cost(Mutations, Cost),
{NewCost, Mutations}.
make_invoice_cart(_, {cart, Cart}, _Shop) ->
Cart;
make_invoice_cart(Cost, {product, TplProduct}, Shop) ->

View File

@ -0,0 +1,239 @@
-module(hg_invoice_mutation).
-include_lib("damsel/include/dmsl_base_thrift.hrl").
-include_lib("damsel/include/dmsl_domain_thrift.hrl").
-export([make_mutations/2]).
-export([get_mutated_cost/2]).
-export([validate_mutations/2]).
-export([apply_mutations/2]).
-type mutation_params() :: dmsl_domain_thrift:'InvoiceMutationParams'().
-type mutation() :: dmsl_domain_thrift:'InvoiceMutation'().
-type mutation_context() :: #{
cost := hg_cash:cash()
}.
-export_type([mutation_params/0]).
-export_type([mutation/0]).
%%
-spec get_mutated_cost([mutation()], Cost) -> Cost when Cost :: hg_cash:cash().
get_mutated_cost(Mutations, Cost) ->
lists:foldl(
fun
({amount, #domain_InvoiceAmountMutation{mutated = MutatedAmount}}, C) ->
C#domain_Cash{amount = MutatedAmount};
(_, C) ->
C
end,
Cost,
Mutations
).
-type invoice_details() :: dmsl_domain_thrift:'InvoiceDetails'().
-type invoice_template_details() :: dmsl_domain_thrift:'InvoiceTemplateDetails'().
-spec validate_mutations([mutation_params()], invoice_details() | invoice_template_details()) -> ok.
validate_mutations(Mutations, #domain_InvoiceDetails{cart = #domain_InvoiceCart{} = Cart}) ->
validate_mutations_w_cart(Mutations, Cart);
validate_mutations(Mutations, {cart, #domain_InvoiceCart{} = Cart}) ->
validate_mutations_w_cart(Mutations, Cart);
validate_mutations(_Mutations, _Details) ->
ok.
validate_mutations_w_cart(Mutations, #domain_InvoiceCart{lines = Lines}) ->
Mutations1 = genlib:define(Mutations, []),
amount_mutation_is_present(Mutations1) andalso cart_is_valid_for_mutation(Lines) andalso
throw(#base_InvalidRequest{
errors = [<<"Amount mutation with multiline cart or multiple items in a line is not allowed">>]
}),
ok.
amount_mutation_is_present(Mutations) ->
lists:any(
fun
({amount, _}) -> true;
(_) -> false
end,
Mutations
).
cart_is_valid_for_mutation(Lines) ->
length(Lines) > 1 orelse (hd(Lines))#domain_InvoiceLine.quantity =/= 1.
-spec apply_mutations([mutation_params()] | undefined, Invoice) -> Invoice when Invoice :: hg_invoice:invoice().
apply_mutations(MutationsParams, Invoice) ->
lists:foldl(fun apply_mutation/2, Invoice, genlib:define(MutationsParams, [])).
apply_mutation(Mutation = {amount, #domain_InvoiceAmountMutation{mutated = NewAmount}}, Invoice) ->
#domain_Invoice{cost = Cost, mutations = Mutations} = Invoice,
update_invoice_details_price(NewAmount, Invoice#domain_Invoice{
cost = Cost#domain_Cash{amount = NewAmount},
mutations = genlib:define(Mutations, []) ++ [Mutation]
});
apply_mutation(_, Invoice) ->
Invoice.
update_invoice_details_price(NewAmount, Invoice) ->
#domain_Invoice{details = Details} = Invoice,
#domain_InvoiceDetails{cart = Cart} = Details,
#domain_InvoiceCart{lines = [Line]} = Cart,
NewLines = [update_invoice_line_price(NewAmount, Line)],
NewCart = Cart#domain_InvoiceCart{lines = NewLines},
Invoice#domain_Invoice{details = Details#domain_InvoiceDetails{cart = NewCart}}.
update_invoice_line_price(NewAmount, Line = #domain_InvoiceLine{price = Price}) ->
Line#domain_InvoiceLine{price = Price#domain_Cash{amount = NewAmount}}.
-spec make_mutations([mutation_params()], mutation_context()) -> [mutation()].
make_mutations(MutationsParams, Context) ->
{Mutations, _} = lists:foldl(fun make_mutation/2, {[], Context}, genlib:define(MutationsParams, [])),
lists:reverse(Mutations).
-define(SATISFY_RANDOMIZATION_CONDITION(P, Amount),
%% Multiplicity check
(P#domain_RandomizationMutationParams.amount_multiplicity_condition =:= undefined orelse
Amount rem P#domain_RandomizationMutationParams.amount_multiplicity_condition =:= 0) andalso
%% Min amount
(P#domain_RandomizationMutationParams.min_amount_condition =:= undefined orelse
P#domain_RandomizationMutationParams.min_amount_condition =< Amount) andalso
%% Max amount
(P#domain_RandomizationMutationParams.max_amount_condition =:= undefined orelse
P#domain_RandomizationMutationParams.max_amount_condition >= Amount)
).
make_mutation(
{amount, {randomization, Params = #domain_RandomizationMutationParams{}}},
{Mutations, Context = #{cost := #domain_Cash{amount = Amount}}}
) when ?SATISFY_RANDOMIZATION_CONDITION(Params, Amount) ->
NewMutation =
{amount, #domain_InvoiceAmountMutation{original = Amount, mutated = calc_new_amount(Amount, Params)}},
{[NewMutation | Mutations], Context};
make_mutation(_, {Mutations, Context}) ->
{Mutations, Context}.
calc_new_amount(Amount, #domain_RandomizationMutationParams{deviation = MaxDeviation, precision = Precision}) ->
Deviation = calc_deviation(MaxDeviation, trunc(math:pow(10, Precision))),
Sign = trunc(math:pow(-1, rand:uniform(2))),
Amount + Sign * Deviation.
calc_deviation(MaxDeviation, PrecisionFactor) ->
Deviation0 = rand:uniform(MaxDeviation + 1) - 1,
erlang:round(Deviation0 / PrecisionFactor) * PrecisionFactor.
%%
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
-spec test() -> _.
-define(mutations(Deviation, Precision, Min, Max, Multiplicity), [
{amount,
{randomization, #domain_RandomizationMutationParams{
deviation = Deviation,
precision = Precision,
min_amount_condition = Min,
max_amount_condition = Max,
amount_multiplicity_condition = Multiplicity
}}}
]).
-define(cash(Amount), #domain_Cash{amount = Amount, currency = ?currency()}).
-define(currency(), #domain_CurrencyRef{symbolic_code = <<"RUB">>}).
-define(invoice(Amount, Lines, Mutations), #domain_Invoice{
id = <<"invoice">>,
shop_id = <<"shop_id">>,
owner_id = <<"owner_id">>,
created_at = <<"1970-01-01T00:00:00Z">>,
status = {unpaid, #domain_InvoiceUnpaid{}},
cost = ?cash(Amount),
due = <<"1970-01-01T00:00:00Z">>,
details = #domain_InvoiceDetails{
product = <<"rubberduck">>,
cart = #domain_InvoiceCart{lines = Lines}
},
mutations = Mutations
}).
-define(mutated_invoice(OriginalAmount, MutatedAmount, Lines),
?invoice(MutatedAmount, Lines, [
{amount, #domain_InvoiceAmountMutation{original = OriginalAmount, mutated = MutatedAmount}}
])
).
-define(not_mutated_invoice(Amount, Lines), ?invoice(Amount, Lines, undefined)).
-define(cart_line(Price), #domain_InvoiceLine{
product = <<"product">>,
quantity = 1,
price = ?cash(Price),
metadata = #{}
}).
-spec apply_mutations_test_() -> [_TestGen].
apply_mutations_test_() ->
lists:flatten([
%% Didn't mutate because of conditions
?_assertEqual(
?not_mutated_invoice(1000_00, [?cart_line(1000_00)]),
apply_mutations(
make_mutations(?mutations(100_00, 2, 0, 100_00, 1_00), #{
cost => ?cash(1000_00)
}),
?not_mutated_invoice(1000_00, [?cart_line(1000_00)])
)
),
?_assertEqual(
?not_mutated_invoice(1234_00, [?cart_line(1234_00)]),
apply_mutations(
make_mutations(?mutations(100_00, 2, 0, 1000_00, 7_00), #{
cost => ?cash(1234_00)
}),
?not_mutated_invoice(1234_00, [?cart_line(1234_00)])
)
),
%% No deviation, stil did mutate, but amount is same
?_assertEqual(
?mutated_invoice(100_00, 100_00, [?cart_line(100_00)]),
apply_mutations(
make_mutations(?mutations(0, 2, 0, 1000_00, 1_00), #{
cost => ?cash(100_00)
}),
?not_mutated_invoice(100_00, [?cart_line(100_00)])
)
),
%% Deviate only with 2 other possible values
[
?_assertMatch(
?mutated_invoice(100_00, A, [?cart_line(A)]) when
A =:= 0 orelse A =:= 100_00 orelse A =:= 200_00,
apply_mutations(
make_mutations(Mutations, #{cost => ?cash(100_00)}),
?not_mutated_invoice(100_00, [?cart_line(100_00)])
)
)
|| Mutations <- lists:duplicate(10, ?mutations(100_00, 4, 0, 1000_00, 1_00))
],
%% Deviate in segment [900_00, 1100_00] without minor units
[
?_assertMatch(
?mutated_invoice(1000_00, A, [?cart_line(A)]) when
A >= 900_00 andalso A =< 1100_00 andalso A rem 100 =:= 0,
apply_mutations(
make_mutations(Mutations, #{cost => ?cash(1000_00)}),
?not_mutated_invoice(1000_00, [?cart_line(1000_00)])
)
)
|| Mutations <- lists:duplicate(10, ?mutations(100_00, 2, 0, 1000_00, 1_00))
]
]).
-endif.

View File

@ -2,6 +2,7 @@
-module(hg_invoice_template).
-include_lib("damsel/include/dmsl_base_thrift.hrl").
-include_lib("damsel/include/dmsl_domain_thrift.hrl").
-include_lib("damsel/include/dmsl_payproc_thrift.hrl").
@ -115,17 +116,17 @@ get_shop(ShopID, Party) ->
set_meta(ID) ->
scoper:add_meta(#{invoice_template_id => ID}).
validate_create_params(#payproc_InvoiceTemplateCreateParams{details = Details}, Shop) ->
ok = validate_details(Details, Shop).
validate_create_params(#payproc_InvoiceTemplateCreateParams{details = Details, mutations = Mutations}, Shop) ->
ok = validate_details(Details, Mutations, Shop).
validate_update_params(#payproc_InvoiceTemplateUpdateParams{details = undefined}, _) ->
ok;
validate_update_params(#payproc_InvoiceTemplateUpdateParams{details = Details}, Shop) ->
ok = validate_details(Details, Shop).
validate_update_params(#payproc_InvoiceTemplateUpdateParams{details = Details, mutations = Mutations}, Shop) ->
ok = validate_details(Details, Mutations, Shop).
validate_details({cart, #domain_InvoiceCart{}}, _) ->
ok;
validate_details({product, #domain_InvoiceTemplateProduct{price = Price}}, Shop) ->
validate_details({cart, #domain_InvoiceCart{}} = Details, Mutations, _) ->
hg_invoice_mutation:validate_mutations(Mutations, Details);
validate_details({product, #domain_InvoiceTemplateProduct{price = Price}}, _, Shop) ->
validate_price(Price, Shop).
validate_price({fixed, Cash}, Shop) ->

View File

@ -480,7 +480,7 @@ make_shop_params(Category, ContractID, PayoutToolID) ->
make_party_params() ->
#payproc_PartyParams{
contact_info = #domain_PartyContactInfo{
email = <<?MODULE_STRING>>
registration_email = <<?MODULE_STRING>>
}
}.

View File

@ -21,15 +21,15 @@
{<<"ctx">>,{pkg,<<"ctx">>,<<"0.6.0">>},2},
{<<"damsel">>,
{git,"https://github.com/valitydev/damsel.git",
{ref,"b04aba83100a4d0adc19b5797372970fd632f911"}},
{ref,"c170117e5fde4ebdc6878e75dcd37ca2779dfb82"}},
0},
{<<"dmt_client">>,
{git,"https://github.com/valitydev/dmt-client.git",
{ref,"b8bc0281dbf1e55a1a67ef6da861e0353ff14913"}},
{ref,"d8a4f490d49c038d96f1cbc2a279164c6f4039f9"}},
0},
{<<"dmt_core">>,
{git,"https://github.com/valitydev/dmt-core.git",
{ref,"75841332fe0b40a77da0c12ea8d5dbb994da8e82"}},
{ref,"19d8f57198f2cbe5b64aa4a923ba32774e505503"}},
1},
{<<"erl_health">>,
{git,"https://github.com/valitydev/erlang-health.git",
@ -51,7 +51,7 @@
{<<"jsx">>,{pkg,<<"jsx">>,<<"3.1.0">>},1},
{<<"limiter_proto">>,
{git,"https://github.com/valitydev/limiter-proto.git",
{ref,"e045813d32e67432e5592d582e59e45df05da647"}},
{ref,"10328404f1cea68586962ed7fce0405b18d62b28"}},
0},
{<<"metrics">>,{pkg,<<"metrics">>,<<"1.0.1">>},2},
{<<"mg_proto">>,
@ -74,7 +74,7 @@
{<<"parse_trans">>,{pkg,<<"parse_trans">>,<<"3.3.1">>},2},
{<<"party_client">>,
{git,"https://github.com/valitydev/party-client-erlang.git",
{ref,"38c7782286877a63087c19de49f26ab175a37de7"}},
{ref,"a82682b6f55f41ff4962b2666bbd12cb5f1ece25"}},
0},
{<<"payproc_errors">>,
{git,"https://github.com/valitydev/payproc-errors-erlang.git",