From fe686d9b747172723cf61ae5f1a152f1be3e3f40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D1=80=D1=82=D0=B5=D0=BC?= Date: Mon, 26 Jan 2026 14:02:22 +0300 Subject: [PATCH] refactored --- apps/hellgate/src/hg_cascade.erl | 2 + apps/hellgate/src/hg_cashflow_utils.erl | 24 +- apps/hellgate/src/hg_invoice_payment.erl | 2752 ++--------------- .../src/hg_invoice_payment_adjustment.erl | 357 +++ .../src/hg_invoice_payment_cashflow.erl | 440 +++ .../src/hg_invoice_payment_construction.erl | 368 +++ .../src/hg_invoice_payment_processing.erl | 583 ++++ .../src/hg_invoice_payment_routing.erl | 600 ++++ .../src/hg_invoice_payment_session.erl | 273 ++ .../hellgate/src/hg_invoice_payment_state.erl | 512 +++ .../src/hg_invoice_payment_validation.erl | 554 ++++ apps/hellgate/src/hg_machine_action.erl | 2 + apps/hellgate/src/hg_session.erl | 2 + 13 files changed, 3976 insertions(+), 2493 deletions(-) create mode 100644 apps/hellgate/src/hg_invoice_payment_adjustment.erl create mode 100644 apps/hellgate/src/hg_invoice_payment_cashflow.erl create mode 100644 apps/hellgate/src/hg_invoice_payment_construction.erl create mode 100644 apps/hellgate/src/hg_invoice_payment_processing.erl create mode 100644 apps/hellgate/src/hg_invoice_payment_routing.erl create mode 100644 apps/hellgate/src/hg_invoice_payment_session.erl create mode 100644 apps/hellgate/src/hg_invoice_payment_state.erl create mode 100644 apps/hellgate/src/hg_invoice_payment_validation.erl diff --git a/apps/hellgate/src/hg_cascade.erl b/apps/hellgate/src/hg_cascade.erl index 2864d56d..703d21b7 100644 --- a/apps/hellgate/src/hg_cascade.erl +++ b/apps/hellgate/src/hg_cascade.erl @@ -9,6 +9,8 @@ -export([is_triggered/4]). +-export_type([cascade_behaviour/0]). + -spec is_triggered( cascade_behaviour() | undefined, operation_failure(), diff --git a/apps/hellgate/src/hg_cashflow_utils.erl b/apps/hellgate/src/hg_cashflow_utils.erl index 387f25e4..d230fe0d 100644 --- a/apps/hellgate/src/hg_cashflow_utils.erl +++ b/apps/hellgate/src/hg_cashflow_utils.erl @@ -18,7 +18,8 @@ timestamp := hg_datetime:timestamp(), varset := hg_varset:varset(), revision := revision(), - merchant_terms => dmsl_domain_thrift:'PaymentsServiceTerms'(), + merchant_terms => + dmsl_domain_thrift:'PaymentsServiceTerms'() | dmsl_domain_thrift:'PaymentRefundsServiceTerms'(), refund => refund(), allocation => hg_allocation:allocation() }. @@ -146,9 +147,24 @@ get_provider_cashflow_selector(#domain_PaymentRefundsProvisionTerms{cash_flow = get_terms_cashflow(payment, MerchantPaymentsTerms) -> MerchantPaymentsTerms#domain_PaymentsServiceTerms.fees; -get_terms_cashflow(refund, MerchantPaymentsTerms) -> - MerchantRefundTerms = MerchantPaymentsTerms#domain_PaymentsServiceTerms.refunds, - MerchantRefundTerms#domain_PaymentRefundsServiceTerms.fees. +get_terms_cashflow(refund, MerchantTerms) -> + case normalize_refund_terms(MerchantTerms) of + #domain_PaymentRefundsServiceTerms{fees = FeesSelector} -> + FeesSelector; + undefined -> + error({misconfiguration, 'No refund terms for payment'}) + end. + +normalize_refund_terms(#domain_PaymentRefundsServiceTerms{} = Terms) -> + Terms; +normalize_refund_terms(#domain_PaymentsServiceTerms{refunds = Terms}) -> + normalize_refund_terms(Terms); +normalize_refund_terms({value, Terms}) -> + normalize_refund_terms(Terms); +normalize_refund_terms(undefined) -> + undefined; +normalize_refund_terms(Other) -> + error({misconfiguration, {'Unexpected refund terms', Other}}). get_selector_value(Name, Selector) -> case Selector of diff --git a/apps/hellgate/src/hg_invoice_payment.erl b/apps/hellgate/src/hg_invoice_payment.erl index dd97bb50..803f8e21 100644 --- a/apps/hellgate/src/hg_invoice_payment.erl +++ b/apps/hellgate/src/hg_invoice_payment.erl @@ -34,6 +34,7 @@ -export([get_payment/1]). -export([get_refunds/1]). +-export([get_refunds_count/1]). -export([get_chargebacks/1]). -export([get_chargeback_state/2]). -export([get_refund/2]). @@ -103,6 +104,48 @@ -export([construct_payment_plan_id/2]). -export([get_payer_payment_tool/1]). +-export([get_payment_payer/1]). +-export([get_recurrent_token/1]). +-export([get_payment_state/2]). + +%% Internal helper functions (exported for use by extracted modules) +-export([get_target/1]). +-export([get_target_type/1]). +-export([get_invoice_id/1]). +-export([get_payment_id/1]). +-export([try_get_refund_state/2]). +-export([rollback_payment_limits/4]). +-export([rollback_payment_cashflow/1]). +-export([maybe_notify_fault_detector/4]). +-export([process_failure/5]). +-export([get_payment_cost/1]). +-export([set_cashflow/2]). +-export([get_captured_cost/2]). +-export([get_captured_allocation/1]). +-export([get_adjustment_status/1]). +-export([define_event_timestamp/1]). +-export([try_accrue_waiting_timing/2]). +-export([update_session/3]). +-export([create_refund_event_context/2]). +-export([hold_shop_limits/2]). +-export([check_shop_limits/2]). +-export([rollback_shop_limits/3]). +-export([commit_shop_limits/2]). +-export([rollback_broken_payment_limits/1]). +-export([hold_limit_routes/4]). +-export([get_merchant_payments_terms/4]). +-export([get_cashflow_plan/1]). +-export([get_route_cascade_behaviour/2]). +-export([is_route_cascade_available/4]). +-export([get_st_meta/1]). +-export([get_payment_flow/1]). +-export([get_payment_created_at/1]). +-export([get_candidate_routes/1]). +-export([get_cashflow/1]). +-export([commit_payment_limits/1]). +-export([commit_payment_cashflow/1]). +-export([get_shop/2]). +-export([get_chargeback_opts/1]). %% @@ -113,6 +156,7 @@ -export_type([opts/0]). -export_type([payment/0]). -export_type([payment_status/0]). +-export_type([payment_status_type/0]). -export_type([refund_id/0]). -export_type([refund_state/0]). -export_type([trx_info/0]). @@ -127,6 +171,8 @@ -export_type([change/0]). -export_type([change_opts/0]). -export_type([action/0]). +-export_type([events/0]). +-export_type([adjustment_id/0]). -export_type([cashflow_context/0]). -type activity() :: @@ -191,8 +237,6 @@ -type adjustment() :: dmsl_domain_thrift:'InvoicePaymentAdjustment'(). -type adjustment_id() :: dmsl_domain_thrift:'InvoicePaymentAdjustmentID'(). -type adjustment_params() :: dmsl_payproc_thrift:'InvoicePaymentAdjustmentParams'(). --type adjustment_state() :: dmsl_domain_thrift:'InvoicePaymentAdjustmentState'(). --type adjustment_status_change() :: dmsl_domain_thrift:'InvoicePaymentAdjustmentStatusChange'(). -type target() :: dmsl_domain_thrift:'TargetInvoicePaymentStatus'(). -type session_target_type() :: 'processed' | 'captured' | 'cancelled' | 'refunded'. -type risk_score() :: hg_inspector:risk_score(). @@ -203,15 +247,11 @@ -type callback() :: dmsl_proxy_provider_thrift:'Callback'(). -type session_change() :: hg_session:change(). -type callback_response() :: dmsl_proxy_provider_thrift:'CallbackResponse'(). --type make_recurrent() :: true | false. -type retry_strategy() :: hg_retry:strategy(). -type capture_data() :: dmsl_payproc_thrift:'InvoicePaymentCaptureData'(). -type payment_session() :: dmsl_payproc_thrift:'InvoicePaymentSession'(). -type failure() :: dmsl_domain_thrift:'OperationFailure'(). --type shop() :: dmsl_domain_thrift:'ShopConfig'(). --type shop_config_ref() :: dmsl_domain_thrift:'ShopConfigRef'(). -type payment_tool() :: dmsl_domain_thrift:'PaymentTool'(). --type recurrent_paytool_service_terms() :: dmsl_domain_thrift:'RecurrentPaytoolsServiceTerms'(). -type session() :: hg_session:t(). -type payment_plan_id() :: hg_accounting:plan_id(). -type route_limit_context() :: dmsl_payproc_thrift:'RouteLimitContext'(). @@ -230,7 +270,8 @@ timestamp := hg_datetime:timestamp(), varset := hg_varset:varset(), revision := hg_domain:revision(), - merchant_terms => dmsl_domain_thrift:'PaymentsServiceTerms'(), + merchant_terms => + dmsl_domain_thrift:'PaymentsServiceTerms'() | dmsl_domain_thrift:'PaymentRefundsServiceTerms'(), allocation => hg_allocation:allocation() | undefined }. @@ -443,6 +484,8 @@ init_(PaymentID, Params, #{timestamp := CreatedAt} = Opts) -> Events = [?payment_started(Payment2)], {collapse_changes(Events, undefined, #{}), {Events, hg_machine_action:instant()}}. +-spec get_merchant_payments_terms(opts(), hg_domain:revision(), hg_datetime:timestamp(), hg_varset:varset()) -> + dmsl_domain_thrift:'PaymentsServiceTerms'(). get_merchant_payments_terms(Opts, Revision, _Timestamp, VS) -> Shop = get_shop(Opts, Revision), TermSet = hg_invoice_utils:compute_shop_terms(Revision, Shop, VS), @@ -450,46 +493,12 @@ get_merchant_payments_terms(Opts, Revision, _Timestamp, VS) -> -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. +get_provider_terminal_terms(Route, VS, Revision) -> + hg_invoice_payment_cashflow:get_provider_terminal_terms(Route, VS, Revision). -spec construct_payer(payer_params()) -> {ok, payer(), map()}. -construct_payer( - {payment_resource, #payproc_PaymentResourcePayerParams{ - resource = Resource, - contact_info = ContactInfo - }} -) -> - {ok, ?payment_resource_payer(Resource, ContactInfo), #{}}; -construct_payer( - {recurrent, #payproc_RecurrentPayerParams{ - recurrent_parent = Parent, - contact_info = ContactInfo - }} -) -> - ?recurrent_parent(InvoiceID, PaymentID) = Parent, - ParentPayment = - try - get_payment_state(InvoiceID, PaymentID) - catch - throw:#payproc_InvoiceNotFound{} -> - throw_invalid_recurrent_parent(<<"Parent invoice not found">>); - throw:#payproc_InvoicePaymentNotFound{} -> - throw_invalid_recurrent_parent(<<"Parent payment not found">>) - end, - #domain_InvoicePayment{payer = ParentPayer} = get_payment(ParentPayment), - ParentPaymentTool = get_payer_payment_tool(ParentPayer), - {ok, ?recurrent_payer(ParentPaymentTool, Parent, ContactInfo), #{parent_payment => ParentPayment}}. +construct_payer(PayerParams) -> + hg_invoice_payment_construction:construct_payer(PayerParams). construct_payment( PaymentID, @@ -498,332 +507,32 @@ construct_payment( Payer, FlowParams, PartyConfigRef, - {ShopConfigRef, Shop} = ShopObj, + ShopObj, VS0, Revision, MakeRecurrent ) -> - PaymentTool = get_payer_payment_tool(Payer), - VS1 = VS0#{ - payment_tool => PaymentTool, - cost => Cost - }, - Terms = hg_invoice_utils:compute_shop_terms(Revision, Shop, VS1), - #domain_TermSet{payments = PaymentTerms, recurrent_paytools = RecurrentTerms} = Terms, - ok = validate_payment_tool( - PaymentTool, - PaymentTerms#domain_PaymentsServiceTerms.payment_methods - ), - ok = validate_cash( + hg_invoice_payment_construction:construct_payment( + PaymentID, + CreatedAt, Cost, - PaymentTerms#domain_PaymentsServiceTerms.cash_limit - ), - Flow = construct_payment_flow( + Payer, FlowParams, - CreatedAt, - PaymentTerms#domain_PaymentsServiceTerms.holds, - PaymentTool - ), - ParentPayment = maps:get(parent_payment, VS1, undefined), - ok = validate_recurrent_intention(Payer, RecurrentTerms, PaymentTool, ShopObj, ParentPayment, MakeRecurrent), - #domain_InvoicePayment{ - id = PaymentID, - created_at = CreatedAt, - party_ref = PartyConfigRef, - shop_ref = ShopConfigRef, - domain_revision = Revision, - status = ?pending(), - cost = Cost, - payer = Payer, - flow = Flow, - make_recurrent = MakeRecurrent, - registration_origin = ?invoice_payment_merchant_reg_origin() - }. - -construct_payment_flow({instant, _}, _CreatedAt, _Terms, _PaymentTool) -> - ?invoice_payment_flow_instant(); -construct_payment_flow({hold, Params}, CreatedAt, Terms, PaymentTool) -> - OnHoldExpiration = Params#payproc_InvoicePaymentParamsFlowHold.on_hold_expiration, - ?hold_lifetime(Seconds) = validate_hold_lifetime(Terms, PaymentTool), - HeldUntil = hg_datetime:format_ts(hg_datetime:parse_ts(CreatedAt) + Seconds), - ?invoice_payment_flow_hold(OnHoldExpiration, HeldUntil). + PartyConfigRef, + ShopObj, + VS0, + Revision, + MakeRecurrent + ). reconstruct_payment_flow(Payment, VS) -> - #domain_InvoicePayment{ - flow = Flow, - created_at = CreatedAt - } = Payment, - reconstruct_payment_flow(Flow, CreatedAt, VS). - -reconstruct_payment_flow(?invoice_payment_flow_instant(), _CreatedAt, VS) -> - VS#{flow => instant}; -reconstruct_payment_flow(?invoice_payment_flow_hold(_OnHoldExpiration, HeldUntil), CreatedAt, VS) -> - Seconds = hg_datetime:parse_ts(HeldUntil) - hg_datetime:parse_ts(CreatedAt), - VS#{flow => {hold, ?hold_lifetime(Seconds)}}. - --spec get_predefined_route(payer()) -> {ok, route()} | undefined. -get_predefined_route(?payment_resource_payer()) -> - undefined; -get_predefined_route(?recurrent_payer() = Payer) -> - get_predefined_recurrent_route(Payer). - --spec get_predefined_recurrent_route(payer()) -> {ok, route()}. -get_predefined_recurrent_route(?recurrent_payer(_, ?recurrent_parent(InvoiceID, PaymentID), _)) -> - PreviousPayment = get_payment_state(InvoiceID, PaymentID), - {ok, get_route(PreviousPayment)}. - -validate_hold_lifetime( - #domain_PaymentHoldsServiceTerms{ - payment_methods = PMs, - lifetime = LifetimeSelector - }, - PaymentTool -) -> - ok = validate_payment_tool(PaymentTool, PMs), - get_selector_value(hold_lifetime, LifetimeSelector); -validate_hold_lifetime(undefined, _PaymentTool) -> - throw_invalid_request(<<"Holds are not available">>). - --spec validate_recurrent_intention( - payer(), - recurrent_paytool_service_terms(), - payment_tool(), - {shop_config_ref(), shop()}, - payment(), - make_recurrent() -) -> ok | no_return(). -validate_recurrent_intention( - ?recurrent_payer() = Payer, - RecurrentTerms, - PaymentTool, - ShopObj, - ParentPayment, - MakeRecurrent -) -> - ok = validate_recurrent_terms(RecurrentTerms, PaymentTool), - ok = validate_recurrent_payer(Payer, MakeRecurrent), - ok = validate_recurrent_parent(ShopObj, ParentPayment); -validate_recurrent_intention(Payer, RecurrentTerms, PaymentTool, _Shop, _ParentPayment, true = MakeRecurrent) -> - ok = validate_recurrent_terms(RecurrentTerms, PaymentTool), - ok = validate_recurrent_payer(Payer, MakeRecurrent); -validate_recurrent_intention(_Payer, _RecurrentTerms, _PaymentTool, _Shop, _ParentPayment, false = _MakeRecurrent) -> - ok. - --spec validate_recurrent_terms(recurrent_paytool_service_terms(), payment_tool()) -> ok | no_return(). -validate_recurrent_terms(undefined, _PaymentTool) -> - throw(#payproc_OperationNotPermitted{}); -validate_recurrent_terms(RecurrentTerms, PaymentTool) -> - #domain_RecurrentPaytoolsServiceTerms{payment_methods = PaymentMethodSelector} = RecurrentTerms, - PMs = get_selector_value(recurrent_payment_methods, PaymentMethodSelector), - % _ = - % hg_payment_tool:has_any_payment_method(PaymentTool, PMs) orelse - % throw_invalid_request(<<"Invalid payment method">>), - %% TODO delete logging after successfull migration tokenization method in domain_config - %% https://rbkmoney.atlassian.net/browse/ED-87 - _ = - case hg_payment_tool:has_any_payment_method(PaymentTool, PMs) of - false -> - logger:notice("PaymentTool: ~p", [PaymentTool]), - logger:notice("RecurrentPaymentMethods: ~p", [PMs]), - throw_invalid_request(<<"Invalid payment method">>); - true -> - ok - end, - ok. - --spec validate_recurrent_parent({shop_config_ref(), shop()}, st()) -> ok | no_return(). -validate_recurrent_parent(ShopObj, ParentPayment) -> - ok = validate_recurrent_token_present(ParentPayment), - ok = validate_recurrent_parent_party(ShopObj, ParentPayment), - ok = validate_recurrent_parent_status(ParentPayment). - --spec validate_recurrent_token_present(st()) -> ok | no_return(). -validate_recurrent_token_present(PaymentState) -> - case get_recurrent_token(PaymentState) of - Token when Token =/= undefined -> - ok; - undefined -> - throw_invalid_recurrent_parent(<<"Parent payment has no recurrent token">>) - end. - --spec validate_recurrent_parent_party({shop_config_ref(), shop()}, st()) -> ok | no_return(). -validate_recurrent_parent_party({_, #domain_ShopConfig{party_ref = PartyConfigRef}}, PaymentState) -> - PaymentPartyConfigRef = get_payment_party_config_ref(get_payment(PaymentState)), - case PartyConfigRef =:= PaymentPartyConfigRef of - true -> - ok; - false -> - throw_invalid_recurrent_parent(<<"Parent payment refer to another party">>) - end. - --spec validate_recurrent_parent_status(st()) -> ok | no_return(). -validate_recurrent_parent_status(PaymentState) -> - case get_payment(PaymentState) of - #domain_InvoicePayment{status = {failed, _}} -> - throw_invalid_recurrent_parent(<<"Invalid parent payment status">>); - _Other -> - ok - end. - --spec validate_recurrent_payer(dmsl_domain_thrift:'Payer'(), make_recurrent()) -> ok | no_return(). -validate_recurrent_payer(?recurrent_payer(), _MakeRecurrent) -> - ok; -validate_recurrent_payer(?payment_resource_payer(), true) -> - ok; -validate_recurrent_payer(_OtherPayer, true) -> - throw_invalid_request(<<"Invalid payer">>). - -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. - -gather_routes(PaymentInstitution, VS, Revision, St) -> - Payment = get_payment(St), - Predestination = choose_routing_predestination(Payment), - #domain_Cash{currency = Currency} = get_payment_cost(Payment), - Payer = Payment#domain_InvoicePayment.payer, - #domain_ContactInfo{email = Email} = get_contact_info(Payer), - CardToken = get_payer_card_token(Payer), - PaymentTool = get_payer_payment_tool(Payer), - ClientIP = get_payer_client_ip(Payer), - hg_routing:gather_routes(Predestination, PaymentInstitution, VS, Revision, #{ - currency => Currency, - payment_tool => PaymentTool, - client_ip => ClientIP, - email => Email, - card_token => CardToken - }). - --spec check_risk_score(risk_score()) -> ok | {error, risk_score_is_too_high}. -check_risk_score(fatal) -> - {error, risk_score_is_too_high}; -check_risk_score(_RiskScore) -> - ok. - --spec choose_routing_predestination(payment()) -> hg_routing:route_predestination(). -choose_routing_predestination(#domain_InvoicePayment{make_recurrent = true}) -> - recurrent_payment; -choose_routing_predestination(#domain_InvoicePayment{payer = ?payment_resource_payer()}) -> - payment. - -% Other payers has predefined routes - -log_route_choice_meta(#{choice_meta := undefined}, _Revision) -> - ok; -log_route_choice_meta(#{choice_meta := ChoiceMeta}, Revision) -> - Metadata = hg_routing:get_logger_metadata(ChoiceMeta, Revision), - logger:log(notice, "Routing decision made", #{routing => Metadata}). - -maybe_log_misconfigurations({misconfiguration, _} = Error) -> - {Format, Details} = hg_routing:prepare_log_message(Error), - ?LOG_MD(warning, Format, Details); -maybe_log_misconfigurations(_Error) -> - ok. - -log_rejected_routes(_, [], _VS) -> - ok; -log_rejected_routes(all, Routes, VS) -> - ?LOG_MD(warning, "No route found for varset: ~p", [VS]), - ?LOG_MD(warning, "No route found, rejected routes: ~p", [Routes]); -log_rejected_routes(limit_misconfiguration, Routes, _VS) -> - ?LOG_MD(warning, "Limiter hold error caused route candidates to be rejected: ~p", [Routes]); -log_rejected_routes(limit_overflow, Routes, _VS) -> - ?LOG_MD(notice, "Limit overflow caused route candidates to be rejected: ~p", [Routes]); -log_rejected_routes(in_blacklist, Routes, _VS) -> - ?LOG_MD(notice, "Route candidates are blacklisted: ~p", [Routes]); -log_rejected_routes(adapter_unavailable, Routes, _VS) -> - ?LOG_MD(notice, "Adapter unavailability caused route candidates to be rejected: ~p", [Routes]); -log_rejected_routes(provider_conversion_is_too_low, Routes, _VS) -> - ?LOG_MD(notice, "Lacking conversion of provider caused route candidates to be rejected: ~p", [Routes]); -log_rejected_routes(forbidden, Routes, VS) -> - ?LOG_MD(notice, "Rejected routes found for varset: ~p", [VS]), - ?LOG_MD(notice, "Rejected routes found, rejected routes: ~p", [Routes]); -log_rejected_routes(_, _Routes, _VS) -> - 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 -) -> - % nothing here yet - VS; -collect_chargeback_varset(undefined, VS) -> - VS. - -collect_refund_varset( - #domain_PaymentRefundsServiceTerms{ - payment_methods = PaymentMethodSelector, - partial_refunds = PartialRefundsServiceTerms - }, - PaymentTool, - VS -) -> - RPMs = get_selector_value(payment_methods, PaymentMethodSelector), - case hg_payment_tool:has_any_payment_method(PaymentTool, RPMs) of - true -> - RVS = collect_partial_refund_varset(PartialRefundsServiceTerms), - VS#{refunds => RVS}; - false -> - VS - end; -collect_refund_varset(undefined, _PaymentTool, VS) -> - VS. - -collect_partial_refund_varset( - #domain_PartialRefundsServiceTerms{ - cash_limit = CashLimitSelector - } -) -> - #{ - partial => #{ - cash_limit => get_selector_value(cash_limit, CashLimitSelector) - } - }; -collect_partial_refund_varset(undefined) -> - #{}. + hg_invoice_payment_construction:reconstruct_payment_flow(Payment, VS). collect_validation_varset(St, Opts) -> - Revision = get_payment_revision(St), - collect_validation_varset(get_party_config_ref(Opts), get_shop_obj(Opts, Revision), get_payment(St), #{}). + hg_invoice_payment_cashflow:collect_validation_varset(St, Opts). collect_validation_varset(PartyConfigRef, ShopObj, Payment, VS) -> - Cost = #domain_Cash{currency = Currency} = get_payment_cost(Payment), - VS0 = collect_validation_varset_(PartyConfigRef, ShopObj, Currency, VS), - VS0#{ - cost => Cost, - payment_tool => get_payment_tool(Payment) - }. + hg_invoice_payment_cashflow:collect_validation_varset(PartyConfigRef, ShopObj, Payment, VS). collect_validation_varset_(PartyConfigRef, {#domain_ShopConfigRef{id = ShopConfigID}, Shop}, Currency, VS) -> #domain_ShopConfig{ @@ -841,48 +550,24 @@ collect_validation_varset_(PartyConfigRef, {#domain_ShopConfigRef{id = ShopConfi -spec construct_payment_plan_id(st()) -> payment_plan_id(). construct_payment_plan_id(#st{opts = Opts, payment = Payment} = St) -> Iter = get_iter(St), - construct_payment_plan_id(get_invoice(Opts), Payment, Iter, normal). + hg_invoice_payment_construction:construct_payment_plan_id(get_invoice(Opts), Payment, Iter, normal). -spec construct_payment_plan_id(st(), legacy | normal) -> payment_plan_id(). construct_payment_plan_id(#st{opts = Opts, payment = Payment} = St, Mode) -> Iter = get_iter(St), - construct_payment_plan_id(get_invoice(Opts), Payment, Iter, Mode). - -construct_payment_plan_id(Invoice, Payment, _Iter, legacy) -> - hg_utils:construct_complex_id([ - get_invoice_id(Invoice), - get_payment_id(Payment) - ]); -construct_payment_plan_id(Invoice, Payment, Iter, _Mode) -> - hg_utils:construct_complex_id([ - get_invoice_id(Invoice), - get_payment_id(Payment), - integer_to_binary(Iter) - ]). - -get_selector_value(Name, Selector) -> - case Selector of - {value, V} -> - V; - Ambiguous -> - error({misconfiguration, {'Could not reduce selector to a value', {Name, Ambiguous}}}) - end. + hg_invoice_payment_construction:construct_payment_plan_id(get_invoice(Opts), Payment, Iter, Mode). %% -spec start_session(target()) -> events(). start_session(Target) -> - [hg_session:wrap_event(Target, hg_session:create())]. + hg_invoice_payment_session:start_session(Target). start_capture(Reason, Cost, Cart, Allocation) -> - [?payment_capture_started(Reason, Cost, Cart, Allocation)] ++ - start_session(?captured(Reason, Cost, Cart, Allocation)). + hg_invoice_payment_session:start_capture(Reason, Cost, Cart, Allocation). start_partial_capture(Reason, Cost, Cart, FinalCashflow, Allocation) -> - [ - ?payment_capture_started(Reason, Cost, Cart, Allocation), - ?cash_flow_changed(FinalCashflow) - ]. + hg_invoice_payment_session:start_partial_capture(Reason, Cost, Cart, FinalCashflow, Allocation). -spec capture(st(), binary(), cash() | undefined, cart() | undefined, opts()) -> {ok, result()}. @@ -955,39 +640,16 @@ cancel(St, Reason) -> Changes = start_session(?cancelled_with_reason(Reason)), {ok, {Changes, hg_machine_action:instant()}}. -assert_capture_cost_currency(undefined, _) -> - ok; -assert_capture_cost_currency(?cash(_, SymCode), #domain_InvoicePayment{cost = ?cash(_, SymCode)}) -> - ok; -assert_capture_cost_currency(?cash(_, PassedSymCode), #domain_InvoicePayment{cost = ?cash(_, SymCode)}) -> - throw(#payproc_InconsistentCaptureCurrency{ - payment_currency = SymCode, - passed_currency = PassedSymCode - }). +assert_capture_cost_currency(Cost, Payment) -> + hg_invoice_payment_validation:assert_capture_cost_currency(Cost, Payment). -validate_processing_deadline(#domain_InvoicePayment{processing_deadline = Deadline}, processed = _TargetType) -> - case hg_invoice_utils:check_deadline(Deadline) of - ok -> - ok; - {error, deadline_reached} -> - {failure, - payproc_errors:construct( - 'PaymentFailure', - {authorization_failed, {processing_deadline_reached, #payproc_error_GeneralFailure{}}} - )} - end; -validate_processing_deadline(_, _TargetType) -> - ok. +%% Delegated to hg_invoice_payment_validation, kept for backward compatibility +-compile({nowarn_unused_function, [validate_processing_deadline/2]}). +validate_processing_deadline(Payment, TargetType) -> + hg_invoice_payment_validation:validate_processing_deadline(Payment, TargetType). -assert_capture_cart(_Cost, undefined) -> - ok; assert_capture_cart(Cost, Cart) -> - case Cost =:= hg_invoice_utils:get_cart_amount(Cart) of - true -> - ok; - _ -> - throw_invalid_request(<<"Capture amount does not match with the cart total amount">>) - end. + hg_invoice_payment_validation:assert_capture_cart(Cost, Cart). check_equal_capture_cost_amount(undefined, _) -> true; @@ -1005,29 +667,11 @@ check_equal_capture_cost_amount(?cash(PassedAmount, _), #domain_InvoicePayment{c passed_amount = PassedAmount }). -validate_merchant_hold_terms(#domain_PaymentsServiceTerms{holds = Terms}) when Terms /= undefined -> - case Terms of - %% Чтобы упростить интеграцию, по умолчанию разрешили частичные подтверждения - #domain_PaymentHoldsServiceTerms{partial_captures = undefined} -> - ok; - #domain_PaymentHoldsServiceTerms{} -> - throw(#payproc_OperationNotPermitted{}) - end; -%% Чтобы упростить интеграцию, по умолчанию разрешили частичные подтверждения -validate_merchant_hold_terms(#domain_PaymentsServiceTerms{holds = undefined}) -> - ok. +validate_merchant_hold_terms(Terms) -> + hg_invoice_payment_validation:validate_merchant_hold_terms(Terms). -validate_provider_holds_terms(#domain_PaymentsProvisionTerms{holds = Terms}) when Terms /= undefined -> - case Terms of - %% Чтобы упростить интеграцию, по умолчанию разрешили частичные подтверждения - #domain_PaymentHoldsProvisionTerms{partial_captures = undefined} -> - ok; - #domain_PaymentHoldsProvisionTerms{} -> - throw(#payproc_OperationNotPermitted{}) - end; -%% Чтобы упростить интеграцию, по умолчанию разрешили частичные подтверждения -validate_provider_holds_terms(#domain_PaymentsProvisionTerms{holds = undefined}) -> - ok. +validate_provider_holds_terms(Terms) -> + hg_invoice_payment_validation:validate_provider_holds_terms(Terms). -spec create_chargeback(st(), opts(), hg_invoice_payment_chargeback:create_params()) -> {chargeback(), result()}. create_chargeback(St, Opts, Params) -> @@ -1066,10 +710,8 @@ reopen_chargeback(ChargebackID, St, Params) -> get_chargeback_id(#payproc_InvoicePaymentChargebackParams{id = ID}) -> ID. -validate_payment_status(Status, #domain_InvoicePayment{status = {Status, _}}) -> - ok; -validate_payment_status(_, #domain_InvoicePayment{status = Status}) -> - throw(#payproc_InvalidPaymentStatus{status = Status}). +validate_payment_status(Status, Payment) -> + hg_invoice_payment_validation:validate_payment_status(Status, Payment). -spec refund(refund_params(), st(), opts()) -> {domain_refund(), result()}. refund(Params, St0, #{timestamp := CreatedAt} = Opts) -> @@ -1079,7 +721,17 @@ refund(Params, St0, #{timestamp := CreatedAt} = Opts) -> 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), + MerchantRefundTerms = get_merchant_refunds_terms(MerchantTerms), + FinalCashflow = make_refund_cashflow( + Refund, + Payment, + Revision, + St, + Opts, + MerchantRefundTerms, + VS, + CreatedAt + ), Changes = hg_invoice_payment_refund:create(#{ refund => Refund, cash_flow => FinalCashflow @@ -1094,7 +746,17 @@ manual_refund(Params, St0, #{timestamp := CreatedAt} = Opts) -> 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), + MerchantRefundTerms = get_merchant_refunds_terms(MerchantTerms), + FinalCashflow = make_refund_cashflow( + Refund, + Payment, + Revision, + St, + Opts, + MerchantRefundTerms, + VS, + CreatedAt + ), TransactionInfo = Params#payproc_InvoicePaymentRefundParams.transaction_info, Changes = hg_invoice_payment_refund:create(#{ refund => Refund, @@ -1137,70 +799,20 @@ make_refund(Params, Payment, Revision, CreatedAt, St, Opts) -> ok = validate_refund(MerchantRefundTerms, Refund, Payment), Refund. -validate_allocation_refund(undefined, _St) -> - ok. +validate_allocation_refund(Allocation, St) -> + hg_invoice_payment_validation:validate_allocation_refund(Allocation, St). make_refund_cashflow(Refund, Payment, Revision, St, Opts, MerchantTerms, VS, Timestamp) -> - Route = get_route(St), - ProviderPaymentsTerms = get_provider_terminal_terms(Route, VS, Revision), - Allocation = Refund#domain_InvoicePaymentRefund.allocation, - CollectCashflowContext = genlib_map:compact(#{ - operation => refund, - provision_terms => get_provider_refunds_terms(ProviderPaymentsTerms, Refund, Payment), - merchant_terms => MerchantTerms, - party => get_party_obj(Opts), - shop => get_shop_obj(Opts, Revision), - route => Route, - payment => Payment, - provider => get_route_provider(Route, Revision), - timestamp => Timestamp, - varset => VS, - revision => Revision, - refund => Refund, - allocation => Allocation - }), - hg_cashflow_utils:collect_cashflow(CollectCashflowContext). + hg_invoice_payment_cashflow:make_refund_cashflow(Refund, Payment, Revision, St, Opts, MerchantTerms, VS, Timestamp). 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}). + hg_invoice_payment_validation:assert_refund_cash(Cash, St). 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. + hg_invoice_payment_validation:assert_previous_refunds_finished(St). -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. - -get_remaining_payment_amount(Cash, St) -> - InterimPaymentAmount = get_remaining_payment_balance(St), - hg_cash:sub(InterimPaymentAmount, Cash). + hg_invoice_payment_validation:assert_refund_cart(RefundCash, Cart, St). -spec get_remaining_payment_balance(st()) -> cash(). get_remaining_payment_balance(St) -> @@ -1228,1188 +840,81 @@ get_remaining_payment_balance(St) -> ). get_merchant_refunds_terms(#domain_PaymentsServiceTerms{refunds = Terms}) when Terms /= undefined -> - Terms; + normalize_refund_terms(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}}). +normalize_refund_terms(#domain_PaymentRefundsServiceTerms{} = Terms) -> + Terms; +normalize_refund_terms({value, Terms}) -> + normalize_refund_terms(Terms); +normalize_refund_terms(Other) -> + error({misconfiguration, {'Unexpected refund terms', Other}}). 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. + hg_invoice_payment_validation:validate_refund(Terms, Refund, Payment). %% -spec create_adjustment(hg_datetime:timestamp(), adjustment_params(), st(), opts()) -> {adjustment(), result()}. -create_adjustment(Timestamp, Params, St, Opts) -> - _ = assert_no_adjustment_pending(St), - case Params#payproc_InvoicePaymentAdjustmentParams.scenario of - {cash_flow, #domain_InvoicePaymentAdjustmentCashFlow{domain_revision = DomainRevision}} -> - create_cash_flow_adjustment(Timestamp, Params, DomainRevision, St, Opts); - {status_change, Change} -> - create_status_adjustment(Timestamp, Params, Change, St, Opts) - end. - --spec create_cash_flow_adjustment( - hg_datetime:timestamp(), - adjustment_params(), - undefined | hg_domain:revision(), - st(), - opts() -) -> {adjustment(), result()}. -create_cash_flow_adjustment(Timestamp, Params, DomainRevision, St, Opts) -> - Payment = get_payment(St), - Route = get_route(St), - _ = assert_payment_status([captured, refunded, charged_back, failed], Payment), - NewRevision = maybe_get_domain_revision(DomainRevision), - OldCashFlow = get_final_cashflow(St), - VS = collect_validation_varset(St, Opts), - Allocation = get_allocation(St), - {Payment1, AdditionalEvents} = maybe_inject_new_cost_amount( - Payment, Params#payproc_InvoicePaymentAdjustmentParams.scenario - ), - Context = #{ - provision_terms => get_provider_terminal_terms(Route, VS, NewRevision), - route => Route, - payment => Payment1, - timestamp => Timestamp, - varset => VS, - revision => NewRevision, - allocation => Allocation - }, - NewCashFlow = - case Payment of - #domain_InvoicePayment{status = {failed, _}} -> - []; - _ -> - calculate_cashflow(Context, Opts) - end, - AdjState = - {cash_flow, #domain_InvoicePaymentAdjustmentCashFlowState{ - scenario = #domain_InvoicePaymentAdjustmentCashFlow{domain_revision = DomainRevision} - }}, - construct_adjustment( - Timestamp, - Params, - NewRevision, - OldCashFlow, - NewCashFlow, - AdjState, - AdditionalEvents, - St - ). - -maybe_inject_new_cost_amount( - Payment, - {'cash_flow', #domain_InvoicePaymentAdjustmentCashFlow{new_amount = NewAmount}} -) when NewAmount =/= undefined -> - OldCost = get_payment_cost(Payment), - NewCost = OldCost#domain_Cash{amount = NewAmount}, - Payment1 = Payment#domain_InvoicePayment{cost = NewCost}, - {Payment1, [?cash_changed(OldCost, NewCost)]}; -maybe_inject_new_cost_amount(Payment, _AdjustmentScenario) -> - {Payment, []}. - --spec create_status_adjustment( - hg_datetime:timestamp(), - adjustment_params(), - adjustment_status_change(), - st(), - opts() -) -> {adjustment(), result()}. -create_status_adjustment(Timestamp, Params, Change, St, Opts) -> - #domain_InvoicePaymentAdjustmentStatusChange{ - target_status = TargetStatus - } = Change, - #domain_InvoicePayment{ - status = Status, - domain_revision = DomainRevision - } = get_payment(St), - ok = assert_adjustment_payment_status(Status), - ok = assert_no_refunds(St), - ok = assert_adjustment_payment_statuses(TargetStatus, Status), - OldCashFlow = get_cash_flow_for_status(Status, St), - NewCashFlow = get_cash_flow_for_target_status(TargetStatus, St, Opts), - AdjState = - {status_change, #domain_InvoicePaymentAdjustmentStatusChangeState{ - scenario = Change - }}, - construct_adjustment( - Timestamp, - Params, - DomainRevision, - OldCashFlow, - NewCashFlow, - AdjState, - [], - St - ). - --spec maybe_get_domain_revision(undefined | hg_domain:revision()) -> hg_domain:revision(). -maybe_get_domain_revision(undefined) -> - hg_domain:head(); -maybe_get_domain_revision(DomainRevision) -> - DomainRevision. - --spec assert_adjustment_payment_status(payment_status()) -> ok | no_return(). -assert_adjustment_payment_status(Status) -> - case is_adjustment_payment_status_final(Status) of - true -> - ok; - false -> - erlang:throw(#payproc_InvalidPaymentStatus{status = Status}) - end. - -assert_no_refunds(St) -> - case get_refunds_count(St) of - 0 -> - ok; - _ -> - throw_invalid_request(<<"Cannot change status of payment with refunds.">>) - end. - --spec assert_adjustment_payment_statuses(TargetStatus :: payment_status(), Status :: payment_status()) -> - ok | no_return(). -assert_adjustment_payment_statuses(Status, Status) -> - erlang:throw(#payproc_InvoicePaymentAlreadyHasStatus{status = Status}); -assert_adjustment_payment_statuses(TargetStatus, _Status) -> - case is_adjustment_payment_status_final(TargetStatus) of - true -> - ok; - false -> - erlang:throw(#payproc_InvalidPaymentTargetStatus{status = TargetStatus}) - end. - --spec is_adjustment_payment_status_final(payment_status()) -> boolean(). -is_adjustment_payment_status_final({captured, _}) -> - true; -is_adjustment_payment_status_final({cancelled, _}) -> - true; -is_adjustment_payment_status_final({failed, _}) -> - true; -is_adjustment_payment_status_final(_) -> - false. - --spec get_cash_flow_for_status(payment_status(), st()) -> final_cash_flow(). -get_cash_flow_for_status({captured, _}, St) -> - get_final_cashflow(St); -get_cash_flow_for_status({cancelled, _}, _St) -> - []; -get_cash_flow_for_status({failed, _}, _St) -> - []. - --spec get_cash_flow_for_target_status(payment_status(), st(), opts()) -> final_cash_flow(). -get_cash_flow_for_target_status({captured, Captured}, St0, Opts) -> - Payment0 = get_payment(St0), - Route = get_route(St0), - Cost = get_captured_cost(Captured, Payment0), - Allocation = get_captured_allocation(Captured), - Payment1 = Payment0#domain_InvoicePayment{ - cost = Cost - }, - Payment2 = - case Payment1 of - #domain_InvoicePayment{changed_cost = ChangedCost} when ChangedCost =/= undefined -> - Payment1#domain_InvoicePayment{ - cost = ChangedCost - }; - _ -> - Payment1 - end, - Timestamp = get_payment_created_at(Payment2), - St = St0#st{payment = Payment2}, - Revision = Payment2#domain_InvoicePayment.domain_revision, - VS = collect_validation_varset(St, Opts), - Context = #{ - provision_terms => get_provider_terminal_terms(Route, VS, Revision), - route => Route, - payment => Payment2, - timestamp => Timestamp, - varset => VS, - revision => Revision, - allocation => Allocation - }, - calculate_cashflow(Context, Opts); -get_cash_flow_for_target_status({cancelled, _}, _St, _Opts) -> - []; -get_cash_flow_for_target_status({failed, _}, _St, _Opts) -> - []. - --spec calculate_cashflow(cashflow_context(), opts()) -> final_cash_flow(). -calculate_cashflow(#{route := Route, revision := Revision} = Context, Opts) -> - CollectCashflowContext = genlib_map:compact(Context#{ - operation => payment, - party => get_party_obj(Opts), - shop => get_shop_obj(Opts, Revision), - provider => get_route_provider(Route, Revision) - }), - hg_cashflow_utils:collect_cashflow(CollectCashflowContext). - --spec calculate_cashflow(hg_payment_institution:t(), cashflow_context(), opts()) -> final_cash_flow(). -calculate_cashflow(PaymentInstitution, #{route := Route, revision := Revision} = Context, Opts) -> - CollectCashflowContext = genlib_map:compact(Context#{ - operation => payment, - party => get_party_obj(Opts), - shop => get_shop_obj(Opts, Revision), - provider => get_route_provider(Route, Revision) - }), - hg_cashflow_utils:collect_cashflow(PaymentInstitution, CollectCashflowContext). - --spec construct_adjustment( - Timestamp :: hg_datetime:timestamp(), - Params :: adjustment_params(), - DomainRevision :: hg_domain:revision(), - OldCashFlow :: final_cash_flow(), - NewCashFlow :: final_cash_flow(), - State :: adjustment_state(), - AdditionalEvents :: events(), - St :: st() -) -> {adjustment(), result()}. -construct_adjustment( - Timestamp, - Params, - DomainRevision, - OldCashFlow, - NewCashFlow, - State, - AdditionalEvents, - St -) -> - ID = construct_adjustment_id(St), - Adjustment = #domain_InvoicePaymentAdjustment{ - id = ID, - status = ?adjustment_pending(), - created_at = Timestamp, - domain_revision = DomainRevision, - reason = Params#payproc_InvoicePaymentAdjustmentParams.reason, - old_cash_flow_inverse = hg_cashflow:revert(OldCashFlow), - new_cash_flow = NewCashFlow, - state = State - }, - Events = [?adjustment_ev(ID, ?adjustment_created(Adjustment)) | AdditionalEvents], - {Adjustment, {Events, hg_machine_action:instant()}}. - -construct_adjustment_id(#st{adjustments = As}) -> - erlang:integer_to_binary(length(As) + 1). - --spec assert_activity(activity(), st()) -> ok | no_return(). -assert_activity(Activity, #st{activity = Activity}) -> - ok; -assert_activity(_Activity, St) -> - %% TODO: Create dedicated error like "Payment is capturing already" - #domain_InvoicePayment{status = Status} = get_payment(St), - throw(#payproc_InvalidPaymentStatus{status = Status}). - -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}). - -assert_no_pending_chargebacks(PaymentState) -> - Chargebacks = [CB#payproc_InvoicePaymentChargeback.chargeback || CB <- get_chargebacks(PaymentState)], - case lists:any(fun hg_invoice_payment_chargeback:is_pending/1, Chargebacks) of - true -> - throw(#payproc_InvoicePaymentChargebackPending{}); - false -> - ok - end. - -assert_no_adjustment_pending(#st{adjustments = As}) -> - lists:foreach(fun assert_adjustment_finalized/1, As). - -assert_adjustment_finalized(#domain_InvoicePaymentAdjustment{id = ID, status = {Status, _}}) when - Status =:= pending; Status =:= processed --> - throw(#payproc_InvoicePaymentAdjustmentPending{id = ID}); -assert_adjustment_finalized(_) -> - ok. - -assert_payment_flow(hold, #domain_InvoicePayment{flow = ?invoice_payment_flow_hold(_, _)}) -> - ok; -assert_payment_flow(_, _) -> - throw(#payproc_OperationNotPermitted{}). - --spec process_adjustment_capture(adjustment_id(), action(), st()) -> machine_result(). -process_adjustment_capture(ID, _Action, St) -> - Opts = get_opts(St), - Adjustment = get_adjustment(ID, St), - ok = assert_adjustment_status(processed, Adjustment), - ok = finalize_adjustment_cashflow(Adjustment, St, Opts), - Status = ?adjustment_captured(maps:get(timestamp, Opts)), - Event = ?adjustment_ev(ID, ?adjustment_status_changed(Status)), - {done, {[Event], hg_machine_action:new()}}. - -prepare_adjustment_cashflow(Adjustment, St, Options) -> - PlanID = construct_adjustment_plan_id(Adjustment, St, Options), - Plan = get_adjustment_cashflow_plan(Adjustment), - plan(PlanID, Plan). - -finalize_adjustment_cashflow(Adjustment, St, Options) -> - PlanID = construct_adjustment_plan_id(Adjustment, St, Options), - Plan = get_adjustment_cashflow_plan(Adjustment), - commit(PlanID, Plan). - -get_adjustment_cashflow_plan(#domain_InvoicePaymentAdjustment{ - old_cash_flow_inverse = CashflowInverse, - new_cash_flow = Cashflow -}) -> - number_plan([CashflowInverse, Cashflow], 1, []). - -number_plan([], _Number, Acc) -> - lists:reverse(Acc); -number_plan([[] | Tail], Number, Acc) -> - number_plan(Tail, Number, Acc); -number_plan([NonEmpty | Tail], Number, Acc) -> - number_plan(Tail, Number + 1, [{Number, NonEmpty} | Acc]). - -plan(_PlanID, []) -> - ok; -plan(PlanID, Plan) -> - _ = hg_accounting:plan(PlanID, Plan), - ok. - -commit(_PlanID, []) -> - ok; -commit(PlanID, Plan) -> - _ = hg_accounting:commit(PlanID, Plan), - ok. - -assert_adjustment_status(Status, #domain_InvoicePaymentAdjustment{status = {Status, _}}) -> - ok; -assert_adjustment_status(_, #domain_InvoicePaymentAdjustment{status = Status}) -> - throw(#payproc_InvalidPaymentAdjustmentStatus{status = Status}). - -construct_adjustment_plan_id(Adjustment, St, Options) -> - hg_utils:construct_complex_id([ - get_invoice_id(get_invoice(Options)), - get_payment_id(get_payment(St)), - {adj, get_adjustment_id(Adjustment)} - ]). - -get_adjustment_id(#domain_InvoicePaymentAdjustment{id = ID}) -> - ID. - -get_adjustment_status(#domain_InvoicePaymentAdjustment{status = Status}) -> - Status. - -get_adjustment_cashflow(#domain_InvoicePaymentAdjustment{new_cash_flow = Cashflow}) -> - Cashflow. - --define(adjustment_target_status(Status), #domain_InvoicePaymentAdjustment{ - state = - {status_change, #domain_InvoicePaymentAdjustmentStatusChangeState{ - scenario = #domain_InvoicePaymentAdjustmentStatusChange{target_status = Status} - }} -}). - -%% - --spec process_signal(timeout, st(), opts()) -> machine_result(). -process_signal(timeout, St, Options) -> - scoper:scope( - payment, - get_st_meta(St), - fun() -> process_timeout(St#st{opts = Options}) end - ). - -process_timeout(St) -> - Action = hg_machine_action:new(), - repair_process_timeout(get_activity(St), Action, St). - --spec process_timeout(activity(), action(), st()) -> machine_result(). -process_timeout({payment, shop_limit_initializing}, Action, St) -> - process_shop_limit_initialization(Action, St); -process_timeout({payment, shop_limit_failure}, Action, St) -> - process_shop_limit_failure(Action, St); -process_timeout({payment, shop_limit_finalizing}, Action, St) -> - process_shop_limit_finalization(Action, St); -process_timeout({payment, risk_scoring}, Action, St) -> - process_risk_score(Action, St); -process_timeout({payment, routing}, Action, St) -> - process_routing(Action, St); -process_timeout({payment, cash_flow_building}, Action, St) -> - process_cash_flow_building(Action, St); -process_timeout({payment, Step}, _Action, St) when - Step =:= processing_session orelse - Step =:= finalizing_session --> - process_session(St); -process_timeout({payment, Step}, Action, St) when - Step =:= processing_failure orelse - Step =:= routing_failure orelse - Step =:= processing_accounter orelse - Step =:= finalizing_accounter --> - process_result(Action, St); -process_timeout({payment, updating_accounter}, Action, St) -> - process_accounter_update(Action, St); -process_timeout({chargeback, ID, Type}, Action, St) -> - process_chargeback(Type, ID, Action, St); -process_timeout({refund, ID}, _Action, St) -> - process_refund(ID, St); -process_timeout({adjustment_new, ID}, Action, St) -> - process_adjustment_cashflow(ID, Action, St); -process_timeout({adjustment_pending, ID}, Action, St) -> - process_adjustment_capture(ID, Action, St); -process_timeout({payment, flow_waiting}, Action, St) -> - finalize_payment(Action, St). - -process_refund(ID, #st{opts = Options0, payment = Payment, repair_scenario = Scenario} = St) -> - RepairScenario = - case hg_invoice_repair:check_for_action(repair_session, Scenario) of - call -> undefined; - RepairAction -> RepairAction - end, - PaymentInfo = construct_payment_info(St, get_opts(St)), - Options1 = Options0#{ - payment => Payment, - payment_info => PaymentInfo, - repair_scenario => RepairScenario - }, - Refund = try_get_refund_state(ID, St), - {Step, {Events0, Action}} = hg_invoice_payment_refund:process(Options1, Refund), - Events1 = hg_invoice_payment_refund:wrap_events(Events0, Refund), - Events2 = - case hg_invoice_payment_refund:is_status_changed(?refund_succeeded(), Events1) of - true -> - process_refund_result(Events1, Refund, St); - false -> - Events1 - end, - {Step, {Events2, Action}}. - -process_refund_result(Changes, Refund0, St) -> - Events = [Event || ?refund_ev(_, Event) <- Changes], - Refund1 = hg_invoice_payment_refund:update_state_with(Events, Refund0), - PaymentEvents = - case - hg_cash:sub( - get_remaining_payment_balance(St), hg_invoice_payment_refund:cash(Refund1) - ) - of - ?cash(0, _) -> - [ - ?payment_status_changed(?refunded()) - ]; - ?cash(Amount, _) when Amount > 0 -> - [] - end, - Changes ++ PaymentEvents. - -repair_process_timeout(Activity, Action, #st{repair_scenario = Scenario} = St) -> - case hg_invoice_repair:check_for_action(fail_pre_processing, Scenario) of - {result, Result} when - Activity =:= {payment, routing} orelse - Activity =:= {payment, cash_flow_building} - -> - rollback_broken_payment_limits(St), - Result; - {result, Result} -> - Result; - call -> - process_timeout(Activity, Action, St) - end. - --spec process_call - ({callback, tag(), callback()}, st(), opts()) -> {callback_response(), machine_result()}; - ({session_change, tag(), session_change()}, st(), opts()) -> {ok, machine_result()}. -process_call({callback, Tag, Payload}, St, Options) -> - scoper:scope( - payment, - get_st_meta(St), - fun() -> process_callback(Tag, Payload, St#st{opts = Options}) end - ); -process_call({session_change, Tag, SessionChange}, St, Options) -> - scoper:scope( - payment, - get_st_meta(St), - fun() -> process_session_change(Tag, SessionChange, St#st{opts = Options}) end - ). - --spec process_callback(tag(), callback(), st()) -> {callback_response(), machine_result()}. -process_callback(Tag, Payload, St) -> - Session = get_activity_session(St), - process_callback(Tag, Payload, Session, St). - --spec process_session_change(tag(), session_change(), st()) -> {ok, machine_result()}. -process_session_change(Tag, SessionChange, St) -> - Session = get_activity_session(St), - process_session_change(Tag, SessionChange, Session, St). - -process_callback(Tag, Payload, Session, St) when Session /= undefined -> - case {hg_session:status(Session), hg_session:tags(Session)} of - {suspended, [Tag | _]} -> - handle_callback(get_activity(St), Payload, Session, St); - _ -> - throw(invalid_callback) - end; -process_callback(_Tag, _Payload, undefined, _St) -> - throw(invalid_callback). - -process_session_change(Tag, SessionChange, Session0, St) when Session0 /= undefined -> - %% NOTE Change allowed only for suspended session. Not suspended - %% session does not have registered callback with tag. - case {hg_session:status(Session0), hg_session:tags(Session0)} of - {suspended, [Tag | _]} -> - {Result, Session1} = hg_session:process_change(SessionChange, Session0), - {ok, finish_session_processing(get_activity(St), Result, Session1, St)}; - _ -> - throw(invalid_callback) - end; -process_session_change(_Tag, _Payload, undefined, _St) -> - throw(invalid_callback). - -%% - --spec process_shop_limit_initialization(action(), st()) -> machine_result(). -process_shop_limit_initialization(Action, St) -> - Opts = get_opts(St), - _ = hold_shop_limits(Opts, St), - case check_shop_limits(Opts, St) of - ok -> - {next, {[?shop_limit_initiated()], hg_machine_action:set_timeout(0, Action)}}; - {error, {limit_overflow = Error, IDs}} -> - Failure = construct_shop_limit_failure(Error, IDs), - Events = [ - ?shop_limit_initiated(), - ?payment_rollback_started(Failure) - ], - {next, {Events, hg_machine_action:set_timeout(0, Action)}} - end. - -construct_shop_limit_failure(limit_overflow, IDs) -> - Error = mk_static_error([authorization_failed, shop_limit_exceeded, unknown]), - Reason = genlib:format("Limits with following IDs overflowed: ~p", [IDs]), - {failure, payproc_errors:construct('PaymentFailure', Error, Reason)}. - -process_shop_limit_failure(Action, #st{failure = Failure} = St) -> - Opts = get_opts(St), - _ = rollback_shop_limits(Opts, St, [ignore_business_error, ignore_not_found]), - {done, {[?payment_status_changed(?failed(Failure))], hg_machine_action:set_timeout(0, Action)}}. - --spec process_shop_limit_finalization(action(), st()) -> machine_result(). -process_shop_limit_finalization(Action, St) -> - Opts = get_opts(St), - _ = commit_shop_limits(Opts, St), - {next, {[?shop_limit_applied()], hg_machine_action:set_timeout(0, Action)}}. - --spec process_risk_score(action(), st()) -> machine_result(). -process_risk_score(Action, St) -> - Opts = get_opts(St), - Revision = get_payment_revision(St), - Payment = get_payment(St), - VS1 = get_varset(St, #{}), - PaymentInstitutionRef = get_payment_institution_ref(Opts, Revision), - PaymentInstitution = hg_payment_institution:compute_payment_institution(PaymentInstitutionRef, VS1, Revision), - RiskScore = repair_inspect(Payment, PaymentInstitution, Opts, St), - Events = [?risk_score_changed(RiskScore)], - case check_risk_score(RiskScore) of - ok -> - {next, {Events, hg_machine_action:set_timeout(0, Action)}}; - {error, risk_score_is_too_high = Reason} -> - logger:notice("No route found, reason = ~p, varset: ~p", [Reason, VS1]), - handle_choose_route_error(Reason, Events, St, Action) - end. - --spec process_routing(action(), st()) -> machine_result(). -process_routing(Action, St) -> - {PaymentInstitution, VS, Revision} = route_args(St), - Ctx0 = hg_routing_ctx:with_guard(build_routing_context(PaymentInstitution, VS, Revision, St)), - %% NOTE We need to handle routing errors differently if route not found - %% before the pipeline. - case hg_routing_ctx:error(Ctx0) of - undefined -> - Ctx1 = run_routing_decision_pipeline(Ctx0, VS, St), - _ = [ - log_rejected_routes(Group, RejectedRoutes, VS) - || {Group, RejectedRoutes} <- hg_routing_ctx:rejections(Ctx1) - ], - Events = produce_routing_events(Ctx1, Revision, St), - {next, {Events, hg_machine_action:set_timeout(0, Action)}}; - Error -> - ok = maybe_log_misconfigurations(Error), - ok = log_rejected_routes(all, hg_routing_ctx:rejected_routes(Ctx0), VS), - handle_choose_route_error(Error, [], St, Action) - end. - -run_routing_decision_pipeline(Ctx0, VS, St) -> - %% NOTE Since this is routing step then current attempt is not yet - %% accounted for in `St`. - NewIter = get_iter(St) + 1, - hg_routing_ctx:pipeline( - Ctx0, - [ - fun(Ctx) -> filter_attempted_routes(Ctx, St) end, - fun(Ctx) -> filter_routes_with_limit_hold(Ctx, VS, NewIter, St) end, - fun(Ctx) -> filter_routes_by_limit_overflow(Ctx, VS, NewIter, St) end, - fun(Ctx) -> hg_routing:filter_by_blacklist(Ctx, build_blacklist_context(St)) end, - fun hg_routing:filter_by_critical_provider_status/1, - fun hg_routing:choose_route_with_ctx/1 - ] - ). - -produce_routing_events(#{error := Error} = Ctx, Revision, St) when Error =/= undefined -> - %% TODO Pass failure subcode from error. Say, if last candidates were - %% rejected because of provider gone critical, then use subcode to highlight - %% the offender. Like 'provider_dead' or 'conversion_lacking'. - Failure = genlib:define(St#st.failure, construct_routing_failure(Error)), - %% NOTE Not all initial candidates have their according limits held. And so - %% we must account only for those that can be rolled back. - RollbackableCandidates = hg_routing_ctx:accounted_candidates(Ctx), - Route = hg_route:to_payment_route(hd(RollbackableCandidates)), - Candidates = - ordsets:from_list([hg_route:to_payment_route(R) || R <- RollbackableCandidates]), - RouteScores = hg_routing_ctx:route_scores(Ctx), - RouteLimits = hg_routing_ctx:route_limits(Ctx), - Decision = build_route_decision_context(Route, Revision), - %% For protocol compatability we set choosen route in route_changed event. - %% It doesn't influence cash_flow building because this step will be - %% skipped. And all limit's 'hold' operations will be rolled back. - %% For same purpose in cascade routing we use route from unfiltered list of - %% originally resolved candidates. - [?route_changed(Route, Candidates, RouteScores, RouteLimits, Decision), ?payment_rollback_started(Failure)]; -produce_routing_events(Ctx, Revision, _St) -> - ok = log_route_choice_meta(Ctx, Revision), - Route = hg_route:to_payment_route(hg_routing_ctx:choosen_route(Ctx)), - Candidates = - ordsets:from_list([hg_route:to_payment_route(R) || R <- hg_routing_ctx:considered_candidates(Ctx)]), - RouteScores = hg_routing_ctx:route_scores(Ctx), - RouteLimits = hg_routing_ctx:route_limits(Ctx), - Decision = build_route_decision_context(Route, Revision), - [?route_changed(Route, Candidates, RouteScores, RouteLimits, Decision)]. - -build_route_decision_context(Route, Revision) -> - ProvisionTerms = hg_routing:get_provision_terms(Route, #{}, Revision), - SkipRecurrent = - case ProvisionTerms#domain_ProvisionTermSet.extension of - #domain_ExtendedProvisionTerms{skip_recurrent = true} -> - true; - _ -> - undefined - end, - #payproc_RouteDecisionContext{skip_recurrent = SkipRecurrent}. - -route_args(St) -> - Opts = get_opts(St), - Revision = get_payment_revision(St), - Payment = get_payment(St), - #{payment_tool := PaymentTool} = VS1 = get_varset(St, #{risk_score => get_risk_score(St)}), - CreatedAt = get_payment_created_at(Payment), - PaymentInstitutionRef = get_payment_institution_ref(Opts, Revision), - MerchantTerms = get_merchant_payments_terms(Opts, Revision, CreatedAt, VS1), - VS2 = collect_refund_varset(MerchantTerms#domain_PaymentsServiceTerms.refunds, PaymentTool, VS1), - VS3 = collect_chargeback_varset(MerchantTerms#domain_PaymentsServiceTerms.chargebacks, VS2), - PaymentInstitution = hg_payment_institution:compute_payment_institution(PaymentInstitutionRef, VS1, Revision), - {PaymentInstitution, VS3, Revision}. - -build_routing_context(PaymentInstitution, VS, Revision, St) -> - Payer = get_payment_payer(St), - case get_predefined_route(Payer) of - {ok, PaymentRoute} -> - hg_routing_ctx:new([hg_route:from_payment_route(PaymentRoute)]); - undefined -> - gather_routes(PaymentInstitution, VS, Revision, St) - end. - -build_blacklist_context(St) -> - Revision = get_payment_revision(St), - #domain_InvoicePayment{payer = Payer} = get_payment(St), - Token = - case get_payer_payment_tool(Payer) of - {bank_card, #domain_BankCard{token = CardToken}} -> - CardToken; - _ -> - undefined - end, - Opts = get_opts(St), - VS1 = get_varset(St, #{}), - PaymentInstitutionRef = get_payment_institution_ref(Opts, Revision), - PaymentInstitution = hg_payment_institution:compute_payment_institution(PaymentInstitutionRef, VS1, Revision), - InspectorRef = get_selector_value(inspector, PaymentInstitution#domain_PaymentInstitution.inspector), - Inspector = hg_domain:get(Revision, {inspector, InspectorRef}), - #{ - revision => Revision, - token => Token, - inspector => Inspector - }. - -filter_attempted_routes(Ctx, #st{routes = AttemptedRoutes}) -> - lists:foldr( - fun(R, C) -> - R1 = hg_route:from_payment_route(R), - R2 = hg_route:to_rejected_route(R1, {'AlreadyAttempted', undefined}), - hg_routing_ctx:reject(already_attempted, R2, C) - end, - Ctx, - AttemptedRoutes - ). - -handle_choose_route_error(Error, Events, St, Action) -> - Failure = construct_routing_failure(Error), - process_failure(get_activity(St), Events, Action, Failure, St). +create_adjustment(Timestamp, Params, St, Opts) -> + hg_invoice_payment_adjustment:create_adjustment(Timestamp, Params, St, Opts). -%% NOTE See damsel payproc errors (proto/payment_processing_errors.thrift) for no route found +-spec calculate_cashflow(cashflow_context(), opts()) -> final_cash_flow(). +calculate_cashflow(Context, Opts) -> + hg_invoice_payment_cashflow:calculate_cashflow(Context, Opts). -construct_routing_failure({rejected_routes, {SubCode, RejectedRoutes}}) when - SubCode =:= limit_misconfiguration orelse - SubCode =:= limit_overflow orelse - SubCode =:= adapter_unavailable orelse - SubCode =:= provider_conversion_is_too_low --> - construct_routing_failure([rejected, SubCode], genlib:format(RejectedRoutes)); -construct_routing_failure({rejected_routes, {_SubCode, RejectedRoutes}}) -> - construct_routing_failure([forbidden], genlib:format(RejectedRoutes)); -construct_routing_failure({misconfiguration = Code, Details}) -> - construct_routing_failure([unknown, {unknown_error, atom_to_binary(Code)}], genlib:format(Details)); -construct_routing_failure(risk_score_is_too_high = Code) -> - construct_routing_failure([Code], undefined); -construct_routing_failure(Error) when is_atom(Error) -> - construct_routing_failure([{unknown_error, Error}], undefined). - -construct_routing_failure(Codes, Reason) -> - {failure, payproc_errors:construct('PaymentFailure', mk_static_error([no_route_found | Codes]), Reason)}. - -mk_static_error([_ | _] = Codes) -> mk_static_error_(#payproc_error_GeneralFailure{}, lists:reverse(Codes)). -mk_static_error_(T, []) -> T; -mk_static_error_(Sub, [Code | Codes]) -> mk_static_error_({Code, Sub}, Codes). - --spec process_cash_flow_building(action(), st()) -> machine_result(). -process_cash_flow_building(Action, St) -> - Route = get_route(St), - Opts = get_opts(St), - Revision = get_payment_revision(St), - Payment = get_payment(St), - Timestamp = get_payment_created_at(Payment), - VS0 = reconstruct_payment_flow(Payment, #{}), - VS1 = collect_validation_varset(get_party_config_ref(Opts), get_shop_obj(Opts, Revision), Payment, VS0), - ProviderTerms = get_provider_terminal_terms(Route, VS1, Revision), - Allocation = get_allocation(St), - Context = #{ - provision_terms => ProviderTerms, - route => Route, - payment => Payment, - timestamp => Timestamp, - varset => VS1, - revision => Revision, - allocation => Allocation - }, - FinalCashflow = calculate_cashflow(Context, Opts), - _ = rollback_unused_payment_limits(St), - _Clock = hg_accounting:hold( - construct_payment_plan_id(St), - {1, FinalCashflow} - ), - Events = [?cash_flow_changed(FinalCashflow)], - {next, {Events, hg_machine_action:set_timeout(0, Action)}}. +-spec calculate_cashflow(hg_payment_institution:t(), cashflow_context(), opts()) -> final_cash_flow(). +calculate_cashflow(PaymentInstitution, Context, Opts) -> + hg_invoice_payment_cashflow:calculate_cashflow(PaymentInstitution, Context, Opts). -%% +assert_activity(Activity, St) -> + hg_invoice_payment_validation:assert_activity(Activity, St). --spec process_chargeback(chargeback_activity_type(), chargeback_id(), action(), st()) -> machine_result(). -process_chargeback(finalising_accounter = Type, ID, Action0, St) -> - ChargebackState = get_chargeback_state(ID, St), - ChargebackOpts = get_chargeback_opts(St), - ChargebackBody = hg_invoice_payment_chargeback:get_body(ChargebackState), - ChargebackTarget = hg_invoice_payment_chargeback:get_target_status(ChargebackState), - MaybeChargedback = maybe_set_charged_back_status(ChargebackTarget, ChargebackBody, St), - {Changes, Action1} = hg_invoice_payment_chargeback:process_timeout(Type, ChargebackState, Action0, ChargebackOpts), - {done, {[?chargeback_ev(ID, C) || C <- Changes] ++ MaybeChargedback, Action1}}; -process_chargeback(Type, ID, Action0, St) -> - ChargebackState = get_chargeback_state(ID, St), - ChargebackOpts = get_chargeback_opts(St), - {Changes0, Action1} = hg_invoice_payment_chargeback:process_timeout(Type, ChargebackState, Action0, ChargebackOpts), - Changes1 = [?chargeback_ev(ID, C) || C <- Changes0], - case Type of - %% NOTE In case if payment is already charged back and we want - %% to reopen and change it, this will ensure machine to - %% continue processing activities following cashflow update - %% event. - updating_cash_flow -> - {next, {Changes1, Action1}}; - _ -> - {done, {Changes1, Action1}} - end. +assert_payment_status(Status, InvoicePayment) -> + hg_invoice_payment_validation:assert_payment_status(Status, InvoicePayment). -maybe_set_charged_back_status(?chargeback_status_accepted(), ChargebackBody, St) -> - InterimPaymentAmount = get_remaining_payment_balance(St), - case hg_cash:sub(InterimPaymentAmount, ChargebackBody) of - ?cash(0, _) -> - [?payment_status_changed(?charged_back())]; - ?cash(Amount, _) when Amount > 0 -> - [] - end; -maybe_set_charged_back_status( - ?chargeback_status_cancelled(), - _ChargebackBody, - #st{ - payment = #domain_InvoicePayment{status = ?charged_back()}, - status_log = [_ActualStatus, PrevStatus | _] - } -) -> - [?payment_status_changed(PrevStatus)]; -maybe_set_charged_back_status(_ChargebackStatus, _ChargebackBody, _St) -> - []. +assert_no_pending_chargebacks(PaymentState) -> + hg_invoice_payment_validation:assert_no_pending_chargebacks(PaymentState). -%% +assert_payment_flow(Flow, Payment) -> + hg_invoice_payment_validation:assert_payment_flow(Flow, Payment). --spec process_adjustment_cashflow(adjustment_id(), action(), st()) -> machine_result(). -process_adjustment_cashflow(ID, _Action, St) -> - Opts = get_opts(St), - Adjustment = get_adjustment(ID, St), - ok = prepare_adjustment_cashflow(Adjustment, St, Opts), - Events = [?adjustment_ev(ID, ?adjustment_status_changed(?adjustment_processed()))], - {next, {Events, hg_machine_action:instant()}}. - -process_accounter_update(Action, #st{partial_cash_flow = FinalCashflow, capture_data = CaptureData} = St) -> - #payproc_InvoicePaymentCaptureData{ - reason = Reason, - cash = Cost, - cart = Cart, - allocation = Allocation - } = CaptureData, - _Clock = hg_accounting:plan( - construct_payment_plan_id(St), - [ - {2, hg_cashflow:revert(get_cashflow(St))}, - {3, FinalCashflow} - ] - ), - Events = start_session(?captured(Reason, Cost, Cart, Allocation)), - {next, {Events, hg_machine_action:set_timeout(0, Action)}}. +-spec get_adjustment_status(adjustment()) -> dmsl_domain_thrift:'InvoicePaymentAdjustmentStatus'(). +get_adjustment_status(#domain_InvoicePaymentAdjustment{status = Status}) -> + Status. %% --spec handle_callback(activity(), callback(), hg_session:t(), st()) -> {callback_response(), machine_result()}. -handle_callback({refund, ID}, Payload, _Session0, St) -> - PaymentInfo = construct_payment_info(St, get_opts(St)), - Refund = try_get_refund_state(ID, St), - {Resp, {Step, {Events0, Action}}} = hg_invoice_payment_refund:process_callback(Payload, PaymentInfo, Refund), - Events1 = hg_invoice_payment_refund:wrap_events(Events0, Refund), - {Resp, {Step, {Events1, Action}}}; -handle_callback(Activity, Payload, Session0, St) -> - PaymentInfo = construct_payment_info(St, get_opts(St)), - Session1 = hg_session:set_payment_info(PaymentInfo, Session0), - {Response, {Result, Session2}} = hg_session:process_callback(Payload, Session1), - {Response, finish_session_processing(Activity, Result, Session2, St)}. - --spec process_session(st()) -> machine_result(). -process_session(St) -> - Session = get_activity_session(St), - process_session(Session, St). - -process_session(undefined, St0) -> - Target = get_target(St0), - TargetType = get_target_type(Target), - Action = hg_machine_action:new(), - case validate_processing_deadline(get_payment(St0), TargetType) of - ok -> - Events = start_session(Target), - Result = {Events, hg_machine_action:set_timeout(0, Action)}, - {next, Result}; - Failure -> - process_failure(get_activity(St0), [], Action, Failure, St0) - end; -process_session(Session0, #st{repair_scenario = Scenario} = St) -> - Session1 = - case hg_invoice_repair:check_for_action(repair_session, Scenario) of - RepairScenario = {result, _} -> - hg_session:set_repair_scenario(RepairScenario, Session0); - call -> - Session0 - end, - PaymentInfo = construct_payment_info(St, get_opts(St)), - Session2 = hg_session:set_payment_info(PaymentInfo, Session1), - {Result, Session3} = hg_session:process(Session2), - finish_session_processing(get_activity(St), Result, Session3, St). - --spec finish_session_processing(activity(), result(), hg_session:t(), st()) -> machine_result(). -finish_session_processing(Activity, {Events0, Action}, Session, St0) -> - Events1 = hg_session:wrap_events(Events0, Session), - case {hg_session:status(Session), hg_session:result(Session)} of - {finished, ?session_succeeded()} -> - TargetType = get_target_type(hg_session:target(Session)), - _ = maybe_notify_fault_detector(Activity, TargetType, finish, St0), - NewAction = hg_machine_action:set_timeout(0, Action), - InvoiceID = get_invoice_id(get_invoice(get_opts(St0))), - St1 = collapse_changes(Events1, St0, #{invoice_id => InvoiceID}), - _ = - case St1 of - #st{new_cash_provided = true, activity = {payment, processing_accounter}} -> - %% Revert with St0 cause default rollback takes into account new cash - %% We need to rollback only current route. - %% Previously used routes are supposed to have their limits already rolled back. - Route = get_route(St0), - Routes = [Route], - _ = rollback_payment_limits(Routes, get_iter(St0), St0, []), - _ = rollback_payment_cashflow(St0); - _ -> - ok - end, - {next, {Events1, NewAction}}; - {finished, ?session_failed(Failure)} -> - process_failure(Activity, Events1, Action, Failure, St0); - _ -> - {next, {Events1, Action}} - end. - --spec finalize_payment(action(), st()) -> machine_result(). -finalize_payment(Action, St) -> - Target = - case get_payment_flow(get_payment(St)) of - ?invoice_payment_flow_instant() -> - ?captured(<<"Timeout">>, get_payment_cost(get_payment(St))); - ?invoice_payment_flow_hold(OnHoldExpiration, _) -> - case OnHoldExpiration of - cancel -> - ?cancelled(); - capture -> - ?captured( - <<"Timeout">>, - get_payment_cost(get_payment(St)) - ) - end - end, - StartEvents = - case Target of - ?captured(Reason, Cost) -> - start_capture(Reason, Cost, undefined, get_allocation(St)); - _ -> - start_session(Target) - end, - {done, {StartEvents, hg_machine_action:set_timeout(0, Action)}}. +-spec process_signal(timeout, st(), opts()) -> machine_result(). +process_signal(timeout, St, Options) -> + hg_invoice_payment_processing:process_signal(timeout, St, Options). --spec process_result(action(), st()) -> machine_result(). -process_result(Action, St) -> - process_result(get_activity(St), Action, St). +-spec process_timeout(activity(), action(), st()) -> machine_result(). +process_timeout(Activity, Action, St) -> + hg_invoice_payment_processing:process_timeout(Activity, Action, St). -process_result({payment, processing_accounter}, Action, #st{new_cash = Cost} = St0) when - Cost =/= undefined --> - %% Rebuild cashflow for new cost - Payment0 = get_payment(St0), - Payment1 = Payment0#domain_InvoicePayment{cost = Cost}, - St1 = St0#st{payment = Payment1}, - Opts = get_opts(St1), - Revision = get_payment_revision(St1), - Timestamp = get_payment_created_at(Payment0), - VS = collect_validation_varset(St1, Opts), - MerchantTerms = get_merchant_payments_terms(Opts, Revision, Timestamp, VS), - Route = get_route(St1), - ProviderTerms = hg_routing:get_payment_terms(Route, VS, Revision), - Context = #{ - provision_terms => ProviderTerms, - merchant_terms => MerchantTerms, - route => Route, - payment => Payment1, - timestamp => Timestamp, - varset => VS, - revision => Revision - }, - FinalCashflow = calculate_cashflow(Context, Opts), - %% Hold limits (only for chosen route) for new cashflow - {_PaymentInstitution, RouteVS, _Revision} = route_args(St1), - Routes = [hg_route:from_payment_route(Route)], - _ = hold_limit_routes(Routes, RouteVS, get_iter(St1), St1), - %% Hold cashflow - St2 = St1#st{new_cash_flow = FinalCashflow}, - _Clock = hg_accounting:plan( - construct_payment_plan_id(St2), - get_cashflow_plan(St2) - ), - {next, {[?cash_flow_changed(FinalCashflow)], hg_machine_action:set_timeout(0, Action)}}; -process_result({payment, processing_accounter}, Action, St) -> - Target = get_target(St), - NewAction = get_action(Target, Action, St), - {done, {[?payment_status_changed(Target)], NewAction}}; -process_result({payment, routing_failure}, Action, #st{failure = Failure} = St) -> - NewAction = hg_machine_action:set_timeout(0, Action), - Routes = get_candidate_routes(St), - _ = rollback_payment_limits(Routes, get_iter(St), St, [ignore_business_error, ignore_not_found]), - {done, {[?payment_status_changed(?failed(Failure))], NewAction}}; -process_result({payment, processing_failure}, Action, #st{failure = Failure} = St) -> - NewAction = hg_machine_action:set_timeout(0, Action), - %% We need to rollback only current route. - %% Previously used routes are supposed to have their limits already rolled back. - Route = get_route(St), - Routes = [Route], - _ = rollback_payment_limits(Routes, get_iter(St), St, []), - _ = rollback_payment_cashflow(St), - Revision = get_payment_revision(St), - Behaviour = get_route_cascade_behaviour(Route, Revision), - case is_route_cascade_available(Behaviour, Route, ?failed(Failure), St) of - true -> process_routing(NewAction, St); - false -> {done, {[?payment_status_changed(?failed(Failure))], NewAction}} - end; -process_result({payment, finalizing_accounter}, Action, St) -> - Target = get_target(St), - _PostingPlanLog = - case Target of - ?captured() -> - commit_payment_limits(St), - commit_payment_cashflow(St); - ?cancelled() -> - Route = get_route(St), - _ = rollback_payment_limits([Route], get_iter(St), St, []), - rollback_payment_cashflow(St) - end, - check_recurrent_token(St), - NewAction = get_action(Target, Action, St), - {done, {[?payment_status_changed(Target)], NewAction}}. +-spec process_call + ({callback, tag(), callback()}, st(), opts()) -> {callback_response(), machine_result()}; + ({session_change, tag(), session_change()}, st(), opts()) -> {ok, machine_result()}. +process_call(Call, St, Options) -> + hg_invoice_payment_processing:process_call(Call, St, Options). -process_failure(Activity, Events, Action, Failure, St) -> - process_failure(Activity, Events, Action, Failure, St, undefined). +route_args(St) -> + hg_invoice_payment_routing:route_args(St). -process_failure({payment, processing_failure}, Events, Action, _Failure, #st{failure = Failure}, _RefundSt) when - Failure =/= undefined --> - %% In case of cascade attempt we may catch and handle routing failure during 'processing_failure' activity - {done, {Events ++ [?payment_status_changed(?failed(Failure))], Action}}; -process_failure({payment, Step}, Events, Action, Failure, _St, _RefundSt) when - Step =:= risk_scoring orelse - Step =:= routing --> - {done, {Events ++ [?payment_status_changed(?failed(Failure))], Action}}; -process_failure({payment, Step} = Activity, Events, Action, Failure, St, _RefundSt) when - Step =:= processing_session orelse - Step =:= finalizing_session --> - Target = get_target(St), - case check_retry_possibility(Target, Failure, St) of - {retry, Timeout} -> - _ = logger:notice("Retry session after transient failure, wait ~p", [Timeout]), - {SessionEvents, SessionAction} = retry_session(Action, Target, Timeout), - {next, {Events ++ SessionEvents, SessionAction}}; - fatal -> - TargetType = get_target_type(Target), - OperationStatus = choose_fd_operation_status_for_failure(Failure), - _ = maybe_notify_fault_detector(Activity, TargetType, OperationStatus, St), - process_fatal_payment_failure(Target, Events, Action, Failure, St) - end. +build_routing_context(PaymentInstitution, VS, Revision, St) -> + hg_invoice_payment_routing:build_routing_context(PaymentInstitution, VS, Revision, St). -check_recurrent_token(#st{ - payment = #domain_InvoicePayment{make_recurrent = true, skip_recurrent = true}, - recurrent_token = undefined -}) -> - ok; -check_recurrent_token(#st{ - payment = #domain_InvoicePayment{id = ID, make_recurrent = true, skip_recurrent = true}, - recurrent_token = _Token -}) -> - _ = logger:warning("Got recurrent token in non recurrent payment. Payment id:~p", [ID]); -check_recurrent_token(#st{ - payment = #domain_InvoicePayment{id = ID, make_recurrent = true}, - recurrent_token = undefined -}) -> - _ = logger:warning("Fail to get recurrent token in recurrent payment. Payment id:~p", [ID]); -check_recurrent_token(#st{ - payment = #domain_InvoicePayment{id = ID, make_recurrent = MakeRecurrent}, - recurrent_token = Token -}) when - (MakeRecurrent =:= false orelse MakeRecurrent =:= undefined) andalso - Token =/= undefined --> - _ = logger:warning("Got recurrent token in non recurrent payment. Payment id:~p", [ID]); -check_recurrent_token(_) -> - ok. +%% NOTE See damsel payproc errors (proto/payment_processing_errors.thrift) for no route found -choose_fd_operation_status_for_failure({failure, Failure}) -> - payproc_errors:match('PaymentFailure', Failure, fun do_choose_fd_operation_status_for_failure/1); -choose_fd_operation_status_for_failure(_Failure) -> - finish. +%% -do_choose_fd_operation_status_for_failure({authorization_failed, {FailType, _}}) -> - DefaultBenignFailures = [ - insufficient_funds, - rejected_by_issuer, - processing_deadline_reached - ], - FDConfig = genlib_app:env(hellgate, fault_detector, #{}), - Config = genlib_map:get(conversion, FDConfig, #{}), - BenignFailures = genlib_map:get(benign_failures, Config, DefaultBenignFailures), - case lists:member(FailType, BenignFailures) of - false -> error; - true -> finish - end; -do_choose_fd_operation_status_for_failure(_Failure) -> - finish. +-spec process_failure(activity(), [change()], action(), failure(), st()) -> machine_result(). +process_failure(Activity, Events, Action, Failure, St) -> + hg_invoice_payment_processing:process_failure(Activity, Events, Action, Failure, St). +-spec maybe_notify_fault_detector(activity(), session_target_type(), atom(), st()) -> ok. maybe_notify_fault_detector({payment, processing_session}, processed, Status, St) -> ProviderRef = get_route_provider(get_route(St)), ProviderID = ProviderRef#domain_ProviderRef.id, @@ -2422,27 +927,23 @@ maybe_notify_fault_detector({payment, processing_session}, processed, Status, St maybe_notify_fault_detector(_Activity, _TargetType, _Status, _St) -> ok. -process_fatal_payment_failure(?cancelled(), _Events, _Action, Failure, _St) -> - error({invalid_cancel_failure, Failure}); -process_fatal_payment_failure(?captured(), _Events, _Action, Failure, _St) -> - error({invalid_capture_failure, Failure}); -process_fatal_payment_failure(?processed(), Events, Action, Failure, _St) -> - RollbackStarted = [?payment_rollback_started(Failure)], - {next, {Events ++ RollbackStarted, hg_machine_action:set_timeout(0, Action)}}. - -retry_session(Action, Target, Timeout) -> - NewEvents = start_session(Target), - NewAction = set_timer({timeout, Timeout}, Action), - {NewEvents, NewAction}. - -get_actual_retry_strategy(Target, #st{retry_attempts = Attempts}) -> - AttemptNum = maps:get(get_target_type(Target), Attempts, 0), - hg_retry:skip_steps(get_initial_retry_strategy(get_target_type(Target)), AttemptNum). +-compile( + {nowarn_unused_function, [ + get_actual_retry_strategy/2, + get_initial_retry_strategy/1, + check_retry_possibility/3, + check_failure_type/2, + get_error_class/1, + do_check_failure_type/1 + ]} +). +-spec get_actual_retry_strategy(target(), st()) -> retry_strategy(). +get_actual_retry_strategy(Target, St) -> + hg_invoice_payment_session:get_actual_retry_strategy(Target, St). -spec get_initial_retry_strategy(session_target_type()) -> retry_strategy(). get_initial_retry_strategy(TargetType) -> - PolicyConfig = genlib_app:env(hellgate, payment_retry_policy, #{}), - hg_retry:new_strategy(maps:get(TargetType, PolicyConfig, no_retry)). + hg_invoice_payment_session:get_initial_retry_strategy(TargetType). -spec check_retry_possibility(Target, Failure, St) -> {retry, Timeout} | fatal when Failure :: failure(), @@ -2450,47 +951,21 @@ get_initial_retry_strategy(TargetType) -> St :: st(), Timeout :: non_neg_integer(). check_retry_possibility(Target, Failure, St) -> - case check_failure_type(Target, Failure) of - transient -> - RetryStrategy = get_actual_retry_strategy(Target, St), - case hg_retry:next_step(RetryStrategy) of - {wait, Timeout, _NewStrategy} -> - {retry, Timeout}; - finish -> - _ = logger:debug("Retries strategy is exceed"), - fatal - end; - fatal -> - _ = logger:debug("Failure ~p is not transient", [Failure]), - fatal - end. + hg_invoice_payment_session:check_retry_possibility(Target, Failure, St). -spec check_failure_type(target(), failure()) -> transient | fatal. -check_failure_type(Target, {failure, Failure}) -> - payproc_errors:match(get_error_class(Target), Failure, fun do_check_failure_type/1); -check_failure_type(_Target, _Other) -> - fatal. +check_failure_type(Target, Failure) -> + hg_invoice_payment_session:check_failure_type(Target, Failure). -get_error_class({Target, _}) when Target =:= processed; Target =:= captured; Target =:= cancelled -> - 'PaymentFailure'; +-spec get_error_class(target()) -> atom(). get_error_class(Target) -> - error({unsupported_target, Target}). - -do_check_failure_type({authorization_failed, {temporarily_unavailable, _}}) -> - transient; -do_check_failure_type(_Failure) -> - fatal. + hg_invoice_payment_session:get_error_class(Target). -get_action(?processed(), Action, St) -> - case get_payment_flow(get_payment(St)) of - ?invoice_payment_flow_instant() -> - hg_machine_action:set_timeout(0, Action); - ?invoice_payment_flow_hold(_, HeldUntil) -> - hg_machine_action:set_deadline(HeldUntil, Action) - end; -get_action(_Target, Action, _St) -> - Action. +-spec do_check_failure_type(failure()) -> transient | fatal. +do_check_failure_type(Failure) -> + hg_invoice_payment_session:do_check_failure_type(Failure). +-compile({nowarn_unused_function, [set_timer/2]}). set_timer(Timer, Action) -> hg_machine_action:set_timer(Timer, Action). @@ -2502,47 +977,9 @@ get_provider_payment_terms(St, Revision) -> VS1 = collect_validation_varset(get_party_config_ref(Opts), get_shop_obj(Opts, Revision), Payment, VS0), hg_routing:get_payment_terms(Route, VS1, Revision). -filter_routes_with_limit_hold(Ctx0, VS, Iter, St) -> - {_Routes, RejectedRoutes} = hold_limit_routes(hg_routing_ctx:candidates(Ctx0), VS, Iter, St), - Ctx1 = reject_routes(limit_misconfiguration, RejectedRoutes, Ctx0), - hg_routing_ctx:stash_current_candidates(Ctx1). - -filter_routes_by_limit_overflow(Ctx0, VS, Iter, St) -> - {_Routes, RejectedRoutes, Limits} = get_limit_overflow_routes(hg_routing_ctx:candidates(Ctx0), VS, Iter, St), - Ctx1 = hg_routing_ctx:stash_route_limits(Limits, Ctx0), - reject_routes(limit_overflow, RejectedRoutes, Ctx1). - -reject_routes(GroupReason, RejectedRoutes, Ctx) -> - lists:foldr( - fun(R, C) -> hg_routing_ctx:reject(GroupReason, R, C) end, - Ctx, - RejectedRoutes - ). - -get_limit_overflow_routes(Routes, VS, Iter, St) -> - Opts = get_opts(St), - Revision = get_payment_revision(St), - Payment = get_payment(St), - Invoice = get_invoice(Opts), - lists:foldl( - fun(Route, {RoutesNoOverflowIn, RejectedIn, LimitsIn}) -> - PaymentRoute = hg_route:to_payment_route(Route), - ProviderTerms = hg_routing:get_payment_terms(PaymentRoute, VS, Revision), - TurnoverLimits = get_turnover_limits(ProviderTerms, strict), - case hg_limiter:check_limits(TurnoverLimits, Invoice, Payment, PaymentRoute, Iter) of - {ok, Limits} -> - {[Route | RoutesNoOverflowIn], RejectedIn, LimitsIn#{PaymentRoute => Limits}}; - {error, {limit_overflow, IDs, Limits}} -> - RejectedRoute = hg_route:to_rejected_route(Route, {'LimitOverflow', IDs}), - {RoutesNoOverflowIn, [RejectedRoute | RejectedIn], LimitsIn#{PaymentRoute => Limits}} - end - end, - {[], [], #{}}, - Routes - ). - %% Shop limits +-spec hold_shop_limits(opts(), st()) -> ok. hold_shop_limits(Opts, St) -> Payment = get_payment(St), Revision = get_payment_revision(St), @@ -2552,6 +989,7 @@ hold_shop_limits(Opts, St) -> TurnoverLimits = get_shop_turnover_limits(Shop), ok = hg_limiter:hold_shop_limits(TurnoverLimits, PartyConfigRef, ShopConfigRef, Invoice, Payment). +-spec commit_shop_limits(opts(), st()) -> ok. commit_shop_limits(Opts, St) -> Payment = get_payment(St), Revision = get_payment_revision(St), @@ -2561,6 +999,7 @@ commit_shop_limits(Opts, St) -> TurnoverLimits = get_shop_turnover_limits(Shop), ok = hg_limiter:commit_shop_limits(TurnoverLimits, PartyConfigRef, ShopConfigRef, Invoice, Payment). +-spec check_shop_limits(opts(), st()) -> ok | {error, term()}. check_shop_limits(Opts, St) -> Payment = get_payment(St), Revision = get_payment_revision(St), @@ -2570,6 +1009,7 @@ check_shop_limits(Opts, St) -> TurnoverLimits = get_shop_turnover_limits(Shop), hg_limiter:check_shop_limits(TurnoverLimits, PartyConfigRef, ShopConfigRef, Invoice, Payment). +-spec rollback_shop_limits(opts(), st(), list()) -> ok. rollback_shop_limits(Opts, St, Flags) -> Payment = get_payment(St), Revision = get_payment_revision(St), @@ -2627,6 +1067,7 @@ do_reject_route(LimiterError, Route, TurnoverLimits, {LimitHeldRoutes, RejectedR RejectedRoute = hg_route:to_rejected_route(Route, {'LimitHoldError', LimitsIDs, LimiterError}), {LimitHeldRoutes, [RejectedRoute | RejectedRoutes]}. +-spec rollback_payment_limits([hg_route:payment_route()], non_neg_integer(), st(), list()) -> ok. rollback_payment_limits(Routes, Iter, St, Flags) -> Opts = get_opts(St), Revision = get_payment_revision(St), @@ -2642,6 +1083,7 @@ rollback_payment_limits(Routes, Iter, St, Flags) -> Routes ). +-spec rollback_broken_payment_limits(st()) -> ok. rollback_broken_payment_limits(St) -> Opts = get_opts(St), Payment = get_payment(St), @@ -2669,15 +1111,10 @@ rollback_broken_payment_limits(St) -> LimitValues ). -rollback_unused_payment_limits(St) -> - Route = get_route(St), - Routes = get_candidate_routes(St), - UnUsedRoutes = Routes -- [Route], - rollback_payment_limits(UnUsedRoutes, get_iter(St), St, [ignore_business_error, ignore_not_found]). - get_turnover_limits(ProviderTerms, Mode) -> hg_limiter:get_turnover_limits(ProviderTerms, Mode). +-spec commit_payment_limits(st()) -> ok. commit_payment_limits(#st{capture_data = CaptureData} = St) -> Opts = get_opts(St), Revision = get_payment_revision(St), @@ -2690,9 +1127,10 @@ commit_payment_limits(#st{capture_data = CaptureData} = St) -> Iter = get_iter(St), hg_limiter:commit_payment_limits(TurnoverLimits, Invoice, Payment, Route, Iter, CapturedCash). +-spec commit_payment_cashflow(st()) -> ok. commit_payment_cashflow(St) -> Plan = get_cashflow_plan(St), - do_try_with_ids( + _ = do_try_with_ids( [ construct_payment_plan_id(St), construct_payment_plan_id(St, legacy) @@ -2700,11 +1138,13 @@ commit_payment_cashflow(St) -> fun(ID) -> hg_accounting:commit(ID, Plan) end - ). + ), + ok. +-spec rollback_payment_cashflow(st()) -> ok. rollback_payment_cashflow(St) -> Plan = get_cashflow_plan(St), - do_try_with_ids( + _ = do_try_with_ids( [ construct_payment_plan_id(St), construct_payment_plan_id(St, legacy) @@ -2712,7 +1152,8 @@ rollback_payment_cashflow(St) -> fun(ID) -> hg_accounting:rollback(ID, Plan) end - ). + ), + ok. -spec do_try_with_ids([payment_plan_id()], fun((payment_plan_id()) -> T)) -> T | no_return(). do_try_with_ids([ID], Func) when is_function(Func, 1) -> @@ -2726,6 +1167,7 @@ do_try_with_ids([ID | OtherIDs], Func) when is_function(Func, 1) -> do_try_with_ids(OtherIDs, Func) end. +-spec get_cashflow_plan(st()) -> [{integer(), hg_cashflow:final_cash_flow()}]. get_cashflow_plan( #st{ partial_cash_flow = PartialCashFlow, @@ -2767,138 +1209,26 @@ set_repair_scenario(Scenario, St) -> construct_payment_info(St, Opts) -> Payment = get_payment(St), Revision = get_payment_revision(St), - construct_payment_info( + hg_invoice_payment_construction:construct_payment_info( get_activity(St), get_target(St), St, #proxy_provider_PaymentInfo{ - shop = construct_proxy_shop(get_shop_obj(Opts, Revision)), - invoice = construct_proxy_invoice(get_invoice(Opts)), - payment = construct_proxy_payment(Payment, get_trx(St)) + shop = hg_invoice_payment_construction:construct_proxy_shop(get_shop_obj(Opts, Revision)), + invoice = hg_invoice_payment_construction:construct_proxy_invoice(get_invoice(Opts)), + payment = hg_invoice_payment_construction:construct_proxy_payment(Payment, get_trx(St)) } ). -construct_payment_info(idle, _Target, _St, PaymentInfo) -> - PaymentInfo; -construct_payment_info( - {payment, _Step}, - Target = ?captured(), - _St, - PaymentInfo -) -> - PaymentInfo#proxy_provider_PaymentInfo{ - capture = construct_proxy_capture(Target) - }; -construct_payment_info({payment, _Step}, _Target, _St, PaymentInfo) -> - PaymentInfo; -construct_payment_info({refund, _ID}, _Target, _St, PaymentInfo) -> - PaymentInfo. - -construct_proxy_payment( - #domain_InvoicePayment{ - id = ID, - created_at = CreatedAt, - domain_revision = Revision, - payer = Payer, - payer_session_info = PayerSessionInfo, - cost = Cost, - make_recurrent = MakeRecurrent, - skip_recurrent = SkipRecurrent, - processing_deadline = Deadline - }, - Trx -) -> - ContactInfo = get_contact_info(Payer), - PaymentTool = get_payer_payment_tool(Payer), - #proxy_provider_InvoicePayment{ - id = ID, - created_at = CreatedAt, - trx = Trx, - payment_resource = construct_payment_resource(Payer), - payment_service = hg_payment_tool:get_payment_service(PaymentTool, Revision), - payer_session_info = PayerSessionInfo, - cost = construct_proxy_cash(Cost), - contact_info = ContactInfo, - make_recurrent = MakeRecurrent, - skip_recurrent = SkipRecurrent, - processing_deadline = Deadline - }. - -construct_payment_resource(?payment_resource_payer(Resource, _)) -> - {disposable_payment_resource, Resource}; -construct_payment_resource(?recurrent_payer(PaymentTool, ?recurrent_parent(InvoiceID, PaymentID), _)) -> - PreviousPayment = get_payment_state(InvoiceID, PaymentID), - RecToken = get_recurrent_token(PreviousPayment), - {recurrent_payment_resource, #proxy_provider_RecurrentPaymentResource{ - payment_tool = PaymentTool, - rec_token = RecToken - }}. - -get_contact_info(?payment_resource_payer(_, ContactInfo)) -> - ContactInfo; -get_contact_info(?recurrent_payer(_, _, ContactInfo)) -> - ContactInfo. - -construct_proxy_invoice( - #domain_Invoice{ - id = InvoiceID, - created_at = CreatedAt, - due = Due, - details = Details, - cost = Cost - } -) -> - #proxy_provider_Invoice{ - id = InvoiceID, - created_at = CreatedAt, - due = Due, - details = Details, - cost = construct_proxy_cash(Cost) - }. - -construct_proxy_shop( - { - #domain_ShopConfigRef{id = ShopConfigID}, - Shop = #domain_ShopConfig{ - location = Location, - category = ShopCategoryRef - } - } -) -> - ShopCategory = hg_domain:get({category, ShopCategoryRef}), - #proxy_provider_Shop{ - id = ShopConfigID, - category = ShopCategory, - name = Shop#domain_ShopConfig.name, - description = Shop#domain_ShopConfig.description, - location = Location - }. - -construct_proxy_cash(#domain_Cash{ - amount = Amount, - currency = CurrencyRef -}) -> - #proxy_provider_Cash{ - amount = Amount, - currency = hg_domain:get({currency, CurrencyRef}) - }. - -construct_proxy_capture(?captured(_, Cost)) -> - #proxy_provider_InvoicePaymentCapture{ - cost = construct_proxy_cash(Cost) - }. - %% -get_party_obj(#{party := Party, party_config_ref := PartyConfigRef}) -> - {PartyConfigRef, Party}. - get_party(#{party := Party}) -> Party. get_party_config_ref(#{party_config_ref := PartyConfigRef}) -> PartyConfigRef. +-spec get_shop(opts(), hg_domain:revision()) -> dmsl_domain_thrift:'ShopConfig'(). get_shop(Opts, Revision) -> {_, Shop} = get_shop_obj(Opts, Revision), Shop. @@ -2906,14 +1236,11 @@ get_shop(Opts, Revision) -> get_shop_obj(#{invoice := Invoice, party_config_ref := PartyConfigRef}, Revision) -> hg_party:get_shop(get_invoice_shop_config_ref(Invoice), PartyConfigRef, Revision). -get_payment_institution_ref(Opts, Revision) -> - Shop = get_shop(Opts, Revision), - Shop#domain_ShopConfig.payment_institution. - -spec get_invoice(opts()) -> invoice(). get_invoice(#{invoice := Invoice}) -> Invoice. +-spec get_invoice_id(invoice()) -> invoice_id(). get_invoice_id(#domain_Invoice{id = ID}) -> ID. @@ -2923,23 +1250,21 @@ get_invoice_cost(#domain_Invoice{cost = Cost}) -> get_invoice_shop_config_ref(#domain_Invoice{shop_ref = ShopConfigRef}) -> ShopConfigRef. +-spec get_payment_id(payment()) -> payment_id(). get_payment_id(#domain_InvoicePayment{id = ID}) -> ID. +-spec get_payment_cost(payment()) -> dmsl_domain_thrift:'Cash'(). get_payment_cost(#domain_InvoicePayment{changed_cost = Cost}) when Cost =/= undefined -> Cost; get_payment_cost(#domain_InvoicePayment{cost = Cost}) -> Cost. +-spec get_payment_flow(payment()) -> dmsl_domain_thrift:'InvoicePaymentFlow'(). get_payment_flow(#domain_InvoicePayment{flow = Flow}) -> Flow. -get_payment_party_config_ref(#domain_InvoicePayment{party_ref = PartyConfigRef}) -> - PartyConfigRef. - -get_payment_tool(#domain_InvoicePayment{payer = Payer}) -> - get_payer_payment_tool(Payer). - +-spec get_payment_created_at(payment()) -> hg_datetime:timestamp(). get_payment_created_at(#domain_InvoicePayment{created_at = CreatedAt}) -> CreatedAt. @@ -2949,30 +1274,6 @@ get_payer_payment_tool(?payment_resource_payer(PaymentResource, _ContactInfo)) - get_payer_payment_tool(?recurrent_payer(PaymentTool, _, _)) -> PaymentTool. -get_payer_card_token(?payment_resource_payer(PaymentResource, _ContactInfo)) -> - case get_resource_payment_tool(PaymentResource) of - {bank_card, #domain_BankCard{token = Token}} -> - Token; - _ -> - undefined - end; -get_payer_card_token(?recurrent_payer(_, _, _)) -> - undefined. - -get_payer_client_ip( - ?payment_resource_payer( - #domain_DisposablePaymentResource{ - client_info = #domain_ClientInfo{ - ip_address = IP - } - }, - _ContactInfo - ) -) -> - IP; -get_payer_client_ip(_OtherPayer) -> - undefined. - get_resource_payment_tool(#domain_DisposablePaymentResource{payment_tool = PaymentTool}) -> PaymentTool. @@ -2986,16 +1287,6 @@ get_varset(St, InitialValue) -> %% --spec throw_invalid_request(binary()) -> no_return(). -throw_invalid_request(Why) -> - throw(#base_InvalidRequest{errors = [Why]}). - --spec throw_invalid_recurrent_parent(binary()) -> no_return(). -throw_invalid_recurrent_parent(Details) -> - throw(#payproc_InvalidRecurrentParentPayment{details = Details}). - -%% - -type change_opts() :: #{ timestamp => hg_datetime:timestamp(), validation => strict, @@ -3003,472 +1294,15 @@ throw_invalid_recurrent_parent(Details) -> }. -spec merge_change(change(), st() | undefined, change_opts()) -> st(). -merge_change(Change, undefined, Opts) -> - merge_change(Change, #st{activity = {payment, new}}, Opts); -merge_change(Change = ?payment_started(Payment), #st{} = St, Opts) -> - _ = validate_transition({payment, new}, Change, St, Opts), - St#st{ - target = ?processed(), - payment = Payment, - activity = {payment, shop_limit_initializing}, - timings = hg_timings:mark(started, define_event_timestamp(Opts)) - }; -merge_change(Change = ?shop_limit_initiated(), #st{} = St, Opts) -> - _ = validate_transition({payment, shop_limit_initializing}, Change, St, Opts), - St#st{ - shop_limit_status = initialized, - activity = {payment, shop_limit_finalizing} - }; -merge_change(Change = ?shop_limit_applied(), #st{} = St, Opts) -> - _ = validate_transition({payment, shop_limit_finalizing}, Change, St, Opts), - St#st{ - shop_limit_status = finalized, - activity = {payment, risk_scoring} - }; -merge_change(Change = ?risk_score_changed(RiskScore), #st{} = St, Opts) -> - _ = validate_transition( - [ - {payment, S} - || S <- [ - risk_scoring, - %% Added for backward compatibility - shop_limit_initializing - ] - ], - Change, - St, - Opts - ), - St#st{ - risk_score = RiskScore, - activity = {payment, routing} - }; -merge_change( - Change = ?route_changed(Route, Candidates, Scores, Limits, Decision), - #st{routes = Routes, route_scores = RouteScores, route_limits = RouteLimits} = St, - Opts -) -> - _ = validate_transition([{payment, S} || S <- [routing, processing_failure]], Change, St, Opts), - Skip = - case Decision of - #payproc_RouteDecisionContext{skip_recurrent = true} -> - true; - _ -> - false - end, - Payment0 = get_payment(St), - Payment1 = Payment0#domain_InvoicePayment{skip_recurrent = Skip}, - St#st{ - %% On route change we expect cash flow from previous attempt to be rolled back. - %% So on `?payment_rollback_started(_)` event for routing failure we won't try to do it again. - cash_flow = undefined, - %% `trx` from previous session (if any) also must be considered obsolete. - trx = undefined, - routes = [Route | Routes], - candidate_routes = ordsets:to_list(Candidates), - activity = {payment, cash_flow_building}, - route_scores = hg_maybe:apply(fun(S) -> maps:merge(RouteScores, S) end, Scores, RouteScores), - route_limits = hg_maybe:apply(fun(L) -> maps:merge(RouteLimits, L) end, Limits, RouteLimits), - payment = Payment1 - }; -merge_change(Change = ?payment_capture_started(Data), #st{} = St, Opts) -> - _ = validate_transition([{payment, S} || S <- [flow_waiting]], Change, St, Opts), - St#st{ - capture_data = Data, - activity = {payment, processing_capture}, - allocation = Data#payproc_InvoicePaymentCaptureData.allocation - }; -merge_change(Change = ?cash_flow_changed(CashFlow), #st{activity = Activity} = St0, Opts) -> - _ = validate_transition( - [ - {payment, S} - || S <- [ - cash_flow_building, - processing_capture, - processing_accounter - ] - ], - Change, - St0, - Opts - ), - St = St0#st{ - final_cash_flow = CashFlow - }, - case Activity of - {payment, processing_accounter} -> - St#st{new_cash = undefined, new_cash_flow = CashFlow}; - {payment, cash_flow_building} -> - St#st{ - cash_flow = CashFlow, - activity = {payment, processing_session} - }; - {payment, processing_capture} -> - St#st{ - partial_cash_flow = CashFlow, - activity = {payment, updating_accounter} - }; - _ -> - St - end; -merge_change(Change = ?rec_token_acquired(Token), #st{} = St, Opts) -> - _ = validate_transition([{payment, processing_session}, {payment, finalizing_session}], Change, St, Opts), - St#st{recurrent_token = Token}; -merge_change(Change = ?cash_changed(_OldCash, NewCash), #st{} = St, Opts) -> - _ = validate_transition( - [{adjustment_new, latest_adjustment_id(St)}, {payment, processing_session}], - Change, - St, - Opts - ), - Payment0 = get_payment(St), - Payment1 = Payment0#domain_InvoicePayment{changed_cost = NewCash}, - St#st{new_cash = NewCash, new_cash_provided = true, payment = Payment1}; -merge_change(Change = ?payment_rollback_started(Failure), St, Opts) -> - _ = validate_transition( - [ - {payment, shop_limit_finalizing}, - {payment, cash_flow_building}, - {payment, processing_session} - ], - Change, - St, - Opts - ), - Activity = - case St of - #st{shop_limit_status = initialized} -> - {payment, shop_limit_failure}; - #st{cash_flow = undefined} -> - {payment, routing_failure}; - _ -> - {payment, processing_failure} - end, - St#st{ - failure = Failure, - activity = Activity, - timings = accrue_status_timing(failed, Opts, St) - }; -merge_change(Change = ?payment_status_changed({failed, _} = Status), #st{payment = Payment} = St, Opts) -> - _ = validate_transition( - [ - {payment, S} - || S <- [ - risk_scoring, - routing, - cash_flow_building, - shop_limit_failure, - routing_failure, - processing_failure - ] - ], - Change, - St, - Opts - ), - (record_status_change(Change, St))#st{ - payment = Payment#domain_InvoicePayment{status = Status}, - activity = idle, - failure = undefined, - timings = accrue_status_timing(failed, Opts, St) - }; -merge_change(Change = ?payment_status_changed({cancelled, _} = Status), #st{payment = Payment} = St, Opts) -> - _ = validate_transition({payment, finalizing_accounter}, Change, St, Opts), - (record_status_change(Change, St))#st{ - payment = Payment#domain_InvoicePayment{status = Status}, - activity = idle, - timings = accrue_status_timing(cancelled, Opts, St) - }; -merge_change(Change = ?payment_status_changed({captured, Captured} = Status), #st{payment = Payment} = St, Opts) -> - _ = validate_transition([idle, {payment, finalizing_accounter}], Change, St, Opts), - (record_status_change(Change, St))#st{ - payment = Payment#domain_InvoicePayment{ - status = Status, - cost = get_captured_cost(Captured, Payment) - }, - activity = idle, - timings = accrue_status_timing(captured, Opts, St), - allocation = get_captured_allocation(Captured) - }; -merge_change(Change = ?payment_status_changed({processed, _} = Status), #st{payment = Payment} = St, Opts) -> - _ = validate_transition({payment, processing_accounter}, Change, St, Opts), - (record_status_change(Change, St))#st{ - payment = Payment#domain_InvoicePayment{status = Status}, - activity = {payment, flow_waiting}, - timings = accrue_status_timing(processed, Opts, St) - }; -merge_change(Change = ?payment_status_changed({refunded, _} = Status), #st{payment = Payment} = St, Opts) -> - _ = validate_transition(idle, Change, St, Opts), - (record_status_change(Change, St))#st{ - payment = Payment#domain_InvoicePayment{status = Status} - }; -merge_change(Change = ?payment_status_changed({charged_back, _} = Status), #st{payment = Payment} = St, Opts) -> - _ = validate_transition(idle, Change, St, Opts), - (record_status_change(Change, St))#st{ - payment = Payment#domain_InvoicePayment{status = Status} - }; -merge_change(Change = ?chargeback_ev(ID, Event), St, Opts) -> - St1 = - case Event of - ?chargeback_created(_) -> - _ = validate_transition(idle, Change, St, Opts), - St#st{activity = {chargeback, ID, preparing_initial_cash_flow}}; - ?chargeback_stage_changed(_) -> - _ = validate_transition(idle, Change, St, Opts), - St; - ?chargeback_levy_changed(_) -> - _ = validate_transition([idle, {chargeback, ID, updating_chargeback}], Change, St, Opts), - St#st{activity = {chargeback, ID, updating_chargeback}}; - ?chargeback_body_changed(_) -> - _ = validate_transition([idle, {chargeback, ID, updating_chargeback}], Change, St, Opts), - St#st{activity = {chargeback, ID, updating_chargeback}}; - ?chargeback_cash_flow_changed(_) -> - Valid = [{chargeback, ID, Activity} || Activity <- [preparing_initial_cash_flow, updating_cash_flow]], - _ = validate_transition(Valid, Change, St, Opts), - case St of - #st{activity = {chargeback, ID, preparing_initial_cash_flow}} -> - St#st{activity = idle}; - #st{activity = {chargeback, ID, updating_cash_flow}} -> - St#st{activity = {chargeback, ID, finalising_accounter}} - end; - ?chargeback_target_status_changed(?chargeback_status_accepted()) -> - _ = validate_transition([idle, {chargeback, ID, updating_chargeback}], Change, St, Opts), - case St of - #st{activity = idle} -> - St#st{activity = {chargeback, ID, finalising_accounter}}; - #st{activity = {chargeback, ID, updating_chargeback}} -> - St#st{activity = {chargeback, ID, updating_cash_flow}} - end; - ?chargeback_target_status_changed(_) -> - _ = validate_transition([idle, {chargeback, ID, updating_chargeback}], Change, St, Opts), - St#st{activity = {chargeback, ID, updating_cash_flow}}; - ?chargeback_status_changed(_) -> - _ = validate_transition([idle, {chargeback, ID, finalising_accounter}], Change, St, Opts), - St#st{activity = idle} - end, - ChargebackSt = merge_chargeback_change(Event, try_get_chargeback_state(ID, St1)), - set_chargeback_state(ID, ChargebackSt, St1); -merge_change(?refund_ev(ID, Event), St, Opts) -> - EventContext = create_refund_event_context(St, Opts), - St1 = - case Event of - ?refund_status_changed(?refund_succeeded()) -> - RefundSt0 = hg_invoice_payment_refund:apply_event( - Event, try_get_refund_state(ID, St), EventContext - ), - DomainRefund = hg_invoice_payment_refund:refund(RefundSt0), - Allocation = get_allocation(St), - FinalAllocation = hg_maybe:apply( - fun(A) -> - #domain_InvoicePaymentRefund{allocation = RefundAllocation} = DomainRefund, - {ok, FA} = hg_allocation:sub(A, RefundAllocation), - FA - end, - Allocation - ), - St#st{allocation = FinalAllocation}; - _ -> - St - end, - RefundSt1 = hg_invoice_payment_refund:apply_event(Event, try_get_refund_state(ID, St1), EventContext), - St2 = set_refund_state(ID, RefundSt1, St1), - case hg_invoice_payment_refund:status(RefundSt1) of - S when S == succeeded; S == failed -> - St2#st{activity = idle}; - _ -> - St2#st{activity = {refund, ID}} - end; -merge_change(Change = ?adjustment_ev(ID, Event), St, Opts) -> - St1 = - case Event of - ?adjustment_created(_) -> - _ = validate_transition(idle, Change, St, Opts), - St#st{activity = {adjustment_new, ID}}; - ?adjustment_status_changed(?adjustment_processed()) -> - _ = validate_transition({adjustment_new, ID}, Change, St, Opts), - St#st{activity = {adjustment_pending, ID}}; - ?adjustment_status_changed(_) -> - _ = validate_transition({adjustment_pending, ID}, Change, St, Opts), - St#st{activity = idle} - end, - Adjustment = merge_adjustment_change(Event, try_get_adjustment(ID, St1)), - St2 = set_adjustment(ID, Adjustment, St1), - % TODO new cashflow imposed implicitly on the payment state? rough - case get_adjustment_status(Adjustment) of - ?adjustment_captured(_) -> - apply_adjustment_effects(Adjustment, St2); - _ -> - St2 - end; -merge_change( - Change = ?session_ev(Target, Event = ?session_started()), - #st{activity = Activity} = St, - Opts -) -> - _ = validate_transition( - [ - {payment, S} - || S <- [ - processing_session, - flow_waiting, - processing_capture, - updating_accounter, - finalizing_session - ] - ], - Change, - St, - Opts - ), - % FIXME why the hell dedicated handling - Session0 = hg_session:apply_event(Event, undefined, create_session_event_context(Target, St, Opts)), - %% We need to pass processed trx_info to captured/cancelled session due to provider requirements - Session1 = hg_session:set_trx_info(get_trx(St), Session0), - St1 = add_session(Target, Session1, St#st{target = Target}), - St2 = save_retry_attempt(Target, St1), - case Activity of - {payment, processing_session} -> - %% session retrying - St2#st{activity = {payment, processing_session}}; - {payment, PaymentActivity} when PaymentActivity == flow_waiting; PaymentActivity == processing_capture -> - %% session flow - St2#st{ - activity = {payment, finalizing_session}, - timings = try_accrue_waiting_timing(Opts, St2) - }; - {payment, updating_accounter} -> - %% session flow - St2#st{activity = {payment, finalizing_session}}; - {payment, finalizing_session} -> - %% session retrying - St2#st{activity = {payment, finalizing_session}}; - _ -> - St2 - end; -merge_change(Change = ?session_ev(Target, Event), St = #st{activity = Activity}, Opts) -> - _ = validate_transition([{payment, S} || S <- [processing_session, finalizing_session]], Change, St, Opts), - Session = hg_session:apply_event( - Event, - get_session(Target, St), - create_session_event_context(Target, St, Opts) - ), - St1 = update_session(Target, Session, St), - % FIXME leaky transactions - St2 = set_trx(hg_session:trx_info(Session), St1), - case Session of - #{status := finished, result := ?session_succeeded()} -> - NextActivity = - case Activity of - {payment, processing_session} -> - {payment, processing_accounter}; - {payment, finalizing_session} -> - {payment, finalizing_accounter}; - _ -> - Activity - end, - St2#st{activity = NextActivity}; - _ -> - St2 - end. - -record_status_change(?payment_status_changed(Status), St) -> - St#st{status_log = [Status | St#st.status_log]}. - -latest_adjustment_id(#st{adjustments = []}) -> - undefined; -latest_adjustment_id(#st{adjustments = Adjustments}) -> - Adjustment = lists:last(Adjustments), - Adjustment#domain_InvoicePaymentAdjustment.id. - -get_routing_attempt_limit( - #st{ - payment = #domain_InvoicePayment{ - party_ref = PartyConfigRef, - shop_ref = ShopConfigRef, - domain_revision = Revision - } - } = St -) -> - {PartyConfigRef, _Party} = hg_party:checkout(PartyConfigRef, Revision), - ShopObj = {_, Shop} = hg_party:get_shop(ShopConfigRef, PartyConfigRef, Revision), - VS = collect_validation_varset(PartyConfigRef, ShopObj, get_payment(St), #{}), - Terms = hg_invoice_utils:compute_shop_terms(Revision, Shop, VS), - #domain_TermSet{payments = PaymentTerms} = Terms, - log_cascade_attempt_context(PaymentTerms, St), - get_routing_attempt_limit_value(PaymentTerms#domain_PaymentsServiceTerms.attempt_limit). - -log_cascade_attempt_context( - #domain_PaymentsServiceTerms{attempt_limit = AttemptLimit}, - #st{routes = AttemptedRoutes} -) -> - ?LOG_MD(notice, "Cascade context: merchant payment terms' attempt limit '~p', attempted routes: ~p", [ - AttemptLimit, AttemptedRoutes - ]). - -get_routing_attempt_limit_value(undefined) -> - 1; -get_routing_attempt_limit_value({decisions, _}) -> - get_routing_attempt_limit_value(undefined); -get_routing_attempt_limit_value({value, #domain_AttemptLimit{attempts = Value}}) when is_integer(Value) -> - Value. - -save_retry_attempt(Target, #st{retry_attempts = Attempts} = St) -> - St#st{retry_attempts = maps:update_with(get_target_type(Target), fun(N) -> N + 1 end, 0, Attempts)}. - -merge_chargeback_change(Change, ChargebackState) -> - hg_invoice_payment_chargeback:merge_change(Change, ChargebackState). - -merge_adjustment_change(?adjustment_created(Adjustment), undefined) -> - Adjustment; -merge_adjustment_change(?adjustment_status_changed(Status), Adjustment) -> - Adjustment#domain_InvoicePaymentAdjustment{status = Status}. - -apply_adjustment_effects(Adjustment, St) -> - apply_adjustment_effect( - status, - Adjustment, - apply_adjustment_effect(cashflow, Adjustment, St) - ). +merge_change(Change, St, Opts) -> + hg_invoice_payment_state:merge_change(Change, St, Opts). -apply_adjustment_effect(status, ?adjustment_target_status(Status), St = #st{payment = Payment}) -> - case Status of - {captured, Capture} -> - St#st{ - payment = Payment#domain_InvoicePayment{ - status = Status, - cost = get_captured_cost(Capture, Payment) - } - }; - _ -> - St#st{ - payment = Payment#domain_InvoicePayment{ - status = Status - } - } - end; -apply_adjustment_effect(status, #domain_InvoicePaymentAdjustment{}, St) -> - St; -apply_adjustment_effect(cashflow, Adjustment, St) -> - set_cashflow(get_adjustment_cashflow(Adjustment), St). +get_routing_attempt_limit(St) -> + hg_invoice_payment_routing:get_routing_attempt_limit(St). -spec validate_transition(activity() | [activity()], change(), st(), change_opts()) -> ok | no_return(). validate_transition(Allowed, Change, St, Opts) -> - case {Opts, is_transition_valid(Allowed, St)} of - {#{}, true} -> - ok; - {#{validation := strict}, false} -> - erlang:error({invalid_transition, Change, St, Allowed}); - {#{}, false} -> - logger:warning( - "Invalid transition for change ~p in state ~p, allowed ~p", - [Change, St, Allowed] - ) - end. - -is_transition_valid(Allowed, St) when is_list(Allowed) -> - lists:any(fun(A) -> is_transition_valid(A, St) end, Allowed); -is_transition_valid(Allowed, #st{activity = Activity}) -> - Activity =:= Allowed. + hg_invoice_payment_validation:validate_transition(Allowed, Change, St, Opts). -spec accrue_status_timing(payment_status_type(), opts(), st()) -> hg_timings:t(). accrue_status_timing(Name, Opts, #st{timings = Timings}) -> @@ -3505,6 +1339,7 @@ get_limit_values_(St, Mode) -> hg_routing_ctx:considered_candidates(Ctx) ). +-spec try_accrue_waiting_timing(change_opts(), st()) -> hg_timings:t(). try_accrue_waiting_timing(Opts, #st{payment = Payment, timings = Timings}) -> case get_payment_flow(Payment) of ?invoice_payment_flow_instant() -> @@ -3517,11 +1352,9 @@ try_accrue_waiting_timing(Opts, #st{payment = Payment, timings = Timings}) -> get_cashflow(#st{cash_flow = FinalCashflow}) -> FinalCashflow. -set_cashflow(Cashflow, #st{} = St) -> - St#st{ - cash_flow = Cashflow, - final_cash_flow = Cashflow - }. +-spec set_cashflow(final_cash_flow(), st()) -> st(). +set_cashflow(Cashflow, St) -> + hg_invoice_payment_state:set_cashflow(Cashflow, St). -spec get_final_cashflow(st()) -> final_cash_flow(). get_final_cashflow(#st{final_cash_flow = Cashflow}) -> @@ -3531,54 +1364,33 @@ get_final_cashflow(#st{final_cash_flow = Cashflow}) -> get_trx(#st{trx = Trx}) -> Trx. -set_trx(undefined, #st{} = St) -> - St; -set_trx(Trx, #st{} = St) -> - St#st{trx = Trx}. - -try_get_refund_state(ID, #st{refunds = Rs}) -> - case Rs of - #{ID := RefundSt} -> - RefundSt; - #{} -> - undefined - end. - -set_chargeback_state(ID, ChargebackSt, #st{chargebacks = CBs} = St) -> - St#st{chargebacks = CBs#{ID => ChargebackSt}}. - -try_get_chargeback_state(ID, #st{chargebacks = CBs}) -> - case CBs of - #{ID := ChargebackSt} -> - ChargebackSt; - #{} -> - undefined - end. +-spec try_get_refund_state(refund_id(), st()) -> refund_state() | undefined. +try_get_refund_state(ID, St) -> + hg_invoice_payment_state:try_get_refund_state(ID, St). -set_refund_state(ID, RefundSt, #st{refunds = Rs} = St) -> - St#st{refunds = Rs#{ID => RefundSt}}. +-spec try_get_chargeback_state(chargeback_id(), st()) -> chargeback_state() | undefined. +try_get_chargeback_state(ID, St) -> + hg_invoice_payment_state:try_get_chargeback_state(ID, St). -spec get_origin(st() | undefined) -> dmsl_domain_thrift:'InvoicePaymentRegistrationOrigin'() | undefined. get_origin(#st{payment = #domain_InvoicePayment{registration_origin = Origin}}) -> Origin. +-spec get_captured_cost(dmsl_domain_thrift:'InvoicePaymentCaptured'(), payment()) -> + dmsl_domain_thrift:'Cash'(). get_captured_cost(#domain_InvoicePaymentCaptured{cost = Cost}, _) when Cost /= undefined -> Cost; get_captured_cost(_, #domain_InvoicePayment{cost = Cost}) -> Cost. +-spec get_captured_allocation(dmsl_domain_thrift:'InvoicePaymentCaptured'()) -> + hg_allocation:allocation() | undefined. get_captured_allocation(#domain_InvoicePaymentCaptured{allocation = Allocation}) -> Allocation. -spec create_session_event_context(target(), st(), change_opts()) -> hg_session:event_context(). -create_session_event_context(Target, St, #{invoice_id := InvoiceID} = Opts) -> - #{ - timestamp => define_event_timestamp(Opts), - target => Target, - route => get_route(St), - invoice_id => InvoiceID, - payment_id => get_payment_id(get_payment(St)) - }. +create_session_event_context(Target, St, Opts) -> + hg_invoice_payment_session:create_session_event_context(Target, St, Opts). -spec create_refund_event_context(st(), change_opts()) -> hg_invoice_payment_refund:event_context(). create_refund_event_context(St, Opts) -> @@ -3601,19 +1413,9 @@ define_refund_cash(?cash(_, SymCode), _St) -> get_refund_cash(#domain_InvoicePaymentRefund{cash = Cash}) -> Cash. -get_refund_created_at(#domain_InvoicePaymentRefund{created_at = CreatedAt}) -> - CreatedAt. - -try_get_adjustment(ID, #st{adjustments = As}) -> - case lists:keyfind(ID, #domain_InvoicePaymentAdjustment.id, As) of - V = #domain_InvoicePaymentAdjustment{} -> - V; - false -> - undefined - end. - -set_adjustment(ID, Adjustment, #st{adjustments = As} = St) -> - St#st{adjustments = lists:keystore(ID, #domain_InvoicePaymentAdjustment.id, As, Adjustment)}. +-spec try_get_adjustment(adjustment_id(), st()) -> adjustment() | undefined. +try_get_adjustment(ID, St) -> + hg_invoice_payment_state:try_get_adjustment(ID, St). get_invoice_state(InvoiceID) -> case hg_invoice:get(InvoiceID) of @@ -3634,31 +1436,26 @@ get_payment_state(InvoiceID, PaymentID) -> end. -spec get_session(target(), st()) -> session(). -get_session(Target, #st{sessions = Sessions, routes = [Route | _PreviousRoutes]}) -> - TargetSessions = maps:get(get_target_type(Target), Sessions, []), - MatchingRoute = fun(#{route := SR}) -> SR =:= Route end, - case lists:search(MatchingRoute, TargetSessions) of - {value, Session} -> Session; - _ -> undefined - end. +get_session(Target, St) -> + hg_invoice_payment_session:get_session(Target, St). -spec add_session(target(), session(), st()) -> st(). -add_session(Target, Session, #st{sessions = Sessions} = St) -> - TargetType = get_target_type(Target), - TargetTypeSessions = maps:get(TargetType, Sessions, []), - St#st{sessions = Sessions#{TargetType => [Session | TargetTypeSessions]}}. +add_session(Target, Session, St) -> + hg_invoice_payment_session:add_session(Target, Session, St). -update_session(Target, Session, #st{sessions = Sessions} = St) -> - TargetType = get_target_type(Target), - [_ | Rest] = maps:get(TargetType, Sessions, []), - St#st{sessions = Sessions#{TargetType => [Session | Rest]}}. +-spec update_session(target(), session(), st()) -> st(). +update_session(Target, Session, St) -> + hg_invoice_payment_session:update_session(Target, Session, St). +-spec get_target(st()) -> target(). get_target(#st{target = Target}) -> Target. +-spec get_target_type(target()) -> session_target_type(). get_target_type({Type, _}) when Type == 'processed'; Type == 'captured'; Type == 'cancelled'; Type == 'refunded' -> Type. +-spec get_recurrent_token(st()) -> undefined | dmsl_domain_thrift:'Token'(). get_recurrent_token(#st{recurrent_token = Token}) -> Token. @@ -3666,51 +1463,24 @@ get_recurrent_token(#st{recurrent_token = Token}) -> get_payment_revision(#st{payment = #domain_InvoicePayment{domain_revision = Revision}}) -> Revision. +-spec get_payment_payer(st()) -> dmsl_domain_thrift:'Payer'(). get_payment_payer(#st{payment = #domain_InvoicePayment{payer = Payer}}) -> Payer. %% -get_activity_session(St) -> - get_activity_session(get_activity(St), St). - --spec get_activity_session(activity(), st()) -> session() | undefined. -get_activity_session({payment, _Step}, St) -> - get_session(get_target(St), St); -get_activity_session({refund, ID}, St) -> - Refund = try_get_refund_state(ID, St), - hg_invoice_payment_refund:session(Refund). - %% -spec collapse_changes([change()], st() | undefined, change_opts()) -> st() | undefined. collapse_changes(Changes, St, Opts) -> - lists:foldl(fun(C, St1) -> merge_change(C, St1, Opts) end, St, Changes). + hg_invoice_payment_state:collapse_changes(Changes, St, Opts). %% -get_route_provider_ref(#domain_PaymentRoute{provider = ProviderRef}) -> - ProviderRef. - get_route_provider(#domain_PaymentRoute{provider = ProviderRef}) -> ProviderRef. -get_route_provider(Route, Revision) -> - hg_domain:get(Revision, {provider, get_route_provider_ref(Route)}). - -inspect(#domain_InvoicePayment{domain_revision = Revision} = Payment, PaymentInstitution, Opts) -> - InspectorRef = get_selector_value(inspector, PaymentInstitution#domain_PaymentInstitution.inspector), - Inspector = hg_domain:get(Revision, {inspector, InspectorRef}), - hg_inspector:inspect(get_shop(Opts, Revision), get_invoice(Opts), Payment, Inspector). - -repair_inspect(Payment, PaymentInstitution, Opts, #st{repair_scenario = Scenario}) -> - case hg_invoice_repair:check_for_action(skip_inspector, Scenario) of - {result, Result} -> - Result; - call -> - inspect(Payment, PaymentInstitution, Opts) - end. - +-spec get_st_meta(st()) -> #{id => payment_id()}. get_st_meta(#st{payment = #domain_InvoicePayment{id = ID}}) -> #{ id => ID @@ -3873,12 +1643,12 @@ get_message(invoice_payment_cash_flow_changed) -> get_message(invoice_payment_status_changed) -> "Invoice payment status is changed". -get_party_client() -> - HgContext = hg_context:load(), - Client = hg_context:get_party_client(HgContext), - Context = hg_context:get_party_client_context(HgContext), - {Client, Context}. - +-spec is_route_cascade_available( + hg_cascade:cascade_behaviour(), + route(), + payment_status(), + st() +) -> boolean(). is_route_cascade_available( Behaviour, Route, @@ -3893,6 +1663,7 @@ is_route_cascade_available( length(get_candidate_routes(St)) > 1 andalso length(AttemptedRoutes) < get_routing_attempt_limit(St). +-spec get_route_cascade_behaviour(route(), hg_domain:revision()) -> hg_cascade:cascade_behaviour(). get_route_cascade_behaviour(Route, Revision) -> ProviderRef = get_route_provider(Route), #domain_Provider{cascade_behaviour = Behaviour} = hg_domain:get(Revision, {provider, ProviderRef}), @@ -3932,7 +1703,7 @@ filter_attempted_routes_test_() -> [ ?_assertMatch( #{candidates := []}, - filter_attempted_routes( + hg_invoice_payment_routing:filter_attempted_routes( hg_routing_ctx:new([]), #st{ activity = idle, @@ -3946,15 +1717,18 @@ filter_attempted_routes_test_() -> ) ), ?_assertMatch( - #{candidates := []}, filter_attempted_routes(hg_routing_ctx:new([]), #st{activity = idle, routes = []}) + #{candidates := []}, + hg_invoice_payment_routing:filter_attempted_routes(hg_routing_ctx:new([]), #st{activity = idle, routes = []}) ), ?_assertMatch( #{candidates := [R1, R2, R3]}, - filter_attempted_routes(hg_routing_ctx:new([R1, R2, R3]), #st{activity = idle, routes = []}) + hg_invoice_payment_routing:filter_attempted_routes(hg_routing_ctx:new([R1, R2, R3]), #st{ + activity = idle, routes = [] + }) ), ?_assertMatch( #{candidates := [R1, R2]}, - filter_attempted_routes( + hg_invoice_payment_routing:filter_attempted_routes( hg_routing_ctx:new([R1, R2, R3]), #st{ activity = idle, @@ -3969,7 +1743,7 @@ filter_attempted_routes_test_() -> ), ?_assertMatch( #{candidates := []}, - filter_attempted_routes( + hg_invoice_payment_routing:filter_attempted_routes( hg_routing_ctx:new([R1, R2, R3]), #st{ activity = idle, diff --git a/apps/hellgate/src/hg_invoice_payment_adjustment.erl b/apps/hellgate/src/hg_invoice_payment_adjustment.erl new file mode 100644 index 00000000..27a414a8 --- /dev/null +++ b/apps/hellgate/src/hg_invoice_payment_adjustment.erl @@ -0,0 +1,357 @@ +%%% Payment adjustment module +%%% +%%% Extracted from hg_invoice_payment.erl for better code organization. +%%% Contains all adjustment-related functions. + +-module(hg_invoice_payment_adjustment). + +-include_lib("damsel/include/dmsl_base_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("payment_events.hrl"). +-include("hg_invoice_payment.hrl"). + +%% Types +-type st() :: hg_invoice_payment:st(). +-type adjustment() :: hg_invoice_payment:adjustment(). +-type adjustment_params() :: dmsl_payproc_thrift:'InvoicePaymentAdjustmentParams'(). +-type adjustment_status_change() :: dmsl_domain_thrift:'InvoicePaymentAdjustmentStatusChange'(). +-type adjustment_state() :: dmsl_domain_thrift:'InvoicePaymentAdjustmentState'(). +-type adjustment_id() :: dmsl_domain_thrift:'InvoicePaymentAdjustmentID'(). +-type payment_status() :: hg_invoice_payment:payment_status(). +-type final_cash_flow() :: hg_cashflow:final_cash_flow(). +-type opts() :: hg_invoice_payment:opts(). +-type result() :: hg_invoice_payment:result(). +-type events() :: hg_invoice_payment:events(). +-type action() :: hg_machine_action:action(). +-type machine_result() :: hg_invoice_payment:machine_result(). +-type payment() :: hg_invoice_payment:payment(). + +%% Adjustment functions +-export([create_adjustment/4]). +-export([create_cash_flow_adjustment/5]). +-export([create_status_adjustment/5]). +-export([process_adjustment_cashflow/3]). +-export([prepare_adjustment_cashflow/3]). +-export([finalize_adjustment_cashflow/3]). +-export([assert_adjustment_payment_status/1]). +-export([assert_adjustment_payment_statuses/2]). +-export([is_adjustment_payment_status_final/1]). +-export([merge_adjustment_change/2]). +-export([apply_adjustment_effects/2]). +-export([apply_adjustment_effect/3]). +-export([get_adjustment_cashflow/1]). +-export([maybe_inject_new_cost_amount/2]). +-export([maybe_get_domain_revision/1]). + +%%% Adjustment creation + +-spec create_adjustment(hg_datetime:timestamp(), adjustment_params(), st(), opts()) -> {adjustment(), result()}. +create_adjustment(Timestamp, Params, St, Opts) -> + _ = hg_invoice_payment_validation:assert_no_adjustment_pending(St), + case Params#payproc_InvoicePaymentAdjustmentParams.scenario of + {cash_flow, #domain_InvoicePaymentAdjustmentCashFlow{domain_revision = DomainRevision}} -> + create_cash_flow_adjustment(Timestamp, Params, DomainRevision, St, Opts); + {status_change, Change} -> + create_status_adjustment(Timestamp, Params, Change, St, Opts) + end. + +-spec create_cash_flow_adjustment( + hg_datetime:timestamp(), + adjustment_params(), + undefined | hg_domain:revision(), + st(), + opts() +) -> {adjustment(), result()}. +create_cash_flow_adjustment(Timestamp, Params, DomainRevision, St, Opts) -> + Payment = hg_invoice_payment:get_payment(St), + Route = hg_invoice_payment:get_route(St), + _ = hg_invoice_payment_validation:assert_payment_status([captured, refunded, charged_back, failed], Payment), + NewRevision = maybe_get_domain_revision(DomainRevision), + OldCashFlow = hg_invoice_payment:get_final_cashflow(St), + VS = hg_invoice_payment_cashflow:collect_validation_varset(St, Opts), + Allocation = hg_invoice_payment:get_allocation(St), + {Payment1, AdditionalEvents} = maybe_inject_new_cost_amount( + Payment, Params#payproc_InvoicePaymentAdjustmentParams.scenario + ), + Context = #{ + provision_terms => hg_invoice_payment:get_provider_terminal_terms(Route, VS, NewRevision), + route => Route, + payment => Payment1, + timestamp => Timestamp, + varset => VS, + revision => NewRevision, + allocation => Allocation + }, + NewCashFlow = + case Payment of + #domain_InvoicePayment{status = {failed, _}} -> + []; + _ -> + hg_invoice_payment_cashflow:calculate_cashflow(Context, Opts) + end, + AdjState = + {cash_flow, #domain_InvoicePaymentAdjustmentCashFlowState{ + scenario = #domain_InvoicePaymentAdjustmentCashFlow{domain_revision = DomainRevision} + }}, + construct_adjustment( + Timestamp, + Params, + NewRevision, + OldCashFlow, + NewCashFlow, + AdjState, + AdditionalEvents, + St + ). + +-spec maybe_inject_new_cost_amount(payment(), adjustment_scenario()) -> {payment(), events()}. +maybe_inject_new_cost_amount( + Payment, + {'cash_flow', #domain_InvoicePaymentAdjustmentCashFlow{new_amount = NewAmount}} +) when NewAmount =/= undefined -> + OldCost = get_payment_cost(Payment), + NewCost = OldCost#domain_Cash{amount = NewAmount}, + Payment1 = Payment#domain_InvoicePayment{cost = NewCost}, + {Payment1, [?cash_changed(OldCost, NewCost)]}; +maybe_inject_new_cost_amount(Payment, _AdjustmentScenario) -> + {Payment, []}. + +-type adjustment_scenario() :: + {'cash_flow', dmsl_domain_thrift:'InvoicePaymentAdjustmentCashFlow'()} + | {'status_change', dmsl_domain_thrift:'InvoicePaymentAdjustmentStatusChange'()}. + +-spec create_status_adjustment( + hg_datetime:timestamp(), + adjustment_params(), + adjustment_status_change(), + st(), + opts() +) -> {adjustment(), result()}. +create_status_adjustment(Timestamp, Params, Change, St, Opts) -> + #domain_InvoicePaymentAdjustmentStatusChange{ + target_status = TargetStatus + } = Change, + #domain_InvoicePayment{ + status = Status, + domain_revision = DomainRevision + } = hg_invoice_payment:get_payment(St), + ok = assert_adjustment_payment_status(Status), + ok = hg_invoice_payment_validation:assert_no_refunds(St), + ok = assert_adjustment_payment_statuses(TargetStatus, Status), + OldCashFlow = hg_invoice_payment_cashflow:get_cash_flow_for_status(Status, St), + NewCashFlow = hg_invoice_payment_cashflow:get_cash_flow_for_target_status(TargetStatus, St, Opts), + AdjState = + {status_change, #domain_InvoicePaymentAdjustmentStatusChangeState{ + scenario = Change + }}, + construct_adjustment( + Timestamp, + Params, + DomainRevision, + OldCashFlow, + NewCashFlow, + AdjState, + [], + St + ). + +-spec maybe_get_domain_revision(undefined | hg_domain:revision()) -> hg_domain:revision(). +maybe_get_domain_revision(undefined) -> + hg_domain:head(); +maybe_get_domain_revision(DomainRevision) -> + DomainRevision. + +-spec assert_adjustment_payment_status(payment_status()) -> ok | no_return(). +assert_adjustment_payment_status(Status) -> + hg_invoice_payment_validation:assert_adjustment_payment_status(Status). + +-spec assert_adjustment_payment_statuses(payment_status(), payment_status()) -> ok | no_return(). +assert_adjustment_payment_statuses(TargetStatus, Status) -> + hg_invoice_payment_validation:assert_adjustment_payment_statuses(TargetStatus, Status). + +-spec is_adjustment_payment_status_final(payment_status()) -> boolean(). +is_adjustment_payment_status_final(Status) -> + hg_invoice_payment_validation:is_adjustment_payment_status_final(Status). + +%%% Adjustment processing + +-spec process_adjustment_cashflow(adjustment_id(), action(), st()) -> machine_result(). +process_adjustment_cashflow(ID, _Action, St) -> + Opts = hg_invoice_payment:get_opts(St), + Adjustment = hg_invoice_payment:get_adjustment(ID, St), + ok = prepare_adjustment_cashflow(Adjustment, St, Opts), + Events = [?adjustment_ev(ID, ?adjustment_status_changed(?adjustment_processed()))], + {next, {Events, hg_machine_action:instant()}}. + +-spec prepare_adjustment_cashflow(adjustment(), st(), opts()) -> ok | no_return(). +prepare_adjustment_cashflow(Adjustment, St, Options) -> + PlanID = construct_adjustment_plan_id(Adjustment, St, Options), + Plan = get_adjustment_cashflow_plan(Adjustment), + plan(PlanID, Plan). + +-spec finalize_adjustment_cashflow(adjustment(), st(), opts()) -> ok | no_return(). +finalize_adjustment_cashflow(Adjustment, St, Options) -> + PlanID = construct_adjustment_plan_id(Adjustment, St, Options), + Plan = get_adjustment_cashflow_plan(Adjustment), + commit(PlanID, Plan). + +-spec get_adjustment_cashflow(adjustment()) -> final_cash_flow(). +get_adjustment_cashflow(#domain_InvoicePaymentAdjustment{new_cash_flow = Cashflow}) -> + Cashflow. + +%%% Adjustment state management + +-spec merge_adjustment_change(adjustment_change(), undefined | adjustment()) -> adjustment(). +merge_adjustment_change(?adjustment_created(Adjustment), undefined) -> + Adjustment; +merge_adjustment_change(?adjustment_status_changed(Status), Adjustment) -> + Adjustment#domain_InvoicePaymentAdjustment{status = Status}. + +-type adjustment_change() :: + {invoice_payment_adjustment_created, dmsl_payproc_thrift:'InvoicePaymentAdjustmentCreated'()} + | {invoice_payment_adjustment_status_changed, dmsl_payproc_thrift:'InvoicePaymentAdjustmentStatusChanged'()}. + +-define(adjustment_target_status(Status), #domain_InvoicePaymentAdjustment{ + state = + {status_change, #domain_InvoicePaymentAdjustmentStatusChangeState{ + scenario = #domain_InvoicePaymentAdjustmentStatusChange{target_status = Status} + }} +}). + +-spec apply_adjustment_effects(adjustment(), st()) -> st(). +apply_adjustment_effects(Adjustment, St) -> + apply_adjustment_effect( + status, + Adjustment, + apply_adjustment_effect(cashflow, Adjustment, St) + ). + +-spec apply_adjustment_effect(status | cashflow, adjustment(), st()) -> st(). +apply_adjustment_effect(status, ?adjustment_target_status(Status), St = #st{payment = Payment}) -> + case Status of + {captured, Capture} -> + St#st{ + payment = Payment#domain_InvoicePayment{ + status = Status, + cost = get_captured_cost(Capture, Payment) + } + }; + _ -> + St#st{ + payment = Payment#domain_InvoicePayment{ + status = Status + } + } + end; +apply_adjustment_effect(status, #domain_InvoicePaymentAdjustment{}, St) -> + St; +apply_adjustment_effect(cashflow, Adjustment, St) -> + set_cashflow(get_adjustment_cashflow(Adjustment), St). + +%%% Internal helper functions + +-spec construct_adjustment( + hg_datetime:timestamp(), + adjustment_params(), + hg_domain:revision(), + final_cash_flow(), + final_cash_flow(), + adjustment_state(), + events(), + st() +) -> {adjustment(), result()}. +construct_adjustment( + Timestamp, + Params, + DomainRevision, + OldCashFlow, + NewCashFlow, + State, + AdditionalEvents, + St +) -> + ID = construct_adjustment_id(St), + Adjustment = #domain_InvoicePaymentAdjustment{ + id = ID, + status = ?adjustment_pending(), + created_at = Timestamp, + domain_revision = DomainRevision, + reason = Params#payproc_InvoicePaymentAdjustmentParams.reason, + old_cash_flow_inverse = hg_cashflow:revert(OldCashFlow), + new_cash_flow = NewCashFlow, + state = State + }, + Events = [?adjustment_ev(ID, ?adjustment_created(Adjustment)) | AdditionalEvents], + {Adjustment, {Events, hg_machine_action:instant()}}. + +-spec construct_adjustment_id(st()) -> adjustment_id(). +construct_adjustment_id(#st{adjustments = As}) -> + erlang:integer_to_binary(length(As) + 1). + +-spec construct_adjustment_plan_id(adjustment(), st(), opts()) -> binary(). +construct_adjustment_plan_id(Adjustment, St, Options) -> + hg_utils:construct_complex_id([ + hg_invoice_payment:get_invoice_id(hg_invoice_payment:get_invoice(Options)), + hg_invoice_payment:get_payment_id(hg_invoice_payment:get_payment(St)), + {adj, get_adjustment_id(Adjustment)} + ]). + +-spec get_adjustment_id(adjustment()) -> adjustment_id(). +get_adjustment_id(#domain_InvoicePaymentAdjustment{id = ID}) -> + ID. + +%%% Helper functions (re-exported from hg_invoice_payment for convenience) + +-spec get_payment_cost(payment()) -> dmsl_domain_thrift:'Cash'(). +get_payment_cost(#domain_InvoicePayment{changed_cost = Cost}) when Cost =/= undefined -> + Cost; +get_payment_cost(#domain_InvoicePayment{cost = Cost}) -> + Cost. + +-spec set_cashflow(final_cash_flow(), st()) -> st(). +set_cashflow(Cashflow, #st{} = St) -> + St#st{ + cash_flow = Cashflow, + final_cash_flow = Cashflow + }. + +-spec get_captured_cost(dmsl_domain_thrift:'InvoicePaymentCaptured'(), payment()) -> + dmsl_domain_thrift:'Cash'(). +get_captured_cost(#domain_InvoicePaymentCaptured{cost = Cost}, _) when Cost /= undefined -> + Cost; +get_captured_cost(_, #domain_InvoicePayment{cost = Cost}) -> + Cost. + +-spec get_adjustment_cashflow_plan(adjustment()) -> [{non_neg_integer(), final_cash_flow()}]. +get_adjustment_cashflow_plan(#domain_InvoicePaymentAdjustment{ + old_cash_flow_inverse = CashflowInverse, + new_cash_flow = Cashflow +}) -> + number_plan([CashflowInverse, Cashflow], 1, []). + +-spec number_plan([final_cash_flow()], non_neg_integer(), [{non_neg_integer(), final_cash_flow()}]) -> + [{non_neg_integer(), final_cash_flow()}]. +number_plan([], _Number, Acc) -> + lists:reverse(Acc); +number_plan([[] | Tail], Number, Acc) -> + number_plan(Tail, Number, Acc); +number_plan([NonEmpty | Tail], Number, Acc) -> + number_plan(Tail, Number + 1, [{Number, NonEmpty} | Acc]). + +-spec plan(binary(), [{non_neg_integer(), final_cash_flow()}]) -> ok | no_return(). +plan(_PlanID, []) -> + ok; +plan(PlanID, Plan) -> + _ = hg_accounting:plan(PlanID, Plan), + ok. + +-spec commit(binary(), [{non_neg_integer(), final_cash_flow()}]) -> ok | no_return(). +commit(_PlanID, []) -> + ok; +commit(PlanID, Plan) -> + _ = hg_accounting:commit(PlanID, Plan), + ok. diff --git a/apps/hellgate/src/hg_invoice_payment_cashflow.erl b/apps/hellgate/src/hg_invoice_payment_cashflow.erl new file mode 100644 index 00000000..4959a261 --- /dev/null +++ b/apps/hellgate/src/hg_invoice_payment_cashflow.erl @@ -0,0 +1,440 @@ +%%% Payment cashflow module +%%% +%%% Extracted from hg_invoice_payment.erl for better code organization. +%%% Contains all cashflow-related functions. + +-module(hg_invoice_payment_cashflow). + +-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("payment_events.hrl"). +-include("hg_invoice_payment.hrl"). + +-define(LOG_MD(Level, Format, Args), logger:log(Level, Format, Args, logger:get_process_metadata())). + +%% Types +-type st() :: hg_invoice_payment:st(). +-type payment() :: hg_invoice_payment:payment(). +-type payment_status() :: hg_invoice_payment:payment_status(). +-type route() :: hg_route:payment_route(). +-type varset() :: hg_varset:varset(). +-type revision() :: hg_domain:revision(). +-type opts() :: hg_invoice_payment:opts(). +-type final_cash_flow() :: hg_cashflow:final_cash_flow(). +-type domain_refund() :: dmsl_domain_thrift:'InvoicePaymentRefund'(). +-type payment_tool() :: dmsl_domain_thrift:'PaymentTool'(). +-type shop() :: dmsl_domain_thrift:'ShopConfig'(). +-type shop_config_ref() :: dmsl_domain_thrift:'ShopConfigRef'(). +-type party_config_ref() :: dmsl_domain_thrift:'PartyConfigRef'(). +-type cashflow_context() :: hg_invoice_payment:cashflow_context(). +-type payment_institution() :: hg_payment_institution:t(). +-type action() :: hg_invoice_payment:action(). +-type machine_result() :: hg_invoice_payment:machine_result(). + +%% Cashflow functions +-export([calculate_cashflow/2]). +-export([calculate_cashflow/3]). +-export([process_cash_flow_building/2]). +-export([make_refund_cashflow/8]). +-export([get_provider_terminal_terms/3]). +-export([get_provider_refunds_terms/3]). +-export([get_provider_partial_refunds_terms/3]). +-export([get_cash_flow_for_status/2]). +-export([get_cash_flow_for_target_status/3]). +-export([collect_chargeback_varset/2]). +-export([collect_refund_varset/3]). +-export([collect_partial_refund_varset/1]). +-export([collect_validation_varset/2]). +-export([collect_validation_varset/4]). + +%%% Cashflow functions + +-spec calculate_cashflow(cashflow_context(), opts()) -> final_cash_flow(). +calculate_cashflow(#{route := Route, revision := Revision} = Context, Opts) -> + CollectCashflowContext = genlib_map:compact(Context#{ + operation => payment, + party => get_party_obj(Opts), + shop => get_shop_obj(Opts, Revision), + provider => get_route_provider(Route, Revision) + }), + hg_cashflow_utils:collect_cashflow(CollectCashflowContext). + +-spec calculate_cashflow(payment_institution(), cashflow_context(), opts()) -> final_cash_flow(). +calculate_cashflow(PaymentInstitution, #{route := Route, revision := Revision} = Context, Opts) -> + CollectCashflowContext = genlib_map:compact(Context#{ + operation => payment, + party => get_party_obj(Opts), + shop => get_shop_obj(Opts, Revision), + provider => get_route_provider(Route, Revision) + }), + hg_cashflow_utils:collect_cashflow(PaymentInstitution, CollectCashflowContext). + +-spec process_cash_flow_building(action(), st()) -> machine_result(). +process_cash_flow_building(Action, St) -> + Route = hg_invoice_payment:get_route(St), + Opts = hg_invoice_payment:get_opts(St), + Revision = hg_invoice_payment:get_payment_revision(St), + Payment = hg_invoice_payment:get_payment(St), + Timestamp = get_payment_created_at(Payment), + VS0 = hg_invoice_payment_construction:reconstruct_payment_flow(Payment, #{}), + VS1 = collect_validation_varset( + get_party_config_ref(Opts), + get_shop_obj(Opts, Revision), + Payment, + VS0 + ), + ProviderTerms = get_provider_terminal_terms(Route, VS1, Revision), + Allocation = hg_invoice_payment:get_allocation(St), + Context = #{ + provision_terms => ProviderTerms, + route => Route, + payment => Payment, + timestamp => Timestamp, + varset => VS1, + revision => Revision, + allocation => Allocation + }, + FinalCashflow = calculate_cashflow(Context, Opts), + _ = rollback_unused_payment_limits(St), + _Clock = hg_accounting:hold( + hg_invoice_payment:construct_payment_plan_id(St), + {1, FinalCashflow} + ), + Events = [?cash_flow_changed(FinalCashflow)], + {next, {Events, hg_machine_action:set_timeout(0, Action)}}. + +-spec make_refund_cashflow( + domain_refund(), + payment(), + revision(), + st(), + opts(), + dmsl_domain_thrift:'PaymentRefundsServiceTerms'(), + varset(), + hg_datetime:timestamp() +) -> final_cash_flow(). +make_refund_cashflow(Refund, Payment, Revision, St, Opts, MerchantTerms, VS, Timestamp) -> + Route = hg_invoice_payment:get_route(St), + ProviderPaymentsTerms = get_provider_terminal_terms(Route, VS, Revision), + Allocation = Refund#domain_InvoicePaymentRefund.allocation, + CollectCashflowContext = genlib_map:compact(#{ + operation => refund, + provision_terms => get_provider_refunds_terms(ProviderPaymentsTerms, Refund, Payment), + merchant_terms => MerchantTerms, + party => get_party_obj(Opts), + shop => get_shop_obj(Opts, Revision), + route => Route, + payment => Payment, + provider => get_route_provider(Route, Revision), + timestamp => Timestamp, + varset => VS, + revision => Revision, + refund => Refund, + allocation => Allocation + }), + hg_cashflow_utils:collect_cashflow(CollectCashflowContext). + +-spec get_provider_terminal_terms(route(), varset(), 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. + +-spec get_provider_refunds_terms( + dmsl_domain_thrift:'PaymentsProvisionTerms'() | undefined, + domain_refund(), + payment() +) -> dmsl_domain_thrift:'PaymentRefundsProvisionTerms'(). +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}}). + +-spec get_provider_partial_refunds_terms( + dmsl_domain_thrift:'PaymentRefundsProvisionTerms'(), + domain_refund(), + payment() +) -> dmsl_domain_thrift:'PaymentRefundsProvisionTerms'(). +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}}). + +-spec get_cash_flow_for_status(payment_status(), st()) -> final_cash_flow(). +get_cash_flow_for_status({captured, _}, St) -> + hg_invoice_payment:get_final_cashflow(St); +get_cash_flow_for_status({cancelled, _}, _St) -> + []; +get_cash_flow_for_status({failed, _}, _St) -> + []. + +-spec get_cash_flow_for_target_status(payment_status(), st(), opts()) -> final_cash_flow(). +get_cash_flow_for_target_status({captured, Captured}, St0, Opts) -> + Payment0 = hg_invoice_payment:get_payment(St0), + Route = hg_invoice_payment:get_route(St0), + Cost = get_captured_cost(Captured, Payment0), + Allocation = get_captured_allocation(Captured), + Payment1 = Payment0#domain_InvoicePayment{ + cost = Cost + }, + Payment2 = + case Payment1 of + #domain_InvoicePayment{changed_cost = ChangedCost} when ChangedCost =/= undefined -> + Payment1#domain_InvoicePayment{ + cost = ChangedCost + }; + _ -> + Payment1 + end, + Timestamp = get_payment_created_at(Payment2), + St = St0#st{payment = Payment2}, + Revision = Payment2#domain_InvoicePayment.domain_revision, + VS = collect_validation_varset(St, Opts), + Context = #{ + provision_terms => get_provider_terminal_terms(Route, VS, Revision), + route => Route, + payment => Payment2, + timestamp => Timestamp, + varset => VS, + revision => Revision, + allocation => Allocation + }, + calculate_cashflow(Context, Opts); +get_cash_flow_for_target_status({cancelled, _}, _St, _Opts) -> + []; +get_cash_flow_for_target_status({failed, _}, _St, _Opts) -> + []. + +-spec collect_chargeback_varset( + dmsl_domain_thrift:'PaymentChargebackServiceTerms'() | undefined, + varset() +) -> varset(). +collect_chargeback_varset( + #domain_PaymentChargebackServiceTerms{}, + VS +) -> + % nothing here yet + VS; +collect_chargeback_varset(undefined, VS) -> + VS. + +-spec collect_refund_varset( + undefined + | dmsl_domain_thrift:'PaymentRefundsServiceTerms'() + | dmsl_domain_thrift:'PaymentsServiceTerms'() + | {value, dmsl_domain_thrift:'PaymentRefundsServiceTerms'()}, + payment_tool(), + varset() +) -> varset(). +collect_refund_varset(Terms, PaymentTool, VS) -> + case normalize_refund_terms(Terms) of + #domain_PaymentRefundsServiceTerms{ + payment_methods = PaymentMethodSelector, + partial_refunds = PartialRefundsServiceTerms + } -> + RPMs = get_selector_value(payment_methods, PaymentMethodSelector), + case hg_payment_tool:has_any_payment_method(PaymentTool, RPMs) of + true -> + RVS = collect_partial_refund_varset(PartialRefundsServiceTerms), + VS#{refunds => RVS}; + false -> + VS + end; + undefined -> + VS + end. + +normalize_refund_terms(#domain_PaymentRefundsServiceTerms{} = Terms) -> + Terms; +normalize_refund_terms(#domain_PaymentsServiceTerms{refunds = Terms}) -> + normalize_refund_terms(Terms); +normalize_refund_terms({value, Terms}) -> + normalize_refund_terms(Terms); +normalize_refund_terms(undefined) -> + undefined; +normalize_refund_terms(Other) -> + error({misconfiguration, {'Unexpected refund terms', Other}}). + +-spec collect_partial_refund_varset( + dmsl_domain_thrift:'PartialRefundsServiceTerms'() | undefined +) -> map(). +collect_partial_refund_varset( + #domain_PartialRefundsServiceTerms{ + cash_limit = CashLimitSelector + } +) -> + #{ + partial => #{ + cash_limit => get_selector_value(cash_limit, CashLimitSelector) + } + }; +collect_partial_refund_varset(undefined) -> + #{}. + +-spec collect_validation_varset(st(), opts()) -> varset(). +collect_validation_varset(St, Opts) -> + Revision = hg_invoice_payment:get_payment_revision(St), + collect_validation_varset( + get_party_config_ref(Opts), + get_shop_obj(Opts, Revision), + hg_invoice_payment:get_payment(St), + #{} + ). + +-spec collect_validation_varset(party_config_ref(), {shop_config_ref(), shop()}, payment(), varset()) -> varset(). +collect_validation_varset(PartyConfigRef, ShopObj, Payment, VS) -> + Cost = #domain_Cash{currency = Currency} = get_payment_cost(Payment), + VS0 = collect_validation_varset_(PartyConfigRef, ShopObj, Currency, VS), + VS0#{ + cost => Cost, + payment_tool => get_payment_tool(Payment) + }. + +%%% Internal helper functions + +collect_validation_varset_(PartyConfigRef, {#domain_ShopConfigRef{id = ShopConfigID}, Shop}, Currency, VS) -> + #domain_ShopConfig{ + category = Category + } = Shop, + VS#{ + party_config_ref => PartyConfigRef, + shop_id => ShopConfigID, + category => Category, + currency => Currency + }. + +get_selector_value(Name, Selector) -> + case Selector of + {value, V} -> + V; + Ambiguous -> + error({misconfiguration, {'Could not reduce selector to a value', {Name, Ambiguous}}}) + end. + +get_party_client() -> + HgContext = hg_context:load(), + Client = hg_context:get_party_client(HgContext), + Context = hg_context:get_party_client_context(HgContext), + {Client, Context}. + +get_route_provider_ref(#domain_PaymentRoute{provider = ProviderRef}) -> + ProviderRef. + +get_route_provider(Route, Revision) -> + hg_domain:get(Revision, {provider, get_route_provider_ref(Route)}). + +get_party_obj(#{party := Party, party_config_ref := PartyConfigRef}) -> + {PartyConfigRef, Party}. + +get_party_config_ref(#{party_config_ref := PartyConfigRef}) -> + PartyConfigRef. + +get_shop_obj(#{invoice := Invoice, party_config_ref := PartyConfigRef}, Revision) -> + hg_party:get_shop(get_invoice_shop_config_ref(Invoice), PartyConfigRef, Revision). + +get_invoice_shop_config_ref(#domain_Invoice{shop_ref = ShopConfigRef}) -> + ShopConfigRef. + +get_payment_cost(#domain_InvoicePayment{changed_cost = Cost}) when Cost =/= undefined -> + Cost; +get_payment_cost(#domain_InvoicePayment{cost = Cost}) -> + Cost. + +get_payment_tool(#domain_InvoicePayment{payer = Payer}) -> + hg_invoice_payment:get_payer_payment_tool(Payer). + +get_payment_created_at(#domain_InvoicePayment{created_at = CreatedAt}) -> + CreatedAt. + +get_captured_cost(#domain_InvoicePaymentCaptured{cost = Cost}, _) when Cost /= undefined -> + Cost; +get_captured_cost(_, #domain_InvoicePayment{cost = Cost}) -> + Cost. + +get_captured_allocation(#domain_InvoicePaymentCaptured{allocation = Allocation}) -> + Allocation. + +get_refund_cash(#domain_InvoicePaymentRefund{cash = Cash}) -> + Cash. + +rollback_unused_payment_limits(St) -> + Route = hg_invoice_payment:get_route(St), + Routes = get_candidate_routes_internal(St), + UnUsedRoutes = Routes -- [Route], + rollback_payment_limits_internal(UnUsedRoutes, hg_invoice_payment:get_iter(St), St, [ + ignore_business_error, ignore_not_found + ]). + +get_candidate_routes_internal(#st{candidate_routes = undefined}) -> + []; +get_candidate_routes_internal(#st{candidate_routes = Routes}) -> + Routes. + +rollback_payment_limits_internal(Routes, Iter, St, Flags) -> + Opts = hg_invoice_payment:get_opts(St), + Revision = hg_invoice_payment:get_payment_revision(St), + Payment = hg_invoice_payment:get_payment(St), + Invoice = get_invoice_internal(Opts), + VS = get_varset_internal(St, Revision), + lists:foreach( + fun(Route) -> + ProviderTerms = hg_routing:get_payment_terms(Route, VS, Revision), + TurnoverLimits = get_turnover_limits_internal(ProviderTerms, strict), + ok = hg_limiter:rollback_payment_limits(TurnoverLimits, Invoice, Payment, Route, Iter, Flags) + end, + Routes + ). + +get_invoice_internal(#{invoice := Invoice}) -> + Invoice. + +get_varset_internal(St, _Revision) -> + Opts = hg_invoice_payment:get_opts(St), + collect_validation_varset(St, Opts). + +get_turnover_limits_internal(ProviderTerms, Mode) -> + hg_limiter:get_turnover_limits(ProviderTerms, Mode). diff --git a/apps/hellgate/src/hg_invoice_payment_construction.erl b/apps/hellgate/src/hg_invoice_payment_construction.erl new file mode 100644 index 00000000..fe68f506 --- /dev/null +++ b/apps/hellgate/src/hg_invoice_payment_construction.erl @@ -0,0 +1,368 @@ +%%% Payment construction module +%%% +%%% Extracted from hg_invoice_payment.erl for better code organization. +%%% Contains all construct_* and get_predefined_* functions. + +-module(hg_invoice_payment_construction). + +-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("payment_events.hrl"). +-include("hg_invoice_payment.hrl"). + +%% Types +-type st() :: hg_invoice_payment:st(). +-type payment() :: hg_invoice_payment:payment(). +-type payer() :: dmsl_domain_thrift:'Payer'(). +-type payer_params() :: dmsl_payproc_thrift:'PayerParams'(). +-type payment_flow_params() :: dmsl_payproc_thrift:'InvoicePaymentParamsFlow'(). +-type payment_info() :: dmsl_proxy_provider_thrift:'PaymentInfo'(). +-type payment_plan_id() :: binary(). +-type route() :: hg_route:payment_route(). +-type failure() :: hg_invoice_payment:failure(). +-type invoice() :: dmsl_domain_thrift:'Invoice'(). +-type payment_id() :: dmsl_domain_thrift:'InvoicePaymentID'(). +-type cash() :: dmsl_domain_thrift:'Cash'(). +-type shop_config_ref() :: dmsl_domain_thrift:'ShopConfigRef'(). +-type shop() :: dmsl_domain_thrift:'ShopConfig'(). +-type party_config_ref() :: dmsl_domain_thrift:'PartyConfigRef'(). +-type make_recurrent() :: true | false. +-type activity() :: hg_invoice_payment:activity(). +-type target() :: hg_invoice_payment:target(). +-type trx_info() :: hg_invoice_payment:trx_info(). + +%% Construction functions +-export([construct_payer/1]). +-export([construct_payment/10]). +-export([construct_payment_flow/4]). +-export([reconstruct_payment_flow/2]). +-export([reconstruct_payment_flow/3]). +-export([construct_payment_info/4]). +-export([construct_payment_plan_id/4]). +-export([construct_routing_failure/1]). +-export([construct_routing_failure/2]). +-export([get_predefined_route/1]). +-export([get_predefined_recurrent_route/1]). + +%% Helper functions for construct_payment_info +-export([construct_proxy_payment/2]). +-export([construct_proxy_invoice/1]). +-export([construct_proxy_shop/1]). +-export([construct_proxy_cash/1]). +-export([construct_proxy_capture/1]). + +%%% Construction functions + +-spec construct_payer(payer_params()) -> {ok, payer(), map()}. +construct_payer( + {payment_resource, #payproc_PaymentResourcePayerParams{ + resource = Resource, + contact_info = ContactInfo + }} +) -> + {ok, ?payment_resource_payer(Resource, ContactInfo), #{}}; +construct_payer( + {recurrent, #payproc_RecurrentPayerParams{ + recurrent_parent = Parent, + contact_info = ContactInfo + }} +) -> + ?recurrent_parent(InvoiceID, PaymentID) = Parent, + ParentPayment = + try + hg_invoice_payment:get_payment_state(InvoiceID, PaymentID) + catch + throw:#payproc_InvoiceNotFound{} -> + throw_invalid_recurrent_parent(<<"Parent invoice not found">>); + throw:#payproc_InvoicePaymentNotFound{} -> + throw_invalid_recurrent_parent(<<"Parent payment not found">>) + end, + #domain_InvoicePayment{payer = ParentPayer} = hg_invoice_payment:get_payment(ParentPayment), + ParentPaymentTool = hg_invoice_payment:get_payer_payment_tool(ParentPayer), + {ok, ?recurrent_payer(ParentPaymentTool, Parent, ContactInfo), #{parent_payment => ParentPayment}}. + +-spec construct_payment( + payment_id(), + dmsl_base_thrift:'Timestamp'(), + cash(), + payer(), + payment_flow_params(), + party_config_ref(), + {shop_config_ref(), shop()}, + map(), + hg_domain:revision(), + make_recurrent() +) -> payment(). +construct_payment( + PaymentID, + CreatedAt, + Cost, + Payer, + FlowParams, + PartyConfigRef, + {ShopConfigRef, Shop} = ShopObj, + VS0, + Revision, + MakeRecurrent +) -> + PaymentTool = hg_invoice_payment:get_payer_payment_tool(Payer), + VS1 = VS0#{ + payment_tool => PaymentTool, + cost => Cost + }, + Terms = hg_invoice_utils:compute_shop_terms(Revision, Shop, VS1), + #domain_TermSet{payments = PaymentTerms, recurrent_paytools = RecurrentTerms} = Terms, + ok = hg_invoice_payment_validation:validate_payment_tool( + PaymentTool, + PaymentTerms#domain_PaymentsServiceTerms.payment_methods + ), + ok = hg_invoice_payment_validation:validate_cash( + Cost, + PaymentTerms#domain_PaymentsServiceTerms.cash_limit + ), + Flow = construct_payment_flow( + FlowParams, + CreatedAt, + PaymentTerms#domain_PaymentsServiceTerms.holds, + PaymentTool + ), + ParentPayment = maps:get(parent_payment, VS1, undefined), + ok = hg_invoice_payment_validation:validate_recurrent_intention( + Payer, RecurrentTerms, PaymentTool, ShopObj, ParentPayment, MakeRecurrent + ), + #domain_InvoicePayment{ + id = PaymentID, + created_at = CreatedAt, + party_ref = PartyConfigRef, + shop_ref = ShopConfigRef, + domain_revision = Revision, + status = ?pending(), + cost = Cost, + payer = Payer, + flow = Flow, + make_recurrent = MakeRecurrent, + registration_origin = ?invoice_payment_merchant_reg_origin() + }. + +-spec construct_payment_flow( + payment_flow_params(), + dmsl_base_thrift:'Timestamp'(), + undefined | dmsl_domain_thrift:'PaymentHoldsServiceTerms'(), + dmsl_domain_thrift:'PaymentTool'() +) -> dmsl_domain_thrift:'InvoicePaymentFlow'(). +construct_payment_flow({instant, _}, _CreatedAt, _Terms, _PaymentTool) -> + ?invoice_payment_flow_instant(); +construct_payment_flow({hold, Params}, CreatedAt, Terms, PaymentTool) -> + OnHoldExpiration = Params#payproc_InvoicePaymentParamsFlowHold.on_hold_expiration, + ?hold_lifetime(Seconds) = hg_invoice_payment_validation:validate_hold_lifetime(Terms, PaymentTool), + HeldUntil = hg_datetime:format_ts(hg_datetime:parse_ts(CreatedAt) + Seconds), + ?invoice_payment_flow_hold(OnHoldExpiration, HeldUntil). + +-spec reconstruct_payment_flow(payment(), hg_varset:varset()) -> hg_varset:varset(). +reconstruct_payment_flow(Payment, VS) -> + #domain_InvoicePayment{ + flow = Flow, + created_at = CreatedAt + } = Payment, + reconstruct_payment_flow(Flow, CreatedAt, VS). + +-spec reconstruct_payment_flow( + dmsl_domain_thrift:'InvoicePaymentFlow'(), + dmsl_base_thrift:'Timestamp'(), + hg_varset:varset() +) -> hg_varset:varset(). +reconstruct_payment_flow(?invoice_payment_flow_instant(), _CreatedAt, VS) -> + VS#{flow => instant}; +reconstruct_payment_flow(?invoice_payment_flow_hold(_OnHoldExpiration, HeldUntil), CreatedAt, VS) -> + Seconds = hg_datetime:parse_ts(HeldUntil) - hg_datetime:parse_ts(CreatedAt), + VS#{flow => {hold, ?hold_lifetime(Seconds)}}. + +-spec get_predefined_route(payer()) -> {ok, route()} | undefined. +get_predefined_route(?payment_resource_payer()) -> + undefined; +get_predefined_route(?recurrent_payer() = Payer) -> + get_predefined_recurrent_route(Payer). + +-spec get_predefined_recurrent_route(payer()) -> {ok, route()}. +get_predefined_recurrent_route(?recurrent_payer(_, ?recurrent_parent(InvoiceID, PaymentID), _)) -> + PreviousPayment = hg_invoice_payment:get_payment_state(InvoiceID, PaymentID), + {ok, hg_invoice_payment:get_route(PreviousPayment)}. + +-spec construct_payment_plan_id(invoice(), payment(), non_neg_integer(), legacy | normal) -> payment_plan_id(). +construct_payment_plan_id(Invoice, Payment, _Iter, legacy) -> + hg_utils:construct_complex_id([ + get_invoice_id(Invoice), + get_payment_id(Payment) + ]); +construct_payment_plan_id(Invoice, Payment, Iter, _Mode) -> + hg_utils:construct_complex_id([ + get_invoice_id(Invoice), + get_payment_id(Payment), + integer_to_binary(Iter) + ]). + +-spec construct_payment_info(activity(), target(), st(), payment_info()) -> payment_info(). +construct_payment_info(idle, _Target, _St, PaymentInfo) -> + PaymentInfo; +construct_payment_info( + {payment, _Step}, + Target = ?captured(), + _St, + PaymentInfo +) -> + PaymentInfo#proxy_provider_PaymentInfo{ + capture = construct_proxy_capture(Target) + }; +construct_payment_info({payment, _Step}, _Target, _St, PaymentInfo) -> + PaymentInfo; +construct_payment_info({refund, _ID}, _Target, _St, PaymentInfo) -> + PaymentInfo. + +-spec construct_routing_failure( + {rejected_routes, {atom(), term()}} + | {misconfiguration, term()} + | risk_score_is_too_high + | atom() +) -> failure(). +construct_routing_failure({rejected_routes, {SubCode, RejectedRoutes}}) when + SubCode =:= limit_misconfiguration orelse + SubCode =:= limit_overflow orelse + SubCode =:= adapter_unavailable orelse + SubCode =:= provider_conversion_is_too_low +-> + construct_routing_failure([rejected, SubCode], genlib:format(RejectedRoutes)); +construct_routing_failure({rejected_routes, {_SubCode, RejectedRoutes}}) -> + construct_routing_failure([forbidden], genlib:format(RejectedRoutes)); +construct_routing_failure({misconfiguration = Code, Details}) -> + construct_routing_failure([unknown, {unknown_error, atom_to_binary(Code)}], genlib:format(Details)); +construct_routing_failure(risk_score_is_too_high = Code) -> + construct_routing_failure([Code], undefined); +construct_routing_failure(Error) when is_atom(Error) -> + construct_routing_failure([{unknown_error, Error}], undefined). + +-spec construct_routing_failure([term()], undefined | binary()) -> failure(). +construct_routing_failure(Codes, Reason) -> + {failure, payproc_errors:construct('PaymentFailure', mk_static_error([no_route_found | Codes]), Reason)}. + +%%% Helper functions + +-spec mk_static_error([term()]) -> term(). +mk_static_error([_ | _] = Codes) -> mk_static_error_(#payproc_error_GeneralFailure{}, lists:reverse(Codes)). +mk_static_error_(T, []) -> T; +mk_static_error_(Sub, [Code | Codes]) -> mk_static_error_({Code, Sub}, Codes). + +-spec construct_proxy_payment(payment(), trx_info()) -> dmsl_proxy_provider_thrift:'InvoicePayment'(). +construct_proxy_payment( + #domain_InvoicePayment{ + id = ID, + created_at = CreatedAt, + domain_revision = Revision, + payer = Payer, + payer_session_info = PayerSessionInfo, + cost = Cost, + make_recurrent = MakeRecurrent, + skip_recurrent = SkipRecurrent, + processing_deadline = Deadline + }, + Trx +) -> + ContactInfo = get_contact_info(Payer), + PaymentTool = hg_invoice_payment:get_payer_payment_tool(Payer), + #proxy_provider_InvoicePayment{ + id = ID, + created_at = CreatedAt, + trx = Trx, + payment_resource = construct_payment_resource(Payer), + payment_service = hg_payment_tool:get_payment_service(PaymentTool, Revision), + payer_session_info = PayerSessionInfo, + cost = construct_proxy_cash(Cost), + contact_info = ContactInfo, + make_recurrent = MakeRecurrent, + skip_recurrent = SkipRecurrent, + processing_deadline = Deadline + }. + +construct_payment_resource(?payment_resource_payer(Resource, _)) -> + {disposable_payment_resource, Resource}; +construct_payment_resource(?recurrent_payer(PaymentTool, ?recurrent_parent(InvoiceID, PaymentID), _)) -> + PreviousPayment = hg_invoice_payment:get_payment_state(InvoiceID, PaymentID), + RecToken = hg_invoice_payment:get_recurrent_token(PreviousPayment), + {recurrent_payment_resource, #proxy_provider_RecurrentPaymentResource{ + payment_tool = PaymentTool, + rec_token = RecToken + }}. + +get_contact_info(?payment_resource_payer(_, ContactInfo)) -> + ContactInfo; +get_contact_info(?recurrent_payer(_, _, ContactInfo)) -> + ContactInfo. + +-spec construct_proxy_invoice(invoice()) -> dmsl_proxy_provider_thrift:'Invoice'(). +construct_proxy_invoice( + #domain_Invoice{ + id = InvoiceID, + created_at = CreatedAt, + due = Due, + details = Details, + cost = Cost + } +) -> + #proxy_provider_Invoice{ + id = InvoiceID, + created_at = CreatedAt, + due = Due, + details = Details, + cost = construct_proxy_cash(Cost) + }. + +-spec construct_proxy_shop({shop_config_ref(), shop()}) -> dmsl_proxy_provider_thrift:'Shop'(). +construct_proxy_shop( + { + #domain_ShopConfigRef{id = ShopConfigID}, + Shop = #domain_ShopConfig{ + location = Location, + category = ShopCategoryRef + } + } +) -> + ShopCategory = hg_domain:get({category, ShopCategoryRef}), + #proxy_provider_Shop{ + id = ShopConfigID, + category = ShopCategory, + name = Shop#domain_ShopConfig.name, + description = Shop#domain_ShopConfig.description, + location = Location + }. + +-spec construct_proxy_cash(cash()) -> dmsl_proxy_provider_thrift:'Cash'(). +construct_proxy_cash(#domain_Cash{ + amount = Amount, + currency = CurrencyRef +}) -> + #proxy_provider_Cash{ + amount = Amount, + currency = hg_domain:get({currency, CurrencyRef}) + }. + +-spec construct_proxy_capture(target()) -> dmsl_proxy_provider_thrift:'InvoicePaymentCapture'(). +construct_proxy_capture(?captured(_, Cost)) -> + #proxy_provider_InvoicePaymentCapture{ + cost = construct_proxy_cash(Cost) + }. + +-spec get_invoice_id(invoice()) -> dmsl_domain_thrift:'InvoiceID'(). +get_invoice_id(#domain_Invoice{id = ID}) -> + ID. + +-spec get_payment_id(payment()) -> payment_id(). +get_payment_id(#domain_InvoicePayment{id = ID}) -> + ID. + +-spec throw_invalid_recurrent_parent(binary()) -> no_return(). +throw_invalid_recurrent_parent(Details) -> + throw(#payproc_InvalidRecurrentParentPayment{details = Details}). diff --git a/apps/hellgate/src/hg_invoice_payment_processing.erl b/apps/hellgate/src/hg_invoice_payment_processing.erl new file mode 100644 index 00000000..2255fad9 --- /dev/null +++ b/apps/hellgate/src/hg_invoice_payment_processing.erl @@ -0,0 +1,583 @@ +%%% Payment processing module +%%% +%%% Extracted from hg_invoice_payment.erl for better code organization. +%%% Handles payment processing signals, calls, timeouts, results, and failures. + +-module(hg_invoice_payment_processing). + +-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"). + +-include("hg_invoice_payment.hrl"). +-include("domain.hrl"). +-include("payment_events.hrl"). + +%% API - exported for use by hg_invoice_payment +-export([process_signal/3]). +-export([process_call/3]). +-export([process_timeout/3]). +-export([process_result/2]). +-export([process_result/3]). +-export([process_failure/5]). +-export([process_failure/6]). +-export([process_fatal_payment_failure/5]). +-export([process_chargeback/4]). +-export([process_accounter_update/2]). +-export([finalize_payment/2]). +-export([process_shop_limit_initialization/2]). +-export([process_shop_limit_failure/2]). +-export([process_shop_limit_finalization/2]). +-export([repair_process_timeout/3]). +-export([get_action/3]). +-export([maybe_set_charged_back_status/3]). + +%% Internal helpers +-export([process_timeout/1]). +-export([process_callback/3]). +-export([process_callback/4]). +-export([process_session_change/3]). +-export([process_session_change/4]). +-export([process_refund/2]). +-export([process_refund_result/3]). +-export([construct_shop_limit_failure/2]). +-export([check_recurrent_token/1]). +-export([choose_fd_operation_status_for_failure/1]). +-export([do_choose_fd_operation_status_for_failure/1]). + +-type activity() :: hg_invoice_payment:activity(). +-type st() :: hg_invoice_payment:st(). +-type opts() :: hg_invoice_payment:opts(). +-type action() :: hg_machine_action:t(). +-type machine_result() :: hg_invoice_payment:machine_result(). +-type tag() :: dmsl_proxy_provider_thrift:'CallbackTag'(). +-type callback() :: dmsl_proxy_provider_thrift:'Callback'(). +-type session_change() :: hg_session:change(). +-type callback_response() :: dmsl_proxy_provider_thrift:'CallbackResponse'(). +-type failure() :: hg_invoice_payment:failure(). +-type change() :: hg_invoice_payment:change(). +-type chargeback_activity_type() :: hg_invoice_payment_chargeback:activity(). +-type chargeback_id() :: hg_invoice_payment_chargeback:id(). +-type target() :: hg_invoice_payment:target(). +-type refund_id() :: hg_invoice_payment:refund_id(). +-type adjustment_id() :: hg_invoice_payment:adjustment_id(). + +%%% API + +-spec process_signal(timeout, st(), opts()) -> machine_result(). +process_signal(timeout, St, Options) -> + scoper:scope( + payment, + hg_invoice_payment:get_st_meta(St), + fun() -> process_timeout(St#st{opts = Options}) end + ). + +-spec process_timeout(st()) -> machine_result(). +process_timeout(St) -> + Action = hg_machine_action:new(), + repair_process_timeout(hg_invoice_payment:get_activity(St), Action, St). + +-spec process_timeout(activity(), action(), st()) -> machine_result(). +process_timeout({payment, shop_limit_initializing}, Action, St) -> + process_shop_limit_initialization(Action, St); +process_timeout({payment, shop_limit_failure}, Action, St) -> + process_shop_limit_failure(Action, St); +process_timeout({payment, shop_limit_finalizing}, Action, St) -> + process_shop_limit_finalization(Action, St); +process_timeout({payment, risk_scoring}, Action, St) -> + hg_invoice_payment_routing:process_risk_score(Action, St); +process_timeout({payment, routing}, Action, St) -> + hg_invoice_payment_routing:process_routing(Action, St); +process_timeout({payment, cash_flow_building}, Action, St) -> + hg_invoice_payment_cashflow:process_cash_flow_building(Action, St); +process_timeout({payment, Step}, _Action, St) when + Step =:= processing_session orelse + Step =:= finalizing_session +-> + hg_invoice_payment_session:process_session(St); +process_timeout({payment, Step}, Action, St) when + Step =:= processing_failure orelse + Step =:= routing_failure orelse + Step =:= processing_accounter orelse + Step =:= finalizing_accounter +-> + process_result(Action, St); +process_timeout({payment, updating_accounter}, Action, St) -> + process_accounter_update(Action, St); +process_timeout({chargeback, ID, Type}, Action, St) -> + process_chargeback(Type, ID, Action, St); +process_timeout({refund, ID}, _Action, St) -> + process_refund(ID, St); +process_timeout({adjustment_new, ID}, Action, St) -> + hg_invoice_payment_adjustment:process_adjustment_cashflow(ID, Action, St); +process_timeout({adjustment_pending, ID}, Action, St) -> + process_adjustment_capture(ID, Action, St); +process_timeout({payment, flow_waiting}, Action, St) -> + finalize_payment(Action, St). + +-spec process_adjustment_capture(adjustment_id(), action(), st()) -> machine_result(). +process_adjustment_capture(ID, _Action, St) -> + Opts = hg_invoice_payment:get_opts(St), + Adjustment = hg_invoice_payment:get_adjustment(ID, St), + ok = hg_invoice_payment_validation:assert_adjustment_status(processed, Adjustment), + ok = hg_invoice_payment_adjustment:finalize_adjustment_cashflow(Adjustment, St, Opts), + Status = ?adjustment_captured(maps:get(timestamp, Opts)), + Event = ?adjustment_ev(ID, ?adjustment_status_changed(Status)), + {done, {[Event], hg_machine_action:new()}}. + +-spec process_call + ({callback, tag(), callback()}, st(), opts()) -> {callback_response(), machine_result()}; + ({session_change, tag(), session_change()}, st(), opts()) -> {ok, machine_result()}. +process_call({callback, Tag, Payload}, St, Options) -> + scoper:scope( + payment, + hg_invoice_payment:get_st_meta(St), + fun() -> process_callback(Tag, Payload, St#st{opts = Options}) end + ); +process_call({session_change, Tag, SessionChange}, St, Options) -> + scoper:scope( + payment, + hg_invoice_payment:get_st_meta(St), + fun() -> process_session_change(Tag, SessionChange, St#st{opts = Options}) end + ). + +-spec process_callback(tag(), callback(), st()) -> {callback_response(), machine_result()}. +process_callback(Tag, Payload, St) -> + Session = hg_invoice_payment_session:get_activity_session(St), + process_callback(Tag, Payload, Session, St). + +-spec process_callback(tag(), callback(), hg_session:t() | undefined, st()) -> + {callback_response(), machine_result()}. +process_callback(Tag, Payload, Session, St) when Session /= undefined -> + case {hg_session:status(Session), hg_session:tags(Session)} of + {suspended, [Tag | _]} -> + hg_invoice_payment_session:handle_callback(hg_invoice_payment:get_activity(St), Payload, Session, St); + _ -> + throw(invalid_callback) + end; +process_callback(_Tag, _Payload, undefined, _St) -> + throw(invalid_callback). + +-spec process_session_change(tag(), session_change(), st()) -> {ok, machine_result()}. +process_session_change(Tag, SessionChange, St) -> + Session = hg_invoice_payment_session:get_activity_session(St), + process_session_change(Tag, SessionChange, Session, St). + +-spec process_session_change(tag(), session_change(), hg_session:t() | undefined, st()) -> + {ok, machine_result()}. +process_session_change(Tag, SessionChange, Session0, St) when Session0 /= undefined -> + %% NOTE Change allowed only for suspended session. Not suspended + %% session does not have registered callback with tag. + case {hg_session:status(Session0), hg_session:tags(Session0)} of + {suspended, [Tag | _]} -> + {Result, Session1} = hg_session:process_change(SessionChange, Session0), + {ok, + hg_invoice_payment_session:finish_session_processing( + hg_invoice_payment:get_activity(St), Result, Session1, St + )}; + _ -> + throw(invalid_callback) + end; +process_session_change(_Tag, _Payload, undefined, _St) -> + throw(invalid_callback). + +-spec process_shop_limit_initialization(action(), st()) -> machine_result(). +process_shop_limit_initialization(Action, St) -> + Opts = hg_invoice_payment:get_opts(St), + _ = hg_invoice_payment:hold_shop_limits(Opts, St), + case hg_invoice_payment:check_shop_limits(Opts, St) of + ok -> + {next, {[?shop_limit_initiated()], hg_machine_action:set_timeout(0, Action)}}; + {error, {limit_overflow = Error, IDs}} -> + Failure = construct_shop_limit_failure(Error, IDs), + Events = [ + ?shop_limit_initiated(), + ?payment_rollback_started(Failure) + ], + {next, {Events, hg_machine_action:set_timeout(0, Action)}} + end. + +-spec construct_shop_limit_failure(atom(), list()) -> failure(). +construct_shop_limit_failure(limit_overflow, IDs) -> + Error = mk_static_error([authorization_failed, shop_limit_exceeded, unknown]), + Reason = genlib:format("Limits with following IDs overflowed: ~p", [IDs]), + {failure, payproc_errors:construct('PaymentFailure', Error, Reason)}. + +-type static_error() :: + {atom() | {'unknown_error', binary()}, dmsl_payproc_error_thrift:'GeneralFailure'() | static_error()}. + +-spec mk_static_error([atom(), ...]) -> static_error(). +mk_static_error([_ | _] = Codes) -> + mk_static_error_(#payproc_error_GeneralFailure{}, lists:reverse(Codes)). + +-spec mk_static_error_(dmsl_payproc_error_thrift:'GeneralFailure'() | static_error(), [atom()]) -> static_error(). +mk_static_error_(T, []) -> + T; +mk_static_error_(Sub, [Code | Codes]) -> + mk_static_error_({Code, Sub}, Codes). + +-spec process_shop_limit_failure(action(), st()) -> machine_result(). +process_shop_limit_failure(Action, #st{failure = Failure} = St) -> + Opts = hg_invoice_payment:get_opts(St), + _ = hg_invoice_payment:rollback_shop_limits(Opts, St, [ignore_business_error, ignore_not_found]), + {done, {[?payment_status_changed(?failed(Failure))], hg_machine_action:set_timeout(0, Action)}}. + +-spec process_shop_limit_finalization(action(), st()) -> machine_result(). +process_shop_limit_finalization(Action, St) -> + Opts = hg_invoice_payment:get_opts(St), + _ = hg_invoice_payment:commit_shop_limits(Opts, St), + {next, {[?shop_limit_applied()], hg_machine_action:set_timeout(0, Action)}}. + +-spec process_chargeback(chargeback_activity_type(), chargeback_id(), action(), st()) -> machine_result(). +process_chargeback(finalising_accounter = Type, ID, Action0, St) -> + ChargebackState = hg_invoice_payment:get_chargeback_state(ID, St), + ChargebackOpts = hg_invoice_payment:get_chargeback_opts(St), + ChargebackBody = hg_invoice_payment_chargeback:get_body(ChargebackState), + ChargebackTarget = hg_invoice_payment_chargeback:get_target_status(ChargebackState), + MaybeChargedback = maybe_set_charged_back_status(ChargebackTarget, ChargebackBody, St), + {Changes, Action1} = hg_invoice_payment_chargeback:process_timeout(Type, ChargebackState, Action0, ChargebackOpts), + {done, {[?chargeback_ev(ID, C) || C <- Changes] ++ MaybeChargedback, Action1}}; +process_chargeback(Type, ID, Action0, St) -> + ChargebackState = hg_invoice_payment:get_chargeback_state(ID, St), + ChargebackOpts = hg_invoice_payment:get_chargeback_opts(St), + {Changes0, Action1} = hg_invoice_payment_chargeback:process_timeout(Type, ChargebackState, Action0, ChargebackOpts), + Changes1 = [?chargeback_ev(ID, C) || C <- Changes0], + case Type of + %% NOTE In case if payment is already charged back and we want + %% to reopen and change it, this will ensure machine to + %% continue processing activities following cashflow update + %% event. + updating_cash_flow -> + {next, {Changes1, Action1}}; + _ -> + {done, {Changes1, Action1}} + end. + +-spec maybe_set_charged_back_status( + dmsl_domain_thrift:'InvoicePaymentChargebackStatus'(), + dmsl_domain_thrift:'Cash'(), + st() +) -> [change()]. +maybe_set_charged_back_status(?chargeback_status_accepted(), ChargebackBody, St) -> + InterimPaymentAmount = hg_invoice_payment:get_remaining_payment_balance(St), + case hg_cash:sub(InterimPaymentAmount, ChargebackBody) of + ?cash(0, _) -> + [?payment_status_changed(?charged_back())]; + ?cash(Amount, _) when Amount > 0 -> + [] + end; +maybe_set_charged_back_status( + ?chargeback_status_cancelled(), + _ChargebackBody, + #st{ + payment = #domain_InvoicePayment{status = ?charged_back()}, + status_log = [_ActualStatus, PrevStatus | _] + } +) -> + [?payment_status_changed(PrevStatus)]; +maybe_set_charged_back_status(_ChargebackStatus, _ChargebackBody, _St) -> + []. + +-spec process_accounter_update(action(), st()) -> machine_result(). +process_accounter_update(Action, #st{partial_cash_flow = FinalCashflow, capture_data = CaptureData} = St) -> + #payproc_InvoicePaymentCaptureData{ + reason = Reason, + cash = Cost, + cart = Cart, + allocation = Allocation + } = CaptureData, + _Clock = hg_accounting:plan( + hg_invoice_payment:construct_payment_plan_id(St), + [ + {2, hg_cashflow:revert(hg_invoice_payment:get_cashflow(St))}, + {3, FinalCashflow} + ] + ), + Events = hg_invoice_payment_session:start_session(?captured(Reason, Cost, Cart, Allocation)), + {next, {Events, hg_machine_action:set_timeout(0, Action)}}. + +-spec finalize_payment(action(), st()) -> machine_result(). +finalize_payment(Action, St) -> + Payment = hg_invoice_payment:get_payment(St), + Target = + case hg_invoice_payment:get_payment_flow(Payment) of + ?invoice_payment_flow_instant() -> + ?captured(<<"Timeout">>, hg_invoice_payment:get_payment_cost(Payment)); + ?invoice_payment_flow_hold(OnHoldExpiration, _) -> + case OnHoldExpiration of + cancel -> + ?cancelled(); + capture -> + ?captured( + <<"Timeout">>, + hg_invoice_payment:get_payment_cost(Payment) + ) + end + end, + StartEvents = + case Target of + ?captured(Reason, Cost) -> + hg_invoice_payment_session:start_capture( + Reason, Cost, undefined, hg_invoice_payment:get_allocation(St) + ); + _ -> + hg_invoice_payment_session:start_session(Target) + end, + {done, {StartEvents, hg_machine_action:set_timeout(0, Action)}}. + +-spec process_result(action(), st()) -> machine_result(). +process_result(Action, St) -> + process_result(hg_invoice_payment:get_activity(St), Action, St). + +-spec process_result(activity(), action(), st()) -> machine_result(). +process_result({payment, processing_accounter}, Action, #st{new_cash = Cost} = St0) when + Cost =/= undefined +-> + %% Rebuild cashflow for new cost + Payment0 = hg_invoice_payment:get_payment(St0), + Payment1 = Payment0#domain_InvoicePayment{cost = Cost}, + St1 = St0#st{payment = Payment1}, + Opts = hg_invoice_payment:get_opts(St1), + Revision = hg_invoice_payment:get_payment_revision(St1), + Timestamp = hg_invoice_payment:get_payment_created_at(Payment0), + VS = hg_invoice_payment_cashflow:collect_validation_varset(St1, Opts), + MerchantTerms = get_merchant_payments_terms(Opts, Revision, Timestamp, VS), + Route = hg_invoice_payment:get_route(St1), + ProviderTerms = hg_routing:get_payment_terms(Route, VS, Revision), + Context = #{ + provision_terms => ProviderTerms, + merchant_terms => MerchantTerms, + route => Route, + payment => Payment1, + timestamp => Timestamp, + varset => VS, + revision => Revision + }, + FinalCashflow = hg_invoice_payment_cashflow:calculate_cashflow(Context, Opts), + %% Hold limits (only for chosen route) for new cashflow + {_PaymentInstitution, RouteVS, _Revision} = hg_invoice_payment_routing:route_args(St1), + Routes = [hg_route:from_payment_route(Route)], + _ = hg_invoice_payment:hold_limit_routes(Routes, RouteVS, hg_invoice_payment:get_iter(St1), St1), + %% Hold cashflow + St2 = St1#st{new_cash_flow = FinalCashflow}, + _Clock = hg_accounting:plan( + hg_invoice_payment:construct_payment_plan_id(St2), + hg_invoice_payment:get_cashflow_plan(St2) + ), + {next, {[?cash_flow_changed(FinalCashflow)], hg_machine_action:set_timeout(0, Action)}}; +process_result({payment, processing_accounter}, Action, St) -> + Target = hg_invoice_payment:get_target(St), + NewAction = get_action(Target, Action, St), + {done, {[?payment_status_changed(Target)], NewAction}}; +process_result({payment, routing_failure}, Action, #st{failure = Failure} = St) -> + NewAction = hg_machine_action:set_timeout(0, Action), + Routes = hg_invoice_payment:get_candidate_routes(St), + _ = hg_invoice_payment:rollback_payment_limits(Routes, hg_invoice_payment:get_iter(St), St, [ + ignore_business_error, + ignore_not_found + ]), + {done, {[?payment_status_changed(?failed(Failure))], NewAction}}; +process_result({payment, processing_failure}, Action, #st{failure = Failure} = St) -> + NewAction = hg_machine_action:set_timeout(0, Action), + %% We need to rollback only current route. + %% Previously used routes are supposed to have their limits already rolled back. + Route = hg_invoice_payment:get_route(St), + Routes = [Route], + _ = hg_invoice_payment:rollback_payment_limits(Routes, hg_invoice_payment:get_iter(St), St, []), + _ = hg_invoice_payment:rollback_payment_cashflow(St), + Revision = hg_invoice_payment:get_payment_revision(St), + Behaviour = hg_invoice_payment:get_route_cascade_behaviour(Route, Revision), + case hg_invoice_payment:is_route_cascade_available(Behaviour, Route, ?failed(Failure), St) of + true -> + hg_invoice_payment_routing:process_routing(NewAction, St); + false -> + {done, {[?payment_status_changed(?failed(Failure))], NewAction}} + end; +process_result({payment, finalizing_accounter}, Action, St) -> + Target = hg_invoice_payment:get_target(St), + _PostingPlanLog = + case Target of + ?captured() -> + hg_invoice_payment:commit_payment_limits(St), + hg_invoice_payment:commit_payment_cashflow(St); + ?cancelled() -> + Route = hg_invoice_payment:get_route(St), + _ = hg_invoice_payment:rollback_payment_limits([Route], hg_invoice_payment:get_iter(St), St, []), + hg_invoice_payment:rollback_payment_cashflow(St) + end, + check_recurrent_token(St), + NewAction = get_action(Target, Action, St), + {done, {[?payment_status_changed(Target)], NewAction}}. + +-spec process_failure(activity(), [change()], action(), failure(), st()) -> machine_result(). +process_failure(Activity, Events, Action, Failure, St) -> + process_failure(Activity, Events, Action, Failure, St, undefined). + +-spec process_failure(activity(), [change()], action(), failure(), st(), undefined) -> machine_result(). +process_failure({payment, processing_failure}, Events, Action, _Failure, #st{failure = Failure}, _RefundSt) when + Failure =/= undefined +-> + %% In case of cascade attempt we may catch and handle routing failure during 'processing_failure' activity + {done, {Events ++ [?payment_status_changed(?failed(Failure))], Action}}; +process_failure({payment, Step}, Events, Action, Failure, _St, _RefundSt) when + Step =:= risk_scoring orelse + Step =:= routing +-> + {done, {Events ++ [?payment_status_changed(?failed(Failure))], Action}}; +process_failure({payment, Step} = Activity, Events, Action, Failure, St, _RefundSt) when + Step =:= processing_session orelse + Step =:= finalizing_session +-> + Target = hg_invoice_payment:get_target(St), + case hg_invoice_payment_session:check_retry_possibility(Target, Failure, St) of + {retry, Timeout} -> + _ = logger:notice("Retry session after transient failure, wait ~p", [Timeout]), + {SessionEvents, SessionAction} = hg_invoice_payment_session:retry_session(Action, Target, Timeout), + {next, {Events ++ SessionEvents, SessionAction}}; + fatal -> + TargetType = hg_invoice_payment:get_target_type(Target), + OperationStatus = choose_fd_operation_status_for_failure(Failure), + _ = hg_invoice_payment:maybe_notify_fault_detector(Activity, TargetType, OperationStatus, St), + process_fatal_payment_failure(Target, Events, Action, Failure, St) + end. + +-spec process_fatal_payment_failure(target(), [change()], action(), failure(), st()) -> machine_result(). +process_fatal_payment_failure(?cancelled(), _Events, _Action, Failure, _St) -> + error({invalid_cancel_failure, Failure}); +process_fatal_payment_failure(?captured(), _Events, _Action, Failure, _St) -> + error({invalid_capture_failure, Failure}); +process_fatal_payment_failure(?processed(), Events, Action, Failure, _St) -> + RollbackStarted = [?payment_rollback_started(Failure)], + {next, {Events ++ RollbackStarted, hg_machine_action:set_timeout(0, Action)}}. + +-spec repair_process_timeout(activity(), action(), st()) -> machine_result(). +repair_process_timeout(Activity, Action, #st{repair_scenario = Scenario} = St) -> + case hg_invoice_repair:check_for_action(fail_pre_processing, Scenario) of + {result, Result} when + Activity =:= {payment, routing} orelse + Activity =:= {payment, cash_flow_building} + -> + hg_invoice_payment:rollback_broken_payment_limits(St), + Result; + {result, Result} -> + Result; + call -> + process_timeout(Activity, Action, St) + end. + +-spec get_action(target(), action(), st()) -> action(). +get_action(?processed(), Action, St) -> + Payment = hg_invoice_payment:get_payment(St), + case hg_invoice_payment:get_payment_flow(Payment) of + ?invoice_payment_flow_instant() -> + hg_machine_action:set_timeout(0, Action); + ?invoice_payment_flow_hold(_, HeldUntil) -> + hg_machine_action:set_deadline(HeldUntil, Action) + end; +get_action(_Target, Action, _St) -> + Action. + +-spec process_refund(refund_id(), st()) -> machine_result(). +process_refund(ID, #st{opts = Options0, payment = Payment, repair_scenario = Scenario} = St) -> + RepairScenario = + case hg_invoice_repair:check_for_action(repair_session, Scenario) of + call -> undefined; + RepairAction -> RepairAction + end, + PaymentInfo = hg_invoice_payment:construct_payment_info(St, hg_invoice_payment:get_opts(St)), + Options1 = Options0#{ + payment => Payment, + payment_info => PaymentInfo, + repair_scenario => RepairScenario + }, + Refund = hg_invoice_payment:try_get_refund_state(ID, St), + {Step, {Events0, Action}} = hg_invoice_payment_refund:process(Options1, Refund), + Events1 = hg_invoice_payment_refund:wrap_events(Events0, Refund), + Events2 = + case hg_invoice_payment_refund:is_status_changed(?refund_succeeded(), Events1) of + true -> + process_refund_result(Events1, Refund, St); + false -> + Events1 + end, + {Step, {Events2, Action}}. + +-spec process_refund_result([change()], hg_invoice_payment_refund:t(), st()) -> [change()]. +process_refund_result(Changes, Refund0, St) -> + Events = [Event || ?refund_ev(_, Event) <- Changes], + Refund1 = hg_invoice_payment_refund:update_state_with(Events, Refund0), + PaymentEvents = + case + hg_cash:sub( + hg_invoice_payment:get_remaining_payment_balance(St), + hg_invoice_payment_refund:cash(Refund1) + ) + of + ?cash(0, _) -> + [ + ?payment_status_changed(?refunded()) + ]; + ?cash(Amount, _) when Amount > 0 -> + [] + end, + Changes ++ PaymentEvents. + +-spec check_recurrent_token(st()) -> ok. +check_recurrent_token(#st{ + payment = #domain_InvoicePayment{make_recurrent = true, skip_recurrent = true}, + recurrent_token = undefined +}) -> + ok; +check_recurrent_token(#st{ + payment = #domain_InvoicePayment{id = ID, make_recurrent = true, skip_recurrent = true}, + recurrent_token = _Token +}) -> + _ = logger:warning("Got recurrent token in non recurrent payment. Payment id:~p", [ID]); +check_recurrent_token(#st{ + payment = #domain_InvoicePayment{id = ID, make_recurrent = true}, + recurrent_token = undefined +}) -> + _ = logger:warning("Fail to get recurrent token in recurrent payment. Payment id:~p", [ID]); +check_recurrent_token(#st{ + payment = #domain_InvoicePayment{id = ID, make_recurrent = MakeRecurrent}, + recurrent_token = Token +}) when + (MakeRecurrent =:= false orelse MakeRecurrent =:= undefined) andalso + Token =/= undefined +-> + _ = logger:warning("Got recurrent token in non recurrent payment. Payment id:~p", [ID]); +check_recurrent_token(_) -> + ok. + +-spec choose_fd_operation_status_for_failure(failure()) -> atom(). +choose_fd_operation_status_for_failure({failure, Failure}) -> + payproc_errors:match('PaymentFailure', Failure, fun do_choose_fd_operation_status_for_failure/1); +choose_fd_operation_status_for_failure(_Failure) -> + finish. + +%% Internal helper - not exported +get_merchant_payments_terms(Opts, Revision, _Timestamp, VS) -> + Shop = hg_invoice_payment:get_shop(Opts, Revision), + TermSet = hg_invoice_utils:compute_shop_terms(Revision, Shop, VS), + TermSet#domain_TermSet.payments. + +-spec do_choose_fd_operation_status_for_failure(tuple()) -> atom(). +do_choose_fd_operation_status_for_failure({authorization_failed, {FailType, _}}) -> + DefaultBenignFailures = [ + insufficient_funds, + rejected_by_issuer, + processing_deadline_reached + ], + FDConfig = genlib_app:env(hellgate, fault_detector, #{}), + Config = genlib_map:get(conversion, FDConfig, #{}), + BenignFailures = genlib_map:get(benign_failures, Config, DefaultBenignFailures), + case lists:member(FailType, BenignFailures) of + false -> error; + true -> finish + end; +do_choose_fd_operation_status_for_failure(_Failure) -> + finish. diff --git a/apps/hellgate/src/hg_invoice_payment_routing.erl b/apps/hellgate/src/hg_invoice_payment_routing.erl new file mode 100644 index 00000000..46d48699 --- /dev/null +++ b/apps/hellgate/src/hg_invoice_payment_routing.erl @@ -0,0 +1,600 @@ +%%% Payment routing module +%%% +%%% Extracted from hg_invoice_payment.erl for better code organization. +%%% Contains all routing-related functions. + +-module(hg_invoice_payment_routing). + +-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("limiter_proto/include/limproto_limiter_thrift.hrl"). + +-include_lib("hellgate/include/domain.hrl"). + +-include("domain.hrl"). +-include("payment_events.hrl"). +-include("hg_invoice_payment.hrl"). + +-define(LOG_MD(Level, Format, Args), logger:log(Level, Format, Args, logger:get_process_metadata())). + +%% Types +-type st() :: hg_invoice_payment:st(). +-type payment() :: hg_invoice_payment:payment(). +-type action() :: hg_invoice_payment:action(). +-type machine_result() :: hg_invoice_payment:machine_result(). +-type risk_score() :: hg_inspector:risk_score(). +-type payment_institution() :: dmsl_domain_thrift:'PaymentInstitution'(). +-type varset() :: hg_varset:varset(). +-type revision() :: hg_domain:revision(). +-type route() :: hg_route:payment_route(). +-type routing_ctx() :: hg_routing_ctx:t(). + +%% Routing functions +-export([gather_routes/4]). +-export([choose_routing_predestination/1]). +-export([log_route_choice_meta/2]). +-export([log_rejected_routes/3]). +-export([filter_attempted_routes/2]). +-export([handle_choose_route_error/4]). +-export([process_routing/2]). +-export([process_risk_score/2]). +-export([check_risk_score/1]). +-export([get_routing_attempt_limit/1]). +-export([route_args/1]). +-export([build_routing_context/4]). + +%% Internal helper functions (exported for use within module) +-export([maybe_log_misconfigurations/1]). +-export([log_cascade_attempt_context/2]). + +%%% Routing functions + +-spec gather_routes(payment_institution(), varset(), revision(), st()) -> routing_ctx(). +gather_routes(PaymentInstitution, VS, Revision, St) -> + Payment = hg_invoice_payment:get_payment(St), + Predestination = choose_routing_predestination(Payment), + #domain_Cash{currency = Currency} = get_payment_cost_internal(Payment), + Payer = Payment#domain_InvoicePayment.payer, + #domain_ContactInfo{email = Email} = get_contact_info(Payer), + CardToken = get_payer_card_token(Payer), + PaymentTool = hg_invoice_payment:get_payer_payment_tool(Payer), + ClientIP = get_payer_client_ip(Payer), + hg_routing:gather_routes(Predestination, PaymentInstitution, VS, Revision, #{ + currency => Currency, + payment_tool => PaymentTool, + client_ip => ClientIP, + email => Email, + card_token => CardToken + }). + +-spec check_risk_score(risk_score()) -> ok | {error, risk_score_is_too_high}. +check_risk_score(fatal) -> + {error, risk_score_is_too_high}; +check_risk_score(_RiskScore) -> + ok. + +-spec choose_routing_predestination(payment()) -> hg_routing:route_predestination(). +choose_routing_predestination(#domain_InvoicePayment{make_recurrent = true}) -> + recurrent_payment; +choose_routing_predestination(#domain_InvoicePayment{payer = ?payment_resource_payer()}) -> + payment. + +-spec log_route_choice_meta(map(), revision()) -> ok. +log_route_choice_meta(#{choice_meta := undefined}, _Revision) -> + ok; +log_route_choice_meta(#{choice_meta := ChoiceMeta}, Revision) -> + Metadata = hg_routing:get_logger_metadata(ChoiceMeta, Revision), + logger:log(notice, "Routing decision made", #{routing => Metadata}). + +-spec maybe_log_misconfigurations(term()) -> ok. +maybe_log_misconfigurations({misconfiguration, _} = Error) -> + {Format, Details} = hg_routing:prepare_log_message(Error), + ?LOG_MD(warning, Format, Details); +maybe_log_misconfigurations(_Error) -> + ok. + +-spec log_rejected_routes(atom(), [hg_route:rejected_route()], varset()) -> ok. +log_rejected_routes(_, [], _VS) -> + ok; +log_rejected_routes(all, Routes, VS) -> + ?LOG_MD(warning, "No route found for varset: ~p", [VS]), + ?LOG_MD(warning, "No route found, rejected routes: ~p", [Routes]); +log_rejected_routes(limit_misconfiguration, Routes, _VS) -> + ?LOG_MD(warning, "Limiter hold error caused route candidates to be rejected: ~p", [Routes]); +log_rejected_routes(limit_overflow, Routes, _VS) -> + ?LOG_MD(notice, "Limit overflow caused route candidates to be rejected: ~p", [Routes]); +log_rejected_routes(in_blacklist, Routes, _VS) -> + ?LOG_MD(notice, "Route candidates are blacklisted: ~p", [Routes]); +log_rejected_routes(adapter_unavailable, Routes, _VS) -> + ?LOG_MD(notice, "Adapter unavailability caused route candidates to be rejected: ~p", [Routes]); +log_rejected_routes(provider_conversion_is_too_low, Routes, _VS) -> + ?LOG_MD(notice, "Lacking conversion of provider caused route candidates to be rejected: ~p", [Routes]); +log_rejected_routes(forbidden, Routes, VS) -> + ?LOG_MD(notice, "Rejected routes found for varset: ~p", [VS]), + ?LOG_MD(notice, "Rejected routes found, rejected routes: ~p", [Routes]); +log_rejected_routes(_, _Routes, _VS) -> + ok. + +-spec process_risk_score(action(), st()) -> machine_result(). +process_risk_score(Action, St) -> + Opts = hg_invoice_payment:get_opts(St), + Revision = hg_invoice_payment:get_payment_revision(St), + Payment = hg_invoice_payment:get_payment(St), + VS1 = get_varset_internal(St, #{}), + PaymentInstitutionRef = get_payment_institution_ref(Opts, Revision), + PaymentInstitution = hg_payment_institution:compute_payment_institution(PaymentInstitutionRef, VS1, Revision), + RiskScore = repair_inspect(Payment, PaymentInstitution, Opts, St), + Events = [?risk_score_changed(RiskScore)], + case check_risk_score(RiskScore) of + ok -> + {next, {Events, hg_machine_action:set_timeout(0, Action)}}; + {error, risk_score_is_too_high = Reason} -> + logger:notice("No route found, reason = ~p, varset: ~p", [Reason, VS1]), + handle_choose_route_error(Reason, Events, St, Action) + end. + +-spec process_routing(action(), st()) -> machine_result(). +process_routing(Action, St) -> + {PaymentInstitution, VS, Revision} = route_args(St), + Ctx0 = hg_routing_ctx:with_guard(build_routing_context(PaymentInstitution, VS, Revision, St)), + %% NOTE We need to handle routing errors differently if route not found + %% before the pipeline. + case hg_routing_ctx:error(Ctx0) of + undefined -> + Ctx1 = run_routing_decision_pipeline(Ctx0, VS, St), + _ = [ + log_rejected_routes(Group, RejectedRoutes, VS) + || {Group, RejectedRoutes} <- hg_routing_ctx:rejections(Ctx1) + ], + Events = produce_routing_events(Ctx1, Revision, St), + {next, {Events, hg_machine_action:set_timeout(0, Action)}}; + Error -> + ok = maybe_log_misconfigurations(Error), + ok = log_rejected_routes(all, hg_routing_ctx:rejected_routes(Ctx0), VS), + handle_choose_route_error(Error, [], St, Action) + end. + +-spec route_args(st()) -> {payment_institution(), varset(), revision()}. +route_args(St) -> + Opts = hg_invoice_payment:get_opts(St), + Revision = hg_invoice_payment:get_payment_revision(St), + Payment = hg_invoice_payment:get_payment(St), + #{payment_tool := PaymentTool} = + VS1 = get_varset_internal(St, #{risk_score => hg_invoice_payment:get_risk_score(St)}), + CreatedAt = hg_invoice_payment:get_payment_created_at(Payment), + PaymentInstitutionRef = get_payment_institution_ref(Opts, Revision), + MerchantTerms = get_merchant_payments_terms(Opts, Revision, CreatedAt, VS1), + VS2 = collect_refund_varset(MerchantTerms#domain_PaymentsServiceTerms.refunds, PaymentTool, VS1), + VS3 = collect_chargeback_varset(MerchantTerms#domain_PaymentsServiceTerms.chargebacks, VS2), + PaymentInstitution = hg_payment_institution:compute_payment_institution(PaymentInstitutionRef, VS1, Revision), + {PaymentInstitution, VS3, Revision}. + +-spec build_routing_context(payment_institution(), varset(), revision(), st()) -> routing_ctx(). +build_routing_context(PaymentInstitution, VS, Revision, St) -> + Payer = hg_invoice_payment:get_payment_payer(St), + case hg_invoice_payment_construction:get_predefined_route(Payer) of + {ok, PaymentRoute} -> + hg_routing_ctx:new([hg_route:from_payment_route(PaymentRoute)]); + undefined -> + gather_routes(PaymentInstitution, VS, Revision, St) + end. + +-spec filter_attempted_routes(routing_ctx(), st()) -> routing_ctx(). +filter_attempted_routes(Ctx, #st{routes = AttemptedRoutes}) -> + lists:foldr( + fun(R, C) -> + R1 = hg_route:from_payment_route(R), + R2 = hg_route:to_rejected_route(R1, {'AlreadyAttempted', undefined}), + hg_routing_ctx:reject(already_attempted, R2, C) + end, + Ctx, + AttemptedRoutes + ). + +-spec handle_choose_route_error(term(), hg_invoice_payment:events(), st(), action()) -> machine_result(). +handle_choose_route_error(Error, Events, St, Action) -> + Failure = hg_invoice_payment_construction:construct_routing_failure(Error), + hg_invoice_payment:process_failure(hg_invoice_payment:get_activity(St), Events, Action, Failure, St). + +-spec get_routing_attempt_limit(st()) -> pos_integer(). +get_routing_attempt_limit( + #st{ + payment = #domain_InvoicePayment{ + party_ref = PartyConfigRef, + shop_ref = ShopConfigRef, + domain_revision = Revision + } + } = St +) -> + {PartyConfigRef, _Party} = hg_party:checkout(PartyConfigRef, Revision), + ShopObj = {_, Shop} = hg_party:get_shop(ShopConfigRef, PartyConfigRef, Revision), + VS = collect_validation_varset(PartyConfigRef, ShopObj, hg_invoice_payment:get_payment(St), #{}), + Terms = hg_invoice_utils:compute_shop_terms(Revision, Shop, VS), + #domain_TermSet{payments = PaymentTerms} = Terms, + log_cascade_attempt_context(PaymentTerms, St), + get_routing_attempt_limit_value(PaymentTerms#domain_PaymentsServiceTerms.attempt_limit). + +%%% Helper functions + +-spec run_routing_decision_pipeline(routing_ctx(), varset(), st()) -> routing_ctx(). +run_routing_decision_pipeline(Ctx0, VS, St) -> + %% NOTE Since this is routing step then current attempt is not yet + %% accounted for in `St`. + NewIter = hg_invoice_payment:get_iter(St) + 1, + hg_routing_ctx:pipeline( + Ctx0, + [ + fun(Ctx) -> filter_attempted_routes(Ctx, St) end, + fun(Ctx) -> filter_routes_with_limit_hold(Ctx, VS, NewIter, St) end, + fun(Ctx) -> filter_routes_by_limit_overflow(Ctx, VS, NewIter, St) end, + fun(Ctx) -> hg_routing:filter_by_blacklist(Ctx, build_blacklist_context(St)) end, + fun hg_routing:filter_by_critical_provider_status/1, + fun hg_routing:choose_route_with_ctx/1 + ] + ). + +-spec produce_routing_events(routing_ctx(), revision(), st()) -> hg_invoice_payment:events(). +produce_routing_events(#{error := Error} = Ctx, Revision, St) when Error =/= undefined -> + %% TODO Pass failure subcode from error. Say, if last candidates were + %% rejected because of provider gone critical, then use subcode to highlight + %% the offender. Like 'provider_dead' or 'conversion_lacking'. + Failure = genlib:define(St#st.failure, hg_invoice_payment_construction:construct_routing_failure(Error)), + %% NOTE Not all initial candidates have their according limits held. And so + %% we must account only for those that can be rolled back. + RollbackableCandidates = hg_routing_ctx:accounted_candidates(Ctx), + Route = hg_route:to_payment_route(hd(RollbackableCandidates)), + Candidates = + ordsets:from_list([hg_route:to_payment_route(R) || R <- RollbackableCandidates]), + RouteScores = hg_routing_ctx:route_scores(Ctx), + RouteLimits = hg_routing_ctx:route_limits(Ctx), + Decision = build_route_decision_context(Route, Revision), + %% For protocol compatability we set choosen route in route_changed event. + %% It doesn't influence cash_flow building because this step will be + %% skipped. And all limit's 'hold' operations will be rolled back. + %% For same purpose in cascade routing we use route from unfiltered list of + %% originally resolved candidates. + [?route_changed(Route, Candidates, RouteScores, RouteLimits, Decision), ?payment_rollback_started(Failure)]; +produce_routing_events(Ctx, Revision, _St) -> + ok = log_route_choice_meta(Ctx, Revision), + Route = hg_route:to_payment_route(hg_routing_ctx:choosen_route(Ctx)), + Candidates = + ordsets:from_list([hg_route:to_payment_route(R) || R <- hg_routing_ctx:considered_candidates(Ctx)]), + RouteScores = hg_routing_ctx:route_scores(Ctx), + RouteLimits = hg_routing_ctx:route_limits(Ctx), + Decision = build_route_decision_context(Route, Revision), + [?route_changed(Route, Candidates, RouteScores, RouteLimits, Decision)]. + +-spec build_route_decision_context(route(), revision()) -> dmsl_payproc_thrift:'RouteDecisionContext'(). +build_route_decision_context(Route, Revision) -> + ProvisionTerms = hg_routing:get_provision_terms(Route, #{}, Revision), + SkipRecurrent = + case ProvisionTerms#domain_ProvisionTermSet.extension of + #domain_ExtendedProvisionTerms{skip_recurrent = true} -> + true; + _ -> + undefined + end, + #payproc_RouteDecisionContext{skip_recurrent = SkipRecurrent}. + +-spec build_blacklist_context(st()) -> map(). +build_blacklist_context(St) -> + Revision = hg_invoice_payment:get_payment_revision(St), + #domain_InvoicePayment{payer = Payer} = hg_invoice_payment:get_payment(St), + Token = + case hg_invoice_payment:get_payer_payment_tool(Payer) of + {bank_card, #domain_BankCard{token = CardToken}} -> + CardToken; + _ -> + undefined + end, + Opts = hg_invoice_payment:get_opts(St), + VS1 = get_varset_internal(St, #{}), + PaymentInstitutionRef = get_payment_institution_ref(Opts, Revision), + PaymentInstitution = hg_payment_institution:compute_payment_institution(PaymentInstitutionRef, VS1, Revision), + InspectorRef = get_selector_value(inspector, PaymentInstitution#domain_PaymentInstitution.inspector), + Inspector = hg_domain:get(Revision, {inspector, InspectorRef}), + #{ + revision => Revision, + token => Token, + inspector => Inspector + }. + +-spec log_cascade_attempt_context(dmsl_domain_thrift:'PaymentsServiceTerms'(), st()) -> ok. +log_cascade_attempt_context( + #domain_PaymentsServiceTerms{attempt_limit = AttemptLimit}, + #st{routes = AttemptedRoutes} +) -> + ?LOG_MD(notice, "Cascade context: merchant payment terms' attempt limit '~p', attempted routes: ~p", [ + AttemptLimit, AttemptedRoutes + ]). + +-spec get_routing_attempt_limit_value(undefined | dmsl_domain_thrift:'AttemptLimitSelector'()) -> pos_integer(). +get_routing_attempt_limit_value(undefined) -> + 1; +get_routing_attempt_limit_value({decisions, _}) -> + get_routing_attempt_limit_value(undefined); +get_routing_attempt_limit_value({value, #domain_AttemptLimit{attempts = Value}}) when is_integer(Value) -> + Value. + +%%% Internal helper functions + +-spec filter_routes_with_limit_hold(routing_ctx(), varset(), pos_integer(), st()) -> routing_ctx(). +filter_routes_with_limit_hold(Ctx0, VS, Iter, St) -> + {_Routes, RejectedRoutes} = hold_limit_routes(hg_routing_ctx:candidates(Ctx0), VS, Iter, St), + Ctx1 = reject_routes(limit_misconfiguration, RejectedRoutes, Ctx0), + hg_routing_ctx:stash_current_candidates(Ctx1). + +-spec filter_routes_by_limit_overflow(routing_ctx(), varset(), pos_integer(), st()) -> routing_ctx(). +filter_routes_by_limit_overflow(Ctx0, VS, Iter, St) -> + {_Routes, RejectedRoutes, Limits} = get_limit_overflow_routes(hg_routing_ctx:candidates(Ctx0), VS, Iter, St), + Ctx1 = hg_routing_ctx:stash_route_limits(Limits, Ctx0), + reject_routes(limit_overflow, RejectedRoutes, Ctx1). + +-spec reject_routes(atom(), [hg_route:rejected_route()], routing_ctx()) -> routing_ctx(). +reject_routes(GroupReason, RejectedRoutes, Ctx) -> + lists:foldr( + fun(R, C) -> hg_routing_ctx:reject(GroupReason, R, C) end, + Ctx, + RejectedRoutes + ). + +-spec hold_limit_routes([hg_route:t()], varset(), pos_integer(), st()) -> + {[hg_route:t()], [hg_route:rejected_route()]}. +hold_limit_routes(Routes0, VS, Iter, St) -> + Opts = hg_invoice_payment:get_opts(St), + Revision = hg_invoice_payment:get_payment_revision(St), + Payment = hg_invoice_payment:get_payment(St), + Invoice = hg_invoice_payment:get_invoice(Opts), + {Routes1, Rejected} = lists:foldl( + fun(Route, {LimitHeldRoutes, RejectedRoutes} = Acc) -> + PaymentRoute = hg_route:to_payment_route(Route), + ProviderTerms = hg_routing:get_payment_terms(PaymentRoute, VS, Revision), + TurnoverLimits = get_turnover_limits(ProviderTerms, strict), + try + ok = hg_limiter:hold_payment_limits(TurnoverLimits, Invoice, Payment, PaymentRoute, Iter), + {[Route | LimitHeldRoutes], RejectedRoutes} + catch + error:(#limiter_LimitNotFound{} = LimiterError) -> + do_reject_route(LimiterError, Route, TurnoverLimits, Acc); + error:(#limiter_InvalidOperationCurrency{} = LimiterError) -> + do_reject_route(LimiterError, Route, TurnoverLimits, Acc); + error:(#limiter_OperationContextNotSupported{} = LimiterError) -> + do_reject_route(LimiterError, Route, TurnoverLimits, Acc); + error:(#limiter_PaymentToolNotSupported{} = LimiterError) -> + do_reject_route(LimiterError, Route, TurnoverLimits, Acc) + end + end, + {[], []}, + Routes0 + ), + {lists:reverse(Routes1), Rejected}. + +-spec do_reject_route( + term(), hg_route:t(), [dmsl_domain_thrift:'TurnoverLimit'()], {[hg_route:t()], [hg_route:rejected_route()]} +) -> + {[hg_route:t()], [hg_route:rejected_route()]}. +do_reject_route(LimiterError, Route, TurnoverLimits, {LimitHeldRoutes, RejectedRoutes}) -> + LimitsIDs = [T#domain_TurnoverLimit.ref#domain_LimitConfigRef.id || T <- TurnoverLimits], + RejectedRoute = hg_route:to_rejected_route(Route, {'LimitHoldError', LimitsIDs, LimiterError}), + {LimitHeldRoutes, [RejectedRoute | RejectedRoutes]}. + +-spec get_limit_overflow_routes([hg_route:t()], varset(), pos_integer(), st()) -> + {[hg_route:t()], [hg_route:rejected_route()], hg_routing:limits()}. +get_limit_overflow_routes(Routes, VS, Iter, St) -> + Opts = hg_invoice_payment:get_opts(St), + Revision = hg_invoice_payment:get_payment_revision(St), + Payment = hg_invoice_payment:get_payment(St), + Invoice = hg_invoice_payment:get_invoice(Opts), + lists:foldl( + fun(Route, {RoutesNoOverflowIn, RejectedIn, LimitsIn}) -> + PaymentRoute = hg_route:to_payment_route(Route), + ProviderTerms = hg_routing:get_payment_terms(PaymentRoute, VS, Revision), + TurnoverLimits = get_turnover_limits(ProviderTerms, strict), + case hg_limiter:check_limits(TurnoverLimits, Invoice, Payment, PaymentRoute, Iter) of + {ok, Limits} -> + {[Route | RoutesNoOverflowIn], RejectedIn, LimitsIn#{PaymentRoute => Limits}}; + {error, {limit_overflow, IDs, Limits}} -> + RejectedRoute = hg_route:to_rejected_route(Route, {'LimitOverflow', IDs}), + {RoutesNoOverflowIn, [RejectedRoute | RejectedIn], LimitsIn#{PaymentRoute => Limits}} + end + end, + {[], [], #{}}, + Routes + ). + +-spec get_turnover_limits(dmsl_domain_thrift:'PaymentsProvisionTerms'(), strict) -> + [dmsl_domain_thrift:'TurnoverLimit'()]. +get_turnover_limits(ProviderTerms, strict) -> + hg_limiter:get_turnover_limits(ProviderTerms, strict). + +-spec repair_inspect(payment(), payment_institution(), hg_invoice_payment:opts(), st()) -> risk_score(). +repair_inspect(Payment, PaymentInstitution, Opts, #st{repair_scenario = Scenario}) -> + case hg_invoice_repair:check_for_action(skip_inspector, Scenario) of + {result, Result} -> + Result; + call -> + inspect(Payment, PaymentInstitution, Opts) + end. + +-spec inspect(payment(), payment_institution(), hg_invoice_payment:opts()) -> risk_score(). +inspect(#domain_InvoicePayment{domain_revision = Revision} = Payment, PaymentInstitution, Opts) -> + InspectorRef = get_selector_value(inspector, PaymentInstitution#domain_PaymentInstitution.inspector), + Inspector = hg_domain:get(Revision, {inspector, InspectorRef}), + hg_inspector:inspect(get_shop(Opts, Revision), hg_invoice_payment:get_invoice(Opts), Payment, Inspector). + +%%% Helper functions that need to access hg_invoice_payment internals + +-spec get_contact_info(dmsl_domain_thrift:'Payer'()) -> dmsl_domain_thrift:'ContactInfo'(). +get_contact_info(?payment_resource_payer(_, ContactInfo)) -> + ContactInfo; +get_contact_info(?recurrent_payer(_, _, ContactInfo)) -> + ContactInfo. + +-spec get_payer_card_token(dmsl_domain_thrift:'Payer'()) -> undefined | dmsl_domain_thrift:'Token'(). +get_payer_card_token(?payment_resource_payer(PaymentResource, _ContactInfo)) -> + case get_resource_payment_tool(PaymentResource) of + {bank_card, #domain_BankCard{token = Token}} -> + Token; + _ -> + undefined + end; +get_payer_card_token(?recurrent_payer(_, _, _)) -> + undefined. + +-spec get_payer_client_ip(dmsl_domain_thrift:'Payer'()) -> undefined | dmsl_domain_thrift:'IPAddress'(). +get_payer_client_ip( + ?payment_resource_payer( + #domain_DisposablePaymentResource{ + client_info = #domain_ClientInfo{ + ip_address = IP + } + }, + _ContactInfo + ) +) -> + IP; +get_payer_client_ip(_OtherPayer) -> + undefined. + +-spec get_resource_payment_tool(dmsl_domain_thrift:'DisposablePaymentResource'()) -> dmsl_domain_thrift:'PaymentTool'(). +get_resource_payment_tool(#domain_DisposablePaymentResource{payment_tool = PaymentTool}) -> + PaymentTool. + +-spec get_payment_institution_ref(hg_invoice_payment:opts(), revision()) -> + dmsl_domain_thrift:'PaymentInstitutionRef'(). +get_payment_institution_ref(Opts, Revision) -> + Shop = get_shop(Opts, Revision), + Shop#domain_ShopConfig.payment_institution. + +-spec get_shop(hg_invoice_payment:opts(), revision()) -> dmsl_domain_thrift:'ShopConfig'(). +get_shop(Opts, Revision) -> + {_, Shop} = get_shop_obj(Opts, Revision), + Shop. + +-spec get_shop_obj(hg_invoice_payment:opts(), revision()) -> + {dmsl_domain_thrift:'ShopConfigRef'(), dmsl_domain_thrift:'ShopConfig'()}. +get_shop_obj(#{invoice := #domain_Invoice{shop_ref = ShopConfigRef}, party_config_ref := PartyConfigRef}, Revision) -> + hg_party:get_shop(ShopConfigRef, PartyConfigRef, Revision). + +-spec get_merchant_payments_terms(hg_invoice_payment:opts(), revision(), dmsl_base_thrift:'Timestamp'(), varset()) -> + dmsl_domain_thrift:'PaymentsServiceTerms'(). +get_merchant_payments_terms(Opts, Revision, _Timestamp, VS) -> + ShopObj = get_shop_obj(Opts, Revision), + Terms = hg_invoice_utils:compute_shop_terms(Revision, element(2, ShopObj), VS), + Terms#domain_TermSet.payments. + +-spec collect_refund_varset( + undefined + | dmsl_domain_thrift:'PaymentRefundsServiceTerms'() + | dmsl_domain_thrift:'PaymentsServiceTerms'() + | {value, dmsl_domain_thrift:'PaymentRefundsServiceTerms'()}, + dmsl_domain_thrift:'PaymentTool'(), + varset() +) -> varset(). +collect_refund_varset(Terms, PaymentTool, VS) -> + case normalize_refund_terms(Terms) of + #domain_PaymentRefundsServiceTerms{ + payment_methods = PaymentMethodSelector, + partial_refunds = PartialRefundsServiceTerms + } -> + RPMs = get_selector_value(payment_methods, PaymentMethodSelector), + case hg_payment_tool:has_any_payment_method(PaymentTool, RPMs) of + true -> + RVS = collect_partial_refund_varset(PartialRefundsServiceTerms), + VS#{refunds => RVS}; + false -> + VS + end; + undefined -> + VS + end. + +normalize_refund_terms(#domain_PaymentRefundsServiceTerms{} = Terms) -> + Terms; +normalize_refund_terms(#domain_PaymentsServiceTerms{refunds = Terms}) -> + normalize_refund_terms(Terms); +normalize_refund_terms({value, Terms}) -> + normalize_refund_terms(Terms); +normalize_refund_terms(undefined) -> + undefined; +normalize_refund_terms(Other) -> + error({misconfiguration, {'Unexpected refund terms', Other}}). + +-spec collect_partial_refund_varset(undefined | dmsl_domain_thrift:'PartialRefundsServiceTerms'()) -> map(). +collect_partial_refund_varset( + #domain_PartialRefundsServiceTerms{ + cash_limit = CashLimitSelector + } +) -> + #{ + partial => #{ + cash_limit => get_selector_value(cash_limit, CashLimitSelector) + } + }; +collect_partial_refund_varset(undefined) -> + #{}. + +-spec collect_chargeback_varset( + undefined | dmsl_domain_thrift:'PaymentChargebackServiceTerms'(), + varset() +) -> varset(). +collect_chargeback_varset( + #domain_PaymentChargebackServiceTerms{}, + VS +) -> + % nothing here yet + VS; +collect_chargeback_varset(undefined, VS) -> + VS. + +-spec collect_validation_varset( + dmsl_domain_thrift:'PartyConfigRef'(), + {dmsl_domain_thrift:'ShopConfigRef'(), dmsl_domain_thrift:'ShopConfig'()}, + payment(), + varset() +) -> varset(). +collect_validation_varset(PartyConfigRef, {#domain_ShopConfigRef{id = ShopConfigID}, Shop}, Payment, VS) -> + #domain_InvoicePayment{cost = Cost, payer = Payer} = Payment, + #domain_Cash{currency = CurrencyRef} = Cost, + #domain_ShopConfig{category = Category} = Shop, + PaymentTool = hg_invoice_payment:get_payer_payment_tool(Payer), + VS#{ + party_config_ref => PartyConfigRef, + party_id => PartyConfigRef#domain_PartyConfigRef.id, + shop_id => ShopConfigID, + category => Category, + currency => CurrencyRef, + cost => Cost, + payment_tool => PaymentTool + }. + +-spec get_selector_value(atom(), term()) -> term(). +get_selector_value(Name, Selector) -> + case Selector of + {value, V} -> + V; + Ambiguous -> + error({misconfiguration, {'Could not reduce selector to a value', {Name, Ambiguous}}}) + end. + +-spec get_payment_cost_internal(payment()) -> dmsl_domain_thrift:'Cash'(). +get_payment_cost_internal(#domain_InvoicePayment{changed_cost = Cost}) when Cost =/= undefined -> + Cost; +get_payment_cost_internal(#domain_InvoicePayment{cost = Cost}) -> + Cost. + +-spec get_varset_internal(st(), varset()) -> varset(). +get_varset_internal(St, InitialValue) -> + Opts = hg_invoice_payment:get_opts(St), + Payment = hg_invoice_payment:get_payment(St), + Revision = hg_invoice_payment:get_payment_revision(St), + VS0 = hg_invoice_payment_construction:reconstruct_payment_flow(Payment, InitialValue), + VS1 = collect_validation_varset(get_party_config_ref(Opts), get_shop_obj(Opts, Revision), Payment, VS0), + VS1. + +-spec get_party_config_ref(hg_invoice_payment:opts()) -> dmsl_domain_thrift:'PartyConfigRef'(). +get_party_config_ref(#{party_config_ref := PartyConfigRef}) -> + PartyConfigRef. diff --git a/apps/hellgate/src/hg_invoice_payment_session.erl b/apps/hellgate/src/hg_invoice_payment_session.erl new file mode 100644 index 00000000..91ab926b --- /dev/null +++ b/apps/hellgate/src/hg_invoice_payment_session.erl @@ -0,0 +1,273 @@ +%%% Payment session management module +%%% +%%% Extracted from hg_invoice_payment.erl for better code organization. +%%% Contains all session-related functions. + +-module(hg_invoice_payment_session). + +-include_lib("damsel/include/dmsl_base_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("payment_events.hrl"). +-include("hg_invoice_payment.hrl"). + +%% Types +-type st() :: hg_invoice_payment:st(). +-type target() :: hg_invoice_payment:target(). +-type session_target_type() :: hg_invoice_payment:session_target_type(). +-type session() :: hg_invoice_payment:session(). +-type activity() :: hg_invoice_payment:activity(). +-type callback() :: hg_session:callback(). +-type callback_response() :: hg_session:callback_response(). +-type machine_result() :: hg_invoice_payment:machine_result(). +-type result() :: hg_invoice_payment:result(). +-type events() :: hg_invoice_payment:events(). +-type failure() :: hg_invoice_payment:failure(). +-type retry_strategy() :: hg_retry:strategy(). +-type action() :: hg_machine_action:action(). +-type change_opts() :: hg_invoice_payment:change_opts(). +-type cash() :: dmsl_domain_thrift:'Cash'(). +-type cart() :: dmsl_domain_thrift:'InvoiceCart'(). +-type allocation() :: hg_allocation:allocation() | undefined. + +%% Session management functions +-export([process_session/1]). +-export([process_session/2]). +-export([handle_callback/4]). +-export([finish_session_processing/4]). +-export([add_session/3]). +-export([create_session_event_context/3]). +-export([get_activity_session/1]). +-export([get_activity_session/2]). +-export([get_session/2]). +-export([update_session/3]). +-export([retry_session/3]). +-export([check_retry_possibility/3]). +-export([get_initial_retry_strategy/1]). +-export([get_actual_retry_strategy/2]). +-export([check_failure_type/2]). +-export([get_error_class/1]). +-export([do_check_failure_type/1]). +-export([start_session/1]). +-export([start_capture/4]). +-export([start_partial_capture/5]). + +%%% Session processing + +-spec process_session(st()) -> machine_result(). +process_session(St) -> + Session = get_activity_session(St), + process_session(Session, St). + +-spec process_session(session() | undefined, st()) -> machine_result(). +process_session(undefined, St0) -> + Target = hg_invoice_payment:get_target(St0), + TargetType = hg_invoice_payment:get_target_type(Target), + Action = hg_machine_action:new(), + case + hg_invoice_payment_validation:validate_processing_deadline( + hg_invoice_payment:get_payment(St0), TargetType + ) + of + ok -> + Events = start_session(Target), + Result = {Events, hg_machine_action:set_timeout(0, Action)}, + {next, Result}; + Failure -> + hg_invoice_payment:process_failure( + hg_invoice_payment:get_activity(St0), [], Action, Failure, St0 + ) + end; +process_session(Session0, #st{repair_scenario = Scenario} = St) -> + Session1 = + case hg_invoice_repair:check_for_action(repair_session, Scenario) of + RepairScenario = {result, _} -> + hg_session:set_repair_scenario(RepairScenario, Session0); + call -> + Session0 + end, + PaymentInfo = hg_invoice_payment:construct_payment_info(St, hg_invoice_payment:get_opts(St)), + Session2 = hg_session:set_payment_info(PaymentInfo, Session1), + {Result, Session3} = hg_session:process(Session2), + finish_session_processing(hg_invoice_payment:get_activity(St), Result, Session3, St). + +-spec handle_callback(activity(), callback(), hg_session:t(), st()) -> {callback_response(), machine_result()}. +handle_callback({refund, ID}, Payload, _Session0, St) -> + PaymentInfo = hg_invoice_payment:construct_payment_info(St, hg_invoice_payment:get_opts(St)), + Refund = hg_invoice_payment:try_get_refund_state(ID, St), + {Resp, {Step, {Events0, Action}}} = hg_invoice_payment_refund:process_callback(Payload, PaymentInfo, Refund), + Events1 = hg_invoice_payment_refund:wrap_events(Events0, Refund), + {Resp, {Step, {Events1, Action}}}; +handle_callback(Activity, Payload, Session0, St) -> + PaymentInfo = hg_invoice_payment:construct_payment_info(St, hg_invoice_payment:get_opts(St)), + Session1 = hg_session:set_payment_info(PaymentInfo, Session0), + {Response, {Result, Session2}} = hg_session:process_callback(Payload, Session1), + {Response, finish_session_processing(Activity, Result, Session2, St)}. + +-spec finish_session_processing(activity(), result(), hg_session:t(), st()) -> machine_result(). +finish_session_processing(Activity, {Events0, Action}, Session, St0) -> + Events1 = hg_session:wrap_events(Events0, Session), + case {hg_session:status(Session), hg_session:result(Session)} of + {finished, ?session_succeeded()} -> + TargetType = hg_invoice_payment:get_target_type(hg_session:target(Session)), + _ = hg_invoice_payment:maybe_notify_fault_detector(Activity, TargetType, finish, St0), + NewAction = hg_machine_action:set_timeout(0, Action), + InvoiceID = hg_invoice_payment:get_invoice_id( + hg_invoice_payment:get_invoice(hg_invoice_payment:get_opts(St0)) + ), + St1 = hg_invoice_payment:collapse_changes(Events1, St0, #{invoice_id => InvoiceID}), + _ = + case St1 of + #st{new_cash_provided = true, activity = {payment, processing_accounter}} -> + %% Revert with St0 cause default rollback takes into account new cash + %% We need to rollback only current route. + %% Previously used routes are supposed to have their limits already rolled back. + Route = hg_invoice_payment:get_route(St0), + Routes = [Route], + _ = hg_invoice_payment:rollback_payment_limits( + Routes, hg_invoice_payment:get_iter(St0), St0, [] + ), + _ = hg_invoice_payment:rollback_payment_cashflow(St0); + _ -> + ok + end, + {next, {Events1, NewAction}}; + {finished, ?session_failed(Failure)} -> + hg_invoice_payment:process_failure(Activity, Events1, Action, Failure, St0); + _ -> + {next, {Events1, Action}} + end. + +%%% Session accessors + +-spec get_session(target(), st()) -> session(). +get_session(Target, #st{sessions = Sessions, routes = [Route | _PreviousRoutes]}) -> + TargetSessions = maps:get(hg_invoice_payment:get_target_type(Target), Sessions, []), + MatchingRoute = fun(#{route := SR}) -> SR =:= Route end, + case lists:search(MatchingRoute, TargetSessions) of + {value, Session} -> Session; + _ -> undefined + end. + +-spec add_session(target(), session(), st()) -> st(). +add_session(Target, Session, #st{sessions = Sessions} = St) -> + TargetType = hg_invoice_payment:get_target_type(Target), + TargetTypeSessions = maps:get(TargetType, Sessions, []), + St#st{sessions = Sessions#{TargetType => [Session | TargetTypeSessions]}}. + +-spec update_session(target(), session(), st()) -> st(). +update_session(Target, Session, #st{sessions = Sessions} = St) -> + TargetType = hg_invoice_payment:get_target_type(Target), + [_ | Rest] = maps:get(TargetType, Sessions, []), + St#st{sessions = Sessions#{TargetType => [Session | Rest]}}. + +-spec get_activity_session(st()) -> session() | undefined. +get_activity_session(St) -> + get_activity_session(hg_invoice_payment:get_activity(St), St). + +-spec get_activity_session(activity(), st()) -> session() | undefined. +get_activity_session({payment, _Step}, St) -> + get_session(hg_invoice_payment:get_target(St), St); +get_activity_session({refund, ID}, St) -> + Refund = hg_invoice_payment:try_get_refund_state(ID, St), + hg_invoice_payment_refund:session(Refund). + +%%% Session creation + +-spec start_session(target()) -> events(). +start_session(Target) -> + [hg_session:wrap_event(Target, hg_session:create())]. + +-spec start_capture(binary(), cash(), undefined | cart(), undefined | allocation()) -> events(). +start_capture(Reason, Cost, Cart, Allocation) -> + [?payment_capture_started(Reason, Cost, Cart, Allocation)] ++ + start_session(?captured(Reason, Cost, Cart, Allocation)). + +-spec start_partial_capture(binary(), cash(), cart(), hg_cashflow:final_cash_flow(), allocation()) -> events(). +start_partial_capture(Reason, Cost, Cart, FinalCashflow, Allocation) -> + [ + ?payment_capture_started(Reason, Cost, Cart, Allocation), + ?cash_flow_changed(FinalCashflow) + ]. + +%%% Session event context + +-spec create_session_event_context(target(), st(), change_opts()) -> hg_session:event_context(). +create_session_event_context(Target, St, #{invoice_id := InvoiceID} = Opts) -> + #{ + timestamp => define_event_timestamp(Opts), + target => Target, + route => hg_invoice_payment:get_route(St), + invoice_id => InvoiceID, + payment_id => hg_invoice_payment:get_payment_id(hg_invoice_payment:get_payment(St)) + }. + +-spec define_event_timestamp(change_opts()) -> integer(). +define_event_timestamp(#{timestamp := Dt}) -> + hg_datetime:parse(Dt, millisecond); +define_event_timestamp(#{}) -> + erlang:system_time(millisecond). + +%%% Session retry + +-spec retry_session(action(), target(), non_neg_integer()) -> {events(), action()}. +retry_session(Action, Target, Timeout) -> + NewEvents = start_session(Target), + NewAction = set_timer({timeout, Timeout}, Action), + {NewEvents, NewAction}. + +-spec get_actual_retry_strategy(target(), st()) -> retry_strategy(). +get_actual_retry_strategy(Target, #st{retry_attempts = Attempts}) -> + AttemptNum = maps:get(hg_invoice_payment:get_target_type(Target), Attempts, 0), + hg_retry:skip_steps(get_initial_retry_strategy(hg_invoice_payment:get_target_type(Target)), AttemptNum). + +-spec get_initial_retry_strategy(session_target_type()) -> retry_strategy(). +get_initial_retry_strategy(TargetType) -> + PolicyConfig = genlib_app:env(hellgate, payment_retry_policy, #{}), + hg_retry:new_strategy(maps:get(TargetType, PolicyConfig, no_retry)). + +-spec check_retry_possibility(Target, Failure, St) -> {retry, Timeout} | fatal when + Failure :: failure(), + Target :: target(), + St :: st(), + Timeout :: non_neg_integer(). +check_retry_possibility(Target, Failure, St) -> + case check_failure_type(Target, Failure) of + transient -> + RetryStrategy = get_actual_retry_strategy(Target, St), + case hg_retry:next_step(RetryStrategy) of + {wait, Timeout, _NewStrategy} -> + {retry, Timeout}; + finish -> + _ = logger:debug("Retries strategy is exceed"), + fatal + end; + fatal -> + _ = logger:debug("Failure ~p is not transient", [Failure]), + fatal + end. + +-spec check_failure_type(target(), failure()) -> transient | fatal. +check_failure_type(Target, {failure, Failure}) -> + payproc_errors:match(get_error_class(Target), Failure, fun do_check_failure_type/1); +check_failure_type(_Target, _Other) -> + fatal. + +-spec get_error_class(target()) -> atom(). +get_error_class({Target, _}) when Target =:= processed; Target =:= captured; Target =:= cancelled -> + 'PaymentFailure'; +get_error_class(Target) -> + error({unsupported_target, Target}). + +-spec do_check_failure_type(failure()) -> transient | fatal. +do_check_failure_type({authorization_failed, {temporarily_unavailable, _}}) -> + transient; +do_check_failure_type(_Failure) -> + fatal. + +-spec set_timer(term(), action()) -> action(). +set_timer(Timer, Action) -> + hg_machine_action:set_timer(Timer, Action). diff --git a/apps/hellgate/src/hg_invoice_payment_state.erl b/apps/hellgate/src/hg_invoice_payment_state.erl new file mode 100644 index 00000000..825a3089 --- /dev/null +++ b/apps/hellgate/src/hg_invoice_payment_state.erl @@ -0,0 +1,512 @@ +%%% Payment state management module +%%% +%%% Extracted from hg_invoice_payment.erl for better code organization. +%%% Contains all state management functions: merge_change, collapse_changes, +%%% record_status_change, and state setters/getters. + +-module(hg_invoice_payment_state). + +-include_lib("damsel/include/dmsl_base_thrift.hrl"). +-include_lib("damsel/include/dmsl_payproc_thrift.hrl"). + +-include_lib("hellgate/include/domain.hrl"). + +-include("payment_events.hrl"). +-include("hg_invoice_payment.hrl"). + +%% Types +-type st() :: hg_invoice_payment:st(). +-type change() :: hg_invoice_payment:change(). +-type change_opts() :: hg_invoice_payment:change_opts(). +-type chargeback_id() :: hg_invoice_payment_chargeback:id(). +-type chargeback_state() :: hg_invoice_payment_chargeback:state(). +-type refund_id() :: hg_invoice_payment:refund_id(). +-type refund_state() :: hg_invoice_payment:refund_state(). +-type adjustment_id() :: dmsl_domain_thrift:'InvoicePaymentAdjustmentID'(). +-type adjustment() :: hg_invoice_payment:adjustment(). +-type final_cash_flow() :: hg_cashflow:final_cash_flow(). +-type trx_info() :: hg_invoice_payment:trx_info(). +-type target() :: hg_invoice_payment:target(). + +%% State management functions +-export([merge_change/3]). +-export([collapse_changes/3]). +-export([record_status_change/2]). +-export([set_chargeback_state/3]). +-export([set_refund_state/3]). +-export([set_adjustment/3]). +-export([set_cashflow/2]). +-export([set_trx/2]). +-export([try_get_refund_state/2]). +-export([try_get_chargeback_state/2]). +-export([try_get_adjustment/2]). +-export([latest_adjustment_id/1]). +-export([save_retry_attempt/2]). + +%%% State merging + +-spec merge_change(change(), st() | undefined, change_opts()) -> st(). +merge_change(Change, undefined, Opts) -> + merge_change(Change, #st{activity = {payment, new}}, Opts); +merge_change(Change = ?payment_started(Payment), #st{} = St, Opts) -> + _ = hg_invoice_payment_validation:validate_transition({payment, new}, Change, St, Opts), + St#st{ + target = ?processed(), + payment = Payment, + activity = {payment, shop_limit_initializing}, + timings = hg_timings:mark(started, hg_invoice_payment:define_event_timestamp(Opts)) + }; +merge_change(Change = ?shop_limit_initiated(), #st{} = St, Opts) -> + _ = hg_invoice_payment_validation:validate_transition({payment, shop_limit_initializing}, Change, St, Opts), + St#st{ + shop_limit_status = initialized, + activity = {payment, shop_limit_finalizing} + }; +merge_change(Change = ?shop_limit_applied(), #st{} = St, Opts) -> + _ = hg_invoice_payment_validation:validate_transition({payment, shop_limit_finalizing}, Change, St, Opts), + St#st{ + shop_limit_status = finalized, + activity = {payment, risk_scoring} + }; +merge_change(Change = ?risk_score_changed(RiskScore), #st{} = St, Opts) -> + _ = hg_invoice_payment_validation:validate_transition( + [ + {payment, S} + || S <- [ + risk_scoring, + %% Added for backward compatibility + shop_limit_initializing + ] + ], + Change, + St, + Opts + ), + St#st{ + risk_score = RiskScore, + activity = {payment, routing} + }; +merge_change( + Change = ?route_changed(Route, Candidates, Scores, Limits, Decision), + #st{routes = Routes, route_scores = RouteScores, route_limits = RouteLimits} = St, + Opts +) -> + _ = hg_invoice_payment_validation:validate_transition( + [{payment, S} || S <- [routing, processing_failure]], Change, St, Opts + ), + Skip = + case Decision of + #payproc_RouteDecisionContext{skip_recurrent = true} -> + true; + _ -> + false + end, + Payment0 = hg_invoice_payment:get_payment(St), + Payment1 = Payment0#domain_InvoicePayment{skip_recurrent = Skip}, + St#st{ + %% On route change we expect cash flow from previous attempt to be rolled back. + %% So on `?payment_rollback_started(_)` event for routing failure we won't try to do it again. + cash_flow = undefined, + %% `trx` from previous session (if any) also must be considered obsolete. + trx = undefined, + routes = [Route | Routes], + candidate_routes = ordsets:to_list(Candidates), + activity = {payment, cash_flow_building}, + route_scores = hg_maybe:apply(fun(S) -> maps:merge(RouteScores, S) end, Scores, RouteScores), + route_limits = hg_maybe:apply(fun(L) -> maps:merge(RouteLimits, L) end, Limits, RouteLimits), + payment = Payment1 + }; +merge_change(Change = ?payment_capture_started(Data), #st{} = St, Opts) -> + _ = hg_invoice_payment_validation:validate_transition([{payment, S} || S <- [flow_waiting]], Change, St, Opts), + St#st{ + capture_data = Data, + activity = {payment, processing_capture}, + allocation = Data#payproc_InvoicePaymentCaptureData.allocation + }; +merge_change(Change = ?cash_flow_changed(CashFlow), #st{activity = Activity} = St0, Opts) -> + _ = hg_invoice_payment_validation:validate_transition( + [ + {payment, S} + || S <- [ + cash_flow_building, + processing_capture, + processing_accounter + ] + ], + Change, + St0, + Opts + ), + St = St0#st{ + final_cash_flow = CashFlow + }, + case Activity of + {payment, processing_accounter} -> + St#st{new_cash = undefined, new_cash_flow = CashFlow}; + {payment, cash_flow_building} -> + St#st{ + cash_flow = CashFlow, + activity = {payment, processing_session} + }; + {payment, processing_capture} -> + St#st{ + partial_cash_flow = CashFlow, + activity = {payment, updating_accounter} + }; + _ -> + St + end; +merge_change(Change = ?rec_token_acquired(Token), #st{} = St, Opts) -> + _ = hg_invoice_payment_validation:validate_transition( + [{payment, S} || S <- [processing_session, finalizing_session]], Change, St, Opts + ), + St#st{recurrent_token = Token}; +merge_change(Change = ?cash_changed(_OldCash, NewCash), #st{} = St, Opts) -> + _ = hg_invoice_payment_validation:validate_transition( + [{adjustment_new, latest_adjustment_id(St)}, {payment, processing_session}], + Change, + St, + Opts + ), + Payment0 = hg_invoice_payment:get_payment(St), + Payment1 = Payment0#domain_InvoicePayment{changed_cost = NewCash}, + St#st{new_cash = NewCash, new_cash_provided = true, payment = Payment1}; +merge_change(Change = ?payment_rollback_started(Failure), St, Opts) -> + _ = hg_invoice_payment_validation:validate_transition( + [ + {payment, shop_limit_finalizing}, + {payment, cash_flow_building}, + {payment, processing_session} + ], + Change, + St, + Opts + ), + Activity = + case St of + #st{shop_limit_status = initialized} -> + {payment, shop_limit_failure}; + #st{cash_flow = undefined} -> + {payment, routing_failure}; + _ -> + {payment, processing_failure} + end, + St#st{ + failure = Failure, + activity = Activity, + timings = hg_invoice_payment:accrue_status_timing(failed, Opts, St) + }; +merge_change(Change = ?payment_status_changed({failed, _} = Status), #st{payment = Payment} = St, Opts) -> + _ = hg_invoice_payment_validation:validate_transition( + [ + {payment, S} + || S <- [ + risk_scoring, + routing, + cash_flow_building, + shop_limit_failure, + routing_failure, + processing_failure + ] + ], + Change, + St, + Opts + ), + (record_status_change(Change, St))#st{ + payment = Payment#domain_InvoicePayment{status = Status}, + activity = idle, + failure = undefined, + timings = hg_invoice_payment:accrue_status_timing(failed, Opts, St) + }; +merge_change(Change = ?payment_status_changed({cancelled, _} = Status), #st{payment = Payment} = St, Opts) -> + _ = hg_invoice_payment_validation:validate_transition({payment, finalizing_accounter}, Change, St, Opts), + (record_status_change(Change, St))#st{ + payment = Payment#domain_InvoicePayment{status = Status}, + activity = idle, + timings = hg_invoice_payment:accrue_status_timing(cancelled, Opts, St) + }; +merge_change(Change = ?payment_status_changed({captured, Captured} = Status), #st{payment = Payment} = St, Opts) -> + _ = hg_invoice_payment_validation:validate_transition([idle, {payment, finalizing_accounter}], Change, St, Opts), + (record_status_change(Change, St))#st{ + payment = Payment#domain_InvoicePayment{ + status = Status, + cost = hg_invoice_payment:get_captured_cost(Captured, Payment) + }, + activity = idle, + timings = hg_invoice_payment:accrue_status_timing(captured, Opts, St), + allocation = hg_invoice_payment:get_captured_allocation(Captured) + }; +merge_change(Change = ?payment_status_changed({processed, _} = Status), #st{payment = Payment} = St, Opts) -> + _ = hg_invoice_payment_validation:validate_transition({payment, processing_accounter}, Change, St, Opts), + (record_status_change(Change, St))#st{ + payment = Payment#domain_InvoicePayment{status = Status}, + activity = {payment, flow_waiting}, + timings = hg_invoice_payment:accrue_status_timing(processed, Opts, St) + }; +merge_change(Change = ?payment_status_changed({refunded, _} = Status), #st{payment = Payment} = St, Opts) -> + _ = hg_invoice_payment_validation:validate_transition(idle, Change, St, Opts), + (record_status_change(Change, St))#st{ + payment = Payment#domain_InvoicePayment{status = Status} + }; +merge_change(Change = ?payment_status_changed({charged_back, _} = Status), #st{payment = Payment} = St, Opts) -> + _ = hg_invoice_payment_validation:validate_transition(idle, Change, St, Opts), + (record_status_change(Change, St))#st{ + payment = Payment#domain_InvoicePayment{status = Status} + }; +merge_change(Change = ?chargeback_ev(ID, Event), St, Opts) -> + St1 = + case Event of + ?chargeback_created(_) -> + _ = hg_invoice_payment_validation:validate_transition(idle, Change, St, Opts), + St#st{activity = {chargeback, ID, preparing_initial_cash_flow}}; + ?chargeback_stage_changed(_) -> + _ = hg_invoice_payment_validation:validate_transition(idle, Change, St, Opts), + St; + ?chargeback_levy_changed(_) -> + _ = hg_invoice_payment_validation:validate_transition( + [idle, {chargeback, ID, updating_chargeback}], Change, St, Opts + ), + St#st{activity = {chargeback, ID, updating_chargeback}}; + ?chargeback_body_changed(_) -> + _ = hg_invoice_payment_validation:validate_transition( + [idle, {chargeback, ID, updating_chargeback}], Change, St, Opts + ), + St#st{activity = {chargeback, ID, updating_chargeback}}; + ?chargeback_cash_flow_changed(_) -> + Valid = [{chargeback, ID, Activity} || Activity <- [preparing_initial_cash_flow, updating_cash_flow]], + _ = hg_invoice_payment_validation:validate_transition(Valid, Change, St, Opts), + case St of + #st{activity = {chargeback, ID, preparing_initial_cash_flow}} -> + St#st{activity = idle}; + #st{activity = {chargeback, ID, updating_cash_flow}} -> + St#st{activity = {chargeback, ID, finalising_accounter}} + end; + ?chargeback_target_status_changed(?chargeback_status_accepted()) -> + _ = hg_invoice_payment_validation:validate_transition( + [idle, {chargeback, ID, updating_chargeback}], Change, St, Opts + ), + case St of + #st{activity = idle} -> + St#st{activity = {chargeback, ID, finalising_accounter}}; + #st{activity = {chargeback, ID, updating_chargeback}} -> + St#st{activity = {chargeback, ID, updating_cash_flow}} + end; + ?chargeback_target_status_changed(_) -> + _ = hg_invoice_payment_validation:validate_transition( + [idle, {chargeback, ID, updating_chargeback}], Change, St, Opts + ), + St#st{activity = {chargeback, ID, updating_cash_flow}}; + ?chargeback_status_changed(_) -> + _ = hg_invoice_payment_validation:validate_transition( + [idle, {chargeback, ID, finalising_accounter}], Change, St, Opts + ), + St#st{activity = idle} + end, + ChargebackSt = hg_invoice_payment_chargeback:merge_change(Event, try_get_chargeback_state(ID, St1)), + set_chargeback_state(ID, ChargebackSt, St1); +merge_change(?refund_ev(ID, Event), St, Opts) -> + EventContext = hg_invoice_payment:create_refund_event_context(St, Opts), + St1 = + case Event of + ?refund_status_changed(?refund_succeeded()) -> + RefundSt0 = hg_invoice_payment_refund:apply_event( + Event, try_get_refund_state(ID, St), EventContext + ), + DomainRefund = hg_invoice_payment_refund:refund(RefundSt0), + Allocation = hg_invoice_payment:get_allocation(St), + FinalAllocation = hg_maybe:apply( + fun(A) -> + #domain_InvoicePaymentRefund{allocation = RefundAllocation} = DomainRefund, + {ok, FA} = hg_allocation:sub(A, RefundAllocation), + FA + end, + Allocation + ), + St#st{allocation = FinalAllocation}; + _ -> + St + end, + RefundSt1 = hg_invoice_payment_refund:apply_event(Event, try_get_refund_state(ID, St1), EventContext), + St2 = set_refund_state(ID, RefundSt1, St1), + case hg_invoice_payment_refund:status(RefundSt1) of + S when S == succeeded; S == failed -> + St2#st{activity = idle}; + _ -> + St2#st{activity = {refund, ID}} + end; +merge_change(Change = ?adjustment_ev(ID, Event), St, Opts) -> + St1 = + case Event of + ?adjustment_created(_) -> + _ = hg_invoice_payment_validation:validate_transition(idle, Change, St, Opts), + St#st{activity = {adjustment_new, ID}}; + ?adjustment_status_changed(?adjustment_processed()) -> + _ = hg_invoice_payment_validation:validate_transition({adjustment_new, ID}, Change, St, Opts), + St#st{activity = {adjustment_pending, ID}}; + ?adjustment_status_changed(_) -> + _ = hg_invoice_payment_validation:validate_transition({adjustment_pending, ID}, Change, St, Opts), + St#st{activity = idle} + end, + Adjustment = hg_invoice_payment_adjustment:merge_adjustment_change(Event, try_get_adjustment(ID, St1)), + St2 = set_adjustment(ID, Adjustment, St1), + % TODO new cashflow imposed implicitly on the payment state? rough + case hg_invoice_payment:get_adjustment_status(Adjustment) of + ?adjustment_captured(_) -> + hg_invoice_payment_adjustment:apply_adjustment_effects(Adjustment, St2); + _ -> + St2 + end; +merge_change( + Change = ?session_ev(Target, Event = ?session_started()), + #st{activity = Activity} = St, + Opts +) -> + _ = hg_invoice_payment_validation:validate_transition( + [ + {payment, S} + || S <- [ + processing_session, + flow_waiting, + processing_capture, + updating_accounter, + finalizing_session + ] + ], + Change, + St, + Opts + ), + % FIXME why the hell dedicated handling + Session0 = hg_session:apply_event( + Event, undefined, hg_invoice_payment:create_session_event_context(Target, St, Opts) + ), + %% We need to pass processed trx_info to captured/cancelled session due to provider requirements + Session1 = hg_session:set_trx_info(hg_invoice_payment:get_trx(St), Session0), + St1 = hg_invoice_payment:add_session(Target, Session1, St#st{target = Target}), + St2 = save_retry_attempt(Target, St1), + case Activity of + {payment, processing_session} -> + %% session retrying + St2#st{activity = {payment, processing_session}}; + {payment, PaymentActivity} when PaymentActivity == flow_waiting; PaymentActivity == processing_capture -> + %% session flow + St2#st{ + activity = {payment, finalizing_session}, + timings = hg_invoice_payment:try_accrue_waiting_timing(Opts, St2) + }; + {payment, updating_accounter} -> + %% session flow + St2#st{activity = {payment, finalizing_session}}; + {payment, finalizing_session} -> + %% session retrying + St2#st{activity = {payment, finalizing_session}}; + _ -> + St2 + end; +merge_change(Change = ?session_ev(Target, Event), St = #st{activity = Activity}, Opts) -> + _ = hg_invoice_payment_validation:validate_transition( + [{payment, S} || S <- [processing_session, finalizing_session]], Change, St, Opts + ), + Session = hg_session:apply_event( + Event, + hg_invoice_payment:get_session(Target, St), + hg_invoice_payment:create_session_event_context(Target, St, Opts) + ), + St1 = hg_invoice_payment:update_session(Target, Session, St), + % FIXME leaky transactions + St2 = set_trx(hg_session:trx_info(Session), St1), + case Session of + #{status := finished, result := ?session_succeeded()} -> + NextActivity = + case Activity of + {payment, processing_session} -> + {payment, processing_accounter}; + {payment, finalizing_session} -> + {payment, finalizing_accounter}; + _ -> + Activity + end, + St2#st{activity = NextActivity}; + _ -> + St2 + end. + +-spec collapse_changes([change()], st() | undefined, change_opts()) -> st() | undefined. +collapse_changes(Changes, St, Opts) -> + lists:foldl(fun(C, St1) -> merge_change(C, St1, Opts) end, St, Changes). + +-spec record_status_change(change(), st()) -> st(). +record_status_change(?payment_status_changed(Status), St) -> + St#st{status_log = [Status | St#st.status_log]}. + +%%% State setters + +-spec set_chargeback_state(chargeback_id(), chargeback_state(), st()) -> st(). +set_chargeback_state(ID, ChargebackSt, #st{chargebacks = CBs} = St) -> + St#st{chargebacks = CBs#{ID => ChargebackSt}}. + +-spec set_refund_state(refund_id(), refund_state(), st()) -> st(). +set_refund_state(ID, RefundSt, #st{refunds = Rs} = St) -> + St#st{refunds = Rs#{ID => RefundSt}}. + +-spec set_adjustment(adjustment_id(), adjustment(), st()) -> st(). +set_adjustment(ID, Adjustment, #st{adjustments = As} = St) -> + St#st{adjustments = lists:keystore(ID, #domain_InvoicePaymentAdjustment.id, As, Adjustment)}. + +-spec set_cashflow(final_cash_flow(), st()) -> st(). +set_cashflow(Cashflow, #st{} = St) -> + St#st{ + cash_flow = Cashflow, + final_cash_flow = Cashflow + }. + +-spec set_trx(trx_info() | undefined, st()) -> st(). +set_trx(undefined, #st{} = St) -> + St; +set_trx(Trx, #st{} = St) -> + St#st{trx = Trx}. + +%%% State getters (try_* versions) + +-spec try_get_refund_state(refund_id(), st()) -> refund_state() | undefined. +try_get_refund_state(ID, #st{refunds = Rs}) -> + case Rs of + #{ID := RefundSt} -> + RefundSt; + #{} -> + undefined + end. + +-spec try_get_chargeback_state(chargeback_id(), st()) -> chargeback_state() | undefined. +try_get_chargeback_state(ID, #st{chargebacks = CBs}) -> + case CBs of + #{ID := ChargebackSt} -> + ChargebackSt; + #{} -> + undefined + end. + +-spec try_get_adjustment(adjustment_id(), st()) -> adjustment() | undefined. +try_get_adjustment(ID, #st{adjustments = As}) -> + case lists:keyfind(ID, #domain_InvoicePaymentAdjustment.id, As) of + V = #domain_InvoicePaymentAdjustment{} -> + V; + false -> + undefined + end. + +%%% Helper functions + +-spec latest_adjustment_id(st()) -> adjustment_id() | undefined. +latest_adjustment_id(#st{adjustments = []}) -> + undefined; +latest_adjustment_id(#st{adjustments = Adjustments}) -> + Adjustment = lists:last(Adjustments), + Adjustment#domain_InvoicePaymentAdjustment.id. + +-spec save_retry_attempt(target(), st()) -> st(). +save_retry_attempt(Target, #st{retry_attempts = Attempts} = St) -> + St#st{ + retry_attempts = maps:update_with(hg_invoice_payment:get_target_type(Target), fun(N) -> N + 1 end, 0, Attempts) + }. diff --git a/apps/hellgate/src/hg_invoice_payment_validation.erl b/apps/hellgate/src/hg_invoice_payment_validation.erl new file mode 100644 index 00000000..92c8e9af --- /dev/null +++ b/apps/hellgate/src/hg_invoice_payment_validation.erl @@ -0,0 +1,554 @@ +%%% Payment validation module +%%% +%%% Extracted from hg_invoice_payment.erl for better code organization. +%%% Contains all validate_* and assert_* functions. + +-module(hg_invoice_payment_validation). + +-include_lib("damsel/include/dmsl_base_thrift.hrl"). +-include_lib("damsel/include/dmsl_payproc_error_thrift.hrl"). +-include_lib("hellgate/include/domain.hrl"). + +-include("payment_events.hrl"). +-include("hg_invoice_payment.hrl"). + +%% Types +-type st() :: hg_invoice_payment:st(). +-type payment() :: hg_invoice_payment:payment(). +-type payment_status() :: hg_invoice_payment:payment_status(). +-type payment_status_type() :: hg_invoice_payment:payment_status_type(). +-type payer() :: dmsl_domain_thrift:'Payer'(). +-type payment_tool() :: dmsl_domain_thrift:'PaymentTool'(). +-type shop() :: dmsl_domain_thrift:'ShopConfig'(). +-type shop_config_ref() :: dmsl_domain_thrift:'ShopConfigRef'(). +-type recurrent_paytool_service_terms() :: dmsl_domain_thrift:'RecurrentPaytoolsServiceTerms'(). +-type activity() :: hg_invoice_payment:activity(). +-type change() :: hg_invoice_payment:change(). +-type change_opts() :: hg_invoice_payment:change_opts(). +-type make_recurrent() :: true | false. +-type adjustment_status_type() :: pending | processed | captured | cancelled. + +%% Validation functions +-export([validate_hold_lifetime/2]). +-export([validate_recurrent_intention/6]). +-export([validate_recurrent_terms/2]). +-export([validate_recurrent_parent/2]). +-export([validate_recurrent_token_present/1]). +-export([validate_recurrent_parent_party/2]). +-export([validate_recurrent_parent_status/1]). +-export([validate_recurrent_payer/2]). +-export([validate_payment_tool/2]). +-export([validate_cash/2]). +-export([validate_limit/2]). +-export([validate_refund_time/3]). +-export([validate_processing_deadline/2]). +-export([validate_merchant_hold_terms/1]). +-export([validate_provider_holds_terms/1]). +-export([validate_payment_status/2]). +-export([validate_allocation_refund/2]). +-export([validate_refund/3]). +-export([validate_partial_refund/3]). +-export([validate_common_refund_terms/3]). +-export([validate_transition/4]). + +%% Assertion functions +-export([assert_capture_cost_currency/2]). +-export([assert_capture_cart/2]). +-export([assert_refund_cash/2]). +-export([assert_remaining_payment_amount/2]). +-export([assert_previous_refunds_finished/1]). +-export([assert_refund_cart/3]). +-export([assert_adjustment_payment_status/1]). +-export([assert_no_refunds/1]). +-export([assert_adjustment_payment_statuses/2]). +-export([assert_activity/2]). +-export([assert_payment_status/2]). +-export([assert_no_pending_chargebacks/1]). +-export([assert_no_adjustment_pending/1]). +-export([assert_adjustment_finalized/1]). +-export([assert_payment_flow/2]). +-export([assert_adjustment_status/2]). + +%% Internal helper functions +-export([is_adjustment_payment_status_final/1]). + +%%% Validation functions + +-spec validate_hold_lifetime( + undefined | dmsl_domain_thrift:'PaymentHoldsServiceTerms'(), + payment_tool() +) -> dmsl_domain_thrift:'HoldLifetime'() | no_return(). +validate_hold_lifetime( + #domain_PaymentHoldsServiceTerms{ + payment_methods = PMs, + lifetime = LifetimeSelector + }, + PaymentTool +) -> + ok = validate_payment_tool(PaymentTool, PMs), + get_selector_value(hold_lifetime, LifetimeSelector); +validate_hold_lifetime(undefined, _PaymentTool) -> + throw_invalid_request(<<"Holds are not available">>). + +-spec validate_recurrent_intention( + payer(), + recurrent_paytool_service_terms(), + payment_tool(), + {shop_config_ref(), shop()}, + payment(), + make_recurrent() +) -> ok | no_return(). +validate_recurrent_intention( + ?recurrent_payer() = Payer, + RecurrentTerms, + PaymentTool, + ShopObj, + ParentPayment, + MakeRecurrent +) -> + ok = validate_recurrent_terms(RecurrentTerms, PaymentTool), + ok = validate_recurrent_payer(Payer, MakeRecurrent), + ok = validate_recurrent_parent(ShopObj, ParentPayment); +validate_recurrent_intention(Payer, RecurrentTerms, PaymentTool, _Shop, _ParentPayment, true = MakeRecurrent) -> + ok = validate_recurrent_terms(RecurrentTerms, PaymentTool), + ok = validate_recurrent_payer(Payer, MakeRecurrent); +validate_recurrent_intention(_Payer, _RecurrentTerms, _PaymentTool, _Shop, _ParentPayment, false = _MakeRecurrent) -> + ok. + +-spec validate_recurrent_terms(recurrent_paytool_service_terms(), payment_tool()) -> ok | no_return(). +validate_recurrent_terms(undefined, _PaymentTool) -> + throw(#payproc_OperationNotPermitted{}); +validate_recurrent_terms(RecurrentTerms, PaymentTool) -> + #domain_RecurrentPaytoolsServiceTerms{payment_methods = PaymentMethodSelector} = RecurrentTerms, + PMs = get_selector_value(recurrent_payment_methods, PaymentMethodSelector), + %% TODO delete logging after successfull migration tokenization method in domain_config + %% https://rbkmoney.atlassian.net/browse/ED-87 + _ = + case hg_payment_tool:has_any_payment_method(PaymentTool, PMs) of + false -> + logger:notice("PaymentTool: ~p", [PaymentTool]), + logger:notice("RecurrentPaymentMethods: ~p", [PMs]), + throw_invalid_request(<<"Invalid payment method">>); + true -> + ok + end, + ok. + +-spec validate_recurrent_parent({shop_config_ref(), shop()}, st()) -> ok | no_return(). +validate_recurrent_parent(ShopObj, ParentPayment) -> + ok = validate_recurrent_token_present(ParentPayment), + ok = validate_recurrent_parent_party(ShopObj, ParentPayment), + ok = validate_recurrent_parent_status(ParentPayment). + +-spec validate_recurrent_token_present(st()) -> ok | no_return(). +validate_recurrent_token_present(PaymentState) -> + case hg_invoice_payment:get_recurrent_token(PaymentState) of + Token when Token =/= undefined -> + ok; + undefined -> + throw_invalid_recurrent_parent(<<"Parent payment has no recurrent token">>) + end. + +-spec validate_recurrent_parent_party({shop_config_ref(), shop()}, st()) -> ok | no_return(). +validate_recurrent_parent_party({_, #domain_ShopConfig{party_ref = PartyConfigRef}}, PaymentState) -> + Payment = hg_invoice_payment:get_payment(PaymentState), + PaymentPartyConfigRef = get_payment_party_config_ref(Payment), + case PartyConfigRef =:= PaymentPartyConfigRef of + true -> + ok; + false -> + throw_invalid_recurrent_parent(<<"Parent payment refer to another party">>) + end. + +-spec validate_recurrent_parent_status(st()) -> ok | no_return(). +validate_recurrent_parent_status(PaymentState) -> + case hg_invoice_payment:get_payment(PaymentState) of + #domain_InvoicePayment{status = {failed, _}} -> + throw_invalid_recurrent_parent(<<"Invalid parent payment status">>); + _Other -> + ok + end. + +-spec validate_recurrent_payer(dmsl_domain_thrift:'Payer'(), make_recurrent()) -> ok | no_return(). +validate_recurrent_payer(?recurrent_payer(), _MakeRecurrent) -> + ok; +validate_recurrent_payer(?payment_resource_payer(), true) -> + ok; +validate_recurrent_payer(_OtherPayer, true) -> + throw_invalid_request(<<"Invalid payer">>). + +-spec validate_payment_tool(payment_tool(), dmsl_domain_thrift:'PaymentMethodSelector'()) -> ok | no_return(). +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. + +-spec validate_cash(dmsl_domain_thrift:'Cash'(), dmsl_domain_thrift:'CashLimitSelector'()) -> ok | no_return(). +validate_cash(Cash, CashLimitSelector) -> + Limit = get_selector_value(cash_limit, CashLimitSelector), + ok = validate_limit(Cash, Limit). + +-spec validate_limit(dmsl_domain_thrift:'Cash'(), dmsl_domain_thrift:'CashRange'()) -> ok | no_return(). +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. + +-spec validate_refund_time(timestamp(), timestamp(), dmsl_domain_thrift:'TimeSpanSelector'()) -> ok | no_return(). +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. + +-spec validate_processing_deadline(payment(), processed) -> ok | dmsl_domain_thrift:'OperationFailure'(). +validate_processing_deadline(#domain_InvoicePayment{processing_deadline = Deadline}, processed = _TargetType) -> + case hg_invoice_utils:check_deadline(Deadline) of + ok -> + ok; + {error, deadline_reached} -> + {failure, + payproc_errors:construct( + 'PaymentFailure', + {authorization_failed, {processing_deadline_reached, #payproc_error_GeneralFailure{}}} + )} + end; +validate_processing_deadline(_, _TargetType) -> + ok. + +-type timestamp() :: dmsl_base_thrift:'Timestamp'(). + +-spec validate_merchant_hold_terms(dmsl_domain_thrift:'PaymentsServiceTerms'()) -> ok | no_return(). +validate_merchant_hold_terms(#domain_PaymentsServiceTerms{holds = Terms}) when Terms /= undefined -> + case Terms of + %% Чтобы упростить интеграцию, по умолчанию разрешили частичные подтверждения + #domain_PaymentHoldsServiceTerms{partial_captures = undefined} -> + ok; + #domain_PaymentHoldsServiceTerms{} -> + throw(#payproc_OperationNotPermitted{}) + end; +%% Чтобы упростить интеграцию, по умолчанию разрешили частичные подтверждения +validate_merchant_hold_terms(#domain_PaymentsServiceTerms{holds = undefined}) -> + ok. + +-spec validate_provider_holds_terms(dmsl_domain_thrift:'PaymentsProvisionTerms'()) -> ok | no_return(). +validate_provider_holds_terms(#domain_PaymentsProvisionTerms{holds = Terms}) when Terms /= undefined -> + case Terms of + %% Чтобы упростить интеграцию, по умолчанию разрешили частичные подтверждения + #domain_PaymentHoldsProvisionTerms{partial_captures = undefined} -> + ok; + #domain_PaymentHoldsProvisionTerms{} -> + throw(#payproc_OperationNotPermitted{}) + end; +%% Чтобы упростить интеграцию, по умолчанию разрешили частичные подтверждения +validate_provider_holds_terms(#domain_PaymentsProvisionTerms{holds = undefined}) -> + ok. + +-spec validate_payment_status(payment_status_type(), payment()) -> ok | no_return(). +validate_payment_status(Status, #domain_InvoicePayment{status = {Status, _}}) -> + ok; +validate_payment_status(_, #domain_InvoicePayment{status = Status}) -> + throw(#payproc_InvalidPaymentStatus{status = Status}). + +-spec validate_allocation_refund(undefined | hg_allocation:allocation(), st()) -> ok. +validate_allocation_refund(undefined, _St) -> + ok. + +-spec validate_refund( + dmsl_domain_thrift:'PaymentRefundsServiceTerms'(), dmsl_domain_thrift:'InvoicePaymentRefund'(), payment() +) -> ok | no_return(). +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. + +-spec validate_partial_refund( + dmsl_domain_thrift:'PaymentRefundsServiceTerms'(), dmsl_domain_thrift:'InvoicePaymentRefund'(), payment() +) -> ok | no_return(). +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{}). + +-spec validate_common_refund_terms( + dmsl_domain_thrift:'PaymentRefundsServiceTerms'(), dmsl_domain_thrift:'InvoicePaymentRefund'(), payment() +) -> ok | no_return(). +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. + +-spec validate_transition(activity() | [activity()], change(), st(), change_opts()) -> ok | no_return(). +validate_transition(Allowed, Change, St, Opts) -> + case {Opts, is_transition_valid(Allowed, St)} of + {#{}, true} -> + ok; + {#{validation := strict}, false} -> + erlang:error({invalid_transition, Change, St, Allowed}); + {#{}, false} -> + logger:warning( + "Invalid transition for change ~p in state ~p, allowed ~p", + [Change, St, Allowed] + ) + end. + +is_transition_valid(Allowed, St) when is_list(Allowed) -> + lists:any(fun(A) -> is_transition_valid(A, St) end, Allowed); +is_transition_valid(Allowed, #st{activity = Activity}) -> + Activity =:= Allowed. + +%%% Assertion functions + +-spec assert_capture_cost_currency(undefined | dmsl_domain_thrift:'Cash'(), payment()) -> ok | no_return(). +assert_capture_cost_currency(undefined, _) -> + ok; +assert_capture_cost_currency(?cash(_, SymCode), #domain_InvoicePayment{cost = ?cash(_, SymCode)}) -> + ok; +assert_capture_cost_currency(?cash(_, PassedSymCode), #domain_InvoicePayment{cost = ?cash(_, SymCode)}) -> + throw(#payproc_InconsistentCaptureCurrency{ + payment_currency = SymCode, + passed_currency = PassedSymCode + }). + +-spec assert_capture_cart(undefined | dmsl_domain_thrift:'Cash'(), undefined | dmsl_domain_thrift:'InvoiceCart'()) -> + ok | no_return(). +assert_capture_cart(_Cost, undefined) -> + ok; +assert_capture_cart(Cost, Cart) -> + case Cost =:= hg_invoice_utils:get_cart_amount(Cart) of + true -> + ok; + _ -> + throw_invalid_request(<<"Capture amount does not match with the cart total amount">>) + end. + +-spec assert_refund_cash(dmsl_domain_thrift:'Cash'(), st()) -> ok | no_return(). +assert_refund_cash(Cash, St) -> + PaymentAmount = get_remaining_payment_amount(Cash, St), + assert_remaining_payment_amount(PaymentAmount, St). + +-spec assert_remaining_payment_amount(dmsl_domain_thrift:'Cash'(), st()) -> ok | no_return(). +assert_remaining_payment_amount(?cash(Amount, _), _St) when Amount >= 0 -> + ok; +assert_remaining_payment_amount(?cash(Amount, _), St) when Amount < 0 -> + Maximum = hg_invoice_payment:get_remaining_payment_balance(St), + throw(#payproc_InvoicePaymentAmountExceeded{maximum = Maximum}). + +-spec assert_previous_refunds_finished(st()) -> ok | no_return(). +assert_previous_refunds_finished(St) -> + PendingRefunds = lists:filter( + fun(#payproc_InvoicePaymentRefund{refund = R}) -> + R#domain_InvoicePaymentRefund.status =:= ?refund_pending() + end, + hg_invoice_payment:get_refunds(St) + ), + case PendingRefunds of + [] -> + ok; + [_R | _] -> + throw(#payproc_OperationNotPermitted{}) + end. + +-spec assert_refund_cart(undefined | dmsl_domain_thrift:'Cash'(), undefined | dmsl_domain_thrift:'InvoiceCart'(), st()) -> + ok | no_return(). +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 = hg_invoice_payment: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. + +-spec assert_adjustment_payment_status(payment_status()) -> ok | no_return(). +assert_adjustment_payment_status(Status) -> + case is_adjustment_payment_status_final(Status) of + true -> + ok; + false -> + erlang:throw(#payproc_InvalidPaymentStatus{status = Status}) + end. + +-spec assert_no_refunds(st()) -> ok | no_return(). +assert_no_refunds(St) -> + case hg_invoice_payment:get_refunds_count(St) of + 0 -> + ok; + _ -> + throw_invalid_request(<<"Cannot change status of payment with refunds.">>) + end. + +-spec assert_adjustment_payment_statuses(TargetStatus :: payment_status(), Status :: payment_status()) -> + ok | no_return(). +assert_adjustment_payment_statuses(Status, Status) -> + erlang:throw(#payproc_InvoicePaymentAlreadyHasStatus{status = Status}); +assert_adjustment_payment_statuses(TargetStatus, _Status) -> + case is_adjustment_payment_status_final(TargetStatus) of + true -> + ok; + false -> + erlang:throw(#payproc_InvalidPaymentTargetStatus{status = TargetStatus}) + end. + +-spec is_adjustment_payment_status_final(payment_status()) -> boolean(). +is_adjustment_payment_status_final({captured, _}) -> + true; +is_adjustment_payment_status_final({cancelled, _}) -> + true; +is_adjustment_payment_status_final({failed, _}) -> + true; +is_adjustment_payment_status_final(_) -> + false. + +-spec assert_activity(activity(), st()) -> ok | no_return(). +assert_activity(Activity, #st{activity = Activity}) -> + ok; +assert_activity(_Activity, St) -> + %% TODO: Create dedicated error like "Payment is capturing already" + #domain_InvoicePayment{status = Status} = hg_invoice_payment:get_payment(St), + throw(#payproc_InvalidPaymentStatus{status = Status}). + +-spec assert_payment_status( + payment_status_type() | [payment_status_type()], + payment() +) -> ok | no_return(). +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}). + +-spec assert_no_pending_chargebacks(st()) -> ok | no_return(). +assert_no_pending_chargebacks(PaymentState) -> + Chargebacks = [ + CB#payproc_InvoicePaymentChargeback.chargeback + || CB <- hg_invoice_payment:get_chargebacks(PaymentState) + ], + case lists:any(fun hg_invoice_payment_chargeback:is_pending/1, Chargebacks) of + true -> + throw(#payproc_InvoicePaymentChargebackPending{}); + false -> + ok + end. + +-spec assert_no_adjustment_pending(st()) -> ok | no_return(). +assert_no_adjustment_pending(#st{adjustments = As}) -> + lists:foreach(fun assert_adjustment_finalized/1, As). + +-spec assert_adjustment_finalized(dmsl_domain_thrift:'InvoicePaymentAdjustment'()) -> ok | no_return(). +assert_adjustment_finalized(#domain_InvoicePaymentAdjustment{id = ID, status = {Status, _}}) when + Status =:= pending; Status =:= processed +-> + throw(#payproc_InvoicePaymentAdjustmentPending{id = ID}); +assert_adjustment_finalized(_) -> + ok. + +-spec assert_payment_flow(hold, payment()) -> ok | no_return(). +assert_payment_flow(hold, #domain_InvoicePayment{flow = ?invoice_payment_flow_hold(_, _)}) -> + ok; +assert_payment_flow(_, _) -> + throw(#payproc_OperationNotPermitted{}). + +-spec assert_adjustment_status(adjustment_status_type(), dmsl_domain_thrift:'InvoicePaymentAdjustment'()) -> + ok | no_return(). +assert_adjustment_status(Status, #domain_InvoicePaymentAdjustment{status = {Status, _}}) -> + ok; +assert_adjustment_status(_, #domain_InvoicePaymentAdjustment{status = Status}) -> + throw(#payproc_InvalidPaymentAdjustmentStatus{status = Status}). + +%%% Helper functions + +get_selector_value(Name, Selector) -> + case Selector of + {value, V} -> + V; + Ambiguous -> + error({misconfiguration, {'Could not reduce selector to a value', {Name, Ambiguous}}}) + end. + +-spec throw_invalid_request(binary()) -> no_return(). +throw_invalid_request(Why) -> + throw(#base_InvalidRequest{errors = [Why]}). + +-spec throw_invalid_recurrent_parent(binary()) -> no_return(). +throw_invalid_recurrent_parent(Details) -> + throw(#payproc_InvalidRecurrentParentPayment{details = Details}). + +get_payment_party_config_ref(#domain_InvoicePayment{party_ref = PartyConfigRef}) -> + PartyConfigRef. + +get_payment_cost(#domain_InvoicePayment{changed_cost = Cost}) when Cost /= undefined -> + Cost; +get_payment_cost(#domain_InvoicePayment{cost = Cost}) -> + Cost. + +get_payment_tool(#domain_InvoicePayment{payer = Payer}) -> + get_payer_payment_tool(Payer). + +get_payer_payment_tool(?payment_resource_payer(PaymentResource, _ContactInfo)) -> + get_resource_payment_tool(PaymentResource); +get_payer_payment_tool(?recurrent_payer(PaymentTool, _, _)) -> + PaymentTool. + +get_resource_payment_tool(#domain_DisposablePaymentResource{payment_tool = PaymentTool}) -> + PaymentTool. + +get_payment_created_at(#domain_InvoicePayment{created_at = CreatedAt}) -> + CreatedAt. + +get_refund_cash(#domain_InvoicePaymentRefund{cash = Cash}) -> + Cash. + +get_refund_created_at(#domain_InvoicePaymentRefund{created_at = CreatedAt}) -> + CreatedAt. + +get_remaining_payment_amount(Cash, St) -> + InterimPaymentAmount = hg_invoice_payment:get_remaining_payment_balance(St), + hg_cash:sub(InterimPaymentAmount, Cash). diff --git a/apps/hellgate/src/hg_machine_action.erl b/apps/hellgate/src/hg_machine_action.erl index 29984a43..37607d6d 100644 --- a/apps/hellgate/src/hg_machine_action.erl +++ b/apps/hellgate/src/hg_machine_action.erl @@ -22,8 +22,10 @@ -type timer() :: mg_proto_base_thrift:'Timer'(). -type t() :: mg_proto_state_processing_thrift:'ComplexAction'(). +-type action() :: t(). -export_type([t/0]). +-export_type([action/0]). %% diff --git a/apps/hellgate/src/hg_session.erl b/apps/hellgate/src/hg_session.erl index a84ee8e4..d576bdeb 100644 --- a/apps/hellgate/src/hg_session.erl +++ b/apps/hellgate/src/hg_session.erl @@ -42,6 +42,8 @@ -export_type([change/0]). -export_type([event_context/0]). -export_type([process_result/0]). +-export_type([callback/0]). +-export_type([callback_response/0]). %% Accessors