diff --git a/apps/hellgate/src/hg_invoice.erl b/apps/hellgate/src/hg_invoice.erl index fedb9379..da82a9c6 100644 --- a/apps/hellgate/src/hg_invoice.erl +++ b/apps/hellgate/src/hg_invoice.erl @@ -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) -> diff --git a/apps/hellgate/src/hg_invoice_handler.erl b/apps/hellgate/src/hg_invoice_handler.erl index bc75807c..27581c8c 100644 --- a/apps/hellgate/src/hg_invoice_handler.erl +++ b/apps/hellgate/src/hg_invoice_handler.erl @@ -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) -> diff --git a/apps/hellgate/src/hg_invoice_mutation.erl b/apps/hellgate/src/hg_invoice_mutation.erl new file mode 100644 index 00000000..e828b9fc --- /dev/null +++ b/apps/hellgate/src/hg_invoice_mutation.erl @@ -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. diff --git a/apps/hellgate/src/hg_invoice_template.erl b/apps/hellgate/src/hg_invoice_template.erl index b2bc83a0..4df3dc7f 100644 --- a/apps/hellgate/src/hg_invoice_template.erl +++ b/apps/hellgate/src/hg_invoice_template.erl @@ -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) -> diff --git a/apps/hellgate/test/hg_ct_helper.erl b/apps/hellgate/test/hg_ct_helper.erl index fc7266a8..89a70d68 100644 --- a/apps/hellgate/test/hg_ct_helper.erl +++ b/apps/hellgate/test/hg_ct_helper.erl @@ -480,7 +480,7 @@ make_shop_params(Category, ContractID, PayoutToolID) -> make_party_params() -> #payproc_PartyParams{ contact_info = #domain_PartyContactInfo{ - email = <> + registration_email = <> } }. diff --git a/rebar.lock b/rebar.lock index aa5cc9b5..06c36220 100644 --- a/rebar.lock +++ b/rebar.lock @@ -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",