Skip to content
17 changes: 12 additions & 5 deletions apps/hellgate/src/hg_invoice.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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]).
Expand Down Expand Up @@ -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,
Expand All @@ -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) ->
Expand Down
33 changes: 23 additions & 10 deletions apps/hellgate/src/hg_invoice_handler.erl
Original file line number Diff line number Diff line change
Expand Up @@ -40,33 +40,35 @@ 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(),
InvoiceID = Params#payproc_InvoiceWithTemplateParams.id,
_ = 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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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)),
Expand All @@ -359,19 +362,29 @@ make_invoice_params(Params) ->
due = InvoiceDue,
cost = InvoiceCost,
context = InvoiceContext,
external_id = ExternalID
external_id = ExternalID,
mutations = MutationsParams
},
{Party, Shop, InvoiceParams}.

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) ->
Expand Down
239 changes: 239 additions & 0 deletions apps/hellgate/src/hg_invoice_mutation.erl
Original file line number Diff line number Diff line change
@@ -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.
Loading