Skip to content

Commit 47b4b8e

Browse files
authored
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
1 parent 0f88093 commit 47b4b8e

File tree

6 files changed

+288
-28
lines changed

6 files changed

+288
-28
lines changed

apps/hellgate/src/hg_invoice.erl

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
-export([get/1]).
3737
-export([get_payment/2]).
3838
-export([get_payment_opts/1]).
39-
-export([create/5]).
39+
-export([create/6]).
4040
-export([marshal_invoice/1]).
4141
-export([unmarshal_history/1]).
4242
-export([collapse_history/1]).
@@ -126,13 +126,20 @@ get_payment_opts(Revision, _, St = #st{invoice = Invoice}) ->
126126
timestamp => hg_datetime:format_now()
127127
}.
128128

129-
-spec create(hg_machine:id(), undefined | hg_machine:id(), hg_party:party_revision(), invoice_params(), allocation()) ->
129+
-spec create(
130+
hg_machine:id(),
131+
undefined | hg_machine:id(),
132+
hg_party:party_revision(),
133+
invoice_params(),
134+
allocation(),
135+
[hg_invoice_mutation:mutation()]
136+
) ->
130137
invoice().
131-
create(ID, InvoiceTplID, PartyRevision, V = #payproc_InvoiceParams{}, Allocation) ->
138+
create(ID, InvoiceTplID, PartyRevision, V = #payproc_InvoiceParams{}, Allocation, Mutations) ->
132139
OwnerID = V#payproc_InvoiceParams.party_id,
133140
ShopID = V#payproc_InvoiceParams.shop_id,
134141
Cost = V#payproc_InvoiceParams.cost,
135-
#domain_Invoice{
142+
hg_invoice_mutation:apply_mutations(Mutations, #domain_Invoice{
136143
id = ID,
137144
shop_id = ShopID,
138145
owner_id = OwnerID,
@@ -147,7 +154,7 @@ create(ID, InvoiceTplID, PartyRevision, V = #payproc_InvoiceParams{}, Allocation
147154
external_id = V#payproc_InvoiceParams.external_id,
148155
client_info = V#payproc_InvoiceParams.client_info,
149156
allocation = Allocation
150-
}.
157+
}).
151158

152159
%%----------------- invoice asserts
153160
assert_invoice(Checks, #st{} = St) when is_list(Checks) ->

apps/hellgate/src/hg_invoice_handler.erl

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -40,33 +40,35 @@ handle_function_('Create', {InvoiceParams}, _Opts) ->
4040
Party = hg_party:get_party(PartyID),
4141
Shop = assert_shop_exists(hg_party:get_shop(ShopID, Party)),
4242
_ = assert_party_shop_operable(Shop, Party),
43+
ok = validate_invoice_mutations(InvoiceParams),
44+
{Cost, Mutations} = maybe_make_mutations(InvoiceParams),
4345
VS = #{
44-
cost => InvoiceParams#payproc_InvoiceParams.cost,
46+
cost => Cost,
4547
shop_id => Shop#domain_Shop.id
4648
},
4749
MerchantTerms = hg_invoice_utils:get_merchant_terms(Party, Shop, DomainRevision, hg_datetime:format_now(), VS),
4850
ok = validate_invoice_params(InvoiceParams, Shop, MerchantTerms),
4951
AllocationPrototype = InvoiceParams#payproc_InvoiceParams.allocation,
50-
Cost = InvoiceParams#payproc_InvoiceParams.cost,
5152
Allocation = maybe_allocation(AllocationPrototype, Cost, MerchantTerms, Party, Shop),
52-
ok = ensure_started(InvoiceID, undefined, Party#domain_Party.revision, InvoiceParams, Allocation),
53+
ok = ensure_started(InvoiceID, undefined, Party#domain_Party.revision, InvoiceParams, Allocation, Mutations),
5354
get_invoice_state(get_state(InvoiceID));
5455
handle_function_('CreateWithTemplate', {Params}, _Opts) ->
5556
DomainRevision = hg_domain:head(),
5657
InvoiceID = Params#payproc_InvoiceWithTemplateParams.id,
5758
_ = set_invoicing_meta(InvoiceID),
5859
TplID = Params#payproc_InvoiceWithTemplateParams.template_id,
5960
{Party, Shop, InvoiceParams} = make_invoice_params(Params),
61+
ok = validate_invoice_mutations(InvoiceParams),
62+
{Cost, Mutations} = maybe_make_mutations(InvoiceParams),
6063
VS = #{
61-
cost => InvoiceParams#payproc_InvoiceParams.cost,
64+
cost => Cost,
6265
shop_id => Shop#domain_Shop.id
6366
},
6467
MerchantTerms = hg_invoice_utils:get_merchant_terms(Party, Shop, DomainRevision, hg_datetime:format_now(), VS),
6568
ok = validate_invoice_params(InvoiceParams, Shop, MerchantTerms),
6669
AllocationPrototype = InvoiceParams#payproc_InvoiceParams.allocation,
67-
Cost = InvoiceParams#payproc_InvoiceParams.cost,
6870
Allocation = maybe_allocation(AllocationPrototype, Cost, MerchantTerms, Party, Shop),
69-
ok = ensure_started(InvoiceID, TplID, Party#domain_Party.revision, InvoiceParams, Allocation),
71+
ok = ensure_started(InvoiceID, TplID, Party#domain_Party.revision, InvoiceParams, Allocation, Mutations),
7072
get_invoice_state(get_state(InvoiceID));
7173
handle_function_('CapturePaymentNew', Args, Opts) ->
7274
handle_function_('CapturePayment', Args, Opts);
@@ -146,8 +148,8 @@ handle_function_('ExplainRoute', {InvoiceID, PaymentID}, _Opts) ->
146148
St = get_state(InvoiceID),
147149
hg_routing_explanation:get_explanation(get_payment_session(PaymentID, St), hg_invoice:get_payment_opts(St)).
148150

149-
ensure_started(ID, TemplateID, PartyRevision, Params, Allocation) ->
150-
Invoice = hg_invoice:create(ID, TemplateID, PartyRevision, Params, Allocation),
151+
ensure_started(ID, TemplateID, PartyRevision, Params, Allocation, Mutations) ->
152+
Invoice = hg_invoice:create(ID, TemplateID, PartyRevision, Params, Allocation, Mutations),
151153
case hg_machine:start(hg_invoice:namespace(), ID, hg_invoice:marshal_invoice(Invoice)) of
152154
{ok, _} -> ok;
153155
{error, exists} -> ok;
@@ -337,7 +339,8 @@ make_invoice_params(Params) ->
337339
product = Product,
338340
description = Description,
339341
details = TplDetails,
340-
context = TplContext
342+
context = TplContext,
343+
mutations = MutationsParams
341344
} = hg_invoice_template:get(TplID),
342345
Party = hg_party:get_party(PartyID),
343346
Shop = assert_shop_exists(hg_party:get_shop(ShopID, Party)),
@@ -359,19 +362,29 @@ make_invoice_params(Params) ->
359362
due = InvoiceDue,
360363
cost = InvoiceCost,
361364
context = InvoiceContext,
362-
external_id = ExternalID
365+
external_id = ExternalID,
366+
mutations = MutationsParams
363367
},
364368
{Party, Shop, InvoiceParams}.
365369

366370
validate_invoice_params(#payproc_InvoiceParams{cost = Cost}, Shop, MerchantTerms) ->
367371
ok = validate_invoice_cost(Cost, Shop, MerchantTerms),
368372
ok.
369373

374+
validate_invoice_mutations(#payproc_InvoiceParams{mutations = Mutations, details = Details}) ->
375+
hg_invoice_mutation:validate_mutations(Mutations, Details).
376+
370377
validate_invoice_cost(Cost, Shop, #domain_TermSet{payments = PaymentTerms}) ->
371378
_ = hg_invoice_utils:validate_cost(Cost, Shop),
372379
_ = hg_invoice_utils:assert_cost_payable(Cost, PaymentTerms),
373380
ok.
374381

382+
maybe_make_mutations(InvoiceParams) ->
383+
Cost = InvoiceParams#payproc_InvoiceParams.cost,
384+
Mutations = hg_invoice_mutation:make_mutations(InvoiceParams#payproc_InvoiceParams.mutations, #{cost => Cost}),
385+
NewCost = hg_invoice_mutation:get_mutated_cost(Mutations, Cost),
386+
{NewCost, Mutations}.
387+
375388
make_invoice_cart(_, {cart, Cart}, _Shop) ->
376389
Cart;
377390
make_invoice_cart(Cost, {product, TplProduct}, Shop) ->
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
-module(hg_invoice_mutation).
2+
3+
-include_lib("damsel/include/dmsl_base_thrift.hrl").
4+
-include_lib("damsel/include/dmsl_domain_thrift.hrl").
5+
6+
-export([make_mutations/2]).
7+
-export([get_mutated_cost/2]).
8+
-export([validate_mutations/2]).
9+
-export([apply_mutations/2]).
10+
11+
-type mutation_params() :: dmsl_domain_thrift:'InvoiceMutationParams'().
12+
-type mutation() :: dmsl_domain_thrift:'InvoiceMutation'().
13+
-type mutation_context() :: #{
14+
cost := hg_cash:cash()
15+
}.
16+
17+
-export_type([mutation_params/0]).
18+
-export_type([mutation/0]).
19+
20+
%%
21+
22+
-spec get_mutated_cost([mutation()], Cost) -> Cost when Cost :: hg_cash:cash().
23+
get_mutated_cost(Mutations, Cost) ->
24+
lists:foldl(
25+
fun
26+
({amount, #domain_InvoiceAmountMutation{mutated = MutatedAmount}}, C) ->
27+
C#domain_Cash{amount = MutatedAmount};
28+
(_, C) ->
29+
C
30+
end,
31+
Cost,
32+
Mutations
33+
).
34+
35+
-type invoice_details() :: dmsl_domain_thrift:'InvoiceDetails'().
36+
-type invoice_template_details() :: dmsl_domain_thrift:'InvoiceTemplateDetails'().
37+
38+
-spec validate_mutations([mutation_params()], invoice_details() | invoice_template_details()) -> ok.
39+
validate_mutations(Mutations, #domain_InvoiceDetails{cart = #domain_InvoiceCart{} = Cart}) ->
40+
validate_mutations_w_cart(Mutations, Cart);
41+
validate_mutations(Mutations, {cart, #domain_InvoiceCart{} = Cart}) ->
42+
validate_mutations_w_cart(Mutations, Cart);
43+
validate_mutations(_Mutations, _Details) ->
44+
ok.
45+
46+
validate_mutations_w_cart(Mutations, #domain_InvoiceCart{lines = Lines}) ->
47+
Mutations1 = genlib:define(Mutations, []),
48+
amount_mutation_is_present(Mutations1) andalso cart_is_valid_for_mutation(Lines) andalso
49+
throw(#base_InvalidRequest{
50+
errors = [<<"Amount mutation with multiline cart or multiple items in a line is not allowed">>]
51+
}),
52+
ok.
53+
54+
amount_mutation_is_present(Mutations) ->
55+
lists:any(
56+
fun
57+
({amount, _}) -> true;
58+
(_) -> false
59+
end,
60+
Mutations
61+
).
62+
63+
cart_is_valid_for_mutation(Lines) ->
64+
length(Lines) > 1 orelse (hd(Lines))#domain_InvoiceLine.quantity =/= 1.
65+
66+
-spec apply_mutations([mutation_params()] | undefined, Invoice) -> Invoice when Invoice :: hg_invoice:invoice().
67+
apply_mutations(MutationsParams, Invoice) ->
68+
lists:foldl(fun apply_mutation/2, Invoice, genlib:define(MutationsParams, [])).
69+
70+
apply_mutation(Mutation = {amount, #domain_InvoiceAmountMutation{mutated = NewAmount}}, Invoice) ->
71+
#domain_Invoice{cost = Cost, mutations = Mutations} = Invoice,
72+
update_invoice_details_price(NewAmount, Invoice#domain_Invoice{
73+
cost = Cost#domain_Cash{amount = NewAmount},
74+
mutations = genlib:define(Mutations, []) ++ [Mutation]
75+
});
76+
apply_mutation(_, Invoice) ->
77+
Invoice.
78+
79+
update_invoice_details_price(NewAmount, Invoice) ->
80+
#domain_Invoice{details = Details} = Invoice,
81+
#domain_InvoiceDetails{cart = Cart} = Details,
82+
#domain_InvoiceCart{lines = [Line]} = Cart,
83+
NewLines = [update_invoice_line_price(NewAmount, Line)],
84+
NewCart = Cart#domain_InvoiceCart{lines = NewLines},
85+
Invoice#domain_Invoice{details = Details#domain_InvoiceDetails{cart = NewCart}}.
86+
87+
update_invoice_line_price(NewAmount, Line = #domain_InvoiceLine{price = Price}) ->
88+
Line#domain_InvoiceLine{price = Price#domain_Cash{amount = NewAmount}}.
89+
90+
-spec make_mutations([mutation_params()], mutation_context()) -> [mutation()].
91+
make_mutations(MutationsParams, Context) ->
92+
{Mutations, _} = lists:foldl(fun make_mutation/2, {[], Context}, genlib:define(MutationsParams, [])),
93+
lists:reverse(Mutations).
94+
95+
-define(SATISFY_RANDOMIZATION_CONDITION(P, Amount),
96+
%% Multiplicity check
97+
(P#domain_RandomizationMutationParams.amount_multiplicity_condition =:= undefined orelse
98+
Amount rem P#domain_RandomizationMutationParams.amount_multiplicity_condition =:= 0) andalso
99+
%% Min amount
100+
(P#domain_RandomizationMutationParams.min_amount_condition =:= undefined orelse
101+
P#domain_RandomizationMutationParams.min_amount_condition =< Amount) andalso
102+
%% Max amount
103+
(P#domain_RandomizationMutationParams.max_amount_condition =:= undefined orelse
104+
P#domain_RandomizationMutationParams.max_amount_condition >= Amount)
105+
).
106+
107+
make_mutation(
108+
{amount, {randomization, Params = #domain_RandomizationMutationParams{}}},
109+
{Mutations, Context = #{cost := #domain_Cash{amount = Amount}}}
110+
) when ?SATISFY_RANDOMIZATION_CONDITION(Params, Amount) ->
111+
NewMutation =
112+
{amount, #domain_InvoiceAmountMutation{original = Amount, mutated = calc_new_amount(Amount, Params)}},
113+
{[NewMutation | Mutations], Context};
114+
make_mutation(_, {Mutations, Context}) ->
115+
{Mutations, Context}.
116+
117+
calc_new_amount(Amount, #domain_RandomizationMutationParams{deviation = MaxDeviation, precision = Precision}) ->
118+
Deviation = calc_deviation(MaxDeviation, trunc(math:pow(10, Precision))),
119+
Sign = trunc(math:pow(-1, rand:uniform(2))),
120+
Amount + Sign * Deviation.
121+
122+
calc_deviation(MaxDeviation, PrecisionFactor) ->
123+
Deviation0 = rand:uniform(MaxDeviation + 1) - 1,
124+
erlang:round(Deviation0 / PrecisionFactor) * PrecisionFactor.
125+
126+
%%
127+
128+
-ifdef(TEST).
129+
-include_lib("eunit/include/eunit.hrl").
130+
131+
-spec test() -> _.
132+
133+
-define(mutations(Deviation, Precision, Min, Max, Multiplicity), [
134+
{amount,
135+
{randomization, #domain_RandomizationMutationParams{
136+
deviation = Deviation,
137+
precision = Precision,
138+
min_amount_condition = Min,
139+
max_amount_condition = Max,
140+
amount_multiplicity_condition = Multiplicity
141+
}}}
142+
]).
143+
144+
-define(cash(Amount), #domain_Cash{amount = Amount, currency = ?currency()}).
145+
146+
-define(currency(), #domain_CurrencyRef{symbolic_code = <<"RUB">>}).
147+
148+
-define(invoice(Amount, Lines, Mutations), #domain_Invoice{
149+
id = <<"invoice">>,
150+
shop_id = <<"shop_id">>,
151+
owner_id = <<"owner_id">>,
152+
created_at = <<"1970-01-01T00:00:00Z">>,
153+
status = {unpaid, #domain_InvoiceUnpaid{}},
154+
cost = ?cash(Amount),
155+
due = <<"1970-01-01T00:00:00Z">>,
156+
details = #domain_InvoiceDetails{
157+
product = <<"rubberduck">>,
158+
cart = #domain_InvoiceCart{lines = Lines}
159+
},
160+
mutations = Mutations
161+
}).
162+
163+
-define(mutated_invoice(OriginalAmount, MutatedAmount, Lines),
164+
?invoice(MutatedAmount, Lines, [
165+
{amount, #domain_InvoiceAmountMutation{original = OriginalAmount, mutated = MutatedAmount}}
166+
])
167+
).
168+
169+
-define(not_mutated_invoice(Amount, Lines), ?invoice(Amount, Lines, undefined)).
170+
171+
-define(cart_line(Price), #domain_InvoiceLine{
172+
product = <<"product">>,
173+
quantity = 1,
174+
price = ?cash(Price),
175+
metadata = #{}
176+
}).
177+
178+
-spec apply_mutations_test_() -> [_TestGen].
179+
apply_mutations_test_() ->
180+
lists:flatten([
181+
%% Didn't mutate because of conditions
182+
?_assertEqual(
183+
?not_mutated_invoice(1000_00, [?cart_line(1000_00)]),
184+
apply_mutations(
185+
make_mutations(?mutations(100_00, 2, 0, 100_00, 1_00), #{
186+
cost => ?cash(1000_00)
187+
}),
188+
?not_mutated_invoice(1000_00, [?cart_line(1000_00)])
189+
)
190+
),
191+
?_assertEqual(
192+
?not_mutated_invoice(1234_00, [?cart_line(1234_00)]),
193+
apply_mutations(
194+
make_mutations(?mutations(100_00, 2, 0, 1000_00, 7_00), #{
195+
cost => ?cash(1234_00)
196+
}),
197+
?not_mutated_invoice(1234_00, [?cart_line(1234_00)])
198+
)
199+
),
200+
201+
%% No deviation, stil did mutate, but amount is same
202+
?_assertEqual(
203+
?mutated_invoice(100_00, 100_00, [?cart_line(100_00)]),
204+
apply_mutations(
205+
make_mutations(?mutations(0, 2, 0, 1000_00, 1_00), #{
206+
cost => ?cash(100_00)
207+
}),
208+
?not_mutated_invoice(100_00, [?cart_line(100_00)])
209+
)
210+
),
211+
212+
%% Deviate only with 2 other possible values
213+
[
214+
?_assertMatch(
215+
?mutated_invoice(100_00, A, [?cart_line(A)]) when
216+
A =:= 0 orelse A =:= 100_00 orelse A =:= 200_00,
217+
apply_mutations(
218+
make_mutations(Mutations, #{cost => ?cash(100_00)}),
219+
?not_mutated_invoice(100_00, [?cart_line(100_00)])
220+
)
221+
)
222+
|| Mutations <- lists:duplicate(10, ?mutations(100_00, 4, 0, 1000_00, 1_00))
223+
],
224+
225+
%% Deviate in segment [900_00, 1100_00] without minor units
226+
[
227+
?_assertMatch(
228+
?mutated_invoice(1000_00, A, [?cart_line(A)]) when
229+
A >= 900_00 andalso A =< 1100_00 andalso A rem 100 =:= 0,
230+
apply_mutations(
231+
make_mutations(Mutations, #{cost => ?cash(1000_00)}),
232+
?not_mutated_invoice(1000_00, [?cart_line(1000_00)])
233+
)
234+
)
235+
|| Mutations <- lists:duplicate(10, ?mutations(100_00, 2, 0, 1000_00, 1_00))
236+
]
237+
]).
238+
239+
-endif.

0 commit comments

Comments
 (0)