Move refunds to new module

This commit is contained in:
Rustem Shaydullin 2022-07-31 18:05:02 +05:00
parent d33fe19e8c
commit 8352254474
No known key found for this signature in database
GPG Key ID: 098B14A3236B5DFD
3 changed files with 870 additions and 297 deletions

View File

@ -196,7 +196,7 @@ handle_function_('GetPayment', {InvoiceID, PaymentID}, _Opts) ->
handle_function_('GetPaymentRefund', {InvoiceID, PaymentID, ID}, _Opts) ->
_ = set_invoicing_meta(InvoiceID, PaymentID),
St = get_state(InvoiceID),
hg_invoice_payment:get_refund(ID, get_payment_session(PaymentID, St));
hg_invoice_payment_refund:get_refund(ID, get_payment_session(PaymentID, St));
handle_function_('GetPaymentChargeback', {InvoiceID, PaymentID, ID}, _Opts) ->
_ = set_invoicing_meta(InvoiceID, PaymentID),
St = get_state(InvoiceID),
@ -322,7 +322,7 @@ get_invoice_state(#st{invoice = Invoice, payments = Payments}) ->
}.
get_payment_state(PaymentSession) ->
Refunds = hg_invoice_payment:get_refunds(PaymentSession),
Refunds = hg_invoice_payment_refund:get_refunds(PaymentSession),
LegacyRefunds =
lists:map(
fun(#payproc_InvoicePaymentRefund{refund = R}) ->
@ -952,7 +952,7 @@ parse_refund_id(ID) ->
ID.
make_new_refund_id(PaymentSession) ->
Refunds = hg_invoice_payment:get_refunds(PaymentSession),
Refunds = hg_invoice_payment_refund:get_refunds(PaymentSession),
construct_refund_id(Refunds).
construct_refund_id(Refunds) ->
@ -967,7 +967,7 @@ find_max_refund_id(#payproc_InvoicePaymentRefund{refund = Refund}, Max) ->
get_refund(ID, PaymentSession) ->
try
hg_invoice_payment:get_refund(ID, PaymentSession)
hg_invoice_payment_refund:get_refund(ID, PaymentSession)
catch
throw:#payproc_InvoicePaymentRefundNotFound{} ->
undefined

View File

@ -323,45 +323,15 @@ get_sessions(#st{sessions = S}) ->
-spec get_refunds(st()) -> [payment_refund()].
get_refunds(#st{refunds = Rs, payment = Payment}) ->
RefundList = lists:map(
fun(#refund_st{refund = R, sessions = S, cash_flow = C}) ->
#payproc_InvoicePaymentRefund{
refund = enrich_refund_with_cash(R, Payment),
sessions = lists:map(fun convert_refund_sessions/1, S),
cash_flow = C
}
end,
maps:values(Rs)
),
lists:sort(
fun(
#payproc_InvoicePaymentRefund{refund = X},
#payproc_InvoicePaymentRefund{refund = Y}
) ->
Xid = X#domain_InvoicePaymentRefund.id,
Yid = Y#domain_InvoicePaymentRefund.id,
Xid =< Yid
end,
RefundList
).
hg_invoice_payment_refund:get_refunds(Rs, Payment).
-spec get_refunds_count(st()) -> non_neg_integer().
get_refunds_count(#st{refunds = Refunds}) ->
maps:size(Refunds).
convert_refund_sessions(#{trx := TR}) ->
#payproc_InvoiceRefundSession{
transaction_info = TR
}.
-spec get_refund(refund_id(), st()) -> domain_refund() | no_return().
get_refund(ID, St = #st{payment = Payment}) ->
case try_get_refund_state(ID, St) of
#refund_st{refund = Refund} ->
enrich_refund_with_cash(Refund, Payment);
undefined ->
throw(#payproc_InvoicePaymentRefundNotFound{})
end.
get_refund(ID, #st{refunds = Rs, payment = Payment}) ->
hg_invoice_payment_refund:get_refund(ID, Rs, Payment).
%%
@ -846,16 +816,6 @@ log_rejected_routes(rejected_route_found, RejectedRoutes, Varset) ->
),
ok.
validate_refund_time(RefundCreatedAt, PaymentCreatedAt, TimeSpanSelector) ->
EligibilityTime = get_selector_value(eligibility_time, TimeSpanSelector),
RefundEndTime = hg_datetime:add_time_span(EligibilityTime, PaymentCreatedAt),
case hg_datetime:compare(RefundCreatedAt, RefundEndTime) of
Result when Result == earlier; Result == simultaneously ->
ok;
later ->
throw(#payproc_OperationNotPermitted{})
end.
collect_chargeback_varset(
#domain_PaymentChargebackServiceTerms{},
VS
@ -1193,158 +1153,24 @@ validate_payment_status(_, #domain_InvoicePayment{status = Status}) ->
throw(#payproc_InvalidPaymentStatus{status = Status}).
-spec refund(refund_params(), st(), opts()) -> {domain_refund(), result()}.
refund(Params, St0, Opts = #{timestamp := CreatedAt}) ->
St = St0#st{opts = Opts},
Revision = hg_domain:head(),
refund(Params, St, Opts) ->
_ = assert_no_pending_chargebacks(St),
Payment = get_payment(St),
VS = collect_validation_varset(St, Opts),
MerchantTerms = get_merchant_payments_terms(Opts, Revision, CreatedAt, VS),
Refund = make_refund(Params, Payment, Revision, CreatedAt, St, Opts),
FinalCashflow = make_refund_cashflow(Refund, Payment, Revision, St, Opts, MerchantTerms, VS, CreatedAt),
Changes = [?refund_created(Refund, FinalCashflow)],
Action = hg_machine_action:instant(),
ID = Refund#domain_InvoicePaymentRefund.id,
{Refund, {[?refund_ev(ID, C) || C <- Changes], Action}}.
Route = get_route(St),
Refunds = get_refunds(St),
RemainingPaymentBalance = get_remaining_payment_balance(St),
Allocation = get_allocation(St),
hg_invoice_payment_refund:refund(Params, Payment, Route, Refunds, RemainingPaymentBalance, Allocation, Opts).
-spec manual_refund(refund_params(), st(), opts()) -> {domain_refund(), result()}.
manual_refund(Params, St0, Opts = #{timestamp := CreatedAt}) ->
St = St0#st{opts = Opts},
Revision = hg_domain:head(),
Payment = get_payment(St),
VS = collect_validation_varset(St, Opts),
MerchantTerms = get_merchant_payments_terms(Opts, Revision, CreatedAt, VS),
Refund = make_refund(Params, Payment, Revision, CreatedAt, St, Opts),
FinalCashflow = make_refund_cashflow(Refund, Payment, Revision, St, Opts, MerchantTerms, VS, CreatedAt),
TransactionInfo = Params#payproc_InvoicePaymentRefundParams.transaction_info,
Changes = [?refund_created(Refund, FinalCashflow, TransactionInfo)],
Action = hg_machine_action:instant(),
ID = Refund#domain_InvoicePaymentRefund.id,
{Refund, {[?refund_ev(ID, C) || C <- Changes], Action}}.
make_refund(Params, Payment, Revision, CreatedAt, St, Opts) ->
manual_refund(Params, St, Opts) ->
_ = assert_no_pending_chargebacks(St),
_ = assert_payment_status(captured, Payment),
PartyRevision = get_opts_party_revision(Opts),
_ = assert_previous_refunds_finished(St),
Cash = define_refund_cash(Params#payproc_InvoicePaymentRefundParams.cash, St),
_ = assert_refund_cash(Cash, St),
Cart = Params#payproc_InvoicePaymentRefundParams.cart,
_ = assert_refund_cart(Params#payproc_InvoicePaymentRefundParams.cash, Cart, St),
Timestamp = get_payment_created_at(Payment),
VS = collect_validation_varset(St, Opts),
MerchantTerms = get_merchant_payments_terms(Opts, Revision, Timestamp, VS),
Allocation = maybe_allocation(
Params#payproc_InvoicePaymentRefundParams.allocation,
Cash,
MerchantTerms,
Opts
),
ok = validate_allocation_refund(Allocation, St),
MerchantRefundTerms = get_merchant_refunds_terms(MerchantTerms),
Refund = #domain_InvoicePaymentRefund{
id = Params#payproc_InvoicePaymentRefundParams.id,
created_at = CreatedAt,
domain_revision = Revision,
party_revision = PartyRevision,
status = ?refund_pending(),
reason = Params#payproc_InvoicePaymentRefundParams.reason,
cash = Cash,
cart = Cart,
external_id = Params#payproc_InvoicePaymentRefundParams.external_id,
allocation = Allocation
},
ok = validate_refund(MerchantRefundTerms, Refund, Payment),
Refund.
validate_allocation_refund(undefined, _St) ->
ok;
validate_allocation_refund(SubAllocation, St) ->
Allocation =
case get_allocation(St) of
undefined ->
throw(#payproc_AllocationNotFound{});
A ->
A
end,
case hg_allocation:sub(Allocation, SubAllocation) of
{ok, _} ->
ok;
{error, {invalid_transaction, Transaction, Details}} ->
throw(#payproc_AllocationInvalidTransaction{
transaction = marshal_transaction(Transaction),
reason = marshal_allocation_sub_details(Details)
})
end.
marshal_allocation_sub_details(negative_amount) ->
<<"Transaction amount is negative">>;
marshal_allocation_sub_details(currency_mismatch) ->
<<"Transaction currency mismatch">>;
marshal_allocation_sub_details(no_transaction_to_sub) ->
<<"No transaction to refund">>.
make_refund_cashflow(Refund, Payment, Revision, St, Opts, MerchantTerms, VS, Timestamp) ->
Payment = get_payment(St),
Route = get_route(St),
Party = get_party(Opts),
Shop = get_shop(Opts),
PaymentInstitutionRef = get_payment_institution_ref(Opts),
ProviderPaymentsTerms = get_provider_terminal_terms(Route, VS, Revision),
ProviderTerms = get_provider_refunds_terms(ProviderPaymentsTerms, Refund, Payment),
Allocation = Refund#domain_InvoicePaymentRefund.allocation,
Provider = get_route_provider(Route, Revision),
collect_cashflow(
refund,
ProviderTerms,
MerchantTerms,
Party,
Shop,
PaymentInstitutionRef,
Route,
Allocation,
Payment,
Refund,
Provider,
Revision,
Timestamp,
VS
).
assert_refund_cash(Cash, St) ->
PaymentAmount = get_remaining_payment_amount(Cash, St),
assert_remaining_payment_amount(PaymentAmount, St).
assert_remaining_payment_amount(?cash(Amount, _), _St) when Amount >= 0 ->
ok;
assert_remaining_payment_amount(?cash(Amount, _), St) when Amount < 0 ->
Maximum = get_remaining_payment_balance(St),
throw(#payproc_InvoicePaymentAmountExceeded{maximum = Maximum}).
assert_previous_refunds_finished(St) ->
PendingRefunds = lists:filter(
fun(#payproc_InvoicePaymentRefund{refund = R}) ->
R#domain_InvoicePaymentRefund.status =:= ?refund_pending()
end,
get_refunds(St)
),
case PendingRefunds of
[] ->
ok;
[_R | _] ->
throw(#payproc_OperationNotPermitted{})
end.
assert_refund_cart(_RefundCash, undefined, _St) ->
ok;
assert_refund_cart(undefined, _Cart, _St) ->
throw_invalid_request(<<"Refund amount does not match with the cart total amount">>);
assert_refund_cart(RefundCash, Cart, St) ->
InterimPaymentAmount = get_remaining_payment_balance(St),
case hg_cash:sub(InterimPaymentAmount, RefundCash) =:= hg_invoice_utils:get_cart_amount(Cart) of
true ->
ok;
_ ->
throw_invalid_request(<<"Remaining payment amount not equal cart cost">>)
end.
Refunds = get_refunds(St),
RemainingPaymentBalance = get_remaining_payment_balance(St),
Allocation = get_allocation(St),
hg_invoice_payment_refund:manual_refund(Params, Payment, Route, Refunds, RemainingPaymentBalance, Allocation, Opts).
get_remaining_payment_amount(Cash, St) ->
InterimPaymentAmount = get_remaining_payment_balance(St),
@ -1375,91 +1201,6 @@ get_remaining_payment_balance(St) ->
get_refunds(St) ++ Chargebacks
).
get_merchant_refunds_terms(#domain_PaymentsServiceTerms{refunds = Terms}) when Terms /= undefined ->
Terms;
get_merchant_refunds_terms(#domain_PaymentsServiceTerms{refunds = undefined}) ->
throw(#payproc_OperationNotPermitted{}).
get_provider_refunds_terms(
#domain_PaymentsProvisionTerms{refunds = Terms},
Refund,
Payment
) when Terms /= undefined ->
Cost = get_payment_cost(Payment),
Cash = get_refund_cash(Refund),
case hg_cash:sub(Cost, Cash) of
?cash(0, _) ->
Terms;
?cash(Amount, _) when Amount > 0 ->
get_provider_partial_refunds_terms(Terms, Refund, Payment)
end;
get_provider_refunds_terms(#domain_PaymentsProvisionTerms{refunds = undefined}, _Refund, Payment) ->
error({misconfiguration, {'No refund terms for a payment', Payment}}).
get_provider_partial_refunds_terms(
#domain_PaymentRefundsProvisionTerms{
partial_refunds = #domain_PartialRefundsProvisionTerms{
cash_limit = CashLimitSelector
}
} = Terms,
Refund,
_Payment
) ->
Cash = get_refund_cash(Refund),
CashRange = get_selector_value(cash_limit, CashLimitSelector),
case hg_cash_range:is_inside(Cash, CashRange) of
within ->
Terms;
{exceeds, _} ->
error({misconfiguration, {'Refund amount doesnt match allowed cash range', CashRange}})
end;
get_provider_partial_refunds_terms(
#domain_PaymentRefundsProvisionTerms{partial_refunds = undefined},
_Refund,
Payment
) ->
error({misconfiguration, {'No partial refund terms for a payment', Payment}}).
validate_refund(Terms, Refund, Payment) ->
Cost = get_payment_cost(Payment),
Cash = get_refund_cash(Refund),
case hg_cash:sub(Cost, Cash) of
?cash(0, _) ->
validate_common_refund_terms(Terms, Refund, Payment);
?cash(Amount, _) when Amount > 0 ->
validate_partial_refund(Terms, Refund, Payment)
end.
validate_partial_refund(
#domain_PaymentRefundsServiceTerms{partial_refunds = PRs} = Terms,
Refund,
Payment
) when PRs /= undefined ->
ok = validate_common_refund_terms(Terms, Refund, Payment),
ok = validate_cash(
get_refund_cash(Refund),
PRs#domain_PartialRefundsServiceTerms.cash_limit
),
ok;
validate_partial_refund(
#domain_PaymentRefundsServiceTerms{partial_refunds = undefined},
_Refund,
_Payment
) ->
throw(#payproc_OperationNotPermitted{}).
validate_common_refund_terms(Terms, Refund, Payment) ->
ok = validate_payment_tool(
get_payment_tool(Payment),
Terms#domain_PaymentRefundsServiceTerms.payment_methods
),
ok = validate_refund_time(
get_refund_created_at(Refund),
get_payment_created_at(Payment),
Terms#domain_PaymentRefundsServiceTerms.eligibility_time
),
ok.
collect_cashflow(
OpType,
ProvisionTerms,
@ -3762,30 +3503,12 @@ set_refund_status(Status, Refund = #domain_InvoicePaymentRefund{}) ->
get_refund_cashflow(#refund_st{cash_flow = CashFlow}) ->
CashFlow.
define_refund_cash(undefined, St) ->
get_remaining_payment_balance(St);
define_refund_cash(?cash(_, SymCode) = Cash, #st{payment = #domain_InvoicePayment{cost = ?cash(_, SymCode)}}) ->
Cash;
define_refund_cash(?cash(_, SymCode), _St) ->
throw(#payproc_InconsistentRefundCurrency{currency = SymCode}).
get_refund_cash(#domain_InvoicePaymentRefund{cash = Cash}) ->
Cash.
get_refund_created_at(#domain_InvoicePaymentRefund{created_at = CreatedAt}) ->
CreatedAt.
enrich_refund_with_cash(Refund, #domain_InvoicePayment{cost = PaymentCash}) ->
#domain_InvoicePaymentRefund{cash = RefundCash} = Refund,
case {RefundCash, PaymentCash} of
{undefined, _} ->
%% Earlier Refunds haven't got field cash and we got this value from PaymentCash.
%% There are some refunds without cash in system that's why for compatablity we save this behaviour.
Refund#domain_InvoicePaymentRefund{cash = PaymentCash};
{?cash(_, SymCode), ?cash(_, SymCode)} ->
Refund
end.
try_get_adjustment(ID, #st{adjustments = As}) ->
case lists:keyfind(ID, #domain_InvoicePaymentAdjustment.id, As) of
V = #domain_InvoicePaymentAdjustment{} ->

View File

@ -0,0 +1,850 @@
%%% Invoice payment submachine
%%%
%%% TODO
%%% - make proper submachine interface
%%% - `init` should provide `next` or `done` to the caller
%%% - handle idempotent callbacks uniformly
%%% - get rid of matches against session status
%%% - tag machine with the provider trx
%%% - distinguish between trx tags and callback tags
%%% - tag namespaces
%%% - think about safe clamping of timers returned by some proxy
%%% - why don't user interaction events imprint anything on the state?
%%% - adjustments look and behave very much like claims over payments
%%% - payment status transition are caused by the fact that some session
%%% finishes, which could have happened in the past, not just now
-module(hg_invoice_payment_refund).
-include_lib("damsel/include/dmsl_base_thrift.hrl").
-include_lib("damsel/include/dmsl_proxy_provider_thrift.hrl").
-include_lib("damsel/include/dmsl_payproc_thrift.hrl").
-include_lib("damsel/include/dmsl_payproc_error_thrift.hrl").
-include_lib("hellgate/include/domain.hrl").
-include_lib("hellgate/include/allocation.hrl").
%% API
-export([get_refunds/2]).
-export([get_refund/3]).
%% Business logic
-export([refund/7]).
-export([manual_refund/7]).
%%
-type refunds() :: #{refund_id() => refund_state()}.
-record(refund_st, {
refund :: undefined | domain_refund(),
cash_flow :: undefined | final_cash_flow(),
sessions = [] :: [session()],
transaction_info :: undefined | trx_info(),
failure :: undefined | failure()
}).
-type refund_state() :: #refund_st{}.
-type party() :: dmsl_domain_thrift:'Party'().
-type invoice() :: dmsl_domain_thrift:'Invoice'().
-type payment() :: dmsl_domain_thrift:'InvoicePayment'().
-type domain_refund() :: dmsl_domain_thrift:'InvoicePaymentRefund'().
-type payment_refund() :: dmsl_payproc_thrift:'InvoicePaymentRefund'().
-type refund_id() :: dmsl_domain_thrift:'InvoicePaymentRefundID'().
-type refund_params() :: dmsl_payproc_thrift:'InvoicePaymentRefundParams'().
-type target() :: dmsl_domain_thrift:'TargetInvoicePaymentStatus'().
-type route() :: dmsl_domain_thrift:'PaymentRoute'().
-type final_cash_flow() :: dmsl_domain_thrift:'FinalCashFlow'().
-type trx_info() :: dmsl_domain_thrift:'TransactionInfo'().
-type session_result() :: dmsl_payproc_thrift:'SessionResult'().
-type proxy_state() :: dmsl_proxy_provider_thrift:'ProxyState'().
-type tag() :: dmsl_proxy_provider_thrift:'CallbackTag'().
-type timeout_behaviour() :: dmsl_timeout_behaviour_thrift:'TimeoutBehaviour'().
-type failure() :: dmsl_domain_thrift:'OperationFailure'().
-type session_status() :: active | suspended | finished.
-type session() :: #{
target := target(),
status := session_status(),
trx := trx_info(),
tags := [tag()],
timeout_behaviour := timeout_behaviour(),
result => session_result(),
proxy_state => proxy_state(),
timings => hg_timings:t()
}.
-type opts() :: #{
party => party(),
invoice => invoice(),
timestamp => hg_datetime:timestamp()
}.
%%
-include("domain.hrl").
-include("payment_events.hrl").
%%
-spec get_refunds(refunds(), payment()) -> [payment_refund()].
get_refunds(Rs, Payment) ->
RefundList = lists:map(
fun(#refund_st{refund = R, sessions = S, cash_flow = C}) ->
#payproc_InvoicePaymentRefund{
refund = enrich_refund_with_cash(R, Payment),
sessions = lists:map(fun convert_refund_sessions/1, S),
cash_flow = C
}
end,
maps:values(Rs)
),
lists:sort(
fun(
#payproc_InvoicePaymentRefund{refund = X},
#payproc_InvoicePaymentRefund{refund = Y}
) ->
Xid = X#domain_InvoicePaymentRefund.id,
Yid = Y#domain_InvoicePaymentRefund.id,
Xid =< Yid
end,
RefundList
).
convert_refund_sessions(#{trx := TR}) ->
#payproc_InvoiceRefundSession{
transaction_info = TR
}.
-spec get_refund(refund_id(), refunds(), payment()) -> domain_refund() | no_return().
get_refund(ID, Refunds, Payment) ->
case try_get_refund_state(ID, Refunds) of
#refund_st{refund = Refund} ->
enrich_refund_with_cash(Refund, Payment);
undefined ->
throw(#payproc_InvoicePaymentRefundNotFound{})
end.
%%
-type event() :: dmsl_payproc_thrift:'InvoicePaymentChangePayload'().
-type action() :: hg_machine_action:t().
-type events() :: [event()].
-type result() :: {events(), action()}.
get_merchant_payments_terms(Opts, Revision, Timestamp, VS) ->
Party = get_party(Opts),
Shop = get_shop(Opts),
TermSet = get_merchant_terms(Party, Shop, Revision, Timestamp, VS),
TermSet#domain_TermSet.payments.
get_merchant_terms(Party, Shop, DomainRevision, Timestamp, VS) ->
ContractID = Shop#domain_Shop.contract_id,
Contract = hg_party:get_contract(ContractID, Party),
ok = assert_contract_active(Contract),
PreparedVS = hg_varset:prepare_contract_terms_varset(VS),
{Client, Context} = get_party_client(),
{ok, Terms} = party_client_thrift:compute_contract_terms(
Party#domain_Party.id,
ContractID,
Timestamp,
{revision, Party#domain_Party.revision},
DomainRevision,
PreparedVS,
Client,
Context
),
Terms.
-spec get_provider_terminal_terms(route(), hg_varset:varset(), hg_domain:revision()) ->
dmsl_domain_thrift:'PaymentsProvisionTerms'() | undefined.
get_provider_terminal_terms(?route(ProviderRef, TerminalRef), VS, Revision) ->
PreparedVS = hg_varset:prepare_varset(VS),
{Client, Context} = get_party_client(),
{ok, TermsSet} = party_client_thrift:compute_provider_terminal_terms(
ProviderRef,
TerminalRef,
Revision,
PreparedVS,
Client,
Context
),
TermsSet#domain_ProvisionTermSet.payments.
assert_contract_active(#domain_Contract{status = {active, _}}) ->
ok;
assert_contract_active(#domain_Contract{status = Status}) ->
throw(#payproc_InvalidContractStatus{status = Status}).
validate_payment_tool(PaymentTool, PaymentMethodSelector) ->
PMs = get_selector_value(payment_methods, PaymentMethodSelector),
_ =
case hg_payment_tool:has_any_payment_method(PaymentTool, PMs) of
false ->
throw_invalid_request(<<"Invalid payment method">>);
true ->
ok
end,
ok.
validate_cash(Cash, CashLimitSelector) ->
Limit = get_selector_value(cash_limit, CashLimitSelector),
ok = validate_limit(Cash, Limit).
validate_limit(Cash, CashRange) ->
case hg_cash_range:is_inside(Cash, CashRange) of
within ->
ok;
{exceeds, lower} ->
throw_invalid_request(<<"Invalid amount, less than allowed minumum">>);
{exceeds, upper} ->
throw_invalid_request(<<"Invalid amount, more than allowed maximum">>)
end.
% Other payers has predefined routes
validate_refund_time(RefundCreatedAt, PaymentCreatedAt, TimeSpanSelector) ->
EligibilityTime = get_selector_value(eligibility_time, TimeSpanSelector),
RefundEndTime = hg_datetime:add_time_span(EligibilityTime, PaymentCreatedAt),
case hg_datetime:compare(RefundCreatedAt, RefundEndTime) of
Result when Result == earlier; Result == simultaneously ->
ok;
later ->
throw(#payproc_OperationNotPermitted{})
end.
collect_validation_varset(Payment, Opts) ->
collect_validation_varset(get_party(Opts), get_shop(Opts), Payment, #{}).
collect_validation_varset(Party, Shop, VS) ->
#domain_Party{id = PartyID} = Party,
#domain_Shop{
id = ShopID,
category = Category,
account = #domain_ShopAccount{currency = Currency}
} = Shop,
VS#{
party_id => PartyID,
shop_id => ShopID,
category => Category,
currency => Currency
}.
collect_validation_varset(Party, Shop, Payment, VS) ->
VS0 = collect_validation_varset(Party, Shop, VS),
VS0#{
cost => get_payment_cost(Payment),
payment_tool => get_payment_tool(Payment)
}.
%%
construct_final_cashflow(Cashflow, Context, AccountMap) ->
hg_cashflow:finalize(Cashflow, Context, AccountMap).
collect_cash_flow_context(
#domain_InvoicePayment{cost = Cost}
) ->
#{
operation_amount => Cost
};
collect_cash_flow_context(
#domain_InvoicePaymentRefund{cash = Cash}
) ->
#{
operation_amount => Cash
}.
get_selector_value(Name, Selector) ->
case Selector of
{value, V} ->
V;
Ambiguous ->
error({misconfiguration, {'Could not reduce selector to a value', {Name, Ambiguous}}})
end.
%%
maybe_allocation(undefined, _Cost, _MerchantTerms, _Opts) ->
undefined;
maybe_allocation(AllocationPrototype, Cost, MerchantTerms, Opts) ->
#domain_PaymentsServiceTerms{
allocations = AllocationSelector
} = MerchantTerms,
Party = get_party(Opts),
Shop = get_shop(Opts),
case
hg_allocation:calculate(
AllocationPrototype,
Party,
Shop,
Cost,
AllocationSelector
)
of
{ok, A} ->
A;
{error, allocation_not_allowed} ->
throw(#payproc_AllocationNotAllowed{});
{error, amount_exceeded} ->
throw(#payproc_AllocationExceededPaymentAmount{});
{error, {invalid_transaction, Transaction, Details}} ->
throw(#payproc_AllocationInvalidTransaction{
transaction = marshal_transaction(Transaction),
reason = marshal_allocation_details(Details)
})
end.
marshal_transaction(#domain_AllocationTransaction{} = T) ->
{transaction, T};
marshal_transaction(#domain_AllocationTransactionPrototype{} = TP) ->
{transaction_prototype, TP}.
marshal_allocation_details(negative_amount) ->
<<"Transaction amount is negative">>;
marshal_allocation_details(zero_amount) ->
<<"Transaction amount is zero">>;
marshal_allocation_details(target_conflict) ->
<<"Transaction with similar target">>;
marshal_allocation_details(currency_mismatch) ->
<<"Transaction currency mismatch">>;
marshal_allocation_details(payment_institutions_mismatch) ->
<<"Transaction target shop Payment Institution mismatch">>.
-spec refund(refund_params(), payment(), route(), refunds(), cash(), hg_allocation:allocation() | undefined, opts()) -> {domain_refund(), result()}.
refund(Params, Payment, Route, Refunds, RemainingPaymentBalance, Allocation, Opts = #{timestamp := CreatedAt}) ->
Revision = hg_domain:head(),
VS = collect_validation_varset(Payment, Opts),
MerchantTerms = get_merchant_payments_terms(Opts, Revision, CreatedAt, VS),
Refund = make_refund(Params, Payment, Revision, CreatedAt, Refunds, RemainingPaymentBalance, Allocation, Opts),
FinalCashflow = make_refund_cashflow(Refund, Payment, Revision, Route, Opts, MerchantTerms, VS, CreatedAt),
Changes = [?refund_created(Refund, FinalCashflow)],
Action = hg_machine_action:instant(),
ID = Refund#domain_InvoicePaymentRefund.id,
{Refund, {[?refund_ev(ID, C) || C <- Changes], Action}}.
-spec manual_refund(refund_params(), payment(), route(), refunds(), cash(), hg_allocation:allocation() | undefined, opts()) -> {domain_refund(), result()}.
manual_refund(Params, Payment, Route, Refunds, RemainingPaymentBalance, Allocation, Opts = #{timestamp := CreatedAt}) ->
Revision = hg_domain:head(),
VS = collect_validation_varset(Payment, Opts),
MerchantTerms = get_merchant_payments_terms(Opts, Revision, CreatedAt, VS),
Refund = make_refund(Params, Payment, Revision, CreatedAt, Refunds, RemainingPaymentBalance, Allocation, Opts),
FinalCashflow = make_refund_cashflow(Refund, Payment, Revision, Route, Opts, MerchantTerms, VS, CreatedAt),
TransactionInfo = Params#payproc_InvoicePaymentRefundParams.transaction_info,
Changes = [?refund_created(Refund, FinalCashflow, TransactionInfo)],
Action = hg_machine_action:instant(),
ID = Refund#domain_InvoicePaymentRefund.id,
{Refund, {[?refund_ev(ID, C) || C <- Changes], Action}}.
make_refund(Params, Payment, Revision, CreatedAt, Refunds, RemainingPaymentBalance, Allocation, Opts) ->
_ = assert_payment_status(captured, Payment),
PartyRevision = get_opts_party_revision(Opts),
_ = assert_previous_refunds_finished(Refunds),
Cash = define_refund_cash(Params#payproc_InvoicePaymentRefundParams.cash, RemainingPaymentBalance, Payment),
_ = assert_refund_cash(Cash, RemainingPaymentBalance),
Cart = Params#payproc_InvoicePaymentRefundParams.cart,
_ = assert_refund_cart(Params#payproc_InvoicePaymentRefundParams.cash, Cart, RemainingPaymentBalance),
Timestamp = get_payment_created_at(Payment),
VS = collect_validation_varset(Payment, Opts),
MerchantTerms = get_merchant_payments_terms(Opts, Revision, Timestamp, VS),
SubAllocation = maybe_allocation(
Params#payproc_InvoicePaymentRefundParams.allocation,
Cash,
MerchantTerms,
Opts
),
ok = validate_allocation_refund(SubAllocation, Allocation),
MerchantRefundTerms = get_merchant_refunds_terms(MerchantTerms),
Refund = #domain_InvoicePaymentRefund{
id = Params#payproc_InvoicePaymentRefundParams.id,
created_at = CreatedAt,
domain_revision = Revision,
party_revision = PartyRevision,
status = ?refund_pending(),
reason = Params#payproc_InvoicePaymentRefundParams.reason,
cash = Cash,
cart = Cart,
external_id = Params#payproc_InvoicePaymentRefundParams.external_id,
allocation = SubAllocation
},
ok = validate_refund(MerchantRefundTerms, Refund, Payment),
Refund.
validate_allocation_refund(undefined, _Allocation) ->
ok;
validate_allocation_refund(_SubAllocation, undefined) ->
throw(#payproc_AllocationNotFound{});
validate_allocation_refund(SubAllocation, Allocation) ->
case hg_allocation:sub(Allocation, SubAllocation) of
{ok, _} ->
ok;
{error, {invalid_transaction, Transaction, Details}} ->
throw(#payproc_AllocationInvalidTransaction{
transaction = marshal_transaction(Transaction),
reason = marshal_allocation_sub_details(Details)
})
end.
marshal_allocation_sub_details(negative_amount) ->
<<"Transaction amount is negative">>;
marshal_allocation_sub_details(currency_mismatch) ->
<<"Transaction currency mismatch">>;
marshal_allocation_sub_details(no_transaction_to_sub) ->
<<"No transaction to refund">>.
make_refund_cashflow(Refund, Payment, Revision, Route, Opts, MerchantTerms, VS, Timestamp) ->
Party = get_party(Opts),
Shop = get_shop(Opts),
PaymentInstitutionRef = get_payment_institution_ref(Opts),
ProviderPaymentsTerms = get_provider_terminal_terms(Route, VS, Revision),
ProviderTerms = get_provider_refunds_terms(ProviderPaymentsTerms, Refund, Payment),
Allocation = Refund#domain_InvoicePaymentRefund.allocation,
Provider = get_route_provider(Route, Revision),
collect_cashflow(
refund,
ProviderTerms,
MerchantTerms,
Party,
Shop,
PaymentInstitutionRef,
Route,
Allocation,
Payment,
Refund,
Provider,
Revision,
Timestamp,
VS
).
assert_refund_cash(Cash, RemainingPaymentBalance) ->
PaymentAmount = get_remaining_payment_amount(Cash, RemainingPaymentBalance),
assert_remaining_payment_amount(PaymentAmount, RemainingPaymentBalance).
assert_remaining_payment_amount(?cash(Amount, _), _RemainingPaymentBalance) when Amount >= 0 ->
ok;
assert_remaining_payment_amount(?cash(Amount, _), RemainingPaymentBalance) when Amount < 0 ->
throw(#payproc_InvoicePaymentAmountExceeded{maximum = RemainingPaymentBalance}).
assert_previous_refunds_finished(Refunds) ->
PendingRefunds = lists:filter(
fun(#payproc_InvoicePaymentRefund{refund = R}) ->
R#domain_InvoicePaymentRefund.status =:= ?refund_pending()
end,
Refunds
),
case PendingRefunds of
[] ->
ok;
[_R | _] ->
throw(#payproc_OperationNotPermitted{})
end.
assert_refund_cart(_RefundCash, undefined, _RemainingPaymentBalance) ->
ok;
assert_refund_cart(undefined, _Cart, _RemainingPaymentBalance) ->
throw_invalid_request(<<"Refund amount does not match with the cart total amount">>);
assert_refund_cart(RefundCash, Cart, RemainingPaymentBalance) ->
case hg_cash:sub(RemainingPaymentBalance, RefundCash) =:= hg_invoice_utils:get_cart_amount(Cart) of
true ->
ok;
_ ->
throw_invalid_request(<<"Remaining payment amount not equal cart cost">>)
end.
get_remaining_payment_amount(Cash, RemainingPaymentBalance) ->
hg_cash:sub(RemainingPaymentBalance, Cash).
get_merchant_refunds_terms(#domain_PaymentsServiceTerms{refunds = Terms}) when Terms /= undefined ->
Terms;
get_merchant_refunds_terms(#domain_PaymentsServiceTerms{refunds = undefined}) ->
throw(#payproc_OperationNotPermitted{}).
get_provider_refunds_terms(
#domain_PaymentsProvisionTerms{refunds = Terms},
Refund,
Payment
) when Terms /= undefined ->
Cost = get_payment_cost(Payment),
Cash = get_refund_cash(Refund),
case hg_cash:sub(Cost, Cash) of
?cash(0, _) ->
Terms;
?cash(Amount, _) when Amount > 0 ->
get_provider_partial_refunds_terms(Terms, Refund, Payment)
end;
get_provider_refunds_terms(#domain_PaymentsProvisionTerms{refunds = undefined}, _Refund, Payment) ->
error({misconfiguration, {'No refund terms for a payment', Payment}}).
get_provider_partial_refunds_terms(
#domain_PaymentRefundsProvisionTerms{
partial_refunds = #domain_PartialRefundsProvisionTerms{
cash_limit = CashLimitSelector
}
} = Terms,
Refund,
_Payment
) ->
Cash = get_refund_cash(Refund),
CashRange = get_selector_value(cash_limit, CashLimitSelector),
case hg_cash_range:is_inside(Cash, CashRange) of
within ->
Terms;
{exceeds, _} ->
error({misconfiguration, {'Refund amount doesnt match allowed cash range', CashRange}})
end;
get_provider_partial_refunds_terms(
#domain_PaymentRefundsProvisionTerms{partial_refunds = undefined},
_Refund,
Payment
) ->
error({misconfiguration, {'No partial refund terms for a payment', Payment}}).
validate_refund(Terms, Refund, Payment) ->
Cost = get_payment_cost(Payment),
Cash = get_refund_cash(Refund),
case hg_cash:sub(Cost, Cash) of
?cash(0, _) ->
validate_common_refund_terms(Terms, Refund, Payment);
?cash(Amount, _) when Amount > 0 ->
validate_partial_refund(Terms, Refund, Payment)
end.
validate_partial_refund(
#domain_PaymentRefundsServiceTerms{partial_refunds = PRs} = Terms,
Refund,
Payment
) when PRs /= undefined ->
ok = validate_common_refund_terms(Terms, Refund, Payment),
ok = validate_cash(
get_refund_cash(Refund),
PRs#domain_PartialRefundsServiceTerms.cash_limit
),
ok;
validate_partial_refund(
#domain_PaymentRefundsServiceTerms{partial_refunds = undefined},
_Refund,
_Payment
) ->
throw(#payproc_OperationNotPermitted{}).
validate_common_refund_terms(Terms, Refund, Payment) ->
ok = validate_payment_tool(
get_payment_tool(Payment),
Terms#domain_PaymentRefundsServiceTerms.payment_methods
),
ok = validate_refund_time(
get_refund_created_at(Refund),
get_payment_created_at(Payment),
Terms#domain_PaymentRefundsServiceTerms.eligibility_time
),
ok.
collect_cashflow(
OpType,
ProvisionTerms,
MerchantTerms,
Party,
Shop,
PaymentInstitutionRef,
Route,
undefined,
Payment,
ContextSource,
Provider,
Revision,
Timestamp,
VS
) ->
ProviderCashflowSelector = get_provider_cashflow_selector(ProvisionTerms),
Amount = get_context_source_amount(ContextSource),
CF = construct_transaction_cashflow(
OpType,
Party,
Shop,
Route,
Amount,
MerchantTerms,
Revision,
Timestamp,
Payment,
Provider,
VS
),
ProviderCashflow = construct_provider_cashflow(
ProviderCashflowSelector,
PaymentInstitutionRef,
VS,
Revision,
Party,
Shop,
Route,
ContextSource,
Payment,
Provider
),
CF ++ ProviderCashflow;
collect_cashflow(
OpType,
ProvisionTerms,
_MerchantTerms,
Party,
Shop,
PaymentInstitutionRef,
Route,
?allocation(Transactions),
Payment,
ContextSource,
Provider,
Revision,
Timestamp,
VS
) ->
CF = lists:foldl(
fun(?allocation_trx(_ID, Target, Amount), Acc) ->
?allocation_trx_target_shop(PartyID, ShopID) = Target,
TargetParty = hg_party:get_party(PartyID),
TargetShop = hg_party:get_shop(ShopID, TargetParty),
construct_transaction_cashflow(
OpType,
TargetParty,
TargetShop,
Route,
Amount,
undefined,
Revision,
Timestamp,
Payment,
Provider,
VS
) ++ Acc
end,
[],
Transactions
),
ProviderCashflowSelector = get_provider_cashflow_selector(ProvisionTerms),
ProviderCashflow = construct_provider_cashflow(
ProviderCashflowSelector,
PaymentInstitutionRef,
VS,
Revision,
Party,
Shop,
Route,
ContextSource,
Payment,
Provider
),
CF ++ ProviderCashflow.
get_context_source_amount(#domain_InvoicePayment{cost = Cost}) ->
Cost;
get_context_source_amount(#domain_InvoicePaymentRefund{cash = Cash}) ->
Cash.
get_provider_cashflow_selector(#domain_PaymentsProvisionTerms{cash_flow = ProviderCashflowSelector}) ->
ProviderCashflowSelector;
get_provider_cashflow_selector(#domain_PaymentRefundsProvisionTerms{cash_flow = ProviderCashflowSelector}) ->
ProviderCashflowSelector.
construct_transaction_cashflow(
OpType,
Party,
Shop,
Route,
Amount,
MerchantPaymentsTerms0,
Revision,
Timestamp,
Payment,
Provider,
VS0
) ->
VS1 = VS0#{
party_id => Party#domain_Party.id,
shop_id => Shop#domain_Shop.id,
cost => Amount
},
MerchantPaymentsTerms1 =
case MerchantPaymentsTerms0 of
undefined ->
TermSet = get_merchant_terms(Party, Shop, Revision, Timestamp, VS1),
TermSet#domain_TermSet.payments;
_ ->
MerchantPaymentsTerms0
end,
MerchantCashflowSelector = get_terms_cashflow(OpType, MerchantPaymentsTerms1),
MerchantCashflow = get_selector_value(merchant_payment_fees, MerchantCashflowSelector),
construct_cashflow(MerchantCashflow, Party, Shop, Route, Amount, Revision, Payment, Provider, VS1).
get_terms_cashflow(payment, MerchantPaymentsTerms) ->
MerchantPaymentsTerms#domain_PaymentsServiceTerms.fees;
get_terms_cashflow(refund, MerchantPaymentsTerms) ->
MerchantRefundTerms = MerchantPaymentsTerms#domain_PaymentsServiceTerms.refunds,
MerchantRefundTerms#domain_PaymentRefundsServiceTerms.fees.
construct_cashflow(MerchantCashflow, Party, Shop, Route, Amount, Revision, Payment, Provider, VS) ->
Contract = hg_party:get_contract(Shop#domain_Shop.contract_id, Party),
PaymentInstitutionRef = Contract#domain_Contract.payment_institution,
PaymentInstitution = hg_payment_institution:compute_payment_institution(
PaymentInstitutionRef,
VS,
Revision
),
AccountMap = hg_accounting:collect_account_map(
Payment,
Party,
Shop,
Route,
PaymentInstitution,
Provider,
VS,
Revision
),
Context = #{
operation_amount => Amount
},
construct_final_cashflow(MerchantCashflow, Context, AccountMap).
construct_provider_cashflow(
ProviderCashflowSelector,
PaymentInstitutionRef,
VS,
Revision,
Party,
Shop,
Route,
ContextSource,
Payment,
Provider
) ->
PaymentInstitution = hg_payment_institution:compute_payment_institution(PaymentInstitutionRef, VS, Revision),
ProviderCashflow = get_selector_value(provider_payment_cash_flow, ProviderCashflowSelector),
Context = collect_cash_flow_context(ContextSource),
AccountMap = hg_accounting:collect_account_map(
Payment,
Party,
Shop,
Route,
PaymentInstitution,
Provider,
VS,
Revision
),
construct_final_cashflow(ProviderCashflow, Context, AccountMap).
assert_payment_status([Status | _], #domain_InvoicePayment{status = {Status, _}}) ->
ok;
assert_payment_status([_ | Rest], InvoicePayment) ->
assert_payment_status(Rest, InvoicePayment);
assert_payment_status(Status, #domain_InvoicePayment{status = {Status, _}}) ->
ok;
assert_payment_status(_, #domain_InvoicePayment{status = Status}) ->
throw(#payproc_InvalidPaymentStatus{status = Status}).
-define(adjustment_target_status(Status), #domain_InvoicePaymentAdjustment{
state =
{status_change, #domain_InvoicePaymentAdjustmentStatusChangeState{
scenario = #domain_InvoicePaymentAdjustmentStatusChange{target_status = Status}
}}
}).
%%
get_party(#{party := Party}) ->
Party.
get_shop(#{party := Party, invoice := Invoice}) ->
hg_party:get_shop(get_invoice_shop_id(Invoice), Party).
get_contract(#{party := Party, invoice := Invoice}) ->
Shop = hg_party:get_shop(get_invoice_shop_id(Invoice), Party),
hg_party:get_contract(Shop#domain_Shop.contract_id, Party).
get_payment_institution_ref(Opts) ->
Contract = get_contract(Opts),
Contract#domain_Contract.payment_institution.
get_opts_party_revision(#{party := Party}) ->
Party#domain_Party.revision.
get_invoice_shop_id(#domain_Invoice{shop_id = ShopID}) ->
ShopID.
get_payment_cost(#domain_InvoicePayment{cost = Cost}) ->
Cost.
get_payment_tool(#domain_InvoicePayment{payer = Payer}) ->
get_payer_payment_tool(Payer).
get_payment_created_at(#domain_InvoicePayment{created_at = CreatedAt}) ->
CreatedAt.
get_payer_payment_tool(?payment_resource_payer(PaymentResource, _ContactInfo)) ->
get_resource_payment_tool(PaymentResource);
get_payer_payment_tool(?customer_payer(_CustomerID, _, _, PaymentTool, _)) ->
PaymentTool;
get_payer_payment_tool(?recurrent_payer(PaymentTool, _, _)) ->
PaymentTool.
get_resource_payment_tool(#domain_DisposablePaymentResource{payment_tool = PaymentTool}) ->
PaymentTool.
%%
-spec throw_invalid_request(binary()) -> no_return().
throw_invalid_request(Why) ->
throw(#base_InvalidRequest{errors = [Why]}).
%%
try_get_refund_state(ID, Rs) ->
case Rs of
#{ID := RefundSt} ->
RefundSt;
#{} ->
undefined
end.
define_refund_cash(undefined, RemainingPaymentBalance, _Payment) ->
RemainingPaymentBalance;
define_refund_cash(?cash(_, SymCode) = Cash, _RemainingPaymentBalance, #domain_InvoicePayment{cost = ?cash(_, SymCode)}) ->
Cash;
define_refund_cash(?cash(_, SymCode), _RemainingPaymentBalance, _Payment) ->
throw(#payproc_InconsistentRefundCurrency{currency = SymCode}).
get_refund_cash(#domain_InvoicePaymentRefund{cash = Cash}) ->
Cash.
get_refund_created_at(#domain_InvoicePaymentRefund{created_at = CreatedAt}) ->
CreatedAt.
enrich_refund_with_cash(Refund, #domain_InvoicePayment{cost = PaymentCash}) ->
#domain_InvoicePaymentRefund{cash = RefundCash} = Refund,
case {RefundCash, PaymentCash} of
{undefined, _} ->
%% Earlier Refunds haven't got field cash and we got this value from PaymentCash.
%% There are some refunds without cash in system that's why for compatablity we save this behaviour.
Refund#domain_InvoicePaymentRefund{cash = PaymentCash};
{?cash(_, SymCode), ?cash(_, SymCode)} ->
Refund
end.
%%
get_route_provider_ref(#domain_PaymentRoute{provider = ProviderRef}) ->
ProviderRef.
get_route_provider(Route, Revision) ->
hg_domain:get(Revision, {provider, get_route_provider_ref(Route)}).
%% Business metrics logging
get_party_client() ->
HgContext = hg_context:load(),
Client = hg_context:get_party_client(HgContext),
Context = hg_context:get_party_client_context(HgContext),
{Client, Context}.